mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
variables: fix binding lifetime, dataset editing and the missing UI to define
Review of #761 turned up three real defects and a feature that was visible but not usable. All of it is fixed here, each defect with a regression test that fails without the fix. - A binding outlived the object it named. Deleting art left its entry in `variables.bindings` forever: it counted as a skip in every dataset application, `variable.list` counted it, and the saved file grew with every delete. `Document::prune_variable_bindings` now lets them go after each edit, as `prune_assets` does for Asset Export's. - `Variables::prune` cleared `active_dataset` by comparing it against the *variable* names being deleted. A dataset named like a variable lost its active state when the variable went. The two are separate namespaces; the block is gone (dataset.delete already did it right). - `variable.delete` reported the number of names asked for, not the number deleted, so deleting a name that was not there still said `deleted: 1`. - `dataset.set` (new): a dataset's values could only be set when it was created, so changing one meant deleting and rebuilding the row — losing its place in the list and the active one. This replaces the row's values and reports what it dropped. - Window › Variables, and dialogs for the "…" commands. Every command had an empty menu path and the command palette runs those with `{}`, so nothing could be defined from the UI at all. The commands now carry their menu path, the "…" ones open a Variables dialog (a name and a kind; a name and a field per variable, its kind's editor), and `dataset.set` opens with the row's own values. - Locked art is neither bound nor rewritten by a dataset, and says so. - `set_plain_text` told a node that existed but was not type that it was missing. - The panel had Document Info's icon, so the two were indistinguishable in the collapsed column; it gets its own, plus New/Delete buttons, and its rows say what their count is. Format round trips now cover variables (the testkit fixture defines both kinds, binds them and applies a row), and ROADMAP/README no longer list the feature as missing. cargo xtask ci: all 7 steps pass.
This commit is contained in:
1 parent
c89906a13a
commit
202894372a
24 files changed
+894
-106
No files matched your search
@@ -221,7 +221,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0
|
||||
- 3D and Materials;
|
||||
- the Photoshop-style raster effects (Effect Gallery);
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- Variables and scripting;
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- packaging for Windows and Linux.
|
||||
|
||||
|
||||
+6
-5
@@ -35,7 +35,7 @@ since 2026-10-01.
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
|
||||
@@ -77,7 +77,8 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
graphic-style libraries generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by
|
||||
pen tilt, bearing and barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every
|
||||
platform. 17–28 h.
|
||||
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
|
||||
6. **Automation:** a scripting surface over the command registry, batch, and the rest of
|
||||
Variables (data merge): image and graph variable kinds, dataset import/export. 6–10 h.
|
||||
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
|
||||
rulers. 15–25 h.
|
||||
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, Affinity files from more versions and platforms
|
||||
@@ -103,7 +104,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
|
||||
- **Automation:**
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- Variables (data merge): text and visibility variables bound to objects, datasets applied in one undo step (`variable.*`, `dataset.*`), with a Variables panel; every command, like the rest, is drivable over MCP and the CLI.
|
||||
- Variables (data merge): text and visibility variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility Dynamic, Capture/Update Data Set, and a row in italics when an edit leaves the art not matching it; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832).
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
@@ -242,8 +243,8 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 62% | scripting surface, batch (Variables: text/visibility variables bound to objects, datasets applied in one undo step, panel; image/graph kinds and dataset import open) | 6–10 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 |
|
||||
| Automation | 3 | 65% | scripting surface, batch (Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 13–20 |
|
||||
| Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
|
||||
| **Feature parity** | **103** | **~75%** | | **~210–330** |
|
||||
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::NodeId;
|
||||
use super::{Document, NodeId};
|
||||
|
||||
/// What a variable drives.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -20,7 +20,8 @@ pub enum VariableKind {
|
||||
}
|
||||
|
||||
impl VariableKind {
|
||||
/// Parse the `kind` of `variable.define` (`text` or `visibility`).
|
||||
/// Parse the `kind` of `variable.define`: `text` or `visibility` (`visible` is accepted
|
||||
/// as the same thing, as the panel and the docs call it both).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"text" => Some(Self::Text),
|
||||
@@ -29,6 +30,7 @@ impl VariableKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind's own name, as `variable.define` takes it and `variable.list` reports it.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "text",
|
||||
@@ -83,19 +85,47 @@ pub struct Variables {
|
||||
}
|
||||
|
||||
impl Variables {
|
||||
/// The variable `name`, if the document defines it.
|
||||
pub fn variable(&self, name: &str) -> Option<&Variable> {
|
||||
self.variables.iter().find(|v| v.name == name)
|
||||
}
|
||||
|
||||
/// Drop the bindings (and the active dataset) that name what is gone.
|
||||
pub fn prune(&mut self, names: &[String]) {
|
||||
/// The dataset `name`, if the document has it.
|
||||
pub fn dataset(&self, name: &str) -> Option<&DataSet> {
|
||||
self.datasets.iter().find(|d| d.name == name)
|
||||
}
|
||||
|
||||
/// The objects bound to the variable `name`, in document order.
|
||||
pub fn objects_of(&self, name: &str) -> Vec<NodeId> {
|
||||
let mut ids: Vec<NodeId> = self.bindings.iter().filter(|(_, v)| *v == name).map(|(id, _)| *id).collect();
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Drop the variables `names`, the dataset values that name them and the bindings to
|
||||
/// them. Datasets named here are left alone: their names are their own namespace (the
|
||||
/// active one is dropped by whoever deletes the dataset).
|
||||
pub fn prune(&mut self, names: &[String]) -> usize {
|
||||
let before = self.variables.len();
|
||||
self.variables.retain(|v| !names.contains(&v.name));
|
||||
for dataset in &mut self.datasets {
|
||||
dataset.values.retain(|k, _| !names.contains(k));
|
||||
}
|
||||
self.bindings.retain(|_, v| !names.contains(v));
|
||||
if self.active_dataset.as_ref().is_some_and(|a| names.contains(a)) {
|
||||
self.active_dataset = None;
|
||||
before - self.variables.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Forget the bindings of objects that are no longer in the document (after every edit),
|
||||
/// like [`Document::prune_assets`] does for Asset Export's. A binding outliving its object
|
||||
/// would count as a skip in every dataset application and grow the saved file forever.
|
||||
pub fn prune_variable_bindings(&mut self) {
|
||||
if self.variables.bindings.keys().all(|id| self.node(*id).is_some()) {
|
||||
return;
|
||||
}
|
||||
let mut bindings = std::mem::take(&mut self.variables.bindings);
|
||||
bindings.retain(|id, _| self.node(*id).is_some());
|
||||
self.variables.bindings = bindings;
|
||||
}
|
||||
}
|
||||
@@ -117,8 +117,12 @@ fn text_ref(s: &Session, id: NodeId) -> Result<TextObject> {
|
||||
/// range does. Dataset apply and friends share this so Variables edits behave
|
||||
/// like Type tool edits.
|
||||
pub(crate) fn set_plain_text(d: &mut vectorcraft_doc::Document, id: NodeId, text: &str) -> Result<()> {
|
||||
let len = match d.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => edit::runs_len(&t.runs),
|
||||
Some(_) => return Err(EngineError::Other(format!("node {} is not text", id.0))),
|
||||
None => return Err(EngineError::NoNode(id)),
|
||||
};
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
let len = edit::runs_len(&t.runs);
|
||||
t.splice_paras(0, len, text);
|
||||
edit::replace_range(&mut t.runs, 0, len, text);
|
||||
refresh_bounds(t);
|
||||
|
||||
@@ -5,17 +5,24 @@
|
||||
//! dataset, and `dataset.select` swaps the whole document to that row in one undo
|
||||
//! step. Agents reach every command through `run_command`, like all engine commands.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{DataValue, NodeId, NodeKind, VariableKind};
|
||||
use vectorcraft_doc::{DataSet, DataValue, NodeId, NodeKind, Variable, VariableKind, Variables};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::textedit::set_plain_text;
|
||||
use super::*;
|
||||
|
||||
fn var_name(p: &Value, cmd: &str) -> Result<String> {
|
||||
match str_param(p, "name").filter(|v| !v.is_empty()) {
|
||||
named(str_param(p, "name"), "name", cmd)
|
||||
}
|
||||
|
||||
/// `key`'s value, as a non-empty name.
|
||||
fn named(v: Option<&str>, key: &str, cmd: &str) -> Result<String> {
|
||||
match v.filter(|v| !v.is_empty()) {
|
||||
Some(v) => Ok(v.to_string()),
|
||||
None => Err(bad(cmd, "missing `name`")),
|
||||
None => Err(bad(cmd, format!("missing `{key}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +50,15 @@ fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result<
|
||||
.ok_or_else(|| bad(cmd, format!("value for `{var}` must match its variable kind")))
|
||||
}
|
||||
|
||||
fn values_param(s: &Session, p: &Value, cmd: &str) -> Result<std::collections::BTreeMap<String, DataValue>> {
|
||||
let mut out = std::collections::BTreeMap::new();
|
||||
/// The `values` object of `dataset.new` / `dataset.set`, each entry checked against its
|
||||
/// variable's kind. Every name must be a variable the document defines: a value nothing
|
||||
/// reads is a typo, not a value to keep.
|
||||
fn values_param(vars: &Variables, p: &Value, cmd: &str) -> Result<BTreeMap<String, DataValue>> {
|
||||
let mut out = BTreeMap::new();
|
||||
let Some(given) = p.get("values") else { return Ok(out) };
|
||||
let Some(obj) = given.as_object() else { return Err(bad(cmd, "`values` must be an object")) };
|
||||
for (var, v) in obj {
|
||||
let kind = s.doc()?.doc.variables.variable(var).map(|d| d.kind).ok_or_else(|| bad(cmd, format!("no variable named `{var}`")))?;
|
||||
let kind = vars.variable(var).map(|d| d.kind).ok_or_else(|| bad(cmd, format!("no variable named `{var}`")))?;
|
||||
out.insert(var.clone(), dataset_value(cmd, var, kind, v)?);
|
||||
}
|
||||
Ok(out)
|
||||
@@ -62,19 +72,15 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, format!("variable `{name}` is already defined")));
|
||||
}
|
||||
s.edit("New Variable", |d, _| {
|
||||
d.variables.variables.push(vectorcraft_doc::Variable { name: name.clone(), kind });
|
||||
d.variables.variables.push(Variable { name: name.clone(), kind });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": name, "kind": kind.label()}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.delete";
|
||||
let names = names_param(p, C)?;
|
||||
let n = s.edit("Delete Variables", |d, _| {
|
||||
d.variables.prune(&names);
|
||||
Ok(names.len())
|
||||
})?;
|
||||
let names = names_param(p, "variable.delete")?;
|
||||
let n = s.edit("Delete Variables", |d, _| Ok(d.variables.prune(&names)))?;
|
||||
Ok(json!({"deleted": n}))
|
||||
}
|
||||
|
||||
@@ -95,7 +101,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.bind";
|
||||
let name = str_param(p, "variable").filter(|v| !v.is_empty()).map(str::to_string).ok_or_else(|| bad(C, "missing `variable`"))?;
|
||||
let name = named(str_param(p, "variable"), "variable", C)?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(v) => v,
|
||||
None => selected_roots(s)?,
|
||||
@@ -103,30 +109,29 @@ fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "nothing to bind"));
|
||||
}
|
||||
let kind = s.doc()?.doc.variables.variable(&name).map(|v| v.kind).ok_or_else(|| bad(C, format!("no variable named `{name}`")))?;
|
||||
// A text variable only drives type; visibility drives anything that exists.
|
||||
if kind == VariableKind::Text {
|
||||
for id in &ids {
|
||||
match s.doc()?.doc.node(*id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(_)) => {}
|
||||
Some(_) => return Err(bad(C, format!("node {} is not type", id.0))),
|
||||
None => return Err(EngineError::NoNode(*id)),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for id in &ids {
|
||||
if s.doc()?.doc.node(*id).is_none() {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
let doc = &s.doc()?.doc;
|
||||
let kind = doc.variables.variable(&name).map(|v| v.kind).ok_or_else(|| bad(C, format!("no variable named `{name}`")))?;
|
||||
for id in &ids {
|
||||
match doc.node(*id) {
|
||||
None => return Err(EngineError::NoNode(*id)),
|
||||
// Art on a locked or hidden layer is not the document's to rewrite, and a text
|
||||
// variable only drives type.
|
||||
Some(_) if !doc.is_editable(*id) => return Err(bad(C, format!("node {} is locked", id.0))),
|
||||
Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => {
|
||||
return Err(bad(C, format!("node {} is not type", id.0)));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
// An object holds one binding, so binding it again replaces what it had; say which.
|
||||
let replaced: Vec<u64> = ids.iter().filter(|id| doc.variables.bindings.get(id).is_some_and(|b| *b != name)).map(|id| id.0).collect();
|
||||
s.edit("Bind Variable", |d, _| {
|
||||
for id in &ids {
|
||||
d.variables.bindings.insert(*id, name.clone());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
Ok(json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "replaced": replaced}))
|
||||
}
|
||||
|
||||
fn unbind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -149,23 +154,41 @@ fn unbind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn dataset_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.new";
|
||||
let name = var_name(p, C)?;
|
||||
let values = values_param(s, p, C)?;
|
||||
if s.doc()?.doc.variables.datasets.iter().any(|d| d.name == name) {
|
||||
let st = s.doc()?;
|
||||
// A dataset and a variable are separate namespaces, so a dataset may share a variable's
|
||||
// name; it is its own row of values either way.
|
||||
let values = values_param(&st.doc.variables, p, C)?;
|
||||
if st.doc.variables.dataset(&name).is_some() {
|
||||
return Err(bad(C, format!("dataset `{name}` already exists")));
|
||||
}
|
||||
s.edit("New Data Set", |d, _| {
|
||||
d.variables.datasets.push(vectorcraft_doc::DataSet { name: name.clone(), values: values.clone() });
|
||||
d.variables.datasets.push(DataSet { name: name.clone(), values: values.clone() });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
|
||||
/// Replace a dataset's values. A variable left out of `values` loses its value in this row,
|
||||
/// so the dataset ends up holding exactly what the call asked for.
|
||||
fn dataset_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.set";
|
||||
let name = var_name(p, C)?;
|
||||
let values = values_param(&s.doc()?.doc.variables, p, C)?;
|
||||
let (set, removed) = s.edit("Edit Data Set", |d, _| {
|
||||
let ds = d.variables.datasets.iter_mut().find(|x| x.name == name).ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
let removed: Vec<String> = ds.values.keys().filter(|k| !values.contains_key(*k)).cloned().collect();
|
||||
ds.values = values.clone();
|
||||
Ok((values.len(), removed))
|
||||
})?;
|
||||
Ok(json!({"name": name, "values": set, "removed": removed}))
|
||||
}
|
||||
|
||||
fn dataset_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.delete";
|
||||
let names = names_param(p, C)?;
|
||||
let names = names_param(p, "dataset.delete")?;
|
||||
let n = s.edit("Delete Data Sets", |d, _| {
|
||||
let before = d.variables.datasets.len();
|
||||
d.variables.datasets.retain(|d| !names.contains(&d.name));
|
||||
// The active dataset's own name is the one that goes, not a variable's.
|
||||
if d.variables.active_dataset.as_ref().is_some_and(|a| names.contains(a)) {
|
||||
d.variables.active_dataset = None;
|
||||
}
|
||||
@@ -182,12 +205,15 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
const C: &str = "dataset.select";
|
||||
let (applied, skipped) = s.edit("Data Set", |d, _| {
|
||||
let ds = d.variables.datasets.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
let (applied, skipped) = s.edit("Apply Data Set", |d, _| {
|
||||
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
let bindings: Vec<(NodeId, String)> = d.variables.bindings.iter().map(|(id, v)| (*id, v.clone())).collect();
|
||||
let mut applied = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
for (id, var) in bindings {
|
||||
// A binding whose variable is gone, whose value this row doesn't carry, or whose
|
||||
// value doesn't fit the variable's kind, is left alone and counted, never
|
||||
// silently reinterpreted.
|
||||
let (kind, value) = match (d.variables.variable(&var).map(|v| v.kind), ds.values.get(&var)) {
|
||||
(Some(kind), Some(value)) if value.fits(kind) => (kind, value.clone()),
|
||||
_ => {
|
||||
@@ -195,22 +221,20 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Art on a locked or hidden layer, and text whose object is gone, can't be
|
||||
// rewritten: skipped and counted, like the rest.
|
||||
if !d.is_editable(id) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let done = match (kind, &value) {
|
||||
(VariableKind::Text, DataValue::Text(text)) => {
|
||||
if d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) {
|
||||
set_plain_text(d, id, text).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
(VariableKind::Visibility, DataValue::Visible(show)) => {
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
n.visible = *show;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
// The Type tool's own whole-range replace, so a dataset's text is styled and
|
||||
// laid out exactly as typing it would be.
|
||||
(VariableKind::Text, DataValue::Text(text)) => set_plain_text(d, id, text).is_ok(),
|
||||
(VariableKind::Visibility, DataValue::Visible(show)) => d.node_mut(id).is_some_and(|n| {
|
||||
n.visible = *show;
|
||||
true
|
||||
}),
|
||||
_ => false,
|
||||
};
|
||||
if done {
|
||||
@@ -226,21 +250,21 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn dataset_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = var_name(p, "dataset.select")?;
|
||||
apply_dataset(s, &name)
|
||||
apply_dataset(s, &var_name(p, "dataset.select")?)
|
||||
}
|
||||
|
||||
fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
if st.doc.variables.datasets.is_empty() {
|
||||
let n = st.doc.variables.datasets.len();
|
||||
if n == 0 {
|
||||
return Err(bad(cmd, "no datasets"));
|
||||
}
|
||||
let at = st.doc.variables.active_dataset.as_ref().and_then(|a| st.doc.variables.datasets.iter().position(|d| &d.name == a));
|
||||
let next = match at {
|
||||
Some(i) if forward => (i + 1) % st.doc.variables.datasets.len(),
|
||||
Some(i) => (i + st.doc.variables.datasets.len() - 1) % st.doc.variables.datasets.len(),
|
||||
None if forward => 0,
|
||||
None => st.doc.variables.datasets.len() - 1,
|
||||
let at = st.doc.variables.active_dataset.as_deref().and_then(|a| st.doc.variables.datasets.iter().position(|d| d.name == a));
|
||||
let next = match (at, forward) {
|
||||
(Some(i), true) => (i + 1) % n,
|
||||
(Some(i), false) => (i + n - 1) % n,
|
||||
(None, true) => 0,
|
||||
(None, false) => n - 1,
|
||||
};
|
||||
let name = st.doc.variables.datasets.get(next).map(|d| d.name.clone()).ok_or_else(|| bad(cmd, "no datasets"))?;
|
||||
apply_dataset(s, &name)
|
||||
@@ -248,28 +272,55 @@ fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result<Value> {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("variable.define", "New Variable", [], None, "{name, kind: text|visibility} define a variable", has_doc, define),
|
||||
cmd!("variable.delete", "Delete Variable", [], None, "{names} delete variables with their bindings and values", has_doc, delete),
|
||||
cmd!("variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list),
|
||||
cmd!("variable.define", "New Variable…", ["Window", "Variables"], None, "{name, kind: text|visibility} define a variable", has_doc, define),
|
||||
cmd!(
|
||||
"variable.delete",
|
||||
"Delete Variable",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{names} delete variables with their bindings and values",
|
||||
has_doc,
|
||||
delete
|
||||
),
|
||||
cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list),
|
||||
cmd!(
|
||||
"variable.bind",
|
||||
"Bind Variable",
|
||||
[],
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{variable, ids?} bind objects (default: the selection) to a variable; text needs type",
|
||||
"{variable, ids?} bind objects (default: the selection) to a variable; text needs type, locked art is refused. An object holds one binding, so this replaces what it had → {ids, replaced}",
|
||||
has_doc,
|
||||
bind
|
||||
),
|
||||
cmd!("variable.unbind", "Unbind Variable", [], None, "{ids?} drop bindings (default: the selection)", has_doc, unbind),
|
||||
cmd!("dataset.new", "New Data Set", [], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new),
|
||||
cmd!("dataset.delete", "Delete Data Set", [], None, "{names} delete datasets", has_doc, dataset_delete),
|
||||
cmd!("dataset.list", "List Data Sets", [], None, "{} → datasets with values and the active one", has_doc, dataset_list),
|
||||
cmd!("dataset.select", "Select Data Set", [], None, "{name} apply a dataset in one undo step → {applied, skipped}", has_doc, dataset_select),
|
||||
cmd!("dataset.next", "Next Data Set", [], None, "{} apply the next dataset, wrapping", has_doc, |s, _| dataset_step(s, "dataset.next", true)),
|
||||
cmd!("dataset.prev", "Previous Data Set", [], None, "{} apply the previous dataset, wrapping", has_doc, |s, _| dataset_step(
|
||||
cmd!("variable.unbind", "Unbind Variable", ["Window", "Variables"], None, "{ids?} drop bindings (default: the selection)", has_doc, unbind),
|
||||
cmd!("dataset.new", "New Data Set…", ["Window", "Variables"], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new),
|
||||
cmd!(
|
||||
"dataset.set",
|
||||
"Edit Data Set…",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name, values?: {variable: value}} replace a dataset's values; a variable left out loses its value in this row → {values, removed}",
|
||||
has_doc,
|
||||
dataset_set
|
||||
),
|
||||
cmd!("dataset.delete", "Delete Data Set", ["Window", "Variables"], None, "{names} delete datasets", has_doc, dataset_delete),
|
||||
cmd!(query "dataset.list", "List Data Sets", [], None, "{} → datasets with values and the active one", has_doc, dataset_list),
|
||||
cmd!(
|
||||
"dataset.select",
|
||||
"Select Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name} apply a dataset in one undo step → {applied, skipped}",
|
||||
has_doc,
|
||||
dataset_select
|
||||
),
|
||||
cmd!("dataset.next", "Next Data Set", ["Window", "Variables"], None, "{} apply the next dataset, wrapping", has_doc, |s, _| dataset_step(
|
||||
s,
|
||||
"dataset.prev",
|
||||
false
|
||||
"dataset.next",
|
||||
true
|
||||
)),
|
||||
cmd!("dataset.prev", "Previous Data Set", ["Window", "Variables"], None, "{} apply the previous dataset, wrapping", has_doc, |s, _| {
|
||||
dataset_step(s, "dataset.prev", false)
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -1226,6 +1226,10 @@ impl Session {
|
||||
if !st.doc.assets.is_empty() {
|
||||
Arc::make_mut(&mut st.doc).prune_assets();
|
||||
}
|
||||
// Variables: bindings let go of deleted art.
|
||||
if !st.doc.variables.bindings.is_empty() {
|
||||
Arc::make_mut(&mut st.doc).prune_variable_bindings();
|
||||
}
|
||||
if doc_sane(&st.doc, &st.selection) {
|
||||
Ok(v)
|
||||
} else {
|
||||
|
||||
@@ -92,7 +92,7 @@ fn mismatched_and_missing_bindings_are_skipped_and_counted() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_variable_cleans_bindings_values_and_active_dataset() {
|
||||
fn deleting_a_variable_cleans_bindings_values_and_leaves_datasets_alone() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
@@ -104,6 +104,8 @@ fn deleting_a_variable_cleans_bindings_values_and_active_dataset() {
|
||||
assert_eq!(vars["variables"].as_array().map(Vec::len), Some(0));
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert!(sets["datasets"][0].get("values").is_none_or(|v| v == &json!({})));
|
||||
// The dataset named "D" is still the active one: `variable.delete` prunes variables, and
|
||||
// the dataset's own name is its own namespace.
|
||||
assert_eq!(sets["active"], "D");
|
||||
s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
@@ -113,6 +115,135 @@ fn deleting_a_variable_cleans_bindings_values_and_active_dataset() {
|
||||
assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({})));
|
||||
}
|
||||
|
||||
/// A dataset may share a variable's name: they are separate namespaces, and deleting the
|
||||
/// variable must not silently unselect the dataset that happens to be called the same.
|
||||
#[test]
|
||||
fn a_dataset_survives_a_variable_of_the_same_name_being_deleted() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "Row", "kind": "text"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Row", "values": {"Row": "y"}})).unwrap();
|
||||
s.execute("dataset.select", &json!({"name": "Row"})).unwrap();
|
||||
s.execute("variable.delete", &json!({"names": ["Row"]})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][0]["name"], "Row", "the dataset is not a variable");
|
||||
assert_eq!(sets["active"], "Row", "the active dataset keeps its name");
|
||||
}
|
||||
|
||||
/// Deleting art lets its bindings go, as it does for Asset Export's assets: a binding that
|
||||
/// outlived its object would count as a skip in every dataset application and grow the file.
|
||||
#[test]
|
||||
fn deleted_art_lets_its_bindings_go() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap();
|
||||
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
let of = |name: &str| vars["variables"].as_array().map(|a| a.iter().find(|v| v["name"] == name).unwrap()["bindings"].as_u64().unwrap()).unwrap();
|
||||
assert_eq!(of("Name"), 0, "the deleted text object took its binding with it");
|
||||
assert_eq!(of("Show"), 1, "the rectangle is still bound");
|
||||
// Nothing stale is left for the application to skip.
|
||||
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0)));
|
||||
}
|
||||
|
||||
/// `deleted` counts what was deleted, not what was asked for.
|
||||
#[test]
|
||||
fn delete_reports_what_it_actually_removed() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "A", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "B", "kind": "text"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!(s.execute("variable.delete", &json!({"names": ["A"]})).unwrap()["deleted"], 1);
|
||||
assert_eq!(s.execute("variable.delete", &json!({"names": ["A"]})).unwrap()["deleted"], 0, "already gone");
|
||||
assert_eq!(s.execute("variable.delete", &json!({"names": ["A", "B"]})).unwrap()["deleted"], 1, "only B was left");
|
||||
assert_eq!(s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap()["deleted"], 1);
|
||||
assert_eq!(s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap()["deleted"], 0);
|
||||
}
|
||||
|
||||
/// An object holds one binding, so binding it again replaces what it had, and the reply says
|
||||
/// which objects were rebound rather than letting the old variable silently lose them.
|
||||
#[test]
|
||||
fn binding_again_replaces_and_says_so() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
s.execute("variable.define", &json!({"name": "First", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Second", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "First", "ids": [t.0]})).unwrap();
|
||||
let v = s.execute("variable.bind", &json!({"variable": "Second", "ids": [t.0]})).unwrap();
|
||||
assert_eq!(v["replaced"], json!([t.0]));
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
let of = |n: &str| vars["variables"].as_array().unwrap().iter().find(|v| v["name"] == n).unwrap()["bindings"].as_u64();
|
||||
assert_eq!((of("First"), of("Second")), (Some(0), Some(1)));
|
||||
// Rebinding to the same variable changes nothing and reports no replacement.
|
||||
assert_eq!(s.execute("variable.bind", &json!({"variable": "Second", "ids": [t.0]})).unwrap()["replaced"], json!([]));
|
||||
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0]})).unwrap()["unbound"], 1);
|
||||
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0]})).unwrap()["unbound"], 0);
|
||||
}
|
||||
|
||||
/// Locked art is the document's to rewrite only while it isn't locked.
|
||||
#[test]
|
||||
fn locked_art_is_neither_bound_nor_rewritten() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap();
|
||||
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
assert!(s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).is_err(), "locked art takes no new binding");
|
||||
// The bindings made before the lock still count, and the application skips the locked art.
|
||||
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(1)));
|
||||
assert_eq!(plain(&s, t), "x", "the locked text was left alone");
|
||||
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible, "the unlocked rectangle still applied");
|
||||
}
|
||||
|
||||
/// `dataset.set` is how a value changes after the row exists: replacing it must not need the
|
||||
/// dataset to be deleted and rebuilt (which loses its place in the list and the active one).
|
||||
#[test]
|
||||
fn dataset_set_replaces_the_row_and_reports_what_it_dropped() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap();
|
||||
|
||||
// The dataset keeps its place and stays active while its values change.
|
||||
let v = s.execute("dataset.set", &json!({"name": "D", "values": {"Name": "z"}})).unwrap();
|
||||
assert_eq!((v["values"].clone(), v["removed"].clone()), (json!(1), json!(["Show"])));
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "z"}}));
|
||||
|
||||
s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "z");
|
||||
assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "`Show` has no value in this row now");
|
||||
|
||||
// Errors are errors, not a silent rewrite of some other row.
|
||||
assert!(s.execute("dataset.set", &json!({"name": "Missing"})).is_err());
|
||||
assert!(s.execute("dataset.set", &json!({"name": "D", "values": {"Ghost": "z"}})).is_err());
|
||||
assert!(s.execute("dataset.set", &json!({"name": "D", "values": {"Show": "yes"}})).is_err());
|
||||
assert!(s.execute("dataset.set", &json!({"name": "D", "values": []})).is_err());
|
||||
// One step, so one undo puts the row back as it was.
|
||||
s.execute("dataset.set", &json!({"name": "D", "values": {"Name": "w"}})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_variables_requests_are_errors_not_panics() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -154,6 +154,15 @@ pub fn rich_session() -> Session {
|
||||
exec(&mut s, "symbol.new", json!({"name": "Dot"}));
|
||||
let t = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 370, "text": "Tap to add", "size": 18})));
|
||||
exec(&mut s, "text.insertInline", json!({"id": t.0, "at": 4, "symbol": "Dot"}));
|
||||
// Variables (data merge): both kinds, bound to art, with two dataset rows and one applied,
|
||||
// so format round trips cover the definitions, the bindings and the rows.
|
||||
exec(&mut s, "variable.define", json!({"name": "Title", "kind": "text"}));
|
||||
exec(&mut s, "variable.define", json!({"name": "Badge", "kind": "visibility"}));
|
||||
exec(&mut s, "variable.bind", json!({"variable": "Title", "ids": [tx.0]}));
|
||||
exec(&mut s, "variable.bind", json!({"variable": "Badge", "ids": [a]}));
|
||||
exec(&mut s, "dataset.new", json!({"name": "Row 1", "values": {"Title": "Merged", "Badge": true}}));
|
||||
exec(&mut s, "dataset.new", json!({"name": "Row 2", "values": {"Title": "Other"}}));
|
||||
exec(&mut s, "dataset.select", json!({"name": "Row 1"}));
|
||||
let _ = (a, b, c, l);
|
||||
exec(&mut s, "select.none", json!({}));
|
||||
s
|
||||
|
||||
@@ -88,6 +88,7 @@ pub mod tile_edge_color;
|
||||
mod tools;
|
||||
mod transform;
|
||||
pub mod transform_each;
|
||||
pub mod variables;
|
||||
pub mod width_point;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -212,6 +213,7 @@ registry! {
|
||||
ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC,
|
||||
Recolor: [recolor::KIND] => recolor::SPEC,
|
||||
Command: ["command"] => command::SPEC,
|
||||
Variables: [variables::KIND] => variables::SPEC,
|
||||
Effect: ["effect"] => effect::SPEC,
|
||||
SaveChanges: [crate::unsaved::KIND] => save_changes::SPEC,
|
||||
Preferences: ["preferences"] => DialogSpec::window(crate::prefs_dialog::show, |app, _| crate::prefs_dialog::confirm(app)),
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
//! The Variables dialogs (`variable.define`, `variable.bind`, `dataset.new`, `dataset.set`
|
||||
//! and `dataset.select`), opened from Window › Variables or the command palette.
|
||||
//!
|
||||
//! One dialog covers all of them: what it shows is what the command takes. A variable is a
|
||||
//! name and a kind; a dataset is a name and one field per variable — a text field for a
|
||||
//! `text` variable, a checkbox for a `visibility` one. `dataset.set` opens with the row's
|
||||
//! current values, so editing a row is editing what it already says.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{DataValue, VariableKind, Variables};
|
||||
|
||||
use super::{DialogSpec, run_and_close};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
|
||||
/// The dialog kind of every Variables dialog.
|
||||
pub const KIND: &str = "variables";
|
||||
|
||||
/// The kinds a variable can have, as `variable.define` names them.
|
||||
const KINDS: [&str; 2] = ["text", "visibility"];
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |d| tl!(&d.str("__label")).to_string(), body, confirm, min_width: 380.0, ..DialogSpec::FORM };
|
||||
|
||||
/// The command the dialog runs and what it is called.
|
||||
fn opened(id: &str) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
"variable.define" => "New Variable",
|
||||
"variable.bind" => "Bind Variable",
|
||||
"dataset.new" => "New Data Set",
|
||||
"dataset.set" => "Edit Data Set",
|
||||
"dataset.select" => "Select Data Set",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Open `id`'s dialog: OK runs the command with the fields below.
|
||||
pub fn open(app: &mut VectorcraftApp, id: &str) -> bool {
|
||||
let Some(label) = opened(id) else { return false };
|
||||
let Some(st) = app.session.active() else {
|
||||
app.status(tl!("no document").to_string());
|
||||
return true;
|
||||
};
|
||||
let vars = st.doc.variables.clone();
|
||||
let mut fields = serde_json::Map::new();
|
||||
match id {
|
||||
"variable.define" => {
|
||||
fields.insert("name".into(), json!(""));
|
||||
fields.insert("kind".into(), json!(KINDS[0]));
|
||||
}
|
||||
"variable.bind" => {
|
||||
fields.insert("variable".into(), json!(""));
|
||||
}
|
||||
"dataset.new" => {
|
||||
fields.insert("name".into(), json!(""));
|
||||
value_fields(&vars, vars.datasets.first().map(|d| &d.values), &mut fields);
|
||||
}
|
||||
"dataset.set" => {
|
||||
// Without a row to edit there is nothing to open.
|
||||
let Some(name) = vars.active_dataset.clone().or_else(|| vars.datasets.first().map(|d| d.name.clone())) else {
|
||||
app.status(tl!("no data set to edit").to_string());
|
||||
return true;
|
||||
};
|
||||
let Some(current) = vars.dataset(&name) else { return true };
|
||||
fields.insert("name".into(), json!(name));
|
||||
value_fields(&vars, Some(¤t.values), &mut fields);
|
||||
}
|
||||
"dataset.select" => {
|
||||
fields.insert("name".into(), json!(""));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
fields.insert("__command".into(), json!(id));
|
||||
fields.insert("__label".into(), json!(label));
|
||||
app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields)));
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether `id` opens one of these dialogs.
|
||||
pub fn opens(id: &str) -> bool {
|
||||
opened(id).is_some()
|
||||
}
|
||||
|
||||
/// One field per variable, prefilled from `current` (the row being edited, else the first row
|
||||
/// so a new one starts from something rather than from nothing).
|
||||
fn value_fields(vars: &Variables, current: Option<&std::collections::BTreeMap<String, DataValue>>, out: &mut serde_json::Map<String, Value>) {
|
||||
for v in &vars.variables {
|
||||
let value = match (current.and_then(|c| c.get(&v.name)), v.kind) {
|
||||
(Some(DataValue::Text(t)), _) => json!(t),
|
||||
(Some(DataValue::Visible(b)), _) => json!(b),
|
||||
// Without a value in the row a text field starts empty (no value in this row) and
|
||||
// a visibility one starts shown: its false is a value, so it is never "unset".
|
||||
(None, VariableKind::Text) => json!(""),
|
||||
(None, VariableKind::Visibility) => json!(true),
|
||||
};
|
||||
out.insert(value_key(&v.name), value);
|
||||
}
|
||||
}
|
||||
|
||||
/// A variable name as a field key. A field key shares the dialog's flat namespace with the
|
||||
/// command's own parameters, so names are prefixed to stay clear of `name` and `values`.
|
||||
fn value_key(name: &str) -> String {
|
||||
format!("value:{name}")
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else {
|
||||
widgets::dim_label(ui, "No document");
|
||||
return false;
|
||||
};
|
||||
let vars = st.doc.variables.clone();
|
||||
let variable_names: Vec<&str> = vars.variables.iter().map(|v| v.name.as_str()).collect();
|
||||
let dataset_names: Vec<&str> = vars.datasets.iter().map(|x| x.name.as_str()).collect();
|
||||
egui::Grid::new("variables-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
let label = |ui: &mut egui::Ui, text: &str| widgets::field_label(ui, egui::RichText::new(text).color(t.text));
|
||||
match d.str("__command").as_str() {
|
||||
"variable.define" => {
|
||||
label(ui, tl!("Name"));
|
||||
text(ui, d, "name", 220.0);
|
||||
ui.end_row();
|
||||
label(ui, tl!("Kind"));
|
||||
dropdown(ui, d, "kind", &KINDS, 220.0);
|
||||
ui.end_row();
|
||||
}
|
||||
"variable.bind" => {
|
||||
label(ui, tl!("Variable"));
|
||||
dropdown(ui, d, "variable", &variable_names, 220.0);
|
||||
ui.end_row();
|
||||
}
|
||||
"dataset.select" => {
|
||||
label(ui, tl!("Data Set"));
|
||||
dropdown(ui, d, "name", &dataset_names, 220.0);
|
||||
ui.end_row();
|
||||
}
|
||||
_ => {
|
||||
label(ui, tl!("Name"));
|
||||
text(ui, d, "name", 220.0);
|
||||
ui.end_row();
|
||||
if vars.variables.is_empty() {
|
||||
widgets::dim_label(ui, "Define a variable first");
|
||||
ui.end_row();
|
||||
return;
|
||||
}
|
||||
// One row per variable, in the order they are defined, each with the editor
|
||||
// its kind takes.
|
||||
for v in &vars.variables {
|
||||
label(ui, &v.name);
|
||||
let key = value_key(&v.name);
|
||||
match v.kind {
|
||||
VariableKind::Text => text(ui, d, &key, 220.0),
|
||||
VariableKind::Visibility => checkbox(ui, d, &key),
|
||||
};
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
/// A text field for `key`, written back as it is typed.
|
||||
fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response {
|
||||
let mut s = d.str(key);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width));
|
||||
if r.changed() {
|
||||
d.fields.insert(key.into(), json!(s));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A checkbox for `key`, written back as it is toggled.
|
||||
fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) -> egui::Response {
|
||||
let mut b = d.bool(key);
|
||||
let r = ui.checkbox(&mut b, "");
|
||||
if r.changed() {
|
||||
d.fields.insert(key.into(), json!(b));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A dropdown over `options` for `key`, written back as it changes. The empty option is first
|
||||
/// so a fresh dialog can still be filled in; a name that is no longer offered reads as empty
|
||||
/// rather than being silently swapped for another one.
|
||||
fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[&str], width: f32) {
|
||||
let cur = d.str(key);
|
||||
let mut labels: Vec<&str> = vec![""];
|
||||
labels.extend_from_slice(options);
|
||||
let shown = if cur.is_empty() || options.contains(&cur.as_str()) { cur.as_str() } else { "" };
|
||||
if let Some(i) = widgets::dropdown(ui, ("variables-dd", key), shown, &labels, width) {
|
||||
d.fields.insert(key.into(), json!(options.get(i.saturating_sub(1)).copied().unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The `values` object the dialog's fields mean: a text variable whose field is empty carries
|
||||
/// no value in this row, and a visibility checkbox is always a value — its false, too.
|
||||
fn values_of(d: &Dialog, vars: &Variables) -> Value {
|
||||
let mut out = serde_json::Map::new();
|
||||
for v in &vars.variables {
|
||||
let key = value_key(&v.name);
|
||||
if v.kind == VariableKind::Text {
|
||||
let text = d.str(&key);
|
||||
if !text.is_empty() {
|
||||
out.insert(v.name.clone(), json!(text));
|
||||
}
|
||||
} else {
|
||||
out.insert(v.name.clone(), json!(d.bool(&key)));
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let cmd = d.str("__command");
|
||||
let name = d.str("name");
|
||||
let vars = app.session.active().map(|st| st.doc.variables.clone()).unwrap_or_default();
|
||||
let p = match cmd.as_str() {
|
||||
"variable.define" => json!({"name": name, "kind": d.str("kind")}),
|
||||
"variable.bind" => json!({"variable": d.str("variable")}),
|
||||
"dataset.new" | "dataset.set" => json!({"name": name, "values": values_of(d, &vars)}),
|
||||
"dataset.select" => json!({"name": name}),
|
||||
_ => json!({}),
|
||||
};
|
||||
run_and_close(app, &cmd, p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn app_with_variables() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("variable.define", json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
app.run("variable.define", json!({"name": "Badge", "kind": "visibility"})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defining_a_variable_from_the_dialog_works() {
|
||||
let mut app = app_with_variables();
|
||||
assert!(open(&mut app, "variable.define"));
|
||||
{
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.kind, KIND);
|
||||
assert_eq!(d.str("name"), "");
|
||||
assert_eq!(d.str("kind"), "text", "text is the default kind");
|
||||
}
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Price"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let vars = app.run("variable.list", json!({})).unwrap();
|
||||
assert!(vars["variables"].as_array().unwrap().iter().any(|v| v["name"] == "Price"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_dataset_carries_the_dialogs_values_and_omits_the_empty_ones() {
|
||||
let mut app = app_with_variables();
|
||||
open(&mut app, "dataset.new");
|
||||
{
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("name".into(), json!("Row 1"));
|
||||
d.fields.insert(value_key("Title"), json!("Hello"));
|
||||
d.fields.insert(value_key("Badge"), json!(false));
|
||||
}
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let sets = app.run("dataset.list", json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][0]["name"], "Row 1");
|
||||
assert_eq!(sets["datasets"][0]["values"]["Title"], json!({"text": "Hello"}));
|
||||
assert_eq!(sets["datasets"][0]["values"]["Badge"], json!({"visible": false}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_dataset_opens_with_the_rows_own_values() {
|
||||
let mut app = app_with_variables();
|
||||
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hello", "Badge": false}})).unwrap();
|
||||
app.run("dataset.set", json!({"name": "Row 1", "values": {"Badge": true}})).unwrap();
|
||||
open(&mut app, "dataset.set");
|
||||
{
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.str("name"), "Row 1");
|
||||
assert_eq!(d.str(&value_key("Title")), "", "the row carries no value for it");
|
||||
assert!(d.bool(&value_key("Badge")), "the row's own value");
|
||||
}
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(value_key("Title"), json!("Changed"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let sets = app.run("dataset.list", json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][0]["values"]["Title"], json!({"text": "Changed"}));
|
||||
assert_eq!(sets["datasets"][0]["values"]["Badge"], json!({"visible": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_a_dataset_from_the_dialog_applies_it() {
|
||||
let mut app = app_with_variables();
|
||||
let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "x"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("variable.bind", json!({"variable": "Title", "ids": [id]})).unwrap();
|
||||
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hi"}})).unwrap();
|
||||
open(&mut app, "dataset.select");
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Row 1"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.run("text.getRange", json!({"id": id})).unwrap()["text"], "Hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_variables_commands_open_one() {
|
||||
let mut app = app_with_variables();
|
||||
assert!(opens("dataset.set") && opens("variable.define"));
|
||||
assert!(!opens("dataset.list") && !opens("dataset.next"), "no dialog for a query or a step");
|
||||
assert!(!open(&mut app, "dataset.list"), "and it opens nothing");
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
}
|
||||
@@ -534,6 +534,17 @@
|
||||
Paragraph Styles Styly odstavců
|
||||
Tabs Tabulátory
|
||||
Variables Proměnné
|
||||
New Variable… Nová proměnná…
|
||||
Bind Variable… Přiřadit proměnnou…
|
||||
Unbind Variable Zrušit přiřazení proměnné
|
||||
Delete Variable Odstranit proměnnou
|
||||
New Data Set… Nová sada dat…
|
||||
Edit Data Set… Upravit sadu dat…
|
||||
Delete Data Set Odstranit sadu dat
|
||||
Select Data Set… Vybrat sadu dat…
|
||||
Next Data Set Další sada dat
|
||||
Previous Data Set Předchozí sada dat
|
||||
Variables Panel Panel proměnných
|
||||
Brush Libraries Knihovny štětců
|
||||
Built-in Libraries Vestavěné knihovny
|
||||
User Defined Uživatelské
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3067,6 +3067,18 @@
|
||||
Next Data Set Conjunto de datos siguiente
|
||||
Previous Data Set Conjunto de datos anterior
|
||||
Data Sets Conjuntos de datos
|
||||
New Variable… Nueva variable…
|
||||
Bind Variable… Vincular variable…
|
||||
New Data Set… Nuevo conjunto de datos…
|
||||
Edit Data Set… Editar conjunto de datos…
|
||||
Select Data Set… Seleccionar conjunto de datos…
|
||||
Variables Panel Panel de variables
|
||||
Data Set Conjunto de datos
|
||||
Kind Tipo
|
||||
Variable Variable
|
||||
no document no hay documento
|
||||
no data set to edit no hay ningún conjunto de datos que editar
|
||||
{name} · {kind} · {count} {name} · {kind} · {count}
|
||||
Variation: Variación:
|
||||
Vector Effects Efectos vectoriales
|
||||
Vector Halftone Semitono vectorial
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3064,6 +3064,18 @@
|
||||
Next Data Set Jeu de données suivant
|
||||
Previous Data Set Jeu de données précédent
|
||||
Data Sets Jeux de données
|
||||
New Variable… Nouvelle variable…
|
||||
Bind Variable… Lier la variable…
|
||||
New Data Set… Nouveau jeu de données…
|
||||
Edit Data Set… Modifier le jeu de données…
|
||||
Select Data Set… Sélectionner le jeu de données…
|
||||
Variables Panel Panneau des variables
|
||||
Data Set Jeu de données
|
||||
Kind Type
|
||||
Variable Variable
|
||||
no document aucun document
|
||||
no data set to edit aucun jeu de données à modifier
|
||||
{name} · {kind} · {count} {name} · {kind} · {count}
|
||||
Variation: Variation :
|
||||
Vector Effects Effets vectoriels
|
||||
Vector Halftone Trame de demi-teintes vectorielle
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3067,6 +3067,18 @@
|
||||
Next Data Set Set di dati successivo
|
||||
Previous Data Set Set di dati precedente
|
||||
Data Sets Set di dati
|
||||
New Variable… Nuova variabile…
|
||||
Bind Variable… Associa variabile…
|
||||
New Data Set… Nuovo set di dati…
|
||||
Edit Data Set… Modifica set di dati…
|
||||
Select Data Set… Seleziona set di dati…
|
||||
Variables Panel Pannello variabili
|
||||
Data Set Set di dati
|
||||
Kind Tipo
|
||||
Variable Variabile
|
||||
no document nessun documento
|
||||
no data set to edit nessun set di dati da modificare
|
||||
{name} · {kind} · {count} {name} · {kind} · {count}
|
||||
Variation: Variazione:
|
||||
Vector Effects Effetti vettoriali
|
||||
Vector Halftone Retino vettoriale
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -581,6 +581,18 @@
|
||||
Next Data Set 次のデータセット
|
||||
Previous Data Set 前のデータセット
|
||||
Data Sets データセット
|
||||
New Variable… 新規変数…
|
||||
Bind Variable… 変数をバインド…
|
||||
New Data Set… 新規データセット…
|
||||
Edit Data Set… データセットを編集…
|
||||
Select Data Set… データセットを選択…
|
||||
Variables Panel 変数パネル
|
||||
Data Set データセット
|
||||
Kind 種類
|
||||
Variable 変数
|
||||
no document ドキュメントがありません
|
||||
no data set to edit 編集するデータセットがありません
|
||||
{name} · {kind} · {count} {name} · {kind} · {count} 件
|
||||
Brush Libraries ブラシライブラリ
|
||||
Built-in Libraries 内蔵ライブラリ
|
||||
User Defined ユーザー定義
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2530,6 +2530,17 @@
|
||||
V: V:
|
||||
Variable Width Profile Perfil de Largura Variável
|
||||
Variables Variáveis
|
||||
New Variable… Nova variável…
|
||||
Bind Variable… Associar variável…
|
||||
Unbind Variable Desassociar variável
|
||||
Delete Variable Excluir variável
|
||||
New Data Set… Novo conjunto de dados…
|
||||
Edit Data Set… Editar conjunto de dados…
|
||||
Delete Data Set Excluir conjunto de dados
|
||||
Select Data Set… Selecionar conjunto de dados…
|
||||
Next Data Set Próximo conjunto de dados
|
||||
Previous Data Set Conjunto de dados anterior
|
||||
Variables Panel Painel de variáveis
|
||||
Variation: Variação:
|
||||
Vector Effects Efeitos Vetoriais
|
||||
Vector Halftone Meio-tom Vetorial
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3063,6 +3063,18 @@
|
||||
Next Data Set Следующий набор данных
|
||||
Previous Data Set Предыдущий набор данных
|
||||
Data Sets Наборы данных
|
||||
New Variable… Новая переменная…
|
||||
Bind Variable… Привязать переменную…
|
||||
New Data Set… Новый набор данных…
|
||||
Edit Data Set… Изменить набор данных…
|
||||
Select Data Set… Выбрать набор данных…
|
||||
Variables Panel Панель переменных
|
||||
Data Set Набор данных
|
||||
Kind Тип
|
||||
Variable Переменная
|
||||
no document нет документа
|
||||
no data set to edit нет набора данных для изменения
|
||||
{name} · {kind} · {count} {name} · {kind} · {count}
|
||||
Variation: Вариация:
|
||||
Vector Effects Векторные эффекты
|
||||
Vector Halftone Векторный полутон
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3059,6 +3059,18 @@
|
||||
Next Data Set Наступний набір даних
|
||||
Previous Data Set Попередній набір даних
|
||||
Data Sets Набори даних
|
||||
New Variable… Нова змінна…
|
||||
Bind Variable… Прив’язати змінну…
|
||||
New Data Set… Новий набір даних…
|
||||
Edit Data Set… Змінити набір даних…
|
||||
Select Data Set… Вибрати набір даних…
|
||||
Variables Panel Панель змінних
|
||||
Data Set Набір даних
|
||||
Kind Тип
|
||||
Variable Змінна
|
||||
no document немає документа
|
||||
no data set to edit немає набору даних для зміни
|
||||
{name} · {kind} · {count} {name} · {kind} · {count}
|
||||
Variation: Варіація:
|
||||
Vector Effects Векторні ефекти
|
||||
Vector Halftone Векторний напівтон
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -2959,6 +2959,18 @@
|
||||
Next Data Set 下一个数据集
|
||||
Previous Data Set 上一个数据集
|
||||
Data Sets 数据集
|
||||
New Variable… 新建变量…
|
||||
Bind Variable… 绑定变量…
|
||||
New Data Set… 新建数据集…
|
||||
Edit Data Set… 编辑数据集…
|
||||
Select Data Set… 选择数据集…
|
||||
Variables Panel 变量面板
|
||||
Data Set 数据集
|
||||
Kind 类型
|
||||
Variable 变量
|
||||
no document 没有文档
|
||||
no data set to edit 没有可编辑的数据集
|
||||
{name} · {kind} · {count} {name} · {kind} · {count}
|
||||
Variation: 变化:
|
||||
Vector Effects 矢量效果
|
||||
Vector Halftone 矢量半色调
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3050,6 +3050,18 @@
|
||||
Next Data Set 下一個資料集
|
||||
Previous Data Set 上一個資料集
|
||||
Data Sets 資料集
|
||||
New Variable… 新增變數…
|
||||
Bind Variable… 繫結變數…
|
||||
New Data Set… 新增資料集…
|
||||
Edit Data Set… 編輯資料集…
|
||||
Select Data Set… 選取資料集…
|
||||
Variables Panel 變數面板
|
||||
Data Set 資料集
|
||||
Kind 類型
|
||||
Variable 變數
|
||||
no document 沒有文件
|
||||
no data set to edit 沒有可編輯的資料集
|
||||
{name} · {kind} · {count} {name} · {kind} · {count}
|
||||
Variation: 變化:
|
||||
Vector Effects 向量效果
|
||||
Vector Halftone 向量半色調
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -47,6 +47,28 @@ fn sub(label: &'static str, items: Vec<Item>) -> Item {
|
||||
fn library_placeholders() -> Vec<Item> {
|
||||
vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")]
|
||||
}
|
||||
|
||||
/// Window › Variables: the panel, then the commands that define and apply what it shows. The
|
||||
/// "…" commands open a Variables dialog (`dialogs::variables`), so the menu and the command
|
||||
/// palette both reach them.
|
||||
fn variables_window_menu(panel: impl Fn(&'static str, &'static str) -> Item) -> Vec<Item> {
|
||||
vec![
|
||||
panel("Variables Panel", crate::panels::variables::ID),
|
||||
Sep,
|
||||
c("New Variable…", "variable.define"),
|
||||
c("Bind Variable…", "variable.bind"),
|
||||
c("Unbind Variable", "variable.unbind"),
|
||||
c("Delete Variable", "variable.delete"),
|
||||
Sep,
|
||||
c("New Data Set…", "dataset.new"),
|
||||
c("Edit Data Set…", "dataset.set"),
|
||||
c("Delete Data Set", "dataset.delete"),
|
||||
Sep,
|
||||
c("Select Data Set…", "dataset.select"),
|
||||
c("Next Data Set", "dataset.next"),
|
||||
c("Previous Data Set", "dataset.prev"),
|
||||
]
|
||||
}
|
||||
use Item::Sep;
|
||||
|
||||
/// UI-level commands: (id, label, shortcut, params doc).
|
||||
@@ -2548,7 +2570,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
panel("Tabs", "tabs"),
|
||||
],
|
||||
),
|
||||
panel("Variables", "variables"),
|
||||
sub("Variables", variables_window_menu(panel)),
|
||||
Sep,
|
||||
sub("Brush Libraries", library_placeholders()),
|
||||
sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()),
|
||||
@@ -2992,6 +3014,11 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Variables (data merge): a name and a kind, a name and a row of values.
|
||||
if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) {
|
||||
crate::dialogs::variables::open(app, id);
|
||||
return;
|
||||
}
|
||||
// Repeat Options: a dialog with the selected repeat's current values.
|
||||
if id == "object.repeat.options"
|
||||
&& p.as_object().is_none_or(|o| o.is_empty())
|
||||
|
||||
@@ -137,7 +137,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
||||
"tabs" => tabs::show(app, ui),
|
||||
flattener_preview::ID => flattener_preview::show(app, ui),
|
||||
"attributes" => attributes::show(app, ui),
|
||||
"variables" => variables::show(app, ui),
|
||||
variables::ID => variables::show(app, ui),
|
||||
"colorThemes" => color_themes::show(app, ui),
|
||||
links::ID => links::show(app, ui),
|
||||
asset_export::ID => asset_export::show(app, ui),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Variables panel: the document's variables (what they drive, what they bind) and
|
||||
//! its datasets. Clicking a dataset applies it; clicking a variable selects what it
|
||||
//! binds; the arrows step to the next or previous dataset. Definitions are made with
|
||||
//! the `variable.*` and `dataset.*` commands (command palette, actions, MCP).
|
||||
//! Variables panel: the document's variables (what they drive, what they bind) and its
|
||||
//! datasets. Clicking a dataset applies it; clicking a variable selects what it binds; the
|
||||
//! arrows step to the next or previous one. Definitions are made with the `variable.*` and
|
||||
//! `dataset.*` commands, from Window › Variables, the command palette or an agent.
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::json;
|
||||
@@ -9,21 +9,28 @@ use serde_json::json;
|
||||
use crate::VectorcraftApp;
|
||||
use crate::widgets;
|
||||
|
||||
/// The panel id, as the dock, the Window menu and `window.panel` name it.
|
||||
pub const ID: &str = "variables";
|
||||
|
||||
/// One variable row: what it is and how many objects it drives.
|
||||
struct VarRow {
|
||||
name: String,
|
||||
kind: &'static str,
|
||||
bound: Vec<u64>,
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some(st) = app.session.active() else {
|
||||
widgets::dim_label(ui, tl!("No document"));
|
||||
return;
|
||||
};
|
||||
// Owned snapshots: the rows below run commands, which borrow the app mutably.
|
||||
let vars: Vec<(String, String, Vec<u64>)> = st
|
||||
let vars: Vec<VarRow> = st
|
||||
.doc
|
||||
.variables
|
||||
.variables
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let bound: Vec<u64> = st.doc.variables.bindings.iter().filter(|(_, name)| *name == &v.name).map(|(id, _)| id.0).collect();
|
||||
(v.name.clone(), v.kind.label().to_string(), bound)
|
||||
})
|
||||
.map(|v| VarRow { name: v.name.clone(), kind: v.kind.label(), bound: st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect() })
|
||||
.collect();
|
||||
let sets: Vec<(String, usize, bool)> = st
|
||||
.doc
|
||||
@@ -32,23 +39,31 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
.iter()
|
||||
.map(|d| (d.name.clone(), d.values.len(), st.doc.variables.active_dataset.as_deref() == Some(d.name.as_str())))
|
||||
.collect();
|
||||
let has_doc = app.session.active().is_some();
|
||||
|
||||
widgets::subheader(ui, tl!("Variables"));
|
||||
if vars.is_empty() {
|
||||
widgets::dim_label(ui, tl!("None"));
|
||||
}
|
||||
for (name, kind, bound) in &vars {
|
||||
let row = format!("{name} · {kind} · {}", bound.len());
|
||||
if ui.selectable_label(false, row).clicked() && !bound.is_empty() {
|
||||
app.run("select.set", json!({"ids": bound})).ok();
|
||||
for row in &vars {
|
||||
// The binding count is what the row says it drives; clicking selects those objects.
|
||||
let text = if row.bound.is_empty() {
|
||||
format!("{} · {}", row.name, row.kind)
|
||||
} else {
|
||||
crate::i18n::fmt(tl!("{name} · {kind} · {count}"), &[("name", &row.name), ("kind", row.kind), ("count", &row.bound.len().to_string())])
|
||||
};
|
||||
if ui.selectable_label(false, text).clicked() && !row.bound.is_empty() {
|
||||
app.run("select.set", json!({"ids": row.bound})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
widgets::subheader(ui, tl!("Data Sets"));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("◀").clicked() {
|
||||
if widgets::icon_button(ui, "chevron-left", tl!("Previous Data Set"), false, 24.0).clicked() {
|
||||
app.run("dataset.prev", json!({})).ok();
|
||||
}
|
||||
if ui.small_button("▶").clicked() {
|
||||
if widgets::icon_button(ui, "chevron-right", tl!("Next Data Set"), false, 24.0).clicked() {
|
||||
app.run("dataset.next", json!({})).ok();
|
||||
}
|
||||
});
|
||||
@@ -57,11 +72,30 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
widgets::dim_label(ui, tl!("None"));
|
||||
}
|
||||
for (name, count, active) in &sets {
|
||||
// The active row is both ticked and selected, as the reference app's is.
|
||||
let row = format!("{}{name} · {count}", if *active { "● " } else { "" });
|
||||
if ui.selectable_label(*active, row).clicked() {
|
||||
app.run("dataset.select", json!({"name": name})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// New and delete, as the reference panel's buttons: the definitions are made here.
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::icon_button(ui, "plus", tl!("New Variable…"), false, 24.0).clicked() {
|
||||
crate::menus::invoke(app, "variable.define", json!({}));
|
||||
}
|
||||
if widgets::icon_button(ui, "plus", tl!("New Data Set…"), false, 24.0).clicked() {
|
||||
crate::menus::invoke(app, "dataset.new", json!({}));
|
||||
}
|
||||
let remove = widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, !vars.is_empty(), 24.0).clicked();
|
||||
if remove {
|
||||
// Everything the document defines goes, in one undo step, as the panel's bin does.
|
||||
let names: Vec<String> = vars.iter().map(|v| v.name.clone()).collect();
|
||||
app.run("variable.delete", json!({"names": names})).ok();
|
||||
}
|
||||
});
|
||||
let _ = (has_doc, ui);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -87,4 +121,64 @@ mod tests {
|
||||
let text = crate::tests_labels::painted_text(&mut app, show);
|
||||
assert!(text.contains("● A"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_is_registered_with_its_own_icon() {
|
||||
let row = crate::state::ICON_PANELS.iter().find(|(id, ..)| *id == ID).expect("in the icon column");
|
||||
assert_eq!(row.1, "Variables");
|
||||
assert_ne!(row.2, "dc-list-view", "Document Info's icon, which the panel used to share");
|
||||
// The icon it does use is one the app ships.
|
||||
assert!(crate::icons::exists(row.2), "{} is not a bundled icon", row.2);
|
||||
}
|
||||
|
||||
/// Every Variables command is in the Window menu, so the feature is reachable without an
|
||||
/// agent: the palette runs these with empty params, which the dialogs turn into a form.
|
||||
#[test]
|
||||
fn every_variables_command_is_in_the_window_menu() {
|
||||
let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
let entries = crate::menus::menu_entries(&app);
|
||||
let in_menu = |id: &str| entries.iter().any(|e| e.command.as_deref() == Some(id));
|
||||
for id in [
|
||||
"variable.define",
|
||||
"variable.delete",
|
||||
"variable.bind",
|
||||
"variable.unbind",
|
||||
"dataset.new",
|
||||
"dataset.set",
|
||||
"dataset.delete",
|
||||
"dataset.select",
|
||||
"dataset.next",
|
||||
"dataset.prev",
|
||||
] {
|
||||
assert!(in_menu(id), "{id} is not in the Window menu");
|
||||
}
|
||||
// The panel is under Window › Variables, and its own entry opens it.
|
||||
let panel = entries
|
||||
.iter()
|
||||
.find(|e| e.command.as_deref() == Some("window.panel") && e.params.get("panel").and_then(serde_json::Value::as_str) == Some(ID))
|
||||
.expect("the Variables panel is listed");
|
||||
assert_eq!(panel.path, vec!["Window".to_string(), "Variables".to_string()]);
|
||||
assert_eq!(panel.label, "Variables Panel");
|
||||
}
|
||||
|
||||
/// A menu item that needs a name opens the dialog rather than running the command with
|
||||
/// empty params, which would only say "missing `name`".
|
||||
#[test]
|
||||
fn the_menu_dialogs_open_instead_of_failing() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
for (cmd, field) in [("variable.define", "kind"), ("variable.bind", "variable"), ("dataset.new", "name"), ("dataset.select", "name")] {
|
||||
app.ui.dialog = None;
|
||||
crate::menus::invoke(&mut app, cmd, json!({}));
|
||||
let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("{cmd} opened no dialog"));
|
||||
assert_eq!(d.kind, crate::dialogs::variables::KIND, "{cmd}");
|
||||
assert_eq!(d.str("__command"), cmd);
|
||||
assert!(d.fields.contains_key(field), "{cmd}: no `{field}` field");
|
||||
}
|
||||
// `dataset.set` needs a row to edit, and says so rather than opening an empty form.
|
||||
app.ui.dialog = None;
|
||||
crate::menus::invoke(&mut app, "dataset.set", json!({}));
|
||||
assert!(app.ui.dialog.is_none(), "nothing to edit yet");
|
||||
assert!(app.ui.status.contains("data set"), "{}", app.ui.status);
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
||||
(crate::panels::links::ID, "Links", "link"),
|
||||
(crate::panels::asset_export::ID, "Asset Export", "share-2"),
|
||||
(crate::panels::css_properties::ID, "CSS Properties", "globe"),
|
||||
("variables", "Variables", "dc-list-view"),
|
||||
(crate::panels::variables::ID, "Variables", "chart-column"),
|
||||
];
|
||||
|
||||
/// Groups of icon panels separated by dividers in the collapsed column.
|
||||
|
||||
Reference in new issue
Block a user