diff --git a/Cargo.lock b/Cargo.lock index 935d51547..461e7d006 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.14.0" +version = "0.15.2" dependencies = [ "arboard", "craft-update", @@ -1330,7 +1330,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.14.0" +version = "0.15.2" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1346,7 +1346,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.14.0" +version = "0.15.2" dependencies = [ "craft-cms", "kurbo", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.14.0" +version = "0.15.2" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1378,7 +1378,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.14.0" +version = "0.15.2" dependencies = [ "chrono", "designcraft-color", @@ -1391,7 +1391,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.14.0" +version = "0.15.2" dependencies = [ "caseless", "craft-atomic", @@ -1431,7 +1431,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.14.0" +version = "0.15.2" dependencies = [ "chrono", "designcraft-color", @@ -1441,7 +1441,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.14.0" +version = "0.15.2" dependencies = [ "core-text", "harfrust 0.13.3", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.14.0" +version = "0.15.2" dependencies = [ "designcraft-doc", "designcraft-images", @@ -1466,7 +1466,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.14.0" +version = "0.15.2" dependencies = [ "flo_curves", "kurbo", @@ -1479,7 +1479,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.14.0" +version = "0.15.2" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1494,7 +1494,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.14.0" +version = "0.15.2" dependencies = [ "craft-cms", "designcraft-color", @@ -1511,7 +1511,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.14.0" +version = "0.15.2" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1524,7 +1524,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.14.0" +version = "0.15.2" dependencies = [ "chrono", "craft-cms", @@ -1536,6 +1536,7 @@ dependencies = [ "designcraft-fonts", "designcraft-geom", "designcraft-images", + "flate2", "hayro-cmap", "hayro-interpret", "hayro-syntax", @@ -1549,7 +1550,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.14.0" +version = "0.15.2" dependencies = [ "chrono", "designcraft-color", @@ -1567,7 +1568,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.14.0" +version = "0.15.2" dependencies = [ "craft-pdf", "designcraft-color", @@ -1586,7 +1587,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.14.0" +version = "0.15.2" dependencies = [ "designcraft-doc", "quick-xml 0.41.0", @@ -1595,7 +1596,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.14.0" +version = "0.15.2" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1607,7 +1608,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.14.0" +version = "0.15.2" dependencies = [ "craft-dialogs", "craft-keymap", @@ -1639,7 +1640,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.14.0" +version = "0.15.2" dependencies = [ "designcraft-engine", "designcraft-format", @@ -7603,7 +7604,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.14.0" +version = "0.15.2" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 22ab1e32d..0c692c191 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.14.0" +version = "0.15.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/NOTICE b/NOTICE index b072488d5..188a3dc76 100644 --- a/NOTICE +++ b/NOTICE @@ -68,6 +68,34 @@ list (author, source, license) is in ASSETS.md. HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- French hyphenation patterns, embedded through the hypher crate (feature "french"): + hyph-fr.tex 2.13 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) 1994-2002 Daniel Flipo, Bernard Gaulle, 2016 Arthur Reutenauer. MIT License: + Permission is hereby granted, free of charge, to any person obtaining a copy of this software + and associated documentation files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: The above copyright notice + and this permission notice shall be included in all copies or substantial portions of the + Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE + AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- Italian hyphenation patterns, embedded through the hypher crate (feature "italian"): + hyph-it.tex 4.9 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) 2008-2011 Claudio Beccari. Available under the LPPL (1.3 or later) or the MIT + License; taken here under the MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software + and associated documentation files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: The above copyright notice + and this permission notice shall be included in all copies or substantial portions of the + Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE + AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - Norwegian hyphenation patterns, embedded through the hypher crate (feature "norwegian"): hyph-no.tex from hyph-utf8, https://github.com/hyphenation/tex-hyphen. Copyright (C) Rune Kleveland and Ole Michael Selberg. Copying and distribution of this file, diff --git a/ROADMAP.md b/ROADMAP.md index b0fce1ba0..e49a22d70 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope default layer queries find a placed PDF anywhere in mixed selections. Regression tests cover IDML export after rejection and both selection orders. -## Status (2026-10-01) +## Status (2026-10-21) **Working today** - Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. @@ -382,9 +382,17 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-30** · The Color Picker takes an eyedropper: dragged from the picker onto the page, it takes the colour the canvas shows (proofed under Proof Colors). No readiness percentage change. +- **2026-10-30** · The Color Picker is laid out as the reference app's: a field and slider for the component chosen, HSB, Lab, RGB and CMYK values, the old colour, and Add CMYK Swatch; Enter and Esc close it once. No readiness percentage change. +- **2026-10-18** · Table of Contents gains Create PDF Bookmarks: included headings become nested page destinations, regenerated in their existing outline position on update without replacing or moving surrounding manual bookmarks. The setting survives native and IDML files. No readiness percentage change. + Newest first. +- **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change. +- **2026-10-30** · File > Package opens a dialog, as the reference app's: a Summary of fonts, links and images and inks, pages for each, a Printing Instructions step, and a PDF made with the options of a preset (#1514, #1515). +- **2026-10-30** · IDML import reads TOC separators and entry styles written with the `$ID/` prefix or as `Properties` children, and the TOC separator notation (`^t`, `^y`, `^m`, `^>`) is shared by the dialog and IDML, with a special-character menu on the separator field (#1249, #1255). - **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change. +- **2026-10-28** · A placed image whose pixels would decode past 1 GiB of RGBA (256 Mpx) is refused from its header, before decoding (#1586). A 288 KB 23,000 px gray PNG used to hang the first draw for 9 s and take the app to 4.5 GB; it now draws as an empty frame, and PDF export skips it with a warning. A 300 ppi A0 scan still fits. No readiness percentage change. - **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change. - **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change. - **2026-10-28** · IDML export writes the document's default `StoryPreference` in `Resources/Preferences.xml`, as InDesign does. Only each story carried one, and IDML readers that take the spread reading order from the document's (Okapi's IDML filter) failed on DesignCraft's files. No readiness percentage change. @@ -423,6 +431,7 @@ Newest first. - **2026-10-24** · A guide pulled out of a ruler keeps the resize cursor across it until it is dropped; the tool's cursor took over as soon as the pointer left the ruler, so the drag looked as if it had stopped. No readiness percentage change. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. - **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change. +- **2026-10-22** · Paragraph Style Options › Paragraph Shading and Paragraph Border are laid out as InDesign's: headed with the page's name; Tint is a spinner with InDesign's presets and has Overprint beside it (shading, border and gap; a new attribute each, kept through IDML; PDF export warns it doesn't overprint yet); Corner Size and Shape is InDesign's two-by-two grid of corner icons, sizes and shape menus showing each shape's glyph, with the link in the middle; offsets and weights have spinners. Found in the Learn Skills Daily course, chapter 41. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. @@ -433,15 +442,24 @@ Newest first. - **2026-10-18** · German text hyphenates using the MIT-licensed 2006 reform patterns (`hyph-de-1996.tex`) through `hypher`. Explicit 2006 names and plain/2006 locale codes reach both composers; traditional and unknown spelling variants stay unhyphenated. Tests cover this distinction, umlauts, ß, compounds, reformed ck/st breaks and paragraph limits. Six of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change. +- **2026-10-23** · Show Hidden Characters marks table cells as InDesign does: every cell shows its own spaces, tabs and paragraph returns and an end-of-story # after its text (an empty cell shows just the #), and the table paragraph's ¶ sits after the table's bottom right corner. Cells showed no marks. No readiness percentage change. +- **2026-10-23** · Tables: with the Type tool in a cell, Shift+Tab goes back a cell (it went on to the next one) and Esc toggles between the text and the cell, as in InDesign: the insertion point selects its cell, and selected cells select the text of the first one (`table.toggleSelection`). Esc left the table for the frame. No readiness percentage change. +- **2026-10-23** · Direct Selection shows a path's direction handles as InDesign: both handles of each selected anchor and, on its neighbours, the handle shaping the segment to it; no handles with no anchor selected, and handles that don't show can't be grabbed. The tool draws the path and its points without the bounding box. Found in the Learn Skills Daily course, chapter 43. No readiness percentage change. +- **2026-10-23** · Tables: Convert Text to Table… and Convert Table to Text… open InDesign's dialogs (they converted at once, with tabs and paragraphs only). Column and Row Separator take Tab, Comma, Paragraph or any characters typed; Number of Columns, available when both separators are the same, fills rows of that many cells (a list of paragraphs into two columns); Convert Text to Table also applies a Table Style. `table.convertFromText` takes `rowSeparator`, `columns` and `tableStyle`, and `table.convertToText` its separators. Convert Text to Table is available only with text selected outside a table. No readiness percentage change. +- **2026-10-23** · Tables: the Table menu follows InDesign's order (Cell Options before Insert, Delete and Select; Merge, Unmerge and Split before Convert Cell Type and Convert Rows; Distribute Rows Evenly before Distribute Columns Evenly), and its first item is Insert Table… with InDesign's Cmd+Alt+Shift+T, which opens the dialog (the shortcut ran `table.insert` with its defaults, behind the dialog). The Table panel stays in Window › Type & Tables (Shift+F9). The Insert Table dialog is laid out as InDesign's: Body Rows and Columns, then Header and Footer Rows, as numbers with spinners and right-aligned labels, and a Table Style applied to the new table. No readiness percentage change. - **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change. - **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change. - **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change. +- **2026-10-22** · Preferences › Units & Increments with no document open, as in InDesign: the ruler units (horizontal and vertical), the ruler origin and the cursor-key and kerning increments set there are what new documents start with, and New Document opens in those units. They are kept with the application preferences (`prefs.set {defaultUnits}`); a document keeps its own once made. Reported by a reader who works in inches and had to change every file. No readiness percentage change. +- **2026-10-22** · French and Italian text is hyphenated, with hyph-utf8's patterns through hypher (hyph-fr 2.13 and hyph-it 4.9, MIT): for the Language set in the Character panel (French, French: Canadian, French: Swiss, Italian, or fr / it locale codes). A word with an elision hyphenates after its apostrophe (l'im-pri-me-rie, dell'im-pa-gi-na-zio-ne), in French and Italian only; other languages leave apostrophe words unbroken. Gap 13 shrinks. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-23** · Type › Text Variables › Convert Variable to Text (`variables.convertToText`): the variable instances in the selected text, in a story, a table cell or a footnote, become plain text of the value each shows on its own page (a running header on page 3 becomes page 3's heading). `variables.convertAllToText {name}` does the same for every instance of one variable in the document, as the Text Variables dialog's Convert to Text button. The variable stays defined; one undo step. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. - **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. - **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change. +- **2026-10-21** · Properties for points selected with Direct Selection, as InDesign's: the title is "Anchor Point(s)", and the panel shows Transform, Appearance, Convert Points (Plain, Corner, Smooth, Symmetrical) and the Delete Point(s) quick action (`path.deletePoints`, one undo step). Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change. +- **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). No readiness percentage change. - **2026-10-19** · A PDF export can repeat its bytes: it recorded the second it was made in, so exporting one document twice gave different files though the pages matched. `SOURCE_DATE_EPOCH` stands in for the clock when the export is given no date, as the reproducible-builds specification defines it; a date passed in still wins, and a malformed value is ignored rather than refusing the export. The timestamp was the only thing that differed, so the files are now identical byte for byte. No readiness percentage change. - **2026-10-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded font program, including placed PDF forms; `validate-pdf` reports the font and page (#909). @@ -455,14 +473,58 @@ Newest first. - **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. - **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change. - **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change. +- **2026-10-21** · Paragraph Border and Paragraph Shading link their four sides as InDesign's: the stroke weights, offsets and corners have the "Make all settings the same" link, on when the four are the same, so a side typed in sets all four; with the link off a side is set alone (a border with only a thick left side). Found in the Learn Skills Daily course, chapter 40. No readiness percentage change. +- **2026-10-21** · Paragraph rules have InDesign's Type (solid, thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document), Gap Color and Gap Tint, and Keep In Frame (rule above); the Paragraph Rules page is laid out as InDesign's (Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent). IDML reads and writes them (`RuleAboveType`, `RuleBelowGapColor`, `KeepRuleAboveInFrame`…). Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. +- **2026-10-21** · New Character Style with text selected starts from what the text has of its own over its paragraph style, as InDesign's ("[None] + Bold + color"), and Apply Style to Selection gives the text the new style, with no override left. A new style's dialog is titled New Paragraph Style / New Character Style. `type.selectionAttrs` reports the text's own character attributes (`ownChars`). Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. +- **2026-10-21** · New Paragraph Style with text selected starts from it, as InDesign's (Based On [No Paragraph Style], every setting of the text that differs) and Apply Style to Selection gives the paragraph the new style; General has Next Style and Reset To Base; Basic Character Formats is in two columns (Size | Leading, Kerning | Tracking, Case | Position). `style.paragraph.edit` honours `nextStyle` (it was ignored) and takes `resetToBase`. Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. +- **2026-10-21** · Find/Change has InDesign's Change and Change/Find: Change changes the text Find Next found and keeps it selected, Change/Find also finds the next match, and the search keeps its domain (a selection or story) across them. The Search list reads Document, Story and Selection. `find.change` takes `found`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. +- **2026-10-21** · Find/Change has InDesign's Find Format: Specify Attributes opens Find Format Settings (the same Style Options and character sections as Change Format Settings), and only text with the format is found. With nothing in Find what, each stretch of text with the format is found, so Change All can restyle every word in a character style. `find.find`, `find.next` and `find.change` take `findFormat {charStyle?, paraStyle?, chars?}`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. +- **2026-10-21** · Find/Change has InDesign's Change Format. Specify Attributes opens Change Format Settings: Style Options (the character and paragraph style to apply, [Any Style] for none), then Basic and Advanced Character Formats, Character Color, OpenType Features, Underline and Strikethrough Options. The box shows the format ("Character Style: eggs"), and Change All gives it to the changed text. With a Change Format and nothing in Change to, the found text stays and is only formatted, as InDesign's (an ⓘ beside Change to says so). `find.change` takes `charStyle` and `paraStyle`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. +- **2026-10-21** · Preferences › Interface follows InDesign's: Appearance has a Color Theme swatch per interface theme (a click shows it at once, Cancel goes back) and Panels sets the Tools panel to a single or double column; UI Size moves to a UI Scaling section of its own. Interface held only UI Size, and the theme could be changed only from the Window menu. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. +- **2026-10-21** · Preferences › File Handling shows Number of Recent Items to Display as a whole number ("20"; it read "20.0"). Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. +- **2026-10-21** · Insert Pages… opens InDesign's dialog: Pages, Insert (After Page, Before Page, At Start of Document, At End of Document) with the page by name, and Parent ([None] or a parent), set to the page in view and its parent. It was a form asking for a page index and a parent id, and pages could not go before the first page; `layout.pages.insert` takes `before` (0: at the start). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change. +- **2026-10-21** · Spelling (Check Spelling and Dynamic Spelling) no longer flags everyday words the bundled hyphenation word list lacks: "we", "us", "if", titles such as "Mr", and words such as email, online, website, database, desktop and download, with their plurals and other forms. Contractions typed with a typographer's apostrophe (don’t, we’re), which Use Typographer's Quotes produces by default, count like their straight-apostrophe forms. Dynamic Spelling used to underline most words of an ordinary sentence. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. +- **2026-10-21** · While a parent is shown, the status bar's page box and Properties › Page name it ("A-Parent") instead of page 1, as InDesign's; their lists have the parents after the pages, and choosing one shows that parent (a page leaves the parents). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change. +- **2026-10-21** · Effects dialog: the Angle field of the shadows, Bevel and Emboss, Satin and Gradient Feather has InDesign's dial beside it, its radius pointing where the light comes from; a click or drag on it sets the angle. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change. +- **2026-10-21** · A double-click on a Fill or Stroke chip of Properties › Appearance opens the Color Picker with its colour, as in InDesign (a click still opens the swatches); for selected text, its own chips open it for the text. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change. +- **2026-10-20** · Properties › Appearance: with several text frames selected, the Fill and Stroke popups offer Apply To Frame or Text, as with one frame, so their text can be coloured at once, as in InDesign. No readiness percentage change. +- **2026-10-20** · Properties › Appearance: the arrow beside Opacity opens a slider under the field, as in InDesign, instead of the Effects panel; a drag shows the opacity live and is one undo step (the Opacity link still opens Effects). No readiness percentage change. +- **2026-10-20** · Properties › Frame Fitting has InDesign's Auto-Fit checkbox and Options button (Frame Fitting Options); the app's checkboxes are now named and ticked-or-not for screen readers. No readiness percentage change. +- **2026-10-20** · Properties › Text Wrap shows the four wrap offsets under the wrap buttons, with the link that makes them the same, as InDesign's Properties panel does (greyed with no wrap). No readiness percentage change. +- **2026-10-21** · Properties › Bullets and Numbering › Options opens the Bullets and Numbering dialog for the selected paragraphs, as InDesign's, instead of the Paragraph panel. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. +- **2026-10-20** · Properties › Appearance: the corner shape list shows each shape's glyph beside its name, drawn as InDesign draws them (Fancy has its own glyph now), and the stroke type list shows each type's line; both dropdowns are named for screen readers. No readiness percentage change. +- **2026-10-21** · Properties › Align: the Align To button shows what the objects align to, one glyph each for the selection, the key object, the margins, the page and the spread, as InDesign's. Found in the Learn Skills Daily course, chapter 12. No readiness percentage change. +- **2026-10-20** · Object › Corner Options opens a Corner Options dialog laid out as InDesign's: each corner's size and shape, the link button that makes all four the same, and Preview. The Corner link in Properties › Appearance opens it too (it opened a Rectangle size dialog). No readiness percentage change. +- **2026-10-20** · The Object menu is in InDesign's order and groups; Paths has Join, Open Path and Close Path (Convert Shape lists only shapes, as InDesign's does) and a Convert Point submenu (Plain, Corner, Smooth, Symmetrical) is added. No readiness percentage change. +- **2026-10-20** · Layout › Create Guides puts its guides on both pages of the spread in view, as InDesign does; "This page" in Apply to narrows them to one page. No readiness percentage change. +- **2026-10-21** · Object › Fitting › Clear Frame Fitting Options (`object.clearFittingOptions`): the frames' auto-fit, crop amounts and reference point go back to their defaults, and the picture stays where it is. The Fitting submenu is grouped as InDesign's. Found in the Learn Skills Daily course, chapter 11. No readiness percentage change. +- **2026-10-21** · Swatches panel rows show InDesign's columns, drawn in code: a crossed pencil on the swatches that can't be edited ([None], [Registration], [Black]), the colour type (process or spot) and the colour mode (CMYK, RGB, Gray, or [None]'s and [Registration]'s own glyphs), in place of a lock and "CMYK" text. Tint reads 100 with nothing selected. No readiness percentage change. +- **2026-10-21** · With a gradient fill or stroke, the Color panel shows the stop selected in the Gradient panel (the stop before a selected midpoint) with its values, and its C/M/Y/K or R/G/B sliders and spectrum colour that stop, keeping the others, as in InDesign. It showed "[None] — choose a colour on the ramp", and a spectrum click replaced the whole gradient with a flat colour. Found in the Learn Skills Daily course, chapter 24. No readiness percentage change. +- **2026-10-20** · A new document's Swatches panel lists InDesign's defaults: [None], [Registration], [Paper], [Black] and six process colours (C=100, M=100, Y=100, C=15 M=100 Y=100, C=75 M=5 Y=100, C=100 M=90 Y=10); the extra M=100 Y=100, C=100 Y=100 and C=100 M=100 swatches are gone. No readiness percentage change. +- **2026-10-21** · The readout beside the cursor while drawing or measuring ("W: 3.13 cm") is InDesign's light grey box with white text, measured from the tutorials' frames; High Contrast keeps it black. It was a dark translucent box. No readiness percentage change. +- **2026-10-20** · Drawing a frame or shape shows its width and height in the document's ruler units, to two decimals and on two lines ("W: 6.08 cm", "H: 6.08 cm"), as InDesign does; it was always whole points. No readiness percentage change. +- **2026-10-20** · Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign; a click still turns the frame into a text frame. No readiness percentage change. +- **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change. +- **2026-10-21** · Text Frame Options is laid out as InDesign's, with Preview: General, Column Rules, Baseline Options, Auto-Size; framed Columns (Number and Width, Gutter and Maximum, Balance Columns), Inset Spacing (Top/Bottom, the link, Left/Right) and Vertical Justification (Align, Paragraph Spacing Limit) with spinner fields; Fixed Number shows each column's width as the fields change. Preview shows the change on the page until OK (one undo step) or Cancel. Justify honours the Paragraph Spacing Limit (`paragraphSpacingLimit` on `object.textFrameOptions`): the space goes between paragraphs up to the limit, the rest between lines. Found in the Learn Skills Daily course, chapter 18. No readiness percentage change. +- **2026-10-21** · Swatches panel: a swatch is renamed in place, as in InDesign: click it, then click its name again (a slow double-click), type, and Enter (Escape keeps the name). Everything that uses it follows; a double-click still opens Swatch Options, and [None], [Registration], [Paper] and [Black] keep their names. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change. +- **2026-10-20** · The loaded place cursor shows a thumbnail of the graphic, as InDesign's does; dragging with it draws the frame at the graphic's proportions from where the drag started, with its scale beside the pointer, and places exactly that frame. No readiness percentage change. +- **2026-10-21** · Drop Shadow, Inner Shadow and Outer Glow have a blend mode, as InDesign's (Effects dialog › Blending › Mode; `mode` on `object.dropShadow`, `object.innerShadow`, `object.outerGlow`; IDML `BlendMode`). New shadows start Multiply and new outer glows Screen in white ([Paper]); effects saved before keep Normal, so documents look as they did. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change. +- **2026-10-14** · The Pages panel labels spreads as InDesign does ("2–3", "2, 1" across a section start, "8, A"), marks each section's first page with a triangle, and scrolls to the page in view. No readiness percentage change. +- **2026-10-18** · Double-clicking a section triangle in the Pages panel opens that page’s numbering options, including from parent-page editing. No readiness percentage change. +- **2026-10-18** · TOC separators preserve right-indent tabs through the dialog and IDML, including localized `$ID/^y` values. A separator picker exposes Tab Character, Right Indent Tab, Em Space and En Space. Existing paragraph tab leaders now work with the tutorial's TOC workflow. No readiness percentage change. +- **2026-10-21** · The Package dialog's Include PDF (Print) has a PDF Preset (the Export PDF presets, the last one used first), as InDesign's Select PDF Preset; `file.package` takes `pdf: {…file.exportPdf options}`, refusing bad options before writing anything. The packaged PDF always had the default settings. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change. +- **2026-10-21** · File › Package… opens InDesign's Package dialog: Summary counts the fonts, links and images and inks the package carries, with a warning where some are missing or modified, and Fonts, Links and Images and Colors and Inks list them (Show Problems Only). Create Printing Instructions asks for the contact details and instructions that go into the package's Instructions.txt, and Include IDML / Include PDF (Print) choose the copies it carries. Package… went straight to the folder picker with fixed options. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change. - **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. - **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. +- **2026-10-18** · Table of Contents offers More Options / Fewer Options, preserving page-number, separator, sorting and level settings while their controls are hidden. No readiness percentage change. +- **2026-10-18** · Table of Contents reserves the title field's width so Contents is readable next to its style picker; the grid previously squeezed it to a few characters. No readiness percentage change. - **2026-10-17** · Inline and anchored objects paint under their frame's text, on screen and in PDF, as InDesign draws them: a picture whose frame reaches into the next line (a negative Y offset) no longer hides the top of that line. No readiness percentage change. - **2026-10-17** · Running headers: *first/last on page* read a page in text order (story order, then column by column), not by height — a multi-column page's right header showed the lowest entry of column 1 instead of the page's last entry. No readiness percentage change. - **2026-10-17** · Parent pages: while parents are edited, the document pages' hidden characters, spelling squiggles and other overlays no longer pile onto the right parent page. No readiness percentage change. +- **2026-10-16** · A Direct Selection click on an image whose frame is already selected selects the image, so Delete removes only the image and leaves the empty frame, as in InDesign. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. @@ -475,6 +537,7 @@ Newest first. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. +- **2026-10-15** · Anchored objects wrap their own story as their anchor lines are set, as in InDesign: the layout no longer depends on where an earlier composition put them (a long story whose pictures moved between frames from pass to pass never settled). No readiness percentage change. - **2026-10-15** · Color Theme: the toolbar tool previews up to five colours from a placed raster and adds them as one undoable swatch group. Local deterministic extraction; transparent padding is ignored. No readiness percentage change. - **2026-10-15** · Applying text styles removes matching local formatting so capture sources inherit later edits; real overrides and named character styles remain. @@ -534,6 +597,8 @@ Newest first. - **2026-10-15** · Flex Layout (InDesign 2026): Object › Flex Layout (Add to, Convert to, Remove) and a Flex Layout panel laid out as InDesign's, also in Properties. A container lays out its objects by direction, wrap, justification, the alignment grid, Fixed/Auto width and height, padding and spacing, after every edit; Add detects aligned objects (direction, Auto size, Space Between at their spacing); resizing a container reflows its objects instead of scaling them; dragging an object in one reorders it. Montilla Design's tutorial replays step for step. No readiness percentage change. - **2026-10-15** · IDML: the CJK leading model InDesign writes on [No paragraph style] of every document (Aki Below) only applies when the document uses a Japanese composer, as in InDesign; a Western document's paragraphs after a size change no longer sit lower than in InDesign (Montilla Design's Flex Layout file: its captions came out overset). No readiness percentage change. - **2026-10-15** · IDML: linked images in a subfolder beside the package load when the package is opened on another machine (a link to `/images/logo.png` finds `images/logo.png` next to the .idml); they showed as missing. No readiness percentage change. +- **2026-10-14** · Numbering & Section Options opened on a page that doesn't start a section is New Section, as in InDesign: Start Section on, 1 offered, and the start numbers stay editable (typing one chooses it). No readiness percentage change. +- **2026-10-14** · The Direct Selection tool pressed on a corner or anchor of an object that isn't selected selects it and pulls just that point, as in InDesign (it moved the whole object). No readiness percentage change. - **2026-10-14** · Overset text is marked on every frame that ends an overset story, selected or not and inside groups too (the red mark at the out port, with frame edges shown); a selected group shows the in/out ports of the text frames inside it. InDesign shows it only on the selected frame, which hides overset labels in grouped objects until Preflight. No readiness percentage change. - **2026-10-14** · Load Swatches adds the colors of an `.ase` file that repeat a name or have none: a repeated name gets a number (`Red 2`), and a color without a name is named by its value (`C=0 M=0 Y=0 K=55`). Before, a repeated name kept one of its colors, and only the first unnamed color was added, with an empty name. No readiness percentage change. - **2026-10-14** · Objects snap to the bleed guide's four sides while moving, resizing or drawing; snapping and the canvas share one bleed rectangle (`Spread::bleed_rect`). No readiness percentage change. diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index e127776ba..ba92e0031 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -230,10 +230,12 @@ fn build( path: &str, recent: &mut Option, ) { - for e in entries { + for (i, e) in entries.iter().enumerate() { match e { Item::Sep => { - let _ = parent.append(&PredefinedMenuItem::separator()); + if menus::has_shown_item(app, &entries[i + 1..]) { + let _ = parent.append(&PredefinedMenuItem::separator()); + } } Item::Sub(label, children) => { let sub = Submenu::new(menu_title(app, label), true); diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs index d27a76428..1847a4134 100644 --- a/crates/color/src/swatch.rs +++ b/crates/color/src/swatch.rs @@ -145,7 +145,9 @@ pub fn apply_tint(c: Color, tint: f32) -> Color { } } -/// A new document's swatches, in panel order. +/// A new document's swatches, in panel order: InDesign's four specials, then its six process +/// colours (cyan, magenta, yellow, red, green and blue as C=15 M=100 Y=100, C=75 M=5 Y=100 and +/// C=100 M=90 Y=10). pub fn default_swatches() -> Vec { let special = |name: &str, value: SwatchValue| Swatch { name: name.into(), value, locked: true, named: true, hidden: false }; let mut v = vec![ @@ -154,17 +156,9 @@ pub fn default_swatches() -> Vec { special(PAPER, SwatchValue::Paper { color: Color::WHITE }), special(BLACK, SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), color_type: ColorType::Process }), ]; - for (c, m, y, k) in [ - (1.0, 0.0, 0.0, 0.0), - (0.0, 1.0, 0.0, 0.0), - (0.0, 0.0, 1.0, 0.0), - (0.0, 1.0, 1.0, 0.0), - (1.0, 0.0, 1.0, 0.0), - (1.0, 1.0, 0.0, 0.0), - (0.15, 1.0, 1.0, 0.0), - (0.75, 0.05, 1.0, 0.0), - (1.0, 0.9, 0.1, 0.0), - ] { + for (c, m, y, k) in + [(1.0, 0.0, 0.0, 0.0), (0.0, 1.0, 0.0, 0.0), (0.0, 0.0, 1.0, 0.0), (0.15, 1.0, 1.0, 0.0), (0.75, 0.05, 1.0, 0.0), (1.0, 0.9, 0.1, 0.0)] + { v.push(Swatch::cmyk(c, m, y, k)); } v @@ -188,6 +182,27 @@ mod tests { assert!(s.iter().any(|s| s.name == "C=100 M=0 Y=0 K=0")); } + /// The Swatches panel of a new InDesign document (Zero2Graphic's page layout tutorial, 3:22). + #[test] + fn defaults_are_indesigns_ten() { + let names: Vec = default_swatches().into_iter().map(|s| s.name).collect(); + assert_eq!( + names, + [ + "[None]", + "[Registration]", + "[Paper]", + "[Black]", + "C=100 M=0 Y=0 K=0", + "C=0 M=100 Y=0 K=0", + "C=0 M=0 Y=100 K=0", + "C=15 M=100 Y=100 K=0", + "C=75 M=5 Y=100 K=0", + "C=100 M=90 Y=10 K=0", + ] + ); + } + #[test] fn resolves_tints_and_specials() { let mut s = default_swatches(); diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 2e9334b65..ea6a0bf42 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -21,7 +21,7 @@ log = { workspace = true } regex = { workspace = true } fancy-regex = { workspace = true } miniz_oxide = "0.8" -hypher = { version = "0.1.8", default-features = false, features = ["alloc", "german", "greek", "portuguese", "spanish", "norwegian"] } +hypher = { version = "0.1.8", default-features = false, features = ["alloc", "french", "german", "greek", "italian", "portuguese", "spanish", "norwegian"] } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 6f9e8f9e3..61546d162 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -385,6 +385,16 @@ impl Grep { self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect() } + /// The first match starting at or after byte `start`, with its replacement: `template` + /// expanded as [`Self::replacements`] does. + pub fn replacement_at(&self, text: &str, start: usize, template: &str) -> Result, String)>, GrepError> { + if start > text.len() || !text.is_char_boundary(start) { + return Ok(None); + } + let Some(caps) = self.re.captures_from_pos(text, start).map_err(run_err)? else { return Ok(None) }; + Ok(caps.get(0).map(|m| (m.range(), fancy_regex::Expander::default().expansion(template, &caps)))) + } + /// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it /// doesn't count toward a GREP style's cap. pub fn find_nonempty(&self, text: &str, limit: usize) -> Result>, GrepError> { diff --git a/crates/compose/src/hyphen.rs b/crates/compose/src/hyphen.rs index f079bc411..be9e7b7e6 100644 --- a/crates/compose/src/hyphen.rs +++ b/crates/compose/src/hyphen.rs @@ -5,7 +5,10 @@ //! `hyph-no.tex` from hyph-utf8 (Rune Kleveland, Ole Michael Selberg), permissive (see NOTICE); //! Greek is `hyph-el-monoton.tex` 5.0 from hyph-utf8 (Dimitrios Filippou), LPPL or MIT, taken //! under MIT. Those are the **monotonic** patterns, which is what `hypher` maps `el` to, so -//! polytonic text hyphenates by them as well. +//! polytonic text hyphenates by them as well. French is `hyph-fr.tex` 2.13 from hyph-utf8 (Daniel +//! Flipo, Bernard Gaulle, Arthur Reutenauer), MIT; Italian is `hyph-it.tex` 4.9 from hyph-utf8 +//! (Claudio Beccari), LPPL or MIT, taken under MIT. A word with an elision (`l'imprimerie`) +//! hyphenates after its apostrophe. //! German uses `hyph-de-1996.tex` (2024-02-28, Deutschsprachige Trennmustermannschaft, MIT): //! despite the filename, these are the reformed 2006 spelling patterns. Only the two 2006 reform //! names and German locale codes without a reform year or with 2006 use this set. Traditional, @@ -137,8 +140,14 @@ fn core_breaks(core: &[char], lang: Lang) -> (Vec, Source) { return (vec![], Source::None); } if let Lang::Patterns(l) = lang { - if core.contains(&'\'') { - return (vec![], Source::None); + if let Some(ap) = core.iter().rposition(|&c| c == '\'') { + // An elision (French "l'", Italian "dell'"): the word after the apostrophe hyphenates, + // with no break up to it. Elsewhere (German "Rock'n'Roll") an apostrophe word gets none. + if !matches!(l, hypher::Lang::French | hypher::Lang::Italian) { + return (vec![], Source::None); + } + let (pts, src) = if ap + 1 < n { core_breaks(&core[ap + 1..], lang) } else { (vec![], Source::None) }; + return (pts.into_iter().map(|p| p + ap + 1).collect(), src); } let s: String = core.iter().collect(); let mut out = Vec::new(); @@ -229,6 +238,7 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec { } let min_before = lim.after_first.max(1); let min_after = lim.before_last.max(1); + let elides = matches!(lang, Lang::Patterns(hypher::Lang::French | hypher::Lang::Italian)); // Run bounds of each point's letter run (trimmed of apostrophes). let run_of = |p: usize| { let mut a = p; @@ -245,6 +255,10 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec { while b > a && is_apostrophe(chars[b - 1]) { b -= 1; } + // After an elision the word is the part after its last apostrophe: the limits count that. + if elides && let Some(ap) = chars[a..b].iter().rposition(|&c| is_apostrophe(c)) { + a += ap + 1; + } (a, b) }; pts.into_iter() @@ -397,6 +411,15 @@ mod tests { assert!(hyphen_points("typography", &lim).is_empty()); } + /// Only French and Italian elide at an apostrophe; other languages leave such words unbroken. + #[test] + fn apostrophe_words_hyphenate_only_in_french_and_italian() { + let de = Lang::for_language("German: 2006 Reform").unwrap(); + assert!(hyphen_points_in("Rock'n'Roll", &Limits::default(), de).is_empty()); + let fr = Lang::for_language("French").unwrap(); + assert!(!hyphen_points_in("l'information", &Limits::default(), fr).is_empty()); + } + #[test] fn german_2006_uses_reformed_patterns() { let de = Lang::for_language("German: 2006 Reform").unwrap(); @@ -478,7 +501,7 @@ mod tests { assert_eq!(h("desesperación"), "de-ses-pe-ra-ción"); assert_eq!(h("teatro"), "tea-tro", "a vowel run stays whole"); assert_eq!(h("calle"), "ca-lle", "ll stays whole"); - assert!(Lang::for_language("French").is_none(), "only languages built in"); + assert!(Lang::for_language("Swedish").is_none(), "only languages built in"); } #[test] @@ -520,6 +543,51 @@ mod tests { assert_eq!(h("beveger"), "be-ve-ger"); } + /// The paragraph limits count the letters of the word after an elision, not the elided prefix. + #[test] + fn elision_limits_apply_to_the_word_after_it() { + let fr = Lang::for_language("French").unwrap(); + let lim = Limits { min_word: 5, after_first: 3, before_last: 2, capitalized: true }; + assert_eq!(hyphen_points_in("l'imprimerie", &lim, fr), vec![7, 9]); + let lim = Limits { min_word: 11, ..Limits::default() }; + assert!(hyphen_points_in("l'imprimerie", &lim, fr).is_empty()); + } + + /// A word with an elision ("l'", "dell'") hyphenates as the word after it. + fn marked(w: &str, l: Lang) -> String { + let pts = hyphen_points_in(w, &Limits::default(), l); + w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect() + } + + #[test] + fn french_uses_the_hyph_fr_patterns() { + let fr = Lang::for_language("French").unwrap(); + for name in ["fr", "fr_FR", "French: Canadian", "French: Swiss"] { + assert_eq!(Lang::for_language(name), Some(fr), "{name}"); + } + assert_eq!(marked("hyphénation", fr), "hy-phé-na-tion"); + assert_eq!(marked("typographie", fr), "ty-po-gra-phie"); + assert_eq!(marked("Développement", fr), "Dé-ve-lop-pe-ment"); + assert_eq!(marked("anticonstitutionnellement", fr), "an-ti-cons-ti-tu-tion-nel-le-ment"); + assert_eq!(marked("paragraphe", fr), "pa-ra-graphe", "no break before a final mute syllable of two letters"); + assert_eq!(marked("l'imprimerie", fr), "l'im-pri-me-rie", "after an elision"); + assert_eq!(marked("l’imprimerie", fr), "l’im-pri-me-rie", "a typographer's apostrophe too"); + } + + #[test] + fn italian_uses_the_hyph_it_patterns() { + let it = Lang::for_language("Italian").unwrap(); + for name in ["it", "it_IT"] { + assert_eq!(Lang::for_language(name), Some(it), "{name}"); + } + assert_ne!(it, Lang::for_language("French").unwrap()); + assert_eq!(marked("sillabazione", it), "sil-la-ba-zio-ne"); + assert_eq!(marked("tipografia", it), "ti-po-gra-fia"); + assert_eq!(marked("Repubblica", it), "Re-pub-bli-ca"); + assert_eq!(marked("impaginazione", it), "im-pa-gi-na-zio-ne"); + assert_eq!(marked("dell'impaginazione", it), "dell'im-pa-gi-na-zio-ne", "after an elision"); + } + #[test] fn heuristic_fallback() { let c: Vec = "happen".chars().collect(); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 6c8bc1206..b19833ff1 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -268,6 +268,8 @@ pub struct Deco { pub path: Option, /// Shown on screen only (not printed or exported). pub nonprinting: bool, + /// Set to overprint (paragraph shading or border); PDF export doesn't overprint yet. + pub overprint: bool, } #[derive(Clone, Debug, Default)] @@ -2569,7 +2571,12 @@ fn finish_paragraph(input: ParagraphFinishInput<'_>) -> ParagraphFinish { .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) .unwrap_or((0.0, 0.0)); let (color, tint) = rule_color(r, above); - ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color, tint, ..Deco::default() }); + // Keep In Frame: not above the frame, as InDesign's (its top, not the text area's + // below the top inset). + let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0 - if f.vertical { 0.0 } else { f.opts.inset[0] }); + let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y }; + let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y); + ft.decos.extend(rule_decos(doc, r, rect, (color, tint), above)); } } if pp.rule_below.on @@ -2580,7 +2587,7 @@ fn finish_paragraph(input: ParagraphFinishInput<'_>) -> ParagraphFinish { let y = l.baseline + r.offset; let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; let (color, tint) = rule_color(r, below); - ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint, ..Deco::default() }); + ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), (color, tint), below)); } // The end of a split block: text after it continues below its deepest sub-column, which // are balanced first. A block that ends the story fills its sub-columns in turn. @@ -5326,6 +5333,55 @@ fn hyphenation_points( out } +/// A paragraph rule drawn in `rect` (its length and weight) with its Type: the gap colour under +/// it, then the line, as dashes, dots, stripes, waves or hash marks. A pattern that can't be laid +/// out along the rule draws solid. +/// `(color, tint)` is the rule's (Text Color already resolved); `text`, the colour of the text +/// the rule goes with, for a gap in Text Color. +fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, (color, tint): (String, f32), text: &(String, f32)) -> Vec { + use designcraft_doc::StrokeType; + let kind = doc.stroke_kind(&r.kind); + let (w, len) = (rect.height(), rect.width()); + let mut out = Vec::new(); + if !matches!(kind, StrokeType::Solid) && r.gap_color != designcraft_color::swatch::NONE && w > 0.0 && len > 0.0 { + let gap = if r.gap_color == designcraft_doc::TEXT_COLOR { text.0.clone() } else { r.gap_color.clone() }; + out.push(Deco { rect, color: gap, tint: r.gap_tint, ..Deco::default() }); + } + let y = rect.center().y; + let mut line = designcraft_geom::BezPath::new(); + line.move_to((rect.x0, y)); + line.line_to((rect.x1, y)); + let path = match &kind { + StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, len) => { + let mut p = designcraft_geom::BezPath::new(); + let (mut x, mut k, mut on) = (rect.x0, 0, true); + while x < rect.x1 { + let step = pattern[k % pattern.len()]; + if on && step > 0.0 { + let r = Rect::new(x, rect.y0, (x + step).min(rect.x1), rect.y1); + p.extend(designcraft_geom::Shape::path_elements(&r, 0.1)); + } + x += step; + k += 1; + on = !on; + } + Some(p) + } + StrokeType::Dotted if StrokeType::expandable_dashes(&[w, w], len) => { + let mut p = designcraft_geom::BezPath::new(); + let mut x = rect.x0 + w / 2.0; + while x <= rect.x1 - w / 2.0 + 1e-9 { + p.extend(designcraft_geom::Shape::path_elements(&designcraft_geom::kurbo::Circle::new((x, y), w / 2.0), 0.1)); + x += 2.0 * w; + } + Some(p) + } + k => k.outline(&line, w, 0.05), + }; + out.push(Deco { rect, color, tint, path, ..Deco::default() }); + out +} + /// `spanned`: a paragraph spans the frame's columns, so the lines move together. fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { @@ -5352,6 +5408,7 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de continue; } let _ = a; + let before: Vec = idx.iter().map(|&i| ft.lines[i].baseline).collect(); match f.opts.vertical_justification { VerticalJustification::Center => idx.iter().for_each(|&i| ft.lines[i].baseline += space / 2.0), VerticalJustification::Bottom => idx.iter().for_each(|&i| ft.lines[i].baseline += space), @@ -5370,14 +5427,39 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de .collect(); let n = rows.last().copied().unwrap_or(0); if n > 0 { - let per = space / n as f64; + // Paragraph Spacing Limit: the space goes between paragraphs, up to the limit + // each; what is left spreads over every line (all of it with no limit). + let mut starts = vec![false; n + 1]; for (&i, &k) in idx.iter().zip(&rows) { - ft.lines[i].baseline += per * k as f64; + if k > 0 && ft.lines[i].first_in_para { + starts[k] = true; + } + } + let gaps = starts.iter().filter(|s| **s).count(); + let limit = f.opts.vj_paragraph_spacing_limit.max(0.0); + let para = if gaps > 0 && limit > 0.0 { (space / gaps as f64).min(limit) } else { 0.0 }; + let per = (space - para * gaps as f64) / n as f64; + let mut shift = vec![0.0; n + 1]; + for k in 1..=n { + shift[k] = shift[k - 1] + per + if starts[k] { para } else { 0.0 }; + } + for (&i, &k) in idx.iter().zip(&rows) { + ft.lines[i].baseline += shift[k]; } } } _ => {} } + // A drop cap moves with its line. + for (&i, b) in idx.iter().zip(before) { + let line = &mut ft.lines[i]; + let dy = line.baseline - b; + if let Some(dc) = &mut line.drop_cap { + dc.baseline += dy; + dc.rect.y0 += dy; + dc.rect.y1 += dy; + } + } } } diff --git a/crates/compose/src/parabox.rs b/crates/compose/src/parabox.rs index f6b90e127..09c5b19e8 100644 --- a/crates/compose/src/parabox.rs +++ b/crates/compose/src/parabox.rs @@ -32,6 +32,7 @@ struct Shading { bottom: BoxBottom, clip: bool, nonprinting: bool, + overprint: bool, } #[derive(Clone, Debug, PartialEq)] @@ -51,6 +52,8 @@ struct Border { display_if_splits: bool, gap_color: String, gap_tint: f32, + overprint: bool, + gap_overprint: bool, } fn finite(v: f64) -> f64 { @@ -72,6 +75,7 @@ impl ParaBox { bottom: pp.shading_bottom, clip: pp.shading_clip, nonprinting: pp.shading_nonprinting, + overprint: pp.shading_overprint, }); let border = pp.border_on.then(|| Border { color: pp.border_color.clone(), @@ -88,6 +92,8 @@ impl ParaBox { display_if_splits: pp.border_display_if_splits, gap_color: pp.border_gap_color.clone(), gap_tint: pp.border_gap_tint, + overprint: pp.border_overprint, + gap_overprint: pp.border_gap_overprint, }); Some(ParaBox { shading, border, merge: pp.border_merge }) } @@ -136,7 +142,7 @@ fn shading_deco(s: &Shading, part: &Part<'_>, frames: &[FrameSpec], first_part: // Corners stay square where the box is cut by a split. let corners = cut_corners(s.corners, !first_part, !last_part); let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4])); - return Some(Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting }); + return Some(Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting, overprint: s.overprint }); } None } @@ -149,15 +155,14 @@ fn border_decos(bd: &Border, part: &Part<'_>, first_part: bool, last_part: bool, let open_top = !first_part && !bd.display_if_splits; let open_bottom = !last_part && !bd.display_if_splits; for (path, gap) in border_paths(bd, o, open_top, open_bottom) { - let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) }; - decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false })); + let (color, tint, overprint) = + if gap { (bd.gap_color.clone(), bd.gap_tint, bd.gap_overprint) } else { (bd.color.clone(), bd.tint, bd.overprint) }; + decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false, overprint })); } if is_plain(bd, open_top, open_bottom) { - decos.extend( - plain_edges(bd, o, open_top, open_bottom) - .into_iter() - .map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })), - ); + decos.extend(plain_edges(bd, o, open_top, open_bottom).into_iter().map(|rect| { + (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false, overprint: bd.overprint }) + })); } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 5d6989de5..d7b87cea9 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3082,11 +3082,18 @@ fn only_english_text_gets_english_hyphenation() { let hyphenated = |d: &Document, sid| all_lines(&compose_story(d, sid, &ComposeOptions::default())).iter().filter(|l| l.hyphenated).count(); let (d, sid, _) = doc_with(text, narrow, ParaAttrs::default()); assert!(hyphenated(&d, sid) > 0, "English hyphenates"); - let mut d2 = d.clone(); - let st = d2.story_mut(sid).unwrap(); - let n = st.len(); - st.format_chars(0..n, |f| f.over.language = Some("French".into())); - assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules"); + // A language without bundled patterns isn't hyphenated with English rules. + let in_language = |lang: &str| { + let mut d2 = d.clone(); + let st = d2.story_mut(sid).unwrap(); + let n = st.len(); + st.format_chars(0..n, |f| f.over.language = Some(lang.into())); + hyphenated(&d2, sid) + }; + assert_eq!(in_language("Swedish"), 0, "Swedish isn't hyphenated with English rules"); + // French and Italian hyphenate with their own patterns. + assert!(in_language("French") > 0, "French hyphenates (hyph-fr)"); + assert!(in_language("Italian") > 0, "Italian hyphenates (hyph-it)"); } // A text frame's bottom inset constrains its last baseline. The full font descent @@ -3738,6 +3745,21 @@ fn text_x(l: &Line, from: usize) -> f64 { l.glyphs.iter().find(|g| g.len > 0 && g.byte >= from).map(|g| g.x).unwrap() } +/// Vertical justification moves the drop cap's box with its line. +#[test] +fn drop_cap_box_follows_vertical_justification() { + let (mut d, sid, fid) = drop_doc_in(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 400.0), drop_cap(3, 1)); + let top = compose_story(&d, sid, &ComposeOptions::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Bottom; + let bottom = compose_story(&d, sid, &ComposeOptions::default()); + let (a, b) = (&top.frame(fid).unwrap().lines[0], &bottom.frame(fid).unwrap().lines[0]); + let dy = b.baseline - a.baseline; + assert!(dy > 1.0); + let (da, db) = (a.drop_cap.unwrap(), b.drop_cap.unwrap()); + assert!((db.baseline - da.baseline - dy).abs() < 1e-6); + assert!((db.rect.y0 - da.rect.y0 - dy).abs() < 1e-6 && (db.rect.y1 - da.rect.y1 - dy).abs() < 1e-6); +} + #[test] fn drop_cap_spans_lines_and_indents_them() { let text = [LOREM; 2].join(" "); @@ -4135,6 +4157,56 @@ fn drop_cap_scales_with_its_point_size() { assert!(b2 - fitted * 1.5 < b0 - base[0].ascent, "a 15 pt cap rises above the first line"); } +/// Paragraph Rules › Type: a wavy, dashed or dotted rule is drawn as its shape (filled paths in +/// the rule's box), a striped one as its bands; Gap Color goes under it. Keep In Frame keeps a rule +/// above the first line inside the frame. +#[test] +fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() { + use designcraft_doc::{Rule, StrokeType}; + let rule = |kind: StrokeType| Rule { on: true, weight: 4.0, kind, ..Default::default() }; + let decos = |r: Rule| { + let para = ParaAttrs { rule_below: Some(r), ..Default::default() }; + let (d, sid, fid) = doc_with("Recipe Categories", Rect::new(0.0, 0.0, 300.0, 200.0), para); + compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos.clone() + }; + let solid = decos(rule(StrokeType::Solid)); + assert!(solid.len() == 1 && solid[0].path.is_none()); + let wavy = decos(rule(StrokeType::Wavy)); + let p = wavy[0].path.as_ref().expect("a wavy rule is a shape"); + assert!(designcraft_geom::Shape::area(p).abs() > 0.0 && (wavy[0].rect.height() - 4.0).abs() < 1e-9); + let subpaths = |p: &designcraft_geom::BezPath| p.elements().iter().filter(|e| matches!(e, designcraft_geom::PathEl::MoveTo(_))).count(); + let dashed = decos(rule(StrokeType::Dashed { pattern: vec![12.0, 4.0] })); + assert!(subpaths(dashed[0].path.as_ref().unwrap()) > 5, "one box per dash"); + let dotted = decos(rule(StrokeType::Dotted)); + assert!(subpaths(dotted[0].path.as_ref().unwrap()) > 10, "one dot per 2 weights"); + let gap = decos(Rule { gap_color: "[Black]".into(), gap_tint: 0.3, ..rule(StrokeType::Dotted) }); + assert_eq!((gap.len(), gap[0].color.as_str(), gap[0].tint, gap[0].path.is_none()), (2, "[Black]", 0.3, true), "the gap colour under the dots"); + assert_eq!(decos(Rule { gap_color: "[Black]".into(), ..rule(StrokeType::Solid) }).len(), 1, "a solid rule has no gaps"); + // A gap in Text Color takes the text's colour, whatever swatch the rule itself has. + let gap = decos(Rule { color: "[Paper]".into(), gap_color: designcraft_doc::TEXT_COLOR.into(), ..rule(StrokeType::Dotted) }); + assert_eq!((gap[0].color.as_str(), gap[1].color.as_str()), ("[Black]", "[Paper]")); + // Keep In Frame: a rule above raised out of the frame comes back to its top. + let above = |keep: bool| { + let r = Rule { on: true, weight: 2.0, offset: 30.0, keep_in_frame: keep, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(r), ..Default::default() }; + let (d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para); + compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos[0].rect + }; + assert!(above(false).y0 < 50.0, "{:?}", above(false)); + assert!((above(true).y0 - 50.0).abs() < 1e-6, "{:?}", above(true)); + // With a top inset the rule may still rise into it: raised out of the frame, it comes back + // to the frame's edge, not to the text area's below the inset. + let r = Rule { on: true, weight: 2.0, offset: 60.0, keep_in_frame: true, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(r), ..Default::default() }; + let (mut d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para); + if let Some(tf) = d.item_mut(fid).and_then(|it| it.text_frame_mut()) { + tf.options.inset = [20.0, 0.0, 0.0, 0.0]; + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let rule = cs.frame(fid).unwrap().decos[0].rect; + assert!((rule.y0 - 50.0).abs() < 1e-6, "kept at the frame's top, in the inset: {rule:?}"); +} + /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the /// rule above, the last character's for the rule below. A swatch colour is used as is. #[test] @@ -7431,6 +7503,60 @@ fn anchored_object_wraps_its_own_storys_later_lines() { assert!((o.origin.y - top).abs() < 1e-6); } +/// Vertical Justification › Justify with a Paragraph Spacing Limit: the space goes between the +/// paragraphs (up to the limit each), the lines of a paragraph keep their leading; past the limit +/// the rest spreads over every line. Without a limit it spreads over every line. +#[test] +fn justify_paragraph_spacing_limit() { + let text = "One line. +Two +Three"; + let baselines = |limit: f64| { + let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.vertical_justification = designcraft_doc::VerticalJustification::Justify; + o.vj_paragraph_spacing_limit = limit; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + all_lines(&cs).iter().map(|l| l.baseline).collect::>() + }; + let (none, big, small) = (baselines(0.0), baselines(1000.0), baselines(20.0)); + assert_eq!(none.len(), 3); + let gaps = |b: &[f64]| (b[1] - b[0], b[2] - b[1]); + // No limit: equal steps. A big limit: all of it between paragraphs (here every line is one). + assert!((gaps(&none).0 - gaps(&none).1).abs() < 1e-6, "{none:?}"); + assert!((gaps(&big).0 - gaps(&big).1).abs() < 1e-6, "{big:?}"); + // A small limit: every paragraph gap takes the whole 20 pt, and the rest spreads over lines, + // so each gap is wider than the top-aligned one by at least 20 pt and narrower than with a big limit. + let top = { + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + all_lines(&compose_story(&d, sid, &ComposeOptions::default())).iter().map(|l| l.baseline).collect::>() + }; + let (natural, small_gaps, big_gaps) = (gaps(&top), gaps(&small), gaps(&big)); + assert!(small_gaps.0 >= natural.0 + 20.0 - 1e-6 && small_gaps.1 >= natural.1 + 20.0 - 1e-6, "{top:?} {small:?}"); + assert!(small_gaps.0 <= big_gaps.0 + 1e-6, "{small:?} {big:?}"); + assert!((none[2] - big[2]).abs() < 1e-6 && (none[2] - small[2]).abs() < 1e-6, "the last line stays at the bottom"); + // A paragraph of two lines keeps its leading when the limit absorbs the space. + let (mut d, sid, fid) = doc_with( + &[LOREM, "End."].join( + " +", + ), + Rect::new(0.0, 0.0, 300.0, 600.0), + ParaAttrs::default(), + ); + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.vertical_justification = designcraft_doc::VerticalJustification::Justify; + o.vj_paragraph_spacing_limit = 10_000.0; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let first: Vec = lines.iter().filter(|l| l.para == 0).map(|l| l.baseline).collect(); + assert!(first.len() > 2, "LOREM runs over several lines"); + let lead = first[1] - first[0]; + assert!(first.windows(2).all(|w| (w[1] - w[0] - lead).abs() < 1e-6), "the paragraph's lines keep their leading: {first:?}"); + let last = lines.iter().find(|l| l.para == 1).unwrap().baseline; + assert!(last - first[first.len() - 1] > lead + 50.0, "the space went between the paragraphs"); +} + /// Each object's wrap pushes the anchors after it down, so where each object lands depends on the /// wraps of all those before it; every object still ends up wrapping the lines it hangs over. #[test] @@ -8065,6 +8191,43 @@ fn greek_text_gets_greek_hyphenation() { assert_eq!(hyphen::hyphen_points_in("ανθρωπος", &hyphen::Limits::default(), el), vec![2, 5]); } +/// An object's wrap pushes the text so that a later object's anchor goes on into the next frame +/// (Learn Skills Daily's long-document exercise: pictures anchored in headings through a 79-page +/// story). Each object is placed when its anchor line is set and wraps only the lines after it, +/// in the frame its anchor lands in: the layout doesn't depend on where an earlier composition put +/// the objects, and composing again gives the same lines. +#[test] +fn an_object_wraps_the_frame_its_anchor_lands_in_after_an_earlier_object_pushed_it_on() { + let text = [LOREM; 6].join(" "); + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), lid, &text, ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(320.0, 0.0, 620.0, 300.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + let plain = compose_story(&d, sid, &ComposeOptions::default()); + // The second object's anchor sits near the end of the first frame without wrap. + let late = plain.frames[0].lines.iter().rev().nth(2).unwrap().range.start + 2; + anchor_box(&mut d, sid, late, 0.0, 0.0, true, WrapMode::BoundingBox); + // The first, early in the first frame: 60 pt over its whole width pushes the later anchor on. + let early = plain.frames[0].lines[1].range.start + 2; + anchor_box(&mut d, sid, early, 0.0, 0.0, true, WrapMode::JumpObject); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let late = late + OBJECT_LEN; + let anchor_frame = cs.frames.iter().position(|f| f.range.contains(&late) || f.range.end == late).unwrap(); + assert_eq!(anchor_frame, 1, "the later anchor went on into the second frame"); + // The first frame's lines under the first object's band keep the full measure (the second + // object is in the other frame); in the second frame, the lines under its object move right. + let left = cs.frames[0].lines[0].x0; + assert!(cs.frames[0].lines.iter().all(|l| (l.x0 - left).abs() < 1e-6)); + let f1 = &cs.frames[1]; + let (li, line) = f1.lines.iter().enumerate().find(|(_, l)| l.range.contains(&late)).unwrap(); + let under: Vec<_> = f1.lines[li + 1..].iter().filter(|l| l.baseline - l.ascent < line.baseline + 60.0).collect(); + assert!(!under.is_empty() && under.iter().all(|l| l.x0 >= f1.lines[0].x0 + 100.0 - 1e-6)); + assert_eq!(f1.objects.len(), 1); + // Deterministic: the same lines again. + assert_eq!(line_layout(&compose_story(&d, sid, &ComposeOptions::default())), line_layout(&cs)); +} + /// Rule Above's offset runs from the first line's baseline up to the rule's bottom edge, as in /// InDesign: a rule set clear of the cap height sits between the rows, not across the row above /// (an INDD project list whose rules crossed the previous paragraph's text). diff --git a/crates/doc/src/attrs/char_attrs.rs b/crates/doc/src/attrs/char_attrs.rs index 4f4cb9d52..154dbf741 100644 --- a/crates/doc/src/attrs/char_attrs.rs +++ b/crates/doc/src/attrs/char_attrs.rs @@ -86,7 +86,7 @@ attr_set! { kenten_font: String = String::new(), kenten_font_style: String = String::new(), /// Mark size in points; `None` is half the text size. - kenten_size: Option = None, + kenten_size: Option = None => "deserialize_explicit_value", /// Horizontal / vertical scale of the mark, 1.0 = 100%. kenten_x_scale: f64 = 1.0, kenten_y_scale: f64 = 1.0, @@ -99,10 +99,10 @@ attr_set! { /// Mark fill and stroke swatches (empty = the text's), tints and stroke weight (`None` = /// the text's). kenten_fill: String = String::new(), - kenten_fill_tint: Option = None, + kenten_fill_tint: Option = None => "deserialize_explicit_value", kenten_stroke: String = String::new(), - kenten_stroke_tint: Option = None, - kenten_stroke_weight: Option = None, + kenten_stroke_tint: Option = None => "deserialize_explicit_value", + kenten_stroke_weight: Option = None => "deserialize_explicit_value", kenten_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, kenten_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, // Shatai (斜体): the character is compressed across the direction at `shatai_angle`. @@ -126,7 +126,7 @@ attr_set! { ruby_font: String = String::new(), ruby_font_style: String = String::new(), /// Points; `None` is half the parent size. - ruby_font_size: Option = None, + ruby_font_size: Option = None => "deserialize_explicit_value", /// 1.0 = 100%. ruby_x_scale: f64 = 1.0, ruby_y_scale: f64 = 1.0, @@ -147,10 +147,10 @@ attr_set! { ruby_scaling_min: f64 = 0.66, /// Ruby colour: empty is the text's colour; `None` tint and weight follow the text. ruby_fill: String = String::new(), - ruby_fill_tint: Option = None, + ruby_fill_tint: Option = None => "deserialize_explicit_value", ruby_stroke: String = String::new(), - ruby_stroke_tint: Option = None, - ruby_stroke_weight: Option = None, + ruby_stroke_tint: Option = None => "deserialize_explicit_value", + ruby_stroke_weight: Option = None => "deserialize_explicit_value", ruby_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, ruby_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, /// Digits (World-Ready): how 0–9 are drawn. diff --git a/crates/doc/src/attrs/para_attrs.rs b/crates/doc/src/attrs/para_attrs.rs index 17edc8cc1..0077c8e12 100644 --- a/crates/doc/src/attrs/para_attrs.rs +++ b/crates/doc/src/attrs/para_attrs.rs @@ -141,6 +141,9 @@ attr_set! { shading_on: bool = false, shading_color: String = "[Black]".into(), shading_tint: f32 = 0.2, + /// Overprint (InDesign's Paragraph Shading › Overprint): stored and kept through IDML; + /// PDF export doesn't overprint yet. + shading_overprint: bool = false, shading_offsets: [f64; 4] = [0.0; 4], /// Paragraph border: stroke weights (top, left, bottom, right), colour, and offsets past the /// text (top, left, bottom, right). @@ -148,6 +151,8 @@ attr_set! { border_weights: [f64; 4] = [1.0; 4], border_color: String = "[Black]".into(), border_tint: f32 = 1.0, + /// Overprint the border's stroke (stored and kept through IDML, as `ParagraphBorderOverprint`). + border_overprint: bool = false, border_offsets: [f64; 4] = [0.0; 4], /// The border's stroke type (a document stroke style by name, or a built-in type), cap and /// join. @@ -167,6 +172,8 @@ attr_set! { /// The colour between the dashes, dots or stripes of a border that isn't solid. border_gap_color: String = "[None]".into(), border_gap_tint: f32 = 1.0, + /// Overprint the border's gap colour (stored and kept through IDML). + border_gap_overprint: bool = false, /// Shading corners: top left, top right, bottom right, bottom left. shading_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4], shading_width: BoxWidth = BoxWidth::Column, @@ -188,7 +195,7 @@ attr_set! { bullet_align: ListAlign = ListAlign::Left, number_align: ListAlign = ListAlign::Left, /// A tab stop for the text after the bullet or number, from the column's left edge. - list_tab: Option = None, + list_tab: Option = None => "deserialize_explicit_value", /// Restart Numbers at This Level After: a paragraph at a higher level (0 = any; otherwise /// that level) starts this level's numbering again. restart_numbers: bool = true, @@ -222,8 +229,11 @@ impl ParaAttrs { let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) }; let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?; let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) }; + // Every field, those at their defaults (left out of the JSON) included. + const RULE_FIELDS: [&str; 12] = + ["on", "weight", "color", "tint", "columnWidth", "offset", "leftIndent", "rightIndent", "type", "gapColor", "gapTint", "keepInFrame"]; for (k, v) in patch { - if !fields.contains_key(k) { + if !RULE_FIELDS.contains(&k.as_str()) { return Err(format!("{key}: unknown rule field `{k}`")); } fields.insert(k.clone(), v.clone()); diff --git a/crates/doc/src/attrs/rules_lists.rs b/crates/doc/src/attrs/rules_lists.rs index f4ef952e4..4b36c3baf 100644 --- a/crates/doc/src/attrs/rules_lists.rs +++ b/crates/doc/src/attrs/rules_lists.rs @@ -17,11 +17,46 @@ pub struct Rule { pub offset: f64, pub left_indent: f64, pub right_indent: f64, + /// Type: the rule's stroke type (solid, dashed, wavy…, or a stroke style of the document). + #[serde(rename = "type", skip_serializing_if = "rule_solid")] + pub kind: crate::item::StrokeType, + /// Gap Color (a swatch; [None]: none) and Gap Tint: under the gaps of a type that has them. + #[serde(skip_serializing_if = "rule_no_gap")] + pub gap_color: String, + #[serde(skip_serializing_if = "rule_full_tint")] + pub gap_tint: f32, + /// Keep In Frame: a rule above the first line of a frame stays inside the frame. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub keep_in_frame: bool, +} + +// A rule's settings at their defaults are left out of the document (and of the attributes). +fn rule_solid(k: &crate::item::StrokeType) -> bool { + *k == crate::item::StrokeType::Solid +} +fn rule_no_gap(c: &str) -> bool { + c == designcraft_color::swatch::NONE +} +fn rule_full_tint(t: &f32) -> bool { + *t == 1.0 } impl Default for Rule { fn default() -> Self { - Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 } + Rule { + on: false, + weight: 1.0, + color: TEXT_COLOR.into(), + tint: 1.0, + column_width: true, + offset: 0.0, + left_indent: 0.0, + right_indent: 0.0, + kind: crate::item::StrokeType::Solid, + gap_color: designcraft_color::swatch::NONE.into(), + gap_tint: 1.0, + keep_in_frame: false, + } } } diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c7259f317..1789092ff 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -661,11 +661,18 @@ impl Document { } /// Parent page (spread index, page index) applied to `page`: the page on its side of its - /// parent spread. + /// parent spread, or in its place for single-sided pages ([`Spread::parent_page_at`]). pub fn parent_page_of(&self, page: &Page) -> Option<(usize, usize)> { let pi = self.parent_index(page.parent?)?; // A parent spread can come without pages from a file. - Some((pi, self.parents.get(pi)?.parent_page_for_side(page.side)?)) + let parent = self.parents.get(pi)?; + // Only a single-sided page's position matters: look for it only then. + let index = if page.side == PageSide::Single && parent.pages.len() >= 2 { + self.spreads.iter().find_map(|s| s.pages.iter().position(|p| p.id == page.id)).unwrap_or(0) + } else { + 0 + }; + Some((pi, parent.parent_page_at(page.side, index)?)) } /// The parent page applied to `page`. @@ -777,7 +784,7 @@ impl Document { c } }; - chain.into_iter().filter_map(|i| Some((i, self.parents.get(i)?.parent_page_for_side(pg.side)?))).collect() + chain.into_iter().filter_map(|i| Some((i, self.parents.get(i)?.parent_page_at(pg.side, page)?))).collect() } /// Whether basing parent `pi` on `base` would make a `based_on` cycle (or names no parent). diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 1d2feb6a8..4c930c878 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -385,6 +385,23 @@ mod tests { use super::*; use designcraft_geom::shapes; + /// Shadows and glows saved before they had a blend mode read Normal, as they were drawn; + /// new ones start as InDesign's: Multiply for shadows, Screen for the outer glow. + #[test] + fn effect_blend_modes_default_as_indesign_and_keep_old_documents() { + let old: DropShadow = + serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "angle": 120, "distance": 7, "size": 5, "spread": 0}"#) + .unwrap(); + assert_eq!(old.mode, BlendMode::Normal); + let old: OuterGlow = serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "size": 7, "spread": 0}"#).unwrap(); + assert_eq!(old.mode, BlendMode::Normal); + assert_eq!(DropShadow::default().mode, BlendMode::Multiply); + assert_eq!(InnerShadow::default().mode, BlendMode::Multiply); + assert_eq!(OuterGlow::default().mode, BlendMode::Screen); + let saved = serde_json::to_value(DropShadow { mode: BlendMode::Screen, ..Default::default() }).unwrap(); + assert_eq!(serde_json::from_value::(saved).unwrap().mode, BlendMode::Screen, "round trip"); + } + #[test] fn graphics_without_a_clipping_path_load_and_save_as_before() { let g = Graphic { diff --git a/crates/doc/src/item/effects.rs b/crates/doc/src/item/effects.rs index fa416643b..46aac2e95 100644 --- a/crates/doc/src/item/effects.rs +++ b/crates/doc/src/item/effects.rs @@ -13,12 +13,22 @@ pub struct DropShadow { pub distance: f64, pub size: f64, pub spread: f64, + /// How the shadow blends with what's behind it (InDesign: Multiply). A document saved before + /// effects had modes reads Normal, as they were drawn then. + #[serde(default = "normal_blend")] + pub mode: BlendMode, +} + +/// The blend of an effect saved before effects had one. +fn normal_blend() -> BlendMode { + BlendMode::Normal } impl Default for DropShadow { fn default() -> Self { DropShadow { on: false, + mode: BlendMode::Multiply, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 120.0, @@ -45,12 +55,16 @@ pub struct InnerShadow { pub size: f64, /// Percentage (0–100) of `size` that hardens the edge instead of blurring it. pub choke: f64, + /// How the shadow blends with the object (InDesign: Multiply; Normal when saved before). + #[serde(default = "normal_blend")] + pub mode: BlendMode, } impl Default for InnerShadow { fn default() -> Self { InnerShadow { on: false, + mode: BlendMode::Multiply, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 120.0, @@ -72,11 +86,15 @@ pub struct OuterGlow { pub size: f64, /// Percentage (0–100) of `size` that grows the silhouette instead of blurring it. pub spread: f64, + /// How the glow blends with what's behind it (InDesign: Screen; Normal when saved before). + #[serde(default = "normal_blend")] + pub mode: BlendMode, } impl Default for OuterGlow { fn default() -> Self { - OuterGlow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, size: 7.0, spread: 0.0 } + // InDesign starts a glow screened in white. + OuterGlow { on: false, color: designcraft_color::swatch::PAPER.into(), opacity: 0.75, size: 7.0, spread: 0.0, mode: BlendMode::Screen } } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index fecb2c3b4..8861dd924 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -831,6 +831,9 @@ pub struct Toc { /// Paragraph style of the title; `None` is the generated "TOC Title" style. #[serde(default, skip_serializing_if = "Option::is_none")] pub title_style: Option, + /// Generate a nested PDF outline from the included paragraphs when the TOC is updated. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub create_bookmarks: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -841,6 +844,16 @@ pub struct Bookmark { pub page: usize, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub children: Vec, + /// Owned by the document's generated TOC, so updating it preserves manual bookmarks. + /// + /// Stored only in native files. Bookmarks (and so this flag) are not written to or read + /// from IDML yet, so after an IDML round trip the next TOC update builds the outline fresh. + /// If IDML bookmark import is added, imported bookmarks arrive with this flag false and the + /// next update would keep them as manual and add a second, duplicate outline. That import + /// must set this flag on the bookmarks the TOC generated (for example by matching them to + /// the TOC entries) when the TOC has `create_bookmarks` on. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub generated_by_toc: bool, } impl Document { diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index 7041f7ed8..caf1c102e 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -452,6 +452,16 @@ impl Spread { PageSide::Single => Some(0), } } + /// The parent page shown behind page `index` of a document spread, on `side`: by side for + /// facing pages ([`Self::parent_page_for_side`]); by position for single-sided pages in a + /// spread of several, as InDesign does, so the second page of a two-page spread shows the + /// parent's second page (the last one, for spreads longer than the parent). + pub fn parent_page_at(&self, side: PageSide, index: usize) -> Option { + match side { + PageSide::Single if self.pages.len() >= 2 => Some(index.min(self.pages.len() - 1)), + _ => self.parent_page_for_side(side), + } + } /// Index of the page under spread x (nearest page for the pasteboard). pub fn page_at_x(&self, x: f64) -> Option { if self.pages.is_empty() { diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index bb56505f7..bbdbc6409 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -39,6 +39,35 @@ pub const FORCED_LINE_BREAK: char = '\u{2028}'; pub const ODD_PAGE_BREAK: char = '\u{E011}'; pub const EVEN_PAGE_BREAK: char = '\u{E012}'; +/// Encode a table-of-contents separator using InDesign's special-character notation. +pub fn toc_separator_notation(text: &str) -> String { + text.replace('^', "^^").replace('\t', "^t").replace(RIGHT_INDENT_TAB, "^y").replace('\u{2003}', "^m").replace('\u{2002}', "^>") +} + +/// Decode TOC notation, preserving unknown codes and an escaped literal caret. +pub fn toc_separator_text(notation: &str) -> String { + let mut text = String::new(); + let mut chars = notation.chars(); + while let Some(ch) = chars.next() { + if ch != '^' { + text.push(ch); + continue; + } + match chars.next() { + Some('t') => text.push('\t'), + Some('y') => text.push(RIGHT_INDENT_TAB), + Some('m') => text.push('\u{2003}'), + Some('>') => text.push('\u{2002}'), + Some('^') | None => text.push('^'), + Some(other) => { + text.push('^'); + text.push(other); + } + } + } + text +} + /// A column, frame or page break character. It ends its paragraph: the story stores it as the /// paragraph's last character, followed by the `\n` separator (also at the end of the story), and /// the next paragraph starts in the next column, frame or page. A break inside a paragraph (from @@ -286,6 +315,24 @@ impl Story { v } + /// The paragraphs `r` touches (at least the one at `r.start`), as (index, byte range without + /// its return), found from `r` itself rather than by listing every paragraph of the story. + pub fn paras_touching(&self, r: std::ops::Range) -> Vec<(usize, std::ops::Range)> { + let start = floor_char_boundary(&self.text, r.start.min(self.text.len())); + let mut pi = self.para_at(start); + let mut a = self.text[..start].rfind('\n').map_or(0, |i| i + 1); + let mut out = Vec::new(); + loop { + let b = self.text[a..].find('\n').map_or(self.text.len(), |i| a + i); + out.push((pi, a..b)); + // The next paragraph starts after this one's return: is it still inside `r`? + if b >= self.text.len() || b + 1 >= r.end { + return out; + } + (a, pi) = (b + 1, pi + 1); + } + } + /// Index of the paragraph containing byte `pos`. pub fn para_at(&self, pos: usize) -> usize { self.text[..floor_char_boundary(&self.text, pos)].bytes().filter(|b| *b == b'\n').count() @@ -639,6 +686,27 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + /// `paras_touching` gives the paragraphs a range reaches into (returns included), as listing + /// every paragraph would, for every range of a story with empty and multi-byte paragraphs. + #[test] + fn paras_touching_matches_the_full_listing() { + let s = st("ab + +çé +last"); + let all = s.para_ranges(); + for a in 0..=s.len() { + for b in a..=s.len() { + if !s.text.is_char_boundary(a) || !s.text.is_char_boundary(b) { + continue; + } + let want: Vec<(usize, std::ops::Range)> = + all.iter().cloned().enumerate().filter(|(i, p)| *i == s.para_at(a) || (a < b && p.start < b && a <= p.end)).collect(); + assert_eq!(s.paras_touching(a..b), want, "{a}..{b}"); + } + } + } + #[test] fn optical_margin_is_optional_in_saved_stories() { let plain = serde_json::to_value(st("Text")).unwrap(); diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 937fefe1f..3782c0181 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -156,6 +156,19 @@ mod tests { assert_eq!(right.selected_owners(CellRange::new(1, 0, 1, 1)), vec![(0, 1), (1, 0)], "owner order, not encounter order"); } + /// A short last row's padding cells take the converted text's paragraph and character format, + /// like the cells with text. + #[test] + fn from_strings_formats_the_padding_cells_too() { + let data = vec![vec!["a".to_string(), "b".to_string()], vec!["c".to_string()]]; + let para = ParaFormat { style: "Body".into(), ..Default::default() }; + let fmt = CharFormat { style: "Strong".into(), ..Default::default() }; + let t = Table::from_strings(1, &data, 200.0, ¶, &fmt); + let pad = &t.cell(1, 1).unwrap().text; + assert_eq!((pad.paras[0].style.as_str(), pad.chars[0].format.style.as_str()), ("Body", "Strong")); + assert_eq!(pad.text, ""); + } + #[test] fn insert_table_gets_own_paragraph() { let mut s = story("before after"); diff --git a/crates/doc/src/table/grid.rs b/crates/doc/src/table/grid.rs index 7ae118744..6a11ffcd1 100644 --- a/crates/doc/src/table/grid.rs +++ b/crates/doc/src/table/grid.rs @@ -55,12 +55,15 @@ impl Table { pub fn from_strings(id: u64, data: &[Vec], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table { let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1); let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width); - for (r, row) in data.iter().enumerate() { - for (c, s) in row.iter().enumerate() { + // Every cell takes the text's format, the padding of a short row too. + for r in 0..t.nrows() { + for c in 0..t.ncols() { let Some(cell) = t.cell_mut(r, c) else { continue }; cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() }); cell.text.chars[0].format = fmt.clone(); - cell.text.insert(0, s); + if let Some(s) = data.get(r).and_then(|row| row.get(c)) { + cell.text.insert(0, s); + } } } t @@ -180,14 +183,19 @@ impl Table { /// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents). pub fn plain_text(&self) -> String { + self.text_with("\t", "\n") + } + + /// The cells' text, columns separated by `col` and rows by `row` (Convert Table to Text). + pub fn text_with(&self, col: &str, row: &str) -> String { let mut out = String::new(); for r in 0..self.nrows() { if r > 0 { - out.push('\n'); + out.push_str(row); } for c in 0..self.ncols() { if c > 0 { - out.push('\t'); + out.push_str(col); } if let Some(cell) = self.cell(r, c) { out.push_str(&cell.text.text.replace('\n', " ")); diff --git a/crates/doc/src/xref.rs b/crates/doc/src/xref.rs index 96e584862..d3456a291 100644 --- a/crates/doc/src/xref.rs +++ b/crates/doc/src/xref.rs @@ -84,7 +84,7 @@ pub fn expand(definition: &str, v: &XrefValues) -> String { let mut rest = definition; while let Some(i) = rest.find('<') { out.push_str(&rest[..i]); - let Some(j) = rest[i..].find('>') else { + let Some(j) = tag_end(&rest[i..]) else { out.push_str(&rest[i..]); return out; }; @@ -120,6 +120,20 @@ pub fn expand(definition: &str, v: &XrefValues) -> String { typographic_quotes(&out) } +/// Byte index of the `>` that ends the tag starting at `s`, skipping quoted attribute values. +/// An unterminated quote falls back to the first `>`, so only that malformed tag is affected. +fn tag_end(s: &str) -> Option { + let mut in_quote = false; + for (k, c) in s.char_indices() { + match c { + '"' => in_quote = !in_quote, + '>' if !in_quote => return Some(k), + _ => {} + } + } + s.find('>') +} + /// Straight double quotes in format text become typographer's quotes. fn typographic_quotes(s: &str) -> String { let mut out = String::with_capacity(s.len()); @@ -388,6 +402,19 @@ pub fn clean(s: &str) -> String { mod tests { use super::*; + #[test] + fn gt_inside_quoted_attribute_does_not_end_tag() { + let v = XrefValues { para: "Intro > the start".into(), ..Default::default() }; + assert_eq!(expand("\" includeDelim=\"false\" />|", &v), "Intro |"); + } + + #[test] + fn unterminated_quote_only_affects_its_own_tag() { + let v = XrefValues { para: "Intro > the start".into(), ..Default::default() }; + let out = expand(" ", &v); + assert!(out.contains("Intro > the start"), "{out}"); + } + #[test] fn formats_expand() { let v = diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index fec81de4b..4ccbeec7e 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -66,6 +66,15 @@ pub fn specs() -> Vec { has_selection, join_paths ), + cmd!( + "path.deletePoints", + "Delete Points", + [], + None, + "{points?: [[id, subpath, index]]} — the Direct Selection tool's selected points by default, in one undo step (Properties › Quick Actions › Delete Point(s)); a subpath left with one point goes, and an object with none is removed → {deleted, removed: [id]}", + has_doc, + delete_points + ), cmd!( "path.convertPoints", "Convert Point", @@ -162,6 +171,66 @@ fn join_paths(s: &mut Session, p: &Value) -> Result { }) } +/// Delete Point(s): removes the given points, or the ones selected with Direct Selection. +fn delete_points(s: &mut Session, p: &Value) -> Result { + const ID: &str = "path.deletePoints"; + let points: Vec<(ItemId, usize, usize)> = match p.get("points") { + Some(v) => serde_json::from_value::>(v.clone()) + .map_err(|e| bad(ID, format!("points: [[id, subpath, index], …] ({e})")))? + .into_iter() + .map(|(id, si, ai)| (ItemId(id), si, ai)) + .collect(), + None => s.doc()?.selection.anchors.clone(), + }; + if points.is_empty() { + return Err(bad(ID, "no points selected")); + } + // Each subpath's points, worked from the last subpath and the last point back, so the indices + // still to delete keep their place. + let mut groups: Vec<((ItemId, usize), Vec)> = Vec::new(); + for (id, si, ai) in points { + match groups.iter_mut().find(|(k, _)| *k == (id, si)) { + Some((_, v)) => v.push(ai), + None => groups.push(((id, si), vec![ai])), + } + } + groups.sort_by_key(|((id, si), _)| std::cmp::Reverse((id.0, *si))); + s.edit(|d, sel| { + let (mut deleted, mut removed) = (0usize, Vec::new()); + for ((id, si), mut ais) in groups { + if d.item(id).is_none() { + continue; + } + path_editable(d, id, ID)?; + let Some(it) = d.item_mut(id) else { continue }; + let Some(sp) = it.path.subpaths.get_mut(si) else { continue }; + ais.sort_unstable(); + ais.dedup(); + for ai in ais.into_iter().rev() { + if ai < sp.anchors.len() { + sp.anchors.remove(ai); + deleted += 1; + } + } + // A subpath needs two points (three to stay closed). + let (left, closed) = (sp.anchors.len(), sp.closed); + if left < 2 { + it.path.subpaths.remove(si); + } else if closed && left < 3 { + sp.closed = false; + } + as_path(&mut it.shape); + if it.path.subpaths.is_empty() { + d.remove_item(id)?; + sel.items.retain(|i| *i != id); + removed.push(id.0); + } + } + sel.anchors.clear(); + Ok(json!({"deleted": deleted, "removed": removed})) + }) +} + /// Convert Point: the selected points (or every point of the selected paths) to a point type. fn convert_points(s: &mut Session, p: &Value) -> Result { let kind = super::str_param(p, "kind").unwrap_or("plain").to_string(); @@ -232,7 +301,7 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result { .and_then(Value::as_array) .map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect()) .unwrap_or_default(); - s.edit(|d, _| { + s.edit(|d, sel| { path_editable(d, id, cmd)?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let scale = inv.determinant().abs().sqrt().max(1e-9); @@ -257,6 +326,8 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result { return Err(bad(cmd, "the drag didn't touch the path")); }; let sp = it.path.subpaths[si].clone(); + // The subpath list changes under the stored point indices: they would name other points. + sel.anchors.retain(|a| a.0 != id); if smooth { it.path.subpaths[si] = designcraft_geom::edit_path::smooth(&sp, a, b, tol * scale); } else { @@ -299,7 +370,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.addAnchor", "missing id"))?; let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?; let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0); - s.edit(|d, _| { + s.edit(|d, sel| { path_editable(d, id, "path.addAnchor")?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; @@ -313,6 +384,10 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result { let t = t.clamp(1e-4, 1.0 - 1e-4); let ai = it.path.subpaths[si].insert_anchor(seg, t); as_path(&mut it.shape); + // Selected points after the new one move up an index, so they stay the same points. + for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si && a.2 >= ai) { + a.2 += 1; + } Ok(json!({"subpath": si, "anchor": ai})) }) } @@ -328,15 +403,27 @@ fn delete_anchor(s: &mut Session, p: &Value) -> Result { } sp.anchors.remove(ai); // A subpath needs two points (three to stay closed). - if sp.anchors.len() < 2 { + let removed_subpath = sp.anchors.len() < 2; + if removed_subpath { it.path.subpaths.remove(si); } else if sp.closed && sp.anchors.len() < 3 { sp.closed = false; } as_path(&mut it.shape); + // The deleted point leaves the selection; the selected points after it (or the subpaths + // after a removed one) move down an index. + sel.anchors.retain(|a| !(a.0 == id && a.1 == si && (a.2 == ai || removed_subpath))); + for a in sel.anchors.iter_mut().filter(|a| a.0 == id) { + if removed_subpath && a.1 > si { + a.1 -= 1; + } else if !removed_subpath && a.1 == si && a.2 > ai { + a.2 -= 1; + } + } if it.path.subpaths.is_empty() { d.remove_item(id)?; sel.items.retain(|i| *i != id); + sel.anchors.retain(|a| a.0 != id); return Ok(json!({"deleted": true})); } Ok(json!({"deleted": false})) @@ -426,6 +513,27 @@ mod tests { } /// Join Path closes one open path, and joins two open paths at their nearest ends. + /// `path.moveAnchors` points may each carry their own movement (Direct Selection sends each + /// point's in its own spread); without one a point takes the shared `dx`, `dy`. + #[test] + fn move_anchors_takes_a_movement_per_point() { + let mut s = crate::Session::new(); + s.execute("file.new", &serde_json::json!({})).unwrap(); + let id = s.execute("frame.create", &serde_json::json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let at = |s: &crate::Session, i: usize| { + let d = &s.doc().unwrap().doc; + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + it.xf * it.path.subpaths[0].anchors[i].p + }; + let (a0, a1) = (at(&s, 0), at(&s, 1)); + s.execute("path.moveAnchors", &serde_json::json!({"points": [[id, 0, 0, 5, 0], [id, 0, 1]], "dx": 0, "dy": 7})).unwrap(); + assert_eq!(at(&s, 0) - a0, designcraft_geom::Vec2::new(5.0, 0.0), "its own"); + assert_eq!(at(&s, 1) - a1, designcraft_geom::Vec2::new(0.0, 7.0), "the shared one"); + for bad in [serde_json::json!([[id, 0, 0, "x", 1]]), serde_json::json!([[id, 0, 0, 5]])] { + assert!(s.execute("path.moveAnchors", &serde_json::json!({"points": bad})).is_err(), "{bad}"); + } + } + #[test] fn join_path_closes_one_path_and_joins_two() { let mut s = Session::new(); @@ -454,6 +562,63 @@ mod tests { } /// Convert Point turns the selected points (or a whole path's) plain, corner, smooth or symmetrical. + /// Delete Point(s) removes the selected points of several paths in one step; a triangle left + /// with two points opens, and Undo brings every point back. + #[test] + fn delete_points_removes_the_selected_points_of_several_paths() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 72, 300, 172], "shape": "polygon", "sides": 3})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"anchors": [[a, 0, 0], [a, 0, 2], [b, 0, 1]]})).unwrap(); + let r = s.execute("path.deletePoints", &json!({})).unwrap(); + assert_eq!(r["deleted"], 3); + let d = &s.doc().unwrap().doc; + let pa = &d.item(ItemId(a)).unwrap().path.subpaths[0]; + assert_eq!(pa.anchors.len(), 2, "two of the square's four points are left"); + assert!(!pa.closed, "two points can't stay closed"); + assert_eq!(d.item(ItemId(b)).unwrap().path.subpaths[0].anchors.len(), 2); + assert!(s.doc().unwrap().selection.anchors.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 4, "one undo step"); + assert_eq!(d.item(ItemId(b)).unwrap().path.subpaths[0].anchors.len(), 3); + // A line's two points: the line goes. + let l = s.execute("path.create", &json!({"anchors": [{"p": [72, 300]}, {"p": [172, 300]}]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("path.deletePoints", &json!({"points": [[l, 0, 0]]})).unwrap(); + assert_eq!(r["removed"], json!([l])); + assert!(s.doc().unwrap().doc.item(ItemId(l)).is_none()); + assert!(s.execute("path.deletePoints", &json!({"points": []})).is_err()); + // Named points need no selected object. + s.execute("selection.set", &json!({"ids": []})).unwrap(); + assert!(s.doc().unwrap().selection.items.is_empty()); + let r = s.execute("path.deletePoints", &json!({"points": [[b, 0, 0]]})).unwrap(); + assert_eq!(r["deleted"], 1); + // Edit › Clear with points selected deletes the points, not their paths. + s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 3, "one point gone, the frame stays"); + // Cut copies whole objects, so it removes whole objects. + s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.item(ItemId(a)).is_none(), "the cut object is gone"); + } + + /// Shift-adding an object to a point selection drops the points, so Clear deletes the objects. + #[test] + fn clear_after_adding_an_object_to_a_point_selection_deletes_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 72, 300, 172]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap(); + s.execute("selection.set", &json!({"ids": [b], "add": true})).unwrap(); + assert!(s.doc().unwrap().selection.anchors.is_empty()); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.item(ItemId(a)).is_none()); + assert!(s.doc().unwrap().doc.item(ItemId(b)).is_none()); + } + #[test] fn convert_point_kinds() { let mut s = Session::new(); @@ -486,16 +651,45 @@ mod tests { let pts: Vec = (0..=20).map(|i| json!({"p": [100.0 + i as f64 * 20.0, if i % 2 == 0 { 300.0 } else { 306.0 }]})).collect(); let id = s.execute("path.create", &json!({"anchors": pts})).unwrap()["id"].as_u64().unwrap(); let n = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count(); + s.execute("selection.set", &json!({"anchors": [[id, 0, 20]]})).unwrap(); let drag: Vec = (0..=12).map(|i| json!([150.0 + i as f64 * 20.0, 303.0])).collect(); s.execute("path.smooth", &json!({"id": id, "points": drag})).unwrap(); let after = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count(); assert!(after < n, "{after} < {n}"); + assert!(s.doc().unwrap().selection.anchors.is_empty(), "stored point indices are stale after the path is rebuilt"); let drag: Vec = (0..=5).map(|i| json!([200.0 + i as f64 * 20.0, 303.0])).collect(); let r = s.execute("path.erase", &json!({"id": id, "points": drag})).unwrap(); assert_eq!(r["subpaths"], 2, "a gap in the middle"); assert!(s.execute("path.erase", &json!({"id": id, "points": [[100, 600]]})).is_err()); } + /// The Direct Selection's selected points stay the same points when the path changes under + /// them: a point added before them, one of them deleted, the path reversed. + #[test] + fn selected_anchors_follow_added_deleted_and_reversed_points() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + let point = |s: &Session, a: (usize, usize)| s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[a.0].anchors[a.1].p; + s.execute("selection.set", &json!({"anchors": [[id, 0, 2], [id, 0, 3]]})).unwrap(); + let picked = |s: &Session| s.doc().unwrap().selection.anchors.iter().map(|a| (a.1, a.2)).collect::>(); + let before: Vec<_> = picked(&s).into_iter().map(|a| point(&s, a)).collect(); + // A point added on the first side, before both. + let first = point(&s, (0, 0)); + let second = point(&s, (0, 1)); + let mid = [(first.x + second.x) / 2.0, (first.y + second.y) / 2.0]; + s.execute("path.addAnchor", &json!({"id": id, "at": mid})).unwrap(); + assert_eq!(picked(&s).into_iter().map(|a| point(&s, a)).collect::>(), before, "after an added point"); + // One of them deleted: the other stays selected, the same point. + let (si, ai) = picked(&s)[0]; + s.execute("path.deleteAnchor", &json!({"id": id, "subpath": si, "anchor": ai})).unwrap(); + assert_eq!(picked(&s).len(), 1); + assert_eq!(point(&s, picked(&s)[0]), before[1], "after a deleted point"); + // Reversed: still the same point. + s.execute("path.reverse", &json!({"ids": [id]})).unwrap(); + assert_eq!(point(&s, picked(&s)[0]), before[1], "after reversing"); + } + #[test] fn add_convert_delete_anchor_points() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 890974f32..7b9834f91 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,6 +8,21 @@ use serde_json::{Value, json}; use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok}; use crate::{HistoryEntry, Result, Session}; +/// `anchors` of `selection.set`: the points that exist, once each. +fn anchors_param(doc: &designcraft_doc::Document, p: &Value) -> Vec<(ItemId, usize, usize)> { + let mut out: Vec<(ItemId, usize, usize)> = Vec::new(); + for v in p.get("anchors").and_then(Value::as_array).into_iter().flatten() { + let n = |i: usize| v.get(i).and_then(Value::as_u64); + let (Some(id), Some(si), Some(ai)) = (n(0), n(1), n(2)) else { continue }; + let (Ok(si), Ok(ai)) = (usize::try_from(si), usize::try_from(ai)) else { continue }; + let a = (ItemId(id), si, ai); + if doc.item(a.0).is_some_and(|it| it.path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())) && !out.contains(&a) { + out.push(a); + } + } + out +} + fn can_undo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) } @@ -235,20 +250,36 @@ pub fn specs() -> Vec { s.typing_format = None; Ok(json!({"redone": e.label})) }), - cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| { - let ids = ids_param(p, "ids").unwrap_or_default(); + cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool, anchors?: [[id, subpath, index]] (Direct Selection: the selected anchor points; their paths are selected too)}", has_doc, |s, p| { + let mut ids = ids_param(p, "ids").unwrap_or_default(); let add = bool_or(p, "add", false); let content = bool_or(p, "content", false); let st = s.doc_mut()?; + let anchors = anchors_param(&st.doc, p); + for (id, _, _) in &anchors { + if !ids.contains(id) { + ids.push(*id); + } + } let ids: Vec = ids.into_iter().filter(|i| st.doc.item(*i).is_some()).collect(); if add { + // Adding objects (no points) leaves no live points: Delete would otherwise remove + // the stale points instead of the objects. + if anchors.is_empty() { + st.selection.anchors.clear(); + } for i in ids { if !st.selection.items.contains(&i) { st.selection.items.push(i); } } + for a in anchors { + if !st.selection.anchors.contains(&a) { + st.selection.anchors.push(a); + } + } } else { - st.selection = Selection { items: ids, content, ..Default::default() }; + st.selection = Selection { items: ids, content, anchors, ..Default::default() }; } st.selection.text = None; st.selection.cells = None; @@ -262,12 +293,15 @@ pub fn specs() -> Vec { st.selection.cells = None; if let Some(i) = st.selection.items.iter().position(|x| *x == id) { st.selection.items.remove(i); + // Its points go with it: arrow keys and Delete act on the selected points. + st.selection.anchors.retain(|a| a.0 != id); // A key object needs another object to align to it. if st.selection.key == Some(id) || st.selection.items.len() < 2 { st.selection.key = None; } } else { st.selection.items.push(id); + st.selection.anchors.clear(); } st.bump_revision(); ok() @@ -320,7 +354,7 @@ pub fn specs() -> Vec { "Clear", ["Edit"], Some("Delete"), - "{ids?} — delete selected items (or the selected text or ruler guides)", + "{ids?} — delete selected items (or the selected text, points or ruler guides)", has_doc, |s, p| { if s.doc()?.selection.text.is_some() && p.get("ids").is_none() { @@ -329,6 +363,10 @@ pub fn specs() -> Vec { if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() { return s.execute("guide.delete", &json!({})); } + // Points selected with Direct Selection: they go, not their paths (as InDesign). + if p.get("ids").is_none() && !s.doc()?.selection.anchors.is_empty() { + return s.execute("path.deletePoints", &json!({})); + } let ids = super::targets(s, p)?; // With a placed graphic itself selected (Direct Selection), only the graphic goes: its // frame stays, empty and selected (as in InDesign). Other items are deleted. @@ -362,6 +400,11 @@ pub fn specs() -> Vec { return Ok(json!({"text": text})); } copy_items(s)?; + // The whole objects are on the clipboard, so the whole objects go, not just selected points. + if p.get("ids").is_none() { + let ids: Vec = s.doc()?.selection.items.iter().map(|i| i.0).collect(); + return s.execute("edit.clear", &json!({"ids": ids})); + } s.execute("edit.clear", p) }), cmd!( diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index a4ee73ce4..270fcfc4c 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -16,6 +16,7 @@ mod registry; use flatten::{rasterize_effects, shadow_coverage}; #[cfg(not(target_arch = "wasm32"))] pub(crate) use pdf::pdf_export; +pub(crate) use pdf::{check_pdf_options, pdf_report}; pub(crate) use pdf::{flatten_ppi, options, print_pdf}; pub use registry::specs; diff --git a/crates/engine/src/cmd/export/pdf.rs b/crates/engine/src/cmd/export/pdf.rs index 903639ff9..94cd85ce2 100644 --- a/crates/engine/src/cmd/export/pdf.rs +++ b/crates/engine/src/cmd/export/pdf.rs @@ -169,41 +169,21 @@ pub(crate) fn print_pdf( /// The PDF that File › Export PDF writes for `p`, with its warnings: the print PDF /// ([`print_pdf`]) with its view options, timings and transitions. File › Print prints it. pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result { - let st = s.doc()?; - let opts = options(p, st.doc.page_count())?; + pdf_report(&s.doc()?.doc, &s.cache, p) +} + +/// [`pdf_export`] of `doc`: `file.package` writes its PDF of the packaged copy with it. +pub(crate) fn pdf_report(doc: &designcraft_doc::Document, cache: &designcraft_compose::Cache, p: &Value) -> Result { + let opts = options(p, doc.page_count())?; let ppi = flatten_ppi("file.exportPdf", p)?; - let mut r = print_pdf(&st.doc, &s.cache, &opts, ppi)?; - let doc: &designcraft_doc::Document = &st.doc; + let (cat, dest) = view_options(p)?; + let mut r = print_pdf(doc, cache, &opts, ppi)?; let spreads = designcraft_pdf::sheet_spreads(doc, &opts); - // Interactive PDF view options. - let mut cat = String::new(); - if p.get("fullScreen").and_then(Value::as_bool) == Some(true) { - cat.push_str("/PageMode/FullScreen"); - } else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) { - cat.push_str("/PageMode/UseOutlines"); - } - match str_param(p, "pageLayout") { - Some("single") => cat.push_str("/PageLayout/SinglePage"), - Some("continuous") => cat.push_str("/PageLayout/OneColumn"), - Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"), - Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"), - Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"), - Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))), - None => {} - } - if let Some(v) = str_param(p, "view") { - // The first page opens with this view (pages are objects in order, so refer by index 0). - let dest = match v { - "fitPage" => "/Fit", - "fitWidth" => "/FitH null", - "actual" => "/XYZ null null 1", - o => { - return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))); - } - }; - if let Some(first) = first_page_ref(&r.bytes) { - cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]")); - } + let mut cat = cat; + if let Some(dest) = dest + && let Some(first) = first_page_ref(&r.bytes) + { + cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]")); } if !cat.is_empty() { match designcraft_pdf::add_catalog_entries(&r.bytes, &cat) { @@ -213,8 +193,7 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result 0.0) { - let n = designcraft_pdf::sheet_spreads(doc, &opts).len(); - let dur: Vec> = (0..n).map(|_| Some(format!("/Dur {secs:.2}"))).collect(); + let dur: Vec> = (0..spreads.len()).map(|_| Some(format!("/Dur {secs:.2}"))).collect(); match designcraft_pdf::add_page_entries(&r.bytes, &dur) { Some(b) => r.bytes = b, None => r.warnings.push("page timings couldn't be set".into()), @@ -232,6 +211,44 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result Result<(String, Option<&'static str>)> { + let mut cat = String::new(); + if p.get("fullScreen").and_then(Value::as_bool) == Some(true) { + cat.push_str("/PageMode/FullScreen"); + } else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) { + cat.push_str("/PageMode/UseOutlines"); + } + match str_param(p, "pageLayout") { + Some("single") => cat.push_str("/PageLayout/SinglePage"), + Some("continuous") => cat.push_str("/PageLayout/OneColumn"), + Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"), + Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"), + Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"), + Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))), + None => {} + } + // The first page opens with this view (pages are objects in order, so refer by index 0). + let dest = match str_param(p, "view") { + None => None, + Some("fitPage") => Some("/Fit"), + Some("fitWidth") => Some("/FitH null"), + Some("actual") => Some("/XYZ null null 1"), + Some(o) => return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))), + }; + Ok((cat, dest)) +} + +/// Refuses PDF options `p` that [`pdf_report`] would refuse for a document of `pages` pages, +/// without exporting: `file.package` checks them before it writes anything. +pub(crate) fn check_pdf_options(p: &Value, pages: usize) -> Result<()> { + options(p, pages)?; + flatten_ppi("file.exportPdf", p)?; + view_options(p)?; + Ok(()) +} + #[cfg(test)] mod tagged_tests { use serde_json::json; diff --git a/crates/engine/src/cmd/file/new.rs b/crates/engine/src/cmd/file/new.rs index 6614a2323..e02ce11e4 100644 --- a/crates/engine/src/cmd/file/new.rs +++ b/crates/engine/src/cmd/file/new.rs @@ -13,10 +13,11 @@ fn find_preset(s: &Session, name: &str) -> Option { } /// The settings `file.new` makes a document with for `p`: its preset, then the settings over it. +/// Without a preset or `units`, it is in the default units (Preferences with no document). fn new_document_settings(s: &Session, p: &Value) -> Result { let mut nd = match str_param(p, "preset") { Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, - None => NewDocument::default(), + None => s.default_new_document(), }; apply_settings(&mut nd, p); check_size("file.new", &nd)?; @@ -40,12 +41,19 @@ pub(super) fn file_new(s: &mut Session, p: &Value) -> Result { let mut nd = new_document_settings(s, p)?; s.untitled += 1; nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); - let d = new_document(&nd); + let mut d = new_document(&nd); + s.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null())); let i = s.add_document(DocState::new(d, None)); Ok(json!({"index": i})) } impl Session { + /// What New Document starts with: the factory settings, in the default units (Preferences + /// with no document open). + pub fn default_new_document(&self) -> NewDocument { + NewDocument { units: self.prefs.default_units.horizontal_units, ..NewDocument::default() } + } + /// New Document's Preview: a document made as `file.new` would make it with `p`, shown while /// the New Document window is open. It replaces the previous preview document in its tab and /// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses @@ -64,7 +72,9 @@ impl Session { self.untitled += 1; } nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); - let st = DocState::new(new_document(&nd), None); + let mut d = new_document(&nd); + self.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null())); + let st = DocState::new(d, None); let uid = st.uid; match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { Some((i, d)) => { diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index 44f993a64..638742885 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -276,7 +276,7 @@ pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result { } // Drag: fit proportionally; click: actual size at the point. let (r, k) = match rect { - Some(r) if r.width() > 2.0 && r.height() > 2.0 => { + Some(r) if r.width() > designcraft_tools::PLACE_DRAG_MIN && r.height() > designcraft_tools::PLACE_DRAG_MIN => { let k = (r.width() / nw).min(r.height() / nh); (Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k) } diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 8d7c68632..371b89012 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -1,12 +1,12 @@ //! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters, -//! optional formatting in the change, scoped to the document, a story or the selection. +//! a Find Format and a Change Format, scoped to the document, a story or the selection. use std::ops::Range; use std::sync::{Arc, Weak}; use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement}; use designcraft_doc::story::floor_char_boundary; -use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel}; +use designcraft_doc::{CellAddr, Document, Selection, Story, StoryId, Styles, TextSel}; use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; @@ -61,18 +61,18 @@ pub fn specs() -> Vec { } ), cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None, - "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)", + "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)", has_doc, find), cmd!( "find.change", "Change All", ["Edit", "Find/Change"], None, - "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, first?: bool (change only the first match), attrs?: {character attributes applied to changed text}} → {count}", + "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), first?: bool (change only the first match), found?: bool (Change: only the text Find Next found, while it is selected and still a match; it stays selected, and Find Next goes on after it in the same domain), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted; with no find, change is literal text", has_doc, change ), - cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), ] } @@ -108,6 +108,227 @@ fn build(p: &Value) -> Result { re.map_err(|e| bad("find", e)) } +/// Find/Change's Find Format: the text a search finds must have it all through. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct FindFormat { + char_style: Option, + para_style: Option, + /// Resolved character attributes, as `type.selectionAttrs` reports them. + chars: serde_json::Map, +} + +impl FindFormat { + fn from_params(p: &Value) -> Result> { + let Some(f) = p.get("findFormat").filter(|f| !f.is_null()) else { return Ok(None) }; + let o = f.as_object().ok_or_else(|| bad("find", "`findFormat` is an object"))?; + // A style is named; a null asks for none. Any other value is refused rather than + // searched without. + let name = |k: &str| match o.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) => Ok(Some(n.clone())), + Some(v) => Err(bad("find", format!("findFormat: `{k}` is a style name, not {v}"))), + }; + let chars = match o.get("chars") { + None | Some(Value::Null) => None, + Some(Value::Object(m)) => Some(m), + Some(v) => return Err(bad("find", format!("findFormat: `chars` is an object of character attributes, not {v}"))), + }; + // Only attributes text has, kept as the document writes them: `font_style` and + // `fontStyle`, or 12 and 12.0, then find the same text. A null asks for nothing. + let mut known = designcraft_doc::CharAttrs::default(); + for (k, v) in chars.into_iter().flatten() { + known.set_json(k, v).map_err(|e| bad("find", format!("findFormat: {e}")))?; + } + let chars = match serde_json::to_value(&known) { + Ok(Value::Object(m)) => m, + _ => serde_json::Map::new(), + }; + let f = Self { char_style: name("charStyle")?, para_style: name("paraStyle")?, chars }; + Ok((f != Self::default()).then_some(f)) + } + + /// Styles the document doesn't have are refused, as `find.change` refuses them. + fn check(&self, styles: &Styles) -> Result<()> { + if let Some(n) = self.char_style.as_ref().filter(|n| styles.char_style(n).is_none()) { + return Err(bad("find", format!("no character style `{n}`"))); + } + if let Some(n) = self.para_style.as_ref().filter(|n| styles.para(n).is_none()) { + return Err(bad("find", format!("no paragraph style `{n}`"))); + } + Ok(()) + } + + /// The text in `r` of `story` that has the format, relative to `r.start`: the longest + /// stretches, each paragraph's apart (a paragraph's `\n` isn't in one). + fn spans(&self, styles: &Styles, story: &Story, r: &Range) -> Vec> { + let mut out: Vec> = Vec::new(); + // Paragraphs and runs both go in text order: the runs before a paragraph are passed once. + let runs: Vec<_> = story.runs().collect(); + let mut first = 0; + for (pi, pr) in story.para_ranges().into_iter().enumerate() { + let (a, b) = (pr.start.max(r.start), pr.end.min(r.end)); + let Some(pf) = story.paras.get(pi) else { continue }; + if a >= b || self.para_style.as_ref().is_some_and(|n| *n != pf.style) { + continue; + } + while runs.get(first).is_some_and(|(run, _)| run.end <= a) { + first += 1; + } + let base = (!self.chars.is_empty()).then(|| styles.resolve_para(pf).1); + for (run, cf) in runs[first..].iter().take_while(|(run, _)| run.start < b) { + let (x, y) = (run.start.max(a), run.end.min(b)); + if x >= y || self.char_style.as_ref().is_some_and(|n| *n != cf.style) { + continue; + } + if let Some(base) = &base { + let cp = serde_json::to_value(styles.resolve_char(base, cf)).unwrap_or_default(); + if !self.chars.iter().all(|(k, v)| cp.get(k).is_some_and(|c| same_value(c, v))) { + continue; + } + } + match out.last_mut() { + Some(last) if last.end == x - r.start => last.end = y - r.start, + _ => out.push(x - r.start..y - r.start), + } + } + } + out + } +} + +/// Attribute values alike: numbers to within rounding (inside objects and lists too), the rest +/// exactly. +fn same_value(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(_), Value::Number(_)) => match (a.as_f64(), b.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() < 1e-6, + _ => a == b, + }, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| same_value(a, b)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_value(v, w))), + _ => a == b, + } +} + +/// Whether match `m` lies in one of `spans`. +fn within(spans: &[Range], m: &Range) -> bool { + spans.iter().any(|s| s.start <= m.start && m.end <= s.end) +} + +/// What a search finds: the pattern's matches (with a Find Format, those whose text has it all +/// through), or, with no pattern, the stretches of text with the Find Format. +struct Search { + re: Option, + format: Option, +} + +/// The search of a Find/Change request: an empty `find` needs a Find Format. +fn search(p: &Value) -> Result { + let format = FindFormat::from_params(p)?; + if format.is_some() && str_param(p, "find") == Some("") { + return Ok(Search { re: None, format }); + } + Ok(Search { re: Some(build(p)?), format }) +} + +impl Search { + fn check(&self, styles: &Styles) -> Result<()> { + self.format.as_ref().map_or(Ok(()), |f| f.check(styles)) + } + + /// Where the Find Format holds in target `r` of story `sid` (of its table cell `cell`, if any; + /// `None`: no Find Format). + fn spans(&self, d: &Document, sid: StoryId, cell: Option, r: &Range) -> Option>> { + let f = self.format.as_ref()?; + Some(d.text_story(sid, cell).map(|story| f.spans(&d.styles, story, r)).unwrap_or_default()) + } + + /// Up to `limit` matches in `text`, target `r` of story `sid` (offsets in `text`). + fn find_all(&self, d: &Document, sid: StoryId, cell: Option, r: &Range, text: &str, limit: usize) -> Result>> { + let spans = self.spans(d, sid, cell, r); + let Some(re) = &self.re else { return Ok(spans.unwrap_or_default().into_iter().take(limit).collect()) }; + let Some(spans) = spans else { return re.find_all(text, limit).map_err(|e| bad("find", e)) }; + Ok(re.find_all(text, usize::MAX).map_err(|e| bad("find", e))?.into_iter().filter(|m| within(&spans, m)).take(limit).collect()) + } + + /// The first match in `text` (target `r` of story `sid`) at or after byte `from`. + fn find_at(&self, d: &Document, sid: StoryId, cell: Option, r: &Range, text: &str, from: usize) -> Result>> { + let spans = self.spans(d, sid, cell, r); + let Some(re) = &self.re else { + // From inside a stretch, the rest of it. + return Ok(spans.unwrap_or_default().into_iter().find(|s| s.end > from).map(|s| s.start.max(from)..s.end)); + }; + let mut at = from; + loop { + let Some(m) = re.find_at(text, at).map_err(|e| bad("find.next", e))? else { return Ok(None) }; + if spans.as_ref().is_none_or(|s| within(s, &m)) { + return Ok(Some(m)); + } + // On past it: its end, or a character on from an empty match. + at = if m.is_empty() { text.get(m.end..).and_then(|t| t.chars().next()).map_or(text.len() + 1, |c| m.end + c.len_utf8()) } else { m.end }; + if at > text.len() { + return Ok(None); + } + } + } + + /// The edit Change makes when the selection `range` (offsets in `text`, target `r` of story + /// `sid`) is what Find Next found there, so the two commands agree on what a match is. With + /// no pattern: a selection inside one stretch with the Find Format (Find Next selects the rest + /// of a stretch from inside it). With a pattern: the match it makes from the selection's start + /// must be the selection, overlapping matches Find Next can select included. + #[allow(clippy::too_many_arguments)] + fn found_edit( + &self, + d: &Document, + sid: StoryId, + cell: Option, + r: &Range, + text: &str, + range: &Range, + rep: &str, + grep: bool, + ) -> Result, String)>> { + let spans = self.spans(d, sid, cell, r); + let Some(re) = &self.re else { + let inside = !range.is_empty() && spans.unwrap_or_default().iter().any(|s| s.start <= range.start && range.end <= s.end); + return Ok(inside.then(|| (range.clone(), rep.to_string()))); + }; + let found = + if grep { re.replacement_at(text, range.start, rep) } else { re.find_at(text, range.start).map(|m| m.map(|m| (m, rep.to_string()))) }; + let found = found.map_err(|e| bad("find.change", e))?; + Ok(found.filter(|(m, _)| m == range && spans.as_ref().is_none_or(|s| within(s, m)))) + } + + /// The edits Change All makes in `text` (target `r` of story `sid`): `rep` expanded for each + /// GREP match, else `rep` itself. + #[allow(clippy::too_many_arguments)] + fn replacements( + &self, + d: &Document, + sid: StoryId, + cell: Option, + r: &Range, + text: &str, + rep: &str, + grep: bool, + first_only: bool, + ) -> Result, String)>> { + let limit = if first_only { 1 } else { usize::MAX }; + match (&self.re, self.spans(d, sid, cell, r)) { + (Some(re), None) if grep => re.replacements(text, rep, first_only).map_err(|e| bad("find.change", e)), + (Some(re), Some(spans)) if grep => Ok(re + .replacements(text, rep, false) + .map_err(|e| bad("find.change", e))? + .into_iter() + .filter(|(m, _)| within(&spans, m)) + .take(limit) + .collect()), + _ => Ok(self.find_all(d, sid, cell, r, text, limit)?.into_iter().map(|m| (m, rep.to_string())).collect()), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] enum Domain { Document, @@ -140,6 +361,7 @@ struct Matcher { grep: bool, case_sensitive: bool, whole_word: bool, + format: Option, } impl Matcher { @@ -149,6 +371,7 @@ impl Matcher { grep: bool_or(p, "grep", false), case_sensitive: bool_or(p, "caseSensitive", false), whole_word: bool_or(p, "wholeWord", false), + format: FindFormat::from_params(p).ok().flatten(), } } } @@ -183,6 +406,29 @@ impl FindContext { self.selection = st.selection.clone(); } + /// After Change put `new_len` bytes in place of `old` in story `sid`: the domain keeps the + /// changed text and the search goes on after it. + fn after_change(&mut self, st: &DocState, sid: StoryId, cell: Option, old: &Range, new_len: usize) { + let moved = |x: usize| if x >= old.end { x - old.len() + new_len } else { x }; + for (tid, tcell, r) in &mut self.targets { + if *tid == sid && *tcell == cell { + *r = moved(r.start)..moved(r.end); + } + } + self.offset = old.start + new_len; + self.last_match_end = Some(self.offset); + let at = self.offset; + if let Some(i) = self + .order + .iter() + .position(|i| self.targets.get(*i).is_some_and(|(tid, tcell, r)| *tid == sid && *tcell == cell && r.start <= at && at <= r.end)) + { + self.target = i; + } + self.document = Arc::downgrade(&st.doc); + self.acknowledge(st); + } + fn restart(&mut self, st: &DocState) -> Result<()> { self.target = 0; self.last_match_end = None; @@ -299,13 +545,14 @@ fn scope(s: &Session, p: &Value) -> Result { const MAX_MATCHES: usize = 10_000; fn find(s: &mut Session, p: &Value) -> Result { - let re = build(p)?; + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; let context = scope(s, p)?; let st = s.doc()?; let mut out = Vec::new(); for (sid, cell, r) in &context.targets { let text = target_text(&st.doc, *sid, *cell, r)?; - for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? { + for m in re.find_all(&st.doc, *sid, *cell, r, text, MAX_MATCHES.saturating_sub(out.len()))? { let mut hit = json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}); if let Some(c) = cell { hit["cell"] = json!({"table": c.table, "row": c.row, "col": c.col}); @@ -349,51 +596,143 @@ fn char_overrides(attrs: Option<&Value>) -> Result { } fn change(s: &mut Session, p: &Value) -> Result { - let re = build(p)?; - let grep = bool_or(p, "grep", false); - let rep = replacement(p); + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; + // With no pattern (a Find Format alone), the change is literal text. + let grep = bool_or(p, "grep", false) && re.re.is_some(); + let rep = if re.re.is_some() { replacement(p) } else { metachars(str_param(p, "change").unwrap_or("")) }; let first_only = bool_or(p, "first", false); let attrs = p.get("attrs").cloned(); let over = char_overrides(attrs.as_ref())?; + // A null attribute clears that override on the found text. + let cleared: Vec = attrs + .as_ref() + .and_then(Value::as_object) + .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) + .unwrap_or_default(); super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; - let targets = scope(s, p)?.targets; - s.edit(|d, sel| { + let char_style = str_param(p, "charStyle").map(str::to_string); + let para_style = str_param(p, "paraStyle").map(str::to_string); + let styles = &s.doc()?.doc.styles; + if let Some(n) = char_style.as_ref().filter(|n| styles.char_style(n).is_none()) { + return Err(bad("find.change", format!("no character style `{n}`"))); + } + if let Some(n) = para_style.as_ref().filter(|n| styles.para(n).is_none()) { + return Err(bad("find.change", format!("no paragraph style `{n}`"))); + } + // InDesign: with a Change Format and nothing in Change to, the found text is only formatted. + let format_only = + str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty()); + let context = scope(s, p)?; + let targets = context.targets.clone(); + // Change: only the text Find Next found, the selection, while it is still a match (as Find + // Next defines one), with its replacement. + let (found, found_out) = if bool_or(p, "found", false) { + let st = s.doc()?; + let selected = st.selection.text.filter(|t| st.selection.cells.is_none() && !t.is_caret()).map(|t| (t.story, t.cell, t.range())); + let hit = selected.and_then(|(sid, cell, range)| { + targets.iter().find_map(|(tid, tcell, r)| { + if *tid != sid || *tcell != cell || range.start < r.start || r.end < range.end { + return None; + } + let text = target_text(&st.doc, *tid, cell, r).ok()?; + let local = range.start - r.start..range.end - r.start; + let (m, out) = re.found_edit(&st.doc, *tid, cell, r, text, &local, &rep, grep).ok()??; + Some(((sid, cell, r.start + m.start..r.start + m.end), out)) + }) + }); + match hit { + Some((hit, out)) => (Some(hit), Some(out)), + None => return Ok(json!({"count": 0})), + } + } else { + (None, None) + }; + let (count, changed) = s.edit(|d, sel| { + let styles = d.styles.clone(); let mut count = 0usize; + let mut changed = None; for (sid, cell, r) in targets { + if found.as_ref().is_some_and(|(at, in_cell, _)| *at != sid || *in_cell != cell) { + continue; + } let Some(story) = d.text_story(sid, cell) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?; - let edits: Vec<(std::ops::Range, String)> = if grep { - re.replacements(text, &rep, first_only) - } else { - re.find_all(text, if first_only { 1 } else { usize::MAX }).map(|ms| ms.into_iter().map(|m| (m, rep.clone())).collect()) - } - .map_err(|e| bad("find.change", e))? - .into_iter() - .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) - .collect(); + let edits: Vec<(std::ops::Range, String)> = match (&found, &found_out) { + // Change: the found text alone, in the target it lies in. + (Some((_, _, range)), Some(out)) => { + if r.start <= range.start && range.end <= r.end { + vec![(range.clone(), out.clone())] + } else { + Vec::new() + } + } + _ => re + .replacements(d, sid, cell, &r, text, &rep, grep, first_only)? + .into_iter() + .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) + .collect(), + }; let Some(st) = d.text_story_mut(sid, cell) else { continue }; for (range, new) in edits.iter().rev() { - st.replace(range.clone(), new); - if !over.is_empty() && !new.is_empty() { - st.format_chars(range.start..range.start + new.len(), |f| f.over.merge(&over)); + let changed = if format_only { + range.clone() + } else { + st.replace(range.clone(), new); + range.start..range.start + new.len() + }; + // A paragraph style goes to the match's paragraphs, an empty match's (`^`) too; + // character formatting needs characters. + if let Some(n) = ¶_style { + super::style::apply_para_style(st, &styles, n, &changed, false); + } + if !changed.is_empty() { + if let Some(n) = &char_style { + super::style::apply_char_style(st, &styles, n, &changed); + } + if !over.is_empty() || !cleared.is_empty() { + st.format_chars(changed, |f| { + f.over.merge(&over); + for k in &cleared { + let _ = f.over.set_json(k, &Value::Null); + } + }); + } } count += 1; } if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) { repair_selection(t, st); } + // Change leaves the changed text selected. + if let Some((_, _, range)) = &found + && let Some((_, new)) = edits.first() + && let Some(t) = sel.text.as_mut() + { + let len = if format_only { range.len() } else { new.len() }; + (t.anchor, t.focus) = (range.start, range.start + len); + changed = Some(len); + } // `first` is the first match of the whole search, not of each story. if first_only && count > 0 { break; } } - Ok(json!({"count": count})) - }) + Ok((count, changed)) + })?; + // Change: Find Next goes on after the changed text, in the search's domain. + if let (Some((sid, cell, old)), Some(len)) = (&found, changed) { + let mut context = context; + context.after_change(s.doc()?, *sid, *cell, old, len); + s.find_context = Some(context); + } + Ok(json!({"count": count})) } fn find_next(s: &mut Session, p: &Value) -> Result { - let re = build(p)?; + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; let mut context = scope(s, p)?; let st = s.doc()?; let mut hit = None; @@ -403,7 +742,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result { if text.get(from..).is_none() { return Err(bad("find", "invalid UTF-8 search cursor")); } - if let Some(m) = re.find_at(text, from).map_err(|e| bad("find.next", e))? { + if let Some(m) = re.find_at(&st.doc, sid, cell, &r, text, from)? { let (a, b) = (r.start + m.start, r.start + m.end); // Match find_iter/captures_iter: suppress an empty match adjacent to the // previous match's end, but keep adjacent nonempty matches. @@ -489,6 +828,34 @@ mod object_find_tests { use crate::Session; + /// A GREP match that is empty (`^`, the start of each paragraph) gets the paragraph style: + /// only character formatting needs characters. + #[test] + fn change_format_styles_paragraphs_of_empty_matches() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute( + "frame.create", + &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One +Two +Three"}), + ) + .unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + let r = s.execute("find.change", &json!({"find": "^", "change": "", "grep": true, "paraStyle": "Method"})).unwrap(); + assert_eq!(r["count"], 3); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert!(story.paras.iter().all(|p| p.style == "Method"), "{:?}", story.paras.iter().map(|p| &p.style).collect::>()); + assert_eq!( + story.text, + "One +Two +Three" + ); + } + #[test] fn find_and_change_objects_by_attributes() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/find/tests.rs b/crates/engine/src/cmd/find/tests.rs index 5914c2e51..348acd294 100644 --- a/crates/engine/src/cmd/find/tests.rs +++ b/crates/engine/src/cmd/find/tests.rs @@ -621,6 +621,27 @@ fn document_scope_searches_and_changes_table_cells() { assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2); } +/// Change (`found`) on text Find Next found in a table cell changes it there, leaves the +/// same text in the story alone, and Find Next goes on after it. +#[test] +fn change_changes_the_text_find_next_found_in_a_cell() { + let (mut s, sid) = scope_session("red here"); + scope_select(&mut s, sid, 0, 0); + let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "red"})).unwrap(); + let p = json!({"find": "red", "change": "blue", "scope": "document"}); + let hit = s.execute("find.next", &p).unwrap(); + assert_eq!(hit["cell"], json!({"table": table, "row": 0, "col": 1}), "{hit}"); + let mut change = p.clone(); + change["found"] = json!(true); + assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); + let t = s.execute("table.get", &json!({"table": table})).unwrap(); + assert_eq!(t["cells"][1]["text"], "blue"); + assert!(s.doc().unwrap().doc.story(sid).unwrap().text.contains("red here"), "the story's red stays"); + let next = s.execute("find.next", &p).unwrap(); + assert!(next.get("cell").is_none() && next.get("start").is_some(), "on to the story: {next}"); +} + fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { let (mut s, sid) = scope_session("red"); scope_select(&mut s, sid, 3, 3); @@ -747,3 +768,194 @@ fn find_scope_revert_releases_context_with_same_document_uid() { assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range"); } + +#[test] +fn change_format_styles_the_found_text_and_keeps_it() { + // The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with + // Change to empty, so the words stay. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("find.change", &json!({"find": "eggs", "change": "", "caseSensitive": true, "charStyle": "eggs"})).unwrap(); + assert_eq!(r["count"], 2); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let style_at = |s: &Session, at: usize| s.doc().unwrap().doc.story(sid).unwrap().format_after(at).style.clone(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(story.text, text, "the words stay"); + for at in [19, 36] { + assert_eq!(&story.text[at..at + 4], "eggs"); + assert_eq!(style_at(&s, at), "eggs"); + } + assert_eq!(style_at(&s, 8), designcraft_doc::NO_CHAR_STYLE, "case sensitive: not \"Eggs\""); + assert_eq!(style_at(&s, 25), designcraft_doc::NO_CHAR_STYLE, "only the found text"); + // A paragraph style goes to the found text's paragraphs, with attributes over the text. + s.execute("find.change", &json!({"find": "cook", "change": "", "paraStyle": "Method", "attrs": {"underline": true}})).unwrap(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!((story.paras[0].style.as_str(), story.paras[1].style.as_str()), (designcraft_doc::BASIC_PARAGRAPH, "Method")); + assert_eq!(story.format_after(25).over.underline, Some(true)); + assert_eq!(story.format_after(19).style, "eggs", "the other text keeps its character style"); + // Change to and a Change Format: the new text is formatted. + s.execute("find.change", &json!({"find": "set", "change": "firm", "charStyle": "eggs"})).unwrap(); + assert_eq!(style_at(&s, s.doc().unwrap().doc.story(sid).unwrap().text.find("firm").unwrap()), "eggs"); + // No such style: refused, nothing changed. + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err()); + assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + // A null attribute is a Change Format too: the found text stays and loses that override. + let before = s.doc().unwrap().doc.story(sid).unwrap().clone(); + s.execute("find.change", &json!({"find": "cook", "change": "", "attrs": {"underline": null}})).unwrap(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(story.text, before.text, "the words stay"); + assert_eq!(story.format_after(25).over.underline, None); + // With no Change Format, an empty Change to still deletes the found text. + s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap(); + assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break")); +} + +#[test] +fn a_find_format_finds_only_text_with_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + for (a, b) in [(6, 10), (12, 16)] { + s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap(); + s.execute("style.character.apply", &json!({"name": "eggs"})).unwrap(); + } + s.execute("text.select", &json!({"story": sid, "anchor": 25, "focus": 25})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Method"})).unwrap(); + let found = |s: &mut Session, p: serde_json::Value| -> Vec<(u64, u64)> { + let m = s.execute("find.find", &p).unwrap(); + m.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap(), m["end"].as_u64().unwrap())).collect() + }; + // "eggs" where it has the character style: the first only. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs"}})), [(6, 10)]); + // Nothing in Find what: each stretch of text in the style. + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})), [(6, 10), (12, 16)]); + // A paragraph style, and resolved attributes. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"paraStyle": "Method"}})), [(23, 27), (32, 36)]); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"fontStyle": "Bold"}}})), [(6, 10), (12, 16)]); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"size": 12}}})), [(0, 22), (23, 36)], "each paragraph apart"); + // Find Next walks the stretches. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + let next = json!({"find": "", "findFormat": {"charStyle": "eggs"}}); + assert_eq!(s.execute("find.next", &next).unwrap()["start"], 6); + assert_eq!(s.execute("find.next", &next).unwrap()["start"], 12); + // Equivalent spellings the attributes accept find the same text: a snake_case key, and a + // nested number written as an integer (14 pt leading on the second paragraph). + s.execute("text.select", &json!({"story": sid, "anchor": 23, "focus": 36})).unwrap(); + s.execute("type.char", &json!({"leading": {"kind": "points", "value": 14.0}})).unwrap(); + for (a, b) in [ + (json!({"fontStyle": "Bold"}), json!({"font_style": "Bold"})), + (json!({"leading": {"kind": "points", "value": 14.0}}), json!({"leading": {"kind": "points", "value": 14}})), + ] { + let (a, b) = (json!({"find": "", "findFormat": {"chars": a}}), json!({"find": "", "findFormat": {"chars": b}})); + let all = found(&mut s, a.clone()); + assert!(!all.is_empty(), "{a}"); + assert_eq!(found(&mut s, b.clone()), all, "{b}"); + for p in [&a, &b] { + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert_eq!(s.execute("find.next", p).unwrap()["start"].as_u64(), Some(all[0].0), "{p}"); + } + } + // A null attribute asks for nothing. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs", "chars": {"size": null}}})), [(6, 10)]); + // With a Change Format: the text in the style goes back to [None], underlined. + let r = s + .execute( + "find.change", + &json!({"find": "", "change": "", "findFormat": {"charStyle": "eggs", "chars": {"font_style": "Bold"}}, "charStyle": "[None]", "attrs": {"underline": true}}), + ) + .unwrap(); + assert_eq!(r["count"], 2, "a snake_case attribute changes the same text"); + assert!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})).is_empty()); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"underline": true}}})), [(6, 10), (12, 16)]); + // Unknown styles and attributes are refused; an empty search needs a Find Format. + assert!(s.execute("find.find", &json!({"find": "", "findFormat": {"charStyle": "Nope"}})).is_err()); + assert!(s.execute("find.next", &json!({"find": "eggs", "findFormat": {"paraStyle": "Nope"}})).is_err()); + assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": {"chars": {"bogus": 1}}})).is_err()); + // Malformed fields are refused, not searched without. + for f in [json!({"chars": [1]}), json!({"chars": 12}), json!({"charStyle": 3}), json!({"paraStyle": ["Body"]})] { + assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": f})).is_err(), "{f}"); + } + assert!(s.execute("find.find", &json!({"find": ""})).is_err()); +} + +/// Change and Change/Find: only the text Find Next found changes; it stays selected and the +/// search goes on after it in its domain (here the original selection). A selection that +/// isn't a match changes nothing and makes no undo step. +#[test] +fn change_changes_the_found_text_and_the_search_goes_on() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let mut change = p.clone(); + change["found"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); + let text = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().text.clone(); + assert_eq!(text(&s), "blue red | red"); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..4, "the changed text stays selected"); + scope_hit(&mut s, &p, sid, 5, 8); + s.execute("find.change", &change).unwrap(); + assert_eq!(text(&s), "blue blue | red"); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "the last red is outside the selection searched"); + // Not a match: nothing changes, no undo step. + scope_select(&mut s, sid, 0, 4); + let history = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "found": true})).unwrap()["count"], 0); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + // A Change Format with nothing in Change to formats the found text only. + scope_select(&mut s, sid, 0, 0); + let fmt = json!({"find": "red", "change": "", "attrs": {"underline": true}}); + let hit = s.execute("find.next", &fmt).unwrap(); + assert_eq!(hit["start"], 12); + let mut found = fmt.clone(); + found["found"] = json!(true); + s.execute("find.change", &found).unwrap(); + assert_eq!(text(&s), "blue blue | red"); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(12).over.underline, Some(true)); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 12..15); +} + +/// Change accepts what Find Next selected even when Change All wouldn't match there: the +/// rest of a Find Format stretch from a caret inside it, and an overlapping match. +#[test] +fn change_takes_any_selection_find_next_made() { + // A caret inside a bold stretch: Find Next with only a Find Format selects the rest of it. + let (mut s, sid) = scope_session("plain bold text"); + s.execute("style.character.create", &json!({"name": "Strong", "chars": {"fontStyle": "Bold"}})).unwrap(); + scope_select(&mut s, sid, 6, 10); + s.execute("style.character.apply", &json!({"name": "Strong"})).unwrap(); + scope_select(&mut s, sid, 8, 8); + let p = json!({"find": "", "change": "", "findFormat": {"charStyle": "Strong"}, "attrs": {"underline": true}, "scope": "story"}); + let hit = s.execute("find.next", &p).unwrap(); + assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(8), Some(10))); + let mut change = p.clone(); + change["found"] = json!(true); + assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); + let st = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!((st.format_after(7).over.underline, st.format_after(8).over.underline), (None, Some(true)), "only the selected rest"); + // "aa" in "aaa" from the second a: Find Next selects 1..3, which Change All never finds. + for grep in [false, true] { + let (mut s, sid) = scope_session("aaa"); + scope_select(&mut s, sid, 1, 1); + let (find, change) = if grep { ("(a)a", "<$1>") } else { ("aa", "b") }; + let p = json!({"find": find, "change": change, "grep": grep, "scope": "story"}); + let hit = s.execute("find.next", &p).unwrap(); + assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(1), Some(3))); + let mut found = p.clone(); + found["found"] = json!(true); + assert_eq!(s.execute("find.change", &found).unwrap()["count"], 1, "grep: {grep}"); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, if grep { "a" } else { "ab" }); + } +} diff --git a/crates/engine/src/cmd/interactive.rs b/crates/engine/src/cmd/interactive.rs index 32562737c..c49425d7a 100644 --- a/crates/engine/src/cmd/interactive.rs +++ b/crates/engine/src/cmd/interactive.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; +/// Register commands for editing hyperlinks and manual bookmarks. pub fn specs() -> Vec { vec![ cmd!( @@ -73,7 +74,7 @@ pub fn specs() -> Vec { let name = str_param(p, "name").map(str::to_string); s.edit(|d, _| { let name = name.clone().unwrap_or_else(|| format!("Bookmark {}", d.bookmarks.len() + 1)); - d.bookmarks.push(Bookmark { name, page, children: vec![] }); + d.bookmarks.push(Bookmark { name, page, children: vec![], generated_by_toc: false }); Ok(json!({"index": d.bookmarks.len() - 1})) }) }), diff --git a/crates/engine/src/cmd/layout/parents.rs b/crates/engine/src/cmd/layout/parents.rs index f53d774f4..293fe27e9 100644 --- a/crates/engine/src/cmd/layout/parents.rs +++ b/crates/engine/src/cmd/layout/parents.rs @@ -117,6 +117,52 @@ mod parent_margin_tests { assert!(s.execute("layout.marginsAndColumns", &json!({"parent": "Z", "margins": 10})).is_err()); } + /// Insert Pages before a page, or at the start of the document (`before: 0`), as InDesign's + /// dialog offers; `after` and `before` together are refused without an undo step. + #[test] + fn pages_go_in_before_a_page_or_at_the_start() { + let mut s = Session::new(); + // Facing: [1] [2 3]. + s.execute("file.new", &json!({"pages": 3})).unwrap(); + let b = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap(); + let ids = |s: &Session| -> Vec { + let d = &s.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page(i).unwrap().id.0).collect() + }; + let parent = |s: &Session, i: usize| s.doc().unwrap().doc.page(i).unwrap().parent.map(|p| p.0); + let old = ids(&s); + s.execute("layout.pages.insert", &json!({"before": 0, "parent": "B"})).unwrap(); + let now = ids(&s); + assert_eq!(now.len(), 4); + assert_eq!(now[1..], old[..], "the old pages follow the new first page"); + assert_eq!(parent(&s, 0), Some(b)); + // Before page 4 (index 3, the old page 3): two pages without a parent. + s.execute("layout.pages.insert", &json!({"before": 3, "count": 2, "parent": null})).unwrap(); + let then = ids(&s); + assert_eq!(then.len(), 6); + assert_eq!(then[..3], now[..3]); + assert_eq!(then[5], old[2]); + assert_eq!((parent(&s, 3), parent(&s, 4)), (None, None)); + // Refused without a change or an undo step: both placements, even one of them null, and a + // placement that isn't a page index. + let undo = s.doc().unwrap().history.undo.len(); + for bad in [ + json!({"after": 0, "before": 1}), + json!({"after": 0, "before": null}), + json!({"after": null, "before": 1}), + json!({"before": -1}), + json!({"before": "0"}), + json!({"before": null}), + json!({"after": 1.5}), + json!({"before": 6}), + json!({"before": 99}), + ] { + assert!(s.execute("layout.pages.insert", &bad).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}"); + assert_eq!(ids(&s), then, "{bad}"); + } + } + #[test] fn applying_a_parent_gives_its_margins_and_local_margins_stay_local() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/layout/registry.rs b/crates/engine/src/cmd/layout/registry.rs index 4826f9273..c3cb69b71 100644 --- a/crates/engine/src/cmd/layout/registry.rs +++ b/crates/engine/src/cmd/layout/registry.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec { "Insert Pages…", ["Layout", "Pages"], Some("Cmd+Shift+P"), - "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}", + "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page; past it: at the end), before?: 0-based page index (instead of `after`; 0: at the start), parent?: id|\"A-Parent\"|\"A\"|null}", has_doc, |s, p| { const ID: &str = "layout.pages.insert"; @@ -27,12 +27,30 @@ pub fn specs() -> Vec { }; let st = s.doc()?; let n = st.doc.page_count(); - // An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end` - // pins deliberately. - let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1)); + // A placement given (null included) must be a page index; only when neither is + // given do the pages go after the last. + if p.get("after").is_some() && p.get("before").is_some() { + return Err(bad("layout.pages.insert", "give `after` or `before`, not both")); + } + // `before` names a page of the document; an `after` past the last page appends + // (`insert_pages_bounds.rs` pins that). + let index = |k: &str| match p.get(k) { + None => Ok(None), + Some(v) => v + .as_u64() + .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) + .filter(|&i| k == "after" || i < n) + .map(|i| Some(i.min(n.saturating_sub(1)))) + .ok_or_else(|| bad(ID, format!("`{k}` must be the index of one of the {n} pages, not {v}"))), + }; + // The page the new ones follow; none: they go first. + let after = match (index("after")?, index("before")?) { + (_, Some(b)) => b.checked_sub(1), + (a, None) => Some(a.unwrap_or(n.saturating_sub(1))), + }; let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); s.edit(|d, _| { - let ids = d.insert_pages(Some(after), count, parent)?; + let ids = d.insert_pages(after, count, parent)?; Ok(json!({"pages": ids.len(), "total": d.page_count()})) }) } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index ad8b25213..aac0d0eb1 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -382,9 +382,10 @@ pub fn specs() -> Vec { "Drop Shadow", ["Object", "Effects"], Some("Cmd+Alt+M"), - "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), ids?}", + "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), mode?: blend mode (multiply…), ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.dropShadow")?; let p2 = p.clone(); set_effects(s, p, "object.dropShadow", move |fx| { let ds = &mut fx.drop_shadow; @@ -398,6 +399,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { ds.color = c.into(); } + if let Some(m) = mode { + ds.mode = m; + } }) } ), @@ -406,9 +410,10 @@ pub fn specs() -> Vec { "Inner Shadow", ["Object", "Effects"], None, - "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, ids?}", + "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, mode?: blend mode, ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.innerShadow")?; let p2 = p.clone(); set_effects(s, p, "object.innerShadow", move |fx| { let e = &mut fx.inner_shadow; @@ -422,6 +427,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } + if let Some(m) = mode { + e.mode = m; + } }) } ), @@ -430,9 +438,10 @@ pub fn specs() -> Vec { "Outer Glow", ["Object", "Effects"], None, - "{on?: bool, opacity?, size?, spread? (%), color?, ids?}", + "{on?: bool, opacity?, size?, spread? (%), color?, mode?: blend mode (screen…), ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.outerGlow")?; let p2 = p.clone(); set_effects(s, p, "object.outerGlow", move |fx| { let e = &mut fx.outer_glow; @@ -443,6 +452,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } + if let Some(m) = mode { + e.mode = m; + } }) } ), @@ -598,7 +610,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", + "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, paragraphSpacingLimit? (pt: Justify adds at most this between paragraphs, the rest between lines), columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -607,7 +619,7 @@ pub fn specs() -> Vec { "Corner Options…", ["Object"], None, - "{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size}] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size), ids?}", + "{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size} | null] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size; null keeps that corner of each object), ids?}", has_selection, corner_options ), @@ -616,7 +628,7 @@ pub fn specs() -> Vec { "Text Wrap", ["Window"], Some("Cmd+Alt+W"), - "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r], invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary", + "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given; null or those: each object's own, unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary", has_selection, text_wrap ), @@ -638,6 +650,15 @@ pub fn specs() -> Vec { has_selection, fitting_options ), + cmd!( + "object.clearFittingOptions", + "Clear Frame Fitting Options", + ["Object", "Fitting"], + None, + "{ids?} — the frames' fitting options back to the defaults: no auto-fit, no crop amounts, the centre reference point; the content stays where it is", + has_selection, + clear_fitting_options + ), cmd!("object.rename", "Rename", [], None, "{id | ids?, name}", has_doc, |s, p| { let name = str_param(p, "name").unwrap_or("").to_string(); set_flag(s, p, move |i| i.name = name.clone(), false) @@ -680,7 +701,15 @@ pub fn specs() -> Vec { path_reverse ), cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close), - cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors), + cmd!( + "path.moveAnchors", + "Move Anchors", + [], + None, + "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out} | {points: [[id, subpath, index, dx?, dy?]], dx?, dy?} (anchors of several paths, as Direct Selection drags them: one step; a point's own dx, dy win over the shared ones) — spread space", + has_doc, + path_move_anchors + ), cmd!( "object.align", "Align", @@ -2101,13 +2130,18 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result { None => None, }; let mode: Option = match p.get("mode") { + // Null keeps each object's own mode (an edit of the offsets or Invert alone). + Some(Value::Null) => None, Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown mode"))?), None if side.is_some() || contour.is_some() => None, None => Some(WrapMode::BoundingBox), }; - let off = match p.get("offset") { - Some(Value::Number(n)) => Some([n.as_f64().unwrap_or(0.0); 4]), - Some(v) => serde_json::from_value(v.clone()).ok(), + // Per side [top, left, bottom, right]; a null side keeps each object's own offset there, so + // an edit of one side keeps the others of every object. + // An array with no number in it changes no offset (and so keeps a custom boundary). + let off: Option<[Option; 4]> = match p.get("offset") { + Some(Value::Number(n)) => Some([n.as_f64(); 4]), + Some(v) => serde_json::from_value::<[Option; 4]>(v.clone()).ok().filter(|o| o.iter().any(Option::is_some)), None => None, }; let invert = p.get("invert").and_then(Value::as_bool); @@ -2127,7 +2161,11 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result { w.mode = m; } if let Some(o) = off { - w.offsets = o; + for (w, v) in w.offsets.iter_mut().zip(o) { + if let Some(v) = v { + *w = v; + } + } } if let Some(v) = invert { w.invert = v; @@ -2152,19 +2190,31 @@ fn corner_options(s: &mut Session, p: &Value) -> Result { use designcraft_geom::corners::{Corner, CornerOptions, CornerShape}; let opts = match p.get("corners") { Some(v) => { - let corners: [Corner; 4] = - serde_json::from_value(v.clone()).map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} entries ({e})")))?; - if corners.iter().any(|c| !c.size.is_finite() || c.size < 0.0) { + let corners: [Option; 4] = serde_json::from_value(v.clone()) + .map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} or null entries ({e})")))?; + if corners.iter().flatten().any(|c| !c.size.is_finite() || c.size < 0.0) { return Err(bad("object.cornerOptions", "corner sizes must be finite and not negative")); } - CornerOptions { corners } + corners } None => { let shape = p.get("shape").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(CornerShape::Rounded); - CornerOptions::uniform(shape, f64_or(p, "size", 12.0)) + CornerOptions::uniform(shape, f64_or(p, "size", 12.0)).corners.map(Some) } }; - set_flag(s, p, move |i| i.corners = opts, false) + // A null corner keeps each object's own. + set_flag( + s, + p, + move |i| { + for (c, new) in i.corners.corners.iter_mut().zip(opts) { + if let Some(new) = new { + *c = new; + } + } + }, + false, + ) } /// `object.matchAttributes`: the selected objects (or `ids`) take `from`'s appearance. @@ -2311,6 +2361,15 @@ fn effects_all(s: &mut Session, p: &Value) -> Result { } if let Some(patch) = &patch { let mut cur = serde_json::to_value(&it.effects).map_err(|e| bad(ID, e.to_string()))?; + // Default effects are left out of the serialized form; a patch without `mode` would + // then read the legacy Normal instead of the effect's current (Multiply, Screen) mode. + for (key, now) in + [("innerShadow", serde_json::to_value(&it.effects.inner_shadow)), ("outerGlow", serde_json::to_value(&it.effects.outer_glow))] + { + if let (Some(o), Ok(now)) = (cur.as_object_mut(), now) { + o.entry(key).or_insert(now); + } + } merge_json(&mut cur, patch); let mut fx: designcraft_doc::Effects = serde_json::from_value(cur).map_err(|e| bad(ID, format!("effects: {e}")))?; sanitize_effects(&mut fx)?; @@ -2505,6 +2564,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { let min_width = ranged("minimumWidth", 0.0, crate::autosize::MAX_SIDE, "a number of points")?; let column_width = ranged("columnWidth", 0.0, 15552.0, "a number of points")?; let first_baseline_min = ranged("firstBaselineMin", 0.0, 15552.0, "a number of points")?; + let spacing_limit = ranged("paragraphSpacingLimit", 0.0, 15552.0, "a number of points")?; let reference = match p.get("autoSizeReference") { None | Some(Value::Null) => None, Some(v) => Some( @@ -2591,6 +2651,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = reference { o.auto_size_ref = v; } + o.vj_paragraph_spacing_limit = spacing_limit.unwrap_or(o.vj_paragraph_spacing_limit); if let Some(v) = p.get("useMinimumHeight").and_then(Value::as_bool) { o.auto_size_use_min_height = v; } @@ -2938,7 +2999,9 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result { } else if fitting.is_some() && g.auto_fit != designcraft_doc::Fitting::None { g.auto_fit = mode; } - if let Some(xf) = g.fitted(r, mode) { + // Turning Auto-Fit off alone leaves the picture where the user put it. + let turn_off_only = auto == Some(false) && fitting.is_none() && align.is_none() && crop.is_none(); + if !turn_off_only && let Some(xf) = g.fitted(r, mode) { g.xf = xf; } n += 1; @@ -2947,6 +3010,35 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result { }) } +/// Clear Frame Fitting Options: auto-fit off, no crop, the centre reference point. The content +/// stays where it is, as in InDesign. +fn clear_fitting_options(s: &mut Session, p: &Value) -> Result { + // Locked frames (or frames on a locked layer) keep their options, as with Fit; frames whose + // options are already clear are left out; with nothing to change, no edit (and no empty undo + // step). + let doc = &s.doc()?.doc; + let set = |g: &designcraft_doc::Graphic| g.auto_fit != designcraft_doc::Fitting::None || g.crop != [0.0; 4] || g.fit_align != 4; + let ids: Vec = targets(s, p)? + .into_iter() + .filter(|id| doc.item(*id).is_some_and(|it| unlocked(doc, it) && matches!(&it.content, Content::Graphic(g) if set(g)))) + .collect(); + if ids.is_empty() { + return Ok(json!({"changed": 0})); + } + s.edit(|d, _| { + let mut n = 0; + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + let Content::Graphic(g) = &mut it.content else { continue }; + g.auto_fit = designcraft_doc::Fitting::None; + g.crop = [0.0; 4]; + g.fit_align = 4; + n += 1; + } + Ok(json!({"changed": n})) + }) +} + fn fit(s: &mut Session, p: &Value) -> Result { let doc = &s.doc()?.doc; let ids: Vec<_> = @@ -3174,6 +3266,20 @@ fn align(s: &mut Session, p: &Value) -> Result { }) } +/// An effect's `mode` parameter: a blend mode by name ("multiply", "screen", or as saved). +/// An effect's `mode`: `None` when omitted or null, an error when it names no blend mode. +fn blend_param(p: &Value, cmd: &str) -> Result> { + match p.get("mode") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_str() + .and_then(BlendMode::parse) + .or_else(|| serde_json::from_value(v.clone()).ok()) + .map(Some) + .ok_or_else(|| bad(cmd, format!("unknown blend mode {v}"))), + } +} + fn distribute(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; if ids.len() < 3 && p.get("spacing").is_none() { @@ -3266,29 +3372,36 @@ fn path_create(s: &mut Session, p: &Value) -> Result { fn path_append(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?; - let subpath = p.get("subpath").and_then(Value::as_u64); + // An omitted subpath is the last one; one given must name a subpath (never falls back). + let subpath = match p.get("subpath") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().ok_or_else(|| bad("path.appendAnchor", "`subpath` must be a subpath index"))?), + }; let at_start = match p.get("at") { None => false, Some(v) if v.as_str() == Some("end") => false, Some(v) if v.as_str() == Some("start") => true, Some(_) => return Err(bad("path.appendAnchor", "`at` must be `start` or `end`")), }; - s.edit(|d, _| { + s.edit(|d, sel| { path_editable(d, id, "path.appendAnchor")?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - let sp = match subpath { - Some(i) => { - usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))? - } - None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?, + let si = match subpath { + Some(i) => usize::try_from(i).ok().filter(|i| *i < it.path.subpaths.len()).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?, + None => it.path.subpaths.len().checked_sub(1).ok_or_else(|| bad("path.appendAnchor", "empty path"))?, }; + let sp = &mut it.path.subpaths[si]; if sp.closed { return Err(bad("path.appendAnchor", "the subpath is closed")); } if at_start { sp.anchors.insert(0, a.transform(inv)); + // The selected points of this sub-path move up one place with the others. + for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si) { + a.2 += 1; + } } else { sp.anchors.push(a.transform(inv)); } @@ -3367,7 +3480,7 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result { { path_editable(doc, *id, "path.reverse")?; } - s.edit(|d, _| { + s.edit(|d, sel| { let mut reversed = 0; for id in &editable { let Some(it) = d.item_mut(*id) else { continue }; @@ -3378,6 +3491,11 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result { if only.is_some_and(|o| o != i) { continue; } + // Selected points keep pointing at the same points, now counted from the other end. + let n = sp.anchors.len(); + for a in sel.anchors.iter_mut().filter(|a| a.0 == *id && a.1 == i && a.2 < n) { + a.2 = n - 1 - a.2; + } sp.anchors.reverse(); for a in &mut sp.anchors { std::mem::swap(&mut a.h_in, &mut a.h_out); @@ -3411,38 +3529,75 @@ fn path_close(s: &mut Session, p: &Value) -> Result { } fn path_move_anchors(s: &mut Session, p: &Value) -> Result { - let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?; - let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + // The anchors to move, by path: `points` (several paths) or `id` with `anchors`. + // Each anchor with its own spread-space movement, when the points give one. + let mut targets: Vec<(ItemId, Vec<(usize, usize, Option)>)> = Vec::new(); + // A `points` that isn't a list is an error, never the `id` form (which could move another path). + let points = match p.get("points") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) => Some(a), + Some(_) => return Err(bad("path.moveAnchors", "`points` must be a list of [id, subpath, index, dx?, dy?]")), + }; + if let Some(points) = points { + for v in points { + let n = |i: usize| v.get(i).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()); + let (Some(id), Some(si), Some(ai)) = (v.get(0).and_then(Value::as_u64), n(1), n(2)) else { + return Err(bad("path.moveAnchors", "each point is [id, subpath, index] or [id, subpath, index, dx, dy]")); + }; + let own = match (v.get(3), v.get(4)) { + (None, None) => None, + (Some(x), Some(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Vec2::new(x, y)), + _ => return Err(bad("path.moveAnchors", "a point's dx and dy are finite numbers")), + }, + _ => return Err(bad("path.moveAnchors", "a point gives both dx and dy, or neither")), + }; + match targets.iter_mut().find(|(i, _)| i.0 == id) { + Some((_, which)) => which.push((si, ai, own)), + None => targets.push((ItemId(id), vec![(si, ai, own)])), + } + } + } else { + let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?; + let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + targets.push((id, which.into_iter().map(|(s, a)| (s, a, None)).collect())); + } let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let handle = str_param(p, "handle").map(str::to_string); s.edit(|d, _| { - path_editable(d, id, "path.moveAnchors")?; - let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); - // Spread-space delta → inner-space delta (linear part only). - let inv = m.inverse(); - let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO); - let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - for (si, ai) in &which { - let Some(a) = it.path.anchor_mut(*si, *ai) else { continue }; - match handle.as_deref() { - Some("in") => { - a.h_in += dv; - if a.kind == designcraft_geom::AnchorKind::Smooth { - a.h_out = a.p - (a.h_in - a.p); - } - } - Some("out") => { - a.h_out += dv; - if a.kind == designcraft_geom::AnchorKind::Smooth { - a.h_in = a.p - (a.h_out - a.p); - } - } - _ => a.translate(dv), - } + for (id, _) in &targets { + path_editable(d, *id, "path.moveAnchors")?; } - if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon { - it.shape = Shape::Path; + for (id, which) in &targets { + let id = *id; + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); + // Spread-space delta → inner-space delta (linear part only). + let inv = m.inverse(); + let inner = |d: Vec2| (inv * Point::new(d.x, d.y)) - (inv * Point::ZERO); + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + for (si, ai, own) in which { + let dv = inner(own.unwrap_or(d0)); + let Some(a) = it.path.anchor_mut(*si, *ai) else { continue }; + match handle.as_deref() { + Some("in") => { + a.h_in += dv; + if a.kind == designcraft_geom::AnchorKind::Smooth { + a.h_out = a.p - (a.h_in - a.p); + } + } + Some("out") => { + a.h_out += dv; + if a.kind == designcraft_geom::AnchorKind::Smooth { + a.h_in = a.p - (a.h_out - a.p); + } + } + _ => a.translate(dv), + } + } + if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon { + it.shape = Shape::Path; + } } ok() }) @@ -4143,6 +4298,31 @@ mod label_tests { mod light_tests { use super::*; + /// The shadow and glow commands take a blend mode by name. + #[test] + fn shadow_and_glow_commands_take_a_blend_mode() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.dropShadow", &json!({"on": true, "mode": "screen", "ids": [id]})).unwrap(); + s.execute("object.innerShadow", &json!({"on": true, "mode": "overlay", "ids": [id]})).unwrap(); + s.execute("object.outerGlow", &json!({"on": true, "mode": "normal", "ids": [id]})).unwrap(); + let fx = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.clone(); + assert_eq!(fx.drop_shadow.mode, BlendMode::Screen); + assert_eq!(fx.inner_shadow.mode, BlendMode::Overlay); + assert_eq!(fx.outer_glow.mode, BlendMode::Normal); + let before = s.doc().unwrap().doc.clone(); + for cmd in ["object.dropShadow", "object.innerShadow", "object.outerGlow"] { + for mode in [json!("multply"), json!(3)] { + let e = s.execute(cmd, &json!({"on": true, "mode": mode, "ids": [id]})).unwrap_err(); + assert!(e.to_string().contains("unknown blend mode"), "{cmd}: {e}"); + } + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed"); + s.execute("object.dropShadow", &json!({"mode": null, "ids": [id]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.drop_shadow.mode, BlendMode::Screen, "null keeps the mode"); + } + #[test] fn global_light_moves_shadows_that_use_it() { let mut s = Session::new(); @@ -4352,6 +4532,60 @@ mod fitting_tests { let g = back.spreads.iter().flat_map(|sp| sp.items.iter()).find_map(|i| i.graphic().cloned()).unwrap(); assert_eq!(g.auto_fit, designcraft_doc::Fitting::FillProportionally); } + + /// Turning Auto-Fit off by itself keeps a moved picture where it is. + #[test] + fn auto_fit_off_alone_keeps_the_picture_position() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap(); + s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally"})).unwrap(); + let xf = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().xf; + s.edit(|d, _| { + if let Some(Content::Graphic(g)) = d.item_mut(ItemId(f)).map(|it| &mut it.content) { + g.xf = designcraft_geom::Affine::translate((7.0, 9.0)) * g.xf; + } + Ok(Value::Null) + }) + .unwrap(); + let moved = xf(&s); + s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": false})).unwrap(); + assert_eq!(xf(&s), moved); + } + + /// Clear Frame Fitting Options puts auto-fit, crop and the reference point back to their + /// defaults and leaves the picture where it is. + #[test] + fn clear_fitting_options_resets_the_frames_options_and_keeps_the_picture() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap(); + s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally", "crop": 10, "align": 0})).unwrap(); + let graphic = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().clone(); + let before = graphic(&s); + assert_ne!(before.auto_fit, designcraft_doc::Fitting::None); + s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); + let g = graphic(&s); + assert_eq!(g.auto_fit, designcraft_doc::Fitting::None); + assert_eq!(g.crop, [0.0; 4]); + assert_eq!(g.fit_align, 4, "the centre reference point"); + assert_eq!(g.xf, before.xf, "the picture stays where it is"); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); + assert_eq!((r["changed"].as_u64(), s.doc().unwrap().history.undo.len()), (Some(0), undo), "already clear: no change, no step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step"); + s.execute("object.lock", &json!({"ids": [f]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); + assert_eq!(r["changed"], 0); + assert_eq!(graphic(&s).crop, [10.0; 4], "a locked frame keeps its options"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no empty undo step"); + } } #[cfg(test)] @@ -5538,12 +5772,14 @@ mod effects_command_tests { let it = st.doc.item(ItemId(id)).unwrap(); let e = &it.effects; assert!(e.inner_shadow.on && e.inner_shadow.angle == 120.0 && e.inner_shadow.size == 7.0); + assert_eq!(e.inner_shadow.mode, designcraft_color::BlendMode::Multiply, "a patch without a mode keeps the default"); assert!(e.drop_shadow.on && e.drop_shadow.distance == 10.0 && e.drop_shadow.size == 9.0, "untouched fields stay"); assert_eq!((e.feather, it.opacity, it.blend), (4.0, 0.5, designcraft_color::BlendMode::Multiply)); // Out of range values are kept in range; garbage changes nothing. s.execute("object.effects", &json!({"ids": [id], "effects": {"outerGlow": {"on": true, "opacity": 3, "spread": 400, "size": -5}}})).unwrap(); let og = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.outer_glow.clone(); assert_eq!((og.opacity, og.spread, og.size), (1.0, 100.0, 0.0)); + assert_eq!(og.mode, designcraft_color::BlendMode::Screen); let before = s.doc().unwrap().doc.clone(); for bad in [json!({"effects": {"dropShadow": {"distance": "far"}}}), json!({"effects": 5}), json!({"blend": "loud"}), json!({"opacity": "x"})] { diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index a6946be4f..7b2141c54 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -203,7 +203,7 @@ pub fn specs() -> Vec { "Package…", ["File"], None, - "{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}", + "{dir: folder to create, idml?: true, pdf?: false|true|{…options of file.exportPdf}, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}", has_doc, package ), @@ -390,6 +390,17 @@ fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec) { fn package(s: &mut Session, p: &Value) -> Result { let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?); let st = s.doc()?; + // The PDF: with the default settings, or with the options of a PDF preset, made as + // file.exportPdf makes it. Read first, so that bad options write nothing. + let pdf = match p.get("pdf") { + None | Some(Value::Null) | Some(Value::Bool(false)) => None, + Some(Value::Bool(true)) => Some(json!({})), + Some(o @ Value::Object(_)) => { + super::export::check_pdf_options(o, st.doc.page_count())?; + Some(o.clone()) + } + Some(v) => return Err(bad("file.package", format!("`pdf` is true, false or PDF export options, not {v}"))), + }; let root = OutputRoot::new(dir.clone())?; let display_title = st.doc.title.clone(); let title = safe_component(&display_title, "Untitled"); @@ -441,18 +452,18 @@ fn package(s: &mut Session, p: &Value) -> Result { idml_layer_note = "\nPlaced PDF layers\nThe IDML copy links the placed PDFs, so it shows their file defaults. The DesignCraft document keeps each placement's Object Layer Options choice.\n"; } } - if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) { + if let Some(params) = pdf { let path = package_output_path(&dir, &title, "pdf")?; - let bytes = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(&packed), &s.cache, &Default::default()) - .map_err(|e| EngineError::Other(e.to_string()))?; - root.write(&path, &bytes)?; + root.write(&path, &super::export::pdf_report(&packed, &s.cache, ¶ms)?.bytes)?; files.push(path); } - // The report: fonts (missing first), links, preflight, the user's instructions. - let mut r = format!("Package report: {display_title}\n\n"); + // The printing instructions first, as the form gave them; then the report: fonts (missing + // first), links, preflight. + let mut r = String::new(); if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) { r += &format!("Instructions\n{t}\n\n"); } + r += &format!("Package report: {display_title}\n\n"); r += "Fonts\n"; r += &font_lines.concat(); if !fallback_lines.is_empty() { diff --git a/crates/engine/src/cmd/package/tests.rs b/crates/engine/src/cmd/package/tests.rs index acc6d657f..4d6513425 100644 --- a/crates/engine/src/cmd/package/tests.rs +++ b/crates/engine/src/cmd/package/tests.rs @@ -25,6 +25,58 @@ fn relocated_packages_rebind_embedded_image_links_on_open() { std::fs::remove_dir_all(dir).unwrap(); } +/// `pdf` takes file.exportPdf's options (a PDF preset's): a PDF/X-4 package PDF; `true` is the +/// default PDF, and bad options are refused. +#[test] +fn package_pdf_takes_export_options() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-pdf-options-{}-{nonce}", std::process::id())); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Options", "width": 72, "height": 72})).unwrap(); + let x4 = dir.join("x4"); + s.execute("file.package", &json!({"dir": x4.to_string_lossy(), "idml": false, "pdf": {"standard": "x4"}})).unwrap(); + let has = |path: &std::path::Path, what: &[u8]| std::fs::read(path).unwrap().windows(what.len()).any(|w| w == what); + assert!(has(&x4.join("Options.pdf"), b"GTS_PDFX")); + let plain = dir.join("plain"); + s.execute("file.package", &json!({"dir": plain.to_string_lossy(), "idml": false, "pdf": true})).unwrap(); + assert!(!has(&plain.join("Options.pdf"), b"GTS_PDFX")); + // Refused before anything is written: bad options, a bad flattener or view option, and a + // `pdf` that is neither a switch nor options. + for bad in [ + json!({"standard": "nope"}), + json!({"flatten": "extreme"}), + json!({"pageLayout": "spiral"}), + json!({"view": "zoomed"}), + json!("print"), + json!(3), + ] { + let out = dir.join("bad"); + assert!(s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": bad})).is_err(), "{bad}"); + assert!(!out.exists(), "{bad}: bad options write nothing"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +/// A preset that flattens transparency (Screen and Email's `flatten: low`) gives the packaged +/// PDF the flattening `file.exportPdf` does. +#[test] +fn the_packaged_pdf_is_flattened_as_export_flattens_it() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-pdf-flatten-{}-{nonce}", std::process::id())); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Flat", "width": 144, "height": 144})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "unassigned"})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap(); + let exported = super::super::base64_decode(s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap()["base64"].as_str().unwrap()); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false, "pdf": {"flatten": "low"}})).unwrap(); + let packaged = std::fs::read(dir.join("Flat.pdf")).unwrap(); + let images = |pdf: &[u8]| pdf.windows(b"/Subtype/Image".len()).filter(|w| *w == b"/Subtype/Image").count(); + assert!(images(&exported) > 0, "the export rasterises the transparent object"); + assert_eq!(images(&packaged), images(&exported), "so does the package"); + std::fs::remove_dir_all(dir).unwrap(); +} + /// A per-placement PDF layer choice cannot travel in an IDML copy that links its files, so the /// report says the copies differ instead of leaving it to be discovered. #[test] @@ -169,6 +221,7 @@ fn package_writes_document_links_and_report() { assert!(out.join("Links").join("logo.svg").exists()); let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}"); + assert!(report.find("Print on matte.") < report.find("Package report:"), "the instructions come first: {report}"); assert!(report.contains("Preflight:"), "{report}"); // The packaged document links to its Links folder. let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap(); diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 989494507..f51ce3c70 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -50,12 +50,14 @@ pub fn specs() -> Vec { "Preferences", [], None, - "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100} → all application preferences", + "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100, defaultUnits?: {horizontalUnits?, verticalUnits?, rulerOrigin?, keyboardIncrement? (pt), kerningIncrement? (1/1000 em)} (Units & Increments of new documents)} → all application preferences", super::always, |s, p| { let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?; let new = merge("prefs.set", cur, p, None)?; - s.prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?; + let prefs: crate::Prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?; + prefs.default_units.check().map_err(|e| bad("prefs.set", e))?; + s.prefs = prefs; s.prefs.trim_recent_files(); serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string())) } @@ -100,6 +102,9 @@ fn merge(cmd: &str, mut cur: Value, p: &Value, allowed: Option<&[&str]>) -> Resu match (c.get_mut(k), v) { (Some(Value::Object(old)), Value::Object(v)) => { for (k2, v2) in v { + if !old.is_empty() && !old.contains_key(k2) { + return Err(bad(cmd, format!("unknown preference {k}.{k2}"))); + } old.insert(k2.clone(), v2.clone()); } } @@ -127,6 +132,7 @@ mod tests { s.execute("prefs.set", &json!({"showFontNamesInEnglish": true})).unwrap(); assert!(s.prefs.show_font_names_in_english); assert!(s.execute("prefs.set", &json!({"nope": 1})).is_err()); + assert!(s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnit": "inches"}})).is_err()); s.execute("file.new", &json!({})).unwrap(); let r = s .execute("document.preferences", &json!({"horizontalUnits": "millimeters", "keyboardIncrement": 2.5, "grid": {"subdivisions": 4}})) @@ -141,6 +147,53 @@ mod tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, designcraft_geom::Unit::Picas); } + + /// Units & Increments set with no document open are what new documents start with, as in + /// InDesign: a reader who works in inches sets them once, not in every file. + #[test] + fn default_units_are_what_new_documents_start_with() { + use designcraft_geom::Unit; + let mut s = Session::new(); + let r = s + .execute( + "prefs.set", + &json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "inches", "keyboardIncrement": 0.5, "rulerOrigin": "page"}}), + ) + .unwrap(); + assert_eq!(r["defaultUnits"]["horizontalUnits"], "inches"); + assert_eq!(r["defaultUnits"]["kerningIncrement"], 20.0, "the rest kept"); + s.execute("file.new", &json!({})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Inches)); + assert_eq!((st.keyboard_increment, st.ruler_origin), (0.5, designcraft_doc::RulerOrigin::Page)); + assert!(!s.doc().unwrap().is_dirty(), "made so, not edited"); + // Inches across, millimetres down: New Document's units (the horizontal ones) keep the + // vertical preference; other units picked there set both rulers. + s.execute("prefs.set", &json!({"defaultUnits": {"verticalUnits": "millimeters"}})).unwrap(); + s.execute("file.new", &json!({"units": "inches"})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Millimeters)); + s.execute("file.new", &json!({"units": "points"})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Points, Unit::Points)); + // A preset keeps its own units, both of them, even when the default horizontal units are + // the preset's. + s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "millimeters", "verticalUnits": "inches"}})).unwrap(); + s.execute("file.new", &json!({"preset": "A4"})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Millimeters, Unit::Millimeters)); + assert_eq!(st.keyboard_increment, 0.5, "the increments still apply"); + s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "millimeters"}})).unwrap(); + // New Document opens with them; bad values are refused and change nothing. + assert_eq!(s.default_new_document().units, Unit::Inches); + for bad in [json!({"horizontalUnits": "furlongs"}), json!({"keyboardIncrement": 0}), json!({"kerningIncrement": -5})] { + assert!(s.execute("prefs.set", &json!({"defaultUnits": bad})).is_err(), "{bad}"); + } + assert_eq!(s.prefs.default_units.keyboard_increment, 0.5); + // Kept with the application preferences. + let saved: crate::Prefs = serde_json::from_slice(&serde_json::to_vec(&s.prefs).unwrap()).unwrap(); + assert_eq!(saved.default_units, s.prefs.default_units); + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 914165387..5e7a425ea 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -81,13 +81,91 @@ pub fn specs() -> Vec { ] } +/// Common words the hyphenation word list (from the early 1990s) lacks: everyday short words +/// such as "we", "us" and "if", titles, and words that came with computers and the web. Sorted, +/// lowercase, base forms (the inflections below reach "emails", "downloaded", "bloggers"). +const COMMON: &[&str] = &[ + "ah", + "bitmap", + "blog", + "bluetooth", + "chatbot", + "checkbox", + "cybersecurity", + "database", + "dataset", + "desktop", + "download", + "dr", + "dropdown", + "ebook", + "ecommerce", + "eh", + "email", + "emoji", + "firewall", + "gigabyte", + "hashtag", + "hm", + "hmm", + "homepage", + "hyperlink", + "if", + "inbox", + "internet", + "jr", + "kilobyte", + "laptop", + "livestream", + "login", + "logout", + "malware", + "megabyte", + "metadata", + "mr", + "offline", + "ok", + "online", + "ow", + "pm", + "podcast", + "popup", + "printout", + "screenshot", + "selfie", + "smartphone", + "smartwatch", + "spreadsheet", + "sr", + "startup", + "terabyte", + "touchscreen", + "um", + "upload", + "us", + "username", + "voicemail", + "vs", + "we", + "webinar", + "webmaster", + "webpage", + "website", + "wifi", + "workflow", + "yay", +]; + /// Is `w` (lowercase) a known word, allowing common inflections of dictionary stems? pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool { - if w.len() <= 1 || dict.get(w).is_some() || user.iter().any(|u| u == w) { + // A typographer's apostrophe (don’t, we’re) is the word list's straight one. + let w = &w.replace('’', "'"); + let has = |w: &str| dict.get(w).is_some() || COMMON.binary_search(&w).is_ok(); + if w.len() <= 1 || has(w) || user.iter().any(|u| u.replace('’', "'") == *w) { return true; } - let w = w.trim_end_matches("'s").trim_end_matches("’s"); - if dict.get(w).is_some() { + let w = w.trim_end_matches("'s"); + if has(w) { return true; } let stems: [(&str, &[&str]); 9] = [ @@ -105,14 +183,14 @@ pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool { if let Some(base) = w.strip_suffix(suf) { for add in adds { let cand = format!("{base}{add}"); - if cand.len() > 1 && dict.get(&cand).is_some() { + if cand.len() > 1 && has(&cand) { return true; } // Doubled consonant: "stopped" → "stop". if let Some(c) = base.chars().last() && base.len() > 2 && base.ends_with(&format!("{c}{c}")) - && dict.get(&base[..base.len() - c.len_utf8()]).is_some() + && has(&base[..base.len() - c.len_utf8()]) { return true; } @@ -128,7 +206,7 @@ pub fn suggest(dict: &Dictionary, w: &str) -> Vec { let mut out: Vec = Vec::new(); let mut push = |c: Vec| { let c: String = c.into_iter().collect(); - if c != w && !out.contains(&c) && dict.get(&c).is_some() { + if c != w && !out.contains(&c) && (dict.get(&c).is_some() || COMMON.binary_search(&c.as_str()).is_ok()) { out.push(c); } }; @@ -238,6 +316,7 @@ mod tests { } assert!(!known(d, &[], "typograpy")); assert!(suggest(d, "typograpy").contains(&"typography".to_string())); + assert!(suggest(d, "emial").contains(&"email".to_string()), "words in COMMON are suggested too"); assert!(known(d, &["designcraft".into()], "designcraft")); } @@ -253,6 +332,53 @@ mod tests { assert_eq!(bad, vec![6..10]); } + /// Everyday words the hyphenation list lacks aren't misspelled ("we", "us", "if", "email"…), + /// with their inflections, and neither are contractions typed with a typographer's + /// apostrophe, which Use Typographer's Quotes turns ' into. + #[test] + fn common_words_and_curly_apostrophes_are_known() { + let d = Dictionary::en_us(); + if d.is_empty() { + return; // data not bundled in this build + } + assert!(COMMON.is_sorted(), "COMMON is binary-searched"); + for w in [ + "we", + "us", + "if", + "ok", + "mr", + "email", + "emails", + "emailed", + "online", + "website", + "websites", + "downloaded", + "database", + "databases", + "blogger", + "don’t", + "can’t", + "i’m", + "we’re", + "they’ve", + "it’s", + "don't", + ] { + assert!(known(d, &[], w), "{w}"); + } + for w in ["dont", "wee’re", "emial"] { + assert!(!known(d, &[], w), "{w}"); + } + // A user word added with a typographer's apostrophe matches either form. + assert!(known(d, &["zorbo’s".into()], "zorbo's") && known(d, &["zorbo's".into()], "zorbo’s")); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "We don’t email us if we’re online."})).unwrap(); + assert_eq!(s.execute("spelling.check", &json!({})).unwrap(), json!([])); + } + #[test] fn check_finds_misspellings() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4cc8a5894..879a089dc 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -17,7 +17,7 @@ use object::{apply_object, clear_object_overrides, create_object, edit_object}; use paragraph::{ apply_char, apply_para, create_char, create_para, delete_char, delete_para, edit_char, edit_para, export_tag_key, group_styles, redefine, }; -pub(crate) use paragraph::{check_grep_styles, grep_style_warnings}; +pub(crate) use paragraph::{apply_char_style, apply_para_style, check_grep_styles, grep_style_warnings}; pub use swatches::parse_color; use swatches::{add_to_swatches, create_swatch, load_swatches, new_swatch, save_swatches, swatch_options}; @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "Paragraph Style Options…", [], None, - "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", + "{name, rename?, basedOn?, nextStyle?: style | null ([Same style]), resetToBase?: bool (Reset To Base: the style sets nothing of its own; `para`/`chars` then apply over its Based On), para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", has_doc, edit_para ), diff --git a/crates/engine/src/cmd/style/paragraph.rs b/crates/engine/src/cmd/style/paragraph.rs index 81711ff10..16671fbd8 100644 --- a/crates/engine/src/cmd/style/paragraph.rs +++ b/crates/engine/src/cmd/style/paragraph.rs @@ -328,7 +328,7 @@ fn apply_para_in_story( f.chars.retain_differences(style_chars); } }); - for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + for (pi, range) in st.paras_touching(r.clone()).into_iter().filter(|(pi, _)| (first..=last).contains(pi)) { let Some(pf) = st.paras.get(pi) else { continue }; let (_, base) = styles.resolve_para(pf); let end = range.end.saturating_add(1).min(st.len()); @@ -345,6 +345,13 @@ fn apply_para_in_story( } } +/// Paragraph style `name` applied to the paragraphs of `r` in `st`: the paragraphs' and their +/// text's overrides that the style now gives go (all of them with `clear`). +pub(crate) fn apply_para_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range, clear: bool) { + let (style_para, style_chars) = styles.resolve_para_style(name); + apply_para_in_story(st, r, name, clear, (&style_para, &style_chars), styles); +} + pub(super) fn apply_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string(); let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false); @@ -374,25 +381,31 @@ pub(super) fn apply_char(s: &mut Session, p: &Value) -> Result { let styles = d.styles.clone(); for t in &targets { if let Some(st) = d.text_story_mut(t.story, t.cell) { - for (pi, range) in st.para_ranges().into_iter().enumerate() { - let range = range.start.max(t.range.start)..range.end.saturating_add(1).min(st.len()).min(t.range.end); - if range.is_empty() { - continue; - } - let Some(pf) = st.paras.get(pi) else { continue }; - let (_, mut inherited) = styles.resolve_para(pf); - inherited.apply(&styles.char_style_attrs(&name)); - st.format_chars(range, |f| { - f.style = name.clone(); - f.over.retain_differences(&inherited); - }); - } + apply_char_style(st, &styles, &name, &t.range); } } ok() }) } +/// Character style `name` applied to `r` in `st`: the text's overrides that the style (over its +/// paragraph) now gives go. +pub(crate) fn apply_char_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range) { + for (pi, range) in st.paras_touching(r.clone()) { + let range = range.start.max(r.start)..range.end.saturating_add(1).min(st.len()).min(r.end); + if range.is_empty() { + continue; + } + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, mut inherited) = styles.resolve_para(pf); + inherited.apply(&styles.char_style_attrs(name)); + st.format_chars(range, |f| { + f.style = name.to_string(); + f.over.retain_differences(&inherited); + }); + } +} + /// In a style, `startAt: null` is Continue from Previous Number: it is stored as an explicit /// "continue" so it also overrides a Start At the style would inherit. fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { @@ -403,8 +416,19 @@ fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { } } +/// The style `p[key]` names: none when absent or null; anything but a name is refused. +fn style_name_param(cmd: &str, p: &Value, key: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) => Ok(Some(n.clone())), + Some(_) => Err(bad(cmd, format!("`{key}` is a style name or null"))), + } +} + pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); + let based_on = style_name_param("style.paragraph.create", p, "basedOn")?; + let next = style_name_param("style.paragraph.create", p, "nextStyle")?; let mut para: ParaAttrs = attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?; continue_numbering(p.get("para"), &mut para); @@ -428,8 +452,6 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { chars = full; } } - let based_on = str_param(p, "basedOn").map(str::to_string); - let next = str_param(p, "nextStyle").map(str::to_string); s.edit(|d, _| { let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base); if let Some(b) = &based_on @@ -437,6 +459,13 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { { return Err(bad("style.paragraph.create", format!("no style `{b}`"))); } + // Next Style: a style of the document, or this one. + if let Some(n) = &next + && *n != name + && d.styles.para(n).is_none() + { + return Err(bad("style.paragraph.create", format!("no style `{n}`"))); + } d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() }); Ok(json!({"name": name})) }) @@ -499,11 +528,41 @@ pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result { crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); let based = p.get("basedOn").cloned(); + let next = p.get("nextStyle").cloned(); + let reset = p.get("resetToBase").and_then(Value::as_bool).unwrap_or(false); + for (key, v) in [("basedOn", &based), ("nextStyle", &next)] { + if v.as_ref().is_some_and(|v| !v.is_string() && !v.is_null()) { + return Err(bad("style.paragraph.edit", format!("`{key}` must be a style name or null"))); + } + } s.edit(|d, _| { - if let Some(Value::String(b)) = &based - && d.styles.para_based_on_cycles(&name, b) + if let Some(Value::String(b)) = &based { + if d.styles.para(b).is_none() { + return Err(bad("style.paragraph.edit", format!("no style `{b}`"))); + } + if d.styles.para_based_on_cycles(&name, b) { + return Err(bad("style.paragraph.edit", "based-on would create a cycle")); + } + } + if let Some(Value::String(n)) = &next + && d.styles.para(n).is_none() { - return Err(bad("style.paragraph.edit", "based-on would create a cycle")); + return Err(bad("style.paragraph.edit", format!("no style `{n}`"))); + } + // Reset To Base: what the style set of its own goes before the edits apply. + if reset { + let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; + st.para = ParaAttrs::default(); + st.chars = CharAttrs::default(); + } + // A new Based On first, so the fields below resolve over the new parent. + if based.is_some() { + let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; + match &based { + Some(Value::String(b)) => st.based_on = Some(b.clone()), + Some(Value::Null) => st.based_on = None, + _ => {} + } } if let Some(n) = &rename { if n.is_empty() || n.starts_with('[') { @@ -522,9 +581,9 @@ pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result { let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; st.para.merge(¶); st.chars.merge(&chars); - match based { - Some(Value::String(b)) => st.based_on = Some(b), - Some(Value::Null) => st.based_on = None, + match next { + Some(Value::String(n)) => st.next_style = Some(n), + Some(Value::Null) => st.next_style = None, _ => {} } if let Some(n) = rename.clone() { @@ -781,7 +840,7 @@ pub(super) fn create_char(s: &mut Session, p: &Value) -> Result { chars = captured; } // Based on [None] is based on nothing. - let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); + let based_on = style_name_param("style.character.create", p, "basedOn")?.filter(|b| b != designcraft_doc::NO_CHAR_STYLE); s.edit(|d, _| { if let Some(b) = &based_on && d.styles.char_style(b).is_none() @@ -1141,6 +1200,7 @@ mod delete_tests { assert_eq!(s.doc().unwrap().history.undo.len(), undo); } + /// Deleting a paragraph style also redirects TOC style references to its replacement. #[test] fn deleting_a_paragraph_style_points_every_use_at_the_replacement() { let mut s = Session::new(); @@ -1162,6 +1222,7 @@ mod delete_tests { entries: vec![TocEntry { entry_style: Some("Table Body".into()), ..TocEntry::new("Table Body", 1) }], page_numbers: false, title_style: Some("Table Body".into()), + create_bookmarks: false, }); Ok(()) }) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 2a124e7a9..d3743df9e 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -28,4 +28,5 @@ mod styles; mod target; pub use registry::specs; -pub(crate) use selection::step_cell; +pub(crate) use selection::{one_selected_cell_to_text, step_cell}; +pub use structure::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_CONVERTED_CELLS, MAX_HEADER_FOOTER_ROWS}; diff --git a/crates/engine/src/cmd/table/registry.rs b/crates/engine/src/cmd/table/registry.rs index 28d451ced..0a8166f89 100644 --- a/crates/engine/src/cmd/table/registry.rs +++ b/crates/engine/src/cmd/table/registry.rs @@ -1,7 +1,7 @@ use super::cell::{get_cell_stroke, set_cell}; use super::content::{drop_cells, place_graphic, sort_rows}; use super::options::{get, options}; -use super::selection::{move_rc, select, step_cell}; +use super::selection::{move_rc, select, step_cell, toggle_selection}; use super::sizing::{distribute_cols, distribute_rows, resize_border, set_col_width, set_row_height}; use super::structure::{ can_convert_rows, convert_from_text, convert_rows, convert_to_text, delete_cols, delete_rows, delete_table, insert, insert_col, insert_row, @@ -82,8 +82,9 @@ fn table_specs() -> Vec { "table.insert", "Create Table", [], - Some("Cmd+Alt+Shift+T"), - "{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point", + // InDesign's Cmd+Alt+Shift+T opens the Insert Table dialog (`app.insertTableDialog`). + None, + "{rows?: body rows (4, at most 500), cols? | columns? (4, at most 200), headerRows? (0), footerRows? (0, each at most 100), tableStyle?: name, width?} — at the text insertion point, styled in the same step", has_text, insert ), @@ -92,7 +93,7 @@ fn table_specs() -> Vec { "Convert Text to Table", ["Table"], None, - "{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows", + "{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), columns?: number of columns when both separators are the same (1, at most 200), tableStyle?: name, width?: table width (default: the column)} — the selected paragraphs become a table, as InDesign's Convert Text to Table; the words tab, comma and paragraph name those separators, any other text is the separator itself", has_text, convert_from_text ), @@ -101,7 +102,7 @@ fn table_specs() -> Vec { "Convert Table to Text", ["Table"], None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs", + "{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the table becomes its cells' text, as InDesign's Convert Table to Text", in_table, convert_to_text ), @@ -207,7 +208,7 @@ fn table_specs() -> Vec { cmd!( "table.placeGraphic", "Convert Cell to Graphic Cell", - ["Table", "Convert Cell Type"], + ["Table"], None, "{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)", in_table, @@ -216,7 +217,7 @@ fn table_specs() -> Vec { cmd!( "table.textCell", "Convert Cell to Text Cell", - ["Table", "Convert Cell Type"], + ["Table"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics", in_table, @@ -397,6 +398,15 @@ fn table_specs() -> Vec { }), cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)), cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)), + cmd!( + noundo "table.toggleSelection", + "Toggle Text and Cell Selection", + [], + None, + "{} — Esc in a table, as InDesign: the insertion point or text in a cell selects that cell; selected cells select the text of the first one", + in_cell, + |s, _| toggle_selection(s) + ), cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), ] } diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index 9e0a11d0e..656dfdd78 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -66,6 +66,55 @@ pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { Ok(json!({"row": row, "col": col})) } +/// With one cell selected (a merged one counts as one), select its whole text instead, so what is +/// typed next replaces it, as InDesign does. Other selections are left as they are. +pub(crate) fn one_selected_cell_to_text(s: &mut Session) -> Result<()> { + let st = s.doc_mut()?; + let (Some(cs), Some(t)) = (st.selection.cells, st.selection.text) else { return Ok(()) }; + let Some(tb) = st.doc.story(cs.story).and_then(|x| x.tables.get(&cs.table)) else { return Ok(()) }; + let (row, col) = tb.owner(cs.range.r0, cs.range.c0); + if tb.owner(cs.range.r1, cs.range.c1) != (row, col) { + return Ok(()); + } + let cell = CellAddr { table: cs.table, row, col }; + let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len()); + st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t }); + Ok(()) +} + +/// Esc in a table: text in a cell becomes that cell's selection, and selected cells the text of +/// the first one (InDesign toggles between the two). +pub(super) fn toggle_selection(s: &mut Session) -> Result { + const ID: &str = "table.toggleSelection"; + let st = s.doc_mut()?; + let t = st.selection.text.ok_or_else(|| bad(ID, "no caret"))?; + let c = t.cell.filter(|c| c.footnote_id().is_none()).ok_or_else(|| bad(ID, "not in a cell"))?; + let cells = match st.selection.cells { + Some(cs) => { + // A merged cell's text belongs to the cell at its top left. + let (row, col) = st + .doc + .story(cs.story) + .and_then(|x| x.tables.get(&cs.table)) + .map_or((cs.range.r0, cs.range.c0), |tb| tb.owner(cs.range.r0, cs.range.c0)); + let cell = CellAddr { table: cs.table, row, col }; + let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len()); + st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t }); + false + } + None => { + st.selection = Selection { + cells: Some(TableSel { story: t.story, table: c.table, range: CellRange::cell(c.row, c.col) }), + text: Some(TextSel { anchor: 0, focus: 0, ..t }), + ..Default::default() + }; + true + } + }; + st.bump_revision(); + Ok(json!({"cells": cells})) +} + /// The table cell under spread point `pt` in text frame `frame`: (story, table, row, col). pub(super) fn cell_at(s: &Session, frame: designcraft_doc::ItemId, pt: designcraft_geom::Point) -> Option<(StoryId, u64, usize, usize)> { let st = s.active()?; diff --git a/crates/engine/src/cmd/table/structure.rs b/crates/engine/src/cmd/table/structure.rs index 2e76c5a3b..0eb0302df 100644 --- a/crates/engine/src/cmd/table/structure.rs +++ b/crates/engine/src/cmd/table/structure.rs @@ -1,6 +1,31 @@ use super::target::{column_width, count, edit_table, in_cell, para_direction, target}; use super::{CellAddr, Result, RowKind, Selection, Session, Table, TextSel, Value, bad, json, ok, str_param}; +/// The most body rows, columns, and header or footer rows `table.insert` makes (the Insert Table +/// dialog's limits too). +pub const MAX_BODY_ROWS: u64 = 500; +pub const MAX_COLUMNS: u64 = 200; +pub const MAX_HEADER_FOOTER_ROWS: u64 = 100; +/// The most cells `table.convertFromText` makes: as many as the largest table `table.insert` +/// makes, however the rows and columns divide them (a long list can make a long table). +pub const MAX_CONVERTED_CELLS: u64 = MAX_BODY_ROWS * MAX_COLUMNS; + +/// A command's `tableStyle`: none for an absent one or [Basic Table] (a new table's look), else the +/// resolved style and the cell styles it names. Anything but an existing style's name is an error. +fn table_style_param(s: &Session, p: &Value, cmd: &str) -> Result)>> { + let name = match p.get("tableStyle") { + None | Some(Value::Null) => return Ok(None), + Some(Value::String(n)) if n == designcraft_doc::BASIC_TABLE => return Ok(None), + Some(Value::String(n)) => n, + Some(_) => return Err(bad(cmd, "`tableStyle` must be a table style's name")), + }; + let styles = &s.doc()?.doc.styles; + if !styles.table.iter().any(|x| x.name == *name) { + return Err(bad(cmd, format!("no table style `{name}`"))); + } + Ok(Some((styles.resolve_table_style(name), styles.cell.clone()))) +} + pub(super) fn insert(s: &mut Session, p: &Value) -> Result { let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?; if t.cell.is_some() { @@ -20,10 +45,11 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { }, } }; - let rows = given("rows", None, 4, 1..=500)?; - let cols = given("cols", Some("columns"), 4, 1..=200)?; - let header = given("headerRows", None, 0, 0..=100)?; - let footer = given("footerRows", None, 0, 0..=100)?; + let rows = given("rows", None, 4, 1..=MAX_BODY_ROWS)?; + let cols = given("cols", Some("columns"), 4, 1..=MAX_COLUMNS)?; + let header = given("headerRows", None, 0, 0..=MAX_HEADER_FOOTER_ROWS)?; + let footer = given("footerRows", None, 0, 0..=MAX_HEADER_FOOTER_ROWS)?; + let style = table_style_param(s, p, "table.insert")?; let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); s.edit(|d, sel| { let id = d.alloc(); @@ -37,6 +63,9 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() }; c.text.chars[0].format = cf.clone(); } + if let Some((ts, cells)) = &style { + ts.apply_to(&mut table, cells); + } let r = t.range(); st.delete(r.clone()); st.insert_table(r.start, table); @@ -45,12 +74,41 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { }) } -pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result { - let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?; - if t.cell.is_some() { - return Err(bad("table.convertFromText", "select text outside tables")); +/// A Convert Text to Table / Convert Table to Text separator: `tab`, `comma`, `paragraph` or the +/// separator's own text (InDesign's fields take any characters). +fn separator(p: &Value, key: &str, default: &str, cmd: &str) -> Result { + let sep = match p.get(key) { + None | Some(Value::Null) => return Ok(default.to_string()), + Some(Value::String(s)) => match s.as_str() { + "tab" => "\t", + "comma" => ",", + "paragraph" => "\n", + other => other, + }, + Some(_) => "", + }; + if sep.is_empty() { + return Err(bad(cmd, format!("`{key}` must be tab, comma, paragraph or the separator's characters"))); } - let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' }; + Ok(sep.to_string()) +} + +pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.convertFromText"; + let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?; + if t.cell.is_some() { + return Err(bad(ID, "select text outside tables")); + } + let (col_sep, row_sep) = (separator(p, "columnSeparator", "\t", ID)?, separator(p, "rowSeparator", "\n", ID)?); + // With one separator for both, InDesign fills rows of Number of Columns cells. + let per_row = match p.get("columns") { + None | Some(Value::Null) => 1, + Some(v) => v + .as_u64() + .filter(|n| (1..=MAX_COLUMNS).contains(n)) + .ok_or_else(|| bad(ID, format!("`columns` must be a whole number from 1 to {MAX_COLUMNS}")))? as usize, + }; + let style = table_style_param(s, p, ID)?; let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); s.edit(|d, sel| { let id = d.alloc(); @@ -62,11 +120,33 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result { return Err(bad("table.convertFromText", "the selection contains a table")); } let (a, b) = (ranges[pa].start, ranges[pb].end); - let data: Vec> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect(); + let text = &st.text[a..b]; + let data: Vec> = if col_sep == row_sep { + let cells: Vec = text.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect(); + cells.chunks(per_row).map(<[String]>::to_vec).collect() + } else { + text.split(row_sep.as_str()).map(|row| row.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect()).collect() + }; + // The table is as wide as its widest row; refuse what no table here could hold. + let cols = data.iter().map(Vec::len).max().unwrap_or(1) as u64; + if cols > MAX_COLUMNS { + return Err(bad(ID, format!("the text makes {cols} columns; a table has at most {MAX_COLUMNS}"))); + } + if cols * data.len() as u64 > MAX_CONVERTED_CELLS { + return Err(bad(ID, format!("the text makes more than {MAX_CONVERTED_CELLS} cells"))); + } + // `Table::new` would clamp the shape and drop the rows past it, with their text. + let (rows, _) = designcraft_doc::clamped_size(data.len(), cols as usize); + if rows < data.len() { + return Err(bad(ID, format!("the text makes {} rows; a table of {cols} columns has at most {rows}", data.len()))); + } let pf = st.paras[pa].clone(); let cf = st.format_after(a).clone(); let mut table = Table::from_strings(id, &data, width, &pf, &cf); table.options.direction = direction; + if let Some((ts, cells)) = &style { + ts.apply_to(&mut table, cells); + } st.delete(a..b); let anchor = st.insert_table(a, table); *sel = Selection::text(TextSel::caret(t.story, anchor)); @@ -76,9 +156,11 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result { pub(super) fn convert_to_text(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.convertToText")?; + let col_sep = separator(p, "columnSeparator", "\t", "table.convertToText")?; + let row_sep = separator(p, "rowSeparator", "\n", "table.convertToText")?; s.edit(|d, sel| { let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?; - let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default(); + let text = st.tables.get(&g.table).map(|t| t.text_with(&col_sep, &row_sep)).unwrap_or_default(); let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?; st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text); *sel = Selection::text(TextSel::caret(g.story, a + text.len())); diff --git a/crates/engine/src/cmd/table/target.rs b/crates/engine/src/cmd/table/target.rs index beaa1c8b0..2e1784560 100644 --- a/crates/engine/src/cmd/table/target.rs +++ b/crates/engine/src/cmd/table/target.rs @@ -25,7 +25,13 @@ pub(super) fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> { pub(super) fn in_cell(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) } + // Footnote text is addressed as a cell too, but isn't in a table. + let in_table = |c: CellAddr| c.footnote_id().is_none(); + if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some_and(in_table))) { + Ok(()) + } else { + Err("not in a table cell".into()) + } } /// The table a command acts on and its target cell range. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 8e6038741..b50850553 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -416,7 +416,7 @@ pub fn specs() -> Vec { "Selection Attributes", [], None, - "{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)", + "{mixed?: bool} → resolved character/paragraph attributes at the text selection; `ownChars`: what the text has of its own over its paragraph (its character style's attributes and its overrides, what a character style made from the selection sets); with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)", has_doc, selection_attrs ), diff --git a/crates/engine/src/cmd/text/caret.rs b/crates/engine/src/cmd/text/caret.rs index 670de4049..0621e30de 100644 --- a/crates/engine/src/cmd/text/caret.rs +++ b/crates/engine/src/cmd/text/caret.rs @@ -380,6 +380,8 @@ pub(super) fn move_caret(s: &mut Session, p: &Value) -> Result { s.typing_format = None; let stm = s.doc_mut()?; stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } }); + // Moving the caret leaves the selected cells, so typing inserts at the caret. + stm.selection.cells = None; stm.bump_revision(); Ok(json!({"pos": new})) } diff --git a/crates/engine/src/cmd/text/edit.rs b/crates/engine/src/cmd/text/edit.rs index fc3f5dddf..fd104cbcd 100644 --- a/crates/engine/src/cmd/text/edit.rs +++ b/crates/engine/src/cmd/text/edit.rs @@ -13,10 +13,13 @@ fn reserve_command_text(st: &mut designcraft_doc::Story, additional: usize) -> R pub(super) fn insert(s: &mut Session, p: &Value) -> Result { let text = crate::cmd::text_param(p, "text"); - // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). - if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) { + // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). Footnote text is + // addressed as a cell too: a Tab there is typed. + if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some_and(|c| c.footnote_id().is_none())) { return crate::cmd::table::step_cell(s, true); } + // Typing with one cell selected replaces its text, as InDesign. + crate::cmd::table::one_selected_cell_to_text(s)?; let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false); let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text }; let tracking = s.doc()?.doc.settings.track_changes; diff --git a/crates/engine/src/cmd/text/story.rs b/crates/engine/src/cmd/text/story.rs index e3547f88d..67f4cf1ad 100644 --- a/crates/engine/src/cmd/text/story.rs +++ b/crates/engine/src/cmd/text/story.rs @@ -125,9 +125,12 @@ pub(super) fn selection_attrs(s: &mut Session, p: &Value) -> Result { let char_delta = character_base.delta(&cp); let style_keys = character_style.keys(); let character_style_overrides = char_delta.keys().iter().filter(|key| style_keys.contains(key)).count(); + // The text's own formatting over its paragraph, as Create New Style from Selection captures it. + let mut own = character_style.clone(); + own.merge(&cf.over); // The selected text (its start, on one line), for previews such as the font menu's. let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); - let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, + let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, "ownChars": own, "paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(), "charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides, "selectedText": selected.trim()}); diff --git a/crates/engine/src/cmd/toc.rs b/crates/engine/src/cmd/toc.rs index 23f5847a4..b69d17e26 100644 --- a/crates/engine/src/cmd/toc.rs +++ b/crates/engine/src/cmd/toc.rs @@ -12,6 +12,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; +/// Register TOC generation and refresh commands. pub fn specs() -> Vec { vec![ cmd!( @@ -19,7 +20,7 @@ pub fn specs() -> Vec { "Table of Contents…", ["Layout"], None, - "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", + "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, createBookmarks?: bool (generate PDF bookmarks; keeps the existing setting when omitted, which is off for a new table), replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", has_doc, generate ), @@ -302,9 +303,30 @@ fn fill(d: &mut Document, toc: &Toc, width: Option) -> Result { st.format_paras(r.start..r.start, |f| *f = ParaFormat { style: name.clone(), ..Default::default() }); } } + let insertion = d.bookmarks.iter().position(|b| b.generated_by_toc).unwrap_or(d.bookmarks.len()); + d.bookmarks.retain(|b| !b.generated_by_toc); + if toc.create_bookmarks { + d.bookmarks.splice(insertion..insertion, bookmarks(&found)); + } Ok(found.len()) } +/// Levels are validated to 1–9. Recursion follows strictly increasing levels, including when +/// a document skips a level; each destination is the physical page, not its section label. +fn bookmarks(found: &[Found]) -> Vec { + /// Collect descendants until the next sibling or ancestor entry. + fn children(iter: &mut std::iter::Peekable>, parent: u8) -> Vec { + let mut out = Vec::new(); + while iter.peek().is_some_and(|f| f.level > parent) { + let Some(f) = iter.next() else { break }; + out.push(designcraft_doc::Bookmark { name: f.text.clone(), page: f.key.0, children: children(iter, f.level), generated_by_toc: true }); + } + out + } + children(&mut found.iter().peekable(), 0) +} + +/// Validate the TOC settings and generate its frame, text, and outline in one edit. fn generate(s: &mut Session, p: &Value) -> Result { let entries = entries_param(p)?; if entries.is_empty() { @@ -320,6 +342,10 @@ fn generate(s: &mut Session, p: &Value) -> Result { return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters"))); } let page_numbers = p.get("pageNumbers").and_then(Value::as_bool).unwrap_or(true); + let create_bookmarks = match p.get("createBookmarks") { + Some(v) => v.as_bool().ok_or_else(|| bad("toc.generate", "createBookmarks must be a boolean"))?, + None => s.doc()?.doc.toc.as_ref().is_some_and(|t| t.create_bookmarks), + }; let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true); let rect = super::rect_param(p, "rect"); if rect.is_some_and(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) { @@ -345,7 +371,7 @@ fn generate(s: &mut Session, p: &Value) -> Result { (sid, w) } }; - let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone() }; + let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone(), create_bookmarks }; let n = fill(d, &toc, width)?; d.toc = Some(toc); Ok(json!({"story": story.0, "entries": n})) @@ -623,6 +649,46 @@ mod tests { assert!(toc_runs(&s).iter().any(|(t, st)| t == "1" && st == "Folio"), "{:?}", toc_runs(&s)); } + /// Refresh preserves manual outline positions, nested destinations, storage, and undo. + #[test] + fn toc_bookmarks_refresh_nested_page_destinations_and_preserve_manual_bookmarks() { + let (mut s, page) = doc_with(&[("First", "Head"), ("Child", "Sub"), ("Last", "Head")]); + s.execute("layout.section", &json!({"page": 1, "style": "lowerRoman", "startNumber": 4})).unwrap(); + s.execute("bookmark.add", &json!({"name": "Manual", "page": page})).unwrap(); + let p = json!({"page": page, "createBookmarks": true, "entries": [ + {"style": "Head", "level": 1}, {"style": "Sub", "level": 3} + ]}); + s.execute("toc.generate", &p).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.bookmarks.iter().map(|b| b.name.as_str()).collect::>(), ["Manual", "First", "Last"]); + assert_eq!(d.bookmarks[1].page, 0, "physical page, although its label is iv"); + assert_eq!(d.bookmarks[1].children[0].name, "Child"); + assert!(d.bookmarks[1].children[0].generated_by_toc); + let sid = d.stories.iter().find(|(_, st)| st.text.starts_with("First")).map(|(sid, _)| *sid).unwrap(); + s.execute("bookmark.add", &json!({"name": "Manual after", "page": page})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 5})).unwrap(); + s.execute("text.insert", &json!({"text": "Changed"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks[1].name, "First", "refresh is explicit"); + s.execute("toc.update", &json!({})).unwrap(); + let refreshed = s.doc().unwrap().doc.bookmarks.clone(); + assert_eq!(refreshed.iter().map(|b| b.name.as_str()).collect::>(), ["Manual", "Changed", "Last", "Manual after"]); + s.execute("toc.update", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed, "updates do not duplicate bookmarks"); + // Native storage retains both the option and ownership; old bookmarks default to manual. + let stored = serde_json::to_value(&s.doc().unwrap().doc).unwrap(); + let back: Document = serde_json::from_value(stored).unwrap(); + assert!(back.toc.as_ref().unwrap().create_bookmarks); + assert_eq!(back.bookmarks, refreshed); + let old: designcraft_doc::Bookmark = serde_json::from_value(json!({"name": "Old", "page": 0})).unwrap(); + assert!(!old.generated_by_toc); + let mut disabled = p; + disabled["createBookmarks"] = json!(false); + s.execute("toc.generate", &disabled).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks.iter().map(|b| b.name.as_str()).collect::>(), ["Manual", "Manual after"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed); + } + #[test] fn toc_entry_options_round_trip_through_the_native_format() { let mut e = TocEntry::new("Head", 2); diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index f3d55099b..81b57b5b3 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -168,7 +168,7 @@ fn parse(xml: &str) -> std::result::Result, String> { _ => String::new(), }; match (depth, child.as_mut(), out.last_mut()) { - (3, Some(c), _) => c.1.push_str(&s), + (3.., Some(c), _) => c.1.push_str(&s), (2, _, Some(el)) => el.text.push_str(&s), _ => {} } @@ -518,6 +518,34 @@ mod tests { assert_eq!(s.doc().unwrap().doc.xml.style_map.len(), 1); } + #[test] + fn import_xml_keeps_text_of_inline_tags_in_mapped_paragraphs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + let a = s + .execute("frame.create", &json!({"rect": [72, 72, 400, 150], "content": "text", "text": "See the glossary entry", "caret": false})) + .unwrap(); + s.execute("text.select", &json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); + s.execute("xml.tag", &json!({"tag": "story", "ids": [a["id"]]})).unwrap(); + s.execute("xml.mapStyle", &json!({"style": "Head", "tag": "title"})).unwrap(); + s.execute("text.select", &json!({"story": a["story"], "anchor": 8, "focus": 16})).unwrap(); + s.execute("xml.tagText", &json!({"tag": "term"})).unwrap(); + let x = s.execute("file.exportXml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(x.contains("See the <term>glossary</term> entry"), "{x}"); + s.execute("file.importXml", &json!({"text": x})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.stories[&designcraft_doc::StoryId(a["story"].as_u64().unwrap())].text, "See the glossary entry"); + } + + #[test] + fn parse_keeps_text_nested_below_a_child_element() { + let els = super::parse("See the x y").unwrap(); + assert_eq!(els.len(), 1); + assert_eq!(els[0].children, vec![("note".to_string(), "See the x y".to_string())]); + } + #[test] fn tag_export_and_import_xml() { let mut s = Session::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 58301e9e6..f42dc5b13 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -250,6 +250,61 @@ pub struct Prefs { pub recent_files: Vec, /// Preferences › File Handling › Number of Recent Items to Display (0 keeps no list). pub recent_files_max: u32, + /// Preferences › Units & Increments set with no document open: what new documents start + /// with, as InDesign's application defaults. A document keeps its own once made. + pub default_units: DefaultUnits, +} + +/// Units & Increments for new documents (Preferences with no document open), in the keys +/// `document.preferences` uses. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DefaultUnits { + pub horizontal_units: designcraft_geom::Unit, + pub vertical_units: designcraft_geom::Unit, + pub ruler_origin: designcraft_doc::RulerOrigin, + /// Cursor Key, in points. + pub keyboard_increment: f64, + /// Kerning/Tracking, in 1/1000 em. + pub kerning_increment: f64, +} + +impl Default for DefaultUnits { + /// A new document's own settings. + fn default() -> Self { + let d = designcraft_doc::DocSettings::default(); + DefaultUnits { + horizontal_units: designcraft_geom::Unit::Picas, + vertical_units: designcraft_geom::Unit::Picas, + ruler_origin: d.ruler_origin, + keyboard_increment: d.keyboard_increment, + kerning_increment: d.kerning_increment, + } + } +} + +impl DefaultUnits { + /// Gives a new document these defaults. Unless it was made from a preset (whose units are its + /// own), its vertical ruler follows the preference while it keeps the default horizontal + /// units (units picked in New Document set both rulers). + pub fn apply(&self, settings: &mut designcraft_doc::DocSettings, preset: bool) { + if !preset && settings.horizontal_units == self.horizontal_units { + settings.vertical_units = self.vertical_units; + } + settings.ruler_origin = self.ruler_origin; + settings.keyboard_increment = self.keyboard_increment; + settings.kerning_increment = self.kerning_increment; + } + + /// Refuses increments that aren't finite and positive (at most 1000). + pub fn check(&self) -> std::result::Result<(), String> { + for (name, v) in [("keyboardIncrement", self.keyboard_increment), ("kerningIncrement", self.kerning_increment)] { + if !(v.is_finite() && v > 0.0 && v <= 1000.0) { + return Err(format!("`defaultUnits.{name}` must be more than 0 and at most 1000")); + } + } + Ok(()) + } } /// The most recent files Preferences lets File › Open Recent keep. @@ -324,6 +379,7 @@ impl Default for Prefs { link_text_files: false, recent_files: Vec::new(), recent_files_max: 20, + default_units: DefaultUnits::default(), } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6347b605f..3590b2814 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -486,6 +486,63 @@ fn place_gun_click_drag_and_into_frame() { assert_eq!(graphic_xf(&s), placed); } +/// A place-cursor drag draws the frame at the graphic's proportions from where it started, as +/// InDesign does, shows its scale, and places exactly that frame, in any direction. +#[test] +fn place_gun_drag_previews_and_places_a_proportional_frame() { + use designcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind}; + let mut s = session(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + let v = ViewInfo::at_zoom(1.0); + let p = designcraft_geom::Point::new; + assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::LoadedGraphic); + // Up and to the left: 80 × 200 holds the 40 × 20 graphic at 200 %, 80 × 40, hanging from the press. + s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 300.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 220.0, 100.0), v).unwrap(); + assert_eq!(s.cursor(p(220.0, 100.0), Mods::default(), v), Cursor::Crosshair, "the crosshair while drawing"); + let o = s.overlays(v); + assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0))), "{o:?}"); + assert!( + o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 200.00% +Y: 200.00%")), + "{o:?}" + ); + s.pointer(&PointerEvent::new(PointerKind::Up, 220.0, 100.0), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0), "the frame previewed"); + // 80 × 1 is past the drag tolerance, but its 2 × 1 proportional frame is too small for + // `place.drop`, which places at natural size: the preview shows that frame and 100 %. + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 400.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 401.0), v).unwrap(); + let natural = designcraft_geom::Rect::new(100.0, 400.0, 140.0, 420.0); + let o = s.overlays(v); + assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == natural)), "{o:?}"); + assert!( + o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 100.00% +Y: 100.00%")), + "{o:?}" + ); + s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 401.0), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), natural, "the frame previewed"); + // A drag under the threshold is a click: no frame drawn, the graphic at actual size where + // the press was. + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 500.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 102.5, 501.25), v).unwrap(); + assert!(!s.overlays(v).iter().any(|o| matches!(o, Overlay::Marquee(_))), "no frame yet"); + assert_eq!(s.cursor(p(102.5, 501.25), Mods::default(), v), Cursor::LoadedGraphic, "the thumbnail stays"); + s.pointer(&PointerEvent::new(PointerKind::Up, 102.5, 501.25), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 500.0, 140.0, 520.0), "actual size at the press"); + // With no graphic loaded there's no thumbnail to show: the crosshair, before any press too. + s.loaded = None; + s.set_tool("placeGun"); + assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::Crosshair); +} + #[test] fn preflight_reports_overset_and_missing_fonts() { let mut s = session(); @@ -877,6 +934,9 @@ fn custom_wrap_path_follows_resize_and_yields_to_offsets() { s.execute("transform.resize", &json!({"from": [b.x0, b.y0, b.x1, b.y1], "to": to})).unwrap(); let w = wrap_path(&s).unwrap().bounds().unwrap(); assert!((w.width() - 80.0).abs() < 1e-6 && (w.height() - 100.0).abs() < 1e-6, "{w:?}"); + // Offsets that are all null change nothing, so the boundary stays. + s.execute("object.textWrap", &json!({"mode": null, "offset": [null, null, null, null]})).unwrap(); + assert!(wrap_path(&s).is_some(), "an all-null offset keeps the custom boundary"); // An offset rebuilds the wrap from the frame. s.execute("object.textWrap", &json!({"offset": 6})).unwrap(); assert_eq!(wrap_path(&s), None); @@ -1661,6 +1721,239 @@ fn a_line_fits_when_its_baseline_does() { assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } +/// Reset To Base with a new Based On: a partial rule edit resolves over the new parent, not the +/// old one. +#[test] +fn reset_to_base_with_a_new_parent_resolves_rule_edits_over_it() { + let mut s = session(); + let rule = |weight: f64, offset: f64| json!({"on": true, "weight": weight, "color": "[Black]", "offset": offset}); + s.execute("style.paragraph.create", &json!({"name": "Old", "para": {"ruleBelow": rule(3.0, 1.0)}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "New", "para": {"ruleBelow": rule(1.0, 9.0)}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Child", "basedOn": "Old"})).unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Child", "resetToBase": true, "basedOn": "New", "para": {"ruleBelow": {"weight": 2}}})) + .unwrap(); + let r = s.doc().unwrap().doc.styles.resolve_para_style("Child").0.rule_below; + assert_eq!((r.weight, r.offset), (2.0, 9.0), "the offset is the new parent's"); +} + +/// Paragraph style references are checked: a Based On or Next Style that names no style, or +/// that isn't a name or null, is refused and changes nothing. +#[test] +fn paragraph_style_references_must_name_styles() { + let mut s = session(); + s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); + assert!(s.execute("style.paragraph.create", &json!({"name": "Lost", "nextStyle": "Nowhere"})).is_err()); + assert!(s.doc().unwrap().doc.styles.para("Lost").is_none()); + // On creation too, a reference that isn't a name or null is refused, not dropped. + for p in [json!({"name": "Lost", "nextStyle": true}), json!({"name": "Lost", "basedOn": 3})] { + assert!(s.execute("style.paragraph.create", &p).is_err(), "{p}"); + assert!(s.doc().unwrap().doc.styles.para("Lost").is_none(), "{p}"); + } + assert!(s.execute("style.character.create", &json!({"name": "Lost", "basedOn": ["Body"]})).is_err()); + assert!(s.doc().unwrap().doc.styles.char_style("Lost").is_none()); + s.execute("style.paragraph.create", &json!({"name": "Self", "nextStyle": "Self"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + for p in [json!({"name": "Body", "basedOn": "Nowhere"}), json!({"name": "Body", "basedOn": 3}), json!({"name": "Body", "nextStyle": true})] { + assert!(s.execute("style.paragraph.edit", &p).is_err(), "{p}"); + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed"); + s.execute("style.paragraph.edit", &json!({"name": "Body", "basedOn": null, "nextStyle": "Self"})).unwrap(); +} + +/// The course's chapter 43: with Direct Selection, a circle's selected anchor shows its handles +/// and they drag, reshaping the curve; the handles of the anchors away from it neither show nor +/// catch a press, as in InDesign. +#[test] +fn direct_selection_drags_only_the_handles_around_the_selected_anchor() { + use designcraft_tools::{PointerEvent, PointerKind}; + let mut s = session(); + let v = ViewInfo::at_zoom(1.0); + let id = s.execute("frame.create", &json!({"rect": [100, 300, 300, 500], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + s.set_tool("directSelection"); + // Each anchor and its two handles, in the spread. + let anchors = |s: &Session| { + let st = s.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap(); + it.path.subpaths[0].anchors.iter().map(|a| (it.xf * a.p, it.xf * a.h_in, it.xf * a.h_out)).collect::>() + }; + let before = anchors(&s); + let lowest = |f: &dyn Fn(f64) -> f64| (0..before.len()).min_by(|a, b| f(before[*a].0.y).total_cmp(&f(before[*b].0.y))).unwrap(); + let (bottom, top) = (lowest(&|y| -y), lowest(&|y| y)); + let press = |s: &mut Session, from: designcraft_geom::Point, to: designcraft_geom::Point| { + s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, (from.x + to.x) / 2.0, (from.y + to.y) / 2.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap(); + }; + // Select the bottom anchor. + let b = before[bottom].0; + press(&mut s, b, b); + assert_eq!(s.doc().unwrap().selection.anchors, [(designcraft_doc::ItemId(id), 0, bottom)]); + // Its handle drags: the handle moves. + let h = before[bottom].2; + press(&mut s, h, h + designcraft_geom::Vec2::new(30.0, 0.0)); + let after = anchors(&s); + assert!((after[bottom].2 - before[bottom].2).hypot() > 20.0, "the bottom handle moved: {:?} → {:?}", before[bottom], after[bottom]); + // The top anchor's handle isn't shown, so a press on it doesn't take it. + let t = before[top].2; + press(&mut s, t, t + designcraft_geom::Vec2::new(0.0, -30.0)); + let after2 = anchors(&s); + assert!((after2[top].2 - before[top].2).hypot() < 1e-6, "the top handle stays: {:?} → {:?}", before[top], after2[top]); +} + +/// Direct Selection: a marquee over the top edges of two frames selects their top points, and a +/// drag on one of them slants both frames in one undo step (InDesign's way to make a row of +/// parallelograms). +#[test] +fn direct_selection_moves_the_points_of_several_frames_at_once() { + use designcraft_tools::{Mods, PointerEvent, PointerKind}; + let mut s = session(); + let v = ViewInfo::at_zoom(1.0); + let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + s.set_tool("directSelection"); + s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 280.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 320.0, 320.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 320.0, 320.0), v).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!(sel.anchors.len(), 4, "{:?}", sel.anchors); + assert!(sel.items.contains(&designcraft_doc::ItemId(a)) && sel.items.contains(&designcraft_doc::ItemId(b))); + let shift = Mods { shift: true, ..Default::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 136.0, 302.0).with_mods(shift), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 136.0, 302.0).with_mods(shift), v).unwrap(); + let corners = |s: &Session, id: u64| -> Vec<(f64, f64)> { + let st = s.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap(); + it.path.subpaths[0].anchors.iter().map(|an| ((it.xf * an.p).x.round(), (it.xf * an.p).y.round())).collect() + }; + // Both frames: top points 36 pt to the right, bottom points where they were. + assert_eq!(corners(&s, a), vec![(136.0, 300.0), (236.0, 300.0), (200.0, 400.0), (100.0, 400.0)]); + assert_eq!(corners(&s, b), vec![(236.0, 300.0), (336.0, 300.0), (300.0, 400.0), (200.0, 400.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(corners(&s, a), vec![(100.0, 300.0), (200.0, 300.0), (200.0, 400.0), (100.0, 400.0)]); + assert_eq!(corners(&s, b), vec![(200.0, 300.0), (300.0, 300.0), (300.0, 400.0), (200.0, 400.0)]); +} + +#[test] +fn selected_points_follow_their_path() { + let mut s = session(); + let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"anchors": [[a, 0, 1], [b, 0, 2]]})).unwrap(); + // Shift-clicking a frame off the selection drops its points too. + s.execute("selection.toggle", &json!({"id": a})).unwrap(); + let id = |n| designcraft_doc::ItemId(n); + assert_eq!(s.doc().unwrap().selection.anchors, vec![(id(b), 0, 2)]); + // A point added before the first one: the selected point keeps pointing at the same anchor. + let l = s.execute("path.create", &json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100]}, {"p": [300, 150]}]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"anchors": [[l, 0, 1]]})).unwrap(); + s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [50, 120]}, "at": "start"})).unwrap(); + let st = s.doc().unwrap(); + let (_, si, ai) = st.selection.anchors[0]; + let it = st.doc.item(id(l)).unwrap(); + assert_eq!((it.xf * it.path.subpaths[si].anchors[ai].p).round(), designcraft_geom::Point::new(200.0, 100.0)); + // `points` that isn't a list is refused, even with an `id` beside it. + for bad in [json!("x"), json!({"0": 1}), json!(5)] { + assert!(s.execute("path.moveAnchors", &json!({"points": bad, "id": l, "anchors": [[0, 0]], "dx": 5})).is_err(), "points {bad}"); + } + for bad in [json!(3), json!(-1), json!("0")] { + assert!(s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [0, 0]}, "subpath": bad})).is_err(), "subpath {bad}"); + } + assert_eq!(s.doc().unwrap().doc.item(id(l)).unwrap().path.subpaths[0].anchors.len(), 4, "nothing appended"); +} + +#[test] +fn direct_selection_pulls_one_corner_of_an_unselected_frame() { + // Chris Nielsen's "The Adobe InDesign Toolbox" (10:10): with the White Arrow, click outside, + // then press on a frame's corner and pull it: only that corner moves (a triangle, nearly). + use designcraft_tools::{PointerEvent, PointerKind, SnapView}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [156, 219, 455, 569], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + let corners = |s: &Session| { + let d = &s.doc().unwrap().doc; + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + it.path.subpaths[0].anchors.iter().map(|a| it.xf * a.p).collect::>() + }; + let before = corners(&s); + s.set_tool("directSelection"); + let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) }; + let layout = s.layout(); + let at = |x: f64, y: f64| layout.to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(x, y)); + let (a, b) = (at(455.0, 219.0), at(455.0, 150.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, a.x, a.y - 30.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap(); + let after = corners(&s); + let moved: Vec<_> = before.iter().zip(&after).filter(|(p, q)| (**p - **q).hypot() > 1e-6).collect(); + assert_eq!(moved.len(), 1, "one corner moved: {before:?} → {after:?}"); + assert!((*moved[0].1 - designcraft_geom::Point::new(455.0, 150.0)).hypot() < 1e-6, "{after:?}"); + assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)], "the frame is selected"); + // One undo step puts the corner back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(corners(&s), before); + // Away from its corners, the White Arrow still drags the whole unselected frame. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let (c, d) = (at(300.0, 219.0), at(300.0, 269.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, c.x, c.y + 20.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, d.x, d.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, d.x, d.y), v).unwrap(); + assert!(corners(&s).iter().zip(&before).all(|(p, q)| (p.y - q.y - 50.0).abs() < 1e-6), "{:?}", corners(&s)); +} + +#[test] +fn direct_selection_on_a_selected_frame_deletes_only_its_image() { + use designcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let dir = tempfile::tempdir().unwrap(); + let png = dir.path().join("photo.png"); + std::fs::write(&png, designcraft_render::Rendered { width: 100, height: 100, pixels: vec![40u8; 100 * 100 * 4] }.to_png()).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("file.place", &json!({"path": png.to_string_lossy(), "frame": frame})).unwrap(); + s.execute("selection.set", &json!({"ids": [frame], "content": false})).unwrap(); + let v = ViewInfo::at_zoom(1.0); + let click = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::Down, 110.0, 150.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 150.0), v).unwrap(); + }; + // The frame is selected with the Selection tool, then the image is clicked with Direct Selection. + s.set_tool("selection"); + click(&mut s); + assert!(!s.doc().unwrap().selection.content); + s.set_tool("directSelection"); + click(&mut s); + assert!(s.doc().unwrap().selection.content, "Direct Selection selects the image inside the selected frame"); + assert!(s.tool_key(ToolKey::Delete, Mods::default(), v).unwrap()); + let st = s.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(frame)).expect("the frame stays"); + assert!(matches!(it.content, designcraft_doc::Content::Unassigned), "{:?}", it.content); + assert_eq!(st.selection.items, vec![designcraft_doc::ItemId(frame)]); + // A frame holding pasted-in items: the click selects them too, so arrow keys move them. + let inner = s.execute("frame.create", &json!({"rect": [320, 120, 360, 160]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [inner]})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + let holder = s.execute("frame.create", &json!({"rect": [300, 100, 400, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [holder]})).unwrap(); + s.execute("edit.pasteInto", &json!({})).unwrap(); + s.set_tool("selection"); + s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 140.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 140.0), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.items, vec![designcraft_doc::ItemId(holder)]); + assert!(!s.doc().unwrap().selection.content); + s.set_tool("directSelection"); + s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 140.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 140.0), v).unwrap(); + let st = s.doc().unwrap(); + assert!(st.selection.content && st.selection.items == [designcraft_doc::ItemId(holder)], "the pasted-in items are selected: {:?}", st.selection); +} + /// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key /// object, and Align to Key Object leaves it in place. A second click on it drops the key. #[test] diff --git a/crates/engine/src/tests/idml.rs b/crates/engine/src/tests/idml.rs index e8766d091..409f6bdf8 100644 --- a/crates/engine/src/tests/idml.rs +++ b/crates/engine/src/tests/idml.rs @@ -516,6 +516,7 @@ fn type_para_sets_a_drop_cap_that_survives_idml() { assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")])); } +/// Imported TOC definitions retain their settings and remain refreshable. #[test] fn table_of_contents_definition_round_trips_through_idml_and_updates() { let mut s = Session::new(); @@ -525,8 +526,8 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() { let basic = designcraft_doc::story::BASIC_PARAGRAPH; s.execute( "toc.generate", - &json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [ - {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "}, + &json!({"page": page, "title": "Inside", "titleStyle": basic, "createBookmarks": true, "entries": [ + {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ \u{E006} "}, {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, ]}), ) @@ -540,6 +541,7 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() { let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); s.execute("file.openIdml", &json!({"base64": b64, "name": "toc.idml"})).unwrap(); let after = s.doc().unwrap().doc.toc.clone().expect("the TOC definition is imported"); + assert!(after.create_bookmarks); assert_eq!((&after.title, &after.title_style, &after.entries), (&before.title, &before.title_style, &before.entries)); assert_eq!(toc_text(&s), text); s.execute("toc.update", &json!({})).unwrap(); diff --git a/crates/engine/src/tests/table.rs b/crates/engine/src/tests/table.rs index ecf0b9bbe..77bcb9bfd 100644 --- a/crates/engine/src/tests/table.rs +++ b/crates/engine/src/tests/table.rs @@ -1,6 +1,7 @@ -use serde_json::json; +use serde_json::{Value, json}; use super::{Session, doc}; +use crate::Result; fn session_with_frame() -> (Session, u64, u64) { let mut s = Session::new(); @@ -127,6 +128,72 @@ fn table_edit_after_anchor_removal_floors_parent_caret() { } } +/// Footnote text is addressed as a cell, but the table commands don't take it for one. +#[test] +fn the_cell_commands_refuse_footnote_text() { + let (mut s, sid, _) = session_with_frame(); + s.execute("story.setText", &json!({"story": sid, "text": "Hello"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5, "focus": 5})).unwrap(); + s.execute("footnote.insert", &json!({"text": "Note"})).unwrap(); + let before = s.doc().unwrap().selection.clone(); + assert!(before.text.and_then(|t| t.cell).and_then(|c| c.footnote_id()).is_some(), "the caret is in the footnote"); + for cmd in ["table.toggleSelection", "table.prevCell", "table.nextCell"] { + assert!(s.execute(cmd, &json!({})).is_err(), "{cmd}"); + } + assert_eq!(s.doc().unwrap().selection, before); + // A Tab there is typed into the footnote, not taken for the next cell. + s.execute("text.insert", &json!({"text": "\t"})).unwrap(); + let notes = s.execute("footnote.list", &json!({})).unwrap(); + assert!(notes[0]["text"].as_str().is_some_and(|t| t.contains('\t')), "{notes}"); +} + +/// Esc in a table toggles between the text and the cell (InDesign's Esc): the caret in a cell +/// selects that cell, and selected cells select the first one's text; Shift+Tab goes back a cell. +#[test] +fn escape_toggles_text_and_cell_selection() { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Phone"})).unwrap(); + let cell = designcraft_doc::CellAddr { table: tid, row: 0, col: 1 }; + assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": true})); + let sel = s.doc().unwrap().selection.clone(); + let cells = sel.cells.unwrap(); + assert_eq!((cells.story.0, cells.table, cells.range), (sid, tid, designcraft_doc::CellRange::cell(0, 1))); + assert_eq!(sel.text.unwrap().cell, Some(cell)); + assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": false})); + let sel = s.doc().unwrap().selection.clone(); + assert!(sel.cells.is_none()); + let t = sel.text.unwrap(); + assert_eq!((t.cell, t.range()), (Some(cell), 0..5), "the cell's text is selected"); + s.execute("table.prevCell", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { col: 0, ..cell })); + // With the cell selected, typing replaces its text and leaves the caret after it. + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("table.toggleSelection", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Fax"})).unwrap(); + let t = s.execute("table.get", &json!({"table": tid})).unwrap(); + assert_eq!(t["cells"][1]["text"], "Fax"); + let sel = s.doc().unwrap().selection.clone(); + assert!(sel.cells.is_none()); + assert_eq!(sel.text.unwrap().range(), 3..3); + // Moving the caret leaves the cell selection: typing inserts at the caret, not over the cell. + s.execute("table.toggleSelection", &json!({})).unwrap(); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + assert!(s.doc().unwrap().selection.cells.is_none()); + s.execute("text.insert", &json!({"text": "!"})).unwrap(); + assert_eq!(s.execute("table.get", &json!({"table": tid})).unwrap()["cells"][1]["text"], "F!ax"); + // A selection that starts inside a merged cell goes back to the merged cell's text. + s.execute("table.select", &json!({"rows": [0, 1], "cols": [0, 0]})).unwrap(); + s.execute("table.merge", &json!({})).unwrap(); + s.execute("table.select", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap(); + s.execute("table.toggleSelection", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { row: 0, col: 0, ..cell })); + // Outside a table there is nothing to toggle. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert!(s.execute("table.toggleSelection", &json!({})).is_err()); +} + fn selected_merged_table(column: bool) -> (Session, u64, u64, u64) { let (mut s, sid, fid) = session_with_frame(); let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); @@ -426,6 +493,29 @@ fn rows_columns_merge_and_options() { assert_eq!(st.doc.story(sid).unwrap().text, "Intro"); } +/// `table.insert` styles the table in the same step; a style that isn't there, or isn't a name, +/// leaves the story alone. Its dimensions stop at the documented limits. +#[test] +fn insert_table_with_a_style_is_one_step() { + let (mut s, sid, _) = session_with_frame(); + s.execute("style.table.create", &json!({"name": "Grid"})).unwrap(); + for bad in [json!("Missing"), json!(7)] { + assert!(s.execute("table.insert", &json!({"tableStyle": bad})).is_err(), "{bad}"); + } + assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty()); + for over in [json!({"rows": 501}), json!({"cols": 201}), json!({"headerRows": 101})] { + assert!(s.execute("table.insert", &over).is_err(), "{over}"); + } + let r = s.execute("table.insert", &json!({"rows": 500, "cols": 200, "headerRows": 100, "tableStyle": "Grid"})).unwrap(); + let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap(); + assert_eq!(t["style"], "Grid"); + let n = |k: &str| t[k].as_array().unwrap().len() as u64; + use crate::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS}; + assert_eq!((n("rows"), n("columns")), (MAX_BODY_ROWS + MAX_HEADER_FOOTER_ROWS, MAX_COLUMNS)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty(), "one undo"); +} + #[test] fn convert_text_to_table_and_back() { let (mut s, sid, _) = session_with_frame(); @@ -444,6 +534,74 @@ fn convert_text_to_table_and_back() { assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Ink\tCoverage\nPlum\t80%\nSunset\t45%\nAfter"); } +/// InDesign's Convert Text to Table and Convert Table to Text separators: tab, comma, paragraph or +/// any characters; with one separator for both, rows of Number of Columns cells; a Table Style. +#[test] +fn convert_text_to_table_with_indesigns_separators() { + let (mut s, sid, _) = session_with_frame(); + let convert = |s: &mut Session, text: &str, p: Value| -> Result { + s.execute("story.setText", &json!({"story": sid, "text": format!("{text}\nAfter")})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + s.execute("table.convertFromText", &p) + }; + let cells = |s: &mut Session, r: &Value| -> (usize, Vec) { + let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap(); + let texts = t["cells"].as_array().unwrap().iter().map(|c| c["text"].as_str().unwrap().to_string()).collect(); + (t["columns"].as_array().unwrap().len(), texts) + }; + // Comma-separated columns, paragraphs for rows. + let r = convert(&mut s, "Ink, Coverage\nPlum, 80%", json!({"columnSeparator": "comma"})).unwrap(); + assert_eq!(cells(&mut s, &r), (2, vec!["Ink".into(), "Coverage".into(), "Plum".into(), "80%".into()])); + check(&s); + // The lesson's list: one paragraph per cell; with two columns, two cells a row. + let list = "Name:\nAddress:\nCity:\nState:"; + let r = convert(&mut s, list, json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap(); + assert_eq!(r["rows"], 2); + assert_eq!(cells(&mut s, &r).0, 2); + let r = convert(&mut s, list, json!({"columnSeparator": "tab"})).unwrap(); + assert_eq!((r["rows"].as_u64(), cells(&mut s, &r).0), (Some(4), 1), "a column of paragraphs"); + // Any characters separate: here " | " between columns and ";" between rows. + let r = convert(&mut s, "a | b;c | d", json!({"columnSeparator": " | ", "rowSeparator": ";"})).unwrap(); + assert_eq!(cells(&mut s, &r), (2, vec!["a".into(), "b".into(), "c".into(), "d".into()])); + // A table style is applied to the new table. + s.execute("style.table.create", &json!({"name": "Grid"})).unwrap(); + let r = convert(&mut s, "x\ty", json!({"tableStyle": "Grid"})).unwrap(); + assert_eq!(s.execute("table.get", &json!({"table": r["table"]})).unwrap()["style"], "Grid"); + // A short last row: its padding cell takes the text's format too. + let r = convert(&mut s, "a\nb\nc", json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap(); + let t = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables[&r["table"].as_u64().unwrap()].clone(); + let fmt = |c: usize| t.cell(1, c).unwrap().text.chars[0].format.clone(); + assert_eq!(fmt(1), fmt(0), "the padding cell's format is the text's"); + for bad in [ + json!({"columnSeparator": ""}), + json!({"rowSeparator": 3}), + json!({"columns": 0}), + json!({"columns": crate::cmd::table::MAX_COLUMNS + 1}), + json!({"tableStyle": "Missing"}), + json!({"tableStyle": 123}), + ] { + assert!(convert(&mut s, "p\tq", bad.clone()).is_err(), "{bad}"); + } + // Text that would make too wide or too big a table is refused, and stays as it was. + use crate::cmd::table::{MAX_COLUMNS, MAX_CONVERTED_CELLS}; + let wide = vec!["x"; MAX_COLUMNS as usize + 1].join("\t"); + assert!(convert(&mut s, &wide, json!({})).is_err(), "a row wider than a table can be"); + let long = vec!["a\tb"; MAX_CONVERTED_CELLS as usize / 2 + 1].join("\n"); + assert!(convert(&mut s, &long, json!({})).is_err(), "more cells than a table can have"); + assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, format!("{long}\nAfter")); + let tall = vec!["a"; designcraft_doc::MAX_TABLE_ROWS + 1].join("\n"); + assert!(convert(&mut s, &tall, json!({})).is_err(), "more rows than a table can have"); + assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, format!("{tall}\nAfter"), "no text is dropped"); + let at_most = vec!["x"; MAX_COLUMNS as usize].join("\t"); + assert!(convert(&mut s, &at_most, json!({})).is_ok(), "a row as wide as a table can be"); + check(&s); + // Back to text with a comma between columns and a tab between rows. + let r = convert(&mut s, "Plum\t80%\nSunset\t45%", json!({})).unwrap(); + s.execute("table.convertToText", &json!({"table": r["table"], "columnSeparator": "comma", "rowSeparator": "tab"})).unwrap(); + check(&s); + assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Plum,80%\tSunset,45%\nAfter"); +} + #[test] fn caret_placement_in_cells_and_cell_formatting() { let (mut s, sid, fid) = session_with_frame(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c982cade7..2badc3a17 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -110,6 +110,7 @@ impl Session { snap: view.snap, unit: view.unit, stroke_in_front: view.stroke_in_front, + loaded: self.loaded.map(|l| l.size), }; Some(f(self, &cx)) } diff --git a/crates/engine/tests/it/insert_pages_bounds.rs b/crates/engine/tests/it/insert_pages_bounds.rs index e96a6cc4b..5f8856246 100644 --- a/crates/engine/tests/it/insert_pages_bounds.rs +++ b/crates/engine/tests/it/insert_pages_bounds.rs @@ -1,4 +1,5 @@ -//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing. +//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing; a +//! `before` must name a page, and one that doesn't is refused without a change. use designcraft_engine::Session; use serde_json::json; @@ -14,3 +15,13 @@ fn insert_after_u64_max_appends_at_the_end() { assert_eq!(after[..2], before[..], "the original pages must stay first; new pages go at the end"); assert!(after[2..].iter().all(|id| !before.contains(id))); } + +#[test] +fn insert_before_u64_max_is_refused_without_overflow() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages":2})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(s.execute("layout.pages.insert", &json!({"before": u64::MAX, "count": 2})).is_err()); + assert_eq!(s.doc().unwrap().doc.page_count(), 2, "no pages added"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); +} diff --git a/crates/epub/src/css.rs b/crates/epub/src/css.rs index 526b10a40..f066d6b3d 100644 --- a/crates/epub/src/css.rs +++ b/crates/epub/src/css.rs @@ -40,23 +40,7 @@ pub(crate) fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: let (weight, style) = css_weight_style(&c.font_style); let _ = write!(out, "font-weight: {weight}; font-style: {style}; "); let _ = write!(out, "font-size: {:.3}em; ", c.size / base_size); - if c.tracking != 0.0 { - let _ = write!(out, "letter-spacing: {:.3}em; ", c.tracking / 1000.0); - } - match c.capitalization { - designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "), - designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => { - out.push_str("font-variant: small-caps; ") - } - _ => {} - } - // One declaration: a later `text-decoration` would replace the earlier one. - match (c.underline, c.strikethrough) { - (true, true) => out.push_str("text-decoration: underline line-through; "), - (true, false) => out.push_str("text-decoration: underline; "), - (false, true) => out.push_str("text-decoration: line-through; "), - (false, false) => {} - } + decor_css(c.tracking, c.capitalization, c.underline, c.strikethrough, out); if let Some(h) = hex(doc, &c.fill, c.fill_tint) { let _ = write!(out, "color: {h}; "); } @@ -150,6 +134,7 @@ pub(crate) fn stylesheet_with(doc: &Document, generics: &BTreeMap String { +/// Tracking, capitalization and line decorations of a style or override, as CSS declarations. +fn attrs_decor_css(c: &CharAttrs, out: &mut String) { + decor_css(c.tracking.unwrap_or(0.0), c.capitalization.unwrap_or_default(), c.underline == Some(true), c.strikethrough == Some(true), out); +} + +/// Tracking, capitalization and line decorations as CSS declarations; defaults write nothing. +fn decor_css(tracking: f64, caps: designcraft_doc::Capitalization, underline: bool, strikethrough: bool, out: &mut String) { + if tracking != 0.0 { + let _ = write!(out, "letter-spacing: {:.3}em; ", tracking / 1000.0); + } + match caps { + designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "), + designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => { + out.push_str("font-variant: small-caps; ") + } + _ => {} + } + // One declaration: a later `text-decoration` would replace the earlier one. + match (underline, strikethrough) { + (true, true) => out.push_str("text-decoration: underline line-through; "), + (true, false) => out.push_str("text-decoration: underline; "), + (false, true) => out.push_str("text-decoration: line-through; "), + (false, false) => {} + } +} + +/// Inline CSS for the local override `o` of a run in character style `style`. Tracking, caps and +/// line decorations are resolved against the style (and its Based On chain), so an override that +/// sets only one of underline/strikethrough keeps the other, and one that clears a value the +/// style sets writes the reset. +pub(crate) fn override_css(doc: &Document, style: &str, o: &CharAttrs) -> String { let mut s = String::new(); if let Some(f) = &o.font_style { let f = f.to_lowercase(); @@ -174,11 +189,23 @@ pub(crate) fn override_css(doc: &Document, o: &CharAttrs) -> String { { let _ = write!(s, "color: {h}; "); } - if o.underline == Some(true) { - s.push_str("text-decoration: underline; "); + let base = doc.styles.char_style_attrs(style); + let mut merged = base.clone(); + merged.merge(o); + let tracking = if o.tracking.is_some() { merged.tracking.unwrap_or(0.0) } else { 0.0 }; + let caps = if o.capitalization.is_some() { merged.capitalization.unwrap_or_default() } else { Default::default() }; + let lines = o.underline.is_some() || o.strikethrough.is_some(); + let (underline, strikethrough) = (lines && merged.underline == Some(true), lines && merged.strikethrough == Some(true)); + if o.tracking.is_some() && tracking == 0.0 && base.tracking.is_some_and(|t| t != 0.0) { + s.push_str("letter-spacing: 0em; "); } - if o.capitalization == Some(designcraft_doc::Capitalization::AllCaps) { - s.push_str("text-transform: uppercase; "); + // The class rule's other caps property would still apply, so clear both before the new mode. + if o.capitalization.is_some() && caps != base.capitalization.unwrap_or_default() && base.capitalization.is_some_and(|c| c != Default::default()) { + s.push_str("text-transform: none; font-variant: normal; "); + } + decor_css(tracking, caps, underline, strikethrough, &mut s); + if lines && !underline && !strikethrough && (base.underline == Some(true) || base.strikethrough == Some(true)) { + s.push_str("text-decoration: none; "); } s.trim_end().to_string() } diff --git a/crates/epub/src/story_writer.rs b/crates/epub/src/story_writer.rs index aed443d0b..40c95f135 100644 --- a/crates/epub/src/story_writer.rs +++ b/crates/epub/src/story_writer.rs @@ -223,7 +223,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> { } else { ("span", String::new()) }; - let style = override_css(doc, &f.over); + let style = override_css(doc, &f.style, &f.over); let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) }; if cls.is_empty() && style_attr.is_empty() { out.push_str(&t); diff --git a/crates/epub/src/tests.rs b/crates/epub/src/tests.rs index f216fe95f..4b0531967 100644 --- a/crates/epub/src/tests.rs +++ b/crates/epub/src/tests.rs @@ -922,3 +922,58 @@ fn char_css_joins_underline_and_strikethrough_in_one_declaration() { assert!(css(false, true).contains("text-decoration: line-through;")); assert!(!css(false, false).contains("text-decoration")); } + +#[test] +fn character_styles_keep_decorations_caps_and_tracking() { + let mut d = Document::new(&NewDocument::default()); + d.styles_mut().character.push(designcraft_doc::CharacterStyle { + name: "Under".into(), + based_on: None, + chars: CharAttrs { + underline: Some(true), + strikethrough: Some(true), + capitalization: Some(designcraft_doc::Capitalization::AllCaps), + tracking: Some(100.0), + ..Default::default() + }, + shortcut: String::new(), + }); + let css = css::stylesheet(&d); + assert!(css.contains("text-decoration: underline line-through;"), "{css}"); + assert!(css.contains("text-transform: uppercase;"), "{css}"); + assert!(css.contains("letter-spacing: 0.100em;"), "{css}"); + let o = CharAttrs { strikethrough: Some(true), ..Default::default() }; + assert_eq!(css::override_css(&d, story::NO_CHAR_STYLE, &o), "text-decoration: line-through;"); +} + +fn char_style(name: &str, based_on: Option<&str>, chars: CharAttrs) -> designcraft_doc::CharacterStyle { + designcraft_doc::CharacterStyle { name: name.into(), based_on: based_on.map(Into::into), chars, shortcut: String::new() } +} + +#[test] +fn override_keeps_style_decorations_and_resets_tracking_and_caps() { + use designcraft_doc::Capitalization; + let mut d = Document::new(&NewDocument::default()); + let styles = d.styles_mut(); + styles.character.push(char_style( + "Base", + None, + CharAttrs { underline: Some(true), tracking: Some(100.0), capitalization: Some(Capitalization::AllCaps), ..Default::default() }, + )); + styles.character.push(char_style("Child", Some("Base"), CharAttrs::default())); + let strike = CharAttrs { strikethrough: Some(true), ..Default::default() }; + for style in ["Base", "Child"] { + assert_eq!(css::override_css(&d, style, &strike), "text-decoration: underline line-through;", "{style}"); + } + let off = CharAttrs { underline: Some(false), ..Default::default() }; + assert_eq!(css::override_css(&d, "Child", &off), "text-decoration: none;"); + let reset = CharAttrs { tracking: Some(0.0), capitalization: Some(Capitalization::Normal), ..Default::default() }; + assert_eq!(css::override_css(&d, "Child", &reset), "letter-spacing: 0em; text-transform: none; font-variant: normal;"); + assert_eq!(css::override_css(&d, story::NO_CHAR_STYLE, &reset), ""); + let styles = d.styles_mut(); + styles.character.push(char_style("Small", None, CharAttrs { capitalization: Some(Capitalization::SmallCaps), ..Default::default() })); + let small = CharAttrs { capitalization: Some(Capitalization::SmallCaps), ..Default::default() }; + let all = CharAttrs { capitalization: Some(Capitalization::AllCaps), ..Default::default() }; + assert_eq!(css::override_css(&d, "Base", &small), "text-transform: none; font-variant: normal; font-variant: small-caps;"); + assert_eq!(css::override_css(&d, "Small", &all), "text-transform: none; font-variant: normal; text-transform: uppercase;"); +} diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index 882f5e6b8..d4b838cf2 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -555,6 +555,14 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { underline_offset: Some(Some(1.0)), strikethrough_weight: Some(Some(2.0)), strikethrough_offset: Some(Some(1.0)), + kenten_size: Some(Some(14.0)), + kenten_fill_tint: Some(Some(0.3)), + kenten_stroke_tint: Some(Some(0.4)), + kenten_stroke_weight: Some(Some(2.0)), + ruby_font_size: Some(Some(14.0)), + ruby_fill_tint: Some(Some(0.3)), + ruby_stroke_tint: Some(Some(0.4)), + ruby_stroke_weight: Some(Some(2.0)), ..Default::default() }; // Legacy JSON loads as format 1, whose upgrade pins Keep Lines Together and Align Left @@ -564,6 +572,7 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { start_at: Some(Some(7)), paragraph_kashida_width: Some(Some(0.5)), kinsoku: Some(Some(Default::default())), + list_tab: Some(Some(36.0)), keep_all_lines: Some(true), drop_cap_align_left: Some(true), ..Default::default() @@ -578,9 +587,18 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { underline_offset: Some(None), strikethrough_weight: Some(None), strikethrough_offset: Some(None), + kenten_size: Some(None), + kenten_fill_tint: Some(None), + kenten_stroke_tint: Some(None), + kenten_stroke_weight: Some(None), + ruby_font_size: Some(None), + ruby_fill_tint: Some(None), + ruby_stroke_tint: Some(None), + ruby_stroke_weight: Some(None), ..Default::default() }; - let clear_para = ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), ..Default::default() }; + let clear_para = + ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), list_tab: Some(None), ..Default::default() }; let selected = if style_clear { std::sync::Arc::make_mut(&mut d.styles).paragraph.push(designcraft_doc::styles::ParagraphStyle { name: "Defaults".into(), @@ -621,6 +639,15 @@ fn r7a_assert_nullable_defaults_survive(d: &Document) { assert_eq!(expected.0.paragraph_kashida_width, None); assert_eq!(expected.0.kinsoku, None); assert_eq!(expected.1.leading_aki, None); + assert_eq!(expected.0.list_tab, None); + assert_eq!(expected.1.kenten_size, None); + assert_eq!(expected.1.kenten_fill_tint, None); + assert_eq!(expected.1.kenten_stroke_tint, None); + assert_eq!(expected.1.kenten_stroke_weight, None); + assert_eq!(expected.1.ruby_font_size, None); + assert_eq!(expected.1.ruby_fill_tint, None); + assert_eq!(expected.1.ruby_stroke_tint, None); + assert_eq!(expected.1.ruby_stroke_weight, None); // Legacy JSON is format 1: its upgrade pins older defaults on the other styles, so only // the native ZIP compares as the whole document. for (bytes, native) in [(save(d).unwrap(), true), (serde_json::to_vec(d).unwrap(), false)] { diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs index cc47fadb2..738be420f 100644 --- a/crates/geom/src/units.rs +++ b/crates/geom/src/units.rs @@ -258,12 +258,13 @@ fn parse_single(tok: &str, default: Unit, percent_base: Option) -> Option String { - // As InDesign shows them: inches to four decimals (a 1p gutter is 0.1667 in, A4 is - // 8.2677 in wide), the other units to three. - let decimals = if matches!(unit, Unit::Inches | Unit::InchesDecimal) { 4 } else { 3 }; + // As InDesign shows them: inches and centimetres to four decimals (a 1p gutter is 0.1667 in + // or 0.4233 cm, A4 is 8.2677 in wide), the other units to three. + let decimals = if matches!(unit, Unit::Inches | Unit::InchesDecimal | Unit::Centimeters) { 4 } else { 3 }; format_measure_prec(pt, unit, decimals) } @@ -323,14 +324,18 @@ mod tests { assert_eq!(format_measure(-12.0, Unit::Picas), "-1p0"); } - /// Inches show four decimals, as InDesign's fields do; other units three. + /// Inches and centimetres show four decimals, as InDesign's fields do; other units three. #[test] - fn inches_show_four_decimals() { + fn inches_and_centimeters_show_four_decimals() { assert_eq!(format_measure(12.0, Unit::Inches), "0.1667 in"); assert_eq!(format_measure(210.0 * PT_PER_MM, Unit::Inches), "8.2677 in"); assert_eq!(format_measure(612.0, Unit::Inches), "8.5 in"); assert_eq!(format_measure(9.0, Unit::Inches), "0.125 in"); assert_eq!(format_measure(10.0, Unit::Millimeters), "3.528 mm"); + // A 1p gutter and a frame's height, as InDesign's New Document and Transform fields show them. + assert_eq!(format_measure(12.0, Unit::Centimeters), "0.4233 cm"); + assert_eq!(format_measure(17.1125 / 2.54 * 72.0, Unit::Centimeters), "17.1125 cm"); + assert_eq!(format_measure(21.0 / 2.54 * 72.0, Unit::Centimeters), "21 cm"); assert_eq!(format_measure(10.0, Unit::Points), "10 pt"); } diff --git a/crates/idml/src/export/item.rs b/crates/idml/src/export/item.rs index 5ed93879b..371718b65 100644 --- a/crates/idml/src/export/item.rs +++ b/crates/idml/src/export/item.rs @@ -220,6 +220,7 @@ impl<'a> Ex<'a> { .attr("Size", num(ds.size)) .attr("Spread", num(ds.spread)) .attr("Opacity", pct(ds.opacity as f64)) + .attr("BlendMode", names::blend_out(ds.mode)) .attr("EffectColor", self.sw(&ds.color)), ); } diff --git a/crates/idml/src/export/styles.rs b/crates/idml/src/export/styles.rs index 9435b533c..bf156c680 100644 --- a/crates/idml/src/export/styles.rs +++ b/crates/idml/src/export/styles.rs @@ -302,7 +302,7 @@ impl<'a> Ex<'a> { .attr("RunIn", "false") .attr("IncludeHidden", "false") .attr("IncludeBookDocuments", "false") - .attr("CreatePdfBookmarks", "false"); + .attr("CreateBookmarks", if toc.create_bookmarks { "true" } else { "false" }); for e in &toc.entries { let format = e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", e.level)); let position = match e.page_number { @@ -335,6 +335,17 @@ impl<'a> Ex<'a> { el.set(&format!("{k}Offset"), num(r.offset)); el.set(&format!("{k}LeftIndent"), num(r.left_indent)); el.set(&format!("{k}RightIndent"), num(r.right_indent)); + el.set(&format!("{k}Type"), names::stroke_type_out(&r.kind)); + // A gap in Text Color is a string property, as the rule's own colour is. + if r.gap_color == designcraft_doc::TEXT_COLOR { + props.push(p(&format!("{k}GapColor"), "string", designcraft_doc::TEXT_COLOR)); + } else { + el.set(&format!("{k}GapColor"), self.sw(&r.gap_color)); + } + el.set(&format!("{k}GapTint"), pct(r.gap_tint as f64)); + if k == "RuleAbove" { + el.set("KeepRuleAboveInFrame", bool_s(r.keep_in_frame)); + } props.push(if r.color == designcraft_doc::TEXT_COLOR { p(&format!("{k}Color"), "string", designcraft_doc::TEXT_COLOR) } else { diff --git a/crates/idml/src/export/text_attrs.rs b/crates/idml/src/export/text_attrs.rs index 38acf0d2a..1c0def755 100644 --- a/crates/idml/src/export/text_attrs.rs +++ b/crates/idml/src/export/text_attrs.rs @@ -22,6 +22,13 @@ macro_rules! b { }; } +/// The Overprint switches of a paragraph's shading, border and border gap. +fn para_box_overprints(el: &mut El, a: &ParaAttrs) { + b!(a, el, shading_overprint, "ParagraphShadingOverprint"); + b!(a, el, border_overprint, "ParagraphBorderOverprint"); + b!(a, el, border_gap_overprint, "ParagraphBorderGapOverprint"); +} + impl<'a> Ex<'a> { pub(super) fn char_attrs(&mut self, el: &mut El, props: &mut Vec, a: &CharAttrs) { if let Some(v) = a.leading_aki { @@ -494,6 +501,7 @@ impl<'a> Ex<'a> { props.push(p("ParagraphShadingColor", "object", self.sw(c))); } n!(a, el, shading_tint, "ParagraphShadingTint", pct); + para_box_overprints(el, a); const SIDES: [&str; 4] = ["Top", "Left", "Bottom", "Right"]; if let Some(o) = a.shading_offsets { for (i, side) in SIDES.iter().enumerate() { diff --git a/crates/idml/src/import/document.rs b/crates/idml/src/import/document.rs index dce466381..4c048f637 100644 --- a/crates/idml/src/import/document.rs +++ b/crates/idml/src/import/document.rs @@ -313,10 +313,12 @@ impl<'r> Importer<'r> { let story = top.iter().find(|e| e.local() == "Story" && e.get("AppliedTOCStyle") == Some(id))?; Some((ts, *self.story_ids.get(story.get("Self")?)?)) })?; - let known = |r: Option<&str>| r.map(|r| self.para_style_ref(r)).filter(|n| self.styles.para(n).is_some()); + // Property children are element text: formatting whitespace around a reference is dropped + // unless the reference as written names a style. + let known = |r: Option<&str>| r.map(|r| self.listed_para_style_ref(r)).filter(|n| self.styles.para(n).is_some()); // Character styles the document has; [None] is stored as no style. let known_chars = |r: Option<&str>| { - r.map(|r| self.char_style_ref(r)).filter(|n| n != designcraft_doc::NO_CHAR_STYLE && self.styles.char_style(n).is_some()) + r.map(|r| self.listed_char_style_ref(r)).filter(|n| n != designcraft_doc::NO_CHAR_STYLE && self.styles.char_style(n).is_some()) }; let entries: Vec = ts .elements() @@ -333,9 +335,10 @@ impl<'r> Importer<'r> { _ => TocPageNumber::After, }; let separator = e.get("Separator").map(crate::names::toc_separator_in).filter(|s| s != "\t" && s.chars().count() <= 64); - let entry_style = known(e.get("FormatStyle")).filter(|n| *n != format!("TOC Level {level}")); - let page_number_style = known_chars(e.get("PageNumberStyle")); - let separator_style = known_chars(e.get("SeparatorStyle")); + // InDesign writes these as `Properties` children; attributes are read too. + let entry_style = known(e.prop("FormatStyle").as_deref()).filter(|n| *n != format!("TOC Level {level}")); + let page_number_style = known_chars(e.prop("PageNumberStyle").as_deref()); + let separator_style = known_chars(e.prop("SeparatorStyle").as_deref()); let sort = e.get("Sort").is_some_and(|v| v.trim() == "true"); Some(TocEntry { style, level, entry_style, page_number, separator, page_number_style, separator_style, sort }) }) @@ -346,6 +349,7 @@ impl<'r> Importer<'r> { } let title: String = ts.get("Title").unwrap_or("").chars().filter(|c| !matches!(c, '\n' | '\r' | '\u{2029}')).take(1024).collect(); let title_style = known(ts.get("TitleStyle")).filter(|n| n != "TOC Title"); - Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style }) + let create_bookmarks = ts.get("CreateBookmarks").or_else(|| ts.get("CreatePdfBookmarks")) == Some("true"); + Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style, create_bookmarks }) } } diff --git a/crates/idml/src/import/object_style.rs b/crates/idml/src/import/object_style.rs index bcdaedb66..343e67211 100644 --- a/crates/idml/src/import/object_style.rs +++ b/crates/idml/src/import/object_style.rs @@ -111,6 +111,9 @@ impl<'r> Importer<'r> { size: ds.num("Size").unwrap_or(def.size), spread: ds.num("Spread").unwrap_or(def.spread), global_light: false, + // Without the attribute (earlier DesignCraft exports) the shadow stays Normal, as it + // rendered then; a new shadow's default is Multiply. + mode: ds.get("BlendMode").map_or(designcraft_color::BlendMode::Normal, names::blend_in), }; } if let Some(f) = t.find("FeatherSetting") diff --git a/crates/idml/src/import/spread.rs b/crates/idml/src/import/spread.rs index e2caa6b18..ca9a3d746 100644 --- a/crates/idml/src/import/spread.rs +++ b/crates/idml/src/import/spread.rs @@ -70,7 +70,7 @@ impl<'r> Importer<'r> { } /// A page's margins and columns, and whether they are local (differ from its parent page's). - fn page_margins(&self, p: &El, r: &Rect, master: bool, parent: Option, side: PageSide) -> (Margins, Columns, bool) { + fn page_margins(&self, p: &El, r: &Rect, master: bool, parent: Option, (side, index): (PageSide, usize)) -> (Margins, Columns, bool) { // A document page's margins are local when they differ from its parent page's; a // page without its own takes the parent page's. let from_parent = if master { @@ -78,7 +78,7 @@ impl<'r> Importer<'r> { } else { parent .and_then(|id| self.parents.iter().find(|sp| sp.id == id)) - .and_then(|sp| sp.pages.get(sp.parent_page_for_side(side)?)) + .and_then(|sp| sp.pages.get(sp.parent_page_at(side, index)?)) .map(|q| (q.margins, q.columns.clone())) }; let (margins, columns) = match (p.find("MarginPreference"), &from_parent) { @@ -126,7 +126,7 @@ impl<'r> Importer<'r> { PageSide::Right }; let parent = p.get("AppliedMaster").and_then(|m| self.parent_ids.get(m)).copied(); - let (margins, columns, local_margins) = self.page_margins(p, r, master, parent, side); + let (margins, columns, local_margins) = self.page_margins(p, r, master, parent, (side, i)); out_pages.push(Page { id: pid, width: r.width(), diff --git a/crates/idml/src/import/styles.rs b/crates/idml/src/import/styles.rs index 5719393a2..fd40a6d96 100644 --- a/crates/idml/src/import/styles.rs +++ b/crates/idml/src/import/styles.rs @@ -223,6 +223,11 @@ impl<'r> Importer<'r> { self.para_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(PARA_BUILTINS, &unescape_id(r.trim_start_matches("ParagraphStyle/")))) } + /// A paragraph style reference written as an element's text: as written when it names a + /// style, otherwise without surrounding whitespace (see [`Self::listed_char_style_ref`]). + pub(super) fn listed_para_style_ref(&self, r: &str) -> String { + if self.para_names.contains_key(r) { self.para_style_ref(r) } else { self.para_style_ref(r.trim()) } + } /// A character style reference written as an element's text (nested, line and GREP styles). /// A style name may end in a space, so the reference is used as written when it names a /// style; only otherwise is surrounding whitespace dropped. @@ -238,7 +243,12 @@ impl<'r> Importer<'r> { // A style or range can set some of a rule's settings (such as only its colour); the others // come from its paragraph style chain, so the rule is whole. let fields = ["", "LineWeight", "Color", "Tint", "Width", "Offset", "LeftIndent", "RightIndent"]; - if !fields.iter().any(|f| e.prop(&format!("{k}{f}")).is_some()) { + // Any of them, or of the type, gap and Keep In Frame settings, makes it an override (the + // rest then come from the style chain, or the defaults: off, 1 pt). + let more = ["Type", "GapColor", "GapTint"]; + let own = fields.iter().chain(&more).any(|f| e.prop(&format!("{k}{f}")).is_some()) + || (k == "RuleAbove" && e.prop("KeepRuleAboveInFrame").is_some()); + if !own { return None; } let v: Vec> = fields.iter().map(|f| self.para_setting(e, &format!("{k}{f}"))).collect(); @@ -246,7 +256,6 @@ impl<'r> Importer<'r> { let num = |i: usize| get(i).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); let on = get(0).map(|v| v.trim() == "true"); let weight = num(1); - on.or(weight.map(|_| false))?; let mut r = Rule { on: on.unwrap_or(false), ..Rule::default() }; if let Some(w) = weight { r.weight = w; @@ -258,17 +267,38 @@ impl<'r> Importer<'r> { r.offset = num(5).unwrap_or(0.0); r.left_indent = num(6).unwrap_or(0.0); r.right_indent = num(7).unwrap_or(0.0); - if let Some(c) = get(2) { - let c = c.trim(); - if c == designcraft_doc::TEXT_COLOR { - r.color = c.to_string(); - } else if c.contains('/') { - r.color = self.swatch_ref(c); - } + // Type, gap colour and tint, Keep In Frame (rule above). + if let Some(t) = self.para_setting(e, &format!("{k}Type")) { + r.kind = names::stroke_type_in(t.trim()); + } + if let Some(c) = self.para_setting(e, &format!("{k}GapColor")) + && let Some(c) = self.rule_color_ref(&c) + { + r.gap_color = c; + } + if let Some(t) = tint(self.para_setting(e, &format!("{k}GapTint")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite())) { + r.gap_tint = t; + } + if k == "RuleAbove" { + r.keep_in_frame = self.para_setting(e, "KeepRuleAboveInFrame").is_some_and(|v| v.trim() == "true"); + } + if let Some(c) = get(2).and_then(|c| self.rule_color_ref(&c)) { + r.color = c; } Some(r) } + /// A rule's colour or gap colour: Text Color (written `Text Color` or `$ID/Text Color`), or + /// a swatch reference; anything else leaves the default. + fn rule_color_ref(&mut self, c: &str) -> Option { + let c = c.trim(); + if c == designcraft_doc::TEXT_COLOR || c.strip_prefix("$ID/") == Some(designcraft_doc::TEXT_COLOR) { + Some(designcraft_doc::TEXT_COLOR.to_string()) + } else { + c.contains('/').then(|| self.swatch_ref(c)) + } + } + /// Paragraph setting `k` of `e` (a paragraph style or a range): its own, else from its /// paragraph style chain (`BasedOn` of a style, the applied style of a range). fn para_setting(&self, e: &El, k: &str) -> Option { diff --git a/crates/idml/src/import/text_attrs.rs b/crates/idml/src/import/text_attrs.rs index 323c14b17..21ad2a13c 100644 --- a/crates/idml/src/import/text_attrs.rs +++ b/crates/idml/src/import/text_attrs.rs @@ -304,6 +304,7 @@ impl<'r> Importer<'r> { a.shading_color = Some(self.swatch_ref(c.trim())); } a.shading_tint = tint(e.num("ParagraphShadingTint")); + a.shading_overprint = e.boolean("ParagraphShadingOverprint"); let sides = |f: &dyn Fn(&str) -> String| -> Option<[f64; 4]> { let v = ["Top", "Left", "Bottom", "Right"].map(|s| e.num(&f(s))); v.iter().any(Option::is_some).then(|| v.map(|x| x.unwrap_or(0.0))) @@ -314,6 +315,7 @@ impl<'r> Importer<'r> { a.border_color = Some(self.swatch_ref(c.trim())); } a.border_tint = tint(e.num("ParagraphBorderTint")); + a.border_overprint = e.boolean("ParagraphBorderOverprint"); a.border_weights = sides(&|s| format!("ParagraphBorder{s}LineWeight")); a.border_offsets = sides(&|s| format!("ParagraphBorder{s}Offset")); a.border_type = e.prop("ParagraphBorderType").map(|v| names::stroke_type_in(v.trim())); @@ -329,6 +331,7 @@ impl<'r> Importer<'r> { a.border_gap_color = Some(self.swatch_ref(c.trim())); } a.border_gap_tint = tint(e.num("ParagraphBorderGapTint")); + a.border_gap_overprint = e.boolean("ParagraphBorderGapOverprint"); a.shading_corners = corners(e, "ParagraphShading"); a.shading_width = e.prop("ParagraphShadingWidth").map(|v| names::box_width_in(v.trim())); a.shading_top = e.prop("ParagraphShadingTopOrigin").map(|v| names::box_top_in(v.trim())); diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index b9102c9ae..7673d3ffc 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -858,28 +858,25 @@ pub fn digits_in(s: &str) -> Option { [Digits::Default, Digits::Arabic, Digits::Hindi, Digits::Farsi, Digits::Native].into_iter().find(|d| digits_out(*d) == s) } -/// A TOC separator in IDML's special-character notation: `^t` is a tab, `^^` a caret. +/// A TOC separator in IDML's special-character notation. pub fn toc_separator_out(s: &str) -> String { - s.replace('^', "^^").replace('\t', "^t") + designcraft_doc::story::toc_separator_notation(s) } -/// IDML TOC separator → text (`^t` and `^y`, the right indent tab, are tabs; other codes stay). +/// IDML may prefix a separator with its localized-string marker. pub fn toc_separator_in(s: &str) -> String { - let mut out = String::new(); - let mut it = s.chars(); - while let Some(c) = it.next() { - if c != '^' { - out.push(c); - continue; - } - match it.next() { - Some('t' | 'y') => out.push('\t'), - Some('^') => out.push('^'), - Some(o) => { - out.push('^'); - out.push(o); - } - None => out.push('^'), - } + designcraft_doc::story::toc_separator_text(s.strip_prefix("$ID/").unwrap_or(s)) +} + +#[cfg(test)] +mod toc_separator_tests { + use super::*; + + #[test] + fn localized_right_indent_tab_is_not_an_ordinary_tab() { + assert_eq!(toc_separator_in("$ID/^y"), "\u{E006}"); + assert_eq!(toc_separator_in("$ID/ ^y "), " \u{E006} "); + assert_eq!(toc_separator_in("^t"), "\t"); + assert_eq!(toc_separator_out("\u{E006}"), "^y"); + assert_eq!(toc_separator_in("^^y ^q ^"), "^y ^q ^"); } - out } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index b5b5a1cd8..c61304883 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -330,12 +330,19 @@ fn indesign_text_defaults() -> Vec { "RuleBelow": "false" => false, "true" => true, |r| r.0.rule_below.on; "RuleBelowColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_below.color; "RuleBelowTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.tint; + "RuleBelowType": "StrokeStyle/$ID/Solid" => designcraft_doc::StrokeType::Solid, "StrokeStyle/$ID/Wavy" => designcraft_doc::StrokeType::Wavy, |r| r.0.rule_below.kind; + "RuleBelowGapColor": "Swatch/None" => "[None]", "Color/Black" => "[Black]", |r| r.0.rule_below.gap_color; + "RuleBelowGapTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.gap_tint; + "KeepRuleAboveInFrame": "false" => false, "true" => true, |r| r.0.rule_above.keep_in_frame; "ParagraphShadingOn": "false" => false, "true" => true, |r| r.0.shading_on; "ParagraphShadingColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.shading_color; "ParagraphShadingTint": "20" => 0.2f32, "50" => 0.5f32, |r| r.0.shading_tint; "ParagraphBorderOn": "false" => false, "true" => true, |r| r.0.border_on; "ParagraphBorderColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.border_color; "ParagraphBorderTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.border_tint; + "ParagraphShadingOverprint": "false" => false, "true" => true, |r| r.0.shading_overprint; + "ParagraphBorderOverprint": "false" => false, "true" => true, |r| r.0.border_overprint; + "ParagraphBorderGapOverprint": "false" => false, "true" => true, |r| r.0.border_gap_overprint; "FontStyle": "Regular" => "Regular", "Bold" => "Bold", |r| r.1.font_style; "PointSize": "12" => 12.0, "9" => 9.0, |r| r.1.size; "Leading": "Auto" => Leading::Auto, "14" => Leading::Points(14.0), |r| r.1.leading; @@ -3849,6 +3856,7 @@ fn object_style_effects_import_by_category_and_based_on() { let base = fx("Base").unwrap(); assert!(base.drop_shadow.on && base.drop_shadow.size == 9.0 && (base.drop_shadow.distance - 5.0).abs() < 1e-9); assert_eq!(base.feather, 0.0, "EnableFeather=false"); + assert_eq!(base.drop_shadow.mode, designcraft_color::BlendMode::Normal, "no BlendMode: Normal, as earlier exports rendered"); let child = fx("Child").unwrap(); assert_eq!((child.drop_shadow.size, child.feather), (9.0, 6.0), "settings from the BasedOn style"); assert!(fx("Off").is_none()); @@ -3934,6 +3942,45 @@ fn style_names_ending_in_a_space_and_partial_rule_overrides_import() { assert_ne!(black.color, white.color, "only the colour is overridden"); } +#[test] +fn a_style_setting_only_a_rules_type_gap_or_keep_in_frame_overrides_it() { + let styles = STYLES.replace( + r#" "#, + r#" + $ID/[No paragraph style] + + + ParagraphStyle/Base + + + $ID/[No paragraph style] + + + $ID/[No paragraph style] + + + $ID/[No paragraph style]Text Color + + "#, + ); + let d = import_idml(&fixture_with_table_styles(&styles, STORY)).unwrap(); + let style = |n: &str| d.styles.paragraph.iter().find(|s| s.name == n).unwrap().para.clone(); + let wavy = style("Wavy").rule_below.expect("the type alone overrides the rule"); + assert_eq!((wavy.on, wavy.weight, wavy.kind), (true, 3.0, designcraft_doc::StrokeType::Wavy), "the rest from the based-on style"); + let gap = style("Gap").rule_below.expect("the gap colour alone, with no on or weight in the chain"); + assert!(!gap.on && gap.gap_color != designcraft_color::swatch::NONE, "{gap:?}"); + assert!(style("Kept").rule_above.expect("Keep In Frame alone").keep_in_frame); + // Text Color, in either spelling, for a gap and a rule colour. + let text = style("TextGap"); + let (below, above) = (text.rule_below.expect("rule below"), text.rule_above.expect("rule above")); + assert_eq!(below.gap_color, designcraft_doc::TEXT_COLOR); + assert_eq!((above.gap_color.as_str(), above.color.as_str()), (designcraft_doc::TEXT_COLOR, designcraft_doc::TEXT_COLOR)); + // And it is written back as Text Color, not as no swatch. + let back = import_idml(&export_idml(&d)).unwrap(); + let below = back.styles.paragraph.iter().find(|s| s.name == "TextGap").unwrap().para.rule_below.clone().unwrap(); + assert_eq!(below.gap_color, designcraft_doc::TEXT_COLOR, "round-trips"); +} + #[test] fn missing_linked_graphics_show_their_preview_and_keep_their_link() { // InDesign's preview of the missing photo (a PNG header is all the importer reads: 4 × 3). @@ -4590,6 +4637,98 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_ assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight)); } +/// A TOC style as InDesign writes it: `$ID/`-prefixed separator (`^y`, the right indent tab) and entry style. +const TOC_STYLES: &str = r#" + + + + + + + + + + + ParagraphStyle/$ID/TOC%3aTOC body text + CharacterStyle/TOC Page Number + $ID/[Same Style] + +"#; + +const TOC_STORY: &str = r#" +Welcome"#; + +#[test] +fn toc_style_entries_read_id_prefixed_separators_and_styles() { + let d = import_idml(&fixture_with_table_styles(TOC_STYLES, TOC_STORY)).unwrap(); + let toc = d.toc.as_ref().expect("the story's TOC style is imported"); + let entry = toc.entries.iter().find(|e| e.style == "Heading 1").unwrap(); + assert_eq!(entry.separator.as_deref(), Some("\u{E006}"), "right-indent tabs must not become ordinary tab stops"); + assert_eq!(entry.entry_style.as_deref(), Some("TOC/TOC body text")); + assert_eq!(entry.page_number_style.as_deref(), Some("TOC Page Number")); + assert_eq!(toc.title_style.as_deref(), Some("TOC/TOC Header")); +} + +#[test] +fn toc_style_entries_fall_back_on_malformed_and_unsupported_values() { + let long = "x".repeat(65); + let entries = format!( + r#" + ParagraphStyle/Missing + CharacterStyle/$ID/[None] + CharacterStyle/Missing + + + + "# + ); + let start = TOC_STYLES.find("").unwrap(); + let styles = format!("{}{entries}\n{}", &TOC_STYLES[..start], &TOC_STYLES[end..]) + .replace(r#"TitleStyle="ParagraphStyle/TOC%3aTOC Header""#, r#"TitleStyle="ParagraphStyle/Missing""#); + let d = import_idml(&fixture_with_table_styles(&styles, TOC_STORY)).unwrap(); + d.check().unwrap(); + let toc = d.toc.as_ref().expect("the TOC style is still imported"); + assert_eq!(toc.title_style, None, "a title style the document lacks is dropped"); + assert_eq!(toc.entries.len(), 3, "an entry without a style name is skipped: {:?}", toc.entries); + let e1 = &toc.entries[0]; + assert_eq!(e1.level, 1, "an unreadable level falls back to 1"); + assert_eq!(e1.page_number, designcraft_doc::TocPageNumber::After, "an unknown page-number position falls back to After"); + assert_eq!(e1.separator.as_deref(), Some("^q"), "an unknown special-character code is kept as written"); + assert_eq!( + (e1.entry_style.as_deref(), e1.page_number_style.as_deref(), e1.separator_style.as_deref()), + (None, None, None), + "undefined and [None] styles are no style" + ); + let e2 = &toc.entries[1]; + assert_eq!(e2.level, 9, "a level past 9 is clamped"); + assert_eq!(e2.separator, None, "an over-long separator is dropped"); + assert_eq!((toc.entries[2].level, toc.entries[2].separator.as_deref()), (2, None), "a plain tab is the default separator"); +} + +#[test] +fn toc_style_references_in_indented_properties_resolve() { + // Property children laid out on their own lines: the whitespace around a reference isn't + // part of the style name. + let styles = TOC_STYLES + .replace( + ">ParagraphStyle/$ID/TOC%3aTOC body text<", + "> + ParagraphStyle/$ID/TOC%3aTOC body text + <", + ) + .replace( + ">CharacterStyle/TOC Page Number<", + "> + CharacterStyle/TOC Page Number + <", + ); + let d = import_idml(&fixture_with_table_styles(&styles, TOC_STORY)).unwrap(); + let entry = d.toc.as_ref().unwrap().entries.iter().find(|e| e.style == "Heading 1").unwrap(); + assert_eq!(entry.entry_style.as_deref(), Some("TOC/TOC body text")); + assert_eq!(entry.page_number_style.as_deref(), Some("TOC Page Number")); +} + /// A graphic placed in a table cell is written into the cell, not dropped. #[test] fn exports_graphic_in_table_cell() { diff --git a/crates/idml/src/tests/facing_ltr.rs b/crates/idml/src/tests/facing_ltr.rs index b1c322ecc..45d1dc065 100644 --- a/crates/idml/src/tests/facing_ltr.rs +++ b/crates/idml/src/tests/facing_ltr.rs @@ -27,6 +27,11 @@ fn story(id: &str, text: &str) -> String { /// right page a guide at 300 and a "Right head". Three pages: 1 alone, then 2–3. Rulers measure /// from each page. fn left_to_right_fixture() -> Vec { + fixture(true) +} + +/// [`left_to_right_fixture`], with facing pages on or off. +fn fixture(facing: bool) -> Vec { let margins = r#""#; let page = |id: &str, x: f64, applied: bool, guide: Option| { let applied = if applied { r#" AppliedMaster="m1""# } else { "" }; @@ -48,7 +53,7 @@ fn left_to_right_fixture() -> Vec { r#""# ); let prefs = format!( - r#""# + r#""# ); zip_files(&[ ("designmap.xml", &map), @@ -109,3 +114,19 @@ fn left_to_right_right_pages_take_mirrored_margins_and_the_right_parent_page() { let again = import_idml(&export_idml(&d)).unwrap(); assert_right_pages_mirror_left_pages(&again); } + +/// Without facing pages, a page of a two-page spread shows the parent page in its place, as in +/// InDesign: page 3, the second page of its spread, shows the parent's second page, not the +/// first page's items again. +#[test] +fn single_sided_pages_show_the_parent_page_in_their_place() { + let d = import_idml(&fixture(false)).unwrap(); + assert!(d.spreads.iter().flat_map(|s| &s.pages).all(|p| p.side == PageSide::Single)); + assert_eq!(shown_heads(&d, 0), ["Left head"], "a page alone shows the parent's first page"); + assert_eq!(shown_heads(&d, 1), ["Left head"]); + assert_eq!(shown_heads(&d, 2), ["Right head"]); + // What the renderer draws behind each page of the second spread. + let (ppi, _) = d.parent_page_for(1).unwrap(); + assert_eq!(d.shown_parent_pages(designcraft_doc::SpreadRef::Doc(1), 0), [(ppi, 0)]); + assert_eq!(d.shown_parent_pages(designcraft_doc::SpreadRef::Doc(1), 1), [(ppi, 1)]); +} diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 817aa7829..97067ac41 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -324,10 +324,22 @@ pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> { .or_else(|| tiff_decoder(bytes)?.dimensions().ok()) } +/// The largest decoded raster, in RGBA bytes: 256 Mpx, a 300 ppi scan of an A0 sheet fits. +const MAX_RGBA_BYTES: u64 = 1 << 30; + +/// Whether a raster's header gives it more pixels than [`decode_rgba`] decodes. +pub fn too_large_to_decode(bytes: &[u8]) -> bool { + // `image` limits only its own output, which can be a quarter of the RGBA copy or less. + pixel_size(bytes).is_some_and(|(w, h)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES) +} + /// Straight (non-premultiplied) RGBA8 of a raster in display sRGB: an embedded ICC profile is /// applied, and CMYK goes through its own profile or the working CMYK space ([`colour`]). pub fn decode_rgba(bytes: &[u8]) -> Option { use colour::{DisplayColour, Pixels, to_display_rgba}; + if too_large_to_decode(bytes) { + return None; + } if is_psd(bytes) { return psd::decode(bytes); } @@ -520,6 +532,9 @@ pub fn is_cmyk_tiff(bytes: &[u8]) -> bool { /// over the decoder's 256 MiB limit; [`is_cmyk_tiff`] tells those apart from RGB files. pub fn decode_cmyk_tiff(bytes: &[u8]) -> Option { use tiff::tags::Tag; + if too_large_to_decode(bytes) { + return None; + } let mut d = tiff_decoder(bytes)?; let has_alpha = match d.colortype().ok()? { tiff::ColorType::CMYK(8 | 16) => false, diff --git a/crates/images/src/tests.rs b/crates/images/src/tests.rs index ba6e032eb..dd09f2577 100644 --- a/crates/images/src/tests.rs +++ b/crates/images/src/tests.rs @@ -401,3 +401,97 @@ fn bmp_decodes() { assert_eq!(pixel_size(&bmp), Some((3, 2))); assert_eq!(decode_rgba(&bmp).unwrap().get_pixel(1, 1).0, [0, 0, 255, 255]); } + +/// A 1-bit gray PNG of `side` x `side` blank pixels, written row by row so the test stays small. +fn blank_png(side: u32) -> Vec { + use std::io::Write; + let chunk = |out: &mut Vec, kind: &[u8; 4], data: &[u8]| { + out.extend((data.len() as u32).to_be_bytes()); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend(kind); + out.extend(data); + out.extend(crc.sum().to_be_bytes()); + }; + let row = vec![0u8; 1 + side.div_ceil(8) as usize]; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + for _ in 0..side { + z.write_all(&row).unwrap(); + } + let mut ihdr = [side.to_be_bytes(), side.to_be_bytes()].concat(); + ihdr.extend([1, 0, 0, 0, 0]); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut png, b"IHDR", &ihdr); + chunk(&mut png, b"IDAT", &z.finish().unwrap()); + chunk(&mut png, b"IEND", &[]); + png +} + +/// A small file that expands past 1 GiB of RGBA is refused from its header (#1586). +#[test] +fn rasters_past_the_rgba_budget_are_refused_before_decoding() { + let png = blank_png(16_385); + assert!(png.len() < 1 << 20, "{} bytes", png.len()); + assert_eq!(pixel_size(&png), Some((16_385, 16_385))); + assert_eq!(natural_size(&png), Some((16_385.0, 16_385.0))); + assert!(too_large_to_decode(&png)); + assert!(decode_rgba(&png).is_none()); + assert!(!too_large_to_decode(&blank_png(16_384))); + let small = decode_rgba(&blank_png(9)).expect("decodes"); + assert_eq!(small.get_pixel(8, 8).0, [0, 0, 0, 255]); +} + +/// A GIF whose logical screen is `side` x `side`, holding one black 1x1 frame. +fn gif_screen(side: u16) -> Vec { + let mut gif = b"GIF89a".to_vec(); + gif.extend(side.to_le_bytes()); + gif.extend(side.to_le_bytes()); + // Two-colour global table: black, white. + gif.extend([0x80, 0, 0, 0, 0, 0, 255, 255, 255]); + // Frame at the origin; LZW minimum code size 2, codes clear, 0, end; trailer. + gif.extend([0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 0x01, 0, 0x3B]); + gif +} + +/// The header of an uncompressed 8-bit CMYK TIFF of `side` x `side` pixels, without the pixels. +fn cmyk_tiff_header(side: u32) -> Vec { + let entries: [(u16, u16, u32, u32); 9] = [ + (256, 4, 1, side), // width + (257, 4, 1, side), // height + (258, 3, 4, 122), // bits per sample, stored after the IFD + (259, 3, 1, 1), // no compression + (262, 3, 1, 5), // separated (CMYK) + (273, 4, 1, 130), // strip offset + (277, 3, 1, 4), // samples per pixel + (278, 4, 1, side), // rows per strip + (279, 4, 1, side * side * 4), // strip byte count + ]; + let mut t = b"II*\0".to_vec(); + t.extend(8u32.to_le_bytes()); + t.extend(9u16.to_le_bytes()); + for (tag, kind, count, value) in entries { + t.extend(tag.to_le_bytes()); + t.extend(kind.to_le_bytes()); + t.extend(count.to_le_bytes()); + t.extend(value.to_le_bytes()); + } + t.extend(0u32.to_le_bytes()); + assert_eq!(t.len(), 122); + t.extend([8u16; 4].iter().flat_map(|b| b.to_le_bytes())); + t +} + +/// GIF tones and CMYK inks check the RGBA budget too (#1586). +#[test] +fn gif_tone_and_cmyk_inks_check_the_rgba_budget() { + let gif = gif_screen(16_385); + assert!(too_large_to_decode(&gif)); + assert_eq!(tone(&gif), None); + assert_eq!(tone(&gif_screen(2)), Some(Tone::Bitmap)); + let tiff = cmyk_tiff_header(16_385); + assert!(is_cmyk_tiff(&tiff)); + assert_eq!(pixel_size(&tiff), Some((16_385, 16_385))); + assert!(too_large_to_decode(&tiff)); + assert_eq!(decode_cmyk_tiff(&tiff), None); +} diff --git a/crates/images/src/tone.rs b/crates/images/src/tone.rs index 10be68445..3d0b945ce 100644 --- a/crates/images/src/tone.rs +++ b/crates/images/src/tone.rs @@ -39,6 +39,9 @@ pub fn tone(bytes: &[u8]) -> Option { }, // Indexed: what the palette holds decides. Transparent pixels are left out of a bitmap. F::Gif => { + if crate::too_large_to_decode(bytes) { + return None; + } let img = image::load_from_memory_with_format(bytes, F::Gif).ok()?.into_rgba8(); let mut opaque = true; let mut two_level = true; diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 2b4275da3..3df877c5c 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -31,4 +31,5 @@ hayro-interpret = { version = "0.7.0", default-features = false, features = ["em hayro-cmap = "0.1" kurbo = { workspace = true } tiff = { workspace = true } +flate2 = "1" designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index a164a6948..ec7c76fd6 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -161,9 +161,22 @@ pub(super) fn cmyk_tiff(data: &[u8], mode: RasterMode) -> Option { Image::from_custom(CmykImage(Arc::new(r)), mode.interpolate).ok() } -/// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded. -/// With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB. +#[cfg(test)] +thread_local! { + /// Rasters `recompress` has decoded on this thread. + static RECOMPRESS_DECODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Opaque raster → JPEG (quality 90). `None` when the image has transparency, is too large or +/// can't be decoded. With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB. pub(super) fn recompress(data: &[u8], mode: RasterMode) -> Option { + if designcraft_images::too_large_to_decode(data) { + return None; + } + #[cfg(test)] + { + RECOMPRESS_DECODES.with(|n| n.set(n.get() + 1)); + } let img = image::load_from_memory(data).ok()?; if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) { return None; @@ -536,3 +549,20 @@ mod eps_tests { assert_eq!(civil(i64::MIN), (-292277022657, 1, 27, 8, 29, 52)); } } + +#[cfg(test)] +mod recompress_tests { + use super::*; + + /// A raster past the RGBA budget is refused before `recompress` decodes it (#1586). + #[test] + fn a_raster_past_the_budget_is_not_decoded() { + let mode = RasterMode { interpolate: false, tag_rgb: false }; + let decodes = || RECOMPRESS_DECODES.with(std::cell::Cell::get); + let start = decodes(); + assert!(recompress(&crate::tests::blank_png(16_385), mode).is_none()); + assert_eq!(decodes(), start); + assert!(recompress(&crate::tests::blank_png(9), mode).is_some()); + assert_eq!(decodes(), start + 1); + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 8326ef574..262e19c8e 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -279,6 +279,32 @@ fn cmyk_tiff() -> (Vec, Vec) { (b, px) } +/// A black `side`² 1-bit grey PNG: well under 1 MB, it decodes to `side`² bytes. +pub(crate) fn blank_png(side: u32) -> Vec { + use std::io::Write; + let chunk = |out: &mut Vec, kind: &[u8; 4], data: &[u8]| { + out.extend((data.len() as u32).to_be_bytes()); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend(kind); + out.extend(data); + out.extend(crc.sum().to_be_bytes()); + }; + let row = vec![0u8; 1 + side.div_ceil(8) as usize]; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + for _ in 0..side { + z.write_all(&row).unwrap(); + } + let mut ihdr = [side.to_be_bytes(), side.to_be_bytes()].concat(); + ihdr.extend([1, 0, 0, 0, 0]); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut png, b"IHDR", &ihdr); + chunk(&mut png, b"IDAT", &z.finish().unwrap()); + chunk(&mut png, b"IEND", &[]); + png +} + fn place(d: &mut Document, data: Vec, px: (u32, u32)) { let asset = designcraft_doc::AssetId(d.alloc()); let mime = designcraft_images::mime(&data).into(); @@ -350,6 +376,19 @@ fn a_cut_off_image_is_skipped_not_fatal() { } } +/// A raster past the RGBA budget is skipped with a warning, also when images are recompressed (#1586). +#[test] +fn an_image_past_the_decode_budget_is_skipped_with_a_warning() { + let side = 16_385; + let mut d = doc_with_text("still here"); + place(&mut d, blank_png(side), (side, side)); + for compress_images in [false, true] { + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("could not be decoded and was skipped")), "compress_images {compress_images}: {:?}", r.warnings); + assert!(image_xobjects(&r.bytes).is_empty(), "compress_images {compress_images}"); + } +} + /// Every image XObject in a PDF: its /ColorSpace name (`?` when not a name) and decoded samples. fn image_xobjects(bytes: &[u8]) -> Vec<(String, Vec)> { use hayro_syntax::object::Name; diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index fdef09fae..bf32f1f3b 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -276,6 +276,9 @@ impl Exporter<'_> { // Paragraph shading and rules under the text. self.as_artifact(s, |me, s| { for d in ft.decos.iter().filter(|d| !d.nonprinting) { + if d.overprint { + me.warn("overprint is not exported yet"); + } if let Some(c) = me.swatch_color(&d.color, d.tint) { match &d.path { Some(p) => me.fill_path(s, p, c), diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index c4406b73c..84d648128 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -202,7 +202,11 @@ pub(crate) fn overprints(f: &Frame, swatch: &str, tint: f32, flag: bool) -> bool pub(crate) fn spread_blends(doc: &Document, sp: &designcraft_doc::Spread) -> bool { let blends = |it: &Item| { let mut any = false; - it.walk(&mut |i| any |= i.blend != Default::default()); + // A screened glow or a shadow with a blend mode blends with the objects below too, not the paper. + let fx_blends = |e: &designcraft_doc::Effects| { + (e.outer_glow.on && e.outer_glow.mode != Default::default()) || (e.drop_shadow.on && e.drop_shadow.mode != Default::default()) + }; + it.walk(&mut |i| any |= i.blend != Default::default() || fx_blends(&i.effects)); any }; sp.items.iter().any(|i| blends(i)) || doc.parents.iter().any(|p| p.items.iter().any(|i| blends(i))) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 06eb4f4dd..af3c0227c 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -102,19 +102,25 @@ impl Renderer { let mut below = vec![]; if e.outer_glow.on { let g = &e.outer_glow; - below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity)); + below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity, g.mode)); } if e.drop_shadow.on { let d = &e.drop_shadow; - below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity)); + below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity, d.mode)); } - for (off, size, pct, color, opacity) in below { + for (off, size, pct, color, opacity, mode) in below { let Some(c) = doc.resolve_color(&color, 1.0) else { continue }; let (sigma, grow) = split(size, pct); let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0))); // The offset moves the geometry, not the filter: vello drops layer content that lies // entirely outside the viewport before filtering. let r = (reach + off).inflate(grow, grow); + // The effect's blend sees what's behind the object: a layer around the filtered one. + let mix = crate::blend_mode(mode).mix; + if mix != Mix::Normal { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); + } self.with_filters(ctx, f, r, sigma, None, |me, c, fr| { let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; c.set_transform(moved.view); @@ -128,6 +134,9 @@ impl Renderer { } c.pop_layer(); }); + if mix != Mix::Normal { + ctx.pop_layer(); + } } // Gradient feather: the object (feathered or not) drawn in its own layer, then kept as // much as the opacity gradient says. @@ -221,7 +230,19 @@ impl Renderer { let s = &e.inner_shadow; let (sigma, choke) = split(s.size, s.choke); let off = offset(doc.light_angle(s.angle, s.global_light), s.distance); - self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), Mix::Normal, painted); + self.inner_edge( + ctx, + f, + reach, + bp, + xf, + off, + sigma, + choke, + color_of(&c, s.opacity.clamp(0.0, 1.0)), + crate::blend_mode(s.mode).mix, + painted, + ); } // Inner glow: from the edges inwards (or out from the centre), screened over the object. if e.inner_glow.on diff --git a/crates/render/tests/golden/inner-shadow.png b/crates/render/tests/golden/inner-shadow.png index c267aa386..5cba47641 100644 Binary files a/crates/render/tests/golden/inner-shadow.png and b/crates/render/tests/golden/inner-shadow.png differ diff --git a/crates/render/tests/it/colorize.rs b/crates/render/tests/it/colorize.rs index 5bf87a54d..a99cc8435 100644 --- a/crates/render/tests/it/colorize.rs +++ b/crates/render/tests/it/colorize.rs @@ -13,7 +13,7 @@ use designcraft_geom::{Affine, Rect, shapes}; use designcraft_render::{RenderOptions, Renderer, color_of}; use image::{ExtendedColorType, ImageEncoder}; -const RED: &str = "C=0 M=100 Y=100 K=0"; +const RED: &str = "C=15 M=100 Y=100 K=0"; const CYAN: &str = "C=100 M=0 Y=0 K=0"; fn encode(px: &[u8], w: u32, ct: ExtendedColorType, gif: bool) -> Vec { diff --git a/crates/render/tests/it/effects.rs b/crates/render/tests/it/effects.rs index 92d04bc44..267d538f9 100644 --- a/crates/render/tests/it/effects.rs +++ b/crates/render/tests/it/effects.rs @@ -141,6 +141,8 @@ fn outer_glow_surrounds_the_object() { g.size = 12.0; g.color = "C=0 M=100 Y=0 K=0".into(); g.opacity = 1.0; + // Normal, so the glow shows on the white page (screened, magenta on white is white). + g.mode = designcraft_color::BlendMode::Normal; let img = render(&d); golden("outer-glow", &img); for (x, y) in [(46, 80), (114, 80), (80, 46), (80, 114)] { @@ -150,6 +152,49 @@ fn outer_glow_surrounds_the_object() { assert_eq!(lum(img.pixel(10, 10)), WHITE); } +/// An effect's blend mode works on what's behind it: a glow screened onto a dark backdrop +/// lightens it in the glow's colour, where Normal paints the glow's colour over it. +#[test] +fn outer_glow_blends_with_its_mode() { + let glow = |mode| { + let mut d = doc(); + rect_item(&mut d, Rect::new(0.0, 0.0, 160.0, 160.0), "C=100 M=90 Y=10 K=0"); + let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]"); + let g = &mut d.item_mut(id).unwrap().effects.outer_glow; + g.on = true; + g.size = 12.0; + g.color = "C=0 M=0 Y=100 K=0".into(); + g.opacity = 1.0; + g.mode = mode; + render(&d) + }; + let screen = glow(designcraft_color::BlendMode::Screen); + let normal = glow(designcraft_color::BlendMode::Normal); + let backdrop = screen.pixel(10, 10); + let (s, n) = (screen.pixel(46, 80), normal.pixel(46, 80)); + // Screen only lightens: every channel at least the backdrop's, and the navy shows through + // (blue stays high), where Normal paints yellow (blue drops). + assert!(s.iter().zip(backdrop).take(3).all(|(a, b)| *a + 2 >= b), "screen lightens: {s:?} over {backdrop:?}"); + assert!(s[2] > n[2] + 25, "screen keeps the backdrop's blue: {s:?} vs normal {n:?}"); +} + +/// A screened glow over blank paper shows in its colour: it blends with the objects below, and +/// the paper is not one of them. +#[test] +fn screened_outer_glow_shows_on_blank_paper() { + let mut d = doc(); + let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]"); + let g = &mut d.item_mut(id).unwrap().effects.outer_glow; + g.on = true; + g.size = 12.0; + g.color = "C=0 M=0 Y=100 K=0".into(); + g.opacity = 1.0; + g.mode = designcraft_color::BlendMode::Screen; + let img = render(&d); + let p = img.pixel(46, 80); + assert!(p[2] < 200 && p[0] > p[2] + 40, "yellow glow beside the item: {p:?}"); +} + #[test] fn inner_shadow_darkens_inside_the_lit_edge() { let mut d = doc(); diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index ff65e7ce7..86f621c61 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -405,6 +405,9 @@ impl Tool for FrameTool { snap_free_line(cx, sr, sa, b, &mut self.guides) }; } + // The readout measures the line as drawn (Shift and snapping included), in + // spread space. + self.last = Some((spread_json(sr), Rect::from_points(sa, b))); out.push(Action::Preview("line.create".into(), json!({"spread": spread_json(sr), "a": [sa.x, sa.y], "b": [b.x, b.y]}))); } else { let end = cx.layout.to_spread(sr, ev.pos); @@ -458,11 +461,18 @@ impl Tool for FrameTool { } fn overlays(&self, cx: &ToolContext) -> Vec { - if let (true, Some(a)) = (self.active, self.start) { - let r = Rect::from_points(a, self.cur); - let _ = cx; + // The frame or line being drawn (Shift, Alt and snapping included, in spread space), in the + // document's units to two decimals, on two lines: InDesign's readout ("W: 3.13 cm", + // "H: 3.13 cm"). Nothing before the first drag step. + if self.active + && self.start.is_some() + && let Some((_, r)) = self.last.as_ref() + { + let units = &cx.doc.settings; + let w = designcraft_geom::units::format_measure_prec(r.width().abs(), units.horizontal_units, 2); + let h = designcraft_geom::units::format_measure_prec(r.height().abs(), units.vertical_units, 2); let mut v = self.guides.clone(); - v.push(Overlay::Measure { p: self.cur, text: format!("W: {:.0} pt H: {:.0} pt", r.width(), r.height()) }); + v.push(Overlay::Measure { p: self.cur, text: format!("W: {w}\nH: {h}") }); return v; } vec![] diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index bb6b9b90c..57872cff9 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -16,7 +16,7 @@ mod frame; mod gradient; pub mod layout; mod nav; -pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX}; +pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX, PLACE_DRAG_MIN}; mod pen; mod pencil; pub mod select; @@ -232,6 +232,8 @@ pub struct ToolContext<'a> { pub unit: Unit, /// The toolbox's Stroke box is in front: sampled colours go to strokes (the Eyedropper). pub stroke_in_front: bool, + /// Natural size, in points, of the graphic loaded in the place cursor. + pub loaded: Option<(f64, f64)>, } impl ToolContext<'_> { diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index f77c5d0bb..74a8c5074 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -133,7 +133,8 @@ impl Tool for PlaceGun { let Some(a) = self.start.take() else { return vec![] }; let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] }; let b = cx.layout.to_spread(sr, ev.pos); - if (ev.pos - a).hypot() < cx.tol(3.0) + let click = (ev.pos - a).hypot() < cx.tol(3.0); + if click && let Some((_, id)) = cx.hit(a) && cx .doc @@ -142,7 +143,12 @@ impl Tool for PlaceGun { { return vec![Action::Exec("place.drop".into(), json!({"frame": id.0}))]; } - let r = designcraft_geom::Rect::from_points(sa, b); + // Under the drag threshold it's a click, as the overlay shows: actual size there. + if click { + return vec![Action::Exec("place.drop".into(), json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y}))]; + } + // The frame the drag drew: at the graphic's proportions from where it started. + let r = cx.loaded.map_or_else(|| designcraft_geom::Rect::from_points(sa, b), |size| proportional(sa, b, size).0); vec![Action::Exec( "place.drop".into(), json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y, "rect": [r.x0, r.y0, r.x1, r.y1]}), @@ -151,11 +157,29 @@ impl Tool for PlaceGun { _ => vec![], } } - fn overlays(&self, _cx: &ToolContext) -> Vec { - match self.start { - Some(a) => vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))], - None => vec![], + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some(a) = self.start else { return vec![] }; + let Some(size) = cx.loaded else { + return vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))]; + }; + // Under the drag threshold the release is a click (actual size where it was pressed): + // no frame is drawn yet, the loaded cursor shows what lands. + if (self.cur - a).hypot() < cx.tol(3.0) { + return vec![]; } + // As InDesign: the frame the graphic will fill, and its scale beside the pointer. + let (r, k) = drawn_frame(a, self.cur, size); + let pct = format!("{:.2}%", k * 100.0); + vec![ + crate::Overlay::Marquee(r), + crate::Overlay::Measure { + p: self.cur, + text: format!( + "X: {pct} +Y: {pct}" + ), + }, + ] } fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { match key { @@ -164,11 +188,43 @@ impl Tool for PlaceGun { _ => vec![], } } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::LoadedGraphic + fn cursor(&self, cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + // Drawing the frame: the crosshair (the loaded graphic's thumbnail would hide the frame). + // Under the drag threshold no frame is drawn yet (the release is a click), so the + // thumbnail stays, as the overlay does. With no graphic loaded there's no thumbnail to + // show: the crosshair throughout. + let drawing = cx.loaded.is_none() || self.start.is_some_and(|a| (self.cur - a).hypot() >= cx.tol(3.0)); + if drawing { Cursor::Crosshair } else { Cursor::LoadedGraphic } } } +/// A dragged frame `place.drop` takes only when it is wider and taller than this, in points; +/// a thinner one places the graphic at its natural size instead. +pub const PLACE_DRAG_MIN: f64 = 2.0; + +/// The frame `place.drop` makes for a place-cursor drag from `a` to `b`: the proportional frame +/// (see [`proportional`]), or the graphic at natural size from `a` when that frame is at most +/// [`PLACE_DRAG_MIN`] in either direction. With its scale. +fn drawn_frame(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) { + let (r, k) = proportional(a, b, size); + if r.width() > PLACE_DRAG_MIN && r.height() > PLACE_DRAG_MIN { + (r, k) + } else { + (designcraft_geom::Rect::new(a.x, a.y, a.x + size.0, a.y + size.1), 1.0) + } +} + +/// The frame a place-cursor drag from `a` to `b` draws for a graphic of natural `size`: the +/// graphic's proportions, from `a` towards `b`, as large as fits the drag. With its scale. +fn proportional(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) { + let (w, h) = (size.0.max(1e-6), size.1.max(1e-6)); + let (dx, dy) = (b.x - a.x, b.y - a.y); + let k = (dx.abs() / w).min(dy.abs() / h); + let k = if k.is_finite() { k } else { 0.0 }; + let c = Point::new(a.x + (w * k).copysign(dx), a.y + (h * k).copysign(dy)); + (designcraft_geom::Rect::from_points(a, c), k) +} + /// Page tool (Shift+P): click a page to select it; drag one of its handles to preview its liquid /// page rule (the page springs back on release), Alt-drag to resize it, Shift to keep its /// proportions; double-click a page for exact sizes. diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 033e1a97c..1fb30e6f7 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -92,6 +92,8 @@ enum Drag { /// None when that point could not be read. The live document during a drag is the /// previous preview, so the snap must not read the anchor from it again. at: Option<(SpreadRef, Point)>, + /// The other selected anchor points, which move with it: (item, subpath, anchor). + others: Vec<(u64, usize, usize)>, }, } @@ -144,21 +146,42 @@ pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Optio anchor_at_in(cx, &cx.selection.items, p) } -/// [`anchor_at`] over the given items. +/// Whether the Direct Selection tool shows (and lets you drag) a direction handle of anchor `ai` +/// of a sub-path of `n` anchors, as InDesign: both handles of a selected anchor, and on its +/// neighbours the handle that shapes the segment to it (`out`: the outgoing handle). With no +/// anchor selected no handle shows. +pub fn handle_shown(n: usize, closed: bool, ai: usize, out: bool, chosen: impl Fn(usize) -> bool) -> bool { + if chosen(ai) { + return true; + } + let neighbour = if out { + if ai + 1 < n { Some(ai + 1) } else { closed.then_some(0) } + } else if ai > 0 { + Some(ai - 1) + } else { + closed.then(|| n.saturating_sub(1)) + }; + neighbour.is_some_and(chosen) +} + +/// [`anchor_at`] over the given items. Of the direction handles, only those [`handle_shown`] +/// shows are found. pub fn anchor_at_in(cx: &ToolContext, ids: &[designcraft_doc::ItemId], p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> { let tol = cx.tol(5.0); for id in ids { let (Some(it), Some(xf)) = (cx.doc.item(*id), cx.item_canvas_xf(*id)) else { continue }; let m = xf * it.xf; for (si, sp) in it.path.subpaths.iter().enumerate() { + let chosen = |a: usize| cx.selection.anchors.contains(&(*id, si, a)); for (ai, a) in sp.anchors.iter().enumerate() { if ((m * a.p) - p).hypot() <= tol { return Some((id.0, si, ai, None)); } - if a.has_in() && ((m * a.h_in) - p).hypot() <= tol { + let shown = |out: bool| handle_shown(sp.anchors.len(), sp.closed, ai, out, chosen); + if a.has_in() && shown(false) && ((m * a.h_in) - p).hypot() <= tol { return Some((id.0, si, ai, Some("in"))); } - if a.has_out() && ((m * a.h_out) - p).hypot() <= tol { + if a.has_out() && shown(true) && ((m * a.h_out) - p).hypot() <= tol { return Some((id.0, si, ai, Some("out"))); } } @@ -181,6 +204,37 @@ fn spread_delta_to_canvas(xf: Affine, d: Vec2) -> Vec2 { (xf * Point::new(d.x, d.y)) - (xf * Point::ORIGIN) } +/// The anchor points inside `r` (canvas) of the unlocked, visible paths, groups' members included. +fn anchors_in(cx: &ToolContext, r: Rect) -> Vec<(ItemId, usize, usize)> { + fn walk(cx: &ToolContext, items: &[std::sync::Arc], r: Rect, out: &mut Vec<(ItemId, usize, usize)>) { + for it in items { + let locked = it.locked || cx.doc.layer(it.layer).is_some_and(|l| l.locked || !l.visible); + if locked || it.hidden { + continue; + } + if let Content::Group { items } = &it.content { + walk(cx, items, r, out); + continue; + } + let Some(xf) = cx.item_canvas_xf(it.id) else { continue }; + let m = xf * it.xf; + for (si, ai, a) in it.path.anchors() { + let q = m * a.p; + if q.x >= r.x0 && q.x <= r.x1 && q.y >= r.y0 && q.y <= r.y1 { + out.push((it.id, si, ai)); + } + } + } + } + let mut out = Vec::new(); + for slot in &cx.layout.slots { + if let Some(sp) = cx.doc.spread(slot.spread) { + walk(cx, &sp.items, r, &mut out); + } + } + out +} + /// The ruler guides a marquee `r` (canvas) crosses that can be selected, as `guide.select` /// takes them: none while guides are hidden or locked. fn guides_crossing(cx: &ToolContext, r: Rect) -> Vec { @@ -217,6 +271,43 @@ fn guides_crossing(cx: &ToolContext, r: Rect) -> Vec { out } +/// `selection.set` params selecting `points`: their paths, and those points. +/// `keep` (Shift): the objects already selected stay selected; otherwise only the paths of +/// `points` are. +fn anchor_selection(cx: &ToolContext, points: &[(ItemId, usize, usize)], keep: bool) -> Value { + let mut ids: Vec = if keep { cx.selection.items.iter().map(|i| i.0).collect() } else { Vec::new() }; + for (i, _, _) in points { + if !ids.contains(&i.0) { + ids.push(i.0); + } + } + let anchors: Vec = points.iter().map(|(i, s, a)| json!([i.0, s, a])).collect(); + json!({"ids": ids, "anchors": anchors}) +} + +/// `path.moveAnchors` points for `points` moved `d` on the canvas: each with its own movement in +/// its spread (a rotated spread view turns it). +fn moved_points(cx: &ToolContext, points: impl IntoIterator, d: Vec2) -> Vec { + points + .into_iter() + .map(|(i, s, a)| { + let v = cx.doc.find(i).map_or(d, |loc| cx.layout.delta_to_spread(loc.spread, d)); + json!([i.0, s, a, v.x, v.y]) + }) + .collect() +} + +/// `d` held to the nearest multiple of 45°, as Shift does. +fn constrain_45(d: Vec2) -> Vec2 { + if d.hypot() < 1e-9 { + return d; + } + let step = std::f64::consts::FRAC_PI_4; + let a = (d.y.atan2(d.x) / step).round() * step; + let proj = d.x * a.cos() + d.y * a.sin(); + Vec2::new(proj * a.cos(), proj * a.sin()) +} + pub struct SelectionTool { direct: bool, drag: Drag, @@ -224,6 +315,8 @@ pub struct SelectionTool { guides: Vec, /// Selected item to toggle if this Shift press never becomes a drag. shift_release: Option, + /// Selected anchor point to take away if this Shift press never becomes a drag. + anchor_release: Option<(ItemId, usize, usize)>, /// One of several selected items to make (or stop being) the key object for Align if this /// press never becomes a drag. key_release: Option, @@ -235,7 +328,17 @@ pub struct SelectionTool { impl SelectionTool { pub fn new(direct: bool) -> Self { - Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, key_release: None, loaded: None, live: None } + Self { + direct, + drag: Drag::None, + hover_handle: None, + guides: vec![], + shift_release: None, + key_release: None, + anchor_release: None, + loaded: None, + live: None, + } } /// [`bracket_at`] for this tool. With Direct Selection an anchor under the pointer wins: the @@ -689,6 +792,7 @@ impl SelectionTool { self.shift_release = None; self.key_release = None; + self.anchor_release = None; // Type on a path: a bracket moves where the text starts or ends. if !ev.mods.shift && let Some((id, end)) = self.bracket_at(cx, p) @@ -700,8 +804,51 @@ impl SelectionTool { && let Some((id, si, ai, handle)) = anchor_at(cx, p) { let at = anchor_spread_point(cx, id, si, ai); - self.drag = Drag::Anchor { id, si, ai, handle, start: p, at }; - return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })]; + let mut out = vec![]; + let mut others = vec![]; + if handle.is_none() { + // InDesign's anchor point selection: a click selects the point, Shift+click + // adds it or takes it away, and dragging a selected point moves all of them. + let me = (ItemId(id), si, ai); + let mut points = cx.selection.anchors.clone(); + let was = points.contains(&me); + if ev.mods.shift && was { + // Kept selected so a Shift-drag can constrain it; a click still takes it + // away, on release, as for objects. + self.anchor_release = Some(me); + } else if ev.mods.shift { + points.push(me); + } else if !was { + points = vec![me]; + } + if points != cx.selection.anchors { + out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points, ev.mods.shift))); + } + others = points.iter().filter(|a| **a != me).map(|(i, s, a)| (i.0, *s, *a)).collect(); + } + let label = if handle.is_some() { + "Move Direction Handle" + } else if others.is_empty() { + "Move Anchor" + } else { + "Move Anchors" + }; + self.drag = Drag::Anchor { id, si, ai, handle, start: p, at, others }; + out.push(Action::Begin(label.into())); + return out; + } + // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object + // that isn't selected selects it and moves just that point (Chris Nielsen's "The + // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). + if self.direct + && !ev.mods.shift + && let Some((_, hit)) = cx.hit(p) + && !cx.selection.contains(hit) + && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) + { + let at = anchor_spread_point(cx, id, si, ai); + self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at, others: vec![] }; + return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": false})), Action::Begin("Move Anchor".into())]; } // A selected placed graphic's own handles resize it inside its frame (the // frame's anchors, under Direct Selection, come first). @@ -776,7 +923,11 @@ impl SelectionTool { } else { out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0}))); } - } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content) { + } else if !cx.selection.contains(id) + || (self.direct && !cx.selection.content && (cx.selection.items.len() > 1 || cx.doc.item(id).is_some_and(holds_content))) + { + // Direct Selection on a selected frame selects its content (a graphic or pasted-in items), + // and on one of several selected objects just that object. out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct}))); } else if !self.direct && cx.selection.items.len() > 1 { // InDesign: a click on one of several selected objects makes it the @@ -1040,8 +1191,18 @@ impl SelectionTool { // Screen y points down: a positive screen angle is clockwise. vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))] } - Drag::Anchor { id, si, ai, handle, start, at } => { - let d = p - start; + Drag::Anchor { id, si, ai, handle, start, at, others } => { + let mut d = p - start; + if d.hypot() >= cx.tol(3.0) { + self.anchor_release = None; + } else if self.anchor_release.is_some() { + // A Shift-press on a selected point is still a click: nothing moves yet. + return vec![]; + } + // Shift: horizontal, vertical or at 45°. + if ev.mods.shift { + d = constrain_45(d); + } let mut dx = d.x; let mut dy = d.y; self.guides.clear(); @@ -1069,18 +1230,25 @@ impl SelectionTool { }, ); // dx and dy stay in canvas space. Snap.delta is spread space. - let corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta); - if corr.x.is_finite() { - dx += corr.x; - } - if corr.y.is_finite() { - dy += corr.y; + let mut corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta); + corr = Vec2::new(if corr.x.is_finite() { corr.x } else { 0.0 }, if corr.y.is_finite() { corr.y } else { 0.0 }); + // Shift: the snap moves the points along the constrained direction only. + if ev.mods.shift && d.hypot() > 1e-9 { + let dir = d / d.hypot(); + corr = dir * corr.dot(dir); } + dx += corr.x; + dy += corr.y; self.guides = hit.guides; } - let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy}); + // The command moves in spread space: each point by the drag in its own spread. + let own = at.map_or(Vec2::new(dx, dy), |(spread, _)| cx.layout.delta_to_spread(spread, Vec2::new(dx, dy))); + let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": own.x, "dy": own.y}); if let Some(h) = handle { params["handle"] = json!(h); + } else if !others.is_empty() { + let points = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| (ItemId(i), s, a)); + params = json!({"points": moved_points(cx, points, Vec2::new(dx, dy))}); } vec![Action::Preview("path.moveAnchors".into(), params)] } @@ -1147,9 +1315,15 @@ impl Tool for SelectionTool { PointerKind::Up => { self.guides.clear(); let release = self.shift_release.take(); + let anchor_release = self.anchor_release.take(); let key = self.key_release.take(); let d = std::mem::replace(&mut self.drag, Drag::None); match d { + // A Shift-click on a selected point that never became a drag takes it away. + Drag::Anchor { .. } if anchor_release.is_some() => { + let points: Vec<_> = cx.selection.anchors.iter().copied().filter(|a| Some(*a) != anchor_release).collect(); + vec![Action::Commit, Action::Exec("selection.set".into(), anchor_selection(cx, &points, true))] + } Drag::Move { .. } | Drag::Resize { .. } | Drag::ContentResize { .. } @@ -1166,6 +1340,21 @@ impl Tool for SelectionTool { }, Drag::Marquee { start, cur } => { let r = Rect::from_points(start, cur); + // Direct Selection: the anchor points inside the marquee, on every path it + // reaches (InDesign's way to select points of several frames at once). + if self.direct { + let mut points = anchors_in(cx, r); + if !points.is_empty() { + if ev.mods.shift { + for a in &cx.selection.anchors { + if !points.contains(a) { + points.push(*a); + } + } + } + return vec![Action::Exec("selection.set".into(), anchor_selection(cx, &points, ev.mods.shift))]; + } + } // Items whose bounds intersect the marquee. let mut ids: Vec = Vec::new(); for slot in &cx.layout.slots { @@ -1202,7 +1391,20 @@ impl Tool for SelectionTool { if key == ToolKey::Escape && self.live.take().is_some_and(|id| cx.selection.items == [id]) { return vec![Action::Handled]; } - let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))]; + let direct_points = self.direct && !cx.selection.anchors.is_empty(); + let mv = |dx: f64, dy: f64| { + // Direct Selection with anchor points selected: the arrow keys move the points, each + // the way the arrow points on screen. + if direct_points { + let points = moved_points(cx, cx.selection.anchors.iter().copied(), Vec2::new(dx, dy)); + return vec![Action::Exec("path.moveAnchors".into(), json!({"points": points}))]; + } + vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))] + }; + // Selected points: Delete and Backspace delete them, not their paths. + if direct_points && matches!(key, ToolKey::Delete | ToolKey::Backspace) { + return vec![Action::Exec("path.deletePoints".into(), json!({}))]; + } if cx.selection.items.is_empty() { // Selected ruler guides: the arrows nudge them, Delete deletes them. if cx.selection.guides.is_empty() { @@ -1347,3 +1549,26 @@ fn edit_text_at(cx: &ToolContext, p: Point) -> Option> { let (_, sp) = cx.layout.spread_at(p).unwrap_or((SpreadRef::Doc(0), p)); Some(vec![Action::SwitchTool("type".into()), Action::Exec("text.placeCaret".into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]) } + +#[cfg(test)] +mod handle_tests { + use super::handle_shown; + + /// InDesign's Direct Selection shows a selected anchor's two handles, and on its neighbours + /// the handle shaping the segment to it; nothing with no anchor selected. + #[test] + fn handles_show_around_the_selected_anchors() { + let shown = |closed: bool, chosen: &[usize]| { + (0..4) + .flat_map(|ai| [false, true].map(|out| (ai, out))) + .filter(|(ai, out)| handle_shown(4, closed, *ai, *out, |a| chosen.contains(&a))) + .collect::>() + }; + assert_eq!(shown(true, &[]), vec![], "no anchor selected"); + assert_eq!(shown(true, &[2]), vec![(1, true), (2, false), (2, true), (3, false)]); + // Closed, the first anchor's neighbours wrap round. + assert_eq!(shown(true, &[0]), vec![(0, false), (0, true), (1, false), (3, true)]); + // Open, the end has no neighbour beyond it. + assert_eq!(shown(false, &[0]), vec![(0, false), (0, true), (1, false)]); + } +} diff --git a/crates/tools/src/snap/tests.rs b/crates/tools/src/snap/tests.rs index 2dfccafae..f97c637c1 100644 --- a/crates/tools/src/snap/tests.rs +++ b/crates/tools/src/snap/tests.rs @@ -64,6 +64,7 @@ fn ctx_on<'a>(doc: &'a Document, sel: &'a Selection, cache: &'a Cache, layout: & snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false, + loaded: None, } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 2a9bd0444..9e2421db5 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -19,6 +19,7 @@ fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout) snap: SnapView::OFF, unit: Unit::Points, stroke_in_front: false, + loaded: None, } } @@ -70,6 +71,56 @@ fn frame_tool_drag_emits_begin_preview_commit() { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 110.0, 60.0)), vec![Action::Commit]); } +/// While a frame is drawn, its size shows in the document's units, as InDesign's readout does +/// ("W: 6.08 cm" over "H: 6.08 cm" in Zero2Graphic's page layout tutorial, 0:43). +#[test] +fn frame_tool_readout_shows_the_size_in_the_document_units() { + let mut d = Document::new(&NewDocument::default()); + d.settings.horizontal_units = Unit::Centimeters; + d.settings.vertical_units = Unit::Centimeters; + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("rectangleFrame"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + // 172.35 pt square: 6.08 cm. + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 182.35, 182.35)); + let texts: Vec = t + .overlays(&cx) + .into_iter() + .filter_map(|o| match o { + Overlay::Measure { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(texts, vec!["W: 6.08 cm\nH: 6.08 cm".to_string()]); +} + +/// The Line tool's readout measures the line as drawn: Shift makes it level, so it has no +/// height, wherever the pointer is. +#[test] +fn line_tool_readout_measures_the_line_as_drawn() { + let mut d = Document::new(&NewDocument::default()); + d.settings.horizontal_units = Unit::Centimeters; + d.settings.vertical_units = Unit::Centimeters; + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("line"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let mut drag = PointerEvent::new(PointerKind::Drag, 182.35, 15.0); + drag.mods.shift = true; + t.pointer(&cx, &drag); + let texts: Vec = t + .overlays(&cx) + .into_iter() + .filter_map(|o| match o { + Overlay::Measure { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(texts.len(), 1, "{texts:?}"); + assert_eq!(texts[0], "W: 6.08 cm\nH: 0 cm", "level, as drawn, not 5 pt down to the pointer"); +} + #[test] fn selection_click_and_marquee() { let mut d = Document::new(&NewDocument::default()); @@ -1085,6 +1136,33 @@ fn double_click_on_a_port_breaks_its_link() { } } +/// With the Type tool, a drag that starts in an empty rectangle draws a new text frame over it +/// (the rectangle stays as it is), as InDesign's does; a click still turns the rectangle into a +/// text frame with the caret in it. +#[test] +fn type_tool_drag_over_an_empty_frame_draws_a_new_text_frame() { + let mut d = Document::new(&NewDocument::default()); + let id = add_rect(&mut d, Rect::new(100.0, 100.0, 300.0, 300.0)); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| (x + off.x, y + off.y); + let mut t = create("type"); + let (x, y) = at(120.0, 120.0); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); + let (x1, y1) = at(280.0, 200.0); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x1, y1)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(cmd, p)] if cmd == "frame.create" && p["content"] == "text"), "{a:?}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x1, y1)), vec![Action::Commit]); + // A click: the caret goes into the rectangle, which becomes a text frame. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y)); + assert!(matches!(&a[..], [Action::Exec(cmd, p)] if cmd == "text.placeCaret" && p["frame"] == id.0), "{a:?}"); + // A release away from the press with no drag in between isn't a click: no caret, no conversion. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x1, y1)).is_empty()); +} + #[test] fn clicks_in_a_row_edit_and_select_text() { let mut d = Document::new(&NewDocument::default()); @@ -1480,6 +1558,32 @@ fn table_frame(degrees: f64) -> (Document, ItemId) { (d, id) } +/// In a table cell Shift+Tab goes back a cell and Esc toggles cell selection, as InDesign; out of +/// a table Tab types a tab and Esc leaves the text. +#[test] +fn type_tool_keys_in_a_table_cell() { + let (d, id) = table_frame(0.0); + let sid = d.item(id).unwrap().text_frame().unwrap().story; + let table = *d.story(sid).unwrap().tables.keys().next().unwrap(); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let cell = designcraft_doc::CellAddr { table, row: 1, col: 1 }; + let s = Selection::text(designcraft_doc::TextSel { cell: Some(cell), ..designcraft_doc::TextSel::caret(sid, 0) }); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("type"); + let shift = Mods { shift: true, ..Default::default() }; + let exec = |c: &str, p: serde_json::Value| vec![Action::Exec(c.into(), p)]; + assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("table.prevCell", serde_json::json!({}))); + assert_eq!(t.key(&cx, ToolKey::Tab, Mods::default()), exec("text.insert", serde_json::json!({"text": "\t"}))); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), exec("table.toggleSelection", serde_json::json!({}))); + // Out of a table, and in footnote text (addressed as a cell too). + for cell in [None, Some(designcraft_doc::CellAddr::footnote(1))] { + let s = Selection::text(designcraft_doc::TextSel { cell, ..designcraft_doc::TextSel::caret(sid, 0) }); + let cx = ctx(&d, &s, &c, &l); + assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("text.insert", serde_json::json!({"text": "\t"})), "{cell:?}"); + assert!(matches!(t.key(&cx, ToolKey::Escape, Mods::default()).first(), Some(Action::SwitchTool(_))), "{cell:?}"); + } +} + #[test] fn type_tool_drags_a_table_column_border() { for degrees in [0.0, 90.0] { @@ -1821,7 +1925,13 @@ fn direct_selection_drags_the_end_anchors_of_type_on_a_path() { let p = Point::new(x + off.x, 300.0 + off.y); let mut direct = create("directSelection"); assert_ne!(direct.cursor(&cx, p, Mods::default()), Cursor::PathBracket, "anchor at {x}"); - assert_eq!(direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)), vec![Action::Begin("Move Anchor".into())]); + // The press selects that anchor point and starts moving it. + let a = direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + assert!( + matches!(a.first(), Some(Action::Exec(cmd, p)) if cmd == "selection.set" && p["anchors"].as_array().map(Vec::len) == Some(1)), + "{a:?}" + ); + assert_eq!(a.last(), Some(&Action::Begin("Move Anchor".into()))); } } @@ -1966,3 +2076,142 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() { assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group"); assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed); } + +#[test] +fn direct_selection_click_on_one_of_several_selected_frames_selects_only_it() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let b = add_rect(&mut d, Rect::new(300.0, 100.0, 400.0, 200.0)); + let (s, c, l) = (Selection::items(vec![a, b]), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = spread_pt(&l, 150.0, 150.0); + let mut t = create("directSelection"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + assert_eq!(acts, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": true}))]); +} + +#[test] +fn pulling_a_corner_of_an_unselected_frame_selects_the_frame_not_its_content() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = spread_pt(&l, 200.0, 100.0); + let mut t = create("directSelection"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + assert_eq!( + acts, + vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": false})), Action::Begin("Move Anchor".into())] + ); +} + +#[test] +fn anchor_drag_on_a_rotated_spread_sends_a_spread_space_delta() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + Arc::make_mut(&mut d.spreads[0]).pages[0].view_rotation = 1; + let (s, c, l) = (Selection::items(vec![a]), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = l.to_canvas(SpreadRef::Doc(0), Point::new(200.0, 100.0)); + let mut t = create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p.x + 50.0, p.y)); + let [Action::Preview(cmd, params)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!(cmd, "path.moveAnchors"); + let want = l.delta_to_spread(SpreadRef::Doc(0), Vec2::new(50.0, 0.0)); + assert!(want.y.abs() > 1.0, "the view rotation turns a canvas x move into a spread y move: {want:?}"); + let (dx, dy) = (params["dx"].as_f64().unwrap(), params["dy"].as_f64().unwrap()); + assert!((dx - want.x).abs() < 1e-6 && (dy - want.y).abs() < 1e-6, "{params} want {want:?}"); +} + +/// Direct Selection selects anchor points with a marquee across several frames, and dragging one of +/// them moves them all, as InDesign does when the top points of a row of frames are dragged to slant +/// them (Zero2Graphic's page layout tutorial, 1:15). +#[test] +fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_together() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let b = add_rect(&mut d, Rect::new(200.0, 100.0, 300.0, 200.0)); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let none = Selection::default(); + let mut t = create("directSelection"); + // A marquee around the top edges: the four top points, not the frames' bottom ones. + let (p0, p1) = (spread_pt(&l, 80.0, 90.0), spread_pt(&l, 320.0, 110.0)); + t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Down, p0.x, p0.y)); + t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Drag, p1.x, p1.y)); + let up = t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Up, p1.x, p1.y)); + let Some(Action::Exec(cmd, p)) = up.last() else { panic!("{up:?}") }; + assert_eq!(cmd, "selection.set"); + assert_eq!(p["ids"], serde_json::json!([a.0, b.0])); + let mut got: Vec<(u64, u64, u64)> = + p["anchors"].as_array().unwrap().iter().map(|v| (v[0].as_u64().unwrap(), v[1].as_u64().unwrap(), v[2].as_u64().unwrap())).collect(); + got.sort(); + assert_eq!(got, vec![(a.0, 0, 0), (a.0, 0, 1), (b.0, 0, 0), (b.0, 0, 1)]); + // With another frame selected before, a marquee without Shift selects only the paths whose + // points it takes; with Shift the other frame stays selected. + let other = add_rect(&mut d, Rect::new(400.0, 400.0, 450.0, 450.0)); + let l = CanvasLayout::new(&d, false); + let was = Selection { items: vec![other], ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + for (mods, want) in [(Mods::default(), serde_json::json!([a.0, b.0])), (shift, serde_json::json!([other.0, a.0, b.0]))] { + t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Down, p0.x, p0.y).with_mods(mods)); + t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Drag, p1.x, p1.y).with_mods(mods)); + let up = t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Up, p1.x, p1.y).with_mods(mods)); + let Some(Action::Exec(_, p)) = up.last() else { panic!("{up:?}") }; + assert_eq!(p["ids"], want, "{mods:?}"); + } + // Those points selected: dragging one moves all four, in one preview. + let sel = Selection { items: vec![a, b], anchors: vec![(a, 0, 0), (a, 0, 1), (b, 0, 0), (b, 0, 1)], ..Default::default() }; + let cx = ctx(&d, &sel, &c, &l); + let at = spread_pt(&l, 200.0, 100.0); + let down = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + assert_eq!(down, vec![Action::Begin("Move Anchors".into())]); + let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 36.0, at.y + 3.0).with_mods(shift)); + let Some(Action::Preview(cmd, p)) = drag.last() else { panic!("{drag:?}") }; + assert_eq!(cmd, "path.moveAnchors"); + assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}"); + // Shift keeps the move horizontal. + // Each point carries its own movement (in its spread): here all the same. + let moves = |p: &serde_json::Value| -> Vec<(f64, f64)> { + p["points"].as_array().unwrap().iter().map(|v| (v[3].as_f64().unwrap(), v[4].as_f64().unwrap())).collect() + }; + assert!(moves(p).iter().all(|&(x, y)| (x - 36.0).abs() < 1e-9 && y.abs() < 1e-9), "{p}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 36.0, at.y)), vec![Action::Commit]); + // A Shift-drag that starts on a selected point keeps it: all four move, constrained. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); + let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 3.0, at.y + 30.0).with_mods(shift)); + let Some(Action::Preview(_, p)) = drag.last() else { panic!("{drag:?}") }; + assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}"); + assert!(moves(p).iter().all(|&(x, y)| x.abs() < 1e-9 && (y - 30.0).abs() < 1e-9), "vertical: {p}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y + 30.0).with_mods(shift)), vec![Action::Commit]); + // Shift+click on a selected point takes it out of the selection, on release; the arrow keys + // move the points. + let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); + assert!(!out.iter().any(|a| matches!(a, Action::Exec(..))), "kept while it may become a drag: {out:?}"); + // A jiggle inside the drag tolerance moves nothing: it is still a click. + let jiggle = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 1.0, at.y + 1.0).with_mods(shift)); + assert!(jiggle.is_empty(), "{jiggle:?}"); + let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 1.0, at.y + 1.0).with_mods(shift)); + let Some(Action::Exec(cmd, p)) = out.last() else { panic!("{out:?}") }; + assert_eq!(cmd, "selection.set"); + assert_eq!(p["anchors"].as_array().map(Vec::len), Some(3), "{p}"); + let keys = t.key(&cx, ToolKey::Right, Mods::default()); + assert!( + matches!(keys.as_slice(), [Action::Exec(cmd, p)] if cmd == "path.moveAnchors" && p["points"].as_array().map(Vec::len) == Some(4)), + "{keys:?}" + ); + // Delete deletes the selected points, not their paths. + assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("path.deletePoints".into(), serde_json::json!({}))]); + // On a spread turned a quarter on screen, → moves the points right on screen: up or down in + // the spread, not across it. + let mut turned = d.clone(); + std::sync::Arc::make_mut(&mut turned.spreads[0]).pages[0].view_rotation = 1; + let lt = CanvasLayout::new(&turned, false); + let cxt = ctx(&turned, &sel, &c, <); + let keys = t.key(&cxt, ToolKey::Right, Mods::default()); + let [Action::Exec(_, p)] = keys.as_slice() else { panic!("{keys:?}") }; + let inc = turned.settings.keyboard_increment; + let want = lt.delta_to_spread(SpreadRef::Doc(0), designcraft_geom::Vec2::new(inc, 0.0)); + assert!(want.x.abs() < 1e-9 && (want.y.abs() - inc).abs() < 1e-9, "a quarter turn: {want:?}"); + assert!(moves(p).iter().all(|&(x, y)| (x - want.x).abs() < 1e-9 && (y - want.y).abs() < 1e-9), "{p}"); +} diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index b879760f0..c03c32670 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -32,6 +32,9 @@ pub struct TypeTool { start: Option, selecting: Option, drawing: bool, + /// A press on an empty frame: (frame, press point in spread space). A click turns it into a + /// text frame with the caret in it; a drag draws a new text frame over it, as in InDesign. + convert: Option<(u64, Point)>, /// A press on a table whose whole rows or columns are selected: (frame, press point in /// spread space) — a drag moves them, a click places the caret. cell_drag: Option<(u64, Point)>, @@ -228,6 +231,7 @@ impl TypeTool { self.cell_drag = None; self.unit = None; self.border = None; + self.convert = None; if ev.kind == PointerKind::Down && let Some(b) = border_at(cx, ev.pos) { @@ -248,6 +252,11 @@ impl TypeTool { self.cell_drag = Some((id.0, sp)); return vec![]; } + // An empty frame: wait to see whether this is a click or a drag. + if clicks == 1 && !ev.mods.shift && !it.is_text_frame() { + self.convert = Some((id.0, sp)); + return vec![]; + } self.selecting = Some(id.0); // Two presses select a word (or a run of spaces), three a line, four a // paragraph, five the story. @@ -299,6 +308,7 @@ impl TypeTool { let mut out = vec![]; if !self.drawing { self.drawing = true; + self.convert = None; out.push(Action::Begin("Create Text Frame".into())); } out.push(Action::Preview( @@ -327,6 +337,14 @@ impl TypeTool { self.selecting = None; return vec![Action::Commit]; } + // A click in an empty frame: it becomes a text frame with the caret in it. A release + // away from the press (with no drag in between) isn't a click: nothing changes. + if let Some((fid, at)) = self.convert.take() { + if start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)) { + return vec![]; + } + return vec![Action::Exec("text.placeCaret".into(), json!({"frame": fid, "point": [at.x, at.y]}))]; + } // Ends a press in text: drops dragged text, or is ignored by the engine. if let Some(fid) = self.selecting.take() { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); @@ -372,6 +390,8 @@ impl Tool for TypeTool { let steps = if mods.cmd || mods.ctrl { 5.0 } else { 1.0 }; vec![Action::Exec("type.kernStep".into(), json!({"steps": sign * steps}))] }; + // A table cell's text, not a footnote's (which is addressed as a cell too). + let in_cell = cx.selection.text.is_some_and(|t| t.cell.is_some_and(|c| c.footnote_id().is_none())); match key { ToolKey::Left if mods.alt && !mods.shift => kern(-1.0), ToolKey::Right if mods.alt && !mods.shift => kern(1.0), @@ -384,7 +404,11 @@ impl Tool for TypeTool { ToolKey::Backspace => vec![Action::Exec("text.delete".into(), json!({"forward": false, "word": mods.alt}))], ToolKey::Delete => vec![Action::Exec("text.delete".into(), json!({"forward": true, "word": mods.alt}))], ToolKey::Enter => vec![Action::Exec("text.insert".into(), json!({"text": if mods.shift { "\u{2028}" } else { "\n" }}))], + // In a table cell Shift+Tab goes back a cell (Tab goes on through `text.insert`). + ToolKey::Tab if mods.shift && in_cell => vec![Action::Exec("table.prevCell".into(), json!({}))], ToolKey::Tab => vec![Action::Exec("text.insert".into(), json!({"text": "\t"}))], + // In a table Esc toggles between the cell's text and the cell, as InDesign. + ToolKey::Escape if in_cell => vec![Action::Exec("table.toggleSelection".into(), json!({}))], ToolKey::Escape => vec![Action::SwitchTool("selection".into()), Action::Exec("text.exitToFrame".into(), json!({}))], _ => vec![], } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 14d291d08..ed207c34b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -44,8 +44,8 @@ use input::{press_count, release_clicks}; #[cfg(test)] use page_decor::{MAX_BASELINES, baseline_grid_lines}; use page_decor::{dashed, draw_flattener_preview, draw_guides, shown_area}; -pub use render::DisplayKey; use render::render_texture; +pub use render::{DisplayKey, color_at}; #[cfg(test)] use render::{c32, invalidate_on_display_change, screen_options}; pub(crate) use rulers::fmt_tick; @@ -56,13 +56,13 @@ use scroll::{mods, navigation_space, scrollbars, spring_zoom_mode}; pub use text_overlay::selected_text; use text_overlay::{draw_dynamic_spelling, draw_hidden_characters}; #[cfg(test)] -use text_overlay::{draw_text_selection, hidden_marks}; +use text_overlay::{draw_text_selection, frame_hidden_marks, hidden_marks}; use tool_overlays::draw_tool_overlays; pub(crate) use view::page_in_view; use view::present_fit; pub use view::{ - apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, drop_point, - fit, go_through_history, go_to_page, set_zoom, show_spread, step_spread, view_centre, zoom_at, + PageBox, apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, + drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, zoom_at, }; pub const RULER: f32 = 15.0; @@ -327,47 +327,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } // Cursor. if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p) && !elsewhere) { - let wants_text = app.session.wants_text(); - let space = navigation_space(app, ui.ctx()); - let zoom = spring_zoom_mode(app, ui.ctx(), space); - let middle = ui.input(|i| i.pointer.middle_down()); - let alt_hand: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_alt_hand", app.pane)))).unwrap_or(false); - let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_pointer_down", app.pane)))).unwrap_or(false); - let alt_text = wants_text && !tool_drag && ui.input(|i| i.modifiers.alt); - let c = if middle || alt_hand { - Cursor::HandGrab - } else if zoom { - if ui.input(|i| i.modifiers.alt) { Cursor::ZoomOut } else { Cursor::ZoomIn } - } else if (space && !ui.input(|i| i.modifiers.command)) || alt_text { - Cursor::Hand - } else { - app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) - }; - // A column guide that a press would drag, or one being dragged: a resize cursor across it. - let dragging = ui.data(|d| d.get_temp::(egui::Id::unique((COLUMN_DRAG, app.pane)))).map(|g| g.spread); - let column = dragging.or_else(|| { - let menu_open = context_menu_open(ui.ctx()); - (!space && !zoom && !middle && !alt_hand && !tool_drag && !menu_open && xf.rot == 0) - .then(|| column_guide_at(app, &xf, p).map(|h| h.0)) - .flatten() - }); - let c = match column { - Some(r) if spread_x_across(app, r) => Cursor::ResizeH, - Some(_) => Cursor::ResizeV, - None => c, - }; - // A ruler guide being dragged keeps the cursor across it until it is dropped, over the - // canvas too (the tool's cursor took over as soon as the pointer left the ruler). Only - // while the button is down: a drag state left behind by a skipped release doesn't stick. - let dragging = ui.input(|i| i.pointer.primary_down()); - let c = match ui.data(|d| d.get_temp::(egui::Id::unique(GUIDE_DRAG))).filter(|_| dragging).map(|g| g.orientation) { - Some(designcraft_doc::Orientation::Horizontal) => Cursor::ResizeV, - Some(designcraft_doc::Orientation::Vertical) => Cursor::ResizeH, - None => c, - }; + let c = pointer_cursor(app, ui, &xf, p); let icon = crate::cursors::show(ui.ctx(), &mut app.cursors, c, p); ui.ctx().set_cursor_icon(icon); draw_cursor_badge(&painter, p, c, &t); + if c == Cursor::LoadedGraphic { + draw_loaded_thumbnail(app, &painter, p, &t); + } } // A second viewport has no panels, dialogs or Quick Apply after its canvas: observe here, // once, as the end of its pass. The root viewport observes at the end of `DesignApp::ui`. @@ -376,6 +342,100 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// The cursor under the pointer at `p` over the canvas: the navigation hands, then the guide +/// resize cursors, then the active tool's. +fn pointer_cursor(app: &mut DesignApp, ui: &egui::Ui, xf: &Xf, p: Pos2) -> Cursor { + let wants_text = app.session.wants_text(); + let space = navigation_space(app, ui.ctx()); + let zoom = spring_zoom_mode(app, ui.ctx(), space); + let middle = ui.input(|i| i.pointer.middle_down()); + let alt_hand: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_alt_hand", app.pane)))).unwrap_or(false); + let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_pointer_down", app.pane)))).unwrap_or(false); + let alt_text = wants_text && !tool_drag && ui.input(|i| i.modifiers.alt); + let c = if middle || alt_hand { + Cursor::HandGrab + } else if zoom { + if ui.input(|i| i.modifiers.alt) { Cursor::ZoomOut } else { Cursor::ZoomIn } + } else if (space && !ui.input(|i| i.modifiers.command)) || alt_text { + Cursor::Hand + } else { + app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) + }; + // A column guide that a press would drag, or one being dragged: a resize cursor across it. + let dragging = ui.data(|d| d.get_temp::(egui::Id::unique((COLUMN_DRAG, app.pane)))).map(|g| g.spread); + let column = dragging.or_else(|| { + let menu_open = context_menu_open(ui.ctx()); + (!space && !zoom && !middle && !alt_hand && !tool_drag && !menu_open && xf.rot == 0) + .then(|| column_guide_at(app, xf, p).map(|h| h.0)) + .flatten() + }); + let c = match column { + Some(r) if spread_x_across(app, r) => Cursor::ResizeH, + Some(_) => Cursor::ResizeV, + None => c, + }; + // A ruler guide being dragged keeps the cursor across it until it is dropped, over the + // canvas too (the tool's cursor took over as soon as the pointer left the ruler). Only + // while the button is down: a drag state left behind by a skipped release doesn't stick. + let dragging = ui.input(|i| i.pointer.primary_down()); + match ui.data(|d| d.get_temp::(egui::Id::unique(GUIDE_DRAG))).filter(|_| dragging).map(|g| g.orientation) { + Some(designcraft_doc::Orientation::Horizontal) => Cursor::ResizeV, + Some(designcraft_doc::Orientation::Vertical) => Cursor::ResizeH, + None => c, + } +} + +/// Longest side of the loaded place cursor's thumbnail, in points. +const LOADED_THUMB: f32 = 128.0; + +/// The loaded place cursor's thumbnail, as InDesign shows it: the graphic, at most 128 pt on its +/// longer side, hanging from the pointer, with an image badge in its corner. +fn draw_loaded_thumbnail(app: &DesignApp, painter: &egui::Painter, p: Pos2, t: &Tokens) { + let (Some(l), Ok(st)) = (app.session.loaded.as_ref(), app.session.doc()) else { return }; + let (w, h) = (l.size.0 as f32, l.size.1 as f32); + if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { + return; + } + let k = LOADED_THUMB / w.max(h); + let r = Rect::from_min_size(p + vec2(2.0, 2.0), vec2(w * k, h * k)); + if let Some(tex) = loaded_thumb_texture(painter.ctx(), &st.doc, l.asset, l.size) { + painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::from_white_alpha(225)); + } else { + // Not drawable here (an unreadable file): its frame only. + painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.measure_text), StrokeKind::Inside); + } + let badge = Rect::from_min_size(r.min + vec2(6.0, 6.0), vec2(16.0, 12.0)); + painter.rect_filled(badge, 2.0, t.measure_bg); + let ink = Stroke::new(1.2, t.measure_text); + let inner = badge.shrink2(vec2(3.0, 2.5)); + painter.rect_stroke(inner, 0.5, ink, StrokeKind::Middle); + painter.add(egui::Shape::line( + vec![pos2(inner.min.x, inner.max.y), pos2(inner.min.x + 3.5, inner.min.y + 2.5), pos2(inner.max.x, inner.max.y)], + ink, + )); +} + +/// The loaded graphic's thumbnail texture, cached per image data. +fn loaded_thumb_texture(ctx: &egui::Context, doc: &Document, asset: designcraft_doc::AssetId, size: (f64, f64)) -> Option { + let a = doc.assets.get(&asset)?; + let key = egui::Id::unique(("loaded_thumb", std::sync::Arc::as_ptr(&a.data) as usize, a.data.len(), a.page)); + if let Some(t) = ctx.data(|d| d.get_temp::(key)) { + return Some(t); + } + // Twice the thumbnail's size in pixels, for high-density screens. + let scale = (2.0 * f64::from(LOADED_THUMB) / size.0.max(size.1)).min(1.0); + let pm = designcraft_render::images::mip(&a.data, a.page, size.0, scale)?; + let (pw, ph) = (pm.width() as usize, pm.height() as usize); + if pw == 0 || ph == 0 { + return None; + } + let ci = egui::ColorImage::from_rgba_premultiplied([pw, ph], pm.data_as_u8_slice()); + let max = ctx.input(|i| i.max_texture_side).min(1024); + let t = ctx.load_texture("loaded_thumb", crate::widgets::fit_texture(ci, max), egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(key, t.clone())); + Some(t) +} + /// Presses in a row on the canvas: the last press's count, and where and when it was released /// (`at` is −∞ while the button is down). #[derive(Clone, Copy, Debug)] diff --git a/crates/ui-egui/src/canvas/column_guides.rs b/crates/ui-egui/src/canvas/column_guides.rs index 34d995fda..c15595442 100644 --- a/crates/ui-egui/src/canvas/column_guides.rs +++ b/crates/ui-egui/src/canvas/column_guides.rs @@ -106,6 +106,7 @@ fn snap_gutter(app: &DesignApp, g: &ColumnDrag, want: f64, shift: bool, zoom: f6 snap: designcraft_tools::SnapView { smart_dimensions: false, smart_spacing: false, ..app.ui.snap_view() }, unit: app.ui.units, stroke_in_front: false, + loaded: None, }; let rect = DRect::new(want, m.y0, want + width, m.y1); let s = designcraft_tools::snap::snap( diff --git a/crates/ui-egui/src/canvas/frames.rs b/crates/ui-egui/src/canvas/frames.rs index e5be4f7c6..718904d7f 100644 --- a/crates/ui-egui/src/canvas/frames.rs +++ b/crates/ui-egui/src/canvas/frames.rs @@ -167,14 +167,16 @@ pub(super) fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, } let mut union: Option = None; let mut color = Color32::from_rgb(79, 153, 255); + let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection"); for id in &sel.items { let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue }; color = layer_color(doc, it); for poly in path_screen(it, xf, a) { painter.add(egui::Shape::line(poly, Stroke::new(1.0, color))); } - if sel.items.len() == 1 && sel.text.is_none() { - // InDesign draws a 1 pt bounding box around the selection. + // InDesign draws a 1 pt bounding box around the selection; with the Direct Selection and + // pen tools, only the path and its points. + if sel.items.len() == 1 && sel.text.is_none() && !direct { painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(1.0, color), StrokeKind::Middle); } else if sel.key == Some(*id) { // The key object for Align: a thicker bounding box, as in InDesign. @@ -205,16 +207,26 @@ pub(super) fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, } } } - let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection"); + // Direct Selection shows only the handles around the selected anchors, as InDesign; the pen + // and the anchor tools show every handle they work on. + let some_handles = app.session.tool_id() == "directSelection"; if direct { + // Looked up once per anchor drawn: a large marquee selection stays linear. + let chosen_set: std::collections::HashSet<(designcraft_doc::ItemId, usize, usize)> = sel.anchors.iter().copied().collect(); for id in &sel.items { let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue }; let m = a * it.xf; let col = layer_color(doc, it); - for sp in &it.path.subpaths { - for an in &sp.anchors { + for (si, sp) in it.path.subpaths.iter().enumerate() { + for (ai, an) in sp.anchors.iter().enumerate() { let p = xf.to_screen(m * an.p); - for (h, has) in [(an.h_in, an.has_in()), (an.h_out, an.has_out())] { + // A selected anchor point is solid, the others hollow (InDesign). + let chosen = chosen_set.contains(&(*id, si, ai)); + let shown = |out: bool| { + !some_handles + || designcraft_tools::select::handle_shown(sp.anchors.len(), sp.closed, ai, out, |a| chosen_set.contains(&(*id, si, a))) + }; + for (h, has) in [(an.h_in, an.has_in() && shown(false)), (an.h_out, an.has_out() && shown(true))] { if has { let hp = xf.to_screen(m * h); painter.line_segment([p, hp], Stroke::new(1.0, col)); @@ -222,7 +234,7 @@ pub(super) fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, } } let r = Rect::from_center_size(p, vec2(5.0, 5.0)); - painter.rect_filled(r, 0.0, Color32::WHITE); + painter.rect_filled(r, 0.0, if chosen { col } else { Color32::WHITE }); painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); } } diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs index eebd41208..4689b5fb6 100644 --- a/crates/ui-egui/src/canvas/guides.rs +++ b/crates/ui-egui/src/canvas/guides.rs @@ -143,6 +143,7 @@ pub(super) fn guide_spot(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option bool { snap: app.ui.snap_view(), unit: app.ui.units, stroke_in_front: false, + loaded: None, }; cx.hit(xf.to_canvas(p)).is_some() } diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 3e2f32659..4616ca11d 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -337,9 +337,12 @@ pub(super) fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui:: events.push(PointerEvent { kind: PointerKind::Down, pos: pos(o), mods: m, clicks: n }); down = true; } + // The place cursor draws a frame from 3 points (its own threshold), before egui starts a + // drag (at `max_click_dist`): follow the pointer from there, so a release between the two + // places the frame that was previewed. + let place_drag = app.session.tool_id() == "placeGun" && origin.zip(latest).is_some_and(|(o, p)| (o - p).length() >= 3.0); if down - && resp.dragged() - && resp.drag_delta() != egui::Vec2::ZERO + && ((resp.dragged() && resp.drag_delta() != egui::Vec2::ZERO) || place_drag) && let Some(p) = latest { events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 1 }); diff --git a/crates/ui-egui/src/canvas/render.rs b/crates/ui-egui/src/canvas/render.rs index 642694b8a..07983944e 100644 --- a/crates/ui-egui/src/canvas/render.rs +++ b/crates/ui-egui/src/canvas/render.rs @@ -1,9 +1,40 @@ -use super::{Affine, CanvasLayout, Color32, DRect, DesignApp, Point, Rect, Xf}; +use super::{Affine, CanvasLayout, Color32, DRect, DesignApp, Point, Pos2, Rect, ScreenMode, Xf}; pub(super) fn c32(c: [u8; 3]) -> Color32 { Color32::from_rgb(c[0], c[1], c[2]) } +/// The colour the canvas shows at screen point `pos` (the Color Picker's eyedropper): one device +/// pixel rendered as the page is, over its paper. None off the pages. +pub fn color_at(app: &mut DesignApp, ctx: &egui::Context, pos: Pos2) -> Option<[u8; 3]> { + let rect = app.canvas_rect.filter(|r| r.contains(pos))?; + let v = *app.view()?; + let xf = Xf::new(rect, &v); + let at = xf.to_canvas(pos); + let st = app.session.active()?; + let doc = st.doc.clone(); + let layout = CanvasLayout::new(&doc, st.editing_parents); + let on_page = layout + .slots + .iter() + .any(|slot| doc.spread(slot.spread).is_some_and(|sp| sp.pages.iter().any(|page| slot.xf.transform_rect_bbox(page.bounds()).contains(at)))); + if !on_page { + return None; + } + let placed: Vec = layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, xf: s.xf }).collect(); + let ppp = ctx.pixels_per_point() as f64; + let view = Affine::scale(ppp) * Affine::scale(xf.zoom) * Affine::translate((-at.x, -at.y)); + let opts = screen_options(app, &layout, app.ui.screen_mode != ScreenMode::Normal, ppp); + let mut img = app.canvas.patcher.render(&doc, &app.session.cache, &placed, (1, 1), view, &opts); + // The colour as the canvas shows it (proofed). + screen_post(app, &mut img); + let [r, g, b, a] = <[u8; 4]>::try_from(img.pixels.get(..4)?).ok()?; + // Premultiplied over the paper. + let paper = doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or(designcraft_color::Color::WHITE).to_rgba8(1.0); + let over = |c: u8, p: u8| (u32::from(c) + u32::from(p) * (255 - u32::from(a)) / 255).min(255) as u8; + Some([over(r, paper[0]), over(g, paper[1]), over(b, paper[2])]) +} + /// Render options for the canvas texture. pub(super) fn screen_options(app: &DesignApp, layout: &CanvasLayout, preview: bool, ppp: f64) -> designcraft_render::RenderOptions { designcraft_render::RenderOptions { @@ -268,10 +299,16 @@ fn render_worker_spawn(ctx: &egui::Context) -> Option Some(r), + _ => None, + }) + .expect("the frame is previewed"); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + let placed = h.state().session.doc().unwrap().doc.spreads[0].items.last().expect("placed").bounds(); + let shown = slot.inverse().transform_rect_bbox(preview); + for (a, b) in [(placed.x0, shown.x0), (placed.y0, shown.y0), (placed.x1, shown.x1), (placed.y1, shown.y1)] { + assert!((a - b).abs() < 0.01, "placed {placed:?}, previewed {shown:?}"); + } +} + #[test] fn a_press_after_a_drag_is_not_a_double_click() { let (delay, dist) = (0.3, 6.0); @@ -1797,6 +1848,7 @@ fn document_grid_is_drawn_where_points_snap_to_it() { snap: SnapView { snap_to_document_grid: true, zone_px: 20.0, ..SnapView::OFF }, unit: Unit::Points, stroke_in_front: false, + loaded: None, }; let (snapped, _) = designcraft_tools::snap::snap_point(&cx, sr, Point::new(page.x0 + 31.3, page.y0 + 47.9)); assert_eq!((snapped.x - page.x0, snapped.y - page.y0), (27.0, 45.0)); @@ -2862,3 +2914,49 @@ fn trailing_spaces_show_dots_before_the_paragraph_mark() { assert!(para_x >= space.x + space.adv, "{mark} at {para_x} overlaps the last space"); } } + +/// As InDesign: an end-of-story # in every cell (after its text), a ¶ where a cell paragraph +/// ends, and the table paragraph's ¶ just after the table's bottom right corner. +#[test] +fn table_cells_show_their_hidden_characters() { + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), DRect::new(0.0, 0.0, 300.0, 300.0), lid, "", Default::default()).unwrap(); + let data = vec![vec!["Name:".to_string(), "".into()], vec!["a\nb".into(), "".into()]]; + let t = designcraft_doc::Table::from_strings(d.alloc(), &data, 200.0, &Default::default(), &Default::default()); + d.story_mut(sid).unwrap().insert_table(0, t); + let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); + let frag = &cs.frames[0].tables[0]; + // The whole frame: the table's own line adds no second ¶; the story's last paragraph + // (after the table) has its #. + let story = d.story(sid).unwrap().text.clone(); + let all = frame_hidden_marks(&story, &cs.frames[0]); + let marks: Vec<_> = all.iter().filter(|m| frag.rect.inflate(2.0, 2.0).contains(m.0)).cloned().collect(); + let ends = |m: &str| marks.iter().filter(|x| x.1 == m).count(); + assert_eq!(ends("#"), 4, "one per cell: {marks:?}"); + assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's, once: {marks:?}"); + assert_eq!(all.iter().filter(|m| m.1 == "¶" && m.3 > 20.0).count(), 0, "no ¶ the size of the table: {all:?}"); + // Each # is inside its cell, after the cell's text. + for c in &frag.cells { + let inside = marks.iter().filter(|m| m.1 == "#" && c.rect.contains(m.0)).count(); + assert_eq!(inside, 1, "cell {},{}: {marks:?}", c.row, c.col); + } + let name = frag.cell(0, 0).unwrap(); + assert!(marks.iter().any(|m| m.1 == "#" && name.rect.contains(m.0) && m.0.x > name.origin.x + 20.0), "after Name:"); + let last = marks.last().unwrap(); + assert_eq!((last.0, last.1), (designcraft_geom::Point::new(frag.rect.x1 + 1.0, frag.rect.y1), "¶")); +} + +#[test] +fn leaving_parent_editing_for_the_left_page_of_a_spread_keeps_that_page_selected() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); + let mut h = harness(app); + h.state_mut().run("layout.parents.edit", json!({"on": true, "parent": "A-Parent"})).unwrap(); + h.run_steps(2); + let b = page_box(h.state()).unwrap(); + // Page 2 is the left page of the second spread. + page_box_go(h.state_mut(), &b, 1); + h.run_steps(3); + assert_eq!(current_page(h.state()), Some(1)); +} diff --git a/crates/ui-egui/src/canvas/text_overlay.rs b/crates/ui-egui/src/canvas/text_overlay.rs index dc48ae720..521759eb0 100644 --- a/crates/ui-egui/src/canvas/text_overlay.rs +++ b/crates/ui-egui/src/canvas/text_overlay.rs @@ -266,6 +266,8 @@ fn draw_inverse_highlight( } /// View → Show Hidden Characters: ¶ paragraph ends, » tabs, · spaces, ¬ forced line breaks, # end of story. +/// As InDesign, every table cell shows its own marks (an empty cell its #), and a table's +/// paragraph return sits after the table's bottom right corner. pub(super) fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) { for story in doc.stories.values() { let cs = app.session.cache.get(doc, story.id, None); @@ -273,19 +275,41 @@ pub(super) fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, x let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; let m = a * doc.text_xf(it); let col = layer_color(doc, it); - for l in &ft.lines { - let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; - let font = egui::FontId::proportional(size); - for (x, mark, centered) in hidden_marks(&story.text, l) { - let p = xf.to_screen(m * Point::new(x, l.baseline)); - let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; - painter.text(p, align, mark, font.clone(), col); - } + for (p, mark, centered, ascent) in frame_hidden_marks(&story.text, ft) { + let size = (ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; + let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; + painter.text(xf.to_screen(m * p), align, mark, egui::FontId::proportional(size), col); } } } } +/// A frame's hidden-character marks in frame space (position at the baseline, mark, centered, +/// the line's ascent): its lines' marks, then its tables' — each text cell's own marks (nested +/// tables included) with its # where its text ends, and a table paragraph's ¶ after the last +/// row's bottom right corner. A table's own glyph-less line has no marks: its ¶ is that one. +pub(super) fn frame_hidden_marks(text: &str, ft: &compose::FrameText) -> Vec<(Point, &'static str, bool, f64)> { + let table_lines: Vec = ft.tables.iter().map(|t| t.line).collect(); + let mut out: Vec<_> = ft + .lines + .iter() + .enumerate() + .filter(|(i, _)| !table_lines.contains(i)) + .flat_map(|(_, l)| hidden_marks(text, l).into_iter().map(move |(x, mark, c)| (Point::new(x, l.baseline), mark, c, l.ascent))) + .collect(); + for t in &ft.tables { + for c in t.cells.iter().filter(|c| c.graphic.is_none()) { + if let Some(cf) = c.text.frames.first() { + out.extend(frame_hidden_marks(&c.source, cf).into_iter().map(|(p, mark, centered, a)| (p + c.origin.to_vec2(), mark, centered, a))); + } + } + if t.last { + out.push((Point::new(t.rect.x1 + 1.0, t.rect.y1), "¶", false, 12.0)); + } + } + out +} + /// The hidden-character marks on line `l` of a story with text `text`: frame-space x, mark, and /// whether the mark is centered on x (spaces) or starts at it. pub(super) fn hidden_marks(text: &str, l: &compose::Line) -> Vec<(f64, &'static str, bool)> { @@ -346,17 +370,31 @@ pub(super) fn draw_dynamic_spelling(app: &DesignApp, ctx: &egui::Context, painte }; let y = l.baseline + l.descent * 0.5; let (p0, p1) = (xf.to_screen(m * Point::new(x0, y)), xf.to_screen(m * Point::new(x1, y))); - // A zigzag 2 px high, 4 px per wave. - let n = ((p1.x - p0.x) / 2.0).max(1.0) as usize; - let pts: Vec = - (0..=n).map(|k| pos2(p0.x + (p1.x - p0.x) * k as f32 / n as f32, p0.y + if k % 2 == 0 { 0.0 } else { 2.0 })).collect(); - painter.add(egui::Shape::line(pts, red)); + // Screen direction of the text's own "down", so the wave hangs below the baseline in rotated, flipped and vertical frames. + let down = xf.to_screen(m * Point::new(x0, y + 1.0)) - p0; + painter.add(egui::Shape::line(squiggle_points(p0, p1, down), red)); } } } } } +/// Zigzag points for a spelling squiggle from `p0` to `p1` (screen space): 2 px high, 4 px per wave, +/// offset along `down` (the screen direction of the text's own "down") so it follows the text frame's +/// rotation and flips. +pub(super) fn squiggle_points(p0: Pos2, p1: Pos2, down: egui::Vec2) -> Vec { + let d = p1 - p0; + let n = (d.length() / 2.0).max(1.0) as usize; + let len = down.length(); + let off = if len > 1e-6 { down / len } else { egui::vec2(0.0, 1.0) }; + (0..=n) + .map(|k| { + let wave = if k % 2 == 0 { 0.0 } else { 2.0 }; + p0 + d * (k as f32 / n as f32) + off * wave + }) + .collect() +} + /// Plain text of the current text selection (story markers resolved to readable characters). pub fn selected_text(app: &DesignApp) -> Option { let st = app.session.active()?; @@ -368,3 +406,38 @@ pub fn selected_text(app: &DesignApp) -> Option { } Some(s.replace(designcraft_doc::story::FORCED_LINE_BREAK, "\n")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn squiggle_horizontal_offsets_down() { + let pts = squiggle_points(pos2(0.0, 10.0), pos2(20.0, 10.0), egui::vec2(0.0, 1.0)); + assert_eq!(pts.len(), 11); + assert!((pts[0].y - 10.0).abs() < 1e-4); + assert!((pts[1].y - 12.0).abs() < 1e-4); + assert!((pts[1].x - 2.0).abs() < 1e-4); + } + + #[test] + fn squiggle_follows_rotation() { + // Frame rotated 90 degrees: the line runs down the screen and the text's "down" points left, + // so the wave must move sideways, not along y. + let pts = squiggle_points(pos2(10.0, 0.0), pos2(10.0, 20.0), egui::vec2(-3.0, 0.0)); + assert_eq!(pts.len(), 11); + assert!((pts[1].y - 2.0).abs() < 1e-4); + assert!((pts[1].x - 8.0).abs() < 1e-4); + assert!((pts[0].x - 10.0).abs() < 1e-4); + } + + #[test] + fn squiggle_follows_horizontal_flip() { + // A horizontally flipped frame runs the word right to left; the wave still hangs below it. + let pts = squiggle_points(pos2(20.0, 10.0), pos2(0.0, 10.0), egui::vec2(0.0, 1.0)); + assert_eq!(pts.len(), 11); + assert!((pts[1].x - 18.0).abs() < 1e-4); + assert!((pts[1].y - 12.0).abs() < 1e-4); + assert!(pts[10].x.abs() < 1e-4); + } +} diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs index 5720ba030..e6beb1e65 100644 --- a/crates/ui-egui/src/canvas/tool_overlays.rs +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -31,10 +31,10 @@ pub(super) fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, x } Overlay::Measure { p, text } => { let s = xf.to_screen(p) + vec2(14.0, 14.0); - let g = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), tok.measure_text); + let g = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), tok.readout_text); let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0)); - painter.rect_filled(r, 3.0, tok.measure_bg); - painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); + painter.rect_filled(r, 3.0, tok.readout_bg); + painter.galley(r.min + vec2(5.0, 3.0), g, tok.readout_text); } Overlay::Line { a, b, color, dashed: d } => { let st = Stroke::new(1.0, c32(color)); diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index db3eebcc8..2ba0876a1 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -153,6 +153,54 @@ pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> { Some((pi, qi)) } +/// The page box's entries (the status bar's, and Properties › Page), as InDesign's: the +/// document's pages by name, then the parents by label ("A-Parent"). +pub struct PageBox { + pub names: Vec, + /// The entry in view: the parent shown while parents are edited, else the page. + pub current: usize, + /// How many of `names` are pages; the parents follow. + pub pages: usize, + parents: Vec, +} + +impl PageBox { + /// The name of the entry in view ("3", "A-Parent"). + pub fn current_name(&self) -> &str { + self.names.get(self.current).map_or("", String::as_str) + } +} + +pub fn page_box(app: &DesignApp) -> Option { + let st = app.session.active()?; + let pages = st.doc.page_count(); + let mut names: Vec = (0..pages).map(|i| app.session.page_label(i)).collect(); + names.extend(st.doc.parents.iter().map(|p| p.parent.as_ref().map(|i| i.label()).unwrap_or_default())); + let current = match current_parent_page(app) { + Some((pi, _)) => pages + pi, + None => current_page(app).unwrap_or(0).min(pages.saturating_sub(1)), + }; + Some(PageBox { names, current, pages, parents: st.doc.parents.iter().map(|p| p.id).collect() }) +} + +/// Shows entry `i` of `b`: a page (leaving the parents, if they are shown) or a parent. +pub fn page_box_go(app: &mut DesignApp, b: &PageBox, i: usize) { + if i < b.pages { + if app.session.active().is_some_and(|st| st.editing_parents) { + let _ = app.run("layout.parents.edit", serde_json::json!({"on": false})); + // Fit the page's spread first, then centre the page: a later refit would centre the + // spread, and the page box would then show its other page. + let spread = app.session.active().and_then(|st| st.doc.page_loc(i)).map(|(si, _)| SpreadRef::Doc(si)); + if let Some(r) = spread { + show_spread(app, r); + } + } + go_to_page(app, i); + } else if let Some(id) = b.parents.get(i - b.pages) { + let _ = app.run("layout.parents.edit", serde_json::json!({"parent": id.0})); + } +} + /// Scroll so absolute page `abs` is centred (keeps zoom). A jump Go Back can return from. pub fn go_to_page(app: &mut DesignApp, abs: usize) { note_jump(app, false); diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index e0a9d27e2..5fde929be 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -114,22 +114,46 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { if icons::button(ui, "prev", 14.0, false, crate::i18n::tr(&app.ui.language, "Previous Spread")).clicked() { crate::canvas::go_to_page(app, cur.saturating_sub(1)); } - let name = app.session.page_label(cur); + // The page box, as InDesign's: the page in view, or the parent while one is shown + // ("A-Parent"); its menu lists the pages, then the parents. + let pb = crate::canvas::page_box(app); + let name = pb.as_ref().map_or_else(String::new, |b| b.current_name().to_string()); let (fr, fresp) = ui.allocate_exact_size(vec2(80.0, 14.0), Sense::click()); ui.painter().rect(fr, 0.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - ui.painter().text(fr.left_center() + vec2(5.0, 0.0), egui::Align2::LEFT_CENTER, &name, small.clone(), t.text); + // A long parent name is cut at the arrow. + ui.painter().with_clip_rect(egui::Rect::from_min_max(fr.min, egui::pos2(fr.max.x - 14.0, fr.max.y))).text( + fr.left_center() + vec2(5.0, 0.0), + egui::Align2::LEFT_CENTER, + &name, + small.clone(), + t.text, + ); ui.painter().text(fr.right_center() - vec2(5.0, 0.0), egui::Align2::RIGHT_CENTER, "▾", small.clone(), t.text); - egui::Popup::menu(&fresp).show(|ui| { - let names: Vec = (0..n).map(|i| app.session.page_label(i)).collect(); - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for (i, nm) in names.iter().enumerate() { - if ui.button(nm).clicked() { - crate::canvas::go_to_page(app, i); - ui.close(); - } - } - }); + let label = crate::i18n::tr(&app.ui.language, "Page"); + fresp.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, label); + info.current_text_value = Some(name.clone()); + info }); + let mut picked = None; + if let Some(b) = &pb { + egui::Popup::menu(&fresp).show(|ui| { + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for (i, nm) in b.names.iter().enumerate() { + if i == b.pages && i > 0 { + ui.separator(); + } + if ui.selectable_label(i == b.current, nm).clicked() { + picked = Some(i); + ui.close(); + } + } + }); + }); + } + if let (Some(b), Some(i)) = (&pb, picked) { + crate::canvas::page_box_go(app, b, i); + } if icons::button(ui, "next", 14.0, false, crate::i18n::tr(&app.ui.language, "Next Spread")).clicked() { crate::canvas::go_to_page(app, (cur + 1).min(n - 1)); } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 5e420de98..742aba403 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -180,6 +180,51 @@ fn status_bar_error_count_opens_the_preflight_panel() { assert!(crate::panel_docking::showing(&h.state().app, "preflight")); } +/// The course's chapter 25: while a parent is shown, the status bar's page box names it +/// ("A-Parent"), as InDesign's. Its menu lists the pages, then the parents: a parent shows +/// that parent, a page leaves the parents for that page. +#[test] +fn the_page_box_names_the_parent_in_view_and_lists_the_parents() { + use egui::accesskit::Role; + let mut app = app(); + app.run("file.new", serde_json::json!({"pages": 4, "facingPages": true})).unwrap(); + app.run("layout.parents.new", serde_json::json!({"prefix": "B", "name": "Cover"})).unwrap(); + app.ui.dock_tab = "properties".into(); + let mut h = test_window::open(app, vec2(1200.0, 700.0)); + let shown = |h: &egui_kittest::Harness<'static, test_window::Window>| { + let b = h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")); + b.value().unwrap_or_default() + }; + assert_eq!(shown(&h), "1"); + h.state_mut().app.run("layout.parents.edit", serde_json::json!({"on": true})).unwrap(); + h.run_steps(6); + assert_eq!(shown(&h), "A-Parent"); + // The menu: a parent. + h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); + h.run_steps(3); + for name in ["1", "2", "3", "4", "A-Parent"] { + // The canvas or a panel may show the name too: the menu lists it. + assert!(h.query_all_by_label(name).next().is_some(), "{name} is listed"); + } + // The menu, drawn last, holds the last match. + h.get_all_by_label("B-Cover").last().unwrap().click(); + h.run_steps(6); + assert_eq!(shown(&h), "B-Cover"); + assert_eq!(crate::canvas::current_parent_page(&h.state().app).map(|(i, _)| i), Some(1)); + // A page: the parents are left. + h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); + h.run_steps(3); + h.get_all_by_label("3").last().unwrap().click(); + h.run_steps(6); + assert!(!h.state().app.session.active().unwrap().editing_parents); + assert_eq!(crate::canvas::current_page(&h.state().app), Some(2)); + assert_eq!(shown(&h), "3"); + // Its accessible name is in the interface's language. + h.state_mut().app.ui.language = "de".into(); + h.run_steps(3); + assert!(h.query_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Seite")).is_some(), "the page box is Seite"); +} + /// As in InDesign, double-clicking the preflight count (or its light) shows the panel. #[test] fn double_clicking_the_status_bar_error_count_opens_the_preflight_panel() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bb3077103..f8532d9cc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -36,6 +36,8 @@ impl Dialog { "epubExport" => json!({"split": false, "mode": "style", "style": designcraft_doc::BASIC_PARAGRAPH}), "qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}), "insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}), + "convertTextToTable" => json!({"columnSeparator": "tab", "rowSeparator": "paragraph", "columns": 1}), + "convertTableToText" => json!({"columnSeparator": "tab", "rowSeparator": "paragraph"}), "insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}), "findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}), "documentSetup" => json!({}), @@ -149,6 +151,218 @@ fn text_field_named(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32, name: } } +/// Insert Pages…, after page `abs` and with that page's parent, as InDesign's. +pub(crate) fn open_insert_pages(app: &mut DesignApp, abs: usize) { + let Some(st) = app.session.active() else { return }; + let abs = abs.min(st.doc.page_count().saturating_sub(1)); + let parent = st.doc.page(abs).and_then(|p| p.parent).map_or_else(String::new, |id| id.0.to_string()); + app.ui.dialog = Some(Dialog::new("insertPages", json!({"count": 1, "insert": "after", "page": abs, "parent": parent}))); +} + +/// Insert Pages, as InDesign's: how many, where (after or before a page, at the start or the end +/// of the document) and with which parent. +fn insert_pages_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let Some(st) = app.session.active() else { return }; + let pages: Vec = (0..st.doc.page_count()).map(|i| app.session.page_label(i)).collect(); + let mut parents = vec![(String::new(), crate::i18n::tr(&lang, "[None]").to_string())]; + parents.extend(st.doc.parents.iter().map(|p| (p.id.0.to_string(), p.parent.as_ref().map(|i| i.label()).unwrap_or_default()))); + egui::Grid::new("insert_pages").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Pages:")); + let mut n = d.n("count").unwrap_or(1.0).clamp(1.0, 9999.0) as u32; + if ui.add(egui::DragValue::new(&mut n).range(1..=9999)).labelled_by(name.id).changed() { + d.fields.insert("count".into(), json!(n)); + } + ui.end_row(); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Insert:")); + ui.horizontal(|ui| { + let wheres = [("after", "After Page"), ("before", "Before Page"), ("start", "At Start of Document"), ("end", "At End of Document")]; + translated_combo(&lang, ui, d, "insert", &wheres).labelled_by(name.id); + // The page, by name: At Start and At End need none. + ui.add_enabled_ui(matches!(d.s("insert").as_str(), "after" | "before"), |ui| { + let cur = (d.n("page").unwrap_or(0.0).max(0.0) as usize).min(pages.len().saturating_sub(1)); + craft_ui::ComboBox::from_id_salt("insert_page") + .selected_text(pages.get(cur).cloned().unwrap_or_default()) + .width(64.0) + .show_ui(ui, |ui| { + for (i, name) in pages.iter().enumerate() { + if ui.selectable_label(i == cur, name).clicked() { + d.fields.insert("page".into(), json!(i)); + } + } + }) + .response + .accessible_name(crate::i18n::tr(&lang, "Page").to_owned()); + }); + }); + ui.end_row(); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Parent:")); + let opts: Vec<(&str, &str)> = parents.iter().map(|(v, l)| (v.as_str(), l.as_str())).collect(); + combo(ui, d, "parent", &opts).labelled_by(name.id); + ui.end_row(); + }); +} +/// The temporary-data key the Printing Instructions dialog sets, each frame, while one of its +/// multi-line fields has focus: Enter then makes a new line there, and the footer doesn't take it +/// for OK. No other dialog changes how Enter works. +const MULTILINE_FOCUS: &str = "dialog_multiline_focus"; + +pub(crate) fn set_multiline_focus(ctx: &egui::Context) { + ctx.data_mut(|m| m.insert_temp(egui::Id::unique(MULTILINE_FOCUS), true)); +} + +/// Whether Enter in dialog `id` goes to a multi-line field this frame. The flag is read (and cleared) +/// for every dialog, but only the Printing Instructions dialog honours it. +fn take_multiline_focus(ctx: &egui::Context, id: &str) -> bool { + let set = ctx.data_mut(|m| m.remove_temp::(egui::Id::unique(MULTILINE_FOCUS))).unwrap_or(false); + set && id == crate::package_dialog::INSTRUCTIONS +} + +/// Separators the Convert dialogs offer by name; anything else typed in the field is the +/// separator's own characters. +const SEPARATORS: [(&str, &str); 3] = [("tab", "Tab"), ("comma", "Comma"), ("paragraph", "Paragraph")]; + +/// The characters a separator field stands for (`table.convertFromText` resolves it the same +/// way), so "comma" and a typed "," are the same separator. +fn resolved_separator(v: &str) -> &str { + match v { + "tab" => "\t", + "comma" => ",", + "paragraph" => "\n", + other => other, + } +} + +/// Whether the Convert dialog's two separators are the same (Number of Columns then applies). +fn same_separators(d: &Dialog) -> bool { + resolved_separator(&d.s("columnSeparator")) == resolved_separator(&d.s("rowSeparator")) +} + +/// A separator field, as InDesign's: type any characters, or pick Tab, Comma or Paragraph from +/// its menu. Stored as `tab`/`comma`/`paragraph` or the typed text. +fn separator_field(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str) { + let cur = d.s(key); + let name = |v: &str| SEPARATORS.iter().find(|s| s.0 == v).map(|s| crate::i18n::tr(lang, s.1).to_string()); + let mut shown = name(&cur).unwrap_or_else(|| cur.clone()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + if ui.add(egui::TextEdit::singleline(&mut shown).id_salt(("sep", key)).desired_width(150.0)).changed() { + let v = SEPARATORS.iter().find(|s| crate::i18n::tr(lang, s.1) == shown).map_or(shown.clone(), |s| s.0.to_string()); + d.fields.insert(key.into(), json!(v)); + } + ui.menu_button("▾", |ui| { + for (v, label) in SEPARATORS { + if ui.selectable_label(cur == v, crate::i18n::tr(lang, label)).clicked() { + d.fields.insert(key.into(), json!(v)); + ui.close(); + } + } + }); + }); +} + +/// Table › Convert Text to Table and Convert Table to Text, laid out as InDesign's: Column and +/// Row Separator; converting text to a table also has Number of Columns (only when both +/// separators are the same: the cells then fill rows of that many) and Table Style. +fn convert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = &app.ui.language; + let to_table = d.id == "convertTextToTable"; + let label = |ui: &mut egui::Ui, text: &str, on: bool| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_enabled(on, egui::Label::new(crate::rtl::widget(ui, format!("{}:", crate::i18n::tr(lang, text))))); + }); + }; + egui::Grid::new(("convert_table", to_table)).num_columns(2).min_col_width(120.0).spacing([8.0, 6.0]).show(ui, |ui| { + label(ui, "Column Separator", true); + separator_field(lang, ui, d, "columnSeparator"); + ui.end_row(); + label(ui, "Row Separator", true); + separator_field(lang, ui, d, "rowSeparator"); + ui.end_row(); + if !to_table { + return; + } + let same = same_separators(d); + label(ui, "Number of Columns", same); + let max = designcraft_engine::cmd::table::MAX_COLUMNS as f64; + let field = crate::widgets::NumField::number("columns", d.n("columns"), "", 0).width(70.0).range(1.0, max).spinner().enabled(same); + if let Some(v) = field.show(ui) { + d.fields.insert("columns".into(), json!(v.round())); + } + ui.end_row(); + label(ui, "Table Style", true); + let names: Vec = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + let cur = table_style_field(d).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); + craft_ui::ComboBox::from_id_salt("convert_table_style").selected_text(&cur).width(170.0).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == cur, n).clicked() { + d.fields.insert("tableStyle".into(), json!(n)); + } + } + }); + ui.end_row(); + }); +} + +/// The dialog's chosen table style: none when unset, null or empty (a new table's [Basic Table]). +fn table_style_field(d: &Dialog) -> Option { + d.fields.get("tableStyle").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string) +} + +/// The dialog's `tableStyle` as the table commands take it: null for none, the style's name, or +/// a value that is neither (set through `ui.dialog.set`) as it is, for the command to refuse. +fn table_style_value(d: &Dialog) -> Value { + match d.fields.get("tableStyle") { + None | Some(Value::String(_)) => table_style_field(d).map_or(Value::Null, Value::String), + Some(v) => v.clone(), + } +} + +/// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then +/// header and footer rows, each a number with a spinner, labels on the right edge of their +/// column) and the Table Style to apply. +fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = &app.ui.language; + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Table Dimensions")).font(semibold(12.0))); + use designcraft_engine::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS}; + // `table.insert`'s limits, so the table made is the one the fields show. + let rows = [ + ("bodyRows", crate::i18n::tr(lang, "Body Rows"), 1.0, MAX_BODY_ROWS), + ("columns", crate::i18n::tr_context(lang, "Columns", "table"), 1.0, MAX_COLUMNS), + ("headerRows", crate::i18n::tr(lang, "Header Rows"), 0.0, MAX_HEADER_FOOTER_ROWS), + ("footerRows", crate::i18n::tr(lang, "Footer Rows"), 0.0, MAX_HEADER_FOOTER_ROWS), + ]; + // Body rows and columns, then (after a gap) header and footer rows; one label width for both. + for (g, pair) in rows.chunks(2).enumerate() { + if g > 0 { + ui.add_space(8.0); + } + egui::Grid::new(("ins_table", g)).num_columns(2).min_col_width(100.0).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, min, max) in pair { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| crate::rtl::label(ui, format!("{label}:"))); + let field = crate::widgets::NumField::number(key, d.n(key), "", 0).width(70.0).range(*min, *max as f64).spinner(); + if let Some(v) = field.show(ui) { + d.fields.insert((*key).into(), json!(v.round())); + } + ui.end_row(); + } + }); + } + ui.add_space(8.0); + let names: Vec = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + let cur = table_style_field(d).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); + ui.horizontal(|ui| { + crate::rtl::label(ui, format!("{}:", crate::i18n::tr(lang, "Table Style"))); + craft_ui::ComboBox::from_id_salt("ins_table_style").selected_text(&cur).width(160.0).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == cur, n).clicked() { + d.fields.insert("tableStyle".into(), json!(n)); + } + } + }); + }); +} + /// Extract once when opened. Cancel is read-only; confirmation adds exactly these colours. pub(crate) fn open_color_theme(app: &mut DesignApp, params: Value) { match app.run("color.extractTheme", params) { @@ -186,6 +400,195 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; + /// Enter in a multi-line field makes a new line in the Printing Instructions only; every other + /// dialog keeps Enter as OK, and the flag does not outlive the frame. + #[test] + fn enter_in_a_multiline_field_is_local_to_the_printing_instructions() { + let ctx = egui::Context::default(); + set_multiline_focus(&ctx); + assert!(take_multiline_focus(&ctx, crate::package_dialog::INSTRUCTIONS)); + assert!(!take_multiline_focus(&ctx, crate::package_dialog::INSTRUCTIONS), "cleared once read"); + set_multiline_focus(&ctx); + assert!(!take_multiline_focus(&ctx, "insertPages")); + } + + /// A page past the end shows as the last page in the dialog, and Before Page puts the new + /// page before that one rather than at the end. + #[test] + fn insert_pages_before_a_page_past_the_end_goes_before_the_last() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 4, "facingPages": false})).unwrap(); + let ids = |app: &DesignApp| { + let d = &app.session.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>() + }; + let old = ids(&app); + app.ui.dialog = Some(Dialog::new("insertPages", json!({"count": 1, "insert": "before", "page": 100, "parent": ""}))); + confirm(&mut app).unwrap(); + let now = ids(&app); + assert_eq!(now.len(), 5); + assert_eq!((now[..3].to_vec(), now[4]), (old[..3].to_vec(), old[3]), "the new page is fourth, before the old last page"); + } + + /// The course's chapter 25: Insert Pages as InDesign's. It opens after the page in view with + /// that page's parent; At Start of Document with B-Cover puts a B page first, and Before Page + /// with [None] puts a page without a parent before the page chosen. + #[test] + fn insert_pages_goes_before_a_page_or_at_the_start_with_the_chosen_parent() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + // Facing: [1] [2 3] [4]. + app.run("file.new", json!({"pages": 4})).unwrap(); + let b = app.run("layout.parents.new", json!({"prefix": "B", "name": "Cover"})).unwrap()["id"].as_u64().unwrap(); + let ids = |app: &DesignApp| -> Vec { + let d = &app.session.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page(i).unwrap().id.0).collect() + }; + let old = ids(&app); + crate::menus::activate(&mut app, "layout.pages.insert", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "insertPages"); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("insert_pages_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + for l in ["Pages:", "Insert:", "Parent:"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + // The dropdown showing `shown`, then its item `choice`. + let pick = |h: &mut Harness<'_, DesignApp>, shown: &str, choice: &str| { + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some(shown)).click(); + h.run_steps(2); + h.get_by_label(choice).click(); + h.run_steps(2); + }; + // After page 1 with A, at first; then At Start of Document, with B-Cover. + pick(&mut h, "After Page", "At Start of Document"); + pick(&mut h, "A-Parent", "B-Cover"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + let now = ids(h.state()); + assert_eq!(now[1..], old[..], "the new page is first"); + let parent = |app: &DesignApp, i: usize| app.session.doc().unwrap().doc.page(i).unwrap().parent.map(|p| p.0); + assert_eq!(parent(h.state(), 0), Some(b)); + // Before page 4, with [None]. + crate::dialogs::open_insert_pages(h.state_mut(), 0); + h.run_steps(3); + pick(&mut h, "After Page", "Before Page"); + pick(&mut h, "1", "4"); + pick(&mut h, "B-Cover", "[None]"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + let then = ids(h.state()); + assert_eq!(then.len(), 6); + assert_eq!(then[..3], now[..3], "the pages before it stay"); + assert_eq!(then[4..], now[3..], "page 4 and those after it follow the new page"); + assert_eq!(parent(h.state(), 3), None); + } + + /// The course's chapter 18: Text Frame Options with Preview on shows the columns and the + /// inset on the page while the dialog is open; Cancel puts the frame back, OK sets them as one + /// undo step. The Paragraph Spacing Limit is set from the dialog too. + #[test] + fn text_frame_options_preview_shows_the_change_until_ok_or_cancel() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "content": "text", "text": "Hello"})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + app.run("selection.set", json!({"ids": [id.0]})).unwrap(); + let opts = |app: &DesignApp| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone(); + let open = |app: &mut DesignApp| { + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(app, &mut d); + d + }; + let mut d = open(&mut app); + d.fields.insert("columns".into(), json!("3")); + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 3, "previewed on the page"); + d.fields.insert("insetTop".into(), json!("1p0")); + text_frame_preview(&mut app, &mut d); + assert_eq!((opts(&app).columns, opts(&app).inset[0]), (3, 12.0), "redone when a field changes"); + d.fields.insert("preview".into(), json!(false)); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 1, "Preview off: the page as it was"); + // A value OK would refuse: the page goes back while the status says why. + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 3); + let gutter = d.fields.get("gutter").cloned().unwrap_or_default(); + d.fields.insert("gutter".into(), json!("wide")); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 1, "the page as it was"); + assert!(!d.fields.contains_key(PREVIEWED) && !d.s("status").is_empty()); + d.fields.insert("gutter".into(), gutter); + d.fields.remove("status"); + // Cancel after a preview: back as it was. + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + app.ui.dialog = Some(d); + cancel(&mut app); + assert_eq!(opts(&app).columns, 1); + // OK after a preview: set once, one undo step. + let mut d = open(&mut app); + d.fields.insert("columns".into(), json!("2")); + d.fields.insert("verticalJustification".into(), json!("justify")); + d.fields.insert("paragraphSpacingLimit".into(), json!("2p0")); + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + let o = opts(&app); + assert_eq!((o.columns, o.vj_paragraph_spacing_limit), (2, 24.0)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(opts(&app).columns, 1, "one undo step"); + } + + #[test] + fn toc_right_indent_separator_survives_the_dialog_and_update() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert( + "included".into(), + json!([ + {"style": "Headline", "level": 1, "separator": " ^y "} + ]), + ); + confirm(&mut app).unwrap(); + if app.session.tool_id() == "tocPlace" { + app.run("toc.place", json!({"x":36,"y":36})).unwrap(); + } + let separator = format!(" {} ", designcraft_doc::story::RIGHT_INDENT_TAB); + let d = &app.session.doc().unwrap().doc; + let toc = d.toc.as_ref().unwrap(); + assert_eq!(toc.entries[0].separator.as_deref(), Some(separator.as_str())); + assert!(d.story(toc.story).unwrap().text.contains(&separator)); + app.run("toc.update", json!({})).unwrap(); + open_toc(&mut app); + assert_eq!(toc_rows(app.ui.dialog.as_ref().unwrap())[0].separator, " ^y "); + for text in ["^y", "^t^y", "^m^>", "unknown ^q and trailing ^", "a\t\u{E006}\u{2003}\u{2002}z"] { + assert_eq!(toc_separator_typed(&toc_separator_shown(text)), text); + } + // The picker after a literal caret: the caret stays, and the code is the code. + for (field, typed) in [("^", "^\u{E006}"), ("^^", "^\u{E006}"), ("a", "a\u{E006}"), ("^^^", "^^\u{E006}")] { + let mut f = field.to_string(); + toc_separator_append(&mut f, "^y"); + assert_eq!(toc_separator_typed(&f), typed, "{field:?} → {f:?}"); + } + } + #[test] fn color_theme_cancel_is_read_only_and_confirm_adds_the_preview() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -385,6 +788,41 @@ fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { const PDF_EXPORT_PRESETS: &[&str] = &["Desktop Printing", "Commercial Printing", "Screen and Email", "PDF/X-1a:2003", "PDF/X-4", "PDF/A-2b", "Custom"]; +/// The `file.exportPdf` options the Export PDF dialog's fields stand for (pages and title aside). +fn pdf_export_params(d: &Dialog) -> Value { + let mut params = json!({ + "standard": d.s("standard"), + "compressImages": d.b("compressImages"), + "spreads": d.b("spreads"), + "bleed": d.b("bleed"), + "tagged": d.b("tagged"), + "marks": { + "crop": d.b("marksCrop"), + "bleed": d.b("marksBleed"), + "pageInfo": d.b("marksPageInfo"), + "weight": d.pt("marksWeight").unwrap_or(0.25), + "offset": d.pt("marksOffset").unwrap_or(6.0), + }, + }); + let flatten = d.s("flatten"); + if !flatten.is_empty() { + params["flatten"] = json!(flatten); + } + params +} + +/// The PDF presets a document can be exported with, by name (Custom aside). +pub(crate) fn pdf_presets() -> impl Iterator { + PDF_EXPORT_PRESETS.iter().copied().filter(|p| *p != "Custom") +} + +/// The `file.exportPdf` options of PDF preset `preset`, as the Export PDF dialog sets them. +pub(crate) fn pdf_preset_params(preset: &str) -> Value { + let mut d = Dialog::new("pdfExport", json!({})); + apply_pdf_preset(&mut d, preset); + pdf_export_params(&d) +} + /// Apply a preset's dependent fields. Flatten is only touched by "Screen and Email" and /// "PDF/X-1a:2003" (High Resolution); "Custom" (and any unknown value) changes nothing. fn apply_pdf_preset(d: &mut Dialog, preset: &str) { @@ -536,6 +974,7 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { "firstBaseline": kind(serde_json::to_value(o.first_baseline).unwrap_or_default()), "firstBaselineMin": fm(o.first_baseline_min), "insetTop": fm(o.inset[0]), "insetLeft": fm(o.inset[1]), "insetBottom": fm(o.inset[2]), "insetRight": fm(o.inset[3]), "verticalJustification": serde_json::to_value(o.vertical_justification).unwrap_or(Value::Null), + "paragraphSpacingLimit": fm(o.vj_paragraph_spacing_limit), "columnRule": o.column_rule, "columnRuleWeight": format_measure(o.column_rule_weight, Unit::Points), "columnRuleColor": o.column_rule_color, "columnRuleTint": percent(o.column_rule_tint), "columnRuleTopInset": fm(o.column_rule_top_inset), "columnRuleBottomInset": fm(o.column_rule_bottom_inset), "columnRuleOffset": fm(o.column_rule_offset), @@ -553,6 +992,10 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { } } d.fields.entry("insetLinked".to_string()).or_insert_with(|| json!(o.inset.iter().all(|v| (v - o.inset[0]).abs() < 1e-9))); + // The frame's width: Fixed Number shows each column's width from it as the fields change. + if let Some(w) = selected_frame_width(app) { + d.fields.insert("_frameWidth".into(), json!(w)); + } d.fields.insert("_shown".into(), shown); } @@ -578,7 +1021,7 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) ui, d, false, - &[("general", "General"), ("baseline", "Baseline Options"), ("autosize", "Auto-Size"), ("rules", "Column Rules")], + &[("general", "General"), ("rules", "Column Rules"), ("baseline", "Baseline Options"), ("autosize", "Auto-Size")], ); ui.separator(); ui.vertical(|ui| match d.s("section").as_str() { @@ -625,49 +1068,146 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) _ => general(&lang, ui, d), }); }); + ui.add_space(6.0); + check(ui, d, "preview", tr("Preview")); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } } -/// Text Frame Options › General: columns, inset spacing, vertical justification, text wrap. +/// A measure field of the dialog with InDesign's spinner (stored as text in the document's unit). +fn spin_measure(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32, enabled: bool) { + let id = format!("tfo_{key}"); + if let Some(v) = crate::widgets::NumField::measure(&id, d.m(key), d.unit).width(w).spinner().range(0.0, 15552.0).enabled(enabled).show(ui) { + d.fields.insert(key.into(), json!(format_measure(v, d.unit))); + } +} + +/// A titled group in a thin frame, as InDesign's Text Frame Options draws them. +fn framed(ui: &mut egui::Ui, title: Option<&str>, add: impl FnOnce(&mut egui::Ui)) { + let t = crate::theme::Tokens::get(ui.ctx()); + ui.add_space(4.0); + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.divider)).inner_margin(egui::Margin::symmetric(10, 8)).corner_radius(2.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + if let Some(title) = title { + crate::rtl::label(ui, egui::RichText::new(title).font(semibold(12.0))); + } + add(ui); + }); +} + +/// A caption right-aligned in `w` (the dialog's fields line up after their captions). +fn caption_at(ui: &mut egui::Ui, text: &str, w: f32) { + ui.allocate_ui_with_layout(egui::vec2(w, crate::widgets::FIELD_H), egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.set_min_width(w); + ui.add(egui::Label::new(crate::rtl::widget(ui, text)).wrap_mode(egui::TextWrapMode::Extend)); + }); +} + +/// Preview on: the options show on the page (redone when a field changes) without an undo step; +/// off, the page goes back. +fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match text_frame_params(app, d) { + Ok(p) => Value::Object(p), + Err(e) => { + // Values OK would refuse don't stay on the page: it goes back to how it was. + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + // Nothing edited (yet, or any more): the page as it was. + if params.as_object().is_some_and(|m| m.keys().all(|k| k == "ids")) { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Text Frame Options") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("object.textFrameOptions", params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + +/// Text Frame Options › General, as InDesign's: Columns (kind, number and width, gutter and +/// maximum, Balance Columns), Inset Spacing (top and bottom, the link, left and right), Vertical +/// Justification (align, paragraph spacing limit) and Ignore Text Wrap. fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { let tr = |s: &'static str| crate::i18n::tr(lang, s); - crate::rtl::label(ui, egui::RichText::new(tr("Columns")).font(semibold(12.0))); - egui::Grid::new("tfo_cols").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Columns:")); - translated_combo(lang, ui, d, "columnsKind", &[("fixedNumber", "Fixed Number"), ("fixedWidth", "Fixed Width")]); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr_context(lang, "Number:", "count")); - text_field(ui, d, "columns", 70.0); - // Fixed Number: the width follows from the frame; Fixed Width: it's set here. - crate::rtl::label(ui, tr("Width:")); - ui.add_enabled_ui(d.s("columnsKind") == "fixedWidth", |ui| text_field(ui, d, "columnWidth", 70.0)); - ui.end_row(); - crate::rtl::label(ui, tr("Gutter:")); - text_field(ui, d, "gutter", 70.0); - ui.end_row(); - }); - check(ui, d, "balanceColumns", tr("Balance Columns")); - ui.add_space(6.0); - crate::rtl::label(ui, egui::RichText::new(tr("Inset Spacing")).font(semibold(12.0))); - // While linked, a side typed in sets all four ("Make all settings the same"). - let sides = ["insetTop", "insetLeft", "insetBottom", "insetRight"]; - let before: Vec = sides.iter().map(|k| d.s(k)).collect(); - // Two rows, Top | Left and Bottom | Right, the link between the columns (fixed-width - // captions; the second column starts where the first row's does). - let mut col2_x: Option = None; - for (r, row) in [[("Top:", "insetTop"), ("Left:", "insetLeft")], [("Bottom:", "insetBottom"), ("Right:", "insetRight")]].into_iter().enumerate() { + let (cap, field, cap2) = (64.0, 110.0, 70.0); + framed(ui, None, |ui| { ui.horizontal(|ui| { - for (k, (label, key)) in row.into_iter().enumerate() { - if k == 1 { - match col2_x { - Some(x) => ui.add_space((x - ui.cursor().min.x).max(0.0)), - None => col2_x = Some(ui.cursor().min.x), - } - } - let x0 = ui.cursor().min.x; - crate::rtl::label(ui, tr(label)); - ui.add_space(((if k == 0 { 56.0 } else { 44.0 }) - (ui.cursor().min.x - x0)).max(0.0)); - text_field(ui, d, key, 70.0); - if k == 0 && r == 0 { + caption_at(ui, tr("Columns:"), cap); + translated_combo(lang, ui, d, "columnsKind", &[("fixedNumber", "Fixed Number"), ("fixedWidth", "Fixed Width")]); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + caption_at(ui, crate::i18n::tr_context(lang, "Number:", "count"), cap); + let n = d.n("columns"); + if let Some(v) = crate::widgets::NumField::number("tfo_columns", n, "", 0).width(field).spinner().range(1.0, 40.0).show(ui) { + d.fields.insert("columns".into(), json!(v.round().to_string())); + } + // Fixed Number: the width follows from the frame; Fixed Width: it's set here. + caption_at(ui, tr("Width:"), cap2); + if d.s("columnsKind") == "fixedWidth" { + spin_measure(ui, d, "columnWidth", field, true); + } else { + // Fixed Number: each column's width, from the frame, the insets and the gutter. + let frame = d.fields.get("_frameWidth").and_then(Value::as_f64); + let n = d.n("columns").unwrap_or(1.0).max(1.0); + let width = frame.map(|w| { + let inner = w - d.m("insetLeft").unwrap_or(0.0) - d.m("insetRight").unwrap_or(0.0); + ((inner - d.m("gutter").unwrap_or(0.0) * (n - 1.0)) / n).max(0.0) + }); + let _ = crate::widgets::NumField::measure("tfo_auto_width", width, d.unit).width(field).spinner().enabled(false).show(ui); + } + }); + ui.horizontal(|ui| { + caption_at(ui, tr("Gutter:"), cap); + spin_measure(ui, d, "gutter", field, true); + // Maximum is for Flexible Width columns, which aren't in the document model yet. + caption_at(ui, tr("Maximum:"), cap2); + ui.add_enabled_ui(false, |ui| { + let _ = crate::widgets::NumField::number("tfo_max", None, "", 0).width(field).spinner().enabled(false).show(ui); + }); + }); + check(ui, d, "balanceColumns", tr("Balance Columns")); + }); + framed(ui, Some(tr("Inset Spacing")), |ui| { + // While linked, a side typed in sets all four ("Make all settings the same"). + let sides = ["insetTop", "insetLeft", "insetBottom", "insetRight"]; + let before: Vec = sides.iter().map(|k| d.s(k)).collect(); + for (r, [(l1, k1), (l2, k2)]) in + [[("Top:", "insetTop"), ("Left:", "insetLeft")], [("Bottom:", "insetBottom"), ("Right:", "insetRight")]].into_iter().enumerate() + { + ui.horizontal(|ui| { + caption_at(ui, tr(l1), cap); + spin_measure(ui, d, k1, field, true); + ui.add_space(8.0); + if r == 0 { let linked = d.b("insetLinked"); let tip = tr("Make all settings the same"); if crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)) @@ -675,26 +1215,40 @@ fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { { d.fields.insert("insetLinked".into(), json!(!linked)); } + } else { + ui.add_space(22.0 + ui.spacing().item_spacing.x); } + caption_at(ui, tr(l2), cap2 - 30.0); + spin_measure(ui, d, k2, field, true); + }); + } + if d.b("insetLinked") + && let Some(k) = sides.iter().zip(&before).find(|(k, b)| d.s(k) != **b).map(|(k, _)| *k) + { + let v = d.fields.get(k).cloned().unwrap_or_default(); + for side in sides { + d.fields.insert(side.into(), v.clone()); } - }); - } - if d.b("insetLinked") - && let Some(k) = sides.iter().zip(&before).find(|(k, b)| d.s(k) != **b).map(|(k, _)| *k) - { - let v = d.fields.get(k).cloned().unwrap_or_default(); - for side in sides { - d.fields.insert(side.into(), v.clone()); } - } - ui.add_space(6.0); - crate::rtl::label(ui, egui::RichText::new(tr("Vertical Justification")).font(semibold(12.0))); - egui::Grid::new("tfo_vj").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Align:")); - translated_combo(lang, ui, d, "verticalJustification", &[("top", "Top"), ("center", "Center"), ("bottom", "Bottom"), ("justify", "Justify")]); - ui.end_row(); }); - ui.add_space(6.0); + framed(ui, Some(tr("Vertical Justification")), |ui| { + ui.horizontal(|ui| { + caption_at(ui, tr("Align:"), 170.0); + translated_combo( + lang, + ui, + d, + "verticalJustification", + &[("top", "Top"), ("center", "Center"), ("bottom", "Bottom"), ("justify", "Justify")], + ); + }); + ui.horizontal(|ui| { + caption_at(ui, tr("Paragraph Spacing Limit:"), 170.0); + let justify = d.s("verticalJustification") == "justify"; + spin_measure(ui, d, "paragraphSpacingLimit", field, justify); + }); + }); + ui.add_space(4.0); check(ui, d, "ignoreWrap", tr("Ignore Text Wrap")); } @@ -829,7 +1383,19 @@ fn text_frame_baseline_grid(d: &Dialog, bad: &dyn Fn(&str) -> String) -> Result< fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result { seed_text_frame_options(app, d); - let d = &*d; + // The preview goes; the options are set once, as one undo step. + if d.fields.remove(PREVIEWED).is_some() { + app.session.cancel_interaction(); + } + let p = text_frame_params(app, d)?; + if p.keys().all(|k| k == "ids") { + return Ok(Value::Null); + } + app.run("object.textFrameOptions", Value::Object(p)) +} + +/// The `object.textFrameOptions` params of what the dialog changed. +fn text_frame_params(app: &DesignApp, d: &Dialog) -> Result, String> { let mut p = Map::new(); let bad = |what: &str| format!("Text Frame Options: {what}"); if edited(d, "columns") { @@ -915,8 +1481,11 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result= 0.0).ok_or_else(|| bad("the paragraph spacing limit is not a measure"))?), + ); } // Typing in a frame: the options apply to that frame. if let Some(st) = app.session.active() @@ -925,7 +1494,7 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result Unit { app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } -/// Layout › Create Guides…: guides go on the page in view (or the parent page being edited), not -/// on the first page, and the gutters are typed in the document's ruler unit. +/// Layout › Create Guides…: guides go on every page of the spread in view (or of the parent +/// spread being edited), as InDesign's do; "This page" narrows them to +/// the page in view. The gutters are typed in the document's ruler unit. pub fn open_create_guides(app: &mut DesignApp) { let unit = doc_unit(app); let Some((spread, page)) = crate::canvas::current_spread_page(app) else { return }; - let editing_parents = matches!(spread, SpreadRef::Parent(_)); let gutter = json!(format_measure(12.0, unit)); let mut d = Dialog::new( "createGuides", json!({"rows": 0, "columns": 0, "rowGutter": gutter, "columnGutter": gutter, "fitTo": "margins", "removeExisting": false, "spread": serde_json::to_value(spread).unwrap_or(json!(0)), "page": page, - // A parent spread is edited as a whole (both pages of a facing spread), so the guides show on every page that uses it. - "target": if editing_parents { "spread" } else { "page" }, + // InDesign puts the guides on both pages of a facing spread (and a parent spread is + // edited as a whole, so they show on every page that uses it). + "target": "spread", "where": target_description(app, spread, page)}), ); d.unit = unit; @@ -1720,14 +2290,16 @@ fn confirm_document_setup(app: &mut DesignApp, d: &Dialog) -> Result { + let lang = app.ui.language.clone(); + let t = crate::theme::Tokens::get(ui.ctx()); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Appearance")).font(semibold(12.0))); + // Color Theme: a swatch per theme, as InDesign's. A click shows it at once; + // Cancel goes back to the theme Preferences opened with. + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&lang, "Color Theme:")); + let cur = d.s("brightness"); + for b in crate::theme::Brightness::ALL { + let (r, resp) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::click()); + let on = cur == b.id(); + let swatch = r.shrink(4.0); + ui.painter().rect_filled(swatch, 1.0, crate::theme::Tokens::for_brightness(b).panel); + ui.painter().rect_stroke(swatch, 1.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + if on || resp.hovered() { + let ring = if on { t.accent_strong } else { t.field_border }; + ui.painter().rect_stroke(r.shrink(1.0), 2.0, egui::Stroke::new(2.0, ring), egui::StrokeKind::Inside); + } + let name = crate::i18n::tr(&lang, b.label()).to_string(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, on, &name)); + if resp.on_hover_text(&name).clicked() && !on { + d.fields.insert("brightness".into(), json!(b.id())); + d.fields.insert("applyBrightness".into(), json!(b.id())); + } + } + }); + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Panels")).font(semibold(12.0))); + ui.horizontal(|ui| { + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Tools Panel:")); + translated_combo(&lang, ui, d, "toolsColumns", &[("single", "Single Column"), ("double", "Double Column")]) + .labelled_by(name.id); + }); + } + "uiScaling" => { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "UI Scaling")).font(semibold(12.0))); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "UI Size:")); @@ -2198,9 +2810,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let _ = crate::shortcuts_dialog::close(app, false); } let Some(mut d) = app.ui.dialog.clone() else { return }; - // Enter and Escape belong to an open popup (a menu, a name field) rather than the dialog. - // Read before the dialog draws: a popup closed by this frame's Escape is still open here. - let popup_open = egui::Popup::is_any_open(ctx); + // Enter and Escape belong to an open popup (a menu, a name field) or to Find/Change's Change + // Format Settings rather than the dialog. Read before the dialog draws: a popup closed by this + // frame's Escape is still open here. + let popup_open = egui::Popup::is_any_open(ctx) || d.fields.get(FORMAT_EDIT).is_some_and(Value::is_object); let mut result: Option = None; let alert_title = d.s("title"); let title = match d.id.as_str() { @@ -2209,7 +2822,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "newDocument" => crate::i18n::tr(&app.ui.language, "New Document"), "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"), - "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"), + "insertPages" => crate::i18n::tr(&app.ui.language, "Insert Pages"), + "insertTable" => crate::i18n::tr(&app.ui.language, "Insert Table…").trim_end_matches('…').trim_end(), + // The menu items' translations (every language has them), without the ellipsis. + "convertTextToTable" => crate::i18n::tr(&app.ui.language, "Convert Text to Table…").trim_end_matches('…').trim_end(), + "convertTableToText" => crate::i18n::tr(&app.ui.language, "Convert Table to Text…").trim_end_matches('…').trim_end(), "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"), "createGuides" => crate::i18n::tr(&app.ui.language, "Create Guides"), "marginsAndColumns" => crate::i18n::tr(&app.ui.language, "Margins and Columns"), @@ -2217,10 +2834,14 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "colorTheme" => crate::i18n::tr(&app.ui.language, "Color Theme"), "documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"), "fileInfo" => crate::i18n::tr(&app.ui.language, "File Info"), + // On a page that doesn't start a section yet, InDesign titles it New Section. + "numberingSection" if d.b("newSection") => crate::i18n::tr(&app.ui.language, "New Section"), "numberingSection" => crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), + "paragraphStyleOptions" if d.b("new") => crate::i18n::tr(&app.ui.language, "New Paragraph Style"), "paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"), "deleteParagraphStyle" => crate::i18n::tr(&app.ui.language, "Delete Paragraph Style"), + "characterStyleOptions" if d.b("new") => crate::i18n::tr(&app.ui.language, "New Character Style"), "characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"), "deleteCharacterStyle" => crate::i18n::tr(&app.ui.language, "Delete Character Style"), "footnoteOptions" => crate::i18n::tr(&app.ui.language, "Footnote Options"), @@ -2264,6 +2885,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "colorGroupOptions" => crate::i18n::tr(&app.ui.language, "Color Group Options"), "layerSettings" => crate::i18n::tr(&app.ui.language, "Layer Options"), crate::effects_dialog::ID => crate::i18n::tr(&app.ui.language, "Effects"), + crate::package_dialog::ID => crate::i18n::tr(&app.ui.language, "Package"), + crate::package_dialog::INSTRUCTIONS => crate::i18n::tr(&app.ui.language, "Printing Instructions"), id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) { Some(c) => crate::i18n::tr(&app.ui.language, c.label.trim_end_matches('…')), None => crate::i18n::tr(&app.ui.language, "Dialog"), @@ -2277,11 +2900,16 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, - "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0, + "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" | "insertTable" | "convertTableToText" => 380.0, + "insertPages" => 440.0, + "convertTextToTable" => 420.0, "newDocument" => 960.0, "hyphenation" => 560.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, + "colorPicker" => 580.0, + crate::package_dialog::ID => 720.0, + crate::package_dialog::INSTRUCTIONS => 520.0, _ => 640.0, }; let width = preferred_width.min(available.x); @@ -2458,40 +3086,93 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); ui.label(d.s("status")); } else { + let (find_formatted, formatted) = (format_set(&d, FIND_FORMAT), format_set(&d, CHANGE_FORMAT)); egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); - text_field(ui, &mut d, "find", 260.0); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); + // InDesign's ⓘ: a Find Format narrows what is found. + if find_formatted { + info_icon( + ui, + crate::i18n::tr( + &app.ui.language, + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + ), + ); + } + }); + text_field_named(ui, &mut d, "find", 260.0, crate::i18n::tr(&app.ui.language, "Find what:")); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); - text_field(ui, &mut d, "change", 260.0); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); + // InDesign's ⓘ: with a Change Format, an empty Change to keeps the text. + if formatted { + info_icon( + ui, + crate::i18n::tr( + &app.ui.language, + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + ), + ); + } + }); + text_field_named(ui, &mut d, "change", 260.0, crate::i18n::tr(&app.ui.language, "Change to:")); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); + let search_label = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); let cur = d.s("scope"); + // InDesign's names for the scopes. + let scope_name = |v: &str| match v { + "story" => "Story", + "selection" => "Selection", + _ => "Document", + }; craft_ui::ComboBox::from_id_salt("fcscope") - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, scope_name(&cur)))) .show_ui(ui, |ui| { for v in ["document", "story", "selection"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() + if ui + .selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, scope_name(v)))) + .clicked() && d.set_field("scope", json!(v)) { app.session.reset_find_scope(); } } - }); + }) + .response + .labelled_by(search_label.id); ui.end_row(); }); ui.horizontal(|ui| { check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); }); + format_box(&app.ui.language, ui, &mut d, true); + format_box(&app.ui.language, ui, &mut d, false); ui.horizontal(|ui| { let params0 = find_change_params(&d); let params = || params0.clone(); + // Change and Change/Find act on the text Find Next found. + let mut found = params0.clone(); + found["found"] = json!(true); + let selected = app.session.active().and_then(|st| st.selection.text).is_some_and(|t| !t.is_caret()); + let next_status = |app: &mut DesignApp, r: Result| match r { + Ok(Value::Null) => crate::i18n::tr(&app.ui.language, "No matches").to_string(), + Ok(_) => crate::i18n::tr(&app.ui.language, "Found").to_string(), + Err(e) => e, + }; if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find Next"))).clicked() { let r = app.run("find.next", params()); - let msg = match r { - Ok(Value::Null) => crate::i18n::tr(&app.ui.language, "No matches").to_string(), - Ok(_) => crate::i18n::tr(&app.ui.language, "Found").to_string(), + let msg = next_status(app, r); + d.fields.insert("status".into(), json!(msg)); + } + if ui + .add_enabled(selected, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change")))) + .clicked() + { + let msg = match app.run("find.change", found.clone()) { + Ok(v) => crate::i18n::tr(&app.ui.language, "{count} replacement(s) made") + .replace("{count}", &v["count"].to_string()), Err(e) => e, }; d.fields.insert("status".into(), json!(msg)); @@ -2504,6 +3185,17 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }; d.fields.insert("status".into(), json!(msg)); } + if ui + .add_enabled( + selected, + egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change/Find"))), + ) + .clicked() + { + let r = app.run("find.change", found.clone()).and_then(|_| app.run("find.next", params())); + let msg = next_status(app, r); + d.fields.insert("status".into(), json!(msg)); + } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Count"))).clicked() { let msg = match app.run("find.find", params()) { Ok(v) => crate::i18n::tr(&app.ui.language, "{count} match(es)") @@ -2549,11 +3241,13 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), - "colorPicker" => color_picker(ui, &mut d), + "colorPicker" => color_picker(app, ui, &mut d), "swatchOptions" => options::swatch_options(app, ui, &mut d), "colorGroupOptions" => options::color_group_options(app, ui, &mut d), "layerSettings" => options::layer_options(app, ui, &mut d), crate::effects_dialog::ID => crate::effects_dialog::show(app, ui, &mut d), + crate::package_dialog::ID => crate::package_dialog::show(app, ui, &mut d), + crate::package_dialog::INSTRUCTIONS => crate::package_dialog::show_instructions(app, ui, &mut d), "preferences" => preferences(app, ui, &mut d, body_height), "print" => print_dialog(app, ui, &mut d), "pdfImport" => { @@ -2950,8 +3644,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), + CORNER_OPTIONS => corner_options_dialog(app, ui, &mut d), TOC => toc_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), + "insertPages" => insert_pages_dialog(app, ui, &mut d), "goToPage" => { ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page")); @@ -2959,27 +3655,15 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } "ruby" => style_cjk::ruby_dialog(app, ui, &mut d), - "insertTable" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); - egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows")); - text_field(ui, &mut d, "bodyRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table")); - text_field(ui, &mut d, "columns", 60.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows")); - text_field(ui, &mut d, "headerRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows")); - text_field(ui, &mut d, "footerRows", 60.0); - ui.end_row(); - }); - } + "insertTable" => insert_table_dialog(app, ui, &mut d), + "convertTextToTable" | "convertTableToText" => convert_table_dialog(app, ui, &mut d), "cellOptions" => cell_options(app, ui, &mut d), "createGuides" => create_guides_dialog(app, ui, &mut d), "marginsAndColumns" => margins_and_columns_dialog(app, ui, &mut d), "textFrameOptions" => { seed_text_frame_options(app, &mut d); text_frame_options_dialog(app, ui, &mut d); + text_frame_preview(app, &mut d); } "colorTheme" => color_theme(app, ui, &mut d), "documentSetup" => { @@ -3004,6 +3688,14 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { _ => {} } }); + if d.id == "colorPicker" { + match d.fields.remove("_close").as_ref().and_then(Value::as_str) { + Some("ok") => result = Some(true), + Some("cancel") => result = Some(false), + _ => {} + } + return; + } if side_actions { return; } @@ -3014,6 +3706,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "updateLink" => crate::i18n::tr(&app.ui.language, " Update ").to_string(), "findFont" => crate::i18n::tr(&app.ui.language, "Replace All").to_string(), "newDocument" => format!(" {} ", crate::i18n::tr(&app.ui.language, "Create")), + crate::package_dialog::ID => crate::i18n::tr(&app.ui.language, "Package…").to_string(), + crate::package_dialog::INSTRUCTIONS => crate::i18n::tr(&app.ui.language, "Continue").to_string(), _ => crate::i18n::tr(&app.ui.language, " OK ").to_string(), }; let cancel_label = match d.id.as_str() { @@ -3027,6 +3721,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { app.ui.style_preview = d.b("preview"); style_options_preview(app, &mut d); } + // A multi-line field with focus (set by the dialog this frame) takes Enter as a new line. + let multiline = take_multiline_focus(ui.ctx(), &d.id); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( @@ -3034,7 +3730,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), ) .clicked() - || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Enter))) + || (!popup_open && !multiline && ui.input(|i| i.key_pressed(egui::Key::Enter))) { result = Some(true); } @@ -3064,9 +3760,18 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { { d.fields.insert("status".into(), json!(e)); } + // So does Preferences › Interface › Color Theme, as InDesign's. + if let Some(b) = d.fields.remove("applyBrightness") + && let Err(e) = app.run("window.brightness", json!({"brightness": b})) + { + d.fields.insert("status".into(), json!(e)); + } if d.id == "newDocument" && result.is_none() { crate::new_document::preview(app, &mut d, ctx); } + if d.id == "findChange" && result.is_none() && d.fields.get(FORMAT_EDIT).is_some_and(Value::is_object) { + format_settings(app, ctx, &mut d); + } app.ui.dialog = Some(d); match result { Some(true) => { @@ -3210,10 +3915,18 @@ pub fn cancel(app: &mut DesignApp) { app.cancel_pdf_import(); if let Some(d) = d && d.id == "preferences" - && let Some(lang) = d.fields.get("openedLanguage").and_then(Value::as_str) - && lang != app.ui.language { - let _ = app.run("app.language", json!({"lang": lang})); + if let Some(lang) = d.fields.get("openedLanguage").and_then(Value::as_str) + && lang != app.ui.language + { + let _ = app.run("app.language", json!({"lang": lang})); + } + // The Color Theme shown while choosing goes back too. + if let Some(b) = d.fields.get("openedBrightness").and_then(Value::as_str) + && b != app.ui.brightness.id() + { + let _ = app.run("window.brightness", json!({"brightness": b})); + } } } @@ -3225,9 +3938,239 @@ fn find_change_params(d: &Dialog) -> Value { if let Some(story) = d.fields.get("story") { p["story"] = story.clone(); } + // Find Format: only text with it is found (with nothing in Find what, all text with it). + if let Some(f) = d.fields.get(FIND_FORMAT).and_then(Value::as_object).filter(|f| !f.is_empty()) { + p["findFormat"] = Value::Object(f.clone()); + } + // Change Format: find.change applies it to the changed text (the other commands ignore it). + if let Some(f) = d.fields.get(CHANGE_FORMAT) { + for key in ["charStyle", "paraStyle"] { + if let Some(v) = f.get(key).filter(|v| v.is_string()) { + p[key] = v.clone(); + } + } + if let Some(chars) = f.get("chars").filter(|c| c.as_object().is_some_and(|o| !o.is_empty())) { + p["attrs"] = chars.clone(); + } + } p } +/// Find/Change's Find Format and Change Format ({charStyle?, paraStyle?, chars?}), as their +/// settings set them; absent when there is none. +const FIND_FORMAT: &str = "findFormat"; +const CHANGE_FORMAT: &str = "changeFormat"; +/// The fields of Find or Change Format Settings while it is open (`for`: find|change, its +/// section, `charStyle`, `paraStyle` and `c.`), until OK makes them the format. +const FORMAT_EDIT: &str = "formatEdit"; + +/// Whether the dialog has a Find (`FIND_FORMAT`) or Change Format. +fn format_set(d: &Dialog, key: &str) -> bool { + d.fields.get(key).and_then(Value::as_object).is_some_and(|f| !f.is_empty()) +} + +/// The sections of Find and Change Format Settings: Style Options, then those of Character Style +/// Options. +const FORMAT_SECTIONS: &[(&str, &str)] = &[ + ("style", "Style Options"), + ("chars", "Basic Character Formats"), + ("advanced", "Advanced Character Formats"), + ("color", "Character Color"), + ("openType", "OpenType Features"), + ("underline", "Underline Options"), + ("strikethrough", "Strikethrough Options"), +]; + +/// A small ⓘ whose tooltip is `tip`. +fn info_icon(ui: &mut egui::Ui, tip: &str) { + let (r, resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), r, "info", crate::theme::Tokens::get(ui.ctx()).icon); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip)); + resp.on_hover_text(tip); +} + +/// What a Find or Change Format box shows: "Character Style: eggs + …". +fn format_summary(lang: &str, format: Option<&Value>) -> String { + let Some(f) = format else { return String::new() }; + let mut parts = Vec::new(); + for (key, label) in [("charStyle", "Character Style:"), ("paraStyle", "Paragraph Style:")] { + if let Some(name) = f.get(key).and_then(Value::as_str) { + parts.push(format!("{} {}", crate::i18n::tr(lang, label), crate::i18n::style_name(lang, name))); + } + } + parts.extend( + f.get("chars").and_then(Value::as_object).into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(lang, k), setting_text(v))), + ); + parts.join(" + ") +} + +/// Find/Change's Find Format (`find`) or Change Format, as InDesign's: a box with what the found +/// text must have or what Change All also gives the changed text, a button that opens its +/// settings and one that clears it. +fn format_box(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, find: bool) { + let (key, label, specify) = if find { + (FIND_FORMAT, "Find Format:", "Specify attributes to find") + } else { + (CHANGE_FORMAT, "Change Format:", "Specify attributes to change") + }; + ui.add_space(6.0); + crate::rtl::label(ui, crate::i18n::tr(lang, label)); + ui.horizontal_top(|ui| { + let t = crate::theme::Tokens::get(ui.ctx()); + egui::Frame::new().fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(6.0).show(ui, |ui| { + ui.set_width(340.0); + ui.set_min_height(40.0); + let summary = format_summary(lang, d.fields.get(key)); + ui.add(egui::Label::new(egui::RichText::new(summary).color(t.text)).wrap()); + }); + ui.vertical(|ui| { + if crate::icons::button(ui, "find-format", 22.0, false, crate::i18n::tr(lang, specify)).clicked() { + let f = d.fields.get(key).cloned().unwrap_or_default(); + let mut edit = Map::new(); + edit.insert("for".into(), json!(if find { "find" } else { "change" })); + edit.insert("section".into(), json!("style")); + for key in ["charStyle", "paraStyle"] { + if let Some(v) = f.get(key) { + edit.insert(key.into(), v.clone()); + } + } + for (k, v) in f.get("chars").and_then(Value::as_object).into_iter().flatten() { + edit.insert(format!("c.{k}"), v.clone()); + } + d.fields.insert(FORMAT_EDIT.into(), Value::Object(edit)); + } + if crate::icons::button(ui, "trash", 22.0, false, crate::i18n::tr(lang, "Clear specified attributes")).clicked() { + d.fields.remove(key); + } + }); + }); +} + +/// Find/Change › Find or Change Format Settings, over Find/Change as InDesign's: Style Options +/// (the character and paragraph style the found text has or the changed text gets, [Any Style] +/// for none), then the character sections of Character Style Options with every field blank (any +/// value, or left as it is). OK makes it the Find or Change Format. +fn format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { + d.fields.remove(FORMAT_EDIT); + return; + }; + let mut fd = + Dialog { id: "changeFormat".into(), fields: d.fields.get(FORMAT_EDIT).and_then(Value::as_object).cloned().unwrap_or_default(), unit: d.unit }; + let base = json!({}); + let cf = CharFields { base: &base, sparse: true }; + let find = fd.s("for") == "find"; + let popup_open = egui::Popup::is_any_open(ctx); + let mut result = None; + let host = egui::Id::unique("format_settings"); + crate::modal_drag::modal(ctx, host, host).backdrop_color(egui::Color32::TRANSPARENT).show(ctx, |ui| { + let title_top = ui.cursor().top(); + let title = if find { "Find Format Settings" } else { "Change Format Settings" }; + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, title)).font(semibold(16.0))); + let margin = egui::Frame::popup(ui.style()).inner_margin; + let title_row = egui::Rect::from_min_max( + egui::pos2(ui.max_rect().left() - margin.leftf(), title_top - margin.topf()), + egui::pos2(ui.max_rect().right() + margin.rightf(), ui.min_rect().bottom()), + ); + crate::modal_drag::title_drag(ui, host, title_row); + ui.add_space(10.0); + ui.horizontal_top(|ui| { + // A fixed height: the separator would otherwise stretch the dialog to the window. + ui.set_min_height(380.0); + ui.set_max_height(380.0); + section_list(&lang, ui, &mut fd, false, FORMAT_SECTIONS); + ui.separator(); + ui.vertical(|ui| { + ui.set_min_width(440.0); + match fd.s("section").as_str() { + "chars" => basic_character_formats(app, ui, &mut fd, &cf), + "advanced" => advanced_character_formats(&lang, ui, &mut fd, &cf), + "color" => character_color(&lang, ui, &mut fd, &cf, &doc), + "openType" => open_type_features(&lang, ui, &mut fd, &cf), + "underline" => line_options(&lang, ui, &mut fd, &cf, &doc, "underline"), + "strikethrough" => line_options(&lang, ui, &mut fd, &cf, &doc, "strikethrough"), + _ => format_style_options(&lang, ui, &mut fd, &doc.styles), + } + }); + }); + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&lang, " OK ")).color(egui::Color32::WHITE))) + .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), + ) + .clicked() + || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + result = Some(true); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Cancel"))).clicked() + || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Escape))) + { + result = Some(false); + } + }); + }); + match result { + Some(true) => { + d.fields.remove(FORMAT_EDIT); + let mut f = Map::new(); + for key in ["charStyle", "paraStyle"] { + if let Some(v) = fd.fields.get(key).filter(|v| v.is_string()) { + f.insert(key.into(), v.clone()); + } + } + let chars: Map = + fd.fields.iter().filter(|(_, v)| !v.is_null()).filter_map(|(k, v)| Some((k.strip_prefix("c.")?.to_string(), v.clone()))).collect(); + if !chars.is_empty() { + f.insert("chars".into(), Value::Object(chars)); + } + let key = if find { FIND_FORMAT } else { CHANGE_FORMAT }; + if f.is_empty() { + d.fields.remove(key); + } else { + d.fields.insert(key.into(), Value::Object(f)); + } + } + Some(false) => { + d.fields.remove(FORMAT_EDIT); + } + None => { + d.fields.insert(FORMAT_EDIT.into(), Value::Object(fd.fields)); + } + } +} + +/// Find or Change Format Settings › Style Options: the character and paragraph style the found +/// text has or the changed text gets ([Any Style]: any, or as it is). +fn format_style_options(lang: &str, ui: &mut egui::Ui, fd: &mut Dialog, styles: &designcraft_doc::Styles) { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Style Options")).font(semibold(13.0))); + ui.add_space(6.0); + let any = crate::i18n::tr(lang, "[Any Style]"); + let chars: Vec<&str> = styles.character.iter().map(|s| s.name.as_str()).collect(); + let paras: Vec<&str> = styles.paragraph.iter().map(|s| s.name.as_str()).collect(); + egui::Grid::new("format_style_options").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, names) in [("charStyle", "Character Style:", &chars), ("paraStyle", "Paragraph Style:", ¶s)] { + crate::rtl::label(ui, crate::i18n::tr(lang, label)); + let cur = fd.fields.get(key).and_then(Value::as_str).map(str::to_string); + let shown = cur.as_deref().map_or_else(|| any.to_string(), |n| crate::i18n::style_name(lang, n).to_string()); + craft_ui::ComboBox::from_id_salt(("format_style", key)).selected_text(crate::rtl::widget(ui, shown)).width(240.0).show_ui(ui, |ui| { + if ui.selectable_label(cur.is_none(), crate::rtl::widget(ui, any)).clicked() { + fd.fields.remove(key); + } + for n in names.iter() { + if ui.selectable_label(cur.as_deref() == Some(*n), crate::rtl::widget(ui, crate::i18n::style_name(lang, n))).clicked() { + fd.fields.insert(key.into(), json!(n)); + } + } + }); + ui.end_row(); + } + }); +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -3284,6 +4227,21 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> Result { ) } "ruby" => app.run("type.ruby", style_cjk::ruby_params(&d)), + "insertPages" => { + let n = app.session.active().map_or(1, |st| st.doc.page_count()); + // The page the menu shows: a page past the end is the last. + let page = (d.n("page").unwrap_or(0.0).max(0.0) as usize).min(n.saturating_sub(1)); + let mut p = json!({"count": d.n("count").unwrap_or(1.0).clamp(1.0, 9999.0) as u64}); + match d.s("insert").as_str() { + "before" => p["before"] = json!(page), + "start" => p["before"] = json!(0), + "end" => p["after"] = json!(n.saturating_sub(1)), + _ => p["after"] = json!(page), + } + // A parent's id, or [None]. + p["parent"] = d.s("parent").parse::().map_or(Value::Null, |id| json!(id)); + app.run("layout.pages.insert", p) + } "goToPage" => { // A page name ("iv", "A-3"), a number, or "+n" for an absolute position. let typed = match d.fields.get("page") { @@ -3299,11 +4257,41 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> Result { None => Err(format!("no page \"{typed}\"")), } } - "insertTable" => app.run( - "table.insert", - json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, - "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}), - ), + "convertTextToTable" | "convertTableToText" => { + let r = if d.id == "convertTextToTable" { + let same = same_separators(&d); + let style = table_style_value(&d); + app.run( + "table.convertFromText", + json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator"), + "columns": if same { d.fields.get("columns").cloned().unwrap_or(json!(1)) } else { Value::Null }, "tableStyle": style}), + ) + } else { + app.run("table.convertToText", json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator")})) + }; + // On an error (an emptied separator field) the dialog stays open to be corrected. + if let Err(e) = &r { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r + } + "insertTable" => { + // The table and its style in one step; a style that is gone leaves the document alone, + // and the dialog stays open to be corrected. + let style = table_style_value(&d); + let r = app.run( + "table.insert", + json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, + "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64, + "tableStyle": style}), + ); + if let Err(e) = &r { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r + } "numberingSection" => { // On an error the dialog stays open to be corrected. let r = crate::section_options::confirm(app, &d); @@ -3442,7 +4430,13 @@ fn confirm_part2(app: &mut DesignApp, d: Dialog) -> Result { if let Some(b) = based.filter(|b| b != designcraft_doc::NO_CHAR_STYLE) { params["basedOn"] = json!(b); } - app.run("style.character.create", params) + // Apply Style to Selection. + app.run("style.character.create", params).and_then(|r| { + if d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { + app.run("style.character.apply", json!({"name": r["name"]}))?; + } + Ok(r) + }) } else { let mut params = json!({"name": name, "chars": chars}); if let Some(b) = based { @@ -3589,24 +4583,7 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result { app.ui.pdf_export.marks_offset = d.s("marksOffset"); app.ui.pdf_export.tagged = d.b("tagged"); - let mut params = json!({ - "standard": d.s("standard"), - "compressImages": d.b("compressImages"), - "spreads": d.b("spreads"), - "bleed": d.b("bleed"), - "tagged": d.b("tagged"), - "marks": { - "crop": d.b("marksCrop"), - "bleed": d.b("marksBleed"), - "pageInfo": d.b("marksPageInfo"), - "weight": d.pt("marksWeight").unwrap_or(0.25), - "offset": d.pt("marksOffset").unwrap_or(6.0), - }, - }); - let flatten = d.s("flatten"); - if !flatten.is_empty() { - params["flatten"] = json!(flatten); - } + let mut params = pdf_export_params(&d); let pages = d.s("pages"); if !pages.trim().is_empty() && pages != "all" { params["pages"] = json!(pages); @@ -3659,7 +4636,23 @@ fn confirm_part4(app: &mut DesignApp, d: Dialog) -> Result { if d.b("cjkFeatures") != d.b("cjkFeatures.initial") { prefs["cjkFeatures"] = json!(d.b("cjkFeatures")); } - app.run("prefs.set", prefs)?; + // No document open: the Units & Increments new documents start with, saved with the + // rest in one `prefs.set` (which checks everything before it changes anything). A value + // that doesn't read, or that it refuses, keeps the dialog open to be fixed, with no + // preference changed. + let saved = default_units_of(&d).and_then(|du| { + if let Some(du) = du { + prefs["defaultUnits"] = du; + } + app.run("prefs.set", prefs) + }); + if let Err(e) = saved { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.status(e.clone()); + app.ui.dialog = Some(d); + return Err(e); + } app.ui.dynamic_spelling = d.b("dynamicSpelling"); if d.fields.contains_key("userWords") { let words: Vec = d.s("userWords").lines().map(str::to_string).collect(); @@ -3678,6 +4671,13 @@ fn confirm_part4(app: &mut DesignApp, d: Dialog) -> Result { }; app.run(view, json!({}))?; app.run("window.richBlack", json!({"on": d.b("richBlack")}))?; + // Interface: the theme (already showing, unless set without a click) and the Tools panel. + if let Some(b) = d.fields.get("brightness").and_then(Value::as_str).filter(|b| *b != app.ui.brightness.id()) { + app.run("window.brightness", json!({"brightness": b}))?; + } + if d.fields.contains_key("toolsColumns") { + app.run("window.toolsDoubleColumn", json!({"on": d.s("toolsColumns") == "double"}))?; + } if let Some(v) = d.n("uiScale") { app.run("window.uiScale", json!({"scale": v / 100.0}))?; } @@ -3696,6 +4696,10 @@ fn confirm_part4(app: &mut DesignApp, d: Dialog) -> Result { } app.run("view.snapPreferences", snap)?; } + if d.b("defaults") { + // The default units went with the preferences above. + return Ok(Value::Null); + } let Some(horizontal) = d.fields.get("horizontalUnits") else { return Ok(Value::Null) }; let mut doc = json!({"horizontalUnits": horizontal}); // Fields can come from `ui.dialog.open`; rulers it doesn't name keep their units. @@ -3897,12 +4901,44 @@ fn confirm_part6(app: &mut DesignApp, d: Dialog) -> Result { } "alert" => Ok(Value::Null), crate::effects_dialog::ID => crate::effects_dialog::confirm(app, &d), + crate::package_dialog::ID | crate::package_dialog::INSTRUCTIONS => { + let r = if d.id == crate::package_dialog::ID { + crate::package_dialog::confirm(app, &d) + } else { + crate::package_dialog::confirm_instructions(app, &d) + }; + // Packaging failed: the dialog stays open with its options and instructions, and the + // status line says why. + if let Err(e) = &r + && app.ui.dialog.is_none() + { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r + } "findChange" => { let result = if d.b("objectMode") { Ok(Value::Null) } else { app.run("find.change", find_change_params(&d)) }; // Confirmation consumes retained bounds before closing, even on an error. app.session.reset_find_scope(); result } + CORNER_OPTIONS => { + // The preview goes; the corners change once, as one undo step. + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + let mut d = d; + seed_corner_options(app, &mut d); + link_corners(&mut d); + let r = corner_options_params(&d).and_then(|p| app.run("object.cornerOptions", p)); + if let Err(e) = &r { + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } STEP_AND_REPEAT => { // The preview goes; the copies are made once, as one undo step. if d.fields.contains_key(PREVIEWED) { @@ -3967,12 +5003,47 @@ fn grep_error(p: &str) -> Option { designcraft_compose::grep::Grep::new(p, Default::default()).err().map(|e| e.to_string()) } +/// What paragraph style `style` resolves to when based on `based` (the Based On chosen in its +/// dialog, which may not be its own yet): `based`'s chain with the style's own settings over it. +fn style_over( + styles: &designcraft_doc::Styles, + based: &str, + style: &designcraft_doc::ParagraphStyle, +) -> (designcraft_doc::ParaProps, designcraft_doc::CharProps) { + styles.resolve_para(&designcraft_doc::ParaFormat { + style: based.into(), + para: style.para.clone(), + chars: style.chars.clone(), + ..Default::default() + }) +} + /// The Justification spacing check of Paragraph Style Options, against what the section showed: /// the style's own values, or a new style's Based On. fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<(), String> { - let shown = if d.b("new") { d.s("basedOn") } else { d.s("name") }; - let base = - app.session.active().map(|st| serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default()).unwrap_or_default(); + // After Reset To Base the style starts from its Based On (the one chosen here, if any). + let base_of = |st: &designcraft_engine::DocState| { + let chosen = d.s("basedOn"); + if chosen.is_empty() { st.doc.styles.para(&d.s("name")).and_then(|s| s.based_on.clone()).unwrap_or_default() } else { chosen } + }; + let base = app + .session + .active() + .map(|st| { + let pp = if d.b("new") { + st.doc.styles.resolve_para_style(&d.s("basedOn")).0 + } else if d.b("resetToBase") { + st.doc.styles.resolve_para_style(&base_of(st)).0 + } else { + // The style's own settings over the Based On chosen in the dialog. + match st.doc.styles.para(&d.s("name")) { + Some(style) => style_over(&st.doc.styles, &base_of(st), style).0, + None => st.doc.styles.resolve_para_style(&d.s("name")).0, + } + }; + serde_json::to_value(pp).unwrap_or_default() + }) + .unwrap_or_default(); check_justification(d, "p.", &base) } @@ -4010,16 +5081,26 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul let (para, chars) = style_option_maps(d); let based = d.s("basedOn"); let rename = d.s("rename").trim().to_string(); + let next = d.fields.get("nextStyle").cloned(); let (r, style) = if d.b("new") { let mut params = json!({"name": if rename.is_empty() { "Paragraph Style 1" } else { rename.as_str() }, "para": para, "chars": chars}); if !based.is_empty() && based != designcraft_doc::NO_PARA_STYLE { params["basedOn"] = json!(based); } + if let Some(Value::String(n)) = &next { + params["nextStyle"] = json!(n); + } let r = app.run("style.paragraph.create", params)?; let style = r["name"].as_str().unwrap_or_default().to_string(); (r, style) } else { let mut params = json!({"name": name, "para": para, "chars": chars}); + if let Some(n) = next { + params["nextStyle"] = n; + } + if d.b("resetToBase") { + params["resetToBase"] = json!(true); + } if !based.is_empty() { params["basedOn"] = if based == designcraft_doc::NO_PARA_STYLE { Value::Null } else { json!(based) }; } @@ -4037,9 +5118,14 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul }; // The style now exists under `style`: if a later step fails, the dialog stays open on it and // a second OK edits it rather than creating another one. + let created = d.b("new"); d.fields.insert("new".into(), json!(false)); d.fields.insert("name".into(), json!(&style)); d.fields.insert("rename".into(), json!(&style)); + // Apply Style to Selection. + if created && d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { + app.run("style.paragraph.apply", json!({"name": style}))?; + } if d.fields.contains_key("x.tag") { let tag = d.s("x.tag"); let tag = if tag == "[Automatic]" { String::new() } else { tag }; @@ -4104,6 +5190,10 @@ fn style_options_preview(app: &mut DesignApp, d: &mut Dialog) { _ => based.clone(), }; } + // Reset To Base shows on the page too. + if paragraph && d.b("resetToBase") { + params["resetToBase"] = json!(true); + } if shown.as_ref() == Some(¶ms) { return; } @@ -4145,9 +5235,17 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo if new && !d.fields.contains_key("rename") { d.fields.insert("rename".into(), json!(next_style_name(&doc.styles, true))); } + let mut style = style; + let reset = !new && d.b("resetToBase"); + if reset { + style.para = Default::default(); + style.chars = Default::default(); + } let names: Vec = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| new || *n != name).collect(); - // A new style shows what it would inherit from its Based On. - let (pp, cp) = doc.styles.resolve_para_style(if new { d.fields.get("basedOn").and_then(Value::as_str).unwrap_or("") } else { &name }); + // A new style shows what it would inherit from its Based On (as does one reset to it). + let based_name = d.fields.get("basedOn").and_then(Value::as_str).map(str::to_string).or(style.based_on.clone()).unwrap_or_default(); + // An existing style shows its own settings over the Based On chosen here. + let (pp, cp) = if new || reset { doc.styles.resolve_para_style(&based_name) } else { style_over(&doc.styles, &based_name, &style) }; let units = doc.settings.horizontal_units; let pv = serde_json::to_value(&pp).unwrap_or_default(); let cv = serde_json::to_value(&cp).unwrap_or_default(); @@ -4288,8 +5386,13 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo "openType" => open_type_features(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }), "underline" => line_options(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc, "underline"), "strikethrough" => line_options(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc, "strikethrough"), - "border" => border_shading(&app.ui.language, ui, d, &pv, &doc, true), - "shading" => border_shading(&app.ui.language, ui, d, &pv, &doc, false), + "border" | "shading" => { + let border = d.s("section") == "border"; + let title = if border { "Paragraph Border" } else { "Paragraph Shading" }; + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, title)).size(14.0)); + ui.add_space(6.0); + border_shading(&app.ui.language, ui, d, &pv, &doc, border); + } "keep" => keep_options(&app.ui.language, ui, d, &pv), "span" => span_columns(&app.ui.language, ui, d, &pv, &doc), "bullets" => { @@ -4333,13 +5436,59 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Based On")); ui.end_row(); + // Next Style: the style of the paragraph that follows when Enter ends this one. + let next_name = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); + let next = match d.fields.get("nextStyle") { + Some(Value::String(n)) => Some(n.clone()), + Some(_) => None, + None => style.next_style.clone().filter(|n| *n != name), + }; + let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); + let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); + craft_ui::ComboBox::from_id_salt("psnext") + .selected_text(crate::rtl::widget(ui, shown)) + .width(220.0) + .show_ui(ui, |ui| { + if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { + d.fields.insert("nextStyle".into(), Value::Null); + } + for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { + if ui + .selectable_label( + next.as_deref() == Some(n), + crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n)), + ) + .clicked() + { + d.fields.insert("nextStyle".into(), json!(n)); + } + } + }) + .response + .labelled_by(next_name.id); + ui.end_row(); }); + // Reset To Base: the style keeps nothing of its own (the edits made here stay). + if !new + && ui + .add_enabled( + !based_name.is_empty() && based_name != designcraft_doc::NO_PARA_STYLE, + egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset To Base"))), + ) + .clicked() + { + d.fields.insert("resetToBase".into(), json!(true)); + } ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Settings:")).font(semibold(12.0))); let summary = paragraph_style_summary(&app.ui.language, d, &style, &doc.styles); - egui::ScrollArea::vertical().id_salt("paragraph_style_settings").max_height(220.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt("paragraph_style_settings").max_height(180.0).show(ui, |ui| { ui.add(egui::Label::new(egui::RichText::new(summary).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); }); + if new { + ui.add_space(8.0); + check(ui, d, "apply", crate::i18n::tr(&app.ui.language, "Apply Style to Selection")); + } } }); }); @@ -4560,9 +5709,14 @@ fn para_measure(ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, unit: /// A paragraph attribute chosen from `opts` (serialized value, label). fn para_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, opts: &[(&str, &str)]) { + para_choice_width(lang, ui, d, pv, key, opts, 200.0); +} + +/// [`para_choice`] `width` wide. +fn para_choice_width(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, opts: &[(&str, &str)], width: f32) { let cur = para_value(d, pv, key); let shown = opts.iter().find(|o| cur == o.0).map_or("", |o| crate::i18n::tr(lang, o.1)); - craft_ui::ComboBox::from_id_salt(("pschoice", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("pschoice", key)).selected_text(crate::rtl::widget(ui, shown)).width(width).show_ui(ui, |ui| { for (v, label) in opts { if ui.selectable_label(cur == *v, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { para_set(d, key, json!(v)); @@ -4575,18 +5729,32 @@ fn para_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: & /// edited side stores the whole array. fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, field: &dyn Fn(&mut egui::Ui, &str, Option) -> Option) { let mut sides: [f64; 4] = serde_json::from_value(para_value(d, pv, key)).unwrap_or_default(); + let linked = linked(d, key, sides.iter().all(|v| (v - sides[0]).abs() < 1e-9)); let mut changed = false; - egui::Grid::new(("pssides", key)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + // As InDesign's: Top and Bottom, the link, Left and Right. + egui::Grid::new(("pssides", key)).num_columns(5).spacing([8.0, 6.0]).show(ui, |ui| { for (i, label) in ["Top:", "Left:", "Bottom:", "Right:"].into_iter().enumerate() { crate::rtl::label(ui, crate::i18n::tr(lang, label)); - if let Some(side) = sides.get_mut(i) - && let Some(v) = field(ui, &format!("ps{key}{i}"), Some(*side)) - { - *side = v; + if let Some(v) = field(ui, &format!("ps{key}{i}"), sides.get(i).copied()) { + // Linked, a side typed in sets all four. + if linked { + sides = [v; 4]; + } else if let Some(side) = sides.get_mut(i) { + *side = v; + } changed = true; } - if i % 2 == 1 { - ui.end_row(); + match i { + // Linked again, the four take Top's value. + 0 if link_toggle(lang, ui, d, key, linked) => { + sides = [sides[0]; 4]; + changed = true; + } + 0 => {} + 2 => { + ui.label(""); + } + _ => ui.end_row(), } } }); @@ -4595,11 +5763,50 @@ fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &s } } +/// Whether the four values of `key` are linked ("Make all settings the same"): as the user left +/// the link, else when the four are the same, as InDesign starts it. +fn linked(d: &Dialog, key: &str, same: bool) -> bool { + d.fields.get(&format!("_linked.{key}")).and_then(Value::as_bool).unwrap_or(same) +} + +/// The link button between a border's or shading's four sides or corners; true when it was +/// clicked to link them, and the caller makes the four the same. +fn link_toggle(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, linked: bool) -> bool { + let tip = crate::i18n::tr(lang, "Make all settings the same"); + let clicked = crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)).clicked(); + if clicked { + d.fields.insert(format!("_linked.{key}"), json!(!linked)); + } + clicked && !linked +} + /// A swatch menu with a colour chip per swatch (hidden swatches left out); returns the swatch /// picked. fn swatch_menu(ui: &mut egui::Ui, doc: &designcraft_doc::Document, id: impl egui::AsIdSalt, current: &str, width: f32) -> Option { + swatch_menu_with(ui, doc, id, current, width, None) +} + +/// [`swatch_menu`] with, given its translated label, Text Color first (a paragraph rule's +/// colour: the text's own), as InDesign's Paragraph Rules › Color. +fn swatch_menu_with( + ui: &mut egui::Ui, + doc: &designcraft_doc::Document, + id: impl egui::AsIdSalt, + current: &str, + width: f32, + text_color: Option<&str>, +) -> Option { let mut picked = None; - craft_ui::ComboBox::from_id_salt(id).selected_text(current).width(width).show_ui(ui, |ui| { + let shown = match text_color { + Some(label) if current == designcraft_doc::TEXT_COLOR => label, + _ => current, + }; + craft_ui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { + if let Some(label) = text_color + && ui.selectable_label(current == designcraft_doc::TEXT_COLOR, label).clicked() + { + picked = Some(designcraft_doc::TEXT_COLOR.to_string()); + } for w in doc.swatches.iter().filter(|w| !w.hidden) { let (c, g) = crate::widgets::swatch_colors(doc, &w.name, 1.0); ui.horizontal(|ui| { @@ -4645,49 +5852,95 @@ fn border_type_menu(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, d } /// Corner size and shape of a border or shading (`key` holds top left, top right, bottom right, -/// bottom left); an edited corner stores all four. +/// bottom left), as InDesign lays them out: Top Left and Top Right over Bottom Left and Bottom +/// Right, each a corner icon, a size and a shape menu showing the shapes' glyphs, with the link in +/// the middle. An edited corner stores all four. fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, units: Unit) { use designcraft_geom::corners::{Corner, CornerShape}; let mut corners: [Corner; 4] = serde_json::from_value(para_value(d, pv, key)).unwrap_or_default(); - let mut changed = false; - egui::Grid::new(("pscorners", key)).num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| { - for (i, label) in ["Top Left:", "Top Right:", "Bottom Right:", "Bottom Left:"].into_iter().enumerate() { - let Some(c) = corners.get_mut(i) else { continue }; - crate::rtl::label(ui, crate::i18n::tr(lang, label)); - if let Some(v) = crate::widgets::measure(ui, &format!("ps{key}{i}"), Some(c.size), units, 70.0) { - c.size = if v.is_finite() { v.clamp(0.0, 10_000.0) } else { 0.0 }; - changed = true; - } - craft_ui::ComboBox::from_id_salt(("pscornershape", key, i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, c.shape.label()))) - .width(130.0) - .show_ui(ui, |ui| { - for shape in CornerShape::ALL { - if ui.selectable_label(c.shape == shape, crate::rtl::widget(ui, crate::i18n::tr(lang, shape.label()))).clicked() { - c.shape = shape; - changed = true; - } - } + let linked = linked(d, key, corners.iter().all(|c| *c == corners[0])); + let mut changed = None; + let mut relinked = false; + let mut corner = |ui: &mut egui::Ui, i: usize, icon: &str, tip: &str, changed: &mut Option| { + let Some(c) = corners.get_mut(i) else { return }; + let tip = crate::i18n::tr(lang, tip); + let (r, resp) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), r, icon, crate::theme::Tokens::get(ui.ctx()).icon); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip)); + resp.on_hover_text(tip); + let id = format!("ps{key}{i}"); + if let Some(v) = crate::widgets::NumField::measure(&id, Some(c.size), units).width(84.0).spinner().range(0.0, 10_000.0).show(ui) { + c.size = if v.is_finite() { v.clamp(0.0, 10_000.0) } else { 0.0 }; + *changed = Some(i); + } + let shape_label = crate::i18n::tr(lang, "Corner Shape"); + let shown = c.shape; + let resp = crate::panels::properties::corner_shape_button(ui, shown, 52.0); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, shape_label)); + egui::Popup::menu(&resp).show(|ui| { + for shape in CornerShape::ALL { + let row = crate::panels::properties::preview_row(ui, c.shape == shape, crate::i18n::tr(lang, shape.label()), 22.0, |p, r, ink| { + crate::panels::properties::paint_corner_shape(p, r, shape, ink) }); + if row.clicked() { + c.shape = shape; + *changed = Some(i); + ui.close(); + } + } + }); + }; + egui::Grid::new(("pscorners", key)).num_columns(3).spacing([14.0, 6.0]).show(ui, |ui| { + for (row, [(li, licon, ltip), (ri, ricon, rtip)]) in [ + [(0, "corner-tl", "Top Left Corner"), (1, "corner-tr", "Top Right Corner")], + [(3, "corner-bl", "Bottom Left Corner"), (2, "corner-br", "Bottom Right Corner")], + ] + .into_iter() + .enumerate() + { + ui.horizontal(|ui| corner(ui, li, licon, ltip, &mut changed)); + // Linked again, the four take Top Left's size and shape. + if row == 0 { + if link_toggle(lang, ui, d, key, linked) { + relinked = true; + } + } else { + ui.label(""); + } + ui.horizontal(|ui| corner(ui, ri, ricon, rtip, &mut changed)); ui.end_row(); } }); - if changed { + if relinked { + corners = [corners[0]; 4]; + changed = Some(0); + } + if let Some(i) = changed { + // Linked, the corner changed sets all four. + if linked { + corners = [corners[i]; 4]; + } para_set(d, key, serde_json::to_value(corners).unwrap_or_default()); } } -/// The colour and tint rows of a border, its gap or shading (`keys`: colour, tint), in a grid. +/// Tint presets (%), as InDesign's tint menus list them. +const TINTS: &[f64] = &[0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0]; + +/// The colour and tint rows of a border, its gap or shading (`keys`: colour, tint, overprint), in +/// a grid; Overprint follows the tint, as InDesign's. +#[allow(clippy::too_many_arguments)] fn para_color_tint( + lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc: &designcraft_doc::Document, - keys: (&str, &str), + keys: (&str, &str, &str), labels: (&str, &str), enabled: bool, ) { - let (color_key, tint_key) = keys; + let (color_key, tint_key, overprint_key) = keys; crate::rtl::label(ui, labels.0); let color = para_value(d, pv, color_key).as_str().unwrap_or("").to_string(); if let Some(w) = ui.add_enabled_ui(enabled, |ui| swatch_menu(ui, doc, ("pscolor", color_key), &color, 160.0)).inner { @@ -4695,10 +5948,18 @@ fn para_color_tint( } ui.end_row(); crate::rtl::label(ui, labels.1); - let tint = para_value(d, pv, tint_key).as_f64().map(|t| t * 100.0); - if let Some(v) = ui.add_enabled_ui(enabled, |ui| crate::widgets::number(ui, &format!("ps{tint_key}"), tint, "%", 60.0, 0)).inner { - para_set(d, tint_key, json!(if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { 1.0 })); - } + let tint = para_value(d, pv, tint_key).as_f64().map(|t| (t * 1000.0).round() / 10.0); + ui.add_enabled_ui(enabled, |ui| { + ui.horizontal(|ui| { + let id = format!("ps{tint_key}"); + let field = crate::widgets::NumField::number(&id, tint, "%", 0).width(96.0).spinner().presets(TINTS).step(10.0).range(0.0, 100.0); + if let Some(v) = field.show(ui) { + para_set(d, tint_key, json!(if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { 1.0 })); + } + ui.add_space(8.0); + para_check(ui, d, pv, overprint_key, crate::i18n::tr(lang, "Overprint")); + }); + }); ui.end_row(); } @@ -4716,12 +5977,17 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, t)).font(semibold(12.0))); }; ui.add_space(4.0); - egui::ScrollArea::vertical().id_salt(("psbsscroll", border)).max_height(330.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt(("psbsscroll", border)).max_height(400.0).show(ui, |ui| { ui.add_enabled_ui(on, |ui| { if border { head(ui, "Stroke"); para_sides(lang, ui, d, pv, &key("Weights"), &|ui: &mut egui::Ui, id: &str, v: Option| { - crate::widgets::number(ui, id, v, " pt", 60.0, 2).map(|w| if w.is_finite() { w.clamp(0.0, 1000.0) } else { 0.0 }) + crate::widgets::NumField::number(id, v, " pt", 2) + .width(84.0) + .spinner() + .range(0.0, 1000.0) + .show(ui) + .map(|w| if w.is_finite() { w.clamp(0.0, 1000.0) } else { 0.0 }) }); ui.add_space(4.0); } else { @@ -4734,12 +6000,12 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc ui.end_row(); } let (color, tint) = (crate::i18n::tr(lang, "Color:"), crate::i18n::tr(lang, "Tint:")); - para_color_tint(ui, d, pv, doc, (&key("Color"), &key("Tint")), (color, tint), true); + para_color_tint(lang, ui, d, pv, doc, (&key("Color"), &key("Tint"), &key("Overprint")), (color, tint), true); if border { // The gap colour shows between the dashes, dots or stripes. let solid = para_value(d, pv, "borderType").get("kind").and_then(Value::as_str).is_none_or(|k| k == "solid"); let (gap, gap_tint) = (crate::i18n::tr(lang, "Gap Color:"), crate::i18n::tr(lang, "Gap Tint:")); - para_color_tint(ui, d, pv, doc, ("borderGapColor", "borderGapTint"), (gap, gap_tint), !solid); + para_color_tint(lang, ui, d, pv, doc, ("borderGapColor", "borderGapTint", "borderGapOverprint"), (gap, gap_tint), !solid); crate::rtl::label(ui, crate::i18n::tr(lang, "Cap:")); para_choice(lang, ui, d, pv, "borderCap", &[("butt", "Butt"), ("round", "Round"), ("projecting", "Projecting")]); ui.end_row(); @@ -4750,28 +6016,45 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc }); head(ui, "Corner Size and Shape"); para_corners(lang, ui, d, pv, &key("Corners"), units); - head(ui, "Offsets"); - para_sides(lang, ui, d, pv, &key("Offsets"), &|ui: &mut egui::Ui, id: &str, v: Option| { - crate::widgets::measure(ui, id, v, units, 60.0) - }); - ui.add_space(4.0); - egui::Grid::new(("psbsedges", border)).num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(lang, "Top Edge:")); - para_choice( - lang, - ui, - d, - pv, - &key("Top"), - &[("ascent", "Ascent"), ("baseline", "Baseline"), ("emBox", "Em Box Top"), ("leading", "Leading")], - ); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(lang, "Bottom Edge:")); - para_choice(lang, ui, d, pv, &key("Bottom"), &[("descent", "Descent"), ("baseline", "Baseline"), ("emBox", "Em Box Bottom")]); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(lang, "Width:")); - para_choice(lang, ui, d, pv, &key("Width"), &[("column", "Column"), ("text", "Text")]); - ui.end_row(); + // The offsets, and beside them the edges and width the box follows, as InDesign's. + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + head(ui, "Offsets"); + para_sides(lang, ui, d, pv, &key("Offsets"), &|ui: &mut egui::Ui, id: &str, v: Option| { + crate::widgets::spin_measure(ui, id, v, units, 72.0) + }); + }); + ui.add_space(12.0); + ui.vertical(|ui| { + head(ui, ""); + egui::Grid::new(("psbsedges", border)).num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Top Edge:")); + para_choice_width( + lang, + ui, + d, + pv, + &key("Top"), + &[("ascent", "Ascent"), ("baseline", "Baseline"), ("emBox", "Em Box Top"), ("leading", "Leading")], + 92.0, + ); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Bottom Edge:")); + para_choice_width( + lang, + ui, + d, + pv, + &key("Bottom"), + &[("descent", "Descent"), ("baseline", "Baseline"), ("emBox", "Em Box Bottom")], + 92.0, + ); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Width:")); + para_choice_width(lang, ui, d, pv, &key("Width"), &[("column", "Column"), ("text", "Text")], 92.0); + ui.end_row(); + }); + }); }); ui.add_space(4.0); if border { @@ -5510,6 +6793,29 @@ fn section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cjk: bool, sectio }); } +/// Preferences with no document open: the Units & Increments new documents start with, as +/// `prefs.set`'s `defaultUnits` (None with a document open). An increment that doesn't read is an +/// error naming the field. +fn default_units_of(d: &Dialog) -> Result, String> { + if !d.b("defaults") { + return Ok(None); + } + let mut du = json!({}); + for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { + if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { + du[k] = v.clone(); + } + } + if let Some(t) = d.fields.get("keyboardIncrement").and_then(Value::as_str) { + du["keyboardIncrement"] = json!(parse_measure(t, Unit::Points).map_err(|_| format!("Cursor Key: `{t}` is not a measurement"))?); + } + if let Some(v) = d.fields.get("kerningIncrement").filter(|v| !v.is_null()) { + let n = d.n("kerningIncrement").filter(|v| v.is_finite()); + du["kerningIncrement"] = json!(n.ok_or_else(|| format!("Kerning/Tracking: `{}` is not a number", v.as_str().unwrap_or_default()))?); + } + Ok(Some(du)) +} + /// "Paragraph Style n" / "Character Style n": the first name no style has. fn next_style_name(styles: &designcraft_doc::Styles, para: bool) -> String { let (base, count) = if para { ("Paragraph Style", styles.paragraph.len()) } else { ("Character Style", styles.character.len()) }; @@ -5533,8 +6839,39 @@ pub fn open_style_options(app: &mut DesignApp, para: bool, name: &str) { pub fn open_new_style(app: &mut DesignApp, para: bool) { let Some(st) = app.session.active() else { return }; let name = next_style_name(&st.doc.styles, para); + let has_text = st.selection.text.is_some(); + let (bp, bc) = st.doc.styles.resolve_para_style(designcraft_doc::NO_PARA_STYLE); let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" }; - app.ui.dialog = Some(Dialog::new(id, json!({"new": true, "rename": name}))); + let mut fields = json!({"new": true, "rename": name}); + // New Paragraph Style with text selected starts from it, as InDesign's: Based On [No Paragraph + // Style] and every setting of the text that differs, applied to the selection on OK. + if para + && has_text + && let Some(sel) = app.session.execute("type.selectionAttrs", &json!({})).ok().filter(|v| !v.is_null()) + { + for (prefix, now, base) in + [("p.", &sel["para"], serde_json::to_value(bp).unwrap_or_default()), ("c.", &sel["chars"], serde_json::to_value(bc).unwrap_or_default())] + { + for (k, v) in now.as_object().into_iter().flatten() { + if !v.is_null() && base.get(k) != Some(v) && base.get(k).is_some() { + fields[format!("{prefix}{k}")] = v.clone(); + } + } + } + fields["apply"] = json!(true); + } + // New Character Style with text selected starts from what the text has of its own over its + // paragraph ("[None] + Bold + color"), applied to the selection on OK, as InDesign's. + if !para + && has_text + && let Some(sel) = app.session.execute("type.selectionAttrs", &json!({})).ok().filter(|v| !v.is_null()) + { + for (k, v) in sel["ownChars"].as_object().into_iter().flatten().filter(|(_, v)| !v.is_null()) { + fields[format!("c.{k}")] = v.clone(); + } + fields["apply"] = json!(true); + } + app.ui.dialog = Some(Dialog::new(id, fields)); } /// The General section of the Character Style Options dialog: name, Based On, and a summary of @@ -5651,7 +6988,13 @@ fn character_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo "underline" => line_options(&lang, ui, d, &cf, &doc, "underline"), "strikethrough" => line_options(&lang, ui, d, &cf, &doc, "strikethrough"), s if style_cjk::is_section(s) => style_cjk::section_ui(app, ui, d, s, &cf, None, &doc), - _ => character_style_general(&lang, ui, d, styles, &style, &based), + _ => { + character_style_general(&lang, ui, d, styles, &style, &based); + if new { + ui.add_space(8.0); + check(ui, d, "apply", crate::i18n::tr(&lang, "Apply Style to Selection")); + } + } }); }); } @@ -5712,7 +7055,7 @@ fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, su fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) { let cur = cf.get(d, key); let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1)); - craft_ui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() { cf.set(d, key, Value::Null); } @@ -5755,6 +7098,7 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let lang = lang.as_str(); let fonts = crate::panels::fonts(app); let menu = crate::panels::font_menu(app); + // Two columns, as InDesign's: the font, then Size | Leading, Kerning | Tracking, Case | Position. egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Font Family:")); let fam = cf.get(d, "fontFamily").as_str().unwrap_or("").to_string(); @@ -5804,9 +7148,10 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); } ui.end_row(); + }); + egui::Grid::new("psc2").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Size:")); char_number(ui, d, cf, "size", " pt", 1.0, (0.1, 1296.0)); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Leading:")); let lv = match cf.get(d, "leading") { v if v["kind"] == "points" => v["value"].as_f64(), @@ -5827,7 +7172,6 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo "kerning", &[(json!({"kind": "metrics"}), "Metrics"), (json!({"kind": "optical"}), "Optical"), (json!({"kind": "none"}), "0")], ); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Tracking:")); char_number(ui, d, cf, "tracking", "", 1.0, (-1000.0, 10000.0)); ui.end_row(); @@ -5845,7 +7189,6 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo (json!("openTypeAllSmallCaps"), "OpenType All Small Caps"), ], ); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Position:")); char_choice( lang, @@ -6074,22 +7417,26 @@ fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog }); } -fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { +fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) -> egui::Response { let cur = d.s(key); let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string(); - craft_ui::ComboBox::from_id_salt(key).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| { - for (v, label) in opts { - if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() { - d.fields.insert(key.into(), json!(v)); + craft_ui::ComboBox::from_id_salt(key) + .selected_text(crate::rtl::widget(ui, &shown)) + .width(170.0) + .show_ui(ui, |ui| { + for (v, label) in opts { + if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() { + d.fields.insert(key.into(), json!(v)); + } } - } - }); + }) + .response } /// Translate enum captions without changing serialized values or user-defined names. -fn translated_combo(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { +fn translated_combo(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) -> egui::Response { let opts: Vec<_> = opts.iter().map(|(value, label)| (*value, crate::i18n::tr(lang, label))).collect(); - combo(ui, d, key, &opts); + combo(ui, d, key, &opts) } /// Style name picker (paragraph or character styles of the active document). @@ -6436,22 +7783,65 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & }); ui.add_space(6.0); let on = rule["on"].as_bool().unwrap_or(false); + let kind: designcraft_doc::StrokeType = serde_json::from_value(rule["type"].clone()).unwrap_or_default(); + let doc = app.session.active().map(|st| &st.doc); + // As InDesign's: Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, + // Left and Right Indent, then Keep In Frame (rule above). ui.add_enabled_ui(on, |ui| { egui::Grid::new(("rules", which)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:")); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("weight"), rule["weight"].as_f64(), " pt", 80.0, 2)) { + if let Some(v) = sized(ui, &mut |ui| { + crate::widgets::NumField::number(&key("weight"), rule["weight"].as_f64(), " pt", 2).width(80.0).spinner().range(0.0, 1000.0).show(ui) + }) { d.fields.insert(key("weight"), json!(v.clamp(0.0, 1000.0))); } + crate::rtl::label(ui, crate::i18n::tr(lang, "Type:")); + let custom: Vec = doc.map_or_else(Vec::new, |doc| doc.stroke_styles.iter().map(|s| s.name.clone()).collect()); + let shown = match &kind { + designcraft_doc::StrokeType::Style { name } => name.clone(), + k => crate::i18n::tr(lang, k.label()).to_string(), + }; + craft_ui::ComboBox::from_id_salt(("rule_type", which)).selected_text(crate::rtl::widget(ui, shown)).width(130.0).show_ui(ui, |ui| { + for k in rule_types().into_iter().chain(custom.into_iter().map(|name| designcraft_doc::StrokeType::Style { name })) { + let label = match &k { + designcraft_doc::StrokeType::Style { name } => name.clone(), + k => crate::i18n::tr(lang, k.label()).to_string(), + }; + if ui.selectable_label(k == kind, crate::rtl::widget(ui, label)).clicked() { + d.fields.insert(key("type"), serde_json::to_value(&k).unwrap_or_default()); + } + } + }); + ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Color:")); let color = rule["color"].as_str().unwrap_or("").to_string(); - if let Some(w) = app.session.active().and_then(|st| swatch_menu(ui, &st.doc, ("rule_color", which), &color, 130.0)) { + let text_color = crate::i18n::tr(lang, "Text Color"); + if let Some(w) = doc.and_then(|doc| swatch_menu_with(ui, doc, ("rule_color", which), &color, 130.0, Some(text_color))) { d.fields.insert(key("color"), json!(w)); } - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:")); if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("tint"), rule["tint"].as_f64().map(|t| t * 100.0), "%", 80.0, 0)) { d.fields.insert(key("tint"), json!((v / 100.0).clamp(0.0, 1.0))); } + ui.end_row(); + // A solid rule has no gaps. + let gaps = !matches!(kind, designcraft_doc::StrokeType::Solid); + crate::rtl::label(ui, crate::i18n::tr(lang, "Gap Color:")); + let gap = rule["gapColor"].as_str().unwrap_or(designcraft_color::swatch::NONE).to_string(); + ui.add_enabled_ui(gaps, |ui| { + if let Some(w) = doc.and_then(|doc| swatch_menu(ui, doc, ("rule_gap", which), &gap, 130.0)) { + d.fields.insert(key("gapColor"), json!(w)); + } + }); + crate::rtl::label(ui, crate::i18n::tr(lang, "Gap Tint:")); + ui.add_enabled_ui(gaps && gap != designcraft_color::swatch::NONE, |ui| { + // A full gap tint is left out of the rule's JSON: it reads 100%. + let gap_tint = rule["gapTint"].as_f64().unwrap_or(1.0) * 100.0; + if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("gapTint"), Some(gap_tint), "%", 80.0, 0)) { + d.fields.insert(key("gapTint"), json!((v / 100.0).clamp(0.0, 1.0))); + } + }); + ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Width:")); let column = rule["columnWidth"].as_bool().unwrap_or(true); craft_ui::ComboBox::from_id_salt(("rule_width", which)) @@ -6464,23 +7854,50 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & } } }); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Offset:")); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::measure(ui, &key("offset"), rule["offset"].as_f64(), v_units, 80.0)) { + if let Some(v) = sized(ui, &mut |ui| { + crate::widgets::NumField::measure(&key("offset"), rule["offset"].as_f64(), v_units).width(80.0).spinner().show(ui) + }) { d.fields.insert(key("offset"), json!(v)); } ui.end_row(); for (label, field) in [("Left Indent:", "leftIndent"), ("Right Indent:", "rightIndent")] { crate::rtl::label(ui, crate::i18n::tr(lang, label)); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::measure(ui, &key(field), rule[field].as_f64(), h_units, 80.0)) { + if let Some(v) = + sized(ui, &mut |ui| crate::widgets::NumField::measure(&key(field), rule[field].as_f64(), h_units).width(80.0).spinner().show(ui)) + { d.fields.insert(key(field), json!(v)); } } ui.end_row(); }); + if which == "ruleAbove" { + let mut keep = rule["keepInFrame"].as_bool().unwrap_or(false); + if ui.checkbox(&mut keep, crate::rtl::widget(ui, crate::i18n::tr(lang, "Keep In Frame"))).changed() { + d.fields.insert(key("keepInFrame"), json!(keep)); + } + } }); } +/// The built-in stroke types a paragraph rule can have (Paragraph Rules › Type). +fn rule_types() -> Vec { + use designcraft_doc::StrokeType as T; + vec![ + T::Solid, + T::ThickThick, + T::ThickThin, + T::ThickThinThick, + T::ThinThick, + T::ThinThickThin, + T::ThinThin, + T::Dashed { pattern: vec![12.0, 4.0] }, + T::Dotted, + T::Hashed, + T::Wavy, + ] +} + /// Document Footnote Options: Numbering and Formatting / Layout tabs. fn footnote_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { @@ -6749,6 +8166,7 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat"; +pub(crate) const CORNER_OPTIONS: &str = "cmd:object.cornerOptions"; const TOC: &str = "cmd:toc.generate"; /// One included style of the Table of Contents dialog, as `toc.generate` takes it, with the @@ -6768,11 +8186,20 @@ struct TocRow { } fn toc_separator_shown(s: &str) -> String { - s.replace('\t', "^t").replace('\u{2003}', "^m").replace('\u{2002}', "^>") + designcraft_doc::story::toc_separator_notation(s) } fn toc_separator_typed(s: &str) -> String { - s.replace("^t", "\t").replace("^m", "\u{2003}").replace("^>", "\u{2002}") + designcraft_doc::story::toc_separator_text(s) +} + +/// Appends the special-character `code` (such as `^y`) to the separator field's notation. A +/// trailing unescaped caret is a literal caret: it's escaped first, so it doesn't swallow the code. +fn toc_separator_append(field: &mut String, code: &str) { + if field.chars().rev().take_while(|&c| c == '^').count() % 2 == 1 { + field.push('^'); + } + field.push_str(code); } fn toc_rows(d: &Dialog) -> Vec { @@ -6817,6 +8244,7 @@ pub fn open_toc(app: &mut DesignApp) { "charStyles": char_styles, "hasToc": toc.is_some(), "replace": toc.is_some(), + "createBookmarks": toc.is_none_or(|t| t.create_bookmarks), }); app.ui.dialog = Some(Dialog::new(TOC, fields)); } @@ -6845,7 +8273,7 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value { e }) .collect(); - let mut p = json!({"title": d.s("title"), "entries": entries, "replace": d.b("replace")}); + let mut p = json!({"title": d.s("title"), "entries": entries, "replace": d.b("replace"), "createBookmarks": d.b("createBookmarks")}); let title_style = d.s("titleStyle"); if !title_style.is_empty() { p["titleStyle"] = json!(title_style); @@ -6858,21 +8286,26 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value { /// A style menu whose first choice, stored as "", is `auto` (the generated paragraph style, or /// [None] for a character style). -fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String) -> bool { +fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String, name: egui::Id) -> bool { let shown = if cur.is_empty() { auto.to_string() } else { cur.clone() }; let mut changed = false; - craft_ui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| { - if ui.selectable_label(cur.is_empty(), auto).clicked() { - cur.clear(); - changed = true; - } - for n in styles.iter().filter(|n| *n != auto) { - if ui.selectable_label(cur == n, n.as_str()).clicked() { - *cur = n.clone(); + craft_ui::ComboBox::from_id_salt(("toc_style", salt)) + .selected_text(shown) + .width(200.0) + .show_ui(ui, |ui| { + if ui.selectable_label(cur.is_empty(), auto).clicked() { + cur.clear(); changed = true; } - } - }); + for n in styles.iter().filter(|n| *n != auto) { + if ui.selectable_label(cur == n, n.as_str()).clicked() { + *cur = n.clone(); + changed = true; + } + } + }) + .response + .labelled_by(name); changed } @@ -6884,19 +8317,40 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); let styles: Vec = d.fields.get("styles").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); let char_styles: Vec = d.fields.get("charStyles").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut expanded = d.fields.get("moreOptions").and_then(Value::as_bool).unwrap_or(true); let mut rows = toc_rows(d); let mut sel = d.n("selected").map_or(0, |n| n.max(0.0) as usize); let mut other = d.s("other"); let mut title_style = d.s("titleStyle"); + // The dialog body's width, less the two labels and the style selector (200 pt) beside the title. + let title_room = ui.available_width() - 300.0; egui::Grid::new("toc_title").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, tr("Title:")); - text_field(ui, d, "title", 200.0); - crate::rtl::label(ui, tr("Style:")); - toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style); + let mut title = d.s("title"); + // 200 pt, reserved: a desired width alone is squeezed by the grid to a few characters. + // Narrower when the dialog is, so the style selector beside it stays in view. + let w = 200.0_f32.min(title_room.max(80.0)); + let field = ui.add_sized([w, ui.spacing().interact_size.y], egui::TextEdit::singleline(&mut title).align(egui::Align2::LEFT_TOP)); + if field.accessible_name(tr("Title:")).changed() { + d.fields.insert("title".into(), json!(title)); + } + let name = crate::rtl::label(ui, tr("Style:")).id; + toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style, name); ui.end_row(); }); ui.add_space(8.0); - crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); + ui.horizontal(|ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); + let toggle = ui.button(tr(if expanded { "Fewer Options" } else { "More Options" })); + // Enter on the focused toggle toggles it (egui activates a focused button with Enter): + // the footer drawn after it mustn't take that Enter for OK too. + if toggle.has_focus() { + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } + if toggle.clicked() { + expanded = !expanded; + } + }); let others: Vec<&String> = styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)).collect(); ui.horizontal_top(|ui| { ui.vertical(|ui| { @@ -6945,50 +8399,77 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { crate::rtl::label(ui, egui::RichText::new(format!("{} {}", tr("Style:"), r.style)).font(semibold(12.0))); let none = designcraft_doc::NO_CHAR_STYLE; egui::Grid::new("toc_entry").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Entry Style:")); + let name = crate::rtl::label(ui, tr("Entry Style:")).id; let auto = format!("TOC Level {}", r.level.clamp(1, 9)); - toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style); + toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style, name); ui.end_row(); - crate::rtl::label(ui, tr("Page Number:")); + if !expanded { + return; + } + let page_number = crate::rtl::label(ui, tr("Page Number:")).id; let choices = [("after", "After Entry"), ("before", "Before Entry"), ("none", "No Page Number")]; let cur = choices.iter().find(|(v, _)| *v == r.page_number).map_or("After Entry", |(_, l)| l); - craft_ui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| { - for (v, l) in choices { - if ui.selectable_label(r.page_number == v, tr(l)).clicked() { - r.page_number = v.into(); + craft_ui::ComboBox::from_id_salt("toc_page_number") + .selected_text(tr(cur)) + .width(200.0) + .show_ui(ui, |ui| { + for (v, l) in choices { + if ui.selectable_label(r.page_number == v, tr(l)).clicked() { + r.page_number = v.into(); + } } - } - }); - crate::rtl::label(ui, tr("Style:")); - toc_style_combo(ui, "page_number", &char_styles, none, &mut r.page_number_style); + }) + .response + .labelled_by(page_number); + let name = crate::rtl::label(ui, tr("Style:")).id; + toc_style_combo(ui, "page_number", &char_styles, none, &mut r.page_number_style, name); ui.end_row(); - crate::rtl::label(ui, tr("Between Entry and Number:")); - ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).align(egui::Align2::LEFT_TOP)); - crate::rtl::label(ui, tr("Style:")); - toc_style_combo(ui, "separator", &char_styles, none, &mut r.separator_style); + let between = crate::rtl::label(ui, tr("Between Entry and Number:")).id; + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).align(egui::Align2::LEFT_TOP)).labelled_by(between); + ui.menu_button("▸", |ui| { + for (label, code) in [("Tab Character", "^t"), ("Right Indent Tab", "^y"), ("Em Space", "^m"), ("En Space", "^>")] { + if ui.button(tr(label)).clicked() { + toc_separator_append(&mut r.separator, code); + ui.close(); + } + } + }) + .response + .on_hover_text(tr("Insert Special Character")); + }); + let name = crate::rtl::label(ui, tr("Style:")).id; + toc_style_combo(ui, "separator", &char_styles, none, &mut r.separator_style, name); ui.end_row(); crate::rtl::label(ui, ""); ui.checkbox(&mut r.sort, tr("Sort Entries in Alphabetical Order")); ui.end_row(); - crate::rtl::label(ui, tr("Level:")); - craft_ui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| { - for l in 1..=9u8 { - if ui.selectable_label(r.level == l, l.to_string()).clicked() { - r.level = l; + let level = crate::rtl::label(ui, tr("Level:")).id; + craft_ui::ComboBox::from_id_salt("toc_level") + .selected_text(r.level.to_string()) + .width(60.0) + .show_ui(ui, |ui| { + for l in 1..=9u8 { + if ui.selectable_label(r.level == l, l.to_string()).clicked() { + r.level = l; + } } - } - }); + }) + .response + .labelled_by(level); ui.end_row(); }); } ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(tr("Options")).font(semibold(12.0))); + check(ui, d, "createBookmarks", &tr("Create PDF Bookmarks")); let has_toc = d.b("hasToc"); ui.add_enabled_ui(has_toc, |ui| check(ui, d, "replace", &tr("Replace Existing Table of Contents"))); if let Some(st) = d.fields.get("status").and_then(Value::as_str) { ui.add_space(6.0); crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); } + d.fields.insert("moreOptions".into(), json!(expanded)); d.fields.insert("included".into(), json!(rows)); d.fields.insert("selected".into(), json!(sel)); d.fields.insert("other".into(), json!(other)); @@ -7108,6 +8589,206 @@ fn step_and_repeat_preview(app: &mut DesignApp, d: &mut Dialog) { d.fields.insert(PREVIEWED.into(), params); } +/// The corners of Object › Corner Options, in `object.cornerOptions` path order (top-left, +/// top-right, bottom-right, bottom-left), with the tip InDesign gives each. +const CORNERS: [(&str, &str); 4] = [("0", "Top Left:"), ("1", "Top Right:"), ("2", "Bottom Right:"), ("3", "Bottom Left:")]; + +/// The Corner Options dialog's fields from the first selected object's corners, once: each +/// corner's size (in the ruler units) and shape, linked when all four are the same. +fn seed_corner_options(app: &DesignApp, d: &mut Dialog) { + use designcraft_geom::corners::Corner; + let Some(st) = app.session.active() else { return }; + d.unit = st.doc.settings.horizontal_units; + if d.fields.contains_key("_seeded") { + return; + } + let corners: [Corner; 4] = st.selection.items.iter().find_map(|id| st.doc.item(*id)).map(|it| it.corners.corners).unwrap_or_default(); + for ((k, _), c) in CORNERS.iter().zip(corners) { + d.fields.entry(format!("size{k}")).or_insert(json!(format_measure(c.size, d.unit))); + d.fields.entry(format!("shape{k}")).or_insert(serde_json::to_value(c.shape).unwrap_or(json!("none"))); + } + let first = corners[0]; + d.fields.entry("link").or_insert(json!(corners.iter().all(|c| *c == first))); + d.fields.insert("_seed".into(), json!(corner_fields(d))); + d.fields.insert("_seeded".into(), json!(true)); +} + +/// Each corner's size and shape fields, in `CORNERS` order. +fn corner_fields(d: &Dialog) -> Vec<(String, String)> { + CORNERS.iter().map(|(k, _)| (d.s(&format!("size{k}")), d.s(&format!("shape{k}")))).collect() +} + +/// The corners edited since the dialog opened: changed from the first object's, or set by a +/// linked edit (all four, even one that was already the value). +fn edited_corners(d: &Dialog) -> [bool; 4] { + let touched: [bool; 4] = d.fields.get("_touched").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let seed: Vec<(String, String)> = d.fields.get("_seed").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let now = corner_fields(d); + std::array::from_fn(|i| touched[i] || seed.get(i) != now.get(i)) +} + +/// Linked corners: a corner changed since the dialog last looked (size or shape, typed or set +/// over the control channel) is all four. +fn link_corners(d: &mut Dialog) { + let now = corner_fields(d); + let seen: Vec<(String, String)> = d.fields.get("_corners").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_else(|| now.clone()); + let mut now = now; + let mut touched: [bool; 4] = d.fields.get("_touched").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + for (t, (a, b)) in touched.iter_mut().zip(now.iter().zip(&seen)) { + *t |= a != b; + } + if d.b("link") + && let Some(i) = now.iter().zip(&seen).position(|(a, b)| a != b) + && let Some(changed) = now.get(i).cloned() + { + for (k, _) in &CORNERS { + d.fields.insert(format!("size{k}"), json!(changed.0)); + d.fields.insert(format!("shape{k}"), json!(changed.1)); + } + now = vec![changed; CORNERS.len()]; + touched = [true; 4]; + } + d.fields.insert("_corners".into(), json!(now)); + d.fields.insert("_touched".into(), json!(touched)); +} + +/// `object.cornerOptions`' params from the dialog: the corners edited, the others null (each +/// selected object keeps its own). A size that does not parse, or a shape the dialog doesn't +/// offer, is an error to show. +fn corner_options_params(d: &Dialog) -> Result { + use designcraft_geom::corners::CornerShape; + let edited = edited_corners(d); + let mut corners = Vec::with_capacity(CORNERS.len()); + for ((k, _), edited) in CORNERS.iter().zip(edited) { + let key = format!("size{k}"); + let size = + d.m(&key).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| format!("Enter a valid corner size, e.g. 12 or 3 mm: {}", d.s(&key)))?; + let shape: CornerShape = match d.fields.get(&format!("shape{k}")) { + None => CornerShape::default(), + Some(v) => serde_json::from_value(v.clone()).map_err(|_| format!("Choose a corner shape: {v} isn't one"))?, + }; + corners.push(if edited { json!({"shape": shape, "size": size}) } else { Value::Null }); + } + Ok(json!({"corners": corners})) +} + +/// A corner's icon: the corner of a small square, with a dot at the corner it sets. +fn paint_corner_icon(ui: &mut egui::Ui, corner: usize, tip: &str) { + let t = crate::theme::Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + resp.on_hover_text(tip); + let b = r.shrink(3.0); + ui.painter().rect_stroke(b, 0.0, egui::Stroke::new(1.0, t.icon), egui::StrokeKind::Inside); + let at = match corner { + 0 => b.left_top(), + 1 => b.right_top(), + 2 => b.right_bottom(), + _ => b.left_bottom(), + }; + ui.painter().rect_filled(egui::Rect::from_center_size(at, egui::vec2(4.0, 4.0)), 0.0, t.accent); +} + +/// Object › Corner Options, as InDesign's: under "Corner Size and Shape:", each corner's size and +/// shape (top corners above, bottom ones below, as on the frame), the link button that makes all +/// four the same, and Preview. +fn corner_options_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + use designcraft_geom::corners::CornerShape; + seed_corner_options(app, d); + link_corners(d); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + crate::rtl::label(ui, tr("Corner Size and Shape:")); + ui.add_space(8.0); + let corner = |ui: &mut egui::Ui, d: &mut Dialog, i: usize| { + let Some((k, tip)) = CORNERS.get(i) else { return }; + ui.horizontal(|ui| { + paint_corner_icon(ui, i, &tr(tip)); + measure_field(ui, d, &format!("size{k}"), 70.0); + let key = format!("shape{k}"); + let shape: CornerShape = d.fields.get(&key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let resp = crate::panels::properties::corner_shape_button(ui, shape, 46.0); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, format!("{} {}", tr(tip), tr(shape.label())))); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(150.0); + for s in CornerShape::ALL { + if ui.selectable_label(s == shape, crate::rtl::widget(ui, tr(s.label()))).clicked() { + d.fields.insert(key.clone(), json!(s)); + ui.close(); + } + } + }); + }); + }; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + corner(ui, d, 0); + ui.add_space(6.0); + corner(ui, d, 3); + }); + ui.add_space(8.0); + let linked = d.b("link"); + let tip = tr("Make all settings the same"); + let resp = ui + .vertical(|ui| { + ui.add_space(12.0); + crate::icons::button(ui, if linked { "link" } else { "link-broken" }, 20.0, linked, &tip) + }) + .inner; + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); + if resp.clicked() { + d.fields.insert("link".into(), json!(!linked)); + } + ui.add_space(8.0); + ui.vertical(|ui| { + corner(ui, d, 1); + ui.add_space(6.0); + corner(ui, d, 2); + }); + }); + link_corners(d); + ui.add_space(10.0); + check(ui, d, "preview", &tr("Preview")); + corner_options_preview(app, d); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } +} + +/// Preview on: the corners show on the frame (redone when a field changes); off: they go back. +fn corner_options_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match corner_options_params(d) { + Ok(p) => p, + Err(e) => { + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Corner Options") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("object.cornerOptions", params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + /// New Cross-Reference: link to a paragraph (paragraph styles on the left, their paragraphs on /// the right) or a text anchor, with a cross-reference format. fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { @@ -7208,107 +8889,297 @@ fn nums(d: &Dialog, k: &str) -> Option<[f64; N]> { Some(out) } -/// What changed the Color Picker's colour this frame. -#[derive(Clone, Copy, PartialEq)] -enum ColorEdit { - None, - /// The saturation/brightness field, the hue slider or the hex field: keeps the colour's model. - Picked, - /// Typed R/G/B values: the colour becomes RGB. - Rgb, - /// Typed C/M/Y/K values: the colour becomes CMYK. - Cmyk, +const EYEDROPPER_DRAG: &str = "color_picker_eyedropper_dragging"; + +/// The Color Picker's eyedropper at `at`, as InDesign's: dragged onto the page, it takes the colour +/// shown under the pointer when it is let go. The colour taken, if any. +fn eyedropper(app: &mut DesignApp, ui: &mut egui::Ui, at: egui::Pos2, tip: String, t: &crate::theme::Tokens) -> Option<[u8; 3]> { + let er = egui::Rect::from_center_size(at, egui::vec2(21.0, 21.0)); + let resp = ui.interact(er, egui::Id::unique("color_picker_eyedropper"), egui::Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); + if resp.hovered() || resp.dragged() { + ui.painter().rect_filled(er, 3.0, t.hover); + } + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(er.center(), egui::vec2(15.0, 15.0)), "tool-eyedropper", t.icon); + // Esc during the drag cancels just the drag (the picker's own Esc/Enter handling stands down + // while `EYEDROPPER_DRAG` is set), and the release takes nothing. + let cancel_id = egui::Id::unique("color_picker_eyedropper_cancelled"); + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(cancel_id, true)); + } + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } + ui.data_mut(|d| d.insert_temp(egui::Id::unique(EYEDROPPER_DRAG), resp.dragged())); + let cancelled = resp.drag_stopped() && ui.data_mut(|d| d.remove_temp::(cancel_id)).unwrap_or(false); + // Let go over the picker itself it takes nothing, though a page may lie under it. (Its modal + // backdrop shares the picker's layer and covers the screen: the picker is where it draws.) + let mut taken = None; + if resp.drag_stopped() + && !cancelled + && let Some(pos) = ui.input(|i| i.pointer.latest_pos()) + && !ui.clip_rect().contains(pos) + { + taken = crate::canvas::color_at(app, ui.ctx(), pos); + } + resp.on_hover_text(tip); + taken } -/// Color Picker: saturation/brightness field and hue slider, with R/G/B, C/M/Y/K and hex fields. -/// -/// `rgb` (0-255) and `cmyk` (percent) hold the values as typed, so a field being edited is never -/// rebuilt from the colour; the hex field keeps its text in `hexEdit` while it has focus and shows -/// the colour again when focus leaves. `model` is what OK applies: picking keeps a CMYK colour CMYK -/// (converted through the CMYK working space); typing R/G/B or C/M/Y/K values chooses that model. -fn color_picker(ui: &mut egui::Ui, d: &mut Dialog) { - use designcraft_color::Color; - let to8 = |v: f64| v.clamp(0.0, 255.0).round() as u8; - let display_rgb = |c: Color| c.to_rgb().map(|v| (f64::from(v) * 255.0).clamp(0.0, 255.0).round()); - let cmyk_of = |rgb: [f64; 3]| { - let c = Color::rgb((rgb[0] / 255.0) as f32, (rgb[1] / 255.0) as f32, (rgb[2] / 255.0) as f32); - c.to_cmyk_managed(designcraft_color::cms::active_settings().intent).map(|v| f64::from(v) * 100.0) +/// Closes the picker for a click on OK or Cancel, else for Enter (OK) or Esc (Cancel) unless an +/// eyedropper drag is on. +fn picker_close(ui: &mut egui::Ui, d: &mut Dialog, ok: bool, cancel: bool) { + let dragging = ui.data(|d| d.get_temp::(egui::Id::unique(EYEDROPPER_DRAG))).unwrap_or(false); + let (enter, esc) = if dragging { + (false, false) + } else { + ( + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)), + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)), + ) }; - let hex_rgb = |s: &str| match Color::from_hex(s) { - Some(Color::Rgb { r, g, b }) => Some([r, g, b].map(|v| (f64::from(v) * 255.0).round())), - _ => None, + if ok || cancel { + d.fields.insert("_close".into(), json!(if cancel { "cancel" } else { "ok" })); + } else if enter || esc { + d.fields.insert("_close".into(), json!(if enter { "ok" } else { "cancel" })); + } +} + +/// The Color Picker, laid out as InDesign's: the colour space view (a field of two components, +/// a slider for the third: the one whose radio button is on, R at first), the new colour over the +/// one it replaces, OK / Cancel / Add CMYK Swatch at the top right, H/S/B beside L/a/b, R/G/B +/// beside C/M/Y/K, and the hex code. Its buttons replace the dialog's own. +fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + use picker_layout as at; + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let old = d.s("hex"); + d.fields.entry("old").or_insert_with(|| json!(old)); + let comp = PickComp::from_key(&d.s("component")); + let mut p = picked_of(d); + let before = p; + d.fields.entry("rgb").or_insert_with(|| json!(p.rgb.map(|x| x * 255.0))); + if !d.fields.contains_key("cmyk") { + d.fields.insert( + "cmyk".into(), + json!( + designcraft_color::Color::Rgb { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] } + .to_cmyk_managed(designcraft_color::cms::active_settings().intent) + .map(|x| x * 100.0) + ), + ); + } + let mut exact_cmyk = None; + let t = crate::theme::Tokens::get(ui.ctx()); + let (area, _) = ui.allocate_exact_size(at::SIZE, egui::Sense::hover()); + let o = area.min.to_vec2(); + let put = |r: egui::Rect| r.translate(o); + let font = egui::FontId::proportional(11.5); + let text = |ui: &egui::Ui, pos: egui::Pos2, align: egui::Align2, s: &str| { + ui.painter().text(pos, align, s, egui::FontId::proportional(11.5), t.text_strong); }; - let mut rgb = nums::<3>(d, "rgb").unwrap_or_else(|| hex_rgb(&d.s("hex")).unwrap_or([0.0; 3])); - let mut cmyk = nums::<4>(d, "cmyk").unwrap_or_else(|| cmyk_of(rgb)); - let mut model = if d.s("model") == "cmyk" || (!d.fields.contains_key("model") && d.fields.contains_key("cmyk")) { "cmyk" } else { "rgb" }; - let mut edit = ColorEdit::None; - let hex_id = egui::Id::unique("color_picker_hex"); - ui.horizontal_top(|ui| { - let mut c = egui::Color32::from_rgb(to8(rgb[0]), to8(rgb[1]), to8(rgb[2])); - let shown = c; - if egui::color_picker::color_picker_color32(ui, &mut c, egui::color_picker::Alpha::Opaque) && c != shown { - rgb = [c.r(), c.g(), c.b()].map(f64::from); - edit = ColorEdit::Picked; - } - ui.vertical(|ui| { - egui::Grid::new("cp_values").num_columns(4).spacing([6.0, 4.0]).show(ui, |ui| { - let mut rgb_rows = rgb.iter_mut().zip(["R", "G", "B"]); - for (v, l) in cmyk.iter_mut().zip(["C", "M", "Y", "K"]) { - if let Some((rv, rl)) = rgb_rows.next() { - ui.label(rl); - // The channel letter is a label beside a bare spinner: name the spinner. - let field = ui.add(egui::DragValue::new(rv).range(0.0..=255.0).max_decimals(1)).accessible_name(rl.to_owned()); - if field.changed() { - edit = ColorEdit::Rgb; - } - } else { - ui.label(""); - ui.label(""); - } - ui.label(l); - let field = ui.add(egui::DragValue::new(v).range(0.0..=100.0).max_decimals(1).suffix("%")).accessible_name(l.to_owned()); - if field.changed() { - edit = ColorEdit::Cmyk; - } - ui.end_row(); - } - }); - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.label("#"); - let editing = ui.memory(|m| m.has_focus(hex_id)); - let mut h = if editing { d.s("hexEdit") } else { format!("{:02x}{:02x}{:02x}", to8(rgb[0]), to8(rgb[1]), to8(rgb[2])) }; - let hex_field = ui.add(egui::TextEdit::singleline(&mut h).id(hex_id).desired_width(70.0)).accessible_name("Hex".to_owned()); - if hex_field.changed() - && let Some(v) = hex_rgb(&h) - { - rgb = v; - edit = ColorEdit::Picked; - } - d.fields.insert("hexEdit".into(), json!(h)); - }); - }); + // The colour space view (a label, so screen readers hear which). + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(egui::Rect::from_min_size(area.min, egui::vec2(260.0, 24.0))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + ui.label( + egui::RichText::new(tr(if comp.lab() { "Lab Color Space View" } else { "RGB Color Space View" })).size(11.5).color(t.text_strong), + ); + }, + ); + let fr = put(at::field()); + let fresp = ui.interact(fr, ui.scope_id().with("cp_field"), egui::Sense::click_and_drag()); + let (tv, _, _) = p.coords(comp); + let cells = if matches!(comp, PickComp::R | PickComp::G | PickComp::Bl | PickComp::H) { 2 } else { 24 }; + gradient_mesh(ui.painter(), fr, cells, cells, |x, y| p.at(comp, tv, x, y).rgb); + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Color field")); + if let Some(pos) = fresp.interact_pointer_pos().filter(|_| fresp.is_pointer_button_down_on() || fresp.clicked()) { + let u = ((pos.x - fr.left()) / fr.width()).clamp(0.0, 1.0); + let v = ((fr.bottom() - pos.y) / fr.height()).clamp(0.0, 1.0); + p = p.at(comp, tv, u, v); + } + // The marker: a cross with an open centre, white over black. Fixed colours: it sits on the + // colour field, whatever the theme. + let (_, u, v) = p.coords(comp); + let m = egui::pos2(fr.left() + u * fr.width(), fr.bottom() - v * fr.height()); + let painter = ui.painter().with_clip_rect(fr); + for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { + let (a, b) = (m + egui::vec2(dx * 3.0, dy * 3.0), m + egui::vec2(dx * 10.0, dy * 10.0)); + let side = egui::vec2(dy, dx) * 1.2; + painter.line_segment([a + side, b + side], egui::Stroke::new(1.0, egui::Color32::BLACK)); + painter.line_segment([a - side, b - side], egui::Stroke::new(1.0, egui::Color32::BLACK)); + painter.line_segment([a, b], egui::Stroke::new(1.5, egui::Color32::WHITE)); + } + // The slider, with a marker on each side (white with a dark edge, readable on any colour). + let sr = put(at::slider()); + let sresp = ui.interact(sr.expand2(egui::vec2(6.0, 0.0)), ui.scope_id().with("cp_slider"), egui::Sense::click_and_drag()); + gradient_mesh(ui.painter(), sr, 1, 48, |_, y| p.slider_at(comp, y)); + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, true, "Color slider")); + if let Some(pos) = sresp.interact_pointer_pos().filter(|_| sresp.is_pointer_button_down_on() || sresp.clicked()) { + let tn = ((sr.bottom() - pos.y) / sr.height()).clamp(0.0, 1.0); + let (_, u, v) = p.coords(comp); + p = p.at(comp, tn, u, v); + } + let (tn, _, _) = p.coords(comp); + let y = sr.bottom() - tn * sr.height(); + for (x, dir) in [(sr.left(), 1.0), (sr.right(), -1.0)] { + let pts = vec![ + egui::pos2(x - 5.0 * dir, y - 4.5), + egui::pos2(x, y - 4.5), + egui::pos2(x + 3.5 * dir, y), + egui::pos2(x, y + 4.5), + egui::pos2(x - 5.0 * dir, y + 4.5), + ]; + ui.painter().add(egui::Shape::convex_polygon(pts, egui::Color32::WHITE, egui::Stroke::new(1.0, egui::Color32::from_gray(40)))); + } + // The new colour over the one it replaces. + let pr = put(at::swatch()); + let old = designcraft_color::Color::from_hex(&d.s("old")).map_or([0.0; 3], |c| c.to_rgb_uncalibrated()); + ui.painter().rect_filled(egui::Rect::from_min_max(pr.min, egui::pos2(pr.max.x, pr.center().y)), 0.0, color32(p.rgb)); + ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(pr.min.x, pr.center().y), pr.max), 0.0, color32(old)); + ui.painter().rect_stroke(pr, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + // OK, Cancel, Add CMYK Swatch: rounded, OK filled. + let pill = |ui: &mut egui::Ui, i: usize, label: &str, primary: bool| -> bool { + let r = put(at::button(i)); + let resp = ui.interact(r, ui.scope_id().with(("cp_button", i)), egui::Sense::click()); + let (fill, fg, stroke) = if primary { + (t.accent_strong, egui::Color32::WHITE, egui::Stroke::NONE) + } else { + (if resp.hovered() { t.hover } else { egui::Color32::TRANSPARENT }, t.text_strong, egui::Stroke::new(1.0, t.text_dim)) + }; + ui.painter().rect(r, r.height() / 2.0, fill, stroke, egui::StrokeKind::Inside); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(12.0), fg); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); + resp.clicked() + }; + // The picker draws its own buttons, so the generic footer's Enter/Esc handling is skipped. + // The keys are consumed here so nothing else reacts to them as well, and a click on a pill + // wins over a key pressed in the same frame, so the dialog closes once. + // The footer's padded OK key, which every catalog has. + let ok = pill(ui, 0, tr(" OK ").trim(), true); + let cancel = pill(ui, 1, &tr("Cancel"), false); + picker_close(ui, d, ok, cancel); + // The C/M/Y/K the fields show: the percentages typed or given, else the colour converted. + let cmyk = nums::<4>(d, "cmyk").map(|v| v.map(|x| x as f32)).unwrap_or_else(|| { + designcraft_color::Color::Rgb { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] } + .to_cmyk_managed(designcraft_color::cms::active_settings().intent) + .map(|v| v * 100.0) }); - match edit { - ColorEdit::None => {} - ColorEdit::Picked => cmyk = cmyk_of(rgb), - ColorEdit::Rgb => { - model = "rgb"; - cmyk = cmyk_of(rgb); - } - ColorEdit::Cmyk => { - model = "cmyk"; - let [c, m, y, k] = cmyk.map(|v| (v / 100.0) as f32); - rgb = display_rgb(Color::cmyk(c, m, y, k)); + if pill(ui, 2, &tr("Add CMYK Swatch"), false) { + let [c, m, y, k] = cmyk.map(|v| v.round()); + match app.run("swatch.create", json!({"color": {"c": c, "m": m, "y": y, "k": k}})) { + Ok(r) => app.status(format!("{} {}", tr("Added swatch"), r["name"].as_str().unwrap_or(""))), + Err(e) => app.status(e), } } - if edit != ColorEdit::None { + // The values: a radio button, a label, the field, a unit. + let mut comp_now = comp; + let mut edited: Option = None; + // (column x of the radio, row, component, label, key, value, range, unit) + let rows: [(f32, usize, Option, &str, &str, f32, (f32, f32), &str); 13] = [ + (at::LEFT, 0, Some(PickComp::H), "H:", "h", p.hsb[0], (0.0, 360.0), "°"), + (at::LEFT, 1, Some(PickComp::S), "S:", "s", p.hsb[1] * 100.0, (0.0, 100.0), "%"), + (at::LEFT, 2, Some(PickComp::Br), "B:", "b", p.hsb[2] * 100.0, (0.0, 100.0), "%"), + (at::RIGHT, 0, Some(PickComp::L), "L:", "l", p.lab[0], (0.0, 100.0), ""), + (at::RIGHT, 1, Some(PickComp::A), "a:", "la", p.lab[1], (-128.0, 127.0), ""), + (at::RIGHT, 2, Some(PickComp::Lb), "b:", "lb", p.lab[2], (-128.0, 127.0), ""), + (at::LEFT, 3, Some(PickComp::R), "R:", "r", p.rgb[0] * 255.0, (0.0, 255.0), ""), + (at::LEFT, 4, Some(PickComp::G), "G:", "g", p.rgb[1] * 255.0, (0.0, 255.0), ""), + (at::LEFT, 5, Some(PickComp::Bl), "B:", "bl", p.rgb[2] * 255.0, (0.0, 255.0), ""), + (at::RIGHT, 3, None, "C:", "c", cmyk[0], (0.0, 100.0), "%"), + (at::RIGHT, 4, None, "M:", "m", cmyk[1], (0.0, 100.0), "%"), + (at::RIGHT, 5, None, "Y:", "y", cmyk[2], (0.0, 100.0), "%"), + (at::RIGHT, 6, None, "K:", "k", cmyk[3], (0.0, 100.0), "%"), + ]; + for (x, row, c, label, key, value, range, unit) in rows { + let cy = area.min.y + at::ROWS[row]; + let x = area.min.x + x; + match c { + Some(c) => { + let rr = egui::Rect::from_center_size(egui::pos2(x, cy), egui::vec2(14.0, 14.0)); + let resp = ui.interact(rr, ui.scope_id().with(("cp_radio", key)), egui::Sense::click()); + ui.painter().circle(rr.center(), 6.0, t.input, egui::Stroke::new(1.0, t.text_dim)); + if comp_now == c { + ui.painter().circle_filled(rr.center(), 2.6, t.text_strong); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, comp_now == c, label)); + if resp.clicked() { + comp_now = c; + } + text(ui, egui::pos2(x + 11.0, cy), egui::Align2::LEFT_CENTER, label); + } + // C/M/Y/K have no radio button: their labels end at the field. + None => text(ui, egui::pos2(x + 28.0, cy), egui::Align2::RIGHT_CENTER, label), + } + let fr = egui::Rect::from_min_size(egui::pos2(x + 35.0, cy - 10.5), egui::vec2(44.0, 21.0)); + if let Some(v) = picker_field(ui, d, fr, key, label.trim_end_matches(':'), value, range) { + if matches!(key, "r" | "g" | "bl") { + d.fields.insert("model".into(), json!("rgb")); + } + if matches!(key, "c" | "m" | "y" | "k") { + d.fields.insert("model".into(), json!("cmyk")); + } + edited = Some(match key { + "h" => Picked::from_hsb([v, p.hsb[1], p.hsb[2]]), + "s" => Picked::from_hsb([p.hsb[0], v / 100.0, p.hsb[2]]), + "b" => Picked::from_hsb([p.hsb[0], p.hsb[1], v / 100.0]), + "l" => Picked::from_lab([v, p.lab[1], p.lab[2]], p.hsb[0]), + "la" => Picked::from_lab([p.lab[0], v, p.lab[2]], p.hsb[0]), + "lb" => Picked::from_lab([p.lab[0], p.lab[1], v], p.hsb[0]), + "r" => Picked::from_rgb([v / 255.0, p.rgb[1], p.rgb[2]], p.hsb[0]), + "g" => Picked::from_rgb([p.rgb[0], v / 255.0, p.rgb[2]], p.hsb[0]), + "bl" => Picked::from_rgb([p.rgb[0], p.rgb[1], v / 255.0], p.hsb[0]), + _ => { + let mut c = cmyk; + c[["c", "m", "y", "k"].iter().position(|k| *k == key).unwrap_or(0)] = v; + exact_cmyk = Some(c); + let [c, m, y, k] = c.map(|x| x / 100.0); + Picked::from_rgb(designcraft_color::Color::Cmyk { c, m, y, k }.to_rgb(), p.hsb[0]) + } + }); + } + if !unit.is_empty() { + text(ui, egui::pos2(fr.right() + 8.0, cy), egui::Align2::LEFT_CENTER, unit); + } + } + if let Some(e) = edited { + p = e; + } + if comp_now != comp { + d.fields.insert("component".into(), json!(comp_now.key())); + } + // The hex code, and the eyedropper beside it. + let cy = area.min.y + at::ROWS[7]; + text(ui, egui::pos2(area.min.x + 321.0, cy), egui::Align2::LEFT_CENTER, "#:"); + let hr = egui::Rect::from_min_size(egui::pos2(area.min.x + 341.0, cy - 10.5), egui::vec2(64.0, 21.0)); + let id = egui::Id::unique("color_picker_hex"); + let editing = ui.memory(|m| m.has_focus(id)); + let mut h = if editing { d.s("hexEdit") } else { p.hex().trim_start_matches('#').to_string() }; + if ui + .put(hr, egui::TextEdit::singleline(&mut h).id(id).font(font).align(egui::Align2::LEFT_CENTER).margin(egui::Margin::symmetric(4, 2))) + .accessible_name("Hex".to_owned()) + .changed() + && (h.len() == 6 || (h.len() == 7 && h.starts_with('#'))) + && let Some(c) = designcraft_color::Color::from_hex(&h) + { + p = Picked::from_rgb(c.to_rgb_uncalibrated(), p.hsb[0]); + } + d.fields.insert("hexEdit".into(), json!(h)); + if let Some(c) = eyedropper(app, ui, egui::pos2(hr.right() + 16.0, cy), tr("Eyedropper: drag it onto the page"), &t) { + p = Picked::from_rgb(c.map(|v| f32::from(v) / 255.0), p.hsb[0]); + } + if p != before { + store_picked(d, &p); + } + // Typed C/M/Y/K are kept as typed, even when the colour shown stays the same (at 100% K, a + // device conversion is black whatever C, M and Y are). + if let Some(c) = exact_cmyk { + d.fields.insert("cmyk".into(), json!(c)); d.fields.insert("edited".into(), json!(true)); } - d.fields.insert("rgb".into(), json!(rgb)); - d.fields.insert("cmyk".into(), json!(cmyk)); - d.fields.insert("model".into(), json!(model)); - d.fields.insert("hex".into(), json!(format!("#{:02x}{:02x}{:02x}", to8(rgb[0]), to8(rgb[1]), to8(rgb[2])))); } /// Find/Replace Font: the document's fonts (missing ones first, flagged) and a replacement. @@ -7474,3 +9345,252 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { #[cfg(test)] mod tests; + +/// The Color Picker's components: the one picked runs along the slider, the other two of its +/// space across the field (InDesign's, as Photoshop's: R puts B across and G up). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PickComp { + H, + S, + Br, + L, + A, + Lb, + R, + G, + Bl, +} + +impl PickComp { + const ALL: [PickComp; 9] = + [PickComp::H, PickComp::S, PickComp::Br, PickComp::L, PickComp::A, PickComp::Lb, PickComp::R, PickComp::G, PickComp::Bl]; + + fn key(self) -> &'static str { + match self { + PickComp::H => "h", + PickComp::S => "s", + PickComp::Br => "b", + PickComp::L => "l", + PickComp::A => "la", + PickComp::Lb => "lb", + PickComp::R => "r", + PickComp::G => "g", + PickComp::Bl => "bl", + } + } + + fn from_key(k: &str) -> PickComp { + PickComp::ALL.into_iter().find(|c| c.key() == k).unwrap_or(PickComp::R) + } + + fn lab(self) -> bool { + matches!(self, PickComp::L | PickComp::A | PickComp::Lb) + } +} + +/// The colour the picker shows, in each of its spaces (HSB keeps its hue through greys). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Picked { + pub rgb: [f32; 3], + pub hsb: [f32; 3], + pub lab: [f32; 3], +} + +impl Picked { + pub fn from_rgb(rgb: [f32; 3], hue: f32) -> Picked { + let rgb = rgb.map(|v| v.clamp(0.0, 1.0)); + let c = designcraft_color::Color::Rgb { r: rgb[0], g: rgb[1], b: rgb[2] }; + let mut hsb = c.to_hsb(); + if hsb[1] <= 1e-4 || hsb[2] <= 1e-4 { + hsb[0] = hue; + } + let l = designcraft_color::cms::lab::srgb_to_lab(rgb); + Picked { rgb, hsb, lab: [l.l, l.a, l.b] } + } + + fn from_hsb(hsb: [f32; 3]) -> Picked { + let hsb = [hsb[0].rem_euclid(360.0), hsb[1].clamp(0.0, 1.0), hsb[2].clamp(0.0, 1.0)]; + let mut p = Picked::from_rgb(designcraft_color::Color::from_hsb(hsb[0], hsb[1], hsb[2]).to_rgb_uncalibrated(), hsb[0]); + p.hsb = hsb; + p + } + + fn from_lab(lab: [f32; 3], hue: f32) -> Picked { + let lab = [lab[0].clamp(0.0, 100.0), lab[1].clamp(-128.0, 127.0), lab[2].clamp(-128.0, 127.0)]; + let mut p = Picked::from_rgb(designcraft_color::cms::lab::lab_to_srgb(designcraft_color::cms::Lab::new(lab[0], lab[1], lab[2])), hue); + p.lab = lab; + p + } + + /// (slider, across, up), each 0..1, for component `c`. + pub fn coords(&self, c: PickComp) -> (f32, f32, f32) { + let [r, g, b] = self.rgb; + let [h, s, v] = self.hsb; + let [l, la, lb] = self.lab; + let (a, bb) = ((la + 128.0) / 255.0, (lb + 128.0) / 255.0); + match c { + PickComp::R => (r, b, g), + PickComp::G => (g, b, r), + PickComp::Bl => (b, r, g), + PickComp::H => (h / 360.0, s, v), + PickComp::S => (s, h / 360.0, v), + PickComp::Br => (v, h / 360.0, s), + PickComp::L => (l / 100.0, a, bb), + PickComp::A => (a, bb, l / 100.0), + PickComp::Lb => (bb, a, l / 100.0), + } + } + + /// The colour at (slider `t`, across `u`, up `v`) for component `c`. + pub fn at(&self, c: PickComp, t: f32, u: f32, v: f32) -> Picked { + let hue = self.hsb[0]; + let ab = |x: f32| x * 255.0 - 128.0; + match c { + PickComp::R => Picked::from_rgb([t, v, u], hue), + PickComp::G => Picked::from_rgb([v, t, u], hue), + PickComp::Bl => Picked::from_rgb([u, v, t], hue), + PickComp::H => Picked::from_hsb([t * 360.0, u, v]), + PickComp::S => Picked::from_hsb([u * 360.0, t, v]), + PickComp::Br => Picked::from_hsb([u * 360.0, v, t]), + PickComp::L => Picked::from_lab([t * 100.0, ab(u), ab(v)], hue), + PickComp::A => Picked::from_lab([v * 100.0, ab(t), ab(u)], hue), + PickComp::Lb => Picked::from_lab([v * 100.0, ab(u), ab(t)], hue), + } + } + + /// The slider's colour at `t`: the hue slider shows pure hues, the others this colour with + /// the component at `t`. + fn slider_at(&self, c: PickComp, t: f32) -> [f32; 3] { + if c == PickComp::H { + return designcraft_color::Color::from_hsb(t * 360.0, 1.0, 1.0).to_rgb_uncalibrated(); + } + let (_, u, v) = self.coords(c); + self.at(c, t, u, v).rgb + } + + pub fn hex(&self) -> String { + let q = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + format!("#{:02x}{:02x}{:02x}", q(self.rgb[0]), q(self.rgb[1]), q(self.rgb[2])) + } +} + +fn color32(rgb: [f32; 3]) -> egui::Color32 { + let q = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + egui::Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2])) +} + +/// A `cols` × `rows` grid of quads over `r`, coloured at each vertex by `f(x, y)` (0..1, y up). +fn gradient_mesh(painter: &egui::Painter, r: egui::Rect, cols: usize, rows: usize, f: impl Fn(f32, f32) -> [f32; 3]) { + let mut mesh = egui::Mesh::default(); + for j in 0..=rows { + for i in 0..=cols { + let (x, y) = (i as f32 / cols as f32, j as f32 / rows as f32); + mesh.colored_vertex(egui::pos2(r.left() + x * r.width(), r.bottom() - y * r.height()), color32(f(x, y))); + } + } + let w = (cols + 1) as u32; + for j in 0..rows as u32 { + for i in 0..cols as u32 { + let a = j * w + i; + mesh.add_triangle(a, a + 1, a + w); + mesh.add_triangle(a + 1, a + w + 1, a + w); + } + } + painter.add(egui::Shape::mesh(mesh)); +} + +/// The colour in the dialog's fields: `hex`, with the HSB hue and the Lab values kept while +/// they round-trip (`_hsb`, `_lab`). +fn picked_of(d: &Dialog) -> Picked { + let mut rgb = designcraft_color::Color::from_hex(&d.s("hex")).map_or([0.0; 3], |c| c.to_rgb_uncalibrated()); + if let Some(raw) = nums::<3>(d, "rgb") { + let raw = raw.map(|x| (x / 255.0) as f32); + if Picked::from_rgb(raw, 0.0).hex() == Picked::from_rgb(rgb, 0.0).hex() { + rgb = raw; + } + } + let hue = nums::<3>(d, "_hsb").map_or(0.0, |h| h[0] as f32); + let mut p = Picked::from_rgb(rgb, hue); + // The exact values the picker last set, while they still give this colour. + if let Some(h) = nums::<3>(d, "_hsb").map(|h| h.map(|v| v as f32)) + && Picked::from_hsb(h).hex() == p.hex() + { + p.hsb = h; + } + if let Some(l) = nums::<3>(d, "_lab").map(|l| l.map(|v| v as f32)) + && Picked::from_lab(l, p.hsb[0]).hex() == p.hex() + { + p.lab = l; + } + p +} + +fn store_picked(d: &mut Dialog, p: &Picked) { + d.fields.insert("edited".into(), json!(true)); + d.fields.insert("rgb".into(), json!(p.rgb.map(|x| x * 255.0))); + d.fields.insert( + "cmyk".into(), + json!( + designcraft_color::Color::Rgb { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] } + .to_cmyk_managed(designcraft_color::cms::active_settings().intent) + .map(|x| x * 100.0) + ), + ); + d.fields.insert("hex".into(), json!(p.hex())); + d.fields.insert("_hsb".into(), json!(p.hsb)); + d.fields.insert("_lab".into(), json!(p.lab)); +} + +/// The Color Picker's place in the dialog, as InDesign's (points from the body's top-left). +mod picker_layout { + use egui::{Rect, pos2, vec2}; + // InDesign's proportions at this app's density (its 30 px fields are 21 pt here). + pub const SIZE: egui::Vec2 = vec2(566.0, 334.0); + pub fn field() -> Rect { + Rect::from_min_size(pos2(2.0, 31.0), vec2(265.0, 287.0)) + } + pub fn slider() -> Rect { + Rect::from_min_size(pos2(285.0, 32.0), vec2(19.0, 282.0)) + } + pub fn swatch() -> Rect { + Rect::from_min_size(pos2(319.0, 0.0), vec2(71.0, 90.0)) + } + /// OK, Cancel, Add … Swatch. + pub fn button(i: usize) -> Rect { + Rect::from_min_size(pos2(433.0, [0.0, 39.0, 78.0][i.min(2)]), vec2(132.0, 25.0)) + } + /// Row centres: H/L, S/a, B/b, (gap), R/C, G/M, B/Y, K, #. + pub const ROWS: [f32; 8] = [129.0, 157.0, 185.0, 224.0, 252.0, 280.0, 308.0, 320.0]; + /// The radio buttons' columns. + pub const LEFT: f32 = 326.0; + pub const RIGHT: f32 = 457.0; +} + +/// A value field, named `name` (its caption): the text typed stays while it has the focus; a +/// number that parses applies. +fn picker_field(ui: &mut egui::Ui, d: &mut Dialog, r: egui::Rect, key: &str, name: &str, value: f32, range: (f32, f32)) -> Option { + let id = egui::Id::unique(("color_picker_field", key)); + let editing = ui.memory(|m| m.has_focus(id)); + let shown = format!("{}", value.round() as i64); + let edit_key = format!("_edit_{key}"); + let mut text = if editing { d.fields.get(&edit_key).and_then(Value::as_str).map(str::to_string).unwrap_or(shown) } else { shown }; + let resp = ui + .put( + r, + egui::TextEdit::singleline(&mut text) + .id(id) + .font(egui::FontId::proportional(11.5)) + .align(egui::Align2::LEFT_CENTER) + .margin(egui::Margin::symmetric(4, 2)), + ) + .accessible_name(name.to_owned()); + let mut out = None; + if resp.changed() + && let Ok(v) = text.trim().parse::() + && v.is_finite() + { + out = Some(v.clamp(range.0, range.1)); + } + d.fields.insert(edit_key, json!(text)); + out +} diff --git a/crates/ui-egui/src/dialogs/options.rs b/crates/ui-egui/src/dialogs/options.rs index 191436fa4..0adcf2d8a 100644 --- a/crates/ui-egui/src/dialogs/options.rs +++ b/crates/ui-egui/src/dialogs/options.rs @@ -352,7 +352,8 @@ mod tests { } fn double_click(h: &mut Harness<'static, DesignApp>, label: &str) { - let at = h.get_by_label(label).rect().center(); + // A swatch row's accessible name is its name, then what its marks show. + let at = h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, label))).rect().center(); h.hover_at(at); // Two clicks without the pointer leaving (`drop_at` would end the double click). for pressed in [true, false, true, false] { diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 84cbf3bea..b253b1bc9 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -563,17 +563,6 @@ fn picker(fields: Value) -> (DesignApp, egui::Context, egui::Rect, u64) { (app, ctx, screen, id) } -/// The rightmost painted `text`: the picker's own field, right of egui's colour editor (which -/// has R/G/B fields of its own). -fn rightmost(labels: &[(String, egui::Rect, egui::Rect)], text: &str) -> egui::Pos2 { - labels - .iter() - .filter(|(t, _, _)| t.trim() == text) - .map(|(_, r, _)| r.center()) - .max_by(|a, b| a.x.total_cmp(&b.x)) - .unwrap_or_else(|| panic!("{text} is painted")) -} - /// Types `text` one character per frame, with an idle frame after each. fn type_slowly(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, text: &str) { for ch in text.chars() { @@ -587,6 +576,28 @@ fn fill(app: &DesignApp, id: u64) -> designcraft_color::Color { d.resolve_color(&d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, 1.0).unwrap() } +fn key_press(key: egui::Key) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE } +} + +#[test] +fn the_color_picker_enter_confirms_and_escape_cancels_even_in_a_field() { + for (key, applied) in [(egui::Key::Enter, true), (egui::Key::Escape, false)] { + let (mut app, ctx, screen, id) = picker(json!({"target": "fill", "hex": "#ffffff"})); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "ffffff").center()); + dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); + type_slowly(&mut app, &ctx, screen, "#f59019"); + dialog_frame(&mut app, &ctx, screen, vec![key_press(key)]); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(app.ui.dialog.is_none(), "{key:?} closes the picker"); + let d = &app.session.active().unwrap().doc; + let swatch = d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch.clone(); + let hex = d.resolve_color(&swatch, 1.0).map(|c| c.to_hex()); + assert_eq!(hex.as_deref() == Some("#f59019"), applied, "{key:?}: {hex:?}"); + } +} + #[test] fn the_hex_field_keeps_typed_text_and_commits_a_valid_code() { let (mut app, ctx, screen, _) = picker(json!({"target": "fill", "hex": "#ffffff"})); @@ -603,7 +614,7 @@ fn the_hex_field_keeps_typed_text_and_commits_a_valid_code() { dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "zz"); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "#").center()); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "#:").center()); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); visible_label(&labels, screen, "f59019"); assert_eq!(app.ui.dialog.as_ref().unwrap().s("hex"), "#f59019"); @@ -613,11 +624,13 @@ fn the_hex_field_keeps_typed_text_and_commits_a_valid_code() { fn typed_channel_values_are_kept_while_typing() { let (mut app, ctx, screen, id) = picker(json!({"target": "fill", "hex": "#010203", "model": "rgb", "rgb": [1, 2, 3]})); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, rightmost(&labels, "1")); + let r = visible_label(&labels, screen, "R:"); + click_dialog(&mut app, &ctx, screen, egui::pos2(r.min.x + 46.0, r.center().y)); dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "12.5"); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - assert!(rightmost(&labels, "12.5").x > rightmost(&labels, "2").x - 1.0, "the typed text stays in the R field"); + visible_label(&labels, screen, "12.5"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["rgb"][1], json!(2.0)); assert_eq!(app.ui.dialog.as_ref().unwrap().s("hex"), "#0d0203"); confirm(&mut app).unwrap(); let designcraft_color::Color::Rgb { r, .. } = fill(&app, id) else { panic!("not RGB") }; @@ -639,7 +652,8 @@ fn a_cmyk_colour_stays_cmyk_unless_rgb_values_are_typed() { // A typed C/M/Y/K percent is a percent: M 1 is 1 %. let (mut app, ctx, screen, id) = picker(cmyk.clone()); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, rightmost(&labels, "50")); + let r = visible_label(&labels, screen, "M:"); + click_dialog(&mut app, &ctx, screen, egui::pos2(r.max.x + 29.0, r.center().y)); dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "1"); confirm(&mut app).unwrap(); @@ -649,7 +663,9 @@ fn a_cmyk_colour_stays_cmyk_unless_rgb_values_are_typed() { let (mut app, ctx, screen, id) = picker(cmyk); let r = app.ui.dialog.as_ref().unwrap().fields["rgb"][0].as_f64().unwrap(); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, rightmost(&labels, &r.to_string())); + let _ = r; + let r = visible_label(&labels, screen, "R:"); + click_dialog(&mut app, &ctx, screen, egui::pos2(r.min.x + 46.0, r.center().y)); dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "10"); confirm(&mut app).unwrap(); @@ -1647,10 +1663,19 @@ fn dialog_frame(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, ev labels } +/// What Find/Change's Search list shows for a scope. +fn scope_label(scope: &str) -> &'static str { + match scope { + "story" => "Story", + "selection" => "Selection", + _ => "Document", + } +} + fn visible_label(labels: &[(String, egui::Rect, egui::Rect)], screen: egui::Rect, expected: &str) -> egui::Rect { let (_, rect, clip) = labels.iter().find(|(text, _, _)| text.trim() == expected).unwrap_or_else(|| panic!("{expected} is painted")); assert!(screen.contains_rect(*rect), "{expected} must stay on screen: {rect:?}"); - assert!(clip.contains_rect(*rect), "{expected} must not be clipped: {rect:?}, {clip:?}"); + assert!(clip.expand(0.5).contains_rect(*rect), "{expected} must not be clipped: {rect:?}, {clip:?}"); *rect } @@ -1730,8 +1755,8 @@ fn scope_dialog_with_tool(text: &str, end: usize, tool: Option<&str>) -> (Design for _ in 0..3 { dialog_frame(&mut app, &ctx, screen, vec![]); } - scope_click(&mut app, &ctx, screen, "document"); - scope_click(&mut app, &ctx, screen, "selection"); + scope_click(&mut app, &ctx, screen, "Document"); + scope_click(&mut app, &ctx, screen, "Selection"); assert_eq!(app.ui.dialog.as_ref().unwrap().s("scope"), "selection"); (app, ctx, screen, sid) } @@ -1806,8 +1831,8 @@ fn find_scope_dialog_explicit_story_ignored_scope_preserves_grep_cursor() { let cur = app.ui.dialog.as_ref().unwrap().s("scope"); let next = if cur == "selection" { "document" } else { "selection" }; if physical { - scope_click(&mut app, &ctx, screen, &cur); - scope_click(&mut app, &ctx, screen, next); + scope_click(&mut app, &ctx, screen, scope_label(&cur)); + scope_click(&mut app, &ctx, screen, scope_label(next)); } else { scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); } @@ -1824,8 +1849,8 @@ fn find_scope_dialog_explicit_story_ignored_scope_preserves_grep_cursor() { let cur = app.ui.dialog.as_ref().unwrap().s("scope"); let next = if cur == "selection" { "document" } else { "selection" }; if physical { - scope_click(&mut app, &ctx, screen, &cur); - scope_click(&mut app, &ctx, screen, next); + scope_click(&mut app, &ctx, screen, scope_label(&cur)); + scope_click(&mut app, &ctx, screen, scope_label(next)); } else { scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); } @@ -2401,7 +2426,7 @@ fn find_scope_dialog_domain_and_object_changes_release_original_range() { #[test] fn dialog_title_and_actions_fit_scaled_viewports() { for language in ["en", "ar"] { - for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0)] { + for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0)] { for (command, title, ok, cancel) in [("app.preferences", "Preferences", "OK", "Cancel"), ("app.newDocumentDialog", "New Document", "Create", "Close")] { @@ -2528,7 +2553,7 @@ fn preferences_horizontal_scroll_reaches_clipped_options() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("app.preferences", json!({})).unwrap(); let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0)); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(360.0, 280.0)); for _ in 0..3 { dialog_frame(&mut app, &ctx, screen, vec![]); } @@ -2587,7 +2612,7 @@ fn new_document_vertical_scroll_reaches_every_field_and_preset() { #[test] fn arabic_new_document_fields_remain_reachable_and_editable() { - for size in [egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0), egui::vec2(1178.0, 814.0)] { + for size in [egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0), egui::vec2(1178.0, 814.0)] { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.ui.language = "ar".into(); app.run("app.newDocumentDialog", json!({})).unwrap(); @@ -3253,7 +3278,9 @@ fn new_character_style_opens_its_options_and_creates_the_style() { ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 9})); assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = char_style(&app, "Character Style 1").unwrap(); - assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() })); + // The selected text's own Italic (its Emphasis style) comes along, as InDesign's New + // Character Style takes it. + assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), font_style: Some("Italic".into()), ..Default::default() })); crate::menus::activate(&mut app, "style.character.create", &Value::Null); assert_eq!(app.ui.dialog.as_ref().unwrap().s("rename"), "Character Style 2"); } @@ -3269,7 +3296,8 @@ fn new_paragraph_style_opens_its_options_and_creates_the_style() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = app.session.active().unwrap().doc.styles.para("Paragraph Style 1").cloned().unwrap(); assert_eq!(st.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(st.chars, CharAttrs { size: Some(20.0), ..Default::default() }); + // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. + assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); } #[test] @@ -3369,7 +3397,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { assert!((centred.center() - egui::pos2(600.0, 400.0)).length() < 1.0, "{centred:?}"); assert!(centred.width() < 1000.0 && centred.height() < 700.0, "the area is the dialog, not the backdrop: {centred:?}"); - let title = h.get_by_label("Create Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title + vec2(100.0, 60.0)); let moved = rect(&h); assert!((moved.min - centred.min - vec2(100.0, 60.0)).length() < 2.0, "{centred:?} -> {moved:?}"); @@ -3379,7 +3407,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { assert_eq!(rect(&h), moved, "a drag in the body leaves the dialog in place"); // The title row stays inside the top edge. - let title = h.get_by_label("Create Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(0.0, 1000.0)); assert!(rect(&h).top().abs() < 1.0, "{:?}", rect(&h)); @@ -3391,7 +3419,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { assert!((rect(&h).center() - centred.center()).length() < 1.0, "{:?}", rect(&h)); // Past the left edge, about 100 px of the title row stays inside. - let title = h.get_by_label("Create Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(900.0, 0.0)); assert!((rect(&h).right() - 100.0).abs() < 2.0, "{:?}", rect(&h)); } @@ -3468,19 +3496,223 @@ fn guide_counts(app: &DesignApp) -> Vec { (0..doc.page_count()).map(|abs| doc.page(abs).unwrap().guides.len()).collect() } +/// The eyedropper takes the colour the canvas shows: with Proof Colors the proofed colour, and +/// with the ink-limit preview the warning colour over the limit. #[test] -fn create_guides_dialog_targets_the_page_in_view() { +fn the_eyedropper_samples_what_proof_colors_and_the_ink_limit_show() { + use egui_kittest::Harness; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("swatch.create", json!({"name": "Vivid Green", "color": [0, 255, 0]})).unwrap(); + let id = app.run("frame.create", json!({"rect": [36, 36, 560, 700], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"ids": [id], "swatch": "Vivid Green"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let target = { + let app = h.state(); + let doc = &app.session.doc().unwrap().doc; + let layout = designcraft_tools::CanvasLayout::new(doc, false); + let v = *app.view().unwrap(); + crate::canvas::Xf::new(app.canvas_rect.unwrap(), &v).to_screen(layout.slots[0].xf * designcraft_geom::Point::new(300.0, 600.0)) + }; + let ctx = h.ctx.clone(); + let sample = |h: &mut Harness<'_, DesignApp>| crate::canvas::color_at(h.state_mut(), &ctx, target).expect("on the page"); + // What a view makes of the plain colour, as the canvas texture gets it. + let shown = |c: [u8; 3], view: &dyn Fn(&mut designcraft_render::Rendered)| { + let mut img = designcraft_render::Rendered { width: 1, height: 1, pixels: vec![c[0], c[1], c[2], 255] }; + view(&mut img); + [img.pixels[0], img.pixels[1], img.pixels[2]] + }; + let plain = sample(&mut h); + h.state_mut().ui.proof_colors = true; + let setup = h.state().ui.proof_setup.clone(); + let proofed = sample(&mut h); + assert_eq!(proofed, shown(plain, &|img| designcraft_render::proof_view(img, &setup))); + assert_ne!(proofed, plain, "the proof changes this green"); +} + +/// The course's chapter 23: the Color Picker's eyedropper, dragged from the picker onto the +/// page and let go there, takes the colour the page shows. +#[test] +fn the_color_pickers_eyedropper_takes_a_colour_off_the_page() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [36, 36, 560, 700], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("swatch.create", json!({"name": "Eyedrop Red", "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap(); + app.run("object.fill", json!({"ids": [id], "swatch": "Eyedrop Red"})).unwrap(); + app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#000000"}))); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + // Where the frame shows on screen. + let target = { + let app = h.state(); + let doc = &app.session.doc().unwrap().doc; + let layout = designcraft_tools::CanvasLayout::new(doc, false); + let v = *app.view().unwrap(); + crate::canvas::Xf::new(app.canvas_rect.unwrap(), &v).to_screen(layout.slots[0].xf * designcraft_geom::Point::new(300.0, 600.0)) + }; + let from = h.get_by_label("Eyedropper: drag it onto the page").rect().center(); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + (target - from) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(target); + h.run_steps(3); + let d = h.state().ui.dialog.as_ref().expect("the picker stays open"); + let [r, g, b, _] = h.state().session.doc().unwrap().doc.resolve_color("Eyedrop Red", 1.0).unwrap().to_rgba8(1.0); + let got: Vec = d.fields["rgb"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + for (a, e) in got.iter().zip([r, g, b]) { + assert!((a - f64::from(e)).abs() <= 3.0, "{got:?} vs {:?}", [r, g, b]); + } + assert!(d.b("edited")); + // Let go off the page (here, on the dialog's own field area): no colour, nothing changes. + let hex = d.s("hex"); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + h.hover_at(from + egui::vec2(-40.0, 0.0)); + h.run_steps(1); + h.drop_at(egui::pos2(2.0, 2.0)); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().s("hex"), hex); + // Let go over the picker's own colour field, with the page under it: nothing either. + let field = h.get_by_label("Color field").rect().center(); + let ctx = h.ctx.clone(); + assert!(crate::canvas::color_at(h.state_mut(), &ctx, field).is_some(), "the page lies under the field"); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + h.hover_at(field); + h.run_steps(1); + h.drop_at(field); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().s("hex"), hex, "the picker isn't sampled"); +} + +/// The Color Picker laid out as InDesign's: a field and a slider for the component whose radio +/// is on, the new colour over the old one, and the values in every space. +#[test] + +fn the_color_picker_keeps_a_typed_cmyk_value_the_colour_shown_does_not_change_with() { + use egui_kittest::kittest::Queryable; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "cmyk": [0.0, 0.0, 0.0, 100.0], "text": false}))); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.run_steps(3); + // A C typed at 100% K is kept, whether or not the colour shown changes with it. + let rgb = h.state().app.ui.dialog.as_ref().unwrap().fields.get("rgb").cloned(); + h.get_by_label("C").focus(); + h.run_steps(1); + h.event(egui::Event::Text("50".into())); + h.run_steps(2); + let d = h.state().app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.fields["cmyk"], json!([50.0, 0.0, 0.0, 100.0]), "rgb {:?} (was {rgb:?})", d.fields.get("rgb")); + assert!(d.b("edited")); +} + +#[test] +fn the_color_picker_picks_on_its_field_and_slider_and_adds_a_cmyk_swatch() { + use egui_kittest::kittest::Queryable; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 200, 200], "shape": "rectangle"})).unwrap(); + app.run("object.color", json!({"color":designcraft_color::Color::from_hex("#7c1315").unwrap(),"target":"fill"})).unwrap(); + app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false}))); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.run_steps(3); + // R at first: R up the slider, B across and G up the field. + let _ = h.get_by_label("RGB Color Space View"); + let field = h.get_by_label("Color field").rect(); + crate::test_window::click_at(&mut h, egui::pos2(field.right() - 1.0, field.top() + 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!((p.rgb[0] - 124.0 / 255.0).abs() < 0.01 && p.rgb[1] > 0.97 && p.rgb[2] > 0.97, "{p:?}: R kept, G and B full"); + // The slider sets R alone. + let slider = h.get_by_label("Color slider").rect(); + crate::test_window::click_at(&mut h, egui::pos2(slider.center().x, slider.bottom() - 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!(p.rgb[0] < 0.02 && p.rgb[1] > 0.97, "{p:?}"); + // Hue on the slider: the field is saturation across, brightness up. + store_picked(h.state_mut().app.ui.dialog.as_mut().unwrap(), &Picked::from_rgb([124.0 / 255.0, 19.0 / 255.0, 21.0 / 255.0], 0.0)); + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("h")); + h.run_steps(2); + crate::test_window::click_at(&mut h, egui::pos2(field.left() + 1.0, field.top() + 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!(p.hsb[1] < 0.02 && p.hsb[2] > 0.98 && (p.hsb[0] - 359.0).abs() < 2.0, "{p:?}: no saturation, full brightness, the hue kept"); + // Lab shows its own view. + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("l")); + h.run_steps(2); + let _ = h.get_by_label("Lab Color Space View"); + // Add CMYK Swatch adds the colour as a CMYK swatch; OK applies it. + store_picked(h.state_mut().app.ui.dialog.as_mut().unwrap(), &Picked::from_rgb([124.0 / 255.0, 19.0 / 255.0, 21.0 / 255.0], 0.0)); + h.run_steps(2); + let before = h.state().app.session.active().unwrap().doc.swatches.len(); + h.get_by_label("Add CMYK Swatch").click(); + h.run_steps(3); + let doc = &h.state().app.session.active().unwrap().doc; + assert_eq!(doc.swatches.len(), before + 1); + assert!(matches!( + doc.swatches.last().unwrap().value, + designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { .. }, .. } + )); + // The percentages the C/M/Y/K fields show, exactly (not the colour converted back). + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("cmyk".into(), json!([0.0, 50.0, 100.0, 0.0])); + h.run_steps(2); + h.get_by_label("Add CMYK Swatch").click(); + h.run_steps(3); + let doc = &h.state().app.session.active().unwrap().doc; + let designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { c, m, y, k }, .. } = + doc.swatches.last().unwrap().value + else { + panic!("a CMYK swatch") + }; + assert_eq!([c, m, y, k].map(|v| (v * 100.0).round()), [0.0, 50.0, 100.0, 0.0]); + // Its own OK, at the top right, applies it. + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().app.ui.dialog.is_none(), "OK closes it"); + let st = h.state().app.session.active().unwrap(); + let id = st.selection.items[0]; + let fill = st.doc.item(id).unwrap().fill.swatch.clone(); + assert_eq!(st.doc.resolve_color(&fill, 1.0).unwrap().to_hex(), "#7c1315"); +} + +#[test] +fn create_guides_dialog_covers_the_spread_in_view() { let mut app = facing_app(6); + // As in InDesign, both pages of the facing spread in view get the guides. + crate::canvas::go_to_page(&mut app, 1); + crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("target"), "spread"); + ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "2"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 0, 0]); + // "This page" narrows them to the page in view. crate::canvas::go_to_page(&mut app, 4); crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "3"})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); - assert_eq!(guide_counts(&app), vec![0, 0, 0, 0, 4, 0]); - // "Both pages of the spread" covers the facing pair. - crate::canvas::go_to_page(&mut app, 1); - crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); - ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "2"})); - ctl(&mut app, "ui.dialog.set", json!({"field": "target", "value": "spread"})); + ctl(&mut app, "ui.dialog.set", json!({"field": "target", "value": "page"})); assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 4, 0]); } @@ -3521,3 +3753,1064 @@ fn character_style_options_set_the_digits() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(char_style(&app, "Emphasis").unwrap().chars.digits, Some(designcraft_doc::Digits::Farsi)); } + +/// Replace the text of the field beside the `nth` label `label` (on its row, to its right) with +/// `new`, and leave the field. +fn retype_beside(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str, nth: usize, new: &str) { + use egui_kittest::kittest::Queryable; + let l = h.get_all_by_label(label).nth(nth).unwrap_or_else(|| panic!("no label {label} #{nth}")).rect(); + let field = h + .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) + .map(|n| n.rect()) + .filter(|r| r.center().x > l.max.x && (r.center().y - l.center().y).abs() < l.height().max(8.0)) + .min_by(|a, b| a.min.x.total_cmp(&b.min.x)) + .unwrap_or_else(|| panic!("no field beside {label} #{nth}")) + .center(); + crate::test_window::click_at(h, field); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(new.into())); + h.run_steps(1); + // Tab leaves the field (Enter would be the dialog's OK). + h.key_press(egui::Key::Tab); + h.run_steps(3); +} + +/// The course's chapter 40: Paragraph Border's four weights are linked as InDesign's, so a +/// side typed in sets all four; with the link off ("Make all settings the same") a side is set +/// alone, as the lesson gives the border only a thick left side. The corners link the same way. +#[test] +fn paragraph_border_sides_and_corners_link_as_indesigns() { + use egui_kittest::kittest::Queryable; + let (app, _) = ruled_paragraph(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); + h.run_steps(4); + let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { + h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() + }; + // The four weights start the same, so linked: Top sets them all. + retype_beside(&mut h, "Top:", 0, "3"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); + // Unlinked, Left alone takes 10 pt. + // The link of the weights (0), corners (1) or offsets (2), where it is now: the dialog + // scrolls as fields take focus. + let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { + let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + assert_eq!(links.len(), 3, "weights, corners and offsets each have a link"); + links[n].center() + }; + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Left:", 0, "10"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); + // Linked again, the four take Top's weight; unlinked once more for the thick left side. + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Left:", 0, "10"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); + // The corners start the same too: Top Left's size goes to all four. + retype_beside(&mut h, "Top Left Corner", 0, "0p4"); + let corners = field(&h, "borderCorners"); + assert!((0..4).all(|i| corners[i]["size"] == json!(4.0)), "{corners}"); + // Unlinked, Top Right alone takes 0p8; linked again, all four take Top Left's 4 pt. + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Top Right Corner", 0, "0p8"); + assert_eq!(field(&h, "borderCorners")[1]["size"], json!(8.0)); + assert_eq!(field(&h, "borderCorners")[0]["size"], json!(4.0)); + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + let corners = field(&h, "borderCorners"); + assert!((0..4).all(|i| corners[i] == corners[0] && corners[i]["size"] == json!(4.0)), "{corners}"); + confirm(&mut h.state_mut().app).unwrap(); + let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); + assert_eq!(style.para.border_weights, Some([3.0, 10.0, 3.0, 3.0])); +} + +/// The course's chapter 41: Paragraph Shading as InDesign's. The page is headed with its name; +/// the tint has Overprint beside it; the corners are a two-by-two grid whose shape menus show +/// the shapes. The lesson rounds the top two corners at 10 pt, unlinked, and moves the +/// offsets. +#[test] +fn paragraph_shading_page_is_laid_out_as_indesigns() { + use designcraft_geom::corners::CornerShape; + use egui_kittest::kittest::Queryable; + let (app, _) = ruled_paragraph(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = + Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "shading", "p.shadingOn": true}))); + h.run_steps(4); + let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { + h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() + }; + assert!(h.get_all_by_label("Paragraph Shading").count() >= 2, "the section list and the page's heading"); + h.get_by_label("Overprint").click(); + h.run_steps(2); + assert_eq!(field(&h, "shadingOverprint"), json!(true)); + // The corners' link (0) and the offsets' (1). + let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { + let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + assert_eq!(links.len(), 2, "corners and offsets each have a link"); + links[n].center() + }; + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + for (corner, nth) in [("Top Left Corner", 0), ("Top Right Corner", 1)] { + retype_beside(&mut h, corner, 0, "10 pt"); + h.get_all_by_label("Corner Shape").nth(nth).unwrap().click(); + h.run_steps(2); + h.get_by_label("Rounded").click(); + h.run_steps(2); + } + let corners: [designcraft_geom::corners::Corner; 4] = serde_json::from_value(field(&h, "shadingCorners")).unwrap(); + assert_eq!( + corners.map(|c| (c.shape, c.size)), + [(CornerShape::Rounded, 10.0), (CornerShape::Rounded, 10.0), (CornerShape::None, 0.0), (CornerShape::None, 0.0)] + ); + // The offsets, unlinked: Top alone. + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Top:", 0, "3 pt"); + assert_eq!(field(&h, "shadingOffsets"), json!([3.0, 0.0, 0.0, 0.0])); + confirm(&mut h.state_mut().app).unwrap(); + let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); + assert_eq!((style.para.shading_on, style.para.shading_overprint), (Some(true), Some(true))); + assert_eq!(style.para.shading_corners.map(|c| c[1].shape), Some(CornerShape::Rounded)); +} + +/// Paragraph Rules › Color offers Text Color above the swatches, so a rule set to a swatch can +/// go back to it; Gap Color lists only swatches. +#[test] +fn the_rule_colour_menu_offers_text_color() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let menu = |ctx: &egui::Context, text: Option<&str>, click: Option| { + let mut picked = None; + let mut labels = Vec::new(); + let mut events = Vec::new(); + if let Some(pos) = click { + events.push(egui::Event::PointerMoved(pos)); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text); + }); + out.textures_delta.clear(); + for s in out.shapes { + if let egui::Shape::Text(t) = s.shape { + labels.push((t.galley.job.text.clone(), t.visual_bounding_rect().center())); + } + } + (picked, labels) + }; + for text in [Some("Text Color"), None] { + let ctx = egui::Context::default(); + let (_, labels) = menu(&ctx, text, None); + let combo = labels.iter().find(|(t, _)| t == "[Black]").unwrap().1; + menu(&ctx, text, Some(combo)); + let (_, labels) = menu(&ctx, text, None); + let item = labels.iter().find(|(t, _)| t == "Text Color").map(|l| l.1); + assert_eq!(item.is_some(), text.is_some(), "{labels:?}"); + if let Some(pos) = item { + let (picked, _) = menu(&ctx, text, Some(pos)); + assert_eq!(picked.as_deref(), Some(designcraft_doc::TEXT_COLOR)); + } + } +} + +/// The course's chapter 20: a paragraph style's rule below becomes wavy, 3 pt, with a gap +/// colour, from Paragraph Rules › Type and Gap Color. +#[test] +fn paragraph_style_rules_take_a_type_and_a_gap_colour() { + let (mut app, _) = ruled_paragraph(); + let ctx = egui::Context::default(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + draw(&mut app, &ctx); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("p.ruleBelow.type".into(), json!({"kind": "wavy"})); + d.fields.insert("p.ruleBelow.weight".into(), json!(3.0)); + d.fields.insert("p.ruleBelow.gapColor".into(), json!("[Paper]")); + } + draw(&mut app, &ctx); + confirm(&mut app).unwrap(); + let r = app.session.doc().unwrap().doc.styles.resolve_para_style("Ruled Child").0.rule_below; + assert_eq!((r.kind, r.weight, r.gap_color.as_str()), (designcraft_doc::StrokeType::Wavy, 3.0, "[Paper]")); +} + +/// A rule's Gap Tint shows 100% at its default (the rule's JSON leaves a full gap tint out). +#[test] +fn a_rules_default_gap_tint_shows_100() { + use egui_kittest::kittest::Queryable; + let (mut app, _) = ruled_paragraph(); + app.run( + "style.paragraph.edit", + json!({"name": "Ruled Child", "para": {"ruleBelow": {"type": {"kind": "dashed", "pattern": [4.0, 2.0]}, "gapColor": "[Paper]"}}}), + ) + .unwrap(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + h.run_steps(4); + let label = h.get_by_label("Gap Tint:").rect(); + let field = h + .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) + .filter(|n| n.rect().center().x > label.max.x && (n.rect().center().y - label.center().y).abs() < 8.0) + .min_by(|a, b| a.rect().min.x.total_cmp(&b.rect().min.x)) + .and_then(|n| n.value()); + assert_eq!(field.as_deref(), Some("100%")); +} + +/// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All +/// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the +/// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and +/// Reset To Base are set from General. +#[test] +fn a_new_paragraph_style_starts_from_the_selected_text() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); + app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); + app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); + open_new_style(&mut app, true); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!( + (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), + (json!(20.0), json!("allCaps"), json!(6.0)) + ); + assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("Recipe Categories")); + d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + } + confirm(&mut app).unwrap(); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); + assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); + let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); + assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(style(&app).chars.size, Some(20.0)); + // Reset To Base: the style keeps nothing of its own, apart from the edits made then. + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("resetToBase".into(), json!(true)); + d.fields.insert("c.tracking".into(), json!(10.0)); + } + confirm(&mut app).unwrap(); + let s = style(&app); + assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); + assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); +} + +/// The course's chapter 22: "eggs" made bold and red on the page, then New Character Style +/// from it. The dialog is titled as InDesign's, starts from what the text has of its own +/// over its paragraph style ("[None] + Bold + color"), and Apply Style to Selection gives the +/// text the style, with no override left. +#[test] +fn a_new_character_style_starts_from_the_selected_text() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Drop eggs into boiling water."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 29})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Recipe", "chars": {"size": 14}})).unwrap(); + app.run("style.paragraph.apply", json!({"name": "Recipe"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 5, "focus": 9})).unwrap(); + app.run("type.char", json!({"attrs": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); + open_new_style(&mut app, false); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.fields["c.fontStyle"].clone(), d.fields["c.fill"].clone()), (json!("Bold"), json!("C=15 M=100 Y=100 K=0"))); + assert!(d.b("apply")); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("new_character_style_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + for l in ["New Character Style", "Apply Style to Selection"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + let app = h.state_mut(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("eggs")); + } + confirm(app).unwrap(); + let style = app.session.doc().unwrap().doc.styles.char_style("eggs").cloned().unwrap(); + assert_eq!((style.chars.font_style.as_deref(), style.chars.fill.as_deref()), (Some("Bold"), Some("C=15 M=100 Y=100 K=0"))); + assert_eq!(style.chars.size, None, "the size is the paragraph style's, not the text's own"); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!((a["characterStyle"].clone(), a["charOverrides"].clone()), (json!("eggs"), json!(0)), "applied to the selection"); +} + +/// A Based On changed in the dialog (without Reset To Base) is what the fields are checked +/// against: the style's own settings over the new parent. +#[test] +fn a_changed_based_on_is_what_the_fields_are_checked_against() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); + d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); +} + +/// With Reset To Base, the justification fields are checked against the base style (the +/// style's own minimum no longer applies), and Preview shows the reset on the page. +#[test] +fn reset_to_base_checks_and_previews_against_the_base() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) + .unwrap(); + app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); + d.fields.insert("resetToBase".into(), json!(true)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); + d.fields.insert("preview".into(), json!(true)); + style_options_preview(&mut app, &mut d); + assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); + let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); + assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); + app.session.cancel_interaction(); +} + +/// The course's chapter 22: Find/Change › Change Format. "eggs", case sensitive, nothing in +/// Change to; Change Format Settings › Style Options sets Character Style "eggs", and the box +/// shows it (with Change to's ⓘ). Escape leaves the settings, not Find/Change. Change All +/// gives every lowercase "eggs" the style and keeps the words; the trash clears the format. +#[test] +fn find_change_gives_the_found_text_a_change_format() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + d.fields.insert("caseSensitive".into(), json!(true)); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("change_format_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Change Format and nothing in Change to, the found text stays and is only formatted"; + assert!(h.query_by_label("Change Format:").is_some()); + assert!(h.query_by_label(TIP).is_none(), "no ⓘ without a Change Format"); + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + // Style Options is the section's name in the list and its heading. + for l in ["Change Format Settings", "Style Options", "Basic Character Formats", "Character Color"] { + assert!(h.query_all_by_label(l).next().is_some(), "{l}"); + } + // Style Options › Character Style: eggs. + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some(), "the box shows it"); + assert!(h.query_by_label(TIP).is_some(), "Change to's ⓘ"); + // Escape closes Change Format Settings only, and keeps the Change Format. + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("findChange")); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + // Change All: the words stay, every lowercase "eggs" has the style. + h.get_by_label("Change All").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("2 replacement(s) made"))); + let story = h.state().session.doc().unwrap().doc.story(sid).cloned().unwrap(); + assert_eq!(story.text, text); + assert_eq!( + [8, 19, 36].map(|at| story.format_after(at).style.clone()), + [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] + ); + // The trash clears it (the second: the first is the Find Format's). + h.get_all_by_label("Clear specified attributes").nth(1).unwrap().click(); + h.run_steps(3); + assert_eq!(field(&h, CHANGE_FORMAT), None); + assert!(h.query_by_label(TIP).is_none()); +} + +/// What Change Format Settings sets goes to find.change: a paragraph style and character +/// attributes as well as a character style; the box sums them up. +#[test] +fn a_change_format_goes_with_change_all() { + let mut d = Dialog::new("findChange", json!({"find": "cook"})); + d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); + let p = find_change_params(&d); + assert_eq!((p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), (json!("eggs"), json!("Method"), json!({"underline": true}))); + assert_eq!(format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); + assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); + // A Find Format goes with every search. + d.fields.insert(FIND_FORMAT.into(), json!({"paraStyle": "Method"})); + assert_eq!(find_change_params(&d)["findFormat"], json!({"paraStyle": "Method"})); +} + +/// Find/Change › Find Format, as InDesign's: Find Format Settings sets the character style the +/// found text must have, the box shows it with Find what's ⓘ, and Count counts only "eggs" in +/// it; with nothing in Find what, Find Next finds the text in the style; the first trash clears +/// it. +#[test] +fn find_format_finds_only_text_with_it() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Break eggs; cook eggs."})).unwrap(); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 6, "focus": 10})).unwrap(); + app.run("style.character.apply", json!({"name": "eggs"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("find_format_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it"; + assert!(h.query_by_label("Find Format:").is_some() && h.query_by_label(TIP).is_none()); + h.get_by_label("Specify attributes to find").click(); + h.run_steps(3); + assert!(h.query_by_label("Find Format Settings").is_some()); + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, FIND_FORMAT), field(&h, CHANGE_FORMAT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some() && h.query_by_label(TIP).is_some()); + h.get_by_label("Count").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("1 match(es)")), "only the \"eggs\" in the style"); + // Nothing in Find what: the text in the style. + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("find".into(), json!("")); + h.run_steps(2); + h.get_by_label("Find Next").click(); + h.run_steps(3); + let sel = h.state().session.doc().unwrap().selection.text.unwrap(); + assert_eq!(sel.range(), 6..10); + // The first trash is the Find Format's. + h.get_all_by_label("Clear specified attributes").next().unwrap().click(); + h.run_steps(3); + assert_eq!(field(&h, FIND_FORMAT), None); +} + +/// The course's chapter 26: Preferences › Interface as InDesign's. A Color Theme swatch shows +/// its theme at once and Cancel goes back; OK keeps the theme and the Tools panel's columns. +/// UI Size is in a UI Scaling section of its own. +#[test] +fn preferences_interface_sets_the_color_theme_and_tools_panel() { + use crate::theme::Brightness; + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let opened = app.ui.brightness; + let open = |app: &mut DesignApp| { + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("interface")); + }; + open(&mut app); + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("prefs_interface_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + let swatch = |h: &Harness<'_, DesignApp>, name: &str| h.get_by(|n| n.role() == Role::RadioButton && n.label().as_deref() == Some(name)).click(); + for l in ["Appearance", "Color Theme:", "Panels", "Tools Panel:"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + // A click shows the theme at once; Cancel goes back. + swatch(&h, "Light"); + h.run_steps(3); + assert_eq!(h.state().ui.brightness, Brightness::Light); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + assert_eq!(h.state().ui.brightness, opened, "Cancel goes back to the theme Preferences opened with"); + // OK keeps the theme and the Tools panel's columns. + open(h.state_mut()); + h.run_steps(3); + swatch(&h, "Dark"); + h.run_steps(2); + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some("Single Column")).click(); + h.run_steps(2); + h.get_by_label("Double Column").click(); + h.run_steps(2); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + assert_eq!(h.state().ui.brightness, Brightness::Dark); + assert!(h.state().ui.tools_double_column); + // The same command the Window menu toggles, with an explicit state for Preferences. + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false)); + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false), "set, not toggled"); + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({})).unwrap(), json!(true), "toggled without `on`"); + // UI Size has a section of its own. + open(h.state_mut()); + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("uiScaling")); + h.run_steps(3); + assert!(h.query_by_label("UI Size:").is_some()); +} + +/// With no document open, Preferences has Units & Increments, as InDesign's: OK keeps them +/// for new documents, and New Document opens in them (Lollo's inches, set once). +#[test] +fn preferences_with_no_document_set_the_units_of_new_documents() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + let mut d = app.ui.dialog.take().expect("Preferences"); + assert!(d.b("defaults"), "for new documents"); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + app.ui.dialog = Some(d.clone()); + let mut labels = vec![]; + for _ in 0..3 { + labels = dialog_frame(&mut app, &ctx, screen, vec![]); + } + assert!(labels.iter().any(|(t, _, _)| t == "Units & Increments"), "listed with no document: {labels:?}"); + d.fields.insert("horizontalUnits".into(), json!("inches")); + d.fields.insert("verticalUnits".into(), json!("inches")); + d.fields.insert("keyboardIncrement".into(), json!("0.5 pt")); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + let du = app.session.prefs.default_units; + assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + assert!(app.session.active().is_none(), "no document made"); + // A Cursor Key of 0 or text that isn't a measure is refused, keeping the dialog open and + // the saved values, the other preferences' too; a Kerning/Tracking of 0.5 is kept as it is. + let scale = app.session.prefs.scale_strokes; + for bad in [json!({"keyboardIncrement": "0 pt"}), json!({"keyboardIncrement": "wide"}), json!({"kerningIncrement": "lots"})] { + let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "scaleStrokes": !scale})); + for (k, v) in bad.as_object().unwrap() { + d.fields.insert(k.clone(), v.clone()); + } + app.ui.dialog = Some(d); + assert!(confirm(&mut app).is_err(), "{bad}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "preferences"), "still open: {bad}"); + assert_eq!(app.session.prefs.default_units.keyboard_increment, 0.5, "{bad}"); + assert_eq!(app.session.prefs.scale_strokes, scale, "no other preference saved: {bad}"); + } + app.ui.dialog = Some(Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "kerningIncrement": "0.5"}))); + confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.default_units.kerning_increment, 0.5, "not rounded up to 1"); + // New Document opens in inches, and the document it makes has inch rulers. + crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null); + let nd = app.ui.dialog.clone().expect("New Document"); + assert_eq!(nd.fields["units"], json!("inches")); + assert_eq!(nd.s("width"), designcraft_geom::format_measure(612.0, Unit::Inches)); + confirm(&mut app).unwrap(); + let st = &app.session.doc().unwrap().doc.settings; + assert_eq!((st.horizontal_units, st.vertical_units, st.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + // With the document open, Preferences edits that document's units, not the defaults. + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + assert!(!app.ui.dialog.as_ref().unwrap().b("defaults")); +} + +/// Preferences › File Handling shows the number of recent items as a whole number, as +/// InDesign's does ("20"; it read "20.0"). +#[test] +fn file_handling_shows_the_recent_items_count_as_a_whole_number() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("files")); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Number of Recent Items to Display:"); + let count = app.ui.dialog.as_ref().unwrap().n("recentFilesMax").unwrap(); + assert_eq!(count, 20.0, "InDesign's default"); + visible_label(&labels, screen, &format!("{count}")); + assert!(!labels.iter().any(|(t, _, _)| t.trim() == format!("{count:.1}")), "no decimal shown"); +} + +/// Collapsing advanced TOC controls changes visibility without discarding their values. +#[test] +fn toc_more_options_preserves_hidden_settings() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert( + "included".into(), + json!([{ + "style": "Headline", "level": 3, "separator": " ^t", "pageNumber": "before", + "pageNumberStyle": "Accent", "separatorStyle": "Emphasis", "sort": true + }]), + ); + let before = toc_params(&app, app.ui.dialog.as_ref().unwrap()); + let revision = app.session.doc().unwrap().revision; + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Between Entry and Number:"); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Fewer Options").center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(!labels.iter().any(|(text, _, _)| text == "Between Entry and Number:")); + visible_label(&labels, screen, "Entry Style:"); + assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "More Options").center()); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Between Entry and Number:"); + assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); + assert_eq!(app.session.doc().unwrap().revision, revision); +} + +/// Enter on the focused Fewer Options / More Options button toggles the options; it doesn't +/// also make the table of contents. +#[test] +fn enter_on_the_toc_options_toggle_only_toggles() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + let revision = app.session.doc().unwrap().revision; + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("toc_toggle_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Fewer Options").focus(); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let d = h.state().ui.dialog.as_ref().expect("the dialog stays open"); + assert_eq!((d.id.as_str(), d.b("moreOptions")), (TOC, false), "the options are fewer"); + assert_eq!(h.state().session.doc().unwrap().revision, revision, "no table of contents made"); + assert!(h.query_by_label("More Options").is_some()); +} + +/// The title stays readable beside the style selector after the grid settles. +#[test] +fn toc_title_field_shows_the_whole_default_title() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let title_row = |labels: &[(String, egui::Rect, egui::Rect)]| { + let y = visible_label(labels, screen, "Title:").center().y; + labels.iter().filter(|(_, rect, _)| (rect.center().y - y).abs() < 2.0).cloned().collect::>() + }; + visible_label(&title_row(&labels), screen, "Contents"); + app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Table of Contents")); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&title_row(&labels), screen, "Table of Contents"); + // A small window: the title narrows and the style selector beside it stays in view. + let small = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, small, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, small, vec![]); + let y = visible_label(&labels, small, "Title:").center().y; + let row: Vec<_> = labels.iter().filter(|(_, rect, _)| (rect.center().y - y).abs() < 2.0).cloned().collect(); + let style = visible_label(&row, small, "TOC Title"); + // The selector is 200 pt wide from just before its text: all of it is in the dialog body. + let (_, _, body) = row.iter().find(|(text, _, _)| text == "Style:").unwrap(); + assert!(style.min.x - 4.0 + 200.0 <= body.max.x, "the style selector fits the dialog: {style:?} in {body:?}"); +} + +/// The bookmarks checkbox persists through generation and reopening the dialog. +#[test] +fn toc_bookmarks_checkbox_sets_and_reopens_the_persisted_option() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + assert!(app.ui.dialog.as_ref().unwrap().b("createBookmarks")); + app.ui.dialog.as_mut().unwrap().fields.insert("included".into(), json!([{"style": "Headline", "level": 1}])); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Create PDF Bookmarks"); + confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.toc.as_ref().unwrap().create_bookmarks); + assert!(!app.session.doc().unwrap().doc.bookmarks.is_empty()); + open_toc(&mut app); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Create PDF Bookmarks").center()); + confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.bookmarks.is_empty()); + open_toc(&mut app); + assert!(!app.ui.dialog.as_ref().unwrap().b("createBookmarks")); +} + +#[test] +fn corner_options_dialog_sets_each_corner_with_preview_and_one_undo_step() { + use designcraft_geom::corners::CornerShape; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 360, 360]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let corners = |app: &DesignApp| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; + let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let u0 = undo(&app); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + // Object › Corner Options… opens InDesign's dialog, not a form of the command's params. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(CORNER_OPTIONS)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Corner Options", "Corner Size and Shape:", "Preview", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + assert!(!labels.iter().any(|(t, _, _)| t.trim() == "Ids"), "no raw command fields"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.s("size1"), d.s("shape1"), d.b("link")), ("0p0".into(), "none".into(), true), "seeded from the frame, linked"); + // Linked: a corner changed is all four. + set(&mut app, "size0", json!("1p0")); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("size2"), "1p0"); + // Unlinked, only the top-right corner rounds, as in the tutorial. + set(&mut app, "link", json!(false)); + for k in 0..4 { + set(&mut app, &format!("size{k}"), json!("0")); + } + set(&mut app, "size1", json!("3 cm")); + set(&mut app, "shape1", json!("rounded")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(corners(&app)[1].shape, CornerShape::Rounded, "Preview shows it"); + assert_eq!(undo(&app), u0, "without an undo step"); + set(&mut app, "preview", json!(false)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(corners(&app)[1].shape, CornerShape::None, "Preview off puts it back"); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let c = corners(&app); + assert_eq!(c.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); + assert!((c[1].size - 3.0 / 2.54 * 72.0).abs() < 1e-6, "{}", c[1].size); + assert_eq!(undo(&app), u0 + 1, "one undo step"); + // Reopened, it shows the frame's corners, no longer linked. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.s("shape1"), d.s("shape0"), d.b("link")), ("rounded".into(), "none".into(), false)); + // A size that does not parse keeps the dialog open with the error; so does a shape the + // dialog doesn't offer (rather than another shape taking its place). + set(&mut app, "size0", json!("abc")); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + set(&mut app, "size0", json!("0")); + set(&mut app, "shape0", json!("spiky")); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + cancel(&mut app); + assert_eq!(corners(&app), c); +} + +/// Several objects: the dialog shows the first one's corners, and OK sets only the corners +/// edited; the others keep each object's own. A linked edit sets all four. +#[test] +fn corner_options_keep_each_objects_untouched_corners() { + use designcraft_geom::corners::CornerShape; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [272, 72, 400, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("object.cornerOptions", json!({"ids": [b], "shape": "bevel", "size": 12})).unwrap(); + app.run("selection.set", json!({"ids": [a, b]})).unwrap(); + let corners = |app: &DesignApp, id: u64| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + let b0 = corners(&app, b); + // Unlinked: only the top-right corner rounds, on both. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "link", json!(false)); + set(&mut app, "size1", json!("1p0")); + set(&mut app, "shape1", json!("rounded")); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let (ca, cb) = (corners(&app, a), corners(&app, b)); + assert_eq!(ca.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); + assert_eq!(cb.map(|c| c.shape), [CornerShape::Bevel, CornerShape::Rounded, CornerShape::Bevel, CornerShape::Bevel], "b keeps its others"); + assert_eq!((cb[0], cb[2], cb[3]), (b0[0], b0[2], b0[3])); + assert_eq!(ca[1], cb[1]); + // Linked: a corner set to the first object's own value still sets all four, on both. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "link", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "size0", json!("0p6")); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let (ca, cb) = (corners(&app, a), corners(&app, b)); + assert_eq!(ca, cb, "all four, on both"); + assert!(ca.iter().all(|c| c.shape == CornerShape::None && (c.size - 6.0).abs() < 1e-6), "{ca:?}"); +} + +#[test] +fn on_a_page_without_a_section_the_window_is_new_section() { + // Chris Nielsen's "Numbering Page Sections" (4:43): page 3 selected, the Pages panel menu's + // Numbering & Section Options opens New Section with Start Section on and 1 offered. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 6, "facingPages": true})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0))); + crate::canvas::go_to_page(&mut app, 2); + crate::menus::activate(&mut app, "layout.section", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.n("page"), d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), + (Some(3.0), true, true, true, "1".into()) + ); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "New Section"); + // Typing a number in the field chooses Start Page Numbering at; OK: page 3 is page 5 (odd, + // so a facing page stays on the right). + let field = visible_label(&labels, screen, "1"); + click_dialog(&mut app, &ctx, screen, field.center()); + dialog_frame( + &mut app, + &ctx, + screen, + vec![ + egui::Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Event::Text("5".into()), + ], + ); + assert!(!app.ui.dialog.as_ref().unwrap().b("automatic"), "typing chose Start Page Numbering at"); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().doc.page_name(2), "5"); + // Opened again on that page, it is Numbering & Section Options, showing the section. + crate::menus::activate(&mut app, "layout.section", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), (false, true, false, "5".into())); + let labels = (0..4).map(|_| dialog_frame(&mut app, &ctx, screen, vec![])).last().unwrap(); + visible_label(&labels, screen, "Numbering & Section Options"); + assert!(!labels.iter().any(|(t, _, _)| t == "New Section")); +} + +/// Find/Change's Change and Change/Find, as InDesign's: Find Next selects a match, Change/Find +/// changes it and selects the next, Change changes that one and keeps it selected. With no +/// text selected they're off. +#[test] +fn change_and_change_find_change_the_found_text() { + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "red red | red"})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("red")); + d.fields.insert("change".into(), json!("blue")); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("change_find_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + assert!(h.query_by_value("Document").is_some(), "the Search choices are InDesign's"); + assert!(h.get_by_label("Change").accesskit_node().is_disabled(), "nothing found yet"); + let text = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.story(sid).unwrap().text.clone(); + let selected = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().selection.text.unwrap().range(); + h.get_by_label("Find Next").click(); + h.run_steps(3); + assert_eq!(selected(&h), 0..3); + h.get_by_label("Change/Find").click(); + h.run_steps(3); + assert_eq!((text(&h), selected(&h)), ("blue red | red".to_string(), 5..8)); + h.get_by_label("Change").click(); + h.run_steps(3); + assert_eq!((text(&h), selected(&h)), ("blue blue | red".to_string(), 5..9)); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().fields["status"], json!("1 replacement(s) made")); +} + +/// Insert Table: the dimensions as typed, and the Table Style chosen is applied to the new +/// table; [Basic Table] (the default) leaves it unstyled. +#[test] +fn insert_table_applies_its_dimensions_and_table_style() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.table.create", json!({"name": "Grid"})).unwrap(); + for (style, expected) in [("Grid", "Grid"), (designcraft_doc::BASIC_TABLE, "")] { + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + assert_eq!(app.run("app.insertTableDialog", json!({})).unwrap(), Value::Null); + for (field, value) in [("bodyRows", json!(5)), ("columns", json!(3)), ("headerRows", json!(1)), ("tableStyle", json!(style))] { + ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); + } + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let t = app.run("table.get", json!({})).unwrap(); + let len = |k: &str| t[k].as_array().map_or(0, Vec::len); + assert_eq!((len("rows"), len("columns"), t["headerRows"].as_u64()), (6, 3, Some(1)), "{t}"); + assert_eq!(t["style"].as_str().unwrap_or(""), expected, "{t}"); + // One Insert Table, one undo. + app.run("edit.undo", json!({})).unwrap(); + assert!(app.run("table.get", json!({})).is_err(), "the table is gone"); + } + // A null style is no style: the table is made, unstyled. + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + app.run("app.insertTableDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": null})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert_eq!(app.run("table.get", json!({})).unwrap()["style"].as_str().unwrap_or(""), ""); + app.run("edit.undo", json!({})).unwrap(); + // A style that no longer exists changes nothing. + let tables = |app: &DesignApp| app.session.active().unwrap().doc.stories.values().map(|s| s.tables.len()).sum::(); + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + app.run("app.insertTableDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": "Gone"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); + let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected"); + assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string())); + // So does a style that isn't a name: the command refuses it, rather than making the table + // unstyled. + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": 42})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("insertTable"), "the dialog stays open"); +} + +/// Convert Text to Table: available with text selected (not with a caret alone); its +/// separators and Number of Columns shape the table. Convert Table to Text takes it back with +/// the separators chosen there. +#[test] +fn convert_dialogs_use_their_separators() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Name:\nAddress:\nCity:\nState:"; + let sid = app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text", "text": text})).unwrap()["story"].clone(); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert!(!crate::menus::menu_enabled(&app, "app.convertTextToTableDialog"), "a caret alone"); + assert!(!crate::menus::menu_enabled(&app, "app.convertTableToTextDialog"), "no table"); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + assert!(crate::menus::menu_enabled(&app, "app.convertTextToTableDialog")); + app.run("app.convertTextToTableDialog", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable")); + // "comma" and a typed "," are the same separator, so Number of Columns applies; a count of + // 0 is refused, not taken as 1, and the dialog stays open. + ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": "comma"})); + ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ","})); + assert!(same_separators(app.ui.dialog.as_ref().unwrap())); + ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": 0})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable")); + // An emptied separator: the command refuses it and the dialog stays open, as typed. + ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ""})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(app.ui.dialog.as_ref().map(|d| (d.id.as_str(), d.s("rowSeparator"))), Some(("convertTextToTable", String::new()))); + for (field, value) in [("columnSeparator", json!("paragraph")), ("rowSeparator", json!("paragraph")), ("columns", json!(2))] { + ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); + } + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let t = app.run("table.get", json!({})).unwrap(); + let len = |k: &str| t[k].as_array().map_or(0, Vec::len); + assert_eq!((len("rows"), len("columns")), (2, 2), "{t}"); + assert!(crate::menus::menu_enabled(&app, "app.convertTableToTextDialog")); + app.run("app.convertTableToTextDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": ", "})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let story = app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid.as_u64().unwrap())).unwrap().text.clone(); + // (The empty paragraph after a table that ends its story stays after the text.) + assert_eq!(story.trim_end(), "Name:, Address:\nCity:, State:"); +} diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 0fc2b4efc..0d27513d2 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -210,18 +210,109 @@ fn percent(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str]) { } /// The effect's own angle at `path`, or with Use Global Light (`own` false) the document's -/// global light angle: editing it then changes that one, for every effect that uses it. +/// global light angle: editing it then changes that one, for every effect that uses it. The dial +/// and the field both edit it. fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], own: bool, global: f64) -> egui::Response { let mut v = if own { num(d, path) } else { global }; - let r = ui.add(egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)); - if r.changed() { + let put = |d: &mut Dialog, v: f64| { if own { set(d, path, json!(v)); } else { d.fields.insert(GLOBAL_LIGHT.into(), json!(v)); } + }; + ui.horizontal(|ui| { + if let Some(a) = angle_dial(ui, v, true) { + v = a; + put(d, v); + } + if ui.add(egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)).changed() { + put(d, v); + } + }) + .response +} + +/// InDesign's angle dial: a circle with a radius pointing where the light comes from (0° to the +/// right, counter-clockwise). A click or a drag on it points it at the pointer. Focused, the +/// arrow keys turn it a degree (Shift: 10°) — Right and Up counter-clockwise — and assistive +/// technology can step it or set its value, as with a slider. +fn angle_dial(ui: &mut egui::Ui, deg: f64, enabled: bool) -> Option { + use egui::accesskit::{Action, ActionData}; + let sense = if enabled { egui::Sense::click_and_drag() } else { egui::Sense::hover() }; + let (r, resp) = ui.allocate_exact_size(egui::vec2(26.0, 26.0), sense); + resp.widget_info(|| egui::WidgetInfo::slider(enabled, deg, "Angle")); + let col = if enabled { ui.visuals().text_color() } else { ui.visuals().weak_text_color() }; + let (c, rad) = (r.center(), 11.0); + ui.painter().circle_stroke(c, rad, egui::Stroke::new(1.2, col)); + let a = deg.to_radians() as f32; + ui.painter().line_segment([c, c + egui::vec2(a.cos(), -a.sin()) * rad], egui::Stroke::new(1.4, col)); + if resp.has_focus() { + ui.painter().circle_stroke(c, rad + 2.0, ui.visuals().selection.stroke); } - r + if !enabled { + return None; + } + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(-180.0); + node.set_max_numeric_value(180.0); + node.set_numeric_value_step(1.0); + node.add_action(Action::SetValue); + node.add_action(Action::Increment); + node.add_action(Action::Decrement); + }); + let mut next = None; + let mut steps = 0i64; + if resp.has_focus() { + // The arrows turn the dial instead of moving the focus. + ui.memory_mut(|m| { + m.set_focus_lock_filter(resp.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + ui.input(|i| { + for e in &i.events { + if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { + let dir = match key { + egui::Key::ArrowRight | egui::Key::ArrowUp => 1, + egui::Key::ArrowLeft | egui::Key::ArrowDown => -1, + _ => 0, + }; + steps += dir * if modifiers.shift { 10 } else { 1 }; + } + } + }); + } + ui.input(|i| { + steps += i.num_accesskit_action_requests(resp.id, Action::Increment) as i64; + steps -= i.num_accesskit_action_requests(resp.id, Action::Decrement) as i64; + for request in i.accesskit_action_requests(resp.id, Action::SetValue) { + if let Some(ActionData::NumericValue(v)) = request.data { + next = Some(v); + } + } + }); + if steps != 0 { + next = Some(deg + steps as f64); + } + if (resp.dragged() || resp.clicked()) + && let Some(a) = resp.interact_pointer_pos().and_then(|p| dial_angle(c, p)) + { + next = Some(a); + } + next.filter(|v| v.is_finite()).map(wrap_angle) +} + +/// `deg` turned into the dial's range, −180 (exclusive) to 180°. +fn wrap_angle(deg: f64) -> f64 { + let a = (deg + 180.0).rem_euclid(360.0) - 180.0; + if a <= -180.0 { 180.0 } else { a } +} + +/// The angle a dial at `c` points at for the pointer at `p`, in whole degrees (−180 to 180, +/// counter-clockwise from the right); none too close to the centre to tell. +fn dial_angle(c: egui::Pos2, p: egui::Pos2) -> Option { + let d = p - c; + // Straight left is 180° (not −180°, which a negative zero would give). + (d.length() > 2.0).then(|| f64::from((-d.y).atan2(d.x)).to_degrees().round()).map(|a| if a <= -180.0 { 180.0 } else { a }) } fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], label: &str) { @@ -318,6 +409,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let key = page; heading(ui, &tr("Blending")); grid("b").show(ui, |ui| { + mode_row(ui, d, key, &tr); row(ui, "Color:"); swatch(app, ui, d, &[key, "color"]); row(ui, "Opacity:"); @@ -359,23 +451,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let key = page; heading(ui, &tr("Blending")); grid("b").show(ui, |ui| { - // Inner Glow: its blend mode (Screen, as InDesign starts it). - if key == "innerGlow" { - row(ui, "Mode:"); - let cur = d.fields.get("fx").and_then(|f| f["innerGlow"]["mode"].as_str()).unwrap_or("Screen").to_string(); - let label = |m: &designcraft_color::BlendMode| { - serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default() - }; - let shown = designcraft_color::BlendMode::ALL.iter().find(|m| label(m) == cur).map_or("Screen", |m| m.label()); - craft_ui::ComboBox::from_id_salt("fx_glow_mode").selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { - for m in designcraft_color::BlendMode::ALL { - if ui.selectable_label(label(&m) == cur, tr(m.label())).clicked() { - set(d, &["innerGlow", "mode"], json!(label(&m))); - } - } - }); - ui.end_row(); - } + mode_row(ui, d, key, &tr); row(ui, "Color:"); swatch(app, ui, d, &[key, "color"]); row(ui, "Opacity:"); @@ -565,6 +641,28 @@ fn summary(lang: &str, d: &Dialog) -> String { format!("{}: {} {:.0}%; {}", crate::i18n::tr(lang, "OBJECT").to_uppercase(), crate::i18n::tr(lang, blend), opacity * 100.0, effects) } +/// The Mode row of a shadow's or glow's Blending: its blend mode, as InDesign's (shadows start +/// Multiply, glows Screen). +fn mode_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, tr: &dyn Fn(&str) -> String) { + crate::rtl::label(ui, tr("Mode:")); + let fallback = if key.ends_with("Glow") { "Screen" } else { "Multiply" }; + let cur = d.fields.get("fx").and_then(|f| f[key]["mode"].as_str()).unwrap_or(fallback).to_string(); + let name = |m: &designcraft_color::BlendMode| serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + let shown = designcraft_color::BlendMode::ALL.iter().find(|m| name(m) == cur).map_or(fallback, |m| m.label()); + let mut picked = None; + craft_ui::ComboBox::from_id_salt(("fx_mode", key.to_string())).selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { + for m in designcraft_color::BlendMode::ALL { + if ui.selectable_label(name(&m) == cur, tr(m.label())).clicked() { + picked = Some(name(&m)); + } + } + }); + if let Some(m) = picked { + set(d, &[key, "mode"], json!(m)); + } + ui.end_row(); +} + /// The dialog's body. pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let lang = app.ui.language.clone(); @@ -767,6 +865,76 @@ mod tests { assert!(!fx(&app).inner_shadow.on); } + /// The angle dial points where the pointer is, as InDesign's: right is 0°, up 90°, left 180°, + /// down −90°, upper left 135°. + #[test] + fn the_angle_dial_points_at_the_pointer() { + let c = egui::pos2(100.0, 100.0); + assert_eq!(dial_angle(c, egui::pos2(120.0, 100.0)), Some(0.0)); + assert_eq!(dial_angle(c, egui::pos2(100.0, 80.0)), Some(90.0)); + assert_eq!(dial_angle(c, egui::pos2(80.0, 100.0)), Some(180.0)); + assert_eq!(dial_angle(c, egui::pos2(100.0, 120.0)), Some(-90.0)); + assert_eq!(dial_angle(c, egui::pos2(90.0, 90.0)), Some(135.0)); + assert_eq!(dial_angle(c, egui::pos2(101.0, 100.0)), None, "too close to the centre"); + } + + /// Without a pointer: Tab focuses the dial, the arrows turn it (Shift: 10°), wrapping at 180°. + #[test] + fn the_angle_dial_turns_with_the_keyboard() { + let ctx = egui::Context::default(); + let key = |key, shift| egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }, + }; + let mut deg = 175.0; + let frame = |events: Vec, deg: &mut f64| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + if let Some(a) = angle_dial(ui, *deg, true) { + *deg = a; + } + }); + out.textures_delta.clear(); + }; + frame(vec![], &mut deg); + frame(vec![key(egui::Key::Tab, false)], &mut deg); + frame(vec![key(egui::Key::ArrowRight, false)], &mut deg); + assert_eq!(deg, 176.0); + frame(vec![key(egui::Key::ArrowUp, true)], &mut deg); + assert_eq!(deg, -174.0, "past 180° it comes round to −174°"); + frame(vec![key(egui::Key::ArrowLeft, true)], &mut deg); + frame(vec![key(egui::Key::ArrowDown, false)], &mut deg); + assert_eq!(deg, 175.0); + assert_eq!(wrap_angle(-180.0), 180.0); + assert_eq!(wrap_angle(540.0), 180.0); + } + + /// Drop Shadow and Outer Glow have InDesign's Mode under Blending (Multiply and Screen to + /// start); a mode picked there is what OK applies. + #[test] + fn shadows_and_glows_have_a_blend_mode() { + let (mut app, id) = app_with_frame(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + open(&mut app, "dropShadow"); + let texts = frame(&mut app, &ctx); + assert!(texts.iter().any(|x| x.trim() == "Mode:") && texts.iter().any(|x| x.trim() == "Multiply"), "{texts:?}"); + crate::dialogs::cancel(&mut app); + open(&mut app, "outerGlow"); + let texts = frame(&mut app, &ctx); + assert!(texts.iter().any(|x| x.trim() == "Screen"), "{texts:?}"); + if let Some(d) = app.ui.dialog.as_mut() { + set(d, &["outerGlow", "mode"], json!("Normal")); + } + frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + let fx = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().effects.clone(); + assert!(fx.outer_glow.on); + assert_eq!(fx.outer_glow.mode, designcraft_color::BlendMode::Normal); + } + #[test] fn offsets_follow_distance_and_angle_as_in_indesign() { let (mut app, _) = app_with_frame(); diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 202de0df4..0c66b894c 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1,5 +1,7 @@ //! Original Arabic interface translations, stored in logical Unicode order. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "خيارات أقل"), + ("Tab Character", "محرف جدولة"), ("File", "ملف"), ("Edit", "تحرير"), ("Layout", "تخطيط"), @@ -31,6 +33,100 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Keep In Frame", "الإبقاء داخل الإطار"), + ("New Paragraph Style", "نمط فقرة جديد"), + ("New Character Style", "نمط حرف جديد"), + ("Next Style:", "النمط التالي:"), + ("[Same style]", "[النمط نفسه]"), + ("Reset To Base", "إعادة التعيين إلى الأساس"), + ("Apply Style to Selection", "تطبيق النمط على التحديد"), + ("Change/Find", "تغيير/بحث"), + ("Find Format:", "البحث عن تنسيق:"), + ("Find Format Settings", "إعدادات البحث عن تنسيق"), + ("Specify attributes to find", "تحديد السمات المراد البحث عنها"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "مع تنسيق للبحث، يُعثر فقط على النص الذي يحمله؛ وإذا كان حقل «البحث عن» فارغًا، فعلى كل نص يحمله", + ), + ("Change Format:", "تغيير التنسيق:"), + ("Change Format Settings", "إعدادات تغيير التنسيق"), + ("Style Options", "خيارات النمط"), + ("[Any Style]", "[أي نمط]"), + ("Specify attributes to change", "تحديد السمات المراد تغييرها"), + ("Clear specified attributes", "مسح السمات المحددة"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "مع تنسيق للتغيير وحقل «تغيير إلى» فارغ، يبقى النص الذي تم العثور عليه ويتغير تنسيقه فقط", + ), + ("Double Column", "عمود مزدوج"), + ("Tools Panel:", "لوحة الأدوات:"), + ("Color Theme:", "سمة الألوان:"), + ("At End of Document", "في نهاية المستند"), + ("At Start of Document", "في بداية المستند"), + ("Before Page", "قبل الصفحة"), + ("After Page", "بعد الصفحة"), + ("Parent:", "الصفحة الأصلية:"), + ("Insert:", "إدراج:"), + ("Stroke Type", "نوع الحد"), + ("Corner Shape", "شكل الزاوية"), + ("Corner Size and Shape:", "حجم الزوايا وشكلها:"), + ("Join", "وصل"), + ("Convert Point", "تحويل النقطة"), + ("New Section", "قسم جديد"), + ("Clear Frame Fitting Options", "مسح خيارات ملاءمة الإطار"), + ("Cannot be edited", "غير قابل للتحرير"), + ("Maximum:", "الحد الأقصى:"), + ("Paragraph Spacing Limit:", "حد تباعد الفقرات:"), + ("Overprint", "طباعة فوقية"), + ("Top Left Corner", "الزاوية العلوية اليسرى"), + ("Top Right Corner", "الزاوية العلوية اليمنى"), + ("Bottom Left Corner", "الزاوية السفلية اليسرى"), + ("Bottom Right Corner", "الزاوية السفلية اليمنى"), + ("Anchor Point(s)", "نقطة (نقاط) الارتكاز"), + ("Convert Points", "تحويل النقاط"), + ("Delete Point(s)", "حذف النقطة (النقاط)"), + ("Convert Text to Table…", "تحويل النص إلى جدول…"), + ("Convert Table to Text…", "تحويل الجدول إلى نص…"), + ("Column Separator", "فاصل الأعمدة"), + ("Row Separator", "فاصل الصفوف"), + ("Number of Columns", "عدد الأعمدة"), + ("Comma", "فاصلة"), + ("RGB Color Space View", "عرض فضاء ألوان RGB"), + ("Lab Color Space View", "عرض فضاء ألوان Lab"), + ("Add CMYK Swatch", "إضافة عينة لون CMYK"), + ("Added swatch", "تمت إضافة عينة اللون"), + ("Eyedropper: drag it onto the page", "القطارة: اسحبها إلى الصفحة"), + ("PDF Preset:", "إعداد PDF المسبق:"), + ("Packaged {count} files into {dir}", "تم حزم {count} ملفات في {dir}"), + ("Continue", "متابعة"), + ("Printing Instructions", "تعليمات الطباعة"), + ("Show Problems Only", "إظهار المشكلات فقط"), + ("Include PDF (Print)", "تضمين PDF (للطباعة)"), + ("Include IDML", "تضمين IDML"), + ("Create Printing Instructions", "إنشاء تعليمات الطباعة"), + ("Entire Publication", "المنشور بالكامل"), + ("Scope:", "النطاق:"), + ("Source", "المصدر"), + ("Installed", "مثبت"), + ("Bundled", "مضمن مع التطبيق"), + ("Substituted", "مستبدل"), + ("Modified", "معدل"), + ("Missing", "مفقود"), + ("Instructions:", "التعليمات:"), + ("Email:", "البريد الإلكتروني:"), + ("Fax:", "الفاكس:"), + ("Phone:", "الهاتف:"), + ("Address:", "العنوان:"), + ("Company:", "الشركة:"), + ("Contact:", "جهة الاتصال:"), + ("Process Black", "أسود معالجة"), + ("Process Yellow", "أصفر معالجة"), + ("Process Magenta", "أرجواني معالجة"), + ("Process Cyan", "سماوي معالجة"), + ("Colors and Inks", "الألوان والأحبار"), + ("Links and Images", "الروابط والصور"), + ("Fonts", "الخطوط"), + ("Summary", "الملخص"), ("Restart Numbering", "إعادة الترقيم"), ("Continue Numbering", "متابعة الترقيم"), ("Keep Options…", "خيارات الإبقاء…"), @@ -391,6 +487,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object Layer Options", "خيارات طبقات الكائن"), ("Colour Picker", "منتقي الألوان"), ("Color Picker", "أداة اختيار الألوان"), + ("Color spectrum", "طيف الألوان"), ("Color Settings", "إعدادات الألوان"), ("User Dictionary", "قاموس المستخدم"), ("Menu Customization", "تخصيص القوائم"), @@ -1975,6 +2072,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "كائن/ملاءمة"), ("Object/Pathfinder", "كائن/باحث المسارات"), ("Object/Paths", "كائن/مسارات"), + ("Object/Convert Point", "كائن/تحويل النقطة"), ("Object/Select", "كائن/تحديد"), ("Object/Transform", "كائن/تحويل"), ("Object/Transform Again", "كائن/تحويل مجدداً"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 76e2df932..b6459bc36 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -3,6 +3,8 @@ //! product's localization was used). Phrases shared with the MIT OR Apache-2.0 Czech catalogs of //! the ArtCraft siblings (VectorCraft, PhotoCraft) are kept identical; see ASSETS.md for sources. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Méně možností"), + ("Tab Character", "Znak tabulátoru"), ("File", "Soubor"), ("Edit", "Úpravy"), ("Layout", "Rozvržení"), @@ -32,6 +34,103 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Keep In Frame", "Zachovat v rámečku"), + ("Gap Tint:", "Odstín mezery:"), + ("New Paragraph Style", "Nový styl odstavce"), + ("New Character Style", "Nový styl znaku"), + ("Next Style:", "Další styl:"), + ("[Same style]", "[Stejný styl]"), + ("Reset To Base", "Obnovit podle základu"), + ("Apply Style to Selection", "Použít styl na výběr"), + ("Change/Find", "Změnit/Najít"), + ("Find Format:", "Hledat formát:"), + ("Find Format Settings", "Nastavení hledání formátu"), + ("Specify attributes to find", "Určit atributy k hledání"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "S formátem hledání se najde jen text, který ho má; s prázdným polem „Najít“ veškerý text, který ho má", + ), + ("Change Format:", "Změnit formát:"), + ("Change Format Settings", "Nastavení změny formátu"), + ("Style Options", "Volby stylu"), + ("[Any Style]", "[Libovolný styl]"), + ("Specify attributes to change", "Určit atributy ke změně"), + ("Clear specified attributes", "Vymazat určené atributy"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Se změnou formátu a prázdným polem „Změnit na“ nalezený text zůstane a změní se jen jeho formát", + ), + ("Double Column", "Dvojitý sloupec"), + ("Single Column", "Jednoduchý sloupec"), + ("Tools Panel:", "Panel nástrojů:"), + ("Color Theme:", "Barevný motiv:"), + ("At End of Document", "Na konec dokumentu"), + ("At Start of Document", "Na začátek dokumentu"), + ("Before Page", "Před stránku"), + ("After Page", "Za stránku"), + ("Parent:", "Vzor:"), + ("Insert:", "Vložit:"), + ("Stroke Type", "Typ tahu"), + ("Corner Shape", "Tvar rohu"), + ("Corner Size and Shape:", "Velikost a tvar rohů:"), + ("Join", "Spojit"), + ("Convert Point", "Převést bod"), + ("Plain", "Obyčejný"), + ("Symmetrical", "Symetrický"), + ("New Section", "Nový oddíl"), + ("Clear Frame Fitting Options", "Vymazat volby přizpůsobení rámečku"), + ("Cannot be edited", "Nelze upravit"), + ("Maximum:", "Maximum:"), + ("Paragraph Spacing Limit:", "Limit mezer mezi odstavci:"), + ("Overprint", "Přetisk"), + ("Top Left Corner", "Levý horní roh"), + ("Top Right Corner", "Pravý horní roh"), + ("Bottom Left Corner", "Levý dolní roh"), + ("Bottom Right Corner", "Pravý dolní roh"), + ("Anchor Point(s)", "Kotevní body"), + ("Convert Points", "Převést body"), + ("Delete Point(s)", "Odstranit body"), + ("Convert Text to Table…", "Převést text na tabulku…"), + ("Convert Table to Text…", "Převést tabulku na text…"), + ("Column Separator", "Oddělovač sloupců"), + ("Row Separator", "Oddělovač řádků"), + ("Number of Columns", "Počet sloupců"), + ("Comma", "Čárka"), + ("RGB Color Space View", "Zobrazení barevného prostoru RGB"), + ("Lab Color Space View", "Zobrazení barevného prostoru Lab"), + ("Add CMYK Swatch", "Přidat vzorek CMYK"), + ("Added swatch", "Vzorek přidán"), + ("Eyedropper: drag it onto the page", "Kapátko: přetáhněte ho na stránku"), + ("PDF Preset:", "Předvolba PDF:"), + ("Continue", "Pokračovat"), + ("Printing Instructions", "Pokyny k tisku"), + ("Show Problems Only", "Zobrazit jen problémy"), + ("Include PDF (Print)", "Zahrnout PDF (tisk)"), + ("Include IDML", "Zahrnout IDML"), + ("Create Printing Instructions", "Vytvořit pokyny k tisku"), + ("Entire Publication", "Celá publikace"), + ("Scope:", "Rozsah:"), + ("Spot", "Přímá"), + ("Process", "Procesní"), + ("Source", "Zdroj"), + ("Installed", "Nainstalováno"), + ("Bundled", "Přibaleno"), + ("Substituted", "Nahrazeno"), + ("Modified", "Změněno"), + ("Missing", "Chybí"), + ("Instructions:", "Pokyny:"), + ("Fax:", "Fax:"), + ("Address:", "Adresa:"), + ("Company:", "Společnost:"), + ("Contact:", "Kontakt:"), + ("Process Black", "Procesní černá"), + ("Process Yellow", "Procesní žlutá"), + ("Process Magenta", "Procesní purpurová"), + ("Process Cyan", "Procesní azurová"), + ("Colors and Inks", "Barvy a inkousty"), + ("Links and Images", "Odkazy a obrazy"), + ("Fonts", "Písma"), + ("Summary", "Souhrn"), ("Restart Numbering", "Začít číslování znovu"), ("Continue Numbering", "Pokračovat v číslování"), ("Keep Options…", "Volby zachování…"), @@ -194,6 +293,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Vybrat obrázek…"), ("Class:", "Třída:"), ("Color Picker", "Výběr barvy"), + ("Color spectrum", "Barevné spektrum"), ("Color Settings", "Nastavení barev"), ("Color:", "Barva:"), ("Columns", "Sloupce"), @@ -1656,6 +1756,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Objekt/Přizpůsobení"), ("Object/Pathfinder", "Objekt/Operace s cestami"), ("Object/Paths", "Objekt/Cesty"), + ("Object/Convert Point", "Objekt/Převést bod"), ("Object/Select", "Objekt/Vybrat"), ("Object/Transform", "Objekt/Transformace"), ("Object/Transform Again", "Objekt/Transformovat znovu"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 24dc45c4c..b4b7cd90d 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -3,6 +3,8 @@ //! Brazilian Portuguese, Italian and Ukrainian tables has a row here; the German column of the shared //! table is carried over and stays the fallback for keys added there after this table. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Weniger Optionen"), + ("Tab Character", "Tabulatorzeichen"), ("File", "Datei"), ("Edit", "Bearbeiten"), ("Layout", "Layout"), @@ -31,6 +33,30 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Stroke Type", "Konturtyp"), + ("Corner Shape", "Eckenform"), + ("Corner Size and Shape:", "Eckengröße und -form:"), + ("Join", "Verbinden"), + ("Convert Point", "Punkt konvertieren"), + ("Plain", "Einfach"), + ("Symmetrical", "Symmetrisch"), + ("New Section", "Neuer Abschnitt"), + ("Clear Frame Fitting Options", "Rahmeneinpassungsoptionen löschen"), + ("Cannot be edited", "Nicht bearbeitbar"), + ("Overprint", "Überdrucken"), + ("Top Left Corner", "Ecke oben links"), + ("Top Right Corner", "Ecke oben rechts"), + ("Bottom Left Corner", "Ecke unten links"), + ("Bottom Right Corner", "Ecke unten rechts"), + ("Anchor Point(s)", "Ankerpunkt(e)"), + ("Convert Points", "Punkte konvertieren"), + ("Delete Point(s)", "Punkt(e) löschen"), + ("Convert Text to Table…", "Text in Tabelle umwandeln …"), + ("Convert Table to Text…", "Tabelle in Text umwandeln …"), + ("Column Separator", "Spaltentrennzeichen"), + ("Row Separator", "Zeilentrennzeichen"), + ("Number of Columns", "Spaltenanzahl"), + ("Comma", "Komma"), ("Restart Numbering", "Nummerierung neu beginnen"), ("Continue Numbering", "Nummerierung fortführen"), ("Keep Options…", "Umbruchoptionen …"), @@ -223,6 +249,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Bild auswählen …"), ("Class:", "Klasse:"), ("Color Picker", "Farbwähler"), + ("Color spectrum", "Farbspektrum"), ("Color Settings", "Farbeinstellungen"), ("Color:", "Farbe:"), ("Columns", "Spalten"), @@ -1668,6 +1695,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Objekt/Anpassen"), ("Object/Pathfinder", "Objekt/Pathfinder"), ("Object/Paths", "Objekt/Pfade"), + ("Object/Convert Point", "Objekt/Punkt konvertieren"), ("Object/Select", "Objekt/Auswählen"), ("Object/Transform", "Objekt/Transformieren"), ("Object/Transform Again", "Objekt/Erneut transformieren"), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index b7123a7ae..18937c075 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -60,7 +60,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Content Placer Tool", "Outil Placeur de contenu"), ("Contributors", "Contributeurs"), ("Convert Direction Point Tool", "Outil Conversion de point directeur"), - ("Convert Point", "Convertir le point"), ("Convert Rows", "Convertir les lignes"), ("Copyright Symbol", "Symbole du copyright"), ("Corner Options", "Options d'arrondi"), @@ -105,7 +104,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Em Dash", "Tiret cadratin"), ("Em Space", "Espace cadratin"), ("Email Address:", "Adresse e-mail :"), - ("Email:", "E-mail :"), ("En Dash", "Tiret demi-cadratin"), ("En Space", "Espace demi-cadratin"), ( @@ -168,10 +166,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Cross-Reference", "Insérer une référence croisée"), ("Insert Field", "Insérer un champ"), ("Insert Math Expression", "Insérer une expression mathématique"), - ("Insert Pages", "Insérer des pages"), ("Internet Standard RGB (sRGB)", "RVB standard Internet (sRGB)"), ("Invalid panel layout; restored the default dock", "Disposition des panneaux non valide ; disposition par défaut restaurée"), - ("Join", "Joindre"), ("Join the ArtCraft Discord", "Rejoindre le Discord d'ArtCraft"), ("Join the community", "Rejoindre la communauté"), ("Kerning", "Crénage"), @@ -245,6 +241,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Objet/Ajustement"), ("Object/Pathfinder", "Objet/Pathfinder"), ("Object/Paths", "Objet/Tracés"), + ("Object/Convert Point", "Objet/Convertir le point"), ("Object/Select", "Objet/Sélectionner"), ("Object/Transform", "Objet/Transformation"), ("Object/Transform Again", "Objet/Répéter la transformation"), @@ -254,7 +251,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("PDF import cancelled: the originating document has closed", "Importation PDF annulée : le document d'origine a été fermé"), ("PRs", "PR"), ("Package", "Assemblage"), - ("Packaged {count} files into {dir}", "{count} fichiers assemblés dans {dir}"), ("Page Break", "Saut de page"), ("Page Options", "Options de page"), ("Page Tool", "Outil Page"), @@ -264,7 +260,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Parent Options…", "Options de gabarit…"), ("Pen Tool", "Outil Plume"), ("Pencil Tool", "Outil Crayon"), - ("Phone:", "Téléphone :"), ("Picas", "Picas"), ("Pin Bar Position", "Épingler la position de la barre"), ("Place with Import Options", "Importer avec les options d'importation"), @@ -321,7 +316,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Select Sentence", "Sélectionner la phrase"), ("Select Story", "Sélectionner l'article"), ("Select Text Unit", "Sélectionner une unité de texte"), - ("Selection", "Sélection"), ("Selection Tool", "Outil Sélection"), ("Sent {count} page(s) to {printer}", "{count} page(s) envoyée(s) à {printer}"), ("Sentence case", "Majuscule en début de phrase"), @@ -346,7 +340,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Subscript:", "Indice :"), ("Superscript:", "Exposant :"), ("Swap Text Fill and Stroke", "Permuter le fond et le contour du texte"), - ("Tab", "Tabulation"), ("Table of Contents", "Table des matières"), ("Table/Convert Cell Type", "Tableau/Convertir le type de cellule"), ("Table/Delete", "Tableau/Supprimer"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 885568696..87ffdbadb 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1,6 +1,11 @@ //! Original Italian interface translations, written from the English source //! using ordinary Italian desktop-publishing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Meno opzioni"), + ("Tab Character", "Carattere di tabulazione"), + ("Right Indent Tab", "Tabulazione rientro destro"), + ("Em Space", "Spazio em"), + ("En Space", "Spazio en"), ("File", "File"), ("Edit", "Modifica"), ("Layout", "Layout"), @@ -29,6 +34,103 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Keep In Frame", "Mantieni nella cornice"), + ("New Paragraph Style", "Nuovo stile di paragrafo"), + ("New Character Style", "Nuovo stile di carattere"), + ("Next Style:", "Stile successivo:"), + ("[Same style]", "[Stesso stile]"), + ("Reset To Base", "Ripristina base"), + ("Apply Style to Selection", "Applica stile alla selezione"), + ("Change/Find", "Sostituisci/Trova"), + ("Find Format:", "Trova formato:"), + ("Find Format Settings", "Impostazioni di ricerca formato"), + ("Specify attributes to find", "Specifica gli attributi da trovare"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Con un formato di ricerca si trova solo il testo che lo ha; con «Trova» vuoto, tutto il testo che lo ha", + ), + ("Change Format:", "Modifica formato:"), + ("Change Format Settings", "Impostazioni di modifica formato"), + ("Style Options", "Opzioni stile"), + ("[Any Style]", "[Qualsiasi stile]"), + ("Specify attributes to change", "Specifica gli attributi da modificare"), + ("Clear specified attributes", "Cancella gli attributi specificati"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Con un formato di modifica e «Sostituisci con» vuoto, il testo trovato resta e cambia solo la formattazione", + ), + ("Double Column", "Colonna doppia"), + ("Tools Panel:", "Pannello Strumenti:"), + ("Color Theme:", "Tema colore:"), + ("At End of Document", "Alla fine del documento"), + ("At Start of Document", "All'inizio del documento"), + ("Before Page", "Prima della pagina"), + ("After Page", "Dopo la pagina"), + ("Parent:", "Pagina mastro:"), + ("Insert:", "Inserisci:"), + ("Insert Pages", "Inserisci pagine"), + ("Stroke Type", "Tipo traccia"), + ("Corner Shape", "Forma angolo"), + ("Corner Size and Shape:", "Dimensione e forma angoli:"), + ("Join", "Unisci"), + ("Convert Point", "Converti punto"), + ("New Section", "Nuova sezione"), + ("Clear Frame Fitting Options", "Cancella opzioni di adattamento cornice"), + ("Cannot be edited", "Non modificabile"), + ("Maximum:", "Massimo:"), + ("Paragraph Spacing Limit:", "Limite spaziatura paragrafo:"), + ("Overprint", "Sovrastampa"), + ("Top Left Corner", "Angolo superiore sinistro"), + ("Top Right Corner", "Angolo superiore destro"), + ("Bottom Left Corner", "Angolo inferiore sinistro"), + ("Bottom Right Corner", "Angolo inferiore destro"), + ("Anchor Point(s)", "Punti di ancoraggio"), + ("Convert Points", "Converti punti"), + ("Delete Point(s)", "Elimina punti"), + ("Tab", "Tabulazione"), + ("Convert Text to Table…", "Converti testo in tabella…"), + ("Convert Table to Text…", "Converti tabella in testo…"), + ("Column Separator", "Separatore colonna"), + ("Row Separator", "Separatore riga"), + ("Number of Columns", "Numero di colonne"), + ("Comma", "Virgola"), + ("RGB Color Space View", "Vista spazio colore RGB"), + ("Lab Color Space View", "Vista spazio colore Lab"), + ("Add CMYK Swatch", "Aggiungi campione CMYK"), + ("Added swatch", "Campione aggiunto"), + ("Eyedropper: drag it onto the page", "Contagocce: trascinalo sulla pagina"), + ("PDF Preset:", "Predefinito PDF:"), + ("Packaged {count} files into {dir}", "{count} file raccolti in {dir}"), + ("Continue", "Continua"), + ("Printing Instructions", "Istruzioni di stampa"), + ("Package", "Pacchetto"), + ("Show Problems Only", "Mostra solo problemi"), + ("Include PDF (Print)", "Includi PDF (stampa)"), + ("Include IDML", "Includi IDML"), + ("Create Printing Instructions", "Crea istruzioni di stampa"), + ("Entire Publication", "Intera pubblicazione"), + ("Scope:", "Ambito:"), + ("Source", "Origine"), + ("Installed", "Installato"), + ("Bundled", "Incluso"), + ("Substituted", "Sostituito"), + ("Modified", "Modificato"), + ("Missing", "Mancante"), + ("Instructions:", "Istruzioni:"), + ("Email:", "E-mail:"), + ("Fax:", "Fax:"), + ("Phone:", "Telefono:"), + ("Address:", "Indirizzo:"), + ("Company:", "Società:"), + ("Contact:", "Contatto:"), + ("Process Black", "Nero quadricromia"), + ("Process Yellow", "Giallo quadricromia"), + ("Process Magenta", "Magenta quadricromia"), + ("Process Cyan", "Cyan quadricromia"), + ("Colors and Inks", "Colori e inchiostri"), + ("Links and Images", "Collegamenti e immagini"), + ("Fonts", "Font"), + ("Summary", "Riepilogo"), ("Restart Numbering", "Riavvia numerazione"), ("Continue Numbering", "Continua numerazione"), ("Keep Options…", "Opzioni di separazione…"), @@ -305,6 +407,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Scegli immagine…"), ("Class:", "Classe:"), ("Color Picker", "Selettore colore"), + ("Color spectrum", "Spettro dei colori"), ("Color Settings", "Impostazioni colore"), ("Color:", "Colore:"), ("Columns", "Colonne"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index f06f85725..641fe64b6 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -3,6 +3,8 @@ //! Portuguese tables has a row here; the menu titles and panel names that were already Japanese in the //! shared table are carried over unchanged. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "オプションを減らす"), + ("Tab Character", "タブ文字"), ("File", "ファイル"), ("Edit", "編集"), ("Layout", "レイアウト"), @@ -30,6 +32,28 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Stroke Type", "線の種類"), + ("Corner Shape", "角のシェイプ"), + ("Corner Size and Shape:", "角のサイズとシェイプ:"), + ("Join", "連結"), + ("Convert Point", "ポイントを切り換え"), + ("New Section", "新規セクション"), + ("Clear Frame Fitting Options", "フレーム調整オプションを消去"), + ("Cannot be edited", "編集不可"), + ("Overprint", "オーバープリント"), + ("Top Left Corner", "左上角"), + ("Top Right Corner", "右上角"), + ("Bottom Left Corner", "左下角"), + ("Bottom Right Corner", "右下角"), + ("Anchor Point(s)", "アンカーポイント"), + ("Convert Points", "ポイントを変換"), + ("Delete Point(s)", "ポイントを削除"), + ("Convert Text to Table…", "テキストを表に変換…"), + ("Convert Table to Text…", "表をテキストに変換…"), + ("Column Separator", "列分解文字"), + ("Row Separator", "行分解文字"), + ("Number of Columns", "列数"), + ("Comma", "カンマ"), ("Restart Numbering", "番号の振り直し"), ("Continue Numbering", "番号の継続"), ("Keep Options…", "段落分離禁止オプション…"), @@ -408,6 +432,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "画像を選択…"), ("Class:", "クラス:"), ("Color Picker", "カラーピッカー"), + ("Color spectrum", "カラースペクトル"), ("Color Settings", "カラー設定"), ("Color:", "カラー:"), ("Columns", "列数"), @@ -1872,6 +1897,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "オブジェクト/オブジェクトサイズの調整"), ("Object/Pathfinder", "オブジェクト/パスファインダー"), ("Object/Paths", "オブジェクト/パス"), + ("Object/Convert Point", "オブジェクト/ポイントを切り換え"), ("Object/Select", "オブジェクト/選択"), ("Object/Transform", "オブジェクト/変形"), ("Object/Transform Again", "オブジェクト/変形を再適用"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 14a9e6203..a2581ab2c 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -4,6 +4,8 @@ //! Portuguese, Japanese, Italian and Ukrainian tables has a row here, plus a few labels that only //! the code or the command table use. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "ნაკლები პარამეტრი"), + ("Tab Character", "ტაბულაციის სიმბოლო"), ("File", "ფაილი"), ("Edit", "რედაქტირება"), ("Layout", "განლაგება"), @@ -32,6 +34,97 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Keep In Frame", "ჩარჩოში შენარჩუნება"), + ("New Paragraph Style", "აბზაცის ახალი სტილი"), + ("New Character Style", "სიმბოლოს ახალი სტილი"), + ("Next Style:", "შემდეგი სტილი:"), + ("[Same style]", "[იგივე სტილი]"), + ("Reset To Base", "საბაზოზე დაბრუნება"), + ("Apply Style to Selection", "სტილის გამოყენება მონიშნულზე"), + ("Change/Find", "ჩანაცვლება/ძიება"), + ("Find Format:", "ფორმატის ძიება:"), + ("Find Format Settings", "ფორმატის ძიების პარამეტრები"), + ("Specify attributes to find", "საძიებო ატრიბუტების მითითება"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "ძიების ფორმატით მხოლოდ ის ტექსტი მოიძებნება, რომელსაც ის აქვს; ცარიელი „ძიება“ ველით — ყველა ასეთი ტექსტი", + ), + ("Change Format:", "ფორმატის შეცვლა:"), + ("Change Format Settings", "ფორმატის შეცვლის პარამეტრები"), + ("Style Options", "სტილის პარამეტრები"), + ("[Any Style]", "[ნებისმიერი სტილი]"), + ("Specify attributes to change", "შესაცვლელი ატრიბუტების მითითება"), + ("Clear specified attributes", "მითითებული ატრიბუტების გასუფთავება"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "ფორმატის შეცვლით და ცარიელი „ჩანაცვლება“ ველით ნაპოვნი ტექსტი რჩება და მხოლოდ ფორმატი იცვლება", + ), + ("Double Column", "ორმაგი სვეტი"), + ("Tools Panel:", "ხელსაწყოების პანელი:"), + ("Color Theme:", "ფერთა თემა:"), + ("At End of Document", "დოკუმენტის ბოლოს"), + ("At Start of Document", "დოკუმენტის დასაწყისში"), + ("Before Page", "გვერდის წინ"), + ("After Page", "გვერდის შემდეგ"), + ("Parent:", "საბაზო გვერდი:"), + ("Insert:", "ჩასმა:"), + ("Stroke Type", "კონტურის ტიპი"), + ("Corner Shape", "კუთხის ფორმა"), + ("Corner Size and Shape:", "კუთხეების ზომა და ფორმა:"), + ("New Section", "ახალი სექცია"), + ("Clear Frame Fitting Options", "ჩარჩოს მორგების პარამეტრების გასუფთავება"), + ("Cannot be edited", "რედაქტირება შეუძლებელია"), + ("Maximum:", "მაქსიმუმი:"), + ("Paragraph Spacing Limit:", "აბზაცებს შორის ინტერვალის ზღვარი:"), + ("Overprint", "ზედაბეჭდვა"), + ("Top Left Corner", "ზედა მარცხენა კუთხე"), + ("Top Right Corner", "ზედა მარჯვენა კუთხე"), + ("Bottom Left Corner", "ქვედა მარცხენა კუთხე"), + ("Bottom Right Corner", "ქვედა მარჯვენა კუთხე"), + ("Anchor Point(s)", "საყრდენი წერტილ(ებ)ი"), + ("Convert Points", "წერტილების გარდაქმნა"), + ("Delete Point(s)", "წერტილ(ებ)ის წაშლა"), + ("Convert Text to Table…", "ტექსტის ცხრილად გარდაქმნა…"), + ("Convert Table to Text…", "ცხრილის ტექსტად გარდაქმნა…"), + ("Column Separator", "სვეტების გამყოფი"), + ("Row Separator", "სტრიქონების გამყოფი"), + ("Number of Columns", "სვეტების რაოდენობა"), + ("Comma", "მძიმე"), + ("RGB Color Space View", "RGB ფერთა სივრცის ხედი"), + ("Lab Color Space View", "Lab ფერთა სივრცის ხედი"), + ("Add CMYK Swatch", "CMYK ფერის ნიმუშის დამატება"), + ("Added swatch", "ფერის ნიმუში დამატებულია"), + ("Eyedropper: drag it onto the page", "პიპეტი: გადაიტანეთ გვერდზე"), + ("PDF Preset:", "PDF-ის წინასწარი პარამეტრი:"), + ("Continue", "გაგრძელება"), + ("Printing Instructions", "ბეჭდვის ინსტრუქციები"), + ("Show Problems Only", "მხოლოდ პრობლემების ჩვენება"), + ("Include PDF (Print)", "PDF-ის ჩართვა (ბეჭდვა)"), + ("Include IDML", "IDML-ის ჩართვა"), + ("Create Printing Instructions", "ბეჭდვის ინსტრუქციების შექმნა"), + ("Entire Publication", "მთელი პუბლიკაცია"), + ("Scope:", "არეალი:"), + ("Source", "წყარო"), + ("Installed", "დაყენებული"), + ("Bundled", "ჩაშენებული"), + ("Substituted", "ჩანაცვლებული"), + ("Modified", "შეცვლილი"), + ("Missing", "აკლია"), + ("Colors and Inks: {process} process, {spot} spot", "ფერები და მელნები: {process} პროცესის მელანი; {spot} სპოტ-საღებავი"), + ("Embedded images: {embedded}", "გამოსახულებები: ჩაშენებულია {embedded}"), + ("Instructions:", "ინსტრუქციები:"), + ("Fax:", "ფაქსი:"), + ("Address:", "მისამართი:"), + ("Company:", "კომპანია:"), + ("Contact:", "საკონტაქტო პირი:"), + ("Process Black", "პროცესის შავი"), + ("Process Yellow", "პროცესის ყვითელი"), + ("Process Magenta", "პროცესის მეწამული"), + ("Process Cyan", "პროცესის ცისფერი"), + ("Colors and Inks", "ფერები და მელნები"), + ("Links and Images", "ბმულები და გამოსახულებები"), + ("Fonts", "შრიფტები"), + ("Summary", "შეჯამება"), ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), ("Continue Numbering", "ნუმერაციის გაგრძელება"), ("Keep Options…", "შენარჩუნების პარამეტრები…"), @@ -227,6 +320,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "გამოსახულების არჩევა…"), ("Class:", "კლასი:"), ("Color Picker", "ფერის ამრჩევი"), + ("Color spectrum", "ფერთა სპექტრი"), ("Color Settings", "ფერის პარამეტრები"), ("Color:", "ფერი:"), ("Columns", "სვეტები"), @@ -1703,6 +1797,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "ობიექტი/ზომაზე მორგება"), ("Object/Pathfinder", "ობიექტი/ტრაექტორიების ოპერაციები"), ("Object/Paths", "ობიექტი/ტრაექტორიები"), + ("Object/Convert Point", "ობიექტი/წერტილის გარდაქმნა"), ("Object/Select", "ობიექტი/მონიშვნა"), ("Object/Transform", "ობიექტი/გარდაქმნა"), ("Object/Transform Again", "ობიექტი/გარდაქმნის გამეორება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index a5eee9e94..fcdb3718e 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -2,6 +2,8 @@ //! are welcome. Terminology follows common Polish DTP usage //! (rozkładówka, łam, strona wzorcowa, obszar informacyjny) and the sibling ArtCraft catalogs. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Mniej opcji"), + ("Tab Character", "Znak tabulacji"), ("File", "Plik"), ("Edit", "Edycja"), ("Layout", "Układ"), @@ -30,6 +32,106 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Keep In Frame", "Zachowaj w ramce"), + ("Gap Tint:", "Odcień odstępu:"), + ("New Paragraph Style", "Nowy styl akapitowy"), + ("New Character Style", "Nowy styl znakowy"), + ("Next Style:", "Następny styl:"), + ("[Same style]", "[Ten sam styl]"), + ("Reset To Base", "Przywróć bazowy"), + ("Apply Style to Selection", "Zastosuj styl do zaznaczenia"), + ("Change/Find", "Zmień/Znajdź"), + ("Find Format:", "Znajdź format:"), + ("Find Format Settings", "Ustawienia wyszukiwania formatu"), + ("Specify attributes to find", "Określ atrybuty do znalezienia"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Przy formacie wyszukiwania znajdowany jest tylko tekst, który go ma; przy pustym polu „Znajdź” — cały taki tekst", + ), + ("Change Format:", "Zmień format:"), + ("Change Format Settings", "Ustawienia zmiany formatu"), + ("Style Options", "Opcje stylu"), + ("[Any Style]", "[Dowolny styl]"), + ("Specify attributes to change", "Określ atrybuty do zmiany"), + ("Clear specified attributes", "Wyczyść określone atrybuty"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Przy zmianie formatu i pustym polu „Zmień na” znaleziony tekst zostaje, a zmienia się tylko jego format", + ), + ("Double Column", "Podwójna kolumna"), + ("Single Column", "Pojedyncza kolumna"), + ("Tools Panel:", "Panel narzędzi:"), + ("Color Theme:", "Motyw kolorów:"), + ("At End of Document", "Na końcu dokumentu"), + ("At Start of Document", "Na początku dokumentu"), + ("Before Page", "Przed stroną"), + ("After Page", "Po stronie"), + ("Parent:", "Strona wzorcowa:"), + ("Insert:", "Wstaw:"), + ("Stroke Type", "Rodzaj obrysu"), + ("Corner Shape", "Kształt narożnika"), + ("Corner Size and Shape:", "Rozmiar i kształt narożników:"), + ("Join", "Połącz"), + ("Convert Point", "Konwertuj punkt"), + ("Plain", "Zwykły"), + ("Symmetrical", "Symetryczny"), + ("New Section", "Nowa sekcja"), + ("Clear Frame Fitting Options", "Wyczyść opcje dopasowania ramki"), + ("Cannot be edited", "Nie można edytować"), + ("Maximum:", "Maksimum:"), + ("Paragraph Spacing Limit:", "Limit odstępu akapitów:"), + ("Overprint", "Nadruk"), + ("Top Left Corner", "Lewy górny narożnik"), + ("Top Right Corner", "Prawy górny narożnik"), + ("Bottom Left Corner", "Lewy dolny narożnik"), + ("Bottom Right Corner", "Prawy dolny narożnik"), + ("Anchor Point(s)", "Punkty kontrolne"), + ("Convert Points", "Konwertuj punkty"), + ("Delete Point(s)", "Usuń punkty"), + ("Convert Text to Table…", "Zamień tekst na tabelę…"), + ("Convert Table to Text…", "Zamień tabelę na tekst…"), + ("Column Separator", "Separator kolumn"), + ("Row Separator", "Separator wierszy"), + ("Number of Columns", "Liczba kolumn"), + ("Comma", "Przecinek"), + ("RGB Color Space View", "Widok przestrzeni barw RGB"), + ("Lab Color Space View", "Widok przestrzeni barw Lab"), + ("Add CMYK Swatch", "Dodaj próbkę CMYK"), + ("Added swatch", "Dodano próbkę"), + ("Eyedropper: drag it onto the page", "Kroplomierz: przeciągnij go na stronę"), + ("PDF Preset:", "Ustawienie predefiniowane PDF:"), + ("Packaged {count} files into {dir}", "Spakowano {count} plików do {dir}"), + ("Continue", "Kontynuuj"), + ("Printing Instructions", "Instrukcje drukowania"), + ("Show Problems Only", "Pokaż tylko problemy"), + ("Include PDF (Print)", "Dołącz PDF (druk)"), + ("Include IDML", "Dołącz IDML"), + ("Create Printing Instructions", "Utwórz instrukcje drukowania"), + ("Entire Publication", "Cała publikacja"), + ("Scope:", "Zakres:"), + ("Spot", "Dodatkowy"), + ("Process", "Procesowy"), + ("Source", "Źródło"), + ("Installed", "Zainstalowana"), + ("Bundled", "Dołączona"), + ("Substituted", "Zastąpiona"), + ("Modified", "Zmieniony"), + ("Missing", "Brak"), + ("Instructions:", "Instrukcje:"), + ("Email:", "E-mail:"), + ("Fax:", "Faks:"), + ("Phone:", "Telefon:"), + ("Address:", "Adres:"), + ("Company:", "Firma:"), + ("Contact:", "Kontakt:"), + ("Process Black", "Czarny procesowy"), + ("Process Yellow", "Żółty procesowy"), + ("Process Magenta", "Magenta procesowa"), + ("Process Cyan", "Cyjan procesowy"), + ("Colors and Inks", "Kolory i farby"), + ("Links and Images", "Łącza i obrazy"), + ("Fonts", "Czcionki"), + ("Summary", "Podsumowanie"), ("Restart Numbering", "Zacznij numerowanie od nowa"), ("Continue Numbering", "Kontynuuj numerowanie"), ("Keep Options…", "Opcje utrzymywania…"), @@ -194,6 +296,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Wybierz obraz…"), ("Class:", "Klasa:"), ("Color Picker", "Próbnik kolorów"), + ("Color spectrum", "Spektrum kolorów"), ("Color Settings", "Ustawienia kolorów"), ("Color:", "Kolor:"), ("Columns", "Łamy"), @@ -1634,6 +1737,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Obiekt/Dopasowanie"), ("Object/Pathfinder", "Obiekt/Kształtowanie ścieżek"), ("Object/Paths", "Obiekt/Ścieżki"), + ("Object/Convert Point", "Obiekt/Konwertuj punkt"), ("Object/Select", "Obiekt/Zaznacz"), ("Object/Transform", "Obiekt/Przekształć"), ("Object/Transform Again", "Obiekt/Przekształć ponownie"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 688ae2339..1562ecc6f 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1,6 +1,11 @@ //! Original Brazilian Portuguese interface translations, written from the English source //! using ordinary Brazilian Portuguese computing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Menos opções"), + ("Tab Character", "Caractere de tabulação"), + ("Right Indent Tab", "Tabulação de recuo à direita"), + ("Em Space", "Espaço eme"), + ("En Space", "Espaço ene"), ("File", "Arquivo"), ("Edit", "Editar"), ("Layout", "Layout"), @@ -32,6 +37,103 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Keep In Frame", "Manter no quadro"), + ("New Paragraph Style", "Novo estilo de parágrafo"), + ("New Character Style", "Novo estilo de caractere"), + ("Next Style:", "Próximo estilo:"), + ("[Same style]", "[Mesmo estilo]"), + ("Reset To Base", "Redefinir para base"), + ("Apply Style to Selection", "Aplicar estilo à seleção"), + ("Change/Find", "Alterar/Localizar"), + ("Find Format:", "Localizar formato:"), + ("Find Format Settings", "Configurações de localização de formato"), + ("Specify attributes to find", "Especificar atributos a localizar"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Com um formato de localização, só o texto que o tem é encontrado; com “Localizar” vazio, todo o texto que o tem", + ), + ("Change Format:", "Alterar formato:"), + ("Change Format Settings", "Configurações de alteração de formato"), + ("Style Options", "Opções de estilo"), + ("[Any Style]", "[Qualquer estilo]"), + ("Specify attributes to change", "Especificar atributos a alterar"), + ("Clear specified attributes", "Limpar atributos especificados"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Com um formato de alteração e “Alterar para” vazio, o texto encontrado permanece e só a formatação muda", + ), + ("Double Column", "Coluna dupla"), + ("Tools Panel:", "Painel Ferramentas:"), + ("Color Theme:", "Tema de cores:"), + ("At End of Document", "No final do documento"), + ("At Start of Document", "No início do documento"), + ("Before Page", "Antes da página"), + ("After Page", "Após a página"), + ("Parent:", "Página principal:"), + ("Insert:", "Inserir:"), + ("Insert Pages", "Inserir páginas"), + ("Stroke Type", "Tipo de traçado"), + ("Corner Shape", "Forma do canto"), + ("Corner Size and Shape:", "Tamanho e forma dos cantos:"), + ("Join", "Unir"), + ("Convert Point", "Converter ponto"), + ("New Section", "Nova seção"), + ("Clear Frame Fitting Options", "Limpar opções de ajuste do quadro"), + ("Cannot be edited", "Não editável"), + ("Maximum:", "Máximo:"), + ("Paragraph Spacing Limit:", "Limite de espaçamento de parágrafo:"), + ("Overprint", "Superimprimir"), + ("Top Left Corner", "Canto superior esquerdo"), + ("Top Right Corner", "Canto superior direito"), + ("Bottom Left Corner", "Canto inferior esquerdo"), + ("Bottom Right Corner", "Canto inferior direito"), + ("Anchor Point(s)", "Ponto(s) de ancoragem"), + ("Convert Points", "Converter pontos"), + ("Delete Point(s)", "Excluir ponto(s)"), + ("Tab", "Tabulação"), + ("Convert Text to Table…", "Converter texto em tabela…"), + ("Convert Table to Text…", "Converter tabela em texto…"), + ("Column Separator", "Separador de coluna"), + ("Row Separator", "Separador de linha"), + ("Number of Columns", "Número de colunas"), + ("Comma", "Vírgula"), + ("RGB Color Space View", "Exibição do espaço de cores RGB"), + ("Lab Color Space View", "Exibição do espaço de cores Lab"), + ("Add CMYK Swatch", "Adicionar amostra CMYK"), + ("Added swatch", "Amostra adicionada"), + ("Eyedropper: drag it onto the page", "Conta-gotas: arraste-o para a página"), + ("PDF Preset:", "Predefinição de PDF:"), + ("Packaged {count} files into {dir}", "{count} arquivos empacotados em {dir}"), + ("Continue", "Continuar"), + ("Printing Instructions", "Instruções de impressão"), + ("Package", "Empacotar"), + ("Show Problems Only", "Mostrar apenas problemas"), + ("Include PDF (Print)", "Incluir PDF (impressão)"), + ("Include IDML", "Incluir IDML"), + ("Create Printing Instructions", "Criar instruções de impressão"), + ("Entire Publication", "Publicação inteira"), + ("Scope:", "Escopo:"), + ("Source", "Origem"), + ("Installed", "Instalada"), + ("Bundled", "Incluída"), + ("Substituted", "Substituída"), + ("Modified", "Modificado"), + ("Missing", "Ausente"), + ("Instructions:", "Instruções:"), + ("Email:", "E-mail:"), + ("Fax:", "Fax:"), + ("Phone:", "Telefone:"), + ("Address:", "Endereço:"), + ("Company:", "Empresa:"), + ("Contact:", "Contato:"), + ("Process Black", "Preto de escala"), + ("Process Yellow", "Amarelo de escala"), + ("Process Magenta", "Magenta de escala"), + ("Process Cyan", "Ciano de escala"), + ("Colors and Inks", "Cores e tintas"), + ("Links and Images", "Vínculos e imagens"), + ("Fonts", "Fontes"), + ("Summary", "Resumo"), ("Restart Numbering", "Reiniciar numeração"), ("Continue Numbering", "Continuar numeração"), ("Keep Options…", "Opções de manutenção…"), @@ -399,6 +501,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object Layer Options", "Opções de camada do objeto"), ("Colour Picker", "Seletor de cores"), ("Color Picker", "Seletor de cores"), + ("Color spectrum", "Espectro de cores"), ("Color Settings", "Configurações de cores"), ("User Dictionary", "Dicionário do usuário"), ("Menu Customization", "Personalização de menus"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index c37e44bcb..8b933fe60 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -3,6 +3,8 @@ //! The translations are our own; counted nouns are genitive plurals because the caption puts the //! category before the number ("страниц: 5"), see `count_label`. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Меньше параметров"), + ("Tab Character", "Символ табуляции"), ("File", "Файл"), ("Edit", "Редактирование"), ("Can't Open the File", "Не удалось открыть файл"), @@ -34,6 +36,106 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Keep In Frame", "Сохранить во фрейме"), + ("Gap Tint:", "Оттенок пробелов:"), + ("New Paragraph Style", "Новый стиль абзаца"), + ("New Character Style", "Новый стиль символов"), + ("Next Style:", "Следующий стиль:"), + ("[Same style]", "[Тот же стиль]"), + ("Reset To Base", "Сбросить до основы"), + ("Apply Style to Selection", "Применить стиль к выделенному"), + ("Change/Find", "Заменить/Найти"), + ("Find Format:", "Найти формат:"), + ("Find Format Settings", "Параметры поиска формата"), + ("Specify attributes to find", "Задать атрибуты для поиска"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "С форматом поиска находится только текст с этим форматом; если поле «Найти» пусто — весь такой текст", + ), + ("Change Format:", "Заменить формат:"), + ("Change Format Settings", "Параметры замены формата"), + ("Style Options", "Параметры стиля"), + ("[Any Style]", "[Любой стиль]"), + ("Specify attributes to change", "Задать атрибуты для замены"), + ("Clear specified attributes", "Очистить заданные атрибуты"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Если задан формат замены, а поле «Заменить на» пусто, найденный текст остаётся, меняется только его формат", + ), + ("Double Column", "Две колонки"), + ("Single Column", "Одна колонка"), + ("Tools Panel:", "Панель инструментов:"), + ("Color Theme:", "Цветовая тема:"), + ("At End of Document", "В конец документа"), + ("At Start of Document", "В начало документа"), + ("Before Page", "Перед страницей"), + ("After Page", "После страницы"), + ("Parent:", "Шаблон:"), + ("Insert:", "Вставить:"), + ("Stroke Type", "Тип обводки"), + ("Corner Shape", "Форма угла"), + ("Corner Size and Shape:", "Размер и форма углов:"), + ("Join", "Соединить"), + ("Convert Point", "Преобразовать точку"), + ("Plain", "Простая"), + ("Symmetrical", "Симметричная"), + ("New Section", "Новый раздел"), + ("Clear Frame Fitting Options", "Очистить параметры подгонки фрейма"), + ("Cannot be edited", "Нельзя изменить"), + ("Maximum:", "Максимум:"), + ("Paragraph Spacing Limit:", "Предел интервала абзацев:"), + ("Overprint", "Наложение"), + ("Top Left Corner", "Левый верхний угол"), + ("Top Right Corner", "Правый верхний угол"), + ("Bottom Left Corner", "Левый нижний угол"), + ("Bottom Right Corner", "Правый нижний угол"), + ("Anchor Point(s)", "Опорные точки"), + ("Convert Points", "Преобразовать точки"), + ("Delete Point(s)", "Удалить точки"), + ("Convert Text to Table…", "Преобразовать текст в таблицу…"), + ("Convert Table to Text…", "Преобразовать таблицу в текст…"), + ("Column Separator", "Разделитель столбцов"), + ("Row Separator", "Разделитель строк"), + ("Number of Columns", "Число столбцов"), + ("Comma", "Запятая"), + ("RGB Color Space View", "Просмотр цветового пространства RGB"), + ("Lab Color Space View", "Просмотр цветового пространства Lab"), + ("Add CMYK Swatch", "Добавить образец CMYK"), + ("Added swatch", "Образец добавлен"), + ("Eyedropper: drag it onto the page", "Пипетка: перетащите её на страницу"), + ("PDF Preset:", "Стиль PDF:"), + ("Packaged {count} files into {dir}", "Упаковано файлов: {count} в {dir}"), + ("Continue", "Продолжить"), + ("Printing Instructions", "Инструкции для печати"), + ("Show Problems Only", "Показывать только проблемы"), + ("Include PDF (Print)", "Включить PDF (печать)"), + ("Include IDML", "Включить IDML"), + ("Create Printing Instructions", "Создать инструкции для печати"), + ("Entire Publication", "Вся публикация"), + ("Scope:", "Область:"), + ("Spot", "Плашечный"), + ("Process", "Триадный"), + ("Source", "Источник"), + ("Installed", "Установлен"), + ("Bundled", "Встроенный"), + ("Substituted", "Заменен"), + ("Modified", "Изменено"), + ("Missing", "Отсутствует"), + ("Instructions:", "Инструкции:"), + ("Email:", "Эл. почта:"), + ("Fax:", "Факс:"), + ("Phone:", "Телефон:"), + ("Address:", "Адрес:"), + ("Company:", "Компания:"), + ("Contact:", "Контакт:"), + ("Process Black", "Триадный черный"), + ("Process Yellow", "Триадный желтый"), + ("Process Magenta", "Триадный пурпурный"), + ("Process Cyan", "Триадный голубой"), + ("Colors and Inks", "Цвета и краски"), + ("Links and Images", "Связи и изображения"), + ("Fonts", "Шрифты"), + ("Summary", "Сводка"), ("Restart Numbering", "Начать нумерацию заново"), ("Continue Numbering", "Продолжить нумерацию"), ("Keep Options…", "Параметры удержания…"), @@ -194,6 +296,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Выбрать изображение…"), ("Class:", "Класс:"), ("Color Picker", "Палитра цветов"), + ("Color spectrum", "Цветовой спектр"), ("Color Settings", "Настройка цветов"), ("Color:", "Цвет:"), ("Columns", "Колонки"), @@ -1638,6 +1741,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Объект/Подгонка"), ("Object/Pathfinder", "Объект/Обработка контуров"), ("Object/Paths", "Объект/Контуры"), + ("Object/Convert Point", "Объект/Преобразовать точку"), ("Object/Select", "Объект/Выбрать"), ("Object/Transform", "Объект/Трансформирование"), ("Object/Transform Again", "Объект/Повторить трансформирование"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index fc1618bf2..2d4688558 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -1,5 +1,42 @@ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. pub(super) const TABLE: &[(&str, [&str; 5])] = &[ + ("Keep In Frame", ["Im Rahmen behalten", "Conserver dans le bloc", "Mantener en el marco", "フレーム内に保持", "保持在框架内"]), + ("Next Style:", ["Nächstes Format:", "Style suivant :", "Estilo siguiente:", "次のスタイル :", "下一样式:"]), + ("[Same style]", ["[Gleiches Format]", "[Même style]", "[Mismo estilo]", "[同じスタイル]", "[相同样式]"]), + ("Reset To Base", ["Auf Basis zurücksetzen", "Rétablir la base", "Restablecer a base", "基準に戻す", "重置为基准"]), + ("New Paragraph Style", ["Neues Absatzformat", "Nouveau style de paragraphe", "Nuevo estilo de párrafo", "新規段落スタイル", "新建段落样式"]), + ("New Character Style", ["Neues Zeichenformat", "Nouveau style de caractère", "Nuevo estilo de carácter", "新規文字スタイル", "新建字符样式"]), + ( + "Apply Style to Selection", + [ + "Format auf Auswahl anwenden", + "Appliquer le style à la sélection", + "Aplicar estilo a la selección", + "選択範囲にスタイルを適用", + "将样式应用于选区", + ], + ), + ( + "Clear Frame Fitting Options", + [ + "Rahmeneinpassungsoptionen löschen", + "Effacer les options d’ajustement du bloc", + "Borrar opciones de encaje de marco", + "フレーム調整オプションを消去", + "清除框架适合选项", + ], + ), + ("Maximum:", ["Maximum:", "Maximum :", "Máximo:", "最大 :", "最大值:"]), + ( + "Paragraph Spacing Limit:", + [ + "Abstandsgrenze für Absätze:", + "Limite d'espacement des paragraphes :", + "Límite de espaciado de párrafo:", + "段落スペース最大値 :", + "段落间距限制:", + ], + ), ( "Character Formatting Controls", [ @@ -115,12 +152,66 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("File Info…", ["Dateiinformationen …", "Informations sur le fichier…", "Información de archivo…", "ファイル情報…", "文件信息…"]), ("File Info", ["Dateiinformationen", "Informations sur le fichier", "Información de archivo", "ファイル情報", "文件信息"]), ("Package…", ["Verpacken …", "Assemblage…", "Empaquetar…", "パッケージ…", "打包…"]), + ("Summary", ["Übersicht", "Résumé", "Resumen", "概要", "摘要"]), + ("Fonts", ["Schriften", "Polices", "Fuentes", "フォント", "字体"]), + ("Links and Images", ["Verknüpfungen und Bilder", "Liens et images", "Vínculos e imágenes", "リンクと画像", "链接和图像"]), + ("Colors and Inks", ["Farben und Druckfarben", "Couleurs et encres", "Colores y tintas", "カラーとインキ", "颜色和油墨"]), + ("Process Cyan", ["Prozess-Cyan", "Cyan quadri", "Cian de cuatricromía", "プロセスシアン", "印刷青色"]), + ("Process Magenta", ["Prozess-Magenta", "Magenta quadri", "Magenta de cuatricromía", "プロセスマゼンタ", "印刷洋红色"]), + ("Process Yellow", ["Prozess-Gelb", "Jaune quadri", "Amarillo de cuatricromía", "プロセスイエロー", "印刷黄色"]), + ("Process Black", ["Prozess-Schwarz", "Noir quadri", "Negro de cuatricromía", "プロセスブラック", "印刷黑色"]), + ("Contact:", ["Kontakt:", "Contact :", "Contacto:", "連絡先:", "联系人:"]), + ("Company:", ["Firma:", "Société :", "Empresa:", "会社名:", "公司:"]), + ("Address:", ["Adresse:", "Adresse :", "Dirección:", "住所:", "地址:"]), + ("Phone:", ["Telefon:", "Téléphone :", "Teléfono:", "電話:", "电话:"]), + ("Fax:", ["Fax:", "Fax :", "Fax:", "FAX:", "传真:"]), + ("Email:", ["E-Mail:", "E-mail :", "Correo electrónico:", "メール:", "电子邮件:"]), + ("Instructions:", ["Anweisungen:", "Instructions :", "Instrucciones:", "指示:", "说明:"]), + ("Missing", ["Fehlt", "Manquant", "Ausente", "見つかりません", "缺失"]), + ("Modified", ["Geändert", "Modifié", "Modificado", "変更", "已修改"]), + ("Substituted", ["Ersetzt", "Remplacée", "Sustituida", "代替", "已替换"]), + ("Bundled", ["Mitgeliefert", "Fournie", "Incluida", "同梱", "内置"]), + ("Installed", ["Installiert", "Installée", "Instalada", "インストール済み", "已安装"]), + ("Source", ["Quelle", "Source", "Origen", "ソース", "来源"]), + ("Scope:", ["Bereich:", "Étendue :", "Ámbito:", "範囲:", "范围:"]), + ("Entire Publication", ["Gesamte Publikation", "Publication entière", "Toda la publicación", "出版物全体", "整个出版物"]), + ( + "Create Printing Instructions", + ["Druckanweisungen erstellen", "Créer des instructions d’impression", "Crear instrucciones de impresión", "印刷指示を作成", "创建打印说明"], + ), + ("Include IDML", ["IDML einschließen", "Inclure l’IDML", "Incluir IDML", "IDML を含める", "包括 IDML"]), + ( + "Include PDF (Print)", + ["PDF einschließen (Druck)", "Inclure le PDF (impression)", "Incluir PDF (impresión)", "PDF を含める(印刷)", "包括 PDF(打印)"], + ), + ("PDF Preset:", ["PDF-Vorgabe:", "Paramètre prédéfini PDF :", "Ajuste preestablecido de PDF:", "PDF プリセット:", "PDF 预设:"]), + ("Show Problems Only", ["Nur Probleme anzeigen", "Afficher uniquement les problèmes", "Mostrar solo problemas", "問題のみ表示", "仅显示问题"]), + ("Printing Instructions", ["Druckanweisungen", "Instructions d’impression", "Instrucciones de impresión", "印刷指示", "打印说明"]), + ("Continue", ["Weiter", "Continuer", "Continuar", "続行", "继续"]), + ( + "Packaged {count} files into {dir}", + [ + "{count} Dateien in {dir} verpackt", + "{count} fichiers assemblés dans {dir}", + "{count} archivos empaquetados en {dir}", + "{count} 個のファイルを {dir} にパッケージしました", + "已将 {count} 个文件打包到 {dir}", + ], + ), // Edit. ("Undo", ["Rückgängig", "Annuler", "Deshacer", "取り消し", "还原"]), ("Redo", ["Wiederholen", "Rétablir", "Rehacer", "やり直し", "重做"]), ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Stroke Type", ["Stroke Type", "Type de contour", "Tipo de trazo", "Stroke Type", "描边类型"]), + ("Corner Shape", ["Corner Shape", "Forme d'angle", "Forma de esquina", "Corner Shape", "角形状"]), + ( + "Corner Size and Shape:", + ["Eckengröße und -form:", "Taille et forme des angles:", "Tamaño y forma de esquinas:", "角のサイズとシェイプ:", "角大小和形状:"], + ), + ("Join", ["Join", "Joindre", "Unir", "Join", "连接"]), + ("Convert Point", ["Convert Point", "Convertir le point", "Convertir punto", "Convert Point", "转换点"]), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( @@ -276,6 +367,28 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Pages Fixed", ["Seiten fixiert", "Pages fixes", "Páginas fijas", "ページを固定", "页面固定"]), ("Parents Fixed", ["Übergeordnete fixiert", "Gabarits fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]), ("Parents", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]), + ( + "RGB Color Space View", + [ + "RGB-Farbraumansicht", + "Affichage de l’espace colorimétrique RVB", + "Vista de espacio de color RGB", + "RGB カラースペース表示", + "RGB 色彩空间视图", + ], + ), + ( + "Lab Color Space View", + [ + "Lab-Farbraumansicht", + "Affichage de l’espace colorimétrique Lab", + "Vista de espacio de color Lab", + "Lab カラースペース表示", + "Lab 色彩空间视图", + ], + ), + ("Add CMYK Swatch", ["CMYK-Farbfeld hinzufügen", "Ajouter une nuance CMJN", "Añadir muestra CMYK", "CMYK スウォッチを追加", "添加 CMYK 色板"]), + ("Added swatch", ["Farbfeld hinzugefügt", "Nuance ajoutée", "Muestra añadida", "スウォッチを追加しました", "已添加色板"]), ( "Bulleted & Numbered Lists", [ @@ -375,6 +488,9 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Pathfinder:", ["Pathfinder:", "Pathfinder :", "Buscatrazos:", "パスファインダー:", "路径查找器:"]), ("Convert Shape:", ["Form konvertieren:", "Convertir la forme :", "Convertir forma:", "シェイプを変換:", "转换形状:"]), ("Convert Point:", ["Punkt konvertieren:", "Convertir le point :", "Convertir punto:", "ポイントを変換:", "转换点:"]), + ("Anchor Point(s)", ["Ankerpunkt(e)", "Point(s) d’ancrage", "Punto(s) de ancla", "アンカーポイント", "锚点"]), + ("Convert Points", ["Punkte konvertieren", "Convertir les points", "Convertir puntos", "ポイントを変換", "转换点"]), + ("Delete Point(s)", ["Punkt(e) löschen", "Supprimer le(s) point(s)", "Eliminar punto(s)", "ポイントを削除", "删除点"]), ("Join Path", ["Pfad zusammenfügen", "Joindre le tracé", "Unir trazado", "パスを連結", "连接路径"]), ("Open Path", ["Pfad öffnen", "Ouvrir le tracé", "Abrir trazado", "パスをオープン", "开放路径"]), ("Plain", ["Einfach", "Simple", "Normal", "プレーン", "普通"]), @@ -953,12 +1069,86 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Center Content", ["Center Content", "Centrer le contenu", "Center Content", "Center Content", "内容居中"]), ("Change All", ["Change All", "Tout remplacer", "Change All", "Change All", "全部更改"]), ("Change to:", ["Change to:", "Remplacer par :", "Change to:", "Change to:", "更改为:"]), + ("Change Format:", ["Format ersetzen:", "Remplacer le format :", "Cambiar formato:", "置換形式 :", "更改格式:"]), + ("Change/Find", ["Ersetzen/Suchen", "Remplacer/Rechercher", "Cambiar/Buscar", "置換して検索", "更改/查找"]), + ("Find Format:", ["Format suchen:", "Rechercher le format :", "Buscar formato:", "検索形式 :", "查找格式:"]), + ( + "Find Format Settings", + ["Einstellungen für Format suchen", "Paramètres de recherche de format", "Ajustes de búsqueda de formato", "検索形式の設定", "查找格式设置"], + ), + ( + "Specify attributes to find", + [ + "Zu suchende Attribute festlegen", + "Indiquer les attributs à rechercher", + "Especificar atributos que buscar", + "検索する属性を指定", + "指定要查找的属性", + ], + ), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + [ + "Mit einem Suchformat wird nur Text gefunden, der es hat; bei leerem Feld „Suchen nach“ jeder Text, der es hat", + "Avec un format de recherche, seul le texte qui l’a est trouvé ; si « Rechercher » est vide, tout le texte qui l’a", + "Con un formato de búsqueda solo se encuentra el texto que lo tiene; con «Buscar» vacío, todo el texto que lo tiene", + "検索形式があるときは、その書式のテキストだけが見つかります。検索文字列が空のときは、その書式のテキストすべてが見つかります", + "设置了查找格式时,只查找具有该格式的文本;“查找内容”为空时,查找所有具有该格式的文本", + ], + ), + ( + "Change Format Settings", + [ + "Einstellungen für Format ersetzen", + "Paramètres de remplacement du format", + "Ajustes de cambio de formato", + "置換形式の設定", + "更改格式设置", + ], + ), + ("Style Options", ["Formatoptionen", "Options de style", "Opciones de estilo", "スタイルオプション", "样式选项"]), + ("[Any Style]", ["[Beliebiges Format]", "[Tout style]", "[Cualquier estilo]", "[任意のスタイル]", "[任意样式]"]), + ( + "Specify attributes to change", + [ + "Zu ersetzende Attribute festlegen", + "Indiquer les attributs à remplacer", + "Especificar atributos que cambiar", + "置換する属性を指定", + "指定要更改的属性", + ], + ), + ( + "Clear specified attributes", + ["Festgelegte Attribute löschen", "Effacer les attributs indiqués", "Borrar atributos especificados", "指定した属性を消去", "清除指定的属性"], + ), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + [ + "Mit einem Ersetzungsformat und leerem Feld „Ersetzen durch“ bleibt der gefundene Text erhalten und wird nur formatiert", + "Avec un format de remplacement et « Remplacer par » vide, le texte trouvé reste et seule sa mise en forme change", + "Con un formato de cambio y «Cambiar a» vacío, el texto encontrado se conserva y solo cambia su formato", + "置換形式があり置換文字列が空のときは、検索したテキストはそのままで書式だけが変わります", + "设置了更改格式且“更改为”为空时,找到的文本保持不变,只更改其格式", + ], + ), ("Character Style:", ["Character Style:", "Style de caractère :", "Character Style:", "Character Style:", "字符样式:"]), ("Checked by default", ["Checked by default", "Cochée par défaut", "Checked by default", "Checked by default", "默认选中"]), ("Choices", ["Choices", "Choix", "Choices", "Choices", "选项"]), ("Choose Image…", ["Choose Image…", "Choisir une image…", "Choose Image…", "Choose Image…", "选择图像…"]), ("Class:", ["Class:", "Classe :", "Class:", "Class:", "类别:"]), ("Color Picker", ["Color Picker", "Sélecteur de couleurs", "Color Picker", "Color Picker", "拾色器"]), + ("Color spectrum", ["Farbspektrum", "Spectre de couleurs", "Espectro de color", "カラースペクトル", "色谱"]), + ( + "Eyedropper: drag it onto the page", + [ + "Pipette: auf die Seite ziehen", + "Pipette : faites-la glisser sur la page", + "Cuentagotas: arrástrelo a la página", + "スポイト : ページにドラッグ", + "吸管:将其拖到页面上", + ], + ), ("Color Settings", ["Color Settings", "Réglages des couleurs", "Color Settings", "Color Settings", "颜色设置"]), ("Color:", ["Color:", "Couleur :", "Color:", "Color:", "颜色:"]), ("Columns", ["Columns", "Colonnes", "Columns", "Columns", "栏"]), @@ -1128,6 +1318,27 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("New Section", ["Neuer Abschnitt", "Nouvelle section", "Nueva sección", "新規セクション", "新建章节"]), + ("Cannot be edited", ["Nicht bearbeitbar", "Non modifiable", "No editable", "編集不可", "不可编辑"]), + ("Selection", ["Auswahl", "Sélection", "Selección", "選択範囲", "选区"]), + ("Overprint", ["Überdrucken", "Surimpression", "Sobreimprimir", "オーバープリント", "叠印"]), + ("Top Left Corner", ["Ecke oben links", "Angle supérieur gauche", "Esquina superior izquierda", "左上角", "左上角"]), + ("Top Right Corner", ["Ecke oben rechts", "Angle supérieur droit", "Esquina superior derecha", "右上角", "右上角"]), + ("Bottom Left Corner", ["Ecke unten links", "Angle inférieur gauche", "Esquina inferior izquierda", "左下角", "左下角"]), + ("Bottom Right Corner", ["Ecke unten rechts", "Angle inférieur droit", "Esquina inferior derecha", "右下角", "右下角"]), + ("Tab", ["Tabulator", "Tabulation", "Tabulación", "タブ", "制表符"]), + ( + "Convert Text to Table…", + ["Text in Tabelle umwandeln …", "Convertir le texte en tableau…", "Convertir texto en tabla…", "テキストを表に変換…", "将文本转换为表格…"], + ), + ( + "Convert Table to Text…", + ["Tabelle in Text umwandeln …", "Convertir le tableau en texte…", "Convertir tabla en texto…", "表をテキストに変換…", "将表格转换为文本…"], + ), + ("Column Separator", ["Spaltentrennzeichen", "Séparateur de colonnes", "Separador de columnas", "列分解文字", "列分隔符"]), + ("Row Separator", ["Zeilentrennzeichen", "Séparateur de rangs", "Separador de filas", "行分解文字", "行分隔符"]), + ("Number of Columns", ["Spaltenanzahl", "Nombre de colonnes", "Número de columnas", "列数", "列数"]), + ("Comma", ["Komma", "Virgule", "Coma", "カンマ", "逗号"]), ("Restart Numbering", ["Nummerierung neu beginnen", "Recommencer la numérotation", "Reiniciar numeración", "番号の振り直し", "重新开始编号"]), ("Continue Numbering", ["Nummerierung fortführen", "Poursuivre la numérotation", "Continuar numeración", "番号の継続", "继续编号"]), ("Keep Options…", ["Umbruchoptionen …", "Options d'enchaînement…", "Opciones de separación…", "段落分離禁止オプション…", "保持选项…"]), @@ -1581,7 +1792,8 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ "适合框架高度到文本", ], ), - ("More Options", ["More Options", "Plus d'options", "More Options", "More Options", "更多选项"]), + ("More Options", ["Weitere Optionen", "Plus d'options", "Más opciones", "その他のオプション", "更多选项"]), + ("Fewer Options", ["Weniger Optionen", "Moins d'options", "Menos opciones", "オプションを減らす", "较少选项"]), ("Flip Horizontal", ["Flip Horizontal", "Retourner horizontalement", "Flip Horizontal", "Flip Horizontal", "水平翻转"]), ( "Rotate 90° Clockwise", @@ -2079,6 +2291,13 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ["Insert Math Expression…", "Insérer une expression mathématique…", "Insert Math Expression…", "Insert Math Expression…", "插入数学公式…"], ), ("Insert Pages…", ["Insert Pages…", "Insérer des pages…", "Insert Pages…", "Insert Pages…", "插入页面…"]), + ("Insert Pages", ["Seiten einfügen", "Insérer des pages", "Insertar páginas", "ページを挿入", "插入页面"]), + ("Insert:", ["Einfügen:", "Insérer :", "Insertar:", "挿入:", "插入:"]), + ("Parent:", ["Vorlagenseite:", "Gabarit :", "Página principal:", "親ページ:", "主页:"]), + ("After Page", ["Nach Seite", "Après la page", "Después de la página", "ページの後", "页面后"]), + ("Before Page", ["Vor Seite", "Avant la page", "Antes de la página", "ページの前", "页面前"]), + ("At Start of Document", ["Am Dokumentanfang", "Au début du document", "Al principio del documento", "ドキュメントの先頭", "文档开头"]), + ("At End of Document", ["Am Dokumentende", "À la fin du document", "Al final del documento", "ドキュメントの末尾", "文档末尾"]), ("Insert Variable", ["Insert Variable", "Insérer une variable", "Insert Variable", "Insert Variable", "插入变量"]), ( "Convert Variable to Text", @@ -2424,6 +2643,9 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ), ("Typical Display", ["Typical Display", "Affichage standard", "Typical Display", "Typical Display", "典型显示"]), ("UI Scaling", ["UI Scaling", "Mise à l'échelle de l'interface", "UI Scaling", "UI Scaling", "UI 缩放"]), + ("Color Theme:", ["Farbdesign:", "Thème de couleur :", "Tema de color:", "カラーテーマ:", "颜色主题:"]), + ("Tools Panel:", ["Werkzeugbedienfeld:", "Panneau Outils :", "Panel Herramientas:", "ツールパネル:", "工具面板:"]), + ("Double Column", ["Doppelte Spalte", "Double colonne", "Columna doble", "2 列", "双栏"]), ("Update Link", ["Update Link", "Mettre à jour le lien", "Update Link", "Update Link", "更新链接"]), ( "User Dictionary Words", @@ -4664,6 +4886,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("OpenType font", ["OpenType-Schrift", "Police OpenType", "Fuente OpenType", "OpenType フォント", "OpenType 字体"]), ("Variable font", ["Variable Schrift", "Police variable", "Fuente variable", "バリアブルフォント", "可变字体"]), ("Color font", ["Farbschrift", "Police couleur", "Fuente de color", "カラーフォント", "彩色字体"]), + ("Tab Character", ["Tabulatorzeichen", "Caractère de tabulation", "Carácter de tabulación", "タブ文字", "制表符"]), // Table of Contents. ( "Sort Entries in Alphabetical Order", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8f9e67d02..5bae3381f 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -2,6 +2,11 @@ //! Common phrases are adapted from the MIT OR Apache-2.0 ArtCraft sibling catalogs //! (VectorCraft, PhotoCraft, WordCraft and DeckCraft); see ASSETS.md for pinned sources. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Менше параметрів"), + ("Tab Character", "Символ табуляції"), + ("Right Indent Tab", "Табуляція правого відступу"), + ("Em Space", "Кругла шпація"), + ("En Space", "Півкругла шпація"), ("File", "Файл"), ("Edit", "Редагування"), ("Layout", "Макет"), @@ -30,6 +35,103 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Keep In Frame", "Зберегти у фреймі"), + ("New Paragraph Style", "Новий стиль абзацу"), + ("New Character Style", "Новий стиль символів"), + ("Next Style:", "Наступний стиль:"), + ("[Same style]", "[Той самий стиль]"), + ("Reset To Base", "Скинути до основи"), + ("Apply Style to Selection", "Застосувати стиль до виділеного"), + ("Change/Find", "Замінити/Знайти"), + ("Find Format:", "Знайти формат:"), + ("Find Format Settings", "Параметри пошуку формату"), + ("Specify attributes to find", "Задати атрибути для пошуку"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "З форматом пошуку знаходиться лише текст із цим форматом; якщо поле «Знайти» порожнє — увесь такий текст", + ), + ("Change Format:", "Замінити формат:"), + ("Change Format Settings", "Параметри заміни формату"), + ("Style Options", "Параметри стилю"), + ("[Any Style]", "[Будь-який стиль]"), + ("Specify attributes to change", "Задати атрибути для заміни"), + ("Clear specified attributes", "Очистити задані атрибути"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Якщо задано формат заміни, а поле «Замінити на» порожнє, знайдений текст лишається, змінюється лише його формат", + ), + ("Double Column", "Подвійна колонка"), + ("Tools Panel:", "Панель інструментів:"), + ("Color Theme:", "Колірна тема:"), + ("At End of Document", "У кінець документа"), + ("At Start of Document", "На початок документа"), + ("Before Page", "Перед сторінкою"), + ("After Page", "Після сторінки"), + ("Parent:", "Шаблон:"), + ("Insert:", "Вставити:"), + ("Insert Pages", "Вставити сторінки"), + ("Stroke Type", "Тип обведення"), + ("Corner Shape", "Форма кута"), + ("Corner Size and Shape:", "Розмір і форма кутів:"), + ("Join", "Зʼєднати"), + ("Convert Point", "Перетворити точку"), + ("New Section", "Новий розділ"), + ("Clear Frame Fitting Options", "Очистити параметри припасування фрейму"), + ("Cannot be edited", "Не можна змінити"), + ("Maximum:", "Максимум:"), + ("Paragraph Spacing Limit:", "Межа інтервалу абзаців:"), + ("Overprint", "Накладання"), + ("Top Left Corner", "Лівий верхній кут"), + ("Top Right Corner", "Правий верхній кут"), + ("Bottom Left Corner", "Лівий нижній кут"), + ("Bottom Right Corner", "Правий нижній кут"), + ("Anchor Point(s)", "Опорні точки"), + ("Convert Points", "Перетворити точки"), + ("Delete Point(s)", "Видалити точки"), + ("Tab", "Табуляція"), + ("Convert Text to Table…", "Перетворити текст на таблицю…"), + ("Convert Table to Text…", "Перетворити таблицю на текст…"), + ("Column Separator", "Роздільник стовпців"), + ("Row Separator", "Роздільник рядків"), + ("Number of Columns", "Кількість стовпців"), + ("Comma", "Кома"), + ("RGB Color Space View", "Перегляд колірного простору RGB"), + ("Lab Color Space View", "Перегляд колірного простору Lab"), + ("Add CMYK Swatch", "Додати зразок CMYK"), + ("Added swatch", "Зразок додано"), + ("Eyedropper: drag it onto the page", "Піпетка: перетягніть її на сторінку"), + ("PDF Preset:", "Набір параметрів PDF:"), + ("Packaged {count} files into {dir}", "Запаковано файлів: {count} у {dir}"), + ("Continue", "Продовжити"), + ("Printing Instructions", "Інструкції для друку"), + ("Package", "Пакування"), + ("Show Problems Only", "Показувати лише проблеми"), + ("Include PDF (Print)", "Включити PDF (друк)"), + ("Include IDML", "Включити IDML"), + ("Create Printing Instructions", "Створити інструкції для друку"), + ("Entire Publication", "Уся публікація"), + ("Scope:", "Область:"), + ("Source", "Джерело"), + ("Installed", "Встановлений"), + ("Bundled", "Вбудований"), + ("Substituted", "Замінено"), + ("Modified", "Змінено"), + ("Missing", "Бракує"), + ("Instructions:", "Інструкції:"), + ("Email:", "Ел. пошта:"), + ("Fax:", "Факс:"), + ("Phone:", "Телефон:"), + ("Address:", "Адреса:"), + ("Company:", "Компанія:"), + ("Contact:", "Контакт:"), + ("Process Black", "Тріадний чорний"), + ("Process Yellow", "Тріадний жовтий"), + ("Process Magenta", "Тріадний пурпуровий"), + ("Process Cyan", "Тріадний блакитний"), + ("Colors and Inks", "Кольори й фарби"), + ("Links and Images", "Зв’язки й зображення"), + ("Fonts", "Шрифти"), + ("Summary", "Зведення"), ("Restart Numbering", "Почати нумерацію знову"), ("Continue Numbering", "Продовжити нумерацію"), ("Keep Options…", "Параметри утримання…"), @@ -308,6 +410,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Вибрати зображення…"), ("Class:", "Клас:"), ("Color Picker", "Палітра кольорів"), + ("Color spectrum", "Колірний спектр"), ("Color Settings", "Налаштування кольору"), ("Color:", "Колір:"), ("Columns", "Колонки"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7ffbbee5a..c8eeee911 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -735,23 +735,40 @@ fn paint_shapes(pen: &mut Pen, name: &str) -> bool { pen.line(&[(10.0, 3.0), (10.0, 17.0)]); pen.line(&[(3.0, 10.0), (17.0, 10.0)]); } - // Pathfinder panel › Convert Point: a point on a path, with its direction handles. - "point-plain" | "point-corner" | "point-smooth" | "point-symmetrical" => { - match name { - "point-plain" | "point-corner" => pen.line(&[(3.0, 16.0), (10.0, 8.0), (17.0, 16.0)]), - _ => pen.line(&[(3.0, 16.0), (5.5, 11.5), (10.0, 8.0), (14.5, 11.5), (17.0, 16.0)]), + // Convert Point (Pathfinder panel, Properties › Convert Points): the anchor square on its + // path. Plain and Corner sit on a corner, Corner with a knob on each arm; Smooth and + // Symmetrical sit on a curve with a straight handle through them, its arms equal for + // Symmetrical. + "point-plain" | "point-corner" => { + pen.line(&[(17.0, 6.0), (6.0, 6.0), (6.0, 17.0)]); + if name == "point-corner" { + pen.fcircle(13.0, 6.0, 1.8); + pen.fcircle(6.0, 13.0, 1.8); } - let handles: &[(f32, f32)] = match name { - "point-corner" => &[(4.5, 4.0), (16.0, 5.0)], - "point-smooth" => &[(5.0, 8.0), (17.5, 8.0)], - "point-symmetrical" => &[(4.0, 8.0), (16.0, 8.0)], - _ => &[], + pen.frect(4.0, 4.0, 8.0, 8.0); + } + "point-smooth" | "point-symmetrical" => { + // A curve through the anchor (a quadratic sampled as a polyline), and the handle. + let ((p0, p2), (h0, h1)) = if name == "point-smooth" { + (((3.0, 6.5), (12.5, 18.0)), ((5.0, 4.0), (15.5, 15.5))) + } else { + (((7.0, 18.0), (17.5, 6.0)), ((5.0, 15.0), (15.0, 5.0))) }; - for &(hx, hy) in handles { - pen.line(&[(10.0, 8.0), (hx, hy)]); - pen.fcircle(hx, hy, 1.4); + let m = (10.0f32, 10.0f32); + let c = (2.0 * m.0 - (p0.0 + p2.0) / 2.0, 2.0 * m.1 - (p0.1 + p2.1) / 2.0); + let curve: Vec<(f32, f32)> = (0..=12) + .map(|k| { + let t = k as f32 / 12.0; + let u = 1.0 - t; + (u * u * p0.0 + 2.0 * u * t * c.0 + t * t * p2.0, u * u * p0.1 + 2.0 * u * t * c.1 + t * t * p2.1) + }) + .collect(); + pen.line(&curve); + pen.line(&[h0, h1]); + for (x, y) in [h0, h1] { + pen.frect(x - 1.4, y - 1.4, x + 1.4, y + 1.4); } - pen.frect(8.5, 6.5, 11.5, 9.5); + pen.frect(8.2, 8.2, 11.8, 11.8); } // ---- Properties panel glyphs (original drawings) "clock" => { @@ -857,6 +874,33 @@ fn paint_page(pen: &mut Pen, name: &str) -> bool { _ => {} } } + // Properties › Align To, one glyph per target, with the drop-down mark: a page with its + // margins, a page, a spread of two pages, a key object drawn bold. + "align-to-margins" | "align-to-page" | "align-to-spread" | "align-to-key" => { + match name { + "align-to-margins" => { + pen.closed(&[(4.0, 2.5), (11.5, 2.5), (14.5, 5.5), (14.5, 16.0), (4.0, 16.0)]); + pen.w = 1.0; + pen.rect(6.5, 6.0, 12.0, 13.5); + } + "align-to-page" => { + pen.closed(&[(4.0, 2.5), (11.5, 2.5), (14.5, 5.5), (14.5, 16.0), (4.0, 16.0)]); + pen.line(&[(11.5, 2.5), (11.5, 5.5), (14.5, 5.5)]); + } + "align-to-spread" => { + pen.rect(1.5, 4.0, 8.5, 15.0); + pen.rect(8.5, 4.0, 15.5, 15.0); + } + _ => { + pen.rect(3.0, 4.0, 15.0, 14.0); + pen.frect(3.0, 4.0, 15.0, 6.0); + pen.frect(3.0, 12.0, 15.0, 14.0); + pen.frect(3.0, 4.0, 5.0, 14.0); + pen.frect(13.0, 4.0, 15.0, 14.0); + } + } + pen.fill(&[(16.0, 17.0), (19.5, 17.0), (17.75, 19.0)]); + } "align-to" | "frame-dashed" => { pen.w = 1.0; for (a, b) in [((3.0, 3.0), (15.0, 3.0)), ((15.0, 3.0), (15.0, 15.0)), ((15.0, 15.0), (3.0, 15.0)), ((3.0, 15.0), (3.0, 3.0))] { @@ -982,6 +1026,18 @@ fn paint_text(pen: &mut Pen, name: &str) -> bool { pen.line(&[(15.0, 13.5), (17.0, 15.5), (15.0, 17.5)]); } } + // A box with one corner marked (Top Left, Top Right, Bottom Left, Bottom Right Corner). + "corner-tl" | "corner-tr" | "corner-bl" | "corner-br" => { + pen.w = 1.0; + pen.rect(4.0, 4.0, 16.0, 16.0); + let (x, y) = match name { + "corner-tl" => (2.5, 2.5), + "corner-tr" => (13.5, 2.5), + "corner-bl" => (2.5, 13.5), + _ => (13.5, 13.5), + }; + pen.frect(x, y, x + 4.0, y + 4.0); + } "vscale" => { pen.text(13.0, 10.5, "T", 15.0); pen.line(&[(4.0, 3.0), (4.0, 17.0)]); @@ -1178,6 +1234,17 @@ fn paint_text(pen: &mut Pen, name: &str) -> bool { pen.rect(3.0, 4.0, 17.0, 16.0); pen.line(&[(3.0, 7.0), (17.0, 7.0)]); } + // Find/Change's Specify Attributes: a letter under a magnifier. + "find-format" => { + pen.text(7.5, 8.5, "A", 12.0); + pen.circle(13.0, 13.0, 3.2); + pen.line(&[(15.3, 15.3), (18.0, 18.0)]); + } + "info" => { + pen.circle(10.0, 10.0, 7.0); + pen.fcircle(10.0, 6.6, 1.0); + pen.line(&[(10.0, 9.0), (10.0, 14.2)]); + } _ => return false, } true @@ -1302,6 +1369,20 @@ mod tests { assert!(!arrow_filled("tool-direct"), "Direct Selection draws a filled arrow"); } + /// Each Align To target has its own glyph, none the placeholder. + #[test] + fn align_to_glyphs_are_distinct() { + let names = ["align-to", "align-to-key", "align-to-margins", "align-to-page", "align-to-spread"]; + let unknown = drawing("no-such-icon"); + let glyphs: Vec = names.iter().map(|n| drawing(n)).collect(); + for (k, g) in glyphs.iter().enumerate() { + assert_ne!(*g, unknown, "{} falls back to the placeholder", names[k]); + for (j, other) in glyphs.iter().enumerate().skip(k + 1) { + assert_ne!(g, other, "{} and {} draw alike", names[k], names[j]); + } + } + } + #[test] fn distribute_icons_are_distinct_from_align_and_each_other() { // `objalign-*` ship upstream and back the Properties Align row; only the diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a53cffcfa..de364b91f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,6 +31,7 @@ pub mod menus; mod modal_drag; mod new_document; mod object_control; +pub mod package_dialog; mod page_history; mod panel_docking; pub mod panels; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ffcfa0988..d1e41d549 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -39,7 +39,8 @@ pub use tables::{CLASSIC_GROUPS, MENUS, TYPOGRAPHY_GROUPS, WORKSPACE_OPEN_PANELS #[cfg(test)] use tree::menu_items; pub use tree::{ - AppMenuItem, MAC_APP_MENU, OPEN_RECENT, all_menu_items, menu_key, menu_tree, native_label, native_menu_tree, recent_items, recent_label, + AppMenuItem, MAC_APP_MENU, OPEN_RECENT, all_menu_items, has_shown_item, menu_key, menu_tree, native_label, native_menu_tree, recent_items, + recent_label, }; pub use ui_commands::{CJK_ONLY, UI_COMMANDS, shown, ui_label}; diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index d11b8deaa..16c7bc191 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -66,6 +66,11 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { crate::section_options::open(app); return; } + if params.is_null() && id == "layout.pages.insert" { + let cur = crate::canvas::current_page(app).unwrap_or(0); + crate::dialogs::open_insert_pages(app, cur); + return; + } if params.is_null() && id == "layout.createGuides" { crate::dialogs::open_create_guides(app); return; diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 6a8d797b5..ee7fdbb5e 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -1,6 +1,6 @@ use super::export::{download_document, export_bytes, export_epub, export_idml, export_pdf, export_png, separations_view}; use super::invoke::guide_spread; -use super::state::{is_window_control, window_control_available}; +use super::state::{is_window_control, menu_enabled, window_control_available}; use super::tables::{CLASSIC_GROUPS, TYPOGRAPHY_GROUPS, workspace_open_panel}; use super::tree::recent_label; use super::{DesignApp, ScreenMode, Value, json}; @@ -116,7 +116,9 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option { - app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({}))); + // In the default units (Preferences with no document open). + let fields = crate::new_document::defaults_for(&app.session.default_new_document()); + app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", fields)); Ok(Value::Null) } "app.openDialog" => match p.get("path").and_then(Value::as_str) { @@ -245,12 +247,9 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option export_bytes(app, p, "rtf", "file.exportText"), "app.packageDialog" => { + // Without a folder, the Package dialog: what the package carries, then the folder. let Some(dir) = p.get("dir").and_then(Value::as_str) else { - let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); - let p = p.clone(); - return Some(app.ask_save(&name, Bound::Document, move |app, dir| { - run_ui(app, "app.packageDialog", &with_path(&p, "dir", dir)).unwrap_or(Ok(Value::Null)) - })); + return Some(crate::package_dialog::open(app).map(|()| Value::Null)); }; let r = app.run("file.package", json!({"dir": dir})); if let Ok(v) = &r { @@ -347,6 +346,20 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option { + if !menu_enabled(app, id) { + return Some(Err("select the text to convert to a table".into())); + } + app.ui.dialog = Some(crate::dialogs::Dialog::new("convertTextToTable", p.clone())); + Ok(Value::Null) + } + "app.convertTableToTextDialog" => { + if !menu_enabled(app, id) { + return Some(Err("place the insertion point in a table to convert it to text".into())); + } + app.ui.dialog = Some(crate::dialogs::Dialog::new("convertTableToText", p.clone())); + Ok(Value::Null) + } "app.insertTableDialog" => { if app.session.active().is_none_or(|d| d.selection.text.is_none()) { return Some(Err("place the insertion point in a text frame to create a table".into())); @@ -510,6 +523,15 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option Option Option flag(&mut app.ui.tools_double_column), + "window.toolsDoubleColumn" => { + app.ui.tools_double_column = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.tools_double_column); + Ok(json!(app.ui.tools_double_column)) + } "window.newWorkspace" => { let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { app.ui.dialog = diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index f9e78c28f..edf7e1c6f 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -175,6 +175,15 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) { return false; } + // As InDesign's: Convert Text to Table wants selected text outside a table, Convert Table to + // Text a table. + if id == "app.convertTextToTableDialog" { + let selected = app.session.active().and_then(|d| d.selection.text).is_some_and(|t| t.cell.is_none() && !t.is_caret()); + return selected && enabled(app, "table.convertFromText"); + } + if id == "app.convertTableToTextDialog" { + return enabled(app, "table.convertToText"); + } if id == "view.goBack" || id == "view.goForward" { return crate::canvas::can_go_through_history(app, if id == "view.goBack" { -1 } else { 1 }); } diff --git a/crates/ui-egui/src/menus/tables.rs b/crates/ui-egui/src/menus/tables.rs index 4d63d485f..e7907e7da 100644 --- a/crates/ui-egui/src/menus/tables.rs +++ b/crates/ui-egui/src/menus/tables.rs @@ -36,6 +36,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "cmd:layout.documentSetup", "cmd:file.info", + "-", + "ui:app.quit", ], ), ( @@ -274,6 +276,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:type.storyDirection|Horizontal|{\"vertical\": false}", "cmd:type.storyDirection|Vertical|{\"vertical\": true}", "<", + "-", "cmd:type.tateChuYoko", "ui:app.rubyDialog", "cmd:type.kenten", @@ -299,57 +302,12 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:transform.again|Transform Sequence Again|{\"sequence\": true}", "cmd:transform.again|Transform Sequence Again Individually|{\"sequence\": true, \"individually\": true}", "<", - ">Flex Layout", - "cmd:flex.add", - "cmd:flex.convert", - "cmd:flex.remove", - "-", - "ui:window.panel|Show Flex Controls|{\"panel\": \"flexLayout\"}", - "<", - ">Paths", - "cmd:object.makeCompoundPath", - "cmd:object.releaseCompoundPath", - "-", - "cmd:path.reverse", - "<", - ">Pathfinder", - "cmd:object.pathfinder|Add|{\"op\": \"add\"}", - "cmd:object.pathfinder|Subtract|{\"op\": \"subtract\"}", - "cmd:object.pathfinder|Intersect|{\"op\": \"intersect\"}", - "cmd:object.pathfinder|Exclude Overlap|{\"op\": \"exclude\"}", - "cmd:object.pathfinder|Minus Back|{\"op\": \"minusBack\"}", - "<", ">Arrange", "cmd:object.bringToFront", "cmd:object.bringForward", "cmd:object.sendBackward", "cmd:object.sendToBack", "<", - "-", - "cmd:object.group", - "cmd:object.ungroup", - "cmd:object.lock", - "cmd:object.unlockAll", - "cmd:object.hide", - "cmd:object.showAll", - "-", - "cmd:object.textFrameOptions", - "ui:app.layerOptions", - "cmd:object.exportOptions", - "cmd:object.primaryTextFrame", - "cmd:object.cornerOptions", - "-", - ">Effects", - "ui:effects.dialog|Transparency…|{\"page\": \"transparency\"}", - "cmd:object.dropShadow", - "cmd:object.innerShadow", - "cmd:object.outerGlow", - "cmd:object.innerGlow", - "cmd:object.bevel", - "cmd:object.satin", - "cmd:object.feather", - "cmd:object.directionalFeather", - "<", ">Select", "cmd:select.firstAbove", "cmd:select.nextAbove", @@ -362,6 +320,90 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:select.previousInGroup", "cmd:select.nextInGroup", "<", + "-", + "cmd:object.group", + "cmd:object.ungroup", + "cmd:object.lock", + "cmd:object.unlockAll", + "cmd:object.hide", + "cmd:object.showAll", + "-", + "ui:app.qrCode", + "-", + "cmd:object.textFrameOptions", + "cmd:object.primaryTextFrame", + ">Anchored Object", + "cmd:anchored.options", + "cmd:anchored.release", + "<", + "-", + ">Fitting", + "ui:fit.fillFrameProportionally", + "ui:fit.contentProportionally", + "-", + "ui:fit.frameToContent", + "ui:fit.contentToFrame", + "ui:fit.centerContent", + "-", + "cmd:object.clearFittingOptions", + "-", + "ui:app.fittingOptions", + "<", + ">Content", + "cmd:object.content|Graphic|{\"type\":\"graphic\"}", + "cmd:object.content|Text|{\"type\":\"text\"}", + "cmd:object.content|Unassigned|{\"type\":\"unassigned\"}", + "<", + "-", + ">Effects", + "ui:effects.dialog|Transparency…|{\"page\": \"transparency\"}", + "cmd:object.dropShadow", + "cmd:object.innerShadow", + "cmd:object.outerGlow", + "cmd:object.innerGlow", + "cmd:object.bevel", + "cmd:object.satin", + "cmd:object.feather", + "cmd:object.directionalFeather", + "<", + "cmd:object.cornerOptions", + "-", + "ui:app.layerOptions", + "cmd:object.exportOptions", + ">Captions", + "cmd:object.caption", + "cmd:object.caption|Generate Live Caption|{\"live\": true}", + "<", + ">Clipping Path", + "cmd:object.clippingPathOptions|None|{\"type\": \"none\"}", + "cmd:object.clippingPath|Alpha Channel|{\"type\": \"alpha\"}", + "cmd:object.clippingPath|Detect Edges|{\"type\": \"edges\"}", + "<", + "-", + ">Flex Layout", + "cmd:flex.add", + "cmd:flex.convert", + "cmd:flex.remove", + "-", + "ui:window.panel|Show Flex Controls|{\"panel\": \"flexLayout\"}", + "<", + "-", + ">Paths", + "cmd:path.join", + "cmd:object.convertShape|Open Path|{\"to\": \"openPath\"}", + "cmd:object.convertShape|Close Path|{\"to\": \"closedPath\"}", + "cmd:path.reverse", + "-", + "cmd:object.makeCompoundPath", + "cmd:object.releaseCompoundPath", + "<", + ">Pathfinder", + "cmd:object.pathfinder|Add|{\"op\": \"add\"}", + "cmd:object.pathfinder|Subtract|{\"op\": \"subtract\"}", + "cmd:object.pathfinder|Intersect|{\"op\": \"intersect\"}", + "cmd:object.pathfinder|Exclude Overlap|{\"op\": \"exclude\"}", + "cmd:object.pathfinder|Minus Back|{\"op\": \"minusBack\"}", + "<", ">Convert Shape", "cmd:object.convertShape|Rectangle|{\"to\": \"rectangle\"}", "cmd:object.convertShape|Rounded Rectangle|{\"to\": \"roundedRectangle\"}", @@ -372,46 +414,26 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:object.convertShape|Polygon|{\"to\": \"polygon\"}", "cmd:object.convertShape|Line|{\"to\": \"line\"}", "cmd:object.convertShape|Orthogonal Line|{\"to\": \"orthogonalLine\"}", - "-", - "cmd:object.convertShape|Open Path|{\"to\": \"openPath\"}", - "cmd:object.convertShape|Closed Path|{\"to\": \"closedPath\"}", "<", - "ui:app.qrCode", - ">Clipping Path", - "cmd:object.clippingPathOptions|None|{\"type\": \"none\"}", - "cmd:object.clippingPath|Alpha Channel|{\"type\": \"alpha\"}", - "cmd:object.clippingPath|Detect Edges|{\"type\": \"edges\"}", - "<", - ">Captions", - "cmd:object.caption", - "cmd:object.caption|Generate Live Caption|{\"live\": true}", - "<", - ">Anchored Object", - "cmd:anchored.options", - "cmd:anchored.release", - "<", - ">Fitting", - "ui:fit.fillFrameProportionally", - "ui:fit.contentProportionally", - "ui:fit.frameToContent", - "ui:fit.contentToFrame", - "ui:fit.centerContent", - "-", - "ui:app.fittingOptions", - "<", - ">Content", - "cmd:object.content|Graphic|{\"type\":\"graphic\"}", - "cmd:object.content|Text|{\"type\":\"text\"}", - "cmd:object.content|Unassigned|{\"type\":\"unassigned\"}", + ">Convert Point", + "cmd:path.convertPoints|Plain|{\"kind\": \"plain\"}", + "cmd:path.convertPoints|Corner|{\"kind\": \"corner\"}", + "cmd:path.convertPoints|Smooth|{\"kind\": \"smooth\"}", + "cmd:path.convertPoints|Symmetrical|{\"kind\": \"symmetrical\"}", "<", ], ), ( + // InDesign's order. The Table panel is in Window › Type & Tables (Shift+F9). "Table", &[ "ui:app.insertTableDialog", - "cmd:table.convertFromText", - "cmd:table.convertToText", + "ui:app.convertTextToTableDialog", + "ui:app.convertTableToTextDialog", + "-", + ">Cell Options", + "ui:app.cellOptionsDialog", + "<", "-", ">Insert", "cmd:table.insertRowAbove", @@ -430,26 +452,20 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.selectTable", "<", "-", - ">Cell Options", - "ui:app.cellOptionsDialog", - "<", "cmd:table.merge", "cmd:table.unmerge", - ">Convert Cell Type", + "cmd:table.splitHorizontally", + "cmd:table.splitVertically", "ui:app.graphicCell|Convert Cell to Graphic Cell…", "cmd:table.textCell", - "<", ">Convert Rows", "cmd:table.convertRowsToHeader", "cmd:table.convertRowsToBody", "cmd:table.convertRowsToFooter", "<", - "cmd:table.splitHorizontally", - "cmd:table.splitVertically", - "cmd:table.distributeColumns", - "cmd:table.distributeRows", "-", - "ui:app.tablePanel", + "cmd:table.distributeRows", + "cmd:table.distributeColumns", ], ), ( diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 5e82f6d3f..efe978dc5 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -6,6 +6,81 @@ fn menu_ids(title: &str) -> Vec<&'static str> { items.iter().filter_map(|e| e.split_once(':').map(|(_, rest)| rest.split('|').next().unwrap_or(rest))).collect() } +/// The Object menu's top level in InDesign's order and groups (as InDesign 2023 shows it in +/// Learn Skills Daily's course), with Paths holding Join, Open and Close Path, Convert Shape +/// only shapes, and a Convert Point submenu. +#[test] +fn the_object_menu_is_in_indesigns_order() { + let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); + // The top-level entries: submenus by name, separators as "-", items by command id. + let mut top = Vec::new(); + let mut depth = 0; + for e in items { + match *e { + "<" => depth -= 1, + e if e.starts_with('>') => { + if depth == 0 { + top.push(e.trim_start_matches('>')); + } + depth += 1; + } + e if depth == 0 => top.push(e.split_once(':').map_or(e, |(_, r)| r.split('|').next().unwrap_or(r))), + _ => {} + } + } + assert_eq!( + top, + [ + "Transform", + "Transform Again", + "Arrange", + "Select", + "-", + "object.group", + "object.ungroup", + "object.lock", + "object.unlockAll", + "object.hide", + "object.showAll", + "-", + "app.qrCode", + "-", + "object.textFrameOptions", + "object.primaryTextFrame", + "Anchored Object", + "-", + "Fitting", + "Content", + "-", + "Effects", + "object.cornerOptions", + "-", + "app.layerOptions", + "object.exportOptions", + "Captions", + "Clipping Path", + "-", + "Flex Layout", + "-", + "Paths", + "Pathfinder", + "Convert Shape", + "Convert Point", + ] + ); + let ids = menu_ids("Object"); + for id in ["path.join", "path.reverse", "path.convertPoints"] { + assert!(ids.contains(&id), "{id}"); + } + let sub = |name: &str| { + let i = items.iter().position(|e| *e == format!(">{name}")).unwrap(); + items[i + 1..].iter().take_while(|e| **e != "<").copied().collect::>() + }; + assert!(sub("Paths").iter().any(|e| e.contains("|Open Path|")) && sub("Paths").iter().any(|e| e.contains("|Close Path|"))); + assert!(!sub("Convert Shape").iter().any(|e| e.contains("openPath") || e.contains("closedPath")), "shapes only"); + assert_eq!(sub("Convert Point").len(), 4); +} + #[test] fn view_items_are_in_the_view_menu_as_in_indesign() { let (view, typ) = (menu_ids("View"), menu_ids("Type")); @@ -29,6 +104,30 @@ fn view_items_are_in_the_view_menu_as_in_indesign() { assert_eq!(ui_label("view.togglePreview").and_then(|c| c.1), Some("W")); } +/// Object › Fitting in InDesign's groups: the proportional fits, the frame and content fits, +/// Clear Frame Fitting Options, then the dialog. +#[test] +fn the_fitting_menu_is_grouped_as_indesigns() { + let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); + let start = items.iter().position(|e| *e == ">Fitting").unwrap() + 1; + let end = start + items[start..].iter().position(|e| *e == "<").unwrap(); + assert_eq!( + &items[start..end], + [ + "ui:fit.fillFrameProportionally", + "ui:fit.contentProportionally", + "-", + "ui:fit.frameToContent", + "ui:fit.contentToFrame", + "ui:fit.centerContent", + "-", + "cmd:object.clearFittingOptions", + "-", + "ui:app.fittingOptions", + ] + ); +} + /// The submenu paths of a menu bar menu ("View/Screen Mode"), as Edit › Menus keys them. fn submenu_paths(title: &str) -> Vec { let items = MENUS.iter().find(|(t, _)| *t == title).map(|(_, items)| *items).unwrap_or_default(); @@ -1953,7 +2052,7 @@ fn searched_ui_commands_run_without_parameters_and_keep_preferences() { let mut scratch = designcraft_engine::Session::new(); scratch.execute("file.new", &json!({})).unwrap(); let pdf = scratch.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone(); - let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected"]; + let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected", "text selected"]; // A two-page document, a saved workspace and non-default preferences, a host with window // controls, and file dialogs that are dismissed. let setup = |context: usize| { @@ -1987,6 +2086,11 @@ fn searched_ui_commands_run_without_parameters_and_keep_preferences() { app.run("file.place", json!({"base64": pdf, "name": "a.pdf", "frame": f})).unwrap(); app.run("selection.set", json!({"ids": [f]})).unwrap(); } + // Convert Text to Table needs text selected. + 5 => { + let f = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": "One Two"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 7})).unwrap(); + } _ => {} } app.ui.dialog = None; @@ -2107,6 +2211,48 @@ fn context_menus_hold_commands_and_menu_bar_submenus() { } } +#[test] +fn the_table_menu_follows_indesigns_order() { + // InDesign's Table menu with a text insertion point (Adobe's "How to make a table"). + let table = menu_tree().into_iter().find(|(m, _)| *m == "Table").unwrap().1; + let labels: Vec = table + .iter() + .map(|i| match i { + Item::Sep => "-".to_string(), + Item::Sub(n, _) => format!("{n} >"), + Item::Cmd { label, .. } => label.clone(), + }) + .collect(); + let expected = [ + "Insert Table…", + "Convert Text to Table…", + "Convert Table to Text…", + "-", + "Cell Options >", + "-", + "Insert >", + "Delete >", + "Select >", + "-", + "Merge Cells", + "Unmerge Cells", + "Split Cell Horizontally", + "Split Cell Vertically", + "Convert Cell to Graphic Cell…", + "Convert Cell to Text Cell", + "Convert Rows >", + "-", + "Distribute Rows Evenly", + "Distribute Columns Evenly", + ]; + assert_eq!(labels, expected); + // Cmd+Alt+Shift+T opens the dialog, as InDesign's; the engine command has no shortcut of + // its own. The Table panel is in Window › Type & Tables. + assert_eq!(ui_label("app.insertTableDialog").and_then(|c| c.1), Some("Cmd+Alt+Shift+T")); + assert_eq!(designcraft_engine::find_command("table.insert").unwrap().shortcut, None); + assert_eq!(ui_label("app.tablePanel").and_then(|c| c.1), Some("Shift+F9")); +} + #[test] fn the_type_menu_follows_indesigns_order() { // InDesign's Type menu, as Chris Nielsen opens it in "Numbering Page Sections" (2:09). @@ -2437,6 +2583,22 @@ fn cjk_menu_items_follow_show_cjk_features() { assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("ruby"), "hidden, not removed"); } +/// The CJK commands sit in a group of their own, after a separator, and hiding them leaves no +/// separator at the end of the menu. +#[test] +fn cjk_menu_items_have_their_own_group() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ty = menu_tree().into_iter().find(|(m, _)| *m == "Type").unwrap().1; + let first = ty.iter().position(|i| matches!(i, Item::Cmd { id, .. } if CJK_ONLY.contains(&id.as_str()))).unwrap(); + assert!(matches!(ty[first - 1], Item::Sep), "separator before the CJK group"); + assert!(matches!(ty[first - 2], Item::Sub(..)), "the group comes after Story Direction"); + app.ui.language = String::new(); + app.run("prefs.set", json!({"cjkFeatures": false})).unwrap(); + assert!(!has_shown_item(&app, &ty[first..]), "nothing left after the separator"); + app.run("prefs.set", json!({"cjkFeatures": true})).unwrap(); + assert!(has_shown_item(&app, &ty[first..])); +} + /// Preferences › Type › Show CJK Features: left as it is, it keeps following the interface /// language; changed, it is kept in prefs.json. #[test] @@ -2976,3 +3138,17 @@ fn forwarded_keys_run_shortcuts() { forwarded_shortcuts(&mut app, &ctx, press(Key::Delete), Modifiers::NONE); assert!(app.session.active().unwrap().doc.item(designcraft_doc::ItemId(frame["id"].as_u64().unwrap())).is_none(), "Delete clears the selection"); } + +#[test] +fn file_menu_ends_with_quit_but_the_macos_menu_bar_leaves_it_to_the_app_menu() { + let file = |tree: Vec<(&'static str, Vec)>| { + let mut out = Vec::new(); + walk(&tree.into_iter().find(|(t, _)| *t == "File").map(|(_, e)| e).unwrap_or_default(), &mut out); + out.into_iter().map(|(_, id, _)| id).collect::>() + }; + assert_eq!(file(menu_tree()).last().map(String::as_str), Some("app.quit"), "File ends with the quit item"); + assert!(!file(native_menu_tree()).iter().any(|id| id == "app.quit"), "macOS has Quit only in the app menu"); + let native = native_menu_tree(); + let file_items = native.iter().find(|(t, _)| *t == "File").map(|(_, e)| e); + assert!(!matches!(file_items.and_then(|e| e.last()), Some(Item::Sep)), "no separator left at the end of File"); +} diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index b711b8376..6ab2e1765 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -175,13 +175,25 @@ pub fn menu_key(menu: &str, label: &str) -> String { format!("{menu}/{label}") } +/// Whether any shown item is in `rest`, the entries after a separator: the separator is drawn only then, so hidden +/// CJK commands leave no separator at the end of a menu (in the window and in the native macOS menu bar). +pub fn has_shown_item(app: &DesignApp, rest: &[Item]) -> bool { + rest.iter().any(|e| match e { + Item::Cmd { id, .. } => shown(app, id), + Item::Sep => false, + Item::Sub(..) => true, + }) +} + /// Draws a menu's items; returns how many were hidden. pub(super) fn menu_items(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item], path: &str) -> usize { let mut hidden = 0; - for it in items { + for (i, it) in items.iter().enumerate() { match it { Item::Sep => { - ui.separator(); + if has_shown_item(app, &items[i + 1..]) { + ui.separator(); + } } Item::Sub(name, children) => { let sub = format!("{path}/{name}"); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 649469f30..4a75a994e 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -35,7 +35,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{page?: transparency|dropShadow|innerShadow|outerGlow|innerGlow|bevel|satin|feather|directionalFeather|gradientFeather} — the Effects dialog for the selected objects", ), - ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{dir?} — choose the package folder to create (file.package)"), + ( + "app.packageDialog", + "Package…", + Some("Cmd+Alt+Shift+P"), + "{dir?} — the Package dialog (what the package carries, printing instructions), then the folder to create; with dir, packages into it at once (file.package)", + ), ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"), ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"), @@ -109,7 +114,14 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{path | text} — a set saved by InDesign's Show Set (.txt), imported as a new shortcut set, which becomes active: matched commands, panels (Window menu items) and tools (Tools items) take its Default shortcuts (an item without Default keys takes its Text keys), or none when every item matched to them has none; other commands lose keys it takes unless they share them as defaults → {set, imported, changed [{id, label, item, line, from, to}], conflictsCleared [{id, label, shortcut}], skipped [{id, item, line, shortcut, takenBy, reason}] (an earlier item whose keys a later one took, or a reserved key), failed [{id, label, change, shortcuts}], unmatched [{item, line, keys, reason: noMatch | ambiguous | tool | context}], unmatchedWithoutShortcut, unreadable [{item, line, keys, reason}], approximated [{item, line, keys, note}] (numeric keypad keys bound as the main keys), suggestions [{item, line, candidates}] (never applied), matchedWithoutChange [{id, label, item, line, reason}] (a keyless panel-menu item that clears nothing), truncated, shortcutSet (the new set's name)}; ids are command keys (`window.panel {\"panel\":\"layers\"}`)", ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), - ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), + ("app.insertTableDialog", "Insert Table…", Some("Cmd+Alt+Shift+T"), "{} — Insert Table dialog (body/header/footer rows, columns, table style)"), + ( + "app.convertTextToTableDialog", + "Convert Text to Table…", + None, + "{} — Convert Text to Table dialog (column and row separators, number of columns, table style)", + ), + ("app.convertTableToTextDialog", "Convert Table to Text…", None, "{} — Convert Table to Text dialog (column and row separators)"), ( "app.cellOptionsDialog", "Strokes and Fills…", @@ -372,7 +384,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{open?: true, tab?: \"about\"|\"contributors\"|\"models\"} — the About window: splash with community links, contributor and model credits (open: false closes it)", ), ("help.checkForUpdates", "Check for Updates…", None, "{} — checks for newer releases and offers to update"), - ("window.toolsDoubleColumn", "Tools: Double Column", None, "{}"), + ("window.toolsDoubleColumn", "Tools: Double Column", None, "{on?: bool (default: toggle)} → whether the Tools panel has two columns"), ( "window.workspace", "Workspace", diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index 08b1a3e69..0acdb36f4 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -59,7 +59,13 @@ fn fields_for(nd: &NewDocument) -> Map { /// The fields a new New Document dialog starts with: the factory settings on the Recent tab. pub(crate) fn defaults() -> Value { - let mut f = fields_for(&NewDocument::default()); + defaults_for(&NewDocument::default()) +} + +/// The fields New Document starts with for `nd` (the session's default new document, in the +/// default units), on the Recent tab. +pub(crate) fn defaults_for(nd: &NewDocument) -> Value { + let mut f = fields_for(nd); f.insert("tab".into(), json!("recent")); f.insert("preset".into(), json!(DEFAULT_CARD)); f.insert("name".into(), json!("")); @@ -327,7 +333,8 @@ fn cards(app: &DesignApp, tab: &str) -> Vec { Card { key, label, nd, custom } }; // Recent and Saved both start with the factory settings, as in InDesign. - let default = Card { key: DEFAULT_CARD.into(), label: crate::i18n::tr(lang, DEFAULT_CARD).into(), nd: NewDocument::default(), custom: false }; + let default = + Card { key: DEFAULT_CARD.into(), label: crate::i18n::tr(lang, DEFAULT_CARD).into(), nd: app.session.default_new_document(), custom: false }; match tab { "recent" => std::iter::once(default) .chain(app.ui.recent_new_documents.iter().enumerate().map(|(i, nd)| named(format!("recent:{i}"), nd.clone()))) @@ -397,7 +404,7 @@ fn card(ui: &mut Ui, c: &Card, units: Unit, selected: bool) -> egui::Response { let (r, resp) = ui.allocate_exact_size(CARD, Sense::click()); if selected { ui.painter().rect(r.shrink(1.0), 3.0, t.hover, Stroke::new(2.0, t.accent), StrokeKind::Inside); - } else if resp.hovered() { + } else if ui.rect_contains_pointer(r) { ui.painter().rect_filled(r, 3.0, t.hover); } let page = Rect::from_center_size(pos2(r.center().x, r.top() + 56.0), vec2(72.0, 72.0)); @@ -503,13 +510,20 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.spacing_mut().item_spacing = vec2(10.0, 10.0); for c in &list { let resp = card(ui, c, card_units, current == c.key); - if resp.clicked() || resp.double_clicked() { - select(d, c); - } - if resp.double_clicked() { - create = true; - } + let mut remove = false; if tab == "saved" && c.key != DEFAULT_CARD { + if current == c.key || ui.rect_contains_pointer(resp.rect) { + let x = if rtl { resp.rect.left() + 4.0 } else { resp.rect.right() - 26.0 }; + let rect = Rect::from_min_size(pos2(x, resp.rect.top() + 4.0), vec2(22.0, 22.0)); + let button = ui.interact(rect, ui.scope_id().with(("delete-preset", &c.key)), Sense::click()); + let label = format!("{}: {}", crate::i18n::tr(&lang, "Delete Preset"), c.label); + button.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label.as_str())); + if button.hovered() { + ui.painter().rect_filled(rect, 2.0, t.hover); + } + crate::icons::paint(ui.painter(), rect.shrink(3.0), "trash", t.icon); + remove = button.on_hover_text(label).clicked(); + } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Delete Preset"))).clicked() { delete = Some(c.nd.title.clone()); @@ -517,6 +531,12 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); } + if remove { + delete = Some(c.nd.title.clone()); + } else if resp.clicked() || resp.double_clicked() { + select(d, c); + create |= resp.double_clicked(); + } } }); if let Some(name) = delete diff --git a/crates/ui-egui/src/new_document/tests.rs b/crates/ui-egui/src/new_document/tests.rs index 5785aed54..bf63b9c87 100644 --- a/crates/ui-egui/src/new_document/tests.rs +++ b/crates/ui-egui/src/new_document/tests.rs @@ -216,6 +216,43 @@ fn enter_in_the_preset_popup_saves_the_preset_and_keeps_the_dialog() { assert!(!egui::Popup::is_any_open(&h.ctx), "the popup closed"); } +#[test] +fn saved_preset_delete_buttons_keep_the_preview_and_never_create_a_document() { + use egui_kittest::kittest::Queryable as _; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.savePreset", json!({"name":"Portrait draft","pages":3,"facingPages":false})).unwrap(); + app.run("file.savePreset", json!({"name":"Landscape draft","width":792,"height":612,"pages":2})).unwrap(); + app.run("app.newDocumentDialog", json!({})).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + h.run_steps(4); + let saved = h.get_by_label("Saved").rect().center(); + crate::test_window::click_at(&mut h, saved); + let portrait = h.get_by_label("Portrait draft").rect().center(); + crate::test_window::click_at(&mut h, portrait); + h.get_by_label("Preview").click(); + h.run_steps(20); + assert!(h.query_by_label("Delete Preset: Portrait draft").is_some(), "the selected preset has its action"); + assert!(h.query_by_label("Delete Preset: [Default]").is_none(), "the factory card cannot be deleted"); + let before = h.state().app.session.active().unwrap().doc.clone(); + let preview = h.state().app.session.new_document_preview(); + let count = h.state().app.session.documents().len(); + let fields = params(h.state().app.ui.dialog.as_ref().unwrap()); + let landscape = h.get_by_label("Landscape draft").rect().center(); + h.event(egui::Event::PointerMoved(landscape)); + h.run_steps(3); + let remove = h.get_by_label("Delete Preset: Landscape draft").rect().center(); + crate::test_window::click_at(&mut h, remove); + assert_eq!(h.state().app.session.prefs.document_presets.iter().map(|p| p.title.as_str()).collect::>(), ["Portrait draft"]); + let remove = h.get_by_label("Delete Preset: Portrait draft").rect().center(); + crate::test_window::click_at(&mut h, remove); + let app = &h.state().app; + assert!(app.session.prefs.document_presets.is_empty()); + assert_eq!(app.session.new_document_preview(), preview); + assert_eq!(app.session.documents().len(), count); + assert_eq!(*app.session.active().unwrap().doc, *before); + assert_eq!(params(app.ui.dialog.as_ref().unwrap()), fields, "deletion does not select another preset or create the preview"); +} + #[test] fn escape_in_the_preset_popup_closes_only_the_popup() { let mut h = window_with_preset_popup(); diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs new file mode 100644 index 000000000..c176d1b82 --- /dev/null +++ b/crates/ui-egui/src/package_dialog.rs @@ -0,0 +1,622 @@ +//! File › Package…: InDesign's Package dialog. Summary counts what the package carries (fonts, +//! links and images, inks), with a warning where something is missing or modified; Fonts, Links +//! and Images and Colors and Inks list them (Show Problems Only). Package… asks for the folder to +//! create and writes the package (`file.package`), after the Printing Instructions when Create +//! Printing Instructions is on. + +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::app::file_dialogs::Bound; +use crate::dialogs::Dialog; +use crate::theme::semibold; + +/// The Package dialog's id. +pub const ID: &str = "package"; +/// The Printing Instructions dialog's id. +pub const INSTRUCTIONS: &str = "printingInstructions"; + +/// The dialog's pages: (key, label). +const PAGES: [(&str, &str); 4] = [("summary", "Summary"), ("fonts", "Fonts"), ("links", "Links and Images"), ("inks", "Colors and Inks")]; + +/// The process inks every package prints with. +const PROCESS_INKS: [&str; 4] = ["Process Cyan", "Process Magenta", "Process Yellow", "Process Black"]; + +/// The Printing Instructions' fields: (key, label, lines). +const INSTRUCTION_FIELDS: [(&str, &str, usize); 7] = [ + ("contact", "Contact:", 1), + ("company", "Company:", 1), + ("address", "Address:", 3), + ("phone", "Phone:", 1), + ("fax", "Fax:", 1), + ("email", "Email:", 1), + ("instructions", "Instructions:", 4), +]; + +/// Open the Package dialog on its Summary, with the document's fonts, links and spot inks. +pub fn open(app: &mut DesignApp) -> Result<(), String> { + if app.session.active().is_none() { + return Err("no document open".into()); + } + let fonts: Vec = app + .session + .execute("font.list", &json!({})) + .ok() + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|f| { + let status = font_status(f); + let name = format!("{} {}", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or("")).trim().to_string(); + json!({"name": name, "source": f["source"].as_str().unwrap_or(""), "status": status}) + }) + .collect(); + let links: Vec = app + .session + .execute("links.list", &json!({})) + .ok() + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|l| { + let page = l["uses"].as_array().and_then(|u| u.iter().find_map(|u| u["page"].as_str())).unwrap_or(""); + json!({"name": l["name"].as_str().unwrap_or(""), "page": page, "status": l["status"].as_str().unwrap_or("ok")}) + }) + .collect(); + // Spot inks print on their own plates unless the Ink Manager makes them process. + let inks = app.session.execute("ink.list", &json!({})).unwrap_or_default(); + let all_process = inks["allToProcess"].as_bool().unwrap_or(false); + let spot: Vec = inks["inks"] + .as_array() + .map(|a| { + a.iter() + .filter(|i| !all_process && !i["process"].as_bool().unwrap_or(false)) + .filter_map(|i| i["name"].as_str()) + .map(|n| json!(n)) + .collect() + }) + .unwrap_or_default(); + // The packaged PDF's preset: the one last used for Export PDF, as InDesign suggests it. + let last = app.ui.pdf_export.preset.clone(); + let preset = crate::dialogs::pdf_presets().find(|p| *p == last).unwrap_or("Desktop Printing"); + let doc = app.session.active().map(|d| d.uid); + app.ui.dialog = Some(Dialog::new( + ID, + json!({"page": "summary", "doc": doc, "fonts": fonts, "links": links, "spot": spot, "instructions": false, "idml": true, "pdf": false, + "pdfPreset": preset, "problemsOnly": false}), + )); + Ok(()) +} + +/// A `font.list` entry's status here: a missing font, or a style its family lacks, is missing +/// (as `file.package` counts them); a style it stands in for is substituted. +fn font_status(f: &Value) -> &'static str { + if f["missing"].as_bool().unwrap_or(false) || f["styleMissing"].as_bool().unwrap_or(false) { + "missing" + } else if f["matchStatus"] == "styleSubstitute" { + "substituted" + } else { + "ok" + } +} + +/// The document the dialog was opened on is still the active one (automation can switch +/// documents while it is open, and the package must be of the one summarised). +fn same_document(app: &DesignApp, d: &Dialog) -> Result<(), String> { + match app.session.active() { + Some(doc) if d.fields.get("doc").and_then(Value::as_u64) == Some(doc.uid) => Ok(()), + _ => Err("the active document changed after the Package dialog opened".into()), + } +} + +fn rows(d: &Dialog, key: &str) -> Vec { + d.fields.get(key).and_then(Value::as_array).cloned().unwrap_or_default() +} + +/// How many of `rows` have one of `statuses`. +fn count(rows: &[Value], statuses: &[&str]) -> usize { + rows.iter().filter(|r| r["status"].as_str().is_some_and(|s| statuses.contains(&s))).count() +} + +/// The Summary's lines: (text, whether it warns). +fn summary_lines(lang: &str, d: &Dialog) -> Vec<(String, bool)> { + let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); + let (fonts, links) = (rows(d, "fonts"), rows(d, "links")); + let missing_fonts = count(&fonts, &["missing"]); + let (modified, missing) = (count(&links, &["modified"]), count(&links, &["missing"])); + vec![ + ( + tr("Fonts: {used} in use, {missing} unavailable") + .replace("{used}", &fonts.len().to_string()) + .replace("{missing}", &missing_fonts.to_string()), + missing_fonts > 0, + ), + ( + tr("Links and Images: {found} linked, {modified} changed, {missing} not found") + .replace("{found}", &links.len().to_string()) + .replace("{modified}", &modified.to_string()) + .replace("{missing}", &missing.to_string()), + modified + missing > 0, + ), + (tr("Embedded images: {embedded}").replace("{embedded}", &count(&links, &["embedded"]).to_string()), false), + ( + tr("Colors and Inks: {process} process, {spot} spot") + .replace("{process}", &PROCESS_INKS.len().to_string()) + .replace("{spot}", &rows(d, "spot").len().to_string()), + false, + ), + ] +} + +/// How a status is shown: coloured with the theme's warning or error colour. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Level { + Warn, + Bad, +} + +impl Level { + fn color(self, ui: &egui::Ui) -> egui::Color32 { + match self { + Level::Warn => ui.visuals().warn_fg_color, + Level::Bad => ui.visuals().error_fg_color, + } + } +} + +fn status_text(lang: &str, status: &str) -> (String, Option) { + let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); + match status { + "missing" => (format!("\u{26A0} {}", tr("Missing")), Some(Level::Bad)), + "modified" => (format!("\u{25B2} {}", tr("Modified")), Some(Level::Warn)), + "substituted" => (tr("Substituted"), Some(Level::Warn)), + "embedded" => (tr("Embedded"), None), + _ => (tr("OK"), None), + } +} + +/// A table: a header row and `cells` rows (each with an optional level colouring its last cell). +fn table(ui: &mut egui::Ui, id: &str, header: &[String], cells: &[(Vec, Option)]) { + let t = crate::theme::Tokens::get(ui.ctx()); + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.field_border)).inner_margin(6.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt(id).max_height(220.0).auto_shrink([false, true]).show(ui, |ui| { + egui::Grid::new(id).num_columns(header.len()).spacing([18.0, 4.0]).striped(true).show(ui, |ui| { + for h in header { + crate::rtl::label(ui, egui::RichText::new(h).font(semibold(11.5))); + } + ui.end_row(); + for (row, color) in cells { + for (i, c) in row.iter().enumerate() { + let mut text = egui::RichText::new(c); + if i + 1 == row.len() + && let Some(color) = color + { + text = text.color(color.color(ui)); + } + crate::rtl::label(ui, text); + } + ui.end_row(); + } + }); + }); + }); +} + +/// The Package dialog: the pages on the left, the page on the right. +pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let page = d.fields.get("page").and_then(Value::as_str).unwrap_or("summary").to_string(); + ui.horizontal_top(|ui| { + // A fixed height, as Preferences': the divider between the pages and the page fills it, + // and the dialog keeps its size from page to page. + ui.set_min_height(320.0); + ui.set_max_height(320.0); + ui.vertical(|ui| { + ui.set_width(150.0); + for (key, label) in PAGES { + if ui.selectable_label(page == key, crate::rtl::widget(ui, tr(label))).clicked() { + d.fields.insert("page".into(), json!(key)); + } + } + }); + ui.separator(); + ui.vertical(|ui| { + ui.set_min_width(460.0); + let title = PAGES.iter().find(|p| p.0 == page).map_or("Summary", |p| p.1); + crate::rtl::label(ui, egui::RichText::new(tr(title)).font(semibold(13.0))); + ui.add_space(6.0); + let problems_only = d.b("problemsOnly"); + match page.as_str() { + "fonts" => { + let fonts = rows(d, "fonts"); + let cells: Vec<_> = fonts + .iter() + .filter(|f| !problems_only || f["status"] != "ok") + .map(|f| { + let (status, color) = status_text(&lang, f["status"].as_str().unwrap_or("ok")); + let source = match f["source"].as_str().unwrap_or("") { + "bundled" => tr("Bundled"), + "installed" => tr("Installed"), + "document" => tr("Document"), + "added" => tr("Added"), + _ => String::new(), + }; + (vec![f["name"].as_str().unwrap_or("").to_string(), source, status], color) + }) + .collect(); + table(ui, "package_fonts", &[tr("Name"), tr("Source"), tr("Status")], &cells); + } + "links" => { + let links = rows(d, "links"); + let cells: Vec<_> = links + .iter() + .filter(|l| !problems_only || matches!(l["status"].as_str(), Some("missing" | "modified"))) + .map(|l| { + let (status, color) = status_text(&lang, l["status"].as_str().unwrap_or("ok")); + (vec![l["name"].as_str().unwrap_or("").to_string(), l["page"].as_str().unwrap_or("").to_string(), status], color) + }) + .collect(); + table(ui, "package_links", &[tr("Name"), tr("Page"), tr("Status")], &cells); + } + "inks" => { + let mut cells: Vec<_> = PROCESS_INKS.iter().map(|n| (vec![tr(n), tr("Process")], None)).collect(); + cells.extend(rows(d, "spot").iter().filter_map(Value::as_str).map(|n| (vec![n.to_string(), tr("Spot")], None))); + // The ink's kind: the "Type:" key (the "Type" key is the menu's, which reads Text). + let kind = tr("Type:").trim_end_matches([':', ':']).trim_end().to_string(); + table(ui, "package_inks", &[tr("Name"), kind], &cells); + } + _ => { + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Scope:")); + crate::rtl::label(ui, tr("Entire Publication")); + }); + ui.add_space(6.0); + for (line, warn) in summary_lines(&lang, d) { + ui.horizontal(|ui| { + let sign = egui::RichText::new(if warn { "\u{26A0}" } else { " " }).color(Level::Warn.color(ui)); + ui.add_sized([14.0, 16.0], egui::Label::new(sign)); + crate::rtl::label(ui, line); + }); + } + ui.add_space(10.0); + for (key, label) in [("instructions", "Create Printing Instructions"), ("idml", "Include IDML"), ("pdf", "Include PDF (Print)")] { + let mut on = d.b(key); + if ui.checkbox(&mut on, crate::rtl::widget(ui, tr(label))).changed() { + d.fields.insert(key.into(), json!(on)); + } + } + // The PDF's preset, as InDesign's Select PDF Preset. + ui.add_enabled_ui(d.b("pdf"), |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + let name = crate::rtl::label(ui, tr("PDF Preset:")); + let cur = d.s("pdfPreset"); + craft_ui::ComboBox::from_id_salt("package_pdf_preset") + .selected_text(crate::rtl::widget(ui, tr(&cur))) + .width(180.0) + .show_ui(ui, |ui| { + for p in crate::dialogs::pdf_presets() { + if ui.selectable_label(cur == p, crate::rtl::widget(ui, tr(p))).clicked() { + d.fields.insert("pdfPreset".into(), json!(p)); + } + } + }) + .response + .labelled_by(name.id); + }); + }); + } + } + if matches!(page.as_str(), "fonts" | "links") { + ui.add_space(4.0); + let mut on = problems_only; + if ui.checkbox(&mut on, crate::rtl::widget(ui, tr("Show Problems Only"))).changed() { + d.fields.insert("problemsOnly".into(), json!(on)); + } + } + }); + }); +} + +/// The `pdf` of `file.package`: false, or the options of the chosen PDF preset. +fn pdf_param(d: &Dialog) -> Value { + if d.b("pdf") { crate::dialogs::pdf_preset_params(&d.s("pdfPreset")) } else { json!(false) } +} + +/// Package…: the Printing Instructions first when asked for, else the folder. +pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { + same_document(app, d)?; + let (idml, pdf) = (d.b("idml"), pdf_param(d)); + if d.b("instructions") { + app.ui.dialog = Some(Dialog::new(INSTRUCTIONS, json!({"idml": idml, "pdf": pdf, "doc": d.fields.get("doc")}))); + return Ok(Value::Null); + } + package_to_folder(app, idml, pdf, None, d.clone()) +} + +/// The Printing Instructions dialog, as InDesign's: who to contact and what to do. +pub fn show_instructions(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + egui::Grid::new("printing_instructions").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, lines) in INSTRUCTION_FIELDS { + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, label)); + let mut text = d.s(key); + let edit = if lines > 1 { egui::TextEdit::multiline(&mut text).desired_rows(lines) } else { egui::TextEdit::singleline(&mut text) }; + // Named by its label, for screen readers. + let resp = ui.add(edit.desired_width(320.0)).labelled_by(name.id); + if resp.changed() { + d.fields.insert(key.into(), json!(text)); + } + // Enter there makes a new line: the footer mustn't take it for Continue. + if lines > 1 && resp.has_focus() { + crate::dialogs::set_multiline_focus(ui.ctx()); + } + ui.end_row(); + } + }); +} + +/// The instructions as they go into the package's Instructions.txt: the contact lines, then the +/// instructions. Empty fields are left out. +fn instructions_text(d: &Dialog) -> String { + let mut out = String::new(); + for (key, label) in + [("contact", "Contact"), ("company", "Company"), ("address", "Address"), ("phone", "Phone"), ("fax", "Fax"), ("email", "Email")] + { + let v = d.s(key); + let v = v.trim(); + if !v.is_empty() { + out += &format!("{label}: {}\n", v.replace('\n', "\n ")); + } + } + let ins = d.s("instructions"); + if !ins.trim().is_empty() { + if !out.is_empty() { + out.push('\n'); + } + out += ins.trim(); + out.push('\n'); + } + out +} + +/// Continue: the folder, with the instructions. +pub fn confirm_instructions(app: &mut DesignApp, d: &Dialog) -> Result { + same_document(app, d)?; + let text = instructions_text(d); + package_to_folder(app, d.b("idml"), d.fields.get("pdf").cloned().unwrap_or(json!(false)), Some(text).filter(|t| !t.is_empty()), d.clone()) +} + +/// Ask for the folder to create (" Folder") and package the document into it once it is +/// chosen (Cancel packages nothing). Packaging that fails reopens `asking`, the dialog that asked, +/// with its options. `pdf`: +/// false, true or the PDF's `file.exportPdf` options. +pub fn package_to_folder(app: &mut DesignApp, idml: bool, pdf: Value, instructions: Option<String>, asking: Dialog) -> Result<Value, String> { + let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); + app.ask_save(&name, Bound::Document, move |app, dir| { + let mut p = json!({"dir": dir, "idml": idml, "pdf": pdf}); + if let Some(t) = instructions { + p["instructions"] = json!(t); + } + let r = app.run("file.package", p); + match &r { + Ok(v) => app.status( + crate::i18n::tr(&app.ui.language, "Packaged {count} files into {dir}") + .replace("{count}", &v["files"].as_array().map_or(0, |f| f.len()).to_string()) + .replace("{dir}", v["dir"].as_str().unwrap_or("")), + ), + Err(_) if app.ui.dialog.is_none() => app.ui.dialog = Some(asking), + Err(_) => {} + } + r + }) +} + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + /// A document with a text frame and an image whose file has gone, in a temporary folder. + fn app_with_missing_link(dir: &std::path::Path) -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"title": "Packed"})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Hello"})).unwrap(); + let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); + let img = dir.join("gone.png"); + std::fs::write(&img, &png).unwrap(); + app.run("file.place", json!({"path": img.to_string_lossy(), "x": 40, "y": 260, "width": 40})).unwrap(); + std::fs::remove_file(&img).unwrap(); + app + } + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("package_test_fonts"); + if ui.data(|d| d.get_temp::<bool>(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + crate::dialogs::show(app, ui.ctx()); + }, + app, + ) + } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-dialog-{tag}-{}-{nonce}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// The course's chapter 27: File › Package opens InDesign's Package dialog. Summary counts the + /// fonts, links (one missing, with a warning) and inks; Links and Images lists the link, and + /// Show Problems Only keeps it; Create Printing Instructions leads to Printing Instructions, + /// whose Continue asks for the folder and writes the package with the instructions. + #[test] + fn package_shows_a_summary_then_the_printing_instructions() { + let dir = temp_dir("summary"); + let mut app = app_with_missing_link(&dir); + let out = dir.join("Packed Folder"); + let picked = out.to_string_lossy().to_string(); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); + crate::menus::activate(&mut app, "app.packageDialog", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(ID)); + let mut h = harness(app); + h.run_steps(3); + assert!(h.query_by_label("Scope:").is_some()); + assert!(h.query_by_label("Links and Images: 1 linked, 0 changed, 1 not found").is_some()); + assert!(h.query_by_label("Colors and Inks: 4 process, 0 spot").is_some()); + let fonts = h.state().ui.dialog.as_ref().unwrap().fields["fonts"].as_array().unwrap().len(); + assert!(h.query_by_label(&format!("Fonts: {fonts} in use, 0 unavailable")).is_some()); + // Links and Images: the link, missing; Show Problems Only keeps it. + h.get_by_label("Links and Images").click(); + h.run_steps(2); + assert!(h.query_by_label("gone.png").is_some()); + assert!(h.query_by_label("\u{26A0} Missing").is_some()); + h.get_by_label("Show Problems Only").click(); + h.run_steps(2); + assert!(h.query_by_label("gone.png").is_some()); + // Back on Summary: Create Printing Instructions, then Package…. + h.get_by_label("Summary").click(); + h.run_steps(2); + h.get_by_label("Create Printing Instructions").click(); + h.run_steps(2); + h.get_by_label("Package…").click(); + h.run_steps(3); + h.state_mut().settle_dialogs(); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some(INSTRUCTIONS)); + for (key, value) in [("contact", "Dave"), ("email", "dave@example.com"), ("instructions", "Call me if anything is wrong.")] { + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert(key.into(), json!(value)); + } + h.run_steps(2); + h.get_by_label("Continue").click(); + h.run_steps(3); + h.state_mut().settle_dialogs(); + assert!(h.state().ui.dialog.is_none()); + let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); + assert!( + report.contains("Contact: Dave") && report.contains("Email: dave@example.com") && report.contains("Call me if anything is wrong."), + "{report}" + ); + assert!(out.join("Packed.idml").exists(), "Include IDML is on by default"); + assert!(!out.join("Packed.pdf").exists(), "Include PDF is off by default"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Without instructions, Package… goes straight to the folder; Include PDF adds the PDF, made + /// with the PDF preset chosen, and Include IDML off leaves the IDML out. + #[test] + fn package_options_choose_the_idml_and_pdf() { + let dir = temp_dir("options"); + let mut app = app_with_missing_link(&dir); + let out = dir.join("Packed Folder"); + let picked = out.to_string_lossy().to_string(); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); + open(&mut app).unwrap(); + let mut h = harness(app); + h.run_steps(3); + h.get_by_label("Include IDML").click(); + h.run_steps(2); + h.get_by_label("Include PDF (Print)").click(); + h.run_steps(2); + // The PDF's preset: PDF/X-4. + h.get_by_value("Desktop Printing").click(); + h.run_steps(2); + h.get_by_label("PDF/X-4").click(); + h.run_steps(2); + h.get_by_label("Package…").click(); + h.run_steps(3); + h.state_mut().settle_dialogs(); + assert!(h.state().ui.dialog.is_none()); + assert!(out.join("Packed.pdf").exists() && !out.join("Packed.idml").exists()); + let pdf = std::fs::read(out.join("Packed.pdf")).unwrap(); + assert!(pdf.windows(8).any(|w| w == b"GTS_PDFX"), "the PDF/X-4 preset's PDF"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Packaging that fails (here, into a folder under a file) keeps the dialog open with its + /// options, and the status line says why. Without a document there's nothing to open. + #[test] + fn a_failed_package_keeps_the_dialog_open() { + let dir = temp_dir("fails"); + let mut app = app_with_missing_link(&dir); + let blocker = dir.join("blocker"); + std::fs::write(&blocker, b"a file, not a folder").unwrap(); + let picked = blocker.join("Packed Folder").to_string_lossy().to_string(); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); + open(&mut app).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("pdf".into(), json!(true)); + crate::dialogs::confirm(&mut app).unwrap(); + // The folder is chosen on a later frame; packaging into it fails then. + app.settle_dialogs(); + let d = app.ui.dialog.as_ref().expect("the dialog opens again"); + assert_eq!((d.id.as_str(), d.b("pdf")), (ID, true), "with its options"); + assert!(!app.ui.status.is_empty()); + app.run("file.close", json!({})).unwrap(); + app.ui.dialog = None; + assert!(app.run("app.packageDialog", json!({})).is_err()); + assert!(app.ui.dialog.is_none()); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// A style the font's family lacks counts as missing, as `file.package` counts it. + #[test] + fn a_missing_font_style_is_a_missing_font() { + assert_eq!(font_status(&json!({"missing": true})), "missing"); + assert_eq!(font_status(&json!({"missing": false, "styleMissing": true})), "missing"); + assert_eq!(font_status(&json!({"missing": false, "matchStatus": "styleSubstitute"})), "substituted"); + assert_eq!(font_status(&json!({"missing": false})), "ok"); + } + + /// The package is of the document the dialog summarised: when another document became active + /// meanwhile, Package… and Continue refuse, and nothing is written. + #[test] + fn package_refuses_when_the_active_document_changed() { + let dir = temp_dir("switched"); + let mut app = app_with_missing_link(&dir); + let out = dir.join("Packed Folder"); + let picked = out.to_string_lossy().to_string(); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); + open(&mut app).unwrap(); + let d = app.ui.dialog.take().unwrap(); + app.run("file.new", json!({"title": "Other"})).unwrap(); + assert!(confirm(&mut app, &d).is_err()); + let ins = Dialog::new(INSTRUCTIONS, json!({"idml": true, "pdf": false, "doc": d.fields["doc"]})); + assert!(confirm_instructions(&mut app, &ins).is_err()); + assert!(!out.exists(), "nothing packaged"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Enter in Address or Instructions makes a new line there; in a one-line field it continues. + #[test] + fn enter_in_a_multiline_instruction_field_is_a_new_line() { + let dir = temp_dir("enter"); + let mut app = app_with_missing_link(&dir); + let doc = app.session.active().unwrap().uid; + app.ui.dialog = Some(Dialog::new(INSTRUCTIONS, json!({"idml": true, "pdf": false, "doc": doc}))); + let mut h = harness(app); + h.run_steps(3); + let field = |h: &Harness<'static, DesignApp>, n: usize| { + h.query_all_by(|x| x.role() == egui::accesskit::Role::TextInput || x.role() == egui::accesskit::Role::MultilineTextInput) + .nth(n) + .unwrap() + .rect() + .center() + }; + // Address is the third field. + let address = field(&h, 2); + h.event(egui::Event::PointerButton { pos: address, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: address, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some(INSTRUCTIONS), "still open"); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 5f1b8829c..2efdd9ec7 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -36,6 +36,9 @@ use crate::theme::Tokens; pub struct SelInfo { /// Bounds measured from the rulers' zero point (as the X/Y fields show them). pub page_rect: Rect, + /// Width and height of the selected objects themselves (with points selected, `page_rect` + /// measures the points). + pub object_size: (f64, f64), pub count: usize, pub fill: String, pub stroke: String, @@ -82,6 +85,19 @@ pub fn color_target(app: &mut DesignApp) -> Option<(String, f32, String, f32)> { Some((i.fill, sel_tint(app, false), i.stroke, sel_tint(app, true))) } +/// [`color_target`], or with nothing selected the colours new shapes get (the Color and Swatches +/// panels then edit those, as InDesign's do). +pub fn color_target_or_defaults(app: &mut DesignApp) -> Option<(String, f32, String, f32)> { + color_target(app).or_else(|| { + let state = app.session.active()?; + if !state.selection.items.is_empty() || state.selection.text.is_some() || state.selection.cells.is_some() { + return None; + } + let colors = &state.doc.settings.new_object; + Some((colors.fill.clone(), colors.fill_tint, colors.stroke.clone(), colors.stroke_tint)) + }) +} + /// Apply a swatch (and tint) to the fill or stroke of what colour edits apply to. pub fn apply_swatch(app: &mut DesignApp, stroke: bool, swatch: &str, tint: Option<f32>) -> Result<Value, String> { remember_swatch(app, swatch); @@ -162,6 +178,22 @@ pub fn guide_field(app: &DesignApp) -> Option<GuideField> { value.is_finite().then_some(GuideField { guide: *g, orientation: guide.orientation, value, origin, unit }) } +/// The anchor points selected with Direct Selection, each with where it is in its spread. +pub fn selected_anchor_points(app: &DesignApp) -> Vec<((designcraft_doc::ItemId, usize, usize), designcraft_geom::Point)> { + let Some(st) = app.session.active() else { return vec![] }; + let d = &st.doc; + st.selection + .anchors + .iter() + .filter_map(|&(id, si, ai)| { + let loc = d.find(id)?; + let it = d.item(id)?; + let a = it.path.subpaths.get(si)?.anchors.get(ai)?; + Some(((id, si, ai), (d.parent_xf(&loc) * it.xf) * a.p)) + }) + .collect() +} + pub fn sel_info(app: &DesignApp) -> Option<SelInfo> { let st = app.session.active()?; if st.selection.items.is_empty() { @@ -183,7 +215,13 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> { }; b = Some(b.map_or(ib, |r| r.union(ib))); } - let b = b?; + let object_size = b.map_or((0.0, 0.0), |r| (r.width(), r.height())); + // Points selected with Direct Selection: Transform measures the points, not their paths. + let points = selected_anchor_points(app); + let b = match points.split_first() { + Some(((_, p0), rest)) => rest.iter().fold(Rect::from_points(*p0, *p0), |r, (_, p)| r.union_pt(*p)), + None => b?, + }; let first = d.item(st.selection.items[0])?; let loc = d.find(first.id)?; let o = d.ruler_origin(loc.spread, b.center().x)?; @@ -201,6 +239,7 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> { }; Some(SelInfo { page_rect: Rect::new(b.x0 - o.x, b.y0 - o.y, b.x1 - o.x, b.y1 - o.y), + object_size, count: st.selection.items.len(), fill: first.fill.swatch.clone(), stroke: first.stroke.swatch.clone(), diff --git a/crates/ui-egui/src/panels/color_panel_tests.rs b/crates/ui-egui/src/panels/color_panel_tests.rs index cd102b2e3..153216100 100644 --- a/crates/ui-egui/src/panels/color_panel_tests.rs +++ b/crates/ui-egui/src/panels/color_panel_tests.rs @@ -81,3 +81,42 @@ fn a_typed_tint_is_kept_while_typing_and_applied_on_enter_or_leaving_the_field() h.run_steps(3); assert_eq!(fill_tint(h.state(), id), 0.0, "dragging the slider to the left end applies 0 %"); } + +/// The course's chapter 24: with a gradient fill, the Color panel shows the stop selected in the +/// Gradient panel (black, then) and its C/M/Y/K fields colour that stop, keeping the others. +#[test] +fn the_color_panel_edits_the_gradient_stop_selected_in_the_gradient_panel() { + use super::swatches::{GRAD_SEL, RampSel}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + // InDesign's default White → Black. + app.run("object.gradient", json!({})).unwrap(); + let mut h = harness(app); + h.run_steps(2); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(GRAD_SEL), RampSel::Stop(1))); + h.run_steps(3); + assert!(h.query_by_label("R=0 G=0 B=0").is_some(), "the black stop's values"); + // C: the topmost field. + let at = h + .query_all_by(|n| matches!(n.role(), Role::SpinButton | Role::TextInput)) + .map(|n| n.rect()) + .min_by(|a, b| a.min.y.total_cmp(&b.min.y)) + .unwrap() + .center(); + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("100".into())); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let doc = h.state().session.doc().unwrap().doc.clone(); + let fill = &doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch; + let g = designcraft_color::swatch::resolve_gradient(&doc.swatches, fill).cloned().expect("still a gradient"); + assert_eq!(g.stops[1].color, designcraft_color::Color::cmyk(1.0, 0.0, 0.0, 1.0), "C 100 on the black stop"); + assert_eq!(g.stops[0].color, designcraft_color::Gradient::default().stops[0].color, "the white stop stays"); +} diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 22f93ae8c..7272dfeed 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -204,6 +204,25 @@ const THUMB_H: f32 = 37.0; /// Parent rows. const ROW_H: f32 = 29.0; +/// A spread's label under its thumbnails, as InDesign's: one name for a single page, "2–3" for +/// pages numbered on in one section (an en dash), and the names apart ("2, 1", "8, A") when a section starts +/// inside the spread. +pub(crate) fn spread_label(app: &DesignApp, doc: &designcraft_doc::Document, first: usize, n: usize) -> String { + let names: Vec<String> = (first..first + n).map(|abs| app.session.page_label(abs)).collect(); + let starts = |abs: usize| abs == 0 || doc.sections.iter().any(|s| s.start == abs); + match (names.first(), names.last()) { + (Some(a), Some(b)) if names.len() > 1 => { + if !app.session.prefs.absolute_page_numbers && (first + 1..first + n).any(starts) { + names.join(", ") + } else { + format!("{a}\u{2013}{b}") + } + } + (Some(a), _) => a.clone(), + _ => String::new(), + } +} + /// A row of the Pages panel's parents list: [None] (`parent` = `None`) or a parent. struct ParentRow { label: String, @@ -263,6 +282,7 @@ fn parent_icon(app: &mut DesignApp, ui: &egui::Ui, right: egui::Pos2, row: &Pare pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; + let uid = st.uid; let doc = st.doc.clone(); let editing_parents = st.editing_parents; let aspect = (doc.settings.page_width / doc.settings.page_height.max(1.0)) as f32; @@ -407,6 +427,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---- Document spreads. let cur = crate::canvas::current_page(app).unwrap_or(0); let cur_spread = doc.page_loc(cur).map(|l| l.0); + let followed_id = egui::Id::unique("pages_panel_followed"); let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0); let th = opts.page_size.page_h(); @@ -428,22 +449,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let spine = sp.spine_x() as f32; let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover()); let top = row.min.y + 8.0; - // Marker above the current spread. if cur_spread == Some(si) && !editing_parents { - let c = egui::pos2(row.min.x + center, row.min.y + 3.5); - ui.painter().add(egui::Shape::convex_polygon( - vec![c + vec2(-5.0, -3.0), c + vec2(5.0, -3.0), c + vec2(0.0, 3.0)], - t.icon, - Stroke::NONE, - )); + let key = (uid, cur); + if ui.data(|d| d.get_temp::<(u64, usize)>(followed_id)) != Some(key) { + ui.scroll_to_rect(row, None); + ui.data_mut(|d| d.insert_temp(followed_id, key)); + } } - // Pages narrower than their numbers ("10" under an Extra Small page) are labelled - // once for the spread. - let narrow = sp.pages.len() > 1 - && (first..first + sp.pages.len()).zip(&sp.pages).any(|(abs, p)| { - let w = ui.painter().layout_no_wrap(app.session.page_label(abs), crate::theme::semibold(11.0), Color32::WHITE).size().x; - (p.width as f32 * scale) < w + 6.0 - }); for (pi, p) in sp.pages.iter().enumerate() { let abs = first + pi; let x0 = row.min.x + center + (p.x as f32 - spine) * scale; @@ -464,6 +476,35 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } ui.painter().rect_stroke(pr, 0.0, Stroke::new(0.5, Color32::from_gray(0x4a)), StrokeKind::Outside); + if abs == 0 || doc.sections.iter().any(|s| s.start == abs) { + let c = egui::pos2(pr.center().x, row.min.y + 4.0); + ui.painter().add(egui::Shape::convex_polygon( + vec![c + vec2(-4.5, -3.0), c + vec2(4.5, -3.0), c + vec2(0.0, 3.0)], + t.icon, + Stroke::NONE, + )); + let marker = + ui.interact(egui::Rect::from_center_size(c, vec2(12.0, 8.0)), ui.scope_id().with(("section", abs)), Sense::click()); + let label = format!( + "{}: {}", + crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), + app.session.page_label(abs) + ); + marker.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); + // A double click, or the keyboard or a screen reader activating it (a + // single pointer click only selects, as on a page). + let activated = marker.double_clicked() || marker.clicked() && !marker.clicked_by(egui::PointerButton::Primary); + if activated { + if editing_parents { + let _ = app.run("layout.parents.edit", json!({"on": false})); + if let Some(view) = app.view_mut() { + view.fitted = false; + } + } + crate::canvas::go_to_page(app, abs); + crate::menus::activate(app, "layout.section", &serde_json::Value::Null); + } + } // Parent prefix in the outer top corner (black on white). if let Some(pid) = p.parent && let Some(pp) = doc.parents.iter().find(|x| x.id == pid) @@ -501,33 +542,29 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { crate::canvas::go_to_page(app, abs); } resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si)); - // Page number under the page: white on a blue badge when selected. Pages too - // narrow for their numbers share one label under the spread (below). - if narrow { - continue; - } - let name = app.session.page_label(abs); - let g = ui.painter().layout_no_wrap(name, crate::theme::semibold(11.0), Color32::WHITE); - let c = egui::pos2(pr.center().x, pr.max.y + 11.0); - if selected { - let br = egui::Rect::from_center_size(c, vec2(g.size().x.max(7.0) + 7.0, 12.5)); - ui.painter().rect_filled(br, 0.0, BADGE); - } - ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); } - if narrow { - let names: Vec<String> = (first..first + sp.pages.len()).map(|abs| app.session.page_label(abs)).collect(); - let label = match (names.first(), names.last()) { - (Some(a), Some(b)) if names.len() > 1 => format!("{a}\u{2013}{b}"), - (Some(a), _) => a.clone(), - _ => String::new(), - }; - let g = ui.painter().layout_no_wrap(label, crate::theme::semibold(11.0), Color32::WHITE); - let c = egui::pos2(row.min.x + center, top + row_h + 11.0); - if cur_spread == Some(si) && !editing_parents { - ui.painter().rect_filled(egui::Rect::from_center_size(c, vec2(g.size().x + 7.0, 12.5)), 0.0, BADGE); + let label = spread_label(app, &doc, first, sp.pages.len()); + // White on the badge; the theme's text colour elsewhere, so it reads on a light panel. + let badge = cur_spread == Some(si) && !editing_parents; + let ink = if badge { Color32::WHITE } else { t.text_strong }; + let g = ui.painter().layout_no_wrap(label.clone(), crate::theme::semibold(11.0), ink); + let c = egui::pos2(row.min.x + center, top + row_h + 11.0); + let br = egui::Rect::from_center_size(c, vec2(g.size().x + 7.0, 12.5)); + if badge { + ui.painter().rect_filled(br, 0.0, BADGE); + } + ui.painter().galley(c - g.size() / 2.0, g, ink); + let lr = ui.interact(br, ui.scope_id().with(("spread_label", si)), Sense::click()); + // A click goes to the spread, so assistive technology hears a button. + lr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); + if lr.clicked() { + if editing_parents { + let _ = app.run("layout.parents.edit", json!({"on": false})); + if let Some(view) = app.view_mut() { + view.fitted = false; + } } - ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); + crate::canvas::go_to_page(app, first); } } // Right-click on the empty part of the panel: the short menu. @@ -646,7 +683,7 @@ fn empty_menu(app: &mut DesignApp, ui: &mut egui::Ui) { let cur = crate::canvas::current_page(app).unwrap_or(0); let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).clicked(); if item(ui, "Insert Pages…") { - app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": cur, "count": 1}))); + crate::dialogs::open_insert_pages(app, cur); ui.close(); } if item(ui, "Move Pages…") { @@ -680,7 +717,7 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, tr(label))).clicked(); // InDesign's order: pages, parents, layouts and numbering, shuffling. if item(ui, "Insert Pages…") { - app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": abs, "count": 1}))); + crate::dialogs::open_insert_pages(app, abs); ui.close(); } if item(ui, "Move Pages…") { @@ -780,3 +817,140 @@ pub(crate) fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { #[cfg(test)] mod tests; + +#[cfg(test)] +mod spread_label_tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + /// Section-marker activation targets its page and leaves parent editing without editing the document. + #[test] + fn double_clicking_a_section_marker_opens_that_pages_numbering_options() { + let mut app = app(); + app.run("layout.section", json!({"page": 1, "startNumber": 1, "style": "lowerRoman"})).unwrap(); + app.run("layout.section", json!({"page": 3, "startNumber": 1, "style": "arabic"})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))); + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + let mut h = Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("section_marker_test_fonts"); + if ui.data(|d| d.get_temp::<bool>(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let pos = h.get_by_label("Numbering & Section Options: 1").rect().center(); + let before = h.state().session.doc().unwrap().doc.clone(); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerMoved(pos)); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + } + let dialog = h.state().ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.id, "numberingSection"); + assert_eq!(dialog.fields.get("page"), Some(&json!(3))); + assert!(!h.state().session.doc().unwrap().editing_parents); + assert!(std::sync::Arc::ptr_eq(&h.state().session.doc().unwrap().doc, &before), "opening options does not edit the section"); + } + + #[test] + fn a_section_marker_opens_its_options_from_the_keyboard_but_not_on_one_click() { + let mut app = app(); + app.run("layout.section", json!({"page": 3, "startNumber": 5})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))); + let mut h = Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("section_marker_key_test_fonts"); + if ui.data(|d| d.get_temp::<bool>(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Numbering & Section Options: 5").click(); + h.run_steps(2); + assert!(h.state().ui.dialog.is_none(), "one pointer click doesn't open the options"); + h.run_steps(30); + h.get_by_label("Numbering & Section Options: 5").click_accesskit(); + h.run_steps(2); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("numberingSection")); + } + + /// Create a facing-page document for section navigation tests. + fn app() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"facingPages": true, "pages": 9})).unwrap(); + app + } + + /// Read the label displayed for each document spread. + fn labels(app: &DesignApp) -> Vec<String> { + let doc = app.session.doc().unwrap().doc.clone(); + (0..doc.spreads.len()).map(|si| spread_label(app, &doc, doc.first_page_of_spread(si), doc.spreads[si].pages.len())).collect() + } + + /// Section boundaries split a spread label instead of implying continuous numbering. + #[test] + fn spreads_are_labelled_as_indesign_labels_them() { + // Chris Nielsen's "Numbering Page Sections": sections starting at 1 on pages 3 and 7, + // the second in letters, label their spreads "2, 1" and "4, A". + let mut app = app(); + assert_eq!(labels(&app), ["1", "2–3", "4–5", "6–7", "8–9"]); + app.run("layout.section", json!({"page": 3, "startNumber": 1})).unwrap(); + app.run("layout.section", json!({"page": 7, "startNumber": 1, "style": "upperLetters"})).unwrap(); + assert_eq!(labels(&app), ["1", "2, 1", "2–3", "4, A", "B–C"]); + // Absolute numbering: positions, always a range. + app.session.prefs.absolute_page_numbers = true; + assert_eq!(labels(&app), ["1", "2–3", "4–5", "6–7", "8–9"]); + } + + /// Labels navigate to document spreads, including when parent editing was active. + #[test] + fn the_panel_shows_spread_labels_and_a_click_on_one_goes_to_its_spread() { + let mut app = app(); + app.run("layout.section", json!({"page": 3, "startNumber": 1})).unwrap(); + // The canvas the panel's clicks move (not drawn here). + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1000.0, 800.0))); + let mut h = Harness::builder().with_size(vec2(260.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + egui::ScrollArea::vertical().show(ui, |ui| show(app, ui)); + }, + app, + ); + h.run_steps(3); + // A spread label is a button: a click goes to the spread. + let _ = h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("2, 1")); + assert!(h.query_by_label("2 1").is_none()); + // Each page is named for screen readers: two pages are numbered 1. + assert_eq!(h.query_all_by_label("Page 1").count(), 2); + h.state_mut().run("layout.parents.edit", json!({"on": true})).unwrap(); + if let Some(view) = h.state_mut().view_mut() { + view.fitted = true; + } + h.run_steps(3); + h.get_by_label("2–3").click(); + h.run_steps(3); + assert!(!h.state().session.doc().unwrap().editing_parents); + assert!(!h.state().view().unwrap().fitted); + assert_eq!(h.state().session.doc().unwrap().doc.page_loc(crate::canvas::current_page(h.state()).unwrap()).unwrap().0, 2); + } +} diff --git a/crates/ui-egui/src/panels/pages/tests.rs b/crates/ui-egui/src/panels/pages/tests.rs index e70c16f9b..b0dcafb06 100644 --- a/crates/ui-egui/src/panels/pages/tests.rs +++ b/crates/ui-egui/src/panels/pages/tests.rs @@ -343,7 +343,7 @@ fn the_panel_menu_follows_indesigns_order() { assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 3); h.get_by_label("Insert Pages…").click(); h.run_steps(3); - assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "cmd:layout.pages.insert")); + assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "insertPages")); } #[test] diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index a05eaccc5..a9f3b4f67 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -30,10 +30,12 @@ pub use flex::flex_panel; use flex::flex_section; pub(crate) use links::run_update_links; pub use links::{info_panel, links_panel, preflight_panel, update_links}; +#[cfg(test)] +use object::align_to_icon; use object::{align_row, appearance_section, guide_section, quick_actions_object, transform_section, wrap_section}; -pub(crate) use object::{corner_type_dropdown, ref_point_proxy, stroke_type_dropdown}; -use object_panels::pathfinder_row; +pub(crate) use object::{corner_shape_button, corner_type_dropdown, paint_corner_shape, preview_row, ref_point_proxy, stroke_type_dropdown}; pub use object_panels::{align_panel, attributes_panel, effects_panel, pathfinder_panel, stroke_panel, wrap_panel}; +use object_panels::{anchor_point_sections, pathfinder_row}; pub(crate) use text::{LANGUAGES, kerning_control, language_menu}; use text::{ bullets_section, character_section, paragraph_section, quick_actions_text, text_appearance_section, text_frame_section, text_style_section, @@ -86,6 +88,9 @@ fn selection_title(app: &DesignApp, info: &Option<SelInfo>) -> String { if st.selection.text.is_some() { return crate::i18n::tr(&app.ui.language, "Characters").into(); } + if !st.selection.anchors.is_empty() { + return crate::i18n::tr(&app.ui.language, "Anchor Point(s)").into(); + } if info.is_none() && !st.selection.guides.is_empty() { return crate::i18n::tr(&app.ui.language, "Ruler Guide").into(); } @@ -153,6 +158,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { document_sections(app, ui); return; }; + if app.session.active().is_some_and(|st| !st.selection.anchors.is_empty()) { + anchor_point_sections(app, ui, &i); + return; + } section(ui, crate::i18n::tr(&app.ui.language, "Transform"), true); transform_section(app, ui, &i); divider(ui); @@ -202,7 +211,21 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("object.fit", json!({"mode": mode})); } } + // Frame Fitting Options…, as InDesign's Options button beside the fitting buttons. + if outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), 64.0).clicked() { + crate::menus::activate(app, "app.fittingOptions", &Value::Null); + } }); + // Auto-Fit: the graphic is refitted whenever its frame is resized. + let mut auto = app + .session + .active() + .and_then(|st| st.selection.items.first().and_then(|id| st.doc.item(*id))) + .and_then(|it| it.graphic()) + .is_some_and(|g| g.auto_fit != designcraft_doc::Fitting::None); + if widgets::checkbox(ui, &mut auto, crate::i18n::tr(&app.ui.language, "Auto-Fit")).changed() { + let _ = app.run("object.fittingOptions", json!({"autoFit": auto})); + } divider(ui); } section(ui, crate::i18n::tr(&app.ui.language, "Text Wrap"), true); diff --git a/crates/ui-egui/src/panels/properties/document.rs b/crates/ui-egui/src/panels/properties/document.rs index 0a7fdbc5a..d68d4531c 100644 --- a/crates/ui-egui/src/panels/properties/document.rs +++ b/crates/ui-egui/src/panels/properties/document.rs @@ -172,6 +172,15 @@ fn page_preset_menu(ui: &mut Ui, pw: f64, ph: f64) -> Option<(f64, f64)> { preset } +/// As the status bar's page box: the page, or the parent shown ("A-Parent"); then the parents. +fn page_box_field(app: &mut DesignApp, ui: &mut Ui, b: Rect) { + let Some(pb) = crate::canvas::page_box(app) else { return }; + let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| widgets::dropdown_list(ui, pb.current_name(), 93.0, &pb.names, Some(pb.current))); + if let Some(i) = picked { + crate::canvas::page_box_go(app, &pb, i); + } +} + pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) { let u = units(app); let Some(st) = app.session.active() else { return }; @@ -287,13 +296,7 @@ pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) { // Page. section(ui, crate::i18n::tr(&app.ui.language, "Page"), true); let b = block(ui, FIELD_H); - let names: Vec<String> = (0..d.page_count()).map(|i| app.session.page_label(i)).collect(); - let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| { - widgets::dropdown_list(ui, &names[cur.min(names.len().saturating_sub(1))], 93.0, &names, Some(cur)) - }); - if let Some(pg) = picked { - crate::canvas::go_to_page(app, pg); - } + page_box_field(app, ui, b); let (add, del) = place(ui, sub(b, 172.0, 0.0, 50.0, FIELD_H), |ui| { ui.spacing_mut().item_spacing.x = 4.0; let a = widgets::icon_toggle_sized(ui, "style-new", false, "Insert Page", vec2(22.0, 21.0)).clicked(); diff --git a/crates/ui-egui/src/panels/properties/flex.rs b/crates/ui-egui/src/panels/properties/flex.rs index 591ce4cfd..35429cc45 100644 --- a/crates/ui-egui/src/panels/properties/flex.rs +++ b/crates/ui-egui/src/panels/properties/flex.rs @@ -171,7 +171,7 @@ pub(super) fn flex_section(app: &mut DesignApp, ui: &mut Ui) { let cur = serde_json::to_value(cur).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); let icon = FLEX_JUSTIFY.iter().find(|j| j.0 == cur).map(|j| j.1).unwrap_or("flex-start"); let resp = place(ui, sub(b, 0.0, 0.0, 58.0, FIELD_H), |ui| { - preview_dropdown(ui, 58.0, |p, r| icons::paint(p, r.shrink2(vec2(6.0, 2.0)), icon, egui::Color32::from_gray(60))) + preview_dropdown(ui, 58.0, &tr("Justification"), |p, r| icons::paint(p, r.shrink2(vec2(6.0, 2.0)), icon, egui::Color32::from_gray(60))) }); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(150.0); diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 3819894fe..a68974f81 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -91,6 +91,75 @@ pub(super) fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } } +/// Properties › Transform for points selected with Direct Selection: X/Y/W/H of the points' +/// bounds (from the reference point); X and Y move the points, W and H spread or gather them +/// about the reference point. No angles: those turn objects. +pub(super) fn anchor_transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { + let u = units(app); + let rp = app.ui.ref_point; + let b = block(ui, 2.0 * FIELD_H + 4.0); + let r = i.page_rect; + let (fx, fy) = ((rp % 3) as f64 / 2.0, (rp / 3) as f64 / 2.0); + let (x, y) = (r.x0 + r.width() * fx, r.y0 + r.height() * fy); + if let Some(p) = ref_point_proxy(ui, sub(b, 5.0, 10.0, 26.0, 26.0), rp) { + app.ui.ref_point = p; + } + let mut set = serde_json::Map::new(); + // A single point has no size: as InDesign, W and H show its object's, greyed out. + let single = crate::panels::selected_anchor_points(app).len() == 1; + let (w, h) = if single { i.object_size } else { (r.width(), r.height()) }; + for (row, (lx, vx, kx, lw, vw, kw)) in [("X:", x, "x", "W:", w, "width"), ("Y:", y, "y", "H:", h, "height")].into_iter().enumerate() { + let yy = row as f32 * 25.0; + place(ui, sub(b, 40.0, yy, 18.0, FIELD_H), |ui| widgets::caption_w(ui, lx, 18.0)); + if let Some(v) = place(ui, sub(b, 59.0, yy, 54.0, FIELD_H), |ui| measure(ui, &format!("pt_{kx}"), Some(vx), u, 54.0)) { + set.insert(kx.into(), json!(v)); + } + place(ui, sub(b, 120.0, yy, 19.0, FIELD_H), |ui| widgets::caption_w(ui, lw, 19.0)); + let id = format!("pt_{kw}"); + let field = NumField::measure(&id, Some(vw), u).width(54.0).enabled(!single); + if let Some(v) = place(ui, sub(b, 140.0, yy, 54.0, FIELD_H), |ui| field.show(ui)) { + set.insert(kw.into(), json!(v)); + } + } + if !set.is_empty() { + let _ = transform_anchors(app, &Value::Object(set)); + } + if more_options(ui, &app.ui.language).clicked() { + show_panel(app, "info"); + } +} + +/// Move the selected points as Properties › Transform's `set` asks ({x?, y?, width?, height?}, +/// in the page coordinates the fields show, from the reference point): one `path.moveAnchors` +/// step, each point by its own movement. A width or height of a single point (0) can't scale. +pub(crate) fn transform_anchors(app: &mut DesignApp, set: &Value) -> Result<Value, String> { + let points = crate::panels::selected_anchor_points(app); + let Some(i) = crate::panels::sel_info(app) else { return Ok(Value::Null) }; + if points.is_empty() { + return Ok(Value::Null); + } + let rp = app.ui.ref_point; + let r = i.page_rect; + let (fx, fy) = ((rp % 3) as f64 / 2.0, (rp / 3) as f64 / 2.0); + // Page coordinates and spread coordinates differ by the ruler origin only. + let b = points.iter().skip(1).fold(designcraft_geom::Rect::from_points(points[0].1, points[0].1), |b, (_, p)| b.union_pt(*p)); + let origin = (b.x0 - r.x0, b.y0 - r.y0); + let reference = (r.x0 + r.width() * fx + origin.0, r.y0 + r.height() * fy + origin.1); + let num = |k: &str| set.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + let scale = |new: Option<f64>, old: f64| new.filter(|_| old > 1e-9).map_or(1.0, |v| v.max(0.0) / old); + let (kx, ky) = (scale(num("width"), r.width()), scale(num("height"), r.height())); + let shift = (num("x").map_or(0.0, |v| v - (r.x0 + r.width() * fx)), num("y").map_or(0.0, |v| v - (r.y0 + r.height() * fy))); + let moved: Vec<Value> = points + .iter() + .map(|&((id, s, a), p)| { + let nx = reference.0 + (p.x - reference.0) * kx + shift.0; + let ny = reference.1 + (p.y - reference.1) * ky + shift.1; + json!([id.0, s, a, nx - p.x, ny - p.y]) + }) + .collect(); + app.run("path.moveAnchors", json!({"points": moved})) +} + /// Properties › Transform for selected ruler guides: X for one vertical guide, Y for one /// horizontal guide; with several guides both fields are empty. pub(super) fn guide_section(app: &mut DesignApp, ui: &mut Ui) { @@ -173,11 +242,11 @@ pub(crate) fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, ("Thin - Thin", "thinThin"), ("Thick - Thick", "thickThick"), ]; - let resp = preview_dropdown(ui, w, |p, r| paint_stroke_type(p, r, cur)); + let resp = preview_dropdown(ui, w, crate::i18n::tr(&app.ui.language, "Stroke Type"), |p, r| paint_stroke_type(p, r, cur, egui::Color32::BLACK)); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(140.0); + ui.set_min_width(170.0); for (label, kind) in TYPES { - if ui.selectable_label(*kind == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + if preview_row(ui, *kind == cur, crate::i18n::tr(&app.ui.language, label), 40.0, |p, r, c| paint_stroke_type(p, r, kind, c)).clicked() { let ty = if *kind == "dashed" { json!({"kind": "dashed", "pattern": [12.0, 4.0]}) } else { json!({"kind": kind}) }; let _ = app.run("object.stroke", json!({"type": ty})); ui.close(); @@ -186,8 +255,7 @@ pub(crate) fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, }); } -fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str) { - let c = egui::Color32::BLACK; +fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str, c: egui::Color32) { let (x0, x1, y) = (r.min.x + 5.0, r.max.x - 5.0, r.center().y); match kind { "dashed" => { @@ -220,10 +288,44 @@ fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str) { } } -/// A dropdown whose face is a light preview box (stroke type, corner shape). -pub(super) fn preview_dropdown(ui: &mut Ui, w: f32, paint: impl FnOnce(&egui::Painter, Rect)) -> egui::Response { +/// A row of a preview dropdown's list, as InDesign's: a small preview of the choice (`preview_w` +/// wide), then its name. Selected rows are highlighted. +pub(crate) fn preview_row( + ui: &mut Ui, + selected: bool, + label: &str, + preview_w: f32, + paint: impl FnOnce(&egui::Painter, Rect, egui::Color32), +) -> egui::Response { + let t = Tokens::get(ui.ctx()); + // As wide as the preview and its name, translated (at least 130 pt for the name), and as the + // popup once its widest row has set it: the rows' highlights then end together. + let font = egui::FontId::proportional(12.0); + let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font, egui::Color32::WHITE).size().x); + let w = (preview_w + 12.0 + text_w + 12.0).max(preview_w + 130.0).max(ui.available_width().min(600.0)); + let (r, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click()); + if selected || resp.hovered() { + ui.painter().rect_filled(r, 2.0, if selected { t.row_selected } else { t.hover }); + } + let ink = if selected { t.text_strong } else { t.text }; + paint(ui.painter(), Rect::from_min_size(r.min + vec2(4.0, 0.0), vec2(preview_w, 22.0)), ink); + crate::rtl::paint( + ui.painter(), + pos2(r.min.x + preview_w + 12.0, r.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.0), + ink, + ); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label)); + resp +} + +/// A dropdown whose face is a light preview box (stroke type, corner shape), named `label`. +pub(super) fn preview_dropdown(ui: &mut Ui, w: f32, label: &str, paint: impl FnOnce(&egui::Painter, Rect)) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, label)); let face = Rect::from_min_max(r.min, pos2(r.max.x - widgets::SEG_W, r.max.y)); ui.painter().rect_filled(face, 0.0, egui::Color32::from_gray(0xdf)); paint(ui.painter(), face); @@ -234,38 +336,62 @@ pub(super) fn preview_dropdown(ui: &mut Ui, w: f32, paint: impl FnOnce(&egui::Pa resp } -fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape) { - let c = egui::Color32::BLACK; - let s = Stroke::new(1.5, c); - let (x0, y0, x1, y1) = (r.min.x + 4.0, r.min.y + 4.0, r.max.x - 9.0, r.max.y - 3.0); - let k = 8.0; - let pts: Vec<egui::Pos2> = match shape { - CornerShape::None => vec![pos2(x0, y0), pos2(x1, y0), pos2(x1, y1)], - CornerShape::Bevel => vec![pos2(x0, y0), pos2(x1 - k, y0), pos2(x1, y0 + k), pos2(x1, y1)], - CornerShape::Inset => vec![pos2(x0, y0), pos2(x1 - k, y0), pos2(x1 - k, y0 + k), pos2(x1, y0 + k), pos2(x1, y1)], - CornerShape::Rounded | CornerShape::InverseRounded | CornerShape::Fancy => { - let mut v = vec![pos2(x0, y0)]; - for i in 0..=8 { - let a = i as f32 / 8.0 * std::f32::consts::FRAC_PI_2; - v.push(if shape == CornerShape::InverseRounded { - pos2(x1 - k * a.cos(), y0 + k * a.sin()) - } else { - pos2(x1 - k + k * a.sin(), y0 + k - k * a.cos()) - }); - } - v.push(pos2(x1, y1)); - v - } +/// A corner shape's glyph, as InDesign draws it in its corner lists: the top edge running into the +/// corner and the right edge leaving it, in a square centred in `r`. +pub(crate) fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape, c: egui::Color32) { + let side = (r.width() - 3.0).min(r.height() - 3.0).max(4.0); + let o = pos2(r.center().x - side / 2.0, r.center().y - side / 2.0); + let at = |x: f32, y: f32| pos2(o.x + x * side, o.y + y * side); + let quarter = |cx: f32, cy: f32, rad: f32, from: f32, to: f32| -> Vec<egui::Pos2> { + (0..=10).map(|i| from + (to - from) * i as f32 / 10.0).map(|a| at(cx + rad * a.cos(), cy + rad * a.sin())).collect() }; - p.add(egui::Shape::line(pts, s)); + use std::f32::consts::{FRAC_PI_2, PI}; + let stroke = Stroke::new(1.6, c); + let line = |pts: Vec<egui::Pos2>| { + p.add(egui::Shape::line(pts, stroke)); + }; + match shape { + CornerShape::None => line(vec![at(0.0, 0.0), at(1.0, 0.0), at(1.0, 1.0)]), + // The corner cut by a diagonal, half the side. + CornerShape::Bevel => line(vec![at(0.0, 0.0), at(0.5, 0.0), at(1.0, 0.5), at(1.0, 1.0)]), + // A square notch. + CornerShape::Inset => line(vec![at(0.0, 0.0), at(0.5, 0.0), at(0.5, 0.45), at(1.0, 0.45), at(1.0, 1.0)]), + // A concave quarter circle centred on the corner. + CornerShape::InverseRounded => { + let mut v = vec![at(0.0, 0.0)]; + v.extend(quarter(1.0, 0.0, 0.5, PI, FRAC_PI_2)); + v.push(at(1.0, 1.0)); + line(v) + } + // A large convex arc. + CornerShape::Rounded => { + let mut v = vec![at(0.0, 0.0)]; + v.extend(quarter(0.08, 0.92, 0.92, -FRAC_PI_2, 0.0)); + v.push(at(1.0, 1.0)); + line(v) + } + // Steps into the corner with a small square loop between them. + CornerShape::Fancy => { + line(vec![at(0.0, 0.0), at(0.38, 0.0), at(0.38, 0.2), at(0.52, 0.2)]); + p.rect_stroke(Rect::from_min_max(at(0.52, 0.06), at(0.76, 0.3)), 0.0, stroke, StrokeKind::Middle); + line(vec![at(0.76, 0.3), at(0.76, 0.48), at(1.0, 0.48), at(1.0, 1.0)]); + } + } +} + +/// A corner shape picker's face: the shape's glyph and the dropdown arrow (the menu is the caller's). +pub(crate) fn corner_shape_button(ui: &mut Ui, shape: CornerShape, width: f32) -> egui::Response { + // The caller names the control (widget_info) once it knows the corner. + preview_dropdown(ui, width, "", |p, r| paint_corner_shape(p, r, shape, egui::Color32::BLACK)) } pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: CornerShape, size: f64, width: f32) { - let resp = preview_dropdown(ui, width, |p, r| paint_corner_shape(p, r, shape)); + let resp = + preview_dropdown(ui, width, crate::i18n::tr(&app.ui.language, "Corner Shape"), |p, r| paint_corner_shape(p, r, shape, egui::Color32::BLACK)); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(140.0); + ui.set_min_width(150.0); for s in CornerShape::ALL { - if ui.selectable_label(s == shape, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, s.label()))).clicked() { + if preview_row(ui, s == shape, crate::i18n::tr(&app.ui.language, s.label()), 22.0, |p, r, c| paint_corner_shape(p, r, s, c)).clicked() { let _ = app.run("object.cornerOptions", json!({"shape": s, "size": if size > 0.0 { size } else { 12.0 }})); ui.close(); } @@ -276,6 +402,10 @@ pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: Corn pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + // Every selected object is a text frame: the popups offer Apply To Frame or Text, for one + // frame or several, as InDesign's do. + let text_frames = + i.is_text && app.session.active().is_some_and(|st| st.selection.items.iter().all(|id| doc.item(*id).is_some_and(|it| it.is_text_frame()))); // Fill. let b = block(ui, FIELD_H); let (fc, fg) = widgets::swatch_colors(&doc, &i.fill, 1.0); @@ -288,7 +418,10 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) i.fill )); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Fill swatch")); - swatch_menu(app, &resp, &doc, &i.fill, false, i.is_text && i.count == 1, false); + swatch_menu(app, &resp, &doc, &i.fill, false, text_frames, false); + if resp.double_clicked() { + open_color_picker(app, &doc, &i.fill, false, false); + } crate::rtl::paint( ui.painter(), b.min + vec2(34.0, FIELD_H / 2.0), @@ -310,7 +443,10 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) i.stroke )); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Stroke swatch")); - swatch_menu(app, &resp, &doc, &i.stroke, true, i.is_text && i.count == 1, false); + swatch_menu(app, &resp, &doc, &i.stroke, true, text_frames, false); + if resp.double_clicked() { + open_color_picker(app, &doc, &i.stroke, true, false); + } if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Stroke"), 56.0)).clicked() { show_panel(app, "stroke"); } @@ -327,7 +463,8 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) let b = block(ui, FIELD_H); icons::paint(ui.painter(), sub(b, 3.0, 1.0, 19.0, 19.0), "corner", t.accent); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Corner"), 56.0)).clicked() { - app.ui.dialog = Some(crate::dialogs::Dialog::new("frameSize", json!({}))); + // Corner Options, as Object › Corner Options… opens it. + crate::menus::activate(app, "object.cornerOptions", &Value::Null); } let (shape, size) = i.corner; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { @@ -355,9 +492,8 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) let mresp = ui.interact(more, ui.scope_id().with("opmore"), Sense::click()); ui.painter().rect(more, 0.0, if mresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.field_border), StrokeKind::Inside); icons::paint(ui.painter(), more.shrink(4.0), "chevron-right", t.icon); - if mresp.clicked() { - show_panel(app, "effects"); - } + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(&app.ui.language, "Opacity"))); + opacity_slider(app, &mresp, i.opacity); ui.add_space(6.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; @@ -370,6 +506,54 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) }); } +/// The Color Picker for a Fill or Stroke chip (a double-click on it, as in InDesign), with the +/// chip's colour: the objects', or with `text` the selected text's. +pub(super) fn open_color_picker(app: &mut DesignApp, doc: &designcraft_doc::Document, swatch: &str, stroke: bool, text: bool) { + let mut fields = crate::panels::swatches::picker_fields(doc.resolve_color(swatch, 1.0), stroke); + fields["text"] = json!(text); + app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", fields)); +} + +/// The Opacity slider that the arrow beside the field opens, as in InDesign: a drag shows the +/// new opacity live and is one undo step when let go. +fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { + let (mut slid, mut held, mut shown) = (None, false, false); + egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + shown = true; + // A thin rail and a round knob, as InDesign draws it. + ui.spacing_mut().slider_width = 130.0; + ui.spacing_mut().slider_rail_height = 2.0; + let mut v = (opacity * 100.0).round(); + let slider = ui.add( + egui::Slider::new(&mut v, 0.0..=100.0).integer().show_value(false).handle_shape(egui::style::HandleShape::Circle).trailing_fill(false), + ); + // The slider has no visible caption (the field beside the arrow is the label), so it's + // named for assistive technology only. + let label = crate::i18n::tr(&app.ui.language, "Opacity"); + slider.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), v, label)); + // The release frame can move the value too; it stays part of the drag's preview, which + // the next frame commits as the one undo step. + let pressed = slider.is_pointer_button_down_on() || slider.dragged() || slider.drag_stopped(); + if slider.changed() { + slid = Some((v, pressed)); + } + held = pressed; + }); + match slid { + Some((v, true)) => app.live_preview("Opacity", "object.opacity", json!({"opacity": v / 100.0})), + Some((v, false)) => { + // The keyboard (arrow keys on the focused slider): applied at once. + app.end_live_preview(); + let _ = app.run("object.opacity", json!({"opacity": v / 100.0})); + } + None if held => app.hold_live_preview(), + // Shown but let go without a change (a click outside, Escape): the drag's preview is + // committed now, before whatever that click does, so that keeps its own undo step. + None if shown => app.end_live_preview(), + None => {} + } +} + /// The Fill (or Stroke) popup of Properties › Appearance, as InDesign's: Apply To (a text frame's /// frame or its text), the tint, then the swatches. `cur` is the frame's swatch. With `text_only` /// (text selected) it colours the selected text. @@ -446,6 +630,17 @@ pub(super) fn swatch_menu( } } +/// The Align To button's glyph shows what the objects align to, as InDesign's. +pub(super) fn align_to_icon(to: &str) -> &'static str { + match to { + "keyObject" => "align-to-key", + "margins" => "align-to-margins", + "page" => "align-to-page", + "spread" => "align-to-spread", + _ => "align-to", + } +} + pub(super) fn align_row(app: &mut DesignApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { @@ -462,7 +657,7 @@ pub(super) fn align_row(app: &mut DesignApp, ui: &mut Ui) { other => other, }; let tip = format!("{}: {}", crate::i18n::tr(&app.ui.language, "Align To"), crate::i18n::tr(&app.ui.language, to_label)); - let resp = icon_toggle(ui, "align-to", false, &tip); + let resp = icon_toggle(ui, align_to_icon(&to), false, &tip); egui::Popup::menu(&resp).show(|ui| { for (label, id) in [ ("Align to Selection", "selection"), @@ -516,14 +711,75 @@ pub(super) fn wrap_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let enabled = i.wrap != "none"; let r = ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, &mut inv, crate::i18n::tr(&app.ui.language, "Invert"))).inner; if r.changed() { - let _ = app.run("object.textWrap", json!({"mode": i.wrap, "invert": inv})); + // Each object keeps its own mode. + let _ = app.run("object.textWrap", json!({"mode": null, "invert": inv})); } }); + wrap_offsets(app, ui, i); if more_options(ui, &app.ui.language).clicked() { show_panel(app, "textWrap"); } } +/// The wrap offsets under the wrap buttons, as InDesign's Properties panel shows them: Top and +/// Bottom, the link that makes all four the same, then Left and Right. Greyed with no wrap. +fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let u = units(app); + let enabled = i.wrap != "none"; + let offsets = i.wrap_offsets; + ui.add_space(4.0); + let b = block(ui, 2.0 * FIELD_H + 5.0); + let mut set = None; + // The link's state belongs to the selection: another selection starts linked again when its + // four offsets are the same. + let selected: Vec<u64> = app.session.active().map(|st| st.selection.items.iter().map(|i| i.0).collect()).unwrap_or_default(); + let link_id = egui::Id::unique(("prop_wrap_linked", selected)); + ui.add_enabled_ui(enabled, |ui| { + // Linked while the four are the same, until the link is clicked. + let same = offsets.iter().all(|v| (v - offsets[0]).abs() < 1e-9); + let linked = ui.data(|d| d.get_temp::<bool>(link_id)).unwrap_or(same); + // Offsets are [top, left, bottom, right]. + for (k, key, icon, tip, x, y) in [ + (0, "wrapTop", "margin-top", "Top", 0.0, 0.0), + (2, "wrapBottom", "margin-bottom", "Bottom", 0.0, FIELD_H + 5.0), + (1, "wrapLeft", "margin-inside", "Left", 140.0, 0.0), + (3, "wrapRight", "margin-outside", "Right", 140.0, FIELD_H + 5.0), + ] { + let ir = sub(b, x, y + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, if enabled { t.icon } else { t.text_dim }); + ui.interact(ir, ui.scope_id().with(("prop_wrap_off", k)), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, tr(tip)); + }); + let nv = place(ui, sub(b, x + 20.0, y, 76.0, FIELD_H), |ui| { + NumField::measure(key, Some(offsets[k]), u).width(76.0).spinner().range(-8000.0, 8000.0).enabled(enabled).show(ui) + }); + if let Some(v) = nv { + // Linked, all four; else this side alone, each object keeping its other offsets. + let mut o = [None; 4]; + if linked { + o = [Some(v); 4]; + } else { + o[k] = Some(v); + } + set = Some(o); + } + } + if place(ui, sub(b, 106.0, (FIELD_H + 5.0) / 2.0, 22.0, FIELD_H), |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, &tr("Make all settings the same"), vec2(22.0, 21.0)) + .clicked() + }) { + ui.data_mut(|d| d.insert_temp(link_id, !linked)); + } + }); + if let Some(o) = set { + // Each object keeps its own mode: only the offsets change. + let _ = app.run("object.textWrap", json!({"mode": null, "offset": o})); + } +} + fn arrange_menu(app: &mut DesignApp, resp: &egui::Response) { egui::Popup::menu(resp).show(|ui| { for (label, cmd) in [ diff --git a/crates/ui-egui/src/panels/properties/object_panels.rs b/crates/ui-egui/src/panels/properties/object_panels.rs index 9d02bec3f..8e6c210de 100644 --- a/crates/ui-egui/src/panels/properties/object_panels.rs +++ b/crates/ui-egui/src/panels/properties/object_panels.rs @@ -1,6 +1,7 @@ +use super::object::{anchor_transform_section, appearance_section}; use super::{ - Arrowhead, Cap, DesignApp, Join, Sense, Stroke, StrokeAlign, StrokeType, Tokens, Ui, Value, caption, divider, icon_toggle, icons, json, measure, - number, pos2, sel_info, text_attrs, units, vec2, widgets, + Arrowhead, Cap, DesignApp, Join, SelInfo, Sense, Stroke, StrokeAlign, StrokeType, Tokens, Ui, Value, caption, divider, full_width, icon_toggle, + icons, json, measure, number, outline_button, pos2, rule, section, section_row, sel_info, text_attrs, units, vec2, widgets, }; /// Stroke panel: weight, cap, miter limit, join, alignment, type, arrowheads, gap colour. @@ -364,6 +365,37 @@ const CONVERT_SHAPE: [(&str, &str, &str); 9] = [ ("shape-orthogonal", "orthogonalLine", "Orthogonal Line"), ]; +/// Points selected with Direct Selection, as InDesign's Properties shows them: Transform, +/// Appearance, Convert Points (the point type of the selected points) and Delete Point(s). +pub(super) fn anchor_point_sections(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { + section(ui, crate::i18n::tr(&app.ui.language, "Transform"), true); + anchor_transform_section(app, ui, i); + divider(ui); + section(ui, crate::i18n::tr(&app.ui.language, "Appearance"), true); + appearance_section(app, ui, i); + divider(ui); + let mut kind = None; + let lang = app.ui.language.clone(); + section_row(ui, crate::i18n::tr(&lang, "Convert Points"), |ui| { + ui.spacing_mut().item_spacing.x = 14.0; + // Laid out from the right: last first, so the row reads Plain … Symmetrical. + for (icon, k, tip) in CONVERT_POINT.iter().rev() { + if icon_toggle(ui, icon, false, crate::i18n::tr(&lang, tip)).clicked() { + kind = Some(k); + } + } + }); + if let Some(k) = kind { + let _ = app.run("path.convertPoints", json!({"kind": k})); + } + rule(ui); + section(ui, crate::i18n::tr(&app.ui.language, "Quick Actions"), true); + let fw = full_width(ui); + if outline_button(ui, crate::i18n::tr(&app.ui.language, "Delete Point(s)"), fw).clicked() { + let _ = app.run("path.deletePoints", json!({})); + } +} + /// Pathfinder panel › Convert Point: (icon, kind, tooltip). const CONVERT_POINT: [(&str, &str, &str); 4] = [ ("point-plain", "plain", "Plain"), diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 095bd5690..7b3f2edb3 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -3,6 +3,7 @@ use egui_kittest::{ kittest::{NodeT, Queryable}, }; +use super::object::transform_anchors; use super::*; /// A link to a panel whose flyout is open leaves the flyout open. @@ -638,6 +639,66 @@ fn clicking_a_swatch_chip_in_the_appearance_fill_menu_applies_it() { assert_eq!(after, name, "clicking the swatch chip applies it"); } +/// The course's chapter 23: a double-click on Properties › Appearance › Fill opens the Color +/// Picker with the fill's colour, as InDesign's (a click opens the swatches). For selected +/// text, the text's Fill chip opens it for the text. +#[test] +fn double_clicking_a_fill_chip_opens_the_color_picker() { + let double_click = |h: &mut Harness<'_, DesignApp>, label: &str| { + let at = h.get_by_label(label).rect().center(); + h.hover_at(at); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + // Frames 1 ms apart, so two clicks make a double-click. + let panel = |app: DesignApp| { + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).with_step_dt(0.001).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + }; + let frame = || { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("object.fill", json!({"swatch": "C=0 M=0 Y=100 K=0"})).unwrap(); + app + }; + let mut h = panel(frame()); + double_click(&mut h, "Fill swatch"); + assert!(h.query_by_label("Tint:").is_none(), "the swatches popup isn't left open too"); + let d = h.state().ui.dialog.clone().expect("the Color Picker"); + assert_eq!((d.id.as_str(), d.s("target"), d.s("model"), d.b("text")), ("colorPicker", "fill".into(), "cmyk".into(), false)); + assert_eq!(d.fields["cmyk"], json!([0.0, 0.0, 100.0, 0.0])); + let mut h = panel(frame()); + double_click(&mut h, "Stroke swatch"); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.s("target")), Some("stroke".into())); + // Selected text: its own Fill chip. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Lemon"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + let mut h = panel(app); + double_click(&mut h, "Text fill swatch"); + let d = h.state().ui.dialog.clone().expect("the Color Picker"); + assert_eq!((d.id.as_str(), d.s("target"), d.b("text")), ("colorPicker", "fill".into(), true)); +} + fn panel(app: DesignApp) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( |ui, app: &mut DesignApp| { @@ -655,6 +716,22 @@ fn panel(app: DesignApp) -> Harness<'static, DesignApp> { h } +/// The course's chapter 21: Properties › Bullets and Numbering › Options opens the Bullets +/// and Numbering dialog, as InDesign's, not the Paragraph panel. +#[test] +fn bullets_options_open_the_bullets_and_numbering_dialog() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 6})).unwrap(); + let mut h = panel(app); + h.get_by_label("Options").click(); + h.run_steps(3); + let d = h.state().ui.dialog.clone().expect("a dialog"); + assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "bullets")); + assert_ne!(h.state().ui.open_panel.as_deref(), Some("paragraph")); +} + /// The course's chapter 21: Properties' Bulleted List button gives the selected paragraphs /// bullets with InDesign's hanging indent; pressed again, both go. #[test] @@ -710,6 +787,205 @@ fn several_shapes_show_pathfinder_and_intersect_combines_them() { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } +/// The arrow beside Opacity opens a slider, as in InDesign: a drag shows the opacity live and +/// is one undo step; the Opacity link still opens the Effects panel. +#[test] +fn the_opacity_arrow_opens_a_slider_and_a_drag_is_one_undo_step() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let opacity = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().opacity; + let steps = undo_steps(&app); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + app.settle_live_preview(); + show(app, ui); + }, + app, + ); + h.run_steps(3); + assert!(h.query_by_role(egui::accesskit::Role::Slider).is_none()); + h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("Opacity")).click(); + h.run_steps(3); + assert!( + h.query_by(|n| n.role() == egui::accesskit::Role::Slider && n.label().as_deref() == Some("Opacity")).is_some(), + "assistive technology names the slider" + ); + let track = h.get_by_role(egui::accesskit::Role::Slider).rect(); + let y = track.center().y; + h.hover_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + h.drag_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + for k in 1..=5 { + h.hover_at(egui::pos2(track.right() - track.width() * 0.05 * k as f32, y)); + h.run_steps(1); + assert_eq!(undo_steps(h.state()), steps, "no undo step while dragging"); + } + let previewed = opacity(h.state()); + assert!(previewed < 0.9 && previewed > 0.5, "the drag shows the opacity live: {previewed}"); + // Let go further along than the last move: the release frame changes the value too. + h.drop_at(egui::pos2(track.right() - track.width() * 0.3, y)); + h.run_steps(3); + assert_eq!(undo_steps(h.state()), steps + 1, "the whole drag is one undo step"); + assert!(opacity(h.state()) < previewed, "the release position is applied: {previewed} → {}", opacity(h.state())); + assert_ne!(h.state().ui.open_panel.as_deref(), Some("effects"), "the arrow doesn't open Effects"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(opacity(h.state()), 1.0); +} + +/// Properties › Frame Fitting has Auto-Fit and an Options button, as InDesign's: ticking +/// Auto-Fit makes the picture refit when its frame is resized. +#[test] +fn properties_frame_fitting_sets_auto_fit_and_opens_its_options() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + app.run("file.place", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "p.png", "frame": id})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let graphic = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().graphic().unwrap().clone(); + assert_eq!(graphic(&app).auto_fit, designcraft_doc::Fitting::None); + let mut h = panel(app); + // A checkbox its section disables says so: Invert, with no text wrap. + use egui_kittest::kittest::NodeT; + assert!(h.get_by_label("Invert").accesskit_node().is_disabled()); + assert!(!h.get_by_label("Auto-Fit").accesskit_node().is_disabled()); + h.get_by_label("Auto-Fit").click(); + h.run_steps(3); + assert_ne!(graphic(h.state()).auto_fit, designcraft_doc::Fitting::None, "Auto-Fit is on"); + // The frame resized: the picture follows it. + h.state_mut().run("transform.resize", json!({"from": [72, 72, 272, 172], "to": [72, 72, 372, 272]})).unwrap(); + let g = graphic(h.state()); + let r = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); + assert!(r.width() > 250.0, "refitted to the larger frame: {r:?}"); + h.get_by_label("Options").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_some(), "Frame Fitting Options opens"); +} + +/// Properties › Text Wrap shows the four offsets under the wrap buttons, as InDesign's +/// Properties panel does: linked at first, so one value sets all four; unlinked, one side. +#[test] +fn properties_text_wrap_shows_and_sets_the_offsets() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("object.textWrap", json!({"mode": "boundingBox", "offset": 0})).unwrap(); + let offsets = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets; + let mut h = panel(app); + // The fields between the Text Wrap heading and Quick Actions: Top, Left, Bottom, Right. + let fields = |h: &Harness<'_, DesignApp>| { + let (top, bottom) = (h.get_by_label("Text Wrap").rect().bottom(), h.get_by_label("Quick Actions").rect().top()); + let mut v: Vec<egui::Rect> = + h.get_all_by_role(egui::accesskit::Role::TextInput).map(|n| n.rect()).filter(|r| r.center().y > top && r.center().y < bottom).collect(); + v.sort_by(|a, b| (a.top(), a.left()).partial_cmp(&(b.top(), b.left())).unwrap()); + v + }; + let f = fields(&h); + assert_eq!(f.len(), 4, "four offsets: {f:?}"); + let type_in = |h: &mut Harness<'_, DesignApp>, at: egui::Rect, text: &str| { + h.hover_at(at.center()); + h.drag_at(at.center()); + h.drop_at(at.center()); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(text.into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + }; + // Linked: the top offset sets all four. + type_in(&mut h, f[0], "12pt"); + assert_eq!(offsets(h.state()), [12.0; 4]); + // Unlinked: the bottom one alone. + let (top, bottom) = (h.get_by_label("Text Wrap").rect().bottom(), h.get_by_label("Quick Actions").rect().top()); + h.get_all_by_label("Make all settings the same").find(|n| n.rect().center().y > top && n.rect().center().y < bottom).unwrap().click(); + h.run_steps(2); + let f = fields(&h); + type_in(&mut h, f[2], "6pt"); + assert_eq!(offsets(h.state()), [12.0, 12.0, 6.0, 12.0], "[top, left, bottom, right]"); + // Two objects with different offsets: a side typed in changes that side on each, and + // each keeps its other three. + let other = h.state_mut().run("frame.create", json!({"rect": [72, 300, 300, 400], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("object.textWrap", json!({"mode": "contour", "offset": [3, 4, 5, 6], "ids": [other]})).unwrap(); + let of = + |h: &Harness<'_, DesignApp>, id: u64| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets; + h.state_mut().run("selection.set", json!({"ids": [id, other]})).unwrap(); + h.run_steps(3); + let f = fields(&h); + type_in(&mut h, f[0], "9pt"); + assert_eq!((of(&h, id), of(&h, other)), ([9.0, 12.0, 6.0, 12.0], [9.0, 4.0, 5.0, 6.0]), "the top alone, on each"); + let mode = + |h: &Harness<'_, DesignApp>, id: u64| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.mode; + assert_eq!((mode(&h, id), mode(&h, other)), (designcraft_doc::WrapMode::BoundingBox, designcraft_doc::WrapMode::Contour), "each keeps its mode"); + // An object whose offsets are the same starts linked, though the link was off before. + let third = h.state_mut().run("frame.create", json!({"rect": [320, 72, 500, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("object.textWrap", json!({"mode": "boundingBox", "offset": 4, "ids": [third]})).unwrap(); + h.state_mut().run("selection.set", json!({"ids": [third]})).unwrap(); + h.run_steps(3); + let f = fields(&h); + type_in(&mut h, f[2], "8pt"); + assert_eq!(of(&h, third), [8.0; 4], "linked again for this selection"); +} + +/// A row is as wide as its translated name needs: a long one isn't cut at a fixed width. +#[test] +fn preview_rows_fit_long_translated_names() { + let ctx = egui::Context::default(); + let long = "Скругленный прямоугольник с обратным углом"; + let mut widths = (0.0, 0.0); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::Area::new(egui::Id::unique("rows")).show(ui.ctx(), |ui| { + ui.set_max_width(10.0); + let r = preview_row(ui, false, long, 40.0, |_, _, _| {}); + let text = ui.fonts_mut(|f| f.layout_no_wrap(long.to_string(), egui::FontId::proportional(12.0), egui::Color32::WHITE).size().x); + widths = (r.rect.width(), 40.0 + 12.0 + text); + }); + }); + out.textures_delta.clear(); + assert!(widths.0 >= widths.1, "{widths:?}"); +} + +/// The corner shape list shows each shape's glyph beside its name, as InDesign's; a row picks +/// the shape and the current one is marked. +#[test] +fn the_corner_shape_list_has_glyph_rows_and_picks_a_shape() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); + h.run_steps(3); + let rows = ["None", "Fancy", "Bevel", "Inset", "Inverse Rounded", "Rounded"]; + for r in rows { + let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, r); + } + h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel").click(); + h.run_steps(3); + let it = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().clone(); + assert!(it.corners.corners.iter().all(|c| c.shape == designcraft_geom::corners::CornerShape::Bevel), "{:?}", it.corners); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); + h.run_steps(3); + use egui_kittest::kittest::NodeT; + let n = h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel"); + assert_eq!(n.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the current shape is marked"); + // The stroke type list has the same rows, by name. + h.key_press(egui::Key::Escape); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Stroke Type").click(); + h.run_steps(3); + let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, "Thick - Thin"); +} + const CYAN: &str = "C=100 M=0 Y=0 K=0"; #[test] @@ -786,6 +1062,121 @@ fn apply_to_text_colours_a_text_frames_text_not_the_frame() { assert_eq!(doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch, "[None]", "the frame isn't"); } +/// The Align To button shows the target picked in its menu. +#[test] +fn the_align_to_button_shows_its_target() { + assert_eq!(align_to_icon("selection"), "align-to"); + assert_eq!(align_to_icon("keyObject"), "align-to-key"); + assert_eq!(align_to_icon("margins"), "align-to-margins"); + assert_eq!(align_to_icon("page"), "align-to-page"); + assert_eq!(align_to_icon("spread"), "align-to-spread"); +} + +#[test] +fn apply_to_text_colours_the_text_of_several_text_frames() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut ids = vec![]; + let mut stories = vec![]; + for x in [72, 250, 430] { + let r = app.run("frame.create", json!({"rect": [x, 72, x + 150, 140], "content": "text", "text": "75%"})).unwrap(); + ids.push(r["id"].as_u64().unwrap()); + stories.push(designcraft_doc::StoryId(r["story"].as_u64().unwrap())); + } + app.run("selection.set", json!({"ids": ids})).unwrap(); + let mut h = panel(app); + h.get_by_label("Fill swatch").click(); + h.run_steps(3); + // Three text frames, as one: Apply To is offered. + assert!(h.query_by_label("Apply To:").is_some()); + h.get_by_label("Text").click(); + h.run_steps(3); + h.get_by_label(CYAN).click(); + h.run_steps(3); + let doc = &h.state().session.active().unwrap().doc; + for (id, story) in ids.iter().zip(&stories) { + assert_eq!(doc.story(*story).unwrap().char_format_at(0).over.fill.as_deref(), Some(CYAN), "every frame's text is cyan"); + assert_eq!(doc.item(designcraft_doc::ids::ItemId(*id)).unwrap().fill.swatch, "[None]", "the frames aren't"); + } +} + +/// Points selected with Direct Selection: InDesign's "Anchor Point(s)" panel, with Convert +/// Points and the Delete Point(s) quick action, as in the Zero2Graphic tutorial (1:25). +#[test] +fn selected_points_show_convert_points_and_delete_points() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [200, 72, 300, 172]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"anchors": [[a, 0, 0], [b, 0, 0]]})).unwrap(); + assert_eq!(selection_title(&app, &super::super::sel_info(&app)), "Anchor Point(s)"); + let mut h = panel(app); + // The four point types (Appearance has a Corner row too). + for tip in ["Plain", "Corner", "Smooth", "Symmetrical"] { + assert!(h.get_all_by_label(tip).next().is_some(), "{tip}"); + } + assert!(h.query_by_label("Align").is_none(), "only the sections InDesign shows for points"); + h.get_by_label("Delete Point(s)").click(); + h.run_steps(3); + let d = &h.state().session.active().unwrap().doc; + for id in [a, b] { + assert_eq!(d.item(designcraft_doc::ids::ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3, "one point gone from each"); + } +} + +/// With points selected, Transform measures and moves the points: one corner of a frame shows +/// that corner, not the frame, and X moves the corner alone; W spreads two points apart. +#[test] +fn transform_measures_and_moves_the_selected_points() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + let corner = |app: &DesignApp, i: usize| { + let d = &app.session.active().unwrap().doc; + let it = d.item(designcraft_doc::ids::ItemId(a)).unwrap(); + it.xf * it.path.subpaths[0].anchors[i].p + }; + let first = corner(&app, 0); + app.run("selection.set", json!({"anchors": [[a, 0, 0]]})).unwrap(); + let i = super::super::sel_info(&app).unwrap(); + assert_eq!((i.page_rect.width(), i.page_rect.height()), (0.0, 0.0), "a point, not the frame"); + app.ui.ref_point = 0; + transform_anchors(&mut app, &json!({"x": i.page_rect.x0 + 10.0})).unwrap(); + assert_eq!(corner(&app, 0), first + designcraft_geom::Vec2::new(10.0, 0.0), "the corner moved"); + assert_eq!(corner(&app, 2), designcraft_geom::Point::new(172.0, 172.0), "the opposite corner stayed"); + // Two points 100 pt apart, W 200 from the left: the right one goes 100 further. + app.run("selection.set", json!({"anchors": [[a, 0, 2], [a, 0, 3]]})).unwrap(); + let (p2, p3) = (corner(&app, 2), corner(&app, 3)); + let w = super::super::sel_info(&app).unwrap().page_rect.width(); + transform_anchors(&mut app, &json!({"width": w * 2.0})).unwrap(); + let (left, right) = if p2.x < p3.x { (2, 3) } else { (3, 2) }; + let (pl, pr) = if left == 2 { (p2, p3) } else { (p3, p2) }; + assert_eq!(corner(&app, left), pl, "the reference side stays"); + assert!((corner(&app, right).x - (pr.x + w)).abs() < 1e-9, "spread to twice the width"); +} + +/// One point has no size: W and H show its frame's, greyed out, as InDesign; two points +/// measure (and set) their own. +#[test] +fn one_selected_point_shows_its_frames_size_greyed() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 172, 222]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"anchors": [[a, 0, 0]]})).unwrap(); + assert_eq!(super::super::sel_info(&app).unwrap().object_size, (100.0, 150.0)); + let mut h = panel(app); + h.run_steps(2); + let greyed = |h: &Harness<'_, DesignApp>| { + h.query_all_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_disabled()).filter_map(|n| n.value()).collect::<Vec<_>>() + }; + let u = units(h.state()); + let f = |v| designcraft_geom::units::format_measure(v, u); + assert_eq!(greyed(&h), [f(100.0), f(150.0)], "the frame's W and H, greyed"); + h.state_mut().run("selection.set", json!({"anchors": [[a, 0, 0], [a, 0, 2]]})).unwrap(); + h.run_steps(2); + assert!(greyed(&h).is_empty(), "two points have a size of their own"); +} + #[test] fn transform_flips_and_rotates_from_properties() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index 342ad8dda..5cbd62c65 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -1,4 +1,4 @@ -use super::object::swatch_menu; +use super::object::{open_color_picker, swatch_menu}; use super::typography::{para_field_min, show_typography_panel}; use super::{ Align, DesignApp, FIELD_H, NumField, Rect, Sense, Stroke, StrokeKind, Tokens, Ui, Value, block, full_width, icon_toggle, icons, json, link_label, @@ -128,7 +128,11 @@ pub(super) fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { ui.painter().text(chip.center(), egui::Align2::CENTER_CENTER, "T", crate::theme::semibold(12.0), tc); } let resp = ui.interact(chip, ui.scope_id().with(("tchip", k)), Sense::click()).on_hover_text(format!("{label}: {sw}")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, if k == 0 { "Text fill swatch" } else { "Text stroke swatch" })); swatch_menu(app, &resp, &doc, &sw, k != 0, false, true); + if resp.double_clicked() { + open_color_picker(app, &doc, &sw, k != 0, true); + } if k == 0 { crate::rtl::paint( ui.painter(), @@ -480,8 +484,9 @@ pub(super) fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); + // As InDesign's: the Bullets and Numbering dialog for the selected paragraphs. if outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), 66.0).clicked() { - show_panel(app, "paragraph"); + let _ = app.run("app.bulletsNumberingDialog", json!({})); } }); }); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index fba3eb5b7..680391e03 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -11,7 +11,18 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); - let target = super::color_target(app); + // Another document (switched to, or moved to this one's place by a close) drops a rename in + // place: a swatch of the same name there isn't the one being renamed. + let uid = st.uid; + if ui.data(|d| d.get_temp::<u64>(egui::Id::unique(RENAME_DOC))) != Some(uid) { + ui.data_mut(|d| { + d.remove_temp::<(String, String)>(egui::Id::unique(RENAMING)); + d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK)); + d.insert_temp(egui::Id::unique(RENAME_DOC), uid); + }); + } + // With nothing selected, the proxy, Tint field and highlighted row are those of new shapes. + let target = super::color_target_or_defaults(app); header(app, ui, &doc, target.as_ref()); // The row of the colour in front of the proxy is highlighted. let fill_cur = target.map(|(fill, _, stroke, _)| if app.ui.stroke_front { stroke } else { fill }); @@ -154,6 +165,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { /// Temp-data key of the swatch last clicked (Delete Swatch removes it). const PICKED: &str = "swatches_picked"; +/// Temp-data key of the swatch renamed in place and its name being typed. +const RENAMING: &str = "swatches_renaming"; +/// Temp-data key of a click on the picked swatch's name: (swatch, time). Unless it turns into a +/// double-click (Swatch Options), it starts renaming. +const RENAME_CLICK: &str = "swatches_rename_click"; +/// Temp-data key of the document (its uid) the panel last showed: a rename belongs to it. +const RENAME_DOC: &str = "swatches_rename_doc"; /// The Swatches panel's top row, as InDesign's: a Fill/Stroke proxy (click: the other in front), /// Formatting Affects Container / Text, and the tint of the colour in front. `target` is @@ -206,7 +224,8 @@ fn header(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Documen } ui.add_space(6.0); crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Tint:")); - let tint = target.map(|(_, ft, _, st)| f64::from(if stroke_front { *st } else { *ft }) * 100.0); + // With nothing selected InDesign's field reads 100. + let tint = target.map_or(Some(100.0), |(_, ft, _, st)| Some(f64::from(if stroke_front { *st } else { *ft }) * 100.0)); if let Some(v) = crate::widgets::number(ui, "swatches_tint", tint, "", 44.0, 0) { let _ = set_tint(app, v); } @@ -242,6 +261,47 @@ fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response { }) } +/// The name field of a swatch being renamed in place, over `rect`; false when `sw` isn't. Enter or +/// a click elsewhere renames it (`swatch.options`), Escape leaves it as it was. +fn rename_field(app: &mut DesignApp, ui: &mut egui::Ui, sw: &designcraft_color::swatch::Swatch, rect: egui::Rect) -> bool { + let key = egui::Id::unique(RENAMING); + let Some((name, mut text)) = ui.data(|d| d.get_temp::<(String, String)>(key)).filter(|(n, _)| *n == sw.name) else { return false }; + let id = egui::Id::unique(("swatch_rename", &sw.name)); + let edit = egui::TextEdit::singleline(&mut text).id(id).font(egui::FontId::proportional(12.5)).margin(vec2(2.0, 1.0)); + let resp = ui.put(rect, edit); + if !resp.has_focus() && !resp.lost_focus() { + // The field opens with the name selected. + resp.request_focus(); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())))); + state.store(ui.ctx(), id); + } + } + if resp.lost_focus() { + ui.data_mut(|d| d.remove_temp::<(String, String)>(key)); + let to = text.trim(); + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !to.is_empty() && to != name { + match app.run("swatch.options", json!({"name": name, "to": to})) { + // Still picked under its new name, unless the click that ended the rename picked + // another swatch. + Ok(_) => ui.data_mut(|d| { + if d.get_temp::<String>(egui::Id::unique(PICKED)).is_none_or(|p| p == name) { + d.insert_temp(egui::Id::unique(PICKED), to.to_string()); + } + }), + Err(e) => { + app.status_error("Swatch Options", e); + } + } + } + } else { + ui.data_mut(|d| d.insert_temp(key, (name, text))); + } + true +} + /// The swatch's chip following the pointer while the row is dragged. fn paint_drag_chip(ui: &egui::Ui, doc: &designcraft_doc::Document, name: &str, p: egui::Pos2) { let t = Tokens::get(ui.ctx()); @@ -252,20 +312,153 @@ fn paint_drag_chip(ui: &egui::Ui, doc: &designcraft_doc::Document, name: &str, p painter.rect_stroke(chip, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); } -/// The type indicator shown at the right of a swatch row: colour mode (spot dot), gradient or tint. -fn swatch_kind_label(lang: &str, value: &SwatchValue) -> String { - match value { +/// What a Swatches panel row marks on its right, as InDesign's columns. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RowMarks { + /// [None], [Registration] and [Black] can't be edited ([Paper] can). + not_editable: bool, + kind: Option<ColorType>, + mode: Option<RowMode>, +} + +/// The colour-mode column: the ink model, or the special swatches' own glyphs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RowMode { + Cmyk, + Rgb, + Gray, + None, + Registration, +} + +fn row_marks(sw: &designcraft_color::swatch::Swatch) -> RowMarks { + use designcraft_color::Color; + // Paper's colour can be edited, whatever the swatch is called (as the options dialog allows). + let not_editable = sw.locked && !matches!(sw.value, SwatchValue::Paper { .. }); + match &sw.value { SwatchValue::Color { color, color_type } => { - let m = match color { - designcraft_color::Color::Cmyk { .. } => "CMYK", - designcraft_color::Color::Rgb { .. } => "RGB", - designcraft_color::Color::Gray { .. } => crate::i18n::tr(lang, "Gray"), + let mode = match color { + Color::Cmyk { .. } => RowMode::Cmyk, + Color::Rgb { .. } => RowMode::Rgb, + Color::Gray { .. } => RowMode::Gray, }; - format!("{}{}", if *color_type == ColorType::Spot { "● " } else { "" }, m) + RowMarks { not_editable, kind: Some(*color_type), mode: Some(mode) } + } + SwatchValue::None => RowMarks { not_editable, kind: None, mode: Some(RowMode::None) }, + SwatchValue::Registration => RowMarks { not_editable, kind: None, mode: Some(RowMode::Registration) }, + _ => RowMarks { not_editable, kind: None, mode: None }, + } +} + +/// A row's accessible name: the swatch's name, then what its marks show (colour type, colour +/// mode, that it can't be edited). [None] and [Registration] say their mode by name. +fn row_label(lang: &str, name: &str, marks: RowMarks) -> String { + let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); + let mut parts = vec![name.to_string()]; + match marks.kind { + Some(ColorType::Process) => parts.push(tr("Process")), + Some(ColorType::Spot) => parts.push(tr("Spot")), + None => {} + } + match marks.mode { + Some(RowMode::Cmyk) => parts.push(tr("CMYK")), + Some(RowMode::Rgb) => parts.push(tr("RGB")), + Some(RowMode::Gray) => parts.push(tr("Gray")), + Some(RowMode::None | RowMode::Registration) | None => {} + } + if marks.not_editable { + parts.push(tr("Cannot be edited")); + } + parts.join(", ") +} + +/// Whether accessible name `label` is swatch `name`'s row (its name, then its marks). +#[cfg(test)] +pub(crate) fn names_swatch(label: &str, name: &str) -> bool { + label.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with(", ")) +} + +/// A pencil with a stroke through it: the swatch can't be edited. Drawn in code. +fn paint_not_editable(p: &egui::Painter, r: egui::Rect, ink: egui::Color32) { + // The pencil: its tip at the lower left, its body up to the right. + let tip = r.left_bottom() + vec2(1.5, -1.5); + let d = vec2(1.0, -1.0).normalized(); + let n = vec2(d.y, -d.x) * 1.9; + let (b0, b1) = (tip + d * 3.4, tip + d * 12.5); + p.add(egui::Shape::convex_polygon(vec![tip, b0 + n, b0 - n], ink, egui::Stroke::NONE)); + p.add(egui::Shape::convex_polygon(vec![b0 + n, b1 + n, b1 - n, b0 - n], ink, egui::Stroke::NONE)); + // The stroke through it, with a gap of the panel's colour around it so it reads on the body. + let (s0, s1) = (r.left_top() + vec2(1.5, 1.5), r.right_bottom() - vec2(1.5, 1.5)); + p.line_segment([s0, s1], egui::Stroke::new(3.2, egui::Color32::from_black_alpha(140))); + p.line_segment([s0, s1], egui::Stroke::new(1.2, ink)); +} + +/// The colour type: a process colour is a halftone square, a spot colour a disc in a square. +fn paint_color_type(p: &egui::Painter, r: egui::Rect, kind: ColorType, ink: egui::Color32) { + let sq = egui::Rect::from_center_size(r.center(), vec2(10.0, 10.0)); + p.rect_filled(sq, 0.0, ink); + if kind == ColorType::Spot { + p.circle_filled(sq.center(), 3.2, egui::Color32::from_black_alpha(110)); + } else { + // A fine halftone: a grey square with a light dot screen. + let dot = egui::Color32::from_black_alpha(70); + for i in 0..4u8 { + for j in 0..4u8 { + let o = if j % 2 == 0 { 1.5 } else { 2.6 }; + p.circle_filled(sq.min + vec2(o + 2.2 * f32::from(i), 1.6 + 2.3 * f32::from(j)), 0.6, dot); + } + } + } +} + +/// The colour mode: CMYK's four inks meeting in the middle, RGB's three primaries, a grey ramp, +/// [None]'s red slash and [Registration]'s target. +fn paint_mode(p: &egui::Painter, r: egui::Rect, mode: RowMode, ink: egui::Color32) { + let sq = egui::Rect::from_center_size(r.center(), vec2(11.0, 11.0)); + let c = sq.center(); + let rgb = egui::Color32::from_rgb; + match mode { + RowMode::Cmyk => { + let corners = [sq.left_top(), sq.right_top(), sq.right_bottom(), sq.left_bottom()]; + // Top, right, bottom, left: magenta, yellow, black, cyan. + let inks = [rgb(236, 0, 140), rgb(255, 230, 0), rgb(30, 30, 30), rgb(0, 174, 239)]; + for (k, colour) in inks.into_iter().enumerate() { + let (a, b) = (corners[k], corners[(k + 1) % 4]); + p.add(egui::Shape::convex_polygon(vec![a, b, c], colour, egui::Stroke::NONE)); + } + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::Rgb => { + let w = sq.width() / 3.0; + for (k, colour) in [rgb(230, 40, 40), rgb(40, 190, 60), rgb(40, 90, 230)].into_iter().enumerate() { + let x = sq.min.x + w * k as f32; + p.rect_filled(egui::Rect::from_min_max(egui::pos2(x, sq.min.y), egui::pos2(x + w, sq.max.y)), 0.0, colour); + } + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::Gray => { + let w = sq.width() / 4.0; + for k in 0..4u8 { + let x = sq.min.x + w * f32::from(k); + p.rect_filled( + egui::Rect::from_min_max(egui::pos2(x, sq.min.y), egui::pos2(x + w, sq.max.y)), + 0.0, + egui::Color32::from_gray(255 - 70 * k), + ); + } + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::None => { + p.rect_filled(sq, 0.0, egui::Color32::WHITE); + p.line_segment([sq.left_bottom(), sq.right_top()], egui::Stroke::new(1.5, rgb(220, 30, 30))); + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::Registration => { + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Inside); + p.circle_stroke(c, 3.0, egui::Stroke::new(1.0, ink)); + p.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], egui::Stroke::new(1.0, ink)); + p.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], egui::Stroke::new(1.0, ink)); } - SwatchValue::Gradient { .. } => "Gradient".into(), - SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0), - _ => String::new(), } } @@ -283,7 +476,10 @@ fn swatch_row( let t = Tokens::get(ui.ctx()); // Rows can be dragged onto the Gradient panel's ramp. let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &sw.name)); + // What the row's marks show, for screen readers too: "Cyan, Process, CMYK". + let marks = row_marks(sw); + let spoken = row_label(&app.ui.language, &sw.name, marks); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &spoken)); resp.dnd_set_drag_payload(SwatchDrag(sw.name.clone())); if resp.dragged() && let Some(p) = ui.ctx().pointer_interact_pos() @@ -298,21 +494,61 @@ fn swatch_row( let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0); let chip = egui::Rect::from_min_size(row.min + vec2(4.0 + indent, 3.0), vec2(16.0, 16.0)); crate::widgets::paint_chip(ui.painter(), chip, c, g); - ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); - // Type indicators: spot dot / process square, colour mode. - let kind = swatch_kind_label(&app.ui.language, &sw.value); - if sw.locked { - icons::paint(ui.painter(), egui::Rect::from_min_size(egui::pos2(row.max.x - 64.0, row.min.y + 4.0), vec2(13.0, 13.0)), "lock", t.text_dim); + let name_rect = egui::Rect::from_min_max(row.min + vec2(26.0 + indent, 1.0), egui::pos2(row.max.x - 72.0, row.max.y - 1.0)); + if !rename_field(app, ui, sw, name_rect) { + ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); + } + // InDesign's columns on the right: not editable, colour type, colour mode. + let col = |k: f32| egui::Rect::from_min_size(egui::pos2(row.max.x - 19.0 - 18.0 * k, row.min.y + 4.5), vec2(13.0, 13.0)); + if marks.not_editable { + paint_not_editable(ui.painter(), col(2.0), t.text_dim); + } + if let Some(kind) = marks.kind { + paint_color_type(ui.painter(), col(1.0), kind, t.text_dim); + } + if let Some(mode) = marks.mode { + paint_mode(ui.painter(), col(0.0), mode, t.text_dim); + } + let label = match &sw.value { + SwatchValue::Gradient { .. } => crate::i18n::tr(&app.ui.language, "Gradient").to_string(), + SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0), + _ => String::new(), + }; + if !label.is_empty() { + ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, label, egui::FontId::proportional(10.5), t.text_dim); } - ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); if resp.clicked() && !resp.double_clicked() { + // A click on the name of the swatch picked already renames it in place, as InDesign's + // (a slow double-click), unless it turns into a double-click. + let picked = ui.data(|d| d.get_temp::<String>(egui::Id::unique(PICKED))).is_some_and(|p| p == sw.name); + // A click on another swatch cancels a rename still waiting. + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); + if picked && !sw.locked && resp.interact_pointer_pos().is_some_and(|p| name_rect.contains(p)) { + let now = ui.input(|i| i.time); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(RENAME_CLICK), (sw.name.clone(), now))); + } ui.data_mut(|d| d.insert_temp(egui::Id::unique(PICKED), sw.name.clone())); let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None); } // Double-click: Swatch Options (the first click has applied the swatch). if resp.double_clicked() { + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name})); } + if let Some((name, at)) = ui.data(|d| d.get_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))).filter(|(n, _)| *n == sw.name) { + let wait = ui.ctx().options(|o| o.input_options.max_double_click_delay); + if ui.data(|d| d.get_temp::<String>(egui::Id::unique(PICKED))).is_none_or(|p| p != name) { + // Another swatch picked since: no rename. + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); + } else if ui.input(|i| i.time) - at > wait { + ui.data_mut(|d| { + d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK)); + d.insert_temp(egui::Id::unique(RENAMING), (name.clone(), name)); + }); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Swatch Options…"))).clicked() { let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name})); @@ -360,7 +596,8 @@ pub enum ColorMode { /// swatch), the spectrum ramp, Add to Swatches. Edits apply unnamed colours (`object.color`). pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let Some((fill, fill_tint, stroke_sw, stroke_tint)) = super::color_target(app) else { + let target = super::color_target_or_defaults(app); + let Some((fill, fill_tint, stroke_sw, stroke_tint)) = target else { ui.label(crate::rtl::widget( ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Select an object or text to color its fill or stroke.")).color(t.text_dim), @@ -418,6 +655,11 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); app.ui.stroke_front = stroke; ui.data_mut(|d| d.insert_temp(mode_id, mode)); + // A gradient: the stop selected in the Gradient panel, as InDesign's Color panel shows it. + if !text && let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, &name).cloned() { + gradient_stop(app, ui, &g, target, mode); + return; + } let sw = doc.swatch(&name).cloned(); let Some(c) = doc.resolve_color(&name, 1.0) else { ui.label(crate::rtl::widget( @@ -570,9 +812,82 @@ fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 { egui::Color32::from_rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) } +/// The Color panel on a gradient fill (or stroke): the stop selected in the Gradient panel (the +/// stop before a selected midpoint), in the panel's colour mode, as InDesign's. Its fields and the +/// spectrum colour that stop. +fn gradient_stop(app: &mut DesignApp, ui: &mut egui::Ui, g: &designcraft_color::Gradient, target: &str, mode: ColorMode) { + use designcraft_color::Color; + let t = Tokens::get(ui.ctx()); + let sel: RampSel = ui.data(|d| d.get_temp(egui::Id::unique(GRAD_SEL))).unwrap_or(RampSel::Stop(0)); + let i = match sel { + RampSel::Stop(i) | RampSel::Mid(i) => i, + } + .min(g.stops.len().saturating_sub(1)); + let Some(stop) = g.stops.get(i) else { return }; + let c = stop.color; + let pct = |v: f32| (v * 100.0).round(); + let values = match c { + Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)), + Color::Gray { k } => format!("K={}", pct(k)), + Color::Rgb { .. } => { + let [r, g, b, _] = c.to_rgba8(1.0); + format!("R={r} G={g} B={b}") + } + }; + ui.label(egui::RichText::new(values).size(11.0).color(t.text_dim)); + let mut new = None; + match mode { + ColorMode::Cmyk => { + let mut v = c.to_cmyk(); + let mut done = false; + for (k, label) in ["C", "M", "Y", "K"].iter().enumerate() { + done |= channel(ui, label, &mut v[k], 100.0, "%", |x| cmyk_ramp(k, x)); + } + if done { + new = Some(Color::cmyk(v[0], v[1], v[2], v[3])); + } + } + ColorMode::Rgb => { + let mut v = c.to_rgb(); + let mut done = false; + for (k, label) in ["R", "G", "B"].iter().enumerate() { + done |= channel(ui, label, &mut v[k], 255.0, "", |x| { + let mut p = [0.0; 3]; + p[k] = x; + egui::Color32::from_rgb((p[0] * 255.0) as u8, (p[1] * 255.0) as u8, (p[2] * 255.0) as u8) + }); + } + if done { + new = Some(Color::rgb(v[0], v[1], v[2])); + } + } + ColorMode::Lab => { + let lab = c.to_lab(); + ui.label(egui::RichText::new(format!("L {:.0} a {:.0} b {:.0}", lab.l, lab.a, lab.b)).color(t.text_dim)); + } + } + if let Some([r, gr, b]) = spectrum_pick(ui, &app.ui.language) { + new = Some(Color::rgb(r, gr, b)); + } + if let Some(c) = new { + let mut stops = stops_json(g); + set_stop_color(&mut stops, i, color_json(&c)); + let _ = app.run("object.gradient", json!({"stops": stops, "target": target})); + } +} + /// The spectrum ramp: hue across, light to dark down; click to apply. fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { + if let Some([r, g, b]) = spectrum_pick(ui, &app.ui.language) { + let text = super::colors_affect_text(app); + let _ = app.run("object.color", json!({"color": {"model": "rgb", "r": r, "g": g, "b": b}, "target": target, "text": text})); + } +} + +/// The spectrum ramp; a click on it gives that colour (RGB, 0–1, in 1/255 steps). +fn spectrum_pick(ui: &mut egui::Ui, language: &str) -> Option<[f32; 3]> { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(language, "Color spectrum"))); let at = |u: f32, v: f32| -> [f32; 3] { let c = egui::ecolor::Hsva::new(u, (v * 2.0).min(1.0), (2.0 - v * 2.0).min(1.0), 1.0).to_rgb(); [c[0], c[1], c[2]] @@ -588,14 +903,9 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { ui.painter().rect_filled(cell, 0.0, egui::Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)); } } - if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - { - let c = at(((p.x - r.min.x) / r.width()).clamp(0.0, 1.0), ((p.y - r.min.y) / r.height()).clamp(0.0, 1.0)); - let text = super::colors_affect_text(app); - let q = |v: f32| (v * 255.0).round() / 255.0; - let _ = app.run("object.color", json!({"color": {"model": "rgb", "r": q(c[0]), "g": q(c[1]), "b": q(c[2])}, "target": target, "text": text})); - } + let p = resp.interact_pointer_pos().filter(|_| resp.clicked())?; + let c = at(((p.x - r.min.x) / r.width()).clamp(0.0, 1.0), ((p.y - r.min.y) / r.height()).clamp(0.0, 1.0)); + Some(c.map(|v| (v * 255.0).round() / 255.0)) } /// A swatch dragged out of the Swatches panel (the Gradient panel's ramp takes it as a stop). @@ -618,6 +928,105 @@ fn color_json(c: &designcraft_color::Color) -> serde_json::Value { } } +#[cfg(test)] +mod stop_color_tests { + use super::*; + + #[test] + fn a_new_stop_colour_drops_the_stops_swatch() { + let mut stops = vec![json!({"location": 0, "color": "#000000", "swatch": "Gold"})]; + set_stop_color(&mut stops, 0, json!("#ff0000")); + assert_eq!(stops[0]["color"], "#ff0000"); + assert!(stops[0].get("swatch").is_none()); + } +} + +#[cfg(test)] +mod color_panel_defaults_tests { + use super::*; + use egui_kittest::kittest::Queryable; + + #[test] + fn color_panel_with_no_selection_edits_future_shapes_without_recoloring_existing_ones() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let old = + designcraft_doc::ItemId(app.run("frame.create", json!({"rect":[72,72,200,200],"content":"unassigned"})).unwrap()["id"].as_u64().unwrap()); + let original = app.session.active().unwrap().doc.item(old).unwrap().stroke.clone(); + app.run("selection.set", json!({"ids":[]})).unwrap(); + app.ui.stroke_front = true; + app.run("window.panel", json!({"panel":"color"})).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + assert!(h.query_by_label("Select an object or text to color its fill or stroke.").is_none()); + let ramp = h.get_by_label("Color spectrum").rect(); + crate::test_window::click_at(&mut h, egui::pos2(ramp.left() + ramp.width() / 3.0, ramp.center().y)); + let defaults = h.state().app.session.active().unwrap().doc.settings.new_object.clone(); + assert_ne!(defaults.stroke, original.swatch); + assert_eq!(h.state().app.session.active().unwrap().doc.item(old).unwrap().stroke, original); + h.state_mut().app.run("selection.set", json!({"ids":[old.0]})).unwrap(); + assert_eq!(crate::panels::color_target(&mut h.state_mut().app).unwrap().2, original.swatch); + h.state_mut().app.run("selection.set", json!({"ids":[]})).unwrap(); + let new = designcraft_doc::ItemId( + h.state_mut().app.run("frame.create", json!({"rect":[240,72,368,200],"content":"unassigned"})).unwrap()["id"].as_u64().unwrap(), + ); + assert_eq!(h.state().app.session.active().unwrap().doc.item(new).unwrap().stroke.swatch, defaults.stroke); + // Two undo steps: the new frame, then the spectrum click's default stroke. + h.state_mut().app.run("edit.undo", json!({})).unwrap(); + h.state_mut().app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().app.session.active().unwrap().doc.settings.new_object.stroke, original.swatch); + assert_eq!(h.state().app.session.active().unwrap().doc.item(old).unwrap().stroke, original); + // With the Fill box in front, the ramp sets the default fill instead. + let original_fill = h.state().app.session.active().unwrap().doc.item(old).unwrap().fill.clone(); + h.state_mut().app.ui.stroke_front = false; + h.run_steps(2); + let ramp = h.get_by_label("Color spectrum").rect(); + crate::test_window::click_at(&mut h, egui::pos2(ramp.left() + ramp.width() * 2.0 / 3.0, ramp.center().y)); + let defaults = h.state().app.session.active().unwrap().doc.settings.new_object.clone(); + assert_ne!(defaults.fill, original_fill.swatch); + assert_eq!(defaults.stroke, original.swatch, "the stroke default is left alone"); + assert_eq!(h.state().app.session.active().unwrap().doc.item(old).unwrap().fill, original_fill); + } + + /// The Swatches panel shows the same defaults: its Tint field reads the default fill's tint, + /// and a click on a swatch row sets the default fill, not an existing frame's. + #[test] + fn swatches_panel_with_no_selection_shows_and_edits_future_shape_colors() { + use egui::accesskit::Role; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let old = + designcraft_doc::ItemId(app.run("frame.create", json!({"rect":[72,72,200,200],"content":"unassigned"})).unwrap()["id"].as_u64().unwrap()); + let original = app.session.active().unwrap().doc.item(old).unwrap().fill.clone(); + app.run("selection.set", json!({"ids":[]})).unwrap(); + app.run("object.fill", json!({"swatch": "[Black]", "tint": 0.4})).unwrap(); + app.ui.stroke_front = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(320.0, 480.0)).build_ui_state( + |ui, app: &mut DesignApp| { + // The panel's fonts are installed on the first frame and used from the next. + let id = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let fields = h.query_all_by(|n| matches!(n.role(), Role::SpinButton | Role::TextInput)).filter_map(|n| n.value()).collect::<Vec<_>>(); + assert!(fields.iter().any(|v| v.starts_with("40")), "the Tint field shows the default fill's 40 %: {fields:?}"); + let row = h.get_by_label("[Paper]").rect().center(); + h.hover_at(row); + h.drag_at(row); + h.drop_at(row); + h.run_steps(3); + let defaults = h.state().session.active().unwrap().doc.settings.new_object.clone(); + assert_eq!((defaults.fill.as_str(), defaults.fill_tint), ("[Paper]", 1.0)); + assert_eq!(h.state().session.active().unwrap().doc.item(old).unwrap().fill, original); + } +} + /// Stops in `object.gradient` form: a stop on a swatch also names it, so it keeps its inks. fn stops_json(g: &designcraft_color::Gradient) -> Vec<serde_json::Value> { g.stops @@ -659,6 +1068,10 @@ fn thumbnail(p: &egui::Painter, r: egui::Rect, g: &designcraft_color::Gradient, p.rect_stroke(r, 0.0, egui::Stroke::new(1.0, border), egui::StrokeKind::Outside); } +/// Temp-data key of what's selected on the Gradient panel's ramp ([`RampSel`]): the Color panel +/// edits that stop. +pub(super) const GRAD_SEL: &str = "grad_sel"; + /// The gradient across `ramp`, sampled in bands. fn paint_ramp(painter: &egui::Painter, ramp: egui::Rect, g: &designcraft_color::Gradient) { let n = 96; @@ -717,7 +1130,7 @@ fn gradient_type_combo(ui: &mut egui::Ui, lang: &str, g: &designcraft_color::Gra /// What's selected on the ramp: a stop, or the midpoint after a stop. #[derive(Clone, Copy, PartialEq, Debug)] -enum RampSel { +pub(super) enum RampSel { Stop(usize), Mid(usize), } @@ -735,6 +1148,14 @@ fn live_drag(d: Option<RampDrag>, n: usize) -> Option<RampDrag> { } /// Sets `key` of stop `i` in `object.gradient` stops; nothing if there's no such stop. +/// A new colour replaces the stop's swatch (`object.gradient` gives `swatch` precedence). +fn set_stop_color(stops: &mut [serde_json::Value], i: usize, color: serde_json::Value) { + if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) { + o.remove("swatch"); + } + set_stop(stops, i, "color", color); +} + fn set_stop(stops: &mut [serde_json::Value], i: usize, key: &str, v: serde_json::Value) { if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) { o.insert(key.to_string(), v); @@ -760,7 +1181,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let is_gradient = cur.is_some(); let g = cur.unwrap_or_default(); let radial = g.kind == designcraft_color::GradientKind::Radial; - let sel_id = egui::Id::unique("grad_sel"); + let sel_id = egui::Id::unique(GRAD_SEL); let drag_id = egui::Id::unique("grad_drag"); let mut sel: RampSel = ui.data(|d| d.get_temp(sel_id)).unwrap_or(RampSel::Stop(0)); let last = g.stops.len().saturating_sub(1); @@ -924,11 +1345,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let mut rgb = [(r * 255.0) as u8, (gg * 255.0) as u8, (bb * 255.0) as u8]; if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() { let mut stops = stops_json(&g); - // A picked color replaces the stop's swatch. - if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) { - o.remove("swatch"); - } - set_stop(&mut stops, i, "color", json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + set_stop_color(&mut stops, i, json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); send = Some(json!({"stops": stops})); } }); @@ -1138,7 +1555,7 @@ mod gradient_panel_tests { // The thumbnail sits left of the Type row. let ty = h.get_by_label("Type:").rect(); let from = egui::pos2(ty.min.x - 21.0, ty.min.y + 12.0); - let to = h.get_by_label(label).rect().center(); + let to = h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, label))).rect().center(); h.event(Event::PointerMoved(from)); h.step(); h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); @@ -1250,14 +1667,14 @@ mod swatches_header_tests { let mut h = harness(app); h.run_steps(3); // Fill in front: a swatch is the fill. - h.get_by_label("C=0 M=100 Y=0 K=0").click(); + h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, "C=0 M=100 Y=0 K=0"))).click(); h.run_steps(2); assert_eq!(items(h.state())[0].fill.swatch, "C=0 M=100 Y=0 K=0"); // The proxy brings the stroke to the front; the next swatch is the stroke. h.get_by_label("Fill").click(); h.run_steps(2); assert!(h.state().ui.stroke_front); - h.get_by_label("C=100 M=0 Y=0 K=0").click(); + h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, "C=100 M=0 Y=0 K=0"))).click(); h.run_steps(2); let it = &items(h.state())[0]; assert_eq!((it.fill.swatch.as_str(), it.stroke.swatch.as_str()), ("C=0 M=100 Y=0 K=0", "C=100 M=0 Y=0 K=0")); @@ -1269,13 +1686,157 @@ mod swatches_header_tests { h.run_steps(2); assert!(!h.state().ui.formatting_affects_text); // Delete Swatch removes the swatch last clicked. - h.get_by_label("Brand").click(); + h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, "Brand"))).click(); h.run_steps(2); h.get_by_label("Delete Swatch").click(); h.run_steps(2); assert!(h.state().session.doc().unwrap().doc.swatch("Brand").is_none()); } + /// A rename in place belongs to its document: with another one active, a swatch of the same + /// name there gets no field, and nothing is renamed. + #[test] + fn a_rename_in_place_ends_with_its_document() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let color = json!({"color": {"c": 14, "m": 8, "y": 95, "k": 0}}); + let name = app.run("swatch.create", color.clone()).unwrap()["name"].as_str().unwrap().to_string(); + let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("rename_test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + super::show(app, ui); + }, + app, + ); + h.run_steps(3); + let fields = h.query_all_by_role(egui::accesskit::Role::TextInput).count(); + let renaming = egui::Id::unique(super::RENAMING); + h.ctx.data_mut(|d| d.insert_temp(renaming, (name.clone(), "Typed".to_string()))); + h.run_steps(2); + assert!(h.query_all_by_role(egui::accesskit::Role::TextInput).count() > fields, "the name is a field"); + // Another document with a swatch of the same name. + h.state_mut().run("file.new", json!({})).unwrap(); + h.state_mut().run("swatch.create", color).unwrap(); + h.run_steps(3); + assert!(h.ctx.data(|d| d.get_temp::<(String, String)>(renaming)).is_none(), "the rename is dropped"); + let doc = &h.state().session.active().unwrap().doc; + assert!(doc.swatch(&name).is_some() && doc.swatch("Typed").is_none()); + } + + /// The course's chapter 23: a swatch added as "C=14 M=8 Y=95 K=0" is renamed in the panel by + /// clicking it, then clicking its name again: the name becomes a field, and Enter renames the + /// swatch (and what uses it). A double-click still opens Swatch Options, Escape keeps the name, + /// and [Black] can't be renamed. + #[test] + fn a_second_click_on_the_picked_swatch_renames_it_in_place() { + use egui::accesskit::Role; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap(); + let added = app.run("swatch.create", json!({"color": {"c": 14, "m": 8, "y": 95, "k": 0}})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(added, "C=14 M=8 Y=95 K=0"); + // Frames 50 ms apart: the double-click time is six of them. + let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).with_step_dt(0.05).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + // The panel's own fields (Tint); renaming adds one. + let fields = h.query_all_by_role(Role::TextInput).count(); + let field = |h: &Harness<'_, DesignApp>| h.query_all_by_role(Role::TextInput).count() > fields; + let has = |h: &Harness<'_, DesignApp>, n: &str| h.state().session.doc().unwrap().doc.swatch(n).is_some(); + let name_at = |h: &Harness<'_, DesignApp>, name: &str| { + let row = h.query_all_by(|n| n.label().is_some_and(|l| names_swatch(&l, name))).next().expect("the swatch's row"); + row.rect().left_center() + vec2(40.0, 0.0) + }; + let click = |h: &mut Harness<'_, DesignApp>, at: egui::Pos2| { + h.hover_at(at); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.step(); + }; + // A click on the name: applied, and picked. + let at = name_at(&h, &added); + click(&mut h, at); + h.run_steps(10); + assert_eq!(items(h.state())[0].fill.swatch, added); + assert!(!field(&h)); + // Again, after the double-click time: the name is a field, with the name selected. + click(&mut h, at); + h.run_steps(10); + assert!(field(&h), "the name is a field"); + h.event(egui::Event::Text("Lemon Yellow Sun".into())); + h.step(); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!(has(&h, "Lemon Yellow Sun") && !has(&h, &added)); + assert_eq!(items(h.state())[0].fill.swatch, "Lemon Yellow Sun", "what uses it follows"); + // Escape keeps the name. + let at = name_at(&h, "Lemon Yellow Sun"); + h.run_steps(10); + click(&mut h, at); + h.run_steps(10); + assert!(field(&h)); + h.event(egui::Event::Text("Sour".into())); + h.step(); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(has(&h, "Lemon Yellow Sun") && !field(&h)); + // A double-click on it opens Swatch Options, and doesn't rename. + h.run_steps(20); + click(&mut h, at); + click(&mut h, at); + h.run_steps(10); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("swatchOptions")); + assert!(!field(&h), "no rename"); + // [Black] keeps its name. + h.state_mut().ui.dialog = None; + h.run_steps(20); + let at = name_at(&h, "[Black]"); + click(&mut h, at); + h.run_steps(10); + click(&mut h, at); + h.run_steps(10); + assert!(!field(&h)); + // A click on another swatch while the rename waits: that one is picked, and nothing + // renames. + let picked = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::<String>(egui::Id::unique(super::PICKED))); + let (lemon, paper) = (name_at(&h, "Lemon Yellow Sun"), name_at(&h, "[Paper]")); + h.run_steps(20); + click(&mut h, lemon); + h.run_steps(10); + click(&mut h, lemon); + click(&mut h, paper); + h.run_steps(10); + assert!(!field(&h), "the waiting rename is cancelled"); + assert_eq!(picked(&h).as_deref(), Some("[Paper]")); + // Renaming, a click on another swatch renames, and picks that one (for Delete Swatch). + click(&mut h, lemon); + h.run_steps(10); + click(&mut h, lemon); + h.run_steps(10); + assert!(field(&h)); + h.event(egui::Event::Text("Lemon".into())); + h.step(); + click(&mut h, paper); + h.run_steps(5); + assert!(has(&h, "Lemon") && !field(&h)); + assert_eq!(picked(&h).as_deref(), Some("[Paper]"), "the swatch clicked stays picked"); + } + #[test] fn the_tint_field_keeps_each_objects_own_swatch() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -1302,3 +1863,54 @@ mod swatches_header_tests { assert_eq!((it[1].fill.swatch.as_str(), it[1].fill.tint), ("C=0 M=100 Y=0 K=0", 0.4)); } } + +#[cfg(test)] +mod row_mark_tests { + use designcraft_color::swatch::{Swatch, default_swatches}; + use designcraft_color::{Color, ColorType, SwatchValue}; + + use super::{RowMarks, RowMode, row_label, row_marks}; + + /// A row's accessible name says what its marks show. + #[test] + fn swatch_rows_say_their_marks() { + let all = default_swatches(); + let label = |name: &str| row_label("", name, row_marks(all.iter().find(|s| s.name == name).unwrap())); + assert_eq!(label("[Black]"), "[Black], Process, CMYK, Cannot be edited"); + assert_eq!(label("[None]"), "[None], Cannot be edited"); + assert_eq!(label("[Paper]"), "[Paper]"); + let cyan = all.iter().find(|s| matches!(&s.value, SwatchValue::Color { .. }) && !s.locked).unwrap(); + let spot = Swatch { + name: "Pantone".into(), + value: SwatchValue::Color { color: Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }, color_type: ColorType::Spot }, + locked: false, + ..cyan.clone() + }; + assert_eq!(row_label("it", &spot.name, row_marks(&spot)), "Pantone, Tinta piatta, RGB"); + } + + /// The Swatches panel's right-hand columns, as InDesign's: [None], [Registration] and [Black] + /// can't be edited, [Paper] can; colours show their type and mode. + #[test] + fn swatch_rows_mark_type_mode_and_whether_they_can_be_edited() { + let all = default_swatches(); + let marks = |name: &str| row_marks(all.iter().find(|s| s.name == name).unwrap()); + assert_eq!(marks("[None]"), RowMarks { not_editable: true, kind: None, mode: Some(RowMode::None) }); + assert_eq!(marks("[Registration]"), RowMarks { not_editable: true, kind: None, mode: Some(RowMode::Registration) }); + assert_eq!(marks("[Paper]"), RowMarks { not_editable: false, kind: None, mode: None }); + assert_eq!(marks("[Black]"), RowMarks { not_editable: true, kind: Some(ColorType::Process), mode: Some(RowMode::Cmyk) }); + let cyan = all.iter().find(|s| matches!(&s.value, SwatchValue::Color { .. }) && !s.locked).unwrap(); + assert_eq!(row_marks(cyan), RowMarks { not_editable: false, kind: Some(ColorType::Process), mode: Some(RowMode::Cmyk) }); + let spot = Swatch { + name: "PANTONE".into(), + value: SwatchValue::Color { color: Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }, color_type: ColorType::Spot }, + locked: false, + ..cyan.clone() + }; + assert_eq!(row_marks(&spot), RowMarks { not_editable: false, kind: Some(ColorType::Spot), mode: Some(RowMode::Rgb) }); + // A locked Paper swatch under another name (a document from elsewhere) is still editable. + let paper = all.iter().find(|s| s.name == designcraft_color::swatch::PAPER).unwrap(); + let renamed = designcraft_color::swatch::Swatch { name: "Papier".into(), ..paper.clone() }; + assert!(!row_marks(&renamed).not_editable); + } +} diff --git a/crates/ui-egui/src/section_options.rs b/crates/ui-egui/src/section_options.rs index ea96f9f60..bb9be5ab4 100644 --- a/crates/ui-egui/src/section_options.rs +++ b/crates/ui-egui/src/section_options.rs @@ -36,13 +36,17 @@ pub(crate) fn open(app: &mut DesignApp) { let doc = &st.doc; let page = current.min(doc.page_count().saturating_sub(1)); let own = doc.sections.iter().find(|s| s.start == page); - // A new section starts from the one the page is in. + // A new section starts from the one the page is in. As in InDesign, the window opened on a + // page that doesn't start a section is New Section: Start Section checked, Start Page + // Numbering at offering 1, Automatic Page Numbering chosen. let base = own.or_else(|| doc.section_of(page)); + let new = page != 0 && own.is_none(); let fields = json!({ "page": page + 1, - "startSection": page == 0 || own.is_some(), + "newSection": new, + "startSection": true, "automatic": own.is_none_or(|s| s.start_number.is_none()), - "startNumber": doc.page_number(page).to_string(), + "startNumber": if new { "1".to_string() } else { doc.page_number(page).to_string() }, "prefix": base.map(|s| s.prefix.clone()).unwrap_or_default(), "style": base.map(|s| s.style).unwrap_or_default(), "marker": base.map(|s| s.marker.clone()).unwrap_or_default(), @@ -160,7 +164,12 @@ pub(crate) fn body(app: &DesignApp, ui: &mut Ui, d: &mut Dialog) { if ui.radio(!automatic, crate::rtl::widget(ui, tr("Start Page Numbering at:"))).clicked() { d.fields.insert("automatic".into(), json!(false)); } - ui.add_enabled_ui(!automatic, |ui| text(ui, d, "startNumber", FIELD_W)); + // As in InDesign, the number stays editable; typing one chooses Start Page Numbering at. + let mut n = d.s("startNumber"); + if ui.add(egui::TextEdit::singleline(&mut n).desired_width(FIELD_W)).changed() { + d.fields.insert("startNumber".into(), Value::String(n)); + d.fields.insert("automatic".into(), json!(false)); + } }); ui.add_space(4.0); group(ui, rtl, tr("Page Numbering"), |ui| { @@ -200,7 +209,12 @@ pub(crate) fn body(app: &DesignApp, ui: &mut Ui, d: &mut Dialog) { } }); if value == ChapterSource::UserDefined { - ui.add_enabled_ui(source == value, |ui| text(ui, d, "chapterStart", FIELD_W)); + // Editable whichever is chosen, as in InDesign; typing a number chooses it. + let mut n = d.s("chapterStart"); + if ui.add(egui::TextEdit::singleline(&mut n).desired_width(FIELD_W)).changed() { + d.fields.insert("chapterStart".into(), Value::String(n)); + d.fields.insert("chapterSource".into(), json!(value)); + } } }); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 846cf2e89..8efa2e423 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -83,6 +83,10 @@ pub struct Tokens { /// Measurement labels on the canvas (smart dimensions, gaps). pub measure_bg: Color32, pub measure_text: Color32, + /// The readout beside the cursor while drawing, moving or measuring ("W: 3.13 cm"): InDesign's + /// light grey box, with dark text in every brightness but High Contrast. + pub readout_bg: Color32, + pub readout_text: Color32, /// Live-corner widget and corner diamonds. pub live_corner: Color32, /// The Windows caption Close button when hovered, and its glyph. @@ -130,6 +134,8 @@ impl Tokens { smart_guide: hex(0x00c853), measure_bg: Color32::from_rgba_unmultiplied(70, 70, 70, 230), measure_text: Color32::WHITE, + readout_bg: hex(0xa8a8a8), + readout_text: hex(0x1e1e1e), live_corner: hex(0xffe500), caption_close: hex(0xc42b1c), caption_close_text: Color32::WHITE, @@ -255,6 +261,9 @@ impl Tokens { ruler_text: hex(0xffffff), section_divider: hex(0xffffff), tab_strip: hex(0x000000), + // White on InDesign's grey is too faint here: black, as the rest of the chrome. + readout_bg: hex(0x000000), + readout_text: Color32::WHITE, ..dark }, } @@ -853,3 +862,36 @@ mod russian_font_tests { } } } + +#[cfg(test)] +mod readout_tests { + use super::{Brightness, Tokens}; + + fn luminance(c: egui::Color32) -> f32 { + let lin = |v: u8| { + let v = v as f32 / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) + } + + fn contrast(a: egui::Color32, b: egui::Color32) -> f32 { + let (x, y) = (luminance(a), luminance(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) + } + + /// The readout beside the cursor ("W: 3.13 cm" while drawing a frame) is InDesign's light + /// grey box in every brightness, with text that stays readable on it; High Contrast keeps + /// it black. + #[test] + fn the_cursor_readout_is_indesigns_light_grey_with_readable_text() { + for b in [Brightness::Dark, Brightness::MediumDark, Brightness::MediumLight, Brightness::Light] { + let t = Tokens::for_brightness(b); + assert_eq!(t.readout_bg, egui::Color32::from_rgb(0xa8, 0xa8, 0xa8), "{b:?}"); + assert!(contrast(t.readout_text, t.readout_bg) >= 4.5, "{b:?}"); + } + let hc = Tokens::for_brightness(Brightness::HighContrast); + assert_eq!(hc.readout_bg, egui::Color32::BLACK); + assert!(contrast(hc.readout_text, hc.readout_bg) >= 4.5); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 4c6965ffc..ec5b6f67b 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -103,23 +103,61 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // on the tool (egui reports releases within 0.8 s as clicks). let hold_id = egui::Id::unique(("flyout_hold", gi)); let ended_hold = resp.clicked() && ui.data_mut(|d| d.remove_temp::<bool>(hold_id)).unwrap_or(false); - if resp.clicked() && !ended_hold { - chosen = Some(shown.id); + // Plain click selects the shown tool; Alt/Option-click cycles the flyout + // group, as the letter-key cycle does. Read the modifiers from the + // primary-release event (Alt may already be up by the time this frame + // reads i.modifiers), preferring a release on this slot; egui still + // counts a click when the pointer drifts a little outside, so a release + // whose press started here counts too, and one pressed elsewhere doesn't. + let click_mods = ui.input(|i| { + let press_here = i.pointer.press_origin().is_some_and(|o| resp.rect.contains(o)); + i.events + .iter() + .rev() + .find_map(|e| match e { + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers, + .. + } if resp.rect.contains(*pos) || press_here => Some(*modifiers), + _ => None, + }) + .unwrap_or(i.modifiers) + }); + let alt = click_mods.alt; + let modified = click_mods.alt || click_mods.shift || click_mods.ctrl || click_mods.command; + // egui's double-click ignores modifiers: remember whether the previous + // click was modified so dialogs only open for two plain clicks. + let prev_mod_id = egui::Id::unique(("tool-click-mod", gi)); + let prev_modified = ui.data(|d| d.get_temp::<bool>(prev_mod_id)).unwrap_or(false); + let slot_click = resp.clicked() && !ended_hold; + if slot_click { + chosen = Some(if g.len() > 1 && alt { + let from = g.iter().position(|tl| tl.id == current).unwrap_or(0); + g[(from + 1) % g.len()].id + } else { + shown.id + }); } if !resp.is_pointer_button_down_on() { ui.data_mut(|d| d.remove::<bool>(hold_id)); } // Double-click a polygon tool: Polygon Settings. - if resp.double_clicked() && matches!(shown.id, "polygon" | "polygonFrame") { + // Modified clicks only cycle / select: exclude them from dialogs. + let plain_double = resp.double_clicked() && !modified && !prev_modified; + if plain_double && matches!(shown.id, "polygon" | "polygonFrame") { let cur = app.session.execute("tool.polygonSettings", &json!({})).unwrap_or_default(); app.ui.dialog = Some(crate::dialogs::Dialog::new("polygonSettings", cur)); } // Double-click a transform tool: its Object › Transform dialog. - if resp.double_clicked() - && let Some(cmd) = transform_dialog_of(shown.id) - { + if plain_double && let Some(cmd) = transform_dialog_of(shown.id) { crate::menus::activate(app, cmd, &Value::Null); } + if slot_click { + ui.data_mut(|d| d.insert_temp(prev_mod_id, modified)); + } let held = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35)); if g.len() > 1 && held { diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index 6a8ea752c..7a0948817 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -378,3 +378,54 @@ fn fill_stroke_proxy_keys() { press(&mut app, &ctx, Key::X, Modifiers::NONE); assert_eq!(app.ui.stroke_front, front, "X while typing leaves the proxy alone"); } + +/// Alt/Option-click cycles a tool group's flyout without opening the menu. +#[test] +fn alt_click_cycles_the_tool_flyout_group() { + use egui::{Event, Modifiers, PointerButton}; + use egui_kittest::kittest::Queryable; + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + // 1 ms frames so an Alt-click and a following plain click can form a real double-click + // (default open() spaces frames by 0.25 s, outside egui's double-click window). + let mut h = crate::test_window::open_stepped(app, vec2(1400.0, 900.0), 1.0, 0.001); + h.state_mut().app.select_tool("rectangleFrame"); + h.run_steps(2); + let at = h.get_by_label("Rectangle Frame Tool (F)").rect().center(); + let alt = Modifiers { alt: true, ..Default::default() }; + // Clear i.modifiers before the release while keeping Alt on the release event, so a + // regression that only reads i.modifiers (and ignores the pointer-release modifiers) fails. + let click = |h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, press_modifiers: Modifiers, current_modifiers: Modifiers| { + h.input_mut().events.push(Event::ModifiersChanged(press_modifiers)); + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: press_modifiers }); + h.input_mut().events.push(Event::ModifiersChanged(current_modifiers)); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: press_modifiers }); + h.run_steps(3); + }; + // Break the double-click window between independent clicks (~0.4 s at 1 ms/step). + let settle = |h: &mut egui_kittest::Harness<'static, crate::test_window::Window>| h.run_steps(400); + click(&mut h, Modifiers::NONE, Modifiers::NONE); + assert_eq!(h.state().app.session.tool_id(), "rectangleFrame"); + assert!(h.state().app.ui.flyout.is_none()); + settle(&mut h); + click(&mut h, alt, Modifiers::NONE); + assert_eq!(h.state().app.session.tool_id(), "ellipseFrame", "Alt-click advances the frame group"); + assert!(h.state().app.ui.flyout.is_none(), "Alt-click must not open the flyout"); + settle(&mut h); + click(&mut h, alt, Modifiers::NONE); + assert_eq!(h.state().app.session.tool_id(), "polygonFrame"); + settle(&mut h); + click(&mut h, alt, Modifiers::NONE); + assert_eq!(h.state().app.session.tool_id(), "rectangleFrame", "Alt-click after the last group tool wraps to the first"); + assert!(h.state().app.ui.flyout.is_none(), "wrap-around Alt-click must not open the flyout"); + settle(&mut h); + // Alt-click onto polygonFrame, then a plain click inside the double-click window: egui + // reports double_clicked, but prev_modified must keep Polygon Settings closed. + h.state_mut().app.select_tool("ellipseFrame"); + h.run_steps(2); + click(&mut h, alt, Modifiers::NONE); + assert_eq!(h.state().app.session.tool_id(), "polygonFrame"); + click(&mut h, Modifiers::NONE, Modifiers::NONE); + assert!(h.state().app.ui.dialog.is_none(), "plain click after Alt-click must not open Polygon Settings"); + assert_eq!(h.state().app.session.tool_id(), "polygonFrame"); +} diff --git a/docs/agents.md b/docs/agents.md index 566357fbe..621173a85 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -54,6 +54,17 @@ same references, and MCP's `batch` tool takes `commands` or `script` text with t `.epub`, `.html`, and `.rtf` or `.txt` (the story being edited or of the selected frame). A name with no extension is a PNG of the page; another extension is an error, and nothing runs. +## Table of contents bookmarks + +`toc.generate` accepts `createBookmarks: true` to generate page-level PDF bookmarks from the +included paragraph styles and their levels. `toc.update` refreshes their titles, hierarchy and +destinations. Refresh keeps the generated outline at its previous position, preserving manual +bookmarks before and after it; disabling the option removes only the generated +bookmarks. Without `createBookmarks`, `toc.generate` keeps the document's existing TOC setting, +which is off when there is no TOC yet, so a script that makes a new TOC gets no bookmarks unless +it passes `createBookmarks: true`. The Table of Contents dialog differs: its Create PDF Bookmarks +option is on by default for a new TOC, and for an existing one it shows the saved setting. + ## Color themes `color.extractTheme {id?}` previews up to five RGB colours from a placed raster image (or the selected image). diff --git a/docs/gaps.md b/docs/gaps.md index e6a22d7a9..7e78c0eda 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -156,7 +156,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 25–40 h. ### 13. Hyphenation and spelling languages (T, L) -- **Missing:** hyphenation patterns for every language but English, Spanish, Portuguese, Norwegian, Greek and German (2006 reform); spelling for +- **Missing:** hyphenation patterns for every language but English, Spanish, Portuguese, Norwegian, Greek, German (2006 reform), French and Italian; spelling for every language but English. InDesign ships dictionaries for ~40 languages. - **Estimate:** 30–60 h (permissively licensed patterns exist for most; spelling word lists need licence review). **Doc:** [typography-parity.md](typography-parity.md) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 690ed23dc..df015a34b 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -36,7 +36,7 @@ come as Middle East editions with an English or French interface and right-to-le | Spanish | es | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; Spanish hyphenation ✓ (#222) | no | partial | 5–8 | | Hindi | hi | ✗ | 0 (0%) | none | Devanagari shaped by harfrust in documents but untested; no Hindi hyphenation or spelling | no | none | 20–30 | | Arabic | ar | Middle East edition (English/French UI) | 1,459 (85%) | menus, panels, most dialogs; no help | RTL UI text (`rtl.rs`: bidi + shaping); Arabic composition, kashida, digits, RTL stories and tables ✓ (arabic-typography.md); list bullets and multi-column RTL open (#100) | no | partial | 15–25 | -| French | fr | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no French hyphenation | no | partial | 6–10 | +| French | fr | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; French hyphenation ✓ (hyph-fr) | no | partial | 6–10 | | Portuguese (Brazil) | pt-br | ✓ (pt) | 1,057 (61%) | menus, panels, some dialogs; no help | Latin ✓; Portuguese hyphenation ✓ | no | partial | 8–12 | | Indonesian | id | ✗ | 0 (0%) | none | Latin ✓ | no | none | 12–18 | | Japanese | ja | ✓ | 1,644 (95%) | whole interface (#272), vertical-type commands; no help | vertical text, kinsoku, aki, ruby, warichu ✓ (partial depth, cjk.md); canvas IME ✓, not checked by hand (#322); Japanese UI and Mincho fonts from craft-fonts | no | partial | 6–10 (+ IME checks, shared with zh) | @@ -68,7 +68,7 @@ eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, ## Document languages InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries). -DesignCraft hyphenates **English, Spanish, Portuguese, Norwegian, Greek and German (2006 reform)** and spell-checks **English** only. Text language +DesignCraft hyphenates **English, French, German (2006 reform), Greek, Italian, Norwegian, Portuguese and Spanish** and spell-checks **English** only. Text language can be set per paragraph and character (Properties, Character panel) and drives quotes and locale shaping. See [typography-parity.md](typography-parity.md). German hyphenation accepts `German: 2006 Reform`, `German: Swiss 2006 Reform` and `de` locale @@ -97,3 +97,4 @@ German variants have no automatic hyphenation; their German shaping and quotes a | 2026-10-10 | major | Created: catalogs measured (1,724 strings), the twelve key languages plus Italian and Ukrainian | | 2026-10-11 | minor | Czech (cs) added: 1,722 strings, plural categories handled | | 2026-10-13 | minor | Polish interface added (1,717 strings) | +| 2026-10-22 | minor | French and Italian hyphenation (hyph-fr, hyph-it): the French row and Document languages updated | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 239920184..155ad1b94 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Keyboard Shortcuts on craft-keymap's shared dialog, with shortcut sets; Convert Variable to Text; list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Text Frame Options layout, Preview and Paragraph Spacing Limit; Swatches rows show InDesign's type columns; Keyboard Shortcuts on craft-keymap's shared dialog, with shortcut sets; Convert Variable to Text; list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -275,12 +275,12 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), Lock Guides (⌥⌘;) and Lock Column Guides (View ▸ Grids & Guides; Lock Column Guides is saved with the document and in IDML), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. -- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), Lock Guides (⌥⌘;) and Lock Column Guides (View ▸ Grids & Guides; Lock Column Guides is saved with the document and in IDML), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint, which reads 100 with nothing selected; a swatch goes to the fill or stroke in front; rows show InDesign's type columns: the crossed pencil on swatches that can't be edited, process or spot, and the colour mode; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP, with Find Format: text that has a character or paragraph style or character attributes; Change and Change/Find on the text Find Next found), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). -- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands. +- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu (in InDesign's order), Table panel, Insert Table dialog (with Table Style), Convert Text to Table and Convert Table to Text dialogs (any separators, Number of Columns, Table Style), caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. @@ -337,7 +337,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, the drop cap style as the leading "through 1 Dropcap" nested style): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), spaces right after them are set at their size without widening the indent, baselines don't move; a one-line drop cap keeps a smaller size of its own and sets its tabs at the paragraph's tab stops; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps. - User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. - Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. -- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. +- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. Overprint for the shading, the border and its gap (`shadingOverprint`, `borderOverprint`, `borderGapOverprint`; IDML ParagraphShadingOverprint, ParagraphBorderOverprint, ParagraphBorderGapOverprint) is kept; PDF export warns that it does not overprint yet, as for objects. Paragraph Style Options › Paragraph Border and Paragraph Shading are laid out as InDesign's: the page's heading, tint spinners with Overprint, corners in a two-by-two grid with shape glyph menus and the link between, spinner offsets. - Font menu laid out like InDesign's: search, a Filters row (classification: serif, slab serif, sans serif, script, monospaced, decorative, symbol, read from PANOSE, the OS/2 family class, the name, else the shape of the "l"; Show Favorites Only; Show Recently Added, fonts added in the last 30 days), the sample text (the selected text, Sample, pangrams) in three sizes, Recently Used fonts at the top (`recentFonts` preference), favourites (stars, `favoriteFonts`), and each family previewed in its own face with its format (TrueType, OpenType, variable, colour). A family of several styles shows how many and opens to list them, each previewed in its own face, and a style picked is applied as it is; Show Similar Fonts (≈) lists the families of the same class nearest by PANOSE. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order. - Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json). - View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). @@ -351,6 +351,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame from the top line's ascent to the bottom line's descent, between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. - Auto-size (Text Frame Options › Auto-Size, `object.textFrameOptions {autoSize: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference, useMinimumHeight, minimumHeight, useMinimumWidth, minimumWidth, noLineBreaks}`): the frame fits its text exactly after every edit (typing, pasting, style changes, option changes) in the same undo step (opening a file keeps the frame sizes it gives); it grows and shrinks around the reference point, which stays put in spread space for rotated frames too, never below the minimum height/width and never past 10,000 pt. Width Only and No Line Breaks set each paragraph on one line; Height and Width without No Line Breaks keeps the width; Keep Proportions scales both sides until the text fits. IDML keeps all the options (`AutoSizing…`, `…ForAutoSizing` attributes). Not covered: frames of a thread other than the last, vertical frames, type on a path, and multi-column balancing in Keep Proportions. - Balance Columns (Text Frame Options › Balance Columns, `object.textFrameOptions {balanceColumns}`, IDML `VerticalBalanceColumns`): in the frame the story ends in, the columns after the last explicit column break fill to about the same depth (the last may be shorter by a line per column); keep options hold, spanning and split paragraphs keep their own balancing, vertical justification then applies to each column as usual, and vertical frames balance their bands. A frame the text runs on from, or that is overset, fills normally. +- Text Frame Options dialog, laid out as InDesign's (General, Column Rules, Baseline Options, Auto-Size; framed Columns, Inset Spacing and Vertical Justification groups with spinner fields; Fixed Number shows each column's width as the fields change). Preview shows the edits on the page until OK (one undo step) or Cancel. An invalid value (such as a gutter that isn't a measure) takes the preview off the page, so the frame shows as it was, and the dialog's status line gives the reason; OK refuses the value too. Preview stays checked and shows the edits again once the value is valid. Vertical Justification › Paragraph Spacing Limit (`object.textFrameOptions {paragraphSpacingLimit}`, IDML `VerticalThreshold`): with Justify, the extra space goes between paragraphs up to the limit, the rest between lines. - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. - Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. Clipping paths saved in IDML (Detect Edges, Alpha Channel, Photoshop Path, User-Modified Path; Invert) hide the image outside the path on screen and in PDF and survive IDML export; `object.clippingPathOptions` reports, inverts or removes one (Object ▸ Clipping Path ▸ None). @@ -445,7 +446,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). A swatch on a placed greyscale or bitmap image (IDML `<Image FillColor>`) colours it on screen and in PDF. New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. -- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. +- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. Points selected with Direct Selection title Properties "Anchor Point(s)", with Convert Points and the Delete Point(s) quick action (`path.deletePoints`). - Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel (InDesign's layout: thumbnail, Type, Location, Angle, Reverse) sets type, angle, reverse and edits stops and midpoints on a ramp (drag, add, drag off to remove, location; drop a swatch from the Swatches panel on a stop or the ramp; stops keep their CMYK, and a stop on a swatch keeps it, so spot stops shade as Separation or DeviceN in PDF), and follows the Fill/Stroke proxy. Edited gradients are unnamed swatches until the Swatches panel's New Swatch names one "New Gradient Swatch" (`swatch.new`, which also duplicates a named swatch); `swatch.create {gradient}` builds one from color swatches; `object.gradient` (`target: stroke` for a stroke's gradient, drawn across the path's box on screen and in PDF); the vector round-trips through IDML (`GradientFill…`/`GradientStroke…`). A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF). Text takes a gradient swatch as its fill or stroke, laid across its frame's box as in InDesign (screen and PDF). - New Document (⌘N) as in InDesign: Recent, Saved, Print, Web and Mobile tabs of page cards (double-click creates) beside the preset's details: document name, size with arrows, units, orientation, pages, start page #, facing pages, primary text frame, columns, margins, bleed and slug per edge (linked or not); Save Document Preset (`file.savePreset`, `file.deletePreset`; saved presets are kept with the preferences, listed by `file.presets` and made by `file.new {preset: name}`); `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`. Preview shows a document with the window's settings in the workspace while it is open; Create keeps it, Close removes it. - Document Setup (⌥⌘P), laid out like InDesign's: intent, number of pages, start page # (an even start begins with a left page), facing pages, binding, Page Size (presets keeping the orientation, width and height with arrows, orientation), margins for every page and parent page, bleed and slug per edge (each group linked or not), and Adjust Layout (objects follow the new page size and margins), all in one undo step; measures are read in the document's units, and a value that can't be read keeps the dialog open; `layout.documentSetup {}` reports the setup, margins included. diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 65086cfdb..9382443aa 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -36,7 +36,7 @@ Fidelity: **unmeasured** unless stated. | Single-line Composer | greedy | greedy with the same ranges | ✓ | | | World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested | | Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab justifies after the tab (#284); set per paragraph in Type › Justification… and the Paragraph panel menu (#531) | -| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no), Greek (hyph-el-monoton) and German 2006 (hyph-de-1996); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame; discretionary U+00AD breaks with both composers regardless of automatic hyphenation and replaces pattern points within its word (#1955) | ~ | other languages don't hyphenate (#87 reported "doesn't work") | +| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no), Greek (hyph-el-monoton), German 2006 (hyph-de-1996), French (hyph-fr) and Italian (hyph-it); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame; discretionary U+00AD breaks with both composers regardless of automatic hyphenation and replaces pattern points within its word (#1955) | ~ | other languages don't hyphenate (#87 reported "doesn't work") | | Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | | | Kerning | Metrics, Optical, manual, none | Metrics, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently | | Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4bc40c627..3e6cac78a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Table of Contents dialog: title field keeps its width, More Options toggles without losing hidden settings; Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -68,8 +68,20 @@ focused text field, and does not interrupt an existing tool drag. Synthetic canv cover the cursors, click anchors, scrub/release ownership, restoration, typing guards and undo history (DC-1). +The Table of Contents title field reserves its edit width inside the title/style row. The +default Contents title is fully visible instead of being clipped to its first few characters. + +The Table of Contents dialog can collapse to its entry-style controls or expand to show page +number, separator, sorting and level controls. Toggling visibility preserves their settings. + ## Remaining effort +The Table of Contents separator field accepts `^y` (Right Indent Tab), with a special-character +picker alongside it. Literal carets use `^^`; ordinary tabs, em/en spaces and surrounding spaces +survive reopening. IDML preserves right-indent separators, including `$ID/^y`, so updating an +imported TOC keeps page numbers at the right edge and inherits paragraph tab leaders. This covers +10:54–16:08 of [Michael Bullo's TOC tutorial](https://www.youtube.com/watch?v=m4y2tm6LH_c). + Applying a paragraph or character style now removes local values equal to the applied style, so text used to capture a style follows later edits to that style. Different local formatting and named character styles stay intact. Paragraph-style application treats selection endpoints @@ -122,9 +134,13 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45 | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Dialogs: Table of Contents title field keeps its edit width; More Options toggles without losing hidden settings | | 2026-10-24 | minor | Panels row: Story panel (Optical Margin Alignment and its size) | | 2026-10-21 | minor | Chrome row: the text Control panel's A/¶ modes; Properties row: ••• expands Character and Paragraph in place | | 2026-10-11 | minor | Live corners row to ✓: widget, diamonds, drag, Shift-drag, Alt-click shape cycling (#186) | | 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests | | 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) | | 2026-10-10 | major | Created: measured menu coverage, 27-row interaction checklist from the code and issues | + +Double-clicking a section triangle in the Pages panel opens Numbering & Section Options for +that section’s first page. It leaves parent-page editing and does not edit section data until confirmed. diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml index 09ac37d73..750400657 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml @@ -17,6 +17,7 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: designcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 @@ -42,6 +43,11 @@ finish-args: - --filesystem=xdg-documents - --filesystem=xdg-pictures # Home/host filesystem access is deliberately not granted. +# The release runner's flatpak-builder has no eu-strip (elfutils), which its debuginfo pass runs +# on cups-client's unstripped tools, in whichever module comes first after they're installed. The +# app's own binaries come from the release tarball, already stripped. +build-options: + no-debuginfo: true modules: # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. # Linked statically against this release's libcups, so a runtime update can't break them; diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.yml index bd456579f..fd56910ce 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.yml @@ -18,6 +18,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: designcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index cf80fbba8..03d7cb8e4 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -106,6 +106,18 @@ <key>CFBundleTypeExtensions</key> <array><string>idml</string></array> </dict> + <!-- InDesign documents and templates, converted to IDML on open (file.open); by extension + for the same reason as above. --> + <dict> + <key>CFBundleTypeName</key> + <string>InDesign Document</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>CFBundleTypeExtensions</key> + <array><string>indd</string><string>indt</string></array> + </dict> </array> <!-- DesignCraft's own format: a zip package, declared as a document rather than an archive so