M3: preserve highlighted Eyedropper ranges and reject incompatible transparency

This commit is contained in:
Don Omondi
2026-10-22 02:58:48 +07:00
committed by bflatastic
parent 10a178f64a
commit a3b2b18cb0
14 changed files with 328 additions and 23 deletions
+4
View File
@@ -148,5 +148,9 @@ sync_github_name = true
[people.addibble]
sync_github_name = true
[people.mockydevs]
real_name = "Don Omondi"
display_name = "Don Omondi"
[people.alpha-rock]
display_name = "Aditya Sisodiya"
+46 -14
View File
@@ -119,7 +119,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eyedropper",
[],
None,
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: the selection), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
has_doc,
copy_from
),
@@ -758,11 +758,13 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// Sample enabled attributes, refusing object transparency that a text range cannot store.
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.copyFrom";
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false));
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None };
let selected = match ids_param(p, "ids") {
Some(v) => outermost(&s.doc()?.doc, v),
None => selected_roots(s)?,
@@ -776,12 +778,38 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
};
targets.retain(|id| *id != src_id);
let picked = Picked::of(s.doc()?.doc.node(src_id).ok_or(EngineError::NoNode(src_id))?);
let range = range.filter(|(id, _, _)| targets.contains(id));
// Character styles cannot represent object transparency. Refuse before even changing
// paint defaults; applying it to the object would also alter the unselected characters.
if let Some((id, _, _)) = range.filter(|_| attrs.appearance.transparency) {
let target = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
if picked.opacity != 1.0 || picked.blend != BlendMode::Normal || target.opacity != 1.0 || target.blend != BlendMode::Normal {
return Err(bad(
C,
"object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object",
));
}
}
if !reverse {
picked.to_defaults(s, &attrs.appearance);
}
if targets.is_empty() || attrs.is_empty() {
return Ok(json!({ "ids": [] }));
}
if let Some((id, start, end)) = range {
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Eyedropper", |d| {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let placed = picked.placed(Some(t.local_bounds()));
vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs));
if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) {
t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone());
}
super::typecmd::refresh_bounds(t);
Ok(count_changed(d, &before, &[id]))
})?;
return Ok(json!({ "ids": [id.0] }));
}
// When every target comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Eyedropper", |d| {
@@ -843,6 +871,22 @@ impl Picked {
}
}
/// Copy character attributes and the enabled paint attributes through the same path for
/// whole text objects and highlighted character ranges.
fn run(&self, st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs) {
if let Some((cs, _)) = self.text.as_ref().filter(|_| a.character) {
*st = CharStyle {
fill: std::mem::take(&mut st.fill),
stroke: std::mem::take(&mut st.stroke),
stroke_width: st.stroke_width,
overprint_fill: st.overprint_fill,
overprint_stroke: st.overprint_stroke,
..cs.clone()
};
}
run_attrs(st, placed, &a.appearance);
}
/// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its
/// stack instead. Type takes the paints on its characters, and character and paragraph
/// attributes from type.
@@ -862,19 +906,7 @@ impl Picked {
let placed = self.placed(Some(t.local_bounds()));
let text = self.text.as_ref();
for r in &mut t.runs {
if let Some((cs, _)) = text.filter(|_| a.character) {
// The character attributes; the paints follow the appearance attributes.
let st = &mut r.style;
*st = CharStyle {
fill: std::mem::take(&mut st.fill),
stroke: std::mem::take(&mut st.stroke),
stroke_width: st.stroke_width,
overprint_fill: st.overprint_fill,
overprint_stroke: st.overprint_stroke,
..cs.clone()
};
}
run_attrs(&mut r.style, &placed, ap);
self.run(&mut r.style, &placed, a);
}
if let Some((_, para)) = text.filter(|_| a.paragraph) {
// The source's first paragraph's attributes, on every paragraph.
+1 -1
View File
@@ -491,7 +491,7 @@ fn swatch_name_clash(d: &Document, old: &str, new: &str) -> bool {
let own = |w: &vectorcraft_color::Swatch| pattern_name(&w.paint).is_some_and(|p| p == old);
let mut holders = d.swatches_iter().filter(|w| w.name == new).peekable();
let only_own = holders.peek().is_some() && holders.all(own);
d.swatch_name_taken(new) && !(only_own && !d.swatches_iter().any(|w| w.name == old && own(w)))
d.swatch_name_taken(new) && (!only_own || d.swatches_iter().any(|w| w.name == old && own(w)))
}
/// Rename a pattern in swatches, paints and the edit record. Of the swatches named `old`, only the
+1 -1
View File
@@ -123,7 +123,7 @@ fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result<Vec<Effect>, St
}
if super::effectcmd::texture_load_effect(&e.id)
&& let Some(t) = params.get(effects::pixel::TEXTURE_IMAGE)
&& !t.as_str().is_some_and(|t| effects::pixel::texture(t).is_some())
&& t.as_str().is_none_or(|t| effects::pixel::texture(t).is_none())
{
return Err(refuse("`textureImage` must be a texture as effect.list gives it".into()));
}
+159
View File
@@ -509,3 +509,162 @@ fn type_takes_the_stroke_options_it_has() {
assert_eq!((st.stroke.color().unwrap().to_hex(), st.stroke_width, st.stroke_cap), ("#0000ff".into(), 5.0, LineCap::Round));
assert!(st.stroke_dash.is_none(), "the dash pattern isn't applied");
}
/// Unsupported object transparency must fail atomically; opting out still samples the range.
#[test]
fn eyedropper_text_range_rejects_object_transparency_without_changes() {
for transparency in [json!({"opacity": 50}), json!({"blend": "multiply"})] {
let mut s = session();
s.prefs.placeholder_text = false;
let src = rect(&mut s, 300.0);
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
s.execute("transparency.set", &transparency).unwrap();
let view = ViewInfo::default();
s.select_tool("type", view).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap();
}
s.tool_text("MAMA NA KAZI", view).unwrap();
s.set_tool_option("select", &json!({"start": 0, "end": 4}));
s.select_tool("eyedropper", view).unwrap();
s.execute("paint.setFill", &json!({"color": "#b00000"})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let paint = s.paint.clone();
let undo = s.doc().unwrap().history.undo.len();
let selection = s.tool_options();
let err = s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap_err();
assert!(err.to_string().contains("object transparency"), "{err}");
assert_eq!(s.doc().unwrap().doc, doc);
assert_eq!(s.paint, paint);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(s.tool_options(), selection);
// Disabling transparency still copies the paint to just the highlighted characters.
s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"transparency": false}}})).unwrap();
let dst = s.doc().unwrap().selection.objects[0];
let NodeKind::Text(t) = node(&s, dst).kind else { panic!() };
assert_eq!(t.runs[0].text, "MAMA");
assert_eq!(t.runs[0].style.fill.color().unwrap().to_hex(), "#00ff00");
assert_eq!(t.runs[1].text, " NA KAZI");
let NodeKind::Text(before) = &doc.node(dst).unwrap().kind else { panic!() };
assert_eq!(t.runs[1].style, before.runs.last().unwrap().style);
}
}
/// An opaque sample cannot replace only a range's inherited object transparency.
#[test]
fn eyedropper_text_range_rejects_target_transparency_without_changes() {
for transparency in [json!({"opacity": 50}), json!({"blend": "multiply"})] {
let mut s = session();
s.prefs.placeholder_text = false;
let src = rect(&mut s, 300.0);
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
let view = ViewInfo::default();
s.select_tool("type", view).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap();
}
s.tool_text("MAMA NA KAZI", view).unwrap();
let dst = s.doc().unwrap().selection.objects[0];
s.execute("transparency.set", &transparency).unwrap();
s.set_tool_option("select", &json!({"start": 0, "end": 4}));
s.select_tool("eyedropper", view).unwrap();
s.execute("paint.setFill", &json!({"color": "#b00000"})).unwrap();
let before = s.doc().unwrap().doc.clone();
let paint = s.paint.clone();
let undo = s.doc().unwrap().history.undo.len();
let selection = s.tool_options();
let err = s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap_err();
assert!(err.to_string().contains("object transparency"), "{err}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.paint, paint);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(s.tool_options(), selection);
// An explicit opt-out samples the range while retaining the object's transparency.
s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"transparency": false}}})).unwrap();
let n = node(&s, dst);
assert_eq!((n.opacity, n.blend), (before.node(dst).unwrap().opacity, before.node(dst).unwrap().blend));
let NodeKind::Text(t) = n.kind else { panic!("not text") };
assert_eq!(t.runs[0].text, "MAMA");
assert_eq!(t.runs[0].style.fill.color().unwrap().to_hex(), "#00ff00");
let NodeKind::Text(old) = &before.node(dst).unwrap().kind else { panic!("not text") };
assert_eq!(t.runs[1].text, " NA KAZI");
assert_eq!(t.runs[1].style, old.runs.last().unwrap().style);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
}
}
/// Choosing the Eyedropper from highlighted type must preserve the character target.
#[test]
fn eyedropper_styles_only_highlighted_characters() {
let mut s = session();
s.prefs.placeholder_text = false;
let src = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Source"})).unwrap());
s.execute("text.setStyle", &json!({"font": "Inter", "size": 30, "fill": "#00ff00"})).unwrap();
let view = ViewInfo::default();
s.select_tool("type", view).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap();
}
let dst = s.doc().unwrap().selection.objects[0];
s.tool_text("MAMA NA KAZI", view).unwrap();
s.set_tool_option("select", &json!({"start": 0, "end": 4}));
let before = match node(&s, dst).kind {
NodeKind::Text(t) => t.first_style(),
_ => panic!(),
};
s.select_tool("eyedropper", view).unwrap();
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
let NodeKind::Text(t) = node(&s, dst).kind else { panic!() };
assert_eq!(t.runs.len(), 2, "only MAMA takes the sampled style");
assert_eq!(t.runs[0].text, "MAMA");
assert_eq!(t.runs[0].style.size, 30.0);
assert_eq!(t.runs[0].style.fill.color().unwrap().to_hex(), "#00ff00");
assert_eq!(t.runs[1].text, " NA KAZI");
assert_eq!(t.runs[1].style, before);
s.execute("edit.undo", &json!({})).unwrap();
let NodeKind::Text(t) = node(&s, dst).kind else { panic!() };
assert_eq!(t.plain_text(), "MAMA NA KAZI", "undo keeps the separate typing step");
assert_eq!(t.runs[0].style, before);
s.execute("edit.redo", &json!({})).unwrap();
s.select_tool("type", view).unwrap();
assert_eq!(s.tool_options()["editing"], dst.0);
assert_eq!((s.tool_options()["start"].as_u64(), s.tool_options()["end"].as_u64()), (Some(0), Some(4)));
}
/// Color-only sampling preserves multibyte ranges and clears stale targets on deselection.
#[test]
fn eyedropper_shift_color_preserves_other_characters_and_clears_on_deselect() {
let mut s = session();
s.prefs.placeholder_text = false;
let src = rect(&mut s, 300.0);
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"color": "#b00000"})).unwrap();
let view = ViewInfo::default();
s.select_tool("type", view).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap();
}
let dst = s.doc().unwrap().selection.objects[0];
s.tool_text("Aé中Z", view).unwrap();
s.set_tool_option("select", &json!({"start": 1, "end": 6}));
let NodeKind::Text(before) = node(&s, dst).kind else { panic!() };
click(&mut s, 325.0, 35.0, Mods { shift: true, ..Default::default() });
let NodeKind::Text(t) = node(&s, dst).kind else { panic!() };
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["A", "é中", "Z"]);
assert_eq!(t.runs[0].style, before.first_style());
assert_eq!(t.runs[2].style, before.first_style());
assert_eq!(t.runs[1].style.fill.color().unwrap().to_hex(), "#00ff00");
assert_eq!(t.runs[1].style.size, before.first_style().size);
assert!(s.overlays(view).iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Highlight { .. })), "the highlighted range remains visible");
s.execute("select.none", &json!({})).unwrap();
assert!(s.tool_options().get("editing").is_none_or(Value::is_null));
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
let NodeKind::Text(t) = node(&s, dst).kind else { panic!() };
assert!(
t.runs.iter().all(|r| r.style.fill.color().unwrap().to_hex() == "#00ff00"),
"after deselecting, ordinary object sampling styles the whole object"
);
}
+11 -2
View File
@@ -92,10 +92,19 @@ impl Session {
if self.tool.id() == id {
return Ok(());
}
// Sampling type keeps its highlighted characters as the target. Capture before the
// Type tool deactivates (which commits typing and clears its editing state).
let is_type = |id: &str| matches!(id, "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath");
let text_selection = ((id == "eyedropper" && is_type(self.tool.id())) || (self.tool.id() == "eyedropper" && is_type(id)))
.then(|| self.tool.options())
.filter(|o| o.get("editing").is_some_and(|v| !v.is_null()) && o.get("start") != o.get("end"));
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
self.apply_actions(acts)?;
self.keep_tool_settings();
self.tool = self.make_tool(id);
if let Some(selection) = text_selection {
self.tool.set_option("textSelection", &selection);
}
if vectorcraft_tools::catalog::is_selection_tool(id) {
self.last_selection_tool = Some(self.tool.id());
}
@@ -512,9 +521,9 @@ impl Session {
}
/// Let the active tool finish work a command from outside it ended ([`vectorcraft_tools::Tool::after_command`]):
/// the Type tool stops editing text that is no longer selected. Only while it edits text.
/// the Type tool and the Eyedropper clear a character range whose text is no longer selected.
pub(crate) fn after_command(&mut self) {
if self.in_tool_actions || !self.tool.wants_text() {
if self.in_tool_actions || (!self.tool.wants_text() && self.tool.id() != "eyedropper") {
return;
}
let acts = self.with_tool_cx(self.last_view, |t, cx| t.after_command(cx));
+12
View File
@@ -803,6 +803,18 @@ impl Tool for TypeTool {
/// `commitTyping` (the caller commits the engine interaction).
fn set_option(&mut self, key: &str, v: &Value) {
match key {
// Transient selection transferred between Type and Eyedropper; never a saved setting.
"textSelection" => {
if let (Some(id), Some(a), Some(b)) = (
v.get("editing").and_then(Value::as_u64),
v.get("anchor").or_else(|| v.get("start")).and_then(Value::as_u64),
v.get("caret").or_else(|| v.get("end")).and_then(Value::as_u64),
) && let (Ok(a), Ok(b)) = (usize::try_from(a), usize::try_from(b))
{
self.start_editing(NodeId(id), b);
self.anchor = a;
}
}
"prevTool" | "previousTool" => {
self.prev_tool = v.as_str().map(String::from);
}
+39 -2
View File
@@ -23,12 +23,14 @@ use vectorcraft_geom::Point;
use super::paint_owner;
use crate::params::color_json;
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Default)]
pub struct EyedropperTool {
/// Sampling for the selected gradient stop: the tool to switch back to afterwards.
stop: Option<String>,
/// The Type tool's highlighted range while sampling, including its selection overlay.
text_selection: crate::text::TypeTool,
/// Brush Options › Key Color (Hue Shift): sample into the dialog, then [`Self::stop`].
brush_key_color: bool,
}
@@ -125,18 +127,37 @@ impl Tool for EyedropperTool {
out
}
/// Expose a transient text range alongside gradient-stop and brush-key sampling state.
fn options(&self) -> Value {
json!({ "stop": self.stop, "brushKeyColor": self.brush_key_color })
let selected = self.text_selection.options();
let mut options = if selected.get("editing").is_some_and(|v| !v.is_null()) { selected } else { json!({}) };
if let Some(o) = options.as_object_mut() {
o.insert("stop".into(), json!(self.stop));
o.insert("brushKeyColor".into(), json!(self.brush_key_color));
}
options
}
/// Accept a stop target or the selection transferred from the Type tool.
fn set_option(&mut self, key: &str, value: &Value) {
match key {
"stop" => self.stop = value.as_str().map(str::to_string),
"brushKeyColor" => self.brush_key_color = value.as_bool().unwrap_or(false),
"textSelection" => self.text_selection.set_option(key, value),
_ => {}
}
}
/// Drop the range when its text is removed or no longer selected.
fn after_command(&mut self, cx: &ToolContext) -> Vec<Action> {
self.text_selection.after_command(cx)
}
/// Keep the sampled character range highlighted without activating text input.
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if self.text_selection.options().get("editing").is_some_and(|v| !v.is_null()) { self.text_selection.overlays(cx) } else { vec![] }
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Eyedropper
}
@@ -148,6 +169,22 @@ mod tests {
use crate::testutil::*;
use vectorcraft_doc::Selection;
/// Upstream brush-key sampling and highlighted-text transfer keep independent state.
#[test]
fn text_range_and_brush_sampling_options_coexist() {
let mut tool = EyedropperTool::default();
tool.set_option("textSelection", &json!({"editing": 1, "anchor": 0, "caret": 4}));
tool.set_option("stop", &json!("selection"));
tool.set_option("brushKeyColor", &json!(true));
let options = tool.options();
assert_eq!((options["editing"].as_u64(), options["start"].as_u64(), options["end"].as_u64()), (Some(1), Some(0), Some(4)));
assert_eq!(options["stop"], "selection");
assert_eq!(options["brushKeyColor"], true);
tool.set_option("brushKeyColor", &json!(false));
assert_eq!(tool.options()["editing"], 1);
assert_eq!(tool.options()["brushKeyColor"], false);
}
#[test]
fn click_copies_appearance_shift_samples_color() {
let (d, id) = doc_with_rect();
@@ -807,6 +807,7 @@ nothing to mark
nothing to print: every artboard picked is blank
nothing to print: the document has no art that prints
object is not a path
object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object
object {_1} has lost its file's contents: relink it
object {_1} has no size
object {_1} is not a placed document
+14
View File
@@ -390,10 +390,24 @@ The original site's plain HTTP archive is no longer fetched or extracted. To ref
review the mirror commit, original-file list, redistribution license, sizes and hashes together;
do not replace expected hashes automatically in response to a download mismatch.
## Highlighted-text Eyedropper (2026-10-21)
Switching from Type to Eyedropper preserves the highlighted character range and its overlay.
Sampling applies enabled font and paint attributes to that range; Shift samples color only.
Returning to Type retains the range; deselection and document changes clear stale ranges.
Character styles cannot represent object opacity or blend modes. With Transparency enabled,
sampling refuses a non-neutral source or target object before changing art, paint defaults,
selection or history. Disable Transparency in Eyedropper Options to sample supported attributes
while preserving the target object's transparency. Whole-object sampling remains unchanged.
Regression evidence: `tests_eyedropper.rs` covers Unicode, font/paint, Undo/Redo, tool switches,
and atomic rejection of source and target opacity/blend modes; `text_range_and_brush_sampling_options_coexist`
covers upstream brush-key sampling. Scores and estimates unchanged.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. |
| 2026-10-21 | minor | Fonts › Leaving out the bundled fonts: vectorcraft-ui-egui's and vectorcraft-mcp's `bundled-fonts` features, and the engine declared without default features. |
| 2026-10-14 | minor | Document the `bundled-fonts` feature of vectorcraft-text and vectorcraft-engine (Fonts › Leaving out the bundled fonts). |
| 2026-10-14 | minor | Document registration of every explicitly supplied craft-fonts face after the existing script-specific prefix; empty input and web selection remain unchanged. |
+9
View File
@@ -380,10 +380,19 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
contrast. Illustrator's own accessibility is limited, so this is mostly beyond parity.
- **Estimate:** 15–25 h.
## Highlighted-text Eyedropper (2026-10-21)
Type/Eyedropper switches preserve highlighted characters; sampling changes only their enabled
font/paint attributes. Enabled non-neutral source or target object transparency is refused
atomically; disable Transparency to preserve object transparency while sampling the range.
`tests_eyedropper.rs` covers ranges, Unicode, history and transparency; tool tests cover brush-key
sampling coexistence. Scores and estimates unchanged. See [development.md](development.md#highlighted-text-eyedropper-2026-10-21).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. |
| 2026-10-21 | minor | 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. Scores and estimates unchanged. |
| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
| 2026-10-21 | minor | G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top. No entry closed, estimates unchanged. |
+11 -2
View File
@@ -52,10 +52,10 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
| 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) | 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 reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; 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 and mixed spot/process gradients export as stops or process colours to SVG/PDF; 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 |
| 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; 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 and mixed spot/process gradients export as stops or process colours to SVG/PDF; 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). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 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 reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; 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 and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
| 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; 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 and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); 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 (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; auto/slider/advanced UI remains open); 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) | 🟡 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, 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 |
@@ -83,10 +83,19 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
M3 follow-up (#1968): native saves retain the per-document transparency grid setting; save/open regression covers both states, older files and SVG. Scores and estimates unchanged.
## Highlighted-text Eyedropper (2026-10-21)
Type/Eyedropper switches preserve highlighted characters; sampling changes only their enabled
font/paint attributes. Enabled non-neutral source or target object transparency is refused
atomically; disable Transparency to preserve object transparency while sampling the range.
`tests_eyedropper.rs` covers ranges, Unicode, history and transparency; tool tests cover brush-key
sampling coexistence. Scores and estimates unchanged. See [development.md](development.md#highlighted-text-eyedropper-2026-10-21).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. |
| 2026-10-21 | minor | 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. Scores and estimates unchanged. |
| 2026-10-21 | minor | Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls. Scores and estimates unchanged. |
| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
+10 -1
View File
@@ -196,7 +196,7 @@ Depth here is "does the basic use work", not the area's full score.
| Shape tools (rectangle, ellipse, polygon, star, line) | 9 | 95% | all shape tools, Live Corners |
| Selection, move, scale, rotate | 9 | 92% | bounding box, smart guides, transform tools |
| Pen and Pencil, basic use | 7 | 88% | [ui-parity.md](ui-parity.md); a few Pencil and Curvature options missing |
| Fill, stroke, colour, swatches | 9 | 95% | M3 done |
| Fill, stroke, colour, swatches | 9 | 95% | M3 done; highlighted-text Eyedropper font/paint and source/target transparency refusal regression-tested; scores and hours unchanged |
| Gradients, basic | 4 | 90% | Gradient panel and on-canvas annotator |
| Type: point and area text, font, size | 8 | 88% | font menu with previews; hyphenation options missing (rarely touched) |
| Align, group, arrange | 6 | 95% | |
@@ -919,10 +919,19 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Evidence for the shared light Color: geometry tests in `tests_lighting`, effect paint tests, command/save/undo/expand and SVG/PDF checks in `three_d_light_color`, and headless picker/reset/preview tests in `tests_revolve`. Revolve keeps smooth colored vector ramps and continuous flat-lit caps. Inflate geometry tests check spherical lighting, shared vertex/edge continuity, concave cell coverage, puffed backs at perspective and legacy single-light/list equivalence; rendered command/export regressions cover smooth shading and editable expansion. Four legacy white-light PNGs match main byte for byte; [release measurements](3d-lighting-performance.md) record the colored solid-fill batch and the remaining source-gradient cost.
## Highlighted-text Eyedropper (2026-10-21)
Type/Eyedropper switches preserve highlighted characters; sampling changes only their enabled
font/paint attributes. Enabled non-neutral source or target object transparency is refused
atomically; disable Transparency to preserve object transparency while sampling the range.
`tests_eyedropper.rs` covers ranges, Unicode, history and transparency; tool tests cover brush-key
sampling coexistence. Scores and estimates unchanged. See [development.md](development.md#highlighted-text-eyedropper-2026-10-21).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. |
| 2026-10-21 | minor | 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. Scores and estimates unchanged. |
| 2026-10-21 | minor | Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls. Scores and estimates unchanged. |
| 2026-10-21 | minor | Snap to Glyph (#3124) removed from View navigation missing lists and menu stubs; scores and hours unchanged at this rounding |
+10
View File
@@ -169,6 +169,7 @@ tools, never checked side by side) isn't captured row by row.
| Space: Hand; Cmd+Space / Cmd+Alt+Space: zoom in/out; middle button pans | done | |
| Document window scroll bars along the right and bottom edges (Normal and Full Screen Mode with Menu Bar; none in Full Screen and Presentation Mode): a thumb as long as the view against the art and the artboards; dragging it scrolls, a click on the track pages, the wheel over a bar scrolls along it; in a rotated view they follow the screen's axes | partial | #2322. The span is the art and the artboards with half a window around them, not Illustrator's 227 in canvas: VectorCraft has no bounded drawing area (#2284). A track click pages rather than jumping to the click; the page size and the rotated-view behaviour are our own choice (the public documentation gives neither) |
| Cmd/Ctrl: last-used selection tool; Cmd+Alt: Group Selection inside Direct Selection | done | Cmd+Alt lends Group Selection only while Direct Selection is active (Cmd borrow from the Pen keeps Direct Selection so Alt stays copy/subtract); engine pointer regressions |
| Eyedropper styles highlighted characters only and preserves ranges across Type switches | done | Font/paint, Shift color-only, Unicode, Undo/Redo and stale-range regression tests; enabled non-neutral source or target object transparency is refused atomically; disable Transparency to sample supported attributes |
| Eyedropper reads visible sources while their layer/object is locked or outside isolation | done | Ordinary/Shift/Shift+Alt and gradient-stop reads preserve the source and selection; Alt reverse remains editable-only; hidden art stays excluded (`eyedropper_sources.rs`, #1251) |
| Add a visible stroke after drawing with inherited None/zero-width paint | done | Paint and Swap Fill/Stroke restore the existing 1 pt default; positive widths and painted zero widths stay as set; Undo/Redo and targeted appearance items tested (`stroke_visibility.rs`, #1253) |
| Double-click a stroked segment to create a width point in Width Point Edit | done | Finite-arithmetic follow-up: engine regressions and full CI passed; [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area). Command and pointer regressions cover opening, sampled widths, adjoining adjustment on/off through checkbox/OK, compound paths, Cancel/Delete and Undo/Redo; dialog layout reviewed. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) |
@@ -267,10 +268,19 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, #
- [x] Compact Color Picker with a distinct header, stacked actions and aligned input groups. #2008; translated action widths, visible warnings, stable mode switching and vertical scrolling in narrow windows; real egui events and two UI scales covered; German and enlarged English renders reviewed locally. Scores unchanged
## Highlighted-text Eyedropper (2026-10-21)
Type/Eyedropper switches preserve highlighted characters; sampling changes only their enabled
font/paint attributes. Enabled non-neutral source or target object transparency is refused
atomically; disable Transparency to preserve object transparency while sampling the range.
`tests_eyedropper.rs` covers ranges, Unicode, history and transparency; tool tests cover brush-key
sampling coexistence. Scores and estimates unchanged. See [development.md](development.md#highlighted-text-eyedropper-2026-10-21).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. |
| 2026-10-21 | minor | 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. Scores and estimates unchanged. |
| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
| 2026-10-21 | minor | G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top (public doc cited in target-app-parity.md). Scores unchanged. |