Merge pull request #1138 from IHAGI-c/feat/graph-data-sheet

Graph Data: a worksheet window
This commit is contained in:
BFlat 2026-10-12 07:18:53 +07:00 committed by GitHub
commit e38e48a03b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 703 additions and 27 deletions

View File

@ -0,0 +1,612 @@
//! Object › Graph › Data…: the Graph Data window, a worksheet of the graph's data.
//!
//! The first row holds the series labels (the legend), the first column the category labels, the
//! top-left cell stays blank; the other cells are values (a blank cell has none). A cell is picked
//! with a click and edited in the entry box above the sheet: Tab enters it and moves right, Enter
//! (Return) down, Shift with either the other way, and the arrow keys move between cells (so they
//! don't move the caret in the entry box). Pasting tab- or line-separated text fills the cells from
//! the picked one. Import Data reads a tab-separated text file, Transpose row/column swaps rows and
//! columns, Switch x/y swaps a scatter graph's y and x columns, Cell Style sets the number of
//! decimals the sheet shows and its columns' width, and Revert reads the graph's data again. Apply
//! (`graph.setData` with its series, categories and rows) redraws the graph and leaves the window
//! open.
//!
//! Fields: `grid` (rows of cell texts), `sel` ([row, column]), `entry` (the picked cell's text
//! being edited), `decimals` (null: as typed), `width` (digits), `__graph` (the graph's id) and
//! `__scatter`.
use serde_json::{Value, json};
use vectorcraft_doc::{MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES};
use super::DialogSpec;
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Graph Data.
pub const KIND: &str = "graphData";
/// The sheet's size: a row of labels over a graph's categories, a column of labels before its
/// series; the least it shows; a cell's longest text.
const MAX_ROWS: usize = MAX_GRAPH_CATEGORIES + 1;
const MAX_COLS: usize = MAX_GRAPH_SERIES + 1;
const MIN_ROWS: usize = 8;
const MIN_COLS: usize = 6;
const MAX_CELL: usize = 256;
/// The Cell Style limits (Adjust column width and decimal digits).
const MAX_DECIMALS: u64 = 10;
const MAX_WIDTH: u64 = 20;
/// A cell's height on the sheet.
const ROW_H: f32 = 19.0;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Graph Data").into(),
body,
confirm: |app, d| apply(app, d).map(|_| Value::Null),
ok: Some("Apply"),
min_width: 620.0,
..DialogSpec::FORM
};
/// Open Graph Data for the selected graph.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
// The query fails without a graph selected, with the engine's own message.
let v = app.session.execute("graph.setData", &json!({})).map_err(|e| e.to_string())?;
let graph = app.session.active().and_then(|st| st.selection.objects.iter().find_map(|id| graph_of(&st.doc, *id)).map(|id| (st, id)));
let scatter =
graph.is_some_and(|(st, id)| st.doc.node(id).and_then(|n| n.graph.as_deref()).is_some_and(|g| g.kind == vectorcraft_doc::GraphKind::Scatter));
let id = graph.map(|(_, id)| id.0);
let grid = grid_from_data(&v);
let entry = cell(&grid, 0, 1);
app.ui.dialog = Some(Dialog::new(
KIND,
json!({ "grid": grid, "sel": [0, 1], "entry": entry, "decimals": null, "width": 7, "__graph": id, "__scatter": scatter }),
));
Ok(Value::Null)
}
/// The graph `id` belongs to (itself, or the graph its parts are in).
fn graph_of(doc: &vectorcraft_doc::Document, id: vectorcraft_doc::NodeId) -> Option<vectorcraft_doc::NodeId> {
let mut cur = Some(id);
while let Some(c) = cur {
if doc.node(c).is_some_and(|n| n.graph.is_some()) {
return Some(c);
}
cur = doc.parent_of(c);
}
None
}
// ---------- the worksheet ----------
type Grid = Vec<Vec<String>>;
/// A button's work, done once the sheet is drawn.
type Action = fn(&mut VectorcraftApp, &mut Dialog) -> Result<(), String>;
/// The sheet's cells from the `graph.setData` query: the series in the first row, each category's
/// label then values below (the label column is there, blank, when the graph has no labels).
pub(crate) fn grid_from_data(v: &Value) -> Grid {
let strings = |k: &str| -> Vec<String> {
v[k].as_array().map(|a| a.iter().map(|s| s.as_str().unwrap_or_default().to_string()).collect()).unwrap_or_default()
};
let (series, categories) = (strings("series"), strings("categories"));
let rows: Vec<Vec<Value>> =
v["rows"].as_array().map(|a| a.iter().map(|r| r.as_array().cloned().unwrap_or_default()).collect()).unwrap_or_default();
let mut grid: Grid = vec![std::iter::once(String::new()).chain(series).collect()];
for (c, row) in rows.iter().enumerate() {
let label = categories.get(c).cloned().unwrap_or_default();
grid.push(std::iter::once(label).chain(row.iter().map(|x| x.as_f64().map(|f| f.to_string()).unwrap_or_default())).collect());
}
grid
}
/// The sheet as `graph.setData` takes it: series labels, category labels (none when all are blank)
/// and the values (a blank cell, or text that isn't a number, has none), without the empty rows and
/// columns past the data.
pub(crate) fn data_from_grid(grid: &Grid) -> Value {
let filled = |s: &String| !s.trim().is_empty();
let rows = grid.iter().rposition(|r| r.iter().any(filled)).map_or(1, |i| i + 1).max(1);
let cols = grid.iter().take(rows).filter_map(|r| r.iter().rposition(filled)).max().map_or(1, |i| i + 1).max(1);
let label = |s: &str| {
let s = s.trim();
// A label typed in straight quotes (as the CSV form takes a number as a label) is the text inside.
s.strip_prefix('"').and_then(|t| t.strip_suffix('"')).unwrap_or(s).to_string()
};
// Series labels up to the last one given (none when all are blank).
let mut series: Vec<String> = (1..cols).map(|c| label(&cell(grid, 0, c))).collect();
while series.last().is_some_and(String::is_empty) {
series.pop();
}
let mut categories: Vec<String> = (1..rows).map(|r| label(&cell(grid, r, 0))).collect();
if categories.iter().all(String::is_empty) {
categories.clear();
}
let values: Vec<Vec<Value>> = (1..rows)
.map(|r| {
(1..cols)
.map(|c| {
let t = cell(grid, r, c);
t.trim().parse::<f64>().ok().filter(|v| v.is_finite() && !t.trim().starts_with('"')).map_or(Value::Null, |v| json!(v))
})
.collect()
})
.collect();
json!({ "series": series, "categories": categories, "rows": values })
}
/// Text from a file or the clipboard as cells: tab-separated when any line holds a tab, else
/// comma-separated (decided once, so a label with a comma in a tab-separated file stays whole), at
/// most a graph's rows and columns.
pub(crate) fn grid_from_text(text: &str) -> Grid {
let sep = if text.contains('\t') { '\t' } else { ',' };
text.lines().take(MAX_ROWS).map(|l| l.trim_end_matches('\r').split(sep).take(MAX_COLS).map(|c| clean(c.trim())).collect()).collect()
}
/// A text file's bytes as text: UTF-16 with its byte-order mark (spreadsheets' "Unicode Text"),
/// else UTF-8 without one.
pub(crate) fn decode(bytes: &[u8]) -> String {
let utf16 = |rest: &[u8], le: bool| {
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|p| if le { u16::from_le_bytes(*p) } else { u16::from_be_bytes(*p) }).collect();
String::from_utf16_lossy(&units)
};
match bytes {
[0xFF, 0xFE, rest @ ..] => utf16(rest, true),
[0xFE, 0xFF, rest @ ..] => utf16(rest, false),
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
_ => String::from_utf8_lossy(bytes).into_owned(),
}
}
/// A cell's text: one line (tabs and line breaks become spaces), at most MAX_CELL characters.
fn clean(s: &str) -> String {
s.chars().map(|c| if matches!(c, '\t' | '\r' | '\n') { ' ' } else { c }).take(MAX_CELL).collect()
}
fn cell(grid: &Grid, r: usize, c: usize) -> String {
grid.get(r).and_then(|row| row.get(c)).cloned().unwrap_or_default()
}
/// Cell `(r, c)` of the dialog's sheet, read in place.
fn cell_at(d: &Dialog, r: usize, c: usize) -> &str {
d.fields.get("grid").and_then(|g| g.get(r)).and_then(|row| row.get(c)).and_then(Value::as_str).unwrap_or_default()
}
/// Set cell `(r, c)` of the dialog's sheet in place, growing it as needed (within the sheet's size).
fn set_cell_at(d: &mut Dialog, r: usize, c: usize, text: &str) {
if r >= MAX_ROWS || c >= MAX_COLS {
return;
}
let grid = d.fields.entry("grid").or_insert_with(|| json!([]));
let Some(rows) = grid.as_array_mut() else { return };
if rows.len() <= r {
rows.resize(r + 1, json!([]));
}
let Some(row) = rows.get_mut(r).and_then(Value::as_array_mut) else { return };
if row.len() <= c {
row.resize(c + 1, json!(""));
}
if let Some(cell) = row.get_mut(c) {
*cell = json!(clean(text));
}
}
/// Transpose row/column: the sheet's rows become its columns.
pub(crate) fn transpose(grid: &Grid) -> Grid {
let cols = grid.iter().map(Vec::len).max().unwrap_or(0);
(0..cols).map(|c| grid.iter().map(|r| r.get(c).cloned().unwrap_or_default()).collect()).collect()
}
/// Switch x/y: each scatter series' y and x columns (1 and 2, 3 and 4…) trade places, labels too; a
/// last y column without its x stays where it is.
pub(crate) fn switch_xy(grid: &Grid) -> Grid {
// Every row as wide as the widest, so a label row shorter than the data swaps with it.
let width = grid.iter().map(Vec::len).max().unwrap_or(0);
grid.iter()
.map(|r| {
let mut r = r.clone();
r.resize(width, String::new());
let mut c = 1;
while c + 1 < r.len() {
r.swap(c, c + 1);
c += 2;
}
r
})
.collect()
}
/// How many rows of the dialog's sheet hold data (below the labels' row), read in place.
fn data_rows(d: &Dialog) -> usize {
let filled = |r: &Value| r.as_array().is_some_and(|r| r.iter().any(|c| c.as_str().is_some_and(|c| !c.trim().is_empty())));
d.fields.get("grid").and_then(Value::as_array).and_then(|g| g.iter().rposition(filled)).unwrap_or(0)
}
/// How big the sheet is drawn: the data, a spare row and column, and at least MIN_ROWS × MIN_COLS.
fn extent(d: &Dialog, sel: (usize, usize)) -> (usize, usize) {
let rows = d.fields.get("grid").and_then(Value::as_array);
let n = rows.map_or(0, Vec::len);
let w = rows.into_iter().flatten().filter_map(Value::as_array).map(Vec::len).max().unwrap_or(0);
((n + 1).max(MIN_ROWS).max(sel.0 + 2).min(MAX_ROWS), (w + 1).max(MIN_COLS).max(sel.1 + 2).min(MAX_COLS))
}
/// A key that moves the picked cell.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Move {
Right,
Down,
Up,
Left,
}
/// The cell `m` moves to from `sel`, within the sheet (the top-left blank cell is skipped).
pub(crate) fn step(sel: (usize, usize), m: Move) -> (usize, usize) {
let to = match m {
Move::Right => (sel.0, (sel.1 + 1).min(MAX_COLS - 1)),
Move::Down => ((sel.0 + 1).min(MAX_ROWS - 1), sel.1),
Move::Up => (sel.0.saturating_sub(1), sel.1),
Move::Left => (sel.0, sel.1.saturating_sub(1)),
};
if to == (0, 0) { sel } else { to }
}
/// A cell as the sheet shows it: a number with `decimals` places when Cell Style sets them, else as
/// typed (the number Apply sends is as typed either way).
pub(crate) fn shown(text: &str, decimals: Option<u64>) -> String {
match (decimals, text.trim().parse::<f64>()) {
(Some(d), Ok(v)) if v.is_finite() && !text.trim().starts_with('"') => format!("{v:.*}", d.min(MAX_DECIMALS) as usize),
_ => text.to_string(),
}
}
fn grid(d: &Dialog) -> Grid {
d.fields.get("grid").and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_default()
}
fn sel(d: &Dialog) -> (usize, usize) {
let get = |i: usize| d.fields.get("sel").and_then(|s| s.get(i)).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
(get(0).min(MAX_ROWS - 1), get(1).min(MAX_COLS - 1))
}
/// The entry box's text into the picked cell.
fn commit(d: &mut Dialog) {
let (r, c) = sel(d);
let text = d.str("entry");
if cell_at(d, r, c) != text {
set_cell_at(d, r, c, &text);
}
}
/// Pick cell `to`, its text in the entry box.
fn pick(d: &mut Dialog, to: (usize, usize)) {
commit(d);
d.fields.insert("sel".into(), json!([to.0, to.1]));
let text = cell_at(d, to.0, to.1).to_string();
d.fields.insert("entry".into(), json!(text));
}
fn replace_grid(d: &mut Dialog, g: Grid) {
let (r, c) = sel(d);
d.fields.insert("entry".into(), json!(cell(&g, r, c)));
d.fields.insert("grid".into(), json!(g));
}
/// Paste: text with tabs or line breaks fills the cells from the picked one.
fn paste_block(d: &mut Dialog, text: &str) {
let (r0, c0) = sel(d);
for (r, row) in grid_from_text(text).into_iter().enumerate() {
for (c, t) in row.into_iter().enumerate() {
set_cell_at(d, r0 + r, c0 + c, &t);
}
}
let text = cell_at(d, r0, c0).to_string();
d.fields.insert("entry".into(), json!(text));
}
/// Apply: the sheet's data to the graph.
fn apply(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut d = d.clone();
commit(&mut d);
// The graph the sheet opened on (else the selected one).
let mut p = data_from_grid(&grid(&d));
p["id"] = d.fields.get("__graph").cloned().unwrap_or(Value::Null);
let r = app.run("graph.setData", p);
if let Some(open) = app.ui.dialog.as_mut().filter(|o| o.kind == KIND) {
open.fields = d.fields;
}
r
}
/// Revert: the graph's data again.
fn revert(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
let id = d.fields.get("__graph").cloned().unwrap_or(Value::Null);
let v = app.session.execute("graph.setData", &json!({ "id": id })).map_err(|e| e.to_string())?;
replace_grid(d, grid_from_data(&v));
Ok(())
}
/// Import Data: a tab-separated text file's rows into the sheet.
fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
let pick = crate::FilePick { filters: vec![("Text", &["txt", "tsv", "csv"])], ..Default::default() };
let path = crate::picks::open(app, &pick).ok_or("cancelled")?;
// The button is off where files can't be read.
let Some(read) = app.services.read.as_ref() else { return Ok(()) };
let bytes = read(&path)?;
replace_grid(d, grid_from_text(&decode(&bytes)));
Ok(())
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let entry_id = egui::Id::new("graph-data-entry");
if ui.memory(|m| m.has_focus(entry_id)) {
// Tab, Enter (Shift: the other way) and the arrow keys move the picked cell while the entry box
// has the keys; the entry keeps them (egui would move the focus on with Tab).
// Shift first: a key asked for without modifiers also matches it with Shift held.
let keys = [
(egui::Modifiers::SHIFT, egui::Key::Tab, Move::Left),
(egui::Modifiers::SHIFT, egui::Key::Enter, Move::Up),
(egui::Modifiers::NONE, egui::Key::Tab, Move::Right),
(egui::Modifiers::NONE, egui::Key::Enter, Move::Down),
(egui::Modifiers::NONE, egui::Key::ArrowDown, Move::Down),
(egui::Modifiers::NONE, egui::Key::ArrowUp, Move::Up),
(egui::Modifiers::NONE, egui::Key::ArrowRight, Move::Right),
(egui::Modifiers::NONE, egui::Key::ArrowLeft, Move::Left),
];
let moved = keys.iter().find(|(m, k, _)| ui.input_mut(|i| i.consume_key(*m, *k))).map(|(_, _, mv)| *mv);
if let Some(m) = moved {
pick(d, step(sel(d), m));
ui.memory_mut(|mem| {
mem.request_focus(entry_id);
mem.move_focus(egui::FocusDirection::None);
});
}
// A pasted block (a spreadsheet's cells) fills the sheet instead of the entry box.
let block = ui.input_mut(|i| {
let k = i.events.iter().position(|e| matches!(e, egui::Event::Paste(s) if s.contains('\t') || s.contains('\n')))?;
match i.events.remove(k) {
egui::Event::Paste(s) => Some(s),
_ => None,
}
});
if let Some(text) = block {
paste_block(d, &text);
}
}
let mut action: Option<Action> = None;
ui.horizontal(|ui| {
let mut text = d.str("entry");
// The entry box has the keys when the window opens, so Enter moves down instead of applying.
widgets::take_dialog_focus(ui, entry_id, &text);
let r = ui.add(egui::TextEdit::singleline(&mut text).id(entry_id).desired_width(260.0));
if r.changed() {
d.fields.insert("entry".into(), json!(clean(&text)));
}
ui.add_space(8.0);
let can_read = app.services.read.is_some();
if ui.add_enabled_ui(can_read, |ui| widgets::flat_button(ui, tl!("Import Data…"), 96.0)).inner.clicked() {
action = Some(|app, d| crate::picks::in_dialog(app, d, import));
}
// Transposed, the categories become series: at most as many as a graph has.
if ui.add_enabled_ui(data_rows(d) <= MAX_GRAPH_SERIES, |ui| widgets::flat_button(ui, tl!("Transpose"), 80.0)).inner.clicked() {
commit(d);
replace_grid(d, transpose(&grid(d)));
}
if ui.add_enabled_ui(d.bool("__scatter"), |ui| widgets::flat_button(ui, tl!("Switch x/y"), 80.0)).inner.clicked() {
commit(d);
replace_grid(d, switch_xy(&grid(d)));
}
if widgets::flat_button(ui, tl!("Revert"), 64.0).clicked() {
action = Some(revert);
}
});
// Cell Style: decimals shown and the columns' width in digits.
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Number of Decimals:"));
let mut dec = d.fields.get("decimals").and_then(Value::as_u64);
let mut on = dec.is_some();
if ui.checkbox(&mut on, "").changed() {
dec = on.then_some(2);
}
let mut n = dec.unwrap_or(2);
if ui.add_enabled(on, egui::DragValue::new(&mut n).range(0..=MAX_DECIMALS)).changed() {
dec = Some(n);
}
d.fields.insert("decimals".into(), json!(dec));
ui.add_space(12.0);
widgets::dim_label(ui, tl!("Column Width:"));
let mut w = d.fields.get("width").and_then(Value::as_u64).unwrap_or(7);
if ui.add(egui::DragValue::new(&mut w).range(0..=MAX_WIDTH)).changed() {
d.fields.insert("width".into(), json!(w));
}
});
ui.add_space(6.0);
let at = sel(d);
let (rows, cols) = extent(d, at);
let decimals = d.fields.get("decimals").and_then(Value::as_u64);
let w = (d.fields.get("width").and_then(Value::as_u64).unwrap_or(7).min(MAX_WIDTH) as f32 * 7.0 + 10.0).max(12.0);
let mut clicked = None;
widgets::list_box(ui, |ui| {
// Only the cells in view are drawn: a graph's sheet can be 10,001 × 257.
egui::ScrollArea::both().id_salt("graph-data-sheet").max_height(280.0).auto_shrink([false, false]).show_viewport(ui, |ui, view| {
let (cw, ch) = (w + 1.0, ROW_H);
ui.set_min_size(egui::vec2(cols as f32 * cw, rows as f32 * ch));
let origin = ui.max_rect().min;
let span = |lo: f32, hi: f32, size: f32, n: usize| ((lo / size).floor().max(0.0) as usize, ((hi / size).ceil().max(0.0) as usize).min(n));
let (r0, r1) = span(view.min.y, view.max.y, ch, rows);
let (c0, c1) = span(view.min.x, view.max.x, cw, cols);
for r in r0..r1 {
for c in c0..c1 {
let rect = egui::Rect::from_min_size(origin + egui::vec2(c as f32 * cw, r as f32 * ch), egui::vec2(w, ch - 1.0));
let text = shown(cell_at(d, r, c), decimals);
let b = egui::Button::selectable((r, c) == at, egui::RichText::new(text).monospace()).truncate();
if ui.put(rect, b).clicked() && (r, c) != (0, 0) {
clicked = Some((r, c));
}
}
}
});
});
if let Some(to) = clicked {
pick(d, to);
ui.memory_mut(|mem| mem.request_focus(entry_id));
}
if let Some(f) = action
&& let Err(e) = f(app, d)
&& e != "cancelled"
{
app.status(e);
}
false
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn g(rows: &[&[&str]]) -> Grid {
rows.iter().map(|r| r.iter().map(|c| c.to_string()).collect()).collect()
}
fn app_with(csv: &str, ty: &str) -> (VectorcraftApp, u64) {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let id =
app.run("graph.create", json!({"type": ty, "x": 0, "y": 0, "width": 200, "height": 150, "csv": csv})).unwrap()["id"].as_u64().unwrap();
(app, id)
}
fn data(app: &mut VectorcraftApp, id: u64) -> Value {
app.session.execute("graph.setData", &json!({"id": id})).unwrap()
}
#[test]
fn opening_and_applying_unchanged_keeps_the_data() {
for (csv, ty) in [
("\tSales, EU\tb|c\nQ1, early\t1\t\n\"2013\"\t3\t4", "column"),
("1,2\n3,4", "column"),
("y,x\n1,10\n2,20", "scatter"),
(",,\nQ1,1,2\n,3,4", "column"),
] {
let (mut app, id) = app_with(csv, ty);
let before = data(&mut app, id);
open(&mut app).unwrap();
let d = app.ui.dialog.clone().unwrap();
apply(&mut app, &d).unwrap();
assert_eq!(data(&mut app, id), before, "{csv}");
}
}
#[test]
fn the_sheet_holds_labels_in_the_first_row_and_column() {
let (mut app, id) = app_with("1,2\n3,4", "column");
assert_eq!(grid_from_data(&data(&mut app, id)), g(&[&[""], &["", "1", "2"], &["", "3", "4"]]));
let p = data_from_grid(&g(&[&["", "a", "", ""], &["Q1", "1", "", ""], &["\"2013\"", "x", "2"], &[], &[""]]));
assert_eq!(p, json!({"series": ["a"], "categories": ["Q1", "2013"], "rows": [[1.0, null], [null, 2.0]]}));
assert_eq!(grid_from_text("\tA, B\tC\r\nx\t1\t2\n"), g(&[&["", "A, B", "C"], &["x", "1", "2"]]));
assert_eq!(decode(b"\xEF\xBB\xBFa\tb"), "a\tb");
let utf16: Vec<u8> = [0xFF, 0xFE].into_iter().chain("a\tb".encode_utf16().flat_map(u16::to_le_bytes)).collect();
assert_eq!(decode(&utf16), "a\tb");
let wide = vec!["x"; 400].join("\t");
assert_eq!(grid_from_text(&wide)[0].len(), MAX_COLS);
}
#[test]
fn transpose_switch_and_moves() {
assert_eq!(transpose(&g(&[&["", "a", "b"], &["Q1", "1", "2"]])), g(&[&["", "Q1"], &["a", "1"], &["b", "2"]]));
assert_eq!(step((0, 1), Move::Right), (0, 2));
assert_eq!(step((0, 1), Move::Down), (1, 1));
assert_eq!(step((1, 0), Move::Up), (1, 0), "the top-left cell stays blank");
assert_eq!(step((MAX_ROWS - 1, 1), Move::Down), (MAX_ROWS - 1, 1));
assert_eq!(shown("3.14159", Some(2)), "3.14");
assert_eq!(shown("3.14159", None), "3.14159");
// Switch x/y in the sheet does what the engine's does.
let (mut app, id) = app_with("y1,x1,y2,x2\n1,10,2,20\n3,30,4,40", "scatter");
open(&mut app).unwrap();
let mut d = app.ui.dialog.clone().unwrap();
let switched = switch_xy(&grid(&d));
replace_grid(&mut d, switched);
apply(&mut app, &d).unwrap();
let sheet = data(&mut app, id);
let (mut other, oid) = app_with("y1,x1,y2,x2\n1,10,2,20\n3,30,4,40", "scatter");
other.run("graph.setData", json!({"id": oid, "switchXY": true})).unwrap();
assert_eq!(sheet, data(&mut other, oid));
}
#[test]
fn data_opens_the_sheet_and_apply_redraws_the_graph() {
let (mut app, id) = app_with(",a,b\nQ1,1,2", "column");
crate::menus::invoke(&mut app, "graph.setData", json!({}));
let mut d = app.ui.dialog.take().unwrap();
assert_eq!(d.kind, KIND);
pick(&mut d, (1, 2));
d.fields.insert("entry".into(), json!("5"));
pick(&mut d, (2, 0));
d.fields.insert("entry".into(), json!("Q2"));
pick(&mut d, (2, 1));
d.fields.insert("entry".into(), json!("7"));
app.ui.dialog = Some(d.clone());
apply(&mut app, &d).unwrap();
assert_eq!(data(&mut app, id)["rows"], json!([[1.0, 5.0], [7.0, null]]));
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND), "Apply leaves the window open");
// A pasted block fills the cells from the picked one.
let mut d = app.ui.dialog.take().unwrap();
pick(&mut d, (1, 1));
paste_block(&mut d, "8\t9\n10\t11\n");
assert_eq!((cell_at(&d, 1, 1), cell_at(&d, 2, 2), d.str("entry")), ("8", "11", "8".to_string()));
// Revert reads the graph again; the tool's Graph Data after drawing opens the same sheet.
replace_grid(&mut d, vec![]);
revert(&mut app, &mut d).unwrap();
assert_eq!(cell_at(&d, 0, 1), "a");
super::super::tools::open_tool_dialog(&mut app, "graphData", json!({}));
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND));
}
#[test]
fn the_keys_move_the_picked_cell_and_keep_the_entry_box() {
let (mut app, id) = app_with(",a,b\nQ1,1,2", "column");
open(&mut app).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
};
let key = |k: egui::Key, m: egui::Modifiers| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m };
let none = egui::Modifiers::NONE;
// The window opening gives the entry box the keys.
frame(&mut app, vec![]);
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text("9".into())]);
frame(&mut app, vec![key(egui::Key::Tab, none)]);
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(sel(d), (0, 2), "Tab: right");
assert!(cell_at(d, 0, 1).ends_with('9'), "the typed text went into the cell it left: {}", cell_at(d, 0, 1));
// Still the entry's keys after a Tab: Enter moves down rather than applying.
frame(&mut app, vec![key(egui::Key::Enter, none)]);
assert_eq!(sel(app.ui.dialog.as_ref().unwrap()), (1, 2), "Enter: down");
assert_eq!(data(&mut app, id)["series"], json!(["a", "b"]), "not applied");
frame(&mut app, vec![key(egui::Key::Tab, egui::Modifiers::SHIFT)]);
assert_eq!(sel(app.ui.dialog.as_ref().unwrap()), (1, 1), "Shift+Tab: left");
frame(&mut app, vec![key(egui::Key::ArrowUp, none)]);
assert_eq!(sel(app.ui.dialog.as_ref().unwrap()), (0, 1), "Up");
}
#[test]
fn a_full_size_sheet_draws_only_what_is_in_view() {
let rows: Vec<Vec<f64>> = (0..MAX_GRAPH_CATEGORIES).map(|i| vec![i as f64; 256]).collect();
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("graph.create", json!({"type": "column", "x": 0, "y": 0, "width": 200, "height": 150, "rows": rows})).unwrap();
open(&mut app).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let t = std::time::Instant::now();
for _ in 0..3 {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(&mut app, ui.ctx()));
out.textures_delta.clear();
}
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND));
}
}

