Merge pull request #6069 from dotCooCoo/fix/graph-series-paint-follows-appearance

Graphs: Appearance commands and the Eyedropper update a series' stored paint
This commit is contained in:
BFlat
2026-11-08 13:20:16 +08:00
committed by GitHub
9 changed files with 1056 additions and 44 deletions
+129 -29
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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, weight and opacity 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<CommandSpec> {
"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, weight and opacity 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<CommandSpec> {
"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, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
clear_appearance
),
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
reduce_basic
),
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
set_item
),
@@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
remove_item
),
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
duplicate_item
),
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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, weight and opacity 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<CommandSpec> {
"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, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
has_doc,
copy_from
),
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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, weight and opacity 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<CommandSpec> {
"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, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {source, target}",
has_doc,
transfer
),
@@ -551,12 +551,17 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
// Where the new fill went in on the active item's object, when it went above the active row.
let mut above = None;
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
// The objects that got a new topmost row of the kind: the series stores its paint even when the object showed it.
let mut added = Vec::new();
for (id, row) in ids.iter().zip(&per) {
if let Some(n) = d.node_mut(*id) {
if let Some(i) = n.appearance.item_of_kind(*row, true)
&& let Some(paint) = n.appearance.paint_at(Some(i), true).cloned()
{
n.appearance.insert_item(i + 1, AppearanceItem::Fill(FillLayer::new(paint)));
if let Some(kind) = top_row_kind(&n.appearance, i + 1) {
added.push((*id, kind));
}
if active == Some(*id) {
above = Some(i + 1);
}
@@ -591,10 +596,14 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
}
} else {
n.appearance.items.push(item);
added.push((*id, fill));
}
}
}
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
let mut rows = top_paint_changed(&before, d, &ids);
rows.extend(painted_rows(d, added));
super::graph::capture_series_top_paints(d, &rows);
Ok(())
})?;
// A fill added above the active row becomes the active row; otherwise the rows above the new
@@ -614,7 +623,8 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
}
}
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 +636,37 @@ 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<Item = (NodeId, bool)>) -> 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()
}
/// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`.
fn top_row_kind(ap: &Appearance, at: usize) -> Option<bool> {
let fill = ap.items.get(at)?.is_fill();
(ap.items.iter().rposition(|it| it.is_fill() == fill && it.visible()) == Some(at)).then_some(fill)
}
/// 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 +704,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
}
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 +762,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
}
}
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 +789,6 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
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 +818,26 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
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 +881,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
}
}
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()
@@ -862,6 +912,9 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
let per = items_per_target(&before, &ids, &idx, aligned(p, C)?);
s.edit("Duplicate Item", |d, _| {
let mut any = false;
// Copies that became the topmost visible row of their kind: the series stores their paint even when the object
// showed it.
let mut added = Vec::new();
for (id, idx) in ids.iter().zip(&per) {
let Some(n) = d.node_mut(*id) else { continue };
let has_contents = n.contents_label().is_some();
@@ -877,9 +930,11 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
if active == Some(*id) {
active_at = Some(at);
}
let count = copies.len();
for (k, item) in copies.into_iter().enumerate() {
ap.insert_item(at + k, item);
}
added.extend((at..at + count).filter_map(|k| top_row_kind(ap, k)).map(|kind| (*id, kind)));
if let Some(c) = contents.filter(|_| has_contents) {
ap.set_contents_at(c);
}
@@ -893,6 +948,10 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(item) = ap.items.get(i).cloned() {
first_at.get_or_insert(i + 1);
ap.insert_item(i + 1, item);
// Copies below this one go in lower down, so a copy that is the topmost row of its kind stays it.
if let Some(kind) = top_row_kind(ap, i + 1) {
added.push((*id, kind));
}
any = true;
}
}
@@ -901,6 +960,9 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("no item at index {}", idx[0])));
}
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
let mut rows = top_paint_changed(&before, d, &ids);
rows.extend(painted_rows(d, added));
super::graph::capture_series_top_paints(d, &rows);
Ok(())
})?;
// Rows at or above each copy move up by one (by the block, for copies placed at `to`).
@@ -918,11 +980,14 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
/// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects.
fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
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<NodeId> = 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::<Vec<_>>() }))
@@ -962,7 +1027,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
}
}
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 +1072,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result<V
}
}
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))
})?;
let m = idx.len();
let landed = first_landed.unwrap_or(to);
@@ -1160,8 +1227,41 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
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::<Vec<_>>() }))
}
+1 -1
View File
@@ -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;
+6 -1
View File
@@ -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.
+4 -4
View File
@@ -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))
+9 -3
View File
@@ -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;
+78 -5
View File
@@ -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<f64>], 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<f64>]| 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<f64>)>) -> 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();
+813
View File
@@ -503,6 +503,819 @@ 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<NodeId>) {
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<vectorcraft_doc::Node>) {
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));
}
/// An opacity set on a Filled Lines band's stroke row is kept, without a stroke paint, and goes on the band's thin black
/// outline when the graph is drawn again.
#[test]
fn a_stroke_opacity_set_on_a_filled_line_band_goes_on_its_outline() {
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": 30})).unwrap();
let kept = spec(&s, id).series_paints.first().cloned().unwrap_or_default();
assert_eq!((kept.stroke.is_none(), kept.stroke_opacity), (true, Some(0.3)));
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();
assert_eq!(band.appearance.stroke().unwrap().opacity, 0.3);
}
/// 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<Vec<f64>> = (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 stroke added on top of a mark replaces the stroke another mark of the series left on the graph, even when the new
/// row shows the paint the mark already showed.
#[test]
fn adding_a_stroke_replaces_the_kept_stroke_with_the_marks_own() {
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();
s.execute("appearance.addStroke", &json!({"ids": [marks[1].0]})).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 fill added right above a mark's active top fill replaces the fill another mark of the series left on the graph.
#[test]
fn adding_a_fill_above_the_active_top_fill_replaces_the_kept_fill() {
let (mut s, id, was, marks) = column_marks();
let fill = top_row(&s, marks[0], true);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#0000ff"})).unwrap();
s.execute("select.set", &json!({"ids": [marks[1].0]})).unwrap();
s.execute("appearance.setActiveItem", &json!({"index": fill})).unwrap();
s.execute("appearance.addFill", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, Some(was));
}
/// A copy of a mark's top stroke replaces the stroke another mark of the series left on the graph.
#[test]
fn duplicating_a_marks_top_stroke_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();
for to in [false, true] {
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap();
let top = top_row(&s, marks[1], false);
let p = if to { json!({"ids": [marks[1].0], "index": top, "to": top + 1}) } else { json!({"ids": [marks[1].0], "index": top}) };
s.execute("appearance.duplicateItem", &p).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))), "{p}");
assert_eq!(kept.stroke_width, Some(2.0), "{p}");
}
}
/// 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() {
+15
View File
@@ -534,6 +534,21 @@ 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 paint or weight for a series that
draws a band. They still store a stroke opacity set on a band, and the band's outline takes it. 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.
+1 -1
View File
@@ -849,7 +849,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.