diff --git a/README.md b/README.md index a848d63f..79b26dbb 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 731a5008..5bc16cba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2), Variables (data merge): text and visibility variables, data sets and the Variables panel (#761); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -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,6 +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 (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). @@ -241,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 | 60% | Variables (data merge), scripting surface, batch | 8–12 | -| 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 | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b45b921a..3c555def 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -41,6 +41,7 @@ pub mod style_libs; pub mod swatches; pub mod text; pub mod trace; +pub mod variables; use std::collections::BTreeMap; use std::sync::Arc; @@ -91,6 +92,7 @@ pub use text::{ TextWrap, VerticalAlign, WrapShape, }; pub use trace::TraceView; +pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; @@ -561,6 +563,9 @@ pub struct Document { /// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub saved_selections: Vec, + /// Variables (data merge): named values bound to objects, applied per dataset. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub variables: Variables, #[serde(default)] pub grid: GridPrefs, #[serde(default = "ppi72")] @@ -693,6 +698,7 @@ impl Document { guides: vec![], views: vec![], saved_selections: vec![], + variables: Variables::default(), grid: GridPrefs::default(), raster_effects_ppi: 72.0, images: BTreeMap::new(), diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs new file mode 100644 index 00000000..45e3a37a --- /dev/null +++ b/crates/doc/src/variables.rs @@ -0,0 +1,207 @@ +//! Variables (data merge): named values bound to objects, with datasets that apply +//! them all at once. One template document produces many variants (badges, price +//! lists, localized copies) by selecting a dataset instead of editing each object. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use super::{Document, NodeId, NodeKind}; + +/// What a variable drives. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum VariableKind { + /// A type object's characters. + #[default] + Text, + /// Whether bound objects show. + Visibility, +} + +impl VariableKind { + /// 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 { + match s.to_ascii_lowercase().as_str() { + "text" => Some(Self::Text), + "visibility" | "visible" => Some(Self::Visibility), + _ => None, + } + } + + /// 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", + Self::Visibility => "visibility", + } + } +} + +/// One named variable. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Variable { + pub name: String, + pub kind: VariableKind, +} + +/// One value a dataset gives a variable. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DataValue { + Text(String), + Visible(bool), +} + +impl DataValue { + /// Whether this value fits the variable's kind (a dataset may carry values for + /// variables of either kind; each binding takes what fits it). + pub fn fits(&self, kind: VariableKind) -> bool { + matches!((self, kind), (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility)) + } +} + +/// One named row of values, applied with `dataset.select`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct DataSet { + pub name: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub values: BTreeMap, +} + +impl DataSet { + /// Whether the art, as [`Document::captured_values`] read it, still holds what this row + /// says. A value the art can't report (its object is locked or gone) isn't drift: the row + /// keeps it, and applying it again would skip it too. + pub fn matches(&self, current: &BTreeMap) -> bool { + self.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v)) + } +} + +/// The variable state of a document: definitions, datasets, bindings +/// (object id → the variables bound to it) and which dataset is active. +/// +/// An object may be driven by several variables — the reference app binds the selected object +/// to the selected variable, one click at a time, and never says a second binding replaces the +/// first — so a binding is a set. `dataset.select` applies them in the order the variables are +/// defined, which is what makes two text variables on one object deterministic: the last one +/// wins, and `applied` counts both. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Variables { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub variables: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub datasets: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub bindings: BTreeMap>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_dataset: Option, +} + +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) + } + + /// The dataset `name`, if the document has it. + pub fn dataset(&self, name: &str) -> Option<&DataSet> { + self.datasets.iter().find(|d| d.name == name) + } + + /// Bind `id` to the variable `name`. True when it was not bound to it already. + pub fn bind(&mut self, id: NodeId, name: &str) -> bool { + self.bindings.entry(id).or_default().insert(name.to_string()) + } + + /// Drop `id`'s binding to `name` (and the binding itself when that was its last variable). + /// True when there was one. + pub fn unbind(&mut self, id: NodeId, name: &str) -> bool { + let Some(bound) = self.bindings.get_mut(&id) else { return false }; + let dropped = bound.remove(name); + if bound.is_empty() { + self.bindings.remove(&id); + } + dropped + } + + /// The objects bound to the variable `name`, in document order. + pub fn objects_of(&self, name: &str) -> Vec { + let mut ids: Vec = self.bindings.iter().filter(|(_, v)| v.contains(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 { + // A set, so a long list of names costs a lookup per value rather than a scan. + let names: BTreeSet<&str> = names.iter().map(String::as_str).collect(); + let before = self.variables.len(); + self.variables.retain(|v| !names.contains(v.name.as_str())); + for dataset in &mut self.datasets { + dataset.values.retain(|k, _| !names.contains(k.as_str())); + } + for bound in self.bindings.values_mut() { + bound.retain(|v| !names.contains(v.as_str())); + } + self.bindings.retain(|_, v| !v.is_empty()); + 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; + } + + /// Whether a variable may read and rewrite the object `id`: it is in the document, neither + /// it nor anything above it is locked, and nothing above it is hidden. Its own visibility + /// doesn't count, as that is what a visibility variable drives: a row that hid it must be + /// able to show it again, and a capture must record it hidden. + pub fn variable_target(&self, id: NodeId) -> bool { + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id)))) + } + + /// What the object `id` holds for the variable of `kind`: its characters, or whether it + /// shows. `None` when the object is gone, is not of that kind, or is locked or on a hidden + /// or locked layer (the document's value is whatever it was left at). + pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option { + if !self.variable_target(id) { + return None; + } + let node = self.node(id)?; + match (kind, &node.kind) { + (VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())), + (VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)), + (VariableKind::Text, _) => None, + } + } + + /// The values a dataset captured right now: what each bound object holds, in the order the + /// variables are defined. `dataset.capture` writes these into a new row and + /// `dataset.update` into the active one; the Variables panel compares the active row with + /// them to tell whether the art still matches what it says. + pub fn captured_values(&self) -> BTreeMap { + let mut out = BTreeMap::new(); + for v in &self.variables.variables { + // An object driven by several variables of the same kind reads from the first one + // bound, so a capture is one value per variable and applies back the same way. + let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value(id, v.kind)); + if let Some(value) = value { + out.insert(v.name.clone(), value); + } + } + out + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index dceb266e..228410de 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -77,6 +77,7 @@ pub(crate) mod textwrap; pub(crate) mod threads; pub(crate) mod typecmd; mod typemenu; +mod variablecmds; pub(crate) mod views; pub mod wand; pub mod webexport; @@ -235,6 +236,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(maskedit::specs()); v.extend(tabs::specs()); v.extend(docmenu::specs()); + v.extend(variablecmds::specs()); v.extend(docinfo::specs()); v.extend(panelcmds::specs()); v.extend(buildcmds::specs()); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 509ea200..0df32a97 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -112,6 +112,23 @@ fn text_ref(s: &Session, id: NodeId) -> Result { } } +/// Replace a type object's whole content: the new text takes the replaced text's +/// style and paragraph styles follow, exactly as `text.editRange` over the full +/// 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))?; + t.splice_paras(0, len, text); + edit::replace_range(&mut t.runs, 0, len, text); + refresh_bounds(t); + Ok(()) +} + fn byte_param(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_u64).map(|v| v as usize) } diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs new file mode 100644 index 00000000..a4755170 --- /dev/null +++ b/crates/engine/src/cmd/variablecmds.rs @@ -0,0 +1,590 @@ +//! Variables (data merge): named values bound to objects, applied per dataset. +//! +//! One template document produces many variants: bind a type object to a `text` +//! variable and anything to a `visibility` variable, put a row of values in each +//! 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::{DataSet, DataValue, NodeId, NodeKind, Variable, VariableKind, Variables}; + +use super::edit::selected_roots; +use super::textedit::set_plain_text; +use super::*; + +/// The longest variable or dataset name, in characters (names arrive from MCP and files). +const MAX_NAME: usize = 255; + +fn var_name(p: &Value, cmd: &str) -> Result { + named(str_param(p, "name"), "name", cmd) +} + +/// `key`'s value, as a name: trimmed, not empty and at most [`MAX_NAME`] characters. +fn named(v: Option<&str>, key: &str, cmd: &str) -> Result { + match v.map(str::trim).filter(|v| !v.is_empty()) { + Some(v) if v.chars().count() > MAX_NAME => Err(bad(cmd, format!("`{key}` is longer than {MAX_NAME} characters"))), + Some(v) => Ok(v.to_string()), + None => Err(bad(cmd, format!("missing `{key}`"))), + } +} + +/// `key`'s value as a name, or `None` when it is missing or empty. +fn optional_name(p: &Value, key: &str, cmd: &str) -> Result> { + match str_param(p, key).map(str::trim).filter(|v| !v.is_empty()) { + Some(v) => named(Some(v), key, cmd).map(Some), + None => Ok(None), + } +} + +/// The `names` of a delete, or without them `fallback` (what the panel acts on) as the one name. +fn names_param(p: &Value, cmd: &str, fallback: Option, nothing: &str) -> Result> { + let Some(given) = p.get("names") else { + return fallback.map(|n| vec![n]).ok_or_else(|| bad(cmd, nothing.to_string())); + }; + match given.as_array() { + Some(names) if !names.is_empty() => names.iter().map(|n| named(n.as_str(), "names", cmd)).collect(), + _ => Err(bad(cmd, "`names` must be a list of names")), + } +} + +/// The objects a bind acts on: `ids` when given (every one checked), else the selection. +fn target_ids(s: &Session, p: &Value, cmd: &str) -> Result> { + if p.get("ids").is_some() { checked_ids_param(s, p, "ids", cmd) } else { selected_roots(s) } +} + +/// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked +/// layer is not the document's to rewrite, and a text variable only drives type. +fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> { + for id in ids { + match doc.node(*id) { + None => return Err(EngineError::NoNode(*id)), + Some(_) if !doc.variable_target(*id) => return Err(bad(cmd, format!("node {} is locked", id.0))), + Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => { + return Err(bad(cmd, format!("node {} is not type", id.0))); + } + Some(_) => {} + } + } + Ok(()) +} + +fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result { + match kind { + VariableKind::Text => v.as_str().map(|t| DataValue::Text(t.to_string())), + VariableKind::Visibility => v.as_bool().map(DataValue::Visible), + } + .ok_or_else(|| bad(cmd, format!("value for `{var}` must match its variable kind"))) +} + +/// 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> { + 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 = 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) +} + +fn define(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.define"; + let name = var_name(p, C)?; + let kind = str_param(p, "kind").and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text` or `visibility`"))?; + if s.doc()?.doc.variables.variable(&name).is_some() { + return Err(bad(C, format!("variable `{name}` is already defined"))); + } + s.edit("New Variable", |d, _| { + d.variables.variables.push(Variable { name: name.clone(), kind }); + Ok(()) + })?; + Ok(json!({"name": name, "kind": kind.label()})) +} + +fn rename(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.rename"; + let from = var_name(p, C)?; + let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?; + let st = s.doc()?; + if st.doc.variables.variable(&to).is_some() { + return Err(bad(C, format!("variable `{to}` already exists"))); + } + s.edit("Rename Variable", |d, _| { + let v = d.variables.variables.iter_mut().find(|v| v.name == from).ok_or_else(|| bad(C, format!("no variable named `{from}`")))?; + v.name = to.clone(); + for bound in d.variables.bindings.values_mut() { + if bound.remove(&from) { + bound.insert(to.clone()); + } + } + for ds in &mut d.variables.datasets { + if let Some(value) = ds.values.remove(&from) { + ds.values.insert(to.clone(), value); + } + } + Ok(()) + })?; + // The highlight names a variable, so it follows the rename rather than going stale. + let st = s.doc_mut()?; + if st.variables_highlight.as_deref() == Some(from.as_str()) { + st.variables_highlight = Some(to.clone()); + } + st.revision += 1; + Ok(json!({"from": from, "name": to})) +} + +/// Make Text Dynamic / Make Visibility Dynamic: define a variable of that kind and bind the +/// selection to it in one step, naming it after what it drives — the panel's buttons. +fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result { + const C: &str = "variable.makeDynamic"; + let ids = selected_roots(s)?; + if ids.is_empty() { + return Err(bad(C, "select the objects to bind")); + } + let doc = &s.doc()?.doc; + check_bindable(doc, &ids, kind, C)?; + // Named after the first object it drives, as the reference panel does; a second variable + // over the same art takes a number rather than colliding. + let shown = ids.first().and_then(|id| doc.node(*id)).map(|n| n.display_name()).unwrap_or_default(); + let base: String = match shown.trim() { + "" => stem.to_string(), + b => b.chars().take(MAX_NAME - 8).collect(), + }; + let mut name = base.clone(); + let mut n = 2; + while doc.variables.variable(&name).is_some() { + name = format!("{base} {n}"); + n += 1; + } + s.edit("Make Dynamic", |d, _| { + d.variables.variables.push(Variable { name: name.clone(), kind }); + for id in &ids { + d.variables.bind(*id, &name); + } + Ok(()) + })?; + Ok(json!({"name": name, "kind": kind.label(), "ids": ids.iter().map(|i| i.0).collect::>()})) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + // Without `names`, the highlighted variable: what the panel's bin and the menu item delete. + let highlighted = s.doc()?.variables_highlight.clone(); + let names = names_param(p, "variable.delete", highlighted, "no variable highlighted")?; + let n = s.edit("Delete Variables", |d, _| Ok(d.variables.prune(&names)))?; + // A highlight of a variable that is gone highlights nothing. + let st = s.doc_mut()?; + if st.variables_highlight.as_ref().is_some_and(|h| st.doc.variables.variable(h).is_none()) { + st.variables_highlight = None; + } + st.revision += 1; + Ok(json!({"deleted": n})) +} + +fn list(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let variables: Vec = st + .doc + .variables + .variables + .iter() + .map(|v| { + let bindings = st.doc.variables.objects_of(&v.name).len(); + json!({"name": v.name, "kind": v.kind.label(), "bindings": bindings}) + }) + .collect(); + Ok(json!({"variables": variables})) +} + +fn bind(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.bind"; + let name = named(str_param(p, "variable"), "variable", C)?; + let ids = target_ids(s, p, C)?; + if ids.is_empty() { + return Err(bad(C, "nothing to bind")); + } + 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}`")))?; + check_bindable(doc, &ids, kind, C)?; + // Binding again is not a second binding, and says so rather than counting as a change. + let already: Vec = ids.iter().filter(|id| doc.variables.objects_of(&name).contains(id)).map(|id| id.0).collect(); + let n = s.edit("Bind Variable", |d, _| Ok(ids.iter().filter(|id| d.variables.bind(**id, &name)).count()))?; + Ok(json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::>(), "bound": n, "already": already})) +} + +fn unbind(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.unbind"; + let ids = target_ids(s, p, C)?; + let name = defined_variable(s, p, C)?; + let n = s.edit("Unbind Variable", |d, _| { + let mut n = 0; + for id in &ids { + // With `variable` only that one binding goes; without it, every one the object has. + match &name { + Some(v) => n += usize::from(d.variables.unbind(*id, v)), + None => n += usize::from(d.variables.bindings.remove(id).is_some()), + } + } + Ok(n) + })?; + Ok(json!({"unbound": n})) +} + +/// The `variable` a command names, which must be defined; `None` when it names none. +fn defined_variable(s: &Session, p: &Value, cmd: &str) -> Result> { + let name = optional_name(p, "variable", cmd)?; + if let Some(v) = &name + && s.doc()?.doc.variables.variable(v).is_none() + { + return Err(bad(cmd, format!("no variable named `{v}`"))); + } + Ok(name) +} + +/// Highlight one Variables panel row: what the panel's Delete, Options… and Select Bound +/// Object act on. Panel state, not art selection: not saved, not an undo step (an empty +/// or missing `variable` clears it), like `layer.highlight`. +fn highlight(s: &mut Session, p: &Value) -> Result { + let name = defined_variable(s, p, "variable.highlight")?; + let st = s.doc_mut()?; + st.variables_highlight = name.clone(); + st.revision += 1; + Ok(json!({"variable": name})) +} + +fn dataset_new(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.new"; + let name = var_name(p, C)?; + 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(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 { + 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 = 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_rename(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.rename"; + let from = var_name(p, C)?; + let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?; + let st = s.doc()?; + if st.doc.variables.dataset(&to).is_some() { + return Err(bad(C, format!("dataset `{to}` already exists"))); + } + s.edit("Rename Data Set", |d, _| { + let ds = d.variables.datasets.iter_mut().find(|d| d.name == from).ok_or_else(|| bad(C, format!("no dataset named `{from}`")))?; + ds.name = to.clone(); + if d.variables.active_dataset.as_deref() == Some(from.as_str()) { + d.variables.active_dataset = Some(to.clone()); + } + Ok(()) + })?; + Ok(json!({"from": from, "name": to})) +} + +/// Capture Data Set: record what the bound objects hold right now as a new row. This, rather +/// than typing values, is how rows are made — change the art, then capture it. +fn dataset_capture(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.capture"; + let st = s.doc()?; + let values = st.doc.captured_values(); + if values.is_empty() { + return Err(bad(C, "bind something to a variable first")); + } + let name = match optional_name(p, "name", C)? { + Some(n) => n, + // "Data Set 1", the first free number, as the reference panel's rows are named. + None => (1..).map(|i| format!("Data Set {i}")).find(|n| st.doc.variables.dataset(n).is_none()).unwrap_or_default(), + }; + if s.doc()?.doc.variables.dataset(&name).is_some() { + return Err(bad(C, format!("dataset `{name}` already exists"))); + } + s.edit("Capture Data Set", |d, _| { + d.variables.datasets.push(DataSet { name: name.clone(), values: values.clone() }); + d.variables.active_dataset = Some(name.clone()); + Ok(()) + })?; + Ok(json!({"name": name, "values": values.len()})) +} + +/// Update Data Set: write what the bound objects hold now into the row that is active. Without +/// an active row there is nothing to update, so say so rather than picking one. +fn dataset_update(s: &mut Session, _p: &Value) -> Result { + const C: &str = "dataset.update"; + let values = s.doc()?.doc.captured_values(); + let Some(name) = s.doc()?.doc.variables.active_dataset.clone() else { + return Err(bad(C, "no active data set")); + }; + if values.is_empty() { + return Err(bad(C, "bind something to a variable first")); + } + s.edit("Update Data Set", |d, _| { + let ds = d.variables.datasets.iter_mut().find(|d| d.name == name).ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?; + ds.values = values.clone(); + Ok(()) + })?; + Ok(json!({"name": name, "values": values.len()})) +} + +fn dataset_delete(s: &mut Session, p: &Value) -> Result { + // Without `names`, the active dataset: what the panel's Delete Data Set deletes. + let active = s.doc()?.doc.variables.active_dataset.clone(); + let names = names_param(p, "dataset.delete", active, "no active data set")?; + 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; + } + Ok(before - d.variables.datasets.len()) + })?; + Ok(json!({"deleted": n})) +} + +fn dataset_list(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let current = st.doc.captured_values(); + let datasets: Vec = st + .doc + .variables + .datasets + .iter() + // `matches` says whether the art still holds what the row says, which is what the + // panel shows in italics. + .map(|d| json!({"name": d.name, "values": d.values, "matches": d.matches(¤t)})) + .collect(); + Ok(json!({"datasets": datasets, "active": st.doc.variables.active_dataset})) +} + +fn apply_dataset(s: &mut Session, name: &str) -> Result { + const C: &str = "dataset.select"; + 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}`")))?; + // One object may be driven by several variables; walk them in definition order, so two + // text variables on the same object land the later one's value and both are counted. + let mut pairs: Vec<(NodeId, String)> = Vec::new(); + for v in &d.variables.variables { + pairs.extend(d.variables.objects_of(&v.name).into_iter().map(|id| (id, v.name.clone()))); + } + let mut applied = 0usize; + let mut skipped = 0usize; + for (id, var) in pairs { + // 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()), + _ => { + skipped += 1; + continue; + } + }; + // Locked art, art on a locked or hidden layer, and text whose object is gone, can't + // be rewritten: skipped and counted, like the rest. A hidden object is rewritten: a + // row that hid it shows it again. + if !d.variable_target(id) { + skipped += 1; + continue; + } + let done = match (kind, &value) { + // 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 { + applied += 1; + } else { + skipped += 1; + } + } + d.variables.active_dataset = Some(name.to_string()); + Ok((applied, skipped)) + })?; + Ok(json!({"name": name, "applied": applied, "skipped": skipped})) +} + +fn dataset_select(s: &mut Session, p: &Value) -> Result { + apply_dataset(s, &var_name(p, "dataset.select")?) +} + +fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result { + let st = s.doc()?; + let n = st.doc.variables.datasets.len(); + if n == 0 { + return Err(bad(cmd, "no datasets")); + } + 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) +} + +pub fn specs() -> Vec { + vec![ + 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 (default: the highlighted one)", + has_doc, + delete + ), + cmd!( + "variable.rename", + "Rename Variable", + ["Window", "Variables"], + None, + "{name, newName} rename a variable, with its bindings and its values in every dataset", + has_doc, + rename + ), + cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list), + cmd!( + "variable.makeTextDynamic", + "Make Text Dynamic", + ["Window", "Variables"], + None, + "{} define a text variable over the selection, named after it and bound to it", + has_selection, + |s, _| make_dynamic(s, VariableKind::Text, "Text") + ), + cmd!( + "variable.makeVisibilityDynamic", + "Make Visibility Dynamic", + ["Window", "Variables"], + None, + "{} define a visibility variable over the selection, named after it and bound to it", + has_selection, + |s, _| make_dynamic(s, VariableKind::Visibility, "Visibility") + ), + cmd!( + "variable.bind", + "Bind Variable", + ["Window", "Variables"], + None, + "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, locked art is refused. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}", + has_doc, + bind + ), + cmd!( + "variable.unbind", + "Unbind Variable", + ["Window", "Variables"], + None, + "{ids?, variable?} drop bindings (default: the selection): `variable` drops that one, else every variable the object has", + has_doc, + unbind + ), + cmd!( + "variable.highlight", + "Highlight Variable", + [], + None, + "{variable?} highlight one Variables panel row (a click); what the panel's Delete, Options… and Select Bound Object act on. An empty or missing `variable` clears it. Not an undo step → {variable}", + has_doc, + highlight + ), + 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.rename", "Rename Data Set", ["Window", "Variables"], None, "{name, newName} rename a dataset", has_doc, dataset_rename), + cmd!( + "dataset.capture", + "Capture Data Set", + ["Window", "Variables"], + None, + "{name?} record what the bound objects hold right now as a new dataset (Data Set 1…), and make it the active one", + has_doc, + dataset_capture + ), + cmd!( + "dataset.update", + "Update Data Set", + ["Window", "Variables"], + None, + "{} write what the bound objects hold now into the active dataset", + has_doc, + dataset_update + ), + cmd!( + "dataset.delete", + "Delete Data Set", + ["Window", "Variables"], + None, + "{names?} delete datasets (default: the active one)", + has_doc, + dataset_delete + ), + cmd!( + query "dataset.list", + "List Data Sets", + [], + None, + "{} → datasets with their values, the active one, and which rows the art still matches", + 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.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) + }), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8a964610..a0463b71 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -86,6 +86,7 @@ pub struct Interaction { /// isolation). pub active_layer: Option, pub layer_rows: Vec, + pub variables_highlight: Option, pub isolation: Option, /// The perspective transform the previews make (`perspective.transform` params): Transform /// Again repeats it once the drag is committed. @@ -121,6 +122,10 @@ pub struct DocState { /// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`, /// `layer.highlight`). pub layer_rows: Vec, + /// The variable highlighted in the Variables panel: what its Delete, Options… and Select + /// Bound Object act on. Panel state, not art selection: not saved, not undoable + /// (`variable.highlight`). A name, so renaming a variable moves the highlight with it. + pub variables_highlight: Option, /// Isolation mode container. pub isolation: Option, pub interaction: Option, @@ -236,6 +241,7 @@ impl DocState { revision: 1, active_layer, layer_rows: vec![], + variables_highlight: None, isolation: None, interaction: None, undo_group: None, @@ -300,6 +306,7 @@ impl DocState { self.selection = it.selection; self.active_layer = it.active_layer; self.layer_rows = it.layer_rows; + self.variables_highlight = it.variables_highlight; self.isolation = it.isolation; self.revision += 1; } @@ -1226,6 +1233,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 { @@ -1290,6 +1301,7 @@ impl Session { preview: None, active_layer: st.active_layer, layer_rows: st.layer_rows.clone(), + variables_highlight: st.variables_highlight.clone(), isolation: st.isolation, perspective_again: None, }); @@ -1663,6 +1675,8 @@ mod tests_typearea; #[cfg(test)] mod tests_units; #[cfg(test)] +mod tests_variables; +#[cfg(test)] mod tests_webexport; #[cfg(test)] mod tests_widthpoints; diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs new file mode 100644 index 00000000..350905c2 --- /dev/null +++ b/crates/engine/src/tests_variables.rs @@ -0,0 +1,591 @@ +//! Variables (data merge): definitions with kinds, bindings to objects, datasets +//! applied in one undo step, and validation that rejects instead of reinterpreting. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, content: &str) -> NodeId { + let r = s.execute("text.create", &json!({"x": 10, "y": 10, "text": content})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn plain(s: &Session, id: NodeId) -> String { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + vectorcraft_doc::NodeKind::Text(t) => t.plain_text(), + _ => panic!("not text"), + } +} + +#[test] +fn text_and_visibility_datasets_apply_in_one_undo_step() { + let mut s = session(); + let t = text(&mut s, "Alice"); + 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": "B", "values": {"Name": "Bob", "Show": false}})).unwrap(); + + let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0))); + assert_eq!(plain(&s, t), "Bob"); + assert!(!s.doc().unwrap().doc.node(r).unwrap().visible); + + // One undo step covers the whole dataset application. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "Alice"); + assert!(s.doc().unwrap().doc.node(r).unwrap().visible); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "Bob"); +} + +#[test] +fn dataset_next_and_prev_wrap_around() { + let mut s = session(); + let t = text(&mut s, "x"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + for name in ["A", "B"] { + s.execute("dataset.new", &json!({"name": name, "values": {"Name": name}})).unwrap(); + } + s.execute("dataset.next", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "A"); + s.execute("dataset.next", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "B"); + s.execute("dataset.next", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "A"); + s.execute("dataset.prev", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "B"); + let v = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(v["active"], "B"); +} + +#[test] +fn mismatched_and_missing_bindings_are_skipped_and_counted() { + 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(); + // A text variable bound to a rectangle applies to nothing; a dataset value for an + // unbound variable is ignored. + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Ghost": "z"}})).unwrap_err(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y"}})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [r.0]})).unwrap_err(); + let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0))); + assert_eq!(plain(&s, t), "y"); +} + +#[test] +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(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y"}})).unwrap(); + s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + s.execute("variable.delete", &json!({"names": ["Name"]})).unwrap(); + let vars = s.execute("variable.list", &json!({})).unwrap(); + 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(); + assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(0)); + assert!(sets["active"].is_null()); + let v: Value = serde_json::from_str(&serde_json::to_string(&s.doc().unwrap().doc).unwrap()).unwrap(); + 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 may be driven by several variables, so binding it again adds to what it has +/// instead of quietly dropping the earlier one. +#[test] +fn one_object_can_be_driven_by_several_variables() { + let mut s = session(); + let t = text(&mut s, "x"); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Badge", "kind": "visibility"})).unwrap(); + + // A text object driven by two text variables keeps both. + assert_eq!(s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap()["bound"], 1); + let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap(); + assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(1), json!([])), "Title is untouched"); + // A rectangle driven by a visibility variable as well as nothing else yet. + s.execute("variable.bind", &json!({"variable": "Badge", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Title": "Hat", "Price": "9.99", "Badge": false}})).unwrap(); + + let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(3), Some(0)), "every binding applied"); + // Both text variables are counted; the later one in definition order is what is left. + assert_eq!(plain(&s, t), "9.99"); + assert!(!s.doc().unwrap().doc.node(r).unwrap().visible); + + 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("Title"), of("Price"), of("Badge")), (Some(1), Some(1), Some(1))); + + // Binding again to one it already has is not a change, and says so. + let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap(); + assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(0), json!([t.0]))); + // Unbinding one variable leaves the object's others alone. + assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Price"})).unwrap()["unbound"], 1); + s.execute("edit.undo", &json!({})).unwrap(); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"][1]["bindings"], 1, "the other binding is still there"); + assert!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Ghost"})).is_err()); + + // Deleting a variable drops only its own binding. + s.execute("variable.delete", &json!({"names": ["Price"]})).unwrap(); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"][0]["bindings"], 1, "Title still drives the text"); +} + +/// `unbound` counts what was actually dropped, like `variable.delete` does. +#[test] +fn unbind_reports_what_it_actually_removed() { + let mut s = session(); + let t = text(&mut s, "x"); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap(); + assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap()["unbound"], 1); + assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap()["unbound"], 0, "already gone"); + // Without `variable` the object's last binding goes and the entry with it. + 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); + // An object with no binding left is not kept as an empty set. + let v: Value = serde_json::from_str(&serde_json::to_string(&s.doc().unwrap().doc).unwrap()).unwrap(); + assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({})), "{v}"); +} + +/// 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"); +} + +/// Make Text Dynamic / Make Visibility Dynamic: what the panel's buttons run. One step from a +/// selection to a bound variable, named after what it drives. +#[test] +fn making_a_selection_dynamic_names_and_binds_it() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + let v = s.execute("variable.makeTextDynamic", &json!({})).unwrap(); + assert_eq!(v["name"], "Alice", "named after the text it drives"); + assert_eq!(v["ids"], json!([t.0])); + + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let v = s.execute("variable.makeVisibilityDynamic", &json!({})).unwrap(); + assert_eq!(v["kind"], "visibility"); + + // The same object twice takes a number rather than colliding with the first variable. + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + let again = s.execute("variable.makeTextDynamic", &json!({})).unwrap(); + assert_eq!(again["name"], "Alice 2"); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"].as_array().map(Vec::len), Some(3)); + + // Nothing selected, art that can't take the kind, and locked art are errors. + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("variable.makeTextDynamic", &json!({})).is_err()); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + assert!(s.execute("variable.makeTextDynamic", &json!({})).is_err(), "a rectangle is not type"); +} + +/// Capture Data Set records what the art holds now, which is how rows are made — the art is +/// edited and captured, rather than the values typed. +#[test] +fn capturing_records_what_the_art_holds_now() { + let mut s = session(); + let t = text(&mut s, "Alice"); + 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(); + + let v = s.execute("dataset.capture", &json!({})).unwrap(); + assert_eq!((v["name"].clone(), v["values"].clone()), (json!("Data Set 1"), json!(2))); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["values"]["Name"], json!({"text": "Alice"})); + assert_eq!(sets["datasets"][0]["values"]["Show"], json!({"visible": true})); + assert_eq!(sets["active"], "Data Set 1"); + + // Change the art, capture again: a second row, and the first still matches its own text. + s.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap(); + assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["matches"], json!(false), "the art moved on"); + s.execute("dataset.capture", &json!({})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][1]["name"], "Data Set 2"); + assert_eq!(sets["datasets"][1]["values"]["Name"], json!({"text": "Bob"})); + assert_eq!(sets["datasets"][0]["matches"], json!(false), "row 1 still describes the old text"); + + // A named capture, and a name that is taken is an error rather than a second row. + s.execute("dataset.capture", &json!({"name": "Winter"})).unwrap(); + assert!(s.execute("dataset.capture", &json!({"name": "Winter"})).is_err()); + + // Nothing bound is nothing to capture. + let mut empty = session(); + empty.execute("variable.define", &json!({"name": "V", "kind": "text"})).unwrap(); + assert!(empty.execute("dataset.capture", &json!({})).is_err()); +} + +/// Update Data Set writes the art's current values into the row that is active. +#[test] +fn updating_writes_the_art_into_the_active_row() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + + // No active row yet: say so rather than quietly writing into some other one. + assert!(s.execute("dataset.update", &json!({})).is_err()); + s.execute("dataset.capture", &json!({})).unwrap(); + s.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap(); + let v = s.execute("dataset.update", &json!({})).unwrap(); + assert_eq!((v["name"].clone(), v["values"].clone()), (json!("Data Set 1"), json!(1))); + assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["matches"], json!(true)); + // One undo step puts the row back as it was. + s.execute("text.editRange", &json!({"id": t.0, "insert": "Caz"})).unwrap(); + s.execute("dataset.update", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "Caz"); +} + +/// Renaming carries the variable's bindings and its values in every row with it — a name is +/// how a variable is referred to, so leaving the old one behind would orphan both. +#[test] +fn renaming_carries_the_bindings_and_values_with_it() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "Bob"}})).unwrap(); + s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + + s.execute("variable.rename", &json!({"name": "Name", "newName": "Title"})).unwrap(); + assert_eq!(plain(&s, t), "Bob", "the row still applied"); + assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["values"]["Title"], json!({"text": "Bob"})); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!((vars["variables"][0]["name"].clone(), vars["variables"][0]["bindings"].clone()), (json!("Title"), json!(1))); + + // Renaming a dataset keeps it active and keeps its values. + s.execute("dataset.rename", &json!({"name": "D", "newName": "Row 1"})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!((sets["active"].clone(), sets["datasets"][0]["name"].clone()), (json!("Row 1"), json!("Row 1"))); + + // Taken and missing names are errors. + s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap(); + assert!(s.execute("variable.rename", &json!({"name": "Title", "newName": "Other"})).is_err()); + assert!(s.execute("variable.rename", &json!({"name": "Ghost", "newName": "X"})).is_err()); + assert!(s.execute("dataset.rename", &json!({"name": "Ghost", "newName": "X"})).is_err()); + // A dataset and a variable are separate namespaces: a dataset may take a variable's name, + // and a variable may take a dataset's. + s.execute("dataset.rename", &json!({"name": "Row 1", "newName": "Title"})).unwrap(); + assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["active"], "Title"); + s.execute("dataset.new", &json!({"name": "Taken"})).unwrap(); + assert!(s.execute("dataset.rename", &json!({"name": "Title", "newName": "Taken"})).is_err()); +} + +/// `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(); + s.execute("variable.define", &json!({"name": "N", "kind": "text"})).unwrap(); + for (cmd, params) in [ + ("variable.define", json!({})), + ("variable.define", json!({"name": "", "kind": "text"})), + ("variable.define", json!({"name": "N", "kind": "colour"})), + ("variable.define", json!({"name": "N", "kind": "text"})), + ("variable.delete", json!({})), + ("variable.bind", json!({})), + ("variable.bind", json!({"variable": "N"})), + ("variable.bind", json!({"variable": "Missing", "ids": [1]})), + ("dataset.new", json!({})), + ("dataset.new", json!({"name": "D", "values": []})), + ("dataset.new", json!({"name": "D", "values": {"N": 5}})), + ("dataset.select", json!({"name": "Missing"})), + ("dataset.next", json!({})), + ] { + assert!(s.execute(cmd, ¶ms).is_err(), "{cmd} {params}"); + } +} + +#[test] +fn highlight_selects_without_unbinding_and_deletes_leave_the_rest() { + // The reviewer's regression case: two variables, two rows. + let mut s = session(); + let a = text(&mut s, "Alice"); + let b = text(&mut s, "Bob"); + for (name, id) in [("Name", a), ("Other", b)] { + s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": name, "ids": [id.0]})).unwrap(); + } + s.execute("dataset.new", &json!({"name": "One", "values": {"Name": "Alice", "Other": "Bob"}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Two", "values": {"Name": "A2", "Other": "B2"}})).unwrap(); + s.execute("dataset.select", &json!({"name": "One"})).unwrap(); + + // What a row click runs (highlight), then what Select Bound Object runs (select). + s.execute("variable.highlight", &json!({"variable": "Name"})).unwrap(); + let bound: Vec = s.doc().unwrap().doc.variables.objects_of("Name").iter().map(|i| i.0).collect(); + s.execute("select.set", &json!({"ids": bound})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + // …and the binding is still there: selecting never unbinds. + assert_eq!(s.doc().unwrap().doc.variables.objects_of("Name"), vec![a]); + + // Deleting one variable leaves the other; deleting the current dataset leaves the other. + s.execute("variable.delete", &json!({"names": ["Name"]})).unwrap(); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"].as_array().unwrap().len(), 1); + assert_eq!(vars["variables"][0]["name"], "Other"); + s.execute("dataset.delete", &json!({"names": ["One"]})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"].as_array().unwrap().len(), 1); + assert_eq!(sets["datasets"][0]["name"], "Two"); +} + +#[test] +fn highlight_follows_rename_and_clears_with_delete() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + assert!(s.execute("variable.highlight", &json!({"variable": "Missing"})).is_err(), "unknown names stay errors"); + let v = s.execute("variable.highlight", &json!({"variable": "Name"})).unwrap(); + assert_eq!(v["variable"], "Name"); + // The highlight names a variable, so the rename carries it along. + s.execute("variable.rename", &json!({"name": "Name", "newName": "Title"})).unwrap(); + assert_eq!(s.doc().unwrap().variables_highlight.as_deref(), Some("Title")); + // Empty clears it, and deleting the highlighted variable clears it too. + s.execute("variable.highlight", &json!({})).unwrap(); + assert!(s.doc().unwrap().variables_highlight.is_none()); + s.execute("variable.highlight", &json!({"variable": "Title"})).unwrap(); + s.execute("variable.delete", &json!({"names": ["Title"]})).unwrap(); + assert!(s.doc().unwrap().variables_highlight.is_none(), "a highlight of a deleted variable highlights nothing"); +} + +/// A row that hides an object must be able to show it again, and hiding it on the artboard +/// (as the Layers panel does) is what a capture records: a visibility variable reads and +/// writes its object's own visibility, which therefore never makes the object off limits. +#[test] +fn a_hidden_object_comes_back_and_is_captured_hidden() { + let mut s = session(); + let t = text(&mut s, "Alice"); + 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, t.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "Shown", "values": {"Name": "Alice", "Show": true}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Hidden", "values": {"Name": "Bob", "Show": false}})).unwrap(); + + s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap(); + assert!(!s.doc().unwrap().doc.node(r).unwrap().visible); + let v = s.execute("dataset.select", &json!({"name": "Shown"})).unwrap(); + assert_eq!(v["skipped"], json!(0), "{v}"); + assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "the row shows it again"); + assert_eq!(plain(&s, t), "Alice", "and rewrites the text of the object it had hidden"); + + // Hidden on the artboard, then captured: the row says hidden, and it matches the art. + s.execute("select.set", &json!({"ids": [t.0, r.0]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + s.execute("dataset.capture", &json!({"name": "Captured"})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + let captured = sets["datasets"].as_array().unwrap().iter().find(|d| d["name"] == "Captured").unwrap().clone(); + assert_eq!(captured["values"]["Show"], json!({"visible": false}), "{captured}"); + assert_eq!(captured["matches"], json!(true)); + + // A hidden object can still be bound by id. + s.execute("variable.define", &json!({"name": "More", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "More", "ids": [r.0]})).unwrap(); +} + +/// Variables and datasets are separate namespaces: renaming a variable leaves a dataset of +/// the same name, and which dataset is active, alone. +#[test] +fn renaming_a_variable_leaves_a_dataset_of_the_same_name_active() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap(); + s.execute("dataset.capture", &json!({"name": "Title"})).unwrap(); + s.execute("variable.rename", &json!({"name": "Title", "newName": "Heading"})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!((sets["active"].clone(), sets["datasets"][0]["name"].clone()), (json!("Title"), json!("Title"))); + s.execute("dataset.update", &json!({})).unwrap(); +} + +/// Without `names`, the deletes act on what the panel shows as current: the highlighted +/// variable and the active dataset (Window › Variables runs them with no params). +#[test] +fn deletes_without_names_take_the_highlight_and_the_active_dataset() { + let mut s = session(); + let t = text(&mut s, "Alice"); + for name in ["A", "B"] { + s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap(); + } + s.execute("variable.bind", &json!({"variable": "A", "ids": [t.0]})).unwrap(); + s.execute("dataset.capture", &json!({})).unwrap(); + s.execute("dataset.capture", &json!({})).unwrap(); + assert!(s.execute("variable.delete", &json!({})).is_err(), "nothing highlighted"); + + s.execute("variable.highlight", &json!({"variable": "B"})).unwrap(); + assert_eq!(s.execute("variable.delete", &json!({})).unwrap()["deleted"], json!(1)); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"].as_array().map(Vec::len), Some(1)); + assert_eq!(vars["variables"][0]["name"], "A"); + + assert_eq!(s.execute("dataset.delete", &json!({})).unwrap()["deleted"], json!(1)); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(1)); + assert_eq!(sets["datasets"][0]["name"], "Data Set 1"); + assert!(s.execute("dataset.delete", &json!({})).is_err(), "no active dataset left"); +} + +/// Ids and names from an agent are checked, never reinterpreted: an `ids` that isn't a list +/// of objects is an error rather than the selection, and names are trimmed and capped. +#[test] +fn ids_and_names_from_an_agent_are_checked() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("variable.define", &json!({"name": " Name ", "kind": "text"})).unwrap(); + assert_eq!(s.execute("variable.list", &json!({})).unwrap()["variables"][0]["name"], "Name"); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + for params in + [json!({"variable": "Name", "ids": 5}), json!({"variable": "Name", "ids": [t.0, "x"]}), json!({"variable": "Name", "ids": [999_999]})] + { + assert!(s.execute("variable.bind", ¶ms).is_err(), "{params}"); + } + assert!(s.doc().unwrap().doc.variables.objects_of("Name").is_empty(), "nothing was bound"); + assert!(s.execute("variable.unbind", &json!({"ids": "all"})).is_err()); + assert!(s.execute("variable.define", &json!({"name": "x".repeat(256), "kind": "text"})).is_err()); + s.execute("variable.define", &json!({"name": "x".repeat(255), "kind": "text"})).unwrap(); + assert!(s.execute("variable.delete", &json!({"names": []})).is_err()); + assert!(s.execute("variable.delete", &json!({"names": [5]})).is_err()); +} diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index e3fb4ea7..d79615cd 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -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 diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index e67461f9..78e7fd8b 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -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)), diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs new file mode 100644 index 00000000..5e1c0f87 --- /dev/null +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -0,0 +1,388 @@ +//! 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"]; + +/// What the dialog shows for each of [`KINDS`]. +fn kind_labels() -> [&'static str; 2] { + [tl!("Text"), tl!("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.rename" => "Variable Options", + "variable.bind" => "Bind Variable", + "dataset.new" => "New Data Set", + "dataset.set" => "Edit Data Set", + "dataset.rename" => "Rename Data Set", + "dataset.select" => "Select Data Set", + _ => return None, + }) +} + +/// The row `id` renames, which the dialog offers as a dropdown when the document has more than +/// one. Variable Options renames the one the panel has highlighted, so it starts there. +fn rename_of(id: &str) -> Option<&'static str> { + Some(match id { + "variable.rename" => "variable", + "dataset.rename" => "name", + _ => return None, + }) +} + +/// Open `id`'s dialog: OK runs the command with the fields below. `preselect` names the row +/// a rename starts on (the panel's highlighted variable, the active dataset); without it, or +/// when it no longer exists, the first row — the dialog still offers the dropdown. +pub fn open(app: &mut VectorcraftApp, id: &str, preselect: Option<&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!("")); + } + _ => { + // A rename: which row, and its new name. + let key = rename_of(id).unwrap_or("name"); + let names: Vec<&str> = if key == "variable" { + vars.variables.iter().map(|v| v.name.as_str()).collect() + } else { + vars.datasets.iter().map(|d| d.name.as_str()).collect() + }; + if names.is_empty() { + app.status(if key == "variable" { tl!("no variable to rename").to_string() } else { tl!("no data set to rename").to_string() }); + return true; + } + fields.insert(key.into(), json!(preselect.filter(|p| names.contains(p)).or(names.first().copied()).unwrap_or_default())); + fields.insert("newName".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>, out: &mut serde_json::Map) { + 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, tl!("No document")); + return false; + }; + let vars = &st.doc.variables; + 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")); + // The kinds by their translated names; the field keeps the command's own word. + let labels = kind_labels(); + let shown = KINDS.iter().position(|k| *k == d.str("kind")).and_then(|i| labels.get(i)).copied().unwrap_or_default(); + if let Some(kind) = widgets::dropdown_names(ui, ("variables-dd", "kind"), shown, &labels, 220.0).and_then(|i| KINDS.get(i)) { + d.fields.insert("kind".into(), json!(kind)); + } + 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(); + } + // A rename: which row, and its new name. + "variable.rename" | "dataset.rename" => { + let (key, label_text, names) = if d.str("__command") == "variable.rename" { + ("variable", tl!("Variable").to_string(), &variable_names) + } else { + ("name", tl!("Data Set").to_string(), &dataset_names) + }; + label(ui, &label_text); + dropdown(ui, d, key, names, 220.0); + ui.end_row(); + label(ui, tl!("New Name")); + text(ui, d, "newName", 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, tl!("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 the document's names `options` (shown as they are, never translated) 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_names(ui, ("variables-dd", key), shown, &labels, width) { + // The empty option (0) empties the field; the rest are `options`, one along. + d.fields.insert(key.into(), json!(i.checked_sub(1).and_then(|i| options.get(i)).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 { + let cmd = d.str("__command"); + let name = d.str("name"); + let p = match cmd.as_str() { + "variable.define" => json!({"name": name, "kind": d.str("kind")}), + "variable.bind" => json!({"variable": d.str("variable")}), + "variable.rename" => json!({"name": d.str("variable"), "newName": d.str("newName")}), + "dataset.rename" => json!({"name": name, "newName": d.str("newName")}), + "dataset.new" | "dataset.set" => { + let values = app.session.active().map(|st| values_of(d, &st.doc.variables)).unwrap_or_else(|| json!({})); + json!({"name": name, "values": values}) + } + "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", None)); + { + 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", None); + { + 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", None); + { + 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", None); + 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", None), "and it opens nothing"); + assert!(app.ui.dialog.is_none()); + } + + #[test] + fn rename_dialogs_start_on_the_preselected_row() { + let mut app = app_with_variables(); + app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hello"}})).unwrap(); + open(&mut app, "variable.rename", Some("Badge")); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("variable"), "Badge"); + // A preselect the document no longer has falls back to the first row. + open(&mut app, "variable.rename", Some("Gone")); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("variable"), "Title"); + open(&mut app, "dataset.rename", Some("Row 1")); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Row 1"); + } +} diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 21f0a5bb..3080a25c 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -496,12 +496,13 @@ mod tests { const SCREEN: Vec2 = vec2(1400.0, 900.0); - /// Frames of the whole window, 1400 × 900, with pointer input one event a frame (as the - /// control channel's `ui.drag` sends it). + /// Frames of the whole window, with pointer input one event a frame (as the control + /// channel's `ui.drag` sends it). struct Harness { app: VectorcraftApp, ctx: egui::Context, time: f64, + screen: Vec2, } fn button(pos: Pos2, pressed: bool) -> Event { @@ -510,16 +511,23 @@ mod tests { impl Harness { fn new() -> Self { + Self::on(SCREEN) + } + + /// The same window at another size. The icon column scrolls when it is too short for + /// every icon, which hides the ones past the bottom — a test that counts them needs + /// a window tall enough to show them all. + fn on(screen: Vec2) -> Self { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - let mut h = Self { app, ctx: egui::Context::default(), time: 0.0 }; + let mut h = Self { app, ctx: egui::Context::default(), time: 0.0, screen }; h.settle(); h } fn frame(&mut self, events: Vec) { self.time += 0.05; - let screen = Rect::from_min_size(Pos2::ZERO, SCREEN); + let screen = Rect::from_min_size(Pos2::ZERO, self.screen); let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), events, ..Default::default() }; let app = &mut self.app; self.ctx @@ -651,10 +659,13 @@ mod tests { #[test] fn icons_dragged_out_float_stack_onto_another_group_and_tear_out_of_it() { - let mut h = Harness::new(); + // Tall enough that the icon column shows every icon, so counting them says what it + // means: one leaves the column, rather than one more scrolling into view. + let mut h = Harness::on(vec2(1400.0, 1600.0)); // Color, Color Guide, Swatches… top to bottom. let icons = h.icons(); let count = icons.len(); + assert_eq!(count, crate::state::ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum::(), "every group's icons are drawn"); h.drag(icons[2].center(), pos2(500.0, 250.0)); assert_eq!(h.groups(), [["swatches"]]); assert_eq!(h.icons().len(), count - 1, "its icon left the column"); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d5064d0f..bbcc8258 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -534,6 +534,20 @@ Paragraph Styles Styly odstavců Tabs Tabulátory Variables Proměnné + New Variable… Nová proměnná… + 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 + Next Data Set Další sada dat + Previous Data Set Předchozí sada dat + Make Text Dynamic Nastavit text jako dynamický + Make Visibility Dynamic Nastavit viditelnost jako dynamickou + Capture Data Set Pořídit sadu dat + Update Data Set Aktualizovat sadu dat + Rename Data Set… Přejmenovat sadu dat… + Variable Options… Možnosti proměnné… Brush Libraries Knihovny štětců Built-in Libraries Vestavěné knihovny User Defined Uživatelské @@ -678,3 +692,5 @@ Diffuse Glow… Rozptýlená záře… Glass… Sklo… Ocean Ripple… Vlnky na hladině… + Rename Data Set Přejmenovat sadu dat + Rename Variable Přejmenovat proměnnou diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b42a0ddc..58d4344e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3954,3 +3954,43 @@ theme Appearance Erscheinungsbild Frosted Mattiert Tiny Lens Kleine Linsen Canvas Leinwand + Highlight Variable Variable hervorheben + New Variable Neue Variable + Delete Variable Variable löschen + List Variables Variablen auflisten + Bind Variable Variable binden + Unbind Variable Variable lösen + New Data Set Neuer Datensatz + Delete Data Set Datensatz löschen + List Data Sets Datensätze auflisten + Select Data Set Datensatz auswählen + Next Data Set Nächster Datensatz + Previous Data Set Vorheriger Datensatz + Data Sets Datensätze + New Variable… Neue Variable… + New Data Set… Neuer Datensatz… + Edit Data Set… Datensatz bearbeiten… + Data Set Datensatz + Kind Art + Variable Variable + no document kein Dokument + no data set to edit kein Datensatz zum Bearbeiten + Make Text Dynamic Text dynamisch machen + Make Visibility Dynamic Sichtbarkeit dynamisch machen + Capture Data Set Datensatz erfassen + Update Data Set Datensatz aktualisieren + Rename Data Set… Datensatz umbenennen… + Variable Options… Variablenoptionen… + Select Bound Object Gebundenes Objekt auswählen + Select All Bound Objects Alle gebundenen Objekte auswählen + Multiple objects Mehrere Objekte + New Name Neuer Name + no variable to rename keine Variable zum Umbenennen + no data set to rename kein Datensatz zum Umbenennen + Rename Data Set Datensatz umbenennen + Rename Variable Variable umbenennen + Variable Options Variablenoptionen + Edit Data Set Datensatz bearbeiten + Define a variable first Definieren Sie zuerst eine Variable + Text Text + Visibility Sichtbarkeit diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6746a917..d52cf1ef 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1195,6 +1195,7 @@ Highlight Alternate Glyphs Resaltar glifos alternativos Highlight anchors on mouse over Resaltar anclas al pasar el puntero Highlight Rows Resaltar filas + Highlight Variable Resaltar variable Highlight Substituted Fonts Resaltar fuentes sustituidas Highlight Substituted Glyphs Resaltar glifos sustituidos History Historial @@ -3055,6 +3056,38 @@ V: V: Variable Width Profile Perfil de anchura variable Variables Variables + New Variable Nueva variable + Delete Variable Eliminar variable + List Variables Enumerar variables + Bind Variable Vincular variable + Unbind Variable Desvincular variable + New Data Set Nuevo conjunto de datos + Delete Data Set Eliminar conjunto de datos + List Data Sets Enumerar conjuntos de datos + Select Data Set Seleccionar conjunto de datos + Next Data Set Conjunto de datos siguiente + Previous Data Set Conjunto de datos anterior + Data Sets Conjuntos de datos + New Variable… Nueva variable… + New Data Set… Nuevo conjunto de datos… + Edit Data Set… Editar conjunto de datos… + 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 + Make Text Dynamic Hacer texto dinámico + Make Visibility Dynamic Hacer visible dinámico + Capture Data Set Capturar conjunto de datos + Update Data Set Actualizar conjunto de datos + Rename Data Set… Renombrar conjunto de datos… + Variable Options… Opciones de variable… + Select Bound Object Seleccionar objeto vinculado + Select All Bound Objects Seleccionar todos los objetos vinculados + Multiple objects Varios objetos + New Name Nuevo nombre + no variable to rename no hay ninguna variable que renombrar + no data set to rename no hay ningún conjunto de datos que renombrar Variation: Variación: Vector Effects Efectos vectoriales Vector Halftone Semitono vectorial @@ -3997,3 +4030,10 @@ axis Both Ambos Blocks Bloques Frosted Esmerilado Tiny Lens Lente pequeña + Rename Data Set Renombrar conjunto de datos + Rename Variable Renombrar variable + Variable Options Opciones de variable + Edit Data Set Editar conjunto de datos + Define a variable first Defina primero una variable + Text Texto + Visibility Visibilidad diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 27150a15..76796bd1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1194,6 +1194,7 @@ Highlight Alternate Glyphs Surligner les glyphes de substitution Highlight anchors on mouse over Mettre en évidence les points d’ancrage au survol Highlight Rows Surligner les lignes + Highlight Variable Surligner la variable Highlight Substituted Fonts Surligner les polices remplacées Highlight Substituted Glyphs Surligner les glyphes remplacés History Historique @@ -3052,6 +3053,38 @@ V: V : Variable Width Profile Profil de largeur variable Variables Variables + New Variable Nouvelle variable + Delete Variable Supprimer la variable + List Variables Lister les variables + Bind Variable Lier la variable + Unbind Variable Délier la variable + New Data Set Nouveau jeu de données + Delete Data Set Supprimer le jeu de données + List Data Sets Lister les jeux de données + Select Data Set Sélectionner le jeu de données + 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… + New Data Set… Nouveau jeu de données… + Edit Data Set… Modifier le jeu de données… + 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 + Make Text Dynamic Rendre le texte dynamique + Make Visibility Dynamic Rendre la visibilité dynamique + Capture Data Set Capturer le jeu de données + Update Data Set Mettre à jour le jeu de données + Rename Data Set… Renommer le jeu de données… + Variable Options… Options de variable… + Select Bound Object Sélectionner l’objet lié + Select All Bound Objects Sélectionner tous les objets liés + Multiple objects Plusieurs objets + New Name Nouveau nom + no variable to rename aucune variable à renommer + no data set to rename aucun jeu de données à renommer Variation: Variation : Vector Effects Effets vectoriels Vector Halftone Trame de demi-teintes vectorielle @@ -4009,3 +4042,10 @@ effect Soft Doux Blocks Blocs Frosted Dépoli Tiny Lens Petites lentilles + Rename Data Set Renommer le jeu de données + Rename Variable Renommer la variable + Variable Options Options de variable + Edit Data Set Modifier le jeu de données + Define a variable first Définissez d’abord une variable + Text Texte + Visibility Visibilité diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 4ab058c3..a9c328e9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1194,6 +1194,7 @@ Highlight Alternate Glyphs Evidenzia glifi alternativi Highlight anchors on mouse over Evidenzia ancoraggi al passaggio del mouse Highlight Rows Evidenzia righe + Highlight Variable Evidenzia variabile Highlight Substituted Fonts Evidenzia font sostituiti Highlight Substituted Glyphs Evidenzia glifi sostituiti History Cronologia @@ -3055,6 +3056,38 @@ V: V: Variable Width Profile Profilo larghezza variabile Variables Variabili + New Variable Nuova variabile + Delete Variable Elimina variabile + List Variables Elenca variabili + Bind Variable Collega variabile + Unbind Variable Scollega variabile + New Data Set Nuovo set di dati + Delete Data Set Elimina set di dati + List Data Sets Elenca set di dati + Select Data Set Seleziona set di dati + Next Data Set Set di dati successivo + Previous Data Set Set di dati precedente + Data Sets Set di dati + New Variable… Nuova variabile… + New Data Set… Nuovo set di dati… + Edit Data Set… Modifica set di dati… + Data Set Set di dati + Kind Tipo + Variable Variabile + no document nessun documento + no data set to edit nessun set di dati da modificare + Make Text Dynamic Rendi testo dinamico + Make Visibility Dynamic Rendi visibilità dinamica + Capture Data Set Cattura set di dati + Update Data Set Aggiorna set di dati + Rename Data Set… Rinomina set di dati… + Variable Options… Opzioni variabile… + Select Bound Object Seleziona oggetto associato + Select All Bound Objects Seleziona tutti gli oggetti associati + Multiple objects Più oggetti + New Name Novo nome + no variable to rename nessuna variabile da rinominare + no data set to rename nessun set di dati da rinominare Variation: Variazione: Vector Effects Effetti vettoriali Vector Halftone Retino vettoriale @@ -3999,3 +4032,10 @@ effect Regular Normale Blocks Blocchi Frosted Smerigliato Tiny Lens Lente piccola + Rename Data Set Rinomina set di dati + Rename Variable Rinomina variabile + Variable Options Opzioni variabile + Edit Data Set Modifica set di dati + Define a variable first Definisci prima una variabile + Text Testo + Visibility Visibilità diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 28a5c518..6c13d3ae 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -569,6 +569,39 @@ Paragraph Styles 段落スタイル Tabs タブ Variables 変数 + New Variable 新規変数 + Delete Variable 変数を削除 + List Variables 変数一覧 + Bind Variable 変数をバインド + Unbind Variable 変数のバインドを解除 + New Data Set 新規データセット + Delete Data Set データセットを削除 + List Data Sets データセット一覧 + Select Data Set データセットを選択 + Next Data Set 次のデータセット + Previous Data Set 前のデータセット + Data Sets データセット + New Variable… 新規変数… + New Data Set… 新規データセット… + Edit Data Set… データセットを編集… + Data Set データセット + Kind 種類 + Variable 変数 + no document ドキュメントがありません + no data set to edit 編集するデータセットがありません + Make Text Dynamic テキストを動的にする + Make Visibility Dynamic 表示を動的にする + Capture Data Set データセットを取得 + Update Data Set データセットを更新 + Rename Data Set… データセットの名前を変更… + Variable Options… 変数のオプション… + Select Bound Object バインドされたオブジェクトを選択 + Select All Bound Objects すべてのバインドされたオブジェクトを選択 + Multiple objects 複数のオブジェクト + New Name 新しい名前 + Rename Variable 変数の名前を変更 + no variable to rename 名前を変更する変数がありません + no data set to rename 名前を変更するデータセットがありません Brush Libraries ブラシライブラリ Built-in Libraries 内蔵ライブラリ User Defined ユーザー定義 @@ -1415,6 +1448,7 @@ High Performance (discrete) 高パフォーマンス (専用 GPU) Highlight Alternate Glyphs 代替字形をハイライト Highlight Rows 行をハイライト + Highlight Variable 変数をハイライト表示 Highlight Substituted Fonts 置換フォントをハイライト Highlight Substituted Glyphs 置換字形をハイライト Highlight anchors on mouse over マウスオーバー時にアンカーをハイライト @@ -3992,3 +4026,9 @@ effect Vertical 垂直 Blocks ブロック Frosted すりガラス Tiny Lens 小さなレンズ + Rename Data Set データセットの名前を変更 + Variable Options 変数のオプション + Edit Data Set データセットを編集 + Define a variable first 先に変数を定義してください + Text テキスト + Visibility 表示状態 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 19022075..b1526a88 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2530,6 +2530,20 @@ V: V: Variable Width Profile Perfil de Largura Variável Variables Variáveis + New Variable… Nova 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 + Next Data Set Próximo conjunto de dados + Previous Data Set Conjunto de dados anterior + Make Text Dynamic Tornar texto dinâmico + Make Visibility Dynamic Tornar visibilidade dinâmica + Capture Data Set Capturar conjunto de dados + Update Data Set Atualizar conjunto de dados + Rename Data Set… Renomear conjunto de dados… + Variable Options… Opções de variável… Variation: Variação: Vector Effects Efeitos Vetoriais Vector Halftone Meio-tom Vetorial @@ -2738,3 +2752,5 @@ Diffuse Glow… Brilho difuso… Glass… Vidro… Ocean Ripple… Ondulação oceânica… + Rename Data Set Renomear conjunto de dados + Rename Variable Renomear variável diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a222cde4..3fd2cfe8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1192,6 +1192,7 @@ Highlight Alternate Glyphs Выделять альтернативные глифы Highlight anchors on mouse over Выделять опорные точки при наведении Highlight Rows Выделять строки + Highlight Variable Подсветить переменную Highlight Substituted Fonts Выделять заменённые шрифты Highlight Substituted Glyphs Выделять заменённые глифы History История @@ -3051,6 +3052,38 @@ V: В: Variable Width Profile Профиль переменной ширины Variables Переменные + New Variable Новая переменная + Delete Variable Удалить переменную + List Variables Перечислить переменные + Bind Variable Привязать переменную + Unbind Variable Отвязать переменную + New Data Set Новый набор данных + Delete Data Set Удалить набор данных + List Data Sets Перечислить наборы данных + Select Data Set Выделить набор данных + Next Data Set Следующий набор данных + Previous Data Set Предыдущий набор данных + Data Sets Наборы данных + New Variable… Новая переменная… + New Data Set… Новый набор данных… + Edit Data Set… Изменить набор данных… + Data Set Набор данных + Kind Тип + Variable Переменная + no document нет документа + no data set to edit нет набора данных для изменения + Make Text Dynamic Сделать текст динамическим + Make Visibility Dynamic Сделать видимость динамической + Capture Data Set Записать набор данных + Update Data Set Обновить набор данных + Rename Data Set… Переименовать набор данных… + Variable Options… Параметры переменной… + Select Bound Object Выбрать связанный объект + Select All Bound Objects Выбрать все связанные объекты + Multiple objects Несколько объектов + New Name Новое имя + no variable to rename нет переменной для переименования + no data set to rename нет набора данных для переименования Variation: Вариация: Vector Effects Векторные эффекты Vector Halftone Векторный полутон @@ -3995,3 +4028,10 @@ axis Both Оба Blocks Блоки Frosted Матовое Tiny Lens Мелкие линзы + Rename Data Set Переименовать набор данных + Rename Variable Переименовать переменную + Variable Options Параметры переменной + Edit Data Set Изменить набор данных + Define a variable first Сначала определите переменную + Text Текст + Visibility Видимость diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 05c421b1..8140c543 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1188,6 +1188,7 @@ Highlight Alternate Glyphs Виділяти альтернативні гліфи Highlight anchors on mouse over Виділяти опорні точки при наведенні Highlight Rows Виділяти рядки + Highlight Variable Підсвітити змінну Highlight Substituted Fonts Виділяти замінені шрифти Highlight Substituted Glyphs Виділяти замінені гліфи History Історія @@ -3047,6 +3048,38 @@ V: В: Variable Width Profile Профіль змінної ширини Variables Змінні + New Variable Нова змінна + Delete Variable Видалити змінну + List Variables Перерахувати змінні + Bind Variable Прив'язати змінну + Unbind Variable Відв'язати змінну + New Data Set Новий набір даних + Delete Data Set Видалити набір даних + List Data Sets Перерахувати набори даних + Select Data Set Виділити набір даних + Next Data Set Наступний набір даних + Previous Data Set Попередній набір даних + Data Sets Набори даних + New Variable… Нова змінна… + New Data Set… Новий набір даних… + Edit Data Set… Змінити набір даних… + Data Set Набір даних + Kind Тип + Variable Змінна + no document немає документа + no data set to edit немає набору даних для зміни + Make Text Dynamic Зробити текст динамічним + Make Visibility Dynamic Зробити видимість динамічною + Capture Data Set Записати набір даних + Update Data Set Оновити набір даних + Rename Data Set… Перейменувати набір даних… + Variable Options… Параметри змінної… + Select Bound Object Вибрати пов’язаний об’єкт + Select All Bound Objects Вибрати всі пов’язані об’єкти + Multiple objects Кілька об’єктів + New Name Нова назва + no variable to rename немає змінної для перейменування + no data set to rename немає набору даних для перейменування Variation: Варіація: Vector Effects Векторні ефекти Vector Halftone Векторний напівтон @@ -3980,3 +4013,10 @@ axis Both Обидві Blocks Блоки Frosted Матове Tiny Lens Дрібні лінзи + Rename Data Set Перейменувати набір даних + Rename Variable Перейменувати змінну + Variable Options Параметри змінної + Edit Data Set Змінити набір даних + Define a variable first Спочатку визначте змінну + Text Текст + Visibility Видимість diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7c4663ee..9bd0df0e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2947,6 +2947,38 @@ V: V: Variable Width Profile 变量宽度配置文件 Variables 变量 + New Variable 新建变量 + Delete Variable 删除变量 + List Variables 列出变量 + Bind Variable 绑定变量 + Unbind Variable 解除变量绑定 + New Data Set 新建数据集 + Delete Data Set 删除数据集 + List Data Sets 列出数据集 + Select Data Set 选择数据集 + Next Data Set 下一个数据集 + Previous Data Set 上一个数据集 + Data Sets 数据集 + New Variable… 新建变量… + New Data Set… 新建数据集… + Edit Data Set… 编辑数据集… + Data Set 数据集 + Kind 类型 + Variable 变量 + no document 没有文档 + no data set to edit 没有可编辑的数据集 + Make Text Dynamic 使文本动态化 + Make Visibility Dynamic 使可见性动态化 + Capture Data Set 捕获数据集 + Update Data Set 更新数据集 + Rename Data Set… 重命名数据集… + Variable Options… 变量选项… + Select Bound Object 选择绑定的对象 + Select All Bound Objects 选择所有绑定的对象 + Multiple objects 多个对象 + New Name 新名称 + no variable to rename 没有可重命名的变量 + no data set to rename 没有可重命名的数据集 Variation: 变化: Vector Effects 矢量效果 Vector Halftone 矢量半色调 @@ -3394,6 +3426,7 @@ Hide Others 隐藏其他 High Performance (discrete) 高性能 (独立显卡) Highlight Rows 突出显示行 + Highlight Variable 突出显示变量 Lock Others 锁定其他 Merge Selected 合并所选图层 Move Rows 移动行 @@ -3628,3 +3661,10 @@ effect Vertical 垂直 Blocks 块状 Frosted 磨砂 Tiny Lens 小镜头 + Rename Data Set 重命名数据集 + Rename Variable 重命名变量 + Variable Options 变量选项 + Edit Data Set 编辑数据集 + Define a variable first 请先定义一个变量 + Text 文本 + Visibility 可见性 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 07735295..a4edda1d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1170,6 +1170,7 @@ Highlight Alternate Glyphs 醒目提示替代字符 Highlight anchors on mouse over 滑鼠移過時醒目提示錨點 Highlight Rows 反白顯示列 + Highlight Variable 突出顯示變數 Highlight Substituted Fonts 醒目提示被取代的字體 Highlight Substituted Glyphs 醒目提示被取代的字符 History 步驟記錄 @@ -3038,6 +3039,38 @@ V: V: Variable Width Profile 可變寬度描述檔 Variables 變數 + New Variable 新增變數 + Delete Variable 刪除變數 + List Variables 列出變數 + Bind Variable 綁定變數 + Unbind Variable 解除變數綁定 + New Data Set 新增資料集 + Delete Data Set 刪除資料集 + List Data Sets 列出資料集 + Select Data Set 選取資料集 + Next Data Set 下一個資料集 + Previous Data Set 上一個資料集 + Data Sets 資料集 + New Variable… 新增變數… + New Data Set… 新增資料集… + Edit Data Set… 編輯資料集… + Data Set 資料集 + Kind 類型 + Variable 變數 + no document 沒有文件 + no data set to edit 沒有可編輯的資料集 + Make Text Dynamic 使文字動態化 + Make Visibility Dynamic 使可見性動態化 + Capture Data Set 擷取資料集 + Update Data Set 更新資料集 + Rename Data Set… 重新命名資料集… + Variable Options… 變數選項… + Select Bound Object 選取繫結的物件 + Select All Bound Objects 選取所有繫結的物件 + Multiple objects 多個物件 + New Name 新名稱 + no variable to rename 沒有可重新命名的變數 + no data set to rename 沒有可重新命名的資料集 Variation: 變化: Vector Effects 向量效果 Vector Halftone 向量半色調 @@ -3409,3 +3442,10 @@ effect Soft 柔和 Blocks 塊狀 Frosted 磨砂 Tiny Lens 小鏡頭 + Rename Data Set 重新命名資料集 + Rename Variable 重新命名變數 + Variable Options 變數選項 + Edit Data Set 編輯資料集 + Define a variable first 請先定義一個變數 + Text 文字 + Visibility 可見性 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a5ba1d39..e0ecb0b0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -47,6 +47,7 @@ fn sub(label: &'static str, items: Vec) -> Item { fn library_placeholders() -> Vec { vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")] } + use Item::Sep; /// UI-level commands: (id, label, shortcut, params doc). @@ -2548,7 +2549,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { panel("Tabs", "tabs"), ], ), - todo("Variables"), + panel("Variables", crate::panels::variables::ID), Sep, sub("Brush Libraries", library_placeholders()), sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()), @@ -2992,6 +2993,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, None); + 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()) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index ec8ef59a..f51648f3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -42,6 +42,7 @@ pub mod tabs; pub mod text_styles; pub mod transform; pub mod transparency; +pub mod variables; use egui::{Rect, Sense, Ui, vec2}; use serde_json::{Value, json}; @@ -136,6 +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::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), @@ -187,6 +189,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool links::ID => links::menu(app, ui), asset_export::ID => asset_export::menu(app, ui), css_properties::ID => css_properties::menu(app, ui), + variables::ID => variables::menu(app, ui), _ => return false, } true diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs new file mode 100644 index 00000000..7eda3b61 --- /dev/null +++ b/crates/ui-egui/src/panels/variables.rs @@ -0,0 +1,468 @@ +//! Variables panel: the document's variables (what they drive, which objects) and its +//! datasets. Clicking a dataset applies it; clicking a variable selects what it binds; the +//! arrows step to the next or previous one. Everything the reference panel does here runs a +//! `variable.*` / `dataset.*` command, so the panel and an agent drive the document the same +//! way — the commands are also in Window › Variables, the palette and over MCP. +//! +//! A row's name goes in italics when an edit has moved the art away from what the row says: +//! the document then no longer matches the active dataset, and "Update Data Set" writes the +//! art back into it. + +use egui::Ui; +use serde_json::json; +use vectorcraft_doc::{NodeId, NodeKind, VariableKind}; +use vectorcraft_engine::DocState; + +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: its kind's icon, its name, and the object it drives. +struct VarRow { + name: String, + kind: VariableKind, + bound: Vec, + /// The name of the object it drives, or "Multiple objects" when it drives more than one. + object: String, +} + +/// One dataset row: its name and how many values it carries. +struct SetRow { + name: String, + values: usize, + active: bool, + /// False once an edit has moved the art away from what this row says. + matches: bool, +} + +/// The row the panel acts on: Delete, Options… and Select Bound Object all mean it. A name +/// the document no longer defines highlights nothing (deleted from the palette, undone). +fn highlighted_row(st: &DocState) -> Option { + st.variables_highlight.clone().filter(|h| st.doc.variables.variable(h).is_some()) +} + +/// What a variable drives, as object ids: what Select Bound Object selects. +fn bound_objects(st: &DocState, name: &str) -> Vec { + st.doc.variables.objects_of(name).iter().map(|i| i.0).collect() +} + +/// A row click: highlight the row, and select what it drives (its bindings are left alone). +pub(crate) fn click_row(app: &mut VectorcraftApp, name: &str, bound: &[u64]) { + app.run("variable.highlight", json!({"variable": name})).ok(); + if !bound.is_empty() { + app.run("select.set", json!({"ids": bound})).ok(); + } +} + +/// Select Bound Object: select what the highlighted variable drives; its bindings stay. +pub(crate) fn select_bound_object(app: &mut VectorcraftApp) { + let ids = app.session.active().map(|st| highlighted_row(st).map(|h| bound_objects(st, &h))).unwrap_or_default().unwrap_or_default(); + if !ids.is_empty() { + app.run("select.set", json!({"ids": ids})).ok(); + } +} + +/// What the selection allows the bottom bar's and the panel menu's commands to do. +struct Picked { + selected: Vec, + has_selection: bool, + /// Unbind needs a bound object under the selection, not just any selection. + selection_bound: bool, + /// Make Text Dynamic needs exactly one type object: a text variable only drives type. + single_type: bool, +} + +fn picked(st: &vectorcraft_engine::DocState) -> Picked { + let selected: Vec = st.selection.objects.iter().map(|i| i.0).collect(); + let selection_bound = selected.iter().any(|id| st.doc.variables.bindings.contains_key(&NodeId(*id))); + let single_type = matches!(selected.as_slice(), [one] if st.doc.node(NodeId(*one)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))); + Picked { has_selection: !selected.is_empty(), selected, selection_bound, single_type } +} + +/// Delete Variable: the highlighted variable only (the command's default); the rest stay. +pub(crate) fn delete_highlighted(app: &mut VectorcraftApp) { + app.run("variable.delete", json!({})).ok(); +} + +/// Delete Data Set: the current one only (the command's default); the rest of the rows stay. +pub(crate) fn delete_active_dataset(app: &mut VectorcraftApp) { + app.run("dataset.delete", json!({})).ok(); +} + +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 = st + .doc + .variables + .variables + .iter() + .map(|v| { + let bound: Vec = st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect(); + let object = match bound.as_slice() { + [] => String::new(), + [one] => st.doc.node(NodeId(*one)).map(|n| n.display_name()).unwrap_or_default(), + _ => tl!("Multiple objects").to_string(), + }; + VarRow { name: v.name.clone(), kind: v.kind, bound, object } + }) + .collect(); + // What the art holds now, read once for every row's drift check. + let current = st.doc.captured_values(); + let sets: Vec = st + .doc + .variables + .datasets + .iter() + .map(|d| SetRow { + name: d.name.clone(), + values: d.values.len(), + active: st.doc.variables.active_dataset.as_deref() == Some(d.name.as_str()), + matches: d.matches(¤t), + }) + .collect(); + let active = sets.iter().find(|s| s.active); + let Picked { selected, has_selection, selection_bound, single_type } = picked(st); + let highlighted = highlighted_row(st); + + // The current data set across the top: its name, and the arrows that step through them. + ui.horizontal(|ui| { + widgets::field_label(ui, tl!("Data Set")); + let names: Vec<&str> = sets.iter().map(|s| s.name.as_str()).collect(); + let current = active.map(|s| s.name.as_str()).unwrap_or(""); + let picked = widgets::dropdown_names(ui, "variables-active", current, &names, 160.0); + if let Some(name) = picked.and_then(|i| names.get(i)) + && *name != current + { + app.run("dataset.select", json!({"name": name})).ok(); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let has = !sets.is_empty(); + if widgets::icon_button_enabled(ui, "chevron-left", tl!("Previous Data Set"), false, has, 24.0).clicked() { + app.run("dataset.prev", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "chevron-right", tl!("Next Data Set"), false, has, 24.0).clicked() { + app.run("dataset.next", json!({})).ok(); + } + }); + }); + + widgets::subheader(ui, tl!("Variables")); + if vars.is_empty() { + widgets::dim_label(ui, tl!("None")); + } + for row in &vars { + ui.horizontal(|ui| { + // The kind's own icon, so a text variable and a visibility one read apart. + let icon = match row.kind { + VariableKind::Text => "type", + VariableKind::Visibility => "eye", + }; + ui.add(egui::Image::new(crate::icons::source(icon)).fit_to_exact_size(egui::vec2(14.0, 14.0))); + // The object a variable drives is named beside it, as the reference panel does. + let text = if row.object.is_empty() { row.name.clone() } else { format!("{} · {}", row.name, row.object) }; + let r = ui.selectable_label(highlighted.as_deref() == Some(row.name.as_str()), text); + // A click highlights the row and selects what it drives; a double click opens + // Variable Options for it. + if r.clicked() { + click_row(app, &row.name, &row.bound); + } + if r.double_clicked() { + crate::dialogs::variables::open(app, "variable.rename", Some(&row.name)); + } + }); + } + + widgets::subheader(ui, tl!("Data Sets")); + if sets.is_empty() { + widgets::dim_label(ui, tl!("None")); + } + for row in &sets { + // The active row is ticked and selected; a row the art has drifted from is in + // italics, so an edit that left the document not matching it is visible. + let label = format!("{}{} · {}", if row.active { "● " } else { "" }, row.name, row.values); + let mut text = egui::RichText::new(label); + if !row.matches { + text = text.italics(); + } + if ui.selectable_label(row.active, text).clicked() && !row.active { + app.run("dataset.select", json!({"name": row.name})).ok(); + } + } + + // The bottom bar: make the selection dynamic, and define and delete what the lists show. + ui.separator(); + ui.horizontal(|ui| { + if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, single_type, 24.0).clicked() { + app.run("variable.makeTextDynamic", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "eye", tl!("Make Visibility Dynamic"), false, has_selection, 24.0).clicked() { + app.run("variable.makeVisibilityDynamic", json!({})).ok(); + } + if widgets::icon_button(ui, "plus", tl!("New Variable…"), false, 24.0).clicked() { + crate::menus::invoke(app, "variable.define", json!({})); + } + if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, selection_bound, 24.0).clicked() { + app.run("variable.unbind", json!({"ids": selected})).ok(); + } + // The bin deletes the highlighted variable only, as the reference panel's does. + if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, highlighted.is_some(), 24.0).clicked() { + delete_highlighted(app); + } + }); + ui.horizontal(|ui| { + // Capture records what the art holds now as a new row (paste: take it in); Update + // writes the art back into the row that is active (save: write it out). + if widgets::icon_button(ui, "clipboard-paste", tl!("Capture Data Set"), false, 24.0).clicked() { + app.run("dataset.capture", json!({})).ok(); + } + let can_update = active.is_some(); + if widgets::icon_button_enabled(ui, "save", tl!("Update Data Set"), false, can_update, 24.0).clicked() { + app.run("dataset.update", json!({})).ok(); + } + }); +} + +/// The panel's (≡) menu: the same commands as its bars and lists, for the ones that act on one +/// row rather than on the selection. +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let Some(st) = app.session.active() else { return }; + let vars: Vec = st.doc.variables.variables.iter().map(|v| v.name.clone()).collect(); + let sets: Vec = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); + let active = st.doc.variables.active_dataset.clone(); + let bound: Vec = st.doc.variables.bindings.keys().map(|i| i.0).collect(); + let highlighted = highlighted_row(st); + let highlighted_bound: Vec = highlighted.as_ref().map(|h| bound_objects(st, h)).unwrap_or_default(); + let Picked { selected, has_selection, selection_bound, single_type } = picked(st); + + if widgets::menu_item(ui, tl!("New Variable…"), true, false) { + crate::menus::invoke(app, "variable.define", json!({})); + } + // Variable Options renames the highlighted variable, else the first. + if widgets::menu_item(ui, tl!("Variable Options…"), !vars.is_empty(), false) { + let name = highlighted.clone().or_else(|| vars.first().cloned()).unwrap_or_default(); + crate::dialogs::variables::open(app, "variable.rename", Some(&name)); + } + if widgets::menu_item(ui, tl!("Make Text Dynamic"), single_type, false) { + app.run("variable.makeTextDynamic", json!({})).ok(); + } + if widgets::menu_item(ui, tl!("Make Visibility Dynamic"), has_selection, false) { + app.run("variable.makeVisibilityDynamic", json!({})).ok(); + } + if widgets::menu_item(ui, tl!("Unbind Variable"), selection_bound, false) { + app.run("variable.unbind", json!({"ids": selected})).ok(); + } + if widgets::menu_item(ui, tl!("Delete Variable"), highlighted.is_some(), false) { + delete_highlighted(app); + } + ui.separator(); + if widgets::menu_item(ui, tl!("Capture Data Set"), true, false) { + app.run("dataset.capture", json!({})).ok(); + } + if widgets::menu_item(ui, tl!("Update Data Set"), active.is_some(), false) { + app.run("dataset.update", json!({})).ok(); + } + if widgets::menu_item(ui, tl!("New Data Set…"), true, false) { + crate::menus::invoke(app, "dataset.new", json!({})); + } + if widgets::menu_item(ui, tl!("Edit Data Set…"), active.is_some(), false) { + crate::menus::invoke(app, "dataset.set", json!({})); + } + if widgets::menu_item(ui, tl!("Rename Data Set…"), !sets.is_empty(), false) { + let name = active.clone().or_else(|| sets.first().cloned()).unwrap_or_default(); + crate::dialogs::variables::open(app, "dataset.rename", Some(&name)); + } + // Only the current data set goes: the rest of the rows stay. + if widgets::menu_item(ui, tl!("Delete Data Set"), active.is_some(), false) { + delete_active_dataset(app); + } + ui.separator(); + // What the highlighted variable drives, selected — its bindings are left alone. + if widgets::menu_item(ui, tl!("Select Bound Object"), !highlighted_bound.is_empty(), false) { + select_bound_object(app); + } + if widgets::menu_item(ui, tl!("Select All Bound Objects"), !bound.is_empty(), false) { + app.run("select.set", json!({"ids": bound})).ok(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app_with_data() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + let t = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Alice"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + app.session.execute("variable.bind", &json!({"variable": "Name", "ids": [t]})).unwrap(); + app.session.execute("dataset.capture", &json!({})).unwrap(); + app + } + + #[test] + fn draws_headless() { + let mut app = app_with_data(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); + out.textures_delta.clear(); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(text.contains("Name") && text.contains("Data Sets") && text.contains("Data Set 1"), "{text}"); + } + + #[test] + fn a_row_the_art_left_behind_is_in_italics() { + let mut app = app_with_data(); + let t = app.session.doc().unwrap().doc.variables.objects_of("Name")[0]; + // The document no longer matches the row once the art is edited. + app.session.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(text.contains("Data Set 1"), "{text}"); + // …and the row's drift is what `dataset.list` reports, so an agent sees it too. + let sets = app.run("dataset.list", json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["matches"], json!(false)); + // Updating writes the art back into the row. + app.run("dataset.update", json!({})).unwrap(); + assert_eq!(app.run("dataset.list", json!({})).unwrap()["datasets"][0]["matches"], json!(true)); + } + + #[test] + fn the_panel_menu_lists_the_row_commands() { + let mut app = app_with_data(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| menu(&mut app, ui)); + out.textures_delta.clear(); + let text = crate::tests_labels::painted_text(&mut app, menu); + for item in [ + "New Variable…", + "Variable Options…", + "Capture Data Set", + "Update Data Set", + "Rename Data Set…", + "Delete Data Set", + "Select Bound Object", + "Select All Bound Objects", + ] { + assert!(text.contains(item), "{item} missing from the panel menu:\n{text}"); + } + } + + /// What the panel acts on is the highlight, and only while it names a variable: a stale one + /// (deleted elsewhere, undone) highlights nothing, so Delete and Options… stay disabled. + #[test] + fn a_highlight_of_a_deleted_variable_highlights_nothing() { + let mut app = app_with_data(); + app.session.execute("variable.highlight", &json!({"variable": "Name"})).unwrap(); + assert_eq!(highlighted_row(app.session.doc().unwrap()).as_deref(), Some("Name")); + assert_eq!(bound_objects(app.session.doc().unwrap(), "Name").len(), 1); + app.session.execute("variable.delete", &json!({"names": ["Name"]})).unwrap(); + assert!(highlighted_row(app.session.doc().unwrap()).is_none()); + } + + /// The reviewer's regression case, through the calls the panel and its menu make: highlight + /// a variable, Select Bound Object, then delete one variable and the current dataset. The + /// selection lands on what the row drives with the binding intact, and the others remain. + #[test] + fn selecting_and_deleting_act_on_the_highlighted_row_only() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + let a = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Alice"})).unwrap()["id"].as_u64().unwrap(); + let b = app.session.execute("text.create", &json!({"x": 10, "y": 80, "text": "Bob"})).unwrap()["id"].as_u64().unwrap(); + for (name, id) in [("Name", a), ("Other", b)] { + app.session.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap(); + app.session.execute("variable.bind", &json!({"variable": name, "ids": [id]})).unwrap(); + } + app.session.execute("dataset.capture", &json!({})).unwrap(); + app.session.execute("dataset.capture", &json!({})).unwrap(); + app.session.execute("dataset.select", &json!({"name": "Data Set 1"})).unwrap(); + + // A row click highlights; Select Bound Object selects what it drives, binding intact. + click_row(&mut app, "Name", &[a]); + assert_eq!(app.session.doc().unwrap().selection.objects, vec![NodeId(a)]); + assert!(!app.session.doc().unwrap().doc.variables.objects_of("Name").is_empty()); + select_bound_object(&mut app); + assert_eq!(app.session.doc().unwrap().selection.objects, vec![NodeId(a)]); + assert!(!app.session.doc().unwrap().doc.variables.objects_of("Name").is_empty()); + + // Delete Variable deletes the highlighted one; Delete Data Set the current one. + delete_highlighted(&mut app); + delete_active_dataset(&mut app); + let vars = app.run("variable.list", json!({})).unwrap(); + assert_eq!(vars["variables"].as_array().unwrap().len(), 1); + assert_eq!(vars["variables"][0]["name"], "Other"); + let sets = app.run("dataset.list", json!({})).unwrap(); + assert_eq!(sets["datasets"].as_array().unwrap().len(), 1); + assert_eq!(sets["datasets"][0]["name"], "Data Set 2"); + + // The panel and its menu still draw with the highlight gone stale. + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(text.contains("Other") && text.contains("Data Set 2"), "{text}"); + let text = crate::tests_labels::painted_text(&mut app, menu); + assert!(text.contains("Select Bound Object"), "{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"); + assert!(crate::icons::exists(row.2), "{} is not a bundled icon", row.2); + } + + /// The collapsed column draws `ICON_PANEL_GROUPS`, not `ICON_PANELS`: a panel registered + /// but in no group opens from the Window menu and then has nowhere to collapse to. Both + /// lists need an entry, and this is the one that was missing. + #[test] + fn the_panel_reaches_the_collapsed_icon_column() { + let in_column = crate::state::ICON_PANEL_GROUPS.iter().any(|g| g.contains(&ID)); + assert!(in_column, "{ID} is in ICON_PANELS but in no ICON_PANEL_GROUPS row, so the collapsed column never draws it"); + // Every id in a group names a panel that is registered with a label and an icon. + for group in crate::state::ICON_PANEL_GROUPS { + for id in *group { + let row = crate::state::ICON_PANELS.iter().find(|p| p.0 == *id).unwrap_or_else(|| panic!("{id} is in a group but not registered")); + assert!(!row.1.is_empty() && crate::icons::exists(row.2), "{id}: {} / {}", row.1, row.2); + } + } + } + + /// Window › Variables opens the panel, as Window's other panel items do; its commands are in + /// the panel, its menu and the command palette. + #[test] + fn window_variables_opens_the_panel() { + let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let entries = crate::menus::menu_entries(&app); + 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.clone(), panel.label.as_str()), (vec!["Window".to_string()], "Variables")); + assert!(!entries.iter().any(|e| e.path == ["Window", "Variables"]), "no Variables submenu"); + } + + /// 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 = app_with_data(); + for (cmd, field) in [ + ("variable.define", "kind"), + ("variable.rename", "newName"), + ("variable.bind", "variable"), + ("dataset.new", "name"), + ("dataset.rename", "newName"), + ("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"); + } + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9c75a81f..3b31518e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -138,9 +138,14 @@ 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"), + (crate::panels::variables::ID, "Variables", "chart-column"), ]; /// Groups of icon panels separated by dividers in the collapsed column. +/// +/// This, not [`ICON_PANELS`], is what the collapsed column draws: a panel listed there but in +/// no group can be opened from the Window menu and `window.panel`, but never appears as an +/// icon. A panel that belongs in the column needs a row here as well as its entry above. pub const ICON_PANEL_GROUPS: &[&[&str]] = &[ &["color", "colorGuide"], &["swatches", "brushes", "symbols", "patternOptions"], @@ -150,6 +155,7 @@ pub const ICON_PANEL_GROUPS: &[&[&str]] = &[ &["transform", "align", "pathfinder"], &["character", "paragraph", "glyphs"], &["history", "actions", "info", "navigator"], + &["variables"], ]; #[derive(Clone, Debug, Serialize, Deserialize)]