Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 e04385e950 Fix #423: Movable dialogs and Offset Path's Joins dropdown
The modal dialogs were anchored to the window's centre, so none of them
could be moved. They now share one modal frame (dialogs/modal.rs): the
band from the window's top edge down to its heading moves it, as a title
bar would, with a grab cursor; it stays on screen and keeps where it was
moved (per dialog, for the session). The shared dialog frame, New
Document (and More Settings), Preferences, Keyboard Shortcuts,
Workspaces and Find Font all use it; About moves by its title bar.

The form dialogs drew every string field as a text box, so Offset
Path's Joins had to be typed. Their choice fields are now dropdowns
(Joins: Miter, Round, Bevel; Average's axis; Shear's and Reflect's
axis), the fields follow the dialog's order (Offset, Joins, Miter Limit;
Rows, Columns, Gutter) instead of alphabetical, and Offset Path has a
Preview that Cancel rolls back.

The axis labels are translated in a new "axis" catalog context: the
plain Horizontal and Vertical rows mean writing direction in Japanese.
ja, es, zh-hans and zh-hant get the axis rows (Horizontal, Vertical,
Both) and Joins, Miter, Bevel, Offset and Axis.

Headless tests drag the heading of four dialog windows (moves, a drag
below the heading doesn't, kept on screen, reopens where it was left),
open the Joins dropdown and choose Round (same result as the command
with round joins), and check the axis dropdowns and the preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:52:05 +07:00
BFlat 4c9d27b4f5 Merge pull request #428 from storytold/fix/issue-421
Fix #421: Document Color Mode can put RGB greys on the black plate
2026-10-08 10:38:20 +07:00
BFlat 45f0dcc397 Merge pull request #425 from storytold/fix/issue-419
Fix #419: PDF/.ai art past the layer scan's limit never shows a hidden layer
2026-10-08 10:38:02 +07:00
BFlat ad28cf4793 Merge pull request #424 from storytold/fix/issue-420
Fix #420: SVG import keeps clipped layers as layers with a clipping mask
2026-10-08 10:37:45 +07:00
bflatasticandClaude Opus 5.5 693e928c9c Fix #421: Document Color Mode can put RGB greys on the black plate
Switching to CMYK separates RGB greys through the CMYK profile into
four-colour greys and a rich black, as Illustrator does; only Gray
colours stay on the black plate. The command catalogue said "greys stay
greys", which read as if R = G = B colours went to K only.

- file.documentColorMode (and its alias) and document.open {colorMode}
  take grays: "profile" (default, unchanged) | "black". With "black",
  RGB greys with R = G = B convert to K only, K = their grey value (the
  inks Edit Colors > Convert to Grayscale before the switch gives);
  other colours convert as before. Swatches, tints, gradient stops and
  pattern tiles follow the same rule.
- Pattern tiles are now converted with the rest of the document (they
  kept RGB colours in a CMYK document before), as the command said.
- The command catalogue, docs/mcp.md and the ROADMAP say what happens to
  Gray colours and RGB greys.
- Tests: the profile path (four-colour greys, rich black, pattern tiles,
  Gray kept), grays: "black" over art, swatches, tints, gradients and
  patterns, document.open, and the issue's file over MCP through a PDF
  export and back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:30:32 +07:00
bflatasticandClaude Opus 5.5 ad3e326d1d Fix #419: PDF/.ai art past the layer scan's limit never shows a hidden layer
The import walks each page's content to learn which optional content
group each marked-content sequence belongs to, and stopped after 2,000,000
operators. Past that, art went to the page's "Page N" layer, visible and
printable, so a layer that was off (or didn't print) showed and printed;
an Illustrator drawing of about a million paths lost 6 of its 15 layers.

- The walk now reads up to 64 million operators a page (it reads about
  15 million a second, twice as fast as the interpreter draws them; the
  issue's 2.1-million-operator file imports in 3 s, a 10-million one in
  15 s). Marked-content tags are kept as 64-bit hashes rather than byte
  vectors, and at most 4 million a page, so the walk's memory stays
  bounded on hostile input.
- When the walk is cut short (or the interpreter and the walk otherwise
  part) and some group is off or doesn't print, art in marked content
  whose group can't be told goes to a hidden, non-printing "Page N
  (unsorted)" layer instead of the page's layer, and the warning says
  so (and that the page was too large to read, when it was). Art drawn
  past the limit inside a group already open stays in its group; art
  outside any group stays on the page's layer. Files whose groups all
  show and print keep the old fallback.

Tests (with the limit lowered): a hidden, non-printing group past the
limit lands on the hidden unsorted layer while the open group keeps its
art and the page's art stays shown; with every group shown, art goes to
the page's layer as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:30:07 +07:00
31 changed files with 1150 additions and 500 deletions

No files matched your search

+4 -3
View File
@@ -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-08 (after 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); see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after 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); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -104,6 +104,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 45 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, the smart guide display options, performance, devices…).
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too. Not yet: Select All across threaded frames (one frame's text is selected).
@@ -135,7 +136,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; Alt deletes their pieces; filled open paths count as closed), Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units, shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups; Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
@@ -147,7 +148,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs).
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export.
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers; strokes kept live, a fill and a stroke of one outline as one object; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
+46 -9
View File
@@ -8,9 +8,11 @@
//! become active when assigned.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, ProofTarget};
use vectorcraft_color::recolor::Neutral;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::ColorMode;
use vectorcraft_render::proof;
@@ -55,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert Document Color Mode",
[],
None,
"same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?} → {changed} (an alias kept for older scripts; the command palette leaves it out)",
"same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?, grays?} → {changed} (an alias kept for older scripts; the command palette leaves it out)",
has_doc,
convert_mode
),
@@ -232,6 +234,28 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"rgb": rgb, "cmyk": cmyk}))
}
/// How Document Color Mode converts neutral RGB greys (R = G = B) to CMYK (the `grays` param).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Grays {
/// Through the CMYK profile like any colour: four-colour greys and a rich black.
#[default]
Profile,
/// On the black plate only: K is the grey's ink percentage (as Convert to Grayscale and then
/// to CMYK would give).
Black,
}
impl Grays {
/// The `grays` param of `cmd`: `"profile"` (default) or `"black"`.
pub(crate) fn param(cmd: &str, p: &Value) -> Result<Self> {
match str_param(p, "grays").map(str::to_ascii_lowercase).as_deref() {
None | Some("profile") => Ok(Grays::Profile),
Some("black") => Ok(Grays::Black),
Some(g) => Err(bad(cmd, format!("grays must be \"profile\" or \"black\", not `{g}`"))),
}
}
}
/// File → Document Color Mode (`file.documentColorMode` and its alias
/// `object.convertDocumentColorMode`): set the mode and, unless `convert: false`, convert every
/// colour through the colour settings.
@@ -243,6 +267,7 @@ pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
};
let intent = intent_param(p, C)?;
let grays = Grays::param(C, p)?;
// Choosing the mode the document already has (the checked menu item) is not an edit.
if s.doc()?.doc.color_mode == mode {
return Ok(json!({ "changed": 0 }));
@@ -250,18 +275,20 @@ pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
let convert = bool_or(p, "convert", true);
let mut changed = 0usize;
s.edit("Document Color Mode", |d, _| {
changed = set_color_mode(d, mode, convert, intent);
changed = set_color_mode(d, mode, convert, intent, grays);
Ok(())
})?;
Ok(json!({ "changed": changed }))
}
/// Put `d` in colour `mode` and, with `convert`, convert every colour of its art, symbols and
/// swatches through the colour settings (`intent`, default: theirs) → how many colours changed.
/// Greys stay greys (they print on the black plate in either mode); Lab colours (spot colour
/// Put `d` in colour `mode` and, with `convert`, convert every colour of its art, symbols,
/// pattern tiles and swatches through the colour settings (`intent`, default: theirs) → how many
/// colours of the art, symbols and patterns changed. Gray colours stay Gray (they print on the
/// black plate in either mode); RGB greys are colours like any other and separate through the
/// CMYK profile, unless `grays` puts them on the black plate. Lab colours (spot colour
/// definitions) are device independent and fit either mode. Document Color Mode and
/// `document.open {colorMode}` both convert this way.
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool, intent: Option<Intent>) -> usize {
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool, intent: Option<Intent>, grays: Grays) -> usize {
d.color_mode = mode;
if !convert {
return 0;
@@ -269,15 +296,25 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode,
let c = cms::active();
let intent = intent.unwrap_or(c.settings().intent);
let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb };
let conv = |col: &Color| if matches!(col, Color::Gray { .. } | Color::Lab { .. }) { *col } else { c.convert(col, model, intent) };
let k_only = model == Model::Cmyk && grays == Grays::Black;
let conv = |col: &Color| match col {
Color::Gray { .. } | Color::Lab { .. } => *col,
// Grey ink percentage, then K only (a Gray colour's CMYK).
Color::Rgb { .. } if k_only && Neutral::of(col).is_some() => c.convert(&c.convert(col, Model::Gray, intent), Model::Cmyk, intent),
_ => c.convert(col, model, intent),
};
let mut changed = 0usize;
proof::map_document_colors(d, &mut |col, _| {
let mut count = |col: &Color, _: proof::Link| {
let n = conv(col);
if n != *col {
changed += 1;
}
n
});
};
proof::map_document_colors(d, &mut count);
for a in d.patterns.iter_mut().flat_map(|p| &mut p.art) {
proof::map_node_colors(Arc::make_mut(a), &mut count);
}
for sw in d.swatches_iter_mut() {
match &mut sw.paint {
Paint::Solid { color, .. } => *color = conv(color),
+1 -1
View File
@@ -189,7 +189,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Color Mode",
["File", "Document Color Mode"],
None,
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols and swatches through the colour settings; swatch links kept; greys stay greys), intent?} → {changed}",
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols, pattern tiles and swatches through the colour settings; swatch links kept; Gray colours stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB greys separate through the CMYK profile like any colour, into four-colour greys and a rich black) | \"black\" (to CMYK, RGB greys with R = G = B go on the black plate only, K = their grey value)} → {changed}",
has_doc,
super::colormgmt::convert_mode
),
+1 -1
View File
@@ -178,7 +178,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
};
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None);
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
}
// Imports are named after the file; a native document keeps its own title (the tab shows the
// file name once it has a path).
+1 -1
View File
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
+7 -2
View File
@@ -6,6 +6,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::{ColorMode, Document};
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs};
use super::super::colormgmt::Grays;
use super::super::*;
use super::{err, source};
@@ -22,6 +23,8 @@ pub struct LoadOptions {
/// The colour mode the document opens in, its colours converted as Document Color Mode does
/// (default: the file's; a PDF painted mostly in CMYK opens in CMYK).
pub color_mode: Option<ColorMode>,
/// How that conversion separates RGB greys (`grays`).
pub grays: Grays,
/// What a PDF's text becomes.
pub text_as: TextAs,
/// A PDF's optional content groups become layers (else one layer per page, without the art
@@ -38,6 +41,7 @@ impl Default for LoadOptions {
crop: CropTo::default(),
password: None,
color_mode: None,
grays: Grays::default(),
text_as: TextAs::default(),
layers: true,
dxf: Default::default(),
@@ -47,7 +51,7 @@ impl Default for LoadOptions {
impl LoadOptions {
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`),
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`,
/// `textAs` (`text` | `outlines`), `layers` (true | false) and `dxf` (the DXF import options).
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
@@ -65,13 +69,14 @@ impl LoadOptions {
Some("cmyk") => Some(ColorMode::Cmyk),
Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))),
};
let grays = Grays::param(cmd, p)?;
let text_as = choice(cmd, "textAs", str_param(p, "textAs"))?;
let layers = match p.get("layers").filter(|v| !v.is_null()) {
None => true,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
};
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
Ok(Self { pages, crop, password, color_mode, text_as, layers, dxf })
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf })
}
/// Does the document read only part of the file, or read it differently (a page range, another
+117 -1
View File
@@ -1,7 +1,7 @@
//! Menu long-tail commands (Object/Edit/Select/Type/View/File), driven through `Session::execute`.
use serde_json::{Value, json};
use vectorcraft_color::Color;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ColorMode, LiveShape, Node, NodeKind, TextKind};
use vectorcraft_geom::Rect;
@@ -859,6 +859,122 @@ fn document_color_mode_converts_colours() {
assert!(s.execute("file.documentColorMode", &json!({"mode": "lab"})).is_err());
}
/// Four RGB greys (#000000, #333333, #808080, #e6e6e6, as in issue #421) and a red, a 40% tint of a
/// grey global swatch, a gradient to grey and a pattern of a grey tile → (session, the five
/// rectangles, the tinted one, the gradient one).
fn greys_document() -> (Session, Vec<NodeId>, NodeId, NodeId) {
let mut s = session();
let ids: Vec<NodeId> = ["#000000", "#333333", "#808080", "#e6e6e6", "#ff0000"]
.iter()
.map(|hex| {
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
fill(&mut s, id, hex);
id
})
.collect();
s.execute("swatch.new", &json!({"name": "Ink", "color": "#808080", "global": true})).unwrap();
let tint = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("paint.setFill", &json!({"ids": [tint.0], "swatch": "Ink", "tint": 40})).unwrap();
let grad = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#333333"}]);
s.execute("paint.setFill", &json!({"ids": [grad.0], "gradient": {"kind": "linear", "stops": stops}})).unwrap();
let tile = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
fill(&mut s, tile, "#808080");
s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Grey Tile", "edit": false})).unwrap();
(s, ids, tint, grad)
}
/// The colour of pattern `name`'s first tile object.
fn pattern_fill(s: &Session, name: &str) -> Color {
s.doc().unwrap().doc.pattern(name).unwrap().art[0].appearance.fill_paint().color().unwrap()
}
/// K of a colour that prints on the black plate only.
fn k_only(c: Color) -> f32 {
match c {
Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k } => k,
other => panic!("not K only: {other:?}"),
}
}
#[test]
fn document_color_mode_separates_rgb_greys_through_the_profile() {
let (mut s, ids, _, _) = greys_document();
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
// As Illustrator converts them: four-colour greys and a rich black.
let Color::Cmyk { c, m, y, k } = fill_of(&s, ids[2]) else { panic!("not CMYK") };
assert!(c > 0.05 && m > 0.05 && y > 0.05 && k > 0.0, "a four-colour mid grey: {c} {m} {y} {k}");
let Color::Cmyk { c, k, .. } = fill_of(&s, ids[0]) else { panic!("not CMYK") };
assert!(k > 0.9 && c > 0.3, "rich black: {c} {k}");
// Pattern tiles are swatches too: their art converts with the rest.
assert!(matches!(pattern_fill(&s, "Grey Tile"), Color::Cmyk { .. }), "{:?}", pattern_fill(&s, "Grey Tile"));
// Gray colours stay Gray.
let mut g = session();
let a = rect(&mut g, 0.0, 0.0, 10.0, 10.0);
g.execute("paint.setFill", &json!({"ids": [a.0], "color": {"gray": 0.3}})).unwrap();
g.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
assert_eq!(fill_of(&g, a), Color::gray(0.3));
}
#[test]
fn document_color_mode_can_put_rgb_greys_on_the_black_plate() {
let (mut profile, pids, ..) = greys_document();
profile.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
let (mut s, ids, tint, grad) = greys_document();
let e = s.execute("file.documentColorMode", &json!({"mode": "cmyk", "grays": "rich"})).unwrap_err().to_string();
assert!(e.contains("grays"), "{e}");
s.execute("file.documentColorMode", &json!({"mode": "cmyk", "grays": "black"})).unwrap();
let close = |a: f32, b: f32| (a - b).abs() < 0.005;
for (id, want) in ids.iter().zip([1.0, 0.8, 0.498, 0.098]) {
let k = k_only(fill_of(&s, *id));
assert!(close(k, want), "K {k}, want {want}");
}
// Other colours convert through the profile as before.
assert_eq!(fill_of(&s, ids[4]), fill_of(&profile, pids[4]));
// Swatches, their tints, gradient stops and pattern tiles follow the same rule.
let d = &s.doc().unwrap().doc;
assert!(close(k_only(d.swatch("Ink").unwrap().paint.color().unwrap()), 0.498));
let Paint::Solid { color, swatch, tint: t } = node(&s, tint).appearance.fill_paint() else { panic!("not solid") };
assert!(close(k_only(color), 0.498 * 0.4) && swatch.as_deref() == Some("Ink") && t == 0.4, "{color:?} {swatch:?} {t}");
let Paint::Gradient(g) = node(&s, grad).appearance.fill_paint() else { panic!("not a gradient") };
let ks: Vec<f32> = g.gradient.stops.iter().map(|st| k_only(st.color)).collect();
assert!(close(ks[0], 0.0) && close(ks[1], 0.8), "{ks:?}");
assert!(close(k_only(pattern_fill(&s, "Grey Tile")), 0.498));
// To RGB, `grays` changes nothing.
s.execute("file.documentColorMode", &json!({"mode": "rgb", "grays": "black"})).unwrap();
let rgb = s.doc().unwrap().doc.layers.clone();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("file.documentColorMode", &json!({"mode": "rgb"})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers, rgb);
}
#[test]
fn opening_in_cmyk_can_put_rgb_greys_on_the_black_plate() {
let mut s = session();
for hex in ["#000000", "#333333", "#808080", "#e6e6e6", "#ff0000"] {
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
fill(&mut s, id, hex);
}
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
let data = vectorcraft_format::base64_encode(svg.as_bytes());
let mut o = Session::new();
o.execute("document.open", &json!({"name": "greys.svg", "dataBase64": data, "colorMode": "cmyk", "grays": "black"})).unwrap();
let d = &o.doc().unwrap().doc;
assert_eq!(d.color_mode, ColorMode::Cmyk);
let mut fills = vec![];
for l in &d.layers {
l.walk(&mut |n| fills.extend(n.children().is_none().then(|| n.appearance.fill_paint().color()).flatten()));
}
assert_eq!(fills.len(), 5, "{fills:?}");
for (c, want) in fills.iter().zip([1.0, 0.8, 0.498, 0.098]) {
let k = k_only(*c);
assert!((k - want).abs() < 0.005, "K {k}, want {want}");
}
assert!(matches!(fills[4], Color::Cmyk { m, y, .. } if m > 0.8 && y > 0.8), "red separates as usual: {:?}", fills[4]);
let e = Session::new().execute("document.open", &json!({"name": "greys.svg", "dataBase64": data, "grays": "k"})).unwrap_err().to_string();
assert!(e.contains("grays"), "{e}");
}
#[test]
fn file_info_sets_title() {
let mut s = session();
+52
View File
@@ -145,3 +145,55 @@ fn pdf_pages(pdf: &[u8]) -> usize {
.unwrap();
h.session.doc().unwrap().doc.artboards.len()
}
/// Issue #421 over MCP: RGB greys switched to CMYK with `grays: "black"` print on the black plate
/// only, in the document and in the exported PDF.
#[test]
fn document_color_mode_puts_rgb_greys_on_the_black_plate() {
let svg = tmp("greys.svg");
std::fs::write(
&svg,
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200pt" height="50pt" viewBox="0 0 200 50">
<rect x="0" y="0" width="50" height="50" fill="#000000"/><rect x="50" y="0" width="50" height="50" fill="#333333"/>
<rect x="100" y="0" width="50" height="50" fill="#808080"/><rect x="150" y="0" width="50" height="50" fill="#e6e6e6"/>
</svg>"##,
)
.unwrap();
let want = [1.0, 0.8, 0.498, 0.098];
let k_only = |h: &Headless| -> Vec<f32> {
let mut ks = vec![];
for l in &h.session.doc().unwrap().doc.layers {
l.walk(&mut |n| {
if n.children().is_none()
&& let Some(c) = n.appearance.fill_paint().color()
{
let v = serde_json::to_value(c).unwrap();
assert!(v["model"] == "cmyk" && v["c"] == 0.0 && v["m"] == 0.0 && v["y"] == 0.0, "not K only: {v}");
ks.push(v["k"].as_f64().unwrap() as f32);
}
});
}
ks
};
let close = |ks: &[f32]| ks.len() == want.len() && ks.iter().zip(want).all(|(k, w)| (k - w).abs() < 0.005);
let mut h = Headless::new();
text(&call_tool(&mut h, "open_file", &json!({"path": svg})));
let r = text(&call_tool(&mut h, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "cmyk", "grays": "black"}})));
assert_eq!(r["changed"], 4);
let ks = k_only(&h);
assert!(close(&ks), "{ks:?}");
// The PDF carries the same inks (DeviceCMYK opens as CMYK).
let pdf = text(&call_tool(&mut h, "export", &json!({"format": "pdf"})));
let mut back = Headless::new();
back.call("engine.execute", json!({"command": "document.open", "params": {"name": "greys.pdf", "dataBase64": pdf["dataBase64"]}})).unwrap();
let ks = k_only(&back);
assert!(close(&ks), "{ks:?}");
// document.open takes the same option.
let mut o = Headless::new();
let r = call_tool(&mut o, "run_command", &json!({"command": "document.open", "params": {"path": svg, "colorMode": "cmyk", "grays": "black"}}));
text(&r);
let ks = k_only(&o);
assert!(close(&ks), "{ks:?}");
let bad = call_tool(&mut o, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "rgb", "grays": "rich"}}));
assert!(bad.is_error, "{bad:?}");
}
+25 -10
View File
@@ -21,7 +21,7 @@ use vectorcraft_geom::{FillRule, PathData};
use crate::import_color::{Colors, Native};
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page};
use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
use crate::import_text::{Families, Look, Placement, TextLine, Upright};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
@@ -32,6 +32,9 @@ const TEXT_OUTLINES: &str = "<Text Outlines>";
const MAX_NESTED: u32 = 8;
/// A tiling pattern read this many times with the same art is taken to always draw it.
const PATTERN_REUSE: u32 = 16;
/// Stands for the group of art whose group couldn't be told (see [`Builder::unsure`]): it goes
/// to a hidden, non-printing layer of its page.
const UNSORTED: usize = usize::MAX;
/// Import a PDF (or PDF-compatible `.ai`) with default options.
pub fn import(bytes: &[u8]) -> Result<Document, PdfError> {
@@ -146,6 +149,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
doc.layers.clear();
let taken: Vec<String> = doc.swatches_iter().map(|s| s.name.clone()).chain(doc.swatch_groups.iter().map(|g| g.name.clone())).collect();
let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken.clone()), opts.text_as, route);
// The art of a group that is off (drawn as `all_on` turned it on) or doesn't print mustn't
// show or print on its page's layer.
b.unsure = route && ocgs.list.iter().any(|g| (all_on && !g.on) || !g.print);
b.taken = taken;
b.warnings = notes;
let cache = InterpreterCache::new();
@@ -198,19 +204,22 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
show_center_mark: false,
show_cross_hairs: false,
});
let page_layer = |b: &mut Builder<'_>, art: Vec<Arc<Node>>| {
let mut layer = Node::layer(b.id(), &format!("Page {}", number + 1), LayerColor::Preset((i % 27) as u8));
if let NodeKind::Layer { children: c, .. } = &mut layer.kind {
*c = art;
let page_layer = |b: &mut Builder<'_>, art: Vec<Arc<Node>>, shown: bool| {
let name = if shown { format!("Page {}", number + 1) } else { format!("Page {} (unsorted)", number + 1) };
let mut layer = Node::layer(b.id(), &name, LayerColor::Preset((i % 27) as u8));
layer.visible = shown;
if let NodeKind::Layer { children: c, printable, .. } = &mut layer.kind {
(*c, *printable) = (art, shown);
}
Slot::Page(Box::new(layer))
};
if parts.is_empty() {
slots.push(page_layer(&mut b, vec![]));
slots.push(page_layer(&mut b, vec![], true));
}
for (key, art) in parts {
match key {
None => slots.push(page_layer(&mut b, art)),
None => slots.push(page_layer(&mut b, art, true)),
Some(UNSORTED) => slots.push(page_layer(&mut b, art, false)),
Some(g) => {
let into = group_art.entry(g).or_insert_with(|| {
place_group(g, &ocgs, &mut slots, &mut nesting);
@@ -542,6 +551,9 @@ struct Builder<'p> {
aligned: bool,
/// Whether optional content groups become layers.
route: bool,
/// Some groups are hidden or don't print: art in marked content whose group can't be told
/// (once the scan and the interpreter part) is [`UNSORTED`] rather than the page's.
unsure: bool,
/// The marked-content sequences open: the group each marks.
marked: Vec<Option<usize>>,
/// A transparency group was just pushed: a form's (whose flags come next) or an image's.
@@ -678,6 +690,7 @@ impl<'p> Builder<'p> {
group_at: 0,
aligned: true,
route,
unsure: false,
marked: vec![],
pending: false,
nested: 0,
@@ -1521,13 +1534,15 @@ impl<'a> Device<'a> for Builder<'_> {
let at = self.tag_at;
self.tag_at += 1;
let group = match self.scan.tags.get(at) {
Some((t, g)) if self.aligned && t.as_slice() == tag => *g,
Some((t, g)) if self.aligned && *t == tag_key(tag) => *g,
_ => {
if self.route && self.aligned {
self.aligned = false;
self.warn("some art couldn't be told apart by layer and went to its page's layer");
let why = if self.scan.cut { "a page draws more than can be read for its layers: " } else { "" };
let went = if self.unsure { "a hidden, non-printing layer of its page" } else { "its page's layer" };
self.warn(&format!("{why}some art couldn't be told apart by layer and went to {went}"));
}
None
self.unsure.then_some(UNSORTED)
}
}
.filter(|_| self.route);
+41 -8
View File
@@ -21,8 +21,14 @@ use hayro_syntax::page::{Page, Resources};
/// The interpreter's limit on nested form XObjects.
const MAX_DEPTH: u32 = 50;
/// The most content operators walked per page: past it the rest of the page isn't told apart.
const MAX_OPS: usize = 2_000_000;
/// The most content operators walked per page (a guard against forms drawn within each other
/// many times over): the art past it can't be told apart by group. A drawing of a million paths
/// takes a few million; the walk reads about 15 million a second, twice as fast as the
/// interpreter draws them.
const MAX_OPS: usize = 64_000_000;
/// The most marked-content sequences noted per page (24 bytes each): past it the rest of the
/// page isn't told apart either.
const MAX_TAGS: usize = 4_000_000;
/// The most groups read.
const MAX_GROUPS: usize = 10_000;
/// The deepest layers nest in the configuration's `/Order`.
@@ -206,10 +212,35 @@ pub(crate) fn text_string(b: &[u8]) -> String {
/// One page as the interpreter walks it.
#[derive(Default)]
pub(crate) struct Scan {
/// Each marked-content sequence: its tag and the group (index into [`Ocgs::list`]) it marks.
pub tags: Vec<(Vec<u8>, Option<usize>)>,
/// Each marked-content sequence: its tag ([`tag_key`]) and the group (index into
/// [`Ocgs::list`]) it marks.
pub tags: Vec<(u64, Option<usize>)>,
/// Each form transparency group: isolated, knockout.
pub groups: Vec<(bool, bool)>,
/// The walk stopped at [`MAX_OPS`] or [`MAX_TAGS`]: the rest of the page isn't told apart.
pub cut: bool,
}
/// A marked-content tag as [`Scan::tags`] keeps it: its FNV-1a hash (the tags are only compared,
/// and a page can have millions).
pub(crate) fn tag_key(tag: &[u8]) -> u64 {
tag.iter().fold(0xcbf2_9ce4_8422_2325, |h, &b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3))
}
/// [`MAX_OPS`], lowered by tests that need a page past it.
#[cfg(not(test))]
fn max_ops() -> usize {
MAX_OPS
}
#[cfg(test)]
thread_local! {
pub(crate) static TEST_MAX_OPS: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_OPS) };
}
#[cfg(test)]
fn max_ops() -> usize {
TEST_MAX_OPS.with(std::cell::Cell::get)
}
struct Walker<'w, 'p> {
@@ -220,6 +251,7 @@ struct Walker<'w, 'p> {
fonts: &'w mut HashMap<u128, String>,
visible: Vec<bool>,
ops: usize,
max_ops: usize,
out: Scan,
/// A form XObject hidden by its own optional content was met.
hidden_form: bool,
@@ -273,7 +305,8 @@ impl Walker<'_, '_> {
self.note_fonts(res);
while let Some(op) = iter.next() {
self.ops += 1;
if self.ops > MAX_OPS {
if self.ops > self.max_ops || self.out.tags.len() >= MAX_TAGS {
self.out.cut = true;
return;
}
match op {
@@ -296,11 +329,11 @@ impl Walker<'_, '_> {
None
}
};
self.out.tags.push((bdc.0.to_vec(), group));
self.out.tags.push((tag_key(bdc.0.as_ref()), group));
}
TypedInstruction::BeginMarkedContent(bmc) => {
self.begin(true);
self.out.tags.push((bmc.0.to_vec(), None));
self.out.tags.push((tag_key(bmc.0.as_ref()), None));
}
TypedInstruction::EndMarkedContent(_) => {
self.visible.pop();
@@ -347,7 +380,7 @@ pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut HashMap<u128, String>) -> Walker<'w, 'p> {
let off = if all_on { HashSet::new() } else { ocgs.off.clone() };
let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, out: Scan::default(), hidden_form: false };
let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, max_ops: max_ops(), out: Scan::default(), hidden_form: false };
w.walk(page.typed_operations(), page.resources(), 0);
w
}
+55 -1
View File
@@ -1,5 +1,6 @@
//! Optional content groups become layers: name, visibility (the default configuration's or the
//! view state's), print state, lock and nesting; art that is off comes in as a hidden layer.
//! view state's), print state, lock and nesting; art that is off comes in as a hidden layer, and
//! art past what is read of a huge page never shows or prints a hidden layer.
use vectorcraft_color::Color;
use vectorcraft_doc::{Document, Node, NodeKind};
@@ -298,3 +299,56 @@ fn thousands_of_layers_hidden_ones_included_all_come_in() {
assert_eq!(d.layers.len(), N);
assert!(d.layers.iter().enumerate().all(|(i, l)| l.visible == (i % 2 == 1) && l.children().is_some_and(|c| c.len() == 1)));
}
/// Import `bytes` with the scan's operator limit lowered to `n`.
fn import_with_max_ops(bytes: &[u8], n: usize) -> ImportReport {
let old = crate::import_scan::TEST_MAX_OPS.replace(n);
let r = import_with_report(bytes, &ImportOptions::default());
crate::import_scan::TEST_MAX_OPS.set(old);
r.unwrap()
}
#[test]
fn art_past_the_scan_limit_never_shows_a_hidden_layer() {
// "Shapes" draws 100 operators before its square, then "Hidden" (off, not printed) its own.
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let filler = "q Q ".repeat(50);
let page = PdfPage {
resources: format!("/Properties << /MC0 {} 0 R /MC1 {} 0 R >>", ids[0], ids[1]),
..PdfPage::new(
100.0,
100.0,
&format!("/OC /MC0 BDC {filler}1 0 0 rg 10 10 30 30 re f EMC /OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC 0 1 0 rg 0 0 5 5 re f"),
)
};
let bytes = pdf_with_catalog(&[page], &[&objs[0], &objs[1]], &catalog, None);
// Within the limit, every group is told apart.
assert_eq!(names(&import(&bytes).unwrap()), ["Shapes", "Hidden", "Page 1"]);
let r = import_with_max_ops(&bytes, 60);
let d = &r.document;
assert_eq!(names(d), ["Shapes", "Page 1 (unsorted)", "Page 1"], "{:?}", r.warnings);
// The art drawn past the limit in a group already open stays in it.
assert!(d.layers[0].visible);
assert_eq!(colors(&d.layers[0]), [Color::rgb(1.0, 0.0, 0.0)]);
// The hidden group's art, past it, can't be told apart: it neither shows nor prints.
let unsorted = &d.layers[1];
assert!(!unsorted.visible && !printable(unsorted));
assert_eq!(colors(unsorted), [Color::rgb(0.0, 0.0, 1.0)]);
// Art outside any group is the page's, as before.
assert!(d.layers[2].visible && printable(&d.layers[2]));
assert_eq!(colors(&d.layers[2]), [Color::rgb(0.0, 1.0, 0.0)]);
assert!(r.warnings.iter().any(|w| w.contains("more than can be read") && w.contains("hidden, non-printing")), "{:?}", r.warnings);
}
#[test]
fn art_past_the_scan_limit_of_shown_layers_stays_shown() {
// Every group shows and prints: what can't be told apart goes to the page's layer, shown.
let bytes = three_groups([&ocg("A", ""), &ocg("B", ""), &ocg("C", "")], &[], "");
let r = import_with_max_ops(&bytes, 4);
let d = &r.document;
assert_eq!(names(d), ["A", "Page 1"], "{:?}", r.warnings);
assert_eq!(colors(&d.layers[0]), [Color::rgb(1.0, 0.0, 0.0)]);
assert!(d.layers[1].visible && printable(&d.layers[1]));
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)]);
assert!(r.warnings.iter().any(|w| w.contains("went to its page's layer")), "{:?}", r.warnings);
}
+2 -1
View File
@@ -13,7 +13,8 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
.collapsible(false)
.resizable(false)
.open(&mut open)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(18)))
.show(ctx, |ui| {
// Tabs: About · Contributors · Models (the credits are compiled in, see `credits`).
+65 -18
View File
@@ -17,16 +17,20 @@ pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
/// [`field`] `width` points wide.
pub(super) fn field_w(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, width: f32) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
row_label(ui, label);
text(ui, d, key, width);
ui.end_row();
}
/// A labelled [`length`] field (one grid row).
pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, unit: Unit) {
/// The label cell of a grid row.
fn row_label(ui: &mut egui::Ui, label: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
}
/// A labelled [`length`] field (one grid row).
pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, unit: Unit) {
row_label(ui, label);
length(ui, d, key, unit, FIELD_W);
ui.end_row();
}
@@ -57,6 +61,30 @@ fn lengths(kind: &str) -> &'static [&'static str] {
}
}
/// The fields of the form dialogs ([`grid`]) that come first, in this order (the others follow by
/// name).
fn order(kind: &str) -> &'static [&'static str] {
match kind {
"offsetPath" => &["offset", "joins", "miterLimit"],
"splitIntoGrid" => &["rows", "columns", "gutter"],
_ => &[],
}
}
/// The choices of a form dialog's ([`grid`]) dropdown field, as (value, label in the UI
/// language); empty for a field typed as text.
fn choices(kind: &str, key: &str) -> Vec<(&'static str, &'static str)> {
// The axes in the "axis" context: some languages read the plain Horizontal and Vertical as
// type orientations.
let axis = |value, label| (value, crate::i18n::tr_ctx(crate::i18n::current(), "axis", label));
match (kind, key) {
("offsetPath", "joins") => vec![("miter", tl!("Miter")), ("round", tl!("Round")), ("bevel", tl!("Bevel"))],
("average", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical"), axis("both", "Both")],
("reflect" | "shear", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical")],
_ => vec![],
}
}
/// A text field `width` wide bound to `d.fields[key]`. Returns true when it changed.
pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> bool {
text_edit(ui, d, key, width).changed()
@@ -111,30 +139,49 @@ pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
/// A dropdown row bound to the string `d.fields[key]`: `label` in a column `widths.0` wide, the
/// dropdown `widths.1` wide; `options` are (value, label).
pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, widths: (f32, f32), options: &[(&str, &str)]) {
crate::widgets::label_row(ui, label, widths.0, |ui| dropdown(ui, d, key, widths.1, options, false));
}
/// A dropdown `width` wide bound to the string `d.fields[key]`; `options` are (value, label), the
/// labels shown as they are when `translated` (else the dropdown translates them). A value that
/// isn't among them shows as it is.
fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, options: &[(&str, &str)], translated: bool) {
let cur = d.str(key);
let shown = options.iter().find(|(v, _)| v.eq_ignore_ascii_case(&cur)).map_or(cur.as_str(), |(_, l)| l);
let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
crate::widgets::label_row(ui, label, widths.0, |ui| {
if let Some((value, _)) = crate::widgets::dropdown(ui, key, shown, &labels, widths.1).and_then(|i| options.get(i)) {
d.fields.insert(key.into(), json!(value));
}
});
let chosen = if translated {
crate::widgets::dropdown_names(ui, key, shown, &labels, width)
} else {
crate::widgets::dropdown(ui, key, shown, &labels, width)
};
if let Some((value, _)) = chosen.and_then(|i| options.get(i)) {
d.fields.insert(key.into(), json!(value));
}
}
/// The generic dialog body: a field per non-boolean value (positions and indices hidden), the
/// distances in `unit`.
/// The generic dialog body: a field per non-boolean value (positions, indices and UI-only keys
/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns.
pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
let lengths = lengths(&d.kind);
let (lengths, first) = (lengths(&d.kind), order(&d.kind));
let mut keys: Vec<String> = d
.fields
.iter()
.filter(|(k, v)| !matches!(k.as_str(), "x" | "y" | "origin" | "index") && !k.starts_with("__") && !v.is_boolean())
.map(|(k, _)| k.clone())
.collect();
// Stable: the fields `order` doesn't name keep their order after the ones it does.
keys.sort_by_key(|k| first.iter().position(|f| f == k).unwrap_or(first.len()));
egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
let keys: Vec<(String, Value)> = d.fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
for (k, v) in keys {
if k == "x" || k == "y" || k == "origin" || k == "index" || v.is_boolean() {
continue;
}
for k in keys {
let options = choices(&d.kind, &k);
if lengths.contains(&k.as_str()) {
length_field(ui, d, &k, &humanized(&k), unit);
} else {
} else if options.is_empty() {
field(ui, d, &k, &humanized(&k));
} else {
row_label(ui, &humanized(&k));
dropdown(ui, d, &k, FIELD_W, &options, true);
ui.end_row();
}
}
});
+39 -50
View File
@@ -40,6 +40,7 @@ pub mod layer_options;
pub mod layers_panel_options;
pub mod liquify;
pub mod missing_links;
pub(crate) mod modal;
pub mod new_color_group;
mod new_document;
pub mod new_swatch;
@@ -99,7 +100,6 @@ pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset};
pub use tools::open_tool_dialog;
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets};
type DialogResult = Result<Value, String>;
@@ -342,62 +342,48 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if let Some(window) = spec.window {
return window(app, ctx);
}
let t = Tokens::get(ctx);
let mut ok = false;
let mut cancel = false;
let mut discard = false;
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
// Modal, but the canvas isn't dimmed so previews stay readable (as in the reference app).
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
let heading = (spec.heading)(&d);
egui::Window::new(heading.as_str())
// One window per kind, so a dialog never inherits another dialog's size.
.id(egui::Id::new(("dialog", d.kind.as_str())))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(MARGIN)))
.show(ctx, |ui| {
// Never wider than the window (a large UI scale in a small window): the text wraps.
let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP);
ui.set_min_width(spec.min_width.min(room));
ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room)));
ui.label(egui::RichText::new(heading.as_str()).font(theme::semibold(16.0)).color(t.text));
ui.add_space(12.0);
// Just opened: its first field takes the keyboard focus, as in the reference app (type a
// value, press Enter). Only the body's fields can take it.
if focus_first {
ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true));
modal::show(ctx, &heading, egui::Id::new(("dialog", d.kind.as_str())), -40.0, MARGIN, |ui| {
// Never wider than the window (a large UI scale in a small window): the text wraps.
let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP);
ui.set_min_width(spec.min_width.min(room));
ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room)));
modal::heading(ui, &heading);
ui.add_space(12.0);
// Just opened: its first field takes the keyboard focus, as in the reference app (type a
// value, press Enter). Only the body's fields can take it.
if focus_first {
ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true));
}
cancel = (spec.body)(app, ui, &mut d);
// Taken (the flag is gone): done. Still there (the window's measuring frame): next frame.
if focus_first && ui.data_mut(|m| m.remove_temp::<bool>(widgets::dialog_focus_flag())).is_none() {
ui.data_mut(|m| m.remove::<bool>(focus_id));
}
ui.add_space(16.0);
// The button row is as wide as the fields above it and as tall as the buttons: a
// right-to-left layout would otherwise take all the room left in the window, so the
// window could never shrink to its content.
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
&& widgets::primary_button(ui, label).clicked()
{
ok = true;
}
cancel = (spec.body)(app, ui, &mut d);
// Taken (the flag is gone): done. Still there (the window's measuring frame): next frame.
if focus_first && ui.data_mut(|m| m.remove_temp::<bool>(widgets::dialog_focus_flag())).is_none() {
ui.data_mut(|m| m.remove::<bool>(focus_id));
ui.add_space(8.0);
if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() {
cancel = true;
}
if let Some(label) = spec.discard {
ui.add_space(28.0);
discard = widgets::secondary_button(ui, label).clicked();
}
ui.add_space(16.0);
// The button row is as wide as the fields above it and as tall as the buttons: a
// right-to-left layout would otherwise take all the room left in the window, so the
// window could never shrink to its content.
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
&& widgets::primary_button(ui, label).clicked()
{
ok = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() {
cancel = true;
}
if let Some(label) = spec.discard {
ui.add_space(28.0);
discard = widgets::secondary_button(ui, label).clicked();
}
});
});
});
if spec.ok.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
ok = true;
}
@@ -482,3 +468,6 @@ mod tests_scale;
#[cfg(test)]
mod tests_perspective;
#[cfg(test)]
mod tests_modal;
+100
View File
@@ -0,0 +1,100 @@
//! The modal dialog window: a backdrop that keeps the rest of the app from taking clicks, and a
//! window with no title bar that opens centred and moves by dragging its heading (the band from its
//! top edge down to the heading), as a title bar would. Where it was moved to stays, per window, for
//! the session, and the window never leaves the screen.
use egui::{Id, Rect, Vec2};
use crate::theme::{self, Tokens};
/// Show the modal dialog window `id` (`title` is its accessible name), `nudge` points below the
/// screen's centre until it is moved, with `margin` inside its frame. `add` draws the contents and
/// should start with [`heading`] (or [`drag_band`]) so the window can be moved.
pub(crate) fn show<R>(ctx: &egui::Context, title: &str, id: Id, nudge: f32, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option<R> {
let t = Tokens::get(ctx);
egui::Area::new(Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::Pos2::ZERO).show(ctx, |ui| {
// Modal, but the canvas isn't dimmed so previews stay readable (as in the reference app).
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
egui::Window::new(title)
.id(id)
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, nudge))
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin)))
.show(ctx, add)
.and_then(|r| r.inner)
}
/// The dialog's heading, which also moves the window ([`drag_band`]).
pub(crate) fn heading(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
// Not selectable: a drag on it moves the window instead of selecting its text.
let r = ui.add(egui::Label::new(egui::RichText::new(text).font(theme::semibold(16.0)).color(t.text)).selectable(false));
drag_band(ui, r.rect.bottom());
}
/// Make the band of the dialog window [`show`] draws `ui` in, from its top edge down to `bottom`, a
/// handle that moves the window. Widgets added later on the band stay clickable (they sit above it).
pub(crate) fn drag_band(ui: &mut egui::Ui, bottom: f32) {
let id = ui.layer_id().id;
// Last frame's rectangle: none while the window measures itself the first time it shows.
let Some(outer) = ui.ctx().memory(|m| m.area_rect(id)) else { return };
let band = Rect::from_x_y_ranges(outer.x_range(), outer.top()..=bottom.max(outer.top()));
let r = ui.interact(band, handle(id), egui::Sense::drag());
if r.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
}
/// The drag handle of window `id`.
fn handle(id: Id) -> Id {
id.with("modal-drag")
}
/// Where window `id` sits, from the screen's centre: `nudge` down until it is first moved. While
/// its [`drag_band`] is dragged, where it sat when the drag began plus how far the pointer has moved
/// since the press, kept on screen (applied before the window places itself, so it follows the
/// pointer without a frame's lag).
fn offset(ctx: &egui::Context, id: Id, nudge: f32) -> Vec2 {
let (key, start_key) = (id.with("modal-offset"), id.with("modal-drag-start"));
let offset = ctx.data(|m| m.get_temp::<Vec2>(key)).unwrap_or(egui::vec2(0.0, nudge));
let dragged = ctx.read_response(handle(id)).is_some_and(|r| r.dragged());
let moved = ctx.input(|i| Some(i.pointer.latest_pos()? - i.pointer.press_origin()?));
let (true, Some(moved), Some(rect)) = (dragged, moved, ctx.memory(|m| m.area_rect(id))) else {
ctx.data_mut(|m| m.remove::<Vec2>(start_key));
return offset;
};
let start = ctx.data_mut(|m| *m.get_temp_mut_or(start_key, offset));
let screen = ctx.content_rect();
let to = on_screen(Rect::from_center_size(screen.center() + start + moved, rect.size()), screen).center() - screen.center();
ctx.data_mut(|m| m.insert_temp(key, to));
to
}
/// `r` moved the least to lie inside `screen` (its top left inside when it is larger).
fn on_screen(r: Rect, screen: Rect) -> Rect {
let x = r.left().min(screen.right() - r.width()).max(screen.left());
let y = r.top().min(screen.bottom() - r.height()).max(screen.top());
Rect::from_min_size(egui::pos2(x, y), r.size())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_moved_window_stays_on_screen() {
let screen = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
let r = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(egui::pos2(x, y), egui::vec2(w, h));
assert_eq!(on_screen(r(100.0, 100.0, 200.0, 100.0), screen), r(100.0, 100.0, 200.0, 100.0));
assert_eq!(on_screen(r(-50.0, 550.0, 200.0, 100.0), screen), r(0.0, 500.0, 200.0, 100.0));
assert_eq!(on_screen(r(700.0, -20.0, 200.0, 100.0), screen), r(600.0, 0.0, 200.0, 100.0));
// Larger than the screen: its top left stays in view.
assert_eq!(on_screen(r(-10.0, -10.0, 900.0, 700.0), screen), r(0.0, 0.0, 900.0, 700.0));
}
}
+6 -15
View File
@@ -151,21 +151,9 @@ fn set_layout(d: &mut Dialog, key: &str, v: Value) {
d.fields.insert("artboardLayout".into(), l);
}
/// The dimmed backdrop and a centred dialog window in the shared dialog style.
/// The New Document dialog window `id` in the shared modal frame.
fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui::Ui)) {
let t = Tokens::get(ctx);
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
egui::Window::new(id)
.id(egui::Id::new(("dialog", id)))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin)))
.show(ctx, add);
super::modal::show(ctx, id, egui::Id::new(("dialog", id)), -20.0, margin, add);
}
/// What the buttons of a window asked for.
@@ -194,6 +182,9 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let mut b = Buttons::default();
window(ctx, KIND, 0, |ui| {
// No heading: the top margin (14) and the empty part of the category tabs' row (30) move the
// window.
super::modal::drag_band(ui, ui.max_rect().top() + 44.0);
ui.horizontal_top(|ui| {
egui::Frame::NONE.inner_margin(egui::Margin { left: 22, right: 14, top: 14, bottom: 18 }).show(ui, |ui| {
ui.vertical(|ui| {
@@ -489,7 +480,7 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
const L: f32 = 150.0;
window(ctx, MORE, 22, |ui| {
ui.set_width(560.0);
ui.label(egui::RichText::new(tl!("More Settings")).font(theme::semibold(16.0)).color(t.text));
super::modal::heading(ui, tl!("More Settings"));
ui.add_space(12.0);
widgets::label_row(ui, tl!("Name:"), L, |ui| {
form::text(ui, &mut d, "name", 300.0);
+21 -7
View File
@@ -1,12 +1,12 @@
//! Path dialogs: Average, Offset Path, Simplify and Split Into Grid.
//! Path dialogs: Average, Offset Path (with Preview), Simplify and Split Into Grid.
use serde_json::{Value, json};
use super::{DialogSpec, run_and_close};
use super::{DialogSpec, form, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), confirm, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, preview: true, ..DialogSpec::FORM };
fn title(kind: &str) -> &'static str {
match kind {
@@ -17,14 +17,28 @@ fn title(kind: &str) -> &'static str {
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (id, params) = match d.kind.as_str() {
/// The command the dialog runs and its parameters.
fn command(d: &Dialog) -> (&'static str, Value) {
match d.kind.as_str() {
"average" => ("path.average", json!({"axis": d.str("axis")})),
"offsetPath" => {
("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)}))
}
"simplify" => ("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})),
_ => ("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})),
};
run_and_close(app, id, params)
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::grid(ui, d, app.session.general_unit());
if d.kind == "offsetPath" {
let (id, params) = command(d);
form::preview(app, ui, d, "Offset Path", id, params);
}
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (id, params) = command(d);
if d.kind == "offsetPath" { form::commit_preview(app, id, params) } else { run_and_close(app, id, params) }
}
+1
View File
@@ -4,6 +4,7 @@ use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
use crate::theme;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -9,6 +9,7 @@ use vectorcraft_engine::cmd::fileio;
use super::*;
use crate::Services;
use crate::theme;
pub(super) type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
@@ -7,6 +7,7 @@ use vectorcraft_testkit::pdf::{PdfPage, pdf};
use super::*;
use crate::io;
use crate::theme;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+203
View File
@@ -0,0 +1,203 @@
//! #423: modal dialogs move by their heading (and stay on screen and where they were moved), and
//! the form dialogs' choice fields are dropdowns (Offset Path's Joins).
use egui::{Event, Pos2, Rect, Vec2, vec2};
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
use crate::theme;
const SCREEN: Vec2 = vec2(1280.0, 900.0);
/// A context drawing the dialog layer in a 1280 × 900 window.
fn ctx() -> egui::Context {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
ctx
}
/// One frame of the dialog layer with `events`: every text drawn, with its screen rect.
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<Event>) -> Vec<(String, Rect)> {
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), events, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
fn collect(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
_ => {}
}
}
let mut texts = vec![];
out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts));
texts
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
}
/// Drag with the mouse from `from` by `by`, in steps, then let go.
fn drag(ctx: &egui::Context, app: &mut VectorcraftApp, from: Pos2, by: Vec2) {
frame(ctx, app, vec![Event::PointerMoved(from), button(from, true)]);
for i in 1..=6 {
frame(ctx, app, vec![Event::PointerMoved(from + by * (i as f32 / 6.0))]);
}
frame(ctx, app, vec![button(from + by, false)]);
frame(ctx, app, vec![]);
}
fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: Pos2) -> Vec<(String, Rect)> {
frame(ctx, app, vec![Event::PointerMoved(pos), button(pos, true)]);
frame(ctx, app, vec![button(pos, false)]);
frame(ctx, app, vec![])
}
/// Frames until a window that just opened has measured and placed itself.
fn settle(ctx: &egui::Context, app: &mut VectorcraftApp) {
for _ in 0..6 {
frame(ctx, app, vec![]);
}
}
fn rect_of(ctx: &egui::Context, id: egui::Id) -> Rect {
ctx.memory(|m| m.area_rect(id)).expect("the dialog window was shown")
}
fn app_with_selection() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 80, "height": 60})).unwrap();
app
}
fn close_to(a: Vec2, b: Vec2) -> bool {
(a - b).length() < 1.5
}
/// Opens a dialog.
type Open = fn(&mut VectorcraftApp);
/// Every kind of modal window: the shared frame (Offset Path), and the ones drawing their own
/// window (New Document without a heading, Preferences, Keyboard Shortcuts).
#[test]
fn dialogs_move_by_their_heading_stay_on_screen_and_where_they_were_moved() {
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
let opens: [(&str, Open, egui::Id); 4] = [
("Offset Path", |app| crate::menus::invoke(app, "object.path.offsetPath", json!({})), egui::Id::new(("dialog", "offsetPath"))),
("New Document", open_new_document, egui::Id::new(("dialog", "newDocument"))),
("Preferences", |app| crate::prefs_dialog::open(app, None), egui::Id::new("dialog-preferences")),
("Keyboard Shortcuts", crate::shortcut_editor::open, egui::Id::new("dialog-shortcuts")),
];
for (name, open, id) in opens {
let (ctx, mut app) = (ctx(), app_with_selection());
open(&mut app);
settle(&ctx, &mut app);
let start = rect_of(&ctx, id);
// Dragging the heading (the band from the top edge down to it) moves the window.
drag(&ctx, &mut app, start.left_top() + vec2(40.0, 10.0), vec2(-90.0, 50.0));
let moved = rect_of(&ctx, id);
assert!(close_to(moved.min - start.min, vec2(-90.0, 50.0)), "{name}: {start:?} → {moved:?}");
// A drag that starts below the heading (in the bottom margin) doesn't.
drag(&ctx, &mut app, moved.left_bottom() + vec2(6.0, -4.0), vec2(80.0, -80.0));
assert_eq!(rect_of(&ctx, id), moved, "{name}: dragging the body moved it");
// Dragged far past the window's edge, it stops at the edge.
drag(&ctx, &mut app, moved.left_top() + vec2(40.0, 10.0), vec2(5000.0, 5000.0));
let edge = rect_of(&ctx, id);
assert!(screen.contains_rect(edge.shrink(0.5)), "{name}: off screen at {edge:?}");
assert!((edge.right() - screen.right()).abs() < 1.5 && (edge.bottom() - screen.bottom()).abs() < 1.5, "{name}: {edge:?}");
// Closed and opened again (this session): where it was left.
app.ui.dialog = None;
frame(&ctx, &mut app, vec![]);
open(&mut app);
settle(&ctx, &mut app);
assert!(close_to(rect_of(&ctx, id).min - edge.min, Vec2::ZERO), "{name}: reopened at {:?}, left at {edge:?}", rect_of(&ctx, id));
}
}
/// Offset Path's Joins is a dropdown of Miter, Round and Bevel (it was a text field), listed after
/// Offset; choosing Round runs the command with round joins.
#[test]
fn offset_path_joins_is_a_dropdown() {
let (ctx, mut app) = (ctx(), app_with_selection());
app.run("select.all", json!({})).unwrap();
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
frame(&ctx, &mut app, vec![]);
let texts = frame(&ctx, &mut app, vec![]);
let at = |texts: &[(String, Rect)], s: &str| texts.iter().find(|(t, _)| t == s).map(|(_, r)| *r);
let (offset, joins, miter) = (at(&texts, "Offset:").unwrap(), at(&texts, "Joins:").unwrap(), at(&texts, "Miter Limit:").unwrap());
assert!(offset.top() < joins.top() && joins.top() < miter.top(), "Offset, Joins, Miter Limit: {texts:?}");
let current = at(&texts, "Miter").expect("Joins shows its choice");
assert!(at(&texts, "miter").is_none(), "Joins is a text field: {texts:?}");
// The list: Miter, Round, Bevel.
let open = click(&ctx, &mut app, current.center());
for choice in ["Round", "Bevel"] {
assert!(at(&open, choice).is_some(), "{choice} isn't listed: {open:?}");
}
let round = at(&open, "Round").unwrap();
click(&ctx, &mut app, round.center());
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["joins"], json!("round"));
// OK: the same result as the command with round joins (not miter).
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let made = |app: &VectorcraftApp| {
let st = app.session.doc().unwrap();
format!("{:?}", st.doc.node(st.selection.objects[0]).unwrap().kind)
};
let direct = |joins: &str| {
let mut app = app_with_selection();
app.run("select.all", json!({})).unwrap();
app.run("object.path.offsetPath", json!({"offset": 10, "joins": joins, "miterLimit": 4})).unwrap();
made(&app)
};
assert_eq!(made(&app), direct("round"));
assert_ne!(made(&app), direct("miter"));
}
/// The other form dialogs' choices are dropdowns too: Average's and Shear's axis.
#[test]
fn form_dialog_axes_are_dropdowns() {
for (id, shown) in [("path.average", "Both"), ("object.shear", "Horizontal"), ("object.reflect", "Vertical")] {
let (ctx, mut app) = (ctx(), app_with_selection());
app.run("select.all", json!({})).unwrap();
crate::menus::invoke(&mut app, id, json!({}));
frame(&ctx, &mut app, vec![]);
let texts = frame(&ctx, &mut app, vec![]);
assert!(texts.iter().any(|(t, _)| t == shown), "{id}: {texts:?}");
assert!(!texts.iter().any(|(t, _)| *t == shown.to_lowercase()), "{id} has a text field: {texts:?}");
}
}
/// The axis choices are translated in the "axis" context: in Japanese the plain Horizontal and
/// Vertical are the type orientations (horizontal and vertical writing).
#[test]
fn axis_choices_read_as_axes_in_every_complete_language() {
use crate::i18n::{LANGUAGES, Lang, tr, tr_ctx};
let ja = Lang::from_code("ja").unwrap();
assert_eq!(tr_ctx(ja, "axis", "Horizontal"), "水平方向");
assert_ne!(tr_ctx(ja, "axis", "Horizontal"), tr(ja, "Horizontal"));
for l in LANGUAGES.iter().filter(|l| l.complete_menus) {
for s in ["Horizontal", "Vertical", "Both"] {
assert!(l.source.lines().any(|line| line.starts_with(&format!("axis\t{s}\t"))), "{}: no axis row for {s}", l.code);
}
}
}
/// Offset Path previews on the canvas; Cancel rolls the preview back.
#[test]
fn offset_path_previews() {
let (ctx, mut app) = (ctx(), app_with_selection());
app.run("select.all", json!({})).unwrap();
let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::<usize>();
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
let texts = frame(&ctx, &mut app, vec![]);
let texts = if texts.is_empty() { frame(&ctx, &mut app, vec![]) } else { texts };
let preview = texts.iter().find(|(t, _)| t == "Preview").map(|(_, r)| *r).expect("a Preview checkbox");
click(&ctx, &mut app, preview.center());
frame(&ctx, &mut app, vec![]);
assert_eq!(count(&app), 2, "previewed");
cancel(&mut app);
assert_eq!(count(&app), 1, "rolled back");
}
@@ -5,6 +5,7 @@ use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
use crate::theme;
/// The save prompt's rect after a few frames in a window of `physical` pixels at UI scale `zoom`.
fn prompt(app: &mut VectorcraftApp, ctx: &egui::Context, physical: egui::Vec2, zoom: f32) -> (egui::Rect, egui::Rect) {
+58 -69
View File
@@ -5,7 +5,7 @@ use egui::Ui;
use serde_json::{Value, json};
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::theme::Tokens;
use crate::widgets;
use crate::{VectorcraftApp, font_menu};
@@ -37,77 +37,66 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let sample = font_menu::sample_text(app);
let mut close = false;
let mut act: Option<&str> = None;
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
egui::Window::new(tl!("Find Font"))
.id(egui::Id::new("dialog-find-font"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22)))
.show(ctx, |ui: &mut Ui| {
ui.set_width(380.0);
ui.label(egui::RichText::new(tl!("Find Font")).font(theme::semibold(16.0)).color(t.text));
ui.add_space(10.0);
widgets::subheader(ui, &crate::i18n::fmt(tl!("Fonts in Document: {count}"), &[("count", &list.len().to_string())]));
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.set_min_height(120.0);
ui.set_width(ui.available_width());
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
for (i, f) in list.iter().enumerate() {
let (note, color) = font_note(f);
let label = format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]);
let text = egui::RichText::new(label).color(color.unwrap_or(t.text));
if ui.selectable_label(i == sel, text).clicked() {
d.fields.insert("selected".into(), json!(i));
}
crate::dialogs::modal::show(ctx, tl!("Find Font"), egui::Id::new("dialog-find-font"), -40.0, 22, |ui: &mut Ui| {
ui.set_width(380.0);
crate::dialogs::modal::heading(ui, tl!("Find Font"));
ui.add_space(10.0);
widgets::subheader(ui, &crate::i18n::fmt(tl!("Fonts in Document: {count}"), &[("count", &list.len().to_string())]));
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.set_min_height(120.0);
ui.set_width(ui.available_width());
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
for (i, f) in list.iter().enumerate() {
let (note, color) = font_note(f);
let label = format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]);
let text = egui::RichText::new(label).color(color.unwrap_or(t.text));
if ui.selectable_label(i == sel, text).clicked() {
d.fields.insert("selected".into(), json!(i));
}
});
});
ui.add_space(10.0);
widgets::subheader(ui, tl!("Replace With Font"));
let fam = d.str("family");
ui.horizontal(|ui| {
// Picks the font only: nothing is previewed on the document.
let pick = font_menu::font_menu(ui, "ff-family", &fam, 220.0, sample.as_deref(), font_menu::MenuLook::of(app));
if let Some((f, style)) = font_menu::picked(app, pick) {
d.fields.insert("family".into(), json!(f));
d.fields.insert("style".into(), json!(style.unwrap_or_default()));
}
let styles = vectorcraft_text::FontDb::global().styles(&d.str("family"));
// "(closest)" is ours; the font's style names are shown as they are.
let closest = tl!("(closest)");
let mut opts: Vec<&str> = vec![closest];
opts.extend(styles.iter().map(String::as_str));
let style = d.str("style");
let cur = if style.is_empty() { closest } else { style.as_str() };
if let Some(i) = widgets::dropdown_names(ui, "ff-style", cur, &opts, 120.0) {
d.fields.insert("style".into(), json!(if i == 0 { String::new() } else { styles[i - 1].clone() }));
}
});
ui.add_space(14.0);
ui.horizontal(|ui| {
let has = sel < list.len();
if ui.add_enabled(has, egui::Button::new(tl!("Find"))).clicked() {
act = Some("find");
}
if ui.add_enabled(has, egui::Button::new(tl!("Change"))).on_hover_text(tl!("In the selected objects")).clicked() {
act = Some("change");
}
if ui.add_enabled(has, egui::Button::new(tl!("Change All"))).clicked() {
act = Some("changeAll");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("Done")).clicked() {
close = true;
}
});
});
});
ui.add_space(10.0);
widgets::subheader(ui, tl!("Replace With Font"));
let fam = d.str("family");
ui.horizontal(|ui| {
// Picks the font only: nothing is previewed on the document.
let pick = font_menu::font_menu(ui, "ff-family", &fam, 220.0, sample.as_deref(), font_menu::MenuLook::of(app));
if let Some((f, style)) = font_menu::picked(app, pick) {
d.fields.insert("family".into(), json!(f));
d.fields.insert("style".into(), json!(style.unwrap_or_default()));
}
let styles = vectorcraft_text::FontDb::global().styles(&d.str("family"));
// "(closest)" is ours; the font's style names are shown as they are.
let closest = tl!("(closest)");
let mut opts: Vec<&str> = vec![closest];
opts.extend(styles.iter().map(String::as_str));
let style = d.str("style");
let cur = if style.is_empty() { closest } else { style.as_str() };
if let Some(i) = widgets::dropdown_names(ui, "ff-style", cur, &opts, 120.0) {
d.fields.insert("style".into(), json!(if i == 0 { String::new() } else { styles[i - 1].clone() }));
}
});
ui.add_space(14.0);
ui.horizontal(|ui| {
let has = sel < list.len();
if ui.add_enabled(has, egui::Button::new(tl!("Find"))).clicked() {
act = Some("find");
}
if ui.add_enabled(has, egui::Button::new(tl!("Change"))).on_hover_text(tl!("In the selected objects")).clicked() {
act = Some("change");
}
if ui.add_enabled(has, egui::Button::new(tl!("Change All"))).clicked() {
act = Some("changeAll");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("Done")).clicked() {
close = true;
}
});
});
});
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
if let (Some(a), Some(from)) = (act, list.get(sel)) {
let r = match a {
@@ -157,7 +146,7 @@ mod tests {
app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Missing Family"})).unwrap();
open(&mut app);
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui.ctx()));
out.textures_delta.clear();
let d = app.ui.dialog.clone().unwrap();
+8
View File
@@ -3013,3 +3013,11 @@
Version Versión
% of all commits % de todos los commits
Lines +/− Líneas +/−
Joins Uniones
Miter Inglete
Bevel Bisel
Offset Desplazamiento
Axis Eje
axis Horizontal Horizontal
axis Vertical Vertical
axis Both Ambos
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -3002,3 +3002,11 @@
Version バージョン
% of all commits 全コミットに占める割合
Lines +/− 行数 +/−
Joins 角の形状
Miter マイター
Bevel ベベル
Offset オフセット
Axis 軸
axis Horizontal 水平方向
axis Vertical 垂直方向
axis Both 両方
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -3260,3 +3260,11 @@
Version 版本
% of all commits 占全部提交的百分比
Lines +/− 行数 +/−
Joins 连接
Miter 斜接
Bevel 斜角
Offset 偏移
Axis 轴
axis Horizontal 水平
axis Vertical 垂直
axis Both 两者
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -3033,3 +3033,11 @@
Version 版本
% of all commits 佔全部提交的比例
Lines +/− 行數 +/−
Joins 轉角
Miter 尖角
Bevel 斜角
Offset 位移
Axis 軸
axis Horizontal 水平
axis Vertical 垂直
axis Both 兩者
Can't render this file because it contains an unexpected character in line 6 and column 47.
+52 -66
View File
@@ -122,81 +122,67 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.dialog.clone() else { return };
let t = Tokens::get(ctx);
let (mut ok, mut cancel, mut reset) = (false, false, false);
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
let cat = d.str("__category");
let cat = PREF_CATEGORIES.iter().find(|c| **c == cat).copied().unwrap_or(PREF_CATEGORIES[0]);
egui::Window::new(tl!("Preferences"))
.id(egui::Id::new("dialog-preferences"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
.show(ctx, |ui| {
ui.set_width(760.0);
ui.label(egui::RichText::new(tl!("Preferences")).font(theme::semibold(16.0)).color(t.text));
ui.add_space(12.0);
ui.horizontal_top(|ui| {
// Category list.
egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(
ui,
|ui| {
ui.set_width(196.0);
ui.set_min_height(430.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
for c in PREF_CATEGORIES {
let sel = *c == cat;
let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text });
let b = egui::Button::selectable(sel, text).frame_when_inactive(false).min_size(egui::vec2(184.0, 24.0));
if ui.add(b).clicked() {
d.fields.insert("__category".into(), json!(c));
}
}
});
},
);
ui.add_space(14.0);
// Fields.
crate::dialogs::modal::show(ctx, tl!("Preferences"), egui::Id::new("dialog-preferences"), -20.0, 20, |ui| {
ui.set_width(760.0);
crate::dialogs::modal::heading(ui, tl!("Preferences"));
ui.add_space(12.0);
ui.horizontal_top(|ui| {
// Category list.
egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
ui.set_width(196.0);
ui.set_min_height(430.0);
ui.vertical(|ui| {
ui.set_width(530.0);
ui.label(egui::RichText::new(tl!(cat)).font(theme::semibold(14.0)).color(t.text_strong));
ui.add_space(8.0);
egui::ScrollArea::vertical().id_salt(("prefs", cat)).max_height(400.0).auto_shrink([false, false]).show(ui, |ui| {
category_fields(ui, &mut d, cat);
});
ui.spacing_mut().item_spacing.y = 1.0;
for c in PREF_CATEGORIES {
let sel = *c == cat;
let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text });
let b = egui::Button::selectable(sel, text).frame_when_inactive(false).min_size(egui::vec2(184.0, 24.0));
if ui.add(b).clicked() {
d.fields.insert("__category".into(), json!(c));
}
}
});
});
ui.add_space(14.0);
ui.horizontal(|ui| {
if widgets::secondary_button(ui, tl!("Reset Preferences"))
.on_hover_text(tl!("Restore every preference to its default (applied on OK)"))
.clicked()
{
reset = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("OK")).clicked() {
ok = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
ui.add_space(16.0);
let i = PREF_CATEGORIES.iter().position(|c| *c == cat).unwrap_or(0);
if ui.add_enabled(i + 1 < PREF_CATEGORIES.len(), egui::Button::new(tl!("Next"))).clicked() {
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i + 1]));
}
if ui.add_enabled(i > 0, egui::Button::new(tl!("Previous"))).clicked() {
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i - 1]));
}
// Fields.
ui.vertical(|ui| {
ui.set_width(530.0);
ui.label(egui::RichText::new(tl!(cat)).font(theme::semibold(14.0)).color(t.text_strong));
ui.add_space(8.0);
egui::ScrollArea::vertical().id_salt(("prefs", cat)).max_height(400.0).auto_shrink([false, false]).show(ui, |ui| {
category_fields(ui, &mut d, cat);
});
});
});
ui.add_space(14.0);
ui.horizontal(|ui| {
if widgets::secondary_button(ui, tl!("Reset Preferences"))
.on_hover_text(tl!("Restore every preference to its default (applied on OK)"))
.clicked()
{
reset = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("OK")).clicked() {
ok = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
ui.add_space(16.0);
let i = PREF_CATEGORIES.iter().position(|c| *c == cat).unwrap_or(0);
if ui.add_enabled(i + 1 < PREF_CATEGORIES.len(), egui::Button::new(tl!("Next"))).clicked() {
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i + 1]));
}
if ui.add_enabled(i > 0, egui::Button::new(tl!("Previous"))).clicked() {
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i - 1]));
}
});
});
});
if reset {
for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() {
d.fields.insert(k, v);
+154 -165
View File
@@ -521,178 +521,167 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
}
}
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
egui::Window::new(tl!("Keyboard Shortcuts"))
.id(egui::Id::new("dialog-shortcuts"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
.show(ctx, |ui| {
ui.set_width(640.0);
ui.label(egui::RichText::new(tl!("Keyboard Shortcuts")).font(theme::semibold(16.0)).color(t.text));
ui.add_space(10.0);
// Set row.
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Set:")).color(t.text_dim));
let cur = d.str("set");
let mut opts: Vec<&str> = PRESETS.to_vec();
if !PRESETS.contains(&cur.as_str()) {
opts.push(CUSTOM);
}
// The sets listed are ours (translated); a set read from an imported file shows
// its name as it is.
if let Some(&name) = crate::dialogs::mixed_dropdown(ui, "kbset", &cur, &opts, 200.0, |_| true).and_then(|i| opts.get(i))
&& let Some(p) = preset(name)
{
ov = p;
d.fields.insert("set".into(), json!(name));
d.fields.insert("__message".into(), json!(""));
}
ui.add_space(12.0);
if ui.button(tl!("Import…")).clicked() {
d.fields.insert("__import".into(), json!(true));
}
if ui.button(tl!("Export…")).clicked() {
d.fields.insert("__export".into(), json!(true));
}
if ui.button(tl!("Reset to Defaults")).clicked() {
ov.clear();
d.fields.insert("set".into(), json!(PRESETS[0]));
d.fields.insert("__message".into(), json!(tl!("All shortcuts reset to defaults.")));
}
});
ui.add_space(8.0);
// Tabs + search.
ui.horizontal(|ui| {
let tab = d.str("tab");
for (id, label) in [("tools", "Tools"), ("menu", "Menu Commands")] {
if ui.selectable_label(tab == id, egui::RichText::new(tl!(label)).font(theme::semibold(12.5))).clicked() {
d.fields.insert("tab".into(), json!(id));
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut q = d.str("query");
if ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search")).desired_width(200.0)).changed() {
d.fields.insert("query".into(), json!(q));
}
});
});
ui.add_space(6.0);
let tools = d.str("tab") != "menu";
let q = d.str("query").to_lowercase();
let selected = d.str("__selected");
let rec = d.str("__recording");
let scroll_to = d.fields.remove("__scrollTo").and_then(|v| v.as_str().map(str::to_string));
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(egui::vec2(360.0, 16.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(360.0);
ui.label(egui::RichText::new(if tools { tl!("Tool") } else { tl!("Command") }).color(t.text_dim).size(11.0));
});
ui.label(egui::RichText::new(tl!("Shortcut")).color(t.text_dim).size(11.0));
});
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| {
for e in entries().iter().filter(|e| e.is_tool == tools) {
let sc = effective_in(&ov, &e.key);
let matches = e.label.to_lowercase().contains(&q)
|| tl!(&e.label).to_lowercase().contains(&q)
|| e.group.to_lowercase().contains(&q)
|| tl!(&e.group).to_lowercase().contains(&q)
|| sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q));
if !q.is_empty() && !matches {
continue;
}
let row = ui.horizontal(|ui| {
let name = if e.is_tool || e.group.is_empty() {
tl!(&e.label).to_string()
} else {
format!("{} › {}", tl!(&e.group), tl!(&e.label))
};
let r = ui
.allocate_ui_with_layout(egui::vec2(360.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(360.0);
ui.add(egui::Button::selectable(selected == e.key, egui::RichText::new(name).color(t.text)).truncate())
})
.inner;
if r.clicked() {
d.fields.insert("__selected".into(), json!(e.key));
}
let label = if rec == e.key {
tl!("Press keys…").to_string()
} else {
sc.as_deref().map(menus::pretty_shortcut).unwrap_or_else(|| "—".into())
};
let changed = ov.contains_key(&e.key);
let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text });
let b = ui
.add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key))
.on_hover_text(tl!("Click, then press the new shortcut (Esc cancels)"));
if b.clicked() {
d.fields.insert("__recording".into(), json!(e.key));
d.fields.insert("__selected".into(), json!(e.key));
}
});
if scroll_to.as_deref() == Some(e.key.as_str()) {
row.response.scroll_to_me(Some(egui::Align::Center));
}
}
});
});
ui.add_space(6.0);
// Selected-row actions + message.
ui.horizontal(|ui| {
let sel = d.str("__selected");
ui.add_enabled_ui(!sel.is_empty(), |ui| {
if ui.button(tl!("Clear")).on_hover_text(tl!("Remove the shortcut")).clicked() {
let _ = assign(&mut ov, &sel, None, true);
d.fields.insert("set".into(), json!(CUSTOM));
}
if ui.button(tl!("Use Default")).clicked() {
reset_one(&mut ov, &sel);
}
});
let conflict = d.str("__conflict");
if !conflict.is_empty() && ui.button(tl!("Go to Conflict")).clicked() {
let tab = if conflict.starts_with("tool:") { "tools" } else { "menu" };
d.fields.insert("tab".into(), json!(tab));
d.fields.insert("query".into(), json!(""));
d.fields.insert("__selected".into(), json!(conflict));
d.fields.insert("__scrollTo".into(), json!(conflict));
d.fields.insert("__conflict".into(), json!(""));
}
});
let msg = d.str("__message");
if !msg.is_empty() {
ui.label(egui::RichText::new(msg).color(t.accent_strong).size(11.5));
crate::dialogs::modal::show(ctx, tl!("Keyboard Shortcuts"), egui::Id::new("dialog-shortcuts"), -20.0, 20, |ui| {
ui.set_width(640.0);
crate::dialogs::modal::heading(ui, tl!("Keyboard Shortcuts"));
ui.add_space(10.0);
// Set row.
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Set:")).color(t.text_dim));
let cur = d.str("set");
let mut opts: Vec<&str> = PRESETS.to_vec();
if !PRESETS.contains(&cur.as_str()) {
opts.push(CUSTOM);
}
let n = all_conflicts(&ov).len();
if n > 0 {
ui.label(
egui::RichText::new(crate::i18n::tn(
n as u64,
"{n} shortcut is assigned more than once",
"{n} shortcuts are assigned more than once",
))
.color(t.text_dim)
.size(11.0),
);
// The sets listed are ours (translated); a set read from an imported file shows
// its name as it is.
if let Some(&name) = crate::dialogs::mixed_dropdown(ui, "kbset", &cur, &opts, 200.0, |_| true).and_then(|i| opts.get(i))
&& let Some(p) = preset(name)
{
ov = p;
d.fields.insert("set".into(), json!(name));
d.fields.insert("__message".into(), json!(""));
}
ui.add_space(12.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("OK")).clicked() {
ok = true;
if ui.button(tl!("Import…")).clicked() {
d.fields.insert("__import".into(), json!(true));
}
if ui.button(tl!("Export…")).clicked() {
d.fields.insert("__export".into(), json!(true));
}
if ui.button(tl!("Reset to Defaults")).clicked() {
ov.clear();
d.fields.insert("set".into(), json!(PRESETS[0]));
d.fields.insert("__message".into(), json!(tl!("All shortcuts reset to defaults.")));
}
});
ui.add_space(8.0);
// Tabs + search.
ui.horizontal(|ui| {
let tab = d.str("tab");
for (id, label) in [("tools", "Tools"), ("menu", "Menu Commands")] {
if ui.selectable_label(tab == id, egui::RichText::new(tl!(label)).font(theme::semibold(12.5))).clicked() {
d.fields.insert("tab".into(), json!(id));
}
ui.add_space(8.0);
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut q = d.str("query");
if ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search")).desired_width(200.0)).changed() {
d.fields.insert("query".into(), json!(q));
}
});
});
ui.add_space(6.0);
let tools = d.str("tab") != "menu";
let q = d.str("query").to_lowercase();
let selected = d.str("__selected");
let rec = d.str("__recording");
let scroll_to = d.fields.remove("__scrollTo").and_then(|v| v.as_str().map(str::to_string));
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(egui::vec2(360.0, 16.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(360.0);
ui.label(egui::RichText::new(if tools { tl!("Tool") } else { tl!("Command") }).color(t.text_dim).size(11.0));
});
ui.label(egui::RichText::new(tl!("Shortcut")).color(t.text_dim).size(11.0));
});
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| {
for e in entries().iter().filter(|e| e.is_tool == tools) {
let sc = effective_in(&ov, &e.key);
let matches = e.label.to_lowercase().contains(&q)
|| tl!(&e.label).to_lowercase().contains(&q)
|| e.group.to_lowercase().contains(&q)
|| tl!(&e.group).to_lowercase().contains(&q)
|| sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q));
if !q.is_empty() && !matches {
continue;
}
let row = ui.horizontal(|ui| {
let name = if e.is_tool || e.group.is_empty() {
tl!(&e.label).to_string()
} else {
format!("{} › {}", tl!(&e.group), tl!(&e.label))
};
let r = ui
.allocate_ui_with_layout(egui::vec2(360.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(360.0);
ui.add(egui::Button::selectable(selected == e.key, egui::RichText::new(name).color(t.text)).truncate())
})
.inner;
if r.clicked() {
d.fields.insert("__selected".into(), json!(e.key));
}
let label = if rec == e.key {
tl!("Press keys…").to_string()
} else {
sc.as_deref().map(menus::pretty_shortcut).unwrap_or_else(|| "—".into())
};
let changed = ov.contains_key(&e.key);
let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text });
let b = ui
.add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key))
.on_hover_text(tl!("Click, then press the new shortcut (Esc cancels)"));
if b.clicked() {
d.fields.insert("__recording".into(), json!(e.key));
d.fields.insert("__selected".into(), json!(e.key));
}
});
if scroll_to.as_deref() == Some(e.key.as_str()) {
row.response.scroll_to_me(Some(egui::Align::Center));
}
}
});
});
ui.add_space(6.0);
// Selected-row actions + message.
ui.horizontal(|ui| {
let sel = d.str("__selected");
ui.add_enabled_ui(!sel.is_empty(), |ui| {
if ui.button(tl!("Clear")).on_hover_text(tl!("Remove the shortcut")).clicked() {
let _ = assign(&mut ov, &sel, None, true);
d.fields.insert("set".into(), json!(CUSTOM));
}
if ui.button(tl!("Use Default")).clicked() {
reset_one(&mut ov, &sel);
}
});
let conflict = d.str("__conflict");
if !conflict.is_empty() && ui.button(tl!("Go to Conflict")).clicked() {
let tab = if conflict.starts_with("tool:") { "tools" } else { "menu" };
d.fields.insert("tab".into(), json!(tab));
d.fields.insert("query".into(), json!(""));
d.fields.insert("__selected".into(), json!(conflict));
d.fields.insert("__scrollTo".into(), json!(conflict));
d.fields.insert("__conflict".into(), json!(""));
}
});
let msg = d.str("__message");
if !msg.is_empty() {
ui.label(egui::RichText::new(msg).color(t.accent_strong).size(11.5));
}
let n = all_conflicts(&ov).len();
if n > 0 {
ui.label(
egui::RichText::new(crate::i18n::tn(
n as u64,
"{n} shortcut is assigned more than once",
"{n} shortcuts are assigned more than once",
))
.color(t.text_dim)
.size(11.0),
);
}
ui.add_space(12.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("OK")).clicked() {
ok = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
});
});
d.fields.insert("overrides".into(), serde_json::to_value(&ov).unwrap_or(json!({})));
let import = d.fields.remove("__import").is_some();
let export = d.fields.remove("__export").is_some();
+56 -69
View File
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::state::{Dialog, DockTab, UiState};
use crate::theme::{self, Tokens};
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -318,79 +318,66 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let (mut ok, mut cancel) = (false, false);
let mut action: Option<(&str, Value)> = None;
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
let manage = d.kind == "manageWorkspaces";
egui::Window::new("Workspace")
.id(egui::Id::new("dialog-workspace"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22)))
.show(ctx, |ui| {
ui.set_width(340.0);
ui.label(
egui::RichText::new(if manage { tl!("Manage Workspaces") } else { tl!("New Workspace") }).font(theme::semibold(16.0)).color(t.text),
);
ui.add_space(12.0);
if manage {
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.set_min_height(140.0);
ui.set_width(ui.available_width());
for n in names(&app.ui) {
let builtin = is_builtin(&n);
let shown = crate::panels::label_or_name(&n, builtin);
let text = if builtin { egui::RichText::new(shown).color(t.text_dim) } else { egui::RichText::new(shown).color(t.text) };
if ui.selectable_label(d.str("selected") == n, text).clicked() && !builtin {
d.fields.insert("selected".into(), json!(n));
d.fields.insert("name".into(), json!(n));
}
crate::dialogs::modal::show(ctx, "Workspace", egui::Id::new("dialog-workspace"), -40.0, 22, |ui| {
ui.set_width(340.0);
crate::dialogs::modal::heading(ui, if manage { tl!("Manage Workspaces") } else { tl!("New Workspace") });
ui.add_space(12.0);
if manage {
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.set_min_height(140.0);
ui.set_width(ui.available_width());
for n in names(&app.ui) {
let builtin = is_builtin(&n);
let shown = crate::panels::label_or_name(&n, builtin);
let text = if builtin { egui::RichText::new(shown).color(t.text_dim) } else { egui::RichText::new(shown).color(t.text) };
if ui.selectable_label(d.str("selected") == n, text).clicked() && !builtin {
d.fields.insert("selected".into(), json!(n));
d.fields.insert("name".into(), json!(n));
}
});
ui.add_space(8.0);
let sel = d.str("selected");
ui.horizontal(|ui| {
let mut name = d.str("name");
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(180.0)).changed() {
d.fields.insert("name".into(), json!(name));
}
if ui.button(tl!("New")).on_hover_text(tl!("Save the current layout under this name")).clicked() {
action = Some(("window.workspace.new", json!({"name": d.str("name")})));
}
ui.add_enabled_ui(!sel.is_empty(), |ui| {
if ui.button(tl!("Rename")).clicked() {
action = Some(("window.workspace.rename", json!({"name": sel, "to": d.str("name")})));
}
if ui.button(tl!("Delete")).clicked() {
action = Some(("window.workspace.delete", json!({"name": sel})));
}
});
});
} else {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim));
let mut name = d.str("name");
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0));
if r.changed() {
d.fields.insert("name".into(), json!(name));
}
});
ui.label(egui::RichText::new(tl!("Saves the current bars, toolbar and panel layout.")).color(t.text_dim).size(11.0));
}
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("OK")).clicked() {
ok = true;
}
ui.add_space(8.0);
if !manage && widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
});
ui.add_space(8.0);
let sel = d.str("selected");
ui.horizontal(|ui| {
let mut name = d.str("name");
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(180.0)).changed() {
d.fields.insert("name".into(), json!(name));
}
if ui.button(tl!("New")).on_hover_text(tl!("Save the current layout under this name")).clicked() {
action = Some(("window.workspace.new", json!({"name": d.str("name")})));
}
ui.add_enabled_ui(!sel.is_empty(), |ui| {
if ui.button(tl!("Rename")).clicked() {
action = Some(("window.workspace.rename", json!({"name": sel, "to": d.str("name")})));
}
if ui.button(tl!("Delete")).clicked() {
action = Some(("window.workspace.delete", json!({"name": sel})));
}
});
});
} else {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim));
let mut name = d.str("name");
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0));
if r.changed() {
d.fields.insert("name".into(), json!(name));
}
});
ui.label(egui::RichText::new(tl!("Saves the current bars, toolbar and panel layout.")).color(t.text_dim).size(11.0));
}
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("OK")).clicked() {
ok = true;
}
ui.add_space(8.0);
if !manage && widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
});
});
if !manage && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
ok = true;
}
+8 -3
View File
@@ -269,8 +269,12 @@ without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOp
with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. A document saved as SVG or PDF
remembers its options, so the next save reuses them. A PDF opened with `pages`, `cropTo` or `password` is only part
of the file, so Save asks for a new name instead of writing it back.
`file.documentColorMode {mode, convert?, intent?}` switches the document colour mode, converting its colours
through the colour settings (`object.convertDocumentColorMode` is an alias kept for older scripts).
`file.documentColorMode {mode, convert?, intent?, grays?}` switches the document colour mode, converting the colours of
the art, symbols, pattern tiles and swatches through the colour settings (`object.convertDocumentColorMode` is an
alias kept for older scripts). Gray colours stay Gray and print on the black plate only. RGB greys are colours like any
other: to CMYK they separate through the profile into four-colour greys and a rich black, as Illustrator converts
them. `grays: "black"` puts RGB greys with R = G = B on the black plate instead, K = their grey value (the inks Edit Colors ›
Convert to Grayscale before the switch would give); other colours convert as usual.
SVG import keeps what the canvas edits live. `<pattern>` becomes a pattern swatch. A `<symbol>` with `<use>` becomes a
symbol (named after its `data-name`, else its id) with an instance per `<use>`; a `<use>` that shows it differently
@@ -370,7 +374,8 @@ and Separation and DeviceN inks become spot swatches the art links to at its tin
(DeviceCMYK, or ICC-based with four components) keep their ink amounts: a CMYK JPEG as it is, any other as a CMYK TIFF
(masked CMYK images, and ones with 1, 2 or 4 bits per sample, open in RGB with a warning). Placing a CMYK TIFF keeps it
CMYK too.
`colorMode: "rgb" | "cmyk"` opens any file in that mode instead, its colours converted as `file.documentColorMode` does:
`colorMode: "rgb" | "cmyk"` opens any file in that mode instead, its colours converted as `file.documentColorMode` does
(`grays` too):
```json
{"name":"run_command","arguments":{"command":"document.pdfInfo","params":{"path":"/tmp/brochure.pdf","thumbnail":2,"cropTo":"trim"}}}