From fbb40fa1af191094d3805e52f531d98630be3d4b Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 6 Nov 2026 09:36:41 -0500 Subject: [PATCH 1/4] The playground opens simple, with a selection bar that floats above the selection and the field's Markdown beside it, keeps every switch, the model and the log behind Developer mode, and stores real picture data written as image paths. Co-Authored-By: Claude Opus 5.5 --- samples/Host48/Program.cs | 4 +- samples/Playground.Desktop/Program.cs | 4 +- .../PlaygroundView.SelectionBar.cs | 146 ++++++++++++++++++ .../PlaygroundView.cs | 112 ++++++++++---- .../SampleMarkdown.cs | 24 ++- .../SamplePictures.cs | 47 +++++- .../SIL.TextEditing.Playground/Scenarios.cs | 9 +- .../FormatUndoObjectClipboardTests.cs | 14 +- .../Harness.cs | 17 +- .../PagingPlaygroundTests.cs | 3 +- .../ScreenshotGenerator.cs | 45 ++++-- .../SimplePlaygroundTests.cs | 130 ++++++++++++++++ 12 files changed, 490 insertions(+), 65 deletions(-) create mode 100644 samples/SIL.TextEditing.Playground/PlaygroundView.SelectionBar.cs create mode 100644 tests/SIL.TextEditing.Playground.Tests/SimplePlaygroundTests.cs 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..25a9514 --- /dev/null +++ b/samples/SIL.TextEditing.Playground/PlaygroundView.SelectionBar.cs @@ -0,0 +1,146 @@ +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!; + bool _selectionBarShown; + + /// True while the selection bar is shown over the active field's selection. + public bool SelectionBarOpen => _selectionBarShown; + + /// 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(); + }; + 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, + 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. + void HideSelectionBar() + { + _selectionBar.IsOpen = false; + _selectionBarShown = false; + } + + /// Shows the bar over the active field's caret while it has a selection, and hides it otherwise. Called when the caret moves, the + /// document changes or another field becomes active; it 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) + { + if (_selectionBarShown) _selectionBar.IsOpen = false; + _selectionBarShown = false; + return; + } + _syncing = true; + int ws = SampleWritingSystems.All.ToList().FindIndex(w => w.Tag == field!.Editor.SelectionWs); + _selectionWs.SelectedIndex = ws; + _syncing = false; + Dispatcher.UIThread.Post(() => + { + if (!_selectionBarEnabled || _active?.Field != field || !field!.Editor.HasSelection) return; + _selectionBar.PlacementTarget = field; + _selectionBar.PlacementRect = field.CaretRect(); + try { _selectionBar.IsOpen = true; } + catch (System.InvalidOperationException) { return; } // no overlay layer (a window with no theme): the bar is not shown + _selectionBarShown = true; + }, DispatcherPriority.Background); + _selectionBarShown = true; + } + } +} diff --git a/samples/SIL.TextEditing.Playground/PlaygroundView.cs b/samples/SIL.TextEditing.Playground/PlaygroundView.cs index 1c9f369..df242fa 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 _logArea = 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,31 @@ 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; + _logArea.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 +285,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()); @@ -376,19 +429,24 @@ 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(_logBox, 2); + var logArea = new DockPanel(); + DockPanel.SetDock(splitter, Dock.Top); + logArea.Children.Add(splitter); + logArea.Children.Add(_logBox); + _logArea = logArea; + Grid.SetRow(logArea, 2); right.Children.Add(_inspectorTabs); - right.Children.Add(splitter); - right.Children.Add(_logBox); + right.Children.Add(logArea); var rightBorder = new Border { Margin = new Thickness(4), Child = right }; var body = new Grid(); @@ -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,9 +860,9 @@ 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.SelectionChanged += (s, e) => { @@ -865,6 +917,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 +958,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 +978,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))); @@ -967,6 +1020,7 @@ void Activate(Session session) RefreshInspector(); DropMarkdownEdits("focus moved to field " + session.Number); RefreshMarkdown(); + UpdateSelectionBar(); } // ------------------------------------------------------------ spelling @@ -1159,7 +1213,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