From 9fe7aec49c4f6618ef6cd1cee7fc33c11a9778f3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 16:31:39 -0800 Subject: [PATCH 1/5] Graphs: Appearance commands and the Eyedropper update a series' stored paint paint.setFill, paint.setStroke, paint.none, Apply Fill to Stroke and Apply Stroke to Fill stored a series' paint on the graph. appearance.setItem, the Eyedropper, appearance.transfer, Clear Appearance, Reduce to Basic Appearance and the add, remove, move and duplicate row commands stored no paint or weight. An Eyedropper pick of a blue fill at 40% onto a red column drew red at 40% after the next redraw. These commands and Show All Hidden Attributes now store the paint and weight of the topmost visible fill and stroke of a path directly in a series group, in the same undo step. A color, weight or opacity that a command writes to that row replaces the stored value even when the mark's own value does not change. A row that has no paint before and after the command does not change the stored paint. These commands store no stroke for a series that draws Filled Lines bands, and a band no longer takes its color from an empty stored stroke. The stored opacity also comes from the topmost visible row: hiding a top row stores the row under it, and an opacity set on a hidden top row is not stored. Follows #5501. --- crates/engine/src/cmd/appearance.rs | 133 ++++- crates/engine/src/cmd/graph.rs | 2 +- crates/engine/src/cmd/graph/axis.rs | 7 +- crates/engine/src/cmd/graph/generate.rs | 8 +- crates/engine/src/cmd/graph/labels.rs | 12 +- crates/engine/src/cmd/graph/series.rs | 83 ++- crates/engine/src/cmd/graph/tests.rs | 754 ++++++++++++++++++++++++ docs/mcp.md | 14 + docs/target-app-parity.md | 2 +- 9 files changed, 971 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 9cfedcb7a..6675c63d0 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, true) ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, false) ), @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, clear_appearance ), @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, reduce_basic ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, set_item ), @@ -90,7 +90,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, remove_item ), @@ -108,7 +108,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, duplicate_item ), @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: 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, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `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, defaultsOnly?: 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, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `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). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, show_all_hidden ), @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {source, target}", has_doc, transfer ), @@ -595,6 +595,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); + super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); Ok(()) })?; // A fill added above the active row becomes the active row; otherwise the rows above the new @@ -614,7 +615,8 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; s.active_appearance_item = None; ok() @@ -626,15 +628,31 @@ fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> { ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect() } -/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost row has another opacity in `d` than -/// in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`]. +/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity in `d` +/// than in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`]. fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> { let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| { - d.node(id).and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill).map(AppearanceItem::opacity)).unwrap_or(1.0) + d.node(id) + .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(AppearanceItem::opacity)) + .unwrap_or(1.0) }; ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect() } +/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another paint, or a +/// stroke another weight, in `d` than in `before` (no row counts as no paint), for +/// [`super::graph::capture_series_top_paints`]. +fn top_paint_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> { + let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| d.node(id).map(|n| super::graph::shown_paint(n, fill)); + ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect() +} + +/// `rows` (each object with the kind of row) whose topmost visible row of that kind has paint: a command that wrote them +/// replaces the paint another mark of the series left on the graph, even when the mark's own row comes out as it was. +fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator) -> Vec<(NodeId, bool)> { + rows.into_iter().filter(|(id, fill)| d.node(*id).is_some_and(|n| !super::graph::shown_paint(n, *fill).0.is_none())).collect() +} + /// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A /// group keeps no fill or stroke rows of its own. fn clear(n: &mut Node) { @@ -672,7 +690,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result { } let changed = if copy { vec![target] } else { vec![source, target] }; let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed)); - Ok(count_changed(d, &before, &[source, target]) + usize::from(kept)) + // The target last: on marks of one series, its paint is the one the series keeps. + let mut rows = top_paint_changed(&before, d, &changed); + rows.extend(painted_rows(d, both_kinds(&[target]))); + let painted = super::graph::capture_series_top_paints(d, &rows); + Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -726,7 +748,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; s.active_appearance_item = None; ok() @@ -752,10 +775,6 @@ fn set_item(s: &mut Session, p: &Value) -> Result { for (id, at) in ids.iter().zip(&per) { let Some(&idx) = at.first() else { continue }; let Some(n) = d.node_mut(*id) else { continue }; - let items = &n.appearance.items; - if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f) == Some(idx)) { - edited.push((*id, fill)); - } if p.get("weight").is_none() && let Some(pa) = &paint { @@ -785,10 +804,26 @@ fn set_item(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("visible").and_then(Value::as_bool) { *vis = v; } + let items = &n.appearance.items; + if let Some(fill) = + items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f && it.visible()) == Some(idx)) + { + edited.push((*id, fill)); + } } - // The graph keeps an opacity set on the topmost fill or stroke of its series art for when it is drawn again. - let kept = p.get("opacity").and_then(Value::as_f64).is_some() && super::graph::capture_series_opacity(d, &edited); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + // The graph keeps an opacity, paint or weight set on the topmost visible fill or stroke of its series art for when + // it is drawn again, and hiding or showing a row changes which row that is. + let mut opacities = top_opacity_changed(&before, d, &ids); + if p.get("opacity").and_then(Value::as_f64).is_some() { + opacities.extend(edited.iter().copied()); + } + let kept = super::graph::capture_series_opacity(d, &opacities); + // A weight goes with the paint of its stroke; a stroke without paint leaves the series' paint alone. + let written = edited.iter().copied().filter(|(_, fill)| paint.is_some() || (!*fill && p.get("weight").is_some())); + let mut rows = top_paint_changed(&before, d, &ids); + rows.extend(painted_rows(d, written)); + let painted = super::graph::capture_series_top_paints(d, &rows); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; if let Some(pa) = &paint { s.remember_paint(pa); @@ -832,7 +867,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { } } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count())); ok() @@ -901,6 +937,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("no item at index {}", idx[0]))); } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); + super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); Ok(()) })?; // Rows at or above each copy move up by one (by the block, for copies placed at `to`). @@ -918,11 +955,14 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { /// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects. fn show_all_hidden(s: &mut Session, p: &Value) -> Result { let roots = require_targets(s, p, "appearance.showAllHidden")?; + let before = s.doc()?.doc.clone(); let ids = s.edit("Show All Hidden Attributes", |d, _| { let shown: Vec = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect(); if shown.is_empty() { return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into())); } + super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &shown)); + super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &shown)); Ok(shown) })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) @@ -962,7 +1002,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result { } } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; // The moved row stays the active one; the rows it passed shift by one. s.remap_appearance_item(&ids, |a| { @@ -1006,7 +1047,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result Result { picked.apply(n, &attrs, append); } } - let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &targets)); - Ok(count_changed(d, &before, &targets) + usize::from(kept)) + // The paints the pick wrote, with a weight going with the paint of its stroke, as in `set_item`. `append` adds + // only the colors of the visible rows the source has. + let (fill, stroke) = (attrs.appearance.fill, attrs.appearance.stroke); + let source_has = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind); + let source_shows = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); + let (paints, opacities) = if append { + let rows = (fill.color && source_shows(true), stroke.color && source_shows(false)); + (rows, rows) + } else { + ( + (fill.color, stroke.color || (stroke.weight && source_has(false))), + (fill.transparency && source_has(true), stroke.transparency && source_has(false)), + ) + }; + // A pick of some attributes edits the topmost row of its kind, which may be hidden; `append` and a whole pick write rows. + let rows_written = append || (fill == FillAttrs::ALL && stroke == StrokeAttrs::ALL); + let top_shows = |id: NodeId, kind: bool| { + let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); + items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(|it| it.visible()) + }; + let written = |(fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { + targets + .iter() + .flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]) + .flatten() + .filter(|(id, kind)| rows_written || top_shows(*id, *kind)) + .collect() + }; + let mut opacity_rows = top_opacity_changed(&before, d, &targets); + opacity_rows.extend(painted_rows(d, written(opacities))); + let mut rows = top_paint_changed(&before, d, &targets); + rows.extend(painted_rows(d, written(paints))); + let kept = super::graph::capture_series_opacity(d, &opacity_rows); + let painted = super::graph::capture_series_top_paints(d, &rows); + Ok(count_changed(d, &before, &targets) + usize::from(kept || painted)) })?; Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index e88e2810c..7a47457c2 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -42,7 +42,7 @@ use data::{fmt_value, parse_csv, to_csv}; use generate::generate; #[cfg(test)] use series::default_series_color; -pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, paint_targets}; +pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, shown_paint}; #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/graph/axis.rs b/crates/engine/src/cmd/graph/axis.rs index 52e49dbee..47858d395 100644 --- a/crates/engine/src/cmd/graph/axis.rs +++ b/crates/engine/src/cmd/graph/axis.rs @@ -1,4 +1,4 @@ -use super::Value; +use super::{GraphSpec, Value}; pub(super) const LABEL_SIZE: f64 = 9.0; @@ -32,6 +32,11 @@ pub(super) fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) { ((lo / step).floor() * step, (hi / step).ceil() * step, step) } +/// The value axis of a radar graph: from 0 to the largest value of any series. +pub(super) fn radar_axis(g: &GraphSpec) -> (f64, f64, f64) { + value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max)) +} + /// A value axis: with both `min` and `max` set (Graph Type › Tick Values › Override Calculated Values), exactly that /// range split into `ticks` divisions (5 when automatic); otherwise a nice axis around the data, using whichever bound /// was given. diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index 6db9a0fe4..d1a5ddde0 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -1,8 +1,8 @@ -use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, tick_values, value_axis}; +use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, radar_axis, tick_values, value_axis}; use super::columns::{Allowance, ColumnArt, column_art, column_designs}; use super::data::fmt_value; use super::labels::{ - FILLED_LINE_BUDGET, Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, label_size, label_width, polyline, recolour_text, runs, shadow_of, + Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_size, label_width, polyline, recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; use super::series::{series_marks, with_series_opacity}; @@ -264,7 +264,7 @@ impl<'a> Plot<'a> { right_edge: g.rect.x1, wedge_labels: vec![], on_top: vec![], - filled: g.filled_lines && g.rows.len().saturating_mul(nser) <= FILLED_LINE_BUDGET, + filled: filled_lines_on(g, nser), bars_reach: None, shadowed: vec![], series: vec![vec![]; nser], @@ -422,7 +422,7 @@ impl<'a> Plot<'a> { let (g, r, ncat, cs) = (self.g, self.r, self.ncat, self.cs); let centre = r.center(); let rad = (r.width().min(r.height()) / 2.0).max(1.0); - let (lo, hi, step) = value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max)); + let (lo, hi, step) = radar_axis(g); let at = |c: usize, v: f64| { let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * c as f64 / ncat as f64; centre + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) * (rad * ((v - lo) / (hi - lo)).clamp(0.0, 1.0)) diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index 664356499..fdd640a95 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -90,9 +90,10 @@ impl Gen<'_> { }; // A run the outline can't be made for (its points all in one place) stays a thin line. if !band.is_empty() { - // The band takes the series' colour, set as a fill or, on a thin line, as its stroke; a thin black outline. + // The band takes the series' color, set as a fill or, on a thin line, as a stroke with paint; a thin black outline. let slot = g.series_paints.get(s); - let fill = slot.and_then(|p| p.fill.clone().or_else(|| p.stroke.clone())).unwrap_or_else(|| default_series_paint(s)); + let fill = + slot.and_then(|p| p.fill.clone().or_else(|| p.stroke.clone().filter(|st| !st.is_none()))).unwrap_or_else(|| default_series_paint(s)); return self.path(band, fill, Paint::solid(Color::BLACK), 0.25); } let (fill, stroke, w) = series_marks(g, s, false, default_series_paint(s), 1.0); @@ -140,7 +141,12 @@ pub(super) const SHADOW_OPACITY: f32 = 0.35; /// Draw Filled Lines: how wide a filled line is. const FILLED_LINE_WIDTH: f64 = 3.0; -pub(super) const FILLED_LINE_BUDGET: usize = 20_000; +const FILLED_LINE_BUDGET: usize = 20_000; + +/// Does graph `g`, with `series` series, draw Filled Lines (up to FILLED_LINE_BUDGET data points)? +pub(super) fn filled_lines_on(g: &GraphSpec, series: usize) -> bool { + g.filled_lines && g.rows.len().saturating_mul(series) <= FILLED_LINE_BUDGET +} /// Most marks a graph draws shadows for (each is a copy). pub(super) const SHADOW_BUDGET: usize = 200_000; diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 1b864ba92..8ab2573ff 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,4 +1,5 @@ -use super::labels::{Role, label_style}; +use super::axis::radar_axis; +use super::labels::{Role, filled_lines_on, label_style}; use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; // ---------- generation ---------- @@ -121,11 +122,82 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo let node = doc.node(*id)?; let paint = node.appearance.paint_at(None, fill)?.clone(); let width = (!fill).then(|| node.appearance.stroke_width()); - Some((r.graph, r.index, paint, width)) + Some((r.graph, r.index, fill, paint, width)) }) .collect(); + store_series_paints(doc, captures) +} + +/// The paint of the topmost visible fill (`true`) or stroke (`false`) of `node`, and a stroke's weight (0 without paint). +/// With no such row: no paint. +pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) { + match node.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()) { + Some(vectorcraft_doc::AppearanceItem::Stroke(st)) if !st.paint.is_none() => (st.paint.clone(), st.width), + Some(it) => (it.paint().clone(), 0.0), + None => (Paint::None, 0.0), + } +} + +/// Copy the paint of the topmost visible fill (`true`) or stroke (`false`), and a stroke's weight, of generated series +/// art just edited (`edited`: each object with the kind of row, later entries replacing earlier ones) into that series' +/// fill or stroke; art left without such a row keeps no paint. A series that draws Filled Lines bands stores no stroke: its +/// bands keep their own outline. Called inside the same document edit, so both undo together. Returns whether a series' +/// captured fill or stroke changed. +pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool { + if edited.is_empty() { + return false; + } + let members = series_members(doc); + if members.is_empty() { + return false; + } + // Each series is checked once: the check reads the graph's whole grid. + let mut bands: HashMap<(NodeId, usize), bool> = HashMap::new(); + let mut captures = Vec::new(); + for (id, fill) in edited { + // Only the paths the graph draws again, not the copy of a graph design. + let Some(r) = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group)) else { continue }; + let Some(node) = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. })) else { continue }; + let drawn = || doc.node(r.graph).and_then(|n| n.extras().graph.as_deref()).is_some_and(|g| series_draws_bands(g, r.index)); + if !*fill && *bands.entry((r.graph, r.index)).or_insert_with(drawn) { + continue; + } + let (paint, width) = shown_paint(node, *fill); + captures.push((r.graph, r.index, *fill, paint, (!*fill).then_some(width))); + } + store_series_paints(doc, captures) +} + +/// Does series `i` draw any Filled Lines band? A line needs two neighboring values, a scatter series two neighboring points +/// in different places (its index counts pairs of columns), and a radar series two categories with a value plotted away +/// from the center. +fn series_draws_bands(g: &GraphSpec, i: usize) -> bool { + let series = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); + if !g.connect_points || !filled_lines_on(g, series) { + return false; + } + let cells = g.cells(); + let cell = |row: &[Option], s: usize| row.get(s).copied().flatten(); + match g.series_kind(i) { + GraphKind::Line => cells.windows(2).any(|w| w.iter().all(|row| cell(row, i).is_some())), + GraphKind::Radar => { + let (lo, hi, _) = radar_axis(g); + cells.len() > 1 && cells.iter().any(|row| (cell(row, i).unwrap_or(0.0) - lo) / (hi - lo) > 0.0) + } + GraphKind::Scatter => { + let s = i.saturating_mul(2); + let point = |row: &[Option]| Some((cell(row, s)?, cell(row, s.saturating_add(1))?)); + cells.windows(2).any(|w| matches!(w, [a, b] if point(a).is_some() && point(b).is_some() && point(a) != point(b))) + } + _ => false, + } +} + +/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a +/// series' captured fill or stroke changed. +fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option)>) -> bool { let mut changed = false; - for (graph, index, paint, width) in captures { + for (graph, index, fill, paint, width) in captures { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; let was = spec.series_paints.clone(); // `index` is below the graph's series count (`series_members`). @@ -144,7 +216,7 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo changed } -/// Copy the opacity of the topmost fill (`true`) or stroke (`false`) of generated series art just edited (`edited`: +/// Copy the opacity of the topmost visible fill (`true`) or stroke (`false`) of generated series art just edited (`edited`: /// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind /// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity /// changed. @@ -162,7 +234,8 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool // Only the paths the graph draws again, not the copy of a graph design. let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?; let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?; - let opacity = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill).map_or(1.0, vectorcraft_doc::AppearanceItem::opacity); + let row = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill && it.visible()); + let opacity = row.map_or(1.0, vectorcraft_doc::AppearanceItem::opacity); Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity))) }) .collect(); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 2be4bbf48..89bb7a4c0 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -503,6 +503,760 @@ fn a_stroke_opacity_on_a_series_line_survives_regeneration() { assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6)); } +/// The colors of series `index`'s marks: each mark's top fill and stroke paint and its stroke weight. +fn marks_of(s: &Session, id: NodeId, index: u32) -> Vec<(Paint, Paint, f64)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, index).children().unwrap().to_vec(); + marks.iter().map(|m| (m.appearance.fill_paint(), m.appearance.stroke_paint(), m.appearance.stroke_width())).collect() +} + +/// Set the top fill of series 0's first mark to the color `hex` with `appearance.setItem`, and return that paint. +fn hex_paint(s: &mut Session, id: NodeId, hex: &str) -> Paint { + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + let row = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": hex})).unwrap(); + s.doc().unwrap().doc.node(bar).unwrap().appearance.fill_paint() +} + +/// A color or weight set with `appearance.setItem` on the topmost fill or stroke of one series mark is kept by the +/// graph: every mark of that series has it after the graph is drawn again, and undo takes it back with its edit. A row +/// under the topmost one is not kept, and a change to one kind of row keeps nothing for the other. +#[test] +fn an_appearance_item_paint_on_a_series_mark_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let blue = hex_paint(&mut s, id, "#0000ff"); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(blue.clone()), None, None)); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + let row = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.iter().rposition(|it| !it.is_fill()).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": "#ff0000", "weight": 3})).unwrap(); + let red = s.doc().unwrap().doc.node(bar).unwrap().appearance.stroke_paint(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke, kept.stroke_width), (Some(red.clone()), Some(3.0))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| *m == (blue.clone(), red.clone(), 3.0)), "{:?}", marks_of(&s, id, 0)); + assert!(marks_of(&s, id, 1).iter().all(|m| m.0 != blue)); + // Undo goes back through the data edit, then takes the stroke paint away with its edit. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(blue.clone()), None, None)); + // The graph does not keep a color set on a fill under the topmost one. + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap(); + let (lower, _) = fill_rows(&s, bar); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": lower, "color": "#00ff00"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue)); +} + +/// A weight set on a stroke row, with no color, is kept by the graph with the row's paint. +#[test] +fn an_appearance_item_weight_on_a_series_mark_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + let row = bar.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [bar.id.0], "index": row, "color": "#ff0000"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [bar.id.0], "index": row, "weight": 4})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(4.0)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.2 == 4.0), "{:?}", marks_of(&s, id, 0)); +} + +/// A column graph with series 0 kept blue at 40% opacity: the graph, the paint, the first marks of series 0 and 1. +fn blue_source() -> (Session, NodeId, Paint, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let blue = hex_paint(&mut s, id, "#0000ff"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (from, to) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id); + s.execute("transparency.set", &json!({"ids": [from.0], "item": 0, "opacity": 40})).unwrap(); + (s, id, blue, from, to) +} + +/// After the graph is drawn again, every mark of series 1 is `blue` at 40%; undo goes back through the data edit and the +/// command before it, which restores the paints the graph kept in `was`. +fn series_1_is_blue_at_40_then_undo(s: &mut Session, id: NodeId, blue: &Paint, was: &[vectorcraft_doc::SeriesPaint]) { + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + for m in series(&n, 1).children().unwrap() { + let f = m.appearance.fill().unwrap(); + assert_eq!((f.paint.clone(), f.opacity), (blue.clone(), 0.4)); + } + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(s, id).series_paints, was); +} + +/// The topmost fill (`true`) or stroke (`false`) row of `mark`. +fn top_row(s: &Session, mark: NodeId, fill: bool) -> usize { + s.doc().unwrap().doc.node(mark).unwrap().appearance.items.iter().rposition(|it| it.is_fill() == fill).unwrap() +} + +/// A new column graph, the fill of series 0's marks and those marks' ids. +fn column_marks() -> (Session, NodeId, Paint, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, 0).children().unwrap().to_vec(); + (s, id, marks[0].appearance.fill_paint(), marks.iter().map(|m| m.id).collect()) +} + +/// An appearance moved onto a mark of another series makes the graph keep its paint with its opacity for that series, +/// and leaves the source series without paint. +#[test] +fn moving_an_appearance_onto_a_series_mark_keeps_its_paint() { + let (mut s, id, blue, from, to) = blue_source(); + let was = spec(&s, id).series_paints; + s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0})).unwrap(); + let paints = spec(&s, id).series_paints; + assert_eq!((paints[0].fill.clone(), paints[1].fill.clone()), (Some(Paint::None), Some(blue.clone()))); + series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was); +} + +/// An appearance copied onto a mark of another series makes the graph keep its paint with its opacity for that series. +#[test] +fn copying_an_appearance_onto_a_series_mark_keeps_its_paint() { + let (mut s, id, blue, from, to) = blue_source(); + let was = spec(&s, id).series_paints; + s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0, "copy": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone())); + series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was); +} + +/// An Eyedropper pick onto a mark of another series makes the graph keep its paint with its opacity for that series. +#[test] +fn an_eyedropper_pick_onto_a_series_mark_keeps_its_paint() { + let (mut s, id, blue, from, to) = blue_source(); + let was = spec(&s, id).series_paints; + s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": [to.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone())); + series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was); +} + +/// A reversed Eyedropper (Alt-click) copies onto the clicked mark, and the graph keeps the paint for the clicked series. +#[test] +fn a_reversed_eyedropper_pick_keeps_its_paint_on_the_clicked_series() { + let (mut s, id, blue, from, to) = blue_source(); + let was = spec(&s, id).series_paints; + s.execute("appearance.copyFrom", &json!({"source": to.0, "ids": [from.0], "reverse": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone())); + series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was); +} + +/// A reversed Eyedropper with nothing selected copies what it picked up last onto the clicked mark. +#[test] +fn a_reversed_eyedropper_pick_of_the_last_sample_keeps_its_paint() { + let (mut s, id, blue, from, to) = blue_source(); + s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": []})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": to.0, "reverse": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue)); +} + +/// Clear Appearance on a series mark leaves it with no fill and no stroke, and the series stays that way when the graph +/// is drawn again. Undo brings the kept paint back. +#[test] +fn clearing_a_series_marks_appearance_updates_its_kept_paint() { + let (mut s, id, _, marks) = column_marks(); + let row = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": row, "color": "#ff0000", "weight": 2})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("appearance.clear", &json!({"ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(Paint::None), Some(Paint::None), Some(0.0))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| *m == (Paint::None, Paint::None, 0.0)), "{:?}", marks_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// A mark with `blue` under a second fill: the graph, the paint, the mark and its (lower, upper) fill rows, with the +/// upper row green and kept. +fn two_fills() -> (Session, NodeId, Paint, NodeId, usize, usize) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let blue = hex_paint(&mut s, id, "#0000ff"); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap(); + let (lower, upper) = fill_rows(&s, bar); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "color": "#00ff00"})).unwrap(); + assert_ne!(spec(&s, id).series_paints[0].fill, Some(blue.clone())); + (s, id, blue, bar, lower, upper) +} + +/// `cmd` makes the graph keep `blue`, and undo brings back the paints it kept before. +fn top_row_command_keeps_blue(s: &mut Session, id: NodeId, blue: &Paint, cmd: &str, p: &serde_json::Value) { + let was = spec(s, id).series_paints; + s.execute(cmd, p).unwrap(); + assert_eq!(spec(s, id).series_paints[0].fill.as_ref(), Some(blue), "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(s, id).series_paints, was, "{cmd}"); +} + +/// Reduce to Basic Appearance with the lower fill as the active row leaves that fill, and the graph keeps its paint. +#[test] +fn reducing_a_series_marks_appearance_keeps_the_row_it_leaves() { + let (mut s, id, blue, bar, lower, _) = two_fills(); + s.execute("select.set", &json!({"ids": [bar.0]})).unwrap(); + s.execute("appearance.setActiveItem", &json!({"index": lower})).unwrap(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.reduceToBasic", &json!({})); +} + +/// Removing a mark's top row makes the graph keep the paint now on top. +#[test] +fn removing_a_series_marks_top_row_keeps_the_paint_now_on_top() { + let (mut s, id, blue, bar, _, upper) = two_fills(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.removeItem", &json!({"ids": [bar.0], "index": upper})); +} + +/// Moving a lower row above the others makes the graph keep its paint. +#[test] +fn moving_a_row_to_a_series_marks_top_keeps_its_paint() { + let (mut s, id, blue, bar, lower, upper) = two_fills(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.moveItem", &json!({"ids": [bar.0], "from": lower, "to": upper})); +} + +/// Moving a block of rows above the others makes the graph keep the paint now on top. +#[test] +fn moving_a_block_of_rows_to_a_series_marks_top_keeps_its_paint() { + let (mut s, id, blue, bar, lower, upper) = two_fills(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.moveItem", &json!({"ids": [bar.0], "indices": [lower], "to": upper})); +} + +/// A copy of a lower row placed on top makes the graph keep its paint. +#[test] +fn a_copy_placed_on_a_series_marks_top_keeps_its_paint() { + let (mut s, id, blue, bar, lower, upper) = two_fills(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.duplicateItem", &json!({"ids": [bar.0], "index": lower, "to": upper + 1})); +} + +/// The mark's only stroke row removed: the series draws without a stroke; a new row comes back as its paint. +#[test] +fn removing_and_adding_a_series_marks_only_stroke_updates_its_kept_paint() { + let (mut s, id, _, marks) = column_marks(); + let bar = marks[0]; + let row = top_row(&s, bar, false); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.removeItem", &json!({"ids": [bar.0], "index": row})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (None, Some(Paint::None), Some(0.0))); + let was = spec(&s, id).series_paints; + s.execute("appearance.addStroke", &json!({"ids": [bar.0]})).unwrap(); + let stroke = s.doc().unwrap().doc.node(bar).unwrap().appearance.stroke_paint(); + assert!(!stroke.is_none()); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(stroke)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// Hiding the top row makes the graph keep the visible row under it, and its opacity; hiding the only one keeps no paint; +/// Show All Hidden Attributes brings the top row back. A hidden row on top of a moved appearance is not kept. +#[test] +fn a_hidden_top_row_is_not_what_the_graph_keeps() { + let (mut s, id, blue, bar, lower, upper) = two_fills(); + let green = s.doc().unwrap().doc.node(bar).unwrap().appearance.fill_paint(); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": lower, "opacity": 50})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None, "the row under the top one"); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == blue), "{:?}", marks_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("appearance.showAllHidden", &json!({"ids": [bar.0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(green), None)); + + // A transfer of [blue, hidden green] onto another series keeps the blue the source shows. + let (mut s, id, blue, bar, _, upper) = two_fills(); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap(); + let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.transfer", &json!({"source": bar.0, "target": to.0, "copy": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue)); + + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "visible": false})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None)); +} + +/// Hiding a top row and Show All Hidden Attributes each undo in one step, with what the graph stores. +#[test] +fn hiding_and_showing_a_series_marks_top_row_undo_with_the_stored_paint() { + let (mut s, id, _, bar, _, upper) = two_fills(); + let was = spec(&s, id).series_paints; + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap(); + let hidden = spec(&s, id).series_paints; + assert_ne!(hidden, was); + s.execute("appearance.showAllHidden", &json!({"ids": [bar.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, hidden); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// An opaque Eyedropper pick onto a mark replaces the opacity another mark of the series stored, even though the mark +/// itself stays opaque: the series is drawn with the picked color at 100%. +#[test] +fn an_opaque_pick_replaces_the_opacity_the_series_stores() { + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4)); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "color": "#0000ff"})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(series(&n, 0).children().unwrap().iter().all(|m| m.appearance.fill().unwrap().opacity == 1.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + + // A pick of the opacity alone replaces it too, and a pick that leaves opacity out keeps it. + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + let colors_only = json!({"appearance": {"fill": {"color": true, "transparency": false}, "stroke": {"color": false}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": colors_only})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4)); + let opacity_only = json!({"appearance": {"fill": {"color": false, "transparency": true}, "stroke": {"color": false}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": opacity_only})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None); +} + +/// An appearance moved between two marks of one series leaves the series with the target's paint, not the source's +/// empty one, and the series still shows when the graph is drawn again. +#[test] +fn moving_an_appearance_within_a_series_keeps_the_targets_paint() { + let (mut s, id, shown, marks) = column_marks(); + s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": marks[1].0})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(shown.clone())); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == shown), "{:?}", marks_of(&s, id, 0)); +} + +/// A row shown again with the color the mark already shows replaces the paint another mark of the series stored. +#[test] +fn showing_a_row_with_the_color_a_mark_shows_replaces_the_kept_paint() { + let (mut s, id, shown, marks) = column_marks(); + s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap(); + let (_, upper) = fill_rows(&s, marks[0]); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap(); + let other = top_row(&s, marks[1], true); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": other, "color": "#ff0000"})).unwrap(); + assert_ne!(spec(&s, id).series_paints[0].fill, Some(shown.clone())); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": true, "color": "#000000"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint())); +} + +/// A color or weight set again to what a mark already shows and an Eyedropper pick that leaves the mark as it was replace +/// the paint another mark of the series stored, in an undo step of their own. Clear Appearance on a clear mark leaves the +/// stored paint and the undo history as they are. +#[test] +fn writing_the_paint_a_series_mark_already_shows_replaces_the_kept_paint() { + let (mut s, id, shown, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + assert_ne!(spec(&s, id).series_paints[0].fill, Some(shown.clone())); + let was = spec(&s, id).series_paints; + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "color": "#000000"})).unwrap(); + let again = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(again)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + for (mark, weight) in [(marks[1], 2), (marks[0], 5), (marks[1], 2)] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": stroke, "color": "#ff0000", "weight": weight})).unwrap(); + } + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0)); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "weight": 5})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "weight": 2})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0)); + + let (mut s, id, shown, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": marks[2].0, "ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(shown)); + + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + let was = spec(&s, id).series_paints; + let shown = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(); + s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + s.execute("edit.undo", &json!({})).unwrap(); + assert_ne!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(), shown, "the undo took back the color, not a clear"); +} + +/// A line graph with a marker: the graph, the first thin line, a marker. +fn line_and_marker() -> (Session, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "line"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, 0).children().unwrap().to_vec(); + let line = marks.iter().find(|m| m.appearance.stroke_width() > 0.0).unwrap().id; + let marker = marks.iter().find(|m| m.appearance.stroke_width() == 0.0 && m.appearance.stroke().is_some()).unwrap().id; + (s, id, line, marker) +} + +/// The first thin line of series 0, and every other path of the series (its markers and legend swatch). +fn line_parts(s: &Session, id: NodeId) -> (vectorcraft_doc::Node, Vec) { + let parts = series_parts(s, id, 0); + let (lines, others): (Vec<_>, Vec<_>) = + parts.into_iter().partition(|m| m.appearance.fill_paint().is_none() && m.appearance.stroke_width() == 1.0); + (lines[0].clone(), others) +} + +/// On a line graph, a stroke set on the thin line goes on the line after a redraw; a fill set on the thin line goes on +/// the markers and the legend swatch, and the line stays without a fill. +#[test] +fn a_line_series_draws_its_kept_stroke_on_the_line_and_its_kept_fill_on_the_markers() { + let (mut s, id, line, _) = line_and_marker(); + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap(); + let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint(); + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, true), "color": "#0000ff"})).unwrap(); + let blue = s.doc().unwrap().doc.node(line).unwrap().appearance.fill_paint(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let (drawn, others) = line_parts(&s, id); + assert_eq!((drawn.appearance.fill_paint(), drawn.appearance.stroke_paint()), (Paint::None, red)); + assert!(others.iter().all(|m| m.appearance.fill_paint() == blue), "{others:?}"); +} + +/// Clear Appearance on a thin line leaves the fill the graph stores for the markers; on a marker that shows no stroke, it +/// leaves the stroke the graph stores for the line. An Eyedropper pick of a stroke-only source onto the line and a copy +/// of one line onto another leave the stored fill too. +#[test] +fn a_row_left_without_paint_leaves_the_paint_the_series_stores() { + let (mut s, id, line, marker) = line_and_marker(); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, true), "color": "#0000ff"})).unwrap(); + let blue = spec(&s, id).series_paints[0].fill.clone().unwrap(); + s.execute("appearance.clear", &json!({"ids": [line.0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.as_ref(), kept.stroke.as_ref()), (Some(&blue), Some(&Paint::None))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let parts = series_parts(&s, id, 0); + assert!(parts.iter().filter(|m| !m.appearance.fill_paint().is_none()).all(|m| m.appearance.fill_paint() == blue)); + assert!(parts.iter().any(|m| m.appearance.fill_paint() == blue), "the markers keep their fill"); + + let (mut s, id, line, marker) = line_and_marker(); + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap(); + let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint(); + s.execute("appearance.clear", &json!({"ids": [marker.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()), "the marker showed no stroke"); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(line_parts(&s, id).0.appearance.stroke_paint(), red); + + let (mut s, id, line, marker) = line_and_marker(); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, true), "color": "#0000ff"})).unwrap(); + let blue = spec(&s, id).series_paints[0].fill.clone().unwrap(); + let other = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.copyFrom", &json!({"source": other.0, "ids": [line.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue.clone()), "the source's line has no fill"); + s.execute("appearance.transfer", &json!({"source": other.0, "target": line.0, "copy": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue)); +} + +/// A line graph with Filled Lines on: the session, the graph and series 0's band. +fn band_graph() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "line", ",a\nQ1,2\nQ2,3\nQ3,2"); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap(); + let band = series_parts(&s, id, 0)[0].id; + (s, id, band) +} + +/// The band's fill, outline paint and outline weight after the graph is drawn again. +fn redrawn_band(s: &mut Session, id: NodeId) -> (Paint, Paint, f64) { + s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [4]]})).unwrap(); + let band = series_parts(s, id, 0)[0].clone(); + (band.appearance.fill_paint(), band.appearance.stroke_paint(), band.appearance.stroke_width()) +} + +/// A fill set on a Filled Lines band is kept, and the band keeps its thin black outline. +#[test] +fn a_filled_line_band_keeps_a_fill_set_on_it() { + let (mut s, id, band) = band_graph(); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": top_row(&s, band, true), "color": "#0000ff"})).unwrap(); + let blue = s.doc().unwrap().doc.node(band).unwrap().appearance.fill_paint(); + assert_eq!(redrawn_band(&mut s, id), (blue, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); +} + +/// A stroke set on a Filled Lines band is not kept: the band keeps its color and its thin black outline. +#[test] +fn a_stroke_set_on_a_filled_line_band_does_not_repaint_it() { + let (mut s, id, band) = band_graph(); + let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); + let row = top_row(&s, band, false); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "color": "#ff0000", "weight": 3})).unwrap(); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none())); + assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); +} + +/// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the +/// band its color: an empty stored stroke is not a color for the band. +#[test] +fn removing_a_filled_line_bands_stroke_leaves_its_color() { + let (mut s, id, band) = band_graph(); + let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); + s.execute("appearance.removeItem", &json!({"ids": [band.0], "index": top_row(&s, band, false)})).unwrap(); + assert_eq!(redrawn_band(&mut s, id).0, was); + + let (mut s, id, band) = band_graph(); + let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); + s.execute("paint.setStroke", &json!({"ids": [band.0], "none": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None)); + assert_eq!(redrawn_band(&mut s, id).0, was); +} + +/// With Connect Data Points off a Filled Lines graph draws no bands, so a stroke set on a marker is kept. +#[test] +fn a_stroke_on_a_marker_is_kept_when_filled_lines_draws_no_bands() { + let (mut s, id, _) = band_graph(); + s.execute("graph.setType", &json!({"id": id.0, "connectPoints": false})).unwrap(); + let marker = series_parts(&s, id, 0)[0].id; + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, false), "color": "#ff0000"})).unwrap(); + assert!(spec(&s, id).series_paints[0].stroke.as_ref().is_some_and(|p| !p.is_none())); +} + +/// A Filled Lines graph of `ty` and `csv` draws no bands, so a stroke set on its first mark is stored and stays on it after +/// the graph is drawn again. +fn stroke_is_kept_without_bands(ty: &str, csv: &str) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, ty, csv); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap(); + let mark = series_parts(&s, id, 0)[0].id; + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": "#ff0000", "weight": 2})).unwrap(); + let red = s.doc().unwrap().doc.node(mark).unwrap().appearance.stroke_paint(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone())); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let drawn = &series_parts(&s, id, 0)[0]; + assert_eq!((drawn.appearance.stroke_paint(), drawn.appearance.stroke_width()), (red, 2.0)); +} + +#[test] +fn a_stroke_is_kept_on_a_one_row_line_series() { + stroke_is_kept_without_bands("line", ",a\nQ1,2"); +} + +#[test] +fn a_stroke_is_kept_on_a_radar_series_of_zeros() { + stroke_is_kept_without_bands("radar", ",a\nQ1,0\nQ2,0\nQ3,0"); +} + +#[test] +fn a_stroke_is_kept_on_a_scatter_series_of_one_repeated_point() { + stroke_is_kept_without_bands("scatter", "a,b\n1,2\n1,2"); +} + +/// A radar series whose values all fall below `axisMin` is plotted at the center and draws no band, so a stroke set on its +/// marks is kept. A radar series with a value above `axisMin` draws a band and stores no stroke. +#[test] +fn a_radar_series_draws_a_band_only_for_values_away_from_the_center() { + for (csv, kept) in [(",a\nQ1,1\nQ2,2\nQ3,3", true), (",a\nQ1,1\nQ2,15\nQ3,3", false)] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "radar", csv); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true, "axisMin": 10, "axisMax": 20})).unwrap(); + let mark = series_parts(&s, id, 0)[0].id; + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": "#ff0000", "weight": 2})).unwrap(); + assert_eq!(spec(&s, id).series_paints.first().is_some_and(|p| p.stroke.is_some()), kept, "{csv}"); + } +} + +/// Past the Filled Lines budget the graph draws thin lines, and a stroke set on one is kept. +#[test] +fn a_stroke_on_a_thin_line_past_the_filled_lines_budget_is_kept() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let rows: Vec> = (0..8_000).map(|i| vec![(i % 7) as f64, 1.0, 2.0]).collect(); + let id = NodeId( + s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true, "markPoints": false})).unwrap(); + let line = series_parts(&s, id, 0)[0].id; + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap(); + assert!(spec(&s, id).series_paints[0].stroke.as_ref().is_some_and(|p| !p.is_none())); +} + +/// A weight of 0 set again on a painted stroke replaces the weight another mark of the series left on the graph. +#[test] +fn a_zero_weight_on_a_painted_stroke_replaces_the_kept_weight() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 0})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0)); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "weight": 0})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(0.0)); +} + +/// An Eyedropper `append` of a source with no stroke leaves the stroke the series keeps. +#[test] +fn appending_a_fill_only_appearance_leaves_the_kept_stroke() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.removeItem", &json!({"ids": [source.0], "index": top_row(&s, source, false)})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0)); + // A pick of the weight alone from that source leaves the mark and what the series keeps as they were. + let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": false, "weight": true}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0)); +} + +/// An Eyedropper `append` of a source with only a hidden fill leaves the fill the series keeps. +#[test] +fn appending_a_hidden_fill_leaves_the_kept_fill() { + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + let kept = spec(&s, id).series_paints[0].fill.clone(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "visible": false})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, kept); +} + +/// An Eyedropper pick of the weight alone reads a hidden stroke too, and that weight replaces the one the series keeps. +#[test] +fn picking_the_weight_of_a_hidden_stroke_replaces_the_kept_weight() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + let row = top_row(&s, source, false); + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#00ff00", "weight": 2, "visible": false})).unwrap(); + let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": false, "weight": true}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0)); +} + +/// An Eyedropper pick of some attributes edits the mark's topmost stroke. When that row is hidden, the series keeps what +/// it stores instead of taking the visible row under it. +#[test] +fn a_partial_pick_onto_a_hidden_top_row_leaves_the_kept_stroke() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.addStroke", &json!({"ids": [marks[1].0]})).unwrap(); + let hidden = top_row(&s, marks[1], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": hidden, "visible": false})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + let row = top_row(&s, source, false); + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#00ff00", "weight": 3})).unwrap(); + let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": true, "weight": true}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0], kept); +} + +/// An Eyedropper `append` writes a visible row under the source's hidden top row, and the series stores that row's paint +/// even when the mark already shows the same paint. +#[test] +fn appending_a_visible_row_under_a_hidden_one_replaces_the_kept_stroke() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + let row = top_row(&s, source, false); + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.addStroke", &json!({"ids": [source.0]})).unwrap(); + let hidden = top_row(&s, source, false); + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": hidden, "visible": false})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap(); + let kept = &spec(&s, id).series_paints[0]; + assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))); + assert_eq!(kept.stroke_width, Some(2.0)); +} + +/// A weight set on a stroke without paint (a line graph's marker) leaves the paint the series keeps for its line. +#[test] +fn a_weight_on_a_stroke_without_paint_leaves_the_kept_stroke() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "line"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, 0).children().unwrap().to_vec(); + let line = marks.iter().find(|m| m.appearance.stroke_width() > 0.0).unwrap().id; + let marker = marks.iter().find(|m| m.appearance.stroke_width() == 0.0 && m.appearance.stroke().is_some()).unwrap().id; + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.stroke.as_ref().is_some_and(|p| !p.is_none())); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, false), "weight": 4})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0], kept); +} + +/// The graph does not keep a paint set on a row of a graph design's copy inside a series. A stroke edit on a line keeps +/// no fill, and a blend mode alone leaves what the graph keeps as it was. +#[test] +fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + save_design(&mut s, "Square"); + s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap(); + let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + assert!(matches!(copy.kind, NodeKind::Group { .. })); + s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [copy.id.0], "index": 0, "color": "#0000ff"})).unwrap(); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none())); + // A path inside the design's copy is a member of the series too, and its paint is not kept either. + let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + let mut paths = vec![]; + copy.walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. }) { + paths.push(n.id); + } + }); + let art = paths[0]; + s.execute("appearance.setItem", &json!({"ids": [art.0], "index": top_row(&s, art, true), "color": "#00ff00"})).unwrap(); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none())); + + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "line"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let line = series(&n, 0).children().unwrap().iter().find(|m| m.appearance.stroke().is_some_and(|st| st.width > 0.0)).unwrap().clone(); + let row = line.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [line.id.0], "index": row, "color": "#0000ff"})).unwrap(); + assert!(spec(&s, id).series_paints[0].fill.is_none(), "a stroke edit on a line keeps no fill"); + + // A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it. + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + let kept = spec(&s, id); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "multiply"})).unwrap(); + assert_eq!(spec(&s, id), kept); +} + /// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out. #[test] fn a_kept_series_opacity_from_a_file_is_held_to_its_range() { diff --git a/docs/mcp.md b/docs/mcp.md index 1adcf32b1..ee9b6a9dc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -534,6 +534,20 @@ stroke instead of the whole object: `run_command effect.apply {"effect": "path.o `effect.remove`, `effect.setParams` (`visible` toggles one) and `effect.duplicate` address that item's effects; `apply_effect` uses the active item. `effect.list` reports each object's item effects under `applied[].items`. +On a path directly inside a graph series, `appearance.setItem`, `appearance.addFill`, `appearance.addStroke`, +`appearance.removeItem`, `appearance.moveItem`, `appearance.duplicateItem`, `appearance.clear`, +`appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer` and `appearance.copyFrom` store the paint, +weight and opacity of the topmost visible fill and stroke on the graph. When the graph is drawn again, columns, bars, +wedges, areas, markers and legend swatches take the stored fill and stroke. A thin line takes the stored stroke and has +no fill. A Filled Lines band takes the stored fill, or the stored stroke when no fill is stored and that stroke has +paint, and has a 0.25 pt black outline, so these commands do not store a stroke for a series that draws a band. Graph +designs keep their own paint. A row that had no paint before the command and has none after it does not change what the +graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other +store the topmost row of the kind even when it is hidden, and store a stroke set on a band. +`stroke.set`, `paint.editGradient`, `paint.swap`, `paint.default`, graphic styles and the recolor commands do not store a +series' paint. An `appearance.*` command given a series group's id edits the group's own rows, and the graph does not +store them. + `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape those outlines. On paths, compound paths and images it does not change what is drawn. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..aac729730 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -840,7 +840,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the opacity set on a mark's top fill or stroke row, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the paint, weight and opacity of a mark's top visible fill or stroke row after an Appearance command or the Eyedropper, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. From 434cf3e692dadbdcd482d249ed9b902948fd45d7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 02:38:40 -0800 Subject: [PATCH 2/5] Graphs: graphic styles and Default and Swap Fill and Stroke update a series' stored paint Applying a graphic style, Redefine Graphic Style, Default Fill and Stroke and Swap Fill and Stroke on a path in a graph series changed the path but not the paint the graph stores for the series. When the graph was drawn again, the series went back to its earlier paint. These commands now store the mark's topmost visible fill and stroke on the graph, in the same undo step as the command: - graphicStyle.apply stores the paint, weight and opacity of both kinds of row. With `add` it stores only the kinds of row that are visible in the style. - graphicStyle.redefine stores the paint, weight and opacity of the marks still linked to the style. - paint.default stores the white fill and the 1 pt black stroke, both opaque. - paint.swap stores the swapped paints and the stroke weight and keeps the stored opacity. It swaps the topmost rows even when they are hidden, and a hidden row it swaps is not stored. The rule for each kind of mark is the one docs/mcp.md gives for the Appearance commands. docs/mcp.md also says what the graph does not store from a graphic style and that a series group given to a graphic style command is not stored. The params text, docs/mcp.md and the Graphs line in docs/target-app-parity.md list the commands. --- crates/engine/src/cmd/appearance.rs | 29 +++++ crates/engine/src/cmd/graph/tests.rs | 163 +++++++++++++++++++++++++++ crates/engine/src/cmd/paint.rs | 11 +- crates/engine/src/cmd/style.rs | 16 ++- docs/mcp.md | 27 +++-- docs/target-app-parity.md | 2 +- 6 files changed, 228 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 6675c63d0..9ddd21279 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -653,6 +653,35 @@ fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator bool { + let top_shows = |d: &vectorcraft_doc::Document, id: NodeId, kind: bool| { + let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); + items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(AppearanceItem::visible) + }; + let written = |d: &vectorcraft_doc::Document, (fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { + let rows = ids.iter().flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]).flatten(); + painted_rows(d, rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind))) + }; + let mut opacity_rows = top_opacity_changed(before, d, ids); + opacity_rows.extend(written(d, opacities)); + let mut rows = top_paint_changed(before, d, ids); + rows.extend(written(d, paints)); + let kept = super::graph::capture_series_opacity(d, &opacity_rows); + let painted = super::graph::capture_series_top_paints(d, &rows); + kept || painted +} + /// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A /// group keeps no fill or stroke rows of its own. fn clear(n: &mut Node) { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 89bb7a4c0..90e9c2833 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1257,6 +1257,169 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { assert_eq!(spec(&s, id), kept); } +/// A graphic style `name` made from a new rectangle with a blue fill at 50% opacity, and without a stroke row unless +/// `stroke`: the rectangle and the blue paint. +fn blue_style(s: &mut Session, name: &str, stroke: bool) -> (NodeId, Paint) { + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.setItem", &json!({"ids": [r.0], "index": top_row(s, r, true), "color": "#0000ff", "opacity": 50})).unwrap(); + if !stroke { + s.execute("appearance.removeItem", &json!({"ids": [r.0], "index": top_row(s, r, false)})).unwrap(); + } + s.execute("graphicStyle.new", &json!({"id": r.0, "name": name})).unwrap(); + let blue = s.doc().unwrap().doc.node(r).unwrap().appearance.fill_paint(); + (r, blue) +} + +/// Series 0's marks with a red 2 pt stroke stored from `marks[1]` and a 40% fill opacity stored from `marks[2]`. +fn stored_red_stroke() -> (Session, NodeId, Vec) { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[1], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[2].0], "index": top_row(&s, marks[2], true), "opacity": 40})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke_width, kept.fill_opacity), (Some(2.0), Some(0.4))); + (s, id, marks) +} + +/// After the graph is drawn again, every mark of series 0 has `fill` at `opacity` and `stroke` at `weight`; two undos (the +/// data edit, then the command) bring back the paints the graph stored in `was`. +fn series_0_is(s: &mut Session, id: NodeId, (fill, opacity, stroke, weight): (&Paint, f32, &Paint, f64), was: &[vectorcraft_doc::SeriesPaint]) { + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + for m in series(&n, 0).children().unwrap() { + let f = m.appearance.fill().unwrap(); + assert_eq!((&f.paint, f.opacity), (fill, opacity)); + assert_eq!((&m.appearance.stroke_paint(), m.appearance.stroke_width()), (stroke, weight)); + } + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(s, id).series_paints, was); +} + +/// A graphic style applied to a series mark is stored on the graph, its fill opacity included. A style without a stroke, +/// applied to a mark that shows one, leaves the series without a stroke. +#[test] +fn a_graphic_style_applied_to_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let (_, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the style and what the graph stored"); + s.execute("edit.redo", &json!({})).unwrap(); + series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was); +} + +/// A graphic style added on top of a series mark's appearance (Alt-click) stores the kinds of row the style shows: a style +/// with only a fill leaves the stroke the series stores. +#[test] +fn a_graphic_style_added_to_a_series_mark_keeps_only_its_own_kinds() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + let red = spec(&s, id).series_paints[0].stroke.clone().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + let (_, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0], "add": true})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0))); + series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was); +} + +/// A graphic style whose stroke row is hidden, added on top of a series mark (Alt-click), stores its fill and leaves the +/// stroke the series stores, although the mark shows a stroke of its own. +#[test] +fn a_graphic_style_with_a_hidden_stroke_added_to_a_series_mark_keeps_the_stored_stroke() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + let red = spec(&s, id).series_paints[0].stroke.clone().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + let (rect, blue) = blue_style(&mut s, "Blue", true); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, false), "visible": false})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Hidden stroke"})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Hidden stroke", "ids": [marks[0].0], "add": true})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0))); + series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was); +} + +/// Redefine Graphic Style restyles the series marks still linked to the style, and the graph stores the new look. +#[test] +fn redefining_a_graphic_style_updates_a_linked_series_mark() { + let (mut s, id, _, marks) = column_marks(); + let (rect, _) = blue_style(&mut s, "Blue", true); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00", "opacity": 100})).unwrap(); + let green = s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint(); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(), green, "the mark follows the style"); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(green.clone()), None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the redefinition and what the graph stored"); + s.execute("edit.redo", &json!({})).unwrap(); + let (stroke, weight) = { + let r = s.doc().unwrap().doc.node(rect).unwrap().clone(); + (r.appearance.stroke_paint(), r.appearance.stroke_width()) + }; + series_0_is(&mut s, id, (&green, 1.0, &stroke, weight), &was); +} + +/// Default Fill and Stroke on a series mark stores a white fill and a 1 pt black stroke, both opaque, on the graph. +#[test] +fn default_fill_and_stroke_on_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let was = spec(&s, id).series_paints; + s.execute("paint.default", &json!({"ids": [marks[0].0]})).unwrap(); + let (white, black) = (Paint::solid(vectorcraft_color::Color::WHITE), Paint::solid(vectorcraft_color::Color::BLACK)); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(white.clone()), None)); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(black.clone()), Some(1.0))); + series_0_is(&mut s, id, (&white, 1.0, &black, 1.0), &was); +} + +/// Swap Fill and Stroke on a series mark stores the swapped paints and the stroke weight, and leaves the opacity the series +/// stores. +#[test] +fn swapping_a_series_marks_fill_and_stroke_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + let (gray, green) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint()); + let was = spec(&s, id).series_paints; + s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(green.clone()), Some(0.4))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(gray.clone()), Some(3.0))); + series_0_is(&mut s, id, (&green, 0.4, &gray, 3.0), &was); +} + +/// Swap Fill and Stroke writes the topmost fill even when it is hidden: the visible fill under it does not replace the +/// fill the series stores. +#[test] +fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() { + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap(); + let red = spec(&s, id).series_paints[0].fill.clone(); + s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap(); + let (_, upper) = fill_rows(&s, marks[0]); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, red); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, red); +} + /// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out. #[test] fn a_kept_series_opacity_from_a_file_is_held_to_its_range() { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 390f6ef49..b3c0d0f90 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Swap Fill and Stroke", [], Some("Shift+X"), - "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults", + "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight (exceptions: docs/mcp.md)", has_doc, swap ), @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Default Fill and Stroke", [], Some("D"), - "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke", + "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, default_paint ), @@ -783,7 +783,9 @@ fn swap(s: &mut Session, p: &Value) -> Result { restore_unpainted_stroke(&mut n.appearance, None, &f); n.appearance.set_stroke(f); } - Ok(super::appearance::count_changed(d, &before, &ids)) + // The swap writes the paint of the topmost fill and stroke, not their opacity. + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 { @@ -811,7 +813,8 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { _ => n.appearance = Appearance::default_art().into(), } } - Ok(super::appearance::count_changed(d, &before, &ids)) + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } super::newart::reset(s); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index a15ea9ff4..6379aeac5 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows; exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {name}", has_doc, style_redefine ), @@ -333,6 +333,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { s.new_art_style(&g, add); return Ok(json!({ "newArt": true })); } + let before = s.doc()?.doc.clone(); s.edit("Apply Graphic Style", |d, _| { if add { let g = d.graphic_styles[i].clone(); @@ -344,12 +345,17 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { n.extras_mut().graphic_style = None; } } + // The graph stores the rows of the kinds the style shows, now on top of each stack. + let shows = |kind: bool| g.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); + let kinds = (shows(true), shows(false)); + super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false); } else { d.graphic_style_id(i); let g = d.graphic_styles[i].clone(); for id in &ids { apply_style(d, *id, &g, chars); } + super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); } Ok(()) })?; @@ -456,6 +462,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let look = GraphicStyle::of(String::new(), captured(n), n); let name = d.graphic_styles[i].name.clone(); let chars = s.prefs.override_char_color; + let before = s.doc()?.doc.clone(); s.edit_named("Redefine Graphic Style “{name}”", &name, false, |d, _| { let id = d.graphic_style_id(i); let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look }); @@ -465,9 +472,10 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); let g = d.graphic_styles[i].clone(); - for nid in follow { - apply_style(d, nid, &g, chars); + for nid in &follow { + apply_style(d, *nid, &g, chars); } + super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false); set_link(d, &[src], Some(id)); Ok(()) })?; diff --git a/docs/mcp.md b/docs/mcp.md index ee9b6a9dc..f23fec388 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -536,17 +536,22 @@ stroke instead of the whole object: `run_command effect.apply {"effect": "path.o On a path directly inside a graph series, `appearance.setItem`, `appearance.addFill`, `appearance.addStroke`, `appearance.removeItem`, `appearance.moveItem`, `appearance.duplicateItem`, `appearance.clear`, -`appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer` and `appearance.copyFrom` store the paint, -weight and opacity of the topmost visible fill and stroke on the graph. When the graph is drawn again, columns, bars, -wedges, areas, markers and legend swatches take the stored fill and stroke. A thin line takes the stored stroke and has -no fill. A Filled Lines band takes the stored fill, or the stored stroke when no fill is stored and that stroke has -paint, and has a 0.25 pt black outline, so these commands do not store a stroke for a series that draws a band. Graph -designs keep their own paint. A row that had no paint before the command and has none after it does not change what the -graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other -store the topmost row of the kind even when it is hidden, and store a stroke set on a band. -`stroke.set`, `paint.editGradient`, `paint.swap`, `paint.default`, graphic styles and the recolor commands do not store a -series' paint. An `appearance.*` command given a series group's id edits the group's own rows, and the graph does not -store them. +`appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer`, `appearance.copyFrom`, `paint.default`, +`graphicStyle.apply` and `graphicStyle.redefine` (on the marks still linked to the style) store the paint, weight and +opacity of the topmost visible fill and stroke on the graph. `graphicStyle.apply` with `add` stores only the kinds of row +that are visible in the style. `paint.swap` stores the swapped paints and the stroke weight, keeps the stored opacity, +and does not store a hidden topmost row that it swaps. The graph does not store the rest of a graphic style's look (more +fill and stroke rows, effects, the object's opacity and blend mode, and the link to the style), and a mark loses it when +the graph is drawn again. When the graph is drawn again, columns, bars, wedges, areas, markers and legend swatches take +the stored fill and stroke. A thin line takes the stored stroke and has no fill. A Filled Lines band takes the stored +fill, or the stored stroke when no fill is stored and that stroke has paint, and has a 0.25 pt black outline, so these +commands do not store a stroke for a series that draws a band. Graph designs keep their own paint. A row that had no +paint before the command and has none after it does not change what the graph stores. `paint.setFill`, +`paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind +even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do +not store a series' paint. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and +`graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store +them. `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index aac729730..6b57dc5ec 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -840,7 +840,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the paint, weight and opacity of a mark's top visible fill or stroke row after an Appearance command or the Eyedropper, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the paint, weight and opacity of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. From 5a9a08fcb14d63783d7c65da428b1cc9424797bb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 04:01:31 -0800 Subject: [PATCH 3/5] Graphs: a series keeps the object opacity and blend modes set on one of its marks An object opacity or blend mode set on a path directly in a graph series, and the blend mode of its topmost visible fill or stroke, were lost when the graph was drawn again. The graph now stores them per series next to the row opacity it already stores, and puts them on every path directly in the series when it draws the series again. - transparency.set and object.setProps store the object's opacity and blend mode. A value the mark already has still replaces the value another mark of the series stored. - appearance.clear, appearance.transfer, appearance.copyFrom with Transparency picked up, graphicStyle.apply without add and graphicStyle.redefine store the object's opacity and blend mode they leave on the mark. - The other Appearance and style commands store them when they change them on a mark. - Every command that stores the opacity of the topmost visible fill or stroke also stores its blend mode. A Filled Lines band's outline takes the stored stroke opacity and blend mode and keeps its black 0.25 pt stroke. - An opacity and blend mode are stored as a pair from the edited mark: setting only the opacity of one mark also stores that mark's blend mode. - A graph design's copy in a series keeps its own values. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/graph.rs | 14 ++ crates/engine/src/cmd/appearance.rs | 52 ++++-- crates/engine/src/cmd/graph.rs | 5 +- crates/engine/src/cmd/graph/series.rs | 118 ++++++++++-- crates/engine/src/cmd/graph/tests.rs | 222 ++++++++++++++++++++++- crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/cmd/object/props.rs | 9 +- crates/engine/src/cmd/paint.rs | 6 +- crates/engine/src/cmd/style.rs | 10 +- docs/mcp.md | 25 ++- docs/target-app-parity.md | 2 +- 11 files changed, 400 insertions(+), 65 deletions(-) diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index b19adc8b0..15f04ce15 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -321,6 +321,20 @@ pub struct SeriesPaint { /// Opacity (0..1) of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. #[serde(skip_serializing_if = "Option::is_none")] pub stroke_opacity: Option, + /// Blend mode of the topmost fill of each path directly in the series group, as [`Self::fill_opacity`]. Absent: Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub fill_blend: Option, + /// Blend mode of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. Absent: + /// Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub stroke_blend: Option, + /// Object opacity (0..1) of each path directly in the series group, taken from the object opacity set on one of them. + /// Absent: fully opaque. + #[serde(skip_serializing_if = "Option::is_none")] + pub opacity: Option, + /// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub blend: Option, } /// Graph data and options. `rows` are categories (one per row of the Graph Data window), each diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 9ddd21279..4c4873447 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", has_doc, clear_appearance ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, set_item ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: 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, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `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). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: 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, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `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). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", has_doc, transfer ), @@ -616,25 +616,33 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let object = super::graph::capture_series_object_opacity(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object)) })?; s.active_appearance_item = None; ok() } +/// Each of `ids` whose object opacity or blend mode is another in `d` than in `before`, for +/// [`super::graph::capture_series_object_opacity`]. +fn object_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { + let of = |d: &vectorcraft_doc::Document, id: NodeId| d.node(id).map(|n| (n.opacity, n.blend)); + ids.iter().copied().filter(|id| of(before, *id) != of(d, *id)).collect() +} + /// Each of `ids` with both kinds of row (fill, then stroke), for [`super::graph::capture_series_opacity`] after an /// edit that replaces whole stacks. fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> { ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect() } -/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity in `d` -/// than in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`]. +/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity or blend +/// mode in `d` than in `before` (no row counts as opaque and Normal), for [`super::graph::capture_series_opacity`]. fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> { let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| { d.node(id) - .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(AppearanceItem::opacity)) - .unwrap_or(1.0) + .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(|it| (it.opacity(), it.blend()))) + .unwrap_or((1.0, BlendMode::Normal)) }; ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect() } @@ -654,9 +662,10 @@ fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator bool { let top_shows = |d: &vectorcraft_doc::Document, id: NodeId, kind: bool| { let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); @@ -679,7 +689,9 @@ pub(super) fn capture_series_writes( rows.extend(written(d, paints)); let kept = super::graph::capture_series_opacity(d, &opacity_rows); let painted = super::graph::capture_series_top_paints(d, &rows); - kept || painted + let objects = if object { ids.to_vec() } else { object_opacity_changed(before, d, ids) }; + let stored = super::graph::capture_series_object_opacity(d, &objects); + kept || painted || stored } /// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A @@ -723,7 +735,8 @@ fn transfer(s: &mut Session, p: &Value) -> Result { let mut rows = top_paint_changed(&before, d, &changed); rows.extend(painted_rows(d, both_kinds(&[target]))); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted)) + let object = super::graph::capture_series_object_opacity(d, &changed); + Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted || object)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -840,10 +853,10 @@ fn set_item(s: &mut Session, p: &Value) -> Result { edited.push((*id, fill)); } } - // The graph keeps an opacity, paint or weight set on the topmost visible fill or stroke of its series art for when - // it is drawn again, and hiding or showing a row changes which row that is. + // The graph keeps an opacity, blend mode, paint or weight set on the topmost visible fill or stroke of its series art + // for when it is drawn again, and hiding or showing a row changes which row that is. let mut opacities = top_opacity_changed(&before, d, &ids); - if p.get("opacity").and_then(Value::as_f64).is_some() { + if p.get("opacity").and_then(Value::as_f64).is_some() || blend.is_some() { opacities.extend(edited.iter().copied()); } let kept = super::graph::capture_series_opacity(d, &opacities); @@ -1265,7 +1278,10 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { rows.extend(painted_rows(d, written(paints))); let kept = super::graph::capture_series_opacity(d, &opacity_rows); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &targets) + usize::from(kept || painted)) + // A pick of the object's transparency writes it on every target. + let objects = if attrs.appearance.transparency { targets.clone() } else { object_opacity_changed(&before, d, &targets) }; + let object = super::graph::capture_series_object_opacity(d, &objects); + Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object)) })?; Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 7a47457c2..8c838d421 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -42,7 +42,10 @@ use data::{fmt_value, parse_csv, to_csv}; use generate::generate; #[cfg(test)] use series::default_series_color; -pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, shown_paint}; +pub(crate) use series::{ + capture_label_styles, capture_series_object_opacity, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, + series_object_opacity_differs, shown_paint, +}; #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 8ab2573ff..7d7d75a16 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,3 +1,5 @@ +use vectorcraft_color::BlendMode; + use super::axis::radar_axis; use super::labels::{Role, filled_lines_on, label_style}; use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; @@ -26,26 +28,43 @@ pub(super) fn series_marks(g: &GraphSpec, i: usize, series_fill: bool, stroke: P (fill, stroke, width) } -/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity on their -/// topmost fill and stroke. Other art, such as a graph design, keeps its own opacity. A value read from a file is -/// held to 0..1, and one that is not a number is left out. +/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity and blend mode on +/// their topmost fill and stroke, and its kept object opacity and blend mode. Other art, such as a graph design, keeps its +/// own. An opacity read from a file is held to 0..1, and one that is not a number is left out. pub(super) fn with_series_opacity(g: &GraphSpec, i: usize, mut items: Vec>) -> Vec> { let Some(slot) = g.series_paints.get(i) else { return items }; let kept = |o: Option| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0)); - let (fill, stroke) = (kept(slot.fill_opacity), kept(slot.stroke_opacity)); - if fill.is_none() && stroke.is_none() { + let (fill, stroke, object) = (kept(slot.fill_opacity), kept(slot.stroke_opacity), kept(slot.opacity)); + if fill.is_none() && stroke.is_none() && object.is_none() && slot.fill_blend.is_none() && slot.stroke_blend.is_none() && slot.blend.is_none() { return items; } for item in &mut items { if !matches!(item.kind, NodeKind::Path { .. }) { continue; } - let ap = &mut Arc::make_mut(item).appearance; - if let (Some(o), Some(f)) = (fill, ap.fill_mut()) { - f.opacity = o; + let n = Arc::make_mut(item); + if let Some(o) = object { + n.opacity = o; } - if let (Some(o), Some(st)) = (stroke, ap.stroke_mut()) { - st.opacity = o; + if let Some(b) = slot.blend { + n.blend = b; + } + let ap = &mut n.appearance; + if let Some(f) = ap.fill_mut() { + if let Some(o) = fill { + f.opacity = o; + } + if let Some(b) = slot.fill_blend { + f.blend = b; + } + } + if let Some(st) = ap.stroke_mut() { + if let Some(o) = stroke { + st.opacity = o; + } + if let Some(b) = slot.stroke_blend { + st.blend = b; + } } } items @@ -216,10 +235,10 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P changed } -/// Copy the opacity of the topmost visible fill (`true`) or stroke (`false`) of generated series art just edited (`edited`: -/// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind -/// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity -/// changed. +/// Copy the opacity and blend mode of the topmost visible fill (`true`) or stroke (`false`) of generated series art just +/// edited (`edited`: each object with the kind of row) into that series' fill or stroke opacity and blend mode; art left +/// without a row of that kind keeps neither. Called inside the same document edit, so both undo together. Returns +/// whether a series' kept opacity or blend mode changed. pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool { if edited.is_empty() { return false; @@ -236,14 +255,15 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?; let row = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill && it.visible()); let opacity = row.map_or(1.0, vectorcraft_doc::AppearanceItem::opacity); - Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity))) + let blend = row.map_or(BlendMode::Normal, vectorcraft_doc::AppearanceItem::blend); + Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity), (blend != BlendMode::Normal).then_some(blend))) }) .collect(); let mut changed = false; - for (graph, index, fill, kept) in captures { + for (graph, index, fill, kept, blend) in captures { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; - // A fully opaque row on a series the graph keeps nothing for leaves the graph as it is. - if kept.is_none() && spec.series_paints.len() <= index { + // A fully opaque Normal row on a series the graph keeps nothing for leaves the graph as it is. + if kept.is_none() && blend.is_none() && spec.series_paints.len() <= index { continue; } let was = spec.series_paints.clone(); @@ -253,15 +273,73 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool } let Some(slot) = spec.series_paints.get_mut(index) else { continue }; if fill { - slot.fill_opacity = kept; + (slot.fill_opacity, slot.fill_blend) = (kept, blend); } else { - slot.stroke_opacity = kept; + (slot.stroke_opacity, slot.stroke_blend) = (kept, blend); } changed |= spec.series_paints != was; } changed } +/// Copy the object opacity and blend mode of generated series art just edited (`edited`) into that series' object opacity +/// and blend mode. Called inside the same document edit, so both undo together. Returns whether a series' kept object +/// opacity or blend mode changed. +pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool { + let mut changed = false; + for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + // A fully opaque Normal object on a series the graph keeps nothing for leaves the graph as it is. + if opacity.is_none() && blend.is_none() && spec.series_paints.len() <= index { + continue; + } + let was = spec.series_paints.clone(); + // `index` is below the graph's series count (`series_members`). + if spec.series_paints.len() <= index { + spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); + } + let Some(slot) = spec.series_paints.get_mut(index) else { continue }; + (slot.opacity, slot.blend) = (opacity, blend); + changed |= spec.series_paints != was; + } + changed +} + +/// Would [`capture_series_object_opacity`] change what the graph stores for `edited`? Reads the document without copying +/// it. +pub(crate) fn series_object_opacity_differs(doc: &Document, edited: &[NodeId]) -> bool { + let mut after: HashMap<(NodeId, usize), (Option, Option)> = HashMap::new(); + let stored = |graph: NodeId, index: usize| { + let spec = doc.node(graph).and_then(|n| n.extras().graph.as_deref()); + spec.and_then(|g| g.series_paints.get(index)).map_or((None, None), |slot| (slot.opacity, slot.blend)) + }; + for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) { + after.insert((graph, index), (opacity, blend)); + } + after.into_iter().any(|((graph, index), pair)| stored(graph, index) != pair) +} + +/// The series and object opacity and blend mode (None for 100% and Normal) of each of `edited` that is a path directly in a +/// series group, in order. +fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, usize, Option, Option)> { + if edited.is_empty() { + return Vec::new(); + } + let members = series_members(doc); + if members.is_empty() { + return Vec::new(); + } + edited + .iter() + .filter_map(|id| { + // Only the paths the graph draws again, not the copy of a graph design. + let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?; + let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?; + Some((r.graph, r.index, (node.opacity < 1.0).then_some(node.opacity), (node.blend != BlendMode::Normal).then_some(node.blend))) + }) + .collect() +} + /// Graph Data › Transpose row/column: categories become series and series categories. pub(super) fn transpose(g: &mut GraphSpec) { let cells = g.cells(); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 90e9c2833..ff85533ca 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1,5 +1,5 @@ use serde_json::json; -use vectorcraft_color::Paint; +use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind}; use crate::{NodeId, Session}; @@ -347,14 +347,14 @@ fn clearing_a_series_marks_appearance_forgets_its_kept_opacity() { } /// Removing the fill whose opacity the graph keeps takes the kept opacity away; an edit that leaves a mark opaque -/// (a blend mode alone) keeps nothing for its series. +/// and Normal (an opacity of 100% alone) keeps nothing for its series. #[test] fn removing_a_series_marks_fill_forgets_its_kept_opacity() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let id = graph(&mut s, "column"); let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; - s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "blend": "multiply"})).unwrap(); + s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 100})).unwrap(); assert!(spec(&s, id).series_paints.is_empty()); s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap(); assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4)); @@ -503,6 +503,198 @@ fn a_stroke_opacity_on_a_series_line_survives_regeneration() { assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6)); } +/// Each mark of series `index`: its object opacity and blend mode, and the blend modes of its top fill and stroke. +fn transparency_of(s: &Session, id: NodeId, index: u32) -> Vec<(f32, BlendMode, BlendMode, BlendMode)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, index).children().unwrap().to_vec(); + marks.iter().map(|m| (m.opacity, m.blend, m.appearance.fill().unwrap().blend, m.appearance.stroke().unwrap().blend)).collect() +} + +/// An object opacity and blend mode set on one series mark with no fill or stroke row picked are kept by the graph: +/// every mark of that series has them after the graph is drawn again, through save and open, the other series stay +/// opaque and Normal, 100% and Normal forget them, and undo takes them back. +#[test] +fn an_object_opacity_and_blend_mode_on_a_series_mark_survive_regeneration() { + let (mut s, id, _, marks) = column_marks(); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply))); + let restored: GraphSpec = serde_json::from_value(serde_json::to_value(spec(&s, id)).unwrap()).unwrap(); + assert_eq!(restored, spec(&s, id)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let normal = BlendMode::Normal; + assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (0.4, BlendMode::Multiply, normal, normal)), "{:?}", transparency_of(&s, id, 0)); + assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1)); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [bar.0], "opacity": 100, "blend": "normal"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the edit and what the graph stored"); +} + +/// `object.setProps` with the opacity a mark already shows replaces the opacity another mark of the series left on the +/// graph, in one undo step, and an opacity on a graph design's copy in a series is not kept. +#[test] +fn an_object_opacity_a_series_mark_already_shows_replaces_the_kept_opacity() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5)); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 100})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, None); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5)); + // Setting it again where the graph already stores it leaves the document and its history as they are. + let undo = s.doc().unwrap().history.undo.len(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + save_design(&mut s, "Square"); + s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap(); + let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + assert!(matches!(copy.kind, NodeKind::Group { .. })); + s.execute("object.setProps", &json!({"id": copy.id.0, "opacity": 50})).unwrap(); + assert!(spec(&s, id).series_paints.iter().all(|p| p.opacity.is_none())); +} + +/// A blend mode set on the top fill or stroke row of one series mark is kept by the graph and drawn on every mark of +/// the series again; Normal forgets it. +#[test] +fn a_row_blend_mode_on_a_series_mark_survives_regeneration() { + let (mut s, id, _, marks) = column_marks(); + let (fill, stroke) = (top_row(&s, marks[0], true), top_row(&s, marks[0], false)); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "item": fill, "blend": "screen"})).unwrap(); + s.execute("transparency.set", &json!({"ids": [marks[1].0], "item": stroke, "blend": "darken"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_blend, kept.stroke_blend), (Some(BlendMode::Screen), Some(BlendMode::Darken))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let normal = BlendMode::Normal; + assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (1.0, normal, BlendMode::Screen, BlendMode::Darken)), "{:?}", transparency_of(&s, id, 0)); + assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1)); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [bar.0], "item": top_row(&s, bar, true), "blend": "normal"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_blend, None); +} + +/// The graph stores an object's or a row's opacity and blend mode as one pair from the mark just edited: setting only +/// one half on another mark replaces both, either way round, and the redraw and undo follow the stored pair. +#[test] +fn setting_half_of_a_series_marks_transparency_stores_that_marks_whole_pair() { + let normal = BlendMode::Normal; + let (mut s, id, _, marks) = column_marks(); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "blend": "multiply"})).unwrap(); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 50})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.5), None)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (0.5, normal)), "{:?}", transparency_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Multiply))); + + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap(); + s.execute("transparency.set", &json!({"ids": [marks[1].0], "blend": "screen"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Screen))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (1.0, BlendMode::Screen)), "{:?}", transparency_of(&s, id, 0)); + + // A fill row: a blend alone on one mark replaces the opacity another mark's row left, and the other way round. + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "darken"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken))); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 60})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.6), None)); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken))); +} + +/// Clear Appearance on a series mark sets its object opacity back to 100% and Normal, and the graph keeps neither. +#[test] +fn clearing_a_series_marks_appearance_forgets_its_kept_object_opacity() { + let (mut s, id, _, marks) = column_marks(); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.4)); +} + +/// An appearance moved or copied onto a mark of another series takes the source's object opacity and blend mode, and +/// the graph keeps them for the target's series; a move leaves the source's series opaque and Normal. +#[test] +fn moving_an_appearance_onto_a_series_mark_keeps_its_object_opacity() { + for copy in [false, true] { + let (mut s, id, _, marks) = column_marks(); + let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": to.0, "copy": copy})).unwrap(); + let paints = spec(&s, id).series_paints; + assert_eq!((paints[1].opacity, paints[1].blend), (Some(0.4), Some(BlendMode::Multiply)), "copy {copy}"); + let source = if copy { (Some(0.4), Some(BlendMode::Multiply)) } else { (None, None) }; + assert_eq!((paints[0].opacity, paints[0].blend), source, "copy {copy}"); + } +} + +/// An Eyedropper pick of the object's transparency onto a series mark makes the graph keep the picked opacity and blend +/// mode for that series, even an opaque Normal pick onto a mark that already shows it. +#[test] +fn an_eyedropper_pick_of_object_transparency_onto_a_series_mark_is_kept() { + let (mut s, id, _, marks) = column_marks(); + let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": marks[0].0, "ids": [to.0]})).unwrap(); + let kept = spec(&s, id).series_paints[1].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply))); + // An opaque Normal source picked onto another opaque mark of series 0 replaces what series 0 stores. + let opaque = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].id; + s.execute("appearance.copyFrom", &json!({"source": opaque.0, "ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); +} + +/// A graphic style made from an object at 50% Multiply, applied to a series mark, makes the graph keep the style's object +/// opacity and blend mode; redefining the style updates them for a mark still linked to it. +#[test] +fn a_graphic_styles_object_opacity_on_a_series_mark_is_kept() { + let (mut s, id, _, marks) = column_marks(); + let (rect, _) = blue_style(&mut s, "Blue", true); + s.execute("object.setProps", &json!({"id": rect.0, "opacity": 50, "blend": "multiply"})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Faded"})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Faded", "ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.5), Some(BlendMode::Multiply))); + s.execute("object.setProps", &json!({"id": rect.0, "opacity": 100, "blend": "normal"})).unwrap(); + s.execute("graphicStyle.redefine", &json!({"name": "Faded", "id": rect.0})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5), "one undo takes back the redefinition and what the graph stored"); + + // An opaque Normal style applied to an opaque mark replaces the opacity another mark of the series left. + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 40})).unwrap(); + blue_style(&mut s, "Blue", true); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, None); +} + /// The colors of series `index`'s marks: each mark's top fill and stroke paint and its stroke weight. fn marks_of(s: &Session, id: NodeId, index: u32) -> Vec<(Paint, Paint, f64)> { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); @@ -1010,6 +1202,22 @@ fn a_stroke_set_on_a_filled_line_band_does_not_repaint_it() { assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); } +/// An opacity and blend mode set on a Filled Lines band's stroke row are kept and drawn on its outline, which keeps its +/// black paint and 0.25 pt weight. +#[test] +fn a_filled_line_bands_outline_keeps_a_stroke_opacity_and_blend_mode() { + let (mut s, id, band) = band_graph(); + let row = top_row(&s, band, false); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 50, "blend": "multiply"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke, kept.stroke_opacity, kept.stroke_blend), (None, Some(0.5), Some(BlendMode::Multiply))); + let (_, outline, width) = redrawn_band(&mut s, id); + assert_eq!((outline, width), (Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); + let band = series_parts(&s, id, 0)[0].clone(); + let st = band.appearance.stroke().unwrap(); + assert_eq!((st.opacity, st.blend), (0.5, BlendMode::Multiply)); +} + /// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the /// band its color: an empty stored stroke is not a color for the band. #[test] @@ -1248,13 +1456,15 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { s.execute("appearance.setItem", &json!({"ids": [line.id.0], "index": row, "color": "#0000ff"})).unwrap(); assert!(spec(&s, id).series_paints[0].fill.is_none(), "a stroke edit on a line keeps no fill"); - // A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it. + // A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it, and + // stores only the blend mode. let (mut s, id, _, marks) = column_marks(); let fill = top_row(&s, marks[0], true); s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); - let kept = spec(&s, id); + let mut kept = spec(&s, id).series_paints; s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "multiply"})).unwrap(); - assert_eq!(spec(&s, id), kept); + kept[0].fill_blend = Some(vectorcraft_color::BlendMode::Multiply); + assert_eq!(spec(&s, id).series_paints, kept); } /// A graphic style `name` made from a new rectangle with a blue fill at 50% opacity, and without a stroke row unless diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 69f464edb..fa5a36be5 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -250,7 +250,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}", + "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md)", has_doc, set_props ), diff --git a/crates/engine/src/cmd/object/props.rs b/crates/engine/src/cmd/object/props.rs index 716181b1e..1fb2faf76 100644 --- a/crates/engine/src/cmd/object/props.rs +++ b/crates/engine/src/cmd/object/props.rs @@ -65,7 +65,11 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { }) }; let d = &s.doc()?.doc; - if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) { + // A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, so a value a mark + // already has still replaces the one another mark left on the graph. + let transparency = opacity.is_some() || blend.is_some(); + let stores = || transparency && crate::cmd::graph::series_object_opacity_differs(d, &ids); + if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) && !stores() { // Hidden or locked objects can't stay selected. if hides { s.select(|d, sel| sel.deselect_uneditable(d))?; @@ -118,6 +122,9 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { }); } } + if transparency { + crate::cmd::graph::capture_series_object_opacity(d, &ids); + } // Hidden or locked objects can't stay selected. if hides { sel.deselect_uneditable(d); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b3c0d0f90..b600abed4 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself)", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). On a path directly in a graph series, the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, transparency ), @@ -784,7 +784,7 @@ fn swap(s: &mut Session, p: &Value) -> Result { n.appearance.set_stroke(f); } // The swap writes the paint of the topmost fill and stroke, not their opacity. - let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true); + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true, false); Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } @@ -813,7 +813,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { _ => n.appearance = Appearance::default_art().into(), } } - let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, false); Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 6379aeac5..e975c067c 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows; exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}", has_doc, style_redefine ), @@ -348,14 +348,14 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { // The graph stores the rows of the kinds the style shows, now on top of each stack. let shows = |kind: bool| g.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); let kinds = (shows(true), shows(false)); - super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false); + super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false, false); } else { d.graphic_style_id(i); let g = d.graphic_styles[i].clone(); for id in &ids { apply_style(d, *id, &g, chars); } - super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); + super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true); } Ok(()) })?; @@ -475,7 +475,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { for nid in &follow { apply_style(d, *nid, &g, chars); } - super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false); + super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false, true); set_link(d, &[src], Some(id)); Ok(()) })?; diff --git a/docs/mcp.md b/docs/mcp.md index f23fec388..cffdd613b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -537,15 +537,22 @@ stroke instead of the whole object: `run_command effect.apply {"effect": "path.o On a path directly inside a graph series, `appearance.setItem`, `appearance.addFill`, `appearance.addStroke`, `appearance.removeItem`, `appearance.moveItem`, `appearance.duplicateItem`, `appearance.clear`, `appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer`, `appearance.copyFrom`, `paint.default`, -`graphicStyle.apply` and `graphicStyle.redefine` (on the marks still linked to the style) store the paint, weight and -opacity of the topmost visible fill and stroke on the graph. `graphicStyle.apply` with `add` stores only the kinds of row -that are visible in the style. `paint.swap` stores the swapped paints and the stroke weight, keeps the stored opacity, -and does not store a hidden topmost row that it swaps. The graph does not store the rest of a graphic style's look (more -fill and stroke rows, effects, the object's opacity and blend mode, and the link to the style), and a mark loses it when -the graph is drawn again. When the graph is drawn again, columns, bars, wedges, areas, markers and legend swatches take -the stored fill and stroke. A thin line takes the stored stroke and has no fill. A Filled Lines band takes the stored -fill, or the stored stroke when no fill is stored and that stroke has paint, and has a 0.25 pt black outline, so these -commands do not store a stroke for a series that draws a band. Graph designs keep their own paint. A row that had no +`graphicStyle.apply` and `graphicStyle.redefine` (on the marks still linked to the style) store the paint, weight, +opacity and blend mode of the topmost visible fill and stroke on the graph. `graphicStyle.apply` with `add` stores only +the kinds of row that are visible in the style. `paint.swap` stores the swapped paints and the stroke weight, keeps the +stored opacity and blend mode, and does not store a hidden topmost row that it swaps. `transparency.set` and +`object.setProps` with an opacity or blend mode for the object, `appearance.clear`, `appearance.transfer`, +`appearance.copyFrom` with Transparency picked up, `graphicStyle.apply` without `add` and `graphicStyle.redefine` store +the object's opacity and blend mode on the graph, and the other commands above store them when they change them on a +mark. The graph stores an opacity and blend mode as a pair, taken from the mark the command edited: a command that sets +only the opacity of one mark's object or row also stores that mark's blend mode in place of the one another mark +stored. Every path directly in the series takes the stored values when the graph is drawn again. The graph does not +store the rest of a graphic style's look (more fill and stroke rows, effects, and the link to the style), and a mark +loses it when the graph is drawn again. When the graph is drawn again, columns, bars, wedges, areas, markers and legend +swatches take the stored fill and stroke. A thin line takes the stored stroke and has no fill. A Filled Lines band takes +the stored fill, or the stored stroke when no fill is stored and that stroke has paint, and has a 0.25 pt black +outline, so these commands do not store a stroke paint or weight for a series that draws a band. The outline takes the +stored stroke opacity and blend mode. Graph designs keep their own paint. A row that had no paint before the command and has none after it does not change what the graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6b57dc5ec..c695f2d5e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -840,7 +840,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the paint, weight and opacity of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. From b6fdecc2e0fe3c39579ae71b9992cea80f0308d3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 19:42:24 -0800 Subject: [PATCH 4/5] Graphs: Appearance, Default Fill and Stroke and graphic style help names the stored opacity and blend mode The help text of appearance.addFill, addStroke, clear, reduceToBasic, removeItem, duplicateItem, moveItem, copyFrom, showAllHidden and transfer, paint.default, and graphicStyle.apply and redefine said the graph stores only the paint and weight of the topmost visible fill and stroke. These commands also store that row's opacity and blend mode, as appearance.setItem's help and docs/mcp.md already say. --- crates/engine/src/cmd/appearance.rs | 20 ++++++++++---------- crates/engine/src/cmd/paint.rs | 2 +- crates/engine/src/cmd/style.rs | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 4c4873447..83e11a3cc 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, true) ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, false) ), @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", has_doc, clear_appearance ), @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, reduce_basic ), @@ -90,7 +90,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, remove_item ), @@ -108,7 +108,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, duplicate_item ), @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: 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, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `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). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: 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, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `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). On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, show_all_hidden ), @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", has_doc, transfer ), diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b600abed4..3128992c6 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Default Fill and Stroke", [], Some("D"), - "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, default_paint ), diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index e975c067c..8de02b506 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}", has_doc, style_redefine ), From 76bb829e3af2f356d049c7ca36ce14ffdcfa2289 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 06:49:57 -0800 Subject: [PATCH 5/5] Graphs: a style that removes a mark's painted row resets the stored opacity Applying or redefining a graphic style on a series mark whose fill or stroke had paint, when the style leaves that row without paint, now stores the row's opacity and blend mode on the graph. Before, the opacity and blend mode another mark of the series left stayed, so a Filled Lines band's outline kept a stroke opacity after a style without a stroke went on a stroked marker. A row with no paint before and after the edit still keeps what the graph stores. --- crates/engine/src/cmd/appearance.rs | 8 ++++++-- crates/engine/src/cmd/graph/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index e24b55105..4f9152bcf 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -687,11 +687,15 @@ pub(super) fn capture_series_writes( let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(AppearanceItem::visible) }; - let written = |d: &vectorcraft_doc::Document, (fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { + let kinds = |(fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { let rows = ids.iter().flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]).flatten(); - painted_rows(d, rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind))) + rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind)).collect() }; + let written = |d: &vectorcraft_doc::Document, rows: (bool, bool)| painted_rows(d, kinds(rows)); let mut opacity_rows = top_opacity_changed(before, d, ids); + // A written row that had paint before the edit counts too when the edit left it without paint: only a row without + // paint both before and after keeps what the graph stores. + opacity_rows.extend(painted_rows(before, kinds(opacities))); opacity_rows.extend(written(d, opacities)); let mut rows = top_paint_changed(before, d, ids); rows.extend(written(d, paints)); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index b90d456bb..2c93799ba 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1218,6 +1218,32 @@ fn a_filled_line_bands_outline_keeps_a_stroke_opacity_and_blend_mode() { assert_eq!((st.opacity, st.blend), (0.5, BlendMode::Multiply)); } +/// A graphic style without a stroke applied to a marker whose stroke had paint replaces the stroke opacity and blend mode +/// the series' Filled Lines band left: the band's outline is drawn opaque and Normal again. A marker that had no stroke +/// before the style leaves them as they are. +#[test] +fn a_style_that_removes_a_marks_stroke_replaces_the_kept_stroke_opacity() { + for stroked in [true, false] { + let (mut s, id, band) = band_graph(); + let marker = series_parts(&s, id, 0)[1].id; + if stroked { + let row = top_row(&s, marker, false); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": row, "color": "#ff0000"})).unwrap(); + } + let row = top_row(&s, band, false); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 40, "blend": "multiply"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_opacity, Some(0.4)); + blue_style(&mut s, "Blue", false); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marker.0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + let want = if stroked { (None, None) } else { (Some(0.4), Some(BlendMode::Multiply)) }; + assert_eq!((kept.stroke_opacity, kept.stroke_blend), want, "stroked {stroked}"); + redrawn_band(&mut s, id); + let st = series_parts(&s, id, 0)[0].appearance.stroke().unwrap().clone(); + assert_eq!((Some(st.opacity).filter(|o| *o < 1.0), Some(st.blend).filter(|b| *b != BlendMode::Normal)), want, "stroked {stroked}"); + } +} + /// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the /// band its color: an empty stored stroke is not a color for the band. #[test]