mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 13:46:52 -06:00
Merge main into fix/1964-frame-size-units
Three files conflicted. In dialogs.rs both sides added code after doc_unit: the PR's doc_vertical_unit and frame_size_dialog stay, followed by main's reworded Create Guides doc comment. In dialogs/tests.rs both sides appended tests at the end of the file: all of main's tests stay and the PR's two Rectangle dialog tests follow them. In panels/properties/object.rs main now opens Corner Options from the Properties Corner link, so main's line is kept and the PR's change there (the Rectangle dialog through frame_size_dialog) is dropped, since that link no longer opens the Rectangle dialog. Follow-up beyond the conflicts: main added a Dialog struct literal for the Change Format dialog, which now also sets the PR's new vertical_unit field to None so the crate builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+42
-1
@@ -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,6 +382,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
## Progress log
|
||||
|
||||
- **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-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.
|
||||
@@ -433,9 +435,12 @@ 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-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** · 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.
|
||||
@@ -455,13 +460,46 @@ 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 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** · 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-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.
|
||||
@@ -474,6 +512,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 `<Image FillColor>` 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.
|
||||
@@ -533,6 +572,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.
|
||||
|
||||
+27
-12
@@ -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<Swatch> {
|
||||
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<Swatch> {
|
||||
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<String> = 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();
|
||||
|
||||
@@ -2569,7 +2569,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 +2585,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 +5331,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<Deco> {
|
||||
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 {
|
||||
|
||||
@@ -4135,6 +4135,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]
|
||||
@@ -8065,6 +8115,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).
|
||||
|
||||
@@ -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<f64> = None,
|
||||
kenten_size: Option<f64> = 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<f32> = None,
|
||||
kenten_fill_tint: Option<f32> = None => "deserialize_explicit_value",
|
||||
kenten_stroke: String = String::new(),
|
||||
kenten_stroke_tint: Option<f32> = None,
|
||||
kenten_stroke_weight: Option<f64> = None,
|
||||
kenten_stroke_tint: Option<f32> = None => "deserialize_explicit_value",
|
||||
kenten_stroke_weight: Option<f64> = 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<f64> = None,
|
||||
ruby_font_size: Option<f64> = 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<f32> = None,
|
||||
ruby_fill_tint: Option<f32> = None => "deserialize_explicit_value",
|
||||
ruby_stroke: String = String::new(),
|
||||
ruby_stroke_tint: Option<f32> = None,
|
||||
ruby_stroke_weight: Option<f64> = None,
|
||||
ruby_stroke_tint: Option<f32> = None => "deserialize_explicit_value",
|
||||
ruby_stroke_weight: Option<f64> = 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.
|
||||
|
||||
@@ -188,7 +188,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<f64> = None,
|
||||
list_tab: Option<f64> = 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 +222,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());
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<Bookmark>,
|
||||
/// 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 {
|
||||
|
||||
@@ -286,6 +286,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<usize>) -> Vec<(usize, std::ops::Range<usize>)> {
|
||||
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 +657,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<usize>)> =
|
||||
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();
|
||||
|
||||
@@ -13,10 +13,11 @@ fn find_preset(s: &Session, name: &str) -> Option<NewDocument> {
|
||||
}
|
||||
|
||||
/// 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<NewDocument> {
|
||||
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<Value> {
|
||||
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)) => {
|
||||
|
||||
@@ -61,18 +61,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
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?, change?|first?|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?, first?: bool (change only the first match), 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",
|
||||
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?, change?|first?|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),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -355,9 +355,28 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<String> = 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 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 targets = scope(s, p)?.targets;
|
||||
s.edit(|d, sel| {
|
||||
let styles = d.styles.clone();
|
||||
let mut count = 0usize;
|
||||
for (sid, cell, r) in targets {
|
||||
let Some(story) = d.text_story(sid, cell) else { continue };
|
||||
@@ -374,9 +393,29 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.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;
|
||||
}
|
||||
@@ -489,6 +528,83 @@ 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::<Vec<_>>());
|
||||
assert_eq!(
|
||||
story.text,
|
||||
"One
|
||||
Two
|
||||
Three"
|
||||
);
|
||||
}
|
||||
|
||||
#[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 find_and_change_objects_by_attributes() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -73,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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}))
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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<u64> {
|
||||
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();
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
};
|
||||
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()}))
|
||||
})
|
||||
}
|
||||
|
||||
+128
-13
@@ -607,7 +607,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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 +616,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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 +638,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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)
|
||||
@@ -2101,13 +2110,18 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => None,
|
||||
};
|
||||
let mode: Option<WrapMode> = 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<f64>; 4]> = match p.get("offset") {
|
||||
Some(Value::Number(n)) => Some([n.as_f64(); 4]),
|
||||
Some(v) => serde_json::from_value::<[Option<f64>; 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 +2141,11 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 +2170,31 @@ fn corner_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Corner>; 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.
|
||||
@@ -2938,7 +2968,9 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} 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 +2979,35 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
// 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<ItemId> = 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<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let ids: Vec<_> =
|
||||
@@ -4352,6 +4413,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)]
|
||||
|
||||
@@ -50,12 +50,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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)]
|
||||
|
||||
@@ -81,13 +81,91 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut push = |c: Vec<char>| {
|
||||
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();
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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<usize>, 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<Value> {
|
||||
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<Value> {
|
||||
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<usize>) {
|
||||
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<Option<String>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
{
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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(())
|
||||
})
|
||||
|
||||
@@ -28,4 +28,4 @@ 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};
|
||||
|
||||
@@ -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,
|
||||
@@ -397,6 +397,15 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
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),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -66,6 +66,55 @@ pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result<Value> {
|
||||
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<Value> {
|
||||
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()?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -416,7 +416,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -380,6 +380,8 @@ pub(super) fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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}))
|
||||
}
|
||||
|
||||
@@ -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<Value> {
|
||||
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;
|
||||
|
||||
@@ -125,9 +125,12 @@ pub(super) fn selection_attrs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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()});
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -19,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<f64>) -> Result<usize> {
|
||||
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<designcraft_doc::Bookmark> {
|
||||
/// Collect descendants until the next sibling or ancestor entry.
|
||||
fn children(iter: &mut std::iter::Peekable<std::slice::Iter<'_, Found>>, parent: u8) -> Vec<designcraft_doc::Bookmark> {
|
||||
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<Value> {
|
||||
let entries = entries_param(p)?;
|
||||
if entries.is_empty() {
|
||||
@@ -320,6 +342,10 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
(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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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);
|
||||
|
||||
@@ -250,6 +250,61 @@ pub struct Prefs {
|
||||
pub recent_files: Vec<String>,
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -877,6 +877,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 +1664,134 @@ 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();
|
||||
}
|
||||
|
||||
#[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::<Vec<_>>()
|
||||
};
|
||||
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]
|
||||
|
||||
@@ -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,7 +526,7 @@ 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": [
|
||||
&json!({"page": page, "title": "Inside", "titleStyle": basic, "createBookmarks": true, "entries": [
|
||||
{"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "},
|
||||
{"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();
|
||||
|
||||
@@ -127,6 +127,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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
@@ -258,12 +258,13 @@ fn parse_single(tok: &str, default: Unit, percent_base: Option<f64>) -> Option<f
|
||||
Some(unit.to_pt(n))
|
||||
}
|
||||
|
||||
/// Format a value (points) in `unit` the way InDesign fields show it: up to 3 decimals, trailing
|
||||
/// zeros trimmed; picas as `3p6`, ciceros as `2c4`.
|
||||
/// Format a value (points) in `unit` the way InDesign fields show it: up to 4 decimals for inches,
|
||||
/// decimal inches and centimetres, up to 3 for the other units, trailing zeros trimmed; picas as
|
||||
/// `3p6`, ciceros as `2c4`.
|
||||
pub fn format_measure(pt: f64, unit: Unit) -> 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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -346,6 +346,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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +238,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<Option<String>> = fields.iter().map(|f| self.para_setting(e, &format!("{k}{f}"))).collect();
|
||||
@@ -246,7 +251,6 @@ impl<'r> Importer<'r> {
|
||||
let num = |i: usize| get(i).and_then(|v| v.trim().parse::<f64>().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 +262,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::<f64>().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<String> {
|
||||
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<String> {
|
||||
|
||||
@@ -330,6 +330,10 @@ fn indesign_text_defaults() -> Vec<AttrRow> {
|
||||
"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;
|
||||
@@ -3934,6 +3938,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#" </RootParagraphStyleGroup>"#,
|
||||
r#" <ParagraphStyle Self="ParagraphStyle/Base" Name="Base" RuleBelow="true" RuleBelowLineWeight="3">
|
||||
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
|
||||
</ParagraphStyle>
|
||||
<ParagraphStyle Self="ParagraphStyle/Wavy" Name="Wavy" RuleBelowType="StrokeStyle/$ID/Wavy">
|
||||
<Properties><BasedOn type="object">ParagraphStyle/Base</BasedOn></Properties>
|
||||
</ParagraphStyle>
|
||||
<ParagraphStyle Self="ParagraphStyle/Gap" Name="Gap" RuleBelowGapColor="Color/Black">
|
||||
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
|
||||
</ParagraphStyle>
|
||||
<ParagraphStyle Self="ParagraphStyle/Kept" Name="Kept" KeepRuleAboveInFrame="true">
|
||||
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
|
||||
</ParagraphStyle>
|
||||
<ParagraphStyle Self="ParagraphStyle/TextGap" Name="TextGap" RuleBelowGapColor="$ID/Text Color" RuleAboveColor="$ID/Text Color">
|
||||
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn><RuleAboveGapColor type="string">Text Color</RuleAboveGapColor></Properties>
|
||||
</ParagraphStyle>
|
||||
</RootParagraphStyleGroup>"#,
|
||||
);
|
||||
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).
|
||||
|
||||
@@ -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<u8> {
|
||||
|
||||
@@ -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<Overlay> {
|
||||
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![]
|
||||
|
||||
@@ -703,6 +703,19 @@ impl SelectionTool {
|
||||
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() })];
|
||||
}
|
||||
// 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 };
|
||||
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).
|
||||
if let Some((id, h)) = content_handle_at(cx, p)
|
||||
@@ -776,7 +789,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
|
||||
@@ -1078,7 +1095,9 @@ impl SelectionTool {
|
||||
}
|
||||
self.guides = hit.guides;
|
||||
}
|
||||
let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy});
|
||||
// path.moveAnchors takes a spread-space delta.
|
||||
let delta = 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": delta.x, "dy": delta.y});
|
||||
if let Some(h) = handle {
|
||||
params["handle"] = json!(h);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,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<String> = 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<String> = 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 +1135,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 +1557,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] {
|
||||
@@ -1966,3 +2069,50 @@ 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:?}");
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ pub struct TypeTool {
|
||||
start: Option<Point>,
|
||||
selecting: Option<u64>,
|
||||
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![],
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2837,3 +2837,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));
|
||||
}
|
||||
|
||||
@@ -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<usize> = 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)> {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<designcraft_doc::SpreadId>,
|
||||
}
|
||||
|
||||
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<PageBox> {
|
||||
let st = app.session.active()?;
|
||||
let pages = st.doc.page_count();
|
||||
let mut names: Vec<String> = (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);
|
||||
|
||||
@@ -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<String> = (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));
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+1149
-100
File diff suppressed because it is too large
Load Diff
@@ -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] {
|
||||
|
||||
@@ -2401,7 +2401,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 +2528,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 +2587,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 +3253,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 +3271,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]
|
||||
@@ -3469,18 +3472,20 @@ fn guide_counts(app: &DesignApp) -> Vec<usize> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_guides_dialog_targets_the_page_in_view() {
|
||||
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]);
|
||||
}
|
||||
@@ -3522,6 +3527,816 @@ fn character_style_options_set_the_digits() {
|
||||
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<egui::Rect> = 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:", 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:", 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]));
|
||||
}
|
||||
|
||||
/// 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<egui::Pos2>| {
|
||||
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::<bool>(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::<bool>(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.
|
||||
h.get_by_label("Clear specified attributes").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!(change_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());
|
||||
}
|
||||
|
||||
/// 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::<bool>(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::<bool>(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::<Vec<_>>()
|
||||
};
|
||||
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"));
|
||||
}
|
||||
|
||||
/// The Rectangle dialog a frame-tool click opens shows the width in the horizontal ruler unit and
|
||||
/// the height in the vertical one, not always picas, and reads bare numbers the same way (#1964).
|
||||
#[test]
|
||||
|
||||
@@ -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<f64> {
|
||||
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<f64> {
|
||||
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) {
|
||||
@@ -767,6 +858,52 @@ 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<egui::Event>, 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_follow_distance_and_angle_as_in_indesign() {
|
||||
let (mut app, _) = app_with_frame();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Original Arabic interface translations, stored in logical Unicode order.
|
||||
pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Fewer Options", "خيارات أقل"),
|
||||
("File", "ملف"),
|
||||
("Edit", "تحرير"),
|
||||
("Layout", "تخطيط"),
|
||||
@@ -31,6 +32,40 @@ 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 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", "غير قابل للتحرير"),
|
||||
("Restart Numbering", "إعادة الترقيم"),
|
||||
("Continue Numbering", "متابعة الترقيم"),
|
||||
("Keep Options…", "خيارات الإبقاء…"),
|
||||
@@ -391,6 +426,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Object Layer Options", "خيارات طبقات الكائن"),
|
||||
("Colour Picker", "منتقي الألوان"),
|
||||
("Color Picker", "أداة اختيار الألوان"),
|
||||
("Color spectrum", "طيف الألوان"),
|
||||
("Color Settings", "إعدادات الألوان"),
|
||||
("User Dictionary", "قاموس المستخدم"),
|
||||
("Menu Customization", "تخصيص القوائم"),
|
||||
@@ -1975,6 +2011,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Object/Fitting", "كائن/ملاءمة"),
|
||||
("Object/Pathfinder", "كائن/باحث المسارات"),
|
||||
("Object/Paths", "كائن/مسارات"),
|
||||
("Object/Convert Point", "كائن/تحويل النقطة"),
|
||||
("Object/Select", "كائن/تحديد"),
|
||||
("Object/Transform", "كائن/تحويل"),
|
||||
("Object/Transform Again", "كائن/تحويل مجدداً"),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 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í"),
|
||||
("File", "Soubor"),
|
||||
("Edit", "Úpravy"),
|
||||
("Layout", "Rozvržení"),
|
||||
@@ -32,6 +33,44 @@ 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 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"),
|
||||
("Restart Numbering", "Začít číslování znovu"),
|
||||
("Continue Numbering", "Pokračovat v číslování"),
|
||||
("Keep Options…", "Volby zachování…"),
|
||||
@@ -194,6 +233,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 +1696,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"),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 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"),
|
||||
("File", "Datei"),
|
||||
("Edit", "Bearbeiten"),
|
||||
("Layout", "Layout"),
|
||||
@@ -31,6 +32,16 @@ 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"),
|
||||
("Restart Numbering", "Nummerierung neu beginnen"),
|
||||
("Continue Numbering", "Nummerierung fortführen"),
|
||||
("Keep Options…", "Umbruchoptionen …"),
|
||||
@@ -223,6 +234,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 +1680,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"),
|
||||
|
||||
@@ -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"),
|
||||
@@ -168,10 +167,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 +242,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"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 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"),
|
||||
("File", "File"),
|
||||
("Edit", "Modifica"),
|
||||
("Layout", "Layout"),
|
||||
@@ -29,6 +30,41 @@ 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 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"),
|
||||
("Restart Numbering", "Riavvia numerazione"),
|
||||
("Continue Numbering", "Continua numerazione"),
|
||||
("Keep Options…", "Opzioni di separazione…"),
|
||||
@@ -305,6 +341,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"),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 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", "オプションを減らす"),
|
||||
("File", "ファイル"),
|
||||
("Edit", "編集"),
|
||||
("Layout", "レイアウト"),
|
||||
@@ -30,6 +31,14 @@ 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", "編集不可"),
|
||||
("Restart Numbering", "番号の振り直し"),
|
||||
("Continue Numbering", "番号の継続"),
|
||||
("Keep Options…", "段落分離禁止オプション…"),
|
||||
@@ -408,6 +417,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Choose Image…", "画像を選択…"),
|
||||
("Class:", "クラス:"),
|
||||
("Color Picker", "カラーピッカー"),
|
||||
("Color spectrum", "カラースペクトル"),
|
||||
("Color Settings", "カラー設定"),
|
||||
("Color:", "カラー:"),
|
||||
("Columns", "列数"),
|
||||
@@ -1872,6 +1882,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Object/Fitting", "オブジェクト/オブジェクトサイズの調整"),
|
||||
("Object/Pathfinder", "オブジェクト/パスファインダー"),
|
||||
("Object/Paths", "オブジェクト/パス"),
|
||||
("Object/Convert Point", "オブジェクト/ポイントを切り換え"),
|
||||
("Object/Select", "オブジェクト/選択"),
|
||||
("Object/Transform", "オブジェクト/変形"),
|
||||
("Object/Transform Again", "オブジェクト/変形を再適用"),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! 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", "ნაკლები პარამეტრი"),
|
||||
("File", "ფაილი"),
|
||||
("Edit", "რედაქტირება"),
|
||||
("Layout", "განლაგება"),
|
||||
@@ -32,6 +33,38 @@ 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 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", "რედაქტირება შეუძლებელია"),
|
||||
("Restart Numbering", "ნუმერაციის თავიდან დაწყება"),
|
||||
("Continue Numbering", "ნუმერაციის გაგრძელება"),
|
||||
("Keep Options…", "შენარჩუნების პარამეტრები…"),
|
||||
@@ -227,6 +260,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Choose Image…", "გამოსახულების არჩევა…"),
|
||||
("Class:", "კლასი:"),
|
||||
("Color Picker", "ფერის ამრჩევი"),
|
||||
("Color spectrum", "ფერთა სპექტრი"),
|
||||
("Color Settings", "ფერის პარამეტრები"),
|
||||
("Color:", "ფერი:"),
|
||||
("Columns", "სვეტები"),
|
||||
@@ -1703,6 +1737,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Object/Fitting", "ობიექტი/ზომაზე მორგება"),
|
||||
("Object/Pathfinder", "ობიექტი/ტრაექტორიების ოპერაციები"),
|
||||
("Object/Paths", "ობიექტი/ტრაექტორიები"),
|
||||
("Object/Convert Point", "ობიექტი/წერტილის გარდაქმნა"),
|
||||
("Object/Select", "ობიექტი/მონიშვნა"),
|
||||
("Object/Transform", "ობიექტი/გარდაქმნა"),
|
||||
("Object/Transform Again", "ობიექტი/გარდაქმნის გამეორება"),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! 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"),
|
||||
("File", "Plik"),
|
||||
("Edit", "Edycja"),
|
||||
("Layout", "Układ"),
|
||||
@@ -30,6 +31,44 @@ 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 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ć"),
|
||||
("Restart Numbering", "Zacznij numerowanie od nowa"),
|
||||
("Continue Numbering", "Kontynuuj numerowanie"),
|
||||
("Keep Options…", "Opcje utrzymywania…"),
|
||||
@@ -194,6 +233,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 +1674,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"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 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"),
|
||||
("File", "Arquivo"),
|
||||
("Edit", "Editar"),
|
||||
("Layout", "Layout"),
|
||||
@@ -32,6 +33,41 @@ 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 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"),
|
||||
("Restart Numbering", "Reiniciar numeração"),
|
||||
("Continue Numbering", "Continuar numeração"),
|
||||
("Keep Options…", "Opções de manutenção…"),
|
||||
@@ -399,6 +435,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"),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 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", "Меньше параметров"),
|
||||
("File", "Файл"),
|
||||
("Edit", "Редактирование"),
|
||||
("Can't Open the File", "Не удалось открыть файл"),
|
||||
@@ -34,6 +35,44 @@ 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 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", "Нельзя изменить"),
|
||||
("Restart Numbering", "Начать нумерацию заново"),
|
||||
("Continue Numbering", "Продолжить нумерацию"),
|
||||
("Keep Options…", "Параметры удержания…"),
|
||||
@@ -194,6 +233,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Choose Image…", "Выбрать изображение…"),
|
||||
("Class:", "Класс:"),
|
||||
("Color Picker", "Палитра цветов"),
|
||||
("Color spectrum", "Цветовой спектр"),
|
||||
("Color Settings", "Настройка цветов"),
|
||||
("Color:", "Цвет:"),
|
||||
("Columns", "Колонки"),
|
||||
@@ -1638,6 +1678,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Object/Fitting", "Объект/Подгонка"),
|
||||
("Object/Pathfinder", "Объект/Обработка контуров"),
|
||||
("Object/Paths", "Объект/Контуры"),
|
||||
("Object/Convert Point", "Объект/Преобразовать точку"),
|
||||
("Object/Select", "Объект/Выбрать"),
|
||||
("Object/Transform", "Объект/Трансформирование"),
|
||||
("Object/Transform Again", "Объект/Повторить трансформирование"),
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
/// 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",
|
||||
"フレーム調整オプションを消去",
|
||||
"清除框架适合选项",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Character Formatting Controls",
|
||||
[
|
||||
@@ -121,6 +147,14 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("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 進)", "十进制英寸"]),
|
||||
(
|
||||
@@ -953,12 +987,50 @@ 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 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", "カラースペクトル", "色谱"]),
|
||||
("Color Settings", ["Color Settings", "Réglages des couleurs", "Color Settings", "Color Settings", "颜色设置"]),
|
||||
("Color:", ["Color:", "Couleur :", "Color:", "Color:", "颜色:"]),
|
||||
("Columns", ["Columns", "Colonnes", "Columns", "Columns", "栏"]),
|
||||
@@ -1128,6 +1200,8 @@ 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", "編集不可", "不可编辑"]),
|
||||
("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 +1655,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 +2154,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 +2506,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",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! 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", "Менше параметрів"),
|
||||
("File", "Файл"),
|
||||
("Edit", "Редагування"),
|
||||
("Layout", "Макет"),
|
||||
@@ -30,6 +31,41 @@ 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 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", "Не можна змінити"),
|
||||
("Restart Numbering", "Почати нумерацію знову"),
|
||||
("Continue Numbering", "Продовжити нумерацію"),
|
||||
("Keep Options…", "Параметри утримання…"),
|
||||
@@ -308,6 +344,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Choose Image…", "Вибрати зображення…"),
|
||||
("Class:", "Клас:"),
|
||||
("Color Picker", "Палітра кольорів"),
|
||||
("Color spectrum", "Колірний спектр"),
|
||||
("Color Settings", "Налаштування кольору"),
|
||||
("Color:", "Колір:"),
|
||||
("Columns", "Колонки"),
|
||||
|
||||
@@ -857,6 +857,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))] {
|
||||
@@ -1178,6 +1205,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 +1340,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<String> = 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -116,7 +116,9 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.newDocumentDialog" => {
|
||||
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) {
|
||||
@@ -510,6 +512,15 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
}
|
||||
f["pasteboard.h"] = m(&doc["pasteboard"][0]);
|
||||
f["pasteboard.v"] = m(&doc["pasteboard"][1]);
|
||||
} else {
|
||||
// No document: Units & Increments are what new documents start with, as InDesign's.
|
||||
let du = app.session.prefs.default_units;
|
||||
f["defaults"] = json!(true);
|
||||
f["horizontalUnits"] = json!(du.horizontal_units);
|
||||
f["verticalUnits"] = json!(du.vertical_units);
|
||||
f["rulerOrigin"] = json!(du.ruler_origin);
|
||||
f["keyboardIncrement"] = json!(designcraft_geom::format_measure(du.keyboard_increment, designcraft_geom::Unit::Points));
|
||||
f["kerningIncrement"] = json!(format!("{}", du.kerning_increment));
|
||||
}
|
||||
f["snap.alignEdges"] = json!(app.ui.align_edges);
|
||||
f["snap.alignCenters"] = json!(app.ui.align_centers);
|
||||
@@ -523,6 +534,10 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
});
|
||||
f["uiScale"] = json!((app.ui.ui_scale * 100.0).round());
|
||||
f["autoCollapseIconicPanels"] = json!(app.ui.dock_auto_collapse);
|
||||
// Interface: Cancel goes back to the theme Preferences opened with.
|
||||
f["brightness"] = json!(app.ui.brightness.id());
|
||||
f["openedBrightness"] = json!(app.ui.brightness.id());
|
||||
f["toolsColumns"] = json!(if app.ui.tools_double_column { "double" } else { "single" });
|
||||
f["richBlack"] = json!(app.ui.rich_black);
|
||||
f["dynamicSpelling"] = json!(app.ui.dynamic_spelling);
|
||||
f["storyEditorSize"] = json!(app.ui.story_editor_size);
|
||||
@@ -984,7 +999,10 @@ fn run_ui_part5(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
app.ui.pending_urls.push(url.clone());
|
||||
Ok(json!({"url": url}))
|
||||
}
|
||||
"window.toolsDoubleColumn" => 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 =
|
||||
|
||||
@@ -299,57 +299,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 +317,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,37 +411,12 @@ 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\"}",
|
||||
"<",
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
};
|
||||
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<String> {
|
||||
let items = MENUS.iter().find(|(t, _)| *t == title).map(|(_, items)| *items).unwrap_or_default();
|
||||
|
||||
@@ -372,7 +372,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",
|
||||
|
||||
@@ -59,7 +59,13 @@ fn fields_for(nd: &NewDocument) -> Map<String, Value> {
|
||||
|
||||
/// 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> {
|
||||
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
|
||||
|
||||
@@ -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::<Vec<_>>(), ["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();
|
||||
|
||||
@@ -82,6 +82,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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -646,7 +646,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 +680,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…") {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -31,7 +31,9 @@ use flex::flex_section;
|
||||
pub(crate) use links::run_update_links;
|
||||
pub use links::{info_panel, links_panel, preflight_panel, update_links};
|
||||
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};
|
||||
#[cfg(test)]
|
||||
use object::{align_to_icon, preview_row};
|
||||
pub(crate) use object::{corner_shape_button, corner_type_dropdown, ref_point_proxy, stroke_type_dropdown};
|
||||
use object_panels::pathfinder_row;
|
||||
pub use object_panels::{align_panel, attributes_panel, effects_panel, pathfinder_panel, stroke_panel, wrap_panel};
|
||||
pub(crate) use text::{LANGUAGES, kerning_control, language_menu};
|
||||
@@ -202,7 +204,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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -173,11 +173,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 +186,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 +219,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(super) 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 +267,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`.
|
||||
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 +333,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 +349,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 +374,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 +394,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::frame_size_dialog(app, 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 +423,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 +437,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 +561,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 +588,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 +642,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 [
|
||||
|
||||
@@ -638,6 +638,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 +715,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 +786,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 +1061,44 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_flips_and_rotates_from_properties() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,8 @@ 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);
|
||||
// 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 });
|
||||
@@ -206,7 +207,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);
|
||||
}
|
||||
@@ -252,20 +254,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 +418,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()
|
||||
@@ -299,12 +437,25 @@ fn swatch_row(
|
||||
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);
|
||||
// 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() {
|
||||
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);
|
||||
@@ -360,7 +511,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 +570,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 +727,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 +818,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 +843,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 +983,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 +1045,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 +1063,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 +1096,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 +1260,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 +1470,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 +1582,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,7 +1601,7 @@ 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);
|
||||
@@ -1302,3 +1634,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 (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,7 +275,7 @@ 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.
|
||||
- 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), 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.
|
||||
|
||||
+8
-1
@@ -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,6 +68,12 @@ 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
|
||||
|
||||
Applying a paragraph or character style now removes local values equal to the applied style,
|
||||
@@ -122,6 +128,7 @@ 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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user