From cc27f29a0133145abb84d8fcd2a3866406930800 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Wed, 11 Nov 2026 07:54:57 -0300 Subject: [PATCH 1/4] Save As on Linux: the file type picked in the save dialog sets the extension (#6180) --- ROADMAP.md | 3 +- apps/wordcraft/src/file_dialogs.rs | 52 +- apps/wordcraft/src/main.rs | 2 + apps/wordcraft/src/portal_save.rs | 895 +++++++++++++++++++++++++++++ docs/ui-parity.md | 4 +- 5 files changed, 947 insertions(+), 9 deletions(-) create mode 100644 apps/wordcraft/src/portal_save.rs diff --git a/ROADMAP.md b/ROADMAP.md index 36984dd36d..226ed33795 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 (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 (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. > @@ -909,6 +909,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 d6e11ef8e8..139da8b127 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -63,18 +63,57 @@ 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())) + }; 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 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: None, + 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_ambiguous_filter_error(&e) => { + log::warn!("portal save format ambiguous ({e}); cancelling save without explicit format choice"); + Ok(Vec::new()) + } + Err(e) => { + 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()) + } } }; let _ = answer.send(picked); @@ -220,8 +259,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() } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 2360a2e510..00f74024c2 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")] diff --git a/apps/wordcraft/src/portal_save.rs b/apps/wordcraft/src/portal_save.rs new file mode 100644 index 0000000000..b5fc05605f --- /dev/null +++ b/apps/wordcraft/src/portal_save.rs @@ -0,0 +1,895 @@ +//! 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. + #[allow(dead_code)] + pub fn new() -> Self { + Self(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false))) + } + + /// Signals that the active save request should be cancelled. + #[allow(dead_code)] + 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("the save dialog request was 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/…`). +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/") +} + +/// 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. + #[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("/home/user/Documents/test.docx"))); + assert!(!is_document_portal(Path::new("/tmp/doc/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/docs/ui-parity.md b/docs/ui-parity.md index dc70e11133..f64c8ac342 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** 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 (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 > **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. @@ -69,7 +69,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 | From 4671e47b50311c16c3e28a044eedf43a5bc191fb Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 10:15:36 -0300 Subject: [PATCH 2/4] Save As: cancel a stalled portal request and recognize the Flatpak document portal (#6180) - Recognize the XDG document portal mount inside a Flatpak sandbox (`/run/flatpak/doc/`), so a changed extension reopens the dialog for the overwrite confirmation like the host mount does. - Give the portal SaveFile request a real cancellation path: the CancelToken is armed before the waiting thread starts and cancelled when the system dialog is given up (File > Open > Browse in WordCraft...). The request closes and the answer reads as a cancel, so a portal that stops answering cannot keep Save As stuck; a late cancel after the answer is ignored. - Map the cancellation error to a cancelled dialog instead of the rfd fallback. - Regression tests for the Flatpak mount, the CancelHandle arm/cancel/disarm, and the UI calling the host's cancel hook when the dialog is given up. Co-authored-by: CommandCodeBot --- apps/wordcraft/src/file_dialogs.rs | 78 +++++++++++++++++++++++- apps/wordcraft/src/main.rs | 6 +- apps/wordcraft/src/portal_save.rs | 23 +++++-- crates/ui-egui/src/file_dialogs.rs | 3 + crates/ui-egui/src/file_dialogs/tests.rs | 21 +++++++ crates/ui-egui/src/services.rs | 4 ++ 6 files changed, 125 insertions(+), 10 deletions(-) diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 139da8b127..50880927bc 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,45 @@ 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")))] + token: Arc>>, +} + +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.token.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(token) = slot.take() { + token.cancel(); + } + } + } + + /// Arms the handle for the portal request about to wait, returning its token. + #[cfg(all(unix, not(target_os = "macos")))] + fn arm(&self) -> crate::portal_save::CancelToken { + let token = crate::portal_save::CancelToken::new(); + *self.token.lock().unwrap_or_else(|e| e.into_inner()) = Some(token.clone()); + token + } + + /// Disarms the handle once the request has answered. + #[cfg(all(unix, not(target_os = "macos")))] + fn disarm(&self) { + *self.token.lock().unwrap_or_else(|e| e.into_inner()) = None; + } +} + /// A picker's answer, waited for off the UI thread. type Picking = Pin> + Send>>; @@ -39,6 +80,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 +93,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) { @@ -68,6 +115,11 @@ impl Launcher { 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_cancel = matches!(&req, FileDialogRequest::Save { .. }).then(|| self.cancel.arm()); + #[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. @@ -84,7 +136,7 @@ impl Launcher { folder: dir.clone().map(std::path::PathBuf::from), filters: save_filters(name), current: Some(0), - cancel: None, + cancel: portal_cancel, timeout: None, }; let portal_res = zbus::blocking::Connection::session() @@ -93,6 +145,7 @@ impl Launcher { 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()) @@ -116,12 +169,16 @@ impl Launcher { } } }; + #[cfg(all(unix, not(target_os = "macos")))] + cancel_handle.disarm(); 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")))] + self.cancel.disarm(); } } } @@ -312,6 +369,23 @@ 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 token = handle.arm(); + assert!(!token.is_cancelled()); + handle.cancel(); + assert!(token.is_cancelled()); + + let answered = handle.arm(); + handle.disarm(); + handle.cancel(); + assert!(!answered.is_cancelled(), "an answered request is no longer 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 00f74024c2..ee5f38be8b 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -340,9 +340,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()), @@ -584,9 +585,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 index b5fc05605f..4edf20bbac 100644 --- a/apps/wordcraft/src/portal_save.rs +++ b/apps/wordcraft/src/portal_save.rs @@ -31,13 +31,11 @@ pub struct CancelToken(std::sync::Arc); impl CancelToken { /// Creates a new cancellation token in the active (not cancelled) state. - #[allow(dead_code)] pub fn new() -> Self { Self(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false))) } /// Signals that the active save request should be cancelled. - #[allow(dead_code)] pub fn cancel(&self) { self.0.store(true, std::sync::atomic::Ordering::Relaxed); } @@ -357,7 +355,7 @@ pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result let reply = loop { if req.cancel.as_ref().is_some_and(CancelToken::is_cancelled) { - return Err("the save dialog request was cancelled".to_string()); + return Err(ERR_CANCELLED.to_string()); } match rx.recv_timeout(Duration::from_millis(100)) { Ok(m) => { @@ -397,10 +395,20 @@ pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result answer(code, &results, elapsed) } -/// Returns true if `path` is located in the XDG document portal (`/run/user//doc/…`). +/// 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/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 @@ -838,13 +846,16 @@ mod tests { assert!(!entry_exists(&target), "nonexistent file does not exist"); } - /// `is_document_portal` identifies paths within the XDG document portal mount. + /// `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) 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 d9359b973b..a144ecd197 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). From 2d286fe252633b2fa4c8a1958de90ca2d4c86779 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 12:36:00 -0300 Subject: [PATCH 3/4] Save As: only the request that armed the portal cancel token disarms it (#6180) An abandoned portal request finishing late cleared the token of a newer request in the shared slot, which left the newer Save dialog uncancellable. Each arming now carries an identity and only its owner clears the slot; a regression test arms two requests and disarms the older one. --- apps/wordcraft/src/file_dialogs.rs | 75 ++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 50880927bc..7e51e9b35c 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -43,7 +43,16 @@ pub type Hook = Box Receiver>; #[derive(Clone, Default)] pub struct CancelHandle { #[cfg(all(unix, not(target_os = "macos")))] - token: Arc>>, + 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 { @@ -51,25 +60,30 @@ impl CancelHandle { pub fn cancel(&self) { #[cfg(all(unix, not(target_os = "macos")))] { - let mut slot = self.token.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(token) = slot.take() { - token.cancel(); + 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. + /// 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) -> crate::portal_save::CancelToken { + 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.token.lock().unwrap_or_else(|e| e.into_inner()) = Some(token.clone()); - token + *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 has answered. + /// 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) { - *self.token.lock().unwrap_or_else(|e| e.into_inner()) = None; + 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; + } } } @@ -117,7 +131,9 @@ impl Launcher { }; // Armed before the thread starts, so giving the dialog up cannot race its start. #[cfg(all(unix, not(target_os = "macos")))] - let portal_cancel = matches!(&req, FileDialogRequest::Save { .. }).then(|| self.cancel.arm()); + 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(); @@ -129,6 +145,7 @@ impl Launcher { #[cfg(all(unix, not(target_os = "macos")))] { 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()), @@ -136,7 +153,7 @@ impl Launcher { folder: dir.clone().map(std::path::PathBuf::from), filters: save_filters(name), current: Some(0), - cancel: portal_cancel, + cancel, timeout: None, }; let portal_res = zbus::blocking::Connection::session() @@ -170,7 +187,9 @@ impl Launcher { } }; #[cfg(all(unix, not(target_os = "macos")))] - cancel_handle.disarm(); + if let Some(armed) = &portal_arm { + cancel_handle.disarm(armed.id); + } let _ = answer.send(picked); ctx.request_repaint(); }); @@ -178,7 +197,9 @@ impl Launcher { if let Err(e) = waiting { log::error!("couldn't wait for the file dialog: {e}"); #[cfg(all(unix, not(target_os = "macos")))] - self.cancel.disarm(); + if let Some(id) = portal_id { + self.cancel.disarm(id); + } } } } @@ -375,15 +396,29 @@ mod tests { #[test] fn cancelling_a_pending_portal_request_marks_its_token() { let handle = super::CancelHandle::default(); - let token = handle.arm(); - assert!(!token.is_cancelled()); + let armed = handle.arm(); + assert!(!armed.token.is_cancelled()); handle.cancel(); - assert!(token.is_cancelled()); + assert!(armed.token.is_cancelled()); let answered = handle.arm(); - handle.disarm(); + handle.disarm(answered.id); handle.cancel(); - assert!(!answered.is_cancelled(), "an answered request is no longer cancelled"); + 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, From 664b033071ae65bcfca7d85b71d056c83410b0af Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 12:52:55 -0300 Subject: [PATCH 4/4] Save As: don't open the rfd fallback after the portal dialog was given up (#6180) Giving the dialog up cancels the request. If the portal then fails with an error other than its cancellation code, the worker opened the rfd dialog even though nothing was waiting for the answer, leaving an unwanted Save dialog on screen. The fallback now checks this request's cancellation token first. --- apps/wordcraft/src/file_dialogs.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 7e51e9b35c..f52edc9e52 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -168,9 +168,17 @@ impl Launcher { Ok(Vec::new()) } Err(e) => { - 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()) + // 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 {