diff --git a/README.md b/README.md index 06c2010..b750847 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The same text has four forms, and only the first two are meant to be edited by h |---|---|---| | **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 | +| **`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 developer mode dumps it in its "Model (debug)" tab | | **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: @@ -55,7 +55,7 @@ The same text has four forms, and only the first two are meant to be edited by h 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. +the playground, edit the Markdown beside the field and press Apply; `MarkdownTabTests` makes this very edit. ## Why it's different @@ -107,9 +107,13 @@ run winforms48 # WinForms form: TextBox, playground, TextBox (FieldWorks' sha 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). 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`. +`run.cmd` and `run.ps1` build the host and start it. The playground shows what a person writing sees: a scenario picker, the field, and +beside it the field as FieldWorks Markdown, which you can edit and apply. Select some text and a small bar appears over it: bold, italic, +underline, writing system and character style, and under **More** the paragraph style and a picture. **Developer mode** (the check box, or +`--dev`) adds every switch, the model's runs and the event log. The scenarios: bidi, Indic and Thai shaping, NFD, inline pictures, unknown +properties, spelling, the environment box, long documents. The Pandoc tests need `pandoc` on `PATH`. + +![The selection bar with More open, over French text](docs/images/selection-bar.png) ## Projects @@ -150,7 +154,7 @@ Planned, not built: - **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 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)). + Markdown pane 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 f63cdfc..82d2cdd 100644 --- a/docs/images/README.md +++ b/docs/images/README.md @@ -10,11 +10,12 @@ Rendered by `tests/SIL.TextEditing.Playground.Tests/ScreenshotGenerator.cs`: a 1 $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), with the same text as FieldWorks Markdown in the Markdown tab. +- `bidi-selection.png`: Latin and Arabic in one paragraph, one selection running across the direction changes ("Latin and Arabic" scenario), the selection bar over it, and the same text as FieldWorks Markdown beside it. +- `selection-bar.png`: the selection bar over French text with More open: bold, italic, underline, writing system, character style, then paragraph style and Picture. - `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 Model tab showing the runs and their property numbers. +- `unknown-properties-inspector.png`: runs that carry properties the editor does not understand (9001, 9002), a paragraph style and a character style, and, in developer mode, 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. diff --git a/docs/images/bidi-selection.png b/docs/images/bidi-selection.png index dba14e7..2f42237 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 91433b6..7897109 100644 Binary files a/docs/images/block-features.png and b/docs/images/block-features.png differ diff --git a/docs/images/capture-hosts.ps1 b/docs/images/capture-hosts.ps1 index afd8f7d..5bb42e4 100644 --- a/docs/images/capture-hosts.ps1 +++ b/docs/images/capture-hosts.ps1 @@ -10,7 +10,7 @@ param( [string]$Scenario = 'Latin and Arabic', [int]$Width = 1280, - [int]$Height = 800 + [int]$Height = 700 # fits a 768-pixel-high screen at y=20; a window that runs off the screen copies blank pixels ) $ErrorActionPreference = 'Stop' $root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path diff --git a/docs/images/copy-as-submenu.png b/docs/images/copy-as-submenu.png index ed1d2c2..f82b2ad 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 dcb3a1e..4937295 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 16714f2..b1ac95c 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 75710b2..8d279d0 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 6f9ab4e..3eb7da8 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 0ef21e1..e54a6af 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 ebedfec..875e3a2 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 8ffe267..4234b2d 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 8659c15..3a36cd7 100644 Binary files a/docs/images/many-fields.png and b/docs/images/many-fields.png differ diff --git a/docs/images/selection-bar.png b/docs/images/selection-bar.png new file mode 100644 index 0000000..d37e6c8 Binary files /dev/null and b/docs/images/selection-bar.png differ diff --git a/docs/images/spelling-context-menu.png b/docs/images/spelling-context-menu.png index c3d8746..800dfe5 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 5d08bb7..f16fb95 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 b30a9d4..6324eff 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 f9fcdff..c45abc7 100644 --- a/docs/manual-testing.md +++ b/docs/manual-testing.md @@ -25,7 +25,12 @@ Where a step says **all hosts**, do it on each of the three and write one result ## The window -* **Top**: scenario picker; field count (for the Many fields scenario); **B I U**; Undo, Redo; Picture, Cut, Copy, +Turn on **Developer mode** (the check box at the top right, or start the host with `--dev`): most steps below use its controls. Without it +the window shows only the scenario picker, the field, the selection bar and the Markdown. + +* **Selection bar**: select text and it appears above the selection: **B I U**, the writing system, the character style, and under + **More** the paragraph style and Picture. It never takes focus from the field and goes away when the selection does. +* **Top** (developer mode): field count (for the Many fields scenario); **B I U**; Undo, Redo; Picture, Cut, Copy, Paste. The toolbar never takes focus from the field. * **Writing-system bar**: `ws:` shows the writing system at the caret. Default analysis (en) and default vernacular (swh) buttons, then every sample writing system: en, fr (analysis) and ar, swh, hi, ta, th (vernacular). With a @@ -36,7 +41,7 @@ Where a step says **all hosts**, do it on each of the three and write one result 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, + direction, style and runs. A run reads `r1 [9,13) ws=ar "text" props{3=1@3, 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. * **Event log** (bottom right): `caret ws en -> ar: would switch keyboard to ar` (the library only reports; it never @@ -52,7 +57,7 @@ Where a step says **all hosts**, do it on each of the three and write one result | 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 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.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@3}` (bold), `2=1@3` (italic), `5=1@3` (underline), and the Markdown tab `**word**`, `*word*` and `[word]{underline=single}`; 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. | | | | | diff --git a/docs/reference/projects.md b/docs/reference/projects.md index 12cfcf8..c60d065 100644 --- a/docs/reference/projects.md +++ b/docs/reference/projects.md @@ -33,9 +33,11 @@ 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), 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/SIL.TextEditing.Playground`** (netstandard2.0, net10.0): the shared playground UI. By default a scenario picker, the + `RichTextField`, a selection bar over the selection (bold, italic, underline, writing system, character style; under More the paragraph + style and a picture) and the field as FieldWorks Markdown, editable and applied as one undo step. Developer mode adds the writing-system bar, + format and undo buttons, block-feature switches (tables, lists, headings; ADR 0033), find and replace, a model tab and an event log. Both + hosts and the integration tests use it; the tests run in developer mode unless they test the default. - **`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, diff --git a/samples/Host48/Program.cs b/samples/Host48/Program.cs index 5372287..07d4cd0 100644 --- a/samples/Host48/Program.cs +++ b/samples/Host48/Program.cs @@ -58,13 +58,13 @@ bool FieldWants(Keys keyData) static class Program { /// FieldWorks' shape: a WinForms form, the playground embedded between two WinForms TextBoxes, to try Tab - /// and key handoff and focus. Options: --scenario "name", --minimized. + /// and key handoff and focus. Options: --scenario "name", --dev (developer mode), --minimized. [STAThread] static void Main(string[] args) { AppBuilder.Configure().UsePlatformDetect().LogToTrace().SetupWithoutStarting(); int s = Array.IndexOf(args, "--scenario"); - var view = s >= 0 && s + 1 < args.Length ? new PlaygroundView(args[s + 1]) : new PlaygroundView(); + var view = new PlaygroundView(s >= 0 && s + 1 < args.Length ? args[s + 1] : Scenarios.MixedBidi, args.Contains("--dev")); var form = new Form { Text = "SIL.TextEditing playground in WinForms (net48, Avalonia " + PlaygroundApp.AvaloniaVersion + ")", diff --git a/samples/Playground.Desktop/Program.cs b/samples/Playground.Desktop/Program.cs index 505a7b8..fe2b478 100644 --- a/samples/Playground.Desktop/Program.cs +++ b/samples/Playground.Desktop/Program.cs @@ -7,7 +7,7 @@ namespace SIL.TextEditing.Samples.PlaygroundDesktop { - /// Options: --scenario "name" opens that scenario; --minimized starts minimized without + /// Options: --scenario "name" opens that scenario; --dev opens in developer mode; --minimized starts minimized without /// taking focus (for smoke runs). sealed class DesktopApp : PlaygroundApp { @@ -17,7 +17,7 @@ public override void OnFrameworkInitializationCompleted() { var args = desktop.Args ?? new string[0]; int s = Array.IndexOf(args, "--scenario"); - var view = s >= 0 && s + 1 < args.Length ? new PlaygroundView(args[s + 1]) : new PlaygroundView(); + var view = new PlaygroundView(s >= 0 && s + 1 < args.Length ? args[s + 1] : Scenarios.MixedBidi, args.Contains("--dev")); var window = new Window { Title = "SIL.TextEditing playground (" + RuntimeLabel + ", Avalonia " + AvaloniaVersion + ")", diff --git a/samples/SIL.TextEditing.Playground/PlaygroundView.SelectionBar.cs b/samples/SIL.TextEditing.Playground/PlaygroundView.SelectionBar.cs new file mode 100644 index 0000000..1d42506 --- /dev/null +++ b/samples/SIL.TextEditing.Playground/PlaygroundView.SelectionBar.cs @@ -0,0 +1,161 @@ +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Primitives.PopupPositioning; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.LogicalTree; +using SIL.TextEditing.Avalonia; + +namespace SIL.TextEditing.Playground +{ + // The selection bar: a small toolbar that floats above the selection while there is one, the way word processors show their mini toolbar. + // The first row is the simple set (bold, italic, underline, writing system, character style); "More" opens the second row with the rest + // (paragraph style, a picture). It is the host's control, built on Avalonia's Popup: the field only says where the caret is (CaretRect). + public sealed partial class PlaygroundView + { + Popup _selectionBar = null!; + StackPanel _selectionMore = null!; + ComboBox _selectionWs = null!; + int _selectionBarGeneration; // a posted open runs only if nothing has hidden or moved the bar since it was posted + + /// True while the selection bar is shown over the active field's selection. + public bool SelectionBarOpen => _selectionBar.IsOpen; + + /// Whether the bar appears over a selection (default true). + public bool SelectionBarEnabled + { + get => _selectionBarEnabled; + set + { + _selectionBarEnabled = value; + UpdateSelectionBar(); + } + } + + bool _selectionBarEnabled = true; + + /// True while the bar's second row (paragraph style, picture) is open. + public bool SelectionBarExpanded => _selectionMore.IsVisible; + + Popup BuildSelectionBar() + { + var simple = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2 }; + simple.Children.Add(BarTool(Names.BarBold, new TextBlock { Text = "B", FontWeight = FontWeight.Bold }, "Bold (Ctrl+B)", () => ToggleBold())); + simple.Children.Add(BarTool(Names.BarItalic, new TextBlock { Text = "I", FontStyle = FontStyle.Italic }, "Italic (Ctrl+I)", () => ToggleItalic())); + simple.Children.Add(BarTool(Names.BarUnderline, new TextBlock { Text = "U", TextDecorations = TextDecorations.Underline }, "Underline (Ctrl+U)", () => ToggleUnderline())); + _selectionWs = new ComboBox + { + Name = Names.BarWritingSystem, + MinWidth = 120, + IsTabStop = false, + ItemsSource = SampleWritingSystems.All.Select(w => w.Tag + " " + w.Name).ToList(), + }; + ToolTip.SetTip(_selectionWs, "Writing system of the selection"); + _selectionWs.SelectionChanged += (s, e) => + { + if (_syncing || _selectionWs.SelectedIndex < 0) return; + SetWritingSystem(SampleWritingSystems.All[_selectionWs.SelectedIndex].Tag); + _active.Field.Focus(); + }; + _selectionWs.DropDownClosed += (s, e) => _active.Field.Focus(); // Esc too: typing goes back to the field + simple.Children.Add(_selectionWs); + simple.Children.Add(StyleBox(Names.BarCharStyle, SampleStyles.CharacterStyles, "Character style", name => ApplyCharacterStyle(name), "Style")); + var more = new ToggleButton { Name = Names.BarMore, Content = "More", Focusable = false, Padding = new Thickness(8, 3) }; + ToolTip.SetTip(more, "More: paragraph style and pictures"); + simple.Children.Add(more); + + _selectionMore = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2, IsVisible = false, Margin = new Thickness(0, 2, 0, 0) }; + _selectionMore.Children.Add(StyleBox(Names.BarParaStyle, SampleStyles.ParagraphStyles, "Paragraph style of the selected paragraphs", name => ApplyParagraphStyle(name), "Paragraph style")); + _selectionMore.Children.Add(BarTool(Names.BarPicture, "Picture", "Put a picture in place of the selection", () => InsertPicture())); + more.IsCheckedChanged += (s, e) => _selectionMore.IsVisible = more.IsChecked == true; + + var rows = new StackPanel(); + rows.Children.Add(simple); + rows.Children.Add(_selectionMore); + var frame = new Border + { + Child = rows, + Background = Brushes.White, + BorderBrush = Brushes.Silver, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(6), + Padding = new Thickness(4), + BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 2, Blur = 8, Color = Color.FromArgb(60, 0, 0, 0) }), + }; + global::Avalonia.Automation.AutomationProperties.SetName(frame, "Formatting"); + var popup = new Popup + { + Name = Names.SelectionBar, + // Above the caret's line, growing up and to the right: the bar's bottom-left corner sits on the top-left of the caret. + Placement = PlacementMode.AnchorAndGravity, + PlacementAnchor = PopupAnchor.TopLeft, + PlacementGravity = PopupGravity.TopRight, + IsLightDismissEnabled = false, + TakesFocusFromNativeControl = false, // in WinForms (Host48) opening it must not take focus from the field + Child = frame, + VerticalOffset = -4, + }; + ((ISetLogicalParent)popup).SetParent(this); + LogicalChildren.Add(popup); + return popup; + } + + Button BarTool(string name, object content, string tip, System.Action click) + { + var button = Tool(name, content, tip, click); + button.Margin = new Thickness(0); + return button; + } + + /// Hides the bar until the selection changes again: a context menu replaces it, and focus leaving the field hides it. + void HideSelectionBar() + { + _selectionBarGeneration++; + _selectionBar.IsOpen = false; + } + + /// Shows the bar over the active field's caret while it has a selection whose caret shows, and hides it otherwise. Called when the + /// caret moves, the document changes, the view scrolls, read-only changes or another field becomes active; the open waits for layout so the + /// caret's place is current. + void UpdateSelectionBar() + { + var field = _active?.Field; + bool show = _selectionBarEnabled && field != null && field.Editor.HasSelection && !field.IsReadOnly && TopLevel.GetTopLevel(this) != null; + if (!show) + { + HideSelectionBar(); + return; + } + bool outer = _syncing; + _syncing = true; + _selectionWs.SelectedIndex = SampleWritingSystems.All.ToList().FindIndex(w => w.Tag == field!.Editor.SelectionWs); + _syncing = outer; + int generation = ++_selectionBarGeneration; + Dispatcher.UIThread.Post(() => + { + if (generation != _selectionBarGeneration || !_selectionBarEnabled || _active?.Field != field || !field!.Editor.HasSelection) return; + var caret = field.CaretRect(); + if (!field.VisibleBounds.Intersects(caret)) { _selectionBar.IsOpen = false; return; } // the selection's end is scrolled out of sight + _selectionBar.PlacementTarget = field; + _selectionBar.PlacementRect = caret; + try { _selectionBar.IsOpen = true; } + catch (System.InvalidOperationException) { } // no overlay layer (a window with no theme): the bar is not shown + }, DispatcherPriority.Background); + } + + /// Focus moved: the bar stays while focus is in the field or in the bar itself (a box's drop-down), and goes otherwise. + void SelectionBarFocusMoved() + { + Dispatcher.UIThread.Post(() => + { + var focused = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as global::Avalonia.LogicalTree.ILogical; + bool inBar = focused != null && (focused == _selectionBar || focused.GetLogicalAncestors().Contains(_selectionBar)); + if (_active?.Field.IsFocused == true || inBar) return; + HideSelectionBar(); + }, DispatcherPriority.Background); + } + } +} diff --git a/samples/SIL.TextEditing.Playground/PlaygroundView.cs b/samples/SIL.TextEditing.Playground/PlaygroundView.cs index 1c9f369..c4d3ccd 100644 --- a/samples/SIL.TextEditing.Playground/PlaygroundView.cs +++ b/samples/SIL.TextEditing.Playground/PlaygroundView.cs @@ -23,7 +23,7 @@ namespace SIL.TextEditing.Playground /// integration tests use this one control. Controls are named (see the Names class) so tests and screen /// readers can find them. /// - public sealed class PlaygroundView : UserControl + public sealed partial class PlaygroundView : UserControl { /// The names of the controls a test or a UI automation client looks for. public static class Names @@ -76,6 +76,17 @@ public static class Names public const string MarkdownApply = "MarkdownApplyButton"; public const string MarkdownRevert = "MarkdownRevertButton"; public const string MarkdownStatus = "MarkdownStatus"; + public const string DeveloperMode = "DeveloperModeCheck"; + public const string DeveloperPanel = "DeveloperPanel"; + public const string SelectionBar = "SelectionBar"; + public const string BarBold = "BarBoldButton"; + public const string BarItalic = "BarItalicButton"; + public const string BarUnderline = "BarUnderlineButton"; + public const string BarWritingSystem = "BarWritingSystemBox"; + public const string BarCharStyle = "BarCharStyleBox"; + public const string BarMore = "BarMoreToggle"; + public const string BarParaStyle = "BarParaStyleBox"; + public const string BarPicture = "BarPictureButton"; public const string EventLog = "EventLog"; public const string ScenarioDescription = "ScenarioDescription"; public const string FindText = "FindText"; @@ -146,6 +157,12 @@ public Session(RichTextField field, int number) readonly TabControl _inspectorTabs; readonly SampleMarkdown _markdownHost; bool _markdownEdited; + bool _developerMode; + CheckBox _developerCheck = null!; + Control _developerPanel = null!; + Control _logSplitter = null!; + Grid _rightGrid = null!; + TabItem _modelTab = null!; bool _settingMarkdown; bool _markdownRefreshPosted; readonly TextBox _logBox; @@ -156,8 +173,11 @@ public Session(RichTextField field, int number) public PlaygroundView() : this(Scenarios.MixedBidi) { } - public PlaygroundView(string scenario) + /// The playground with loaded. Without it shows only what a person + /// writing sees: the field, the selection bar and the Markdown; developer mode adds every switch, the model and the event log. + public PlaygroundView(string scenario, bool developerMode = false) { + _developerMode = developerMode; _picker = new ComboBox { Name = Names.ScenarioPicker, MinWidth = 220, Margin = new Thickness(2), IsTabStop = false, ItemsSource = Scenarios.All.Select(s => s.Name).ToList() }; ToolTip.SetTip(_picker, "Load a ready-made document"); _picker.SelectionChanged += (s, e) => @@ -193,6 +213,8 @@ public PlaygroundView(string scenario) ScrollViewer.SetVerticalScrollBarVisibility(_logBox, ScrollBarVisibility.Auto); Content = BuildLayout(); + _selectionBar = BuildSelectionBar(); + ApplyDeveloperMode(); AddHandler(InputElement.KeyDownEvent, OnPreviewKey, RoutingStrategies.Tunnel); _active = null!; @@ -223,6 +245,32 @@ public string MarkdownText set => _markdown.Text = value; } public string MarkdownStatusText => _markdownStatus.Text ?? string.Empty; + /// Developer mode: every switch and tool bar, the model tab and the event log. Off, the playground shows the field, the selection + /// bar and the Markdown only. + public bool DeveloperMode + { + get => _developerMode; + set + { + if (_developerMode == value) return; + _developerMode = value; + ApplyDeveloperMode(); + } + } + + void ApplyDeveloperMode() + { + _developerPanel.IsVisible = _developerMode; + _logSplitter.IsVisible = _developerMode; + _logBox.IsVisible = _developerMode; + _rightGrid.RowDefinitions[2].Height = _developerMode ? new GridLength(1, GridUnitType.Star) : new GridLength(0); // the Markdown takes the whole height + _modelTab.IsVisible = _developerMode; + if (!_developerMode) ShowsMarkdown = true; + _syncing = true; + _developerCheck.IsChecked = _developerMode; + _syncing = false; + } + /// True while the Markdown tab (the default) is shown; false while the model dump is. public bool ShowsMarkdown { @@ -238,9 +286,15 @@ public bool ShowsMarkdown Control BuildLayout() { + var slim = new DockPanel { Margin = new Thickness(4, 2), LastChildFill = false }; + _developerCheck = new CheckBox { Name = Names.DeveloperMode, Content = "Developer mode", Focusable = false, Margin = new Thickness(2) }; + ToolTip.SetTip(_developerCheck, "Every switch and tool bar, the model and the event log"); + _developerCheck.IsCheckedChanged += (s, e) => { if (!_syncing) DeveloperMode = _developerCheck.IsChecked == true; }; + DockPanel.SetDock(_developerCheck, Dock.Right); + slim.Children.Add(_developerCheck); + slim.Children.Add(_picker); + var toolbar = new WrapPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(4, 2) }; - toolbar.Children.Add(Label("Scenario:")); - toolbar.Children.Add(_picker); toolbar.Children.Add(Label("Fields:")); toolbar.Children.Add(_countPicker); toolbar.Children.Add(Spacer()); @@ -269,6 +323,7 @@ Control BuildLayout() if (_syncing) return; _active.Field.IsReadOnly = _readOnlyCheck.IsChecked == true; Log("field read-only: " + (_active.Field.IsReadOnly ? "on" : "off")); + UpdateSelectionBar(); }; toolbar.Children.Add(_readOnlyCheck); _singleParagraphCheck = new CheckBox { Name = Names.SingleParagraph, Content = "Single paragraph", Focusable = false, Margin = new Thickness(2) }; @@ -376,16 +431,19 @@ Control BuildLayout() var fieldBorder = new Border { BorderBrush = Brushes.Gray, BorderThickness = new Thickness(1), Margin = new Thickness(4), Child = fieldScroll }; var right = new Grid(); + _rightGrid = right; right.RowDefinitions.Add(new RowDefinition(2, GridUnitType.Star)); 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 }; - _inspectorTabs.ItemsSource = new[] { BuildMarkdownTab(), new TabItem { Name = Names.ModelTab, Header = "Model (debug)", FontSize = 14, Content = _inspector } }; + _modelTab = new TabItem { Name = Names.ModelTab, Header = "Model (debug)", FontSize = 14, Content = _inspector }; + _inspectorTabs.ItemsSource = new[] { BuildMarkdownTab(), _modelTab }; _inspectorTabs.SelectedIndex = 0; _inspectorTabs.SelectionChanged += (s, e) => { if (ShowsMarkdown) RefreshMarkdown(); }; Grid.SetRow(_inspectorTabs, 0); - Grid.SetRow(splitter, 1); + Grid.SetRow(splitter, 1); // a GridSplitter works only as a direct child of its Grid Grid.SetRow(_logBox, 2); + _logSplitter = splitter; right.Children.Add(_inspectorTabs); right.Children.Add(splitter); right.Children.Add(_logBox); @@ -403,11 +461,15 @@ Control BuildLayout() body.Children.Add(vsplit); body.Children.Add(rightBorder); + var developer = new StackPanel { Name = Names.DeveloperPanel }; + developer.Children.Add(toolbar); + developer.Children.Add(_description); + developer.Children.Add(wsBar); + developer.Children.Add(findBar); + _developerPanel = developer; var top = new StackPanel(); - top.Children.Add(toolbar); - top.Children.Add(_description); - top.Children.Add(wsBar); - top.Children.Add(findBar); + top.Children.Add(slim); + top.Children.Add(developer); var root = new DockPanel { Background = Brushes.WhiteSmoke }; DockPanel.SetDock(top, Dock.Top); root.Children.Add(top); @@ -423,19 +485,9 @@ 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); @@ -808,10 +860,11 @@ void SyncBlockChecks() static Border Spacer() => new Border { Width = 12 }; - ComboBox StyleBox(string name, IReadOnlyList styles, string tip, Action apply) + ComboBox StyleBox(string name, IReadOnlyList styles, string tip, Action apply, string placeholder = "(choose)") { - var box = new ComboBox { Name = name, MinWidth = 140, Margin = new Thickness(2), IsTabStop = false, ItemsSource = styles, SelectedIndex = -1, PlaceholderText = "(choose)" }; + var box = new ComboBox { Name = name, MinWidth = 140, Margin = new Thickness(2), IsTabStop = false, ItemsSource = styles, SelectedIndex = -1, PlaceholderText = placeholder }; ToolTip.SetTip(box, tip); + box.DropDownClosed += (s, e) => _active.Field.Focus(); // Esc too: typing goes back to the field box.SelectionChanged += (s, e) => { if (_syncing || !(box.SelectedItem is string style)) return; @@ -865,6 +918,7 @@ public void LoadScenario(string name) RefreshInspector(); DropMarkdownEdits("scenario loaded"); RefreshMarkdown(); + if (_selectionBar != null) UpdateSelectionBar(); if (TopLevel.GetTopLevel(this) != null) Dispatcher.UIThread.Post(() => _active.Field.Focus()); } @@ -905,7 +959,7 @@ void AddSession(Document document, int number, bool many) e.Handled = _leaveHandled.IsChecked == true; }; field.EnterRequested += m => Log("Enter passed to the host" + (m == KeyModifiers.None ? string.Empty : " (" + m + ")") + ": the host's default button would run"); - field.ContextMenuRequested += (o, e) => { Activate(session); ShowContextMenu(session, e); }; + field.ContextMenuRequested += (o, e) => { Activate(session); HideSelectionBar(); ShowContextMenu(session, e); }; field.ScrollRequested += (o, e) => { // The playground leaves scrolling to Avalonia's scroll viewer (the field asks it when nothing handles the request); it only @@ -925,8 +979,8 @@ 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(); PostMarkdownRefresh(); UpdateEnvironmentButtons(); } }; - field.Editor.CaretMoved += () => { if (_active == session) { UpdateWsLabel(); RefreshInspector(); UpdateEnvironmentButtons(); } }; + field.Editor.DocumentChanged += (a, b, c) => { if (_active == session) { RefreshInspector(); PostMarkdownRefresh(); UpdateEnvironmentButtons(); UpdateSelectionBar(); } }; + field.Editor.CaretMoved += () => { if (_active == session) { UpdateWsLabel(); RefreshInspector(); UpdateEnvironmentButtons(); UpdateSelectionBar(); } }; field.LanguageAnalysisChanged += (o, e) => { if (_active == session) Log("language service: " + field.LanguageTokens.Count + " tokens, " + field.LanguageDiagnostics.Count + " diagnostics" + string.Concat(field.LanguageDiagnostics.Select(d => "; " + d.Severity + " " + d.Code + " at " + d.Range.Start.Character + "-" + d.Range.End.Character))); @@ -937,6 +991,8 @@ void AddSession(Document document, int number, bool many) }; field.LanguageServiceFailed += error => Log("language service failed: " + error.Message); field.GotFocus += (s, e) => Activate(session); + field.LostFocus += (s, e) => { if (_active == session) SelectionBarFocusMoved(); }; + field.EffectiveViewportChanged += (s, e) => { if (_active == session && field.Editor.HasSelection) UpdateSelectionBar(); }; // the bar follows scrolling if (many) { @@ -967,6 +1023,7 @@ void Activate(Session session) RefreshInspector(); DropMarkdownEdits("focus moved to field " + session.Number); RefreshMarkdown(); + UpdateSelectionBar(); } // ------------------------------------------------------------ spelling @@ -1159,7 +1216,7 @@ bool Toggle(string label, int property, int on = TextPropertyNumbers.ToggleOn) return r => { if (allOn) r.Props.IntProps.Remove(property); - else r.Props.SetInt(property, on); + else r.Props.SetInt(property, on, TextPropertyNumbers.VariationEnum); // FieldWorks stores toggles and enums with ktpvEnum }; }); } diff --git a/samples/SIL.TextEditing.Playground/SampleMarkdown.cs b/samples/SIL.TextEditing.Playground/SampleMarkdown.cs index 50991f7..16d2f13 100644 --- a/samples/SIL.TextEditing.Playground/SampleMarkdown.cs +++ b/samples/SIL.TextEditing.Playground/SampleMarkdown.cs @@ -8,7 +8,7 @@ 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 + public sealed class SampleMarkdown : IWritingSystemMapper, IKnownWritingSystems, IObjectMapper { readonly WritingSystemRegistry _registry; @@ -23,11 +23,33 @@ public SampleMarkdown(WritingSystemRegistry registry) public bool IsKnown(string tag) => _registry.Contains(tag); public IReadOnlyList KnownTags { get; } + /// A picture of the sample project is written as an image with its path and GUID; any other object as the default mapper says. + public MarkdownObject? Describe(string objData) + { + var picture = SamplePictures.Find(objData); + if (picture != null) return new MarkdownObject { Kind = MarkdownObjectKind.Picture, Guid = picture.Guid, Path = picture.Path, Caption = picture.Caption }; + return DefaultObjectMapper.Instance.Describe(objData); + } + + /// A picture is found by its GUID, or by its path when the Markdown has no GUID; an unknown one is fwmd.picture.unknown. + public bool TryCreateObjData(MarkdownObject target, out string objData, out string? problem) + { + if (target.Kind == MarkdownObjectKind.Picture) + { + var picture = SamplePictures.All.FirstOrDefault(p => target.Guid != null ? p.Guid == target.Guid : p.Path == target.Path); + objData = picture?.ObjData ?? string.Empty; + problem = picture == null ? "no picture " + (target.Guid?.ToString() ?? target.Path) + " in the sample project" : null; + return picture != null; + } + return DefaultObjectMapper.Instance.TryCreateObjData(target, out objData, out problem); + } + /// 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, + Objects = this, WriteFrontMatter = false, StyleNames = new MarkdownStyleNames { diff --git a/samples/SIL.TextEditing.Playground/SamplePictures.cs b/samples/SIL.TextEditing.Playground/SamplePictures.cs index 1d7b519..e4a4744 100644 --- a/samples/SIL.TextEditing.Playground/SamplePictures.cs +++ b/samples/SIL.TextEditing.Playground/SamplePictures.cs @@ -1,20 +1,61 @@ +using System; using System.Collections.Generic; +using System.Linq; using Avalonia; using Avalonia.Media; using Avalonia.Media.Imaging; namespace SIL.TextEditing.Playground { - /// Small generated bitmaps standing in for picture objects: the object data picks the colour. + /// One picture of the sample project: a CmPicture's GUID, the file under the project's pictures folder and its caption. + public sealed class SamplePicture + { + public SamplePicture(Guid guid, string path, string caption, uint fill) + { + Guid = guid; + Path = path; + Caption = caption; + Fill = fill; + ObjData = SamplePictures.PictureData(guid); + } + + public Guid Guid { get; } + /// The file, from the project root, as FieldWorks Markdown writes it: ![caption](/files/pictures/red-square.png){guid=...}. + public string Path { get; } + public string Caption { get; } + /// The object data of the run: FieldWorks' moveable-object type (U+0008) followed by the GUID as eight UTF-16 code units. + public string ObjData { get; } + internal uint Fill { get; } + } + + /// The sample project's pictures and small generated bitmaps that stand in for their files. Sample data only. public sealed class SamplePictures { + /// FieldWorks' object-data type of a picture in text (kodtGuidMoveableObjDisp). + public const char PictureType = (char)8; + + public static SamplePicture Red { get; } = new SamplePicture(new Guid("6f1d3a52-0c4e-4b8a-9d2f-1a7e5c3b9d01"), "/files/pictures/red-square.png", "Red square", 0xFFD04040u); + public static SamplePicture Blue { get; } = new SamplePicture(new Guid("0b9e7c14-5a3d-4f62-8e1b-2c4d6f8a0b02"), "/files/pictures/blue-square.png", "Blue square", 0xFF4060D0u); + public static IReadOnlyList All { get; } = new[] { Red, Blue }; + + /// The object data FieldWorks stores for a picture with this GUID. + public static string PictureData(Guid guid) + { + var bytes = guid.ToByteArray(); + var chars = new char[8]; + for (int i = 0; i < 8; i++) chars[i] = (char)(bytes[2 * i] | (bytes[2 * i + 1] << 8)); + return PictureType + new string(chars); + } + + /// The sample picture an object run's data names, or null. + public static SamplePicture? Find(string objData) => All.FirstOrDefault(p => p.ObjData == objData); + readonly Dictionary _cache = new Dictionary(); public IImage? Get(string data) { if (_cache.TryGetValue(data, out var image)) return image; - uint fill = data.Contains("red") ? 0xFFD04040u : data.Contains("blue") ? 0xFF4060D0u : 0xFF40A060u; - image = Create(fill); + image = Create(Find(data)?.Fill ?? 0xFF40A060u); _cache[data] = image; return image; } diff --git a/samples/SIL.TextEditing.Playground/Scenarios.cs b/samples/SIL.TextEditing.Playground/Scenarios.cs index 5e0d27b..5f0176b 100644 --- a/samples/SIL.TextEditing.Playground/Scenarios.cs +++ b/samples/SIL.TextEditing.Playground/Scenarios.cs @@ -42,9 +42,10 @@ public static class Scenarios public const string ManyFields = "Many fields"; public const string Empty = "Empty"; - /// The object data of the sample picture runs; the playground draws a coloured box for each. - public const string RedPicture = "\u0008pic-red"; - public const string BluePicture = "\u0008pic-blue"; + /// The object data of the sample picture runs: real picture data (a GUID) for two pictures of the sample project, which the + /// Markdown writes as ![Red square](/files/pictures/red-square.png){guid=...}; the playground draws a coloured box for each. + public static readonly string RedPicture = SamplePictures.Red.ObjData; + public static readonly string BluePicture = SamplePictures.Blue.ObjData; public static IReadOnlyList All { get; } = new List { @@ -314,7 +315,7 @@ static Document OpaqueDoc() p.Runs.Add(unknown); p.Runs.Add(new Run { Ws = "en", Text = ", then bold with one of them: " }); var boldAndUnknown = new Run { Ws = "en", Text = "bold + 9003" }; - boldAndUnknown.Props.SetInt(TextPropertyNumbers.Bold, TextPropertyNumbers.ToggleOn).SetInt(9003, 7); + boldAndUnknown.Props.SetInt(TextPropertyNumbers.Bold, TextPropertyNumbers.ToggleOn, TextPropertyNumbers.VariationEnum).SetInt(9003, 7); p.Runs.Add(boldAndUnknown); p.Runs.Add(new Run { Ws = "fr", Text = " et du français." }); diff --git a/tests/SIL.TextEditing.Playground.Tests/FormatUndoObjectClipboardTests.cs b/tests/SIL.TextEditing.Playground.Tests/FormatUndoObjectClipboardTests.cs index b1b3d59..a228611 100644 --- a/tests/SIL.TextEditing.Playground.Tests/FormatUndoObjectClipboardTests.cs +++ b/tests/SIL.TextEditing.Playground.Tests/FormatUndoObjectClipboardTests.cs @@ -21,13 +21,13 @@ public void Bold_toggles_on_the_selection_keeps_paragraph_ids_and_toggles_back_t var p0 = s.Editor.Document.Paragraphs[0]; Assert.Equal(runsBefore + 2, p0.Runs.Count); Assert.Equal("word", p0.Runs.Single(IsBold).Text); - Assert.Contains("ws=en \"word\" props{3=1}", s.Inspector); + Assert.Contains("ws=en \"word\" props{3=1@3}", s.Inspector); Assert.Equal(ids, s.Editor.Document.Paragraphs.Select(p => p.Key).ToList()); Assert.Contains("selection 0:4 -> 0:8", s.Inspector); s.Click(PlaygroundView.Names.Bold); Assert.Equal(runsBefore, s.Editor.Document.Paragraphs[0].Runs.Count); - Assert.DoesNotContain("props{3=1}", s.Inspector); + Assert.DoesNotContain("props{3=1@3}", s.Inspector); } }); @@ -44,7 +44,7 @@ public void Bold_with_ctrl_b_italic_and_underline_use_their_property_numbers_and s.Press(Key.B, KeyModifiers.Control); s.Click(PlaygroundView.Names.Italic); s.Click(PlaygroundView.Names.Underline); - Assert.Contains("ws=en \"The\" props{2=1, 3=1, 5=3}", s.Inspector); + Assert.Contains("ws=en \"The\" props{2=1@3, 3=1@3, 5=3@3}", s.Inspector); } }); @@ -56,7 +56,7 @@ public void Formatting_keeps_unknown_properties_on_the_runs_it_touches() => Head s.Select(1, 0, s.ParagraphText(1).Length); s.Click(PlaygroundView.Names.Bold); Assert.Contains("9001=42@1", s.Inspector); - Assert.Contains("3=1, 9003=7", s.Inspector); + Assert.Contains("3=1@3, 9003=7", s.Inspector); Assert.Contains("9002=\"a;b=c<&>é\"", s.Inspector); s.Click(PlaygroundView.Names.Bold); Assert.Contains("9001=42@1", s.Inspector); @@ -87,11 +87,11 @@ public void Undo_and_redo_buttons_drive_the_host_owned_stack_for_typing_and_for_ s.Select(3, 0, 2); s.Click(PlaygroundView.Names.Bold); - Assert.Contains("props{3=1}", s.Inspector); + Assert.Contains("props{3=1@3}", s.Inspector); s.Click(PlaygroundView.Names.Undo); - Assert.DoesNotContain("props{3=1}", s.Inspector); + Assert.DoesNotContain("props{3=1@3}", s.Inspector); s.Click(PlaygroundView.Names.Redo); - Assert.Contains("props{3=1}", s.Inspector); + Assert.Contains("props{3=1@3}", s.Inspector); } }); diff --git a/tests/SIL.TextEditing.Playground.Tests/Harness.cs b/tests/SIL.TextEditing.Playground.Tests/Harness.cs index b40d8a9..c33f633 100644 --- a/tests/SIL.TextEditing.Playground.Tests/Harness.cs +++ b/tests/SIL.TextEditing.Playground.Tests/Harness.cs @@ -12,6 +12,7 @@ using Avalonia.Styling; using Avalonia.Themes.Fluent; using Avalonia.Threading; +using Avalonia.LogicalTree; using Avalonia.VisualTree; using SIL.TextEditing.Avalonia; using Xunit; @@ -54,9 +55,11 @@ public sealed class Session : IDisposable readonly Window _window; public Window Window => _window; - public Session(string scenario = Scenarios.MixedBidi, double width = 1100, double height = 700) + /// A playground in a headless window. The tests drive the developer-mode controls, so developer mode is on unless + /// says otherwise. + public Session(string scenario = Scenarios.MixedBidi, double width = 1100, double height = 700, bool developerMode = true) { - View = new PlaygroundView(scenario); + View = new PlaygroundView(scenario, developerMode); _window = new Window { Width = width, Height = height, Content = View }; _window.Show(); Headless.Pump(); @@ -76,6 +79,16 @@ public Session(string scenario = Scenarios.MixedBidi, double width = 1100, doubl public T Find(string name) where T : Control => _window.GetVisualDescendants().OfType().FirstOrDefault(c => c.Name == name) ?? throw new InvalidOperationException("No " + typeof(T).Name + " named " + name); + /// A control by name in the logical tree, which also holds what a popup shows (the selection bar) whether or not it is open. + public T FindLogical(string name) where T : Control => + View.GetLogicalDescendants().OfType().FirstOrDefault(c => c.Name == name) ?? throw new InvalidOperationException("No " + typeof(T).Name + " named " + name); + + public void ClickLogical(string buttonName) + { + FindLogical