mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Paint: fill, stroke, gradient and Appearance panel commands that change nothing record no undo step
These commands edited the document even when nothing changed, so the call recorded an undo step, emptied Redo and marked the document modified: - Fill, Stroke, None, Apply Last Color and Apply Last Gradient with the paint the objects have already, also on characters selected with the Type tool. - stroke.set and stroke.setAdvanced with the options the strokes have already. - paint.editGradient, paint.setGradientGeom and the freeform point commands when the gradient comes out as it was. - Invert and Complement on the Appearance panel's selected row when no color changes. - Swap Fill and Stroke and Default Fill and Stroke when every object comes out as it was. - appearance.setItem with the values the item has or an index no object has, and transparency.set with an item and the values that item has; appearance.removeItem with indices past every stack; appearance.moveItem, Reduce to Basic Appearance, Clear Appearance, the Eyedropper (appearance.copyFrom) and appearance.transfer when every object comes out as it was. These commands now return without editing the document. The replies have the same form as before. On a graph's series or labels, a fill or stroke that the art has already still records one undo step when the graph does not keep that paint for them (for example after the Eyedropper painted the art), and the graph then keeps it. The Eyedropper test whose second copy changed nothing gives its target a green fill first, which the copy turns red again. One line in the evidence list of docs/target-app-parity.md names these commands, apart from Invert and Complement.
This commit is contained in:
1 parent
d0bb09c16f
commit
9cbab1383b
8 files changed
+290
-41
No files matched your search
@@ -318,7 +318,8 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
|||||||
|
|
||||||
/// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke
|
/// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke
|
||||||
/// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters).
|
/// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters).
|
||||||
/// Nothing targeted is not an edit.
|
/// Nothing targeted is not an edit, and when `f` leaves every target as it was, the document and
|
||||||
|
/// its undo history stay as they are.
|
||||||
pub(crate) fn edit_items(
|
pub(crate) fn edit_items(
|
||||||
s: &mut Session,
|
s: &mut Session,
|
||||||
ids: &[NodeId],
|
ids: &[NodeId],
|
||||||
@@ -328,10 +329,12 @@ pub(crate) fn edit_items(
|
|||||||
fill: bool,
|
fill: bool,
|
||||||
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
|
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(()))
|
edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`edit_items`], then `after` on the ids `f` changed, inside the same undo step.
|
/// [`edit_items`], then `after` on the ids `f` ran on, inside the same undo step. `after` returns
|
||||||
|
/// whether it changed the document; when it did not and `f` left every target as it was, the
|
||||||
|
/// document and its undo history stay as they are.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) fn edit_items_then(
|
pub(crate) fn edit_items_then(
|
||||||
s: &mut Session,
|
s: &mut Session,
|
||||||
@@ -341,21 +344,29 @@ pub(crate) fn edit_items_then(
|
|||||||
label: &str,
|
label: &str,
|
||||||
fill: bool,
|
fill: bool,
|
||||||
mut f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
|
mut f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
|
||||||
mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result<()>,
|
mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result<bool>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
s.edit(label, |d, _| {
|
let before = s.doc()?.doc.clone();
|
||||||
let mut changed = Vec::new();
|
super::overprint::edit_counted(s, label, |d| {
|
||||||
|
let mut edited = Vec::new();
|
||||||
for id in ids {
|
for id in ids {
|
||||||
let Some(n) = d.node_mut(*id) else { continue };
|
let Some(n) = d.node_mut(*id) else { continue };
|
||||||
let index = item.resolve(&n.appearance, fill, cmd)?;
|
let index = item.resolve(&n.appearance, fill, cmd)?;
|
||||||
f(n, index)?;
|
f(n, index)?;
|
||||||
changed.push(*id);
|
edited.push(*id);
|
||||||
}
|
}
|
||||||
after(d, &changed)
|
let more = after(d, &edited)?;
|
||||||
})
|
Ok(count_changed(d, &before, &edited) + usize::from(more))
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many of `ids` differ in `d` from `before`.
|
||||||
|
pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_doc::Document, ids: &[NodeId]) -> usize {
|
||||||
|
ids.iter().filter(|id| d.node(**id) != before.node(**id)).count()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
|
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
|
||||||
@@ -428,13 +439,15 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
|||||||
|
|
||||||
fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let ids = require_targets(s, p, "appearance.clear")?;
|
let ids = require_targets(s, p, "appearance.clear")?;
|
||||||
s.edit("Clear Appearance", |d, _| {
|
// When every object is cleared already, the document and its undo history stay as they are.
|
||||||
|
let before = s.doc()?.doc.clone();
|
||||||
|
super::overprint::edit_counted(s, "Clear Appearance", |d| {
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
if let Some(n) = d.node_mut(*id) {
|
if let Some(n) = d.node_mut(*id) {
|
||||||
clear(n);
|
clear(n);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(count_changed(d, &before, &ids))
|
||||||
})?;
|
})?;
|
||||||
s.active_appearance_item = None;
|
s.active_appearance_item = None;
|
||||||
ok()
|
ok()
|
||||||
@@ -456,7 +469,9 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
return Err(bad(C, "`source` and `target` are the same object"));
|
return Err(bad(C, "`source` and `target` are the same object"));
|
||||||
}
|
}
|
||||||
let copy = bool_or(p, "copy", false);
|
let copy = bool_or(p, "copy", false);
|
||||||
s.edit(if copy { "Copy Appearance" } else { "Move Appearance" }, |d, _| {
|
// When both objects come out as they were, the document and its undo history stay as they are.
|
||||||
|
let before = s.doc()?.doc.clone();
|
||||||
|
super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| {
|
||||||
let src = d.node(source).cloned().ok_or(EngineError::NoNode(source))?;
|
let src = d.node(source).cloned().ok_or(EngineError::NoNode(source))?;
|
||||||
let to = d.node_mut(target).ok_or(EngineError::NoNode(target))?;
|
let to = d.node_mut(target).ok_or(EngineError::NoNode(target))?;
|
||||||
let mut ap = src.appearance.clone();
|
let mut ap = src.appearance.clone();
|
||||||
@@ -473,7 +488,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
clear(n);
|
clear(n);
|
||||||
(n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
|
(n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(count_changed(d, &before, &[source, target]))
|
||||||
})?;
|
})?;
|
||||||
s.active_appearance_item = None;
|
s.active_appearance_item = None;
|
||||||
Ok(json!({ "source": source.0, "target": target.0 }))
|
Ok(json!({ "source": source.0, "target": target.0 }))
|
||||||
@@ -486,7 +501,9 @@ fn is_group(n: &Node) -> bool {
|
|||||||
|
|
||||||
fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let ids = require_targets(s, p, "appearance.reduceToBasic")?;
|
let ids = require_targets(s, p, "appearance.reduceToBasic")?;
|
||||||
s.edit("Reduce to Basic Appearance", |d, _| {
|
// When every object comes out as it was, the document and its undo history stay as they are.
|
||||||
|
let before = s.doc()?.doc.clone();
|
||||||
|
super::overprint::edit_counted(s, "Reduce to Basic Appearance", |d| {
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
if let Some(n) = d.node_mut(*id) {
|
if let Some(n) = d.node_mut(*id) {
|
||||||
// The topmost visible fill and stroke stay, without their own transparency or effects.
|
// The topmost visible fill and stroke stay, without their own transparency or effects.
|
||||||
@@ -505,7 +522,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(count_changed(d, &before, &ids))
|
||||||
})?;
|
})?;
|
||||||
s.active_appearance_item = None;
|
s.active_appearance_item = None;
|
||||||
ok()
|
ok()
|
||||||
@@ -519,7 +536,10 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
return Err(bad("appearance.setItem", "select objects or give ids"));
|
return Err(bad("appearance.setItem", "select objects or give ids"));
|
||||||
}
|
}
|
||||||
let blend = str_param(p, "blend").and_then(BlendMode::parse);
|
let blend = str_param(p, "blend").and_then(BlendMode::parse);
|
||||||
s.edit("Appearance", |d, _| {
|
// When every object comes out as it was (for example, when none has item `index`), the document
|
||||||
|
// and its undo history stay as they are.
|
||||||
|
let before = s.doc()?.doc.clone();
|
||||||
|
super::overprint::edit_counted(s, "Appearance", |d| {
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
let Some(n) = d.node_mut(*id) else { continue };
|
let Some(n) = d.node_mut(*id) else { continue };
|
||||||
if p.get("weight").is_none()
|
if p.get("weight").is_none()
|
||||||
@@ -552,7 +572,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
*vis = v;
|
*vis = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(count_changed(d, &before, &ids))
|
||||||
})?;
|
})?;
|
||||||
if let Some(pa) = &paint {
|
if let Some(pa) = &paint {
|
||||||
s.remember_paint(pa);
|
s.remember_paint(pa);
|
||||||
@@ -579,7 +599,10 @@ fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
|||||||
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let idx = item_indices(p, "appearance.removeItem")?;
|
let idx = item_indices(p, "appearance.removeItem")?;
|
||||||
let ids = require_targets(s, p, "appearance.removeItem")?;
|
let ids = require_targets(s, p, "appearance.removeItem")?;
|
||||||
s.edit("Remove Item", |d, _| {
|
// When no object has an item at any of the indices, the document and its undo history stay as
|
||||||
|
// they are.
|
||||||
|
let before = s.doc()?.doc.clone();
|
||||||
|
super::overprint::edit_counted(s, "Remove Item", |d| {
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
if let Some(n) = d.node_mut(*id) {
|
if let Some(n) = d.node_mut(*id) {
|
||||||
// From the top down, so the lower indices stay valid.
|
// From the top down, so the lower indices stay valid.
|
||||||
@@ -588,7 +611,7 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(count_changed(d, &before, &ids))
|
||||||
})?;
|
})?;
|
||||||
s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
|
s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
|
||||||
ok()
|
ok()
|
||||||
@@ -653,7 +676,10 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let from = index_param(p, "from", C)?;
|
let from = index_param(p, "from", C)?;
|
||||||
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
|
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
|
||||||
let mut landed = to;
|
let mut landed = to;
|
||||||
s.edit("Reorder Appearance", |d, _| {
|
// When every object's stack comes out as it was, the document and its undo history stay as they
|
||||||
|
// are.
|
||||||
|
let before = s.doc()?.doc.clone();
|
||||||
|
super::overprint::edit_counted(s, "Reorder Appearance", |d| {
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
let Some(n) = d.node_mut(*id) else { continue };
|
let Some(n) = d.node_mut(*id) else { continue };
|
||||||
landed = n.appearance.move_item(from, to).ok_or_else(|| bad(C, format!("no item at index {from}")))?;
|
landed = n.appearance.move_item(from, to).ok_or_else(|| bad(C, format!("no item at index {from}")))?;
|
||||||
@@ -661,7 +687,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
n.appearance.set_contents_at(k);
|
n.appearance.set_contents_at(k);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(count_changed(d, &before, &ids))
|
||||||
})?;
|
})?;
|
||||||
// The moved row stays the active one; the rows it passed shift by one.
|
// The moved row stays the active one; the rows it passed shift by one.
|
||||||
s.remap_appearance_item(&ids, |a| {
|
s.remap_appearance_item(&ids, |a| {
|
||||||
@@ -681,14 +707,18 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
/// `appearance.moveItem {from: "contents"}`: put the Contents (Characters) row of each target that
|
/// `appearance.moveItem {from: "contents"}`: put the Contents (Characters) row of each target that
|
||||||
/// has one above its bottom `to` items.
|
/// has one above its bottom `to` items.
|
||||||
fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
|
fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
|
||||||
let slot = s.edit("Reorder Appearance", |d, _| {
|
// When every Contents row is above the bottom `to` items already, the document and its undo
|
||||||
let mut slot = None;
|
// history stay as they are.
|
||||||
|
let before = s.doc()?.doc.clone();
|
||||||
|
let mut slot = None;
|
||||||
|
super::overprint::edit_counted(s, "Reorder Appearance", |d| {
|
||||||
for id in ids {
|
for id in ids {
|
||||||
let Some(n) = d.node_mut(*id).filter(|n| n.contents_label().is_some()) else { continue };
|
let Some(n) = d.node_mut(*id).filter(|n| n.contents_label().is_some()) else { continue };
|
||||||
n.appearance.set_contents_at(to);
|
n.appearance.set_contents_at(to);
|
||||||
slot.get_or_insert(n.appearance.contents_at());
|
slot.get_or_insert(n.appearance.contents_at());
|
||||||
}
|
}
|
||||||
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers and type have a Contents (Characters) row"))
|
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers and type have a Contents (Characters) row"))?;
|
||||||
|
Ok(count_changed(d, &before, ids))
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({ "contents": slot }))
|
Ok(json!({ "contents": slot }))
|
||||||
}
|
}
|
||||||
@@ -733,13 +763,15 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if targets.is_empty() || attrs.is_empty() {
|
if targets.is_empty() || attrs.is_empty() {
|
||||||
return Ok(json!({ "ids": [] }));
|
return Ok(json!({ "ids": [] }));
|
||||||
}
|
}
|
||||||
s.edit("Eyedropper", |d, _| {
|
// When every target comes out as it was, the document and its undo history stay as they are.
|
||||||
|
let before = s.doc()?.doc.clone();
|
||||||
|
super::overprint::edit_counted(s, "Eyedropper", |d| {
|
||||||
for id in &targets {
|
for id in &targets {
|
||||||
if let Some(n) = d.node_mut(*id) {
|
if let Some(n) = d.node_mut(*id) {
|
||||||
picked.apply(n, &attrs, append);
|
picked.apply(n, &attrs, append);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(count_changed(d, &before, &targets))
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1269,10 +1269,11 @@ pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
|||||||
/// Copy the paint just applied to generated series art into that series' fill or stroke. Called
|
/// Copy the paint just applied to generated series art into that series' fill or stroke. Called
|
||||||
/// inside the same document edit, so the visible paint and future regeneration undo together.
|
/// inside the same document edit, so the visible paint and future regeneration undo together.
|
||||||
/// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour.
|
/// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour.
|
||||||
pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bool) {
|
/// Returns whether a series' captured fill or stroke changed.
|
||||||
|
pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bool) -> bool {
|
||||||
let members = series_members(doc);
|
let members = series_members(doc);
|
||||||
if members.is_empty() {
|
if members.is_empty() {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
let captures: Vec<_> = ids
|
let captures: Vec<_> = ids
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1285,8 +1286,10 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo
|
|||||||
Some((r.graph, r.index, paint, width))
|
Some((r.graph, r.index, paint, width))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let mut changed = false;
|
||||||
for (graph, index, paint, width) in captures {
|
for (graph, index, paint, width) in captures {
|
||||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.graph.as_deref_mut()) else { continue };
|
let Some(spec) = doc.node_mut(graph).and_then(|n| n.graph.as_deref_mut()) else { continue };
|
||||||
|
let was = spec.series_paints.clone();
|
||||||
// `index` is below the graph's series count (`series_members`).
|
// `index` is below the graph's series count (`series_members`).
|
||||||
if spec.series_paints.len() <= index {
|
if spec.series_paints.len() <= index {
|
||||||
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
|
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
|
||||||
@@ -1298,7 +1301,9 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo
|
|||||||
slot.stroke = Some(paint);
|
slot.stroke = Some(paint);
|
||||||
slot.stroke_width = width;
|
slot.stroke_width = width;
|
||||||
}
|
}
|
||||||
|
changed |= spec.series_paints != was;
|
||||||
}
|
}
|
||||||
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Graph Data › Transpose row/column: categories become series and series categories.
|
/// Graph Data › Transpose row/column: categories become series and series categories.
|
||||||
@@ -1370,18 +1375,19 @@ fn graph_labels(doc: &Document) -> HashMap<NodeId, (NodeId, Role)> {
|
|||||||
/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels
|
/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels
|
||||||
/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or
|
/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or
|
||||||
/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so
|
/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so
|
||||||
/// they undo together.
|
/// they undo together. Returns whether a kept style changed.
|
||||||
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) {
|
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool {
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
let labels = graph_labels(doc);
|
let labels = graph_labels(doc);
|
||||||
if labels.is_empty() {
|
if labels.is_empty() {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect();
|
let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect();
|
||||||
kinds.sort_by_key(|(g, r)| (g.0, *r as u8));
|
kinds.sort_by_key(|(g, r)| (g.0, *r as u8));
|
||||||
kinds.dedup();
|
kinds.dedup();
|
||||||
|
let mut changed = false;
|
||||||
for (graph, role) in kinds {
|
for (graph, role) in kinds {
|
||||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.graph.as_deref_mut()) else { continue };
|
let Some(spec) = doc.node_mut(graph).and_then(|n| n.graph.as_deref_mut()) else { continue };
|
||||||
let mut style = label_style(&spec.label_styles, role);
|
let mut style = label_style(&spec.label_styles, role);
|
||||||
@@ -1391,8 +1397,10 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &
|
|||||||
Role::Category => &mut spec.label_styles.categories,
|
Role::Category => &mut spec.label_styles.categories,
|
||||||
Role::Legend => &mut spec.label_styles.legend,
|
Role::Legend => &mut spec.label_styles.legend,
|
||||||
};
|
};
|
||||||
|
changed |= slot.as_ref() != Some(&style);
|
||||||
*slot = Some(style);
|
*slot = Some(style);
|
||||||
}
|
}
|
||||||
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_data(s: &mut Session, p: &Value) -> Result<Value> {
|
fn set_data(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
@@ -2100,6 +2108,40 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fill that a series' marks or a value axis number have already is one undo step when the graph
|
||||||
|
/// keeps no fill for them (as after a command that paints only the art): the art stays as it was
|
||||||
|
/// and the graph keeps the fill. Given again, the fill records no undo step.
|
||||||
|
#[test]
|
||||||
|
fn a_fill_the_art_has_already_is_kept_by_the_graph_in_one_step() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||||
|
let id = graph(&mut s, "column");
|
||||||
|
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
|
||||||
|
let label = text_id(&s, id, "Axes", "4");
|
||||||
|
for target in [series_id, label] {
|
||||||
|
let fill = json!({"ids": [target.0], "color": "#e8573f"});
|
||||||
|
s.execute("paint.setFill", &fill).unwrap();
|
||||||
|
// The graph keeps nothing, as after a command that paints only the art.
|
||||||
|
s.edit("Forget", |d, _| {
|
||||||
|
let g = d.node_mut(id).unwrap().graph.as_deref_mut().unwrap();
|
||||||
|
g.series_paints.clear();
|
||||||
|
g.label_styles = vectorcraft_doc::LabelStyles::default();
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let art = s.doc().unwrap().doc.node(target).unwrap().clone();
|
||||||
|
let steps = s.doc().unwrap().history.undo.len();
|
||||||
|
s.execute("paint.setFill", &fill).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "{target:?}");
|
||||||
|
assert_eq!(s.doc().unwrap().doc.node(target).unwrap(), &art, "the art is as it was");
|
||||||
|
let g = spec(&s, id);
|
||||||
|
let kept = if target == series_id { g.series_paints[0].fill.clone() } else { g.label_styles.values.map(|st| st.fill) };
|
||||||
|
assert_eq!(kept.and_then(|p| p.color()).map(|c| c.to_hex()), Some("#e8573f".into()), "{target:?}");
|
||||||
|
s.execute("paint.setFill", &fill).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "nothing left to keep: {target:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn painting_one_bar_sets_the_colour_used_by_the_whole_series_on_rebuild() {
|
fn painting_one_bar_sets_the_colour_used_by_the_whole_series_on_rebuild() {
|
||||||
let mut s = Session::new();
|
let mut s = Session::new();
|
||||||
|
|||||||
@@ -381,7 +381,9 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
|||||||
&& let Some((id, a, b)) = super::textedit::editing_range(s)?
|
&& let Some((id, a, b)) = super::textedit::editing_range(s)?
|
||||||
&& a != b
|
&& a != b
|
||||||
{
|
{
|
||||||
s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| {
|
// When the type comes out as it was, the document and its undo history stay as they are.
|
||||||
|
let before = s.doc()?.doc.clone();
|
||||||
|
super::overprint::edit_counted(s, if fill { "Fill Color" } else { "Stroke Color" }, |d| {
|
||||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||||
let (xf, lb) = (t.xf, t.local_bounds());
|
let (xf, lb) = (t.xf, t.local_bounds());
|
||||||
vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| {
|
vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| {
|
||||||
@@ -395,7 +397,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
super::typecmd::refresh_bounds(t);
|
super::typecmd::refresh_bounds(t);
|
||||||
Ok(())
|
Ok(super::appearance::count_changed(d, &before, &[id]))
|
||||||
})?;
|
})?;
|
||||||
if bool_or(p, "focus", true) {
|
if bool_or(p, "focus", true) {
|
||||||
s.fill_active = fill;
|
s.fill_active = fill;
|
||||||
@@ -454,8 +456,8 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
|||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
|d, changed| {
|
|d, changed| {
|
||||||
super::graph::capture_series_paints(d, changed, fill);
|
let series = super::graph::capture_series_paints(d, changed, fill);
|
||||||
super::graph::capture_label_styles(d, changed, &|st| {
|
let labels = super::graph::capture_label_styles(d, changed, &|st| {
|
||||||
if fill {
|
if fill {
|
||||||
st.fill = paint.clone();
|
st.fill = paint.clone();
|
||||||
} else {
|
} else {
|
||||||
@@ -465,7 +467,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
|||||||
st.stroke = paint.clone();
|
st.stroke = paint.clone();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(series || labels)
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
// Rejected edits leave the next shape's appearance and active proxy untouched.
|
// Rejected edits leave the next shape's appearance and active proxy untouched.
|
||||||
@@ -515,7 +517,9 @@ pub(super) fn restore_unpainted_stroke(ap: &mut Appearance, index: Option<usize>
|
|||||||
fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let ids = paint_targets(s, p)?;
|
let ids = paint_targets(s, p)?;
|
||||||
if !ids.is_empty() {
|
if !ids.is_empty() {
|
||||||
s.edit("Swap Fill and Stroke", |d, _| {
|
// When every object comes out as it was, the document and its undo history stay as they are.
|
||||||
|
let before = s.doc()?.doc.clone();
|
||||||
|
super::overprint::edit_counted(s, "Swap Fill and Stroke", |d| {
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
let Some(n) = d.node_mut(*id) else { continue };
|
let Some(n) = d.node_mut(*id) else { continue };
|
||||||
if let NodeKind::Text(t) = &mut n.kind {
|
if let NodeKind::Text(t) = &mut n.kind {
|
||||||
@@ -532,7 +536,7 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
restore_unpainted_stroke(&mut n.appearance, None, &f);
|
restore_unpainted_stroke(&mut n.appearance, None, &f);
|
||||||
n.appearance.set_stroke(f);
|
n.appearance.set_stroke(f);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(super::appearance::count_changed(d, &before, &ids))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 {
|
if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 {
|
||||||
@@ -545,7 +549,9 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let ids = paint_targets(s, p)?;
|
let ids = paint_targets(s, p)?;
|
||||||
if !ids.is_empty() {
|
if !ids.is_empty() {
|
||||||
s.edit("Default Fill and Stroke", |d, _| {
|
// When every object comes out as it was, the document and its undo history stay as they are.
|
||||||
|
let before = s.doc()?.doc.clone();
|
||||||
|
super::overprint::edit_counted(s, "Default Fill and Stroke", |d| {
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
let Some(n) = d.node_mut(*id) else { continue };
|
let Some(n) = d.node_mut(*id) else { continue };
|
||||||
match &mut n.kind {
|
match &mut n.kind {
|
||||||
@@ -558,7 +564,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
_ => n.appearance = Appearance::default_art().into(),
|
_ => n.appearance = Appearance::default_art().into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(super::appearance::count_changed(d, &before, &ids))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
super::newart::reset(s);
|
super::newart::reset(s);
|
||||||
|
|||||||
@@ -332,6 +332,94 @@ fn clear_and_reduce_to_basic() {
|
|||||||
assert_eq!(node(&s, id).opacity, 0.4);
|
assert_eq!(node(&s, id).opacity, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
|
||||||
|
fn history(s: &Session) -> (usize, usize, bool) {
|
||||||
|
let st = s.doc().unwrap();
|
||||||
|
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select `ids` with a move of them left to redo, and mark the document saved.
|
||||||
|
fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
|
||||||
|
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||||
|
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
s.doc_mut().unwrap().mark_saved();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill, stroke and gradient edits that leave every object as it was (the color, stroke options,
|
||||||
|
/// gradient or item values the objects have already) record no undo step: the step to redo stays
|
||||||
|
/// and the document stays unmodified. A fill that changes one of them is one step.
|
||||||
|
#[test]
|
||||||
|
fn fill_stroke_and_gradient_edits_that_change_nothing_record_no_undo_step() {
|
||||||
|
let mut s = session();
|
||||||
|
let a = rect(&mut s, 50.0);
|
||||||
|
let b = rect(&mut s, 200.0);
|
||||||
|
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||||
|
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||||||
|
run(&mut s, "paint.setStroke", json!({"color": "#0000ff"}));
|
||||||
|
run(&mut s, "stroke.set", json!({"weight": 3, "dash": [4, 2], "cap": "round"}));
|
||||||
|
run(&mut s, "appearance.addFill", json!({}));
|
||||||
|
run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill, Fill], every fill red
|
||||||
|
run(&mut s, "paint.setGradientGeom", json!({"item": 0, "start": [60, 150], "end": [140, 150]}));
|
||||||
|
run(&mut s, "paint.editGradient", json!({"item": 2, "kind": "freeform"}));
|
||||||
|
run(&mut s, "paint.freeform.setPoint", json!({"item": 2, "index": 0, "color": "#00ff00"}));
|
||||||
|
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||||||
|
let before = history(&s);
|
||||||
|
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
|
||||||
|
for (id, p) in [
|
||||||
|
("paint.setFill", json!({"color": "#ff0000"})),
|
||||||
|
("paint.setStroke", json!({"color": "#0000ff"})),
|
||||||
|
("paint.setFill", json!({"color": "#ff0000", "ids": [a.0]})),
|
||||||
|
("paint.lastColor", json!({"stroke": false})),
|
||||||
|
("stroke.set", json!({"weight": 3, "dash": [4, 2], "cap": "round"})),
|
||||||
|
("stroke.setAdvanced", json!({"arrowScale": [100, 100]})),
|
||||||
|
("paint.setGradientGeom", json!({"item": 0, "start": [60, 150], "end": [140, 150]})),
|
||||||
|
("paint.editGradient", json!({"item": 0, "kind": "linear"})),
|
||||||
|
("paint.freeform.setPoint", json!({"item": 2, "index": 0, "color": "#00ff00"})),
|
||||||
|
("transparency.set", json!({"item": 1, "opacity": 100, "blend": "normal"})),
|
||||||
|
] {
|
||||||
|
run(&mut s, id, p.clone());
|
||||||
|
assert_eq!(history(&s), before, "{id} {p}");
|
||||||
|
}
|
||||||
|
run(&mut s, "paint.setFill", json!({"color": "#00ff00", "ids": [b.0]}));
|
||||||
|
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||||
|
assert_eq!(fill_paint(&node(&s, b), 3).color().unwrap().to_hex(), "#00ff00");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appearance panel commands that leave every object as it was record no undo step: Reduce to Basic
|
||||||
|
/// Appearance on a basic appearance, an item set to the values it has or to an index no object has,
|
||||||
|
/// Remove Item past every stack, Move Item to where the item is, the Eyedropper and Move Appearance
|
||||||
|
/// between objects that look the same, and Clear Appearance on cleared objects. Clearing objects that
|
||||||
|
/// have something to clear is one step.
|
||||||
|
#[test]
|
||||||
|
fn appearance_commands_that_change_nothing_record_no_undo_step() {
|
||||||
|
let mut s = session();
|
||||||
|
let a = rect(&mut s, 50.0);
|
||||||
|
let b = rect(&mut s, 200.0);
|
||||||
|
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||||||
|
let before = history(&s);
|
||||||
|
for (id, p) in [
|
||||||
|
("appearance.reduceToBasic", json!({})),
|
||||||
|
("appearance.setItem", json!({"index": 1, "opacity": 100, "blend": "normal", "visible": true, "weight": 1})),
|
||||||
|
("appearance.setItem", json!({"index": 5, "opacity": 50})),
|
||||||
|
("appearance.removeItem", json!({"index": 5})),
|
||||||
|
("appearance.moveItem", json!({"from": 1, "to": 1})),
|
||||||
|
("appearance.copyFrom", json!({"source": b.0, "ids": [a.0]})),
|
||||||
|
("appearance.transfer", json!({"source": b.0, "target": a.0, "copy": true})),
|
||||||
|
] {
|
||||||
|
run(&mut s, id, p.clone());
|
||||||
|
assert_eq!(history(&s), before, "{id} {p}");
|
||||||
|
}
|
||||||
|
run(&mut s, "appearance.clear", json!({}));
|
||||||
|
assert_eq!(history(&s), (before.0 + 1, 0, true), "the first Clear is one step");
|
||||||
|
s.doc_mut().unwrap().mark_saved();
|
||||||
|
let cleared = history(&s);
|
||||||
|
for (id, p) in [("appearance.clear", json!({})), ("appearance.transfer", json!({"source": b.0, "target": a.0}))] {
|
||||||
|
run(&mut s, id, p.clone());
|
||||||
|
assert_eq!(history(&s), cleared, "{id} {p}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn graphic_style_apply_replaces_the_stack() {
|
fn graphic_style_apply_replaces_the_stack() {
|
||||||
let mut s = session();
|
let mut s = session();
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ fn picking_up_without_weight_keeps_the_target_weight() {
|
|||||||
assert_eq!(s.paint.stroke_width, weight, "new art keeps its weight too");
|
assert_eq!(s.paint.stroke_width, weight, "new art keeps its weight too");
|
||||||
// Applying no transparency (one call) keeps the target's opacity; undo is one step per copy.
|
// Applying no transparency (one call) keeps the target's opacity; undo is one step per copy.
|
||||||
s.execute("transparency.set", &json!({"opacity": 100, "ids": [dst.0]})).unwrap();
|
s.execute("transparency.set", &json!({"opacity": 100, "ids": [dst.0]})).unwrap();
|
||||||
|
// A green fill, which the copy turns red again.
|
||||||
|
s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": [dst.0]})).unwrap();
|
||||||
let undo = s.doc().unwrap().history.undo.len();
|
let undo = s.doc().unwrap().history.undo.len();
|
||||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"transparency": false}}})).unwrap();
|
s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"transparency": false}}})).unwrap();
|
||||||
assert_eq!((node(&s, dst).opacity, s.doc().unwrap().history.undo.len()), (1.0, undo + 1));
|
assert_eq!((node(&s, dst).opacity, s.doc().unwrap().history.undo.len()), (1.0, undo + 1));
|
||||||
|
|||||||
@@ -118,6 +118,57 @@ fn swap_and_default_work_on_type() {
|
|||||||
assert_eq!(st.stroke, Paint::None);
|
assert_eq!(st.stroke, Paint::None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
|
||||||
|
fn history(s: &Session) -> (usize, usize, bool) {
|
||||||
|
let st = s.doc().unwrap();
|
||||||
|
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select `ids` with a move of them left to redo, and mark the document saved.
|
||||||
|
fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
|
||||||
|
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||||
|
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
s.doc_mut().unwrap().mark_saved();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default Fill and Stroke on the default fill and stroke, None on a stroke of None, Invert and
|
||||||
|
/// Complement on that stroke's Appearance row, Swap Fill and Stroke with the same fill and stroke,
|
||||||
|
/// and Apply Last Color and Apply Last Gradient on paint that is the last color or gradient already
|
||||||
|
/// record no undo step: the step to redo stays and the document stays unmodified.
|
||||||
|
#[test]
|
||||||
|
fn proxy_commands_that_change_nothing_record_no_undo_step() {
|
||||||
|
let mut s = session();
|
||||||
|
let id = rect(&mut s);
|
||||||
|
let no_step = |s: &mut Session, calls: &[(&str, serde_json::Value)]| {
|
||||||
|
select_with_a_step_to_redo(s, &[id]);
|
||||||
|
let before = history(s);
|
||||||
|
for (cmd, p) in calls {
|
||||||
|
s.execute(cmd, p).unwrap();
|
||||||
|
assert_eq!(history(s), before, "{cmd} {p}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
no_step(&mut s, &[("paint.default", json!({}))]);
|
||||||
|
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||||
|
no_step(&mut s, &[("paint.none", json!({}))]);
|
||||||
|
// The stroke's Appearance row (item 1) has no color to invert or complement.
|
||||||
|
let row = json!({"item": 1, "stroke": true});
|
||||||
|
no_step(&mut s, &[("paint.invert", row.clone()), ("paint.complement", row)]);
|
||||||
|
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||||
|
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
||||||
|
no_step(&mut s, &[("paint.swap", json!({})), ("paint.lastColor", json!({})), ("paint.lastColor", json!({"stroke": false}))]);
|
||||||
|
s.execute(
|
||||||
|
"paint.setFill",
|
||||||
|
&json!({"gradient": {"kind": "radial", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
no_step(&mut s, &[("paint.lastGradient", json!({"stroke": false}))]);
|
||||||
|
// Swapping a gradient fill and a red stroke is one step.
|
||||||
|
let before = history(&s);
|
||||||
|
s.execute("paint.swap", &json!({})).unwrap();
|
||||||
|
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recent_colors_are_fed_by_every_paint_command() {
|
fn recent_colors_are_fed_by_every_paint_command() {
|
||||||
let mut s = session();
|
let mut s = session();
|
||||||
|
|||||||
@@ -595,3 +595,30 @@ fn a_fill_chosen_with_characters_selected_colours_only_them() {
|
|||||||
s.execute("edit.undo", &json!({})).unwrap();
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
assert!(obj(&s, id).runs.iter().all(|r| r.style.fill == before));
|
assert!(obj(&s, id).runs.iter().all(|r| r.style.fill == before));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fill or stroke that the characters selected with the Type tool have already records no undo
|
||||||
|
/// step: the step to redo stays and the document stays unmodified.
|
||||||
|
#[test]
|
||||||
|
fn a_fill_the_selected_characters_have_records_no_undo_step() {
|
||||||
|
let mut s = session();
|
||||||
|
let v = ViewInfo::default();
|
||||||
|
s.select_tool("type", v).unwrap();
|
||||||
|
click(&mut s, 50.0, 300.0);
|
||||||
|
s.tool_text("Sample Text", v).unwrap();
|
||||||
|
s.set_tool_option("select", &json!({"start": 0, "end": 6}));
|
||||||
|
s.execute("paint.setFill", &json!({"color": "#00a000"})).unwrap();
|
||||||
|
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
||||||
|
// A blue fill, undone, leaves a step to redo.
|
||||||
|
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
s.doc_mut().unwrap().mark_saved();
|
||||||
|
let history = |s: &Session| {
|
||||||
|
let st = s.doc().unwrap();
|
||||||
|
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||||
|
};
|
||||||
|
let before = history(&s);
|
||||||
|
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
|
||||||
|
s.execute("paint.setFill", &json!({"color": "#00a000"})).unwrap();
|
||||||
|
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
||||||
|
assert_eq!(history(&s), before);
|
||||||
|
}
|
||||||
@@ -469,6 +469,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
|||||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
||||||
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
|
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
|
||||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||||
|
Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing.
|
||||||
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
|
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
|
||||||
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
|
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
|
||||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||||
|
|||||||
Reference in new issue
Block a user