Merge main into fix/plugin-grant-app-approval

This commit is contained in:
dotCooCoo
2026-11-08 22:17:36 -08:00
98 changed files with 3625 additions and 363 deletions
@@ -1,5 +1,5 @@
use super::{
Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, SubPath, Touched, Value, b, bad, check_numbers,
Arc, Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, Touched, Value, b, bad, check_numbers,
f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_leaves, replace_with_pieces, root_ids,
shape_node, switch_or,
};
@@ -41,6 +41,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
let gap = gap_param(p);
// The map the Shape Builder tool already has, which a click or drag of it is made over.
let shared = s.tool.shape_builder_map().filter(|(_, g)| *g == gap);
let prepared = s.tool.shape_builder_merge();
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
let (ids, merged, touched, split) = s.edit(label, |d, sel| {
let (shapes, leaves) = b::shape_builder_shapes(d, &roots, open_filled_as_closed);
@@ -89,16 +90,28 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
touched.add(map.hit(q, Some(tol)));
return Ok((ids, None, touched, Some(split)));
}
let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() };
touched.extend(&map.builder_hits_along(&pts, piece_tol));
touched.edges = map.erased_edges(&touched);
let touched = map.merge_touched(&pts, marquee, piece_tol);
if touched.is_empty() {
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
}
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
let inputs = b::merge_inputs(&leaves.iter().collect::<Vec<_>>(), open_filled_as_closed);
// The tool computed this merge on another thread when it would come out the same.
let ready = prepared.as_ref().filter(|m| {
shared.as_ref().is_some_and(|(s, _)| Arc::ptr_eq(s, &m.map) && std::ptr::eq(s.as_ref(), map))
&& m.gap == gap
&& m.touched == touched
&& m.inputs == inputs
});
let computed;
let geometry = match ready {
Some(m) => &m.geometry,
None => {
computed = b::merge_geometry(map, &touched, gap, &inputs);
&computed
}
};
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p));
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
let union = &geometry.union;
// Pick Color From Artwork: the merged shape takes the look of the front-most object under the
// drag's first point, else under its last point, else of the front-most object. Color
// Swatches: the current fill, with the rest of the look of the front-most object over the
@@ -115,7 +128,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
// What goes in place of each object the map was made from (none: it stays as it is).
let mut outs: Vec<Option<Vec<Node>>> = vec![];
let mut merged_id = None;
for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() {
for (i, leaf) in leaves.iter().take(map.shapes.len()).enumerate() {
let mut out = None;
let pieces: Vec<usize> = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect();
if !pieces.is_empty() {
@@ -131,41 +144,15 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
}
out = Some(kept);
}
} else {
} else if let Some(Some(rest)) = geometry.rests.get(i) {
// A shape loses the regions deleted or merged, then the edges deleted (each one on
// its outline, shared edges from every shape along them).
let in_union = picked.iter().any(|r| r.sources.contains(&i));
let mut rest = if in_union {
// What is left of it: its faces not merged or deleted, united. They share their
// edges with the merged faces exactly, where subtracting the merged shape left
// slivers of the outline along them (#569). A sliver goes in only where the
// shape covers it and the merged shape does not take it in.
let kept: Vec<&po::Region> = map
.regions
.iter()
.enumerate()
.filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k))
.map(|(_, (r, ..))| r)
.collect();
if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) }
// its outline, shared edges from every shape along them). What is left of its own
// outline keeps its fill rule; united faces fill with non-zero.
out = Some(if rest.path.is_empty() {
vec![]
} else {
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
};
let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none();
// Edges come back from the planar map within a few hundredths of the outlines they
// follow, whatever else is selected.
let cut_tol = b::outline_snap(&sh.path);
let mut cut = false;
for piece in &cuts {
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
rest = r;
cut = true;
}
}
if in_union || cut {
// What is left of its own outline keeps its fill rule; united faces fill with non-zero.
out = Some(if rest.is_empty() { vec![] } else { vec![remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))] });
}
vec![remnant(d, leaf, rest.path.clone(), rest.united.then_some(FillRule::NonZero))]
});
}
// The merged shape goes in place of the object it takes its look from, so it stays in
// that object's group (a clip group too).
+2
View File
@@ -752,6 +752,8 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
/// file becomes the document's own; the live document gets them in [`SaveJob::finish`]). Bad
/// options fail here, before anything is encoded.
pub fn save_job(s: &mut Session, plan: SavePlan) -> Result<SaveJob> {
// A Shape Builder merge still being computed for a click is saved with the document.
s.finish_tool_edit_later()?;
// The web build, without a clock, leaves the dates unless given one.
let at = if plan.path.is_some() && plan.retargets() { clock_date(s, "modified", plan.modified) } else { None };
job_for_stamped(s, s.doc()?, plan, at)
+6
View File
@@ -678,6 +678,12 @@ impl Session {
for e in self.prefs.keep_folders(&mut p) {
log::warn!("{e}");
}
// The edit the active tool computes on another thread is applied first (the Preferences
// dialog applies its settings here, outside a command). A failure to apply it was the
// edit's own, and the preferences apply all the same.
if self.depth == 0 {
let _ = self.finish_tool_edit_later();
}
let grid_changed = p.gridline_every != self.prefs.gridline_every || p.grid_subdivisions != self.prefs.grid_subdivisions;
let history_changed = p.history_states != self.prefs.history_states;
let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color;
+29 -1
View File
@@ -1,4 +1,4 @@
use super::{NodeId, Value, cmd};
use super::{NodeId, Value, cmd, find_command};
/// The commands that act on the active artboard (#693), which the app passes as their `artboard`:
/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, Distribute
@@ -78,6 +78,34 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) {
}
}
/// Queries that apply the edit the active tool is computing before they run, as edits do
/// ([`Session::finish_tool_edit`]): a batch runs edits, Find Objects with `select`, Select All
/// Instances and Lock Guides change the selection, and the proof views, the preferences and added
/// fonts give the documents new revisions (they draw again, and type lays out again).
const WAIT_FOR_TOOL_EDIT: &[&str] = &[
"command.batch",
"document.find",
"symbol.selectInstances",
"view.guides.lock",
"view.proofSetup",
"view.proofColors",
"view.overprintPreview",
"view.separationsPreview",
"prefs.set",
"prefs.reset",
"text.addFontFiles",
];
/// Does command `id`, run on its own, first apply the edit the active tool is computing on
/// another thread and the input held for it ([`Session::finish_tool_edit`])? Every command that
/// can edit, select, save or give a document a new revision does; queries that only read do not
/// (`commands_that_do_not_wait_for_a_tool_edit_change_no_document` in the command sweep checks
/// them). A frontend that adds params from
/// its own state (the active artboard) applies them first itself.
pub fn waits_for_tool_edit(id: &str) -> bool {
find_command(id).is_some_and(|spec| spec.journal || WAIT_FOR_TOOL_EDIT.contains(&id))
}
/// Journaled commands that write a file and change nothing in the document: with an object of a
/// dynamic symbol instance's art selected, they read the instance as queries do
/// ([`crate::Session::execute`]).
+14 -1
View File
@@ -62,6 +62,19 @@ pub struct Session {
/// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
/// back as it was at the release ([`Session::pointer`]).
pub(crate) lender: Option<Box<dyn Tool>>,
/// Pointer and key input that came while the tool computed an edit on another thread, each with
/// its number and view, made in order once the edit is applied.
held_input: std::collections::VecDeque<(u64, tooling::HeldInput, ViewInfo)>,
/// The number the next pointer or key event handed to the session gets.
input_count: u64,
/// The number of the event whose handling started the edit the tool is computing.
edit_started_by: Option<u64>,
/// Set while the tool's edit and the input held for it are applied: the commands that run then
/// do not start applying them again.
settling_tool_edit: bool,
/// Requests for the UI from making held input where nothing could take them (a command, a tool
/// switch), which the next [`Session::tool_poll`] returns.
held_ui: Vec<UiRequest>,
/// The active document (its uid) and its [`History::step`] when the tool last handled a pointer
/// event or drew its overlays: when they differ at the next one, the tool is told first
/// ([`vectorcraft_tools::Tool::history_moved`]). None until the first.
@@ -314,7 +327,7 @@ mod session;
pub(crate) use command_sets::SELECTS_GIVEN_IDS;
#[cfg(test)]
use command_sets::given_ids;
pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD};
pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit};
pub use doc_state::{DocState, OpenRows, RecalledSelection};
pub use error::{EngineError, Result};
pub use history::{History, HistoryEntry, NamedStep, default_history_budget};
+1 -1
View File
@@ -1,4 +1,4 @@
use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only};
use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only, waits_for_tool_edit};
use super::doc_state::DocState;
use super::error::{EngineError, Result};
use super::history::NamedStep;
+8
View File
@@ -46,6 +46,11 @@ impl Session {
journal: Journal::default(),
tool: vectorcraft_tools::create("selection"),
lender: None,
held_input: Default::default(),
input_count: 0,
edit_started_by: None,
settling_tool_edit: false,
held_ui: vec![],
tool_step: None,
selection_at_lend: None,
lent_selection: None,
@@ -162,6 +167,9 @@ impl Session {
}
self.press_type_selection = None;
let view = self.last_view;
// The edit the tool computes on another thread and the input held for it belong to the
// document being left. The switch goes on whatever they did, as for the deactivation.
let _ = self.finish_tool_edit_later();
// The switch goes on whatever the lent tool's last actions did, as for the deactivation.
let _ = self.give_back_tool(view);
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
+5
View File
@@ -230,6 +230,11 @@ impl Session {
if label != "Typing" && cmd::typing_in_progress(self) {
cmd::settle_interaction(self)?;
}
// An edit the tool computes on another thread (a Shape Builder merge) is applied first too,
// so the snapshot the interaction previews from and goes back to holds it.
if !self.in_interaction() {
self.finish_tool_edit_later()?;
}
self.clear_deferred_overlay_cache();
let paint = self.paint.clone();
let st = self.doc_mut()?;
+7 -1
View File
@@ -1,6 +1,6 @@
use super::{
CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard,
strip_replay_only,
strip_replay_only, waits_for_tool_edit,
};
impl Session {
@@ -39,6 +39,12 @@ impl Session {
/// Execute a command by id. This is THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?;
// An edit the active tool is computing on another thread (a Shape Builder merge) is
// applied before a command that may edit, select, save or give a document a new revision,
// as the click came first. Queries that only read do not wait for it.
if self.depth == 0 && waits_for_tool_edit(id) {
self.finish_tool_edit_later()?;
}
let outer = std::mem::replace(&mut self.reading, !spec.journal || JOURNALED_READS.contains(&id));
let r = self.execute_spec(spec, id, params);
self.reading = outer;
+4
View File
@@ -207,6 +207,10 @@ impl Session {
/// [`Session::end_undo_scope`]: open an undo group there or, when one is open already, join it,
/// so that the edits are part of its step. `None` without a document.
pub fn begin_undo_scope(&mut self) -> Option<UndoScope> {
// An edit the tool computes on another thread is a step before the scope, so the scope's
// snapshot holds it. A failure to apply it was the edit's own, and the scope begins all the
// same.
let _ = self.finish_tool_edit_later();
let journal = self.journal.len();
let st = self.active()?;
let uid = st.uid;
+480
View File
@@ -607,6 +607,486 @@ fn shape_builder_merge_uses_the_map_the_tool_has() {
assert_eq!(maps_built(), built + 1, "another gap");
}
/// 40 squares 20 pt wide in a row, each 10 pt right of the one before (160 segments, which the
/// Shape Builder merges on another thread), all selected.
fn square_row(s: &mut Session) -> Vec<NodeId> {
let ids: Vec<NodeId> = (0..40).map(|k| rect(s, f64::from(k) * 10.0, 0.0, 20.0, 20.0)).collect();
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
ids
}
/// The active document of `s` without its dates, so documents made in two sessions at different
/// times compare equal when their art is.
fn undated(s: &Session) -> vectorcraft_doc::Document {
let mut d = (*s.doc().unwrap().doc).clone();
(d.metadata.created, d.metadata.modified) = (None, None);
d
}
/// The Shape Builder over [`square_row`], its map built, after a click at (15, 10): the merge is
/// being computed on another thread, and the document is as it was.
fn click_row(s: &mut Session, v: ViewInfo) {
s.select_tool("shapeBuilder", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 15.0, 10.0), v).unwrap();
s.tool_poll(v, true).unwrap();
let revision = s.doc().unwrap().revision;
s.pointer(&PointerEvent::new(PointerKind::Down, 15.0, 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 15.0, 10.0), v).unwrap();
assert!(s.tool_editing() && s.tool_waiting());
assert_eq!(s.doc().unwrap().revision, revision, "the release returns before the merge is applied");
}
/// A click of the Shape Builder on a large selection merges on another thread, and the merge the
/// next poll applies is the command's own, as one undo step.
#[test]
fn shape_builder_merges_a_large_selection_on_another_thread() {
use vectorcraft_tools::builder::merges_computed;
let v = ViewInfo::default();
let mut s = session();
square_row(&mut s);
click_row(&mut s, v);
let computed = merges_computed();
s.tool_poll(v, true).unwrap();
assert!(!s.tool_editing());
assert_eq!(merges_computed(), computed, "the command takes the merge computed on another thread");
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
assert_eq!(layer_children(&s), 41, "the overlap and what is left of the two squares over it");
let mut direct = session();
square_row(&mut direct);
direct.execute("shapeBuilder.merge", &json!({"points": [[15, 10]]})).unwrap();
assert!(undated(&s) == undated(&direct), "the same document as the command gives");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(layer_children(&s), 40, "one undo step");
}
/// What comes after a click whose merge is being computed on another thread waits for the merge
/// and runs on the merged art, as when the merge ran on the calling thread: a command (an edit,
/// an undo, a selection, a save, a batch, Lock Guides, the proof views, a preference, Add Font
/// Files), an option change, another tool, a save made outside a command, preferences applied
/// outside a command, a dialog's preview, a scrubbed field's undo scope, a Cmd-click or Cmd-drag
/// with the selection tool. The merge is the one computed on the other thread.
#[test]
fn shape_builder_applies_a_merge_before_what_comes_next() {
use vectorcraft_tools::builder::merges_computed;
type Next = (&'static str, fn(&mut Session, &[NodeId], &std::path::Path));
let v = ViewInfo::default();
let next: Vec<Next> = vec![
("Select All Instances", |s, _, _| {
s.execute("symbol.selectInstances", &json!({"name": "S"})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects.len(), 1);
}),
("an edit", |s, _, _| {
s.execute("object.move", &json!({"dx": 0, "dy": 100})).unwrap();
}),
("an undo", |s, _, _| {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(layer_children(s), 40, "the undo takes the merge back");
s.execute("edit.redo", &json!({})).unwrap();
}),
("another selection", |s, _, _| {
s.execute("select.inverse", &json!({})).unwrap();
}),
("Select All", |s, _, _| {
s.execute("select.all", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects.len(), 41);
}),
("a save", |s, _, dir| {
s.execute("file.saveAs", &json!({"path": dir.join("a.vectorcraft").to_string_lossy()})).unwrap();
assert!(!s.doc().unwrap().is_dirty(), "the file holds the merge");
}),
("a save outside a command", |s, _, dir| {
let p = json!({"path": dir.join("b.vectorcraft").to_string_lossy()});
let plan = cmd::fileio::save_plan(s, cmd::fileio::SaveMode::SaveAs, &p).unwrap();
assert_eq!(layer_children(s), 40, "planning a save leaves the document");
let job = cmd::fileio::save_job(s, plan).unwrap();
assert_eq!(layer_children(s), 41, "the snapshot is taken after the merge");
drop(job);
}),
("a batch", |s, _, _| {
s.execute("command.batch", &json!({"commands": [{"command": "object.move", "params": {"dx": 0, "dy": 50}}]})).unwrap();
}),
("an option change", |s, _, _| {
s.set_tool_option_cmd(&json!({"key": "gapDetection", "value": true})).unwrap();
}),
("another tool", |s, _, _| {
s.select_tool("selection", ViewInfo::default()).unwrap();
}),
("a closed document", |s, _, _| {
s.execute("file.close", &json!({})).unwrap();
}),
("Lock Guides", |s, _, _| {
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
}),
("an option set from a panel", |s, _, _| {
s.set_tool_option("gapDetection", &json!(true));
}),
("a dialog's preview, canceled", |s, _, _| {
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 7, "dy": 0})).unwrap();
s.preview("object.move", &json!({"dx": 9, "dy": 0})).unwrap();
s.cancel_interaction().unwrap();
assert_eq!(layer_children(s), 41, "canceling the preview keeps the merge");
}),
("a dialog's preview, kept", |s, _, _| {
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 7, "dy": 0})).unwrap();
s.commit_interaction().unwrap();
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Move", "Shape Builder"]);
}),
("a scrubbed field, canceled", |s, _, _| {
let scope = s.begin_undo_scope().unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.end_undo_scope(scope, true);
assert_eq!(
s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()),
Some("Shape Builder"),
"the scrub's step is undone, not the merge"
);
}),
("a held click, then another tool", |s, _, _| {
let v = ViewInfo::default();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
assert!(s.tool_editing(), "the click is held");
s.select_tool("selection", v).unwrap();
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Shape Builder", "Shape Builder"], "the held click merges with the Shape Builder first");
}),
("a held click, then another document", |s, _, _| {
let v = ViewInfo::default();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
let first = s.active_index().unwrap();
s.add_document(vectorcraft_doc::Document::new(100.0, 100.0), None);
assert!(!s.tool_editing());
let labels: Vec<&str> = s.documents()[first].history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Shape Builder", "Shape Builder"], "the held click merges in its own document first");
s.set_active(first);
}),
("a Cmd-click", |s, _, _| {
let v = ViewInfo::default();
let cmd = Mods { cmd: true, ..Default::default() };
assert_eq!(s.cursor(Point::new(500.0, 300.0), cmd, v), vectorcraft_tools::Cursor::Busy, "Cmd shows the merge is computed");
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 500.0, 300.0).with_mods(cmd), v).unwrap();
}
assert!(s.tool_editing(), "the click is held");
while s.tool_waiting() {
s.tool_poll(v, true).unwrap();
}
assert!(s.doc().unwrap().selection.is_empty(), "the lent selection tool deselects after the merge");
assert_eq!(s.tool_id(), "shapeBuilder");
}),
("a Cmd-drag", |s, _, _| {
let v = ViewInfo::default();
let cmd = Mods { cmd: true, ..Default::default() };
for (kind, y) in [(PointerKind::Down, 10.0), (PointerKind::Drag, 60.0), (PointerKind::Drag, 110.0), (PointerKind::Up, 110.0)] {
s.pointer(&PointerEvent::new(kind, 305.0, y).with_mods(cmd), v).unwrap();
}
assert!(s.tool_editing(), "the drag is held");
while s.tool_waiting() {
s.tool_poll(v, true).unwrap();
}
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Move", "Shape Builder"]);
}),
// The proof views redraw every document (a new revision); they are left off, as the
// other tests of the process-wide proof view expect.
("Separations Preview", |s, _, _| {
s.execute("view.separationsPreview", &json!({"on": false})).unwrap();
}),
("Overprint Preview", |s, _, _| {
s.execute("view.overprintPreview", &json!({"on": false})).unwrap();
}),
("Proof Colors", |s, _, _| {
s.execute("view.proofColors", &json!({"on": false})).unwrap();
}),
("Proof Setup", |s, _, _| {
s.execute("view.proofSetup", &json!({})).unwrap();
}),
("a preference", |s, _, _| {
let every = s.prefs.gridline_every;
s.execute("prefs.set", &json!({"key": "gridlineEvery", "value": every + 10.0})).unwrap();
}),
("Reset Preferences", |s, _, _| {
s.execute("prefs.reset", &json!({})).unwrap();
}),
("preferences applied outside a command", |s, _, _| {
let mut p = s.prefs.clone();
p.gridline_every += 10.0;
s.apply_prefs(p);
}),
("Add Font Files", |s, _, _| {
// No files: the command fails after the merge.
assert!(s.execute("text.addFontFiles", &json!({"files": []})).is_err());
}),
];
let _proof = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = vectorcraft_testkit::temp_dir("shape-builder-merge-first");
for (what, then) in next {
let mut s = session();
if what == "Select All Instances" {
let far = rect(&mut s, 500.0, 500.0, 20.0, 20.0);
s.execute("symbol.new", &json!({"name": "S", "ids": [far.0]})).unwrap();
}
let ids = square_row(&mut s);
click_row(&mut s, v);
let computed = merges_computed();
let journal = s.journal.len();
then(&mut s, &ids, &dir);
assert!(!s.tool_editing(), "{what}");
assert_eq!(merges_computed(), computed, "{what}: the merge computed on another thread");
assert_eq!(s.journal.since(journal).first().map(|j| j.0.as_str()), Some("shapeBuilder.merge"), "{what}: the merge comes first");
if what != "a closed document" {
assert!(layer_children(&s) >= 41, "{what}: the merge stays");
}
}
}
/// Gestures and arrow keys that come while a merge is computed on another thread are held and
/// made after it, in order: the document is the one they make one after the other, and the
/// events count as made once they are and the merge they start is applied.
#[test]
fn shape_builder_holds_gestures_made_during_a_merge() {
type Gesture = fn(&mut Session, ViewInfo);
let cases: [(&str, bool, Gesture); 6] = [
("a second click", false, |s, v| {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
}),
("a drag", false, |s, v| {
for (kind, x) in [(PointerKind::Down, 75.0), (PointerKind::Drag, 85.0), (PointerKind::Drag, 95.0), (PointerKind::Up, 95.0)] {
s.pointer(&PointerEvent::new(kind, x, 10.0), v).unwrap();
}
}),
("two clicks", false, |s, v| {
for x in [55.0, 135.0] {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, x, 10.0), v).unwrap();
}
}
}),
("a Cmd-click", false, |s, v| {
let cmd = Mods { cmd: true, ..Default::default() };
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 500.0, 300.0).with_mods(cmd), v).unwrap();
}
}),
("a Cmd-drag and a click", false, |s, v| {
let cmd = Mods { cmd: true, ..Default::default() };
for (kind, y) in [(PointerKind::Down, 10.0), (PointerKind::Drag, 110.0), (PointerKind::Up, 110.0)] {
s.pointer(&PointerEvent::new(kind, 305.0, y).with_mods(cmd), v).unwrap();
}
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
}),
("an arrow key", true, |s, v| {
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
}),
];
let v = ViewInfo::default();
let start = |swatches: bool| {
let mut s = session();
if swatches {
s.select_tool("shapeBuilder", v).unwrap();
s.set_tool_option_cmd(&json!({"key": "pickColorFrom", "value": "swatches"})).unwrap();
}
square_row(&mut s);
click_row(&mut s, v);
s
};
for (what, swatches, send) in cases {
// One after the other: the merge applied before the gesture, and each merge after its own.
let mut one = start(swatches);
while one.tool_waiting() {
one.tool_poll(v, true).unwrap();
}
send(&mut one, v);
while one.tool_waiting() {
one.tool_poll(v, true).unwrap();
}
// While the merge is computed.
let mut s = start(swatches);
let revision = s.doc().unwrap().revision;
let from = s.input_count();
send(&mut s, v);
let to = s.input_count();
assert!(s.tool_editing() && !s.input_applied(from, to), "{what}: held");
assert_eq!(s.doc().unwrap().revision, revision, "{what}: nothing made before the merge");
while s.tool_waiting() {
s.tool_poll(v, true).unwrap();
}
assert!(!s.tool_editing() && s.input_applied(from, to), "{what}");
assert!(undated(&s) == undated(&one), "{what}: the document the gestures make one after the other");
assert_eq!(s.doc().unwrap().selection, one.doc().unwrap().selection, "{what}");
assert_eq!(s.paint.fill, one.paint.fill, "{what}");
let labels = |s: &Session| s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect::<Vec<_>>();
assert_eq!(labels(&s), labels(&one), "{what}");
// The click's merge alone.
let mut merged = start(swatches);
while merged.tool_waiting() {
merged.tool_poll(v, true).unwrap();
}
let changed = labels(&s).len() > labels(&merged).len()
|| s.paint.fill != merged.paint.fill
|| s.doc().unwrap().selection != merged.doc().unwrap().selection;
assert!(changed, "{what}: the gesture did something");
}
}
/// A press held while a merge is computed on another thread, then dropped (a long touch, a
/// canceled touch, a second finger), is never made: once the merge is applied, the tool is at
/// rest and the document is the one the same input makes, dropped the same way, after the merge.
/// A held click before the press is still made, and so is a perspective plane key held after it.
#[test]
fn shape_builder_drops_a_press_held_during_a_merge() {
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
type Gesture = fn(&mut Session, ViewInfo);
fn press(s: &mut Session, v: ViewInfo, x: f64, mods: Mods) {
s.pointer(&PointerEvent::new(PointerKind::Down, x, 10.0).with_mods(mods), v).unwrap();
}
fn click(s: &mut Session, v: ViewInfo) {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
}
// What a case sends, and whether the perspective grid is shown.
let cases: [(&str, bool, Gesture); 7] = [
("a press", false, |s, v| press(s, v, 75.0, Mods::default())),
("a drag", false, |s, v| {
press(s, v, 75.0, Mods::default());
s.pointer(&PointerEvent::new(PointerKind::Drag, 95.0, 10.0), v).unwrap();
}),
("a Cmd-press", false, |s, v| press(s, v, 305.0, Mods { cmd: true, ..Default::default() })),
("a press and a key", false, |s, v| {
press(s, v, 75.0, Mods::default());
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
}),
("a press and an arrow key", false, |s, v| {
press(s, v, 75.0, Mods::default());
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
}),
("a press and a plane key", true, |s, v| {
press(s, v, 75.0, Mods::default());
s.tool_key(ToolKey::Digit(3), Mods::default(), v).unwrap();
}),
("a click, then a press", false, |s, v| {
click(s, v);
press(s, v, 135.0, Mods::default());
}),
];
let v = ViewInfo::default();
let start = |grid: bool| {
let mut s = session();
s.select_tool("shapeBuilder", v).unwrap();
s.set_tool_option_cmd(&json!({"key": "pickColorFrom", "value": "swatches"})).unwrap();
if grid {
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
}
square_row(&mut s);
click_row(&mut s, v);
s
};
// A tool left in the middle of a gesture keeps waiting: the polls stop after a few.
let settle = |s: &mut Session| {
for _ in 0..8 {
if !s.tool_waiting() {
return;
}
s.tool_poll(v, true).unwrap();
}
};
let labels = |s: &Session| s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect::<Vec<_>>();
let plane = |s: &Session| PerspectiveGrid::from_doc(&s.doc().unwrap().doc).unwrap().plane;
for (what, grid, send) in cases {
// The same input after the merge, dropped the same way.
let mut one = start(grid);
settle(&mut one);
send(&mut one, v);
one.drop_tool_press(v).unwrap();
settle(&mut one);
let mut s = start(grid);
if grid {
assert_ne!(plane(&s), Plane::Right, "{what}: the plane before the key");
}
let from = s.input_count();
send(&mut s, v);
let to = s.input_count();
assert!(s.tool_editing() && !s.input_applied(from, to), "{what}: held");
s.drop_tool_press(v).unwrap();
settle(&mut s);
assert!(!s.tool_waiting() && !s.tool_editing() && s.input_applied(from, to), "{what}");
assert!(!s.tool_busy(), "{what}: the tool is at rest");
assert_eq!(s.tool_id(), "shapeBuilder", "{what}: no tool is lent");
assert!(undated(&s) == undated(&one), "{what}: the document the input makes after the merge");
assert_eq!(labels(&s), labels(&one), "{what}");
assert_eq!(s.doc().unwrap().selection, one.doc().unwrap().selection, "{what}");
assert_eq!(s.paint.fill, one.paint.fill, "{what}");
if grid {
assert_eq!(plane(&one), Plane::Right, "{what}: the plane key is made after the merge");
assert_eq!(plane(&s), Plane::Right, "{what}: the plane key is made");
}
}
}
/// A new revision of the document made outside a command while a merge is computed on another
/// thread keeps the merge when the selected art is as it was (the next poll applies it, as the
/// command computes it), and drops it when the art changed.
#[test]
fn shape_builder_keeps_a_merge_over_a_new_revision_of_the_same_art() {
use vectorcraft_tools::builder::merges_computed;
let v = ViewInfo::default();
let mut s = session();
square_row(&mut s);
click_row(&mut s, v);
let computed = merges_computed();
s.active_mut().unwrap().revision += 1;
s.tool_poll(v, true).unwrap();
assert!(!s.tool_editing());
assert_eq!(merges_computed(), computed, "the merge computed on another thread");
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
let mut direct = session();
square_row(&mut direct);
direct.execute("shapeBuilder.merge", &json!({"points": [[15, 10]]})).unwrap();
assert!(undated(&s) == undated(&direct), "the same document as the command gives");
// The art changed: the merge is dropped.
let mut s = session();
let ids = square_row(&mut s);
click_row(&mut s, v);
let journal = s.journal.len();
let st = s.active_mut().unwrap();
Arc::make_mut(&mut st.doc).node_mut(ids[1]).unwrap().transform(Affine::translate((0.0, 50.0)), false);
st.revision += 1;
s.tool_poll(v, true).unwrap();
assert!(!s.tool_editing());
assert_eq!(s.journal.len(), journal, "nothing merged");
assert_eq!(layer_children(&s), 40);
}
/// A merge computed for the tool Cmd lent the selection tool's drag away from is applied before
/// a command too.
#[test]
fn shape_builder_merge_of_the_lending_tool_comes_first() {
let v = ViewInfo::default();
let mut s = session();
square_row(&mut s);
click_row(&mut s, v);
let lent = s.make_tool("selection");
s.lender = Some(std::mem::replace(&mut s.tool, lent));
assert!(s.tool_editing());
let journal = s.journal.len();
s.execute("object.move", &json!({"dx": 0, "dy": 100})).unwrap();
assert!(!s.tool_editing());
assert_eq!(s.journal.since(journal).first().map(|j| j.0.as_str()), Some("shapeBuilder.merge"));
assert_eq!(s.tool_id(), "selection", "the lent tool stays active until the release");
assert_eq!(s.lender.as_ref().map(|t| t.id()), Some("shapeBuilder"));
}
#[test]
fn shape_builder_tool_merges_the_narrow_shape_it_highlights_in_a_wide_selection() {
let mut s = session();
+273 -9
View File
@@ -83,6 +83,23 @@ pub enum UiRequest {
/// Place a file with these `file.place` params: a click or drag of the place cursor, when
/// [`Session::place_cursor_requests`] is set.
Place(Value),
/// A press held while the tool computed an edit on another thread was made now, at `pos` in the
/// document with [`crate::DocState::uid`] `doc`, with tool `tool` active (the selection tool Cmd
/// lent): the frontend does what it does after a press it hands over itself.
HeldPress {
doc: Option<u64>,
pos: Point,
tool: &'static str,
},
}
/// Pointer or key input that came while the active tool computed an edit on another thread
/// ([`Tool::editing`]), made in order once the edit is applied ([`Session::tool_poll`],
/// [`Session::finish_tool_edit`]).
#[derive(Clone, Copy, Debug)]
pub(crate) enum HeldInput {
Pointer(PointerEvent),
Key(ToolKey, Mods),
}
impl Session {
@@ -106,6 +123,7 @@ impl Session {
/// Choose a tool (the toolbar, its shortcut, MCP), finishing any pending tool work first.
pub fn select_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
self.finish_tool_edit_later()?;
// Choosing a perspective tool shows the document's perspective grid; Hide Grid hides it
// again, the tool staying chosen.
if matches!(id, "perspectiveGrid" | "perspectiveSelection")
@@ -129,6 +147,8 @@ impl Session {
/// Switch to tool `id` without choosing it afresh (back from a temporary tool: a hidden
/// perspective grid stays hidden), finishing any pending tool work first.
pub fn switch_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
// The edit the tool computes on another thread and the input held for it come first.
self.finish_tool_edit_later()?;
self.give_back_tool(view)?;
if self.tool.id() == id {
return Ok(());
@@ -430,8 +450,85 @@ impl Session {
self.tool_step = now;
}
/// Feed a pointer event to the active tool. Returns requests for the UI.
/// Feed a pointer event to the active tool. Returns requests for the UI. While the tool computes
/// an edit on another thread, a press and what follows it are held and made once the edit is
/// applied, in order ([`Session::tool_poll`]); moves of the pointer with no button held go to
/// the tool at once.
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
let n = self.next_input();
if ev.kind != PointerKind::Move && (!self.held_input.is_empty() || self.tool_edit_pending()) {
self.held_input.push_back((n, HeldInput::Pointer(*ev), view));
return Ok(vec![]);
}
let r = self.pointer_now(ev, view);
self.note_edit_start(n);
r
}
/// The number of the next pointer or key event handed to the session.
fn next_input(&mut self) -> u64 {
let n = self.input_count;
self.input_count = n.wrapping_add(1);
n
}
/// How many pointer and key events have been handed to the session ([`Session::pointer`],
/// [`Session::tool_key`]); with [`Session::input_applied`], a frontend tells when the events it
/// handed over have been made.
pub fn input_count(&self) -> u64 {
self.input_count
}
/// Have the events numbered `from..to` ([`Session::input_count`] before and after handing them
/// over) been made, and the edit they started on another thread applied?
pub fn input_applied(&self, from: u64, to: u64) -> bool {
let ours = |n: &u64| (from..to).contains(n);
!self.held_input.iter().any(|(n, ..)| ours(n)) && !(self.tool_edit_pending() && self.edit_started_by.as_ref().is_some_and(ours))
}
/// After event `n` was made: the event that started the edit being computed, if one is.
fn note_edit_start(&mut self, n: u64) {
if !self.tool_edit_pending() {
self.edit_started_by = None;
} else if self.edit_started_by.is_none() {
self.edit_started_by = Some(n);
}
}
/// Is the active tool, or the tool Cmd lent the selection tool's drag away from, computing an
/// edit on another thread?
fn tool_edit_pending(&self) -> bool {
self.tool.editing() || self.lender.as_ref().is_some_and(|t| t.editing())
}
/// Makes the input held while the tool computed an edit, in order, until a held gesture starts
/// another such edit.
fn replay_held_input(&mut self) -> Result<Vec<UiRequest>> {
let mut ui = vec![];
let mut failed = None;
while !self.tool_edit_pending()
&& let Some((n, input, view)) = self.held_input.pop_front()
{
let r = match input {
HeldInput::Pointer(ev) => self.pointer_now(&ev, view).map(|mut r| {
if ev.kind == PointerKind::Down {
r.push(UiRequest::HeldPress { doc: self.active().map(|d| d.uid), pos: ev.pos, tool: self.tool.id() });
}
r
}),
HeldInput::Key(key, mods) => self.tool_key_now(key, mods, view),
};
self.note_edit_start(n);
match r {
Ok(r) => ui.extend(r),
// The input after it is still made, as it was when each event came on its own.
Err(e) => failed = failed.or(Some(e)),
}
}
failed.map_or(Ok(ui), Err)
}
fn pointer_now(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
self.pointer_event(ev, view, true)
}
@@ -442,8 +539,25 @@ impl Session {
/// nothing and a click opens no size dialog. The tool gets back the persistent options it had
/// when the press began, and none of the press's changes to them are stored. A dropped Type
/// press restores the prior selection. The freeform gradient point selected once the tool
/// took the press is selected again.
/// took the press is selected again. A press held while the tool computes an edit on another
/// thread has not reached the tool: it is removed from the held input with the pointer events
/// and tool keys held after it, and the tool is left as it is. A perspective plane key held
/// after it stays held, since the plane it picks outlasts a dropped press. Pointer events held
/// after the last held release belong to the press the tool has, and are removed with it.
pub fn drop_tool_press(&mut self, view: ViewInfo) -> Result<()> {
let (from, held) = self.held_press();
let keep: Vec<bool> = (self.held_input.iter().enumerate())
.map(|(j, (_, input, _))| match input {
_ if j < from => true,
HeldInput::Pointer(_) => false,
HeldInput::Key(key, _) => !held || self.plane_key(*key).is_some(),
})
.collect();
let mut keep = keep.into_iter();
self.held_input.retain(|_| keep.next().unwrap_or(true));
if held {
return Ok(());
}
// Nothing to roll back without a document; the tool still drops the press and gets the release.
let _ = self.cancel_interaction();
let before = self.press_options.take();
@@ -485,6 +599,19 @@ impl Session {
r.map(drop)
}
/// Where the input of the press in progress starts in the held input, and whether the press
/// itself is held: the last held press when no release is held after it; otherwise the input
/// after the last held release (all of it when none is held), which follows a press the tool
/// already has.
fn held_press(&self) -> (usize, bool) {
let ends = |kind: PointerKind| matches!(kind, PointerKind::Down | PointerKind::Up);
match self.held_input.iter().rposition(|(_, input, _)| matches!(input, HeldInput::Pointer(ev) if ends(ev.kind))) {
Some(i) if matches!(self.held_input.get(i), Some((_, HeldInput::Pointer(ev), _)) if ev.kind == PointerKind::Down) => (i, true),
Some(i) => (i + 1, false),
None => (0, false),
}
}
/// The options of `t` that [`Self::store_tool_settings`] stores.
fn persistent_options(t: &dyn Tool) -> Map<String, Value> {
let opts = t.options();
@@ -619,19 +746,148 @@ impl Session {
self.tool.wants_samples()
}
/// Is the active tool waiting for work it runs on another thread ([`Tool::waiting`])?
/// Is the active tool waiting for work it runs on another thread ([`Tool::waiting`]), is input
/// held until an edit it computes is applied, or are requests for the UI left from making it?
pub fn tool_waiting(&self) -> bool {
self.tool.waiting()
self.tool.waiting() || !self.held_input.is_empty() || !self.held_ui.is_empty()
}
/// Lets the active tool check on the work [`Session::tool_waiting`] reports, and with `wait`
/// wait for it to finish ([`Tool::poll`]).
pub fn tool_poll(&mut self, view: ViewInfo, wait: bool) -> Result<()> {
self.with_tool_cx(view, |t, cx| t.poll(cx, wait));
self.take_tool_panic()
/// wait for it to finish ([`Tool::poll`]); an edit the work made is applied, and then the input
/// held for it is made. Returns requests for the UI, those left from making held input where
/// nothing could take them first ([`Session::finish_tool_edit_later`]).
pub fn tool_poll(&mut self, view: ViewInfo, wait: bool) -> Result<Vec<UiRequest>> {
if self.settling_tool_edit {
return Ok(vec![]);
}
self.settling_tool_edit = true;
let r = self.tool_poll_now(view, wait);
self.settling_tool_edit = false;
// On an error the requests left wait for the next poll.
let r = r?;
let mut ui = std::mem::take(&mut self.held_ui);
ui.extend(r);
Ok(ui)
}
fn tool_poll_now(&mut self, view: ViewInfo, wait: bool) -> Result<Vec<UiRequest>> {
let acts = self.with_tool_cx(view, |t, cx| t.poll(cx, wait));
self.take_tool_panic()?;
let applied = !acts.is_empty();
let mut ui = self.apply_actions(acts)?;
if !self.tool_edit_pending() {
self.edit_started_by = None;
// The input held for an edit applied now is made at the next poll, so what waits for the
// edit alone (a `ui.pointer` reply) sees the document as the edit left it.
if !applied {
ui.extend(self.replay_held_input()?);
}
}
Ok(ui)
}
/// Is the active tool computing an edit on another thread ([`Tool::editing`]), or is input held
/// until it is applied?
pub fn tool_editing(&self) -> bool {
self.tool_edit_pending() || !self.held_input.is_empty()
}
/// Waits for the edit the active tool computes on another thread and applies it
/// ([`Tool::finish_edit`]), then makes the input held for it, waiting in turn for an edit that
/// starts, so what runs next sees the document as the gestures left it. Commands, saves, option
/// changes, interactions, undo scopes and tool or document switches call it first. Returns
/// requests for the UI, with those left from earlier ([`Session::finish_tool_edit_later`]); on
/// an error, the next [`Session::tool_poll`] returns them.
pub fn finish_tool_edit(&mut self) -> Result<Vec<UiRequest>> {
if self.settling_tool_edit {
return Ok(vec![]);
}
let mut ui = std::mem::take(&mut self.held_ui);
if self.tool_editing() {
self.settling_tool_edit = true;
let r = self.finish_tool_edit_now();
self.settling_tool_edit = false;
match r {
Ok(r) => ui.extend(r),
Err(e) => {
ui.append(&mut self.held_ui);
self.held_ui = ui;
return Err(e);
}
}
}
Ok(ui)
}
/// [`Session::finish_tool_edit`] for a caller that cannot hand requests to the UI (a command, a
/// tool switch): the next [`Session::tool_poll`] returns them, and [`Session::tool_waiting`]
/// holds until then.
pub(crate) fn finish_tool_edit_later(&mut self) -> Result<()> {
let ui = self.finish_tool_edit()?;
self.held_ui.extend(ui);
Ok(())
}
fn finish_tool_edit_now(&mut self) -> Result<Vec<UiRequest>> {
let mut ui = vec![];
let mut failed = None;
loop {
match self.finish_pending_edit() {
Ok(r) => ui.extend(r),
Err(e) => failed = failed.or(Some(e)),
}
// A tool that cannot finish its edit keeps the input held.
if self.tool_edit_pending() {
break;
}
self.edit_started_by = None;
if self.held_input.is_empty() {
break;
}
match self.replay_held_input() {
Ok(r) => ui.extend(r),
Err(e) => failed = failed.or(Some(e)),
}
}
match failed {
None => Ok(ui),
Some(e) => {
self.held_ui.extend(ui);
Err(e)
}
}
}
/// Waits for the edit being computed and applies it: the active tool's, or that of the tool
/// Cmd lent the selection tool's drag away from, which is active meanwhile.
fn finish_pending_edit(&mut self) -> Result<Vec<UiRequest>> {
if self.tool.editing() {
let acts = self.with_tool_cx(self.last_view, |t, cx| t.finish_edit(cx));
self.take_tool_panic()?;
return self.apply_actions(acts);
}
let Some(lender) = self.lender.take_if(|t| t.editing()) else { return Ok(vec![]) };
let lent = std::mem::replace(&mut self.tool, lender);
let acts = self.with_tool_cx(self.last_view, |t, cx| t.finish_edit(cx));
let r = self.take_tool_panic().and_then(|()| self.apply_actions(acts));
self.lender = Some(std::mem::replace(&mut self.tool, lent));
r
}
/// A key for the active tool. While the tool computes an edit on another thread, the key is held
/// and made once the edit is applied, after the input held before it.
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
let n = self.next_input();
if self.tool_editing() {
self.held_input.push_back((n, HeldInput::Key(key, mods), view));
return Ok(vec![]);
}
let r = self.tool_key_now(key, mods, view);
self.note_edit_start(n);
r
}
fn tool_key_now(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
// 1–4 pick the active perspective plane while the grid is shown.
if let Some(plane) = self.plane_key(key) {
self.execute("perspective.plane.set", &serde_json::json!({ "plane": plane.id() }))?;
@@ -837,7 +1093,8 @@ impl Session {
/// it is on, makes the drawing tools' a crosshair, [`Cursor::precise`]). With Cmd held, the pointer of the selection tool a
/// press would borrow ([`Self::lend_selection_tool`]).
pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor {
let lent = if mods.cmd && self.lender.is_none() { self.lent_tool(self.tool.id(), &mods) } else { None };
// While an edit is computed, a Cmd press is held for the active tool, which shows it is busy.
let lent = if mods.cmd && self.lender.is_none() && !self.tool_editing() { self.lent_tool(self.tool.id(), &mods) } else { None };
let c = match lent {
Some(id) => {
let lent = self.make_tool(id);
@@ -860,6 +1117,9 @@ impl Session {
self.tool.options()
}
pub fn set_tool_option(&mut self, key: &str, v: &Value) {
// The active tool's edit being computed is applied with the options it was started with. A
// failure to apply it was the edit's own, and the option is set all the same.
let _ = self.finish_tool_edit_later();
if key == "queue" && self.tool.id() == "place" {
self.note_place_queue_roots();
}
@@ -934,6 +1194,10 @@ impl Session {
{
return Err(format!("unknown tool `{t}`"));
}
// The active tool's merge being computed is applied with the options it was started with.
if tool.is_none_or(|t| t == self.tool.id()) {
self.finish_tool_edit_later().map_err(|e| e.to_string())?;
}
let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
if let Some(k) = p.get("key").and_then(Value::as_str) {
values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null));
+50
View File
@@ -219,6 +219,56 @@ fn every_command_with_junk_params() {
);
}
/// Each document's uid, revision and selected ids, the active document and the current paint.
type ToolEditState = (Vec<(u64, u64, Vec<u64>)>, Option<usize>, vectorcraft_tools::PaintDefaults);
/// What a command that runs while the active tool computes an edit on another thread must leave
/// as it was: every document's revision and selection, which document is active, and the current
/// paint.
fn tool_edit_state(s: &Session) -> ToolEditState {
let docs = s.documents().iter().map(|d| (d.uid, d.revision, d.selection.objects.iter().map(|i| i.0).collect())).collect();
(docs, s.active_index(), s.paint.clone())
}
/// Every command that runs at once while the active tool computes an edit on another thread (a
/// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the
/// documents' revisions and selections, the active document and the current paint as they were,
/// with `{}`, junk params and params that turn its options on and off: the edit, computed for the
/// document as it was, still applies after it.
#[test]
fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() {
let path = safe_path();
let mut failures = vec![];
for fx in [Fixture::Single, Fixture::Multi] {
for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) {
let mut cases = junk_params(c.params, &path);
if heavy(c.id) {
cases.truncate(12);
}
for k in param_keys(c.params) {
for v in [json!(true), json!(false), json!(1), json!("on")] {
cases.push(json!({ k.as_str(): v }));
}
}
// Find Objects selects only with a filter, which one key at a time does not give it.
if c.id == "document.find" {
cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]);
}
for p in &cases {
let mut s = fx.session();
let before = tool_edit_state(&s);
let _ = catch_quiet(|| no_panic(s.execute(c.id, p)));
if tool_edit_state(&s) != before {
failures.push(format!("{} {p} [{fx:?}]", c.id));
}
}
}
}
failures.sort();
failures.dedup_by(|a, b| a.split(' ').next() == b.split(' ').next());
assert!(failures.is_empty(), "{} commands change a document without waiting for a tool edit:\n{}", failures.len(), failures.join("\n"));
}
/// Junk inside the obvious structured params: ids arrays, anchors, matrices, colours, stops.
#[test]
fn structured_junk() {
+59
View File
@@ -3239,6 +3239,50 @@ fn text_editing_of(document: &str, objects: &str) -> String {
/// Area Type Options as an area frame's data may keep them: rows, columns, gutters and the first
/// baseline, and keys besides, each with a value of any kind, in or out of range.
/// A paragraph's attributes as a text document might keep them: an alignment (key 0), auto leading
/// (7), hyphenation options (10 to 15) and tab stops (30) of any value or type, the stops a list of
/// any length whose entries may be anything.
fn arb_paragraph_attributes() -> impl Strategy<Value = String> {
let values = prop::sample::select(vec![
"0",
"1",
"2",
"3",
"6",
"7",
"-1",
"2.5",
"1e400",
"-1e400",
"NaN",
"inf",
"1e9",
"(x)",
"true",
"false",
"/nil",
"[ 1 ]",
"<< /0 1 >>",
]);
let keys = prop::sample::select(vec!["0", "7", "10", "11", "12", "13", "14", "15"]);
let stop = (values.clone(), values.clone(), 0usize..3).prop_map(|(at, align, shape)| match shape {
0 => format!("<< /0 {at} /1 {align} >>"),
1 => format!("<< /1 {align} >>"),
_ => at.to_string(),
});
let tabs = (prop::collection::vec(stop, 0..40), 0usize..4).prop_map(|(stops, shape)| match shape {
0 => format!("/30 << /0 [ {} ] >>", stops.join(" ")),
1 => "/30 << /0 7 >>".to_string(),
2 => "/30 [ 1 2 ]".to_string(),
_ => String::new(),
});
(prop::collection::vec((keys, values), 0..8), tabs).prop_map(|(kv, tabs)| {
let mut attrs: Vec<String> = kv.iter().map(|(k, v)| format!("/{k} {v}")).collect();
attrs.push(tabs);
attrs.join(" ")
})
}
fn arb_area_options() -> impl Strategy<Value = String> {
let keys = prop::sample::select(vec!["3", "4", "5", "7", "8", "10", "13", "14"]);
let values = prop::sample::select(vec![
@@ -3463,6 +3507,18 @@ proptest! {
survive(".ai text frames with hostile area options", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
}
/// The same text document with hostile paragraph attributes: an alignment, auto leading,
/// hyphenation options and tab stops of any value, type or size.
#[test]
fn text_frames_with_hostile_paragraph_attributes_never_panic(attrs in arb_paragraph_attributes()) {
let text = frames_document_text().replace("/5 << /0 2 /1 4 /2 6 >>", &format!("/5 << /1 4 /2 6 {attrs} >>"));
let editing = text_editing_of(&text, FRAME_OBJECTS);
let bytes = editing_eps(&editing);
survive("EPS text frames with hostile paragraph attributes", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?;
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&editing)].concat();
survive(".ai text frames with hostile paragraph attributes", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
}
/// The type on a path of the same text document with hostile Type on a Path Options, opened as
/// an EPS document and as a `.ai`'s editing data.
#[test]
@@ -5119,6 +5175,9 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"-9 /Real (CornerRadius) ,",
"1 /Bool (Absolute) ,",
"-0.5 /Real ,",
"0.5 /Real ,",
"1 /Real ,",
"1e308 /Real ,",
"1.5 /Real ,",
"-2 /Real ,",
"; (BSLeftWidthsArray) ,",
+1 -1
View File
@@ -1308,7 +1308,7 @@ impl<'a> Reader<'a> {
self.drawn_looks += self.pattern_looks.values().sum::<usize>();
if self.drawn_looks > 0 {
warnings.push(format!(
"objects with several fills or strokes, effects or brushes came in as their drawn look ({}): their appearance isn't editable yet",
"objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({}): their appearance isn't editable yet",
self.drawn_looks
));
}
+35 -1
View File
@@ -752,6 +752,24 @@ impl<'a> Reader<'a> {
{
return;
}
// A variable-width stroke's look that draws nothing but copies read from `%_` lines: an
// empty group, which [`styled`] takes the stroke for.
let drawn;
let look = if !hidden
&& slot_of(object.name.as_deref()).is_none()
&& matches!(look.kind, NodeKind::Group { .. })
&& self.styles.get(&style).is_some_and(|st| st.uneven)
&& !draws(look, &self.hidden_ids)
{
let mut copy = look.clone();
if let Some(children) = copy.children_mut() {
children.clear();
}
drawn = copy;
&drawn
} else {
look
};
// A style read here: the object, painted as it says.
if let Some(mut appearance) = self.styles.get(&style).and_then(|st| {
styled(
@@ -1067,7 +1085,8 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
// A symbol instance draws its symbol's art: it keeps its drawn look.
if !st.plain
|| matches!(object.kind, NodeKind::SymbolInstance { .. })
|| (!slot && !st.simple && !stacked)
// A look with no outline in it to keep takes a variable-width stroke as the object's own.
|| (!slot && !st.simple && !stacked && (!st.uneven || draws(look, &HashSet::new())))
|| (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty())
|| (st.pathfinder
&& (slot || !st.items.is_empty() || object.is_compound_shape() || !matches!(object.kind, NodeKind::Group { clip: false, .. })))
@@ -1275,6 +1294,21 @@ fn strip(n: &mut Node, hidden: &HashSet<NodeId>) {
}
}
/// Whether `n` holds anything that draws, apart from the children read from `%_` lines (`hidden`):
/// a path or a compound path with a paint of its own, or a node other than a group or those.
fn draws(n: &Node, hidden: &HashSet<NodeId>) -> bool {
if hidden.contains(&n.id) {
return false;
}
let paints = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none());
match (&n.kind, n.children()) {
(NodeKind::Compound { .. }, Some(children)) => paints && children.iter().any(|c| c.visible && !hidden.contains(&c.id)),
(NodeKind::Group { .. }, Some(children)) => children.iter().any(|c| c.visible && draws(c, hidden)),
(NodeKind::Path { .. }, _) => paints,
_ => true,
}
}
/// Whether ASCII85 data in comment lines, up to its `~>`, follows the entry keyed `key` of a
/// dictionary of type `ty`: the `/ASCII85Decode` of a `/Binary` dictionary or a text document, or a
/// non-native art object's (`/ForeignObject`'s) `/Data`.
+21 -3
View File
@@ -174,13 +174,16 @@ const PATHFINDER_OPTIONS: [(&str, Kind, f64); 12] = [
pub(super) struct ArtStyle {
/// The style reads as [`Self::items`] and [`Self::effects`].
pub plain: bool,
/// It is one fill and one stroke at most, in solid colours or the object's own gradient, with
/// It is one fill and one stroke at most, none of them [`Self::uneven`], in solid colours or the object's own gradient, with
/// no transparency or effects of their own, and only Drop Shadow, Inner Glow, Outer Glow,
/// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke),
/// Bas Relief read from its settings block and some Distort & Transform effects ([`any_object_takes`]) on the object
/// (and Pathfinder effects when it has no fills or strokes): what any object is read with. Type takes the other plain styles too (its drawn look
/// can't be retyped), paths and compound paths [`Self::stacked`] ones; other objects keep their drawn look, which is the app's own rendering.
pub simple: bool,
/// It would be [`Self::simple`] but for a Stroke Style filter's variable width written as one
/// centered array ([`centered_widths`]): an object whose drawn look is empty takes it with its profile.
pub uneven: bool,
/// It is several fills and strokes in solid colours or gradients (strokes without a brush),
/// each with its own visibility, opacity and blend mode but no effects of its own, under only
/// the effects any object takes that don't move the outline: what a path or compound path is
@@ -1601,7 +1604,7 @@ fn style(filters: &[Filter]) -> ArtStyle {
st.plain = b.plain;
let bas_relief = bas_relief_read(filters);
let count = |stroke: bool| st.items.iter().filter(|i| matches!(i, StyleItem::Stroke { .. }) == stroke).count();
st.simple = st.plain
let simple = st.plain
&& count(true) <= 1
&& count(false) <= 1
&& st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { .. } | StyleItem::Gradient { .. }))
@@ -1612,7 +1615,11 @@ fn style(filters: &[Filter]) -> ArtStyle {
&& (!st.moves_outline() || st.effects.len() == 1)
// Pathfinder effects on a group with no fills or strokes of its own (`styled` checks the group).
&& (!st.pathfinder || st.items.is_empty());
st.stacked = st.plain
// A stroke with one centered width array comes in as the drawn look, which holds the outline as the file draws it.
let centered = filters.iter().any(centered_widths);
(st.simple, st.uneven) = (simple && !centered, simple && centered);
st.stacked = !centered
&& st.plain
&& !st.pathfinder
&& !st.moves_outline()
&& st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Gradient { .. } | StyleItem::Stroke { brush: None, .. }))
@@ -1621,6 +1628,17 @@ fn style(filters: &[Filter]) -> ArtStyle {
st
}
/// Whether `f` is a painted Stroke Style filter with a variable width written as one centered array,
/// `BSCenteredWidthsArray`. A Stroke Offset filter's profile and one written as each side's widths
/// (`BSLeftWidthsArray` and `BSRightWidthsArray`, as VectorCraft saves it) are not.
fn centered_widths(f: &Filter) -> bool {
let each_side = f
.arrays
.get("BSVariableWidthTValuesArray")
.is_some_and(|at| ["BSLeftWidthsArray", "BSRightWidthsArray"].iter().all(|key| f.arrays.get(*key).is_some_and(|w| w.len() == at.len())));
f.name == STROKE && f.paints.0 && !each_side && width_profile(&f.arrays).is_some()
}
mod adapter;
#[cfg(test)]
mod tests;
+51 -12
View File
@@ -5,20 +5,59 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V]) {
super::paint_op(f, op, vals, &mut Names::default());
}
#[test]
fn a_variable_width_stroke_keeps_its_profile() {
let data = b"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/// The style `Tapered`: a 30 pt stroke whose beautiful-strokes dictionary holds `arrays`.
fn width_style(arrays: &str) -> ArtStyle {
let data = format!(
"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 30 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\
/Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
/Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n";
let styles = art_styles(data);
let style = &styles["Tapered"];
assert!(style.plain);
let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{:?}", style.items) };
assert_eq!(*width, 30.0);
assert_eq!(profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)]));
/Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n{arrays}; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n"
);
art_styles(data.as_bytes()).remove("Tapered").unwrap_or_default()
}
/// A beautiful-strokes array `key` of `values`.
fn bs_array(key: &str, values: &[&str]) -> String {
let items: String = values.iter().map(|v| format!("{v} /Real ,\n")).collect();
format!("/Array :\n{items}; ({key}) ,\n")
}
#[test]
fn a_centered_width_array_reads_as_uneven_with_its_profile() {
// GUARDS: the style counted as `simple`, and any object read with it dropped its drawn look.
let t = |v: &[&str]| bs_array("BSVariableWidthTValuesArray", v);
let w = |v: &[&str]| bs_array("BSCenteredWidthsArray", v);
for (arrays, points) in [
(t(&["0", "0.5", "1"]) + &w(&["0", "1", "0"]), vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]),
(t(&["0", "1"]) + &w(&["1", "0.1"]), vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)]),
] {
let style = width_style(&arrays);
assert!(style.plain && !style.simple && style.uneven, "{arrays:?}: {style:?}");
let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{:?}", style.items) };
assert_eq!((*width, profile.as_ref().map(|p| p.points.clone())), (30.0, Some(points)), "{arrays:?}");
}
}
#[test]
fn a_uniform_or_unreadable_width_profile_stays_a_live_stroke() {
let t = |v: &[&str]| bs_array("BSVariableWidthTValuesArray", v);
let w = |v: &[&str]| bs_array("BSCenteredWidthsArray", v);
for arrays in [
String::new(),
t(&["0", "1"]) + &w(&["1", "1"]),
// No widths, widths of another length, widths that aren't numbers.
t(&["0", "0.5", "1"]),
t(&["0", "0.5", "1"]) + &w(&["0", "1"]),
t(&["0", "1"]) + "/Array :\n(x) /String ,\n(y) /String ,\n; (BSCenteredWidthsArray) ,\n",
// Positions that aren't numbers.
t(&["nan", "nan"]) + &w(&["0", "1"]),
] {
let style = width_style(&arrays);
assert!(style.plain && style.simple, "{arrays:?}: {style:?}");
let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{arrays:?}: {:?}", style.items) };
assert_eq!((*width, profile), (30.0, &None), "{arrays:?}");
}
}
#[test]
+113
View File
@@ -6452,6 +6452,31 @@ fn an_object_with_a_feather_style_comes_in_with_a_live_effect() {
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
/// The art style `Tapered`: a 4 pt stroke with width points `widths` (position, width) along it,
/// their widths written as one centered array.
fn width_profile_style(widths: &[(f64, f64)]) -> String {
width_arrays_style(widths, &["BSCenteredWidthsArray"])
}
/// The art style `Tapered`: a 4 pt stroke with width points `widths` (position, width) along it,
/// their widths written under each of `keys`.
fn width_arrays_style(widths: &[(f64, f64)], keys: &[&str]) -> String {
let array = |values: Vec<f64>| values.iter().map(|v| format!("{v} /Real ,\n")).collect::<String>();
let width_arrays: String = keys.iter().map(|key| format!("/Array :\n{}; ({key}) ,\n", array(widths.iter().map(|w| w.1).collect()))).collect();
format!(
"%AI9_BeginArtStyles\n\
/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\
/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 4 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\
/Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n/Array :\n{}; (BSVariableWidthTValuesArray) ,\n{width_arrays}\
; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n\
%AI9_EndArtStyles\n",
array(widths.iter().map(|w| w.0).collect()),
)
}
/// An art style section with style `PF`: an Effect › Pathfinder filter with `Command` `command`
/// and `Mix` `mix` in the object's chain, over a fill and stroke that paint nothing.
fn pathfinder_styles(command: u32, mix: f64) -> String {
@@ -6482,6 +6507,73 @@ fn pathfinder_group() -> String {
)
}
#[test]
fn a_variable_width_stroke_comes_in_as_its_drawn_outline() {
// GUARDS: the object came in as a live stroke drawn from its profile, and the outline in the file was dropped.
let hidden_line = "0 5 m\n10 5 L\n".replace('\n', "\n%_");
let fills = format!("{RED}{}f\n", rect_path(30.0, 0.0, 40.0, 10.0));
let art_ = format!("{fills}0 Ae\nu\n{RED}{}f\nU\n%_{hidden_line}S\n1 (Tapered) XW\n{fills}", rect_path(0.0, 3.0, 10.0, 7.0));
let lens = width_profile_style(&[(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)]);
let r = read_ok(&format!("{lens}{}", layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 3, "{a:#?}");
let look = &a[1];
assert!(matches!(look.kind, NodeKind::Group { .. }), "{look:#?}");
let [outline] = look.children().unwrap().as_slice() else { panic!("{look:#?}") };
assert!(matches!(outline.kind, NodeKind::Path { .. }) && outline.appearance.fill().is_some(), "{outline:#?}");
assert!(outline.appearance.stroke().is_none_or(|s| s.profile.is_none()), "{outline:#?}");
assert!([&a[0], &a[2]].iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the objects beside it stay in place");
assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings);
// A uniform profile is a plain stroke: the object comes in as itself.
let uniform = width_profile_style(&[(0.0, 1.0), (1.0, 1.0)]);
let r = read_ok(&format!("{uniform}{}", layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 3, "{a:#?}");
assert!(matches!(a[1].kind, NodeKind::Path { .. }), "{:#?}", a[1]);
assert_eq!(a[1].appearance.stroke().map(|s| (s.width, s.profile.is_none())), Some((4.0, true)), "{:#?}", a[1]);
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
/// Only a Stroke Style filter's widths written as one centered array come in as the drawn look.
/// A Stroke Offset stroke and widths written for each side, as VectorCraft saves them, stay live.
#[test]
fn only_centered_widths_on_a_plain_stroke_come_in_as_the_drawn_outline() {
// GUARDS: every stroke with a profile went to its drawn look, so a Stroke Offset stroke and the
// app's own files lost their live profile.
let hidden_line = "0 5 m\n10 5 L\n".replace('\n', "\n%_");
let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_{hidden_line}S\n1 (Tapered) XW\n", rect_path(0.0, 3.0, 10.0, 7.0));
let lens = [(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)];
let r = read_ok(&format!("{}{}", width_profile_style(&lens), layer(&art_)));
let a = art(&r.doc);
let [look] = a.as_slice() else { panic!("{a:#?}") };
assert!(matches!(look.kind, NodeKind::Group { .. }), "{look:#?}");
assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings);
// One side's array alone doesn't replace the centered one, so the widths are still centered.
let r = read_ok(&format!("{}{}", width_arrays_style(&lens, &["BSCenteredWidthsArray", "BSLeftWidthsArray"]), layer(&art_)));
let a = art(&r.doc);
let [look] = a.as_slice() else { panic!("{a:#?}") };
assert!(matches!(look.kind, NodeKind::Group { .. }), "{look:#?}");
let sides = width_arrays_style(&lens, &["BSLeftWidthsArray", "BSRightWidthsArray"]);
let profile = "/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n0.5 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
/Array :\n0 /Real ,\n1 /Real ,\n0 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n";
let offset = format!(
"%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n",
offset_stroke_style_with("Tapered", &format!("0 /Bool (StrokeOffsetInside) ,\n{profile}"), OFFSET_STROKE)
);
for (why, style) in [("each side's widths", sides), ("a Stroke Offset stroke", offset)] {
let r = read_ok(&format!("{style}{}", layer(&art_)));
let a = art(&r.doc);
let [object] = a.as_slice() else { panic!("{why}: {a:#?}") };
assert!(matches!(object.kind, NodeKind::Path { .. }), "{why}: {object:#?}");
assert!(object.appearance.stroke().is_some_and(|s| s.profile.is_some()), "{why}: {object:#?}");
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings);
}
}
#[test]
fn a_group_with_a_pathfinder_effect_keeps_its_members_and_the_effect() {
// GUARDS: the style was not read, and the group came in as its drawn look: one path, with no effect.
@@ -6498,6 +6590,27 @@ fn a_group_with_a_pathfinder_effect_keeps_its_members_and_the_effect() {
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
#[test]
fn a_variable_width_stroke_with_an_empty_look_comes_in_as_itself() {
// GUARDS: with no outline in its look to keep, the object was dropped.
let hidden_line = "0 5 m\n10 5 L\n".replace('\n', "\n%_");
let hidden_fill = format!("{}f\n", rect_path(0.0, 3.0, 10.0, 7.0)).replace('\n', "\n%_");
let lens = width_profile_style(&[(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)]);
// An empty look, ones whose group or compound path holds only a copy read from `%_` lines, and
// ones that hold only a path painted with nothing or a hidden painted path.
let unpainted = format!("u\n{}n\nU\n", rect_path(0.0, 3.0, 10.0, 7.0));
let invisible = format!("u\n1 Xw\n{RED}{}f\n0 Xw\nU\n", rect_path(0.0, 3.0, 10.0, 7.0));
for look in ["u\nU\n".to_string(), format!("u\nu\n%_{hidden_fill}\nU\nU\n"), format!("u\n*u\n%_{hidden_fill}\n*U\nU\n"), unpainted, invisible] {
let art_ = format!("{look}%_{hidden_line}S\n1 (Tapered) XW\n");
let r = read_ok(&format!("{lens}{}", layer(&art_)));
let a = art(&r.doc);
let [object] = a.as_slice() else { panic!("{look:?}: {a:#?}") };
assert!(matches!(object.kind, NodeKind::Path { .. }), "{look:?}: {object:#?}");
assert_eq!(object.appearance.stroke().map(|s| (s.width, s.profile.is_some())), Some((4.0, true)), "{look:?}: {object:#?}");
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{look:?}: {:?}", r.warnings);
}
}
#[test]
fn each_pathfinder_operation_on_a_group_draws_as_vectorcraft_draws_it() {
// GUARDS: only Add (`Command` 0) was read; the other operations came in as the drawn look's one path.
+322 -61
View File
@@ -25,7 +25,10 @@ use std::collections::BTreeMap;
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject, TextRun};
use vectorcraft_doc::{
CharStyle, HyphenationOptions, JustificationOptions, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TabAlign,
TabStop, TextKind, TextObject, TextRun,
};
use vectorcraft_geom::{Affine, PathData, Point, Rect};
/// Longest story read (bytes).
@@ -383,8 +386,33 @@ struct Para {
space_before: f64,
space_after: f64,
hyphenate: bool,
/// Key 7: the share of its type size a line takes as its auto leading (1.2: 120%, the default;
/// Justification… sets 0 to 500%:
/// https://helpx.adobe.com/illustrator/using/line-character-spacing.html). `None`: the default.
auto_leading: Option<f64>,
/// Keys 10 to 15, in the order of Paragraph panel menu › Hyphenation…: Words Longer Than,
/// After First, Before Last, Hyphen Limit, Hyphenation Zone (pt) and Hyphenate Capitalized
/// Words. A value that can't be read, or is out of its range, keeps the default.
hyphenation: HyphenationOptions,
/// Key 30, its list (key 0): the tab stops, each its position from the frame's left edge (key
/// 0) and alignment (key 1: 0 left, the default, 1 center, 2 right, 3 decimal, in the order of
/// the Tabs panel's buttons), sorted. A stop that can't be read is left out, and so are those
/// past the first [`MAX_TABS`]. Leaders aren't read.
tabs: Vec<TabStop>,
/// The paragraph has more than [`MAX_TABS`] stops: those past them were left out.
tabs_truncated: bool,
}
/// Most tab stops read for one paragraph.
const MAX_TABS: usize = 1024;
/// Largest tab stop position read (in the text document's units), as `text.tabs.set` takes it.
const MAX_TAB_POSITION: f64 = 100_000.0;
/// Most tab stops a story's paragraphs take in all: a paragraph style's stops are copied into
/// every paragraph that has them. The paragraphs past it take the default stops, with a warning.
const MAX_STORY_TABS: usize = 1 << 20;
/// The warning when [`MAX_STORY_TABS`] leaves tab stops out.
pub(crate) const TABS_LEFT_OUT: &str = "tab stops past VectorCraft's limits were left out";
/// The text of one story, in the units of the text document.
#[derive(Debug)]
pub(super) struct Story {
@@ -405,6 +433,20 @@ pub(super) struct Story {
/// Vertical type (Type › Type Orientation › Vertical): its lines are columns, from right to
/// left. Its layout's lines run down from the anchor, each column further left.
vertical: bool,
/// Tab stops past [`MAX_STORY_TABS`] were left out of its paragraphs.
tabs_left_out: std::cell::Cell<bool>,
}
/// Spend `budget` on the tab stops `tabs`: when there aren't enough left, they're left out (the
/// default stops apply). Whether they were.
fn spend_tabs(tabs: &mut Vec<TabStop>, budget: &mut usize) -> bool {
if tabs.len() <= *budget {
*budget -= tabs.len();
false
} else {
tabs.clear();
true
}
}
#[derive(Clone, Debug)]
@@ -607,7 +649,12 @@ impl Texts {
if runs.is_empty() {
runs.push((text.chars().count(), self.style(None)));
}
Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical })
let mut budget = MAX_STORY_TABS;
let mut tabs_left_out = false;
for (_, p) in &mut paras {
tabs_left_out |= p.tabs_truncated | spend_tabs(&mut p.tabs, &mut budget);
}
Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical, tabs_left_out: tabs_left_out.into() })
}
/// A paragraph's attributes (`run`: its attributes, key 5, and the paragraph style it is based
@@ -633,6 +680,46 @@ impl Texts {
space_before: num("4"),
space_after: num("5"),
hyphenate: !matches!(pick("9"), Some(Val::Bool(false))),
auto_leading: pick("7").and_then(Val::num).filter(|v| v.is_finite() && *v > 0.0 && *v <= 5.0),
hyphenation: {
let d = HyphenationOptions::default();
let count = |k: &str, lo: f64, hi: f64, or: u32| {
pick(k).and_then(Val::num).filter(|v| v.is_finite() && v.fract() == 0.0 && (lo..=hi).contains(v)).map_or(or, |v| v as u32)
};
HyphenationOptions {
words_longer_than: count("10", 1.0, 25.0, d.words_longer_than),
after_first: count("11", 1.0, 15.0, d.after_first),
before_last: count("12", 1.0, 15.0, d.before_last),
hyphen_limit: count("13", 0.0, 25.0, d.hyphen_limit),
zone: pick("14").and_then(Val::num).filter(|v| v.is_finite() && (0.0..=1296.0).contains(v)).unwrap_or(d.zone),
capitalized: match pick("15") {
Some(Val::Bool(b)) => *b,
_ => d.capitalized,
},
}
},
tabs_truncated: pick("30").and_then(|t| t.get("0")).map(Val::list).is_some_and(|list| list.len() > MAX_TABS),
tabs: {
let list = pick("30").and_then(|t| t.get("0")).map(Val::list).unwrap_or_default();
let mut tabs: Vec<TabStop> = list
.iter()
.take(MAX_TABS)
.filter_map(|t| {
let position = t.get("0").and_then(Val::num).filter(|p| (0.0..=MAX_TAB_POSITION).contains(p))?;
let align = match t.get("1").map(Val::num) {
None | Some(Some(0.0)) => TabAlign::Left,
Some(Some(1.0)) => TabAlign::Center,
Some(Some(2.0)) => TabAlign::Right,
Some(Some(3.0)) => TabAlign::Decimal,
_ => return None,
};
Some(TabStop { position, align, leader: String::new(), align_on: '.' })
})
.collect();
tabs.sort_by(|a, b| a.position.total_cmp(&b.position));
tabs.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6);
tabs
},
}
}
@@ -1001,7 +1088,6 @@ impl Story {
// The text document measures in the art's units too: the document's scale carries its
// sizes and spacings onto the page (a Large Canvas copy is written at 1:10; #2031).
let scale = scale * art_scale(to_doc);
let leading = self.line_leading(scale);
let chars: Vec<char> = text.chars().collect();
let mut runs = vec![];
let mut from = 0usize;
@@ -1012,13 +1098,13 @@ impl Story {
if piece.is_empty() {
continue;
}
runs.push(TextRun::new(piece, char_style(st, scale, leading)));
runs.push(TextRun::new(piece, char_style(st, scale)));
}
if from < chars.len() {
let last = self.runs.last().map(|r| &r.1)?;
runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::<String>(), char_style(last, scale, leading)));
runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::<String>(), char_style(last, scale)));
}
let first = self.runs.first().map(|r| char_style(&r.1, scale, leading))?;
let first = self.runs.first().map(|r| char_style(&r.1, scale))?;
let mut t = TextObject::point(Point::ZERO, "", first);
if !runs.is_empty() {
t.runs = runs;
@@ -1065,6 +1151,21 @@ impl Story {
!self.lines.is_empty() && vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), t)
}
/// Area type of one frame that the file lays more lines out in than fit here: in the fallback
/// font, or a last line whose descent the file lets pass the frame's bottom. The frame is made
/// taller from its bottom (`vectorcraft_text::fit_source_lines`) so the file's lines show.
/// Whether it was.
pub(super) fn fit_last_lines(&self, t: &mut TextObject) -> bool {
matches!(self.frames.as_slice(), [FrameKind::Area(..)])
&& vectorcraft_text::fit_source_lines(vectorcraft_text::FontDb::global(), t, self.lines.len())
}
/// Were tab stops past [`MAX_STORY_TABS`] left out of its paragraphs (so far: making its text
/// object counts them again)?
pub(super) fn tabs_left_out(&self) -> bool {
self.tabs_left_out.get()
}
/// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and
/// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its
/// page draws it: its first baseline is fixed where the file has it. The font's ascent below the
@@ -1103,38 +1204,6 @@ impl Story {
true
}
/// The leading the file's own line positions imply: the distance from one line to the next, less
/// the space between paragraphs that is part of it (a paragraph's space after and the next one's
/// space before; a layout's lines are where that space has put them, and the text object adds it
/// again).
fn line_leading(&self, scale: f64) -> Option<f64> {
let para_at = |c: usize| {
let mut end = 0usize;
for (n, p) in &self.paras {
end = end.saturating_add(*n);
if c < end {
return Some(p);
}
}
self.paras.last().map(|(_, p)| p)
};
let chars: Vec<char> = self.text.chars().collect();
let breaks: Vec<usize> = chars.iter().enumerate().filter(|(_, c)| matches!(c, '\r' | '\x03')).map(|(i, _)| i).collect();
// More lines than breaks: area type wraps, and which break ends which line is unknown.
let wraps = self.lines.len() > breaks.len() + 1;
self.lines.windows(2).enumerate().find_map(|(i, w)| {
let pitch = w[1].1 - w[0].1;
let between = match breaks.get(i) {
Some(&at) if !wraps && chars.get(at) == Some(&'\r') => {
para_at(at).map_or(0.0, |p| p.space_after) + para_at(at + 1).map_or(0.0, |p| p.space_before)
}
_ => 0.0,
};
let l = (pitch - between) * scale;
(l.is_finite() && l > 0.0).then_some(l)
})
}
/// The style of each paragraph of `text` (paragraphs split at `\n`).
fn paragraph_styles(&self, text: &str, scale: f64) -> Vec<ParaStyle> {
let count = text.split('\n').count();
@@ -1142,9 +1211,13 @@ impl Story {
let mut runs = self.paras.iter();
let mut current = runs.next();
let mut left = current.map_or(usize::MAX, |r| r.0);
let mut budget = MAX_STORY_TABS;
for p in text.split('\n') {
let para = current.map(|r| r.1.clone()).unwrap_or_default();
out.push(para_style(&para, scale));
let mut style = current.map_or_else(|| para_style(&Para::default(), scale), |r| para_style(&r.1, scale));
if spend_tabs(&mut style.tabs, &mut budget) {
self.tabs_left_out.set(true);
}
out.push(style);
// Each paragraph and its ending `\r` count.
let mut used = p.chars().count() + 1;
while used > 0 {
@@ -1165,6 +1238,8 @@ impl Story {
}
}
/// The paragraph style of paragraph attributes `p`, its lengths (indents, spacing, the
/// hyphenation zone) scaled by `scale`, as sizes are.
fn para_style(p: &Para, scale: f64) -> ParaStyle {
ParaStyle {
justify: match p.justify {
@@ -1184,21 +1259,33 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle {
space_before: p.space_before * scale,
space_after: p.space_after * scale,
hyphenate: p.hyphenate,
hyphenation: HyphenationOptions { zone: p.hyphenation.zone * scale, ..p.hyphenation.clone() },
tabs: p.tabs.iter().map(|t| TabStop { position: t.position * scale, ..t.clone() }).collect(),
// A right-to-left paragraph lays its text out from the right whatever letters it has
// (" 120.5 mm " reads "mm 120.5"); without the key, from its first strong letter.
direction: p.rtl.then_some(ParaDirection::RightToLeft),
justification: JustificationOptions {
// Rounded, so the file's 1.2 is the default 120% exactly.
auto_leading: p.auto_leading.map_or(JustificationOptions::default().auto_leading, |a| (a * 1e6).round() / 1e4),
..JustificationOptions::default()
},
..ParaStyle::default()
}
}
fn char_style(s: &Style, scale: f64, leading: Option<f64>) -> CharStyle {
/// The character style of character attributes `s`: its font from the fonts the app has, its
/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`.
fn char_style(s: &Style, scale: f64) -> CharStyle {
let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style);
let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
CharStyle {
font_family: family,
font_style: style,
size: s.size * scale,
leading: s.leading.map(|l| l * scale).or(leading),
// Auto leading stays auto: the layout gives each line the paragraph's share of the largest
// type on it, as the file's own layout does ("The largest leading value in a line of type
// determines the leading for that line": https://helpx.adobe.com/illustrator/using/line-character-spacing.html).
leading: s.leading.map(|l| l * scale),
tracking: s.tracking,
baseline_shift: s.baseline_shift * scale,
h_scale: s.h_scale * 100.0,
@@ -1247,6 +1334,130 @@ mod tests {
assert_eq!(justify(b"/0 1 /33 0"), Justify::Right);
}
/// Key 7 is the paragraph's auto leading, a share of the type size; one that can't be read
/// leaves the default, 120%.
#[test]
fn a_paragraph_keeps_its_auto_leading() {
let texts = Texts {
stories: vec![],
fonts: vec![],
defaults: Val::Dict(BTreeMap::new()),
para_defaults: read(b"/7 1.2").unwrap(),
char_sheets: vec![],
para_sheets: vec![],
frames: vec![],
};
let auto = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justification.auto_leading;
assert_eq!(auto(b""), JustificationOptions::default().auto_leading);
assert_eq!(auto(b"/7 1.5"), 150.0);
assert_eq!(auto(b"/7 1.15"), 115.0);
for unreadable in [b"/7 0".as_slice(), b"/7 -1", b"/7 1e9", b"/7 /nil"] {
assert!((auto(unreadable) - 120.0).abs() < 1e-9, "{}", String::from_utf8_lossy(unreadable));
}
}
/// Keys 10 to 15 are the paragraph's hyphenation options; one that can't be read, or is out of
/// its range, keeps the default. The zone is in points of the text document, scaled as sizes are.
#[test]
fn a_paragraph_keeps_its_hyphenation_options() {
let texts = Texts {
stories: vec![],
fonts: vec![],
defaults: Val::Dict(BTreeMap::new()),
para_defaults: read(b"/10 6 /11 2 /12 2 /13 0 /14 36.0 /15 true").unwrap(),
char_sheets: vec![],
para_sheets: vec![],
frames: vec![],
};
let options = |own: &[u8], scale: f64| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale).hyphenation;
let want = HyphenationOptions { words_longer_than: 6, after_first: 2, before_last: 2, hyphen_limit: 0, zone: 36.0, capitalized: true };
assert_eq!(options(b"", 1.0), want);
assert_eq!(
options(b"/10 8 /13 3 /15 false", 0.5),
HyphenationOptions { words_longer_than: 8, hyphen_limit: 3, zone: 18.0, capitalized: false, ..want.clone() }
);
let d = HyphenationOptions::default();
let bare = Texts {
stories: vec![],
fonts: vec![],
defaults: Val::Dict(BTreeMap::new()),
para_defaults: Val::Dict(BTreeMap::new()),
char_sheets: vec![],
para_sheets: vec![],
frames: vec![],
};
let own = |own: &[u8]| para_style(&bare.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).hyphenation;
assert_eq!(own(b""), d);
assert_eq!(own(b"/10 0 /11 99 /12 2.5 /13 -1 /14 -3 /15 1"), d);
}
/// Key 30 holds the paragraph's tab stops: their positions (key 0), sorted, and alignments (key
/// 1: 0 left, 1 center, 2 right, 3 decimal), scaled as sizes are. A stop that can't be read is
/// left out. A user's price list keeps a right stop at 269.29 and a left one at 272.13.
#[test]
fn a_paragraph_keeps_its_tab_stops() {
let texts = Texts {
stories: vec![],
fonts: vec![],
defaults: Val::Dict(BTreeMap::new()),
para_defaults: Val::Dict(BTreeMap::new()),
char_sheets: vec![],
para_sheets: vec![],
frames: vec![],
};
let tabs = |own: &[u8], scale: f64| {
para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale)
.tabs
.iter()
.map(|t| (t.position, t.align))
.collect::<Vec<_>>()
};
assert_eq!(tabs(b"", 1.0), []);
assert_eq!(
tabs(b"/30 << /0 [ << /0 272.12598 /1 0 >> << /0 269.29135 /1 2 >> ] >>", 1.0),
[(269.29135, TabAlign::Right), (272.12598, TabAlign::Left)]
);
assert_eq!(
tabs(b"/30 << /0 [ << /0 10 /1 1 >> << /0 20 /1 3 >> << /0 30 >> ] >>", 0.5),
[(5.0, TabAlign::Center), (10.0, TabAlign::Decimal), (15.0, TabAlign::Left)]
);
// Past MAX_TABS stops, the rest are left out, and the paragraph says so (the story's warning).
let many = |n: usize| {
let stops: String = (0..n).map(|i| format!("<< /0 {i} >> ")).collect();
texts.para(Some(&read(format!("/5 << /30 << /0 [ {stops}] >> >>").as_bytes()).unwrap()))
};
let p = many(MAX_TABS + 6);
assert!(p.tabs_truncated && p.tabs.len() == MAX_TABS);
let p = many(MAX_TABS);
assert!(!p.tabs_truncated && p.tabs.len() == MAX_TABS);
// Unreadable: a position out of range or not a number, an unknown alignment.
assert_eq!(
tabs(b"/30 << /0 [ << /0 -1 >> << /0 1e9 >> << /0 /nil >> << /0 5 /1 7 >> << /0 6 /1 /x >> << /0 7 /1 2 >> ] >>", 1.0),
[(7.0, TabAlign::Right)]
);
}
/// A story's paragraphs take at most [`MAX_STORY_TABS`] tab stops in all (a paragraph style's
/// are copied into each paragraph): those past it take the default stops, and the story says so.
#[test]
fn a_story_s_tab_stops_are_bounded() {
let mut budget = 3;
let mut tabs = vec![TabStop { position: 1.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }; 2];
assert!(!spend_tabs(&mut tabs, &mut budget) && tabs.len() == 2 && budget == 1);
assert!(spend_tabs(&mut tabs, &mut budget) && tabs.is_empty() && budget == 1);
// Paragraphs of a style with MAX_TABS stops each, more than the story takes.
let stop = TabStop { position: 1.0, align: TabAlign::Left, leader: String::new(), align_on: '.' };
let para = Para { tabs: vec![stop; MAX_TABS], ..Para::default() };
let count = MAX_STORY_TABS / MAX_TABS + 2;
let s = story(&"a\r".repeat(count), &[0.0], &para);
assert!(!s.tabs_left_out());
let styles = s.paragraph_styles(&format!("{}a", "a\n".repeat(count - 1)), 1.0);
assert_eq!(styles.len(), count);
assert!(s.tabs_left_out());
assert_eq!(styles.iter().map(|p| p.tabs.len()).sum::<usize>(), MAX_STORY_TABS);
assert!(styles.last().is_some_and(|p| p.tabs.is_empty()));
}
/// Where point type lies runs along its baseline: a label turned a quarter (as a vertical
/// dimension's is) lies in a tall box, not a wide one.
#[test]
@@ -1636,6 +1847,7 @@ mod tests {
)],
path_spacing: 0.0,
vertical: false,
tabs_left_out: false.into(),
}
}
@@ -1882,25 +2094,54 @@ mod tests {
}
}
/// The baselines of the text object `story` comes in as, laid out.
fn baselines_of(story: &Story) -> Vec<f64> {
let Some(node) = story.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY) else { return vec![] };
let NodeKind::Text(t) = &node.kind else { return vec![] };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).baselines().iter().map(|b| b.0.y).collect()
}
/// Auto leading stays auto: each line takes the paragraph's share of the largest type on it, as
/// the file's own layout does, not the distance between the first two lines for every line. A
/// 15 pt heading over 10 pt type at 120%: 18 pt between the heading's lines, then 12 pt.
#[test]
fn auto_leading_follows_the_size_of_each_line() {
let mut s = story("Big\x03Bigger\rsmall\r", &[0.0, 18.0, 30.0], &Para::default());
let base = s.runs[0].1.clone();
s.runs = vec![(11, Style { size: 15.0, ..base.clone() }), (6, Style { size: 10.0, ..base })];
assert_eq!(leading_of(&s), None);
let ys = baselines_of(&s);
assert_eq!(ys.len(), 3, "{ys:?}");
assert!(((ys[1] - ys[0]).abs() - 18.0).abs() < 1e-6, "{ys:?}");
assert!(((ys[2] - ys[1]).abs() - 12.0).abs() < 1e-6, "{ys:?}");
// At a share the paragraph sets (key 7) of 150%: 22.5 pt, then 15 pt.
let share = Para { auto_leading: Some(1.5), ..Para::default() };
s.paras = s.paras.iter().map(|(n, _)| (*n, share.clone())).collect();
let ys = baselines_of(&s);
assert!(((ys[1] - ys[0]).abs() - 22.5).abs() < 1e-6, "{ys:?}");
assert!(((ys[2] - ys[1]).abs() - 15.0).abs() < 1e-6, "{ys:?}");
}
#[test]
fn leading_leaves_out_the_space_between_paragraphs_that_the_lines_already_have() {
// Lines 10.6 apart that are three paragraphs with 5 less after each: auto leading (15.6) less 5.
let para = Para { space_after: -5.0, ..Para::default() };
// The text object keeps auto leading and the spacing, so its lines are where the file's are.
let s = story("one\rtwo\rthree\r", &[0.0, 10.6, 21.2], &para);
assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9);
assert!((leading_of(&s).unwrap() - 15.6).abs() < 1e-9);
// Space after and space before both count; the scale of the frame carries onto the leading.
assert_eq!(leading_of(&s), None);
let ys = baselines_of(&s);
assert!(ys.len() == 3 && ys.windows(2).all(|w| ((w[1] - w[0]).abs() - 10.6).abs() < 1e-6), "{ys:?}");
// Space after and space before both count.
let para = Para { space_after: 4.0, space_before: 2.0, ..Para::default() };
let s = story("one\rtwo\r", &[0.0, 20.0], &para);
assert!((s.line_leading(1.0).unwrap() - 14.0).abs() < 1e-9);
assert!((s.line_leading(0.5).unwrap() - 7.0).abs() < 1e-9);
let ys = baselines_of(&story("one\rtwo\r", &[0.0, 21.6], &para));
assert!(((ys[1] - ys[0]).abs() - 21.6).abs() < 1e-6, "{ys:?}");
}
/// #2031: a Large Canvas editing copy measures its text document in its art's units, a tenth
/// of the page's: the story's size and leading reach the page through `to_doc`'s scale.
#[test]
fn a_story_in_a_scaled_art_space_keeps_its_size_and_leading() {
let s = story("one\rtwo\r", &[0.0, 20.0], &Para::default());
let s = story("one\rtwo\r", &[0.0, 15.6], &Para::default());
// A copy written at 1:10 (see `ai::read::ensure_space`): its art's units reach the
// document, 100 points to the page, through `to_doc`'s scale.
let to_doc = Affine::scale(10.0) * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]);
@@ -1909,24 +2150,18 @@ mod tests {
_ => panic!("not text"),
};
assert_eq!(t.runs[0].style.size, 130.0, "13 pt at the page's scale");
assert_eq!(t.runs[0].style.leading, Some(200.0), "a 20-unit line pitch at the page's scale");
assert_eq!(t.runs[0].style.leading, None, "auto leading: 120% of its size at the page's scale");
assert_eq!(t.para.justification.auto_leading, 120.0);
}
#[test]
fn leading_between_lines_of_a_paragraph_is_their_distance() {
// A line break inside a paragraph has no paragraph spacing between its lines.
let para = Para { space_after: -5.0, space_before: 3.0, ..Para::default() };
let s = story("one\x03two\x03three\r", &[0.0, 15.6, 31.2], &para);
assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9);
// Paragraphs without spacing: the distance as it was.
let s = story("one\rtwo\r", &[0.0, 15.6], &Para::default());
assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9);
// Area type whose paragraph wraps: lines and breaks don't pair up, so nothing is taken off.
let para = Para { space_after: -5.0, ..Para::default() };
let s = story("one two three\rfour\r", &[0.0, 15.6, 31.2, 41.8], &para);
assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9);
// One line has no distance to read.
assert_eq!(story("one\r", &[0.0], &Para::default()).line_leading(1.0), None);
let ys = baselines_of(&story("one\x03two\x03three\r", &[0.0, 15.6, 31.2], &para));
assert!(ys.len() == 3 && ys.windows(2).all(|w| ((w[1] - w[0]).abs() - 15.6).abs() < 1e-6), "{ys:?}");
// One line.
assert_eq!(baselines_of(&story("one\r", &[0.0], &Para::default())).len(), 1);
}
/// A `\x03` ends a line inside its paragraph: it comes in as a forced line break, and the
@@ -1970,6 +2205,32 @@ mod tests {
assert!(!story("Heading\r", &[], &Para::default()).fit_first_line(&mut t) && height(&t).unwrap().3 == 13.0);
}
/// The file sets three lines in a frame whose third line's descent passes its bottom: the frame
/// grows from its bottom so the third shows. Only when the story's one frame is area type.
#[test]
fn the_last_lines_the_file_sets_in_its_frame_show() {
let db = vectorcraft_text::FontDb::global();
let area = |height: f64| {
let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle { size: 13.0, ..CharStyle::default() });
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 20.0 + height)) };
t
};
let tall = vectorcraft_text::layout(db, &area(400.0));
let third = &tall.lines[2];
let mut t = area(third.baseline - 20.0 + third.descent / 2.0);
assert_eq!(vectorcraft_text::layout(db, &t).lines.len(), 2);
let mut s = story("one\rtwo\rthree\r", &[0.0, 15.6, 31.2], &Para::default());
// A point-type frame (the helper's) isn't fitted.
assert!(!s.fit_last_lines(&mut t));
s.frames = vec![FrameKind::Area(vec![], AreaFrame::default())];
assert!(s.fit_last_lines(&mut t));
assert_eq!(vectorcraft_text::layout(db, &t).lines.len(), 3);
// A story threaded through two frames: its lines are the first frame's alone.
let mut t = area(third.baseline - 20.0 + third.descent / 2.0);
s.frames.push(FrameKind::Area(vec![], AreaFrame::default()));
assert!(!s.fit_last_lines(&mut t));
}
#[test]
fn area_type_s_first_line_goes_where_the_file_lays_it_out() {
// GUARDS: area type's first baseline was the font's ascent below the frame's top, where the
+15 -1
View File
@@ -59,7 +59,7 @@ pub(super) fn make_slots(
to_doc: Affine,
warn: &mut Vec<String>,
) -> Vec<Option<Made>> {
let mut taller = 0;
let (mut taller, mut deeper, mut tabs_left_out) = (0, 0, false);
let made = infos
.iter()
.enumerate()
@@ -67,6 +67,7 @@ pub(super) fn make_slots(
let t = template?;
let story = stories.get(k)?.as_ref()?;
let mut node = story.node(NodeId(0), info.frame, t, to_doc)?;
tabs_left_out |= story.tabs_left_out();
let mut region = story.region(t, to_doc);
// The first line where the file has it, in the text document's frame (that of a
// copied object is its original's, as its lines are).
@@ -80,6 +81,11 @@ pub(super) fn make_slots(
{
taller += 1;
}
if let NodeKind::Text(text) = &mut node.kind
&& story.fit_last_lines(text)
{
deeper += 1;
}
Some((Box::new(node), region))
})
.collect();
@@ -88,6 +94,14 @@ pub(super) fn make_slots(
"area type in frames too short for a line of the font it shows in got taller frames ({taller}): their type wouldn't show otherwise"
));
}
if deeper > 0 {
warn.push(format!(
"area type whose last lines the file sets in frames too short for them here got taller frames ({deeper}): those lines wouldn't show otherwise"
));
}
if tabs_left_out {
warn.push(crate::import::ate::TABS_LEFT_OUT.into());
}
made
}
+30
View File
@@ -5774,6 +5774,36 @@ fn type_with_convert_to_shape_comes_in_as_type_with_it_live() {
);
}
#[test]
fn type_with_a_variable_width_stroke_keeps_it_live() {
// GUARDS: other objects with a variable-width stroke come in as their drawn look; type keeps the
// stroke, since its drawn look can't be retyped.
use vectorcraft_doc::AppearanceItem;
let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let style = "%AI9_BeginArtStyles\n/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n0 0 0 1 0 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\
/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n0.5 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
/Array :\n0 /Real ,\n1 /Real ,\n0 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n%AI9_EndArtStyles\n";
let drawing = "0 0 0 1 k\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf";
let look = format!("u\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n{drawing}\nU\n9 () XW\n");
let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect();
let art = native_with_text(&(style.to_string() + &layer("Art", true, &format!("{look}{own}1 (Tapered) XW\n"))), &doc);
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
let mut texts = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
texts.push((t.plain_text(), n.appearance.clone()));
}
});
let [(text, a)] = texts.as_slice() else { panic!("{texts:?} {notes:?}") };
assert_eq!(text, "Bang");
let [AppearanceItem::Stroke(stroke)] = a.items.as_slice() else { panic!("{a:?}") };
assert_eq!(stroke.profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]));
assert!(!notes.iter().any(|w| w.contains("drawn look")), "{notes:?}");
}
#[test]
fn point_type_without_a_matrix_is_placed_by_its_layout() {
// GUARDS: Illustrator 24 leaves an unturned, unscaled point type's matrix out of its frame; such
+7 -4
View File
@@ -173,6 +173,8 @@ impl Headless {
UiRequest::Place(p) => {
self.session.execute("file.place", &p).map_err(|e| e.to_string())?;
}
// The host keeps its view's artboard on a press, as for the presses it hands over.
UiRequest::HeldPress { .. } => {}
}
}
Ok(())
@@ -204,10 +206,11 @@ impl Headless {
};
let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut requests)?;
// With no frames to show the highlight in later, a hover waits for the work the tool
// runs on another thread.
if kind == PointerKind::Move && self.session.tool_waiting() {
self.session.tool_poll(self.view, true).map_err(|e| e.to_string())?;
// With no frames to show the highlight or apply an edit in later, each event waits for
// the work the tool runs on another thread (a hover's map, a release's merge).
if self.session.tool_waiting() {
let reqs = self.session.tool_poll(self.view, true).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut requests)?;
}
let hold = PointerEvent::json_hold(e);
if hold > 0.0 {
+9
View File
@@ -498,6 +498,15 @@ fn headless_hover_waits_for_the_shape_builder_map() {
assert!(!r.is_error, "{r:?}");
assert!(!h.session.tool_waiting());
assert!(!h.session.overlays(h.view).is_empty(), "the region under the pointer is highlighted");
// A click's reply comes once the merge the tool computes on another thread is applied, with
// the selection after it.
let click = json!({"events": [{"kind": "down", "x": 115, "y": 110}, {"kind": "up", "x": 115, "y": 110}]});
let r = crate::call_tool(&mut h, "pointer_gesture", &click);
assert!(!r.is_error, "{r:?}");
assert!(!h.session.tool_waiting() && !h.session.tool_editing());
let text: String = r.content.iter().filter_map(|c| c["text"].as_str()).collect();
let reply: Value = serde_json::from_str(&text).unwrap();
assert_eq!(reply["selection"].as_array().map(Vec::len), Some(41), "the overlap and the 40 squares, two of them cut: {reply}");
}
/// inspect_document reads back the paint type's characters show, as add_text and set_paint give it.
+1 -1
View File
@@ -39,7 +39,7 @@ use breaking::tab_advance;
use breaking::{break_line, candidates};
#[cfg(test)]
pub(crate) use driver::layout_faces_per_call;
pub use driver::{decorations, fit_first_line, layout, layout_with, scaled};
pub use driver::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled};
use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths};
#[cfg(test)]
+32
View File
@@ -62,6 +62,38 @@ pub fn fit_first_line(db: &FontDb, t: &mut TextObject) -> bool {
true
}
/// Area type whose source lays out `lines` lines in its frame, more than fit here: a font that isn't
/// available shows in the fallback font, whose lines may reach lower, and the source may set a last
/// line whose descent passes the frame's bottom, which isn't drawn here. For importers: the frame is
/// made taller, from its bottom, by its lines' deepest descent plus 0.05 pt, when that lays out
/// more of the source's lines and no more than it has. Horizontal type only. Whether it was.
pub fn fit_source_lines(db: &FontDb, t: &mut TextObject, lines: usize) -> bool {
let TextKind::Area { frame } = &t.kind else { return false };
if t.vertical {
return false;
}
let Some(b) = frame.bounds().filter(|b| b.height() > 1e-6) else { return false };
let laid = layout(db, t);
let have = laid.lines.len();
if !laid.overflow || have == 0 || have >= lines {
return false;
}
// The deepest descent of its lines (a little more, for the rounding of the layout).
let Some(descent) = laid.lines.iter().map(|l| l.descent).reduce(f64::max).filter(|d| d.is_finite() && *d > 0.0) else { return false };
let by = (b.height() + descent + 0.05) / b.height();
if !(by.is_finite() && by > 1.0) {
return false;
}
let taller = frame.transformed(Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, by) * Affine::translate((0.0, -b.y0)));
let short = std::mem::replace(&mut t.kind, TextKind::Area { frame: taller });
let now = layout(db, t).lines.len();
if now > have && now <= lines {
return true;
}
t.kind = short;
false
}
/// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a
/// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's
/// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift
+58 -7
View File
@@ -410,6 +410,9 @@ struct LineJustification {
start_x: f64,
after: Vec<Option<usize>>,
kashidas: Vec<(usize, f64)>,
/// Right-aligned area type: the line's last tab, which takes the text after it to the right
/// edge (the text before it sits at its stops, from the left edge).
last_tab: Option<usize>,
}
fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool {
@@ -464,6 +467,9 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
true
}
/// The geometry of the line of `paragraph` from glyph `index`, placed at `position`: where it
/// ends with its trailing spaces trimmed, its hyphen, its room and margins, the glyphs that hang or
/// stay out of justification, and whether it is the paragraph's last line.
fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData<'_, '_>, position: LinePosition, index: usize) -> LineGeometry {
let para = paragraph.para;
let sg = &paragraph.sg;
@@ -571,7 +577,11 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData
// Justification adds nothing to the list marker's glyphs.
let mut held = held;
held.extend(index..paragraph.marker.min(body));
let last_of_para = position.end >= n || position.end.checked_sub(1).and_then(|i| sg.get(i)).is_some_and(|g| g.ch == '\u{2028}');
// A line a soft return ends is justified as a wrapped one is: only the paragraph's last line
// keeps the alignment of Justify Left, Center or Right. A paragraph that ends in a soft return
// ends with the empty line after it (`run_paragraph_lines`).
let soft_return = position.end > index && sg.get(position.end - 1).is_some_and(|g| g.ch == '\u{2028}');
let last_of_para = position.end >= n && !soft_return;
LineGeometry {
position,
index,
@@ -631,6 +641,9 @@ fn flow_line_flex(glyph: usize, flex: &[Flex], held: &[usize], hung: &[usize]) -
value
}
/// How `line` of `paragraph` is aligned: where it starts, what justification adds to its spaces,
/// gaps and glyphs, its kashidas, and, in right-aligned area type, its last tab, which takes the
/// text after it to the right edge.
fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry) -> LineJustification {
let para = paragraph.para;
let sg = &paragraph.sg;
@@ -718,7 +731,14 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
}
}
let position = &line.position;
let start_x = if justify || pushed_in {
// Right-aligned area type with a tab: the text before the last tab sits at its stops, from the
// left edge, and the text after it against the right edge, as Illustrator's own layout in a
// user's `.ai` sets a price list. Centred and justified lines are aligned as a whole.
let last_tab = (para.justify == Justify::Right && !justify && !paragraph.rtl && paragraph.regions.is_some())
// A tab ending the line counts: its text sits at the left edge, the tab up to the right one.
.then(|| (line.index..line.position.end).rev().find(|&j| sg.get(j).is_some_and(|g| g.ch == '\t' && g.len > 0)))
.flatten();
let start_x = if justify || pushed_in || last_tab.is_some() {
line.ax0 - line.opt_l
} else if paragraph.regions.is_none() {
match align {
@@ -733,9 +753,43 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
_ => line.ax1 + line.opt_r - width,
}
};
LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas }
LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab }
}
/// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop
/// ([`tab_advance`], measured from `tab_origin` less `tab_shift`), or, for the last tab of
/// right-aligned area type ([`LineJustification::last_tab`]), up to where the rest of the line
/// ends on the right edge. The tab after a list marker keeps the width `prepend_marker` gave it
/// (`advance`).
fn tab_glyph<'p>(
paragraph: &'p ParagraphData<'_, '_>,
line: &LineGeometry,
justification: &LineJustification,
glyph_index: usize,
x: f64,
(tab_origin, tab_shift): (f64, f64),
advance: f64,
) -> (f64, &'p str) {
let sg = &paragraph.sg;
// The tab of a list marker keeps the width `prepend_marker` gave it.
if !sg.get(glyph_index).is_some_and(|g| g.len > 0) {
return (advance, "");
}
let (advance, leader) = tab_advance(&paragraph.para.tabs, tab_origin, x - tab_shift, sg.get(glyph_index + 1..line.trimmed).unwrap_or_default());
if justification.last_tab != Some(glyph_index) {
return (advance, leader);
}
// The width of what follows up to the line's end (nothing, when the tab ends the line), with
// the hyphen a hyphenated line ends with.
let after = sg.get(glyph_index + 1..line.trimmed).unwrap_or_default();
let hyphen = line.hyphen.as_ref().map_or(0.0, |g| g.adv);
let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::<f64>() - line.end_trim + hyphen };
((line.ax1 + line.opt_r - rest - x).max(0.0), leader)
}
/// Lay out `line` of `paragraph` as `justification` aligns it: each glyph placed along the
/// baseline (tabs as [`tab_glyph`] sets them), its hyphen, and the line's record, then the pen
/// moved on past it.
fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) {
let para = paragraph.para;
let sg = &paragraph.sg;
@@ -775,10 +829,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
let mut scale = 1.0;
let mut leader = "";
if glyph.ch == '\t' {
// The tab of a list marker keeps the width `prepend_marker` gave it.
if glyph.len > 0 {
(advance, leader) = tab_advance(&para.tabs, tab_origin, x - tab_shift, &sg[glyph_index + 1..trimmed.max(glyph_index + 1)]);
}
(advance, leader) = tab_glyph(paragraph, line, justification, glyph_index, x, (tab_origin, tab_shift), advance);
} else if glyph_index < body {
let flex = flow_line_flex(glyph_index, &paragraph.flex, &line.held, &line.hung);
if is_word_space(glyph) {
+1 -1
View File
@@ -47,7 +47,7 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout};
use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2};
pub use layout::{decorations, fit_first_line, layout, layout_with, scaled};
pub use layout::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled};
pub use vectorcraft_doc::TextObject;
pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign};
+89 -1
View File
@@ -306,6 +306,39 @@ fn area_alignment_and_justify() {
assert!((l.lines[0].x1 - frame.x1).abs() < 1e-6);
}
/// A source that sets a last line whose descent passes its frame's bottom: the frame grows from its
/// bottom by its lines' deepest descent plus 0.05 pt, so that line shows, and only as many lines as
/// the source has.
#[test]
fn fit_source_lines_keeps_the_last_lines_the_source_sets() {
let text = "one\ntwo\nthree";
let tall = layout(db(), &area(text, style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), Justify::Left));
let third = &tall.lines[2];
// The third baseline is in the frame, its descent isn't.
let frame = Rect::new(0.0, 0.0, 200.0, third.baseline + third.descent / 2.0);
let mut t = area(text, style(12.0), frame, Justify::Left);
let short = layout(db(), &t);
assert!(short.overflow && short.lines.len() == 2);
// A source with no more lines than fit leaves the frame as it is.
assert!(!fit_source_lines(db(), &mut t, 2));
assert_eq!(layout(db(), &t).lines.len(), 2);
assert!(fit_source_lines(db(), &mut t, 3));
let fitted = layout(db(), &t);
assert!(!fitted.overflow && fitted.lines.len() == 3);
let TextKind::Area { frame: grown } = &t.kind else { panic!("area type") };
let grown = grown.bounds().unwrap();
assert!(grown.y0 == frame.y0 && grown.y1 > frame.y1 && grown.y1 <= frame.y1 + third.descent + 0.1, "{grown:?}");
// Fitted, nothing more to do.
assert!(!fit_source_lines(db(), &mut t, 3));
// More than a descent short: a line the source can't have set in its frame stays out.
let frame = Rect::new(0.0, 0.0, 200.0, tall.lines[1].baseline + tall.lines[1].descent + 1.0);
let mut t = area(text, style(12.0), frame, Justify::Left);
assert!(!fit_source_lines(db(), &mut t, 3));
// Point type and vertical type aren't fitted.
let mut p = point(text, style(12.0));
assert!(!fit_source_lines(db(), &mut p, 9));
}
#[test]
fn indents_and_paragraph_spacing() {
let frame = Rect::new(0.0, 0.0, 200.0, 400.0);
@@ -880,12 +913,67 @@ fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() {
}
}
/// Right-aligned area type with tabs: the text before the last tab sits at its stops, measured from
/// the frame's left edge, and the text after the last tab against the right edge, as Illustrator's
/// own layout in a user's `.ai` (used locally, not committed) sets a list of an item and two
/// prices: with a right stop at 269.29 and a left one at 272.13, the item starts at 0, the first
/// price ends at 269.29 and the second ends on the edge.
#[test]
fn right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge() {
use vectorcraft_doc::{TabAlign, TabStop};
let stop = |position: f64, align: TabAlign| TabStop { position, align, leader: String::new(), align_on: '.' };
let frame = Rect::new(0.0, 0.0, 336.0, 100.0);
let mut t = area("Item\t12.50\t24.00", style(13.0), frame, Justify::Right);
t.para.tabs = vec![stop(269.29, TabAlign::Right), stop(272.13, TabAlign::Left)];
let lay = layout(db(), &t);
let glyph = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).unwrap();
let end = |byte: usize| glyph(byte).origin.x + glyph(byte).advance;
assert!(glyph(0).origin.x.abs() < 1e-6, "the item starts at the left edge: {}", glyph(0).origin.x);
let first_price_end = "Item\t12.50".len() - 1;
assert!((end(first_price_end) - 269.29).abs() < 1e-6, "the first price ends on its right stop: {}", end(first_price_end));
let last = t.plain_text().len() - 1;
assert!((end(last) - 336.0).abs() < 1e-6, "the last price ends on the edge: {}", end(last));
// One tab: the text after it is against the edge, whatever its stop.
let mut one = area("Another item\t8.00", style(13.0), frame, Justify::Right);
one.para.tabs = t.para.tabs.clone();
let lay = layout(db(), &one);
let first = lay.glyphs.first().unwrap();
let last = lay.glyphs.last().unwrap();
assert!(
first.origin.x.abs() < 1e-6 && (last.origin.x + last.advance - 336.0).abs() < 1e-6,
"{} {}",
first.origin.x,
last.origin.x + last.advance
);
// A tab ending the line (a heading in the user's file): the text before it at the left edge,
// the tab to the right edge.
let lay = layout(db(), &area("Heading\t\nNext", style(13.0), frame, Justify::Right));
assert!(lay.glyphs.first().unwrap().origin.x.abs() < 1e-6, "{}", lay.glyphs.first().unwrap().origin.x);
let tab = lay.glyphs.iter().find(|g| g.byte == 7).unwrap();
assert!((tab.origin.x + tab.advance - 336.0).abs() < 1e-6, "the tab ends at {}", tab.origin.x + tab.advance);
// Text after the last tab that breaks with a hyphen: the hyphen ends on the edge too.
let text = "A\tinternationalization";
let mut hyphenated = area(text, style(13.0), Rect::new(0.0, 0.0, 100.0, 200.0), Justify::Right);
hyphenated.para.hyphenate = true;
hyphenated.para.composer = vectorcraft_doc::Composer::SingleLine;
let lay = layout(db(), &hyphenated);
let line = &lay.lines[0];
assert!(line.end > 2 && line.end < text.len(), "the first line breaks inside the word: {line:?}");
let last = lay.glyphs.get(line.glyph_end - 1).unwrap();
assert!((last.origin.x + last.advance - 100.0).abs() < 1e-6, "the hyphen ends at {}", last.origin.x + last.advance);
// Without a tab, the line is right aligned as a whole.
let lay = layout(db(), &area("Name", style(13.0), frame, Justify::Right));
assert!(lay.glyphs.first().unwrap().origin.x > 200.0);
}
#[test]
fn a_centred_or_right_aligned_line_sets_its_tabs_as_if_it_started_at_the_left() {
use vectorcraft_doc::{TabAlign, TabStop};
// "A<tab>B" with a stop at 100 pt is 100 pt plus B wide whatever the alignment: the whole line
// moves, the gap between A and B stays 100 pt, and a right aligned line ends on the edge (#2457).
for justify in [Justify::Center, Justify::Right, Justify::JustifyCenter, Justify::JustifyRight] {
// (Right aligned area type sets the text after its last tab against the edge instead: see
// `right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`.)
for justify in [Justify::Center, Justify::JustifyCenter, Justify::JustifyRight] {
let mut t = area("A\tB", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), justify);
t.para.tabs = vec![TabStop { position: 100.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
let lay = layout(db(), &t);
+21 -7
View File
@@ -1970,14 +1970,28 @@ fn soft_return_breaks_line_without_new_paragraph() {
assert_eq!(&t.plain_text()[l1.start..l1.end], "Line 2");
}
/// A line ending in a soft return is justified as a wrapped line is: only the paragraph's last
/// line keeps its alignment. Illustrator's own layout in a user's `.ai` (used locally, not
/// committed) spreads such lines to the frame's edge.
#[test]
fn soft_return_in_justified_text_does_not_spread() {
let text = "Short\u{2028}A much longer second line of text inside the frame.";
let t = area(text, style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::JustifyLeft);
let lay = layout(db(), &t);
assert!(lay.lines.len() >= 2);
let l0 = &lay.lines[0];
assert!(l0.x1 - l0.x0 < 100.0, "short line ending with soft return is not justified: {}", l0.x1 - l0.x0);
fn soft_return_in_justified_text_spreads_like_a_wrapped_line() {
let frame = Rect::new(0.0, 0.0, 300.0, 200.0);
let text = "A line of a few words\u{2028}and the last line.";
for justify in [Justify::JustifyLeft, Justify::JustifyCenter, Justify::JustifyRight] {
let lay = layout(db(), &area(text, style(12.0), frame, justify));
assert_eq!(lay.lines.len(), 2);
let (l0, l1) = (&lay.lines[0], &lay.lines[1]);
assert!((l0.x0 - frame.x0).abs() < 1e-6 && (l0.x1 - frame.x1).abs() < 1e-6, "{justify:?}: {l0:?}");
assert!(l1.x1 - l1.x0 < 200.0, "{justify:?}: the last line isn't justified: {l1:?}");
// A soft return ending the paragraph: the empty line after it is the last one, so the line
// it ends is justified too.
let lay = layout(db(), &area("A line of a few words\u{2028}", style(12.0), frame, justify));
let l0 = lay.lines.first().unwrap();
assert!((l0.x0 - frame.x0).abs() < 1e-6 && (l0.x1 - frame.x1).abs() < 1e-6, "{justify:?}, trailing soft return: {l0:?}");
}
// Aligned type stays aligned.
let lay = layout(db(), &area(text, style(12.0), frame, Justify::Left));
assert!(lay.lines[0].x1 < frame.x1 - 100.0);
}
#[test]
+261 -16
View File
@@ -32,7 +32,7 @@ mod live_paint;
mod lookup;
mod map;
use cache::RegionCache;
use cache::{BACKGROUND_SEGMENTS, CacheKey, RegionCache};
#[cfg(test)]
use live_paint::LpHover;
use live_paint::lp_color;
@@ -45,7 +45,10 @@ pub use lookup::{
is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for,
sources, visible_edge_near, visible_face_at,
};
pub use map::{BuilderMap, HATCH, HIGHLIGHT_RED, Hit, Touched, hatch, maps_built, sample_polyline, sorted_roots};
pub use map::{
BuilderMap, HATCH, HIGHLIGHT_RED, Hit, MergeGeometry, MergeInput, PreparedMerge, Rest, Touched, hatch, maps_built, merge_geometry, merge_inputs,
merges_computed, sample_polyline, sorted_roots,
};
/// Small, Medium and Large gap lengths in points.
pub const GAP_LENGTHS: [f64; 3] = [3.0, 6.0, 12.0];
@@ -169,6 +172,52 @@ pub struct ShapeBuilderTool {
/// The pointer went more than [`CLICK_TOL_PX`] from the press during the drag, so the gesture
/// is not a click.
far: bool,
/// The merge of the last gesture over a large selection, being computed on another thread.
merging: Option<PendingMerge>,
/// That merge once computed, for `shapeBuilder.merge` ([`Tool::shape_builder_merge`]).
prepared: Option<Arc<PreparedMerge>>,
/// A gesture made while the map of a large selection is being built on another thread.
deferred: Option<Deferred>,
}
/// A Shape Builder gesture made while the map of the selection was being built on another
/// thread: the map key and the inputs it was made over, its pointer events, which [`Tool::poll`]
/// replays once the map is there, and whether the release has come.
struct Deferred {
key: CacheKey,
shapes: Vec<po::Shape>,
events: Vec<PointerEvent>,
released: bool,
}
/// A Shape Builder merge being computed on another thread: the map key it is for (the document,
/// its revision and the selection), the `shapeBuilder.merge` params that apply it, what it was
/// computed from, and the channel the result arrives on.
struct PendingMerge {
key: CacheKey,
params: Value,
map: Arc<BuilderMap>,
touched: Touched,
gap: f64,
inputs: Vec<MergeInput>,
done: Receiver<MergeGeometry>,
}
/// Computes [`merge_geometry`] on a new thread; None when no thread starts.
#[cfg(not(target_arch = "wasm32"))]
fn spawn_merge(map: Arc<BuilderMap>, touched: Touched, gap: f64, inputs: Vec<MergeInput>) -> Option<Receiver<MergeGeometry>> {
let (tx, done) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("vectorcraft-shape-builder-merge".into()).spawn(move || {
// The tool may have dropped the receiver meanwhile (another tool, another document).
let _ = tx.send(merge_geometry(&map, &touched, gap, &inputs));
});
spawned.is_ok().then_some(done)
}
/// The web build has no threads: the merge runs in the command.
#[cfg(target_arch = "wasm32")]
fn spawn_merge(_map: Arc<BuilderMap>, _touched: Touched, _gap: f64, _inputs: Vec<MergeInput>) -> Option<Receiver<MergeGeometry>> {
None
}
/// How close (screen px) the pointer must come to a line piece or an edge to delete it.
@@ -213,6 +262,132 @@ impl ShapeBuilderTool {
alt.then(|| cx.tol(LINE_TOL_PX))
}
/// Starts computing the merge `params` describe on another thread when the map of the
/// selection is current and has more than [`BACKGROUND_SEGMENTS`] segments; `false` when it
/// did not (small selections, the web build), and the command computes it.
fn start_merge(&mut self, cx: &ToolContext, params: &Value) -> bool {
let key = RegionCache::key_for(cx, self.opts.open_filled_as_closed);
if cfg!(target_arch = "wasm32") || params.get("splitStroke").is_some() || self.cache.key.as_ref() != Some(&key) {
return false;
}
let map = Arc::clone(&self.cache.map);
let segments: usize = map.shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::<usize>()).sum();
if segments <= BACKGROUND_SEGMENTS {
return false;
}
// What the command reads from its params, read the same way.
let num = |v: &Value| v.as_f64().unwrap_or(f64::NAN);
let pts: Vec<Point> = params["points"].as_array().map(|a| a.iter().map(|q| Point::new(num(&q[0]), num(&q[1]))).collect()).unwrap_or_default();
let marquee = params["rect"].as_array().filter(|r| r.len() == 4).map(|r| {
let (x, y) = (num(&r[0]), num(&r[1]));
(Point::new(x, y), Point::new(x + num(&r[2]), y + num(&r[3])))
});
let piece_tol = (params["erase"] == json!(true)).then(|| params["tolerance"].as_f64().unwrap_or(2.0).clamp(0.0, 1000.0));
let touched = map.merge_touched(&pts, marquee, piece_tol);
if touched.is_empty() {
return false;
}
let roots = sorted_roots(cx.doc, &cx.selection.objects);
let (_, leaves) = shape_builder_shapes(cx.doc, &roots, self.opts.open_filled_as_closed);
let inputs = merge_inputs(&leaves, self.opts.open_filled_as_closed);
let gap = self.gap();
let Some(done) = spawn_merge(Arc::clone(&map), touched.clone(), gap, inputs.clone()) else { return false };
self.merging = Some(PendingMerge { key, params: params.clone(), map, touched, gap, inputs, done });
true
}
/// Takes the merge being computed on another thread once it has arrived, or with `wait` once
/// it arrives: the action that applies it, while the document, the selection and its inputs are
/// those it was computed for. When one of them changed (an edit or a selection made other than
/// by a command, which applies the merge first), the merge is dropped at once, without waiting
/// for the thread. A copy of the document (a save stamping its dates) and a new revision that leaves
/// the inputs as they were keep the merge. When the thread failed, the command computes the
/// merge and reports what went wrong.
fn poll_merge(&mut self, cx: &ToolContext, wait: bool) -> Vec<Action> {
let Some(mut m) = self.merging.take() else { return vec![] };
let now = RegionCache::key_for(cx, m.key.3);
if (now.1.0, &now.2) != (m.key.1.0, &m.key.2) {
return vec![];
}
// A new revision that leaves the selection's inputs as they were (a view setting or a
// preference that redraws the document) keeps the merge.
if now.1 != m.key.1 {
if !self.inputs_are(cx, m.key.3, &m.map.shapes) {
return vec![];
}
m.key = now;
}
let got = if wait { m.done.recv().map_err(|_| TryRecvError::Disconnected) } else { m.done.try_recv() };
let geometry = match got {
Ok(g) => Some(g),
Err(TryRecvError::Empty) => {
self.merging = Some(m);
return vec![];
}
Err(TryRecvError::Disconnected) => None,
};
self.prepared = geometry.map(|geometry| Arc::new(PreparedMerge { map: m.map, touched: m.touched, gap: m.gap, inputs: m.inputs, geometry }));
vec![Action::Exec("shapeBuilder.merge".into(), m.params)]
}
/// Is the map of the selection being built on another thread (it is not current yet)?
fn map_building(&mut self, cx: &ToolContext) -> bool {
self.cache.ready(cx, self.opts.open_filled_as_closed).is_none() && self.cache.pending.is_some()
}
/// Are the inputs of the selection in `cx` `shapes`? The map, or the map being built, is filed
/// under the key of `cx` first when they are its inputs ([`RegionCache::rekey`]).
fn inputs_are(&mut self, cx: &ToolContext, open_filled_as_closed: bool, shapes: &[po::Shape]) -> bool {
let _ = self.cache.rekey(cx, open_filled_as_closed);
let key = RegionCache::key_for(cx, open_filled_as_closed);
if self.cache.key.as_ref() == Some(&key) {
return self.cache.shapes == shapes;
}
if let Some(p) = self.cache.pending.as_ref().filter(|p| p.key == key) {
return p.shapes == shapes;
}
let roots = sorted_roots(cx.doc, &cx.selection.objects);
shape_builder_shapes(cx.doc, &roots, open_filled_as_closed).0 == shapes
}
/// Is a gesture made while the map was being built released and waiting for the map?
fn deferred_released(&self) -> bool {
self.deferred.as_ref().is_some_and(|d| d.released)
}
/// Replays the released gesture made while the map was being built once the map is there, or
/// with `wait` once it is built, and with `wait` also waits for the merge it starts: the
/// actions the gesture gives. When the document, the selection or its inputs changed since the
/// gesture, it is dropped; a new revision that leaves them as they were keeps it.
fn replay_deferred(&mut self, cx: &ToolContext, wait: bool) -> Vec<Action> {
if !self.deferred_released() {
return vec![];
}
let ofac = self.opts.open_filled_as_closed;
let now = RegionCache::key_for(cx, ofac);
if let Some(mut d) = self.deferred.take() {
if (d.key.1.0, &d.key.2) != (now.1.0, &now.2) || (d.key.1 != now.1 && !self.inputs_are(cx, ofac, &d.shapes)) {
return vec![];
}
d.key = now;
self.deferred = Some(d);
}
if wait {
self.cache.get(cx, self.opts.open_filled_as_closed);
} else if self.map_building(cx) {
return vec![];
}
let Some(d) = self.deferred.take() else { return vec![] };
let mut acts = vec![];
for ev in &d.events {
acts.extend(self.pointer(cx, ev));
}
if wait {
acts.extend(self.poll_merge(cx, true));
}
acts
}
/// Adds what the drag from `from` to `to` touches, picked as `shapeBuilder.merge` picks it.
fn touch(&mut self, cx: &ToolContext, from: Option<Point>, to: Point) {
let pts: Vec<Point> = from.into_iter().chain([to]).collect();
@@ -274,6 +449,8 @@ impl Tool for ShapeBuilderTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
// The command has taken the merge computed for the last gesture by now.
self.prepared = None;
match ev.kind {
PointerKind::Move => {
self.alt = ev.mods.alt;
@@ -282,7 +459,8 @@ impl Tool for ShapeBuilderTool {
vec![]
}
PointerKind::Down => {
if cx.selection.is_empty() {
// While a merge is being computed, a press starts no gesture.
if cx.selection.is_empty() || self.editing() {
return vec![];
}
self.dragging = true;
@@ -291,11 +469,35 @@ impl Tool for ShapeBuilderTool {
self.points = vec![p];
self.far = false;
self.touched = Touched::default();
// While the map is being built on another thread, the gesture is recorded and
// replayed once the map is there.
if self.map_building(cx) {
let key = RegionCache::key_for(cx, self.opts.open_filled_as_closed);
let shapes = match self.cache.pending.as_ref().filter(|p| p.key == key) {
Some(p) => p.shapes.clone(),
None => self.cache.inputs(cx, self.opts.open_filled_as_closed).0,
};
self.deferred = Some(Deferred { key, shapes, events: vec![*ev], released: false });
return vec![];
}
self.touch(cx, None, p);
vec![]
}
PointerKind::Drag if self.dragging => {
self.at = Some(p);
if let Some(d) = &mut self.deferred {
// The drag's line or marquee shows meanwhile.
d.events.push(*ev);
if let Some((a, _)) = self.marquee {
self.marquee = Some((a, p));
} else if self.opts.straight_line {
self.points.truncate(1);
self.points.push(p);
} else if self.points.last().is_none_or(|q| q.distance(p) >= cx.tol(1.0)) {
self.points.push(p);
}
return vec![];
}
if self.points.first().is_some_and(|s| s.distance(p) > cx.tol(CLICK_TOL_PX)) {
self.far = true;
}
@@ -316,6 +518,13 @@ impl Tool for ShapeBuilderTool {
}
PointerKind::Up if self.dragging => {
self.dragging = false;
if let Some(d) = &mut self.deferred {
d.events.push(*ev);
d.released = true;
self.points.clear();
self.marquee = None;
return vec![];
}
if self.marquee.is_some() {
self.marquee_to(cx, p);
} else if self.opts.straight_line {
@@ -361,6 +570,10 @@ impl Tool for ShapeBuilderTool {
if self.opts.swatches {
params["pickColorFrom"] = json!("swatches");
}
// A large selection's merge is computed on another thread; `poll` applies it.
if self.start_merge(cx, &params) {
return vec![];
}
vec![Action::Exec("shapeBuilder.merge".into(), params)]
}
_ => vec![],
@@ -369,10 +582,13 @@ impl Tool for ShapeBuilderTool {
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
let map = &self.cache.map;
let color = if self.alt { HIGHLIGHT_RED } else { HATCH };
let show: Vec<Hit> = if self.dragging {
let t = &self.touched;
// While a merge is being computed, what its gesture touched stays highlighted.
let map = self.merging.as_ref().map_or(&self.cache.map, |m| &m.map);
// The mode of the gesture being merged, else the drag's or the hover's.
let alt = self.merging.as_ref().filter(|_| !self.dragging).map_or(self.alt, |m| m.params["erase"] == json!(true));
let color = if alt { HIGHLIGHT_RED } else { HATCH };
let touched = if self.dragging { Some(&self.touched) } else { self.merging.as_ref().map(|m| &m.touched) };
let show: Vec<Hit> = if let Some(t) = touched {
let regions = t.regions.iter().map(|&i| Hit::Region(i));
regions.chain(t.lines.iter().map(|&i| Hit::Line(i))).chain(map.erased_edges(t).into_iter().map(Hit::Edge)).collect()
} else {
@@ -381,7 +597,7 @@ impl Tool for ShapeBuilderTool {
for hit in show {
match hit {
// Highlight Fill off: merge mode leaves the regions as they are.
Hit::Region(_) if !self.alt && !self.opts.highlight_fill => {}
Hit::Region(_) if !alt && !self.opts.highlight_fill => {}
Hit::Region(i) => {
if let Some((_, bp, _)) = map.regions.get(i) {
out.push(Overlay::Path { path: hatch(bp, cx.tol(4.0)), color, width: 1.0, dashed: false });
@@ -424,7 +640,10 @@ impl Tool for ShapeBuilderTool {
fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor {
// A drag keeps the mode it started in; hovering follows the key.
let erase = if self.dragging { self.alt } else { m.alt };
if erase {
if self.editing() {
// The merge of the last gesture, or the map it waits for, is being computed.
Cursor::Busy
} else if erase {
Cursor::ShapeBuilderErase
} else if self.stroke.is_some() && !self.dragging {
// Over a stroke a click splits.
@@ -439,30 +658,53 @@ impl Tool for ShapeBuilderTool {
}
fn waiting(&self) -> bool {
self.cache.waiting()
self.cache.waiting() || self.merging.is_some() || self.deferred.is_some()
}
fn poll(&mut self, cx: &ToolContext, wait: bool) {
if !self.cache.waiting() || self.dragging {
return;
fn editing(&self) -> bool {
self.merging.is_some() || self.deferred_released()
}
fn finish_edit(&mut self, cx: &ToolContext) -> Vec<Action> {
let mut acts = self.replay_deferred(cx, true);
acts.extend(self.poll_merge(cx, true));
acts
}
/// Choosing another tool or another document applies the merge being computed first.
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.finish_edit(cx)
}
fn poll(&mut self, cx: &ToolContext, wait: bool) -> Vec<Action> {
let mut acts = self.poll_merge(cx, wait);
if !acts.is_empty() || self.dragging {
return acts;
}
// The map that arrives may be for an earlier revision; the hover then starts the current
// one's, which `wait` waits for too.
loop {
while self.cache.waiting() {
self.cache.settle(wait);
if let Some(p) = self.at {
self.hover_at(cx, p);
}
if !wait || !self.cache.waiting() {
if !wait {
break;
}
}
// A gesture made while the map was being built goes on once it is there.
acts.extend(self.replay_deferred(cx, wait));
acts
}
fn shape_builder_map(&self) -> Option<(Arc<BuilderMap>, f64)> {
self.cache.key.is_some().then(|| (Arc::clone(&self.cache.map), self.cache.gap))
}
fn shape_builder_merge(&self) -> Option<Arc<PreparedMerge>> {
self.prepared.clone()
}
/// With Pick Color From Color Swatches, the Left and Right arrow keys pick the color swatch
/// before or after the current fill. Escape during a drag cancels it: the release after it
/// merges and deletes nothing.
@@ -471,13 +713,16 @@ impl Tool for ShapeBuilderTool {
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if !self.claims_key(cx, key) {
// While a merge is being computed, the arrows leave the current fill it takes as it was.
if !self.claims_key(cx, key) || self.editing() {
return vec![];
}
if key == ToolKey::Escape {
self.dragging = false;
self.points.clear();
self.touched = Touched::default();
// A gesture recorded while the map is being built is canceled with it.
self.deferred = None;
(self.marquee, self.hover, self.far) = (None, None, false);
return vec![];
}
+67 -21
View File
@@ -8,18 +8,21 @@ pub(super) type CacheKey = (usize, (u64, u64), Vec<NodeId>, bool);
/// Hovering builds the map of inputs with more segments than this on another thread
/// ([`RegionCache::ready`]); smaller maps are built on the calling thread.
const BACKGROUND_SEGMENTS: usize = 128;
pub(super) const BACKGROUND_SEGMENTS: usize = 128;
/// A map being built on another thread: the key it is for, the drawn outlines of its inputs, and
/// the channel it arrives on.
struct Pending {
key: CacheKey,
/// A map being built on another thread: the key it is for, its inputs and their drawn outlines,
/// and the channel it arrives on.
pub(super) struct Pending {
pub(super) key: CacheKey,
pub(super) shapes: Vec<po::Shape>,
outlines: Vec<PathData>,
done: Receiver<BuilderMap>,
}
/// The planar map of the current selection, cached by document identity, revision, selection and
/// whether filled open paths count as closed.
/// whether filled open paths count as closed. When only the document's identity or revision
/// changed and the selection's inputs did not (a view setting, a preference, a copy of the
/// document), the map is kept under the new key ([`RegionCache::rekey`]).
#[derive(Default)]
pub(super) struct RegionCache {
pub(super) key: Option<CacheKey>,
@@ -31,8 +34,12 @@ pub(super) struct RegionCache {
pub(super) gap: f64,
/// The outlines of the Shape Builder's inputs as they are drawn (see [`BuilderMap::stroke_at`]).
pub(super) outlines: Vec<PathData>,
/// The inputs the map was built from.
pub(super) shapes: Vec<po::Shape>,
/// The last key whose inputs [`Self::rekey`] compared with the map's.
compared: Option<CacheKey>,
/// The map being built on another thread, if any.
pending: Option<Pending>,
pub(super) pending: Option<Pending>,
/// The key of the last map whose build on another thread failed.
failed: Option<CacheKey>,
/// How many times the map has been built.
@@ -76,13 +83,13 @@ impl RegionCache {
Self { live: true, ..Default::default() }
}
fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey {
pub(super) fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey {
(cx.doc as *const Document as usize, cx.revision, cx.selection.objects.clone(), open_filled_as_closed)
}
/// The inputs of the selection's map, back → front, and their drawn outlines (Shape Builder
/// only).
fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec<po::Shape>, Vec<PathData>) {
pub(super) fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec<po::Shape>, Vec<PathData>) {
let roots = sorted_roots(cx.doc, &cx.selection.objects);
if self.live {
(shapes_for(cx.doc, &roots).0, vec![])
@@ -93,8 +100,9 @@ impl RegionCache {
}
}
fn install(&mut self, key: CacheKey, map: BuilderMap, outlines: Vec<PathData>) {
fn install(&mut self, key: CacheKey, map: BuilderMap, shapes: Vec<po::Shape>, outlines: Vec<PathData>) {
self.map = Arc::new(map);
self.shapes = shapes;
self.outlines = outlines;
self.key = Some(key);
#[cfg(test)]
@@ -122,8 +130,12 @@ impl RegionCache {
pub(super) fn get(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> &BuilderMap {
let key = Self::key_for(cx, open_filled_as_closed);
if self.key.as_ref() != Some(&key) {
let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.outlines)));
let (map, outlines) = match built {
let inputs = self.rekey(cx, open_filled_as_closed);
if self.key.as_ref() == Some(&key) {
return &self.map;
}
let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.shapes, p.outlines)));
let (map, shapes, outlines) = match built {
Some(b) => {
#[cfg(test)]
{
@@ -132,15 +144,45 @@ impl RegionCache {
b
}
None => {
let (shapes, outlines) = self.inputs(cx, open_filled_as_closed);
(build_map(shapes, self.live, self.gap), outlines)
let (shapes, outlines) = inputs.unwrap_or_else(|| self.inputs(cx, open_filled_as_closed));
(build_map(shapes.clone(), self.live, self.gap), shapes, outlines)
}
};
self.install(key, map, outlines);
self.install(key, map, shapes, outlines);
}
&self.map
}
/// When the map, or the map being built, is for the selection of `cx` but for another revision
/// or address of the document, and the selection's inputs and their outlines are those it is
/// made from, files it under the key of `cx`: nothing it is made from changed. Returns the
/// inputs it computed when that did not file a map (they changed), for the caller to build
/// from.
pub(super) fn rekey(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> Option<(Vec<po::Shape>, Vec<PathData>)> {
let key = Self::key_for(cx, open_filled_as_closed);
let other = |k: &CacheKey| *k != key && (&k.2, k.3) == (&key.2, key.3);
if self.pending.as_ref().is_some_and(|p| p.key == key) {
return None;
}
let installed = self.key.as_ref().is_some_and(other);
let building = self.pending.as_ref().is_some_and(|p| other(&p.key));
// The inputs are compared once for each key.
if (!installed && !building) || self.compared.as_ref() == Some(&key) {
return None;
}
self.compared = Some(key.clone());
let (shapes, outlines) = self.inputs(cx, open_filled_as_closed);
if installed && self.shapes == shapes && self.outlines == outlines {
self.key = Some(key);
return None;
}
if let Some(p) = self.pending.as_mut().filter(|p| other(&p.key) && p.shapes == shapes && p.outlines == outlines) {
p.key = key;
return None;
}
Some((shapes, outlines))
}
/// Takes the map being built on another thread once it has arrived, or with `wait` once it
/// arrives, as the map for the key it was built for.
pub(super) fn settle(&mut self, wait: bool) {
@@ -152,7 +194,7 @@ impl RegionCache {
{
self.background += 1;
}
self.install(p.key, map, p.outlines);
self.install(p.key, map, p.shapes, p.outlines);
}
// The build failed (a bug in the planar map): hovering does not build it again, and a
// gesture builds it on the calling thread, which reports the failure.
@@ -171,21 +213,25 @@ impl RegionCache {
if self.key.as_ref() == Some(&key) {
return Some(&self.map);
}
let inputs = self.rekey(cx, open_filled_as_closed);
if self.key.as_ref() == Some(&key) {
return Some(&self.map);
}
if self.pending.is_some() || self.failed.as_ref() == Some(&key) {
return None;
}
let (shapes, outlines) = self.inputs(cx, open_filled_as_closed);
let (shapes, outlines) = inputs.unwrap_or_else(|| self.inputs(cx, open_filled_as_closed));
let segments: usize = shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::<usize>()).sum();
if segments > BACKGROUND_SEGMENTS && cfg!(not(target_arch = "wasm32")) {
if let Some(done) = spawn_build(shapes, self.live, self.gap) {
self.pending = Some(Pending { key, outlines, done });
if let Some(done) = spawn_build(shapes.clone(), self.live, self.gap) {
self.pending = Some(Pending { key, shapes, outlines, done });
return None;
}
// No thread started: build here.
return Some(self.get(cx, open_filled_as_closed));
}
let map = build_map(shapes, self.live, self.gap);
self.install(key, map, outlines);
let map = build_map(shapes.clone(), self.live, self.gap);
self.install(key, map, shapes, outlines);
Some(&self.map)
}
+3 -2
View File
@@ -282,9 +282,9 @@ impl Tool for LivePaintBucketTool {
self.cache.waiting()
}
fn poll(&mut self, cx: &ToolContext, wait: bool) {
fn poll(&mut self, cx: &ToolContext, wait: bool) -> Vec<Action> {
if !self.cache.waiting() {
return;
return vec![];
}
// As the Shape Builder's: the map that arrives may be for an earlier revision. Over a part
// of a Live Paint group the hover takes no map.
@@ -297,6 +297,7 @@ impl Tool for LivePaintBucketTool {
break;
}
}
vec![]
}
}
+2
View File
@@ -284,4 +284,6 @@ pub(super) fn faces_of(regions: Vec<po::Region>) -> Vec<Face> {
thread_local! {
/// The Shape Builder maps built on this thread ([`maps_built`]).
pub(super) static MAPS_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// The Shape Builder merges computed on this thread ([`merges_computed`]).
pub(super) static MERGES_COMPUTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
+113 -1
View File
@@ -1,4 +1,4 @@
use super::lookup::{Face, MAPS_BUILT, faces_of, outline_snap, shape_builder_shapes};
use super::lookup::{Face, MAPS_BUILT, MERGES_COMPUTED, faces_of, node_outline, outline_snap, shape_builder_shapes};
use super::*;
/// Shape Builder hover hatch colour (neutral grey) and Live Paint highlight red.
@@ -12,6 +12,12 @@ pub fn maps_built() -> usize {
MAPS_BUILT.with(std::cell::Cell::get)
}
/// How many Shape Builder merges ([`merge_geometry`]) this thread has computed, for tests that
/// count them.
pub fn merges_computed() -> usize {
MERGES_COMPUTED.with(std::cell::Cell::get)
}
/// The Shape Builder's planar map of a selection.
#[derive(Default)]
pub struct BuilderMap {
@@ -345,6 +351,112 @@ impl BuilderMap {
let bounds_one = |(e, _): &(po::Shape, Rect)| self.sides(e).iter().any(|r| r.is_some_and(|r| touched.regions.contains(&r)));
touched.edges.iter().copied().filter(|&k| self.edges.get(k).is_some_and(|e| !bounds_one(e))).collect()
}
/// What `shapeBuilder.merge` touches: the regions the marquee from `marquee.0` to `marquee.1`
/// reaches, then what the drag along `pts` touches ([`Self::builder_hits_along`]), and the
/// edges that deletes ([`Self::erased_edges`]).
pub fn merge_touched(&self, pts: &[Point], marquee: Option<(Point, Point)>, piece_tol: Option<f64>) -> Touched {
let mut touched = Touched { regions: marquee.map(|(a, b)| self.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() };
touched.extend(&self.builder_hits_along(pts, piece_tol));
touched.edges = self.erased_edges(&touched);
touched
}
}
/// What a merge needs of each input besides the map: its outline as drawn ([`node_outline`]) and
/// whether its fill closes its open subpaths (Consider Open Filled Paths as Closed, and a fill).
#[derive(Clone, Debug, PartialEq)]
pub struct MergeInput {
pub outline: Option<PathData>,
pub fill_closes: bool,
}
/// The [`MergeInput`] of each of `leaves`.
pub fn merge_inputs(leaves: &[&Node], open_filled_as_closed: bool) -> Vec<MergeInput> {
leaves
.iter()
.map(|l| MergeInput { outline: node_outline(l).map(|o| o.0), fill_closes: open_filled_as_closed && !l.appearance.fill_paint().is_none() })
.collect()
}
/// What is left of an input a merge or a delete changes: its faces not touched, united
/// (`united`), or its own outline, with the deleted edges cut out of it.
#[derive(Clone, Debug, PartialEq)]
pub struct Rest {
pub path: PathData,
pub united: bool,
}
/// The new shapes of a Shape Builder merge or delete ([`merge_geometry`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MergeGeometry {
/// The union of the touched regions (empty when none is touched).
pub union: PathData,
/// For each closed input, what goes in its place when the gesture changes it.
pub rests: Vec<Option<Rest>>,
}
/// The new shapes of a gesture over `map` that touched `touched`, closing gaps up to `gap`: the
/// union of the touched regions, and for each closed input that has a touched region or a deleted
/// edge, what is left of it. An input a touched region covers keeps its other faces, united
/// (they share their edges with the merged faces exactly, where subtracting the merged shape left
/// slivers of the outline along them, #569; a sliver goes in only where the input covers it and
/// the merged shape does not take it in); then each deleted edge is cut out of it. Inputs with
/// pieces of open paths (`map.lines`) are left to the caller.
pub fn merge_geometry(map: &BuilderMap, touched: &Touched, gap: f64, inputs: &[MergeInput]) -> MergeGeometry {
MERGES_COMPUTED.with(|n| n.set(n.get() + 1));
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
let faces = po::FaceMerger::with_gap(&map.shapes, gap);
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
let rests = map
.shapes
.iter()
.enumerate()
.map(|(i, sh)| {
let input = inputs.get(i)?;
let in_union = picked.iter().any(|r| r.sources.contains(&i));
if map.lines.iter().any(|(l, _)| l.key == i as u64) || (!in_union && cuts.is_empty()) {
return None;
}
let mut rest = if in_union {
let kept: Vec<&po::Region> = map
.regions
.iter()
.enumerate()
.filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k))
.map(|(_, (r, ..))| r)
.collect();
if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) }
} else {
input.outline.clone().unwrap_or_else(|| sh.path.clone())
};
// Edges come back from the planar map within a few hundredths of the outlines they
// follow, whatever else is selected.
let cut_tol = outline_snap(&sh.path);
let mut cut = false;
for piece in &cuts {
if let Some(r) = po::cut_out(&rest, piece, input.fill_closes, cut_tol) {
rest = r;
cut = true;
}
}
(in_union || cut).then_some(Rest { path: rest, united: in_union })
})
.collect();
MergeGeometry { union, rests }
}
/// A merge computed on another thread for a Shape Builder click or drag
/// ([`Tool::shape_builder_merge`]): the map, what the gesture touched and the inputs it was
/// computed from, and the result. `shapeBuilder.merge` takes the result when it would compute the
/// same.
pub struct PreparedMerge {
pub map: Arc<BuilderMap>,
pub touched: Touched,
pub gap: f64,
pub inputs: Vec<MergeInput>,
pub geometry: MergeGeometry,
}
/// The regions, line pieces and edges a Shape Builder gesture has touched, in the order touched.
+269 -12
View File
@@ -132,20 +132,34 @@ fn shape_builder_builds_its_map_once_for_hover_alt_hover_and_a_merge() {
// The merge left the document as it was (it failed, say): the map still fits it.
t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 75.0));
assert_eq!(t.cache.builds, 1, "a hover after a merge that changed nothing");
// A new revision of the same art (a view setting, a preference) keeps the map.
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
t.pointer(&cx2, &ev(PointerKind::Move, 75.0, 75.0));
assert_eq!(t.cache.builds, 2, "a hover on a new revision");
assert_eq!(t.cache.builds, 1, "a hover on a new revision of the same art");
assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
// The same objects, one of them moved.
let mut moved = d.clone();
let second = s.objects[1];
moved.node_mut(second).unwrap().transform(vectorcraft_geom::Affine::translate((10.0, 0.0)), false);
let cx3 = ToolContext { revision: (1, 3), ..cx(&moved, &s, &p) };
t.pointer(&cx3, &ev(PointerKind::Move, 75.0, 75.0));
assert_eq!(t.cache.builds, 2, "a hover on a new revision of other art");
}
/// A row of `n` selected squares 20 pt wide, each overlapping the next by half: 4 segments each.
fn square_row(n: usize) -> (Document, Selection) {
square_row_at(n, 0.0)
}
/// [`square_row`] with the squares' tops at `y`: the same objects, moved.
fn square_row_at(n: usize, y: f64) -> (Document, Selection) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let mut s = Selection::default();
for k in 0..n {
let x = k as f64 * 10.0;
let id = d.alloc_id();
d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 20.0)), Appearance::default_art())).unwrap();
d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art())).unwrap();
s.add(id);
}
(d, s)
@@ -176,9 +190,10 @@ fn shape_builder_hover_builds_a_large_map_on_another_thread() {
assert_eq!((t.cache.builds, t.cache.background), (1, 1));
assert_eq!(t.overlays(&cx1).len(), 2, "the region's hatch and outline");
// A new revision while the map of the one before it is being built: only the newer one's
// map is used.
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
let cx3 = ToolContext { revision: (1, 3), ..cx(&d, &s, &p) };
// map is used. Each revision moves the squares down 1 pt.
let rows: Vec<Document> = (0..7).map(|k| square_row_at(40, f64::from(k)).0).collect();
let cx2 = ToolContext { revision: (1, 2), ..cx(&rows[2], &s, &p) };
let cx3 = ToolContext { revision: (1, 3), ..cx(&rows[3], &s, &p) };
t.pointer(&cx2, &hover);
t.pointer(&cx3, &hover);
poll_until_ready(&mut t, &cx3);
@@ -186,29 +201,265 @@ fn shape_builder_hover_builds_a_large_map_on_another_thread() {
assert_eq!((t.cache.builds, t.cache.background), (3, 3), "the earlier revision's map, then the newer one's");
assert_eq!(t.cache.key.as_ref().map(|k| k.1), Some((1, 3)));
// A host without frames waits for the map.
let cx4 = ToolContext { revision: (1, 4), ..cx(&d, &s, &p) };
let cx4 = ToolContext { revision: (1, 4), ..cx(&rows[4], &s, &p) };
t.pointer(&cx4, &hover);
assert!(t.waiting() && t.hover.is_none());
t.poll(&cx4, true);
assert!(!t.waiting());
assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
assert_eq!((t.cache.builds, t.cache.background), (4, 4));
// A press while the map is being built waits for it, and the drag merges.
let cx5 = ToolContext { revision: (1, 5), ..cx(&d, &s, &p) };
// A drag while the map is being built is replayed once it is there, and merges (on
// another thread, see `shape_builder_merges_a_large_selection_on_another_thread` and
// `shape_builder_gesture_waits_for_the_map_without_holding_the_caller`).
let cx5 = ToolContext { revision: (1, 5), ..cx(&rows[5], &s, &p) };
t.pointer(&cx5, &hover);
t.pointer(&cx5, &ev(PointerKind::Down, 15.0, 10.0));
t.pointer(&cx5, &ev(PointerKind::Drag, 35.0, 10.0));
let acts = t.pointer(&cx5, &ev(PointerKind::Up, 35.0, 10.0));
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}");
assert!(t.pointer(&cx5, &ev(PointerKind::Up, 35.0, 10.0)).is_empty());
let acts = t.poll(&cx5, true);
assert_eq!((t.cache.builds, t.cache.background), (5, 5));
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}");
assert!(!t.waiting());
// Changing an option forgets a map being built with the old one.
let cx6 = ToolContext { revision: (1, 6), ..cx(&d, &s, &p) };
let cx6 = ToolContext { revision: (1, 6), ..cx(&rows[6], &s, &p) };
t.pointer(&cx6, &hover);
t.set_option("gapDetection", &json!(true));
assert!(!t.waiting());
}
/// Polls `t` until it stops waiting, as the desktop app does each frame, and returns the
/// actions the polls gave.
fn poll_actions(t: &mut dyn Tool, cx: &ToolContext) -> Vec<Action> {
let start = std::time::Instant::now();
let mut acts = vec![];
while t.waiting() {
assert!(start.elapsed() < std::time::Duration::from_secs(60), "the tool is still waiting");
std::thread::sleep(std::time::Duration::from_millis(2));
acts.extend(t.poll(cx, false));
}
acts
}
/// A Shape Builder over `square_row(40)` (160 segments) that has merged nothing, with its map,
/// after a click at (15, 10) that started a merge on another thread.
fn clicked_row(cx: &ToolContext) -> ShapeBuilderTool {
let mut t = ShapeBuilderTool::default();
t.pointer(cx, &ev(PointerKind::Move, 15.0, 10.0));
poll_until_ready(&mut t, cx);
t.pointer(cx, &ev(PointerKind::Down, 15.0, 10.0));
assert!(t.pointer(cx, &ev(PointerKind::Up, 15.0, 10.0)).is_empty(), "the release returns before the merge is computed");
assert!(t.waiting() && t.editing());
t
}
#[test]
fn shape_builder_merges_a_large_selection_on_another_thread() {
let (d, s) = square_row(40);
let p = paint();
let cx = cx(&d, &s, &p);
let computed = merges_computed();
let mut t = clicked_row(&cx);
assert_eq!(t.cursor(&cx, Point::new(15.0, 10.0), Mods::default()), Cursor::Busy);
assert_eq!(t.overlays(&cx).len(), 2, "the clicked region's hatch and outline stay");
// A press while the merge is being computed starts no gesture.
assert!(t.pointer(&cx, &ev(PointerKind::Down, 35.0, 10.0)).is_empty());
assert!(!t.busy());
assert!(t.pointer(&cx, &ev(PointerKind::Up, 35.0, 10.0)).is_empty());
let acts = poll_actions(&mut t, &cx);
assert_eq!(merges_computed(), computed, "the merge was computed on another thread");
let [Action::Exec(id, params)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!((id.as_str(), &params["points"]), ("shapeBuilder.merge", &json!([[15.0, 10.0]])));
assert!(!t.waiting() && !t.editing());
assert_eq!(t.cursor(&cx, Point::new(15.0, 10.0), Mods::default()), Cursor::ShapeBuilder);
let m = t.shape_builder_merge().expect("the merge the command takes");
assert!(Arc::ptr_eq(&m.map, &t.cache.map));
assert_eq!((m.touched.regions.len(), m.touched.lines.len(), m.touched.edges.len()), (1, 0, 0));
assert!(!m.geometry.union.is_empty());
assert_eq!(m.geometry, merge_geometry(&m.map, &m.touched, m.gap, &m.inputs));
assert_eq!(m.geometry.rests.iter().flatten().count(), 2, "the two squares over the region change");
// The next event lets it go.
t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0));
assert!(t.shape_builder_merge().is_none());
}
#[test]
fn shape_builder_drops_a_merge_the_art_changed_under() {
let (d, s) = square_row(40);
let p = paint();
let mut fewer = Selection::default();
fewer.set(s.objects.iter().skip(1).copied());
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
let (moved, _) = square_row_at(40, 5.0);
let later = [
// An edit or an undo: the squares moved.
ToolContext { revision: (1, 2), ..cx(&moved, &s, &p) },
// Another document.
ToolContext { revision: (2, 1), ..cx(&d, &s, &p) },
// Another selection.
ToolContext { revision: (1, 1), ..cx(&d, &fewer, &p) },
];
for (k, cx2) in later.iter().enumerate() {
let mut t = clicked_row(&cx1);
// Dropped at the first poll, before the thread is done.
assert!(t.poll(cx2, false).is_empty(), "change {k}: the merge is not applied");
assert!(!t.editing() && !t.waiting() && t.shape_builder_merge().is_none(), "change {k}");
}
// Unchanged, the same wait applies it.
let mut t = clicked_row(&cx1);
assert_eq!(t.poll(&cx1, true).len(), 1);
// A copy of the document at the same revision (a save stamps its dates on a copy), and
// finishing the edit before a command, apply it too.
let copy = d.clone();
let cx3 = ToolContext { revision: (1, 1), ..cx(&copy, &s, &p) };
let mut t = clicked_row(&cx1);
assert_eq!(t.finish_edit(&cx3).len(), 1);
assert!(!t.editing() && t.shape_builder_merge().is_some());
// Switching away applies it.
let mut t = clicked_row(&cx1);
assert_eq!(t.deactivate(&cx1).len(), 1);
assert!(!t.editing());
}
/// A new revision of the document that leaves the selection's inputs as they were (a view
/// setting or a preference that redraws it) keeps the merge being computed, the gesture made
/// while the map is being built and the map itself: the merge applies with the map it was
/// computed over, and no map is built again.
#[test]
fn shape_builder_keeps_its_work_over_a_new_revision_of_the_same_art() {
let (d, s) = square_row(40);
let p = paint();
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
// A merge being computed.
let computed = merges_computed();
let mut t = clicked_row(&cx1);
let acts = poll_actions(&mut t, &cx2);
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}");
assert_eq!(merges_computed(), computed, "the merge computed on another thread");
let m = t.shape_builder_merge().expect("the merge the command takes");
assert!(Arc::ptr_eq(&m.map, &t.cache.map), "the map the merge was computed over");
assert_eq!(t.cache.key.as_ref().map(|k| k.1), Some((1, 2)));
assert_eq!(t.cache.builds, 1, "no map built again");
// Finishing it before a command too.
let mut t = clicked_row(&cx1);
assert_eq!(t.finish_edit(&cx2).len(), 1);
// A hover over the new revision builds no map.
let mut t = ShapeBuilderTool::default();
let hover = ev(PointerKind::Move, 15.0, 10.0);
t.pointer(&cx1, &hover);
poll_until_ready(&mut t, &cx1);
t.pointer(&cx2, &hover);
assert!(!t.waiting() && matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
assert_eq!(t.cache.builds, 1);
// A map being built, and a gesture made meanwhile.
let drag = [ev(PointerKind::Down, 15.0, 10.0), ev(PointerKind::Drag, 35.0, 10.0), ev(PointerKind::Up, 35.0, 10.0)];
let mut ready = ShapeBuilderTool::default();
ready.pointer(&cx1, &hover);
poll_until_ready(&mut ready, &cx1);
for e in &drag {
ready.pointer(&cx1, e);
}
let want = poll_actions(&mut ready, &cx1);
let mut t = ShapeBuilderTool::default();
t.pointer(&cx1, &hover);
for e in &drag {
assert!(t.pointer(&cx1, e).is_empty());
}
assert!(t.editing() && t.cache.builds == 0, "the gesture waits for the map");
assert_eq!(poll_actions(&mut t, &cx2), want);
assert_eq!((t.cache.builds, t.cache.background), (1, 1), "the map built for the first revision");
// Finishing it before a command waits for the map being built rather than building it again.
let mut t = ShapeBuilderTool::default();
t.pointer(&cx1, &hover);
for e in &drag {
t.pointer(&cx1, e);
}
assert_eq!(t.finish_edit(&cx2), want);
assert_eq!((t.cache.builds, t.cache.background), (1, 1), "the map built on the other thread");
// Another document with the same art drops the merge.
let cx3 = ToolContext { revision: (2, 1), ..cx(&d, &s, &p) };
let mut t = clicked_row(&cx1);
assert!(t.poll(&cx3, false).is_empty());
assert!(!t.editing());
}
/// A press while the map of a large selection is being built on another thread does not wait
/// for it: the gesture is recorded, each event returns at once, and the polls replay it once
/// the map is there, giving the merge the same gesture gives over the map. Finishing the edit
/// waits for the map and the merge; another revision of other art drops the gesture.
#[test]
fn shape_builder_gesture_waits_for_the_map_without_holding_the_caller() {
let (d, s) = square_row(200);
let p = paint();
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
let hover = ev(PointerKind::Move, 15.0, 10.0);
let drag = [ev(PointerKind::Down, 15.0, 10.0), ev(PointerKind::Drag, 35.0, 10.0), ev(PointerKind::Up, 35.0, 10.0)];
let mut ready = ShapeBuilderTool::default();
ready.pointer(&cx1, &hover);
poll_until_ready(&mut ready, &cx1);
for e in &drag {
ready.pointer(&cx1, e);
}
let want = poll_actions(&mut ready, &cx1);
assert!(matches!(want.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{want:?}");
let made_while_building = || {
let mut t = ShapeBuilderTool::default();
t.pointer(&cx1, &hover);
for e in &drag {
assert!(t.pointer(&cx1, e).is_empty());
}
assert_eq!(t.cache.builds, 0, "the gesture did not wait for the map");
assert!(t.editing() && t.waiting());
t
};
let mut t = made_while_building();
assert_eq!(t.cursor(&cx1, Point::new(35.0, 10.0), Mods::default()), Cursor::Busy);
// A press before the gesture is replayed starts nothing.
assert!(t.pointer(&cx1, &ev(PointerKind::Down, 55.0, 10.0)).is_empty());
assert!(t.pointer(&cx1, &ev(PointerKind::Up, 55.0, 10.0)).is_empty());
assert_eq!(poll_actions(&mut t, &cx1), want);
assert!(!t.editing() && t.shape_builder_merge().is_some());
// Finishing the edit before a command waits for both.
let mut t = made_while_building();
assert_eq!(t.finish_edit(&cx1), want);
// Another revision of other art before the map is there drops the gesture.
let mut t = made_while_building();
let (moved, _) = square_row_at(200, 5.0);
let cx2 = ToolContext { revision: (1, 2), ..cx(&moved, &s, &p) };
assert!(t.poll(&cx2, false).is_empty());
assert!(!t.editing());
}
/// Escape during a drag made while the map of a large selection is being built cancels the
/// recorded gesture too: the release and the polls after it merge nothing, the tool stops
/// waiting once the map is there, and the next drag merges on its own.
#[test]
fn shape_builder_escape_cancels_a_gesture_made_while_the_map_is_built() {
let (d, s) = square_row(200);
let p = paint();
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
let mut t = ShapeBuilderTool::default();
t.pointer(&cx1, &ev(PointerKind::Move, 15.0, 10.0));
assert!(t.pointer(&cx1, &ev(PointerKind::Down, 15.0, 10.0)).is_empty());
assert!(t.pointer(&cx1, &ev(PointerKind::Drag, 35.0, 10.0)).is_empty());
assert_eq!(t.cache.builds, 0, "the gesture did not wait for the map");
assert!(t.claims_key(&cx1, ToolKey::Escape));
assert!(t.key(&cx1, ToolKey::Escape, Mods::default()).is_empty());
assert!(t.pointer(&cx1, &ev(PointerKind::Up, 35.0, 10.0)).is_empty());
assert!(poll_actions(&mut t, &cx1).is_empty(), "the canceled gesture merges nothing");
assert!(!t.waiting() && !t.editing());
// The next drag merges what it touches, with no event of the canceled one.
let mut fresh = ShapeBuilderTool::default();
fresh.pointer(&cx1, &ev(PointerKind::Move, 55.0, 10.0));
poll_until_ready(&mut fresh, &cx1);
let next = [ev(PointerKind::Down, 55.0, 10.0), ev(PointerKind::Drag, 75.0, 10.0), ev(PointerKind::Up, 75.0, 10.0)];
for e in &next {
fresh.pointer(&cx1, e);
t.pointer(&cx1, e);
}
assert_eq!(poll_actions(&mut t, &cx1), poll_actions(&mut fresh, &cx1));
}
#[test]
fn shape_builder_hover_builds_a_small_map_at_once() {
let (d, s) = square_row(3);
@@ -1110,9 +1361,15 @@ fn bucket_keeps_its_map_while_the_document_is_unchanged() {
// The fill left the document as it was: the map still fits it.
t.pointer(&cx1, &ev(PointerKind::Move, 25.0, 50.0));
assert_eq!(t.cache.builds, 1, "a hover after a fill that changed nothing");
// A new revision of the same art keeps the map; one with an object moved builds it again.
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
t.pointer(&cx2, &ev(PointerKind::Move, 25.0, 50.0));
assert_eq!(t.cache.builds, 2, "a hover on a new revision");
assert_eq!(t.cache.builds, 1, "a hover on a new revision of the same art");
let mut moved = d.clone();
moved.node_mut(b).unwrap().transform(vectorcraft_geom::Affine::translate((10.0, 0.0)), false);
let cx3 = ToolContext { revision: (1, 3), ..cx(&moved, &s, &p) };
t.pointer(&cx3, &ev(PointerKind::Move, 25.0, 50.0));
assert_eq!(t.cache.builds, 2, "a hover on a new revision of other art");
}
#[test]
+26 -6
View File
@@ -267,21 +267,41 @@ pub trait Tool: Send {
false
}
/// Is the tool waiting for work it runs on another thread (the planar map of a large selection
/// for the Shape Builder and the Live Paint Bucket)? It highlights no region under the pointer
/// until the work is done. While this holds, the desktop app calls [`Tool::poll`] each frame,
/// and hosts without frames call it with `wait` after a [`PointerKind::Move`].
/// for the Shape Builder and the Live Paint Bucket, a Shape Builder merge of one)? It
/// highlights nothing under the pointer until a map is done. While this holds, the desktop app
/// calls [`Tool::poll`] each frame, and hosts without frames call it with `wait` after each
/// pointer event.
fn waiting(&self) -> bool {
false
}
/// Checks on the work [`Tool::waiting`] reports, waiting for it to finish with `wait`. Once it
/// is done, the tool shows the highlight under the pointer where it last moved.
fn poll(&mut self, _cx: &ToolContext, _wait: bool) {}
/// Is the tool computing an edit on another thread, which [`Tool::poll`] applies once it is
/// done (a Shape Builder merge of a large selection)?
fn editing(&self) -> bool {
false
}
/// Checks on the work [`Tool::waiting`] reports, waiting for it to finish with `wait`. Once a
/// map is done, the tool shows the highlight under the pointer where it last moved; once an
/// edit is done, the actions returned apply it.
fn poll(&mut self, _cx: &ToolContext, _wait: bool) -> Vec<Action> {
vec![]
}
/// Waits for the edit [`Tool::editing`] reports and returns the actions that apply it, before
/// a command runs on the document; none when the document's revision or the selection changed
/// since the gesture.
fn finish_edit(&mut self, _cx: &ToolContext) -> Vec<Action> {
vec![]
}
/// The Shape Builder's planar map of the selection, with the outline edges, and the gap it
/// closes, when the tool has one. `shapeBuilder.merge` uses it in place of a map of its own
/// when the map's inputs are the merge's inputs.
fn shape_builder_map(&self) -> Option<(std::sync::Arc<builder::BuilderMap>, f64)> {
None
}
/// The Shape Builder merge the tool computed on another thread for its last gesture, which
/// `shapeBuilder.merge` takes when it would compute the same ([`builder::PreparedMerge`]).
fn shape_builder_merge(&self) -> Option<std::sync::Arc<builder::PreparedMerge>> {
None
}
}
/// Create a tool by id. Unknown or not-yet-implemented tools fall back to a no-op tool that keeps the id.
+8 -4
View File
@@ -110,6 +110,7 @@ impl VectorcraftApp {
deferred_control: None,
synthetic_dropped_before: 0,
pending_screenshots: vec![],
pointer_replies: vec![],
queued_screenshots: vec![],
screenshot_token: 0,
screenshot_reserved: 0,
@@ -338,8 +339,9 @@ impl VectorcraftApp {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// The window's close button (or the system quitting the app) asks about unsaved documents,
// once the saves running in the background are done. Not while a file dialog's sheet is on
// the window: what asked for it runs again when it answers.
// once the saves running in the background are done and a Shape Builder merge being
// computed is applied. Not while a file dialog's sheet is on the window: what asked for it
// runs again when it answers.
if ctx.input(|i| i.viewport().close_requested()) {
// This native viewport is going away: release its IME context. No request is
// allocated here: the close prompt or file sheet publishes no custom descriptor, so a
@@ -347,7 +349,7 @@ impl VectorcraftApp {
// fresh generation on the next pass (never inherits a composition across the prompt).
crate::ime::close_viewport(self, ctx);
self.ime_context.end_viewport(ctx.viewport_id());
background::wait_all(self);
unsaved::finish_pending(self);
if self.file_sheet_open() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.status(picks::busy());
@@ -375,6 +377,7 @@ impl VectorcraftApp {
crate::chrome::sync_window_title(self, ctx);
crate::chrome::sync_fullscreen(self, ctx);
self.drain_control(ctx);
self.answer_pointer_replies(ctx);
// An egui `PasteImage` with a rich system clipboard: run the paste command now, once. It
// goes through `run`, whose `edit.paste` branch calls `adopt_system_clipboard` (which clears
// the request) to read the formats and then pastes them. A text paste in the same UI pass
@@ -559,7 +562,8 @@ impl VectorcraftApp {
tools_float::clear_canvas_dock_zone(&ctx);
}
// Beside the canvas, docked Tools draws inside the canvas panel (`toolbar::show_beside_canvas`).
if self.ui.toolbar && self.ui.screen_mode < 3 && (!self.ui.tools_beside_canvas || self.ui.toolbar_pos.is_some()) {
// So does Tools docked on the right, left of the panels.
if self.ui.toolbar && self.ui.screen_mode < 3 && ((!self.ui.tools_beside_canvas && !self.ui.toolbar_right) || self.ui.toolbar_pos.is_some()) {
toolbar::show(self, ui);
} else if !self.ui.toolbar && self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() {
// Window › Tools hides the Tools panel alone; Tab hides the toolbars made with New
+13 -1
View File
@@ -1,5 +1,5 @@
use super::fonts::MAX_RECENT_FONTS;
use super::{Value, VectorcraftApp, dialogs, fileio, io, menus, panels, picks, toolbar};
use super::{Value, VectorcraftApp, canvas, dialogs, fileio, io, menus, panels, picks, toolbar};
use crate::placing;
use crate::state::ReplaceFontWarning;
@@ -94,6 +94,17 @@ impl VectorcraftApp {
result
}
/// A command that waits for the edit the tool computes on another thread and the input held
/// for it waits before the app adds the view's active artboard to its params: a held press
/// may change it. The app's own commands (its dialogs, panels and view) wait too.
fn wait_for_tool_edit(&mut self, id: &str) {
let ui_command = vectorcraft_engine::find_command(id).is_none();
if (ui_command || vectorcraft_engine::waits_for_tool_edit(id)) && self.session.tool_waiting() {
let r = self.session.finish_tool_edit();
canvas::apply_requests(self, r);
}
}
/// [`Self::run`] it, inside what asks for file dialogs.
fn run_now(&mut self, id: &str, params: Value) -> Result<Value, String> {
crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size);
@@ -104,6 +115,7 @@ impl VectorcraftApp {
if cfg!(debug_assertions) && !self.session.replaying() {
menus::check_params(&self.session, id, &params)?;
}
self.wait_for_tool_edit(id);
// A `.ai` file a command writes (a save, an export, a timestamped copy, a batch's save) names
// the view's active artboard.
io::remember_active_artboard(self);
+36 -1
View File
@@ -1,6 +1,6 @@
use super::{
MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot, QueuedScreenshot, SCREENSHOT_TIMEOUT_MS,
VectorcraftApp, file_access, json, now_ms,
VectorcraftApp, canvas, file_access, json, now_ms,
};
use crate::control;
@@ -112,6 +112,9 @@ impl VectorcraftApp {
}
}
}
control::Outcome::AfterToolEdit { from, to, document } => {
self.pointer_replies.push(control::PendingPointerReply { from, to, document, reply });
}
}
}
if handled == control::MAX_CONTROL_REQUESTS_PER_FRAME {
@@ -121,6 +124,38 @@ impl VectorcraftApp {
self.control_rx = Some(rx);
}
/// Sends the `ui.pointer` replies that wait for the tool's edit once their events have been
/// made and the edit is applied. The canvas checks on the tool each frame it is drawn; this
/// checks too, for frames without it.
pub(crate) fn answer_pointer_replies(&mut self, ctx: &egui::Context) {
// Those whose events the canvas's check made go first, before this check makes the input
// held after them.
self.send_pointer_replies();
if self.pointer_replies.is_empty() {
return;
}
if self.session.tool_editing() {
let r = self.session.tool_poll(self.view_info(), false);
canvas::apply_requests(self, r);
}
self.send_pointer_replies();
if !self.pointer_replies.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
/// Sends the waiting `ui.pointer` replies whose events have been made.
fn send_pointer_replies(&mut self) {
for pending in std::mem::take(&mut self.pointer_replies) {
let control::PendingPointerReply { from, to, document, reply } = pending;
if self.session.input_applied(from, to) {
let _ = reply.send(control::pointer_reply_for(self, document));
} else {
self.pointer_replies.push(control::PendingPointerReply { from, to, document, reply });
}
}
}
/// The active document's identity: its process-unique uid and revision. A capture records it
/// at admission and issuance and refuses to answer with a different document or undo state.
pub(super) fn document_identity(&self) -> Option<(u64, u64)> {
+28 -9
View File
@@ -376,19 +376,29 @@ pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_e
{
v.artboard = i;
}
// A press with a selection tool inside an artboard makes it the active one (#693), as the
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it, Align to
// Artboard aligns to it, and All on Active Artboard selects on it.
if ev.kind == PointerKind::Down
&& vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id())
&& let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos))
&& let Some(v) = app.view_mut()
{
v.artboard = i;
if ev.kind == PointerKind::Down {
pressed_with(app, app.session.active_index(), ev.pos, app.session.tool_id());
}
apply_requests(app, r);
}
/// A press at `pos` in document `index` with tool `tool` (a selection tool) inside an artboard
/// makes it that document's active one (#693), as the Artboard tool's does: Paste in Place and in
/// Front or Back then paste onto it, Align to Artboard aligns to it, and All on Active Artboard
/// selects on it.
fn pressed_with(app: &mut VectorcraftApp, index: Option<usize>, pos: Point, tool: &str) {
if !vectorcraft_tools::catalog::is_selection_tool(tool) {
return;
}
app.sync_views();
if let Some(i) = index
&& let Some(board) = app.session.documents().get(i).and_then(|d| d.doc.artboard_at(pos))
&& let Some(v) = app.views.get_mut(i)
{
v.artboard = board;
}
}
/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error.
pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Vec<vectorcraft_engine::UiRequest>>) {
match r {
@@ -399,6 +409,15 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Ve
vectorcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t),
vectorcraft_engine::UiRequest::Status(msg) => app.status(msg),
vectorcraft_engine::UiRequest::Place(p) => crate::place::place_from_cursor(app, &p),
// In the document the press was made in, which may no longer be the active one.
vectorcraft_engine::UiRequest::HeldPress { doc, pos, tool } => {
let index = doc.and_then(|uid| app.session.documents().iter().position(|d| d.uid == uid));
pressed_with(app, index, pos, tool);
// The selection tool Cmd lent has the press now, so its drag can carry art to another tab.
if let Some(ctx) = app.ui_ctx.clone() {
crate::document_drag::begin(app, &ctx, pos);
}
}
}
}
}
+5 -5
View File
@@ -147,12 +147,12 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
app.session.tool_hover_ended(view);
ui.ctx().request_repaint();
}
// The tool works on another thread (a large selection's planar map): it checks each frame
// whether the work is done, and then shows the highlight under the pointer.
// The tool works on another thread (a large selection's planar map or merge): it checks each
// frame whether the work is done, and then shows the highlight under the pointer or applies
// the edit.
if app.session.tool_waiting() {
if let Err(e) = app.session.tool_poll(view, false) {
app.status(e.to_string());
}
let r = app.session.tool_poll(view, false);
apply_requests(app, r);
ui.ctx().request_repaint_after(std::time::Duration::from_millis(16));
}
if pointer.primary_released()
+45 -1
View File
@@ -96,6 +96,13 @@ fn reserve_synthetic(app: &mut VectorcraftApp, additional: usize) -> Result<(),
pub type ControlResponse = Value;
pub(crate) struct PendingPointerReply {
pub from: u64,
pub to: u64,
pub document: Option<u64>,
pub reply: Sender<ControlResponse>,
}
pub struct ControlRequest {
pub method: String,
pub params: Value,
@@ -167,6 +174,34 @@ pub enum Outcome {
path: Option<String>,
data: bool,
},
/// A `ui.pointer` reply ([`pointer_reply`]), sent once the events numbered `from..to`
/// ([`vectorcraft_engine::Session::input_applied`]) have been made and the edit they started
/// on another thread is applied, so it shows the selection after the edit.
AfterToolEdit {
from: u64,
to: u64,
document: Option<u64>,
},
}
/// The reply to `ui.pointer`: the selection, the key object and the active tool.
pub(crate) fn pointer_reply(app: &mut VectorcraftApp) -> Value {
pointer_reply_for(app, app.session.active().map(|d| d.uid))
}
pub(crate) fn pointer_reply_for(app: &VectorcraftApp, document: Option<u64>) -> Value {
let state = document.and_then(|uid| app.session.documents().iter().find(|d| d.uid == uid));
match state {
Some(state) => json!({
"ok": true,
"result": {
"selection": state.selection.objects.iter().map(|id| id.0).collect::<Vec<_>>(),
"key": state.selection.key.map(|id| id.0),
"tool": app.session.tool_id(),
}
}),
None => json!({"ok": false, "error": "the gesture's document is no longer open"}),
}
}
fn ok(v: Value) -> Outcome {
@@ -580,6 +615,7 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe
let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default();
let view = app.view_info();
let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v));
let from = app.session.input_count();
let held =
PointerEvent::json_backquote(p, false).and_then(|d| events.iter().map(|e| PointerEvent::json_backquote(e, d)).collect::<Result<Vec<_>, _>>());
let held = match held {
@@ -620,7 +656,15 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe
}
}
ctx.request_repaint();
wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()})))
// A Shape Builder merge of a large selection is computed on another thread, and the
// reply to the events that started it waits for it without holding the frames up. A
// gesture sent while it is computed is made after it, and its reply waits until then;
// a hover is answered at once.
let to = app.session.input_count();
if !app.session.input_applied(from, to) {
return Outcome::AfterToolEdit { from, to, document: app.session.active().map(|d| d.uid) };
}
Outcome::Done(pointer_reply(app))
}
/// `ui.click` and `ui.drag`: pointer input through egui itself.
+279
View File
@@ -1,5 +1,6 @@
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::{ControlRequest, MAX_DIALOG_FIELDS, Outcome, handle};
use crate::state::Dialog;
@@ -11,6 +12,27 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value
r
}
#[test]
fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
let (reply, received) = std::sync::mpsc::channel();
let at = app.session.input_count();
app.pointer_replies.push(super::PendingPointerReply { from: at, to: at, document: app.session.active().map(|d| d.uid), reply });
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("tool.select", json!({"tool": "rectangle"})).unwrap();
app.answer_pointer_replies(&egui::Context::default());
let result = received.try_recv().unwrap();
assert_eq!(result["ok"], true, "{result}");
assert_eq!(result["result"]["selection"], json!([id]));
assert_eq!(result["result"]["key"], Value::Null);
assert_eq!(result["result"]["tool"], "rectangle");
}
#[test]
fn control_lists_and_selects_optional_addon_tools() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
@@ -338,6 +360,263 @@ fn pointer_replies_name_the_key_object() {
assert_eq!(click(&mut app), Some(Value::Null), "a click on the key again lets it go");
}
/// A Shape Builder click on a large selection merges on another thread: the frames go on, and
/// the `ui.pointer` reply comes once the merge is applied, with the selection after it.
#[test]
fn pointer_reply_waits_for_a_merge_on_another_thread() {
let (tx, rx) = std::sync::mpsc::channel();
let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx);
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
for k in 0..40 {
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
let ctx = egui::Context::default();
let send = |app: &mut VectorcraftApp, events: Value| {
let (req, reply) = ControlRequest::new("ui.pointer", json!({"events": events}));
tx.send(req).unwrap();
app.drain_control(&ctx);
reply
};
let hover = send(&mut app, json!([{"kind": "move", "x": 15, "y": 10}]));
assert_eq!(hover.try_recv().unwrap()["ok"], true, "a hover is answered at once");
app.session.tool_poll(app.view_info(), true).unwrap();
let revision = app.session.active().unwrap().revision;
let click = send(&mut app, json!([{"kind": "down", "x": 15, "y": 10}, {"kind": "up", "x": 15, "y": 10}]));
assert!(click.try_recv().is_err() && app.session.tool_editing(), "the click is not answered before the merge is applied");
// A hover sent meanwhile is answered at once; a click is made after the merge, and its
// reply waits until then.
let hover = send(&mut app, json!([{"kind": "move", "x": 35, "y": 10}]));
assert_eq!(hover.try_recv().map(|r| r["ok"].clone()), Ok(json!(true)), "a hover during the merge");
let cmd = json!({"cmd": true});
let press = send(&mut app, json!([{"kind": "down", "x": 500, "y": 150, "mods": cmd}, {"kind": "up", "x": 500, "y": 150, "mods": cmd}]));
assert!(press.try_recv().is_err(), "a Cmd-click during the merge waits for it");
let hover = send(&mut app, json!([{"kind": "move", "x": 45, "y": 10}]));
assert_eq!(hover.try_recv().map(|r| r["ok"].clone()), Ok(json!(true)), "a hover after the held Cmd-click");
let start = std::time::Instant::now();
let reply = loop {
if let Ok(r) = click.try_recv() {
break r;
}
assert!(app.session.tool_editing(), "the merge is being computed until the reply goes");
assert_eq!(app.session.active().unwrap().revision, revision);
assert!(start.elapsed() < std::time::Duration::from_secs(60), "no reply");
std::thread::sleep(std::time::Duration::from_millis(2));
// A frame: the canvas checks on the tool, then the replies are answered.
let r = app.session.tool_poll(app.view_info(), false);
crate::canvas::apply_requests(&mut app, r);
app.answer_pointer_replies(&ctx);
};
assert_eq!(reply["ok"], true, "{reply}");
assert_eq!(reply["result"]["selection"].as_array().map(Vec::len), Some(41), "{reply}: the selection after the merge");
assert_ne!(app.session.active().unwrap().revision, revision);
let press = loop {
if let Ok(r) = press.try_recv() {
break r;
}
assert!(start.elapsed() < std::time::Duration::from_secs(60), "no reply to the Cmd-click");
let r = app.session.tool_poll(app.view_info(), false);
crate::canvas::apply_requests(&mut app, r);
app.answer_pointer_replies(&ctx);
};
assert_eq!(press["result"]["selection"].as_array().map(Vec::len), Some(0), "{press}: the lent selection tool deselected after the merge");
assert_eq!(press["result"]["tool"], "shapeBuilder", "{press}");
}
/// A Cmd-click held while a Shape Builder merge is computed on another thread makes the artboard
/// it lands in active once it is made, as a Cmd-click made at once does: by the frames' checks,
/// by choosing another tool first, or before a command that acts on the active artboard takes
/// it.
#[test]
fn a_held_cmd_click_makes_its_artboard_active() {
for then in ["frames", "another tool", "All on Active Artboard", "another document"] {
held_cmd_click(then);
}
}
fn held_cmd_click(then: &str) {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
if then == "another document" {
// A document with an artboard where the press is, whose view keeps its own.
app.run("file.new", json!({"width": 1400, "height": 200})).unwrap();
app.run("artboard.new", json!({"x": 1000, "y": 0, "width": 300, "height": 200})).unwrap();
app.view_mut().unwrap().artboard = 0;
}
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
app.run("artboard.new", json!({"x": 1000, "y": 0, "width": 300, "height": 200})).unwrap();
for k in 0..40 {
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
app.view_mut().unwrap().artboard = 0;
let view = app.view_info();
let at = |kind, x, mods| PointerEvent { kind, pos: vectorcraft_geom::Point::new(x, 10.0), mods, pressure: 1.0, backquote: false, pen: None };
crate::canvas::dispatch(&mut app, &at(PointerKind::Move, 15.0, Mods::default()), view);
app.session.tool_poll(view, true).unwrap();
crate::canvas::dispatch(&mut app, &at(PointerKind::Down, 15.0, Mods::default()), view);
crate::canvas::dispatch(&mut app, &at(PointerKind::Up, 15.0, Mods::default()), view);
let cmd = Mods { cmd: true, ..Default::default() };
crate::canvas::dispatch(&mut app, &at(PointerKind::Down, 1100.0, cmd), view);
crate::canvas::dispatch(&mut app, &at(PointerKind::Up, 1100.0, cmd), view);
assert_eq!(app.view().unwrap().artboard, 0, "{then}: the Cmd-click is held");
if then == "another tool" {
app.select_tool("rectangle");
assert!(!app.session.tool_editing(), "{then}");
}
if then == "All on Active Artboard" {
app.run("select.allOnArtboard", json!({})).unwrap();
assert!(app.session.doc().unwrap().selection.is_empty(), "{then}: the second artboard, which holds no art");
}
let doc = app.session.active_index().unwrap();
if then == "another document" {
// A document tab chosen.
app.session.set_active(0);
}
while app.session.tool_waiting() {
let r = app.session.tool_poll(view, true);
crate::canvas::apply_requests(&mut app, r);
}
app.sync_views();
assert_eq!(app.views[doc].artboard, 1, "{then}: the Cmd-click made the artboard it landed in active");
if then == "another document" {
assert_eq!(app.views[0].artboard, 0, "{then}: the other document's view keeps its artboard");
}
}
/// A dialog chosen from the menu, or with `ui.menuDialog`, while a Shape Builder merge is
/// computed on another thread opens after the merge is applied, on the merged art. So do the
/// app's own dialogs (Recolor Artwork), from the menu or a panel's button.
#[test]
fn menu_dialogs_open_after_a_merge_on_another_thread() {
for route in ["menu", "ui.menuDialog", "app dialog from the menu", "app dialog from a panel"] {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
for k in 0..40 {
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
let view = app.view_info();
let at = |kind| PointerEvent {
kind,
pos: vectorcraft_geom::Point::new(15.0, 10.0),
mods: Mods::default(),
pressure: 1.0,
backquote: false,
pen: None,
};
crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view);
app.session.tool_poll(view, true).unwrap();
crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view);
crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view);
assert!(app.session.tool_editing(), "{route}");
match route {
"menu" => crate::menus::invoke(&mut app, "object.move", json!({})),
"ui.menuDialog" => {
app.run("ui.menuDialog", json!({"command": "object.move"})).unwrap();
}
"app dialog from the menu" => crate::menus::invoke(&mut app, "ui.recolorDialog", json!({})),
_ => {
app.run("ui.recolorDialog", json!({})).unwrap();
}
}
assert!(app.ui.dialog.is_some(), "{route}: the dialog is open");
assert!(!app.session.tool_editing(), "{route}");
let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone());
assert_eq!(merged.as_deref(), Some("Shape Builder"), "{route}");
assert_eq!(app.session.doc().unwrap().selection.objects.len(), 41, "{route}: the dialog acts on the merged art");
}
}
/// The Separations panel's checkboxes and a preference set while a Shape Builder merge is
/// computed on another thread give the documents new revisions: the merge is applied first,
/// and the document keeps it.
#[test]
fn panels_that_redraw_apply_a_merge_on_another_thread_first() {
let cases = [
("view.separationsPreview", json!({"on": false})),
("view.overprintPreview", json!({"on": false})),
("prefs.set", json!({"values": {"gridlineEvery": 90}})),
];
for (id, params) in cases {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
for k in 0..40 {
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
let view = app.view_info();
let at = |kind| PointerEvent {
kind,
pos: vectorcraft_geom::Point::new(15.0, 10.0),
mods: Mods::default(),
pressure: 1.0,
backquote: false,
pen: None,
};
crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view);
app.session.tool_poll(view, true).unwrap();
crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view);
crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view);
assert!(app.session.tool_editing(), "{id}");
app.run(id, params).unwrap();
assert!(!app.session.tool_editing(), "{id}");
for _ in 0..3 {
app.session.tool_poll(view, false).unwrap();
}
let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone());
assert_eq!(merged.as_deref(), Some("Shape Builder"), "{id}");
assert_eq!(app.session.doc().unwrap().selection.objects.len(), 41, "{id}");
}
}
/// Saving, exporting or closing while a Shape Builder merge is computed on another thread
/// applies the merge first: the file holds it, and closing asks to save it.
#[test]
fn save_and_close_apply_a_merge_on_another_thread_first() {
let dir = vectorcraft_testkit::temp_dir("control-merge-save");
for then in ["file.saveAs", "file.exportAs", "file.close"] {
let services = Services { write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), ..Default::default() };
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
for k in 0..40 {
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
// As if just saved: only the merge modifies it.
app.session.doc_mut().unwrap().mark_saved();
let view = app.view_info();
let at = |kind| PointerEvent {
kind,
pos: vectorcraft_geom::Point::new(15.0, 10.0),
mods: Mods::default(),
pressure: 1.0,
backquote: false,
pen: None,
};
crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view);
app.session.tool_poll(view, true).unwrap();
crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view);
crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view);
assert!(app.session.tool_editing() && !app.session.doc().unwrap().is_dirty(), "{then}");
let path = dir.join(if then == "file.exportAs" { "a.svg" } else { "a.vectorcraft" });
let p = if then == "file.close" { json!({}) } else { json!({"path": path.to_string_lossy()}) };
let r = app.run(then, p).unwrap();
assert!(!app.session.tool_editing(), "{then}");
let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone());
assert_eq!(merged.as_deref(), Some("Shape Builder"), "{then}");
match then {
"file.saveAs" => assert!(!app.session.doc().unwrap().is_dirty(), "the file holds the merge"),
"file.exportAs" => assert!(app.session.doc().unwrap().is_dirty(), "an export leaves the document modified"),
_ => assert_eq!(r["pending"], "saveChanges", "closing asks to save the merge"),
}
}
}
/// `ui.dialog.set` of a field the open dialog does not have is an error that lists its fields,
/// and the dialog stays as it was. Fields a dialog may not hold yet are still set: `discard`
/// with a discard button, `preview` with a live preview, `__` fields and the spec's `inputs`.
@@ -358,6 +358,7 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut
let top = ui.cursor().top();
ui.label(egui::RichText::new(tl!("PRESET DETAILS")).font(theme::semibold(11.0)).color(t.text_dim));
ui.add_space(8.0);
form::caption(ui, tl!("Document Name"));
ui.horizontal(|ui| {
form::text(ui, d, "name", DETAILS - 50.0);
save_button(app, ui, d);
@@ -876,6 +877,44 @@ mod tests {
assert!(selected(&out), "the turned preset stays chosen: {:?}", field(&app, "preset"));
}
/// The document's name field has a caption of its own under the Preset Details heading, and
/// the column stays inside the dialog's height with Save Preset and Advanced Options open.
#[test]
fn the_name_field_is_captioned_and_the_column_fits() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.newDialog", json!({})).unwrap();
set(&mut app, "advanced", json!(true));
set(&mut app, super::SAVING, json!(true));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut time = 0.0;
let mut run = |app: &mut VectorcraftApp| {
time += 0.5;
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1000.0)));
let raw = egui::RawInput { screen_rect, time: Some(time), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
out
};
run(&mut app);
let out = run(&mut app);
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut texts = vec![];
out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts));
let at = |name: &str| texts.iter().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("{name} is missing: {texts:?}"));
let (heading, caption, value) = (at("PRESET DETAILS"), at("Document Name"), at("Untitled-1"));
assert!(heading.bottom() <= caption.top() && caption.bottom() <= value.top(), "{heading:?} {caption:?} {value:?}");
assert!(value.bottom() <= at("Preset Name").top(), "the preset's own name field follows");
let used = at("Create").bottom() - heading.top();
assert!(used <= super::HEIGHT, "the column used {used}");
}
#[test]
fn a_preset_fills_the_details_and_create_makes_the_document() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+1 -1
View File
@@ -467,7 +467,7 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
let screen = ctx.content_rect();
let id = drawer_id();
let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(WIDTH, 200.0), |r| r.size());
let x = (bar.right() + GAP).min(screen.right() - size.x - GAP).max(screen.left());
let x = crate::toolbar::beside(bar, size.x, GAP, crate::toolbar::room(app, ctx).shrink(GAP));
// Never above the toolbar's top: higher up are the window's title bar (and on macOS its
// buttons) and the bars under it.
let top = bar.top().max(screen.top() + GAP);
+3 -2
View File
@@ -3928,7 +3928,7 @@ axis Both И двете
Dock to Bottom Закотвяне долу
Dock Control Bar Закотвяне на контролната лента
Drag to float the toolbar Плъзнете, за да направите лентата с инструменти плаваща
Drag to the window's left edge to dock Плъзнете до левия ръб на прозореца за закотвяне
Drag to the window's left or right edge to dock Плъзнете до левия или десния ръб на прозореца за закотвяне
Float Tool Group Плаваща група инструменти
@msg no library `{id}` няма библиотека `{id}`
@msg select some type first първо изберете текст
@@ -4618,7 +4618,7 @@ axis Both И двете
@msg its objects are nested too deeply обектите му са вложени твърде дълбоко
@msg its section ending with `%{end}` doesn't end разделът му, завършващ на `%{end}`, няма край
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) четките, които VectorCraft все още не може да прочете, бяха пропуснати от панела „Четки“ ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet обектите с множество запълвания или щрихове, ефекти или четки се появиха във вид на изрисувания си изглед ({_1}): тяхната поява все още не подлежи на редактиране
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet обектите с множество запълвания или щрихове, щрихове с променлива ширина, ефекти или четки се появиха във вид на изрисувания си изглед ({_1}): тяхната поява все още не подлежи на редактиране
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding името `{name}` не е в UTF-8 и е прочетено като {label}: ако изглежда грешно, отворете файла с друго legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding името `{name}` не е в UTF-8, а части от него не са валидни и в {label}: отворете файла с друго legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) още имена, които не са в UTF-8, са прочетени като {label} ({count})
@@ -6426,3 +6426,4 @@ breakText Glyphs Глифи
# Align to Pixel Grid
Align to Pixel Grid Подравняване към пикселната мрежа
@msg side is left or right, not `{s}` side е left или right, а не `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -1269,7 +1269,7 @@ lighting Light Svjetlo
X: X:
Y: Y:
Drag to float the toolbar Prevucite da odvojite traku s alatima
Drag to the window's left edge to dock Prevucite do lijeve ivice prozora za sidrenje
Drag to the window's left or right edge to dock Prevucite do lijeve ili desne ivice prozora za sidrenje
Crystallize Kristalizuj
Add Anchor Point Tool Alat za dodavanje sidrene tačke
Anchor Point Tool Alat sidrena tačka
@@ -5014,7 +5014,7 @@ imageTrace Outline Obris
@msg its objects are nested too deeply njeni objekti su previše duboko ugniježđeni
@msg its section ending with `%{end}` doesn't end njen odjeljak koji završava s `%{end}` ne završava
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) četkice koje VectorCraft još ne može pročitati izostavljene su iz panela Četkice ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekti s više ispuna ili poteza, efekata ili četkica stigli su kao njihov iscrtani izgled ({_1}): njihov izgled se još ne može uređivati
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekti s više ispuna ili poteza, poteza promjenljive širine, efekata ili četkica stigli su kao njihov iscrtani izgled ({_1}): njihov izgled se još ne može uređivati
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding naziv `{name}` nije u UTF-8 i pročitan je kao {label}: ako izgleda pogrešno, otvorite datoteku s drugim legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding naziv `{name}` nije u UTF-8, a dijelovi nisu ispravni ni u {label}: otvorite datoteku s drugim legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) još naziva koji nisu u UTF-8 pročitano je kao {label} ({count})
@@ -6477,3 +6477,4 @@ shape Open Otvoren
# Align to Pixel Grid
Align to Pixel Grid Poravnaj s mrežom piksela
@msg side is left or right, not `{s}` side je left ili right, a ne `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -1253,7 +1253,7 @@ antiAlias Sharp Nítid
Drag to float as a toolbar Arrossega per surar com a barra d’eines
Drag to float the toolbar Arrossega per fer surar la barra d’eines
Drag to move Arrossega per moure
Drag to the window's left edge to dock Arrossega fins a la vora esquerra de la finestra per acoblar
Drag to the window's left or right edge to dock Arrossega fins a la vora esquerra o dreta de la finestra per acoblar
Drag values or labels. Double-click a value to type. Arrossega els valors o les etiquetes. Fes doble clic en un valor per escriure’l.
Draw Dibuixa
Draw Behind Dibuixa al darrere
@@ -5507,7 +5507,7 @@ digits Malayalam Malaiàlam
@msg objects can't be nested more than 32 deep els objectes no es poden niar més de 32 nivells
@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path els objectes dins d’una fusió, un embolcall, una repetició o un grup Intertwine no es poden adjuntar a un traçat
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) els pinzells que VectorCraft encara no pot llegir s'han deixat fora del tauler Pinzells ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet els objectes amb diversos farciments o traços, efectes o pinzells han arribat amb el seu aspecte dibuixat ({_1}): la seva aparença encara no és editable
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet els objectes amb diversos farciments o traços, traços d’amplada variable, efectes o pinzells han arribat amb el seu aspecte dibuixat ({_1}): la seva aparença encara no és editable
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding el nom `{name}` no és UTF-8 i s’ha llegit com a {label}: si sembla incorrecte, obriu el fitxer amb un altre legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding el nom `{name}` no és UTF-8, i algunes parts tampoc no són {label} vàlid: obriu el fitxer amb un altre legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) s’han llegit com a {label} més noms que no són UTF-8 ({count})
@@ -6639,3 +6639,4 @@ breakText Glyphs Glifs
# Align to Pixel Grid
Align to Pixel Grid Alinea a la quadrícula de píxels
@msg only paths and compound paths can be aligned to the pixel grid només els traçats i els traçats compostos es poden alinear a la quadrícula de píxels
@msg side is left or right, not `{s}` side ha de ser left o right, no `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -1289,7 +1289,7 @@ lighting Light Světlo
X: X:
Y: Y:
Drag to float the toolbar Přetažením vytvoříte plovoucí panel nástrojů
Drag to the window's left edge to dock Přetažením k levému okraji okna panel ukotvíte
Drag to the window's left or right edge to dock Přetažením k levému nebo pravému okraji okna panel ukotvíte
# Tools
Crystallize Krystalizace
@@ -5089,7 +5089,7 @@ imageTrace Outline Obrys
@msg its objects are nested too deeply jeho objekty jsou vnořené příliš hluboko
@msg its section ending with `%{end}` doesn't end jeho oddíl končící `%{end}` nemá konec
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) štětce, které VectorCraft zatím neumí načíst, byly vynechány z panelu Štětce ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekty s více výplněmi nebo tahy, efekty či štětci byly načteny jako vykreslený vzhled ({_1}): jejich vzhled zatím nelze upravovat
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekty s více výplněmi nebo tahy, tahy s proměnnou šířkou, efekty či štětci byly načteny jako vykreslený vzhled ({_1}): jejich vzhled zatím nelze upravovat
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding název `{name}` není v UTF-8 a byl načten jako {label}: pokud vypadá špatně, otevřete soubor s jiným legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding název `{name}` není v UTF-8 a jeho části nejsou platné ani v {label}: otevřete soubor s jiným legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) další názvy, které nejsou v UTF-8, byly načteny jako {label} ({count})
@@ -6658,3 +6658,4 @@ breakText Glyphs Glyfy
# Align to Pixel Grid
Align to Pixel Grid Zarovnat na mřížku obrazových bodů
@msg only paths and compound paths can be aligned to the pixel grid na mřížku obrazových bodů lze zarovnat jen cesty a složené cesty
@msg side is left or right, not `{s}` side je left nebo right, ne `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -1204,7 +1204,7 @@ lighting Light Lys
X: X:
Y: Y:
Drag to float the toolbar Træk for at frigøre værktøjslinjen
Drag to the window's left edge to dock Træk til vinduets venstre kant for at fastgøre
Drag to the window's left or right edge to dock Træk til vinduets venstre eller højre kant for at fastgøre
Crystallize Krystallisér
Add Anchor Point Tool Tilføj ankerpunkt-værktøj
Anchor Point Tool Ankerpunktværktøj
@@ -4706,7 +4706,7 @@ imageTrace Outline Kontur
@msg its objects are nested too deeply dens objekter er indlejret for dybt
@msg its section ending with `%{end}` doesn't end dens sektion, der ender med `%{end}`, ender ikke
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) pensler, som VectorCraft endnu ikke kan læse, blev udeladt af panelet Pensler ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekter med flere fyld eller streger, effekter eller pensler kom ind, som de er tegnet ({_1}): deres udseende er endnu ikke redigerbart
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekter med flere fyld eller streger, streger med variabel bredde, effekter eller pensler kom ind, som de er tegnet ({_1}): deres udseende er endnu ikke redigerbart
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding navnet `{name}` er ikke UTF-8 og blev læst som {label}: hvis det ser forkert ud, så åbn filen med en anden legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding navnet `{name}` er ikke UTF-8, og dele af det er heller ikke gyldig {label}: åbn filen med en anden legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) flere navne, der ikke er UTF-8, blev læst som {label} ({count})
@@ -6440,3 +6440,4 @@ breakText Glyphs Glyffer
# Align to Pixel Grid
Align to Pixel Grid Juster efter pixelgitter
@msg side is left or right, not `{s}` side er left eller right, ikke `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -3937,7 +3937,7 @@ theme Appearance Erscheinungsbild
Dock to Bottom Unten andocken
Dock Control Bar Steuerungsleiste andocken
Drag to float the toolbar Ziehen, um die Werkzeugleiste abzulösen
Drag to the window's left edge to dock Zum Andocken an den linken Fensterrand ziehen
Drag to the window's left or right edge to dock Zum Andocken an den linken oder rechten Fensterrand ziehen
Float Tool Group Werkzeuggruppe ablösen
# Status and error messages (`@msg`), translated where the status bar shows them; the message itself
@@ -4697,7 +4697,7 @@ theme Appearance Erscheinungsbild
@msg its objects are nested too deeply seine Objekte sind zu tief verschachtelt
@msg its section ending with `%{end}` doesn't end sein Abschnitt, der mit `%{end}` enden soll, endet nicht
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) Pinsel, die VectorCraft noch nicht lesen kann, wurden im Bedienfeld „Pinsel“ ausgelassen ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet Objekte mit mehreren Flächen oder Konturen, Effekten oder Pinseln wurden so übernommen, wie sie gezeichnet aussehen ({_1}): Ihr Aussehen ist noch nicht bearbeitbar
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet Objekte mit mehreren Flächen oder Konturen, Konturen mit variabler Breite, Effekten oder Pinseln wurden so übernommen, wie sie gezeichnet aussehen ({_1}): Ihr Aussehen ist noch nicht bearbeitbar
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding Der Name `{name}` ist kein UTF-8 und wurde als {label} gelesen: Wenn er falsch aussieht, öffnen Sie die Datei mit einer anderen legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding Der Name `{name}` ist kein UTF-8, und Teile davon sind auch kein gültiges {label}: Öffnen Sie die Datei mit einer anderen legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) Weitere Namen, die kein UTF-8 sind, wurden als {label} gelesen ({count})
@@ -6245,3 +6245,4 @@ breakText Glyphs Glyphen
# Align to Pixel Grid
Align to Pixel Grid Am Pixelraster ausrichten
@msg only paths and compound paths can be aligned to the pixel grid nur Pfade und zusammengesetzte Pfade können am Pixelraster ausgerichtet werden
@msg side is left or right, not `{s}` side ist left oder right, nicht `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -4145,7 +4145,7 @@ digits Malayalam Μαλαγιάλαμ
Drag to float as a toolbar Σύρετε για αποδέσμευση ως γραμμή εργαλείων
Put back in the toolbar Επαναφορά στη γραμμή εργαλείων
Drag to float the toolbar Σύρετε για αποδέσμευση της γραμμής εργαλείων
Drag to the window's left edge to dock Σύρετε στο αριστερό άκρο του παραθύρου για σύνδεση
Drag to the window's left or right edge to dock Σύρετε στο αριστερό ή στο δεξί άκρο του παραθύρου για σύνδεση
Float Tool Group Αποδέσμευση ομάδας εργαλείων
@msg a PostScript error ({name} in `{op}`) σφάλμα PostScript ({name} στην εντολή `{op}`)
@msg a PostScript error ({name} in `{op}`), in {procs} σφάλμα PostScript ({name} στην εντολή `{op}`), στις διαδικασίες {procs}
@@ -4729,7 +4729,7 @@ digits Malayalam Μαλαγιάλαμ
@msg its objects are nested too deeply τα αντικείμενά του έχουν υπερβολικά βαθιά ένθεση
@msg its section ending with `%{end}` doesn't end η ενότητά του που τελειώνει σε `%{end}` δεν τερματίζεται
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) τα πινέλα που το VectorCraft δεν μπορεί ακόμη να διαβάσει παραλείφθηκαν από τον πίνακα Πινέλα ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet τα αντικείμενα με πολλαπλά γεμίσματα, περιγράμματα, εφέ ή πινέλα εισήχθησαν ως απεικονίσεις ({_1}): η εμφάνισή τους δεν είναι ακόμη επεξεργάσιμη
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet τα αντικείμενα με πολλαπλά γεμίσματα, περιγράμματα, περιγράμματα μεταβλητού πλάτους, εφέ ή πινέλα εισήχθησαν ως απεικονίσεις ({_1}): η εμφάνισή τους δεν είναι ακόμη επεξεργάσιμη
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding το όνομα `{name}` δεν είναι UTF-8 και διαβάστηκε ως {label}: αν φαίνεται λάθος, ανοίξτε το αρχείο με άλλο legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding το όνομα `{name}` δεν είναι UTF-8 και τμήματά του δεν είναι έγκυρα ούτε σε {label}: ανοίξτε το αρχείο με άλλο legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) περισσότερα ονόματα που δεν είναι UTF-8 διαβάστηκαν ως {label} ({count})
@@ -6610,3 +6610,4 @@ breakText Glyphs Γλύφοι
# Align to Pixel Grid
Align to Pixel Grid Στοίχιση στο πλέγμα εικονοστοιχείων
@msg only paths and compound paths can be aligned to the pixel grid μόνο διαδρομές και σύνθετες διαδρομές μπορούν να στοιχιστούν στο πλέγμα εικονοστοιχείων
@msg side is left or right, not `{s}` το side είναι left ή right, όχι `{s}`
Can't render this file because it is too large.
+3 -2
View File
@@ -4022,7 +4022,7 @@ axis Both Ambos
Dock to Bottom Anclar abajo
Dock Control Bar Anclar barra de control
Drag to float the toolbar Arrastrar para hacer flotar la barra de herramientas
Drag to the window's left edge to dock Arrastrar al borde izquierdo de la ventana para anclar
Drag to the window's left or right edge to dock Arrastrar al borde izquierdo o derecho de la ventana para anclar
Float Tool Group Hacer flotar el grupo de herramientas
# Status and error messages (`@msg`), translated where the status bar shows them; the message itself
@@ -4781,7 +4781,7 @@ axis Both Ambos
@msg its objects are nested too deeply sus objetos están anidados a demasiada profundidad
@msg its section ending with `%{end}` doesn't end su sección que termina con `%{end}` no termina
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) los pinceles que VectorCraft aún no puede leer se omitieron del panel Pinceles ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet los objetos con varios rellenos o trazos, efectos o pinceles se importaron con su aspecto dibujado ({_1}): su aspecto aún no se puede editar
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet los objetos con varios rellenos o trazos, trazos de ancho variable, efectos o pinceles se importaron con su aspecto dibujado ({_1}): su aspecto aún no se puede editar
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding el nombre `{name}` no está en UTF-8 y se leyó como {label}: si no se ve bien, abre el archivo con otro legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding el nombre `{name}` no está en UTF-8 y partes de él tampoco son {label} válido: abre el archivo con otro legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) se leyeron como {label} más nombres que no están en UTF-8 ({count})
@@ -6601,3 +6601,4 @@ breakText Glyphs Glifos
# Align to Pixel Grid
Align to Pixel Grid Alinear con cuadrícula de píxeles
@msg only paths and compound paths can be aligned to the pixel grid solo los trazados y los trazados compuestos se pueden alinear con la cuadrícula de píxeles
@msg side is left or right, not `{s}` side es left o right, no `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -1267,7 +1267,7 @@ lighting Light Valo
X: X:
Y: Y:
Drag to float the toolbar Kelluta työkaluriviä vetämällä
Drag to the window's left edge to dock Telakoi vetämällä ikkunan vasempaan reunaan
Drag to the window's left or right edge to dock Telakoi vetämällä ikkunan vasempaan tai oikeaan reunaan
Crystallize Kiteytä
Add Anchor Point Tool Lisää ankkuripiste -työkalu
Anchor Point Tool Ankkuripistetyökalu
@@ -4991,7 +4991,7 @@ imageTrace Outline Ääriviiva
@msg its objects are nested too deeply sen objektit ovat liian syvällä sisäkkäin
@msg its section ending with `%{end}` doesn't end sen `%{end}`-merkintään päättyvä osio ei pääty
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) siveltimet, joita VectorCraft ei vielä osaa lukea, jätettiin pois Siveltimet-paneelista ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objektit, joilla on useita täyttöjä tai viivoja, tehosteita tai siveltimiä, tuotiin piirretyn näköisinä ({_1}): niiden ulkoasua ei voi vielä muokata
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objektit, joilla on useita täyttöjä tai viivoja, vaihtelevan levyisiä viivoja, tehosteita tai siveltimiä, tuotiin piirretyn näköisinä ({_1}): niiden ulkoasua ei voi vielä muokata
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding nimi `{name}` ei ole UTF-8:aa, ja se luettiin koodauksella {label}: jos se näyttää väärältä, avaa tiedosto toisella legacyNameEncoding-arvolla
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding nimi `{name}` ei ole UTF-8:aa, eivätkä kaikki sen osat ole kelvollista {label}-koodausta: avaa tiedosto toisella legacyNameEncoding-arvolla
@msg more names that aren't UTF-8 were read as {label} ({count}) muita nimiä, jotka eivät ole UTF-8:aa, luettiin koodauksella {label} ({count})
@@ -6693,3 +6693,4 @@ breakText Glyphs Merkit
# Align to Pixel Grid
Align to Pixel Grid Tasaa pikseliruudukkoon
@msg only paths and compound paths can be aligned to the pixel grid vain polut ja yhdistelmäpolut voi tasata pikseliruudukkoon
@msg side is left or right, not `{s}` side on left tai right, ei `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -4011,7 +4011,7 @@ axis Both Les deux
Dock to Bottom Ancrer en bas
Dock Control Bar Ancrer la barre de contrôle
Drag to float the toolbar Faire glisser pour rendre la barre d’outils flottante
Drag to the window's left edge to dock Faire glisser vers le bord gauche de la fenêtre pour ancrer
Drag to the window's left or right edge to dock Faire glisser vers le bord gauche ou droit de la fenêtre pour ancrer
Float Tool Group Rendre le groupe d’outils flottant
# Status and error messages (`@msg`), translated where the status bar shows them; the message itself
@@ -4768,7 +4768,7 @@ axis Both Les deux
@msg its objects are nested too deeply leurs objets sont imbriqués trop profondément
@msg its section ending with `%{end}` doesn't end leur section qui se termine par `%{end}` ne se termine pas
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) les formes que VectorCraft ne sait pas encore lire ont été omises du panneau Formes ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet les objets à plusieurs fonds ou contours, effets ou formes ont été importés tels qu’ils sont dessinés ({_1}) : leur aspect n’est pas encore modifiable
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet les objets à plusieurs fonds ou contours, contours de largeur variable, effets ou formes ont été importés tels qu’ils sont dessinés ({_1}) : leur aspect n’est pas encore modifiable
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding le nom `{name}` n’est pas en UTF-8 et a été lu en {label} : s’il semble incorrect, ouvrez le fichier avec un autre legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding le nom `{name}` n’est pas en UTF-8, et certaines parties ne sont pas non plus du {label} valide : ouvrez le fichier avec un autre legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) d’autres noms qui ne sont pas en UTF-8 ont été lus en {label} ({count})
@@ -6653,3 +6653,4 @@ breakText Glyphs Glyphes
# Align to Pixel Grid
Align to Pixel Grid Aligner sur la grille de pixels
@msg only paths and compound paths can be aligned to the pixel grid seuls les tracés et les tracés transparents peuvent être alignés sur la grille de pixels
@msg side is left or right, not `{s}` side vaut left ou right, pas `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -1384,7 +1384,7 @@ antiAlias Sharp शार्प
Drag to float as a toolbar एक टूलबार के रूप में तैरने के लिए खींचें
Drag to float the toolbar टूलबार को तैरने के लिए खींचें
Drag to move आगे बढ़ने के लिए
Drag to the window's left edge to dock खिड़की के बाएं किनारे पर डॉक करने के लिए खींचें
Drag to the window's left or right edge to dock खिड़की के बाएं या दाएं किनारे पर डॉक करने के लिए खींचें
Drag values or labels. Double-click a value to type. मूल्यों या लेबल खींचें। टाइप करने के लिए एक मान को डबल क्लिक करें।
Draw ड्रा
Draw Behind पीछे खींचना
@@ -7000,7 +7000,7 @@ antiAlias Sharp शार्प
@msg warped text objects that came in as drawn art, not as editable type: {_1} मोड़े गए टेक्स्ट ऑब्जेक्ट जो संपादन योग्य टेक्स्ट के बजाय कला के रूप में आए: {_1}
@msg objects that drew differently from the file's own page came in as its art: {_1} उन ऑब्जेक्ट्स को जो फ़ाइल के स्वयं के पृष्ठ से अलग-अलग तरीके से आकर्षित होते हैं, उनकी कला के रूप में आया: {_1}
@msg objects were left out ऑब्जेक्ट्स को छोड़ दिया गया था
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet कई भराव या स्ट्रोक, इफ़ेक्ट या ब्रश के साथ ऑब्जेक्टएं उनके ड्रॉइंग लुक ({_1}) के रूप में आईं: उनकी उपस्थिति अभी तक संपादन योग्य नहीं है
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet कई भराव या स्ट्रोक, परिवर्तनीय चौड़ाई वाले स्ट्रोक, इफ़ेक्ट या ब्रश के साथ ऑब्जेक्टएं उनके ड्रॉइंग लुक ({_1}) के रूप में आईं: उनकी उपस्थिति अभी तक संपादन योग्य नहीं है
@msg offset out of range सीमा से बाहर ऑफसेट
@msg Offset Path: {e} ऑफसेट पाथ: {e}
@msg Offset Path: select paths ऑफसेट पाथ: सिलेक्शन पाथ
@@ -8100,3 +8100,4 @@ theme Appearance अपीयरेंस
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads फ़ाइल उन {MAX_INPUT_BYTES} बाइट्स से बड़ी है जो इंपोर्टर पढ़ता है
@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` इंपोर्टर 1 से {MAX_EXTENSIONS} `extensions` घोषित करता है
@msg an importer always gets an import's budget: leave `budget` out इंपोर्टर को हमेशा इंपोर्ट का बजट मिलता है: `budget` छोड़ दें
@msg side is left or right, not `{s}` side left या right होना चाहिए, `{s}` नहीं
Can't render this file because it is too large.
+3 -2
View File
@@ -1161,7 +1161,7 @@ font-technology Variable Változó
Drag to float as a toolbar Húzd el, hogy önálló eszköztárként lebegjen
Drag to float the toolbar Húzd el az eszköztár lebegtetéséhez
Drag to move Húzással áthelyezhető
Drag to the window's left edge to dock Dokkoláshoz húzd az ablak bal szélére
Drag to the window's left or right edge to dock Dokkoláshoz húzd az ablak bal vagy jobb szélére
Step up or down Léptetés fel vagy le
Drag values or labels. Double-click a value to type. Húzd az értékeket vagy a címkéket. Gépeléshez kattints duplán egy értékre.
Draw Rajzolás
@@ -5169,7 +5169,7 @@ imageTrace Outline Körvonal
@msg object {id} is not a path az objektum nem útvonal: {id}
@msg object {id} is not a selected top-level object az objektum nem kijelölt legfelső szintű objektum: {id}
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) a VectorCraft által még nem olvasható ecsetek kimaradtak az Ecsetek panelről ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet a több kitöltésű vagy körvonalú, effektusos vagy ecsetes objektumok a megrajzolt kinézetükkel kerültek be ({_1}): a megjelenésük még nem szerkeszthető
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet a több kitöltésű vagy körvonalú, változó szélességű körvonalú, effektusos vagy ecsetes objektumok a megrajzolt kinézetükkel kerültek be ({_1}): a megjelenésük még nem szerkeszthető
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding a(z) `{name}` név nem UTF-8, ezért {label} kódolással lett beolvasva: ha hibásnak tűnik, nyissa meg a fájlt másik legacyNameEncoding értékkel
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding a(z) `{name}` név nem UTF-8, és egyes részei {label} kódolásban sem érvényesek: nyissa meg a fájlt másik legacyNameEncoding értékkel
@msg more names that aren't UTF-8 were read as {label} ({count}) további nem UTF-8 nevek lettek {label} kódolással beolvasva ({count})
@@ -6642,3 +6642,4 @@ breakText Glyphs Glifák
# Align to Pixel Grid
Align to Pixel Grid Igazítás a pixelrácshoz
@msg only paths and compound paths can be aligned to the pixel grid csak útvonalak és összetett útvonalak igazíthatók a pixelrácshoz
@msg side is left or right, not `{s}` a side értéke left vagy right lehet, nem `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -1204,7 +1204,7 @@ lighting Light Cahaya
X: X:
Y: Y:
Drag to float the toolbar Seret untuk mengambangkan bilah alat
Drag to the window's left edge to dock Seret ke tepi kiri jendela untuk memasang
Drag to the window's left or right edge to dock Seret ke tepi kiri atau kanan jendela untuk memasang
Crystallize Kristalisasi
Add Anchor Point Tool Alat tambah titik jangkar
Anchor Point Tool Alat titik jangkar
@@ -4706,7 +4706,7 @@ imageTrace Outline Kontur
@msg its objects are nested too deeply objeknya bersarang terlalu dalam
@msg its section ending with `%{end}` doesn't end bagian yang berakhir dengan `%{end}` tidak berakhir
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) kuas yang belum dapat dibaca VectorCraft tidak dimasukkan ke panel Kuas ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objek dengan beberapa isian atau goresan, efek, atau kuas masuk sebagai tampilan gambarnya ({_1}): penampilannya belum dapat diedit
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objek dengan beberapa isian atau goresan, goresan dengan lebar bervariasi, efek, atau kuas masuk sebagai tampilan gambarnya ({_1}): penampilannya belum dapat diedit
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding nama `{name}` bukan UTF-8 dan dibaca sebagai {label}: jika terlihat salah, buka file dengan legacyNameEncoding lain
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding nama `{name}` bukan UTF-8, dan sebagiannya juga bukan {label} yang valid: buka file dengan legacyNameEncoding lain
@msg more names that aren't UTF-8 were read as {label} ({count}) nama lain yang bukan UTF-8 dibaca sebagai {label} ({count})
@@ -6440,3 +6440,4 @@ breakText Glyphs Glif
# Align to Pixel Grid
Align to Pixel Grid Sejajarkan ke grid piksel
@msg side is left or right, not `{s}` side adalah left atau right, bukan `{s}`
Can't render this file because it contains an unexpected character in line 2306 and column 5.
+3 -2
View File
@@ -4064,7 +4064,7 @@ axis Both Entrambi
Dock to Bottom Ancora in basso
Dock Control Bar Ancora barra di controllo
Drag to float the toolbar Trascina per rendere mobile la barra degli strumenti
Drag to the window's left edge to dock Trascina sul bordo sinistro della finestra per ancorare
Drag to the window's left or right edge to dock Trascina sul bordo sinistro o destro della finestra per ancorare
Float Tool Group Rendi mobile gruppo strumenti
# Status and error messages (`@msg`), translated where the status bar shows them; the message itself
@@ -4823,7 +4823,7 @@ axis Both Entrambi
@msg its objects are nested too deeply i loro oggetti sono annidati troppo in profondità
@msg its section ending with `%{end}` doesn't end la loro sezione che termina con `%{end}` non termina
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) i pennelli che VectorCraft non sa ancora leggere sono stati esclusi dal pannello Pennelli ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet gli oggetti con più riempimenti o tratti, effetti o pennelli sono stati importati come appaiono disegnati ({_1}): il loro aspetto non è ancora modificabile
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet gli oggetti con più riempimenti o tratti, tratti a larghezza variabile, effetti o pennelli sono stati importati come appaiono disegnati ({_1}): il loro aspetto non è ancora modificabile
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding il nome `{name}` non è in UTF-8 ed è stato letto come {label}: se sembra errato, apri il file con un altro legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding il nome `{name}` non è in UTF-8 e alcune sue parti non sono valide nemmeno in {label}: apri il file con un altro legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) altri nomi non in UTF-8 sono stati letti come {label} ({count})
@@ -6279,3 +6279,4 @@ breakText Glyphs Glifi
# Align to Pixel Grid
Align to Pixel Grid Allinea alla griglia pixel
@msg only paths and compound paths can be aligned to the pixel grid solo i tracciati e i tracciati composti possono essere allineati alla griglia pixel
@msg side is left or right, not `{s}` side è left o right, non `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -4006,7 +4006,7 @@ axis Both 両方
Dock to Bottom 下部にドッキング
Dock Control Bar コントロールバーをドッキング
Drag to float the toolbar ドラッグしてツールバーをフローティング
Drag to the window's left edge to dock ウィンドウの左端までドラッグしてドッキング
Drag to the window's left or right edge to dock ウィンドウの左端または右端までドラッグしてドッキング
Float Tool Group ツールグループをフローティング
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
@@ -4514,7 +4514,7 @@ swatch Bright Violet ビビッドバイオレット
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 表示に使われるフォントの 1 行に対して低すぎるフレーム内のエリア内文字は、フレームを高くしました ({_1}): そうしないと文字が表示されません
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it グラデーション「{name}」はファイル内で定義されていません: 代わりに単色を使用しています
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) VectorCraft がまだ読み込めないブラシ {_1} 個はブラシパネルに追加されませんでした
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 塗りや線が複数ある、または効果やブラシを適用したオブジェクト {_1} 個は見た目のまま読み込まれました: アピアランスはまだ編集できません
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 塗りや線が複数ある、可変幅の線がある、または効果やブラシを適用したオブジェクト {_1} 個は見た目のまま読み込まれました: アピアランスはまだ編集できません
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 名前 `{name}` は UTF-8 ではないため {label} として読み込みました: 正しくない場合は別の legacyNameEncoding でファイルを開いてください
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 名前 `{name}` は UTF-8 ではなく、一部は {label} としても無効です: 別の legacyNameEncoding でファイルを開いてください
@msg more names that aren't UTF-8 were read as {label} ({count}) UTF-8 ではないほかの名前を {label} として読み込みました ({count})
@@ -6344,3 +6344,4 @@ breakText Glyphs 字形
# Align to Pixel Grid
Align to Pixel Grid ピクセルグリッドに整合
@msg only paths and compound paths can be aligned to the pixel grid ピクセルグリッドに整合できるのはパスと複合パスのみです
@msg side is left or right, not `{s}` side は left または right です(`{s}` ではありません)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -1144,7 +1144,7 @@ lighting Light სინათლე
Toggle single/double column ერთი/ორი სვეტის გადართვა
W: სიგ.:
Drag to float the toolbar გადაათრიეთ ხელსაწყოთა ზოლის მცურავად გადასაქცევად
Drag to the window's left edge to dock მისამაგრებლად გადაათრიეთ ფანჯრის მარცხენა კიდესთან
Drag to the window's left or right edge to dock მისამაგრებლად გადაათრიეთ ფანჯრის მარცხენა ან მარჯვენა კიდესთან
Crystallize კრისტალიზაცია
Add Anchor Point Tool საყრდენი წერტილის დამატების ხელსაწყო
Anchor Point Tool საყრდენი წერტილის ხელსაწყო
@@ -4515,7 +4515,7 @@ font-technology Variable ცვლადი
@msg its objects are nested too deeply მისი ობიექტები ზედმეტად ღრმადაა ჩადგმული
@msg its section ending with `%{end}` doesn't end მისი სექცია, რომელიც `%{end}`-ით მთავრდება, არ სრულდება
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) ფუნჯები, რომლებსაც VectorCraft ჯერ ვერ კითხულობს, ფუნჯების პანელში არ დაემატა ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet რამდენიმე შევსების ან კონტურის, ეფექტის ან ფუნჯის მქონე ობიექტები შემოვიდა დახატული სახით ({_1}): მათი იერსახე ჯერ არ რედაქტირდება
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet რამდენიმე შევსების ან კონტურის, ცვლადი სისქის კონტურის, ეფექტის ან ფუნჯის მქონე ობიექტები შემოვიდა დახატული სახით ({_1}): მათი იერსახე ჯერ არ რედაქტირდება
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding სახელი `{name}` არ არის UTF-8 და წაკითხულია როგორც {label}: თუ არასწორად გამოიყურება, გახსენით ფაილი სხვა legacyNameEncoding-ით
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding სახელი `{name}` არ არის UTF-8 და მისი ნაწილები არც {label}-ში არის სწორი: გახსენით ფაილი სხვა legacyNameEncoding-ით
@msg more names that aren't UTF-8 were read as {label} ({count}) სხვა სახელები, რომლებიც არ არის UTF-8, წაკითხულია როგორც {label} ({count})
@@ -6719,3 +6719,4 @@ font-script Japanese იაპონური
# Align to Pixel Grid
Align to Pixel Grid პიქსელურ ბადეზე გასწორება
@msg only paths and compound paths can be aligned to the pixel grid პიქსელურ ბადეზე მხოლოდ ტრაექტორიების და შედგენილი ტრაექტორიების გასწორებაა შესაძლებელი
@msg side is left or right, not `{s}` side არის left ან right და არა `{s}`
Can't render this file because it is too large.
+3 -2
View File
@@ -3990,7 +3990,7 @@ axis Both 양쪽
Dock to Bottom 아래쪽에 고정
Dock Control Bar 컨트롤 막대 고정
Drag to float the toolbar 드래그하여 도구 모음 분리
Drag to the window's left edge to dock 창 왼쪽 가장자리로 드래그하여 고정
Drag to the window's left or right edge to dock 창 왼쪽 또는 오른쪽 가장자리로 드래그하여 고정
Float Tool Group 도구 그룹 분리
Services 서비스
Hide VectorCraft VectorCraft 숨기기
@@ -4441,7 +4441,7 @@ theme Appearance 외관
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 표시되는 글꼴의 한 줄보다 낮은 프레임의 영역 문자는 프레임을 더 높였습니다({_1}). 그렇지 않으면 문자가 표시되지 않습니다
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 그레이디언트 “{name}”이(가) 파일에 정의되어 있지 않아 단색으로 대체했습니다
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) VectorCraft에서 아직 읽을 수 없는 브러시는 브러시 패널에서 제외되었습니다({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 칠이나 선이 여러 개인 개체, 효과, 브러시가 있는 개체는 그려진 모양({_1})으로 가져왔습니다: 모양새는 아직 편집할 수 없습니다
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 칠이나 선이 여러 개인 개체, 폭이 변하는 선, 효과, 브러시가 있는 개체는 그려진 모양({_1})으로 가져왔습니다: 모양새는 아직 편집할 수 없습니다
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 이름 `{name}`은(는) UTF-8이 아니어서 {label}(으)로 읽었습니다: 잘못 보이면 다른 legacyNameEncoding으로 파일을 여세요
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 이름 `{name}`은(는) UTF-8이 아니고 일부는 {label}에서도 올바르지 않습니다: 다른 legacyNameEncoding으로 파일을 여세요
@msg more names that aren't UTF-8 were read as {label} ({count}) UTF-8이 아닌 다른 이름도 {label}(으)로 읽었습니다({count})
@@ -6240,3 +6240,4 @@ breakText Glyphs 글리프
# Align to Pixel Grid
Align to Pixel Grid 픽셀 격자에 정렬
@msg only paths and compound paths can be aligned to the pixel grid 패스와 컴파운드 패스만 픽셀 격자에 정렬할 수 있습니다
@msg side is left or right, not `{s}` side는 left 또는 right입니다(`{s}` 아님)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -1218,7 +1218,7 @@ lighting Light Licht
X: X:
Y: Y:
Drag to float the toolbar Sleep om de werkbalk zwevend te maken
Drag to the window's left edge to dock Sleep naar de linkerrand van het venster om te dokken
Drag to the window's left or right edge to dock Sleep naar de linker- of rechterrand van het venster om te dokken
Crystallize Kristalliseren
Add Anchor Point Tool Ankerpunt toevoegen
Anchor Point Tool Ankerpunt-gereedschap
@@ -4718,7 +4718,7 @@ imageTrace Outline Contour
@msg its objects are nested too deeply de objecten ervan zijn te diep genest
@msg its section ending with `%{end}` doesn't end zijn sectie die op `%{end}` eindigt, eindigt niet
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) penselen die VectorCraft nog niet kan lezen, zijn weggelaten uit het deelvenster Penselen ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objecten met meerdere vullingen of lijnen, effecten of penselen zijn als hun getekende uiterlijk binnengekomen ({_1}): hun vormgeving is nog niet bewerkbaar
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objecten met meerdere vullingen of lijnen, lijnen met variabele breedte, effecten of penselen zijn als hun getekende uiterlijk binnengekomen ({_1}): hun vormgeving is nog niet bewerkbaar
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding de naam `{name}` is geen UTF-8 en is gelezen als {label}: als die er verkeerd uitziet, open het bestand dan met een andere legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding de naam `{name}` is geen UTF-8, en delen ervan zijn ook geen geldige {label}: open het bestand met een andere legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) meer namen die geen UTF-8 zijn, zijn gelezen als {label} ({count})
@@ -6491,3 +6491,4 @@ breakText Glyphs Glyphs
# Align to Pixel Grid
Align to Pixel Grid Uitlijnen op pixelraster
@msg side is left or right, not `{s}` side is left of right, niet `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -1217,7 +1217,7 @@ lighting Light Światło
Toggle single/double column Przełącz jedną/dwie kolumny
W: S:
Drag to float the toolbar Przeciągnij, aby odpiąć pasek narzędzi
Drag to the window's left edge to dock Przeciągnij do lewej krawędzi okna, aby zadokować
Drag to the window's left or right edge to dock Przeciągnij do lewej lub prawej krawędzi okna, aby zadokować
Crystallize Krystalizacja
Add Anchor Point Tool Narzędzie Dodaj punkt kontrolny
Anchor Point Tool Narzędzie Punkt kontrolny
@@ -4915,7 +4915,7 @@ imageTrace Outline Kontur
@msg its objects are nested too deeply jego obiekty są zagnieżdżone zbyt głęboko
@msg its section ending with `%{end}` doesn't end jego sekcja kończąca się na `%{end}` nie ma końca
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) pędzle, których VectorCraft jeszcze nie odczytuje, pominięto w panelu Pędzle ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet obiekty z kilkoma wypełnieniami lub obrysami, efektami albo pędzlami wczytano jako ich narysowany wygląd ({_1}): ich wyglądu nie można jeszcze edytować
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet obiekty z kilkoma wypełnieniami lub obrysami, obrysami o zmiennej szerokości, efektami albo pędzlami wczytano jako ich narysowany wygląd ({_1}): ich wyglądu nie można jeszcze edytować
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding nazwa `{name}` nie jest w UTF-8 i została odczytana jako {label}: jeśli wygląda źle, otwórz plik z innym legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding nazwa `{name}` nie jest w UTF-8, a jej części nie są poprawne również w {label}: otwórz plik z innym legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) kolejne nazwy spoza UTF-8 odczytano jako {label} ({count})
@@ -6514,3 +6514,4 @@ breakText Glyphs Glify
# Align to Pixel Grid
Align to Pixel Grid Wyrównaj do siatki pikseli
@msg only paths and compound paths can be aligned to the pixel grid do siatki pikseli można wyrównać tylko ścieżki i ścieżki złożone
@msg side is left or right, not `{s}` side to left lub right, a nie `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -3610,7 +3610,7 @@ axis Horizontal Horizontal
axis Vertical Vertical
axis Both Ambos
Drag to float the toolbar Arraste para flutuar a barra de ferramentas
Drag to the window's left edge to dock Arraste até a borda esquerda da janela para encaixar
Drag to the window's left or right edge to dock Arraste até a borda esquerda ou direita da janela para encaixar
@msg no library `{id}` nenhuma biblioteca `{id}`
@msg select some type first selecione algum texto primeiro
@msg kerning must be a number, "auto", or "optical" O kerning deve ser um número, “auto” ou “optical”
@@ -4350,7 +4350,7 @@ axis Both Ambos
@msg its objects are nested too deeply seus objetos estão aninhados com profundidade excessiva
@msg its section ending with `%{end}` doesn't end sua seção que termina com `%{end}` não termina
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) os pincéis que o VectorCraft ainda não consegue ler ficaram fora do painel Pincéis ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objetos com vários preenchimentos ou traços, efeitos ou pincéis entraram com a aparência desenhada ({_1}): sua aparência ainda não é editável
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objetos com vários preenchimentos ou traços, traços de largura variável, efeitos ou pincéis entraram com a aparência desenhada ({_1}): sua aparência ainda não é editável
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding o nome `{name}` não está em UTF-8 e foi lido como {label}: se parecer errado, abra o arquivo com outro legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding o nome `{name}` não está em UTF-8, e partes dele também não são {label} válido: abra o arquivo com outro legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) mais nomes que não estão em UTF-8 foram lidos como {label} ({count})
@@ -6731,3 +6731,4 @@ swatch Yellow Green Verde-amarelado
# Align to Pixel Grid
Align to Pixel Grid Alinhar à Grade de Pixels
@msg only paths and compound paths can be aligned to the pixel grid somente caminhos e caminhos compostos podem ser alinhados à grade de pixels
@msg side is left or right, not `{s}` side é left ou right, não `{s}`
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -2
View File
@@ -4058,7 +4058,7 @@ axis Both Оба
Dock to Bottom Прикрепить снизу
Dock Control Bar Прикрепить панель управления
Drag to float the toolbar Перетащите, чтобы сделать панель инструментов плавающей
Drag to the window's left edge to dock Перетащите к левому краю окна для прикрепления
Drag to the window's left or right edge to dock Перетащите к левому или правому краю окна для прикрепления
Float Tool Group Сделать группу инструментов плавающей
# Status and error messages (`@msg`), translated where the status bar shows them; the message itself
@@ -4817,7 +4817,7 @@ axis Both Оба
@msg its objects are nested too deeply объекты в них вложены слишком глубоко
@msg its section ending with `%{end}` doesn't end раздел, заканчивающийся на `%{end}`, не заканчивается
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) кисти, которые VectorCraft пока не умеет читать, не добавлены в панель «Кисти» ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet объекты с несколькими заливками или обводками, эффектами или кистями импортированы в нарисованном виде ({_1}): их оформление пока нельзя редактировать
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet объекты с несколькими заливками или обводками, обводками переменной ширины, эффектами или кистями импортированы в нарисованном виде ({_1}): их оформление пока нельзя редактировать
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding имя `{name}` не в UTF-8 и прочитано как {label}: если оно выглядит неверно, откройте файл с другим legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding имя `{name}` не в UTF-8, и его части недопустимы и в {label}: откройте файл с другим legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) другие имена не в UTF-8 прочитаны как {label} ({count})
@@ -6747,3 +6747,4 @@ shape Open Открытый
# Align to Pixel Grid
Align to Pixel Grid Выровнять по пиксельной сетке
@msg only paths and compound paths can be aligned to the pixel grid по пиксельной сетке можно выровнять только контуры и составные контуры
@msg side is left or right, not `{s}` side — это left или right, а не `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -3938,7 +3938,7 @@ axis Both Båda
Dock to Bottom Docka nederst
Dock Control Bar Docka kontrollfältet
Drag to float the toolbar Dra för att göra verktygsfältet flytande
Drag to the window's left edge to dock Dra till fönstrets vänsterkant för att docka
Drag to the window's left or right edge to dock Dra till fönstrets vänster- eller högerkant för att docka
Float Tool Group Gör verktygsgruppen flytande
@msg no library `{id}` inget bibliotek `{id}`
@msg select some type first markera någon text först
@@ -4679,7 +4679,7 @@ axis Both Båda
@msg its objects are nested too deeply dess objekt är för djupt kapslade
@msg its section ending with `%{end}` doesn't end dess avsnitt som slutar med `%{end}` avslutas aldrig
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) penslar som VectorCraft ännu inte kan läsa utelämnades från panelen Penslar ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekt med flera fyllningar eller konturer, effekter eller penslar importerades som sitt ritade utseende ({_1}): deras utseende går inte att redigera än
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekt med flera fyllningar eller konturer, konturer med variabel bredd, effekter eller penslar importerades som sitt ritade utseende ({_1}): deras utseende går inte att redigera än
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding namnet `{name}` är inte UTF-8 och lästes som {label}: om det ser fel ut, öppna filen med en annan legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding namnet `{name}` är inte UTF-8, och delar av det är inte heller giltig {label}: öppna filen med en annan legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) fler namn som inte är UTF-8 lästes som {label} ({count})
@@ -8143,3 +8143,4 @@ digits Hindi Hindi
# Align to Pixel Grid
Align to Pixel Grid Justera mot pixelrutnät
@msg only paths and compound paths can be aligned to the pixel grid bara banor och sammansatta banor kan justeras mot pixelrutnätet
@msg side is left or right, not `{s}` side är left eller right, inte `{s}`
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3 -2
View File
@@ -3824,7 +3824,7 @@ axis Both Her İkisi
Dock to Bottom Alta yerleştir
Dock Control Bar Kontrol çubuğunu yerleştir
Drag to float the toolbar Araç çubuğunu ayırmak için sürükleyin
Drag to the window's left edge to dock Sabitlemek için pencerenin sol kenarına sürükleyin
Drag to the window's left or right edge to dock Sabitlemek için pencerenin sol veya sağ kenarına sürükleyin
Float Tool Group Araç Grubunu Yüzer Yap
@msg no library `{id}` `{id}` kütüphanesi yok
@msg select some type first önce bir tür seçin
@@ -4516,7 +4516,7 @@ axis Both Her İkisi
@msg its objects are nested too deeply nesneleri çok derin bir şekilde iç içe geçmiş
@msg its section ending with `%{end}` doesn't end `%{end}` ile biten bölümü bitmiyor
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) VectorCraft'ın henüz okuyamadığı fırçalar Fırçalar panelinin dışında bırakıldı ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet çeşitli dolgulara veya konturlara, efektlere veya fırçalara sahip nesneler çizilmiş görünümleri olarak geldi ({_1}): görünümleri henüz düzenlenemez
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet çeşitli dolgulara veya konturlara, değişken genişlikli konturlara, efektlere veya fırçalara sahip nesneler çizilmiş görünümleri olarak geldi ({_1}): görünümleri henüz düzenlenemez
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding `{name}` adı UTF-8 değil ve {label} olarak okundu: yanlış görünüyorsa dosyayı başka bir legacyNameEncoding ile açın
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding `{name}` adı UTF-8 değil ve bazı bölümleri {label} olarak da geçerli değil: dosyayı başka bir legacyNameEncoding ile açın
@msg more names that aren't UTF-8 were read as {label} ({count}) UTF-8 olmayan diğer adlar {label} olarak okundu ({count})
@@ -6614,3 +6614,4 @@ breakText Glyphs Glifler
# Align to Pixel Grid
Align to Pixel Grid Piksel Izgarasına Hizala
@msg only paths and compound paths can be aligned to the pixel grid piksel ızgarasına yalnızca yollar ve bileşik yollar hizalanabilir
@msg side is left or right, not `{s}` side left veya right olmalıdır, `{s}` değil
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3 -2
View File
@@ -4053,7 +4053,7 @@ axis Both Обидві
Dock to Bottom Прикріпити внизу
Dock Control Bar Прикріпити панель керування
Drag to float the toolbar Перетягніть, щоб від’єднати панель інструментів
Drag to the window's left edge to dock Перетягніть до лівого краю вікна для прикріплення
Drag to the window's left or right edge to dock Перетягніть до лівого або правого краю вікна для прикріплення
Float Tool Group Від’єднати групу інструментів
@msg no library `{id}` немає бібліотеки `{id}`
@msg select some type first спочатку виділіть текст
@@ -4807,7 +4807,7 @@ axis Both Обидві
@msg its objects are nested too deeply об’єкти в них вкладені надто глибоко
@msg its section ending with `%{end}` doesn't end розділ, що закінчується на `%{end}`, не закінчується
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) пензлі, які VectorCraft поки не вміє читати, не додано до панелі «Пензлі» ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet об’єкти з кількома заливками або обведеннями, ефектами або пензлями імпортовані в намальованому вигляді ({_1}): їх оформлення поки що не можна редагувати
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet об’єкти з кількома заливками або обведеннями, обведеннями змінної ширини, ефектами або пензлями імпортовані в намальованому вигляді ({_1}): їх оформлення поки що не можна редагувати
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding назва `{name}` не в UTF-8 і прочитана як {label}: якщо вона виглядає неправильно, відкрийте файл з іншим legacyNameEncoding
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding назва `{name}` не в UTF-8, і її частини недійсні також у {label}: відкрийте файл з іншим legacyNameEncoding
@msg more names that aren't UTF-8 were read as {label} ({count}) інші назви не в UTF-8 прочитано як {label} ({count})
@@ -6259,3 +6259,4 @@ breakText Glyphs Гліфи
# Align to Pixel Grid
Align to Pixel Grid Вирівняти за піксельною сіткою
@msg only paths and compound paths can be aligned to the pixel grid за піксельною сіткою можна вирівняти лише контури і складені контури
@msg side is left or right, not `{s}` side — це left або right, а не `{s}`
Can't render this file because it contains an unexpected character in line 182 and column 5.
+3 -2
View File
@@ -1045,7 +1045,7 @@ font-technology Variable Biến thiên
Drag to float as a toolbar Kéo để thả nổi thanh công cụ
Drag to float the toolbar Kéo để thả nổi thanh công cụ
Drag to move Kéo để di chuyển
Drag to the window's left edge to dock Kéo đến cạnh trái cửa sổ để neo
Drag to the window's left or right edge to dock Kéo đến cạnh trái hoặc phải cửa sổ để neo
Step up or down Tăng hoặc giảm một bước
Drag values or labels. Double-click a value to type. Kéo giá trị hoặc nhãn. Bấm đúp vào giá trị để nhập.
Draw Vẽ
@@ -4824,7 +4824,7 @@ theme Appearance Giao diện
@msg object {id} is not a path đối tượng {id} không phải là đường dẫn
@msg object {id} is not a selected top-level object đối tượng {id} không phải là đối tượng cấp cao nhất đã chọn
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) các cọ mà VectorCraft chưa đọc được đã bị bỏ khỏi bảng Cọ ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet đối tượng có nhiều tô hoặc nét, hiệu ứng hoặc cọ đã vào dưới dạng bề ngoài ({_1}): diện mạo chưa thể chỉnh sửa
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet đối tượng có nhiều tô hoặc nét, nét có độ rộng thay đổi, hiệu ứng hoặc cọ đã vào dưới dạng bề ngoài ({_1}): diện mạo chưa thể chỉnh sửa
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding tên `{name}` không phải UTF-8 và đã được đọc theo {label}: nếu trông sai, hãy mở tệp với legacyNameEncoding khác
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding tên `{name}` không phải UTF-8, và một số phần cũng không hợp lệ trong {label}: hãy mở tệp với legacyNameEncoding khác
@msg more names that aren't UTF-8 were read as {label} ({count}) thêm các tên không phải UTF-8 đã được đọc theo {label} ({count})
@@ -6260,3 +6260,4 @@ breakText Glyphs Glyph
# Align to Pixel Grid
Align to Pixel Grid Căn theo lưới pixel
@msg only paths and compound paths can be aligned to the pixel grid chỉ đường dẫn và đường dẫn phức mới căn theo lưới pixel được
@msg side is left or right, not `{s}` side là left hoặc right, không phải `{s}`
Can't render this file because it contains an unexpected character in line 204 and column 5.
+3 -2
View File
@@ -4227,7 +4227,7 @@ imageTrace Outline 轮廓
@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers 为保留图层,{_1} × {_2} 像素的嵌入图像已缩小为 {_3} × {_4} 像素
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 文件中未定义渐变“{name}”:已改用纯色代替
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) VectorCraft 尚无法读取的画笔未加入“画笔”面板 ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 带有多个填色或描边、效果或画笔的对象已按其绘制外观导入 ({_1}):其外观尚不可编辑
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 带有多个填色或描边、可变宽度描边、效果或画笔的对象已按其绘制外观导入 ({_1}):其外观尚不可编辑
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 名称 `{name}` 不是 UTF-8,已按 {label} 读取:如果显示不正确,请使用其他 legacyNameEncoding 打开文件
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 名称 `{name}` 不是 UTF-8,且部分内容在 {label} 中也无效:请使用其他 legacyNameEncoding 打开文件
@msg more names that aren't UTF-8 were read as {label} ({count}) 其他非 UTF-8 名称已按 {label} 读取 ({count})
@@ -5146,7 +5146,7 @@ axis Both 两者
Dock to Bottom 停放到底部
Dock Control Bar 停放控制栏
Drag to float the toolbar 拖动以浮动工具栏
Drag to the window's left edge to dock 拖到窗口左边缘以停放
Drag to the window's left or right edge to dock 拖到窗口左边缘或右边缘以停放
Float Tool Group 浮动工具组
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
@@ -6499,3 +6499,4 @@ breakText Glyphs 字形
# Align to Pixel Grid
Align to Pixel Grid 对齐像素网格
@msg only paths and compound paths can be aligned to the pixel grid 只有路径和复合路径可以对齐像素网格
@msg side is left or right, not `{s}` side 应为 left 或 right,而不是 `{s}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -2
View File
@@ -2068,7 +2068,7 @@ file-filter Templates 範本
@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers 為保留圖層,{_1} × {_2} 像素的內嵌影像已縮小為 {_3} × {_4} 像素
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 檔案中未定義漸層「{name}」:已改用單色替代
@msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) VectorCraft 尚無法讀取的筆刷未加入「筆刷」面板 ({_1})
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 具有多個填色或筆畫、效果或筆刷的物件已依其繪製外觀匯入 ({_1}):其外觀目前尚無法編輯
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 具有多個填色或筆畫、可變寬度筆畫、效果或筆刷的物件已依其繪製外觀匯入 ({_1}):其外觀目前尚無法編輯
@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 名稱 `{name}` 不是 UTF-8,已依 {label} 讀取:若顯示不正確,請以其他 legacyNameEncoding 開啟檔案
@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 名稱 `{name}` 不是 UTF-8,且部分內容在 {label} 中也無效:請以其他 legacyNameEncoding 開啟檔案
@msg more names that aren't UTF-8 were read as {label} ({count}) 其他非 UTF-8 名稱已依 {label} 讀取 ({count})
@@ -4969,7 +4969,7 @@ axis Both 兩者
Dock to Bottom 停駐到底部
Dock Control Bar 停駐控制列
Drag to float the toolbar 拖曳以浮動工具列
Drag to the window's left edge to dock 拖曳到視窗左側邊緣以停駐
Drag to the window's left or right edge to dock 拖曳到視窗左側或右側邊緣以停駐
Float Tool Group 浮動工具組
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
@@ -6297,3 +6297,4 @@ breakText Glyphs 字符
# Align to Pixel Grid
Align to Pixel Grid 對齊像素格點
@msg only paths and compound paths can be aligned to the pixel grid 只有路徑和複合路徑可以對齊像素格點
@msg side is left or right, not `{s}` side 應為 left 或 right,而不是 `{s}`
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -33,6 +33,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
// encoder (an exporter plug-in is told the file it writes).
let params = &if params.is_object() { params.clone() } else { json!({}) };
let f = fileio::writable_format(format, path.as_deref())?;
// A Shape Builder merge still being computed for a click is exported with the document.
let r = app.session.finish_tool_edit();
crate::canvas::apply_requests(app, r);
// A `.ai` file names the view's active artboard.
remember_active_artboard(app);
let st = app.session.active().ok_or("no document")?;
+4 -1
View File
@@ -94,7 +94,7 @@ use tests::{
mod tests;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
@@ -161,6 +161,9 @@ pub struct VectorcraftApp {
synthetic_dropped_before: u64,
/// Window captures asked for and not answered yet.
pending_screenshots: Vec<PendingScreenshot>,
/// `ui.pointer` replies waiting for their events and the edit the tool computes on another
/// thread ([`control::Outcome::AfterToolEdit`]), with the numbers of those events.
pointer_replies: Vec<control::PendingPointerReply>,
/// Admitted captures whose settled frame has not come yet (issued one at a time).
queued_screenshots: Vec<QueuedScreenshot>,
screenshot_token: u64,
+5
View File
@@ -138,6 +138,11 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
if waits_for_ime(app, id) {
return;
}
// A command chosen while the tool computes an edit on another thread (a Shape Builder merge)
// comes after it: the edit is applied first, so the command's dialog, or the app's own dialog
// (Recolor Artwork), opens on the art as the gesture left it.
let r = app.session.finish_tool_edit();
crate::canvas::apply_requests(app, r);
// A shortcut or a palette pick on a filter plug-in does nothing while its menu item is dimmed.
if id == "plugin.dialog" && !enabled(app, id) {
return;
+15 -10
View File
@@ -820,16 +820,21 @@ fn run_ui_part10(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result
"ui.svgFilterDialog" => crate::dialogs::svg_filter::open(app, p),
"ui.importSvgFilter" => crate::dialogs::svg_filter::import_file(app),
"ui.spotColors" => crate::dialogs::spot_colors::open(app),
"ui.menuDialog" => match s("command").as_deref() {
Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c),
c => match c.and_then(|c| menu_dialog(app, c)) {
Some((kind, fields)) => {
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
Ok(Value::Null)
}
None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()),
},
},
"ui.menuDialog" => {
// As from the menu ([`invoke`]), the edit the tool computes on another thread first.
let r = app.session.finish_tool_edit();
crate::canvas::apply_requests(app, r);
match s("command").as_deref() {
Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c),
c => match c.and_then(|c| menu_dialog(app, c)) {
Some((kind, fields)) => {
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
Ok(Value::Null)
}
None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()),
},
}
}
"ui.widthPointEdit" => crate::dialogs::width_point::open(app, p),
"ui.corners" => crate::dialogs::corners::open(app, p),
"ui.hyphenation" => crate::dialogs::hyphenation::open(app),
+1 -1
View File
@@ -615,7 +615,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"window.panel.dock",
"Dock Panel",
"",
"{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window, or beside the canvas inside any left-side dock while window.toolsBesideCanvas is on. Returns {layout, closed, floating}",
"{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window, or beside the canvas inside any left-side dock while window.toolsBesideCanvas is on; with side: right (or End) on the right, between the canvas and the panels, as dropping it there or on the window's right edge does (default: the side it docked at last; left or Start docks it on the left), returning {panel, floating, pos, side}. Returns {layout, closed, floating}",
),
(
"window.collapseDock",
+45 -5
View File
@@ -1418,13 +1418,53 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
crate::panels::panel_menu_items(self.app, ui, id);
}
fn group_header_width(&self, _panels: &[String], _active: &String, _floating: bool, _strip_width: f32) -> f32 {
28.0
fn group_header_width(&self, panels: &[String], _active: &String, floating: bool, _strip_width: f32) -> f32 {
let flyout = self.app.ui.open_panel.as_ref().is_some_and(|panel| panels.contains(panel));
if floating || flyout { 52.0 } else { 28.0 }
}
fn group_header(&mut self, ui: &mut egui::Ui, _panels: &[String], active: &String, _floating: bool) {
let rect = egui::Rect::from_center_size(ui.max_rect().center(), egui::vec2(16.0, 16.0));
crate::panels::panel_menu(self.app, ui, active, rect);
fn group_header(&mut self, ui: &mut egui::Ui, panels: &[String], active: &String, floating: bool) {
let header = ui.max_rect();
let flyout = self.app.ui.open_panel.clone().filter(|panel| panels.contains(panel));
let menu_cell = if floating || flyout.is_some() {
let split = (header.right() - 28.0).max(header.left());
let cell = egui::Rect::from_min_max(header.min, egui::pos2(split, header.bottom()));
if cell.is_positive() {
let rect = egui::Rect::from_center_size(cell.center(), egui::vec2(24.0_f32.min(cell.width()), 24.0_f32.min(cell.height())));
let tokens = crate::theme::Tokens::get(ui.ctx());
let response = ui.interact(rect, ui.scope_id().with(("collapse-to-icons", active)), egui::Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Collapse to Icons")));
let color = if response.hovered() || response.has_focus() { tokens.text_strong } else { tokens.text_dim };
ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).text(
rect.center(),
egui::Align2::CENTER_CENTER,
"»",
egui::TextStyle::Button.resolve(ui.style()),
color,
);
if response.on_hover_text(tl!("Collapse to Icons")).clicked() {
let result = if let Some(panel) = flyout {
// The opening icon can differ from the currently active tab.
self.app.run("window.panel", json!({"panel": panel}))
} else {
self.app.run("window.panel.dock", json!({"panel": active, "icon": true, "group": true}))
};
match result {
// Dock commands reveal their destination. This control leaves the group collapsed.
Ok(_) => self.app.ui.open_panel = None,
Err(error) => self.app.ui.status = error,
}
}
}
egui::Rect::from_min_max(egui::pos2(split, header.top()), header.max)
} else {
header
};
if menu_cell.is_positive() {
let rect =
egui::Rect::from_center_size(menu_cell.center(), egui::vec2(16.0_f32.min(menu_cell.width()), 16.0_f32.min(menu_cell.height())));
crate::panels::panel_menu(self.app, ui, active, rect);
}
}
}
@@ -25,6 +25,7 @@ struct Window {
frames: usize,
/// The texts its last frame drew, with their rects in the window.
texts: Vec<(String, Rect)>,
accessibility: Vec<egui::accesskit::Node>,
}
struct Host {
@@ -82,7 +83,14 @@ impl Host {
let mut host = host.borrow_mut();
let builder = &viewport.builder;
let rect = Rect::from_min_size(builder.position.unwrap_or_default(), builder.inner_size.unwrap_or(vec2(300.0, 200.0)));
host.windows.entry(id).or_insert_with(|| Window { rect, events: Vec::new(), dropped: Vec::new(), frames: 0, texts: Vec::new() });
host.windows.entry(id).or_insert_with(|| Window {
rect,
events: Vec::new(),
dropped: Vec::new(),
frames: 0,
texts: Vec::new(),
accessibility: Vec::new(),
});
let viewports = host.infos();
let (time, focused) = (host.time, host.focus == id);
let Some(window) = host.windows.get_mut(&id) else { return };
@@ -171,10 +179,13 @@ impl Host {
}
let mut shown = Vec::new();
full.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
let accessibility =
full.platform_output.accesskit_update.take().map(|update| update.nodes.into_iter().map(|(_, node)| node).collect()).unwrap_or_default();
let mut host = host.borrow_mut();
host.child_layer_order.insert(id, layer_order);
if let Some(window) = host.windows.get_mut(&id) {
window.texts = shown;
window.accessibility = accessibility;
}
host.apply(&full);
}
@@ -349,6 +360,22 @@ fn open_button_menu(h: &mut Harness, viewport: ViewportId, side: f32, rank: impl
menu
}
/// Click a named button, independent of other controls sharing its size or position.
fn open_named_button_menu(h: &mut Harness, viewport: ViewportId, name: &str) -> Pos2 {
let buttons: Vec<Rect> = h.host.borrow().windows[&viewport]
.accessibility
.iter()
.filter(|node| node.role() == egui::accesskit::Role::Button && node.label() == Some(name))
.filter_map(|node| node.bounds())
.map(|bounds| Rect::from_min_max(pos2(bounds.x0 as f32, bounds.y0 as f32), pos2(bounds.x1 as f32, bounds.y1 as f32)))
.collect();
assert_eq!(buttons.len(), 1, "one visible button named {name}");
let at = buttons[0].center();
h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(at), button(at, true), button(at, false)]);
h.frames(8);
at
}
/// A click on the ≡ button in the OS window `viewport`, the 16 pt square at the right end of the
/// panel's tab strip. Returns where the button is.
fn open_panel_menu(h: &mut Harness, viewport: ViewportId) -> Pos2 {
@@ -421,14 +448,18 @@ fn the_library_submenu_scrolls_inside_a_short_native_window() {
assert_submenu_reaches_its_end(&mut h, "swatches", 240.0, "Open Swatch Library", "Default Swatches", "Other Library…");
}
/// The Layers panel's Filter popup, under the topmost 24 pt button: the wheel brings its last row
/// The Layers panel's named Filter button opens its popup: the wheel brings its last row
/// into a short window.
#[test]
fn the_layers_filter_popup_scrolls_inside_a_short_native_window() {
let mut h = Harness::new();
h.ctx.enable_accesskit();
let viewport = h.float("layers");
resize_window(&mut h, viewport, 160.0);
open_button_menu(&mut h, viewport, 24.0, |at| -at.y);
// The header's collapse control is also 24 pt square and sits above Filter.
assert!(text_rect(&h, viewport, "»").is_some(), "keep the competing header control in this regression");
open_named_button_menu(&mut h, viewport, "Filter");
assert!(floating_window(&layout_of(&h.app.ui), "layers").is_some(), "Filter must not collapse the panel");
let over = text_rect(&h, viewport, "By Object").expect("the popup's By Object heading");
assert!(text_rect(&h, viewport, "Hidden").is_none_or(|end| end.bottom() > 160.0), "the popup opens at the top of its list");
wheel_down(&mut h, viewport, over.center(), 5000.0);
@@ -1542,3 +1573,31 @@ fn wheel_without_a_move_uses_the_floating_viewports_pointer() {
h.frame(vec![]);
assert_ne!(h.app.run("text.fonts", json!({})).unwrap(), before);
}
#[test]
fn floating_collapse_arrow_in_a_native_window_moves_the_group_to_the_rail() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap();
h.app.run("window.panel.activate", json!({"panel": "layers"})).unwrap();
h.frames(3);
let viewport = h.viewport("layers");
h.host.borrow_mut().focus = viewport;
let arrow = h.host.borrow().windows[&viewport]
.texts
.iter()
.find(|(text, _)| text == "»")
.map(|(_, rect)| rect.center())
.expect("the native floating header paints a collapse arrow");
for pressed in [true, false] {
h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(arrow), button(arrow, pressed)]);
h.frames(1);
}
h.frames(3);
let layout = layout_of(&h.app.ui);
assert!(floating_window(&layout, "layers").is_none());
assert_eq!(group_of(&layout, &"layers".into()), ["properties", "layers", "libraries"]);
assert!(["properties", "layers", "libraries"].iter().all(|p| in_iconic_column(&layout, p)));
let frames = h.host.borrow().windows[&viewport].frames;
h.frames(3);
assert_eq!(h.host.borrow().windows[&viewport].frames, frames, "the emptied palette is no longer rendered");
}
+154
View File
@@ -284,6 +284,45 @@ fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() {
assert_eq!(h.app.ui.toolbar_pos, None, "dropped on the left edge, it docks");
}
/// The Tools panel also docks on the right, left of the panels: dropped on the window's right edge
/// it stands between the canvas and the panels, its flyouts open toward the canvas, the side is
/// kept with the preferences, and it drags back to the left edge.
#[test]
fn the_tools_panel_docks_on_the_right_left_of_the_panels() {
let mut h = Harness::new();
let zone = docked_tools(&h);
h.drag(zone.min + vec2(20.0, 12.0), pos2(600.0, 400.0));
let [x, y] = h.app.ui.toolbar_pos.expect("the Tools panel floats");
h.drag(pos2(x + 20.0, y + 7.0), pos2(SCREEN.right() - 10.0, 500.0));
assert_eq!((h.app.ui.toolbar_pos, h.app.ui.toolbar_right), (None, true), "dropped on the right edge, it docks on the right");
settle(&mut h);
let bar = docked_tools(&h);
let canvas = h.app.canvas_rect.expect("the canvas");
let layers = h.tab("layers").expect("the Layers panel, docked right");
assert!(canvas.right() <= bar.left() + 1.0 && bar.right() <= layers.left() + 1.0, "canvas {canvas:?}, Tools {bar:?}, Layers {layers:?}");
assert!(SCREEN.right() - bar.right() > 100.0, "the panels keep the window's right edge: {bar:?}");
// Its flyouts and drawer open toward the canvas, not over the panels.
let room = crate::toolbar::room(&h.app, &h.ctx);
assert!(room.right() <= bar.right() + 0.5, "{room:?}");
let left = crate::toolbar::beside(bar, 160.0, 8.0, room);
assert!(left + 160.0 <= bar.left() - 7.0, "left of the toolbar: {left}");
let prefs: crate::state::UiState = serde_json::from_slice(&serde_json::to_vec(&h.app.ui).unwrap()).unwrap();
assert!(prefs.toolbar_right, "kept with the preferences");
// Dragged back to the window's left edge, it docks there again.
h.drag(bar.min + vec2(20.0, 12.0), pos2(600.0, 400.0));
let [x, y] = h.app.ui.toolbar_pos.expect("floats again");
h.drag(pos2(x + 20.0, y + 7.0), pos2(12.0, 500.0));
assert_eq!((h.app.ui.toolbar_pos, h.app.ui.toolbar_right), (None, false));
settle(&mut h);
assert!(docked_tools(&h).left() < 10.0, "at the window's left edge again");
// Agents: window.panel.dock takes the side (left/right, or Start/End as panels do).
let r = h.app.run("window.panel.dock", json!({"panel": "tools", "side": "End"})).unwrap();
assert_eq!((r["side"].as_str(), h.app.ui.toolbar_right), (Some("right"), true));
h.app.run("window.panel.float", json!({"panel": "tools"})).unwrap();
assert_eq!(h.app.run("window.panel.dock", json!({"panel": "tools"})).unwrap()["side"], "right", "back on the side it docked at last");
assert!(h.app.run("window.panel.dock", json!({"panel": "tools", "side": "top"})).is_err());
}
/// The docked Tools panel as last drawn.
fn docked_tools(h: &Harness) -> Rect {
h.ctx.data(|d| d.get_temp::<Rect>(crate::toolbar::docked_rect_id())).expect("the docked Tools panel")
@@ -997,3 +1036,118 @@ fn other_function_keys_and_keyboard_guards_keep_their_routes() {
assert_eq!(saved.session.doc().unwrap().doc.node_count(), 2, "F12 reloads the saved document");
std::fs::remove_dir_all(dir).unwrap();
}
fn collapse_to_icons_button(h: &Harness, panel: &str) -> Option<Rect> {
let panel = panel.to_string();
h.ctx.read_response(area_id().with(("header", &panel)).with(("collapse-to-icons", &panel))).map(|r| r.rect)
}
#[test]
fn floating_collapse_arrow_moves_the_whole_group_into_the_icon_rail() {
let mut h = Harness::new();
assert!(collapse_to_icons_button(&h, "properties").is_none(), "docked headers have no extra button");
h.app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap();
h.app.run("window.panel.activate", json!({"panel": "layers"})).unwrap();
for _ in 0..3 {
h.frame(vec![]);
}
let before = h.layout();
let members = group_of(&before, &"layers".into());
let columns = end_columns(&before).len();
let arrow = collapse_to_icons_button(&h, "layers").expect("floating header offers collapse to icons");
assert!(arrow.width() <= 24.0 && arrow.height() <= 24.0, "a compact header control: {arrow:?}");
h.click(arrow.center());
let layout = h.layout();
assert!(floating_window(&layout, "layers").is_none(), "the old floating window is removed");
assert_eq!(group_of(&layout, &"layers".into()), members, "all tabs move together");
assert!(members.iter().all(|p| in_iconic_column(&layout, p)));
assert_eq!(end_columns(&layout).len(), columns, "reuse the existing icon rail");
assert!(h.app.ui.open_panel.is_none(), "the group stays collapsed until its icon is clicked");
let icon = h.hit(&Hit::Icon("layers".into())).expect("Layers now has a rail icon");
h.click(icon.center());
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"));
assert!(h.tab("layers").is_some(), "the collapsed group can reopen as a flyout");
assert!(collapse_to_icons_button(&h, "layers").is_some(), "the reopened flyout also offers collapse to icons");
}
#[test]
fn floating_collapse_arrow_leaves_other_floating_groups_alone() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap();
h.app.run("window.panel.float", json!({"panel": "color", "below": "layers"})).unwrap();
for _ in 0..3 {
h.frame(vec![]);
}
let arrow = collapse_to_icons_button(&h, "layers").expect("floating group has its own arrow");
h.click(arrow.center());
let layout = h.layout();
assert!(in_iconic_column(&layout, "layers"));
assert!(floating_window(&layout, "color").is_some(), "another group in the same window stays floating");
assert!(!in_iconic_column(&layout, "color"));
assert_eq!(main_panels(&layout), ["properties", "libraries"]);
}
#[test]
fn floating_collapse_arrow_creates_a_rail_when_none_exists_and_keeps_the_menu() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap();
let mut layout = h.layout();
if let Some(Node::Split { second, .. }) = layout.root.as_mut()
&& let Node::Dock { columns, .. } = second.as_mut()
{
columns.retain(|column| !column.iconic);
}
assert!(icon_column(&end_columns(&layout)).is_none());
h.app.ui.docking = Some(layout);
for _ in 0..3 {
h.frame(vec![]);
}
let panel = "layers".to_string();
let menu =
h.ctx.read_response(area_id().with(("header", &panel)).with(("panel-menu", &panel))).expect("the existing panel menu remains available").rect;
let arrow = collapse_to_icons_button(&h, "layers").expect("floating group offers collapse");
assert!(!menu.intersects(arrow), "menu and arrow have separate hit targets");
h.click(menu.center());
assert!(egui::Popup::is_any_open(&h.ctx), "the existing menu still opens");
h.press(egui::Key::Escape, egui::Modifiers::NONE);
h.click(arrow.center());
let layout = h.layout();
assert!(in_iconic_column(&layout, "layers"));
assert!(floating_window(&layout, "layers").is_none());
assert!(h.app.ui.open_panel.is_none(), "collapse does not immediately open a flyout");
assert_eq!(main_panels(&layout), ["properties", "libraries"]);
}
#[test]
fn rail_flyout_collapse_arrow_folds_the_panel_back_without_moving_it() {
let mut h = Harness::new();
let icon = h.hit(&Hit::Icon("swatches".into())).unwrap();
h.click(icon.center());
let before = h.layout();
let arrow = collapse_to_icons_button(&h, "swatches").expect("opened rail panels offer collapse to icons");
h.click(arrow.center());
assert!(h.app.ui.open_panel.is_none());
assert!(craft_ui::docking::flyout::<String>(&h.ctx, area_id()).is_none());
assert_eq!(h.layout(), before, "folding a flyout leaves the dock layout intact");
let icon = h.hit(&Hit::Icon("swatches".into())).expect("the rail icon stays available");
h.click(icon.center());
assert_eq!(h.app.ui.open_panel.as_deref(), Some("swatches"));
assert!(h.tab("swatches").is_some());
}
#[test]
fn rail_flyout_collapse_arrow_closes_the_opening_icon_after_switching_tabs() {
let mut h = Harness::new();
h.app.ui.docking = Some(h.layout());
let icon = h.hit(&Hit::Icon("swatches".into())).unwrap();
h.click(icon.center());
let brushes = h.tab("brushes").unwrap();
h.click(brushes.center());
assert_eq!(h.app.ui.open_panel.as_deref(), Some("swatches"));
let before = h.layout();
let arrow = collapse_to_icons_button(&h, "brushes").expect("the active tab has a collapse arrow");
h.click(arrow.center());
assert!(h.app.ui.open_panel.is_none());
assert!(craft_ui::docking::flyout::<String>(&h.ctx, area_id()).is_none());
assert_eq!(h.layout(), before, "all tabs and their active state stay in the rail");
}
+4
View File
@@ -192,6 +192,10 @@ pub fn available(app: &VectorcraftApp) -> bool {
/// checked here first, so a bad one is an error at once. When no thread could start, the file is
/// placed at once → `file.place`'s reply.
pub fn start(app: &mut VectorcraftApp, p: &Value, dropped: bool) -> Result<Value, String> {
// The edit the tool computes on another thread comes first: the object Replace swaps is the
// one selected after it.
let r = app.session.finish_tool_edit();
crate::canvas::apply_requests(app, r);
let job = app.session.prepare_place(p).map_err(|e| e.to_string())?;
let name = job.name();
let started = spawn(app, name.clone(), name, dropped, move || job.read().map(|r| Read::Place(Box::new(r))).map_err(|e| e.to_string()))?;
+40
View File
@@ -1166,6 +1166,46 @@ fn a_replace_whose_object_was_deleted_meanwhile_places_nothing() {
assert!(state(&app) == cleared, "nothing placed");
}
/// A Replace started while a Shape Builder merge is computed on another thread swaps the object
/// as the merge leaves it: the merge is applied first.
#[test]
fn a_replace_during_a_merge_on_another_thread_swaps_the_merged_object() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
let path = saved("replace-merge", "photo.tif", &picture(Tiff, 60, 40));
let mut app = app(true);
for k in 0..40 {
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("object.group", json!({})).unwrap();
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
let view = app.view_info();
let at = |kind, x| PointerEvent {
kind,
pos: vectorcraft_geom::Point::new(x, 10.0),
mods: Mods::default(),
pressure: 1.0,
backquote: false,
pen: None,
};
crate::canvas::dispatch(&mut app, &at(PointerKind::Move, 5.0), view);
app.session.tool_poll(view, true).unwrap();
// A drag across the row merges every square into one object, the one Replace swaps.
for (kind, x) in [(PointerKind::Down, 5.0), (PointerKind::Drag, 200.0), (PointerKind::Drag, 405.0), (PointerKind::Up, 405.0)] {
crate::canvas::dispatch(&mut app, &at(kind, x), view);
}
assert!(app.session.tool_editing());
start(&mut app, &json!({"path": path, "replace": true}), false).unwrap();
assert!(!app.session.tool_editing(), "the merge is applied first");
placed(&mut app);
assert!(app.ui.status.is_empty(), "{}", app.ui.status);
let mut placed_names = vec![];
app.session.doc().unwrap().doc.walk(|n| placed_names.extend(n.name.clone()));
assert!(placed_names.iter().any(|n| n == "photo.tif"), "the merged art is replaced: {placed_names:?}");
let labels: Vec<String> = app.session.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.clone()).collect();
assert_eq!(labels.last().map(String::as_str), Some("Shape Builder"), "{labels:?}");
}
/// A file read while a dialog is open waits for the dialog to close, as a document that opens
/// does (#1694): the dialog's OK acts on the active document.
#[test]
+8 -1
View File
@@ -74,8 +74,9 @@ pub const ROTATE_VIEW_ANGLES: [(&str, f64); 12] = [
("-90°", -90.0),
];
use super::{ControlResponse, Sender};
use super::ControlResponse;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
/// Zoom stop stepping reads this (set each UI frame and before commands) so canvas wheel zoom
/// matches the status bar when Display Print Size at 100% Zoom is on.
@@ -472,6 +473,11 @@ pub struct UiState {
/// `window.toolsBesideCanvas`). Off by default, and off in state saved without it.
#[serde(default)]
pub tools_beside_canvas: bool,
/// The docked Tools panel stands on the right, between the canvas and the panels, instead of on
/// the left (dropped there, or `window.panel.dock {panel: "tools", side: "right"}`). Off by
/// default, and off in state saved without it.
#[serde(default)]
pub toolbar_right: bool,
pub status_bar: bool,
pub help_bar: bool,
pub dock: bool,
@@ -1009,6 +1015,7 @@ impl Default for UiState {
dock_width: None,
toolbar_pos: None,
tools_beside_canvas: false,
toolbar_right: false,
status_bar: true,
help_bar: true,
dock: true,
+52
View File
@@ -4,6 +4,7 @@ use egui::{Event, PointerButton, Pos2};
use serde_json::json;
use vectorcraft_engine::Session;
use vectorcraft_geom::Point;
use vectorcraft_tools::{PointerEvent, PointerKind};
use crate::{VectorcraftApp, canvas, chrome};
@@ -142,6 +143,57 @@ fn drag_fixture_modifiers(modifiers: egui::Modifiers) -> (VectorcraftApp, egui::
(app, ctx, grab, tabs)
}
/// A Cmd-press held while a Shape Builder merge is computed reaches the lent Selection tool once
/// the merge is applied, and its drag carries the artwork over another tab.
#[test]
fn a_cmd_drag_held_during_a_merge_activates_another_tab() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width":800,"height":200,"name":"Source"})).unwrap();
for k in 0..40 {
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("file.new", json!({"width":800,"height":200,"name":"Target"})).unwrap();
app.session.set_active(0);
app.select_tool("shapeBuilder");
app.ui.task_bar = false;
app.ui.view.rulers = false;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
app.ui_ctx = Some(ctx.clone());
frame(&mut app, &ctx, 0.0, vec![]);
let tabs = frame(&mut app, &ctx, 0.01, vec![]);
let view = app.view_info();
app.session.pointer(&PointerEvent::new(PointerKind::Move, 15.0, 10.0), view).unwrap();
app.session.tool_poll(view, true).unwrap();
app.session.pointer(&PointerEvent::new(PointerKind::Down, 15.0, 10.0), view).unwrap();
app.session.pointer(&PointerEvent::new(PointerKind::Up, 15.0, 10.0), view).unwrap();
assert!(app.session.tool_editing(), "the click starts a merge on another thread");
let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(355.0, 10.0));
let modifiers = egui::Modifiers::COMMAND;
frame(
&mut app,
&ctx,
0.05,
vec![
Event::ModifiersChanged(modifiers),
Event::PointerMoved(grab),
Event::PointerButton { pos: grab, button: PointerButton::Primary, pressed: true, modifiers },
],
);
assert_eq!(app.session.tool_id(), "shapeBuilder", "the press waits for the merge");
let r = app.session.tool_poll(view, true);
canvas::apply_requests(&mut app, r);
frame(&mut app, &ctx, 0.08, vec![]);
assert_eq!(app.session.tool_id(), "selection", "the held press borrowed the Selection tool");
frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]);
let tab = tabs[1].center();
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tab)]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.8, vec![]);
assert_eq!(app.session.active_index(), Some(1), "holding the artwork over the tab activates it");
}
/// Verify that leaving a tab restarts its dwell timer and hovering its close button never activates it.
#[test]
fn tab_hover_delay_restarts_after_leaving_and_close_button_does_not_activate() {
+61 -16
View File
@@ -16,7 +16,7 @@ use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, icons, widgets};
pub(crate) const PITCH: f32 = 30.0;
const WIDTH: f32 = 48.0;
pub(crate) const WIDTH: f32 = 48.0;
/// Seconds a press on a tool group's button is held before its flyout opens.
const LONG_PRESS: f64 = 0.35;
/// Width of the grab bar down a flyout's right side: dragging it tears the flyout off.
@@ -105,21 +105,39 @@ fn panel_width(ui: &crate::state::UiState) -> f32 {
}
/// Space reserved inside the protected canvas panel for a visible Tools panel docked beside the
/// canvas (`UiState::tools_beside_canvas`); none while Tools docks at the window's edge.
/// canvas (`UiState::tools_beside_canvas`) or on the canvas's right (`UiState::toolbar_right`);
/// none while Tools docks at the window's left edge.
pub(crate) fn docked_width(ui: &crate::state::UiState) -> f32 {
if ui.tools_beside_canvas && ui.toolbar && ui.screen_mode < 3 && ui.toolbar_pos.is_none() { panel_width(ui) + 6.0 } else { 0.0 }
if (ui.tools_beside_canvas || ui.toolbar_right) && ui.toolbar && ui.screen_mode < 3 && ui.toolbar_pos.is_none() {
panel_width(ui) + 6.0
} else {
0.0
}
}
/// With `UiState::tools_beside_canvas` on, the canvas-edge redocking target, registered even
/// while Tools floats, and the docked panel, which follows the canvas's left edge as dock columns
/// resize, collapse or hide. With it off, `app.rs` draws Tools before the docking layout.
///
/// The canvas's right edge, left of the panels, is a redocking target too, and Tools docked there
/// (`UiState::toolbar_right`) is drawn at it whichever way the setting is.
pub(crate) fn show_beside_canvas(app: &mut VectorcraftApp, ui: &mut Ui) {
if app.ui.tools_beside_canvas && app.ui.toolbar && app.ui.screen_mode < 3 {
crate::tools_float::set_dock_zones(ui.ctx(), ui.available_rect_before_wrap(), WIDTH);
if app.ui.toolbar_pos.is_none() {
show(app, ui);
}
if !app.ui.toolbar || app.ui.screen_mode >= 3 {
return;
}
let canvas = ui.available_rect_before_wrap();
crate::tools_float::set_right_dock_zone(ui.ctx(), canvas, WIDTH);
if app.ui.tools_beside_canvas {
crate::tools_float::set_dock_zones(ui.ctx(), canvas, WIDTH);
}
if app.ui.toolbar_pos.is_none() && (app.ui.tools_beside_canvas || app.ui.toolbar_right) {
show(app, ui);
}
}
/// Is the Tools panel docked on the right, between the canvas and the panels?
pub(crate) fn docked_right(ui: &crate::state::UiState) -> bool {
ui.toolbar_right && ui.toolbar_pos.is_none()
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -131,8 +149,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let w = panel_width(&app.ui);
match app.ui.toolbar_pos {
None => {
let margin = egui::Margin { left: 6, right: 0, top: 6, bottom: 6 };
let docked = egui::Panel::left("toolbar").resizable(false).exact_size(w + 6.0).frame(egui::Frame::NONE).show(ui, |ui| {
// The card's outer gap faces the edge it docks at.
let (panel, margin) = if app.ui.toolbar_right {
(egui::Panel::right("toolbar"), egui::Margin { left: 0, right: 6, top: 6, bottom: 6 })
} else {
(egui::Panel::left("toolbar"), egui::Margin { left: 6, right: 0, top: 6, bottom: 6 })
};
let docked = panel.resizable(false).exact_size(w + 6.0).frame(egui::Frame::NONE).show(ui, |ui| {
crate::theme::card(ui, &t, margin, |ui| body(app, ui, cols, None));
});
ui.ctx().data_mut(|d| d.insert_temp(docked_rect_id(), docked.response.rect));
@@ -211,7 +234,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
{
app.ui.status = e;
}
let grip_tip = if floating.is_some() { tl!("Drag to the window's left edge to dock") } else { tl!("Drag to float the toolbar") };
let grip_tip = if floating.is_some() { tl!("Drag to the window's left or right edge to dock") } else { tl!("Drag to float the toolbar") };
gresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), grip_tip));
gresp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(grip_tip);
// The tools and the controls under them scroll in a window too short for them.
@@ -850,6 +873,27 @@ fn press_travel(ctx: &egui::Context) -> Option<egui::Vec2> {
ctx.input(|i| i.pointer.press_origin().zip(i.pointer.interact_pos()).map(|(a, b)| b - a))
}
/// Where the toolbar's flyouts and drawer may open: the window, or with the toolbar docked right
/// the part left of it, so they open toward the canvas instead of over the panels.
pub(crate) fn room(app: &VectorcraftApp, ctx: &egui::Context) -> egui::Rect {
let mut r = ctx.content_rect();
if docked_right(&app.ui)
&& let Some(x) = ctx.data(|d| d.get_temp::<egui::Rect>(docked_rect_id())).map(|bar| bar.right())
{
r.max.x = r.max.x.min(x);
}
r
}
/// The left edge of something `width` wide opened beside `anchor` (a tool, the toolbar), `gap`
/// from it: on its right when it fits in `screen`, else on its left (a toolbar docked right),
/// kept inside the screen either way.
pub(crate) fn beside(anchor: egui::Rect, width: f32, gap: f32, screen: egui::Rect) -> f32 {
let right = anchor.right() + gap;
let x = if right + width <= screen.right() { right } else { anchor.left() - gap - width };
x.min(screen.right() - width).max(screen.left())
}
fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.ui.flyout.is_none() {
return;
@@ -865,12 +909,13 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
let active = app.session.tool_id();
let toolbar = ctx.data(|d| d.get_temp::<Option<String>>(flyout_toolbar_id())).flatten();
let mut input = FlyoutInput::default();
let resp = egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show(
ctx,
|ui| {
// Beside the tool, on the side with room for it: toward the canvas from a toolbar docked right.
let size = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).map_or(vec2(160.0, 0.0), |r| r.size());
let x = beside(anchor, size.x, 8.0, room(app, ctx));
let resp =
egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(pos2(x, anchor.top() - 1.0)).show(ctx, |ui| {
flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref()));
},
);
});
// Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the
// end of the long press that opened the flyout): it floats where the pointer is.
let at = ctx.input(|i| i.pointer.interact_pos());
+54 -14
View File
@@ -3,11 +3,12 @@
//! docked panel floats it under the pointer, a floating panel's title bar moves it, and a release
//! over the strip along the window's left edge (lit up) docks it there. With Keep Tools Beside the
//! Canvas on (`UiState::tools_beside_canvas`), a release over the canvas's left edge docks it too,
//! and either release docks it beside the canvas. Escape or losing the window's focus puts it back
//! where the drag began.
//! and either release docks it beside the canvas. A release over the canvas's right edge (left of
//! the panels) or the window's right edge docks it on the right, between the canvas and the panels.
//! Escape or losing the window's focus puts it back where the drag began.
//!
//! `window.panel.float {panel: "tools"}` / `window.panel.dock {panel: "tools"}` do the same for
//! agents. Its position is kept with the preferences and in user workspaces, and a position saved
//! agents (`side: right` docks it on the right). Its position and side are kept with the preferences and in user workspaces, and a position saved
//! on a bigger window is clamped into this one when drawn.
use egui::{CornerRadius, Id, Key, Pos2, Rect, Stroke, Vec2, pos2, vec2};
@@ -29,6 +30,8 @@ struct Move {
grab: Vec2,
from: Pos2,
original: Option<[f32; 2]>,
/// The side it was docked on before the drag.
right: bool,
}
fn move_id() -> Id {
@@ -49,6 +52,18 @@ pub(crate) fn tools_zone_id() -> Id {
Id::unique("tools-float-zone")
}
/// The strip along the canvas's right edge, left of the panels, where the Tools panel docks on the
/// right, as last laid out.
pub(crate) fn tools_zone_right_id() -> Id {
Id::unique("tools-float-zone-right")
}
/// Accept a drop left of the panels docked right: the canvas's right edge.
pub(crate) fn set_right_dock_zone(ctx: &egui::Context, canvas: Rect, width: f32) {
let zone = Rect::from_min_max(pos2(canvas.right() - width, canvas.top()), canvas.max);
ctx.data_mut(|d| d.insert_temp(tools_zone_right_id(), zone));
}
fn window_zone_id() -> Id {
Id::unique("tools-float-window-zone")
}
@@ -93,7 +108,7 @@ pub(crate) fn cancel_move_on_escape(app: &mut VectorcraftApp, ctx: &egui::Contex
fn start(app: &VectorcraftApp, ctx: &egui::Context, grab: Vec2) {
let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) else { return };
ctx.data_mut(|d| d.insert_temp(move_id(), Move { grab, from, original: app.ui.toolbar_pos }));
ctx.data_mut(|d| d.insert_temp(move_id(), Move { grab, from, original: app.ui.toolbar_pos, right: app.ui.toolbar_right }));
}
/// The Tools panel's title bar was pressed (`title`, the panel's bounds `bounds`): docked, dragging
@@ -116,13 +131,23 @@ pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: &
}
}
/// The docking strip under `at`, if any: the window's left edge, and with `beside_canvas` the
/// canvas's left edge as well.
fn drop_zone(ctx: &egui::Context, at: Pos2, beside_canvas: bool) -> Option<Rect> {
ctx.data(|d| {
/// The docking strip under `at`, if any, and whether it docks the panel on the right: the window's
/// left edge, with `beside_canvas` the canvas's left edge as well, and on the right the canvas's
/// right edge or anywhere along the window's right edge (which lights up the strip left of the
/// panels, where it docks).
fn drop_zone(ctx: &egui::Context, at: Pos2, beside_canvas: bool) -> Option<(Rect, bool)> {
let (left, right) = ctx.data(|d| {
let canvas = d.get_temp::<Rect>(tools_zone_id()).filter(|_| beside_canvas);
[canvas, d.get_temp::<Rect>(window_zone_id())].into_iter().flatten().find(|r| r.contains(at))
})
let left = [canvas, d.get_temp::<Rect>(window_zone_id())].into_iter().flatten().find(|r| r.contains(at));
(left, d.get_temp::<Rect>(tools_zone_right_id()))
});
if let Some(r) = left {
return Some((r, false));
}
let r = right?;
// Read outside the data lock above: egui's context locks are not re-entrant.
let at_edge = at.x >= ctx.content_rect().right() - crate::toolbar::WIDTH && r.y_range().contains(at.y);
(r.contains(at) || at_edge).then_some((r, true))
}
fn highlight(ctx: &egui::Context, zone: Rect) {
@@ -138,6 +163,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(m) = ctx.data(|d| d.get_temp::<Move>(move_id())) else { return };
if !gesture_allowed(ctx) {
app.ui.toolbar_pos = m.original;
app.ui.toolbar_right = m.right;
ctx.data_mut(|d| d.remove::<Move>(move_id()));
if ctx.input(|i| i.key_pressed(Key::Escape)) {
let frame = ctx.cumulative_frame_nr_for(ctx.viewport_id());
@@ -155,14 +181,15 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
{
app.ui.toolbar_pos = Some([p.x, p.y]);
}
if let Some(zone) = zone {
if let Some((zone, _)) = zone {
highlight(ctx, zone);
}
return;
}
ctx.data_mut(|d| d.remove::<Move>(move_id()));
if zone.is_some() {
if let Some((_, right)) = zone {
app.ui.toolbar_pos = None;
app.ui.toolbar_right = right;
}
}
@@ -191,9 +218,22 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
if !(raw.eq_ignore_ascii_case("tools") || raw.eq_ignore_ascii_case("toolbar")) {
return None;
}
Some(position(p).map(|at| {
// Docking: on the left, or on the right left of the panels (default: the side it docked at last).
let right = match p.get("side").and_then(Value::as_str) {
None => Ok(app.ui.toolbar_right),
// `Start` and `End`, as panels take them, too.
Some(s) if s.eq_ignore_ascii_case("left") || s.eq_ignore_ascii_case("start") => Ok(false),
Some(s) if s.eq_ignore_ascii_case("right") || s.eq_ignore_ascii_case("end") => Ok(true),
Some(s) => Err(format!("side is left or right, not `{s}`")),
};
Some(position(p).and_then(|at| {
let right = right?;
app.ui.toolbar_pos = float.then(|| at.map_or(TOOLS_AT, |p| [p.x, p.y]));
app.ui.toolbar |= float;
json!({ "panel": "tools", "floating": float, "pos": app.ui.toolbar_pos })
if !float {
app.ui.toolbar_right = right;
}
let side = if app.ui.toolbar_right { "right" } else { "left" };
Ok(json!({ "panel": "tools", "floating": float, "pos": app.ui.toolbar_pos, "side": side }))
}))
}
+11 -3
View File
@@ -23,11 +23,19 @@ pub(crate) fn refuse_while_picking(app: &VectorcraftApp) -> Result<(), String> {
if app.file_sheet_open() { Err(crate::picks::busy().into()) } else { Ok(()) }
}
/// Finishes the work that decides whether there is anything left to save: the saves running in
/// the background, and an edit the active tool is computing on another thread (a Shape Builder
/// merge), which is applied.
pub fn finish_pending(app: &mut VectorcraftApp) {
crate::background::wait_all(app);
let r = app.session.finish_tool_edit();
crate::canvas::apply_requests(app, r);
}
/// File → Close (or a tab's ×) for document `i`.
pub fn close(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
refuse_while_picking(app)?;
// A save still running decides whether there is anything left to save.
crate::background::wait_all(app);
finish_pending(app);
let dirty = app.session.documents().get(i).ok_or("no such document")?.is_dirty();
if dirty { ask(app, i, "close") } else { close_now(app, i) }
}
@@ -36,7 +44,7 @@ pub fn close(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
/// or finish once none is left.
pub fn close_all(app: &mut VectorcraftApp, then: &str) -> Result<Value, String> {
refuse_while_picking(app)?;
crate::background::wait_all(app);
finish_pending(app);
if let Some(i) = app.session.documents().iter().position(|d| d.is_dirty()) {
return ask(app, i, then);
}
+5
View File
@@ -61,6 +61,9 @@ pub struct Workspace {
/// The docked Tools panel sits beside the canvas, inside any left-side panels (built-in
/// workspaces and workspaces saved without it: at the window's left edge).
pub tools_beside_canvas: bool,
/// The docked Tools panel stands on the right, left of the panels (built-in workspaces and
/// workspaces saved without it: on the left).
pub toolbar_right: bool,
/// Main dock column width in screen points while `docking` is none (#1753).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dock_width: Option<f32>,
@@ -203,6 +206,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
docking_collapsed: Default::default(),
toolbar_pos: ui.toolbar_pos,
tools_beside_canvas: ui.tools_beside_canvas,
toolbar_right: ui.toolbar_right,
dock_width: ui.dock_width,
toolbars: ui.custom_toolbars.clone(),
}
@@ -231,6 +235,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
ui.docking_hidden_groups = w.docking_hidden_groups.clone();
ui.toolbar_pos = w.toolbar_pos;
ui.tools_beside_canvas = w.tools_beside_canvas;
ui.toolbar_right = w.toolbar_right;
ui.dock_width = w.dock_width;
crate::state::sanitize_toolbar_pos(&mut ui.toolbar_pos);
ui.custom_toolbars = w.toolbars.clone();
+1 -1
View File
@@ -96,7 +96,7 @@ the two can still win that race.
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. With the Artboard tool it lists the artboard entries (see `docs/mcp.md`), after selecting the artboard there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item |
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found |
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found. With the Shape Builder and such a selection, the app also merges off the UI thread: the reply to the events that end a click or drag comes once the merge is applied. A click or drag made before the regions are found does not wait for them; the app records it and makes it once they are found. Until then the canvas keeps drawing, with the touched regions highlighted and the busy pointer. A click or drag sent meanwhile, with `mods.cmd` too, and the Shape Builder's arrow keys are made after the merge in the order sent, and the reply to such a request comes once its events are made and any merge they start is applied; a request of `move` events alone is answered at once. A command that can edit, select, save or redraw the documents (an edit, an undo, a selection, a save or an export, a tool option, another tool, another document, closing, a dialog's preview, a menu item, a command of the app's own such as `ui.recolorDialog`, the proof and separations views, a preference, added fonts) waits for the merge and the input sent before it, and runs after them; queries that only read, such as `document.inspect`, run at once on the document before the merge |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
| `ui.move` | `{x, y}` | screen-point pointer move |
| `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above |
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -113,6 +113,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G1 Interaction fidelity
- **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged.
- **Done:** Distance Guides ([public page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html)): with Smart Guides on and something selected, the Selection tool with Alt/Option held over another object shows the horizontal and vertical distances between them, and over an empty part of an artboard the selection's distances from its four edges, labelled in the General unit; Preferences › Smart Guides › Invoke Distance Guides turns them off. Hover only, as documented: not while moving or nudging. No entry closed; estimates unchanged.
- **Done (part of #4028):** Recolor Artwork › Assign: double-clicking a row's new color opens the Color Picker, as the [public page](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/assign-new-colors-to-selected-artwork.html) describes. OK sets the row's new color in the model it was picked in, so a CMYK mix is no longer a hex value converted back; Cancel leaves the row. A Lab new color is kept, but can't be typed yet: the picker's Lab fields work in RGB, as for fill and stroke. **Missing:** Lab entry in the picker, swatch libraries in the picker and the row's right-click menu (Pick from Swatches, Limit to Library).
@@ -831,6 +833,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
| 2026-11-05 | minor | G15: nearby hits on plain long strokes and outlines check their candidate segments directly; a 200,000-anchor middle hit took about 11–13 ms before and 1.0–1.4 ms after in paired runs. An inside-bounds miss fell from 18.7 ms to 1.6 ms. Estimate unchanged; idle budget run still open. |
| 2026-11-05 | minor | G15: an image zoomed in on is separated, colorized or proofed only for the tiles in view (#4155); 100% zoom on that file 8.6 s → 0.05 s. Estimate unchanged. |
+2 -3
View File
@@ -451,8 +451,8 @@ opens the picker through `run_command` with the remote backend.
| `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. |
| `inspect_ui` | `{}` | UI state. Remote mode only. |
| `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … |
| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default, and a `backquote` that is not a boolean is an error. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. |
| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. When that input is dropped because the document, a dialog or the focus changed first, the call fails and its events are not sent. |
| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default, and a `backquote` that is not a boolean is an error. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. |
| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. When that input is dropped because the document, a dialog or the focus changed first, the call fails and its events are not sent. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. |
| `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. |
| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. |
| `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. |
@@ -463,7 +463,6 @@ opens the picker through `run_command` with the remote backend.
| `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. |
| `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. |
| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin.<id>"` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. |
| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warning for a top-level `compress`, `version`, `preview` or `precision`. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. |
| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. |
+5 -1
View File
@@ -541,7 +541,11 @@ In this example the character and paragraph styles are cut short; a filter gets
`pixelsOmitted: false`. A CMYK image's pixels are its inks through the working CMYK profile. The pixels of all the
images in one input take at most 16 MiB in base64. A larger image, one whose pixels don't fit what the images before
it in paint order left, and a linked image (`linked: true`) have
`pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. The host never
`pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. An image of any
size also has `pixelsOmitted: true` when its data is missing from the document, when its width or height is 0, when
its data cannot be decoded (or needs more than 64 MiB of decoder memory, or is a TIFF the host rejects as too
large), or when the dimensions in its data differ from `pixelSize`. A plug-in checks `pixelsOmitted` before it
reads `pixelsBase64`, which is present only when `pixelsOmitted` is `false`. The host never
reads a linked image's file for a filter and never gives its path.
- A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the
document has no symbol of that name. `overrides` is `{}`.
+9 -6
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (numeric fields step on wheel hover without taking keyboard focus, #5949) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -40,6 +40,8 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i
| Date | Change | Summary |
|---|---|---|
| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops from the left edge (#6147). Scores and estimates unchanged. |
| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. |
| 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |
@@ -131,13 +133,13 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 |
@@ -151,7 +153,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 |
| M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above |
@@ -188,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
| 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. |
| 2026-11-07 | minor | M2: with the Pen or Curvature tool, Edit › Copy and Cut take the anchors and segments a Cmd/Ctrl-lent Direct Selection picked, not the whole path (#6123); estimates unchanged |
| 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. |
+14 -3
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (`.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -20,12 +20,14 @@ Spiral decay now applies after each full wind of four segments. A newly drawn sp
Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged.
**Feature evidence last updated:** 2026-11-05.
**Feature evidence last updated:** 2026-11-07.
Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged.
The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement.
- File formats and Type core (#6147): `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text before a line's last tab at its stops, from the left edge, and the text after it against the right edge. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`; scores and estimates unchanged
- File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently; scores and estimates unchanged
- Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged
- Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches (scores/hours unchanged)
@@ -37,6 +39,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. |
| 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. |
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
| 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged |
| 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). The other corners' widgets then hide: a Shift-click on another corner's anchor adds that corner and shows its widget, and a Shift-click on a showing widget deselects its corner. Adding a corner by Shift-clicking its widget while it is hidden stays open (#1232). Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. |
@@ -229,7 +233,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated |
| Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated; MCP `add_text` exposes existing vertical type; score and hours unchanged |
| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged |
| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), scores and hours unchanged |
| Libraries, Links, Package | 2 | 82% | shared libraries, linked graphics, gradients and patterns as library items (no cloud by design) | 2–3 | estimated |
| Generative AI (Text to Vector, Generative Recolor, Shape Fill, Concept to Vector, Prompt to Edit, Rewrite Text) | 2 | 0% | a pluggable provider and the panels; needs an owner decision on models | 30–60 | measured (absent) |
| **Feature depth** | **105** | **~75%** | | **~250–420** | |
@@ -535,6 +539,8 @@ since 2026-10-01.
## Evidence: what ships today, by area
- **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged.
- 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo.
- Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab.
@@ -552,6 +558,8 @@ since 2026-10-01.
- **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged.
- **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged.
- **PDF editable import:** Place from bytes or a path with `link: false`, and `clipboard.importPdf` followed by Paste, preserve live type on the selected page. Place still honors `textAs: "outlines"`. Synthetic two-page regressions: `crates/engine/src/cmd/fileio/tests_pdffidelity.rs`. No change to the PDF reader's existing fallback rules.
- **Tab stops in `.ai`/EPS type and right-aligned lines with tabs (#6147):** type from the text document keeps its paragraphs' tab stops (key 30: positions and left, center, right and decimal alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. In right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none when the tab ends the line, with its hyphen when the line is hyphenated) against the right edge; centred and justified lines, point type and right-to-left paragraphs are aligned as a whole as before. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`. Scores unchanged.
- **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines and no more than the file has (`vectorcraft_text::fit_source_lines`), with a warning when it does: the frame can stay as it is, or show only some of them; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged.
- **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged.
- **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged.
- **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged.
@@ -1881,6 +1889,9 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); evidence added; scores unchanged. |
| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out closer to the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, possibly more of the last lines the file sets in an area frame of its own, and a line a soft return ends justified (#6137); evidence added; scores unchanged. |
| 2026-11-06 | minor | `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; evidence updated; scores unchanged. |
| 2026-11-08 | minor | Panel numeric wheel turns do not seed canvas zoom glides; corrected the disabled-field scroll description (#5949). Scores and estimates unchanged. |
| 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. |
+5 -3
View File
@@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
@@ -19,7 +19,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Feature | Status | Notes |
|---|---|---|
| Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. Order, bounds and Undo grouping are VectorCraft's choices; scores unchanged. |
| Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; soft return lines in justified text do not spread (#2754) |
| Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; in justified text a line a soft return ends is justified as a wrapped line is, only the paragraph's last line keeping its alignment, as Illustrator's own layout in a user's `.ai` sets it (evidence: `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`; first done the other way in #2754) |
| Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. |
| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen or another document or window becomes active, so later edits with the Type tool return there too), or to the Selection tool when there is no such tool, as when the Type tool was chosen from the toolbar or after such a switch (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) |
| Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) |
@@ -45,7 +45,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests` |
| Roman Hanging Punctuation | done | Paragraph panel menu › Roman Hanging Punctuation, `text.setFormat {romanHangingPunctuation}` (#2756): per paragraph, Optical Margin Alignment's punctuation and amounts without the letters; paragraph styles carry it. Evidence: `tests::roman_hanging_punctuation_hangs_punctuation_but_not_letters`, `typecmd::area_tests::roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on` |
| Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` |
| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together |
| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred and justified lines are still aligned as a whole) |
| Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` |
| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary |
| Smart Punctuation, Change Case, Find & Replace | done | |
@@ -81,6 +81,8 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
| Date | Change | Summary |
|---|---|---|
| 2026-11-07 | minor | Tabs: in right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none, when the tab ends the line) against the right edge; the line was aligned as a whole, which moved its start off the left edge. Illustrator's own layout in a user's `.ai` (used locally, not committed) sets its price lists so. Centred and justified lines keep their alignment as a whole. Scores and estimates unchanged. |
| 2026-11-07 | minor | Soft return: in Justify Left, Center or Right, the line a soft return ends is justified as a wrapped line is; only the paragraph's last line keeps its alignment. Illustrator's own layout in a user's `.ai` (used locally, not committed) justifies those lines. Scores and estimates unchanged. |
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
| 2026-11-07 | minor | Alt + numeric-keypad codes with NumLock off: the keypad keys arrive as Insert, End, arrows, Clear, Home and Page Up/Down, which Windows still composes into the code; with Alt held they now count as digits and are taken out of the frame instead of moving the caret, while the separate navigation keys (extended-key flag) keep their meaning (follow-up to #5939). Scores and estimates unchanged. |
| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |
+4 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (numeric fields step on wheel hover without taking keyboard focus, #5949) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
## Feature submission evidence
@@ -341,6 +341,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
## Panels, Control bar, Properties, context menus
- **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged.
| Interaction | Status | Evidence |
|---|---|---|
| Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate |
@@ -488,6 +490,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
| 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. |
| 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, as with Direct Selection chosen, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged |
| 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected, a Shift-click on a corner widget picks that corner alone instead of deselecting it (#5188); adding a hidden corner's widget by Shift-click stays open (#1232); scores and estimates unchanged |