diff --git a/ROADMAP.md b/ROADMAP.md index 435d113a24..03ccfb19f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** trivial (Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word (#5853)); previously trivial (progress log: #4501 refactor regressions restored: per-section header grid, ODT default font, link tooltip, #4683); previously trivial (progress log: the window moves by its title bar behind a modal dialog, #5124); previously trivial (progress log: `convert --strict` and `render --strict` refuse a silently incomplete PDF or PNG (#6005, #6008)); ; trivial (progress log: ODT columns stating only their shares); previously trivial (progress log: ODT font size for every script); previously trivial (progress log: ODT text turning off what its style turns on); previously trivial (progress log: edited ODT paragraphs keep custom styles with an outline level); previously trivial (progress log: ODT pictures keep the size their file gives); previously trivial (progress log: ODT frames keep the page they are anchored to); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; previously trivial (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured); previously trivial (progress log: ODT documents keep their language); previously trivial (progress log: ODT table and row backgrounds) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** trivial (progress log: Save As on Linux: the desktop portal's Save dialog reports the file type picked, updating the extension so saving as PDF writes a .pdf, #6180); previously trivial (Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word (#5853)); previously trivial (progress log: #4501 refactor regressions restored: per-section header grid, ODT default font, link tooltip, #4683); previously trivial (progress log: the window moves by its title bar behind a modal dialog, #5124); previously trivial (progress log: `convert --strict` and `render --strict` refuse a silently incomplete PDF or PNG (#6005, #6008)); ; trivial (progress log: ODT columns stating only their shares); previously trivial (progress log: ODT font size for every script); previously trivial (progress log: ODT text turning off what its style turns on); previously trivial (progress log: edited ODT paragraphs keep custom styles with an outline level); previously trivial (progress log: ODT pictures keep the size their file gives); previously trivial (progress log: ODT frames keep the page they are anchored to); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; previously trivial (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured); previously trivial (progress log: ODT documents keep their language); previously trivial (progress log: ODT table and row backgrounds) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > > **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged. @@ -911,6 +911,7 @@ MATH glyphs that reference the same construction now share its immutable variant | Date | Change | Summary | |---|---|---| +| 2026-11-10 | trivial | Save As on Linux: the desktop portal's Save dialog reports the file type picked, updating the extension so saving as PDF writes a .pdf (#6180) | | 2026-11-04 | trivial | Progress log: #4501 refactor regressions restored (#4683) | | 2026-11-06 | trivial | Progress log: edited ODT paragraphs keep custom styles with an outline level (gap 19) | | 2026-10-31 | trivial | Control/MCP #2596 review fixes; readiness estimates unchanged | diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 54e1730a77..73e8f8a53f 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -23,6 +23,8 @@ use std::future::Future; use std::pin::Pin; use std::sync::mpsc::{Receiver, Sender, channel}; +#[cfg(all(unix, not(target_os = "macos")))] +use std::sync::{Arc, Mutex}; use wordcraft_ui_egui::{FileDialogAnswer, FileDialogRequest}; @@ -32,6 +34,59 @@ type Request = (FileDialogRequest, Sender); /// The `Services::file_dialog` hook. pub type Hook = Box Receiver>; +/// Cancels the portal Save dialog the launcher is waiting on, if any. +/// +/// The portal's dialog lives outside the app's window, so a portal that stops answering cannot be +/// dismissed from WordCraft. Giving the dialog up (File › Open › Browse in WordCraft…) cancels the +/// request: the waiting thread closes it and reports a cancellation, and the built-in browser +/// opens instead. A no-op where Save As doesn't ask the portal (macOS and Windows). +#[derive(Clone, Default)] +pub struct CancelHandle { + #[cfg(all(unix, not(target_os = "macos")))] + armed: Arc>>, + #[cfg(all(unix, not(target_os = "macos")))] + next_id: Arc, +} + +/// The token a request installed in [`CancelHandle`], with the identity that owns it. +#[cfg(all(unix, not(target_os = "macos")))] +struct Armed { + id: u64, + token: crate::portal_save::CancelToken, +} + +impl CancelHandle { + /// Cancels the pending portal request, if there is one. + pub fn cancel(&self) { + #[cfg(all(unix, not(target_os = "macos")))] + { + let mut slot = self.armed.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(armed) = slot.take() { + armed.token.cancel(); + } + } + } + + /// Arms the handle for the portal request about to wait, returning its token and identity. + #[cfg(all(unix, not(target_os = "macos")))] + fn arm(&self) -> Armed { + let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1); + let token = crate::portal_save::CancelToken::new(); + *self.armed.lock().unwrap_or_else(|e| e.into_inner()) = Some(Armed { id, token: token.clone() }); + Armed { id, token } + } + + /// Disarms the handle once the request with `id` has answered, leaving a newer request's + /// token alone: an abandoned request finishing late must not make the next one uncancellable. + #[cfg(all(unix, not(target_os = "macos")))] + fn disarm(&self, id: u64) { + let mut slot = self.armed.lock().unwrap_or_else(|e| e.into_inner()); + if slot.as_ref().is_some_and(|armed| armed.id == id) { + *slot = None; + } + } +} + /// A picker's answer, waited for off the UI thread. type Picking = Pin> + Send>>; @@ -39,6 +94,7 @@ type Picking = Pin> + Send>>; pub struct Launcher { queue: Receiver, ctx: egui::Context, + cancel: CancelHandle, } /// The hook for [`Services::file_dialog`](wordcraft_ui_egui::Services::file_dialog) and the @@ -51,10 +107,15 @@ pub fn hook(ctx: &egui::Context) -> (Hook, Launcher) { let _ = queue_tx.send((req, answer)); rx }); - (hook, Launcher { queue, ctx: ctx.clone() }) + (hook, Launcher { queue, ctx: ctx.clone(), cancel: CancelHandle::default() }) } impl Launcher { + /// A handle that cancels the portal Save dialog the launcher is waiting on, if any. + pub fn cancel_handle(&self) -> CancelHandle { + self.cancel.clone() + } + /// Show the queued dialogs with the window as their parent, so the portal (or the system) /// keeps them on top of it. Call after every pass that may ask for one. pub fn show(&self, parent: &eframe::Frame) { @@ -63,26 +124,90 @@ impl Launcher { let picking = picker(rfd::AsyncFileDialog::new().set_parent(parent), &req); #[cfg(all(unix, not(target_os = "macos")))] let unparented = picker(rfd::AsyncFileDialog::new(), &req); + #[cfg(all(unix, not(target_os = "macos")))] + let parent_id = { + use eframe::wgpu::rwh::HasWindowHandle; + crate::portal_save::parent_id(parent.window_handle().ok().map(|h| h.as_raw())) + }; + // Armed before the thread starts, so giving the dialog up cannot race its start. + #[cfg(all(unix, not(target_os = "macos")))] + let portal_arm = matches!(&req, FileDialogRequest::Save { .. }).then(|| self.cancel.arm()); + #[cfg(all(unix, not(target_os = "macos")))] + let portal_id = portal_arm.as_ref().map(|armed| armed.id); + #[cfg(all(unix, not(target_os = "macos")))] + let cancel_handle = self.cancel.clone(); let ctx = self.ctx.clone(); let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || { // The pickers only start when first polled: without a system dialog they never do. let picked = match no_system_dialog() { Some(reason) => Err(reason), None => { - // Only the portal folds a failure into "cancelled" (#2999). #[cfg(all(unix, not(target_os = "macos")))] - let picked = pick(picking, unparented, TOO_QUICK); + { + if let FileDialogRequest::Save { name, dir } = &req { + let cancel = portal_arm.as_ref().map(|armed| armed.token.clone()); + let portal_req = crate::portal_save::SaveRequest { + parent: parent_id, + name: Some(name.clone()), + suggested: Some(name.clone()), + folder: dir.clone().map(std::path::PathBuf::from), + filters: save_filters(name), + current: Some(0), + cancel, + timeout: None, + }; + let portal_res = zbus::blocking::Connection::session() + .map_err(|e| e.to_string()) + .and_then(|conn| crate::portal_save::save_as(&conn, portal_req)); + match portal_res { + Ok(Some(path)) => Ok(vec![path.to_string_lossy().into_owned()]), + Ok(None) => Ok(Vec::new()), + Err(e) if crate::portal_save::is_cancelled_error(&e) => Ok(Vec::new()), + Err(e) if crate::portal_save::is_ambiguous_filter_error(&e) => { + log::warn!("portal save format ambiguous ({e}); cancelling save without explicit format choice"); + Ok(Vec::new()) + } + Err(e) => { + // Giving the dialog up cancels the request. A portal that then + // fails with another error must not open the rfd dialog, which + // nothing would be waiting for. + if portal_arm.as_ref().is_some_and(|armed| armed.token.is_cancelled()) { + log::info!("portal save dialog given up ({e})"); + Ok(Vec::new()) + } else { + log::info!("portal save dialog unavailable ({e}), using rfd"); + let picked = pick(picking, unparented, TOO_QUICK); + picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect()) + } + } + } + } else { + // Only the portal folds a failure into "cancelled" (#2999). + let picked = pick(picking, unparented, TOO_QUICK); + picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect()) + } + } #[cfg(not(all(unix, not(target_os = "macos"))))] - let picked: Result<_, String> = Ok(pollster::block_on(picking)); - picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect()) + { + let picked: Result<_, String> = Ok(pollster::block_on(picking)); + picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect()) + } } }; + #[cfg(all(unix, not(target_os = "macos")))] + if let Some(armed) = &portal_arm { + cancel_handle.disarm(armed.id); + } let _ = answer.send(picked); ctx.request_repaint(); }); // The answer's sender went with the closure, so the app sees the dialog as cancelled. if let Err(e) = waiting { log::error!("couldn't wait for the file dialog: {e}"); + #[cfg(all(unix, not(target_os = "macos")))] + if let Some(id) = portal_id { + self.cancel.disarm(id); + } } } } @@ -222,8 +347,9 @@ fn save_exts(name: &str) -> Vec { /// The "Save as type" list (#1399): one named entry per format, the suggested one first (see /// [`save_exts`] for why the order matters). /// -/// rfd doesn't report which entry the user picked, so if they choose "PDF" but leave "name.docx" -/// in the name box, the extension in the name wins, as the format is chosen by extension. +/// On Linux and the BSDs, [`crate::portal_save`] asks the portal directly over D-Bus and gives a +/// name the dialog left alone the chosen type's extension (#6180). When falling back to `rfd`, the +/// extension in the name wins, as `rfd` doesn't report which entry the user picked. fn save_filters(name: &str) -> Vec<(String, Vec)> { save_exts(name).into_iter().map(|e| (format!("{} (*.{e})", format_name(&e)), vec![e])).collect() } @@ -274,6 +400,37 @@ mod tests { assert_eq!(pick(async { vec![] }, slow_cancel(), quick), Ok(vec![]), "the second dialog cancelled for real"); } + /// Giving the portal Save dialog up cancels the request the launcher armed, and a request + /// that has already answered is not affected by a later cancel. + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn cancelling_a_pending_portal_request_marks_its_token() { + let handle = super::CancelHandle::default(); + let armed = handle.arm(); + assert!(!armed.token.is_cancelled()); + handle.cancel(); + assert!(armed.token.is_cancelled()); + + let answered = handle.arm(); + handle.disarm(answered.id); + handle.cancel(); + assert!(!answered.token.is_cancelled(), "an answered request is no longer cancelled"); + } + + /// A request that finishes late must not disarm the token of a request started after it: + /// otherwise the newer Save dialog would become uncancellable. + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn a_late_answer_keeps_the_newer_requests_token() { + let handle = super::CancelHandle::default(); + let abandoned = handle.arm(); + let newer = handle.arm(); + handle.disarm(abandoned.id); + handle.cancel(); + assert!(newer.token.is_cancelled(), "the newer request is still armed"); + assert!(!abandoned.token.is_cancelled(), "only the armed request is cancelled"); + } + /// #1399: the Save dialog's type list has one named entry per format, the suggested one first, /// every savable extension exactly once. #[test] diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 0e3b7d6b50..18b7b7935f 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -21,6 +21,8 @@ mod file_dialogs; #[cfg(any(target_os = "windows", target_os = "macos", test))] mod graphics; mod logging; +#[cfg(all(unix, not(target_os = "macos")))] +mod portal_save; mod print; mod region; #[cfg(target_os = "macos")] @@ -367,9 +369,10 @@ fn write_prefs(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { /// through the system spooler (`print`), the system clipboard's text, formatted copies, /// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting /// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295). -fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services { +fn services(file_dialog: file_dialogs::Hook, cancel_file_dialog: file_dialogs::CancelHandle, ctx: &egui::Context) -> Services { Services { file_dialog: Some(file_dialog), + cancel_file_dialog: Some(Box::new(move || cancel_file_dialog.cancel())), printers: Some(print::discover()), print_native: Some(print::submit()), cancel_print: Some(print::cancel()), @@ -613,9 +616,10 @@ fn main() -> eframe::Result { let region = region::system(); let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) }; let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx); + let cancel_file_dialog = dialogs.cancel_handle(); let mut session = Session::new(doc); session.system_region = region; - let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx)); + let mut app = WordApp::new(session, services(file_dialog, cancel_file_dialog, &cc.egui_ctx)); app.set_startup_update_check(true); load_prefs(&mut app); // After the preferences, so the reason it gives is in the interface language. diff --git a/apps/wordcraft/src/portal_save.rs b/apps/wordcraft/src/portal_save.rs new file mode 100644 index 0000000000..4edf20bbac --- /dev/null +++ b/apps/wordcraft/src/portal_save.rs @@ -0,0 +1,906 @@ +//! File › Save As through the XDG desktop portal on Linux and BSDs (#6180). +//! +//! The GTK file chooser behind the portal keeps the typed name when the user picks another file +//! type, and `rfd` 0.17 reads only the file name from the portal's answer, so `Document1.docx` +//! with PDF picked in the dropdown came back as `Document1.docx` and was saved as a Word document. +//! The portal's answer does say which type was picked (`current_filter`), so Save As asks the portal +//! itself over D-Bus via `zbus` and gives a name the dialog left alone that type's extension +//! ([`with_chosen_type`]). `rfd` stays the fallback (zenity, or no portal), and this can go once an +//! `rfd` release reads the picked type (upstream PolyMeilex/rfd#341). +//! +//! The dialog is attached to the window on X11 (`x11:`). On Wayland it has no parent: naming the +//! window to the portal needs an xdg-foreign handle, which takes `unsafe` code the workspace forbids. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::Duration; + +use zbus::blocking::{MessageIterator, Proxy}; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + +/// One file type in the dialog: its label and extensions (`("PDF (*.pdf)", ["pdf"])`). +pub type Filter = (String, Vec); + +/// Quicker than anyone can see a dialog and dismiss it (#2999). +const TOO_QUICK: Duration = Duration::from_millis(300); + +/// A thread-safe cancellation token to signal cancellation of an ongoing save request. +#[derive(Clone, Debug, Default)] +pub struct CancelToken(std::sync::Arc); + +impl CancelToken { + /// Creates a new cancellation token in the active (not cancelled) state. + pub fn new() -> Self { + Self(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false))) + } + + /// Signals that the active save request should be cancelled. + pub fn cancel(&self) { + self.0.store(true, std::sync::atomic::Ordering::Relaxed); + } + + /// Returns `true` if cancellation has been requested. + pub fn is_cancelled(&self) -> bool { + self.0.load(std::sync::atomic::Ordering::Relaxed) + } +} + +impl PartialEq for CancelToken { + /// Compares two cancellation tokens by their current cancellation status. + fn eq(&self, other: &Self) -> bool { + self.is_cancelled() == other.is_cancelled() + } +} + +impl Eq for CancelToken {} + +/// A Save dialog request to pass to the desktop portal. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SaveRequest { + /// The portal's parent window identifier (`x11:`), or empty. + pub parent: String, + /// The name the dialog shows: the suggested file name (`Document1.docx`). + pub name: Option, + /// The suggested file's name (`Document1.docx`): a name that comes back with its extension still + /// takes the extension of the type picked ([`with_chosen_type`]). + pub suggested: Option, + /// The initial folder to open in, if any. + pub folder: Option, + /// The list of filters offered in the dropdown. + pub filters: Vec, + /// The index of the filter the dialog starts on. + pub current: Option, + /// Optional cancellation token allowing the caller to cancel a pending save request. + pub cancel: Option, + /// Optional timeout after which a request waiting for a response is considered stalled. + pub timeout: Option, +} + +/// What the portal dialog returned: the path, as typed, and the filter chosen in the dropdown. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Saved { + /// The filesystem path chosen in the dialog. + pub path: PathBuf, + /// The filter chosen in the dropdown, if reported by the portal: `(label, extensions)`. + pub filter: Option, +} + +/// The portal's parent window identifier for `window`: `x11:` on X11, empty elsewhere. +/// +/// On Wayland, exporting an xdg-foreign handle requires `unsafe` code forbidden by workspace rules +/// (`#![forbid(unsafe_code)]`, Rust Rule 1), so Wayland dialogs remain unparented, matching +/// PhotoCraft and VectorCraft. +pub fn parent_id(window: Option) -> String { + use eframe::wgpu::rwh::RawWindowHandle; + match window { + Some(RawWindowHandle::Xlib(h)) => format!("x11:{:x}", h.window), + Some(RawWindowHandle::Xcb(h)) => format!("x11:{:x}", h.window.get()), + _ => String::new(), + } +} + +/// Converts a filter tuple into the portal's wire format: label and a list of `(0, "*.ext")` glob pairs. +fn wire(filter: &Filter) -> (String, Vec<(u32, String)>) { + let globs = filter.1.iter().map(|e| (0u32, if e.is_empty() || e == "*" { "*".to_string() } else { format!("*.{e}") })).collect(); + (filter.0.clone(), globs) +} + +/// The `SaveFile` options dictionary for `req`, answering on `token`. +fn options<'a>(req: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> { + let mut o: HashMap<&'static str, Value<'a>> = HashMap::from([("handle_token", Value::from(token)), ("modal", Value::from(true))]); + if let Some(name) = &req.name { + o.insert("current_name", Value::from(name.as_str())); + } + if let Some(folder) = &req.folder { + // A byte string with its terminating NUL, as the portal specifies. + use std::os::unix::ffi::OsStrExt; + let mut bytes = folder.as_os_str().as_bytes().to_vec(); + bytes.push(0); + o.insert("current_folder", Value::from(bytes)); + } + if !req.filters.is_empty() { + let wired = req.filters.iter().map(wire).collect::>(); + o.insert("filters", Value::from(wired)); + } + if let Some(f) = req.current.and_then(|i| req.filters.get(i)).or_else(|| req.filters.first()) { + o.insert("current_filter", Value::from(wire(f))); + } + o +} + +/// Reads the portal's `Response`: code 0 with the first `uris` entry. 1 is Cancel, and 2 a dialog +/// closed some other way (e.g. Esc in GNOME): no file either. When code 2 arrives quicker than +/// [`TOO_QUICK`], the portal failed to show its dialog (#2999) and an error is returned so the +/// caller can fall back. +fn answer(code: u32, results: &HashMap, elapsed: Duration) -> Result, String> { + match code { + 0 => {} + 1 => return Ok(None), + 2 if elapsed < TOO_QUICK => return Err("the save dialog closed at once".to_string()), + 2 => return Ok(None), + _ => return Err(format!("the save dialog failed (portal response {code})")), + } + let uris: Vec = results + .get("uris") + .ok_or_else(|| "no file in the portal's answer".to_string())? + .try_clone() + .map_err(|e| e.to_string())? + .try_into() + .map_err(|e: zbus::zvariant::Error| e.to_string())?; + let uri = uris.first().ok_or_else(|| "no file in the portal's answer".to_string())?; + let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?; + let filter = results.get("current_filter").and_then(|v| { + let val = v.try_clone().ok()?; + if let Ok((label, globs)) = <(String, Vec<(u32, String)>)>::try_from(val.clone()) { + let mut exts = Vec::new(); + for (_, g) in globs { + let s = g.trim_start_matches('*').trim_start_matches('.'); + if !s.is_empty() && s != "*" { + exts.push(s.to_ascii_lowercase()); + } + } + if exts.is_empty() { + exts.extend(extract_exts_from_label(&label)); + } + Some((label, exts)) + } else if let Ok((label, exts)) = <(String, Vec)>::try_from(val.clone()) { + Some((label, exts.into_iter().map(|s| s.to_ascii_lowercase()).collect())) + } else if let Ok(label) = String::try_from(val) { + let exts = extract_exts_from_label(&label); + Some((label, exts)) + } else { + None + } + }); + Ok(Some(Saved { path, filter })) +} + +/// Extracts file extensions from a filter label such as `"PDF (*.pdf)"` or `"*.pdf"`. +fn extract_exts_from_label(label: &str) -> Vec { + let text = if let Some(inside) = label.split('(').nth(1).and_then(|s| s.split(')').next()) { inside } else { label }; + text.split(|c: char| c == ';' || c == ',' || c.is_whitespace()) + .filter_map(|part| { + let s = part.trim().trim_start_matches('*').trim_start_matches('.'); + if s.is_empty() || s == "*" || s.contains('/') || s.contains('\\') { None } else { Some(s.to_ascii_lowercase()) } + }) + .collect() +} + +/// Converts a `file://` URI from the portal into a local `PathBuf`. +/// Returns `None` for non-local URIs or invalid percent-encoding. +pub fn file_uri_path(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + // Only local files: `file:///…` or `file://localhost/…`. + let rest = rest.strip_prefix("localhost").unwrap_or(rest); + if !rest.starts_with('/') { + return None; + } + let bytes = rest.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes.get(i) == Some(&b'%') { + let hex = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())?; + out.push(hex); + i = i.saturating_add(3); + } else if let Some(&b) = bytes.get(i) { + out.push(b); + i = i.saturating_add(1); + } else { + break; + } + } + use std::os::unix::ffi::OsStringExt; + Some(PathBuf::from(std::ffi::OsString::from_vec(out))) +} + +/// Returns the index of the chosen filter in `filters` by matching the picked index, or the first filter +/// if only one was offered. +fn chosen_type(filters: &[Filter], picked: Option) -> Option { + match picked { + Some(i) if i < filters.len() => Some(i), + None if filters.len() == 1 => Some(0), + _ => None, + } +} + +/// Returns `path` with the extension of the file type picked in the save dialog (`chosen`), +/// where the dialog left the suggested name's extension alone (#6180). +/// +/// The GTK file chooser keeps the typed name when another type is picked. Only a name that +/// still has the extension the dialog opened with (`opened_with`, e.g. `Document1.docx` with +/// PDF picked) or none gets the type's extension, as PolyMeilex/rfd#341 does. An extension the +/// user explicitly typed that WordCraft saves (e.g. `art.txt` with PDF untouched) is kept, +/// and so does one of the type's own extensions. Any extension that WordCraft does not save +/// (e.g. `scan.2024`) is treated as part of the stem, and the type's extension is added. +pub fn with_chosen_type(path: &Path, opened_with: Option<&str>, chosen: &[String]) -> PathBuf { + let Some(first) = chosen.first() else { return path.to_path_buf() }; + let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase); + let opened_ext = opened_with + .and_then(|n| Path::new(n).extension()) + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str())); + match ext { + Some(ext) if chosen.contains(&ext) => path.to_path_buf(), + Some(ext) if opened_ext.as_ref() == Some(&ext) => path.with_extension(first), + Some(ext) if wordcraft_engine::io::SAVE_EXTS.contains(&ext.as_str()) => path.to_path_buf(), + _ => { + let mut name = path.as_os_str().to_owned(); + name.push("."); + name.push(first); + PathBuf::from(name) + } + } +} + +/// Returns true if `path` exists as a file, directory, or symlink on disk. +fn entry_exists(path: &Path) -> bool { + match path.symlink_metadata() { + Ok(_) => true, + Err(e) => e.kind() != std::io::ErrorKind::NotFound, + } +} + +/// Closes the active portal request handle by calling `org.freedesktop.portal.Request.Close`. +pub fn close_request(conn: &zbus::blocking::Connection, portal: Option<&str>, handle: &OwnedObjectPath) { + const DESKTOP: &str = "org.freedesktop.portal.Desktop"; + const REQUEST: &str = "org.freedesktop.portal.Request"; + let dest = portal.unwrap_or(DESKTOP); + if let Ok(proxy) = Proxy::new(conn, dest, handle.as_str(), REQUEST) { + let _ = proxy.call_method("Close", &()); + } +} + +/// RAII guard that closes an open portal request handle if dropped before being disarmed. +struct RequestGuard<'a> { + conn: &'a zbus::blocking::Connection, + portal: Option, + handle: Option, +} + +impl<'a> RequestGuard<'a> { + /// Creates a new guard for the given request `handle`. + fn new(conn: &'a zbus::blocking::Connection, portal: Option, handle: OwnedObjectPath) -> Self { + Self { conn, portal, handle: Some(handle) } + } + + /// Disarms the guard when a response has been successfully received, preventing `Close` from being called on drop. + fn disarm(&mut self) { + self.handle = None; + } + + /// Returns a reference to the request handle, if still active. + fn handle(&self) -> Option<&OwnedObjectPath> { + self.handle.as_ref() + } +} + +impl<'a> Drop for RequestGuard<'a> { + /// Closes the active portal request handle when dropped unless disarmed. + fn drop(&mut self) { + if let Some(handle) = self.handle.take() { + close_request(self.conn, self.portal.as_deref(), &handle); + } + } +} + +/// Asks the XDG desktop portal on `conn` for the `SaveFile` dialog of `req` and waits for its answer. +pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result, String> { + const DESKTOP: &str = "org.freedesktop.portal.Desktop"; + const REQUEST: &str = "org.freedesktop.portal.Request"; + static NEXT: AtomicU32 = AtomicU32::new(0); + let token = format!("wordcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)); + + // Listen before asking, on every request path: a portal older than version 0.9 ignores + // `handle_token` and answers on the request path it returns, and its answer can come before + // the call's reply. The stream keeps every `Response` from the time it is registered. + let dbus = zbus::blocking::fdo::DBusProxy::new(conn).map_err(|e| e.to_string())?; + let rule = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender(DESKTOP) + .and_then(|b| b.interface(REQUEST)) + .and_then(|b| b.member("Response")) + .map_err(|e| e.to_string())? + .build(); + dbus.add_match_rule(rule).map_err(|e| e.to_string())?; + + let owner_rule = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender("org.freedesktop.DBus") + .and_then(|b| b.interface("org.freedesktop.DBus")) + .and_then(|b| b.member("NameOwnerChanged")) + .map_err(|e| e.to_string())? + .build(); + dbus.add_match_rule(owner_rule).map_err(|e| e.to_string())?; + + let responses = MessageIterator::from(conn); + let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?; + + let start = std::time::Instant::now(); + let call = chooser.call_method("SaveFile", &(req.parent.as_str(), "Save As", options(req, &token))).map_err(|e| e.to_string())?; + let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?; + let portal = call.header().sender().map(|s| s.as_str().to_string()); + + let mut guard = RequestGuard::new(conn, portal.clone(), handle); + let (tx, rx) = std::sync::mpsc::channel(); + let _listener = std::thread::Builder::new().name("portal save listener".into()).spawn(move || { + for msg in responses.filter_map(Result::ok) { + if tx.send(msg).is_err() { + break; + } + } + }); + + let reply = loop { + if req.cancel.as_ref().is_some_and(CancelToken::is_cancelled) { + return Err(ERR_CANCELLED.to_string()); + } + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(m) => { + let header = m.header(); + if header.path().map(|p| p.as_str()) == guard.handle().map(|h| h.as_str()) && header.sender().map(|s| s.as_str()) == portal.as_deref() + { + break Ok(m); + } + #[allow(clippy::collapsible_if)] + if header.interface().map(|i| i.as_str()) == Some("org.freedesktop.DBus") + && header.member().map(|m| m.as_str()) == Some("NameOwnerChanged") + { + if let Ok((name, old_owner, "")) = m.body().deserialize::<(&str, &str, &str)>() { + if (name == DESKTOP || portal.as_deref() == Some(name)) && (portal.is_none() || portal.as_deref() == Some(old_owner)) { + break Err("the desktop portal terminated while the save dialog was open".to_string()); + } + if name.starts_with("org.freedesktop.impl.portal.desktop") { + break Err("the desktop portal backend terminated while the save dialog was open".to_string()); + } + } + } + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if req.timeout.is_some_and(|t| start.elapsed() >= t) { + break Err("the save dialog request timed out".to_string()); + } + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + break Err("the save dialog closed without an answer".to_string()); + } + } + }?; + + guard.disarm(); + let elapsed = start.elapsed(); + let (code, results): (u32, HashMap) = reply.body().deserialize().map_err(|e| e.to_string())?; + answer(code, &results, elapsed) +} + +/// Returns true if `path` is located in the XDG document portal (`/run/user//doc/…`, or +/// `/run/flatpak/doc/…` inside a Flatpak sandbox). +fn is_document_portal(path: &Path) -> bool { + let s = path.to_string_lossy(); + ((s.starts_with("/run/user/") || s.starts_with("/var/run/user/")) && s.contains("/doc/")) || s.starts_with("/run/flatpak/doc/") +} + +/// Error message returned when a pending save request was cancelled by the caller. +pub const ERR_CANCELLED: &str = "the save dialog request was cancelled"; + +/// Returns true if `err` represents a save request cancelled by the caller (not a cancel in the +/// dialog itself, which is `Ok(None)`). +pub fn is_cancelled_error(err: &str) -> bool { + err == ERR_CANCELLED +} + +/// Error message returned when the portal dialog does not report the selected filter and +/// the saved filename does not explicitly specify a distinct format. +pub const ERR_AMBIGUOUS_FILTER: &str = "the save dialog did not report which file type was picked"; + +/// Returns true if `err` represents an ambiguous filter error from the desktop portal. +pub fn is_ambiguous_filter_error(err: &str) -> bool { + err == ERR_AMBIGUOUS_FILTER +} + +/// Finds the index of the filter in `filters` matching `saved_filter`, first by exact label, +/// then by unambiguous match against the filter's extensions. +fn match_filter(filters: &[Filter], saved_filter: Option<&Filter>) -> Option { + let (label, exts) = saved_filter?; + if let Some(pos) = filters.iter().position(|(l, _)| l == label) { + return Some(pos); + } + if !exts.is_empty() { + let matching: Vec = filters + .iter() + .enumerate() + .filter(|(_, (_, f_exts))| exts.iter().any(|e| f_exts.iter().any(|fe| fe.eq_ignore_ascii_case(e)))) + .map(|(i, _)| i) + .collect(); + if matching.len() == 1 { + return matching.first().copied(); + } + } + None +} + +/// Returns true if the portal did not provide a unique matching filter when multiple filters were offered +/// and the user did not explicitly type a filename with a distinct supported format extension. +pub fn is_ambiguous_filter(saved: &Saved, req: &SaveRequest) -> bool { + if req.filters.len() <= 1 { + return false; + } + if match_filter(&req.filters, saved.filter.as_ref()).is_some() { + return false; + } + let ext = saved.path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase); + let suggested_ext = req + .suggested + .as_deref() + .and_then(|n| Path::new(n).extension()) + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str())); + match ext { + Some(ext) if wordcraft_engine::io::SAVE_EXTS.contains(&ext.as_str()) => suggested_ext.as_ref() == Some(&ext), + _ => true, + } +} + +/// Save As on Linux and BSDs: the portal's dialog, with the file name given the extension of the +/// file type picked in the dropdown. When that changes the name to one that already exists on disk, +/// the dialog opens again under the new name, so the portal asks before replacing it (it asked only +/// about the typed name). +pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Result, String> { + loop { + let Some(saved) = save_file(conn, &req)? else { return Ok(None) }; + if is_ambiguous_filter(&saved, &req) { + return Err(ERR_AMBIGUOUS_FILTER.to_string()); + } + let picked = match_filter(&req.filters, saved.filter.as_ref()); + let exts: &[String] = match chosen_type(&req.filters, picked) { + Some(i) => req.filters.get(i).map(|(_, e)| e.as_slice()).unwrap_or(&[]), + None => &[], + }; + let path = with_chosen_type(&saved.path, req.suggested.as_deref(), exts); + let needs_reopen = entry_exists(&path) || (path != saved.path && is_document_portal(&saved.path)); + if path == saved.path || !needs_reopen { + return Ok(Some(path)); + } + req.name = path.file_name().map(|n| n.to_string_lossy().into_owned()); + req.suggested = req.name.clone(); + req.folder = path.parent().map(Path::to_path_buf); + req.current = picked; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// #6180: the file type picked in the save dialog gives the name its extension where the + /// dialog left the one it opened with, and only there. + #[test] + fn the_picked_file_type_gives_the_name_its_extension() { + let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::>(); + let (pdf, docx) = (exts(&["pdf"]), exts(&["docx", "docm"])); + let opened = Some("Document1.docx"); + let cases = [ + // PDF picked, the name as the dialog opened (or renamed, its extension kept). + ("/docs/Document1.docx", opened, &pdf, "/docs/Document1.pdf"), + ("/docs/letter.docx", opened, &pdf, "/docs/letter.pdf"), + ("/docs/LETTER.DOCX", opened, &pdf, "/docs/LETTER.pdf"), + // No extension: takes the picked type's extension. + ("/docs/report", opened, &pdf, "/docs/report.pdf"), + // An extension WordCraft doesn't save is part of the name. + ("/docs/scan.2024", opened, &pdf, "/docs/scan.2024.pdf"), + // An opened extension WordCraft doesn't save (e.g. .5 in Report 1.5) is kept as part of the stem. + ("/docs/Report 1.5", Some("Report 1.5"), &docx, "/docs/Report 1.5.docx"), + // Typed, an extension WordCraft saves is the user's: kept, whatever the type says. + ("/docs/notes.txt", opened, &pdf, "/docs/notes.txt"), + ("/docs/page.html", opened, &pdf, "/docs/page.html"), + ("/docs/file.rtf", opened, &pdf, "/docs/file.rtf"), + // Already the type's: kept. + ("/docs/letter.pdf", opened, &pdf, "/docs/letter.pdf"), + ("/docs/letter.PDF", opened, &pdf, "/docs/letter.PDF"), + // The dialog opened on a .dotx template: that is the extension the type replaces. + ("/docs/memo.dotx", Some("memo.dotx"), &pdf, "/docs/memo.pdf"), + ("/docs/doc.docx", Some("memo.dotx"), &pdf, "/docs/doc.docx"), + // Word document picked for a file named without extension. + ("/docs/contract", opened, &docx, "/docs/contract.docx"), + ]; + for (typed, opened_with, chosen, want) in cases { + assert_eq!( + with_chosen_type(Path::new(typed), opened_with, chosen), + PathBuf::from(want), + "failed for typed={typed}, opened_with={opened_with:?}" + ); + } + // No type picked: the name as typed is kept. + assert_eq!(with_chosen_type(Path::new("/docs/report.docx"), opened, &[]), PathBuf::from("/docs/report.docx")); + } + + #[test] + fn file_uris_become_local_paths() { + assert_eq!(file_uri_path("file:///home/me/Document1.docx"), Some(PathBuf::from("/home/me/Document1.docx"))); + assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.pdf"), Some(PathBuf::from("/home/me/A bé.pdf"))); + assert_eq!(file_uri_path("file://localhost/tmp/x.pdf"), Some(PathBuf::from("/tmp/x.pdf"))); + assert_eq!(file_uri_path("file:///tmp/a%ZZ.pdf"), None); + assert_eq!(file_uri_path("file:///tmp/a%"), None); + assert_eq!(file_uri_path("file:///tmp/a%2"), None); + assert_eq!(file_uri_path("sftp://host/x.pdf"), None); + assert_eq!(file_uri_path("file://host/x.pdf"), None); + assert_eq!(file_uri_path("http://example.com/doc.pdf"), None); + } + + #[test] + fn x11_windows_are_named_to_the_portal() { + use eframe::wgpu::rwh::{RawWindowHandle, XlibWindowHandle}; + assert_eq!(parent_id(Some(RawWindowHandle::Xlib(XlibWindowHandle::new(0x3a0000f)))), "x11:3a0000f"); + assert_eq!(parent_id(None), ""); + } + + #[test] + fn answer_parses_portal_response_or_errors_on_quick_failure() { + let mut results = HashMap::new(); + let uris: Vec = vec!["file:///tmp/doc.docx".to_string()]; + results.insert("uris".to_string(), OwnedValue::try_from(Value::from(uris)).unwrap()); + let globs: Vec<(u32, String)> = vec![(0u32, "*.docx".to_string())]; + let filter: (String, Vec<(u32, String)>) = ("Word Document (*.docx)".to_string(), globs); + results.insert("current_filter".to_string(), OwnedValue::try_from(Value::from(filter)).unwrap()); + + // Code 0 with filter + let ans = answer(0, &results, Duration::from_millis(1000)).unwrap(); + assert_eq!( + ans, + Some(Saved { path: PathBuf::from("/tmp/doc.docx"), filter: Some(("Word Document (*.docx)".to_string(), vec!["docx".to_string()])) }) + ); + + // Code 0 without filter + let mut results_no_filter = HashMap::new(); + results_no_filter.insert("uris".to_string(), results.get("uris").unwrap().clone()); + let ans_no_filter = answer(0, &results_no_filter, Duration::from_millis(1000)).unwrap(); + assert_eq!(ans_no_filter, Some(Saved { path: PathBuf::from("/tmp/doc.docx"), filter: None })); + + // Code 1 (Cancel) + assert_eq!(answer(1, &results, Duration::from_millis(50)).unwrap(), None); + + // Code 2 quick (< TOO_QUICK) + assert!(answer(2, &results, Duration::from_millis(100)).is_err()); + + // Code 2 slow (>= TOO_QUICK) + assert_eq!(answer(2, &results, Duration::from_millis(500)).unwrap(), None); + + // Code 3 (failure) + assert!(answer(3, &results, Duration::from_millis(1000)).is_err()); + } + + struct KillOnDrop(std::process::Child); + + impl Drop for KillOnDrop { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + /// Configuration for spawning a mock portal daemon. + struct MockPortalConfig { + code: u32, + delay_reply: Option, + exit_after_reply: bool, + send_response: bool, + close_called: Option>, + emit_backend_owner_loss: bool, + } + + impl Default for MockPortalConfig { + fn default() -> Self { + Self { code: 0, delay_reply: None, exit_after_reply: false, send_response: true, close_called: None, emit_backend_owner_loss: false } + } + } + + /// Spawns a mock portal daemon that answers `SaveFile` and handles `Close` according to `config`. + fn mock_portal_with_config(config: MockPortalConfig) -> Option<(zbus::blocking::Connection, KillOnDrop)> { + use std::io::BufRead as _; + + let daemon = std::process::Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(std::process::Stdio::piped()) + .spawn() + .ok()?; + let mut guard = KillOnDrop(daemon); + let mut address = String::new(); + std::io::BufReader::new(guard.0.stdout.take()?).read_line(&mut address).ok()?; + let address = address.trim().to_string(); + let server = + zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?; + let incoming = zbus::blocking::MessageIterator::from(&server); + let thread_address = address.clone(); + std::thread::spawn(move || { + for msg in incoming { + let Ok(msg) = msg else { break }; + let header = msg.header(); + if header.message_type() != zbus::message::Type::MethodCall { + continue; + } + if header.member().map(|m| m.as_str()) == Some("Close") { + if let Some(flag) = &config.close_called { + flag.store(true, Ordering::Relaxed); + } + let _ = server.reply(&header, &()); + continue; + } + if header.member().map(|m| m.as_str()) != Some("SaveFile") { + continue; + } + let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap)>() else { + break; + }; + let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok()); + let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break }; + let token = text("handle_token").unwrap_or_default(); + let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); + let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break }; + + if config.exit_after_reply { + let _ = server.reply(&header, &handle); + drop(server); + break; + } + + if config.emit_backend_owner_loss { + let _ = server.reply(&header, &handle); + let addr = thread_address.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(50)); + if let Ok(backend) = zbus::blocking::connection::Builder::address(addr.as_str()) + .and_then(|b| b.name("org.freedesktop.impl.portal.desktop.gtk")) + .and_then(|b| b.build()) + { + std::thread::sleep(Duration::from_millis(50)); + drop(backend); + } + }); + continue; + } + + if config.send_response { + let to = header.sender().cloned().map(zbus::names::BusName::from); + let results: HashMap<&str, Value<'_>> = HashMap::new(); + let _ = server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(config.code, &results)); + } + + if let Some(delay) = config.delay_reply { + std::thread::sleep(delay); + } + if server.reply(&header, &handle).is_err() { + break; + } + } + }); + let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?; + Some((client, guard)) + } + + /// Spawns a mock portal daemon that answers `SaveFile`. + /// If `delay_reply` is set, it emits the Response signal immediately and delays the method + /// reply by that duration. If `exit_after_reply` is set, it replies to `SaveFile` and exits + /// without sending a `Response`. + fn mock_portal(code: u32, delay_reply: Option, exit_after_reply: bool) -> Option<(zbus::blocking::Connection, KillOnDrop)> { + mock_portal_with_config(MockPortalConfig { + code, + delay_reply, + exit_after_reply, + send_response: true, + close_called: None, + emit_backend_owner_loss: false, + }) + } + + /// A delayed SaveFile method reply with an already-queued response code 2 measures elapsed time + /// from before calling SaveFile, so it is treated as a user cancellation rather than a quick + /// portal failure that re-opens another dialog. + #[test] + fn delayed_method_reply_with_queued_cancel_is_not_treated_as_quick_failure() { + let Some((client, _daemon)) = mock_portal(2, Some(Duration::from_millis(350)), false) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let req = SaveRequest { + name: Some("test.docx".to_string()), + suggested: Some("test.docx".to_string()), + filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])], + ..Default::default() + }; + let res = save_file(&client, &req); + assert_eq!(res, Ok(None)); + } + + /// If the desktop portal terminates while a save dialog is open (e.g. crashing after replying + /// to SaveFile), save_file detects NameOwnerChanged and returns an error immediately. + #[test] + fn portal_terminating_while_open_returns_error() { + let Some((client, _daemon)) = mock_portal(0, None, true) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let req = SaveRequest { + name: Some("test.docx".to_string()), + suggested: Some("test.docx".to_string()), + filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])], + ..Default::default() + }; + let res = save_file(&client, &req); + assert!(res.is_err(), "portal termination returns an error so rfd fallback runs: got {res:?}"); + } + + /// If the desktop portal backend implementation (e.g. GTK, GNOME, KDE) terminates while the + /// dialog is open, save_file detects NameOwnerChanged on the implementation service and returns + /// an error immediately so the rfd fallback can run. + #[test] + fn portal_backend_terminating_while_open_returns_error() { + let Some((client, _daemon)) = + mock_portal_with_config(MockPortalConfig { emit_backend_owner_loss: true, send_response: false, ..Default::default() }) + else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let req = SaveRequest { + name: Some("test.docx".to_string()), + suggested: Some("test.docx".to_string()), + filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])], + ..Default::default() + }; + let res = save_file(&client, &req); + assert!(res.is_err(), "backend termination returns an error so rfd fallback runs: got {res:?}"); + assert!(res.as_ref().unwrap_err().contains("backend terminated"), "expected backend termination error: got {res:?}"); + } + + /// When a save request is cancelled via `CancelToken`, save_file aborts waiting for the response, + /// calls Close on the portal request object path, and returns a cancellation error immediately. + #[test] + fn save_file_cancellation_closes_request_and_returns_error() { + let close_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let Some((client, _daemon)) = + mock_portal_with_config(MockPortalConfig { send_response: false, close_called: Some(close_called.clone()), ..Default::default() }) + else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let cancel = CancelToken::new(); + let cancel_thread = cancel.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(50)); + cancel_thread.cancel(); + }); + let req = SaveRequest { + name: Some("test.docx".to_string()), + suggested: Some("test.docx".to_string()), + filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])], + cancel: Some(cancel), + ..Default::default() + }; + let res = save_file(&client, &req); + assert!(res.is_err(), "cancelled request returns error: got {res:?}"); + assert!(res.as_ref().unwrap_err().contains("cancelled"), "expected cancellation error: got {res:?}"); + std::thread::sleep(Duration::from_millis(50)); + assert!(close_called.load(Ordering::Relaxed), "Close must be called on portal request handle"); + } + + /// When a save request times out waiting for a stalled portal backend response, save_file aborts, + /// calls Close on the portal request object path, and returns a timeout error. + #[test] + fn save_file_timeout_closes_request_and_returns_error() { + let close_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let Some((client, _daemon)) = + mock_portal_with_config(MockPortalConfig { send_response: false, close_called: Some(close_called.clone()), ..Default::default() }) + else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let req = SaveRequest { + name: Some("test.docx".to_string()), + suggested: Some("test.docx".to_string()), + filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])], + timeout: Some(Duration::from_millis(50)), + ..Default::default() + }; + let res = save_file(&client, &req); + assert!(res.is_err(), "timed out request returns error: got {res:?}"); + assert!(res.as_ref().unwrap_err().contains("timed out"), "expected timeout error: got {res:?}"); + std::thread::sleep(Duration::from_millis(50)); + assert!(close_called.load(Ordering::Relaxed), "Close must be called on portal request handle"); + } + + /// `entry_exists` detects existing files and dangling symlinks, distinguishing them from + /// absent entries. + #[test] + fn entry_exists_detects_dangling_symlinks() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("nonexistent_target.txt"); + let link = dir.path().join("dangling_link.txt"); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!(!link.exists(), "target does not exist so exists() is false"); + assert!(entry_exists(&link), "symlink entry exists so entry_exists() is true"); + } + assert!(!entry_exists(&target), "nonexistent file does not exist"); + } + + /// `is_document_portal` identifies paths within the XDG document portal mount, including the + /// `/run/flatpak/doc` mount a Flatpak sandbox sees. + #[test] + fn is_document_portal_detects_portal_fuse_paths() { + assert!(is_document_portal(Path::new("/run/user/1000/doc/1234abcd/test.docx"))); + assert!(is_document_portal(Path::new("/var/run/user/1000/doc/by-app/app/test.docx"))); + assert!(is_document_portal(Path::new("/run/flatpak/doc/1234abcd/test.docx"))); + assert!(!is_document_portal(Path::new("/home/user/Documents/test.docx"))); + assert!(!is_document_portal(Path::new("/tmp/doc/test.docx"))); + assert!(!is_document_portal(Path::new("/run/flatpak/other/test.docx"))); + } + + /// When the portal omits `current_filter`, ambiguous saves (unchanged suggested extension or bare name) + /// are rejected, while an explicitly typed different format is accepted. + #[test] + fn ambiguous_filters_require_explicit_format() { + let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::>(); + let filters = vec![("Word Document (*.docx)".into(), exts(&["docx"])), ("PDF (*.pdf)".into(), exts(&["pdf"]))]; + let req = SaveRequest { filters: filters.clone(), suggested: Some("Document1.docx".into()), ..Default::default() }; + + // Reported filter with matching label is not ambiguous. + let with_filter = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: Some(("Word Document (*.docx)".into(), exts(&["docx"]))) }; + assert_eq!(match_filter(&filters, with_filter.filter.as_ref()), Some(0)); + assert!(!is_ambiguous_filter(&with_filter, &req)); + + // Reported filter with unmatched label but unambiguous extension (e.g. KDE *.pdf) is not ambiguous. + let kde_pdf_filter = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: Some(("*.pdf".into(), exts(&["pdf"]))) }; + assert_eq!(match_filter(&filters, kde_pdf_filter.filter.as_ref()), Some(1)); + assert!(!is_ambiguous_filter(&kde_pdf_filter, &req)); + + // Reported filter with unrecognized extensions and unchanged suggested extension is ambiguous. + let unknown_filter = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: Some(("Custom (*.xyz)".into(), exts(&["xyz"]))) }; + assert_eq!(match_filter(&filters, unknown_filter.filter.as_ref()), None); + assert!(is_ambiguous_filter(&unknown_filter, &req)); + + // Multiple filters matching same extension is ambiguous when label doesn't match. + let duplicate_filters = vec![("Plain Text (*.txt)".into(), exts(&["txt"])), ("Text Document (*.txt)".into(), exts(&["txt"]))]; + let dup_filter = ("*.txt".to_string(), exts(&["txt"])); + assert_eq!(match_filter(&duplicate_filters, Some(&dup_filter)), None); + + // Missing filter with unchanged suggested extension is ambiguous. + let unchanged = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: None }; + assert!(is_ambiguous_filter(&unchanged, &req)); + + // Missing filter with bare filename is ambiguous. + let bare = Saved { path: PathBuf::from("/docs/Document1"), filter: None }; + assert!(is_ambiguous_filter(&bare, &req)); + + // Missing filter but user explicitly typed a different format is NOT ambiguous. + let explicit = Saved { path: PathBuf::from("/docs/Document1.pdf"), filter: None }; + assert!(!is_ambiguous_filter(&explicit, &req)); + + // Single filter offered is never ambiguous even if filter is omitted. + let single_req = + SaveRequest { filters: vec![("PDF (*.pdf)".into(), exts(&["pdf"]))], suggested: Some("Document1.docx".into()), ..Default::default() }; + assert!(!is_ambiguous_filter(&unchanged, &single_req)); + } +} diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index a4399b6f00..f6a3ff16a2 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -174,6 +174,9 @@ impl WordApp { /// Treat the system dialog the host is showing as cancelled, e.g. one that never showed up. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn give_up_file_dialog(&mut self) { + if let Some(cancel) = &self.services.cancel_file_dialog { + cancel(); + } if let Some(d) = self.file_dialog.take() { let _ = self.finish_pick(d.after, None); } diff --git a/crates/ui-egui/src/file_dialogs/tests.rs b/crates/ui-egui/src/file_dialogs/tests.rs index 837cb96a87..853f22da1f 100644 --- a/crates/ui-egui/src/file_dialogs/tests.rs +++ b/crates/ui-egui/src/file_dialogs/tests.rs @@ -252,6 +252,27 @@ fn answer_all(shown: &Shown, picked: &[&str]) { tx.send(Ok(picked.iter().map(|p| p.to_string()).collect())).unwrap(); } +/// Giving the system dialog up (File › Open › Browse in WordCraft…) tells the host to cancel its +/// waiting request, so a portal that stopped answering cannot keep Save As stuck. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn giving_up_a_file_dialog_cancels_the_host_request() { + let cancelled = Rc::new(std::cell::Cell::new(false)); + let flag = cancelled.clone(); + let services = Services { cancel_file_dialog: Some(Box::new(move || flag.set(true))), ..Default::default() }; + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services); + let (tx, rx) = channel(); + a.file_dialog = Some(PendingDialog { + rx, + req: FileDialogRequest::Save { name: "Document1.docx".into(), dir: None }, + after: AfterPick::Export { ext: "pdf".into() }, + }); + a.give_up_file_dialog(); + assert!(cancelled.get(), "the host is told to stop waiting"); + assert!(a.file_dialog.is_none(), "the pending dialog is taken as cancelled"); + drop(tx); +} + fn typed(a: &mut WordApp) { a.run("text.insert", json!({"text": UNSAVED})).unwrap(); assert!(a.session.dirty); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 5228e0783b..baaff42c28 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -21,6 +21,10 @@ pub struct Services { /// sender counts as cancelled. An `Err` says no system dialog could be shown, and the app shows /// its built-in file browser instead (#576). The app shows one dialog at a time ([`file_dialogs`]). pub file_dialog: Option std::sync::mpsc::Receiver>>, + /// Desktop: stop waiting for the system file dialog the host is showing, if it can (a portal + /// that never answers). The app calls it when it gives the dialog up (File › Open › Browse in + /// WordCraft…), so the host closes the request and answers as cancelled. + pub cancel_file_dialog: Option>, /// Web: open a file picker; the file arrives later through `inbox`. pub open_async: Option>, /// Web: files (name, bytes) delivered asynchronously (picker, drag and drop). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ed6d198388..b5332c3b1c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,7 +1,11 @@ # UI parity with Microsoft Word + fix/6205-image-copy-paste > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** trivial (clipboard: a copied picture reaches the system clipboard as PNG, with HTML where the host writes it, while a copy of only shapes stays in WordCraft, #6205); previously trivial (Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word (#5853)); previously trivial (a press the canvas handles, such as Ctrl+click on a link, no longer also shows the link tooltip that frame, #4683); previously trivial (the window moves by its title bar behind a modal dialog, as in Word for Mac, #5124); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux and the BSDs no longer looks semi-bold); previously trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (dialogs are modal except Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field, and a press beside one shakes it, #3859); previously trivial (showing the Format Shape pane leaves a gradient angle above 359.9° as it is, #3955); previously trivial (Return on File › Print prints, as Word's default Print button does); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (missing fonts flagged in the font box and status bar, read from what layout drew; metric-compatible substitutes and symbol fonts not flagged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (a long comment's card and balloon stop at ten lines, #3382); previously minor (Arabic mirrored interface: ribbon, panes, File page, dialogs, menus; document canvas independent, #1195); previously trivial (Alt, F badges the Backstage pages; Alt, F, P and V open Print, #4960); previously trivial (View › Macros opens a Macros dialog with Run, Delete and Record, #4327); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (Table Properties: row, column and cell settings apply to the selected rows, columns and cells; mixed row check boxes, #4857); previously trivial (Insert › Shapes shows the basic shapes as a grid of drawn icon tiles, #4349); previously trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** trivial (Save As on Linux: the desktop portal's Save dialog reports the file type picked, updating the extension so saving as PDF writes a .pdf, #6180); previously trivial (Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word (#5853)); previously trivial (a press the canvas handles, such as Ctrl+click on a link, no longer also shows the link tooltip that frame, #4683); previously trivial (the window moves by its title bar behind a modal dialog, as in Word for Mac, #5124); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux and the BSDs no longer looks semi-bold); previously trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (dialogs are modal except Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field, and a press beside one shakes it, #3859); previously trivial (showing the Format Shape pane leaves a gradient angle above 359.9° as it is, #3955); previously trivial (Return on File › Print prints, as Word's default Print button does); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (missing fonts flagged in the font box and status bar, read from what layout drew; metric-compatible substitutes and symbol fonts not flagged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (a long comment's card and balloon stop at ten lines, #3382); previously minor (Arabic mirrored interface: ribbon, panes, File page, dialogs, menus; document canvas independent, #1195); previously trivial (Alt, F badges the Backstage pages; Alt, F, P and V open Print, #4960); previously trivial (View › Macros opens a Macros dialog with Run, Delete and Record, #4327); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (Table Properties: row, column and cell settings apply to the selected rows, columns and cells; mixed row check boxes, #4857); previously trivial (Insert › Shapes shows the basic shapes as a grid of drawn icon tiles, #4349); previously trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 + main + > **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged. Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -70,7 +74,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Interface size (File › Options › General: Auto, which keeps the system display scale and shows the size it draws at, or an absolute 75–300 %, so "100%" is one screen pixel per point; `ui.interfaceSize`) (#475, #822). A size from before #822 (on top of the display scale) becomes the absolute size it drew at. On the web the browser's zoom is part of the display scale, so a chosen size cancels browser zoom (Ctrl+plus/minus no longer resize the interface); Auto follows it | ✅ | system display scaling only | 100% | — | | Animation effects (File › Options › Accessibility: Use system setting, On, Off; `ui.animations`). Off: a wheel notch scrolls at once; Ctrl+wheel zooms in whole steps, each drawn finished in its own frame (a notch at once; touchpad pinches and high-resolution wheels in steps of about 10% rather than gliding); menus, buttons and the scroll to the caret neither fade nor glide | ✅ "Use system setting" reads Windows (Animation effects), macOS (Reduce motion), the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers) and browsers (`prefers-reduced-motion`), and follows a change while running. Run on Windows and against a stand-in portal on Linux; not yet tried on a Mac or a real GNOME or KDE session | not compared | 90% | 0.5–1 | | Focus and Full Screen (#1678, #980) | View › Focus fills the screen with the document on black, with a slim bar whose "⋯" drops the ribbon over the page; Full Screen View (`view.fullScreenView`, any key in Customize Keyboard) shows the pages alone with Search at the top; the two turn each other off. View › Window › Full Screen (`view.fullScreen`, F11, ⌃⌘F on macOS) fills the screen with the whole window, ribbon and all. Esc leaves Focus or Full Screen View, then Full Screen, once nothing nearer takes it; leaving gives back the earlier window size; the window manager's or macOS's own full screen keeps them in step. Ctrl+F1 (⌘⌥R on macOS) collapses or expands the ribbon; a shortcut of your own on Ctrl+F1 wins. Web: Full Screen off (the browser's own F11); Focus and Full Screen View hide the chrome only | Focus, Toggle Full Screen View (Alt, V, U, #1697), Word for Mac's View › Enter Full Screen, ⌘⌥R to collapse or expand the ribbon (Ctrl+F1 on Windows) | 85% | 0.5 | -| File dialogs (Open, Save As, Export, Insert Picture) | system dialog, without blocking the window (#94), its Save As type list naming every format with the document's own first (#1399); Linux/BSD without a file chooser portal or zenity: WordCraft's own browser, also File › Open › Browse in WordCraft… (#576) | system dialog | 95% | — | +| File dialogs (Open, Save As, Export, Insert Picture) | system dialog, without blocking the window (#94), its Save As type list naming every format with the document's own first (#1399); Linux/BSD: the desktop portal's Save dialog reports the file type picked, updating the extension so saving as PDF writes a PDF (#6180); Linux/BSD without a file chooser portal or zenity: WordCraft's own browser, also File › Open › Browse in WordCraft… (#576) | system dialog | 95% | — | | Linux display server (File › Options › Advanced: Automatic, Wayland, X11 (XWayland); `ui.displayServer`, #2184), kept in `ui.json` and applied at the next launch. `WORDCRAFT_WAYLAND` overrides it (`0`, `false`, `no` or `off` for X11, any other value for Wayland), and a choice that isn't available falls back to the other instead of failing to start; the log's `display:` line says which ran and why | ✅ Linux and the BSDs only; the choice is unit-tested, not yet tried on a real GNOME or KDE session | no equivalent (no Word for Linux) | 100% | 0 | | Platform conventions (macOS menus/traffic lights #255, Windows title bar, Linux CSD on Wayland #78) | partial | native | 70% | 3–5 | | Screen readers (VoiceOver, Narrator, Orca) | AccessKit on; the document canvas describes paragraphs, headings (level), list items, tables, links, pictures (alt text) and the caret and selection, for the pages on screen (#488); the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, also onto an empty line after Enter or Shift+Enter (#1119); File › Home and New's templates and recent documents are buttons named by their caption or file name, with the path when two file names are the same (#3176); not yet tried with real screen readers | full | 50% | 5–8 |