diff --git a/README.md b/README.md index 96b9410..06c2010 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,52 @@ Pandoc reads the same way the library does. The host owns the data and the undo **Status: unreleased. The API will change.** Everything outside the [Roadmap](#roadmap) is built and tested today. -![Latin and Arabic in one paragraph, with one selection running across the direction changes](docs/images/bidi-selection.png) +![Latin and Arabic in one paragraph with one selection running across the direction changes, and the same text as FieldWorks Markdown beside it](docs/images/bidi-selection.png) | | | |---|---| | ![Hindi, Tamil and Thai shaping](docs/images/indic-thai-shaping.png) | ![Inline pictures in left-to-right and right-to-left text](docs/images/inline-pictures.png) | | [Hindi, Tamil and Thai, shaped, with a conjunct selected as one cluster](docs/images/README.md) | [Inline pictures in left-to-right and right-to-left text](docs/images/README.md) | -| ![Runs with unknown properties and the live inspector](docs/images/unknown-properties-inspector.png) | ![Spelling squiggles](docs/images/spelling-squiggles.png) | +| ![Runs with unknown properties and the model tab](docs/images/unknown-properties-inspector.png) | ![Spelling squiggles](docs/images/spelling-squiggles.png) | | [Properties the editor does not know survive every edit](docs/images/README.md) | [Spelling per writing system; blue where a word mixes two](docs/images/README.md) | | ![The phonological-environment box](docs/images/environment-box.png) | ![Headings, lists and tables with their switches](docs/images/block-features.png) | | [A notation box with a language service: chips and diagnostics](docs/images/README.md) | [Tables, lists and headings, switched on or off per field](docs/images/README.md) | | ![The playground inside a WinForms form](docs/images/host-winforms-fieldworks-shape.png) | ![Composed and decomposed text and emoji](docs/images/nfd-surrogates.png) | | [Inside WinForms on .NET Framework 4.8: FieldWorks' shape](docs/images/README.md) | [NFD, stacked marks and surrogate pairs](docs/images/README.md) | +## What people and agents edit + +The first paragraph of the picture above, as FieldWorks Markdown, the form a person or an AI agent reads and edits: + +```markdown +The word [كتاب]{ws=ar} means book; [مرحبا بالعالم]{ws=ar} means hello world. +``` + +Unmarked text is the analysis writing system (English here); `[...]{ws=ar}` is another writing system and `[...]{.v}` the vernacular. A new +run starts wherever the writing system or a property changes, so nothing numbers runs or records offsets. The two right-to-left +paragraphs of the picture share one `::: {rightToLeft=1}` block. A file adds a short front matter, generated by the writer, that names the +writing systems ([the format](docs/formats/fieldworks-markdown.md)). + +The same text has four forms, and only the first two are meant to be edited by hand: + +| Form | What it is for | Edited by hand | +|---|---|---| +| **FieldWorks Markdown** (`*.fw.md`) | People, AI agents, files and diffs; Pandoc reads it the same way | Yes | +| **Record file** | A whole FieldWorks record: a YAML frame whose rich values are FieldWorks Markdown ([record files](docs/formats/record-files.md)) | Yes | +| **`Document`, `Paragraph`, `Run`** | The editor's model in memory: per run the text, writing system and property bag; per paragraph the host's key | No: the playground's "Model (debug)" tab dumps it | +| **liblcm `ITsString`** | FieldWorks' storage; the LCModel adapter maps it to and from the run model | No | + +**An agent's edit** is a text edit. Change the gloss and mark a vernacular word: + +```diff +-The word [كتاب]{ws=ar} means book; [مرحبا بالعالم]{ws=ar} means hello world. ++The word [كتاب]{ws=ar} means [kitabu]{.v}; [مرحبا بالعالم]{ws=ar} means hello world. +``` + +The reader checks it: a malformed span or an unknown writing system is a finding with a line, a column and, where one exists, the exact +fix ([findings](docs/formats/findings.md)), and nothing is applied while there is an error. The host applies the rest as one undo step. In +the playground, edit the Markdown tab and press Apply; `MarkdownTabTests` makes this very edit. + ## Why it's different - **A writing system on every run.** A `Run` holds text, a writing-system id, a property bag keyed by FieldWorks property numbers, and @@ -75,7 +108,8 @@ run desktop10 --scenario "Inline picture (ORC)" ``` `run.cmd` and `run.ps1` build the host and start it. The playground opens ready-made scenarios (bidi, Indic and Thai shaping, NFD, inline pictures, unknown properties, spelling, the -environment box, long documents) with a live model inspector. The Pandoc tests need `pandoc` on `PATH`. +environment box, long documents). Beside the field, the Markdown tab shows the field as FieldWorks Markdown, which you can edit and apply, +and the Model tab shows its runs. The Pandoc tests need `pandoc` on `PATH`. ## Projects @@ -115,8 +149,8 @@ Planned, not built: ([ADR 0031](docs/adr/0031-shared-editing-and-interchange-layer.md), [ADR 0033](docs/adr/0033-block-features.md)). - **The `fwmd` command-line checker** ([#161](https://github.com/sillsdev/SIL.TextEditing/issues/161)). - **The FieldWorks Markdown clipboard form** ([#168](https://github.com/sillsdev/SIL.TextEditing/issues/168)). -- **Editor integration**: an inline WYSIWYG and Markdown source mode, and a VS Code extension with a language server. The plan and a proposed ADR - are awaiting owner decisions ([#170](https://github.com/sillsdev/SIL.TextEditing/issues/170)). +- **Editor integration**: an inline WYSIWYG and Markdown source mode, and a VS Code extension with a language server. The playground's + Markdown tab is a host-side sketch of the source mode. The plan and a proposed ADR are awaiting owner decisions ([#170](https://github.com/sillsdev/SIL.TextEditing/issues/170)). - **Retiring the liblcm refusals** once FieldWorks pins a liblcm with its fixes [#410](https://github.com/sillsdev/liblcm/issues/410), [#413](https://github.com/sillsdev/liblcm/issues/413) and [#414](https://github.com/sillsdev/liblcm/issues/414) ([#159](https://github.com/sillsdev/SIL.TextEditing/issues/159)). ## Contributing diff --git a/docs/images/README.md b/docs/images/README.md index 2dbe974..f63cdfc 100644 --- a/docs/images/README.md +++ b/docs/images/README.md @@ -4,24 +4,24 @@ Pictures of the playground (`samples/SIL.TextEditing.Playground`) for the README ## The headless ones -Rendered by `tests/SIL.TextEditing.Playground.Tests/ScreenshotGenerator.cs`: a 1280 x 800 headless Avalonia 12 window (net10.0, Skia, Fluent light theme, the playground's own fonts: Segoe UI, Nirmala UI, Leelawadee UI), cropped to the field and the inspector beside it. The test is skipped unless `TEXTEDITING_SCREENSHOTS=1`, so the normal test run never writes here. +Rendered by `tests/SIL.TextEditing.Playground.Tests/ScreenshotGenerator.cs`: a 1280 x 800 headless Avalonia 12 window (net10.0, Skia, Fluent light theme, the playground's own fonts: Segoe UI, Nirmala UI, Leelawadee UI), cropped to the field and the tabs beside it (Markdown, or Model for the run dump). The test is skipped unless `TEXTEDITING_SCREENSHOTS=1`, so the normal test run never writes here. ```powershell $env:TEXTEDITING_SCREENSHOTS=1; dotnet test tests\SIL.TextEditing.Playground.Tests -c Release -f net10.0 --filter ScreenshotGenerator ``` -- `bidi-selection.png`: Latin and Arabic in one paragraph, one selection running across the direction changes ("Latin and Arabic" scenario). +- `bidi-selection.png`: Latin and Arabic in one paragraph, one selection running across the direction changes ("Latin and Arabic" scenario), with the same text as FieldWorks Markdown in the Markdown tab. - `indic-thai-shaping.png`: Hindi, Tamil and Thai shaped correctly, with a Devanagari conjunct selected as one cluster. - `nfd-surrogates.png`: composed and decomposed accents, stacked marks, and emoji (surrogate pairs and a ZWJ family), with the emoji selected. - `inline-pictures.png`: inline picture objects (U+FFFC) in left-to-right and right-to-left text, selected along with the text around them. -- `unknown-properties-inspector.png`: runs that carry properties the editor does not understand (9001, 9002), a paragraph style and a character style, and the live inspector showing the run model. +- `unknown-properties-inspector.png`: runs that carry properties the editor does not understand (9001, 9002), a paragraph style and a character style, and the Model tab showing the runs and their property numbers. - `environment-box.png`: the phonological-environment box, one line with coloured chips and a squiggle on a class the language service rejects. - `environment-fix-menu.png`: right-click on a problem in the environment box: the service's message, its fix ("Insert the closing parenthesis") and the Insert items. - `spelling-squiggles.png`: red squiggles on misspelled words, blue on a word that glues two writing systems, nothing on do-not-check or read-only text. - `spelling-context-menu.png`: the context menu on a misspelled word: the word, its suggestions, Find, Cut, Copy, Paste and Copy as. - `copy-as-submenu.png`: the "Copy as" submenu open (plain text, a debug dump of the runs, and a disabled Markdown entry when no converter is registered). - `block-features.png`: a heading, a bulleted list and a table (as table-styled paragraphs, one column for now) with the Tables, Lists and Headings switches visible and on. -- `long-document.png`: the 200-paragraph scenario scrolled to the caret in the middle, mixed writing systems; the inspector says "paragraphs 200". +- `long-document.png`: the 200-paragraph scenario scrolled to the caret in the middle, mixed writing systems; the Markdown tab shows the document as FieldWorks Markdown from its start. - `many-fields.png`: fifty small fields in one window, in English, French, Swahili and Arabic. ## The real windows diff --git a/docs/images/bidi-selection.png b/docs/images/bidi-selection.png index e947479..dba14e7 100644 Binary files a/docs/images/bidi-selection.png and b/docs/images/bidi-selection.png differ diff --git a/docs/images/block-features.png b/docs/images/block-features.png index f5c7f6e..91433b6 100644 Binary files a/docs/images/block-features.png and b/docs/images/block-features.png differ diff --git a/docs/images/copy-as-submenu.png b/docs/images/copy-as-submenu.png index b2c73ff..ed1d2c2 100644 Binary files a/docs/images/copy-as-submenu.png and b/docs/images/copy-as-submenu.png differ diff --git a/docs/images/desktop-window.png b/docs/images/desktop-window.png index 2486598..dcb3a1e 100644 Binary files a/docs/images/desktop-window.png and b/docs/images/desktop-window.png differ diff --git a/docs/images/environment-box.png b/docs/images/environment-box.png index e29644c..16714f2 100644 Binary files a/docs/images/environment-box.png and b/docs/images/environment-box.png differ diff --git a/docs/images/environment-fix-menu.png b/docs/images/environment-fix-menu.png index d9984aa..75710b2 100644 Binary files a/docs/images/environment-fix-menu.png and b/docs/images/environment-fix-menu.png differ diff --git a/docs/images/host-winforms-fieldworks-shape.png b/docs/images/host-winforms-fieldworks-shape.png index 46a1daf..6f9ab4e 100644 Binary files a/docs/images/host-winforms-fieldworks-shape.png and b/docs/images/host-winforms-fieldworks-shape.png differ diff --git a/docs/images/indic-thai-shaping.png b/docs/images/indic-thai-shaping.png index 008238c..0ef21e1 100644 Binary files a/docs/images/indic-thai-shaping.png and b/docs/images/indic-thai-shaping.png differ diff --git a/docs/images/inline-pictures.png b/docs/images/inline-pictures.png index 6a269f7..ebedfec 100644 Binary files a/docs/images/inline-pictures.png and b/docs/images/inline-pictures.png differ diff --git a/docs/images/long-document.png b/docs/images/long-document.png index 0a16c4b..8ffe267 100644 Binary files a/docs/images/long-document.png and b/docs/images/long-document.png differ diff --git a/docs/images/many-fields.png b/docs/images/many-fields.png index 9e25f6b..8659c15 100644 Binary files a/docs/images/many-fields.png and b/docs/images/many-fields.png differ diff --git a/docs/images/spelling-context-menu.png b/docs/images/spelling-context-menu.png index 39fbcdf..c3d8746 100644 Binary files a/docs/images/spelling-context-menu.png and b/docs/images/spelling-context-menu.png differ diff --git a/docs/images/spelling-squiggles.png b/docs/images/spelling-squiggles.png index 90c42e7..5d08bb7 100644 Binary files a/docs/images/spelling-squiggles.png and b/docs/images/spelling-squiggles.png differ diff --git a/docs/images/unknown-properties-inspector.png b/docs/images/unknown-properties-inspector.png index c79a535..b30a9d4 100644 Binary files a/docs/images/unknown-properties-inspector.png and b/docs/images/unknown-properties-inspector.png differ diff --git a/docs/manual-testing.md b/docs/manual-testing.md index 5df83b7..f9fcdff 100644 --- a/docs/manual-testing.md +++ b/docs/manual-testing.md @@ -5,7 +5,7 @@ applications' clipboards and focus handoff in a real WinForms form. A person doe shared sample UI that all three hosts show. It is meant for a tester who has not seen the code. Write **PASS** or **FAIL** in the Result column of every step you do, and put what you saw in Notes when it differs -from Expected. A step you could not do is **SKIP** with the reason. Copy the inspector or the event log (select the +from Expected. A step you could not do is **SKIP** with the reason. Copy the Model tab or the event log (select the text, Ctrl+C) into a bug report; they are plain text and show exactly what the model holds. ## Start @@ -32,7 +32,10 @@ Where a step says **all hosts**, do it on each of the three and write one result selection a button changes the selected text; with a caret it sets what you type next, as a keyboard switch would. Sample data only; nothing is read from a project. * **Field** (left): the editor. Ctrl+B, Ctrl+I, Ctrl+U, Ctrl+Z, Ctrl+Y, Ctrl+A, Ctrl+C, Ctrl+X and Ctrl+V work in it. -* **Inspector** (top right): caret, anchor and selection as paragraph:offset; undo counts; every paragraph with its key (`(none)` until the host assigns one), +* **Markdown tab** (top right, shown first): the field as FieldWorks Markdown, the form a person or an agent edits. Edit it and press + Apply to put it in the field as one undo step; an error, read-only text or a second paragraph in a single-paragraph field changes nothing + and says why. Revert shows the field again. +* **Model tab** (beside the Markdown tab; click it for the steps below that read the model): caret, anchor and selection as paragraph:offset; undo counts; every paragraph with its key (`(none)` until the host assigns one), direction, style and runs. A run reads `r1 [9,13) ws=ar "text" props{3=1, 9001=42@1}`: offsets, writing system, text with escapes (`́` a combining mark, `\U0001F600` a surrogate pair, `` an object), then the property bag as `number=value`, `@n` for a variation. Documents over 30 paragraphs show a window around the caret. @@ -43,18 +46,18 @@ Where a step says **all hosts**, do it on each of the three and write one result | # | Steps | Expected | D10 | D48 | W48 | Notes | |---|---|---|---|---|---|---| -| 1.1 | Start. Click in the text of the first paragraph and type a few Latin letters. | Letters appear at the caret; the caret moves; `ws:` stays `en`; the inspector's run text and caret offset update with each key. | | | | | -| 1.2 | Pick each scenario in turn. | Each loads without a delay you notice; the description under the toolbar changes; the inspector shows the new document; no text is clipped or overlaps. | | | | | +| 1.1 | Start. Click in the text of the first paragraph and type a few Latin letters. | Letters appear at the caret; the caret moves; `ws:` stays `en`; the Model tab's run text and caret offset update with each key. | | | | | +| 1.2 | Pick each scenario in turn. | Each loads without a delay you notice; the description under the toolbar changes; the Model tab shows the new document; no text is clipped or overlaps. | | | | | | 1.3 | In Latin and Arabic, click inside the Arabic word, then inside an English word. | `ws:` follows the caret (`ar`, then `en`); the log gets `would switch keyboard to ar` and `... to en`. | | | | | -| 1.4 | Put a caret in English text, click the `fr` button, type `bonjour`. | `ws: fr`; the typed text is a new run `ws=fr` in the inspector. | | | | | -| 1.5 | Click Default vernacular, type `habari`; then Default analysis. | `ws: swh`, then `ws: en`; run `ws=swh "habari"` in the inspector. | | | | | +| 1.4 | Put a caret in English text, click the `fr` button, type `bonjour`. | `ws: fr`; the typed text is a new run `ws=fr` in the Model tab. | | | | | +| 1.5 | Click Default vernacular, type `habari`; then Default analysis. | `ws: swh`, then `ws: en`; run `ws=swh "habari"` in the Model tab. | | | | | | 1.6 | Select a word with Shift+Right, click the `hi` button. | Only the selected text becomes `ws=hi` and is drawn in the Hindi font; the selection stays. Undo restores it. | | | | | -| 1.7 | Select a word, press B, then B again; then Ctrl+I and Ctrl+U. | Bold, italic, underline are drawn; the inspector shows `props{3=1}` (bold), `2=1` (italic), `5=1` (underline); pressing B again removes it and the runs merge back. Paragraph keys do not change. | | | | | -| 1.8 | Type, press Undo, Undo, Redo, Redo. Then Ctrl+Z and Ctrl+Y in the field. | Text, caret and the inspector's `undo n done, n undone` follow; the log says `undo requested (button)` or `(Ctrl+Z in the field)`. | | | | | -| 1.9 | Press Enter in the middle of a paragraph, then Backspace at the start of the new one. | Two paragraphs, then one. The first keeps its key on a split and on the join; the new right-hand paragraph has `key=(none)` until the host assigns one (compare in the inspector). | | | | | +| 1.7 | Select a word, press B, then B again; then Ctrl+I and Ctrl+U. | Bold, italic, underline are drawn; the Model tab shows `props{3=1}` (bold), `2=1` (italic), `5=1` (underline); pressing B again removes it and the runs merge back. Paragraph keys do not change. | | | | | +| 1.8 | Type, press Undo, Undo, Redo, Redo. Then Ctrl+Z and Ctrl+Y in the field. | Text, caret and the Model tab's `undo n done, n undone` follow; the log says `undo requested (button)` or `(Ctrl+Z in the field)`. | | | | | +| 1.9 | Press Enter in the middle of a paragraph, then Backspace at the start of the new one. | Two paragraphs, then one. The first keeps its key on a split and on the join; the new right-hand paragraph has `key=(none)` until the host assigns one (compare in the Model tab). | | | | | | 1.10 | Press Enter at the very start of a paragraph. | A new empty paragraph appears above; the new paragraph shows `key=(none)` and the original key stays on the paragraph that holds the text. | | | | | | 1.11 | Scenario *Inline picture*: put the caret after a picture, Backspace; press Picture to insert one; Delete before a picture. | A picture is deleted or inserted as one unit (one character, ``), never half. Works in the Arabic paragraph too. | | | | | -| 1.12 | Scenario *Unknown properties*: select the whole second paragraph, press B twice, then split and join it with Enter and Backspace. | Properties 9001, 9002, 9003 and the paragraph style are still in the inspector, unchanged. | | | | | +| 1.12 | Scenario *Unknown properties*: select the whole second paragraph, press B twice, then split and join it with Enter and Backspace. | Properties 9001, 9002, 9003 and the paragraph style are still in the Model tab, unchanged. | | | | | ## 2. Keyboard layout switching on caret moves (all hosts) @@ -73,7 +76,7 @@ Add the input methods under Settings, Time and language, Language: Japanese (Mic | # | Steps | Expected | D10 | D48 | W48 | Notes | |---|---|---|---|---|---|---| | 3.1 | Type a sentence of English, French accents (AltGr or a French layout) and punctuation. | Everything appears as typed; dead keys compose (`e` after `^`); no doubled or lost letters. | | | | | -| 3.2 | Japanese IME: in the *Empty* scenario type `nihongo`. | The composition shows inline with an underline; the candidate window sits at the caret; Space cycles candidates; Enter commits the text into the field; the inspector shows it only after the commit. | | | | | +| 3.2 | Japanese IME: in the *Empty* scenario type `nihongo`. | The composition shows inline with an underline; the candidate window sits at the caret; Space cycles candidates; Enter commits the text into the field; the Model tab shows it only after the commit. | | | | | | 3.3 | Same, then press Esc during composition. | The composition disappears and nothing is inserted. | | | | | | 3.4 | Chinese Pinyin: type `zhongwen`. | Same as 3.2. | | | | | | 3.5 | Compose while the caret is inside Arabic text, then in the RTL paragraph. | The composition appears at the caret, the candidate window is not left behind, the committed text lands at the caret. | | | | | @@ -85,7 +88,7 @@ Add the input methods under Settings, Time and language, Language: Japanese (Mic | # | Steps | Expected | D10 | D48 | W48 | Notes | |---|---|---|---|---|---|---| | 4.1 | Select the Keyman keyboard, type a sequence in the *Empty* scenario. | The result matches typing the same keys in Notepad. | | | | | -| 4.2 | Type a sequence in which a later key rewrites earlier characters (reorder, or replace the last syllable). | The earlier characters are changed correctly: the field reports its surrounding text to the keyboard. Check the inspector for stray characters. | | | | | +| 4.2 | Type a sequence in which a later key rewrites earlier characters (reorder, or replace the last syllable). | The earlier characters are changed correctly: the field reports its surrounding text to the keyboard. Check the Model tab for stray characters. | | | | | | 4.3 | Move the caret into the middle of existing text and type the same sequence. | Same result as at the end of the text. | | | | | ## 5. Right-to-left: caret and selection by mouse and arrows (all hosts) @@ -96,10 +99,10 @@ caret about two characters from where you clicked (fixed in Avalonia 12). Record | # | Steps | Expected | D10 | D48 | W48 | Notes | |---|---|---|---|---|---|---| | 5.1 | In the all-Arabic paragraph press Home, then Left repeatedly. | Arrows are logical (as in FieldWorks): in a right-to-left paragraph Left goes forward through the text, so the caret moves to the left on screen, one cluster at a time, never inside a joined letter. Right goes back. | | | | | -| 5.2 | Press Left repeatedly across an English word inside the RTL paragraph, then Right back. | Left walks the text in logical order and Right reverses it, one cluster per key, never skipping or repeating an offset (check the inspector). Inside the English word the caret moves right on screen for Left, and at the language boundary it may jump across the line: that is expected, FieldWorks does the same. | | | | | +| 5.2 | Press Left repeatedly across an English word inside the RTL paragraph, then Right back. | Left walks the text in logical order and Right reverses it, one cluster per key, never skipping or repeating an offset (check the Model tab). Inside the English word the caret moves right on screen for Left, and at the language boundary it may jump across the line: that is expected, FieldWorks does the same. | | | | | | 5.3 | Click in the middle of an Arabic word. | The caret is where you clicked (D10). On D48 and W48 it may be two characters off: note it (known). | | | | | -| 5.4 | Click and drag across the boundary between Arabic and English text. | The selection is one visual block or the correct set of blocks for the logical range; the inspector's `selection a -> b` matches the highlight. | | | | | -| 5.5 | Shift+Left and Shift+Right from the middle of the RTL paragraph, then the same in an LTR paragraph. | Shift+Left grows the selection forward in the RTL paragraph (backward in LTR), Shift+Right the reverse; the inspector's caret offset moves by one cluster per key and the anchor stays. | | | | | +| 5.4 | Click and drag across the boundary between Arabic and English text. | The selection is one visual block or the correct set of blocks for the logical range; the Model tab's `selection a -> b` matches the highlight. | | | | | +| 5.5 | Shift+Left and Shift+Right from the middle of the RTL paragraph, then the same in an LTR paragraph. | Shift+Left grows the selection forward in the RTL paragraph (backward in LTR), Shift+Right the reverse; the Model tab's caret offset moves by one cluster per key and the anchor stays. | | | | | | 5.6 | Scenario *Inline picture*: arrow across the picture in the Arabic paragraph. | The caret passes the picture in one step. (Known: the picture may sit in the wrong place in RTL text on both Avalonia versions.) | | | | | | 5.7 | Scenario *Devanagari, Tamil, Thai*: arrow and Backspace through conjuncts and stacked marks. | The caret stops between clusters, never inside; Backspace removes what the language expects (compare with Notepad). | | | | | | 5.8 | Scenario *Latin and Arabic*, LTR paragraph with Arabic inside: put the caret just before the Arabic word, press Right once; then click after it, press Left until you are back at the same offset. | The offset is the same but the caret is drawn on different sides of the boundary, depending on the direction you arrived from. | | | | | @@ -114,12 +117,12 @@ Scenario *NFD, marks, surrogates, PUA* (it sets the field's normalisation form t | 5a.1 | Type `e`, then a combining acute (or a keyboard that sends it), then Backspace. | Only the accent goes; `e` stays. | | | | | | 5a.2 | Put the caret before a letter with two marks after it (`e` plus acute plus circumflex) and press Delete. | The letter and both marks go in one press. | | | | | | 5a.3 | Backspace after an emoji (a surrogate pair), and Delete before one. | The whole emoji goes each time, never half. | | | | | -| 5a.4 | With NFD set, type or paste a precomposed `é` (U+00E9). | The inspector shows two code points (`e` plus U+0301); the caret is after both. | | | | | +| 5a.4 | With NFD set, type or paste a precomposed `é` (U+00E9). | The Model tab shows two code points (`e` plus U+0301); the caret is after both. | | | | | | 5a.5 | With NFD set, copy `é` and paste it into Notepad, then check its form (for instance by Backspace: one press removes the whole `é`). | Notepad receives the precomposed form (NFC). Pasting back into the field inside the same window keeps the stored NFD. | | | | | ## 6. Tab and focus in and out of the WinForms host (W48) -Tab order is: first WinForms TextBox, the playground field, second WinForms TextBox. The toolbar and the inspector +Tab order is: first WinForms TextBox, the playground field, second WinForms TextBox. The toolbar and the tabs are not tab stops (click them). | # | Steps | Expected | D10 | D48 | W48 | Notes | @@ -129,7 +132,7 @@ are not tab stops (click them). | 6.3 | Press Shift+Tab twice. | Focus returns to the field, then to the first TextBox. | n/a | n/a | | | | 6.4 | In the field press Left, Right, Up, Down, Home, End, Backspace, Delete, Ctrl+A, Ctrl+C, Ctrl+X, Ctrl+V, Ctrl+Z, Ctrl+Y. | Each acts in the field; none moves WinForms focus or beeps; Enter makes a new paragraph and does not press a default button. | n/a | n/a | | | | 6.5 | Click a toolbar button (Bold), then type. | Focus stays in the field; typing continues. | | | | | -| 6.6 | In the Many fields scenario click different fields, then Tab. | The inspector and `ws:` follow the clicked field; Tab leaves the control rather than walking through every field (note what it does). | n/a | n/a | | | +| 6.6 | In the Many fields scenario click different fields, then Tab. | The Model tab and `ws:` follow the clicked field; Tab leaves the control rather than walking through every field (note what it does). | n/a | n/a | | | | 6.7 | Alt+Tab to another program and back. | The caret returns to the field where it was; typing works at once. | | | | | | 6.8 | D10 and D48: press Tab in the field. | Focus leaves the field (the toolbar is not a tab stop), or stays; note which. | | | n/a | | @@ -150,8 +153,8 @@ only by the playground and the tests) and plain text. A FieldWorks host also cop |---|---|---|---|---|---|---| | 8.1 | Select text in *Latin and Arabic* that crosses the English/Arabic boundary, Copy, paste into Notepad. | The plain text arrives, in logical order, with paragraph breaks as line breaks. | | | | | | 8.2 | Same, paste into Word. | The text arrives. (Formatting need not.) | | | | | -| 8.3 | Type text in Notepad and in Word (with some Arabic), copy it, Paste in the field. | The text appears at the caret in the active writing system; the inspector shows it as one run; a selection is replaced. | | | | | -| 8.4 | Select bold and fr text in the field, Copy, move the caret, Paste. | The pasted runs keep their writing systems and bold (compare the inspector before and after). | | | | | +| 8.3 | Type text in Notepad and in Word (with some Arabic), copy it, Paste in the field. | The text appears at the caret in the active writing system; the Model tab shows it as one run; a selection is replaced. | | | | | +| 8.4 | Select bold and fr text in the field, Copy, move the caret, Paste. | The pasted runs keep their writing systems and bold (compare the Model tab before and after). | | | | | | 8.5 | *Unknown properties*: copy a run with 9001/9002/9003, paste it into another paragraph. | The properties come with the text, unchanged. | | | | | | 8.6 | Copy in one host window, paste in another (run `run desktop10` and `run desktop48`). | The runs and properties arrive. | | | | | | 8.7 | Cut with a selection across paragraphs, then Undo. | The text and paragraph keys return. | | | | | @@ -164,7 +167,7 @@ only by the playground and the tests) and plain text. A FieldWorks host also cop |---|---|---|---|---|---|---| | 9.1 | Drag-resize the window quickly; scroll the *Long document* with the wheel and the scrollbar. | No white flashes or tearing; text re-wraps; the caret stays on the same character. | | | | | | 9.2 | Move the window to a monitor at 150 percent (or change the scale), or set the scale to 150 percent. | Text is sharp; the caret and selection stay aligned with the glyphs; the picture is not blurry. | | | | | -| 9.3 | *Long document*: press Ctrl+End, type, press Ctrl+Home. | The view follows the caret; typing is not slower than in a short document; the inspector shows a window around the caret. | | | | | +| 9.3 | *Long document*: press Ctrl+End, type, press Ctrl+Home. | The view follows the caret; typing is not slower than in a short document; the Model tab shows a window around the caret. | | | | | | 9.4 | *Many fields* with 1000 fields (Fields picker, then pick the scenario again): scroll and type in a field far down. | The window stays usable. Record how long it takes to load and whether scrolling stutters. | | | | | | 9.5 | Select all (Ctrl+A) in the *Long document* and press B. | It finishes in a reasonable time; Undo restores it in one step. | | | | | | 9.6 | Switch Windows between light, dark and a high contrast theme (Settings, Accessibility, Contrast themes) while the playground is open, with the fields' Foreground and Background not set. | The text and page follow at once, without a pause: in high contrast the page is the system window colour, the text the window text colour and a selection the highlight colour. Record anything unreadable. A field whose host set both colours keeps them (the host's choice, docs/reference/rendering.md, "Colours and themes"). | | | | | @@ -179,9 +182,9 @@ Ctrl+Left goes to the next word. | # | Steps | Expected | D10 | D48 | W48 | Notes | |---|---|---|---|---|---|---| -| 10.1 | Click in the middle of an English word, then on the left half and the right half of one letter. | The caret appears at the nearer side of the letter; no selection; the inspector shows `selection none`. | | | | | +| 10.1 | Click in the middle of an English word, then on the left half and the right half of one letter. | The caret appears at the nearer side of the letter; no selection; the Model tab shows `selection none`. | | | | | | 10.2 | Click at the edge between English and Arabic text, first on one side of the boundary and then on the other. | The caret is drawn on the side you clicked (two different places can be the same offset); typing there continues the text you clicked next to. | | | | | -| 10.3 | Click a word, then Shift+click further on, then Shift+click before the anchor. | The selection grows from the first click, flips when you pass the anchor, and the inspector's `anchor` never moves. | | | | | +| 10.3 | Click a word, then Shift+click further on, then Shift+click before the anchor. | The selection grows from the first click, flips when you pass the anchor, and the Model tab's `anchor` never moves. | | | | | | 10.4 | Drag from the middle of one paragraph to the middle of a later one, then drag back up past the start of the document and down past the end. | Everything between is highlighted, including the whole middle paragraphs; going past the edges selects to the start or the end. | | | | | | 10.5 | Double-click an English word, an Arabic word, a space and a comma. | The word, the Arabic word, the space run and the punctuation are selected, nothing more. Double-click on the last letter of a word, right half, still picks that word. | | | | | | 10.6 | Double-click a word and, holding the second press, drag over two more words. | The selection grows a whole word at a time, in both directions. | | | | | @@ -190,8 +193,8 @@ Ctrl+Left goes to the next word. | 10.9 | From the End position in 10.8 press Up, Down, Down. | Each key moves one visual line. After a short line the caret returns to about the original horizontal position (the preferred position), not to the short line's end. | | | | | | 10.10 | Click in a wrapped line just right of its last word. | The caret is drawn at the end of that line, not at the start of the next line. | | | | | | 10.11 | Ctrl+Right and Ctrl+Left through an English sentence, then through the Arabic paragraph; add Shift. | The caret jumps a word at a time (English: to the start of the next word); at the end of a paragraph the next press enters the neighbouring paragraph; with Shift the selection follows. | | | | | -| 10.12 | Ctrl+Home, Ctrl+End, Ctrl+A, and with Shift for the first two. | The caret goes to the very start and end of the document; Ctrl+A selects everything, and the inspector shows `selection 0:0 -> last:end`. | | | | | -| 10.13 | Select across three paragraphs with the mouse and press Delete (or Backspace, or type a letter). | One paragraph is left holding the text before and after the selection; its key in the inspector is the first paragraph's key (unless the first part was empty, then the last paragraph's key). Undo restores all three. | | | | | +| 10.12 | Ctrl+Home, Ctrl+End, Ctrl+A, and with Shift for the first two. | The caret goes to the very start and end of the document; Ctrl+A selects everything, and the Model tab shows `selection 0:0 -> last:end`. | | | | | +| 10.13 | Select across three paragraphs with the mouse and press Delete (or Backspace, or type a letter). | One paragraph is left holding the text before and after the selection; its key in the Model tab is the first paragraph's key (unless the first part was empty, then the last paragraph's key). Undo restores all three. | | | | | ## 11. Host hooks: find and replace, context menu, paging (all hosts) @@ -219,8 +222,8 @@ whether completion works with an input method or Keyman active, and whether the | # | Steps | Expected | D10 | D48 | W48 | Notes | |---|---|---|---|---|---|---| -| 12.1 | Load the scenario and look at `/ [V] _ #`. | One line. The slash is outlined, `[V]` is a blue chip, `_` a purple chip, `#` an orange one, each behind the plain text; nothing changes in the inspector's runs. | | | | | -| 12.2 | Type `/ [X] _`. | `[X]` gets a red outline and a red squiggle under the X; the log shows `fwmd.env.unknown-class`. Nothing about the squiggle is in the inspector. | | | | | +| 12.1 | Load the scenario and look at `/ [V] _ #`. | One line. The slash is outlined, `[V]` is a blue chip, `_` a purple chip, `#` an orange one, each behind the plain text; nothing changes in the Model tab's runs. | | | | | +| 12.2 | Type `/ [X] _`. | `[X]` gets a red outline and a red squiggle under the X; the log shows `fwmd.env.unknown-class`. Nothing about the squiggle is in the Model tab. | | | | | | 12.3 | Rest the pointer on the class `[V]`, then on the red squiggle. | A tooltip: `Natural class V: Vowels.`; for the squiggle, the problem's message. | | | | | | 12.4 | Press Enter, then Shift+Enter. | Enter: the log says it was passed to the host; Shift+Enter does nothing. The text stays one line. | | | | | | 12.5 | Clear the box, press Ctrl+Space. | A popup under the caret offers `/`. Down, Up move the highlight; Tab accepts (focus stays in the field) and the text is `/ `. | | | | | diff --git a/docs/reference/projects.md b/docs/reference/projects.md index a0ef2c7..12cfcf8 100644 --- a/docs/reference/projects.md +++ b/docs/reference/projects.md @@ -34,15 +34,15 @@ The `ITsString` adapter and the TsString XML clipboard form. See [The LCModel ad Samples do not ship. - **`samples/SIL.TextEditing.Playground`** (netstandard2.0, net10.0): the shared playground UI. A `RichTextField`, scenario picker, - writing-system bar, format and undo buttons, block-feature switches (tables, lists, headings; ADR 0033), live model inspector and event - log. Both hosts and the integration tests use it. + writing-system bar, format and undo buttons, block-feature switches (tables, lists, headings; ADR 0033), a Markdown tab (the field as FieldWorks + Markdown, editable, applied as one undo step), a model tab and an event log. Both hosts and the integration tests use it. - **`samples/Playground.Desktop`** (net48, net10.0): plain Avalonia desktop app showing the playground. Avalonia 11.3.17 on net48, Avalonia 12 on net10.0 (Motif's shape). - **`samples/Host48`** (net48): WinForms form hosting the playground through `WinFormsAvaloniaControlHost` between two WinForms TextBoxes, Avalonia 11.3.17: FieldWorks' shape. The playground loads ready-made documents (Latin and Arabic, Indic and Thai shaping, NFD and surrogates, inline pictures, unknown -properties, an environment box with a language service, 200 paragraphs, many fields) and shows what the model holds in a live inspector. +properties, an environment box with a language service, 200 paragraphs, many fields) and shows each as FieldWorks Markdown and as the runs the model holds. ## Test projects diff --git a/samples/SIL.TextEditing.Playground/PlaygroundView.cs b/samples/SIL.TextEditing.Playground/PlaygroundView.cs index 1257030..1c9f369 100644 --- a/samples/SIL.TextEditing.Playground/PlaygroundView.cs +++ b/samples/SIL.TextEditing.Playground/PlaygroundView.cs @@ -13,12 +13,13 @@ using Avalonia.Threading; using SIL.TextEditing; using SIL.TextEditing.Avalonia; +using SIL.TextEditing.Markdown; namespace SIL.TextEditing.Playground { /// /// The shared playground: a (or many), a scenario picker, a writing-system bar, format and - /// undo buttons, copy and paste, a live inspector of the model and an event log. Both sample hosts and the + /// undo buttons, copy and paste, a Markdown tab (the field as FieldWorks Markdown, editable), a model tab (the runs) and an event log. Both sample hosts and the /// integration tests use this one control. Controls are named (see the Names class) so tests and screen /// readers can find them. /// @@ -68,6 +69,13 @@ public static class Names public const string WsLabel = "WsLabel"; public const string SelectionLabel = "SelectionLabel"; public const string Inspector = "Inspector"; + public const string InspectorTabs = "InspectorTabs"; + public const string MarkdownTab = "MarkdownTab"; + public const string ModelTab = "ModelTab"; + public const string MarkdownSource = "MarkdownSource"; + public const string MarkdownApply = "MarkdownApplyButton"; + public const string MarkdownRevert = "MarkdownRevertButton"; + public const string MarkdownStatus = "MarkdownStatus"; public const string EventLog = "EventLog"; public const string ScenarioDescription = "ScenarioDescription"; public const string FindText = "FindText"; @@ -133,6 +141,13 @@ public Session(RichTextField field, int number) readonly TextBlock _wsLabel; readonly TextBlock _selectionLabel; readonly TextBox _inspector; + readonly TextBox _markdown; + readonly TextBlock _markdownStatus; + readonly TabControl _inspectorTabs; + readonly SampleMarkdown _markdownHost; + bool _markdownEdited; + bool _settingMarkdown; + bool _markdownRefreshPosted; readonly TextBox _logBox; Session _active; bool _syncing; @@ -161,6 +176,18 @@ public PlaygroundView(string scenario) _inspector = new TextBox { Name = Names.Inspector, IsReadOnly = true, AcceptsReturn = true, TextWrapping = TextWrapping.NoWrap, FontFamily = new FontFamily("Cascadia Mono,Consolas,monospace"), FontSize = 12, IsTabStop = false }; ScrollViewer.SetHorizontalScrollBarVisibility(_inspector, ScrollBarVisibility.Auto); ScrollViewer.SetVerticalScrollBarVisibility(_inspector, ScrollBarVisibility.Auto); + _markdownHost = new SampleMarkdown(_registry); + _markdownStatus = new TextBlock { Name = Names.MarkdownStatus, TextWrapping = TextWrapping.Wrap, Foreground = Brushes.DimGray, Margin = new Thickness(2, 4) }; + _markdown = new TextBox { Name = Names.MarkdownSource, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, FontFamily = new FontFamily("Cascadia Mono,Consolas,monospace"), FontSize = 13 }; + ScrollViewer.SetVerticalScrollBarVisibility(_markdown, ScrollBarVisibility.Auto); + global::Avalonia.Automation.AutomationProperties.SetName(_markdown, "The field as FieldWorks Markdown"); + _markdown.PropertyChanged += (s, e) => + { + if (e.Property != TextBox.TextProperty || _settingMarkdown || _markdownEdited) return; + _markdownEdited = true; + _markdownStatus.Text = "Edited. Apply puts it in the field as one undo step; Revert shows the field again."; + }; + _inspectorTabs = new TabControl { Name = Names.InspectorTabs }; _logBox = new TextBox { Name = Names.EventLog, IsReadOnly = true, AcceptsReturn = true, TextWrapping = TextWrapping.NoWrap, FontFamily = new FontFamily("Cascadia Mono,Consolas,monospace"), FontSize = 12, IsTabStop = false }; ScrollViewer.SetHorizontalScrollBarVisibility(_logBox, ScrollBarVisibility.Auto); ScrollViewer.SetVerticalScrollBarVisibility(_logBox, ScrollBarVisibility.Auto); @@ -189,6 +216,19 @@ public PlaygroundView(string scenario) public SimpleUndoStack Undo => _active.Undo; public string WsLabelText => _wsLabel.Text ?? string.Empty; public string InspectorText => _inspector.Text ?? string.Empty; + /// The Markdown tab's text: the active field as FieldWorks Markdown, or what was typed there and not yet applied. + public string MarkdownText + { + get => _markdown.Text ?? string.Empty; + set => _markdown.Text = value; + } + public string MarkdownStatusText => _markdownStatus.Text ?? string.Empty; + /// True while the Markdown tab (the default) is shown; false while the model dump is. + public bool ShowsMarkdown + { + get => _inspectorTabs.SelectedIndex == 0; + set => _inspectorTabs.SelectedIndex = value ? 0 : 1; + } public IReadOnlyList EventLog => _log; /// Completes when the last copy, cut or paste started from a button has finished. public Task ClipboardIdle { get; private set; } = Task.CompletedTask; @@ -340,10 +380,13 @@ Control BuildLayout() right.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); right.RowDefinitions.Add(new RowDefinition(1, GridUnitType.Star)); var splitter = new GridSplitter { Height = 5, Background = Brushes.LightGray, ResizeDirection = GridResizeDirection.Rows, HorizontalAlignment = HorizontalAlignment.Stretch }; - Grid.SetRow(_inspector, 0); + _inspectorTabs.ItemsSource = new[] { BuildMarkdownTab(), new TabItem { Name = Names.ModelTab, Header = "Model (debug)", FontSize = 14, Content = _inspector } }; + _inspectorTabs.SelectedIndex = 0; + _inspectorTabs.SelectionChanged += (s, e) => { if (ShowsMarkdown) RefreshMarkdown(); }; + Grid.SetRow(_inspectorTabs, 0); Grid.SetRow(splitter, 1); Grid.SetRow(_logBox, 2); - right.Children.Add(_inspector); + right.Children.Add(_inspectorTabs); right.Children.Add(splitter); right.Children.Add(_logBox); var rightBorder = new Border { Margin = new Thickness(4), Child = right }; @@ -372,6 +415,161 @@ Control BuildLayout() return root; } + /// The Markdown tab: the active field as FieldWorks Markdown, which a person or an agent edits, with Apply and Revert. A host-side + /// sketch of the planned source mode (issue 170): Apply parses the text and replaces the field's paragraphs through the editor, so the change + /// is one host undo step, and an error leaves the field as it was. + TabItem BuildMarkdownTab() + { + var buttons = new StackPanel { Orientation = Orientation.Horizontal }; + buttons.Children.Add(Tool(Names.MarkdownApply, "Apply", "Read the Markdown and put it in the field as one undo step (nothing changes while it has an error)", () => ApplyMarkdown())); + buttons.Children.Add(Tool(Names.MarkdownRevert, "Revert", "Drop the edits and show the field as Markdown again", () => RevertMarkdown())); + var intro = new TextBlock + { + Text = "What a person or an AI agent edits: one line per paragraph. Unmarked text is " + SampleWritingSystems.DefaultAnalysis + + ", [text]{ws=ar} is another writing system and [text]{.v} the vernacular (" + SampleWritingSystems.DefaultVernacular + ").", + TextWrapping = TextWrapping.Wrap, + Foreground = Brushes.DimGray, + Margin = new Thickness(2, 4), + }; + var panel = new DockPanel(); + DockPanel.SetDock(intro, Dock.Top); + DockPanel.SetDock(buttons, Dock.Bottom); + DockPanel.SetDock(_markdownStatus, Dock.Bottom); + panel.Children.Add(intro); + panel.Children.Add(buttons); + panel.Children.Add(_markdownStatus); + panel.Children.Add(_markdown); + return new TabItem { Name = Names.MarkdownTab, Header = "Markdown", FontSize = 14, Content = panel }; + } + + /// Shows the active field as Markdown, unless the tab is hidden or holds edits that were neither applied nor reverted. + void RefreshMarkdown() + { + _markdownRefreshPosted = false; + if (_markdownEdited || !ShowsMarkdown) return; + _settingMarkdown = true; + try { _markdown.Text = MarkdownConverter.ToMarkdown(_active.Field.Editor.Document, _markdownHost.Options()); } + finally { _settingMarkdown = false; } + _markdownStatus.Text = string.Empty; + } + + /// After an edit in the field: the Markdown is written again once the dispatcher is idle, so a burst of typing writes it once, not per key. + void PostMarkdownRefresh() + { + if (_markdownRefreshPosted || _markdownEdited || !ShowsMarkdown) return; + _markdownRefreshPosted = true; + Dispatcher.UIThread.Post(RefreshMarkdown, DispatcherPriority.Background); + } + + /// Before the tab shows another field or scenario: edits that were not applied are dropped, and the log says so. + void DropMarkdownEdits(string why) + { + if (_markdownEdited) Log("markdown: edits not applied were dropped (" + why + ")"); + _markdownEdited = false; + } + + /// Reads the Markdown tab and, when it has no error, puts what it says in the field as one host undo step. The host applies the + /// change itself, as a delta its undo stack records (ADR 0003), so every paragraph's style, direction and properties are exactly what the + /// Markdown says. Before that it does what the field would: it refuses a change to a read-only field, to read-only text or that would give a + /// single-paragraph field more than one paragraph, makes new blocks the field does not allow plain (ADR 0033), keeps read-only marks out of + /// new text and applies the field's normalisation form. Unchanged paragraphs keep their keys (see CarryKeys). Returns true when the field changed. + public bool ApplyMarkdown() + { + var editor = _active.Field.Editor; + var result = MarkdownConverter.Parse(MarkdownText, _markdownHost.Options()); + var errors = result.Diagnostics.Where(d => d.Severity == MarkdownSeverity.Error).ToList(); + if (errors.Count > 0) + return NotApplied(string.Join("; ", errors.Select(d => d.Id + " at line " + d.Line + ", column " + d.Column + ": " + d.Message)), + errors.Count + " error(s): " + string.Join(", ", errors.Select(d => d.Id))); + + var old = editor.Document.Paragraphs; + var paragraphs = result.Document.Paragraphs.ToList(); + var fresh = paragraphs.Where(n => !old.Any(o => MarkdownConverter.AreEquivalent(o, n))).ToList(); + var flattening = BlockFlattener.Flatten(fresh, editor.BlockFeatures, editor.BlockRecognizer); + if (MarkdownConverter.AreEquivalent(old, paragraphs)) + { + _markdownEdited = false; + RefreshMarkdown(); + _markdownStatus.Text = "No change: the Markdown says what the field already holds." + Findings(result); + Log("markdown: no change"); + return false; + } + int last = old.Count - 1; + if (editor.IsReadOnly) return NotApplied("the field is read-only.", "the field is read-only"); + if (!editor.CanEditRange(new TextPosition(0, 0), new TextPosition(last, old[last].Length))) + return NotApplied("the field holds read-only text, which an edit as Markdown would replace.", "read-only text"); + if (editor.SingleParagraph && paragraphs.Count > 1) + return NotApplied("the field holds one paragraph and the Markdown has " + paragraphs.Count + ".", "one paragraph only"); + + // What the field does to anything inserted: new text cannot bring read-only marks in, and takes the field's normalisation form. + foreach (var p in fresh) + foreach (var r in p.Runs) r.Props.IntProps.Remove(TextPropertyNumbers.Editable); + if (editor.Document.Normalization is System.Text.NormalizationForm form) + foreach (var p in paragraphs) + foreach (var r in p.Runs) + if (!r.IsObject) r.Text = TextNormalizer.Normalize(r.Text, form); + CarryKeys(old, paragraphs); + var delta = new EditDelta + { + Start = 0, + Before = old.Select(o => o.Clone()).ToList(), + After = paragraphs, + CaretBefore = editor.Caret, + CaretAfter = new TextPosition(0, 0), + Label = "edit as Markdown", + }; + editor.Apply(delta); + _active.Undo.Done.Add(delta); + _active.Undo.Undone.Clear(); + _markdownEdited = false; + RefreshMarkdown(); + _markdownStatus.Text = "Applied as one undo step." + (flattening.Total > 0 ? " The field does not allow " + flattening + "; they are now plain paragraphs." : string.Empty) + Findings(result); + Log("markdown: applied as one undo step" + (flattening.Total > 0 ? ", " + flattening + " made plain" : string.Empty)); + return true; + } + + /// Keys go with paragraphs: a new paragraph equivalent to an old one, matched in order, takes its key; a changed paragraph between two + /// matches takes the key of the old paragraph at the same place between them when there is one; any other paragraph has none for the host to assign. + static void CarryKeys(IReadOnlyList old, List paragraphs) + { + var taken = new bool[old.Count]; + var match = new int[paragraphs.Count]; + int from = 0; + for (int i = 0; i < paragraphs.Count; i++) + { + match[i] = -1; + for (int j = from; j < old.Count; j++) + if (MarkdownConverter.AreEquivalent(old[j], paragraphs[i])) { match[i] = j; taken[j] = true; from = j + 1; break; } + } + int prev = -1; + for (int i = 0; i < paragraphs.Count; i++) + { + if (match[i] >= 0) { paragraphs[i].Key = old[match[i]].Key; prev = match[i]; continue; } + int next = old.Count; + for (int k = i + 1; k < paragraphs.Count; k++) if (match[k] >= 0) { next = match[k]; break; } + int candidate = prev + 1; + if (candidate < next && candidate < old.Count && !taken[candidate]) { paragraphs[i].Key = old[candidate].Key; taken[candidate] = true; prev = candidate; } + else paragraphs[i].Key = null; + } + } + + bool NotApplied(string status, string log) + { + _markdownStatus.Text = "Not applied: " + status; + Log("markdown: not applied, " + log); + return false; + } + + static string Findings(MarkdownParseResult result) => + result.Diagnostics.Count > 0 ? " Findings: " + string.Join(", ", result.Diagnostics.Select(d => d.Id)) + "." : string.Empty; + + /// Drops edits in the Markdown tab and shows the field again. + public void RevertMarkdown() + { + _markdownEdited = false; + RefreshMarkdown(); + } + // ------------------------------------------------------------ find and replace (the host's dialog, ADR 0021) Control BuildFindBar() @@ -665,6 +863,8 @@ public void LoadScenario(string name) UpdateFindHighlight(); UpdateWsLabel(); RefreshInspector(); + DropMarkdownEdits("scenario loaded"); + RefreshMarkdown(); if (TopLevel.GetTopLevel(this) != null) Dispatcher.UIThread.Post(() => _active.Field.Focus()); } @@ -680,8 +880,8 @@ void AddSession(Document document, int number, bool many) PreferredWidth = many ? 300 : 500, Margin = new Thickness(many ? 2 : 6), }; - // The playground has no liblcm and no Markdown converter, so its one rich clipboard form is the field's debug dump - // (a real host registers TsString and FieldWorks Markdown, ADR 0029). + // The playground has no liblcm and registers no FieldWorks Markdown clipboard form (issue 168), so its one rich clipboard form is the + // field's debug dump (a real host registers TsString and FieldWorks Markdown, ADR 0029). The Markdown tab shows the field as Markdown. var dump = new InterchangeClipboardForm(); field.ClipboardForms.Add(dump).PasteOrder.Insert(0, dump.Id); field.WritingSystemsMapped += (o, mapped) => Log("clipboard: notice, writing system(s) " + string.Join(", ", mapped.Missing.Select(m => m.Length == 0 ? "(none)" : m)) @@ -725,7 +925,7 @@ void AddSession(Document document, int number, bool many) if (_active == session && d.Label != "type") Log("edit: " + d.Label + " (paragraphs " + d.Start + ".." + (d.Start + Math.Max(d.Before.Count, d.After.Count) - 1) + ")"); }; field.Editor.UndoGroupEnded += id => { if (_active == session) Log("undo step " + id + " ended: the host closes its undo task"); }; - field.Editor.DocumentChanged += (a, b, c) => { if (_active == session) { RefreshInspector(); UpdateEnvironmentButtons(); } }; + field.Editor.DocumentChanged += (a, b, c) => { if (_active == session) { RefreshInspector(); PostMarkdownRefresh(); UpdateEnvironmentButtons(); } }; field.Editor.CaretMoved += () => { if (_active == session) { UpdateWsLabel(); RefreshInspector(); UpdateEnvironmentButtons(); } }; field.LanguageAnalysisChanged += (o, e) => { @@ -765,6 +965,8 @@ void Activate(Session session) Log("focus: field " + session.Number); UpdateWsLabel(); RefreshInspector(); + DropMarkdownEdits("focus moved to field " + session.Number); + RefreshMarkdown(); } // ------------------------------------------------------------ spelling diff --git a/samples/SIL.TextEditing.Playground/SIL.TextEditing.Playground.csproj b/samples/SIL.TextEditing.Playground/SIL.TextEditing.Playground.csproj index a8921e7..824c04e 100644 --- a/samples/SIL.TextEditing.Playground/SIL.TextEditing.Playground.csproj +++ b/samples/SIL.TextEditing.Playground/SIL.TextEditing.Playground.csproj @@ -11,5 +11,6 @@ + diff --git a/samples/SIL.TextEditing.Playground/SampleMarkdown.cs b/samples/SIL.TextEditing.Playground/SampleMarkdown.cs new file mode 100644 index 0000000..50991f7 --- /dev/null +++ b/samples/SIL.TextEditing.Playground/SampleMarkdown.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using SIL.TextEditing.Avalonia; +using SIL.TextEditing.Markdown; + +namespace SIL.TextEditing.Playground +{ + /// What the playground tells the FieldWorks Markdown converter: unmarked text is the default analysis (en), .v is the default + /// vernacular (swh), and the known writing systems are the registry's. The Markdown pane writes the body only: the front matter would list + /// the same writing systems, and a host supplies them. Sample data only. + public sealed class SampleMarkdown : IWritingSystemMapper, IKnownWritingSystems + { + readonly WritingSystemRegistry _registry; + + public SampleMarkdown(WritingSystemRegistry registry) + { + _registry = registry; + KnownTags = SampleWritingSystems.All.Select(w => w.Tag).ToList(); + } + + public string DefaultAnalysis => SampleWritingSystems.DefaultAnalysis; + public string DefaultVernacular => SampleWritingSystems.DefaultVernacular; + public bool IsKnown(string tag) => _registry.Contains(tag); + public IReadOnlyList KnownTags { get; } + + /// The options the Markdown pane writes and reads with. The style names are the playground's stylesheet's (SampleStyles): its plain + /// paragraphs have no style name, where a FieldWorks project's have "Normal". + public MarkdownOptions Options() => new MarkdownOptions + { + WritingSystems = this, + WriteFrontMatter = false, + StyleNames = new MarkdownStyleNames + { + Normal = string.Empty, + Heading1 = "Heading 1", + BulletedList = "List Bullet", + NumberedList = "List Number", + BlockQuote = "Block Quote", + TableHeader = "Table Header", + TableRow = "Table Row", + }, + }; + } +} diff --git a/samples/SIL.TextEditing.Playground/Scenarios.cs b/samples/SIL.TextEditing.Playground/Scenarios.cs index 020957e..5e0d27b 100644 --- a/samples/SIL.TextEditing.Playground/Scenarios.cs +++ b/samples/SIL.TextEditing.Playground/Scenarios.cs @@ -50,7 +50,7 @@ public static class Scenarios { new Scenario(MixedBidi, "An LTR paragraph with Arabic inside, an RTL paragraph with Latin and digits inside, and a plain Arabic one. Try arrows, mouse selection and typing at the boundaries.", _ => One(MixedDoc())), new Scenario(IndicThai, "Hindi conjuncts and vowel signs, Tamil, and Thai with stacked marks. Carets must stop on clusters, never inside one.", _ => One(IndicDoc())), - new Scenario(Unicode, "Decomposed accents (NFD) next to composed ones, stacked combining marks, surrogate pairs, emoji sequences and private-use characters. The inspector shows the exact code units.", _ => One(Stored(UnicodeDoc(), System.Text.NormalizationForm.FormD))), + new Scenario(Unicode, "Decomposed accents (NFD) next to composed ones, stacked combining marks, surrogate pairs, emoji sequences and private-use characters. The Model tab shows the exact code units.", _ => One(Stored(UnicodeDoc(), System.Text.NormalizationForm.FormD))), new Scenario(Picture, "Inline picture objects (U+FFFC with object data), in LTR text, in RTL text and side by side. Backspace or Delete removes a picture whole. Use Picture to insert one.", _ => One(PictureDoc())), new Scenario(Drawing, "Every drawing property of a FieldWorks text string, from the run's own properties and from the host's named styles (the playground keeps a small stylesheet). Pick a style in the toolbar to apply it.", _ => One(DrawingDoc())), new Scenario(Paragraphs, "Every paragraph drawing property from the host's paragraph styles and from a paragraph's own properties: alignment, first-line and hanging indents, margins, spacing, line height, colour, borders, a line limit, bullets and numbers. Pick a paragraph style in the toolbar to try one on the paragraph at the caret.", _ => One(ParagraphsDoc())), @@ -60,7 +60,7 @@ public static class Scenarios new Scenario(Environment, "A phonological environment, the way FieldWorks writes one: a slash, the left context, an underscore and the right context, with natural classes in square brackets, segments, optional items in parentheses and the word boundary #. The box is one line (Enter goes to the host) and its text is the stored value: the colours are chips drawn behind plain text (in a light or a dark theme), problems are squiggles that are never saved in the text, and a host language service (the sample, over a made-up phonology: segments p t k b d g m n s l r h a e i o u ai ts; classes C, V, +son and +lab, +vd) explains them. Hover for help; Ctrl+Space, or typing [, offers completions (arrows, Enter or Tab, Esc); right-click a problem for its fix; the Insert buttons are enabled as FieldWorks' Insert menu is. Try / [X] _ , / (a _ , / _ _ , / chr _ , then Drop class V and Restore class V.", _ => One(EnvironmentDoc())), new Scenario(Long, "200 paragraphs in mixed writing systems: scrolling, caret at the far end, and cost of edits in a long document.", _ => One(LongDoc(200))), new Scenario(FindReplace, "Text for the find bar: repeated words in several cases, an accented word stored composed, decomposed and bare, words in two writing systems, a picture, and a read-only label. Try Match case, Match diacritics, Whole word, a regular expression, Highlight all, Replace and Replace All (one undo step). Right-click a word, a picture or a selection to see what a context menu is told.", _ => One(FindDoc())), - new Scenario(ManyFields, "N small fields in one window: tab between them by clicking, undo is per field, the inspector follows the focused field.", ManyDocs, many: true), + new Scenario(ManyFields, "N small fields in one window: tab between them by clicking, undo is per field, the Markdown and Model tabs follow the focused field.", ManyDocs, many: true), new Scenario(Empty, "One empty paragraph: type from scratch.", _ => One(EmptyDoc())), }; @@ -304,7 +304,7 @@ static Run FontFeatures(string text, string? features) static Document OpaqueDoc() { - var styled = Para("en", false, "en", "A paragraph with a style (see the inspector)."); + var styled = Para("en", false, "en", "A paragraph with a style (see the Model tab)."); styled.Style = "Block Quote"; var p = new Paragraph { DefaultWs = "en" }; diff --git a/tests/SIL.TextEditing.Playground.Tests/MarkdownTabTests.cs b/tests/SIL.TextEditing.Playground.Tests/MarkdownTabTests.cs new file mode 100644 index 0000000..7b88c53 --- /dev/null +++ b/tests/SIL.TextEditing.Playground.Tests/MarkdownTabTests.cs @@ -0,0 +1,201 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Xunit; + +namespace SIL.TextEditing.Playground.Tests +{ + /// The playground's Markdown tab: the field as FieldWorks Markdown, which a person or an agent edits and applies as one undo step. + public class MarkdownTabTests + { + const string BookLine = "The word [كتاب]{ws=ar} means book; [مرحبا بالعالم]{ws=ar} means hello world."; + + public static IEnumerable ScenarioNames() => Scenarios.All.Where(s => !s.Many).Select(s => new object[] { s.Name }); + + [Fact] + public void The_markdown_tab_is_shown_first_and_holds_the_field_one_line_per_paragraph() => Headless.Run(() => + { + using (var s = new Session(Scenarios.MixedBidi)) + { + Assert.True(s.View.ShowsMarkdown); + var lines = s.View.MarkdownText.Split('\n').Where(l => l.Length > 0).ToList(); + Assert.Equal(BookLine, lines[0]); + // The two right-to-left paragraphs share one div that says so; the others are plain lines. + Assert.Equal(new[] + { + BookLine, + "::: {rightToLeft=1}", + "[هذه جملة عربية فيها ]{ws=ar}English words[ و ]{ws=ar}1234[ أرقام.]{ws=ar}", + "[فقرة عربية قصيرة.]{ws=ar}", + ":::", + "Plain English to finish, with an [expression française]{ws=fr} in the middle.", + }, lines); + Assert.DoesNotContain("key=", s.View.MarkdownText); + } + }); + + [Fact] + public void Typing_in_the_field_updates_the_markdown() => Headless.Run(() => + { + using (var s = new Session(Scenarios.MixedBidi)) + { + s.Caret(0, 0); + s.Type("Look: "); + Headless.Pump(); // the Markdown is written again once the dispatcher is idle + Assert.StartsWith("Look: The word [كتاب]{ws=ar}", s.View.MarkdownText); + } + }); + + [Fact] + public void An_edit_to_the_markdown_applies_as_one_undo_step_and_undo_restores_the_field() => Headless.Run(() => + { + using (var s = new Session(Scenarios.MixedBidi)) + { + var before = s.ParagraphText(0); + int done = s.View.Undo.Done.Count; + // What an agent does: change the gloss and mark a vernacular word, as text. + s.View.MarkdownText = s.View.MarkdownText.Replace("means book;", "means [kitabu]{.v};"); + Assert.Contains("Edited", s.View.MarkdownStatusText); + s.Click(PlaygroundView.Names.MarkdownApply); + + Assert.Contains("means kitabu;", s.ParagraphText(0)); + var run = s.Editor.Document.Paragraphs[0].Runs.Single(r => r.Text == "kitabu"); + Assert.Equal(SampleWritingSystems.DefaultVernacular, run.Ws); + Assert.Equal(done + 1, s.View.Undo.Done.Count); + Assert.Equal("edit as Markdown", s.View.Undo.Done[s.View.Undo.Done.Count - 1].Label); + Assert.Contains("Applied as one undo step", s.View.MarkdownStatusText); + Assert.Contains("[kitabu]{.v}", s.View.MarkdownText); + + Assert.True(s.View.UndoLast()); + Headless.Pump(); + Assert.Equal(before, s.ParagraphText(0)); + Assert.Equal(BookLine, s.View.MarkdownText.Split('\n')[0]); + } + }); + + [Fact] + public void Markdown_with_an_error_changes_nothing_and_keeps_what_was_typed() => Headless.Run(() => + { + using (var s = new Session(Scenarios.MixedBidi)) + { + var before = s.ParagraphText(0); + int done = s.View.Undo.Done.Count; + var broken = s.View.MarkdownText.Replace("{ws=ar} means book", "{} means book"); + s.View.MarkdownText = broken; + s.Click(PlaygroundView.Names.MarkdownApply); + + Assert.Equal(before, s.ParagraphText(0)); + Assert.Equal(done, s.View.Undo.Done.Count); + Assert.StartsWith("Not applied: fwmd.", s.View.MarkdownStatusText); + Assert.Equal(broken, s.View.MarkdownText); + + s.Click(PlaygroundView.Names.MarkdownRevert); + Assert.Equal(BookLine, s.View.MarkdownText.Split('\n')[0]); + } + }); + + [Fact] + public void A_heading_made_on_the_first_line_is_applied_and_undone_exactly() => Headless.Run(() => + { + using (var s = new Session(Scenarios.MixedBidi)) + { + var key = s.ParagraphKey(0); + s.View.MarkdownText = "# " + s.View.MarkdownText; + Assert.True(s.View.ApplyMarkdown(), s.View.MarkdownStatusText); + Assert.Equal("Heading 1", s.Editor.Document.Paragraphs[0].Style); + Assert.Equal(key, s.ParagraphKey(0)); + Assert.StartsWith("# The word", s.View.MarkdownText); + + Assert.True(s.View.UndoLast()); + Headless.Pump(); + Assert.Equal(string.Empty, s.Editor.Document.Paragraphs[0].Style); + Assert.Equal(BookLine, s.View.MarkdownText.Split('\n')[0]); + } + }); + + [Fact] + public void A_heading_the_field_does_not_allow_is_made_plain() => Headless.Run(() => + { + using (var s = new Session(Scenarios.MixedBidi)) + { + s.Editor.BlockFeatures = BlockFeatures.All & ~BlockFeatures.Headings; + s.View.MarkdownText = s.View.MarkdownText + "\n# A new heading\n"; + Assert.True(s.View.ApplyMarkdown(), s.View.MarkdownStatusText); + var added = s.Editor.Document.Paragraphs[s.Editor.Document.Paragraphs.Count - 1]; + Assert.Equal("A new heading", added.Text); + Assert.Equal(string.Empty, added.Style); + Assert.Contains("now plain paragraphs", s.View.MarkdownStatusText); + } + }); + + [Fact] + public void An_edit_that_would_replace_read_only_text_is_refused() => Headless.Run(() => + { + using (var s = new Session(Scenarios.ReadOnlyRuns)) + { + int done = s.View.Undo.Done.Count; + var before = s.ParagraphText(0); + var edited = s.View.MarkdownText + "\nOne more line.\n"; + s.View.MarkdownText = edited; + Assert.False(s.View.ApplyMarkdown()); + Assert.Contains("read-only text", s.View.MarkdownStatusText); + Assert.Equal(done, s.View.Undo.Done.Count); + Assert.Equal(before, s.ParagraphText(0)); + Assert.Equal(edited, s.View.MarkdownText); + } + }); + + [Fact] + public void A_line_added_at_the_top_has_no_key_and_every_other_paragraph_keeps_its_own() => Headless.Run(() => + { + using (var s = new Session(Scenarios.MixedBidi)) + { + var keys = s.Editor.Document.Paragraphs.Select(p => p.Key).ToList(); + s.View.MarkdownText = "A new first line.\n\n" + s.View.MarkdownText; + Assert.True(s.View.ApplyMarkdown(), s.View.MarkdownStatusText); + Assert.Null(s.ParagraphKey(0)); + Assert.Equal(keys, s.Editor.Document.Paragraphs.Skip(1).Select(p => p.Key).ToList()); + } + }); + + [Fact] + public void Applied_text_takes_the_field_s_normalisation_form_and_no_read_only_mark() => Headless.Run(() => + { + using (var s = new Session(Scenarios.MixedBidi)) + { + s.Editor.Document.Normalization = System.Text.NormalizationForm.FormC; + // The reader gives NFD; a field stored NFC must get the composed letter. "editable=no" marks the run read-only. + s.View.MarkdownText = s.View.MarkdownText + "\n[cafe\u0301]{editable=no}\n"; + Assert.True(s.View.ApplyMarkdown(), s.View.MarkdownStatusText); + var added = s.Editor.Document.Paragraphs[s.Editor.Document.Paragraphs.Count - 1]; + Assert.Equal("caf\u00E9", added.Text); + Assert.All(added.Runs, r => Assert.False(r.Props.IntProps.ContainsKey(TextPropertyNumbers.Editable))); + } + }); + + [Theory] + [MemberData(nameof(ScenarioNames))] + public void Applying_the_markdown_unchanged_makes_no_undo_step(string name) => Headless.Run(() => + { + using (var s = new Session(name)) + { + int done = s.View.Undo.Done.Count; + Assert.False(s.View.ApplyMarkdown(), s.View.MarkdownStatusText); + Assert.Equal(done, s.View.Undo.Done.Count); + Assert.Contains("No change", s.View.MarkdownStatusText); + } + }); + + [Fact] + public void The_model_tab_still_shows_the_runs() => Headless.Run(() => + { + using (var s = new Session(Scenarios.MixedBidi)) + { + s.View.ShowsMarkdown = false; + Assert.False(s.View.ShowsMarkdown); + Assert.Contains("ws=ar", s.Inspector); + Assert.NotNull(s.Find(PlaygroundView.Names.ModelTab)); + } + }); + } +} diff --git a/tests/SIL.TextEditing.Playground.Tests/ScreenshotGenerator.cs b/tests/SIL.TextEditing.Playground.Tests/ScreenshotGenerator.cs index ae0bb10..3b226b1 100644 --- a/tests/SIL.TextEditing.Playground.Tests/ScreenshotGenerator.cs +++ b/tests/SIL.TextEditing.Playground.Tests/ScreenshotGenerator.cs @@ -83,7 +83,7 @@ static Rect From(Session s, Control control, double height) } /// The strip that starts at the field and the inspector beside it (the inspector, because a long field is scrolled and its own top is out of sight). - static Rect FieldStrip(Session s, double height) => From(s, s.Find(PlaygroundView.Names.Inspector), height); + static Rect FieldStrip(Session s, double height) => From(s, s.Find(PlaygroundView.Names.InspectorTabs), height); static void Select(Session s, int paragraph, string from, string to) { @@ -145,6 +145,8 @@ public void Unknown_properties_in_the_inspector() => Headless.Run(() => var text = s.ParagraphText(1); int at = text.IndexOf("unknown", StringComparison.Ordinal); s.Select(1, at, at + 7); + // The model tab: the run dump shows the property numbers the editor carries without understanding them. + s.View.ShowsMarkdown = false; Save(s, "unknown-properties-inspector", FieldStrip(s, 345)); } });