View File

@ -4,11 +4,11 @@
//! design named "New Design"), Rename (to the name typed), Delete Design (the selected designs),
//! Paste Design (a copy of the selected design into the document, selected, to edit) and Select
//! Unused (the designs no graph draws with), each run at once by `graph.design`; Close closes.
//! Marker: the design the selected graph series draw their data points with; OK runs
//! `graph.marker`.
//! Marker: the design the selected graph series draw their data points with, or None (the default
//! square marker); OK runs `graph.marker`.
//!
//! Fields: `selected` (the selected designs' names; Marker: one name) and, in Graph Design, `name`
//! (the name Rename gives).
//! Fields: `selected` (the selected designs' names; Marker: one name, null for None) and, in Graph
//! Design, `name` (the name Rename gives).
use serde_json::{Value, json};
@ -38,14 +38,7 @@ pub(super) const DESIGN_SPEC: DialogSpec = DialogSpec {
pub(super) const MARKER_SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Graph Marker").into(),
body: marker_body,
confirm: |app, d| {
let design = d.str("selected");
if design.is_empty() {
app.ui.dialog = None;
return Ok(Value::Null);
}
run_and_close(app, "graph.marker", json!({ "design": design }))
},
confirm: |app, d| run_and_close(app, "graph.marker", json!({ "design": d.fields.get("selected").and_then(Value::as_str) })),
min_width: 300.0,
..DialogSpec::FORM
};
@ -59,11 +52,10 @@ pub fn open_design(app: &mut VectorcraftApp) -> Result<Value, String> {
Ok(Value::Null)
}
/// Open Marker for the selected graph series, on the design they draw with (else the first).
/// Open Marker for the selected graph series, on the design they draw with (None for the default).
pub fn open_marker(app: &mut VectorcraftApp) -> Result<Value, String> {
let v = app.session.execute("graph.marker", &json!({})).map_err(|e| e.to_string())?;
let current = v["design"].as_str().map(str::to_string).or_else(|| names(app).ok()?.first().cloned()).unwrap_or_default();
app.ui.dialog = Some(Dialog::new(MARKER, json!({ "selected": current })));
app.ui.dialog = Some(Dialog::new(MARKER, json!({ "selected": v["design"].as_str() })));
Ok(Value::Null)
}
@ -200,12 +192,18 @@ fn design_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> b
fn marker_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let names = names(app).unwrap_or_default();
// A list as wide as Graph Design's, not the whole window.
ui.set_width(220.0);
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("graph-markers").min_scrolled_height(160.0).max_height(160.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(160.0);
let current = d.fields.get("selected").and_then(Value::as_str).map(str::to_string);
if ui.selectable_label(current.is_none(), tl!("None")).clicked() {
d.fields.insert("selected".into(), Value::Null);
}
for n in &names {
if ui.selectable_label(d.str("selected") == *n, n.as_str()).clicked() {
if ui.selectable_label(current.as_deref() == Some(n.as_str()), n.as_str()).clicked() {
d.fields.insert("selected".into(), json!(n));
}
}
@ -265,11 +263,17 @@ mod tests {
let g = app.run("graph.create", json!({"type": "line", "x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [g]})).unwrap();
super::open_marker(&mut app).unwrap();
let d: Dialog = app.ui.dialog.take().unwrap();
assert_eq!(d.str("selected"), "New Design", "the first design");
let mut d: Dialog = app.ui.dialog.take().unwrap();
assert_eq!(d.fields["selected"], json!(null), "None: the default marker");
d.fields.insert("selected".into(), json!("New Design"));
(super::MARKER_SPEC.confirm)(&mut app, &d).unwrap();
assert_eq!(app.session.execute("graph.marker", &json!({})).unwrap()["design"], "New Design");
super::open_marker(&mut app).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("selected"), "New Design");
let mut d: Dialog = app.ui.dialog.take().unwrap();
assert_eq!(d.str("selected"), "New Design");
// None goes back to the default marker.
d.fields.insert("selected".into(), json!(null));
(super::MARKER_SPEC.confirm)(&mut app, &d).unwrap();
assert_eq!(app.session.execute("graph.marker", &json!({})).unwrap()["design"], json!(null));
}
}

View File

@ -35,6 +35,7 @@ pub mod flattener_presets;
mod form;
pub mod freehand;
mod gradient_stop;
pub mod graph_data;
pub mod graph_design;
pub mod graphic_style_options;
pub mod halftone;
@ -226,6 +227,7 @@ registry! {
Message: [confirm::MESSAGE] => confirm::MESSAGE_SPEC,
NewSwatch: [new_swatch::KIND] => new_swatch::SPEC,
NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC,
GraphData: [graph_data::KIND] => graph_data::SPEC,
GraphDesign: [graph_design::DESIGN] => graph_design::DESIGN_SPEC,
GraphMarker: [graph_design::MARKER] => graph_design::MARKER_SPEC,
GradientStop: ["gradientStop"] => gradient_stop::SPEC,

View File

@ -25,11 +25,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
"command",
json!({"__command": "graph.create", "__label": "Graph", "type": p.get("type").cloned().unwrap_or(json!("column")), "x": x, "y": y, "width": 200, "height": 150}),
),
// After drawing a graph: Graph Data (CSV: header row of series, then category, values…).
"graphData" => match app.session.execute("graph.setData", &json!({})) {
Ok(v) => Dialog::new("command", json!({"__command": "graph.setData", "__label": "Graph Data", "csv": v["csv"]})),
Err(_) => return,
},
"graphData" => {
// After drawing a graph: Graph Data, its worksheet.
if let Err(e) = super::graph_data::open(app) {
app.status(e);
}
return;
}
// A Flare tool click: the Flare Tool Options, with the tool's values; OK draws a flare there.
"flare" => {
super::flare_options::open(app, Some(vectorcraft_geom::Point::new(x, y)));

View File

@ -4186,6 +4186,11 @@ effect Regular Pravidelný
@msg missing panel layout chybí rozložení panelů
@msg unsupported native docking operation nepodporovaná nativní operace ukotvení
@msg this kind of brush has no options dialog yet tento druh štětce zatím nemá dialogové okno možností
Column Width: Šířka sloupce:
Import Data… Importovat data…
Number of Decimals: Počet desetinných míst:
Switch x/y Prohodit x/y
Transpose Transponovat
Phone 393×852 Telefon 393×852
Phone 430×932 Telefon 430×932
Phone 360×800 Telefon 360×800

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1110,6 +1110,11 @@
Delete Design Design löschen
Paste Design Design einfügen
Select Unused Nicht verwendete auswählen
Import Data… Daten importieren…
Transpose Transponieren
Switch x/y x/y vertauschen
Number of Decimals: Anzahl Dezimalstellen:
Column Width: Spaltenbreite:
No Legend Keine Legende
Standard Legend Standardlegende
Legends in Wedges Legenden in Segmenten

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1127,6 +1127,11 @@
Delete Design Eliminar diseño
Paste Design Pegar diseño
Select Unused Seleccionar no utilizados
Import Data… Importar datos…
Transpose Transponer
Switch x/y Intercambiar x/y
Number of Decimals: Número de decimales:
Column Width: Ancho de columna:
No Legend Sin leyenda
Standard Legend Leyenda estándar
Legends in Wedges Leyendas en cuñas

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1126,6 +1126,11 @@
Delete Design Supprimer la conception
Paste Design Coller la conception
Select Unused Sélectionner les éléments inutilisés
Import Data… Importer des données…
Transpose Transposer
Switch x/y Permuter x/y
Number of Decimals: Nombre de décimales :
Column Width: Largeur de colonne :
No Legend Aucune légende
Standard Legend Légende standard
Legends in Wedges Légendes dans les secteurs

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1126,6 +1126,11 @@
Delete Design Elimina design
Paste Design Incolla design
Select Unused Seleziona non utilizzati
Import Data… Importa dati…
Transpose Trasponi
Switch x/y Scambia x/y
Number of Decimals: Numero di decimali:
Column Width: Larghezza colonna:
No Legend Nessuna legenda
Standard Legend Legenda standard
Legends in Wedges Legende negli spicchi

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2990,6 +2990,11 @@
Delete Design デザインを削除
Paste Design デザインをペースト
Select Unused 未使用を選択
Import Data… データを読み込み…
Transpose 行と列を入れ替え
Switch x/y X/Y を入れ替え
Number of Decimals: 小数点以下の桁数:
Column Width: 列の幅:
No Legend 凡例なし
Standard Legend 標準の凡例
Legends in Wedges くさび形内に凡例

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1124,6 +1124,11 @@
Delete Design Удалить дизайн
Paste Design Вставить дизайн
Select Unused Выбрать неиспользуемые
Import Data… Импорт данных…
Transpose Транспонировать
Switch x/y Поменять x/y
Number of Decimals: Число десятичных знаков:
Column Width: Ширина столбца:
No Legend Без легенды
Standard Legend Стандартная легенда
Legends in Wedges Легенды в секторах

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1120,6 +1120,11 @@
Delete Design Видалити дизайн
Paste Design Вставити дизайн
Select Unused Вибрати невикористані
Import Data… Імпорт даних…
Transpose Транспонувати
Switch x/y Поміняти x/y
Number of Decimals: Кількість десяткових знаків:
Column Width: Ширина стовпця:
No Legend Без легенди
Standard Legend Стандартна легенда
Legends in Wedges Легенди в секторах

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -1077,6 +1077,11 @@
Delete Design 删除设计
Paste Design 粘贴设计
Select Unused 选择未使用项
Import Data… 导入数据…
Transpose 转置
Switch x/y 切换 x/y
Number of Decimals: 小数位数:
Column Width: 列宽:
No Legend 无图例
Standard Legend 标准图例
Legends in Wedges 楔形中的图例

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1101,6 +1101,11 @@
Delete Design 刪除設計
Paste Design 貼上設計
Select Unused 選取未使用項目
Import Data… 匯入資料…
Transpose 轉置
Switch x/y 切換 x/y
Number of Decimals: 小數位數:
Column Width: 欄寬:
No Legend 無圖例
Standard Legend 標準圖例
Legends in Wedges 楔形中的圖例

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3056,8 +3056,12 @@ fn to_field(ctx: &egui::Context, field: egui::Id, e: egui::Event) {
/// Object › Graph › Type… or Data… (`id`): its dialog, with the selected graph's current values
/// (the command's query).
pub(crate) fn graph_dialog(app: &mut VectorcraftApp, id: &str) -> Result<Value, String> {
// Graph Data: the worksheet.
if id == "graph.setData" {
return crate::dialogs::graph_data::open(app);
}
let v = app.session.execute(id, &json!({})).map_err(|e| e.to_string())?;
let (label, fields) = if id == "graph.setData" { ("Graph Data", json!({"csv": v["csv"]})) } else { ("Graph Type", v) };
let (label, fields) = ("Graph Type", v);
app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})).map(|_| json!({ "dialog": "command" }))
}

View File

@ -59,7 +59,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines and kept label formatting done; graph Column designs, Import Data and the data sheet's cell style pending | 6–10 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
@ -79,6 +79,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | M12: the Graph Data worksheet. Scores/hours unchanged. |
| 2026-10-11 | minor | M12: graph label formatting kept through regeneration. Scores/hours unchanged. |
| 2026-10-11 | minor | M12: Graph Type's widths past 100%, First Row/Column in Front, Draw Filled Lines. Scores/hours unchanged. |
| 2026-10-11 | minor | M12: Graph Type's Add Drop Shadow and Add Legend Across Top. Scores/hours unchanged. |

View File

@ -414,7 +414,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Column designs, Import Data and the Graph Data sheet's cell style remain open.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. Column designs remain open.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
@ -467,6 +467,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | The Graph Data worksheet (Import Data, Cell Style, Transpose, Switch x/y, Revert). Scores unchanged within rounding |
| 2026-10-11 | minor | Graph label formatting kept through regeneration. Scores unchanged within rounding |
| 2026-10-11 | minor | Graph Type's widths past 100%, First Row/Column in Front and Draw Filled Lines. Scores unchanged within rounding |
| 2026-10-11 | minor | Graph Type's Add Drop Shadow and Add Legend Across Top. Scores unchanged within rounding |