From c8e64d72cc46e6c9dd6a657708522916734a1056 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 13:58:46 -0300 Subject: [PATCH 1/2] UI: vertical scrollbar track advances one screen (#5853) (#5892) --- ROADMAP.md | 4 +- crates/ui-egui/src/canvas.rs | 110 +++++++++++- crates/ui-egui/src/canvas/tests.rs | 264 +++++++++++++++++++++++++++++ docs/ui-parity.md | 4 +- 4 files changed, 376 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 36984dd36d..435d113a24 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,8 @@ **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 (Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word (#5853)); previously trivial (progress log: #4501 refactor regressions restored: per-section header grid, ODT default font, link tooltip, #4683); previously trivial (progress log: the window moves by its title bar behind a modal dialog, #5124); previously trivial (progress log: `convert --strict` and `render --strict` refuse a silently incomplete PDF or PNG (#6005, #6008)); ; trivial (progress log: ODT columns stating only their shares); previously trivial (progress log: ODT font size for every script); previously trivial (progress log: ODT text turning off what its style turns on); previously trivial (progress log: edited ODT paragraphs keep custom styles with an outline level); previously trivial (progress log: ODT pictures keep the size their file gives); previously trivial (progress log: ODT frames keep the page they are anchored to); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; previously trivial (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured); previously trivial (progress log: ODT documents keep their language); previously trivial (progress log: ODT table and row backgrounds) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 + > > **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged. > @@ -183,6 +184,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-06: Line numbers take the document's "Line Number" style over its defaults, as in Word (Aptos 12 pt black in a manuscript whose defaults are Aptos 12 pt, measured in Word for Mac), instead of a fixed 10 pt grey. Readiness estimates unchanged. - 2026-11-07: Newly opened secondary windows constrain their initial viewport dimensions to the monitor's usable area with margins for system panels, preventing new windows from exceeding screen width on high-DPI scaled displays (#5871). - 2026-11-07: A floating anchor after a page break stays with text on the far side of the same wrapped row (Leaf #107); marker-only cover anchors retain their rule. Picture spacing, drop position, Undo/Redo and DOCX regressions. +- 2026-11-07: Vertical scrollbar track: clicking above the elevator advances one screen up and clicking below it advances one screen down, matching Word's behavior, instead of jumping to the clicked document position (#5853). Readiness estimates unchanged. - 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged. - 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged. - 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index af20588859..23d24365ad 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -824,6 +824,7 @@ struct CanvasFinish<'a> { vruler: Option, scroll_base: f32, rescaled: bool, + scroll_style: egui::style::ScrollStyle, } /// What a window's own scrolling is measured against for Synchronous Scrolling (#322). @@ -833,8 +834,11 @@ struct ScrollSync { base: f32, /// The zoom changed: the offset moved, nobody scrolled. rescaled: bool, + /// The scroll style applied to the canvas scroll area (#5853). + style: egui::style::ScrollStyle, } +/// Update scroll offset, scrollbar tip, and current viewed page state for the canvas frame. fn update_canvas_scroll_state( app: &mut WordApp, ui: &mut Ui, @@ -858,12 +862,13 @@ fn update_canvas_scroll_state( viewed_page(extent, &geo.rects, &geo.bands, app.canvas.scroll_offset.y, out.inner_rect.height()).min(layout.pages.len().saturating_sub(1)) }); crate::ink::replay_bar(app, ui, out.inner_rect); + app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO); + scrollbar_track_paging(app, ui, out, sync.style); // This window's own scrolling (wheel, scroll bar, keys, caret), for a partner window. let own = app.canvas.scroll_offset.y - sync.base; if app.session.windows.sync_scroll && !sync.rescaled && own.abs() > 0.01 && geo.scale > 0.0 { app.canvas.sync_out += own / geo.scale; } - app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO); if app.session.view.read_mode { let step = app.session.view.read_width * geo.scale + GAP; if step.is_finite() && step > 0.0 { @@ -876,6 +881,82 @@ fn update_canvas_scroll_state( app.canvas.viewport = Some(out.inner_rect); } +/// Advance the vertical scroll by one screen when clicking in the track above or below the elevator (#5853). +/// +/// In Word, clicking in the vertical scrollbar track above the elevator moves one screen up, +/// and clicking below it moves one screen down. Sensed here over the track regions outside the +/// elevator so egui does not jump the scroll offset to the clicked position. +/// +/// Paging regions track egui's selected vertical scrollbar side (`ui.style().right_to_left`) +/// and allocated scrollbar width (`scroll_style.allocated_width()`), placing the paging bounds +/// on the left in RTL and on the right in LTR across solid, floating, and thin scroll styles. +fn scrollbar_track_paging( + app: &mut WordApp, + ui: &mut Ui, + out: &egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec)>, + scroll_style: egui::style::ScrollStyle, +) { + let scroll_id = out.id.with(1usize); + let dragging_bar = ui.ctx().is_being_dragged(scroll_id); + if app.session.view.read_mode || !out.inner_rect.is_finite() || !out.content_size.is_finite() || app.canvas.scroll_max.y <= 0.0 || dragging_bar { + return; + } + let step = out.inner_rect.height(); + if !step.is_finite() || step <= 0.0 { + return; + } + let inner_rect = out.inner_rect; + let full_width = scroll_style.bar_width; + let outer_margin = scroll_style.bar_outer_margin; + let allocated_width = scroll_style.allocated_width(); + let vertical_bar_on_left = ui.style().right_to_left; + let (bar_left, bar_right) = if vertical_bar_on_left { + let outer_edge = (inner_rect.min.x - allocated_width).max(ui.clip_rect().min.x) + outer_margin; + (outer_edge, outer_edge + full_width) + } else { + let outer_edge = (inner_rect.max.x + allocated_width).min(ui.clip_rect().max.x) - outer_margin; + (outer_edge - full_width, outer_edge) + }; + if !bar_left.is_finite() || !bar_right.is_finite() || bar_right <= bar_left { + return; + } + let handle_min_length = ui.spacing().scroll.handle_min_length; + let content_h = out.content_size.y; + let offset_y = app.canvas.scroll_offset.y; + let max_offset_y = app.canvas.scroll_max.y; + + let from_content = |content: f32| egui::remap_clamp(content, 0.0..=content_h, inner_rect.min.y..=inner_rect.max.y); + let handle_size = (from_content(offset_y + step) - from_content(offset_y)).max(handle_min_length); + let handle_start = if max_offset_y > 0.0 { + egui::remap_clamp(offset_y, 0.0..=max_offset_y, inner_rect.min.y..=(inner_rect.max.y - handle_size)) + } else { + inner_rect.min.y + }; + let handle_end = handle_start + handle_size; + + let track_above = Rect::from_min_max(pos2(bar_left, inner_rect.min.y), pos2(bar_right, handle_start)); + if track_above.height() > 0.0 { + let resp = ui.interact(track_above, out.id.with("track_above"), Sense::click_and_drag()); + if resp.clicked() && resp.interact_pointer_pos().is_some_and(|p| track_above.contains(p)) { + let target_y = (offset_y - step).max(0.0); + app.canvas.scroll_request = Some(vec2(app.canvas.scroll_offset.x, target_y)); + app.canvas.scroll_offset.y = target_y; + ui.ctx().request_repaint(); + } + } + + let track_below = Rect::from_min_max(pos2(bar_left, handle_end), pos2(bar_right, inner_rect.max.y)); + if track_below.height() > 0.0 { + let resp = ui.interact(track_below, out.id.with("track_below"), Sense::click_and_drag()); + if resp.clicked() && resp.interact_pointer_pos().is_some_and(|p| track_below.contains(p)) { + let target_y = (offset_y + step).min(max_offset_y); + app.canvas.scroll_request = Some(vec2(app.canvas.scroll_offset.x, target_y)); + app.canvas.scroll_offset.y = target_y; + ui.ctx().request_repaint(); + } + } +} + fn update_canvas_focus(app: &mut WordApp, ui: &mut Ui, resp: &egui::Response, ime_geometry: Option<(Rect, Rect)>, ime_interrupt: bool) { // Space and Enter also set `Response::clicked` on a focused click-sensing widget // (egui's fake primary click). Re-requesting an already focused widget no longer @@ -1328,9 +1409,25 @@ fn handle_secondary_selection(app: &mut WordApp, resp: &egui::Response, rects: & } } +/// Finish the canvas frame: update scroll state, handle focus, input, accessibility, and context menus. fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec)>) { - let CanvasFinish { app, ui, layout, geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler, scroll_base, rescaled } = finish; - update_canvas_scroll_state(app, ui, layout, geo, caret, &out, ScrollSync { base: scroll_base, rescaled }); + let CanvasFinish { + app, + ui, + layout, + geo, + caret, + ime_geometry, + ime_interrupt, + area, + dim_body, + hruler, + vruler, + scroll_base, + rescaled, + scroll_style, + } = finish; + update_canvas_scroll_state(app, ui, layout, geo, caret, &out, ScrollSync { base: scroll_base, rescaled, style: scroll_style }); let (resp, rects) = out.inner; // A click on the page leaves the selected comment balloon. dismiss_balloon_on_canvas_click(app, ui, &resp); @@ -1526,6 +1623,7 @@ fn live_selection_screen_rects(app: &mut WordApp, rects: &[Rect], scale: f32) -> .collect() } +/// Render the document canvas, pages, scroll bars, rulers, and interactive controls for the current frame. pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let full = ui.available_rect_before_wrap(); @@ -1539,7 +1637,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area)); // The bars are drawn with `ui_area`'s style; what the pages draw gets the inherited one back. let inherited = std::sync::Arc::clone(ui_area.style()); - ui_area.spacing_mut().scroll = canvas_scroll_style(); + if ui.spacing().scroll == egui::style::ScrollStyle::floating() { + ui_area.spacing_mut().scroll = canvas_scroll_style(); + } canvas_scroll_thumb(ui_area.visuals_mut(), &t); let mut ime_geometry: Option<(Rect, Rect)> = None; let mut ime_interrupt = false; @@ -1828,6 +1928,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { crate::table_mouse::paint(app, &painter, &t, &layout, &rects, geo.scale); (resp, rects) }); + let scroll_style = ui_area.spacing().scroll; finish_canvas_frame( CanvasFinish { app, @@ -1843,6 +1944,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { vruler, scroll_base, rescaled, + scroll_style, }, out, ); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index a276701fcb..be83a19924 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -699,6 +699,7 @@ fn dragging_the_scrollbar_names_the_page_at_the_top() { } a.run("view.commentsPane", json!({"value": false})).unwrap(); a.run("view.hideWhiteSpace", json!({"value": hide_white_space})).unwrap(); + a.run("caret.docStart", json!({})).unwrap(); let (rev, sel) = (a.session.rev(), a.session.sel.clone()); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( |ui, app: &mut WordApp| { @@ -835,6 +836,269 @@ fn the_document_scroll_bars_show_with_the_pointer_away() { } } +/// #5853: clicking in the vertical scrollbar track below the elevator moves one screen down, +/// and clicking above the elevator moves one screen up, rather than jumping to that position in the document. +#[test] +fn clicking_vertical_scrollbar_track_advances_one_screen() { + use egui::accesskit::Role; + use egui::{Event, PointerButton}; + use egui_kittest::kittest::Queryable; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + for _ in 0..11 { + a.run("text.insert", json!({"text": "Page text"})).unwrap(); + a.run("layout.break", json!({"kind": "page"})).unwrap(); + } + a.run("view.commentsPane", json!({"value": false})).unwrap(); + a.run("caret.docStart", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + assert_eq!(h.state_mut().session.layout().pages.len(), 12); + let bar = h + .query_all_by_role(Role::ScrollBar) + .map(|n| n.rect()) + .filter(|r| r.height() > r.width()) + .max_by(|a, b| a.height().total_cmp(&b.height())) + .expect("the canvas's vertical scrollbar"); + + let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + + let click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| { + h.event(Event::PointerMoved(at)); + h.step(); + h.event(button(at, true)); + h.step(); + h.event(button(at, false)); + for _ in 0..3 { + h.step(); + } + }; + + let start_offset = h.state().canvas.scroll_offset.y; + assert_eq!(start_offset, 0.0, "starts at the top"); + let viewport_height = h.state().canvas.viewport.expect("viewport").height(); + + // Click near the bottom of the scrollbar (well below the elevator). + let near_bottom = pos2(bar.center().x, bar.bottom() - 20.0); + click(&mut h, near_bottom); + + let after_click_down = h.state().canvas.scroll_offset.y; + assert!((after_click_down - viewport_height).abs() < 2.0, "advances one screen down: expected ~{viewport_height}, got {after_click_down}"); + + // Now click near the top of the scrollbar (above the elevator). + let near_top = pos2(bar.center().x, bar.top() + 10.0); + click(&mut h, near_top); + + let after_click_up = h.state().canvas.scroll_offset.y; + assert!(after_click_up.abs() < 2.0, "advances one screen up back to top: expected ~0.0, got {after_click_up}"); +} + +/// #5853: in a right-to-left interface, egui places the vertical scrollbar on the left; +/// clicking in the scrollbar track advances one screen up/down along that left edge. +#[test] +fn clicking_vertical_scrollbar_track_advances_one_screen_in_rtl() { + use egui::accesskit::Role; + use egui::{Event, PointerButton}; + use egui_kittest::kittest::Queryable; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + for _ in 0..11 { + a.run("text.insert", json!({"text": "Page text"})).unwrap(); + a.run("layout.break", json!({"kind": "page"})).unwrap(); + } + a.run("view.commentsPane", json!({"value": false})).unwrap(); + a.run("caret.docStart", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + ui.style_mut().set_right_to_left(true); + app.ui(ui); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + assert_eq!(h.state_mut().session.layout().pages.len(), 12); + let bar = h + .query_all_by_role(Role::ScrollBar) + .map(|n| n.rect()) + .filter(|r| r.height() > r.width()) + .max_by(|a, b| a.height().total_cmp(&b.height())) + .expect("the canvas's vertical scrollbar"); + + assert!(bar.center().x < 100.0, "scrollbar is on the left in RTL: {bar:?}"); + + let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + + let click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| { + h.event(Event::PointerMoved(at)); + h.step(); + h.event(button(at, true)); + h.step(); + h.event(button(at, false)); + for _ in 0..3 { + h.step(); + } + }; + + let start_offset = h.state().canvas.scroll_offset.y; + assert_eq!(start_offset, 0.0, "starts at the top"); + let viewport_height = h.state().canvas.viewport.expect("viewport").height(); + + let near_bottom = pos2(bar.center().x, bar.bottom() - 20.0); + click(&mut h, near_bottom); + + let after_click_down = h.state().canvas.scroll_offset.y; + assert!((after_click_down - viewport_height).abs() < 2.0, "advances one screen down in RTL: expected ~{viewport_height}, got {after_click_down}"); + + let near_top = pos2(bar.center().x, bar.top() + 10.0); + click(&mut h, near_top); + + let after_click_up = h.state().canvas.scroll_offset.y; + assert!(after_click_up.abs() < 2.0, "advances one screen up back to top in RTL: expected ~0.0, got {after_click_up}"); +} + +/// #5853: clicking within the vertical scrollbar track under `ScrollStyle::thin()` advances one screen. +#[test] +fn clicking_thin_vertical_scrollbar_track_advances_one_screen() { + use egui::accesskit::Role; + use egui::{Event, PointerButton}; + use egui_kittest::kittest::Queryable; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + for _ in 0..11 { + a.run("text.insert", json!({"text": "Page text"})).unwrap(); + a.run("layout.break", json!({"kind": "page"})).unwrap(); + } + a.run("view.commentsPane", json!({"value": false})).unwrap(); + a.run("caret.docStart", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + ui.spacing_mut().scroll = egui::style::ScrollStyle::thin(); + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + assert_eq!(h.state_mut().session.layout().pages.len(), 12); + let bar = h + .query_all_by_role(Role::ScrollBar) + .map(|n| n.rect()) + .filter(|r| r.height() > r.width()) + .max_by(|a, b| a.height().total_cmp(&b.height())) + .expect("the canvas's vertical scrollbar"); + + let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + + let click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| { + h.event(Event::PointerMoved(at)); + h.step(); + h.event(button(at, true)); + h.step(); + h.event(button(at, false)); + for _ in 0..3 { + h.step(); + } + }; + + let start_offset = h.state().canvas.scroll_offset.y; + assert_eq!(start_offset, 0.0, "starts at the top"); + let viewport_height = h.state().canvas.viewport.expect("viewport").height(); + + let near_bottom = pos2(bar.center().x, bar.bottom() - 20.0); + click(&mut h, near_bottom); + + let after_click_down = h.state().canvas.scroll_offset.y; + assert!( + (after_click_down - viewport_height).abs() < 2.0, + "advances one screen down with thin scrollbar: expected ~{viewport_height}, got {after_click_down}" + ); + + let near_top = pos2(bar.center().x, bar.top() + 10.0); + click(&mut h, near_top); + + let after_click_up = h.state().canvas.scroll_offset.y; + assert!(after_click_up.abs() < 2.0, "advances one screen up back to top with thin scrollbar: expected ~0.0, got {after_click_up}"); +} + +/// #5853: clicks and drags on page content just outside the vertical scrollbar track do not trigger +/// paging and can place the caret or start a selection. +#[test] +fn clicking_and_dragging_outside_scrollbar_does_not_page() { + use egui::accesskit::Role; + use egui::{Event, PointerButton}; + use egui_kittest::kittest::Queryable; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + for _ in 0..11 { + a.run("text.insert", json!({"text": "A long line of sample text that fills across the page for testing selections."})).unwrap(); + a.run("layout.break", json!({"kind": "page"})).unwrap(); + } + a.run("view.commentsPane", json!({"value": false})).unwrap(); + a.run("caret.docStart", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + let bar = h + .query_all_by_role(Role::ScrollBar) + .map(|n| n.rect()) + .filter(|r| r.height() > r.width()) + .max_by(|a, b| a.height().total_cmp(&b.height())) + .expect("the canvas's vertical scrollbar"); + + let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + + // Point just to the left of the scrollbar track (outside egui's scrollbar hit rect). + let just_outside = pos2(bar.min.x - 2.0, bar.bottom() - 20.0); + h.event(Event::PointerMoved(just_outside)); + h.step(); + h.event(button(just_outside, true)); + h.step(); + h.event(button(just_outside, false)); + for _ in 0..3 { + h.step(); + } + + assert_eq!(h.state().canvas.scroll_offset.y, 0.0, "clicking outside the scrollbar track does not page"); + + // Dragging just outside the scrollbar does not page. + let drag_from = pos2(bar.min.x - 3.0, 300.0); + let drag_to = pos2(bar.min.x - 3.0, 350.0); + h.event(Event::PointerMoved(drag_from)); + h.step(); + h.event(button(drag_from, true)); + h.step(); + for k in 1..=4 { + h.event(Event::PointerMoved(drag_from + (drag_to - drag_from) * (k as f32 / 4.0))); + h.step(); + } + h.event(button(drag_to, false)); + for _ in 0..3 { + h.step(); + } + + assert_eq!(h.state().canvas.scroll_offset.y, 0.0, "dragging outside the scrollbar track does not page"); +} + /// #3880: the status bar names the page being looked at. With the caret scrolled out of view, that /// is the page at the top of the window; while the caret is on screen, its page. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index dc70e11133..28e3312c11 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 (Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word (#5853)); previously trivial (a press the canvas handles, such as Ctrl+click on a link, no longer also shows the link tooltip that frame, #4683); previously trivial (the window moves by its title bar behind a modal dialog, as in Word for Mac, #5124); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux and the BSDs no longer looks semi-bold); previously trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (dialogs are modal except Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field, and a press beside one shakes it, #3859); previously trivial (showing the Format Shape pane leaves a gradient angle above 359.9° as it is, #3955); previously trivial (Return on File › Print prints, as Word's default Print button does); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (missing fonts flagged in the font box and status bar, read from what layout drew; metric-compatible substitutes and symbol fonts not flagged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (a long comment's card and balloon stop at ten lines, #3382); previously minor (Arabic mirrored interface: ribbon, panes, File page, dialogs, menus; document canvas independent, #1195); previously trivial (Alt, F badges the Backstage pages; Alt, F, P and V open Print, #4960); previously trivial (View › Macros opens a Macros dialog with Run, Delete and Record, #4327); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (Table Properties: row, column and cell settings apply to the selected rows, columns and cells; mixed row check boxes, #4857); previously trivial (Insert › Shapes shows the basic shapes as a grid of drawn icon tiles, #4349); previously trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **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. @@ -58,6 +58,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | | Web Layout width | Text wraps to the whole work area (window width at the current zoom, 144–20,000 pt) and re-wraps once a window resize has settled (not on every frame of a drag) or at once on a zoom, keeping the caret in view; the sheet runs edge to edge with no centred page. Draft and Outline keep their 800 pt column; headless sessions keep 800 pt (#1876) | text across the window | 95% | — | | Document scroll bars | ✅ always drawn beside the page in the room left for them, wherever the pointer is, with a grey thumb that stands out from its track in the light and the dark theme, and more so under the pointer and while dragged; they were invisible until the pointer reached the window's right edge (#5746, `canvas::tests::the_document_scroll_bars_show_with_the_pointer_away`); hidden in Read Mode, as in Word | always shown | 90% | — | +| Vertical scrollbar track paging | ✅ clicking the vertical scrollbar track above the elevator moves one screen up, and below it moves one screen down, as in Word (#5853, `canvas::tests::clicking_vertical_scrollbar_track_advances_one_screen`); it previously jumped to the clicked position | moves one screen | 90% | — | | Scrollbar drag page tip | ✅ while the vertical scrollbar is dragged, a tip beside the pointer names the page at the top of the window in the status bar's words ("Page 7 of 10"), on one line however long the number (#2551, #3341, `canvas::tests::dragging_the_scrollbar_names_the_page_at_the_top`); its look and wording not yet compared with Word's | not compared | 70% | 0.5 | | Interface text on screen (Linux, BSD) | Drawn by egui with Inter's ttfautohint hints, which match FreeType's light autohinting: measured against headless Firefox (Inter, 18.1 and 15.2 px at 1.45x), the same rows of pixels and weight (ink within 1 %) in the light theme. The dark theme no longer looks semi-bold: egui's dark default thickened light-on-dark text (about 21 % more ink than Firefox); it now adds a light 7 % (plain coverage, Firefox's weight, looked soft on the dark tab row; chosen side by side on a 145 % desktop). Not yet: subpixel (LCD) edges | sharp interface text | 90% | — | | Page text on screen (Linux, BSD) | Lightly hinted as FreeType's `hintslight` draws it (Firefox and other apps there): FreeType's autohinter puts baselines, x-heights and cap heights on whole pixels, nothing moves sideways, so lines break as before. Measured against headless Firefox (Arial, 21.27 px): the same rows of pixels and weight. Not yet: subpixel (LCD) edges; the interface text (egui) is hinted separately. macOS keeps its darkened unhinted text; Windows and exports are unchanged | sharp document text | 90% | 4–8 (subpixel edges) | @@ -127,6 +128,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | Date | Change | Summary | |---|---|---| +| 2026-11-07 | trivial | Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word, instead of jumping to that position in the document (#5853) | | 2026-11-04 | trivial | Canvas: a press the canvas handles itself (Ctrl+click on a link, switching into a header or footer, equation entry) skips the link tooltip that frame; hovering shows it again; a regression from the #4501 refactor (#4683) | | 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | | 2026-11-06 | trivial | The document's scroll bars are always drawn, in the room beside the page left for them, with a grey thumb that stands out from its track in both themes; they were invisible until the pointer reached the window's right edge, so without a mouse wheel nothing showed how to reach page 2 (#5746) | From 10e5bb8bce582cf940a463d0ff197ffa99c80906 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 13:59:04 -0300 Subject: [PATCH 2/2] View: editable Markdown source view for .md files (#5826) (#6004) * View: editable Markdown source view for .md files (#5826) Co-authored-by: oldgranola * Address review feedback on Markdown source view - Fix view.markdownSourceText purity and input validation - Single-pass block line mapping in markdown parser - Reuse parsed documents and block line ranges on apply and leave - Bound memory usage with MAX_SOURCE_BYTES and clamp caret byte offsets - Isolate temp directories in tests and test caret validation - Preserve dirty state while markdown text edits are pending - Flush pending markdown text edits in swap_doc * Address CodeRabbit review feedback on Markdown source view - Exempt view.markdownSourceText in command refusal - Preserve original source line mapping when filtering link definitions - Map between egui char indices and byte-offset caret in history and undo * Remap block line numbers in a single linear pass * Refuse over-limit Markdown Source edits to prevent document truncation * Keep a Markdown file's own text when it is opened from handed-over bytes `file.open` kept a Markdown file's exact text only for path-backed opens, so a platform that hands the bytes over (data) lost front matter, comments and the author's line endings on the first save, and the rewritten flag could not say so. The kept text now comes from the same bytes `read_document` decodes, still bounded by MAX_SOURCE_BYTES through FileText::from_bytes; the path read stays for opens without data. --------- Co-authored-by: oldgranola Co-authored-by: CommandCodeBot --- apps/wordcraft/src/file_dialogs.rs | 4 + crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/file.rs | 46 +- crates/engine/src/cmd/md_source.rs | 897 +++++++++++++++++++++++++ crates/engine/src/io.rs | 14 + crates/engine/src/session.rs | 4 + crates/engine/src/session/documents.rs | 15 + crates/engine/src/session/history.rs | 5 + crates/engine/src/session/lifecycle.rs | 4 + crates/engine/src/session/run.rs | 19 +- crates/engine/src/session/snapshot.rs | 8 +- crates/engine/src/tests.rs | 54 +- crates/formats/src/markdown.rs | 4 +- crates/formats/src/markdown/blocks.rs | 276 +++++--- crates/formats/src/markdown/tests.rs | 50 ++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/commands.rs | 11 + crates/ui-egui/src/app/files.rs | 11 +- crates/ui-egui/src/app/frame.rs | 18 +- crates/ui-egui/src/chrome.rs | 6 +- crates/ui-egui/src/icons.rs | 9 + crates/ui-egui/src/keytips.rs | 1 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/md_source.rs | 386 +++++++++++ crates/ui-egui/src/ribbon/view.rs | 3 + crates/ui-egui/src/tests.rs | 14 + 26 files changed, 1766 insertions(+), 97 deletions(-) create mode 100644 crates/engine/src/cmd/md_source.rs create mode 100644 crates/ui-egui/src/md_source.rs diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index d6e11ef8e8..54e1730a77 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -209,6 +209,8 @@ fn save_exts(name: &str) -> Vec { .extension() .and_then(std::ffi::OsStr::to_str) .map(str::to_ascii_lowercase) + // A .markdown file is under Markdown's one entry in the list. + .map(|e| if e == "markdown" { "md".to_string() } else { e }) .filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str())) .unwrap_or_else(|| "docx".to_string()); let mut exts: Vec = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len()); @@ -301,6 +303,8 @@ mod tests { assert_eq!(save_exts("novel.docx")[0], "docx"); assert_eq!(save_exts("report.odt")[0], "odt"); assert_eq!(save_exts("annual report.pdf")[0], "pdf"); + assert_eq!(save_exts("notes.md")[0], "md"); + assert_eq!(save_exts("notes.markdown")[0], "md"); // A suggested name always has an extension; anything unreadable falls back to Word's. assert_eq!(save_exts("no extension")[0], "docx"); assert_eq!(save_exts("mystery.rtf2")[0], "docx"); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index bf71b35d63..ad11a55be6 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -24,6 +24,7 @@ pub mod insert; pub mod inspector; pub mod lists; pub mod mailings; +pub mod md_source; pub mod moves; pub mod objects; pub mod options; @@ -73,6 +74,7 @@ pub fn registry() -> Registry { v.extend(lists::specs()); v.extend(inspector::specs()); v.extend(view::specs()); + v.extend(md_source::specs()); v.extend(outline::specs()); v.extend(insert::specs()); v.extend(equation::specs()); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c676e2c802..0a067b6d4c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -269,6 +269,10 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { super::view::restore_zoom(s, z); } s.path = Some(path.into()); + // A Markdown file's own text, for the view to show and Save to write back as it is. + if super::md_source::is_markdown_file(std::path::Path::new(path)) { + s.md_file = markdown_file_text(path, v); + } // Like Word, a document opened with a password is saved with it again. if encrypted { s.password = password.map(crate::io::Password::new); @@ -278,6 +282,28 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { ) } +/// A Markdown file's own text as `file.open` finds it: the bytes the caller hands over (base64 in +/// `data`, the same ones `read_document` decodes), else the head of the file at `path`. +/// `FileText::from_bytes` keeps it only when it can be written back as it is. +fn markdown_file_text(path: &str, v: &Value) -> super::md_source::FileText { + if let Some(data) = p::str(v, "data") { + // `read_document` has already refused a text it cannot decode. + return super::insert::base64_decode(data).map(|bytes| super::md_source::FileText::from_bytes(&bytes)).unwrap_or_default(); + } + // Without `data`, the file's own bytes; not reachable on the web, where a file only arrives + // as `data`. + #[cfg(not(target_arch = "wasm32"))] + { + let max = u64::try_from(super::md_source::MAX_SOURCE_BYTES).unwrap_or(u64::MAX).saturating_add(1); + crate::io::read_file_head(path, max).map(|bytes| super::md_source::FileText::from_bytes(&bytes)).unwrap_or_default() + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + super::md_source::FileText::default() + } +} + /// The document the file at `path` holds (or its bytes, base64 in `data`, with `path` only /// naming it), read with `password`, and whether it was encrypted: what `file.open` and /// `file.newFromTemplate` read. An empty file (File Explorer's New › Microsoft Word Document makes @@ -364,6 +390,8 @@ fn encrypt(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"encrypted": s.password.is_some()})) } +/// Executes `file.save`: writes the document or text to disk, preserving exact file text for +/// unedited Markdown files, and updating session path and dirty state. fn save(s: &mut Session, v: &Value) -> CmdResult { let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) { Some(p) => p, @@ -407,20 +435,32 @@ fn save(s: &mut Session, v: &Value) -> CmdResult { let password = s.password.as_ref().map(crate::io::Password::as_str); // A header/footer just made and left empty isn't saved (#960). let copy = super::insert::saved_copy(s); - if let Err(e) = crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts) { + // A Markdown file's own text goes back as it is while it still says what the document holds; + // once it doesn't (an edit in the formatted view), the document is written out again. + let exact = super::md_source::text_to_save(s, &path, opts.markup); + let rewritten = exact.is_none() && !s.md_file.is_empty() && super::md_source::is_markdown_file(&path); + let written = match &exact { + Some(text) => crate::io::save_text_path(&path, text), + None => crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts), + }; + if let Err(e) = written { (s.doc.settings.zoom, s.doc.core.last_modified_by, s.doc.core.created, s.doc.core.modified, s.doc.core.revision) = before_stamp; return Err(CmdError::Failed(e)); } let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy()); // A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a // copy, except over the document's own file: a note.txt opened and saved is saved (#2376). + // A Markdown file saved as another one loses nothing its own save wouldn't: it moves there. let own_file = s.path.as_deref() == Some(path.as_path()); - if own_file || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) { + let markdown_to_markdown = super::md_source::is_markdown_file(&path) && s.path.as_deref().is_some_and(super::md_source::is_markdown_file); + if own_file || markdown_to_markdown || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) { s.path = Some(path.clone()); s.dirty = false; s.relayout(); + // What the file holds now. + s.md_file = super::md_source::FileText::saved(exact.filter(|_| super::md_source::is_markdown_file(&path))); } - Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted})) + Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted, "rewritten": rewritten})) } /// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)? diff --git a/crates/engine/src/cmd/md_source.rs b/crates/engine/src/cmd/md_source.rs new file mode 100644 index 0000000000..6d73496124 --- /dev/null +++ b/crates/engine/src/cmd/md_source.rs @@ -0,0 +1,897 @@ +//! View › Markdown Source: a Markdown file's document as editable text. +//! +//! The document holds no Markdown while it is edited, so entering writes it out as text, and the +//! text waits beside the document ([`MdSource`]) while it is edited. Leaving reads a changed text +//! back into a new document; one Undo takes back the whole visit. A visit that changes nothing +//! leaves the document as it was, whatever Markdown can't represent. +//! +//! While the text is open it is the document: commands that edit the document are refused, those +//! that read it (Save, Export) first get the text applied, and those that change the view leave. +//! +//! The file's own text is kept beside the document too ([`FileText`]): while it still stands for +//! the document, the view shows it and Save writes it, unchanged. An edit in the formatted view +//! makes the document something only writing it out again can say. + +use std::cell::RefCell; +use std::sync::Arc; + +use serde_json::{Value, json}; +use wordcraft_doc::{Document, Path, Pos, StoryRef}; + +use crate::doc_state::Selection; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +/// The largest text the view takes, in bytes: its text box lays all of it out after each edit. +pub const MAX_SOURCE_BYTES: usize = 2 * 1024 * 1024; + +const LABEL: &str = "Markdown Source"; + +/// The text of an open Markdown Source view. +#[derive(Clone)] +pub struct MdSource { + /// What the view shows and edits. + pub text: String, + /// The caret in `text`, as a byte offset. The front end keeps it current. + pub caret: usize, + /// The front end still has to put its caret at `caret` and show it. + pub place_caret: bool, + /// The front end still has to reset its typing history from earlier visits. + pub reset_undo: bool, + /// Markdown can't represent all of the document: applying a changed text loses something. + pub loses_content: bool, + /// When the front end last applied the text while it was being typed, on its own clock. + pub applied_at_ms: f64, + /// The text on entry. + entry_text: Arc, + /// The document on entry, which that text stands for: back to it, nothing is lost. + entry_doc: Arc, + /// The line each top-level block of `entry_doc` starts on. + entry_lines: Vec, + /// The text the document now holds. + applied: Arc, + /// The visit has its undo step. + has_step: bool, + /// The undo mark recorded when the visit took its undo step, if one was taken. + step: Option, + /// The line each top-level block starts on, from the last parse. + block_lines: Vec, +} + +impl MdSource { + /// The text holds edits the document doesn't have yet. + pub fn is_pending(&self) -> bool { + self.text != *self.applied + } +} + +/// A Markdown file's own text, which keeps what the document can't (front matter, comments, a +/// code block's language, the author's own spacing): the text the file was opened or last saved +/// with, and the last text the view applied. Either stands for the document while reading it +/// gives the document as it is, so an Undo back to either finds it again. +#[derive(Clone, Default)] +pub struct FileText { + saved: Option, + latest: Option, + cached_exact: RefCell)>>, +} + +impl FileText { + /// A file's bytes as its text, if they can be written back as they are: valid UTF-8, + /// not empty, and no longer than the view takes. + pub fn from_bytes(bytes: &[u8]) -> Self { + let keep = !bytes.is_empty() && bytes.len() <= MAX_SOURCE_BYTES; + let saved = keep.then(|| std::str::from_utf8(bytes).ok()).flatten().map(str::to_string); + Self { saved, latest: None, cached_exact: RefCell::new(None) } + } + + /// The text just written to the file, which is the file's own text now. + pub fn saved(text: Option) -> Self { + Self { saved: text, latest: None, cached_exact: RefCell::new(None) } + } + + /// No text is kept. + pub fn is_empty(&self) -> bool { + self.saved.is_none() && self.latest.is_none() + } +} + +/// The kept text that stands for the document as it is, if one does: the latest first. +fn exact_text(s: &Session) -> Option { + if let Some((cached_rev, ref res)) = *s.md_file.cached_exact.borrow() + && cached_rev == s.rev() + { + return res.clone(); + } + let kept = [s.md_file.latest.as_deref(), s.md_file.saved.as_deref()]; + let res = kept.into_iter().flatten().find(|text| parse(&s.doc, text).same_content(&s.doc)).map(str::to_string); + *s.md_file.cached_exact.borrow_mut() = Some((s.rev(), res.clone())); + res +} + +/// What Save writes to the Markdown file `path`, in place of the document written out again: the +/// file's own text, while it stands for the document. Not for a Print Markup copy, which adds +/// the comments. +pub(crate) fn text_to_save(s: &Session, path: &std::path::Path, markup: bool) -> Option { + if markup || !is_markdown_file(path) { + return None; + } + exact_text(s) +} + +/// Command specifications for Markdown Source view toggling and text inspection. +pub fn specs() -> Vec { + vec![ + // Not `pure`: leaving edits the document. Entering changes nothing, so it adds no undo step. + CommandSpec::new(TOGGLE, LABEL, "View › Views", toggle).params(r#"{"value"?: bool}"#).when(can_toggle), + CommandSpec::new(TEXT, "Markdown Source Text", "View › Views", text) + .params(r#"{"text"?: string (up to 2 MB), "caret"?: integer >= 0}"#) + .when(|s| s.md_source.is_none().then_some("only in Markdown Source")), + ] +} + +const TOGGLE: &str = "view.markdownSource"; +const TEXT: &str = "view.markdownSourceText"; +/// The views that replace this one: they show the document, so the text goes into it first. +const LEAVES: [&str; 5] = ["view.printLayout", "view.webLayout", "view.draft", "view.outline", "view.readMode"]; + +/// Whether a file's name says it is Markdown. +pub fn is_markdown_file(path: &std::path::Path) -> bool { + path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown")) +} + +/// Whether the current session's path points to a Markdown file. +fn is_markdown_path(s: &Session) -> bool { + s.path.as_deref().is_some_and(is_markdown_file) +} + +/// Checks whether the Markdown Source view can currently be toggled in `s`. +fn can_toggle(s: &Session) -> Option<&'static str> { + if s.md_source.is_some() { + None + } else if !is_markdown_path(s) { + Some("only for Markdown (.md) files") + } else if s.view.read_mode { + Some("not in Read Mode") + } else if s.doc.settings.track_changes || !s.doc.revisions.is_empty() { + Some("not with tracked changes") + } else if s.doc.settings.protection.is_some() { + Some("not in a protected document") + } else { + None + } +} + +/// Toggles or sets the Markdown Source view state. +fn toggle(s: &mut Session, v: &Value) -> CmdResult { + let on = p::bool(v, "value").unwrap_or(s.md_source.is_none()); + match (on, s.md_source.is_some()) { + (true, false) => enter(s)?, + (false, true) => leave(s), + _ => {} + } + Ok(json!({"value": s.md_source.is_some()})) +} + +/// Gets or sets the Markdown Source text and caret position. +fn text(s: &mut Session, v: &Value) -> CmdResult { + let new_text = p::str(v, "text"); + if new_text.is_some_and(|t| t.len() > MAX_SOURCE_BYTES) { + return Err(CmdError::Params(too_long())); + } + if let Some(cv) = v.get("caret").filter(|c| !c.is_null()) + && cv.as_u64().is_none() + { + return Err(CmdError::Params("`caret` must be a non-negative integer".into())); + } + let Some(src) = s.md_source.as_mut() else { return Err(CmdError::Disabled("only in Markdown Source".into())) }; + let mut edited = false; + if let Some(t) = new_text { + edited = src.text != t; + src.text = t.to_string(); + src.caret = src.text.floor_char_boundary(src.caret.min(src.text.len())); + } + if let Some(caret) = v.get("caret").and_then(Value::as_u64) { + let caret = usize::try_from(caret).unwrap_or(usize::MAX).min(src.text.len()); + src.caret = src.text.floor_char_boundary(caret); + src.place_caret = true; + } + if edited { + src.place_caret = true; + s.dirty = true; + } + let mut result = json!({"caret": src.caret, "losesContent": src.loses_content}); + if new_text.is_none() { + result["text"] = json!(&src.text); + } + Ok(result) +} + +/// Error message returned when Markdown Source text exceeds [`MAX_SOURCE_BYTES`]. +fn too_long() -> String { + format!("Markdown Source takes up to {} MB of text", MAX_SOURCE_BYTES / (1024 * 1024)) +} + +/// Enters Markdown Source view, preparing source text and mapping caret position. +fn enter(s: &mut Session) -> Result<(), CmdError> { + // The file's own text when it still says what the document holds, so nothing is lost. + let (text, loses_content, block_lines) = match exact_text(s) { + Some(text) => { + let (_, lines) = parse_with_lines(&s.doc, &text); + (text, false, lines) + } + None => { + let text = wordcraft_formats::markdown::export(&s.doc); + if text.len() > MAX_SOURCE_BYTES { + return Err(CmdError::Failed(too_long())); + } + let (doc, lines) = parse_with_lines(&s.doc, &text); + let loses_content = !doc.same_content(&s.doc); + (text, loses_content, lines) + } + }; + let block = if s.sel.focus.story == StoryRef::Body { s.sel.focus.path.0.first().copied().unwrap_or(0) as usize } else { 0 }; + let line = block_lines.get(block).copied().unwrap_or(0); + let caret = line_start_byte(&text, line); + let text_arc: Arc = Arc::from(text.as_str()); + let entry_lines = block_lines.clone(); + s.md_source = Some(MdSource { + caret, + place_caret: true, + reset_undo: true, + loses_content, + applied_at_ms: 0.0, + entry_doc: Arc::new(s.doc.clone()), + entry_lines, + entry_text: text_arc.clone(), + applied: text_arc, + text, + has_step: false, + step: None, + block_lines, + }); + Ok(()) +} + +/// Leaves Markdown Source view, applying changes to the document model. +fn leave(s: &mut Session) { + // `Session::run` took a step for this command: the visit's first, or one too many. + // If the toggle was joined to an earlier edit (`join_next_undo`), it created no checkpoint. + let had_step = s.md_source.as_ref().is_some_and(|src| src.has_step); + let step = s.md_source.as_ref().and_then(|src| src.step); + let mark = s.undo_mark(); + let toggle_created_step = step.is_some_and(|step| mark > step); + if toggle_created_step { + s.drop_last_undo_step(); + } + let mark_after = s.undo_mark(); + if let Some(src) = s.md_source.as_mut() { + src.has_step = true; + if src.step.is_none() { + src.step = Some(mark_after); + } + } + apply(s); + if let Some(src) = s.md_source.take() + && (had_step || src.text != *src.entry_text) + { + let block = block_of_caret(&src.text, src.caret, &s.doc, &src.block_lines); + s.sel = Selection::caret(block); + } +} + +/// The text into the document, if it has edits the document lacks. Its first change in a visit +/// takes the visit's undo step. Save, AutoSave and crash recovery see the text this way. +pub fn apply(s: &mut Session) { + let (text, entry_text, entry_doc, has_step, entry_lines) = { + let Some(src) = s.md_source.as_ref().filter(|src| src.is_pending()) else { return }; + if src.text.len() > MAX_SOURCE_BYTES { + return; + } + (src.text.clone(), src.entry_text.clone(), src.entry_doc.clone(), src.has_step, src.entry_lines.clone()) + }; + let (doc, lines) = if text == *entry_text { ((*entry_doc).clone(), entry_lines) } else { parse_with_lines(&s.doc, &text) }; + let step = if !has_step { + s.checkpoint(LABEL); + Some(s.undo_mark()) + } else { + s.md_source.as_ref().and_then(|src| src.step) + }; + s.doc = doc; + s.note_body_replaced(); + s.math = None; + s.touch(); + s.clamp_selection(); + s.md_file.latest = Some(text.clone()); + *s.md_file.cached_exact.borrow_mut() = Some((s.rev(), Some(text.clone()))); + if let Some(src) = s.md_source.as_mut() { + src.applied = Arc::from(text.as_str()); + src.block_lines = lines; + src.has_step = true; + src.step = step; + } +} + +/// `text` as a document and block lines, with what isn't in Markdown from `current`: its page, settings, +/// properties and language, as opening the file gave it. +fn parse_with_lines(current: &Document, text: &str) -> (Document, Vec) { + let (mut doc, lines) = wordcraft_formats::markdown::import_on_with_lines(text, ¤t.last_section); + doc.ensure_nonempty(); + doc.settings = current.settings.clone(); + doc.core = current.core.clone(); + Arc::make_mut(&mut doc.styles).default_chr.lang = current.styles.default_chr.lang.clone(); + (doc, lines) +} + +/// `text` as a document, with what isn't in Markdown from `current`: its page, settings, +/// properties and language, as opening the file gave it. +fn parse(current: &Document, text: &str) -> Document { + parse_with_lines(current, text).0 +} + +/// The byte offset where `line` starts, or the end of a text with fewer lines. +fn line_start_byte(text: &str, line: usize) -> usize { + if line == 0 { + return 0; + } + let mut lines = 0; + for (i, &b) in text.as_bytes().iter().enumerate() { + if b == b'\n' { + lines += 1; + if lines == line { + return i.saturating_add(1); + } + } + } + text.len() +} + +/// The 0-based line that byte offset `byte` belongs to. +fn line_of_byte(text: &str, byte: usize) -> usize { + let slice = text.as_bytes().get(..byte).unwrap_or(text.as_bytes()); + slice.iter().filter(|&&b| b == b'\n').count() +} + +/// Where the caret at byte offset `caret` of `text` lands in `doc`: at the +/// start of the top-level block its line belongs to. +fn block_of_caret(text: &str, caret: usize, doc: &Document, block_lines: &[usize]) -> Pos { + let caret_line = line_of_byte(text, caret); + let top = if block_lines.is_empty() { 0 } else { block_lines.partition_point(|&l| l <= caret_line).saturating_sub(1) }; + let top = top.min(doc.body.len().saturating_sub(1)); + let top = u32::try_from(top).unwrap_or(0); + let path = doc.para_paths(StoryRef::Body).into_iter().find(|path| path.0.first().is_some_and(|b| *b >= top)); + match path { + Some(path) => Pos { story: StoryRef::Body, path, off: 0 }, + None => Pos { story: StoryRef::Body, path: Path::top(0), off: 0 }, + } +} + +/// Why the open text refuses command `id`, if it does. +pub(crate) fn refusal(s: &Session, id: &str, mutates: bool) -> Option<&'static str> { + (s.md_source.is_some() + && id != TOGGLE + && id != TEXT + && !LEAVES.contains(&id) + && (mutates || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo" | "edit.repeat"))) + .then_some("leave Markdown Source first") +} + +/// What a command does while the text is open, before it runs (see the module's header). +pub(crate) fn before_command(s: &mut Session, id: &str, mutates: bool) -> Result<(), CmdError> { + if s.md_source.is_none() || id == TOGGLE || id == TEXT { + return Ok(()); + } + if LEAVES.contains(&id) { + s.run(TOGGLE, &json!({"value": false}))?; + } else if let Some(why) = refusal(s, id, mutates) { + return Err(CmdError::Disabled(format!("{id}: {why}"))); + } else if !id.starts_with("caret.") && !id.starts_with("select.") && !id.starts_with("view.") { + apply(s); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use wordcraft_layout::ViewMode; + + const SAMPLE: &str = "# Title\n\nFirst paragraph.\n\n## Second\n\n- one\n- two\n\nLast **bold** paragraph.\n"; + + /// Creates a test session with a Markdown document and a dummy path. + fn session(md: &str) -> Session { + let mut s = Session::new(crate::io::open_bytes("a.md", md.as_bytes()).unwrap()); + s.path = Some(PathBuf::from("/tmp/wordcraft-md-source-test.md")); + s.dirty = false; + s + } + + /// Runs the Markdown Source text command with `text`. + fn set_text(s: &mut Session, text: &str) { + s.run(TEXT, &json!({"text": text})).unwrap(); + } + + /// Extracts the plain body text of `s`. + fn body_text(s: &Session) -> String { + s.doc.plain_text(StoryRef::Body) + } + + /// Verifies entering and leaving without changes does not mutate document or add undo step. + #[test] + fn a_visit_that_changes_nothing_is_no_edit() { + let mut s = session(SAMPLE); + let before = s.doc.clone(); + let depth = s.undo_depth(); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(s.md_source.is_some()); + assert!(!s.dirty, "entering is not an edit"); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(s.md_source.is_none()); + assert!(s.doc.same_content(&before)); + assert!(!s.dirty, "a visit without edits leaves no unsaved changes"); + assert_eq!(s.undo_depth(), depth, "and no undo step"); + } + + /// Verifies edited source text becomes document upon leaving and one undo reverts the visit. + #[test] + fn an_edited_text_becomes_the_document_and_one_undo_takes_it_back() { + let mut s = session(SAMPLE); + let before = s.doc.clone(); + s.run(TOGGLE, &json!({})).unwrap(); + let text = s.run(TEXT, &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.starts_with("# Title"), "{text}"); + set_text(&mut s, &text.replace("First paragraph.", "Changed paragraph.")); + assert!(s.dirty, "the edit is an unsaved change"); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(body_text(&s).contains("Changed paragraph.")); + s.run("edit.undo", &json!({})).unwrap(); + assert!(s.doc.same_content(&before), "one undo restores the document"); + assert!(s.can_redo()); + } + + static TEST_DIR_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + /// Generates a unique temporary directory for test file isolation. + fn unique_temp_dir(tag: &str) -> PathBuf { + let n = TEST_DIR_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); + std::env::temp_dir().join(format!("wordcraft-md-{tag}-{}-{n}-{nanos}", std::process::id())) + } + + /// Verifies saving within source view writes text to disk while keeping view open and preserving undo. + #[test] + fn save_writes_the_text_and_the_visit_stays_one_undo_step() { + let dir = unique_temp_dir("source-save"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("saved.md"); + let mut s = session(SAMPLE); + s.path = Some(path.clone()); + let before = s.doc.clone(); + let depth = s.undo_depth(); + s.run(TOGGLE, &json!({})).unwrap(); + set_text(&mut s, "# Saved one\n"); + s.run("file.save", &json!({})).unwrap(); + assert!(std::fs::read_to_string(&path).unwrap().contains("Saved one")); + assert!(!s.dirty); + assert!(s.md_source.is_some(), "saving keeps the view open"); + set_text(&mut s, "# Saved two\n"); + s.run("file.save", &json!({})).unwrap(); + assert!(std::fs::read_to_string(&path).unwrap().contains("Saved two")); + set_text(&mut s, "# Left three\n"); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(body_text(&s).contains("Left three")); + assert_eq!(s.undo_depth(), depth + 1, "one step for the whole visit"); + s.run("edit.undo", &json!({})).unwrap(); + assert!(s.doc.same_content(&before)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Verifies returning to the original entry text restores formatting that Markdown cannot represent and maps the caret using entry block lines. + #[test] + fn going_back_to_the_entry_text_restores_what_markdown_cannot_hold() { + let mut s = session(SAMPLE); + std::sync::Arc::make_mut(&mut s.doc.styles).get_mut("Normal").unwrap().chr.bold = Some(true); + let before = s.doc.clone(); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(s.md_source.as_ref().unwrap().loses_content, "Markdown doesn't keep a changed style"); + let text = s.md_source.as_ref().unwrap().text.clone(); + set_text(&mut s, "# Other\n"); + apply(&mut s); + assert!(!s.doc.same_content(&before)); + set_text(&mut s, &text); + let last_off = text.find("Last ").unwrap_or(0); + s.run(TEXT, &json!({"caret": last_off})).unwrap(); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(s.doc.same_content(&before)); + assert_eq!(s.sel.focus.path.0.first().copied(), Some(5)); + } + + /// Verifies that leaving Markdown Source with `join_next_undo` after an applied edit keeps the visit's undo checkpoint. + #[test] + fn leaving_joined_after_an_applied_edit_keeps_the_visits_undo_step() { + let mut s = session(SAMPLE); + let before = body_text(&s); + s.run(TOGGLE, &json!({})).unwrap(); + set_text(&mut s, "# Intermediate\n"); + apply(&mut s); + assert_eq!(s.undo_depth(), 1); + set_text(&mut s, "# Final\n"); + s.join_next_undo(); + s.run(TOGGLE, &json!({"value": false})).unwrap(); + assert!(s.md_source.is_none()); + assert!(body_text(&s).contains("Final")); + assert_eq!(s.undo_depth(), 1); + assert!(s.can_undo()); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(body_text(&s), before); + } + + /// Verifies that opening an unedited Markdown file reports no content loss on entry. + #[test] + fn markdown_the_document_came_from_is_not_reported_as_lost() { + let s = &mut session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(!s.md_source.as_ref().unwrap().loses_content); + } + + /// Verifies standard document editing commands are disabled while Markdown Source view is open. + #[test] + fn edits_are_refused_while_the_text_is_open_and_nothing_is_lost() { + let mut s = session(SAMPLE); + let before = s.doc.clone(); + s.run(TOGGLE, &json!({})).unwrap(); + set_text(&mut s, "# Kept\n"); + for id in ["format.bold", "edit.undo", "edit.redo"] { + assert!(matches!(s.run(id, &json!({})), Err(CmdError::Disabled(_))), "{id}"); + } + assert!(matches!(s.run("text.insert", &json!({"text": "x"})), Err(CmdError::Disabled(_)))); + assert!(s.doc.same_content(&before)); + assert_eq!(s.md_source.as_ref().unwrap().text, "# Kept\n"); + } + + /// Verifies switching view mode leaves Markdown Source view and applies edits. + #[test] + fn changing_the_view_leaves_with_the_edits() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + set_text(&mut s, "# Applied\n"); + s.run("view.webLayout", &json!({})).unwrap(); + assert!(s.md_source.is_none()); + assert!(body_text(&s).contains("Applied")); + assert_eq!(s.view.mode, ViewMode::Web); + } + + /// Verifies only unprotected Markdown files without tracked changes can enter Markdown Source. + #[test] + fn only_markdown_files_without_tracking_or_protection_enter() { + let mut s = Session::new(Document::from_text("Text")); + assert!(s.run(TOGGLE, &json!({})).is_err(), "no file"); + s.path = Some(PathBuf::from("/tmp/a.docx")); + assert!(s.run(TOGGLE, &json!({})).is_err(), "not Markdown"); + s.path = Some(PathBuf::from("/tmp/A.MARKDOWN")); + s.doc.settings.track_changes = true; + assert!(s.run(TOGGLE, &json!({})).is_err(), "tracking"); + s.doc.settings.track_changes = false; + s.doc.settings.protection = Some("readOnly".into()); + assert!(s.run(TOGGLE, &json!({})).is_err(), "protected"); + s.doc.settings.protection = None; + s.view.read_mode = true; + assert!(s.run(TOGGLE, &json!({})).is_err(), "Read Mode"); + s.view.read_mode = false; + s.run(TOGGLE, &json!({})).unwrap(); + } + + /// Verifies source text exceeding limit is refused with CmdError::Failed. + #[test] + fn a_text_too_long_is_refused() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + let long = "x".repeat(MAX_SOURCE_BYTES + 1); + assert!(matches!(s.run(TEXT, &json!({"text": long})), Err(CmdError::Params(_)))); + s.run(TOGGLE, &json!({})).unwrap(); + s.set_document(Document::from_text(&"x".repeat(MAX_SOURCE_BYTES + 1))); + s.path = Some(PathBuf::from("/tmp/a.md")); + assert!(matches!(s.run(TOGGLE, &json!({})), Err(CmdError::Failed(_)))); + assert!(s.md_source.is_none()); + } + + /// Verifies caret stays synchronized with its paragraph when entering and leaving source view. + #[test] + fn the_caret_stays_with_its_paragraph_both_ways() { + let mut s = session(SAMPLE); + let target = s + .doc + .para_paths(StoryRef::Body) + .into_iter() + .find(|path| s.doc.para(StoryRef::Body, path).is_some_and(|para| para.plain_text().contains("Last"))) + .unwrap(); + s.sel = Selection::caret(Pos { story: StoryRef::Body, path: target.clone(), off: 2 }); + s.run(TOGGLE, &json!({})).unwrap(); + let src = s.md_source.as_ref().unwrap(); + let line: String = src.text[src.caret..].chars().take_while(|c| *c != '\n').collect(); + assert!(line.contains("Last"), "caret on {line:?} in {:?}", src.text); + let changed = src.text.replace("First paragraph.", "First changed paragraph."); + let caret = changed.find("Last").unwrap(); + s.run(TEXT, &json!({"text": changed, "caret": caret})).unwrap(); + s.run(TOGGLE, &json!({})).unwrap(); + let para = s.doc.para(StoryRef::Body, &s.sel.focus.path).unwrap(); + assert!(para.plain_text().contains("Last"), "{:?}", para.plain_text()); + } + + /// Verifies that the caret in the trailing paragraph appended after a final table maps to the end of the text. + #[test] + fn caret_in_appended_paragraph_after_final_table_stays_at_end() { + let md = "| Col1 | Col2 |\n|---|---|\n| Val1 | Val2 |\n"; + let mut s = session(md); + let trailing_para = s + .doc + .para_paths(StoryRef::Body) + .into_iter() + .find(|path| path.0.first() == Some(&1)) + .expect("document ending in a table must have an appended paragraph"); + s.sel = Selection::caret(Pos { story: StoryRef::Body, path: trailing_para, off: 0 }); + + s.run(TOGGLE, &json!({})).unwrap(); + let src = s.md_source.as_ref().unwrap(); + assert_eq!(src.caret, src.text.len(), "caret must land after the table at the end of the text"); + + s.run(TOGGLE, &json!({})).unwrap(); + assert_eq!(s.sel.focus.path.0.first(), Some(&1), "caret must stay in the appended paragraph after table"); + } + + /// Verifies hostile carets and texts do not panic. + #[test] + fn hostile_carets_and_texts_do_not_panic() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + for (text, caret) in [("", u64::MAX), ("é\n\n\n", 2), ("```\nopen fence", 9), ("|\n|-", 1), ("> > > >\n- - - -", 3)] { + s.run(TEXT, &json!({"text": text, "caret": caret})).unwrap(); + s.run(TOGGLE, &json!({})).unwrap(); + s.clamp_selection(); + assert!(s.doc.para(s.sel.focus.story, &s.sel.focus.path).is_some(), "{text:?}"); + s.run(TOGGLE, &json!({})).unwrap(); + } + } + + /// Verifies a parked document preserves its Markdown Source text. + #[test] + fn a_parked_document_keeps_its_text() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + set_text(&mut s, "# Mine\n"); + let first = s.document_id(); + let _second = s.add_opened_document(Document::from_text("Other")); + assert!(s.md_source.is_none(), "the other document has no text open"); + #[cfg(not(target_arch = "wasm32"))] + { + let first_rec = s.recovery_docs().find(|r| r.id == first).unwrap(); + assert!(first_rec.doc.plain_text(StoryRef::Body).contains("Mine"), "pending text was applied before parking"); + } + s.activate(first).unwrap(); + assert_eq!(s.md_source.as_ref().unwrap().text, "# Mine\n"); + } + + /// What the document can't hold, all of which a save without formatted edits keeps. + const OWN: &str = "---\ntitle: Notes\n---\n\nTitle\n=====\n\n\n\n* star bullet\n* another\n\n- [ ] a task\n- [x] done\n\n```bash\necho hi\n```\n\nSome *emphasis* and a [link](https://example.com \"its title\").\nA wrapped\nline.\n\n![a picture](pic.png)\n\nLast paragraph.\n"; + + /// A scratch folder holding `name` with `text`, opened in a new session. + fn open_file(dir: &str, name: &str, text: &[u8]) -> (Session, PathBuf) { + let dir = unique_temp_dir(&format!("own-{dir}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, text).unwrap(); + let mut s = Session::new(Document::from_text("")); + s.run("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + (s, path) + } + + /// Saves the session document via `file.save`. + fn save(s: &mut Session) -> Value { + s.run("file.save", &json!({})).unwrap() + } + + /// A scratch folder holding `name`, opened from the bytes `file.open` is handed (`data`, as a + /// platform hands a file over). The path holds other bytes, so a save that left them alone + /// would show. + fn open_handed_over(dir: &str, name: &str, text: &[u8]) -> (Session, PathBuf) { + let dir = unique_temp_dir(&format!("handed-{dir}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, b"not the opened text").unwrap(); + let mut s = Session::new(Document::from_text("")); + let data = crate::cmd::insert::base64_encode(text); + s.run("file.open", &json!({"path": path.to_string_lossy(), "data": data})).unwrap(); + (s, path) + } + + /// Verifies a Markdown file opened from handed-over bytes keeps its own text and saves it back + /// byte-for-byte, as one opened from its path does. + #[test] + fn a_file_opened_from_handed_over_bytes_keeps_its_own_text() { + for (dir, text) in [("plain", OWN.to_string()), ("crlf", OWN.replace('\n', "\r\n"))] { + let (mut s, path) = open_handed_over(dir, "own.md", text.as_bytes()); + assert!(!s.md_file.is_empty(), "{dir}: the opened text must be kept"); + assert_eq!(save(&mut s)["rewritten"], false, "{dir}"); + assert_eq!(std::fs::read(&path).unwrap(), text.as_bytes(), "{dir}"); + done(&path); + } + } + + /// Removes the temporary test folder created for `path`. + fn done(path: &std::path::Path) { + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + /// Verifies unedited Markdown files are written back byte-for-byte upon save. + #[test] + fn a_file_saved_without_edits_is_written_back_byte_for_byte() { + for (dir, text) in [("plain", OWN.to_string()), ("crlf", OWN.replace('\n', "\r\n")), ("bom", format!("\u{FEFF}{OWN}"))] { + let (mut s, path) = open_file(dir, "own.md", text.as_bytes()); + assert_eq!(save(&mut s)["rewritten"], false, "{dir}"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), text, "{dir}"); + done(&path); + } + } + + /// Verifies BOM-prefixed Markdown files retain original bytes when saved without edits. + #[test] + fn a_bom_prefixed_file_saved_without_edits_retains_its_original_bytes() { + let bom = [b"\xEF\xBB\xBF".as_slice(), OWN.as_bytes()].concat(); + let (mut s, path) = open_file("bom-exact", "own.md", &bom); + assert!(!s.md_file.is_empty(), "BOM file text must be kept"); + assert_eq!(save(&mut s)["rewritten"], false); + let read = std::fs::read(&path).unwrap(); + assert_eq!(read, bom, "Saved bytes must match original BOM bytes byte-for-byte"); + done(&path); + } + + /// Verifies Markdown Source view displays the file's own unedited text. + #[test] + fn the_view_shows_the_files_own_text() { + let (mut s, path) = open_file("view", "own.md", OWN.as_bytes()); + s.run(TOGGLE, &json!({})).unwrap(); + let src = s.md_source.as_ref().unwrap(); + assert_eq!(src.text, OWN); + assert!(!src.loses_content); + done(&path); + } + + /// Verifies edits made directly in Markdown Source view are saved as typed. + #[test] + fn edits_in_the_view_are_saved_as_typed() { + let (mut s, path) = open_file("typed", "own.md", OWN.as_bytes()); + s.run(TOGGLE, &json!({})).unwrap(); + let typed = OWN.replace("Last paragraph.", "\n\n* Last *changed* paragraph."); + set_text(&mut s, &typed); + s.run(TOGGLE, &json!({})).unwrap(); + assert_eq!(save(&mut s)["rewritten"], false); + assert_eq!(std::fs::read_to_string(&path).unwrap(), typed); + // Saved while the view is open: the same. + s.run(TOGGLE, &json!({})).unwrap(); + let again = typed.replace("another", "a third"); + set_text(&mut s, &again); + assert_eq!(save(&mut s)["rewritten"], false); + assert_eq!(std::fs::read_to_string(&path).unwrap(), again); + done(&path); + } + + /// Verifies formatted edits force re-exporting the document on save with a warning. + #[test] + fn an_edit_in_the_formatted_view_writes_the_document_out_again_once_with_a_warning() { + let (mut s, path) = open_file("formatted", "own.md", OWN.as_bytes()); + s.run("text.insert", &json!({"text": "New "})).unwrap(); + assert_eq!(save(&mut s)["rewritten"], true); + let written = std::fs::read_to_string(&path).unwrap(); + assert!(written.contains("New "), "{written}"); + assert!(!written.contains(""), "written out again: {written}"); + s.run("text.insert", &json!({"text": "Again "})).unwrap(); + assert_eq!(save(&mut s)["rewritten"], false, "warned once: the file holds the document's own text now"); + done(&path); + } + + /// Verifies undoing back to original kept text restores original formatting. + #[test] + fn undoing_back_to_a_kept_text_finds_it_again() { + // A formatted edit undone. + let (mut s, path) = open_file("undo", "own.md", OWN.as_bytes()); + s.run("text.insert", &json!({"text": "New "})).unwrap(); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(save(&mut s)["rewritten"], false); + assert_eq!(std::fs::read_to_string(&path).unwrap(), OWN); + + // A visit to the view undone: back to the opened text, then redone: the typed one. + s.run(TOGGLE, &json!({})).unwrap(); + let typed = OWN.replace("Last paragraph.", "Typed paragraph."); + set_text(&mut s, &typed); + s.run(TOGGLE, &json!({})).unwrap(); + s.run("edit.undo", &json!({})).unwrap(); + save(&mut s); + assert_eq!(std::fs::read_to_string(&path).unwrap(), OWN); + s.run("edit.redo", &json!({})).unwrap(); + assert_eq!(save(&mut s)["rewritten"], true, "the typed text was given up for the saved one"); + assert!(std::fs::read_to_string(&path).unwrap().contains("Typed paragraph.")); + done(&path); + } + + /// Verifies files that cannot go back as they were are written out without rewriting warnings. + #[test] + fn files_that_cannot_go_back_as_they_were_are_written_out_without_a_warning() { + let latin1 = b"Caf\xE9 au lait.\n".to_vec(); + for (dir, bytes) in [("latin1", latin1), ("empty", Vec::new())] { + let (mut s, path) = open_file(dir, "own.md", &bytes); + assert!(s.md_file.is_empty(), "{dir}"); + s.run("text.insert", &json!({"text": "New "})).unwrap(); + assert_eq!(save(&mut s)["rewritten"], false, "{dir}"); + done(&path); + } + // A Word document saved as Markdown is a copy written out as always. + let mut s = Session::new(Document::from_text("Hello")); + let dir = unique_temp_dir("own-docx"); + std::fs::create_dir_all(&dir).unwrap(); + s.path = Some(dir.join("a.docx")); + let r = s.run("file.saveAs", &json!({"path": dir.join("a.md").to_string_lossy()})).unwrap(); + assert_eq!(r["rewritten"], false); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Verifies markdown source text command sets place_caret flag on explicit carets and edits. + #[test] + fn markdown_source_text_command_sets_place_caret() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(s.md_source.as_ref().unwrap().place_caret); + s.md_source.as_mut().unwrap().place_caret = false; + s.run(TEXT, &json!({"caret": 3})).unwrap(); + assert!(s.md_source.as_ref().unwrap().place_caret); + s.md_source.as_mut().unwrap().place_caret = false; + s.run(TEXT, &json!({"text": "# Changed\n"})).unwrap(); + assert!(s.md_source.as_ref().unwrap().place_caret); + } + + /// Verifies caret finds its matching line in file's own text. + #[test] + fn the_caret_finds_its_line_in_the_files_own_text() { + let (mut s, path) = open_file("caret", "own.md", OWN.as_bytes()); + let target = s + .doc + .para_paths(StoryRef::Body) + .into_iter() + .find(|path| s.doc.para(StoryRef::Body, path).is_some_and(|para| para.plain_text().contains("Last"))) + .unwrap(); + s.sel = Selection::caret(Pos { story: StoryRef::Body, path: target, off: 0 }); + s.run(TOGGLE, &json!({})).unwrap(); + let src = s.md_source.as_ref().unwrap(); + let line: String = src.text[src.caret..].chars().take_while(|c| *c != '\n').collect(); + assert_eq!(line, "Last paragraph."); + done(&path); + } + + /// Verifies opening another document over session closes Markdown Source view. + #[test] + fn opening_over_the_document_closes_the_view() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + s.set_document(Document::from_text("New")); + assert!(s.md_source.is_none()); + } + + /// Verifies invalid and negative carets are refused with CmdError::Params. + #[test] + fn invalid_and_negative_carets_are_refused() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + for bad in [json!({"caret": -1}), json!({"caret": -100}), json!({"caret": 2.5}), json!({"caret": "three"})] { + assert!(matches!(s.run(TEXT, &bad), Err(CmdError::Params(_))), "{bad:?}"); + } + } + + /// Verifies markdown source text command is marked as mutating. + #[test] + fn markdown_source_text_is_not_marked_pure() { + let s = session(SAMPLE); + let spec = s.registry.get(TEXT).unwrap(); + assert!(spec.mutates, "view.markdownSourceText mutates session and dirty state"); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 6f196c7e4f..b758ec3e77 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -851,11 +851,25 @@ pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<& safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display())) } +/// Saves a document to `path` with optional password and export options on web targets. #[cfg(target_arch = "wasm32")] pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>, _opts: &ExportOptions) -> Result<(), String> { Err(format!("{}: files are saved through the browser on the web", path.display())) } +/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`]. +#[cfg(not(target_arch = "wasm32"))] +pub fn save_text_path(path: &std::path::Path, text: &str) -> Result<(), String> { + crate::file_access::check_write(&path.to_string_lossy())?; + safe_save::write(path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display())) +} + +/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`]. +#[cfg(target_arch = "wasm32")] +pub fn save_text_path(path: &std::path::Path, _text: &str) -> Result<(), String> { + Err(format!("{}: files are saved through the browser on the web", path.display())) +} + /// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else the /// legacy encoding they look like — GBK, Big5, Shift_JIS, Windows-1252… (see /// [`wordcraft_formats::txt::decode`]). diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index f1a839006f..84efd4b5d6 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -164,6 +164,10 @@ pub struct Session { typing: Option, /// Picture edits: edited media key → original media key (Reset Picture). pub originals: std::collections::HashMap, + /// View › Markdown Source: its text, while it is open. + pub md_source: Option, + /// A Markdown file's own text, so Save can write it unchanged. + pub md_file: crate::cmd::md_source::FileText, /// Picture effects, styles and borders: drawn media key → what was drawn on which bitmap, /// so the next one replaces them instead of drawing over them (#2052). pub picture_looks: std::collections::HashMap, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index be763af1fd..83344462dc 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -70,6 +70,8 @@ pub struct DocState { pub(super) layout: LayoutSlots, pub(super) typing: Option, pub(super) originals: std::collections::HashMap, + pub(super) md_source: Option, + pub(super) md_file: crate::cmd::md_source::FileText, pub(super) picture_looks: std::collections::HashMap, pub(super) versions: Vec<(String, String, Document)>, pub(super) autosave: bool, @@ -124,6 +126,8 @@ impl DocState { layout: Default::default(), typing: None, originals: Default::default(), + md_source: None, + md_file: Default::default(), picture_looks: Default::default(), versions: Vec::new(), autosave: true, @@ -187,6 +191,7 @@ fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitle impl Session { /// Swap the active document's state with `d`'s: `d` then holds the document that was active. fn swap_doc(&mut self, d: &mut DocState) { + crate::cmd::md_source::apply(self); use std::mem::swap; // Every field is listed, so a new one doesn't build until someone decides whether each // open document has its own (then it goes in `DocState` and is swapped below) or the @@ -224,6 +229,8 @@ impl Session { layout: _, typing: _, originals: _, + md_source: _, + md_file: _, picture_looks: _, versions: _, autosave: _, @@ -318,6 +325,8 @@ impl Session { layout, typing, originals, + md_source, + md_file, picture_looks, versions, autosave, @@ -365,6 +374,8 @@ impl Session { swap(&mut self.layout, layout); swap(&mut self.typing, typing); swap(&mut self.originals, originals); + swap(&mut self.md_source, md_source); + swap(&mut self.md_file, md_file); swap(&mut self.picture_looks, picture_looks); swap(&mut self.versions, versions); swap(&mut self.autosave, autosave); @@ -389,6 +400,7 @@ impl Session { if id == self.document_id { return Ok(()); } + crate::cmd::md_source::apply(self); let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?; let mut d = self.others.swap_remove(at); self.swap_doc(&mut d); @@ -469,7 +481,10 @@ impl Session { self.add(doc, 0) } + /// Adds `doc` to the session and makes it active, parking the previously active document + /// and applying any pending Markdown Source text first. fn add(&mut self, mut doc: Document, untitled: u32) -> u64 { + crate::cmd::md_source::apply(self); doc.ensure_nonempty(); let mut view = self.view.clone(); view.outline.collapsed.clear(); diff --git a/crates/engine/src/session/history.rs b/crates/engine/src/session/history.rs index eddc81151f..be15c2cd77 100644 --- a/crates/engine/src/session/history.rs +++ b/crates/engine/src/session/history.rs @@ -26,6 +26,11 @@ impl Session { true } + /// Take back the newest undo step: a command that joins an earlier step of its own. + pub(crate) fn drop_last_undo_step(&mut self) { + self.history.pop(); + } + /// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so /// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change. pub fn push_undo(&mut self, label: &str, doc: Document, sel: Selection) { diff --git a/crates/engine/src/session/lifecycle.rs b/crates/engine/src/session/lifecycle.rs index d6a03e2490..c6ce42ad0d 100644 --- a/crates/engine/src/session/lifecycle.rs +++ b/crates/engine/src/session/lifecycle.rs @@ -68,6 +68,8 @@ impl Session { layout, typing, originals, + md_source, + md_file, picture_looks, versions, autosave, @@ -136,6 +138,8 @@ impl Session { layout, typing, originals, + md_source, + md_file, picture_looks, last_command: None, recording: None, diff --git a/crates/engine/src/session/run.rs b/crates/engine/src/session/run.rs index 3339e181c5..2d5a08a8de 100644 --- a/crates/engine/src/session/run.rs +++ b/crates/engine/src/session/run.rs @@ -46,13 +46,28 @@ impl Session { /// A command that changes nothing is not an edit: it adds no undo step, marks no /// unsaved changes, forces no relayout and leaves the redo stack alone (#23). fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option, pending: &Option) -> bool { - doc.same_content(&self.doc) && *sel == self.sel && *column == self.column && *pending == self.pending + doc.same_content(&self.doc) + && *sel == self.sel + && *column == self.column + && *pending == self.pending + && !self.md_source.as_ref().is_some_and(|src| src.is_pending()) } /// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and /// control channel). Mutating commands are undoable; a failed command leaves the document as /// it was; a panic inside a command becomes an error. pub fn run(&mut self, id: &str, params: &Value) -> Result { + if self.md_source.is_some() + && let Some(mutates) = self.registry.get(id).map(|spec| spec.mutates) + { + crate::cmd::md_source::before_command(self, id, mutates)?; + } + self.run_command(id, params) + } + + /// Dispatches `id` through the command registry with transactional rollback on failure, + /// tracking history, undo checkpoints, and IME outcomes. + fn run_command(&mut self, id: &str, params: &Value) -> Result { // The document the command starts in, and the first id a document it opens can have. let home = self.document_id; let mark = document_id_mark(); @@ -160,7 +175,7 @@ impl Session { // Whether this command pushed an undo step (a no-op command's step is dropped below, // leaving its undo group without one). let mut pushed = false; - if spec.mutates { + if spec.mutates && spec.id != "view.markdownSourceText" { let label = if typing { "Typing" } else { spec.label }; // A joined command merges into its group's step; a group whose first command // changed nothing has none, so the next command in it starts one (#4001). diff --git a/crates/engine/src/session/snapshot.rs b/crates/engine/src/session/snapshot.rs index 7d9c7c056d..54e8af6b94 100644 --- a/crates/engine/src/session/snapshot.rs +++ b/crates/engine/src/session/snapshot.rs @@ -7,6 +7,8 @@ impl Session { pub fn set_document(&mut self, doc: Document) { self.autocap = None; let replaced = self.document_id; + self.md_source = None; + self.md_file = Default::default(); self.document_id = next_document_id(); if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) { *at = self.document_id; @@ -110,6 +112,8 @@ impl Session { // The word being typed, like `column`: valid only while `sel` is where typing left it. typing: _, originals: _, + md_source: _, + md_file: _, picture_looks: _, last_command: _, recording: _, @@ -253,7 +257,9 @@ impl Session { /// Restrict Editing refuses outright. `None` when it can run (or doesn't exist). pub fn why_disabled(&self, id: &str) -> Option<&'static str> { let spec = self.registry.get(id)?; - (spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None }) + (spec.enabled)(self) + .or_else(|| crate::cmd::md_source::refusal(self, id, spec.mutates)) + .or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None }) } /// Put the revision back to `rev` after a command that changed nothing or failed. What the diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6439b3de9e..22ea3b72e7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5623,16 +5623,66 @@ fn saving_a_text_file_over_itself_saves_the_document() { assert!(!s.dirty, "{name}: saved by its own path"); // Plain text somewhere else is still a copy: the document keeps its file and its changes. + // (Markdown saved as Markdown moves there: see the test below.) run(&mut s, "text.insert", json!({"text": "!"})); let copy = dir.join(format!("copy-{name}")); run(&mut s, "file.saveAs", json!({"path": copy.to_string_lossy()})); assert!(copy.exists()); - assert!(s.dirty, "{name}: a copy elsewhere leaves it unsaved"); - assert_eq!(s.path.as_deref(), Some(path.as_path())); + if name == "note.txt" { + assert!(s.dirty, "{name}: a copy elsewhere leaves it unsaved"); + assert_eq!(s.path.as_deref(), Some(path.as_path())); + } } let _ = std::fs::remove_dir_all(&dir); } +/// A Markdown file saved as another Markdown file is that file from now on: the next save goes +/// there and the first file stays as it was. Other formats keep the #2376 rule. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn markdown_saved_as_markdown_moves_to_the_new_file() { + let dir = std::env::temp_dir().join(format!("wordcraft-engine-md-save-as-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let first = dir.join("a.md"); + std::fs::write(&first, b"First\n").unwrap(); + for name in ["b.md", "c.markdown", "D.MD"] { + let mut s = s(); + std::fs::write(&first, b"First\n").unwrap(); + let second = dir.join(name); + run(&mut s, "file.open", json!({"path": first.to_string_lossy()})); + run(&mut s, "text.insert", json!({"text": "Hello "})); + run(&mut s, "file.saveAs", json!({"path": second.to_string_lossy()})); + assert!(!s.dirty, "{name}: saved"); + assert_eq!(s.path.as_deref(), Some(second.as_path()), "{name}"); + + run(&mut s, "text.insert", json!({"text": "Later "})); + run(&mut s, "file.save", json!({})); + assert!(!s.dirty, "{name}: saved again"); + assert!(std::fs::read_to_string(&second).unwrap().contains("Later"), "{name}: the next save goes to the new file"); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "First\n", "{name}: the first file is left alone"); + } + + // Markdown to plain text is still a copy. + let mut s = s(); + run(&mut s, "file.open", json!({"path": first.to_string_lossy()})); + run(&mut s, "text.insert", json!({"text": "Hello "})); + run(&mut s, "file.saveAs", json!({"path": dir.join("copy.txt").to_string_lossy()})); + assert!(s.dirty, "a text copy leaves it unsaved"); + assert_eq!(s.path.as_deref(), Some(first.as_path())); + + // So is plain text to Markdown. + let note = dir.join("note.txt"); + std::fs::write(¬e, b"Note\n").unwrap(); + let mut text = self::s(); + run(&mut text, "file.open", json!({"path": note.to_string_lossy()})); + run(&mut text, "text.insert", json!({"text": "Hello "})); + run(&mut text, "file.saveAs", json!({"path": dir.join("copy.md").to_string_lossy()})); + assert!(text.dirty, "a Markdown copy of a text file leaves it unsaved"); + assert_eq!(text.path.as_deref(), Some(note.as_path())); + let _ = std::fs::remove_dir_all(&dir); +} + /// A save that fails to write (no permission, disk full, a bad path) leaves the document's own /// save stamps untouched: `stamp_save` runs before the fallible write, so a prior version of this /// left `modified`/`created`/`last_modified_by`/`revision`/`zoom` changed for a save that never diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 046935599e..5f9859074c 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -27,7 +27,7 @@ mod inlines; /// header made 2 million cells and took 3 to 5 GB to convert; past this, rows are left out. pub(crate) const MAX_TABLE_CELLS: usize = 1 << 18; -pub use blocks::{Refs, import, import_on, parse}; +pub use blocks::{Refs, import, import_on, import_on_with_lines, parse, parse_with_lines}; pub(crate) use export::escape; pub use export::export; #[cfg(test)] @@ -35,6 +35,6 @@ use export::{escape_line_start, inlines_md, table_md, write_blocks}; pub use inlines::parse_inlines; #[cfg(test)] -use blocks::split_row; +use blocks::{remap_original_lines, split_row}; #[cfg(test)] mod tests; diff --git a/crates/formats/src/markdown/blocks.rs b/crates/formats/src/markdown/blocks.rs index ab6294b109..0aeb919dd8 100644 --- a/crates/formats/src/markdown/blocks.rs +++ b/crates/formats/src/markdown/blocks.rs @@ -101,12 +101,18 @@ fn link_definition(line: &str) -> Option<(String, String)> { /// definition. A definition anywhere else stays in the document and reads as text, which is what /// it did before definitions were understood at all; deleting a line that was part of a /// paragraph would lose it silently. -fn take_definitions(lines: &mut Vec) -> Refs { +/// +/// Returns the parsed references and the 0-based indexes of the removed lines. +fn take_definitions(lines: &mut Vec) -> (Refs, Vec) { let mut refs = Refs::new(); let mut open: Option<(char, usize)> = None; // The start of the document begins a block. let mut starts_block = true; + let mut removed = Vec::new(); + let mut at = 0usize; lines.retain(|line| { + let this = at; + at += 1; let t = line.trim_start_matches([' ', '\t']); // Four spaces of indentation is code, not a fence (`parse_blocks`), on both sides. let marker = (indent(line) < 4).then(|| fence(t)).flatten(); @@ -130,26 +136,97 @@ fn take_definitions(lines: &mut Vec) -> Refs { // The first definition of a label wins, as CommonMark says, and definitions may // follow one another. refs.entry(label).or_insert(dest.into()); + removed.push(this); return false; } starts_block = is_blank(line) || atx_heading(t).is_some() || thematic_break(t); true }); - refs + (refs, removed) +} + +/// Maps monotonically non-decreasing line indexes in lines filtered of link definitions back to +/// their original source lines in a single linear pass. +pub(super) fn remap_original_lines(block_lines: &mut [usize], removed: &[usize]) { + let mut rem_iter = removed.iter().copied().peekable(); + let mut shift = 0; + for l in block_lines.iter_mut() { + while let Some(&r) = rem_iter.peek() { + if r <= *l + shift { + shift += 1; + rem_iter.next(); + } else { + break; + } + } + *l += shift; + } +} + +/// Context for block parsing: depth, nesting, reference definitions, cell budget, and line recording. +struct BlockCtx<'a> { + depth: usize, + level: u8, + refs: &'a Refs, + budget: &'a std::cell::Cell, + lines_out: Option<&'a mut Vec>, +} + +impl BlockCtx<'_> { + /// Records the starting line of a top-level block if line tracking is active. + fn record_line(&mut self, line: usize) { + if self.depth == 0 + && let Some(lo) = self.lines_out.as_mut() + { + lo.push(line); + } + } + + /// Records `count` blocks originating at `line` if line tracking is active. + fn record_lines(&mut self, line: usize, count: usize) { + if self.depth == 0 + && let Some(lo) = self.lines_out.as_mut() + { + for _ in 0..count { + lo.push(line); + } + } + } } /// Parse Markdown text into the flow model. pub fn parse(text: &str) -> model::Flow { + parse_with_lines(text).0 +} + +/// [`parse`] returning the flow model and the 0-based starting line of each top-level block. +pub fn parse_with_lines(text: &str) -> (model::Flow, Vec) { let text = text.strip_prefix('\u{FEFF}').unwrap_or(text); let mut lines: Vec = text.split('\n').map(|l| expand_tabs(l.trim_end_matches('\r'))).collect(); - let refs = take_definitions(&mut lines); + let (refs, removed) = take_definitions(&mut lines); let budget = std::cell::Cell::new(super::MAX_TABLE_CELLS); - let mut blocks = parse_blocks(&lines, 0, 0, &refs, &budget); + let mut block_lines = Vec::new(); + let mut ctx = BlockCtx { depth: 0, level: 0, refs: &refs, budget: &budget, lines_out: Some(&mut block_lines) }; + let mut blocks = parse_blocks(&lines, &mut ctx); // A leading "Title" from front matter isn't supported; a first-level heading stays a heading. if blocks.is_empty() { blocks.push(FBlock::Para(Para::default())); + block_lines.push(0); } - model::Flow { blocks, meta: Default::default() } + remap_original_lines(&mut block_lines, &removed); + (model::Flow { blocks, meta: Default::default() }, block_lines) +} + +/// [`import_on`] returning the document and the 0-based starting line of each top-level block. +pub fn import_on_with_lines(text: &str, page: &wordcraft_doc::SectionProps) -> (Document, Vec) { + model::begin_import(); + let (flow, mut lines) = parse_with_lines(text); + let doc = model::to_doc_in(&flow, page.clone()); + if doc.body.len() > lines.len() { + let end_line = text.as_bytes().iter().filter(|&&b| b == b'\n').count(); + lines.resize(doc.body.len(), end_line); + } + (doc, lines) } /// Leading tabs → spaces (tab stops of 4); the rest of the line is kept. @@ -369,18 +446,22 @@ fn interrupts(l: &str) -> bool { || list_marker(t).is_some_and(|m| (!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or(""))) } -fn flush_para(lines: &mut Vec, out: &mut Vec, depth: usize, refs: &Refs) { +/// Flushes accumulated paragraph text into `out`, recording its starting line if tracked. +fn flush_para(lines: &mut Vec, start_line: &mut Option, out: &mut Vec, ctx: &mut BlockCtx<'_>) { if lines.is_empty() { + *start_line = None; return; } + let line_no = start_line.take().unwrap_or(0); let text = join_lines(lines); lines.clear(); - let inlines = parse_inlines(&text, &Fmt::default(), depth, refs); + let inlines = parse_inlines(&text, &Fmt::default(), ctx.depth, ctx.refs); if inlines.is_empty() { return; } let p = Para { inlines, ..Default::default() }; out.push(FBlock::Para(p)); + ctx.record_line(line_no); } /// Paragraph lines → text with `\n` for hard breaks and spaces for soft ones. @@ -405,32 +486,83 @@ fn join_lines(lines: &[String]) -> String { s } +/// Creates a single code block paragraph for an indented code line. fn code_para(line: &str) -> FBlock { let mut p = Para::new(Kind::Code); p.push_text(line, &Fmt::default()); FBlock::Para(p) } -/// Parse block structure. `level` is the list nesting level for lists found here. -/// `budget`: the table cells the document may still make ([`super::MAX_TABLE_CELLS`]). -fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell) -> Vec { +/// Attempts to parse a table starting at `lines[*i]`. Returns `true` if a table was parsed. +fn parse_table(lines: &[String], i: &mut usize, t: &str, ctx: &mut BlockCtx<'_>, out: &mut Vec) -> bool { + let Some((aligns, width)) = lines.get(*i + 1).and_then(|l| delimiter_row(l, MAX_COLS)) else { return false }; + let (head, head_width) = split_row(t, MAX_COLS); + if head_width != width { + return false; + } + let table_start = *i; + let mut table = FTable::default(); + let cell = |s: &str, header: bool, a: Option| { + let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), ctx.depth, ctx.refs), ..Default::default() }; + Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() } + }; + let cols = aligns.len(); + let fits = |rows: usize| rows < MAX_ROWS && ctx.budget.get() >= cols; + let mut cut = head_width > cols; + if fits(0) { + ctx.budget.set(ctx.budget.get() - cols); + table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect()); + } else { + cut = true; + } + *i += 2; + while let Some(l) = lines.get(*i) { + if is_blank(l) || !l.contains('|') || interrupts(l) { + break; + } + if !table.rows.is_empty() && fits(table.rows.len()) { + ctx.budget.set(ctx.budget.get() - cols); + let (mut cells, _) = split_row(l, cols); + cells.resize(cols, String::new()); + table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect()); + } else { + cut = true; + } + *i += 1; + } + if cut { + model::dropped(model::Dropped::TableCells); + } + if !table.rows.is_empty() { + out.push(FBlock::Table(table)); + ctx.record_line(table_start); + } + true +} + +/// Parse block structure. +fn parse_blocks(lines: &[String], ctx: &mut BlockCtx<'_>) -> Vec { let mut out = Vec::new(); let mut para: Vec = Vec::new(); + let mut para_start = None; let mut i = 0; while let Some(line) = lines.get(i) { if is_blank(line) { - flush_para(&mut para, &mut out, depth, refs); + flush_para(&mut para, &mut para_start, &mut out, ctx); i += 1; continue; } let ind = indent(line); if ind >= 4 { if !para.is_empty() { + if para_start.is_none() { + para_start = Some(i); + } para.push(line.clone()); i += 1; continue; } - parse_indented_code(lines, &mut i, &mut out); + parse_indented_code(lines, &mut i, &mut out, ctx); continue; } let t = line.trim_start_matches([' ', '\t']); @@ -439,44 +571,50 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: let u = t.trim_end(); if !u.is_empty() && (u.chars().all(|c| c == '=') || (u.chars().all(|c| c == '-'))) { let lvl = if u.starts_with('=') { 1 } else { 2 }; + let line_no = para_start.take().unwrap_or(i); let text = join_lines(¶); para.clear(); let mut p = Para::new(Kind::Heading(lvl)); - p.inlines = parse_inlines(&text, &Fmt::default(), depth, refs); + p.inlines = parse_inlines(&text, &Fmt::default(), ctx.depth, ctx.refs); out.push(FBlock::Para(p)); + ctx.record_line(line_no); i += 1; continue; } } if let Some((n, body)) = atx_heading(t) { - flush_para(&mut para, &mut out, depth, refs); + flush_para(&mut para, &mut para_start, &mut out, ctx); let mut p = Para::new(Kind::Heading(n)); - p.inlines = parse_inlines(body, &Fmt::default(), depth, refs); + p.inlines = parse_inlines(body, &Fmt::default(), ctx.depth, ctx.refs); out.push(FBlock::Para(p)); + ctx.record_line(i); i += 1; continue; } if thematic_break(t) { - flush_para(&mut para, &mut out, depth, refs); + flush_para(&mut para, &mut para_start, &mut out, ctx); out.push(FBlock::Para(Para::new(Kind::Rule))); + ctx.record_line(i); i += 1; continue; } if let Some((fc, fl)) = fence(t) { - flush_para(&mut para, &mut out, depth, refs); + flush_para(&mut para, &mut para_start, &mut out, ctx); i += 1; while let Some(l) = lines.get(i) { + let cur = i; i += 1; let lt = l.trim_start_matches([' ', '\t']); if indent(l) < 4 && fence(lt).is_some_and(|(c, n)| c == fc && n >= fl) && lt.trim_start_matches(fc).trim().is_empty() { break; } out.push(code_para(strip_indent(l, ind))); + ctx.record_line(cur); } continue; } if t.starts_with("") { @@ -486,89 +624,58 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: continue; } if t.starts_with('>') { - flush_para(&mut para, &mut out, depth, refs); - parse_quote(lines, &mut i, depth, level, refs, budget, &mut out); + flush_para(&mut para, &mut para_start, &mut out, ctx); + let quote_start = i; + let before = out.len(); + parse_quote(lines, &mut i, ctx, &mut out); + ctx.record_lines(quote_start, out.len() - before); continue; } - if para.is_empty() - && t.contains('|') - && let Some((aligns, width)) = lines.get(i + 1).and_then(|l| delimiter_row(l, MAX_COLS)) - { - let (head, head_width) = split_row(t, MAX_COLS); - if head_width == width { - let mut table = FTable::default(); - let cell = |s: &str, header: bool, a: Option| { - let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), depth, refs), ..Default::default() }; - Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() } - }; - // The document keeps no more columns or rows than this, nor cells past its budget, - // so those aren't built; their lines are still the table's. - let cols = aligns.len(); - let fits = |rows: usize| rows < MAX_ROWS && budget.get() >= cols; - let mut cut = head_width > cols; - if fits(0) { - budget.set(budget.get() - cols); - table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect()); - } else { - cut = true; - } - i += 2; - while let Some(l) = lines.get(i) { - if is_blank(l) || !l.contains('|') || interrupts(l) { - break; - } - if !table.rows.is_empty() && fits(table.rows.len()) { - budget.set(budget.get() - cols); - let (mut cells, _) = split_row(l, cols); - cells.resize(cols, String::new()); - table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect()); - } else { - cut = true; - } - i += 1; - } - if cut { - model::dropped(model::Dropped::TableCells); - } - if !table.rows.is_empty() { - out.push(FBlock::Table(table)); - } - continue; - } + if para.is_empty() && t.contains('|') && parse_table(lines, &mut i, t, ctx, &mut out) { + continue; } if let Some(m) = list_marker(t) && (para.is_empty() || ((!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or("")))) { - flush_para(&mut para, &mut out, depth, refs); - i = parse_list(lines, i, depth, level, &mut out, refs, budget); + flush_para(&mut para, &mut para_start, &mut out, ctx); + i = parse_list(lines, i, ctx, &mut out); continue; } + if para_start.is_none() { + para_start = Some(i); + } para.push(line.clone()); i += 1; } - flush_para(&mut para, &mut out, depth, refs); + flush_para(&mut para, &mut para_start, &mut out, ctx); out } -fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec) { - let mut code: Vec<&str> = Vec::new(); +/// Parses indented code block starting at `lines[*i]`. +fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec, ctx: &mut BlockCtx<'_>) { + let mut code: Vec<(usize, &str)> = Vec::new(); while let Some(line) = lines.get(*i) { + let cur = *i; if is_blank(line) { - code.push(""); + code.push((cur, "")); } else if indent(line) >= 4 { - code.push(strip_indent(line, 4)); + code.push((cur, strip_indent(line, 4))); } else { break; } *i += 1; } - while code.last().is_some_and(|line| line.is_empty()) { + while code.last().is_some_and(|(_, line)| line.is_empty()) { code.pop(); } - out.extend(code.into_iter().map(code_para)); + for (line_idx, text) in code { + out.push(code_para(text)); + ctx.record_line(line_idx); + } } -fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell, out: &mut Vec) { +/// Parses block quote starting at `lines[*i]`. +fn parse_quote(lines: &[String], i: &mut usize, ctx: &BlockCtx<'_>, out: &mut Vec) { let mut inner = Vec::new(); while let Some(line) = lines.get(*i) { let trimmed = line.trim_start_matches([' ', '\t']); @@ -582,13 +689,14 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: & } *i += 1; } - if depth >= MAX_NEST { + if ctx.depth >= MAX_NEST { let mut p = Para::new(Kind::Quote); p.push_text(&inner.join(" "), &Fmt::default()); out.push(FBlock::Para(p)); return; } - for mut block in parse_blocks(&inner, depth + 1, level, refs, budget) { + let mut child = BlockCtx { depth: ctx.depth + 1, level: ctx.level, refs: ctx.refs, budget: ctx.budget, lines_out: None }; + for mut block in parse_blocks(&inner, &mut child) { if let FBlock::Para(p) = &mut block && p.kind == Kind::Normal { @@ -598,6 +706,7 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: & } } +/// Computes columns and byte count for spaces following a list item marker. fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) { let mut cols = 0; let mut bytes = 0; @@ -626,7 +735,7 @@ fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) { } /// Parse a list starting at `lines[i]`; returns the index after it. -fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec, refs: &Refs, budget: &std::cell::Cell) -> usize { +fn parse_list(lines: &[String], mut i: usize, ctx: &mut BlockCtx<'_>, out: &mut Vec) -> usize { let Some(first) = lines.get(i).and_then(|l| list_marker(l.trim_start_matches([' ', '\t']))) else { return i + 1 }; while let Some(line) = lines.get(i) { let ind = indent(line); @@ -638,6 +747,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut if m.ordered != first.ordered || m.ch != first.ch { break; } + let item_start = i; let after = t.get(m.len..).unwrap_or(""); let (spaces, skip) = if indent(after) > 4 { (1, 1) @@ -667,14 +777,17 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut while item.len() > 1 && item.last().is_some_and(String::is_empty) { item.pop(); } - let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start, ..Default::default() }; - let inner = if depth >= MAX_NEST { + let li = ListInfo { ordered: first.ordered, level: ctx.level.min(8), start: first.start, ..Default::default() }; + let inner = if ctx.depth >= MAX_NEST { let mut p = Para::default(); p.push_text(&item.join(" "), &Fmt::default()); vec![FBlock::Para(p)] } else { - parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs, budget) + let mut child = + BlockCtx { depth: ctx.depth + 1, level: ctx.level.saturating_add(1).min(8), refs: ctx.refs, budget: ctx.budget, lines_out: None }; + parse_blocks(&item, &mut child) }; + let before_count = out.len(); let mut placed = false; if inner.is_empty() { out.push(FBlock::Para(Para { list: Some(li), ..Default::default() })); @@ -712,6 +825,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut } } } + ctx.record_lines(item_start, out.len() - before_count); // Blank lines between items. while lines.get(i).is_some_and(|l| is_blank(l)) { let next_is_item = lines.get(i + 1).is_some_and(|l| { diff --git a/crates/formats/src/markdown/tests.rs b/crates/formats/src/markdown/tests.rs index 24bbea36ee..439166c96e 100644 --- a/crates/formats/src/markdown/tests.rs +++ b/crates/formats/src/markdown/tests.rs @@ -776,6 +776,7 @@ fn exported_link_destinations_read_back_unchanged() { assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)"); } +/// Verifies that trailing escaped hashes on headings survive round-trip export. #[test] fn heading_trailing_hash_survives_round_trip() { let heading = |md: &str| { @@ -797,3 +798,52 @@ fn heading_trailing_hash_survives_round_trip() { assert_eq!(export(&import(&md)), want, "second export of {src:?}"); } } + +/// Verifies that block line mapping tracks source-line indexes accurately across definitions. +#[test] +fn block_line_mapping() { + let md = "# Title\n\nFirst paragraph.\n\n## Second\n\n- one\n- two\n\n```\ncode\n```\n\nLast paragraph.\n"; + let (flow, lines) = parse_with_lines(md); + assert_eq!(flow.blocks.len(), lines.len()); + let (doc, doc_lines) = import_on_with_lines(md, &wordcraft_doc::SectionProps::default()); + assert_eq!(doc.body.len(), doc_lines.len()); + assert_eq!(lines, doc_lines); + assert_eq!(lines[0], 0); + assert_eq!(lines[1], 2); + assert_eq!(lines[2], 4); + + // Reference definitions are removed from blocks, but block line numbers must match original source lines. + let md_refs = "[link]: https://example.com\n# Title\n\n[other]: https://example.org\nParagraph with [link][].\n"; + let (flow, lines) = parse_with_lines(md_refs); + assert_eq!(flow.blocks.len(), lines.len()); + assert_eq!(lines[0], 1, "heading on line 1 after definition on line 0"); + assert_eq!(lines[1], 4, "paragraph on line 4 after definition on line 3"); +} + +/// Verifies linear remapping of original lines after removing definitions. +#[test] +fn remap_original_lines_cases() { + let mut lines = vec![0, 1, 2, 3, 4]; + remap_original_lines(&mut lines, &[2, 5]); + assert_eq!(lines, vec![0, 1, 3, 4, 6]); + + let mut lines = vec![0, 1]; + remap_original_lines(&mut lines, &[1, 2, 3]); + assert_eq!(lines, vec![0, 4]); + + let mut lines = vec![0]; + remap_original_lines(&mut lines, &[0]); + assert_eq!(lines, vec![1]); + + let mut lines = vec![1, 1, 2]; + remap_original_lines(&mut lines, &[1]); + assert_eq!(lines, vec![2, 2, 3]); + + let mut lines = vec![0, 2]; + remap_original_lines(&mut lines, &[]); + assert_eq!(lines, vec![0, 2]); + + let mut empty: Vec = Vec::new(); + remap_original_lines(&mut empty, &[1, 2]); + assert!(empty.is_empty()); +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index b2533e5f1f..77c2b82580 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -22,6 +22,7 @@ mod pictures; mod prefs; pub(crate) use commands::works_without_document; +pub(crate) use frame::STATUS_MS; pub(crate) use input::control_click_as_secondary; impl WordApp { diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 568c7ef839..b3c6628fb8 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -28,6 +28,10 @@ impl WordApp { /// Close, Envelopes, Labels and Finish & Merge on a document with unsaved changes first ask /// Save / Don't Save / Cancel, and the command runs once that is answered (`ui.saveChanges`). pub fn run(&mut self, id: &str, params: Value) -> Result { + // While Markdown Source is open, Undo and Redo work on its typing, as Ctrl+Z does there. + if matches!(id, "edit.undo" | "edit.redo") && self.session.md_source.is_some() { + return crate::md_source::undo(self, id == "edit.redo"); + } // Comment text still being typed is written first, so a save or export has it and a // close, New or Open counts it as a change (#1348). A draft the document refuses is not // part of it: saving and exporting go on without it (the refusal stays on the status @@ -256,6 +260,13 @@ impl WordApp { { self.autosave_path = self.session.path.clone(); } + // A Markdown file edited in the formatted view was written out again, not kept as it was. + if let Ok(v) = &r + && matches!(id, "file.save" | "file.saveAs") + && v.get("rewritten").and_then(Value::as_bool) == Some(true) + { + self.status_long(tl!("Saved. Edits in the formatted view meant writing the Markdown out again, so its own layout wasn't kept.")); + } // A renamed file is the same file: AutoSave keeps covering it, and Recent drops the old name. if r.is_ok() && id == "file.rename" diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 64c38ee683..2cf1692c90 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -297,16 +297,19 @@ impl WordApp { self.default_save_name() } - /// The name Save suggests: the open file's own Word format (so a .docm keeps its macros), - /// otherwise .docx. A document never saved and without a title of its own is named after its - /// first words, as Word suggests them (#5793), instead of `Document1`. + /// The name Save suggests: the open file's own Word format (so a .docm keeps its macros) or, + /// in a dialog, its own Markdown format (a browser's download keeps the whole document as + /// .docx), otherwise .docx. A document never saved and without a title of its own is named after + /// its first words, as Word suggests them (#5793), instead of `Document1`. pub(crate) fn default_save_name(&self) -> String { let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string()); let unnamed = self.session.path.is_none() && self.session.file_name.is_none() && self.session.doc.core.title.trim().is_empty(); let first = || self.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).find_map(|t| suggested_stem(&t)); let stem = unnamed.then(first).flatten().unwrap_or_else(|| self.title_stem()); + let markdown = + self.services.download.is_none() && self.session.path.as_deref().is_some_and(wordcraft_engine::cmd::md_source::is_markdown_file); match ext { - Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"), + Some(e) if markdown || wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"), _ => format!("{stem}.docx"), } } diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index cd52541c99..41d5f88111 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -7,6 +7,11 @@ use super::{ use crate::i18n; +/// How long the status bar shows a message, in milliseconds. +pub(crate) const STATUS_MS: f64 = 6000.0; +/// How long [`WordApp::status_long`] shows one: a warning that is easy to miss in six seconds. +const STATUS_LONG_MS: f64 = 20000.0; + impl WordApp { /// A file that wouldn't open (#1103): log it, and say on the status bar which file and why, /// whatever handed it over (the command line, a macOS open event, a drop). Returns the message. @@ -17,10 +22,17 @@ impl WordApp { msg } + /// Sets a temporary status message in the bottom status bar. pub fn status(&mut self, s: impl Into) { self.status_msg = Some((s.into(), now_ms())); } + /// A status message that stays [`STATUS_LONG_MS`] instead of the usual six seconds: the bar + /// shows a message until six seconds after its time, so its time is set that much later. + pub fn status_long(&mut self, s: impl Into) { + self.status_msg = Some((s.into(), now_ms() + STATUS_LONG_MS - STATUS_MS)); + } + /// Write the current preferences out through the host's hook, if it has one (#2716). The /// desktop app saves them at exit; a change that should survive a crash, like taking a /// document off the recent list, needs a save at the moment of the change. `None`: the host @@ -99,7 +111,11 @@ impl WordApp { } let fill = if screen == chrome::Screen::Focus { chrome::FOCUS_CANVAS } else { t.canvas }; egui::CentralPanel::default().frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| { - canvas::show(self, ui); + if self.session.md_source.is_some() { + crate::md_source::show(self, ui); + } else { + canvas::show(self, ui); + } }); if self.focus_ribbon && !moved(self) { chrome::focus_ribbon(self, ctx); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b74b63b6d3..837235ef60 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -53,6 +53,8 @@ const QAT_MENU: [&str; 10] = /// Match the toolbar's enabled state for keyboard activation too. pub(crate) fn qat_enabled(app: &WordApp, id: &str) -> bool { match id { + // The Markdown Source pane's arrows undo its typing. + "edit.undo" | "edit.redo" if app.session.md_source.is_some() => crate::md_source::can_undo(app, id == "edit.redo"), "edit.undo" => app.session.can_undo(), "edit.redo" => app.session.can_redo(), "file.save" => app.has_document(), @@ -82,6 +84,8 @@ fn quick_access(ui: &mut Ui, app: &mut WordApp) { } crate::widgets::command_menu(&resp, app, id); if id == "edit.undo" { + // The list is the document's steps, not the Markdown Source pane's typing. + let enabled = enabled && app.session.md_source.is_none(); undo_list_button(ui, app, enabled); } } @@ -962,7 +966,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { } }); if let Some((msg, at)) = &app.status_msg - && crate::now_ms() - at < 6000.0 + && crate::now_ms() - at < crate::app::STATUS_MS { let font = TypeRung::Caption.regular(); let track = TypeRung::Caption.tracking(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 33ccaec873..117349384e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1034,6 +1034,15 @@ fn paint_view_and_tables(pen: &Pen, name: &str) -> bool { pen.line(&[(12.5, 15.5), (17.0, 15.5)]); } "draft" => pen.lines(3.0, 17.0, &[4.0, 7.0, 10.0, 13.0, 16.0]), + // A heading mark, then lines of text. + "markdownSource" => { + pen.line_c(&[(4.0, 3.0), (4.0, 9.0)], a); + pen.line_c(&[(7.5, 3.0), (7.5, 9.0)], a); + pen.line_c(&[(2.5, 5.0), (9.0, 5.0)], a); + pen.line_c(&[(2.5, 7.0), (9.0, 7.0)], a); + pen.lines(3.0, 17.0, &[12.0, 15.0]); + pen.lines(11.0, 17.0, &[4.0, 7.0]); + } "focus" => { pen.line(&[(3.0, 7.0), (3.0, 3.0), (7.0, 3.0)]); pen.line(&[(13.0, 3.0), (17.0, 3.0), (17.0, 7.0)]); diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 1ebd6679a7..085a006013 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -374,6 +374,7 @@ fn controls(tab: &str) -> Vec { c("Read Mode", "view.readMode"), c("Print Layout", "view.printLayout"), c("Web Layout", "view.webLayout"), + c("Markdown Source", "view.markdownSource"), c("Outline", "view.outline"), c("Draft", "view.draft"), c("Focus", "view.focus"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8e25e4d1ca..659466d177 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -54,6 +54,7 @@ pub mod keys; pub mod keytips; pub(crate) mod language_packs; mod local_time; +pub mod md_source; pub mod mini_toolbar; pub mod objects; pub mod options; diff --git a/crates/ui-egui/src/md_source.rs b/crates/ui-egui/src/md_source.rs new file mode 100644 index 0000000000..c1f49a941e --- /dev/null +++ b/crates/ui-egui/src/md_source.rs @@ -0,0 +1,386 @@ +//! View › Markdown Source: the text pane that takes the page's place. The text itself and what it +//! means for the document are the engine's (`wordcraft_engine::cmd::md_source`). + +use egui::text::{CCursor, CCursorRange}; +use egui::{FontId, Key, KeyboardShortcut, Modifiers, Ui}; +use serde_json::json; + +use crate::WordApp; +use crate::clock::now_ms; +use crate::theme::Tokens; + +/// How often a text with edits reaches the document while it is being typed, in milliseconds, so +/// crash recovery has them. +const APPLY_EVERY_MS: f64 = 5000.0; + +/// Draw the pane in the place of the page. +pub fn show(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + // The page isn't drawn, so it can't have the keyboard. + app.canvas.focused = false; + app.canvas.want_focus = false; + // A focused text box turns the global shortcuts off. + if ui.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::S))) { + let _ = app.run("file.save", json!({})); + } + let now = now_ms(); + if app.session.md_source.as_ref().is_some_and(|src| src.is_pending() && now - src.applied_at_ms > APPLY_EVERY_MS) { + wordcraft_engine::cmd::md_source::apply(&mut app.session); + if let Some(src) = app.session.md_source.as_mut() { + src.applied_at_ms = now; + } + } + let doc_id = app.session.document_id(); + let id = box_id(doc_id); + let mut changed = false; + let mut refused = false; + { + let Some(src) = app.session.md_source.as_mut() else { return }; + if src.reset_undo { + src.reset_undo = false; + reset_undo_history(ui.ctx(), doc_id); + } + if src.loses_content { + egui::Frame::NONE.fill(t.hover).inner_margin(egui::Margin::symmetric(12, 6)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(tl!( + "This document has content Markdown can't hold. Changing the text here removes it; one Undo afterwards brings it back." + )); + }); + } + let has_input = ui.input(|i| i.events.iter().any(|e| !matches!(e, egui::Event::PointerMoved(_)))); + let prev_text = if has_input { Some(src.text.clone()) } else { None }; + let prev_caret = src.caret; + egui::ScrollArea::vertical().id_salt(id).auto_shrink(false).show(ui, |ui| { + let size = ui.available_size(); + let mut out = egui::TextEdit::multiline(&mut src.text) + .id(id) + .hint_text(tl!("Markdown Source")) + .font(FontId::monospace(14.0)) + .desired_width(f32::INFINITY) + .min_size(size) + .frame(egui::Frame::NONE.inner_margin(16)) + .text_color(t.text) + .char_limit(wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES) + .lock_focus(true) + .show(ui); + changed = out.response.changed(); + if changed && src.text.len() > wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES { + if let Some(prev) = prev_text { + src.text = prev; + src.caret = prev_caret; + } else { + let limit = src.text.floor_char_boundary(wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES); + src.text.truncate(limit); + src.caret = src.caret.min(limit); + } + changed = false; + refused = true; + src.place_caret = true; + } + let caret_changed = if src.place_caret { + src.place_caret = false; + let char_idx = src.text.get(..src.caret).map_or(0, |s| s.chars().count()); + let caret = CCursor::new(char_idx); + out.state.cursor.set_char_range(Some(CCursorRange::one(caret))); + out.state.store(ui.ctx(), id); + out.response.request_focus(); + let spot = out.galley.pos_from_cursor(caret).translate(out.galley_pos.to_vec2()); + ui.scroll_to_rect(spot, Some(egui::Align::Center)); + true + } else if let Some(range) = out.cursor_range { + let old_caret = src.caret; + let char_idx: usize = range.primary.index.into(); + src.caret = src.text.char_indices().nth(char_idx).map_or(src.text.len(), |(b, _)| b); + old_caret != src.caret + } else { + false + }; + if changed || caret_changed { + invalidate_undo_cache(ui.ctx(), doc_id); + } + }); + } + if refused { + app.status(tl!("Markdown Source takes up to 2 MB of text")); + } + if changed { + app.session.dirty = true; + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(APPLY_EVERY_MS / 1000.0)); + } +} + +/// The text box of a document's pane, found the same way by Undo and Redo as by the pane. +fn box_id(document: u64) -> egui::Id { + egui::Id::unique("md_source").with(document) +} + +/// The context storage key for caching the pane's undo/redo availability. +fn undo_cache_id(document: u64) -> egui::Id { + egui::Id::unique("md_source_undo_cache").with(document) +} + +/// Cached undo and redo capabilities for a document's Markdown Source text box. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CachedUndoState { + can_undo: bool, + can_redo: bool, +} + +/// Invalidates the cached undo and redo capability for `document`. +pub(crate) fn invalidate_undo_cache(ctx: &egui::Context, document: u64) { + ctx.data_mut(|d| d.remove::(undo_cache_id(document))); +} + +/// Clears the stored typing history for `document`'s Markdown Source text box and invalidates the cache. +pub(crate) fn reset_undo_history(ctx: &egui::Context, document: u64) { + let id = box_id(document); + if let Some(mut state) = egui::text_edit::TextEditState::load(ctx, id) { + state.clear_undoer(); + state.store(ctx, id); + } + invalidate_undo_cache(ctx, document); +} + +/// A text box's state for its typing history: the selection and the text. +type Typed = (CCursorRange, String); + +/// The text box's typing history, with the state Undo and Redo start from. +fn history(app: &WordApp) -> Option<(egui::Context, egui::text_edit::TextEditState, egui::util::undoer::Undoer, Typed)> { + let ctx = app.ctx.clone()?; + let src = app.session.md_source.as_ref()?; + let mut state = egui::text_edit::TextEditState::load(&ctx, box_id(app.session.document_id()))?; + if src.reset_undo { + state.clear_undoer(); + } + let undoer = state.undoer(); + let caret_chars = src.text[..src.text.floor_char_boundary(src.caret)].chars().count(); + let range = state.cursor.char_range().unwrap_or(CCursorRange::one(CCursor::new(caret_chars))); + Some((ctx, state, undoer, (range, src.text.clone()))) +} + +/// Whether the pane's Undo (or Redo, with `redo`) has a step to take. +pub fn can_undo(app: &WordApp, redo: bool) -> bool { + let Some(ctx) = app.ctx.as_ref() else { return false }; + let doc_id = app.session.document_id(); + if app.session.md_source.as_ref().is_some_and(|s| s.reset_undo) { + reset_undo_history(ctx, doc_id); + } + let cache_key = undo_cache_id(doc_id); + if let Some(cached) = ctx.data(|d| d.get_temp::(cache_key)) { + return if redo { cached.can_redo } else { cached.can_undo }; + } + let Some((ctx, _, undoer, now)) = history(app) else { return false }; + let can_u = undoer.has_undo(&now); + let can_r = undoer.has_redo(&now); + ctx.data_mut(|d| d.insert_temp(cache_key, CachedUndoState { can_undo: can_u, can_redo: can_r })); + if redo { can_r } else { can_u } +} + +/// Undo (or Redo, with `redo`) typing in the pane, as Ctrl+Z (Ctrl+Y) does in it: the toolbar's +/// arrows and the Undo and Redo commands work on the text while it is open, not on the document. +pub fn undo(app: &mut WordApp, redo: bool) -> Result { + let Some((ctx, mut state, mut undoer, now)) = history(app) else { return Err(tl!("Nothing to undo").into()) }; + let step = if redo { undoer.redo(&now) } else { undoer.undo(&now) }.cloned(); + let Some((range, text)) = step else { + return Err(if redo { tl!("Nothing to redo") } else { tl!("Nothing to undo") }.into()); + }; + state.cursor.set_char_range(Some(range)); + state.set_undoer(undoer); + state.store(&ctx, box_id(app.session.document_id())); + if let Some(src) = app.session.md_source.as_mut() { + src.text = text; + let char_idx: usize = range.primary.index.into(); + src.caret = src.text.char_indices().nth(char_idx).map_or(src.text.len(), |(b, _)| b); + } + invalidate_undo_cache(&ctx, app.session.document_id()); + app.session.dirty = true; + ctx.request_repaint(); + Ok(json!({"text": true})) +} + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::kittest::Queryable; + + /// Builds a test harness for Markdown Source UI integration tests. + fn harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> { + egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ) + } + + /// A saved Markdown file, with its folder for the test to remove. + fn opened(name: &str) -> (WordApp, std::path::PathBuf) { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "wordcraft-ui-md-source-{name}-{}-{}-{}", + std::process::id(), + id, + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("note.md"); + std::fs::write(&path, "# Heading\n\nSome text.\n").unwrap(); + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + (a, dir) + } + + /// Verifies Markdown Source pane displays text and captures keyboard focus. + #[test] + fn the_pane_shows_the_text_and_takes_the_keyboard() { + let (mut a, dir) = opened("keys"); + a.run("view.markdownSource", json!({})).unwrap(); + let mut h = harness(a); + h.run(); + assert!(!h.state().canvas.focused, "the page isn't drawn"); + assert!(h.ctx.text_edit_focused(), "the text box has the keyboard"); + assert!(h.get_all_by_value("# Heading\n\nSome text.\n").next().is_some(), "the Markdown is shown"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies typing in source view marks document dirty and Ctrl+S saves it. + #[test] + fn typing_is_an_unsaved_change_and_ctrl_s_saves_it() { + let (mut a, dir) = opened("save"); + a.run("view.markdownSource", json!({})).unwrap(); + let mut h = harness(a); + h.run(); + h.state_mut().session.md_source.as_mut().unwrap().text.insert_str(0, "Typed. "); + h.state_mut().session.dirty = true; + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); + h.run(); + let saved = std::fs::read_to_string(dir.join("note.md")).unwrap(); + assert!(saved.contains("Typed."), "{saved}"); + assert!(!h.state().session.dirty); + assert!(h.state().session.md_source.is_some(), "saving keeps the pane"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies QAT undo and redo arrows work for typing in the source pane. + #[test] + fn the_arrows_undo_and_redo_typing_in_the_pane() { + let (mut a, dir) = opened("arrows"); + a.run("view.markdownSource", json!({})).unwrap(); + let mut h = harness(a); + h.run(); + assert!(!crate::chrome::qat_enabled(h.state(), "edit.undo"), "nothing typed yet"); + h.event(egui::Event::Text("Typed. ".into())); + h.run(); + let typed = h.state().session.md_source.as_ref().unwrap().text.clone(); + assert!(typed.contains("Typed. "), "{typed}"); + assert!(crate::chrome::qat_enabled(h.state(), "edit.undo")); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run(); + assert_eq!(h.state().session.md_source.as_ref().unwrap().text, "# Heading\n\nSome text.\n", "Undo takes the typing back"); + assert!(h.state().session.md_source.is_some(), "and stays in the pane"); + assert!(crate::chrome::qat_enabled(h.state(), "edit.redo")); + h.state_mut().run("edit.redo", json!({})).unwrap(); + h.run(); + assert_eq!(h.state().session.md_source.as_ref().unwrap().text, typed, "Redo puts it back"); + assert!(h.state_mut().run("edit.redo", json!({})).is_err(), "nothing more to redo"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies rewrite warning stays displayed for twenty seconds on the status bar. + #[test] + fn the_rewrite_warning_stays_longer_than_other_messages() { + let (mut a, dir) = opened("warning"); + a.run("text.insert", json!({"text": "Formatted edit. "})).unwrap(); + a.run("file.save", json!({})).unwrap(); + let (msg, at) = a.status_msg.clone().unwrap(); + assert!(msg.contains("writing the Markdown out again"), "{msg}"); + let left = at + crate::app::STATUS_MS - now_ms(); + assert!(left > 15_000.0, "shown for about 20 seconds, {left} ms left"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies formatting ribbon controls are greyed out while Markdown Source pane is active. + #[test] + fn the_ribbon_greys_out_edits_while_the_pane_is_open() { + let (mut a, dir) = opened("grey"); + assert_eq!(a.session.why_disabled("format.bold"), None); + a.run("view.markdownSource", json!({})).unwrap(); + assert_eq!(a.session.why_disabled("format.bold"), Some("leave Markdown Source first")); + assert_eq!(a.session.why_disabled("view.markdownSource"), None); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies that typing or pasting beyond `MAX_SOURCE_BYTES` is refused, keeping the previous text and warning. + #[test] + fn text_exceeding_max_source_bytes_is_refused_and_warns() { + let (mut a, dir) = opened("oversize"); + a.run("view.markdownSource", json!({})).unwrap(); + let limit = wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES; + let half = limit / 2; + let original = format!("START {} END", "é".repeat(half - 10)); + let original_len = original.len(); + a.session.md_source.as_mut().unwrap().text = original.clone(); + let mut h = harness(a); + h.run(); + h.event(egui::Event::Text("é".repeat(100))); + h.run(); + let current_text = h.state().session.md_source.as_ref().unwrap().text.clone(); + assert_eq!(current_text, original, "over-limit text must be refused without truncating document end"); + assert_eq!(current_text.len(), original_len); + let status = h.state().status_msg.as_ref().map(|(s, _)| s.as_str()); + assert_eq!(status, Some("Markdown Source takes up to 2 MB of text")); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies that pointer movement alone does not take a snapshot or alter Markdown Source state. + #[test] + fn pointer_movement_does_not_mutate_or_affect_markdown_source() { + let (mut a, dir) = opened("pointer"); + a.run("view.markdownSource", json!({})).unwrap(); + let mut h = harness(a); + h.run(); + let before_text = h.state().session.md_source.as_ref().unwrap().text.clone(); + h.event(egui::Event::PointerMoved(egui::pos2(100.0, 200.0))); + h.run(); + assert_eq!(h.state().session.md_source.as_ref().unwrap().text, before_text); + assert!(!h.state().session.dirty); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies that reopening Markdown Source after formatted edits resets undo history + /// so that previous visit edits cannot be restored by Undo. + #[test] + fn reopening_pane_resets_undo_history_from_previous_visit() { + let (mut a, dir) = opened("reopen_undo"); + a.run("view.markdownSource", json!({})).unwrap(); + let mut h = harness(a); + h.run(); + h.event(egui::Event::Text("First visit. ".into())); + h.run(); + assert!(crate::chrome::qat_enabled(h.state(), "edit.undo"), "undo enabled after typing"); + + // Leave pane back to formatted view + h.state_mut().run("view.markdownSource", json!({})).unwrap(); + h.run(); + assert!(h.state().session.md_source.is_none(), "left source view"); + + // Make formatted edits in document + h.state_mut().run("text.insert", json!({"text": "Formatted edit. "})).unwrap(); + h.run(); + + // Reopen pane + h.state_mut().run("view.markdownSource", json!({})).unwrap(); + h.run(); + + // Undo must NOT be enabled for the Markdown pane, and edit.undo must return error + assert!(!crate::chrome::qat_enabled(h.state(), "edit.undo"), "undo must be reset on reopening pane"); + assert!(h.state_mut().run("edit.undo", json!({})).is_err(), "undo command must fail when nothing typed in current visit"); + + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/ui-egui/src/ribbon/view.rs b/crates/ui-egui/src/ribbon/view.rs index c9226a2c65..5f94be3d42 100644 --- a/crates/ui-egui/src/ribbon/view.rs +++ b/crates/ui-egui/src/ribbon/view.rs @@ -5,6 +5,7 @@ use super::{Ui, WordApp, big, big_toggle, group, json, menu_button, small, vec2} /// and Templates groups. pub(super) fn view(app: &mut WordApp, ui: &mut Ui) { let v = app.session.view.clone(); + let v_source = app.session.md_source.is_some(); group(ui, "Views", None, app, |ui, app| { big(ui, app, "readMode", "Read\nMode", "view.readMode", json!({}), false); big(ui, app, "printLayout", "Print\nLayout", "view.printLayout", json!({}), false); @@ -12,6 +13,8 @@ pub(super) fn view(app: &mut WordApp, ui: &mut Ui) { stack(ui, |ui| { small(ui, app, "outline", Some("Outline"), "Outline", crate::widgets::ButtonAction::new("view.outline", json!({}), false)); small(ui, app, "draft", Some("Draft"), "Draft", crate::widgets::ButtonAction::new("view.draft", json!({}), false)); + let source = crate::widgets::ButtonAction::new("view.markdownSource", json!({}), v_source); + small(ui, app, "markdownSource", Some("Markdown"), "Markdown Source", source); }); }); group(ui, "Immersive", None, app, |ui, app| { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 9d9af6b0d4..e14640a2d2 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -3274,6 +3274,20 @@ fn window_close_save_writes_the_file_and_quits() { let _ = std::fs::remove_dir_all(&dir); } +/// Save As suggests a Markdown file's own format, as it does a Word file's; anything else is .docx. +#[test] +fn save_as_suggests_a_markdown_files_own_format() { + let dir = scratch("save-name"); + for (name, suggested) in [("notes.md", "notes.md"), ("notes.markdown", "notes.markdown"), ("notes.MD", "notes.MD"), ("notes.txt", "notes.docx")] { + let path = dir.join(name); + std::fs::write(&path, b"Hello\n").unwrap(); + let mut a = app(); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(a.default_save_name(), suggested, "{name}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// #2376: a text file opened, edited and saved still asked to save on close, and Save in that /// prompt wrote the file but left the window open; only Don't Save closed it. #[test]