mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
Paste: Paste without Formatting keeps a pasted graph's labels plain when it is drawn again
A graph keeps its labels' character and paragraph formats for each redraw. Paste without Formatting reset the labels' text but left those formats in the graph, so the next redraw (Graph Data, Graph Type) gave the labels their source formatting back. Paste without Formatting now drops the formats the graph keeps and sets the pasted labels as a new graph's labels are set: 9 pt black, unscaled (a rotated graph's labels keep their rotation), default paragraph attributes, each keeping its alignment, and white on a dark pie wedge. Before, they took 12 pt and lost their alignment. The rest of the graph's art is pasted as it was copied. A swatch-linked character fill the graph kept for its labels stays behind with the other formats. When the document's swatches give a pasted pie's wedges another color (a swatch merged into the document's swatch of that name, or a swatch changed in the document the pie was copied from), Paste and Paste without Formatting set the legend labels in those wedges white on a dark wedge and black on a light one, as a redraw does. Before, they kept the color chosen for the copied wedge, which could leave white text on a light wedge. A label whose characters are not all white or all black keeps its colors.
This commit is contained in:
@@ -179,7 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste",
|
||||
["Edit"],
|
||||
Some("Cmd+V"),
|
||||
"{center?: [x, y], artboardsAt?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers and sublayers they came from (by the names of the layer and the layers around it in another document; a missing sublayer is made inside its layer; a hidden or locked one is an error and nothing is pasted). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay, or centered on `artboardsAt` (the Artboard tool's canvas menu passes the right-click point; a value that is not [x, y] is an error) (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
|
||||
"{center?: [x, y], artboardsAt?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}; when the document's swatches change the color of a pasted pie's wedges (after a merge, or pasted back after a swatch it uses changed), the legend labels in those wedges are white on a dark wedge and black on a light one unless the pie keeps a legend label fill. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers and sublayers they came from (by the names of the layer and the layers around it in another document; a missing sublayer is made inside its layer; a hidden or locked one is an error and nothing is pasted). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay, or centered on `artboardsAt` (the Artboard tool's canvas menu passes the right-click point; a value that is not [x, y] is an error) (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Offset)
|
||||
),
|
||||
@@ -1455,6 +1455,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
for (k, n) in clip.nodes.iter().enumerate() {
|
||||
let mut c = d.reid(n);
|
||||
imported.apply(&mut c);
|
||||
// The document's swatches (merged, or changed since the copy in the same document) can give a pie's
|
||||
// wedges another color: their legend labels follow it.
|
||||
super::graph::wedge_label_contrast(n, &mut c);
|
||||
let sc = vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() };
|
||||
if xf != Affine::IDENTITY {
|
||||
// A cross-document drag carries the graphic's appearance with it,
|
||||
|
||||
@@ -40,7 +40,7 @@ pub use commands::specs;
|
||||
pub(crate) use data::{apply_csv, parse_csv};
|
||||
#[cfg(test)]
|
||||
use data::{fmt_value, to_csv};
|
||||
pub(crate) use edit::{graphs_holding, keep_placed};
|
||||
pub(crate) use edit::{edit_graph_art, graphs_holding, keep_placed};
|
||||
#[cfg(test)]
|
||||
use generate::generate;
|
||||
#[cfg(test)]
|
||||
@@ -48,8 +48,8 @@ use series::default_series_color;
|
||||
pub(crate) use series::{
|
||||
LabelTextChange, capture_label_anti_alias, capture_label_changes, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows,
|
||||
capture_label_styles, capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art,
|
||||
label_object_opacity_differs, labels_in, paint_targets, series_group_marks, series_group_marks_where, series_groups_among, series_marks_in,
|
||||
series_object_opacity_differs, shown_paint,
|
||||
label_object_opacity_differs, labels_in, paint_targets, plain_labels, series_group_marks, series_group_marks_where, series_groups_among,
|
||||
series_marks_in, series_object_opacity_differs, shown_paint, wedge_label_contrast,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -88,6 +88,23 @@ pub(crate) fn keep_placed(d: &mut Document, graphs: &[(NodeId, Option<Rect>, Opt
|
||||
}
|
||||
}
|
||||
|
||||
/// Let `edit` change the art of graph node `n` (a copy outside any document) without the next redraw reading the
|
||||
/// change as a move or scale: the plot rect first takes any move or scale of the art since it was generated, as a
|
||||
/// redraw measures it, and the bounds the graph keeps become those of the changed art (also for a graph that had none,
|
||||
/// so a later move is followed).
|
||||
pub(crate) fn edit_graph_art(n: &mut Node, edit: impl FnOnce(&mut Node)) {
|
||||
let moved = measured(n).and_then(|(placed, now)| n.extras().graph.as_deref().and_then(|g| carried(g.rect, placed, now)));
|
||||
if let (Some(rect), Some(g)) = (moved, n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) {
|
||||
g.rect = rect;
|
||||
}
|
||||
edit(n);
|
||||
let children = n.children();
|
||||
let (whole, art) = (children.and_then(|c| children_bounds(c)), children.and_then(|c| art_bounds(c)));
|
||||
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
|
||||
(g.placed, g.placed_art) = (whole, art);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last
|
||||
/// generation, which is recorded in the spec's `placed_art` bounds, or `placed` in graphs saved without them).
|
||||
pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> {
|
||||
|
||||
@@ -198,7 +198,7 @@ fn bar_rect(horizontal: bool, a: f64, w: f64, v0: f64, v1: f64) -> Rect {
|
||||
}
|
||||
|
||||
/// White text on a dark wedge.
|
||||
fn is_dark(fill: &Paint) -> bool {
|
||||
pub(super) fn is_dark(fill: &Paint) -> bool {
|
||||
fill.color().is_some_and(|c| {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_geom::Shape;
|
||||
|
||||
use super::axis::radar_axis;
|
||||
use super::labels::{Role, filled_lines_on, label_para, label_style};
|
||||
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad};
|
||||
use super::generate::is_dark;
|
||||
use super::labels::{Role, filled_lines_on, label_para, label_size, label_style};
|
||||
use super::{
|
||||
Affine, Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad,
|
||||
refresh_bounds,
|
||||
};
|
||||
|
||||
// ---------- generation ----------
|
||||
|
||||
@@ -639,35 +644,558 @@ fn graph_labels(doc: &Document) -> HashMap<NodeId, (NodeId, Role)> {
|
||||
let mut out = HashMap::new();
|
||||
doc.walk(|n| {
|
||||
let Some(g) = n.extras().graph.as_deref() else { return };
|
||||
for c in n.children().into_iter().flatten() {
|
||||
match (&c.kind, c.name.as_deref(), c.extras().series_index) {
|
||||
(NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => {
|
||||
out.insert(c.id, (n.id, Role::Category));
|
||||
}
|
||||
(NodeKind::Group { .. }, Some("Legend"), None) => {
|
||||
for t in c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))) {
|
||||
out.insert(t.id, (n.id, Role::Legend));
|
||||
}
|
||||
}
|
||||
(NodeKind::Group { .. }, Some("Axes"), None) => {
|
||||
let texts: Vec<NodeId> =
|
||||
c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))).map(|t| t.id).collect();
|
||||
let cats = match g.kind {
|
||||
GraphKind::Scatter => 0,
|
||||
GraphKind::Radar => texts.len(),
|
||||
_ => g.categories.iter().take(g.rows.len().max(1)).filter(|c| !c.is_empty()).count().min(texts.len()),
|
||||
};
|
||||
for (i, t) in texts.iter().enumerate() {
|
||||
out.insert(*t, (n.id, if i + cats >= texts.len() { Role::Category } else { Role::Value }));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for (id, role) in own_labels(n, g) {
|
||||
out.insert(id, (n.id, role));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The generated labels of graph `n` (with spec `g`) and what each shows, as [`graph_labels`] finds them.
|
||||
fn own_labels(n: &Node, g: &GraphSpec) -> Vec<(NodeId, Role)> {
|
||||
let mut out = vec![];
|
||||
for c in n.children().into_iter().flatten() {
|
||||
match (&c.kind, c.name.as_deref(), c.extras().series_index) {
|
||||
(NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => {
|
||||
out.push((c.id, Role::Category));
|
||||
}
|
||||
(NodeKind::Group { .. }, Some("Legend"), None) => {
|
||||
for t in c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))) {
|
||||
out.push((t.id, Role::Legend));
|
||||
}
|
||||
}
|
||||
(NodeKind::Group { .. }, Some("Axes"), None) => {
|
||||
let texts: Vec<NodeId> = c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))).map(|t| t.id).collect();
|
||||
let cats = match g.kind {
|
||||
GraphKind::Scatter => 0,
|
||||
GraphKind::Radar => texts.len(),
|
||||
_ => g.categories.iter().take(g.rows.len().max(1)).filter(|c| !c.is_empty()).count().min(texts.len()),
|
||||
};
|
||||
for (i, t) in texts.iter().enumerate() {
|
||||
out.push((*t, if i + cats >= texts.len() { Role::Category } else { Role::Value }));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Set the labels of graph node `n` (a copy outside any document) as a new graph's labels are set: the character and
|
||||
/// paragraph formats the graph keeps for them are dropped, and each label takes its kind's default character style,
|
||||
/// unscaled (keeping its rotation), and default paragraph attributes, keeping its alignment and where it starts. A pie's legend label on a dark
|
||||
/// wedge is white, as a redraw sets it. Every other object in the graph goes to `other`. The rest of the graph's art,
|
||||
/// and what its labels say, stay as they are.
|
||||
pub(crate) fn plain_labels(n: &mut Node, other: &dyn Fn(&mut Node)) {
|
||||
fn plain(l: &mut vectorcraft_doc::LabelStyles) {
|
||||
(l.values, l.categories, l.legend) = (None, None, None);
|
||||
(l.values_para, l.categories_para, l.legend_para) = (None, None, None);
|
||||
}
|
||||
// Which legend labels a redraw sets on a dark wedge, read while the labels are as the graph drew them.
|
||||
let on_dark = match n.extras().graph.as_deref() {
|
||||
Some(g) if g.kind == GraphKind::Pie && g.legend && g.pie_legend_in_wedges => {
|
||||
let mut redraw = g.clone();
|
||||
plain(&mut redraw.label_styles);
|
||||
redrawn_on_dark(n, &redraw).unwrap_or_else(|| legend_on_dark(n, g))
|
||||
}
|
||||
_ => HashMap::new(),
|
||||
};
|
||||
let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) else { return };
|
||||
plain(&mut g.label_styles);
|
||||
let Some(g) = n.extras().graph.as_deref() else { return };
|
||||
let roles: HashMap<NodeId, Role> = own_labels(n, g).into_iter().collect();
|
||||
struct Plain<'a> {
|
||||
roles: HashMap<NodeId, Role>,
|
||||
other: &'a dyn Fn(&mut Node),
|
||||
}
|
||||
fn visit(c: &mut Node, p: &Plain) {
|
||||
let role = p.roles.get(&c.id).copied();
|
||||
if let (Some(role), NodeKind::Text(t)) = (role, &mut c.kind) {
|
||||
let plain = vectorcraft_doc::LabelStyles::default();
|
||||
let justify = t.para_at(0).justify;
|
||||
let text = t.plain_text();
|
||||
t.runs = vec![vectorcraft_doc::TextRun { text, style: label_style(&plain, role), inline: None }];
|
||||
let mut para = label_para(&plain, role);
|
||||
para.justify = justify;
|
||||
t.set_all_paras(para);
|
||||
// The label loses its scale and keeps its rotation (and a reflection), so it stays turned with the
|
||||
// rest of a rotated graph's art.
|
||||
let [a, b, c, d, _, _] = t.xf.as_coeffs();
|
||||
let flip = if a * d - b * c < 0.0 { Affine::scale_non_uniform(1.0, -1.0) } else { Affine::IDENTITY };
|
||||
t.xf = Affine::translate(t.xf.translation()) * Affine::rotate(b.atan2(a)) * flip;
|
||||
refresh_bounds(t);
|
||||
return;
|
||||
}
|
||||
// The Axes and Legend groups, and any other group of the graph's own, are looked through.
|
||||
if role.is_none() && matches!(c.kind, NodeKind::Group { .. }) && c.extras().graph.is_none() {
|
||||
for k in c.children_mut().into_iter().flatten() {
|
||||
visit(Arc::make_mut(k), p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
(p.other)(c);
|
||||
}
|
||||
let p = Plain { roles, other };
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
visit(Arc::make_mut(c), &p);
|
||||
}
|
||||
set_legend_fills(n, &on_dark);
|
||||
}
|
||||
|
||||
/// Give each legend label of graph `n` in `on_dark` white characters (`true`) or black ones.
|
||||
fn set_legend_fills(n: &mut Node, on_dark: &HashMap<NodeId, bool>) {
|
||||
if on_dark.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The legend labels are in the graph's Legend group.
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
if !c.children().into_iter().flatten().any(|t| on_dark.contains_key(&t.id)) {
|
||||
continue;
|
||||
}
|
||||
for t in Arc::make_mut(c).children_mut().into_iter().flatten() {
|
||||
let Some(dark) = on_dark.get(&t.id) else { continue };
|
||||
let fill = Paint::solid(if *dark { Color::WHITE } else { Color::BLACK });
|
||||
if matches!(&t.kind, NodeKind::Text(text) if text.runs.iter().all(|r| r.style.fill == fill)) {
|
||||
continue;
|
||||
}
|
||||
if let NodeKind::Text(text) = &mut Arc::make_mut(t).kind {
|
||||
text.runs.iter_mut().for_each(|r| r.style.fill = fill.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The legend labels of graph `n`: the type in its Legend group, as [`own_labels`] finds them.
|
||||
fn legend_labels(n: &Node) -> Vec<&Node> {
|
||||
legend_in(n.children().map_or(&[], |c| c.as_slice()))
|
||||
}
|
||||
|
||||
/// The type in the Legend group among a graph's `children`.
|
||||
fn legend_in(children: &[Arc<Node>]) -> Vec<&Node> {
|
||||
children
|
||||
.iter()
|
||||
.filter(|c| matches!(c.kind, NodeKind::Group { .. }) && c.name.as_deref() == Some("Legend") && c.extras().series_index.is_none())
|
||||
.flat_map(|c| c.children().into_iter().flatten())
|
||||
.map(|t| t.as_ref())
|
||||
.filter(|t| matches!(t.kind, NodeKind::Text(_)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A pie wedge: its series, its outline and whether the fill it shows is dark.
|
||||
struct Wedge {
|
||||
series: u32,
|
||||
bounds: vectorcraft_geom::Rect,
|
||||
path: vectorcraft_geom::BezPath,
|
||||
dark: bool,
|
||||
}
|
||||
|
||||
/// The wedges of pie `n` (the paths of its series groups), in paint order.
|
||||
fn pie_wedges(n: &Node) -> Vec<Wedge> {
|
||||
wedges_in(n.children().map_or(&[], |c| c.as_slice()))
|
||||
}
|
||||
|
||||
/// The wedges among a pie's `children`, as [`pie_wedges`] finds them.
|
||||
fn wedges_in(children: &[Arc<Node>]) -> Vec<Wedge> {
|
||||
let mut wedges = vec![];
|
||||
for c in children {
|
||||
let Some(series) = c.extras().series_index else { continue };
|
||||
c.walk(&mut |m| {
|
||||
if let NodeKind::Path { path, .. } = &m.kind {
|
||||
let path = path.to_bezpath();
|
||||
wedges.push(Wedge { series, bounds: path.bounding_box(), path, dark: is_dark(&shown_paint(m, true).0) });
|
||||
}
|
||||
});
|
||||
}
|
||||
wedges
|
||||
}
|
||||
|
||||
/// The text of the label of each series of graph `g` in its wedges, with the series that show it (several when series
|
||||
/// share a name).
|
||||
fn series_by_label(g: &GraphSpec) -> HashMap<String, Vec<u32>> {
|
||||
let mut names: HashMap<String, Vec<u32>> = HashMap::new();
|
||||
for (s, name) in g.series.iter().enumerate() {
|
||||
if let Ok(s) = u32::try_from(s) {
|
||||
names.entry(name.replace('|', "\n")).or_default().push(s);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// The legend labels of pie `n`, each with whether a redraw from spec `g` sets it on a dark wedge, by the fill each wedge
|
||||
/// of `n` shows now. The pie is drawn again apart from any document, which places each label in its wedge as `g` sets
|
||||
/// it and tells each label's series. Each redrawn wedge takes the fill of the wedge of `n` of its series with the same
|
||||
/// bounds, and each legend label of `n` is the nearest redrawn one with the same text, places measured in the bounds of
|
||||
/// all the wedges. `None` when a wedge or a label has no counterpart of its own, or a wedge out of its place in paint
|
||||
/// order has several (art or labels added, removed, edited or restyled since the pie was drawn).
|
||||
fn redrawn_on_dark(n: &Node, g: &GraphSpec) -> Option<HashMap<NodeId, bool>> {
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
// Each series name that shows a label is drawn with a mark and its index after it, which moves no line, so each
|
||||
// redrawn label tells its series.
|
||||
const MARK: char = '\u{1f}';
|
||||
let mut marked = g.clone();
|
||||
for (s, name) in marked.series.iter_mut().enumerate() {
|
||||
if !super::labels::blank_label(name) {
|
||||
name.push(MARK);
|
||||
name.push_str(&s.to_string());
|
||||
}
|
||||
}
|
||||
let legend = legend_labels(n);
|
||||
let mut scratch = Document::new(1.0, 1.0);
|
||||
let art = super::generate::generate(&mut scratch, &marked);
|
||||
let redrawn = legend_in(&art);
|
||||
let now = pie_wedges(n);
|
||||
let mut wedges = wedges_in(&art);
|
||||
if redrawn.len() != legend.len() || wedges.len() != now.len() {
|
||||
return None;
|
||||
}
|
||||
// Places are measured in the bounds of all the wedges, since the pie may have been moved or resized since it was drawn.
|
||||
let frame = |ws: &[Wedge]| ws.iter().map(|w| w.bounds).reduce(|a, b| a.union(b));
|
||||
let (Some(was), Some(is)) = (frame(&wedges), frame(&now)) else { return None };
|
||||
let at = |f: vectorcraft_geom::Rect, p: vectorcraft_geom::Point| ((p.x - f.x0) / f.width().max(1e-9), (p.y - f.y0) / f.height().max(1e-9));
|
||||
let corners = |f: vectorcraft_geom::Rect, w: &Wedge| {
|
||||
let (a, b) = (at(f, vectorcraft_geom::Point::new(w.bounds.x0, w.bounds.y0)), at(f, vectorcraft_geom::Point::new(w.bounds.x1, w.bounds.y1)));
|
||||
[a.0, a.1, b.0, b.1]
|
||||
};
|
||||
// A wedge and a redrawn one are the same wedge when they show the same series with the same bounds. Wedges are
|
||||
// compared this many times in all, labels included; past it, nothing is matched.
|
||||
let mut budget: usize = 1 << 20;
|
||||
let mut copied_wedges: HashMap<u32, Vec<(usize, [f64; 4])>> = HashMap::new();
|
||||
for (k, w) in now.iter().enumerate() {
|
||||
copied_wedges.entry(w.series).or_default().push((k, corners(is, w)));
|
||||
}
|
||||
// The wedge in the same place in paint order when its bounds are the same, else the one of the series whose bounds
|
||||
// are nearest, when they are the same to within 0.001 of the whole; each copied wedge is matched once.
|
||||
let off = |c: &[f64; 4], d: &[f64; 4]| c.iter().zip(d).map(|(p, q)| (p - q).abs()).fold(0.0, f64::max);
|
||||
let mut used = vec![false; now.len()];
|
||||
let mut fills = Vec::with_capacity(wedges.len());
|
||||
for (i, r) in wedges.iter().enumerate() {
|
||||
let c = corners(was, r);
|
||||
let k = match now.get(i) {
|
||||
Some(w) if w.series == r.series && off(&c, &corners(is, w)) <= 1e-7 => i,
|
||||
_ => {
|
||||
let group = copied_wedges.get(&r.series)?;
|
||||
if budget < group.len() {
|
||||
return None;
|
||||
}
|
||||
budget -= group.len();
|
||||
let (k, d) = group.iter().map(|(k, d)| (*k, off(&c, d))).min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
if d > 1e-3 {
|
||||
return None;
|
||||
}
|
||||
k
|
||||
}
|
||||
};
|
||||
if std::mem::replace(used.get_mut(k)?, true) {
|
||||
return None;
|
||||
}
|
||||
fills.push(now.get(k)?.dark);
|
||||
}
|
||||
for (r, dark) in wedges.iter_mut().zip(fills) {
|
||||
r.dark = dark;
|
||||
}
|
||||
// Each label's words and origin, in the frame of its pie's wedges.
|
||||
let place = |labels: &[&Node], f| {
|
||||
labels
|
||||
.iter()
|
||||
.map(|t| match &t.kind {
|
||||
NodeKind::Text(text) => Some((text.plain_text(), at(f, text.xf * vectorcraft_geom::Point::ORIGIN))),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
};
|
||||
let (Some(copied), Some(mut drawn)) = (place(&legend, is), place(&redrawn, was)) else { return None };
|
||||
// The series of each redrawn label, and its words without the mark.
|
||||
let mut series = Vec::with_capacity(drawn.len());
|
||||
for (words, _) in &mut drawn {
|
||||
let (said, s) = words.rsplit_once(MARK)?;
|
||||
let s: usize = s.parse().ok()?;
|
||||
let name = g.series.get(s)?;
|
||||
if *said != name.replace('|', "\n") {
|
||||
return None;
|
||||
}
|
||||
series.push((s, name.as_str()));
|
||||
words.truncate(said.len());
|
||||
}
|
||||
// Each label is the nearest redrawn label that says the same, and no two labels are the same redrawn one.
|
||||
let mut same: HashMap<&str, Vec<usize>> = HashMap::new();
|
||||
for (j, (words, _)) in drawn.iter().enumerate() {
|
||||
same.entry(words.as_str()).or_default().push(j);
|
||||
}
|
||||
let mut taken = vec![false; drawn.len()];
|
||||
let mut matched = Vec::with_capacity(copied.len());
|
||||
for (words, p) in &copied {
|
||||
let group = same.get(words.as_str())?;
|
||||
if budget < group.len() {
|
||||
return None;
|
||||
}
|
||||
budget -= group.len();
|
||||
let away = |j: &usize| drawn.get(*j).map_or(f64::INFINITY, |(_, q)| (p.0 - q.0).powi(2) + (p.1 - q.1).powi(2));
|
||||
let j = *group.iter().min_by(|a, b| away(a).total_cmp(&away(b)))?;
|
||||
if std::mem::replace(taken.get_mut(j)?, true) {
|
||||
return None;
|
||||
}
|
||||
matched.push(j);
|
||||
}
|
||||
let mut by_series: HashMap<u32, Vec<&Wedge>> = HashMap::new();
|
||||
for w in &wedges {
|
||||
by_series.entry(w.series).or_default().push(w);
|
||||
}
|
||||
// A redraw puts the middle of a label's lines (its series name split at `|`, at the default leading) 0.35 of the
|
||||
// label size below a point inside its wedge, at the label's origin across. The label is on the nearest wedge of its
|
||||
// series to that point, the one holding it. When the wedges of the series are all dark or all light, the place does
|
||||
// not matter.
|
||||
let uniform: HashMap<u32, Option<bool>> =
|
||||
by_series.iter().map(|(s, ws)| (*s, ws.first().map(|w| w.dark).filter(|d| ws.iter().all(|w| w.dark == *d)))).collect();
|
||||
let ls = label_size(&g.label_styles, Role::Legend);
|
||||
let mut out = HashMap::new();
|
||||
for (t, j) in legend.iter().zip(matched) {
|
||||
let (Some(r), Some((s, name))) = (redrawn.get(j), series.get(j).copied()) else { return None };
|
||||
let s = u32::try_from(s).ok()?;
|
||||
if let Some(Some(dark)) = uniform.get(&s) {
|
||||
out.insert(t.id, *dark);
|
||||
continue;
|
||||
}
|
||||
let NodeKind::Text(text) = &r.kind else { return None };
|
||||
let middle = name.split('|').count().saturating_sub(1) as f64 * ls * 1.2 / 2.0;
|
||||
let point = text.xf * vectorcraft_geom::Point::new(0.0, middle - ls * 0.35);
|
||||
let named = by_series.get(&s)?;
|
||||
if budget < named.len() {
|
||||
return None;
|
||||
}
|
||||
budget -= named.len();
|
||||
// The nearest wedge, measured exactly only while its bounds are nearer than the nearest one found so far.
|
||||
let to_box = |b: vectorcraft_geom::Rect| {
|
||||
let (dx, dy) = ((b.x0 - point.x).max(point.x - b.x1).max(0.0), (b.y0 - point.y).max(point.y - b.y1).max(0.0));
|
||||
dx * dx + dy * dy
|
||||
};
|
||||
let away = |w: &Wedge| {
|
||||
if w.path.contains(point) { 0.0 } else { w.path.segments().map(|seg| seg.nearest(point, 1e-3).distance_sq).fold(f64::INFINITY, f64::min) }
|
||||
};
|
||||
let mut by_box: Vec<(f64, &Wedge)> = named.iter().map(|w| (to_box(w.bounds), *w)).collect();
|
||||
by_box.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut best: Option<(f64, bool)> = None;
|
||||
for (near, w) in by_box {
|
||||
if best.is_some_and(|b| near >= b.0) {
|
||||
break;
|
||||
}
|
||||
let d = away(w);
|
||||
if best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, w.dark));
|
||||
}
|
||||
}
|
||||
out.insert(t.id, best?.1);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The legend labels of graph `n` (a pie with Legends in Wedges, its labels where it drew them), each with whether a
|
||||
/// redraw sets it on a dark wedge, by the fill each wedge shows now. A label is placed by the point a redraw centers it
|
||||
/// on, which lies inside its wedge: the wedge holding it of the series the label names (the nearest one when none holds
|
||||
/// it), or of any series when the label names none. When the wedges of the series a label names are all dark or all
|
||||
/// light, the label's place does not matter.
|
||||
fn legend_on_dark(n: &Node, g: &GraphSpec) -> HashMap<NodeId, bool> {
|
||||
wedge_contrast(&legend_labels(n), &pie_wedges(n), g)
|
||||
}
|
||||
|
||||
/// The point a redraw centers legend label `text` of series `name` on, in a pie of spec `g` whose legend labels are
|
||||
/// `ls` points: 0.35 of the label size above where a redraw (`label` in `labels.rs`) puts the middle of the label's
|
||||
/// lines, at the label's origin across. The lines are those of the series name, split at `|`, at the default leading;
|
||||
/// with kept paragraph attributes, a redraw puts the middle of the first and last baselines there instead, when each of
|
||||
/// those lines that holds characters has one baseline and the first and last hold characters.
|
||||
fn label_anchor(text: &vectorcraft_doc::TextObject, name: &str, g: &GraphSpec, ls: f64) -> vectorcraft_geom::Point {
|
||||
let lines: Vec<&str> = name.split('|').collect();
|
||||
let ys: Vec<f64> = text.baselines().map(|(a, _)| a.y).collect();
|
||||
let set = lines.iter().filter(|l| !l.is_empty()).count();
|
||||
let ends_set = lines.first().is_some_and(|l| !l.is_empty()) && lines.last().is_some_and(|l| !l.is_empty());
|
||||
let middle = match (ys.first(), ys.last()) {
|
||||
(Some(first), Some(last)) if g.label_styles.legend_para.is_some() && ends_set && ys.len() == set => (first + last) / 2.0,
|
||||
_ => lines.len().saturating_sub(1) as f64 * ls * 1.2 / 2.0,
|
||||
};
|
||||
text.xf * vectorcraft_geom::Point::new(0.0, middle - ls * 0.35)
|
||||
}
|
||||
|
||||
/// [`legend_on_dark`] for legend labels `legend` of a pie of spec `g` with wedges `wedges`.
|
||||
fn wedge_contrast(legend: &[&Node], wedges: &[Wedge], g: &GraphSpec) -> HashMap<NodeId, bool> {
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
let mut out = HashMap::new();
|
||||
if legend.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let mut by_series: HashMap<u32, Vec<&Wedge>> = HashMap::new();
|
||||
for w in wedges {
|
||||
by_series.entry(w.series).or_default().push(w);
|
||||
}
|
||||
// For each label text, the wedges of the series that show it, and how many of them are dark.
|
||||
let named_wedges: HashMap<String, (Vec<&Wedge>, usize)> = series_by_label(g)
|
||||
.into_iter()
|
||||
.map(|(name, series)| {
|
||||
let named: Vec<&Wedge> = series.iter().filter_map(|s| by_series.get(s)).flatten().copied().collect();
|
||||
let darks = named.iter().filter(|w| w.dark).count();
|
||||
(name, (named, darks))
|
||||
})
|
||||
.collect();
|
||||
let none = (vec![], 0);
|
||||
// For each label text, the first series name that shows it (names that differ only in `|` against a line break of
|
||||
// their own show the same text, and nothing in the label tells them apart).
|
||||
let mut series_names: HashMap<String, &str> = HashMap::new();
|
||||
for name in &g.series {
|
||||
series_names.entry(name.replace('|', "\n")).or_insert(name.as_str());
|
||||
}
|
||||
let ls = label_size(&g.label_styles, Role::Legend);
|
||||
// The search for the nearest wedge looks at this many wedges in all; past it, a label takes the contrast most of the
|
||||
// wedges of its series show.
|
||||
let mut budget: usize = 1 << 20;
|
||||
for t in legend {
|
||||
let NodeKind::Text(text) = &t.kind else { continue };
|
||||
let id = t.id;
|
||||
let words = text.plain_text();
|
||||
let (named, darks) = named_wedges.get(&words).unwrap_or(&none);
|
||||
let darks = *darks;
|
||||
if (darks == 0 || darks == named.len())
|
||||
&& let Some(w) = named.first()
|
||||
{
|
||||
out.insert(id, w.dark);
|
||||
continue;
|
||||
}
|
||||
let name = series_names.get(&words).map_or_else(|| words.replace('\n', "|"), |s| (*s).to_owned());
|
||||
let at = label_anchor(text, &name, g, ls);
|
||||
if named.is_empty() {
|
||||
let cost = wedges.len();
|
||||
let dark = budget >= cost && wedges.iter().any(|w| w.dark && w.bounds.contains(at) && w.path.contains(at));
|
||||
budget = budget.saturating_sub(cost);
|
||||
out.insert(id, dark);
|
||||
continue;
|
||||
}
|
||||
if budget < named.len() {
|
||||
out.insert(id, darks * 2 > named.len());
|
||||
continue;
|
||||
}
|
||||
budget -= named.len();
|
||||
// The nearest wedge, measured exactly only while its bounds are nearer than the nearest one found so far.
|
||||
let to_box = |r: vectorcraft_geom::Rect| {
|
||||
let (dx, dy) = ((r.x0 - at.x).max(at.x - r.x1).max(0.0), (r.y0 - at.y).max(at.y - r.y1).max(0.0));
|
||||
dx * dx + dy * dy
|
||||
};
|
||||
let away = |w: &Wedge| {
|
||||
if w.path.contains(at) { 0.0 } else { w.path.segments().map(|s| s.nearest(at, 1e-3).distance_sq).fold(f64::INFINITY, f64::min) }
|
||||
};
|
||||
let mut by_box: Vec<(f64, &Wedge)> = named.iter().map(|w| (to_box(w.bounds), *w)).collect();
|
||||
by_box.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut best = (f64::INFINITY, darks * 2 > named.len());
|
||||
for (near, w) in by_box {
|
||||
if near >= best.0 {
|
||||
break;
|
||||
}
|
||||
let d = away(w);
|
||||
if d < best.0 {
|
||||
best = (d, w.dark);
|
||||
}
|
||||
}
|
||||
out.insert(id, best.1);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Does `n` hold, or is it, a pie that colors its legend labels by their wedge ([`picks_legend_fill`])?
|
||||
fn holds_wedge_labels(n: &Node) -> bool {
|
||||
let mut found = false;
|
||||
n.walk(&mut |m| found |= m.extras().graph.as_deref().is_some_and(picks_legend_fill));
|
||||
found
|
||||
}
|
||||
|
||||
/// Recolor the legend labels of every pie in or at `after` that colors them by their wedge ([`picks_legend_fill`]) when
|
||||
/// its wedges changed color from `before` (the same art before it took the document's swatches): a label white on a
|
||||
/// dark wedge, or black on a light one, takes the contrast its wedge shows now, as a redraw sets it. The wedge of a label
|
||||
/// is any wedge of the series it names that showed the label's contrast before, or the nearest of them when those
|
||||
/// wedges changed apart; a label naming no series, and a label whose characters are not all white or all black, keep
|
||||
/// their colors. Paste calls it
|
||||
/// after the pasted art takes the document's swatches.
|
||||
pub(crate) fn wedge_label_contrast(before: &Node, after: &mut Node) {
|
||||
if !holds_wedge_labels(after) {
|
||||
return;
|
||||
}
|
||||
recolor_legend(before, after);
|
||||
let Some(was) = before.children() else { return };
|
||||
for (b, a) in was.iter().zip(after.children_mut().into_iter().flatten()) {
|
||||
if holds_wedge_labels(a) {
|
||||
wedge_label_contrast(b, Arc::make_mut(a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`wedge_label_contrast`] for graph `after` alone, not the graphs inside it.
|
||||
fn recolor_legend(before: &Node, after: &mut Node) {
|
||||
let Some(g) = after.extras().graph.as_deref().filter(|g| picks_legend_fill(g)) else { return };
|
||||
let (was, now) = (pie_wedges(before), pie_wedges(after));
|
||||
if was.len() != now.len() || was.iter().zip(&now).all(|(b, a)| b.dark == a.dark) {
|
||||
return;
|
||||
}
|
||||
// For each series, the contrast its wedges show now, by the contrast they showed before (`None`: they differ).
|
||||
let mut shift: HashMap<(u32, bool), Option<bool>> = HashMap::new();
|
||||
for (b, a) in was.iter().zip(&now) {
|
||||
shift.entry((a.series, b.dark)).and_modify(|d| *d = d.filter(|d| *d == a.dark)).or_insert(Some(a.dark));
|
||||
}
|
||||
let names = series_by_label(g);
|
||||
let (white, black) = (Paint::solid(Color::WHITE), Paint::solid(Color::BLACK));
|
||||
let mut on_dark = HashMap::new();
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
// How far point `at` lies from a wedge: 0 inside it.
|
||||
let away = |w: &Wedge, at: vectorcraft_geom::Point| {
|
||||
if w.path.contains(at) { 0.0 } else { w.path.segments().map(|s| s.nearest(at, 1e-3).distance_sq).fold(f64::INFINITY, f64::min) }
|
||||
};
|
||||
// Wedge visits left for labels whose series' wedges changed apart, so a huge pie stays bounded.
|
||||
let mut budget: usize = 1 << 20;
|
||||
// For each label text, the first series name that shows it, as `wedge_contrast` reads it.
|
||||
let mut series_names: HashMap<String, &str> = HashMap::new();
|
||||
for name in &g.series {
|
||||
series_names.entry(name.replace('|', "\n")).or_insert(name.as_str());
|
||||
}
|
||||
let ls = label_size(&g.label_styles, Role::Legend);
|
||||
// The positions of each series' wedges in `was` and `now`.
|
||||
let mut by_series: HashMap<u32, Vec<usize>> = HashMap::new();
|
||||
for (i, a) in now.iter().enumerate() {
|
||||
by_series.entry(a.series).or_default().push(i);
|
||||
}
|
||||
for t in legend_labels(after) {
|
||||
let NodeKind::Text(text) = &t.kind else { continue };
|
||||
let was_dark = if text.runs.iter().all(|r| r.style.fill == white) {
|
||||
true
|
||||
} else if text.runs.iter().all(|r| r.style.fill == black) {
|
||||
false
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let series: &[u32] = names.get(&text.plain_text()).map_or(&[], |s| s.as_slice());
|
||||
let mut shown = series.iter().filter_map(|s| shift.get(&(*s, was_dark)));
|
||||
let Some(first) = shown.next().copied() else { continue };
|
||||
if let Some(dark) = first.filter(|d| shown.all(|e| *e == Some(*d))) {
|
||||
on_dark.insert(t.id, dark);
|
||||
continue;
|
||||
}
|
||||
// The wedges of the series it names changed apart: the label follows the one nearest to the point a redraw
|
||||
// centers it on among those that showed its contrast before.
|
||||
let words = text.plain_text();
|
||||
let name = series_names.get(&words).map_or_else(|| words.replace('\n', "|"), |s| (*s).to_owned());
|
||||
let at = label_anchor(text, &name, g, ls);
|
||||
let candidates: Vec<(&Wedge, &Wedge)> = series
|
||||
.iter()
|
||||
.filter_map(|s| by_series.get(s))
|
||||
.flatten()
|
||||
.filter_map(|i| was.get(*i).zip(now.get(*i)))
|
||||
.filter(|(b, _)| b.dark == was_dark)
|
||||
.collect();
|
||||
if budget < candidates.len() {
|
||||
continue;
|
||||
}
|
||||
budget -= candidates.len();
|
||||
let nearest = candidates.iter().map(|(b, a)| (away(b, at), a.dark)).min_by(|x, y| x.0.total_cmp(&y.0));
|
||||
if let Some((_, dark)) = nearest {
|
||||
on_dark.insert(t.id, dark);
|
||||
}
|
||||
}
|
||||
set_legend_fills(after, &on_dark);
|
||||
}
|
||||
|
||||
/// Keep the anti-aliasing method `method` given to graph labels `ids` for every label of their kind, as
|
||||
/// [`capture_label_styles`] keeps a formatting change. Returns whether a kept method changed.
|
||||
pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], method: vectorcraft_doc::text::TypeAntiAlias) -> bool {
|
||||
|
||||
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste without Formatting",
|
||||
["Edit"],
|
||||
Some("Cmd+Alt+V"),
|
||||
"{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind, as do the labels of pasted graphs → {ids, added, merged, renamed}",
|
||||
"{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind, as do the labels of pasted graphs, which take a new graph's label character style and paragraph attributes (keeping their alignment); a pasted graph no longer keeps its labels' character and paragraph formats, so a later redraw sets them the same way → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
paste_plain
|
||||
),
|
||||
@@ -1767,15 +1767,12 @@ fn strip_formatting(n: &mut vectorcraft_doc::Node) {
|
||||
t.set_all_paras(Default::default());
|
||||
refresh_bounds(t);
|
||||
}
|
||||
// A graph keeps the names of its labels' character and paragraph styles for each redraw.
|
||||
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
|
||||
let l = &mut g.label_styles;
|
||||
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
|
||||
st.style_name = None;
|
||||
}
|
||||
for pa in [&mut l.values_para, &mut l.categories_para, &mut l.legend_para].into_iter().flatten() {
|
||||
pa.style_name = None;
|
||||
}
|
||||
// A graph keeps its labels' character and paragraph formats for each redraw: they go with the
|
||||
// labels' own formatting, and the labels are set as a new graph's are. The graph's generated
|
||||
// bounds follow its changed labels, so the next redraw does not read them as a move.
|
||||
if n.extras().graph.is_some() {
|
||||
super::graph::edit_graph_art(n, |n| super::graph::plain_labels(n, &strip_formatting));
|
||||
return;
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
|
||||
@@ -531,7 +531,844 @@ fn a_pasted_graph_brings_and_follows_its_label_styles_and_paints() {
|
||||
new_doc(&mut s);
|
||||
let v = s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap();
|
||||
assert!(doc(&s).char_styles.is_empty() && doc(&s).para_styles.is_empty(), "{v}");
|
||||
assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand, None, None));
|
||||
// The value labels' red characters stay behind too; the series fill and the legend rows keep Brand.
|
||||
assert_eq!(graph_links(&s, ids_of(&v)[0]), (vec!["Brand".to_string(); 2], None, None));
|
||||
}
|
||||
|
||||
/// Each label of graph `id` (the text of its Axes and Legend groups) with its size and its first paragraph's format.
|
||||
fn graph_label_looks(s: &Session, id: NodeId) -> Vec<(String, f64, vectorcraft_doc::ParaStyle)> {
|
||||
let mut out = vec![];
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
out.push((t.plain_text(), t.first_style().size, t.para_at(0).clone()));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Paste without Formatting sets a pasted graph's labels in a new graph's label size and paragraph attributes, and the
|
||||
/// graph keeps them when it is drawn again: the character and paragraph formats its labels keep stay behind with their
|
||||
/// text formatting.
|
||||
#[test]
|
||||
fn a_graph_pasted_without_formatting_keeps_plain_labels_when_drawn_again() {
|
||||
let mut s = session();
|
||||
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 120, "height": 80, "csv": csv})).unwrap());
|
||||
let fresh = graph_label_looks(&s, g);
|
||||
sel(&mut s, &[g]);
|
||||
s.execute("text.setStyle", &json!({"size": 14})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"spaceAfter": 6})).unwrap();
|
||||
assert!(graph_label_looks(&s, g).iter().all(|(_, size, para)| *size == 14.0 && para.space_after == 6.0));
|
||||
// The labels as drawn are also set in a character style.
|
||||
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 14.0}})).unwrap();
|
||||
let mut labels = vec![];
|
||||
node(&s, g).walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Text(_)) {
|
||||
labels.push(n.id);
|
||||
}
|
||||
});
|
||||
for id in labels {
|
||||
if let Some(NodeKind::Text(t)) = doc_mut(&mut s).node_mut(id).map(|n| &mut n.kind) {
|
||||
t.runs.iter_mut().for_each(|r| r.style.style_name = Some("Emphasis".into()));
|
||||
}
|
||||
}
|
||||
copy(&mut s, &[g]);
|
||||
assert_eq!(s.clipboard.char_styles.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Emphasis"]);
|
||||
// Where a redraw of the copied graph puts its plot.
|
||||
s.execute("graph.setData", &json!({"id": g.0, "csv": csv})).unwrap();
|
||||
let plot = node(&s, g).extras().graph.as_deref().unwrap().rect;
|
||||
|
||||
new_doc(&mut s);
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let v = s.execute("edit.pasteWithoutFormatting", &json!({"dx": 10, "dy": 20})).unwrap();
|
||||
let p = ids_of(&v)[0];
|
||||
assert!(doc(&s).char_styles.is_empty(), "the labels' character style stays behind: {v}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Paste without Formatting"));
|
||||
let l = node(&s, p).extras().graph.as_deref().unwrap().label_styles.clone();
|
||||
assert_eq!((l.values, l.categories, l.legend), (None, None, None));
|
||||
assert_eq!((l.values_para, l.categories_para, l.legend_para), (None, None, None));
|
||||
let pasted = graph_label_looks(&s, p);
|
||||
assert_eq!(pasted, fresh, "the labels are set as a new graph's are");
|
||||
|
||||
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
|
||||
assert_eq!(graph_label_looks(&s, p), pasted, "and stay so when the graph is drawn again");
|
||||
let r = node(&s, p).extras().graph.as_deref().unwrap().rect;
|
||||
let off = [r.x0 - plot.x0 - 10.0, r.y0 - plot.y0 - 20.0, r.x1 - plot.x1 - 10.0, r.y1 - plot.y1 - 20.0];
|
||||
assert!(off.iter().all(|d| d.abs() < 1e-6), "the plot moves by the offset and keeps its size: {plot:?} → {r:?}");
|
||||
s.execute("graph.setType", &json!({"id": p.0, "type": "column"})).unwrap();
|
||||
assert_eq!(graph_label_looks(&s, p), pasted, "a change of type draws them the same way");
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(doc(&s).node(p).is_none(), "one undo takes the paste back");
|
||||
}
|
||||
|
||||
/// Paste without Formatting leaves the rest of a pasted graph's art as it was copied: a column painted by hand keeps its
|
||||
/// paint, and nothing is drawn again.
|
||||
#[test]
|
||||
fn a_graph_pasted_without_formatting_keeps_its_art() {
|
||||
let mut s = session();
|
||||
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 120, "height": 80, "csv": csv})).unwrap());
|
||||
let red = Paint::solid(hex("ff0000"));
|
||||
let mut column = None;
|
||||
node(&s, g).walk(&mut |n| {
|
||||
if column.is_none() && matches!(n.kind, NodeKind::Path { .. }) && n.appearance.fill_paint() != Paint::None {
|
||||
column = Some(n.id);
|
||||
}
|
||||
});
|
||||
let column = column.unwrap();
|
||||
let n = doc_mut(&mut s).node_mut(column).unwrap();
|
||||
n.appearance = vectorcraft_doc::Appearance::basic(red.clone(), Paint::None, 0.0).into();
|
||||
/// Every object of graph `id` that is not type: its kind's name, its fill and its bounds.
|
||||
fn art(s: &Session, id: NodeId) -> Vec<(String, Paint, Option<Rect>)> {
|
||||
let mut out = vec![];
|
||||
node(s, id).walk(&mut |n| {
|
||||
if !matches!(n.kind, NodeKind::Text(_)) {
|
||||
out.push((format!("{:?}", std::mem::discriminant(&n.kind)), n.appearance.fill_paint(), n.geometric_bounds()));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
let copied = art(&s, g);
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({"dx": 15, "dy": 25})).unwrap())[0];
|
||||
let pasted = art(&s, p);
|
||||
let dx = pasted[0].2.unwrap().x0 - copied[0].2.unwrap().x0;
|
||||
let dy = pasted[0].2.unwrap().y0 - copied[0].2.unwrap().y0;
|
||||
let moved: Vec<_> = copied.into_iter().map(|(k, f, b)| (k, f, b.map(|b| b + vectorcraft_geom::Vec2::new(dx, dy)))).collect();
|
||||
assert_eq!(pasted.len(), moved.len());
|
||||
for (a, b) in pasted.iter().zip(&moved) {
|
||||
assert_eq!((&a.0, &a.1), (&b.0, &b.1));
|
||||
let (a, b) = (a.2.unwrap(), b.2.unwrap());
|
||||
assert!((a.x0 - b.x0).abs() + (a.y0 - b.y0).abs() + (a.x1 - b.x1).abs() + (a.y1 - b.y1).abs() < 1e-6, "{a:?} {b:?}");
|
||||
}
|
||||
assert!(pasted.iter().any(|(_, f, _)| *f == red), "the column keeps the paint it was given");
|
||||
}
|
||||
|
||||
/// The labels of a scaled graph pasted without formatting are set at a new graph's label size, unscaled, as a redraw
|
||||
/// sets them.
|
||||
#[test]
|
||||
fn a_scaled_graph_pasted_without_formatting_gets_unscaled_labels() {
|
||||
let mut s = session();
|
||||
let csv = ",2024\nQ1,3\nQ2,5";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 50, "y": 50, "width": 120, "height": 80, "csv": csv})).unwrap());
|
||||
let fresh = graph_label_looks(&s, g)[0].1;
|
||||
sel(&mut s, &[g]);
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap();
|
||||
/// Each label's size as drawn: its character size times the scale of its transform.
|
||||
fn drawn_sizes(s: &Session, id: NodeId) -> Vec<f64> {
|
||||
let mut out = vec![];
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
out.push(t.first_style().size * t.xf.determinant().abs().sqrt());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
assert!(drawn_sizes(&s, g).iter().all(|z| (z - fresh * 2.0).abs() < 1e-6), "{:?}", drawn_sizes(&s, g));
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
assert!(drawn_sizes(&s, p).iter().all(|z| (z - fresh).abs() < 1e-6), "{:?}", drawn_sizes(&s, p));
|
||||
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
|
||||
assert!(drawn_sizes(&s, p).iter().all(|z| (z - fresh).abs() < 1e-6), "{:?}", drawn_sizes(&s, p));
|
||||
|
||||
// A rotated graph's labels keep their rotation, so they stay turned with the rest of its art.
|
||||
/// Each label's rotation in degrees: the angle of its transform's x axis.
|
||||
fn angles(s: &Session, id: NodeId) -> Vec<f64> {
|
||||
let mut out = vec![];
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
let [a, b, ..] = t.xf.as_coeffs();
|
||||
out.push(b.atan2(a).to_degrees());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
let r = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 300, "y": 50, "width": 120, "height": 80, "csv": csv})).unwrap());
|
||||
sel(&mut s, &[r]);
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap();
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
let turned = angles(&s, r);
|
||||
assert!(turned.iter().all(|a| (a.abs() - 30.0).abs() < 1e-6), "{turned:?}");
|
||||
copy(&mut s, &[r]);
|
||||
new_doc(&mut s);
|
||||
let q = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
assert_eq!(angles(&s, q).len(), turned.len());
|
||||
assert!(angles(&s, q).iter().zip(&turned).all(|(a, b)| (a - b).abs() < 1e-6), "{:?} against {turned:?}", angles(&s, q));
|
||||
assert!(drawn_sizes(&s, q).iter().all(|z| (z - fresh).abs() < 1e-6), "{:?}", drawn_sizes(&s, q));
|
||||
}
|
||||
|
||||
/// A pie's legend labels in their wedges keep a redraw's contrast when pasted without formatting: white on a dark
|
||||
/// wedge, black on a light one.
|
||||
#[test]
|
||||
fn pie_labels_in_wedges_pasted_without_formatting_stay_readable() {
|
||||
let mut s = session();
|
||||
let csv = ",North,South\nP,1,1";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
/// Each text of graph `id` with its characters' fill.
|
||||
fn fills(s: &Session, id: NodeId) -> Vec<(String, Paint)> {
|
||||
let mut out = vec![];
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
out.push((t.plain_text(), t.first_style().fill.clone()));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
let drawn = fills(&s, g);
|
||||
assert!(drawn.contains(&("North".into(), Paint::solid(Color::WHITE))), "the first wedge is black: {drawn:?}");
|
||||
sel(&mut s, &[g]);
|
||||
s.execute("text.setStyle", &json!({"size": 14})).unwrap();
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
assert_eq!(fills(&s, p), drawn);
|
||||
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
|
||||
assert_eq!(fills(&s, p), drawn);
|
||||
}
|
||||
|
||||
/// A two-line legend label in a narrow dark wedge, whose text sits off the wedge's middle line, stays white when its graph
|
||||
/// is pasted without formatting.
|
||||
#[test]
|
||||
fn a_legend_label_in_a_narrow_wedge_pasted_without_formatting_stays_readable() {
|
||||
let mut s = session();
|
||||
// The first wedge fills a quarter from the top; the dark fourth series ("Narrow", series 3) follows it for 0.2 degrees,
|
||||
// narrower than the label's distance from the wedge's middle line.
|
||||
let csv = ",A,B,C,Narrow|,E\nP,9,0,0,0.02,26.98";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
fn narrow(s: &Session, id: NodeId) -> Paint {
|
||||
let mut out = None;
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.plain_text().starts_with("Narrow")
|
||||
{
|
||||
out = Some(t.first_style().fill.clone());
|
||||
}
|
||||
});
|
||||
out.unwrap()
|
||||
}
|
||||
assert_eq!(narrow(&s, g), Paint::solid(Color::WHITE));
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
assert_eq!(narrow(&s, p), Paint::solid(Color::WHITE));
|
||||
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
|
||||
assert_eq!(narrow(&s, p), Paint::solid(Color::WHITE));
|
||||
// A standard legend beside the pie stays black.
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "standard"})).unwrap();
|
||||
assert_eq!(narrow(&s, g), Paint::solid(Color::BLACK));
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
assert_eq!(narrow(&s, p), Paint::solid(Color::BLACK));
|
||||
// Two series with one name: each label keeps its own wedge's contrast, though the second label lies nearer the
|
||||
// middle of the first (dark) wedge's bounds than of its own.
|
||||
fn xs(s: &Session, id: NodeId) -> Vec<Paint> {
|
||||
let mut out = vec![];
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.plain_text() == "X"
|
||||
{
|
||||
out.push(t.first_style().fill.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
let csv = ",X,X,Y\nP,8,1,89";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 0, "y": 0, "width": 2000, "height": 2000, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
let drawn = xs(&s, g);
|
||||
assert_eq!(drawn, vec![Paint::solid(Color::WHITE), Paint::solid(Color::BLACK)]);
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
assert_eq!(xs(&s, p), drawn);
|
||||
}
|
||||
|
||||
/// A pie wedge whose dark global swatch merges into the document's light swatch of that name gets a black legend label,
|
||||
/// as a redraw gives it, with Paste and with Paste without Formatting.
|
||||
#[test]
|
||||
fn wedge_labels_follow_a_merged_swatch_when_pasted() {
|
||||
let mut s = session();
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": "#000000", "global": true})).unwrap();
|
||||
let csv = ",North,South\nP,1,1";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
let north = node(&s, g).children().unwrap().iter().find(|c| c.extras().series_index == Some(0)).unwrap().id;
|
||||
s.execute("paint.setFill", &json!({"ids": [north.0], "swatch": "Ink"})).unwrap();
|
||||
fn north_fill(s: &Session, id: NodeId) -> Paint {
|
||||
let mut out = None;
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.plain_text() == "North"
|
||||
{
|
||||
out = Some(t.first_style().fill.clone());
|
||||
}
|
||||
});
|
||||
out.unwrap()
|
||||
}
|
||||
assert_eq!(north_fill(&s, g), Paint::solid(Color::WHITE));
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": "#ffffff", "global": true})).unwrap();
|
||||
for cmd in ["edit.pasteWithoutFormatting", "edit.paste"] {
|
||||
let p = ids_of(&s.execute(cmd, &json!({"swatchConflict": "merge"})).unwrap())[0];
|
||||
assert_eq!(north_fill(&s, p), Paint::solid(Color::BLACK), "{cmd}");
|
||||
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
|
||||
assert_eq!(north_fill(&s, p), Paint::solid(Color::BLACK), "{cmd} redrawn");
|
||||
}
|
||||
// Add keeps the copied color under a new name, and the label stays white.
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({"swatchConflict": "add"})).unwrap())[0];
|
||||
assert_eq!(north_fill(&s, p), Paint::solid(Color::WHITE));
|
||||
// Pasted back into its own document after the swatch was changed there, the pie takes the swatch as it is now, and
|
||||
// so does the label.
|
||||
new_doc(&mut s);
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": "#000000", "global": true})).unwrap();
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
let north = node(&s, g).children().unwrap().iter().find(|c| c.extras().series_index == Some(0)).unwrap().id;
|
||||
s.execute("paint.setFill", &json!({"ids": [north.0], "swatch": "Ink"})).unwrap();
|
||||
assert_eq!(north_fill(&s, g), Paint::solid(Color::WHITE));
|
||||
copy(&mut s, &[g]);
|
||||
s.execute("swatch.edit", &json!({"name": "Ink", "color": "#ffffff"})).unwrap();
|
||||
for cmd in ["edit.pasteWithoutFormatting", "edit.paste"] {
|
||||
let p = ids_of(&s.execute(cmd, &json!({})).unwrap())[0];
|
||||
assert_eq!(north_fill(&s, p), Paint::solid(Color::BLACK), "{cmd} into its own document");
|
||||
}
|
||||
}
|
||||
|
||||
/// A wedge label with some characters given another color keeps them when a merged swatch is pasted with its pie.
|
||||
#[test]
|
||||
fn a_partly_colored_wedge_label_keeps_its_colors_when_a_swatch_merges() {
|
||||
let mut s = session();
|
||||
let r = swatched_rect(&mut s, "Ink", "#000000");
|
||||
let csv = ",North,South\nP,1,1";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
let mut north = None;
|
||||
node(&s, g).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.plain_text() == "North"
|
||||
{
|
||||
north = Some(n.id);
|
||||
}
|
||||
});
|
||||
s.execute("text.setStyle", &json!({"id": north.unwrap().0, "fill": "#ff0000", "start": 0, "end": 2})).unwrap();
|
||||
fn fills(s: &Session, id: NodeId) -> Vec<Paint> {
|
||||
let mut out = vec![];
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.plain_text() == "North"
|
||||
{
|
||||
out = t.runs.iter().map(|r| r.style.fill.clone()).collect();
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
let drawn = fills(&s, g);
|
||||
assert!(drawn.contains(&Paint::solid(hex("#ff0000"))) && drawn.len() > 1, "{drawn:?}");
|
||||
copy(&mut s, &[r, g]);
|
||||
new_doc(&mut s);
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": "#ffffff", "global": true})).unwrap();
|
||||
let ids = ids_of(&s.execute("edit.paste", &json!({"swatchConflict": "merge"})).unwrap());
|
||||
let p = ids.into_iter().find(|id| node(&s, *id).extras().graph.is_some()).unwrap();
|
||||
assert_eq!(fills(&s, p), drawn);
|
||||
}
|
||||
|
||||
/// When a merged swatch changes only one of the wedges of a series, only that wedge's legend label changes color, also
|
||||
/// for a long label set left so that most of it lies over the other pie.
|
||||
#[test]
|
||||
fn a_merged_swatch_recolors_only_the_label_of_the_wedge_it_changes() {
|
||||
merged_swatch_recolors_one_label("North", false);
|
||||
merged_swatch_recolors_one_label("North and its neighbors, with every station reporting a total for the year, counted again by hand", true);
|
||||
}
|
||||
|
||||
/// The paths of the first series' wedges of graph `g`, in paint order.
|
||||
fn first_series_wedges(s: &Session, g: NodeId) -> Vec<NodeId> {
|
||||
let mut wedges = vec![];
|
||||
for c in node(s, g).children().unwrap().iter() {
|
||||
if c.extras().series_index == Some(0) {
|
||||
c.walk(&mut |m| {
|
||||
if matches!(m.kind, NodeKind::Path { .. }) {
|
||||
wedges.push(m.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
wedges
|
||||
}
|
||||
|
||||
/// [`a_merged_swatch_recolors_only_the_label_of_the_wedge_it_changes`] for a first series called `name`, its legend
|
||||
/// labels set left when `left`.
|
||||
fn merged_swatch_recolors_one_label(name: &str, left: bool) {
|
||||
let mut s = session();
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": "#000000", "global": true})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Coal", "color": "#000000", "global": true})).unwrap();
|
||||
let csv = format!(",\"{name}\",South\nP,1,1\nQ,1,1");
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 400, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges", "piePosition": "even"})).unwrap();
|
||||
// The two wedges of the first series, painted with the two swatches.
|
||||
let wedges = first_series_wedges(&s, g);
|
||||
assert_eq!(wedges.len(), 2);
|
||||
s.execute("paint.setFill", &json!({"ids": [wedges[0].0], "swatch": "Ink"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [wedges[1].0], "swatch": "Coal"})).unwrap();
|
||||
/// The bounds of object `id` in graph `g`.
|
||||
fn bounds(s: &Session, g: NodeId, id: NodeId) -> Rect {
|
||||
let mut out = None;
|
||||
node(s, g).walk(&mut |n| {
|
||||
if n.id == id {
|
||||
out = n.geometric_bounds();
|
||||
}
|
||||
});
|
||||
out.unwrap()
|
||||
}
|
||||
/// Each label of the first series with its fill, and whether it starts nearer the Ink wedge than the Coal one
|
||||
/// (`wedges`), with the bounds of the Ink label.
|
||||
fn labels(s: &Session, g: NodeId, name: &str, wedges: &[NodeId]) -> (Vec<(bool, Paint)>, Option<Rect>) {
|
||||
let (ink, coal) = (bounds(s, g, wedges[0]).center(), bounds(s, g, wedges[1]).center());
|
||||
let (mut out, mut on_ink) = (vec![], None);
|
||||
node(s, g).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.plain_text() == name
|
||||
{
|
||||
let at = t.xf.translation().to_point();
|
||||
let nearer = at.distance(ink) < at.distance(coal);
|
||||
if nearer {
|
||||
on_ink = n.geometric_bounds();
|
||||
}
|
||||
out.push((nearer, t.first_style().fill.clone()));
|
||||
}
|
||||
});
|
||||
out.sort_by_key(|(nearer, _)| *nearer);
|
||||
(out, on_ink)
|
||||
}
|
||||
if left {
|
||||
let mut ids = vec![];
|
||||
node(&s, g).walk(&mut |n| {
|
||||
if matches!(&n.kind, NodeKind::Text(t) if t.plain_text() == name) {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
for id in ids {
|
||||
s.execute("text.setStyle", &json!({"id": id.0, "justify": "left"})).unwrap();
|
||||
}
|
||||
}
|
||||
let white = Paint::solid(Color::WHITE);
|
||||
let (drawn, on_ink) = labels(&s, g, name, &wedges);
|
||||
assert_eq!(drawn, vec![(false, white.clone()), (true, white.clone())]);
|
||||
if left {
|
||||
// The Ink label's middle lies in the Coal wedge, so its middle does not tell its wedge.
|
||||
let coal = bounds(&s, g, wedges[1]);
|
||||
assert!(coal.contains(on_ink.unwrap().center()), "{on_ink:?} against {coal:?}");
|
||||
}
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": "#ffffff", "global": true})).unwrap();
|
||||
for cmd in ["edit.pasteWithoutFormatting", "edit.paste"] {
|
||||
let p = ids_of(&s.execute(cmd, &json!({"swatchConflict": "merge"})).unwrap())[0];
|
||||
// The pasted graph's wedges are in the copy's order.
|
||||
let pasted = first_series_wedges(&s, p);
|
||||
assert_eq!(labels(&s, p, name, &pasted).0, vec![(false, white.clone()), (true, Paint::solid(Color::BLACK))], "{cmd} {name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-line legend labels of a light and a dark series with one name keep a redraw's colors when pasted without
|
||||
/// formatting, though the narrow light wedge's label reaches into the dark wedge after it.
|
||||
#[test]
|
||||
fn shared_name_labels_in_a_narrow_wedge_pasted_without_formatting_keep_their_colors() {
|
||||
let mut s = session();
|
||||
let csv = ",A,B,Q|,Q|\nP,9,0,0.02,26.98";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
fn qs(s: &Session, id: NodeId) -> Vec<Paint> {
|
||||
let mut out = vec![];
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.plain_text().starts_with('Q')
|
||||
{
|
||||
out.push(t.first_style().fill.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
let drawn = qs(&s, g);
|
||||
assert_eq!(drawn.len(), 2);
|
||||
assert_ne!(drawn[0], drawn[1], "one light and one dark series");
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
assert_eq!(qs(&s, p), drawn);
|
||||
// Names that hold a line break of their own (a redraw sets them as one line at the point) keep them too.
|
||||
new_doc(&mut s);
|
||||
let named = json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "series": ["A", "B", "Q\nQ", "Q\nQ"], "categories": ["P"], "rows": [[9, 0, 0.02, 26.98]]});
|
||||
let g = id_of(&s.execute("graph.create", &named).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
let broken = qs(&s, g);
|
||||
assert_eq!(broken.len(), 2);
|
||||
assert_ne!(broken[0], broken[1], "names with line breaks");
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
assert_eq!(qs(&s, p), broken, "names with line breaks");
|
||||
// So do labels whose names show the same text, one with a line break of its own and one with `|`.
|
||||
new_doc(&mut s);
|
||||
let named = json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "series": ["Q\n", "B", "Q|", "Q|"], "categories": ["P"], "rows": [[9, 0, 0.02, 26.98]]});
|
||||
let g = id_of(&s.execute("graph.create", &named).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
// The fills of the Q labels by their place from left to right and top to bottom.
|
||||
fn placed(s: &Session, id: NodeId) -> Vec<Paint> {
|
||||
let mut out = vec![];
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.plain_text().starts_with('Q')
|
||||
{
|
||||
let o = t.xf.translation();
|
||||
out.push((o.x, o.y, t.first_style().fill.clone()));
|
||||
}
|
||||
});
|
||||
out.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
|
||||
out.into_iter().map(|(_, _, f)| f).collect()
|
||||
}
|
||||
let alike = placed(&s, g);
|
||||
assert_eq!(alike.len(), 3);
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
assert_eq!(placed(&s, p), alike, "names that show the same text");
|
||||
// The narrow light wedge's label (the second) brought to the front keeps them too.
|
||||
new_doc(&mut s);
|
||||
let g = id_of(&s.execute("graph.create", &named).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
let legend = node(&s, g).children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap().clone();
|
||||
let labels = legend.children().unwrap();
|
||||
assert_eq!(labels.len(), 3);
|
||||
s.execute("object.arrange.bringToFront", &json!({"ids": [labels[1].id.0]})).unwrap();
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
assert_eq!(placed(&s, p), alike, "names that show the same text, labels reordered");
|
||||
// Labels given another size, which leaves them where they were drawn, keep them too.
|
||||
new_doc(&mut s);
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
sel(&mut s, &[g]);
|
||||
s.execute("text.setStyle", &json!({"size": 14})).unwrap();
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
assert_eq!(qs(&s, p), drawn, "resized labels");
|
||||
// Large labels of four series of one name, drawn again at that size, keep the colors that drawing gave them.
|
||||
new_doc(&mut s);
|
||||
let four = ",Q|,Q|,Q|,Q|\nP,1,1,1,1";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": four})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
sel(&mut s, &[g]);
|
||||
s.execute("text.setStyle", &json!({"size": 200})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": g.0, "csv": four})).unwrap();
|
||||
let large = placed(&s, g);
|
||||
assert_eq!(large.len(), 4);
|
||||
assert!(large.contains(&Paint::solid(Color::WHITE)) && large.contains(&Paint::solid(Color::BLACK)), "{large:?}");
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
assert_eq!(placed(&s, p), large, "large labels drawn again");
|
||||
// A scaled and rotated pie pasted with a swatch that merges keeps them too.
|
||||
new_doc(&mut s);
|
||||
let r = swatched_rect(&mut s, "Ink", "#000000");
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
sel(&mut s, &[g]);
|
||||
s.execute("object.scale", &json!({"sx": 400, "sy": 400})).unwrap();
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
let drawn = qs(&s, g);
|
||||
assert_ne!(drawn[0], drawn[1]);
|
||||
copy(&mut s, &[r, g]);
|
||||
new_doc(&mut s);
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": "#ffffff", "global": true})).unwrap();
|
||||
let ids = ids_of(&s.execute("edit.paste", &json!({"swatchConflict": "merge"})).unwrap());
|
||||
let p = ids.into_iter().find(|id| node(&s, *id).extras().graph.is_some()).unwrap();
|
||||
assert_eq!(qs(&s, p), drawn);
|
||||
}
|
||||
|
||||
/// The legend labels of a pie that kept a fill of their own (red, or black on every wedge), scaled, with two series of
|
||||
/// one name, are pasted without formatting in the colors a redraw gives them.
|
||||
#[test]
|
||||
fn recolored_wedge_labels_pasted_without_formatting_match_a_redraw() {
|
||||
for fill in ["#ff0000", "#000000"] {
|
||||
recolored_wedge_labels_match_a_redraw(fill);
|
||||
}
|
||||
}
|
||||
|
||||
fn recolored_wedge_labels_match_a_redraw(fill: &str) {
|
||||
let mut s = session();
|
||||
let csv = ",A,B,Q|,Q|\nP,9,0,0.02,26.98";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
sel(&mut s, &[g]);
|
||||
s.execute("text.setStyle", &json!({"fill": fill})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 400, "sy": 400})).unwrap();
|
||||
fn legend(s: &Session, id: NodeId) -> Vec<(String, Paint)> {
|
||||
let mut out = vec![];
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& ["A", "Q"].iter().any(|p| t.plain_text().starts_with(p))
|
||||
{
|
||||
out.push((t.plain_text(), t.first_style().fill.clone()));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
assert!(legend(&s, g).iter().all(|(_, f)| *f == Paint::solid(hex(fill))), "{fill}: {:?}", legend(&s, g));
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
let pasted = legend(&s, p);
|
||||
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
|
||||
let redrawn = legend(&s, p);
|
||||
assert_eq!(pasted, redrawn, "{fill}");
|
||||
assert!(
|
||||
redrawn.iter().any(|(_, f)| *f == Paint::solid(Color::WHITE)) && redrawn.iter().any(|(_, f)| *f == Paint::solid(Color::BLACK)),
|
||||
"{fill}: {redrawn:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A wedge whose fill was changed after its label was drawn gives the label, pasted without formatting, the color a
|
||||
/// redraw gives it.
|
||||
#[test]
|
||||
fn a_repainted_wedge_sets_its_label_color_when_pasted_without_formatting() {
|
||||
let mut s = session();
|
||||
let csv = ",North,South\nP,1,1";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": g.0, "csv": csv})).unwrap();
|
||||
fn labels(s: &Session, id: NodeId) -> Vec<(String, Paint)> {
|
||||
let mut out = vec![];
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& ["North", "South"].contains(&t.plain_text().as_str())
|
||||
{
|
||||
out.push((t.plain_text(), t.first_style().fill.clone()));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
// Each wedge takes the other's contrast: the dark one (its label white) white, the light one black.
|
||||
let drawn = labels(&s, g);
|
||||
let white = |name: &str| drawn.iter().any(|(t, f)| t == name && *f == Paint::solid(Color::WHITE));
|
||||
let mut wedges = vec![];
|
||||
for c in node(&s, g).children().unwrap().iter() {
|
||||
let Some(series) = c.extras().series_index else { continue };
|
||||
let dark = white(["North", "South"][series as usize]);
|
||||
c.walk(&mut |m| {
|
||||
if let NodeKind::Path { .. } = m.kind {
|
||||
wedges.push((m.id, dark));
|
||||
}
|
||||
});
|
||||
}
|
||||
assert!(wedges.iter().any(|w| w.1) && wedges.iter().any(|w| !w.1), "{drawn:?}");
|
||||
for (id, dark) in &wedges {
|
||||
s.execute("paint.setFill", &json!({"ids": [id.0], "color": if *dark { "#ffffff" } else { "#000000" }})).unwrap();
|
||||
}
|
||||
let before = labels(&s, g);
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
let pasted = labels(&s, p);
|
||||
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
|
||||
assert_eq!(pasted, labels(&s, p), "copied as {before:?}");
|
||||
}
|
||||
|
||||
/// When one pie's wedge of a series is repainted and another pie's wedge of that series is not, each pasted legend label
|
||||
/// takes the contrast of the wedge it is in.
|
||||
#[test]
|
||||
fn wedges_of_one_series_painted_apart_keep_their_label_colors_when_pasted_without_formatting() {
|
||||
let mut s = session();
|
||||
fn north(s: &Session, id: NodeId) -> Vec<Paint> {
|
||||
let mut out = vec![];
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.plain_text() == "North"
|
||||
{
|
||||
out.push(t.first_style().fill.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
// Two pies side by side, and two stacked pies of one series (a disk on a larger one, and on one nearly its size).
|
||||
for (csv, position) in [(",North,South\nP,1,1\nQ,1,1", "even"), (",North\nP,1\nQ,3", "stacked"), (",North\nP,1000\nQ,999", "stacked")] {
|
||||
// The second time, the repainted wedge is brought to the front of its series first.
|
||||
let mut pasted = vec![];
|
||||
for reorder in [false, true] {
|
||||
pasted.push(paste_painted_apart(&mut s, csv, position, reorder, north));
|
||||
}
|
||||
assert_eq!(pasted[0], pasted[1], "{csv}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste without Formatting a pie graph made from `csv` with pies at `position` and Legends in Wedges, after its first
|
||||
/// wedge of the first series takes the other contrast and, with `reorder`, comes to the front of its series. Returns
|
||||
/// what `north` reads from the pasted graph, after checking the two values differ.
|
||||
fn paste_painted_apart(s: &mut Session, csv: &str, position: &str, reorder: bool, north: fn(&Session, NodeId) -> Vec<Paint>) -> Vec<Paint> {
|
||||
new_doc(s);
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 400, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges", "piePosition": position})).unwrap();
|
||||
let drawn = north(s, g);
|
||||
assert_eq!(drawn.len(), 2);
|
||||
assert_eq!(drawn[0], drawn[1]);
|
||||
// Repaint the first North wedge with the contrast its label shows, so it takes the other contrast.
|
||||
let mut first = None;
|
||||
for c in node(s, g).children().unwrap().iter() {
|
||||
if c.extras().series_index == Some(0) && first.is_none() {
|
||||
c.walk(&mut |m| {
|
||||
if first.is_none() && matches!(m.kind, NodeKind::Path { .. }) {
|
||||
first = Some(m.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let first = first.unwrap();
|
||||
let color = if drawn[0] == Paint::solid(Color::WHITE) { "#ffffff" } else { "#000000" };
|
||||
s.execute("paint.setFill", &json!({"ids": [first.0], "color": color})).unwrap();
|
||||
if reorder {
|
||||
s.execute("object.arrange.bringToFront", &json!({"ids": [first.0]})).unwrap();
|
||||
}
|
||||
copy(s, &[g]);
|
||||
new_doc(s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
let labels = north(s, p);
|
||||
assert_ne!(labels[0], labels[1], "drawn as {drawn:?}, {position}, reorder {reorder}");
|
||||
labels
|
||||
}
|
||||
|
||||
/// Legend labels of series that share a name, brought into another order, each keep the contrast of the wedge they are
|
||||
/// in when the pie is pasted without formatting.
|
||||
#[test]
|
||||
fn reordered_shared_name_labels_keep_their_colors_when_pasted_without_formatting() {
|
||||
let mut s = session();
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": ",X,X\nP,1,1"})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
// Each label's fill, by its place from left to right and top to bottom.
|
||||
fn labels(s: &Session, id: NodeId) -> (Vec<NodeId>, Vec<Paint>) {
|
||||
let mut out = vec![];
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.plain_text() == "X"
|
||||
{
|
||||
let o = t.xf.translation();
|
||||
out.push((o.x, o.y, n.id, t.first_style().fill.clone()));
|
||||
}
|
||||
});
|
||||
out.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
|
||||
out.into_iter().map(|(_, _, id, f)| (id, f)).unzip()
|
||||
}
|
||||
let (ids, drawn) = labels(&s, g);
|
||||
assert_eq!(drawn.len(), 2);
|
||||
assert_ne!(drawn[0], drawn[1]);
|
||||
let first = node(&s, g).children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap().children().unwrap()[0].id;
|
||||
assert!(ids.contains(&first));
|
||||
s.execute("object.arrange.bringToFront", &json!({"ids": [first.0]})).unwrap();
|
||||
copy(&mut s, &[g]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
assert_eq!(labels(&s, p).1, drawn);
|
||||
}
|
||||
|
||||
/// A wedge's hidden fill does not set its legend label's color: the fill it shows does.
|
||||
#[test]
|
||||
fn a_hidden_wedge_fill_does_not_color_its_label() {
|
||||
let mut s = session();
|
||||
let csv = ",North,South\nP,1,1";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
|
||||
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
|
||||
let before = node(&s, g).clone();
|
||||
let mut n = before.clone();
|
||||
// A hidden black fill above the light South wedge's fill.
|
||||
for c in n.children_mut().unwrap().iter_mut().filter(|c| c.extras().series_index == Some(1)) {
|
||||
for w in std::sync::Arc::make_mut(c).children_mut().unwrap().iter_mut() {
|
||||
let w = std::sync::Arc::make_mut(w);
|
||||
if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = w.appearance.items.iter().find(|it| it.is_fill()).cloned() {
|
||||
let hidden = vectorcraft_doc::FillLayer { paint: Paint::solid(Color::BLACK), visible: false, ..f };
|
||||
w.appearance.items.push(vectorcraft_doc::AppearanceItem::Fill(hidden));
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::cmd::graph::wedge_label_contrast(&before, &mut n);
|
||||
let mut south = None;
|
||||
n.walk(&mut |m| {
|
||||
if let NodeKind::Text(t) = &m.kind
|
||||
&& t.plain_text() == "South"
|
||||
{
|
||||
south = Some(t.first_style().fill.clone());
|
||||
}
|
||||
});
|
||||
assert_eq!(south, Some(Paint::solid(Color::BLACK)));
|
||||
}
|
||||
|
||||
/// A graph inside a group, and a graph inside a graph, get plain labels too.
|
||||
#[test]
|
||||
fn graphs_in_groups_pasted_without_formatting_get_plain_labels() {
|
||||
let mut s = session();
|
||||
let csv = ",2024\nQ1,3\nQ2,5";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 120, "height": 80, "csv": csv})).unwrap());
|
||||
let fresh = graph_label_looks(&s, g);
|
||||
sel(&mut s, &[g]);
|
||||
s.execute("text.setStyle", &json!({"size": 14})).unwrap();
|
||||
let r = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
|
||||
sel(&mut s, &[g, r]);
|
||||
let group = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
// A copy of the graph placed inside it, as a file may hold one.
|
||||
let inner = {
|
||||
let d = doc_mut(&mut s);
|
||||
let mut copy = d.node(g).unwrap().clone();
|
||||
copy = d.reid(©);
|
||||
let id = copy.id;
|
||||
d.node_mut(g).unwrap().children_mut().unwrap().push(std::sync::Arc::new(copy));
|
||||
id
|
||||
};
|
||||
copy(&mut s, &[group]);
|
||||
new_doc(&mut s);
|
||||
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
|
||||
let mut graphs = vec![];
|
||||
node(&s, p).walk(&mut |n| {
|
||||
if let Some(spec) = n.extras().graph.as_deref() {
|
||||
graphs.push((n.id, spec.label_styles.clone()));
|
||||
}
|
||||
});
|
||||
assert_eq!(graphs.len(), 2, "{inner:?}");
|
||||
for (id, l) in graphs {
|
||||
assert_eq!((l.values, l.categories, l.legend, l.values_para, l.categories_para, l.legend_para), (None, None, None, None, None, None));
|
||||
let looks = graph_label_looks(&s, id);
|
||||
assert!(looks.iter().all(|(_, size, _)| *size == fresh[0].1), "{looks:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph without its generated bounds (as a file may hold it) is drawn again where Paste without Formatting put it.
|
||||
#[test]
|
||||
fn a_graph_without_generated_bounds_pasted_without_formatting_follows_the_offset() {
|
||||
let mut s = session();
|
||||
let csv = ",2024\nQ1,3\nQ2,5";
|
||||
let g = id_of(&s.execute("graph.create", &json!({"type": "bar", "x": 50, "y": 60, "width": 100, "height": 80, "csv": csv})).unwrap());
|
||||
let plot = {
|
||||
let mut extras = doc_mut(&mut s).node_mut(g).unwrap().extras_mut();
|
||||
let spec = extras.graph.as_mut().unwrap();
|
||||
spec.placed = None;
|
||||
spec.rect
|
||||
};
|
||||
copy(&mut s, &[g]);
|
||||
let v = s.execute("edit.pasteWithoutFormatting", &json!({"dx": 30, "dy": -5})).unwrap();
|
||||
let p = ids_of(&v)[0];
|
||||
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
|
||||
let r = node(&s, p).extras().graph.as_deref().unwrap().rect;
|
||||
let off = [r.x0 - plot.x0 - 30.0, r.y0 - plot.y0 + 5.0, r.x1 - plot.x1 - 30.0, r.y1 - plot.y1 + 5.0];
|
||||
assert!(off.iter().all(|d| d.abs() < 1e-6), "{plot:?} → {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+9
-1
@@ -7058,10 +7058,18 @@ added is reused), which the pasted objects follow. Linked colours show the docum
|
||||
spot colour as its Spot Colors option says). The result reports `{ids, added, merged, renamed: [{kind, from,
|
||||
to}]}`.
|
||||
|
||||
`edit.pasteWithoutFormatting` sets the labels of a pasted graph in a new graph's label character style and
|
||||
paragraph attributes, keeping each label's alignment. The pasted graph no longer keeps its labels' character and
|
||||
paragraph formats, so a later `graph.setData` or `graph.setType` sets the labels the same way. The rest of the
|
||||
graph's art is pasted as it was copied.
|
||||
|
||||
A global or spot swatch whose name the document gives another colour is a conflict: `clipboard.conflicts` lists
|
||||
them, and `swatchConflict` answers: `"merge"` (the default: the objects take the document's swatch), `"add"` (the
|
||||
pasted swatch comes in renamed) or one answer per name. Pasting back into the document the objects came from uses
|
||||
its resources as they are now and raises no conflict.
|
||||
its resources as they are now and raises no conflict. When the document's swatches change the color of a pasted pie's
|
||||
wedges (after a merge, or in the document the pie came from after a swatch it uses was changed), the legend labels in
|
||||
those wedges are set white on a dark wedge and black on a light one, as a redraw sets them, unless the pie keeps a fill
|
||||
for its legend labels.
|
||||
|
||||
Placement: `edit.paste {center}` centres the objects on a point (the app passes the view centre), else offsets them
|
||||
by `dx`/`dy` (the Paste Offset preference). With nothing selected, `edit.pasteInFront` / `edit.pasteInBack` put them
|
||||
|
||||
Reference in New Issue
Block a user