diff --git a/docs/images/README.md b/docs/images/README.md new file mode 100644 index 0000000..2dbe974 --- /dev/null +++ b/docs/images/README.md @@ -0,0 +1,36 @@ +# Screenshots + +Pictures of the playground (`samples/SIL.TextEditing.Playground`) for the README and the docs. Each is regenerated, not hand-made. + +## 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. + +```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). +- `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. +- `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". +- `many-fields.png`: fifty small fields in one window, in English, French, Swahili and Arabic. + +## The real windows + +`capture-hosts.ps1` builds and starts each host through `run.ps1`, moves its window to a fixed place and size on the screen, copies those pixels with `System.Drawing`, saves the PNG and closes the host. It needs an unlocked interactive desktop; the window is made topmost so nothing else is in the pixels. Run it from the repository root: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File docs\images\capture-hosts.ps1 +``` + +- `host-winforms-fieldworks-shape.png`: `samples/Host48`, FieldWorks' shape: a WinForms form on net48 (Avalonia 11.3.17) with the playground between two WinForms TextBoxes. +- `desktop-window.png`: `samples/Playground.Desktop`, the plain Avalonia window on net10.0 (Avalonia 12). diff --git a/docs/images/bidi-selection.png b/docs/images/bidi-selection.png new file mode 100644 index 0000000..e947479 Binary files /dev/null and b/docs/images/bidi-selection.png differ diff --git a/docs/images/block-features.png b/docs/images/block-features.png new file mode 100644 index 0000000..f5c7f6e Binary files /dev/null and b/docs/images/block-features.png differ diff --git a/docs/images/capture-hosts.ps1 b/docs/images/capture-hosts.ps1 new file mode 100644 index 0000000..afd8f7d --- /dev/null +++ b/docs/images/capture-hosts.ps1 @@ -0,0 +1,80 @@ +<# +.SYNOPSIS + Screenshots the two sample hosts as real windows: the WinForms form that hosts the playground between two TextBoxes (FieldWorks' shape) and the plain Avalonia desktop window. +.DESCRIPTION + Builds and starts each host through run.ps1, waits for its window, moves it to a fixed place and size, copies those screen pixels (System.Drawing, no extra software), saves a PNG + into docs/images and closes the host. It needs an unlocked interactive Windows desktop and no window covering the host's place on the screen. +.EXAMPLE + powershell -NoProfile -ExecutionPolicy Bypass -File docs\images\capture-hosts.ps1 +#> +param( + [string]$Scenario = 'Latin and Arabic', + [int]$Width = 1280, + [int]$Height = 800 +) +$ErrorActionPreference = 'Stop' +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$out = $PSScriptRoot + +Add-Type -AssemblyName System.Drawing +Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class Win +{ + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; } + [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); + [DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr h, int x, int y, int w, int hgt, bool repaint); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int w, int hgt, uint flags); + [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr hdc, uint flags); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr h, int attr, out RECT r, int size); +} +'@ +[void][Win]::SetProcessDPIAware() + +function Capture([string]$Target, [string]$File, [string[]]$HostArgs) { + # run.cmd (run.ps1) says "Started as process "; that id, never a process name, is the host this call owns and closes. + $quoted = ($HostArgs | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } }) -join ' ' + $started = (& cmd.exe /c ("`"" + (Join-Path $root 'run.cmd') + "`" $Target $quoted") 2>&1) | Out-String + if ($started -notmatch 'as process (\d+)') { throw "run.cmd did not report a process id for ${Target}: $started" } + $hostId = [int]$Matches[1] + try { + $p = $null + for ($i = 0; $i -lt 120; $i++) { + Start-Sleep -Milliseconds 500 + $p = Get-Process -Id $hostId -ErrorAction Stop + if ($p.MainWindowHandle -ne 0) { break } + } + if ($p.MainWindowHandle -eq 0) { throw "No window for $Target (process $hostId)" } + $h = $p.MainWindowHandle + [void][Win]::ShowWindow($h, 9) + [void][Win]::MoveWindow($h, 20, 20, $Width, $Height, $true) + [void][Win]::SetWindowPos($h, [IntPtr]::new(-1), 20, 20, $Width, $Height, 0x0040) # topmost, so nothing else is in the copied pixels + [void][Win]::SetForegroundWindow($h) + Start-Sleep -Seconds 3 # first layout, fonts and the caret + $r = New-Object Win+RECT + # The visible frame, without the invisible resize border the window rectangle includes. + if ([Win]::DwmGetWindowAttribute($h, 9, [ref]$r, [Runtime.InteropServices.Marshal]::SizeOf($r)) -ne 0) { [void][Win]::GetWindowRect($h, [ref]$r) } + $w = $r.Right - $r.Left; $hgt = $r.Bottom - $r.Top + $bmp = New-Object System.Drawing.Bitmap $w, $hgt + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.CopyFromScreen($r.Left, $r.Top, 0, 0, $bmp.Size) + $g.Dispose() + $bmp.Save((Join-Path $out $File), [System.Drawing.Imaging.ImageFormat]::Png) + $bmp.Dispose() + Write-Host "Saved $File ($w x $hgt) from $Target" + } + finally { + $p = Get-Process -Id $hostId -ErrorAction SilentlyContinue + if ($p) { + [void]$p.CloseMainWindow() + if (-not $p.WaitForExit(5000)) { $p.Kill() } + } + } +} +Capture 'winforms48' 'host-winforms-fieldworks-shape.png' @('--scenario', $Scenario) +Capture 'desktop10' 'desktop-window.png' @('--scenario', $Scenario) + diff --git a/docs/images/copy-as-submenu.png b/docs/images/copy-as-submenu.png new file mode 100644 index 0000000..b2c73ff Binary files /dev/null and b/docs/images/copy-as-submenu.png differ diff --git a/docs/images/desktop-window.png b/docs/images/desktop-window.png new file mode 100644 index 0000000..2486598 Binary files /dev/null and b/docs/images/desktop-window.png differ diff --git a/docs/images/environment-box.png b/docs/images/environment-box.png new file mode 100644 index 0000000..e29644c Binary files /dev/null and b/docs/images/environment-box.png differ diff --git a/docs/images/environment-fix-menu.png b/docs/images/environment-fix-menu.png new file mode 100644 index 0000000..d9984aa Binary files /dev/null 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 new file mode 100644 index 0000000..46a1daf Binary files /dev/null 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 new file mode 100644 index 0000000..008238c Binary files /dev/null and b/docs/images/indic-thai-shaping.png differ diff --git a/docs/images/inline-pictures.png b/docs/images/inline-pictures.png new file mode 100644 index 0000000..6a269f7 Binary files /dev/null and b/docs/images/inline-pictures.png differ diff --git a/docs/images/long-document.png b/docs/images/long-document.png new file mode 100644 index 0000000..0a16c4b Binary files /dev/null and b/docs/images/long-document.png differ diff --git a/docs/images/many-fields.png b/docs/images/many-fields.png new file mode 100644 index 0000000..9e25f6b Binary files /dev/null and b/docs/images/many-fields.png differ diff --git a/docs/images/nfd-surrogates.png b/docs/images/nfd-surrogates.png new file mode 100644 index 0000000..90955b9 Binary files /dev/null and b/docs/images/nfd-surrogates.png differ diff --git a/docs/images/spelling-context-menu.png b/docs/images/spelling-context-menu.png new file mode 100644 index 0000000..39fbcdf Binary files /dev/null and b/docs/images/spelling-context-menu.png differ diff --git a/docs/images/spelling-squiggles.png b/docs/images/spelling-squiggles.png new file mode 100644 index 0000000..90c42e7 Binary files /dev/null and b/docs/images/spelling-squiggles.png differ diff --git a/docs/images/unknown-properties-inspector.png b/docs/images/unknown-properties-inspector.png new file mode 100644 index 0000000..c79a535 Binary files /dev/null and b/docs/images/unknown-properties-inspector.png differ diff --git a/tests/SIL.TextEditing.Playground.Tests/ScreenshotGenerator.cs b/tests/SIL.TextEditing.Playground.Tests/ScreenshotGenerator.cs new file mode 100644 index 0000000..ae0bb10 --- /dev/null +++ b/tests/SIL.TextEditing.Playground.Tests/ScreenshotGenerator.cs @@ -0,0 +1,263 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Xunit; + +namespace SIL.TextEditing.Playground.Tests +{ + /// A fact that is opt-in: it runs only when the environment variable is 1 and is skipped otherwise, so the default test run + /// never writes into the working tree. It needs Avalonia 12 (net10.0) for the same pixels every time. + public sealed class ScreenshotFactAttribute : FactAttribute + { + public const string Switch = "TEXTEDITING_SCREENSHOTS"; + + public ScreenshotFactAttribute() + { +#if !NET10_0_OR_GREATER + Skip = "screenshots are made on net10.0 (Avalonia 12) only"; +#else + if (Environment.GetEnvironmentVariable(Switch) != "1") + Skip = "opt-in: set " + Switch + "=1 to write the PNGs in docs/images"; +#endif + } + } + + /// Regenerates the screenshots in docs/images: the playground in a headless Skia window of a fixed size with the Fluent theme and the playground's own + /// fixed fonts, one PNG per scenario that shows what the editor does. Run it with + /// $env:TEXTEDITING_SCREENSHOTS=1; dotnet test tests\SIL.TextEditing.Playground.Tests -c Release -f net10.0 --filter ScreenshotGenerator. + public class ScreenshotGenerator + { + const double W = 1280, H = 800; + + static string OutDir() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "SIL.TextEditing.sln"))) dir = dir.Parent; + if (dir == null) throw new InvalidOperationException("SIL.TextEditing.sln not found above " + AppContext.BaseDirectory); + var path = Path.Combine(dir.FullName, "docs", "images"); + Directory.CreateDirectory(path); + return path; + } + + /// Renders the window (open menus included) and writes the whole of it, or of it, as a PNG. + static void Save(Session s, string name, Rect? crop = null) + { + Headless.Pump(); + var window = s.Window; + var full = new PixelSize((int)window.Width, (int)window.Height); + var area = crop.HasValue ? new PixelRect((int)crop.Value.X, (int)crop.Value.Y, (int)crop.Value.Width, (int)crop.Value.Height) : new PixelRect(full); + using (var render = new RenderTargetBitmap(full)) + { + render.Render(window); + var stride = full.Width * 4; + var buffer = new byte[stride * full.Height]; + var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); + try { render.CopyPixels(new PixelRect(full), handle.AddrOfPinnedObject(), buffer.Length, stride); } + finally { handle.Free(); } + using (var cropped = new WriteableBitmap(area.Size, new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Premul)) + { + using (var fb = cropped.Lock()) + for (int y = 0; y < area.Height; y++) + Marshal.Copy(buffer, (area.Y + y) * stride + area.X * 4, fb.Address + y * fb.RowBytes, area.Width * 4); +#if NET10_0_OR_GREATER + cropped.Save(Path.Combine(OutDir(), name + ".png"), new PngBitmapEncoderOptions()); +#else + cropped.Save(Path.Combine(OutDir(), name + ".png")); +#endif + } + } + } + + /// A full-width strip of the window of pixels that starts a little above , so each shot starts at + /// the field (or at the switches above it) however many lines the scenario description takes. + static Rect From(Session s, Control control, double height) + { + double top = Math.Max(0, control.TranslatePoint(new Point(0, 0), s.Window)!.Value.Y - 8); + return new Rect(0, top, W, Math.Min(height, H - top)); + } + + /// 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 void Select(Session s, int paragraph, string from, string to) + { + var text = s.ParagraphText(paragraph); + int a = text.IndexOf(from, StringComparison.Ordinal), b = text.IndexOf(to, a, StringComparison.Ordinal) + to.Length; + Assert.True(a >= 0 && b > a, from); + s.Select(paragraph, a, b); + } + + [ScreenshotFact] + public void Bidi_selection() => Headless.Run(() => + { + using (var s = new Session(Scenarios.MixedBidi, W, H)) + { + // From the Latin "means" across the Arabic "مرحبا بالعالم" to "hello": the selection changes direction twice. + Select(s, 0, "means book", "hello"); + Save(s, "bidi-selection", FieldStrip(s, 345)); + } + }); + + [ScreenshotFact] + public void Indic_and_thai_shaping() => Headless.Run(() => + { + using (var s = new Session(Scenarios.IndicThai, W, H)) + { + // One Devanagari conjunct (kshatriya) selected: the selection covers whole clusters. + Select(s, 0, "क्षत्रिय", "क्षत्रिय"); + Save(s, "indic-thai-shaping", FieldStrip(s, 345)); + } + }); + + [ScreenshotFact] + public void Nfd_and_surrogates() => Headless.Run(() => + { + using (var s = new Session(Scenarios.Unicode, W, H)) + { + Select(s, 1, "Surrogates", "\U0001F600"); + // Only the first two lines: the private-use line below them has no font to draw it, and shows a missing-glyph box. + var strip = FieldStrip(s, 62); + Save(s, "nfd-surrogates", new Rect(0, strip.Y, 760, 62)); + } + }); + + [ScreenshotFact] + public void Inline_pictures() => Headless.Run(() => + { + using (var s = new Session(Scenarios.Picture, W, H)) + { + s.Select(0, 3, 36); + Save(s, "inline-pictures", FieldStrip(s, 345)); + } + }); + + [ScreenshotFact] + public void Unknown_properties_in_the_inspector() => Headless.Run(() => + { + using (var s = new Session(Scenarios.Opaque, W, H)) + { + var text = s.ParagraphText(1); + int at = text.IndexOf("unknown", StringComparison.Ordinal); + s.Select(1, at, at + 7); + Save(s, "unknown-properties-inspector", FieldStrip(s, 345)); + } + }); + + [ScreenshotFact] + public void Environment_with_language_service() => Headless.Run(() => + { + using (var s = new Session(Scenarios.Environment, W, H)) + { + s.Editor.SetCaret(new TextPosition(0, 0)); + s.Editor.SetCaret(new TextPosition(0, s.ParagraphText(0).Length), true); + s.Type("/ [X] _ (a ,"); + s.Press(Key.End); + Headless.Pump(); + Save(s, "environment-box", FieldStrip(s, 200)); + } + }); + + [ScreenshotFact] + public void Environment_context_menu() => Headless.Run(() => + { + using (var s = new Session(Scenarios.Environment, W, H)) + { + s.Editor.SetCaret(new TextPosition(0, 0)); + s.Editor.SetCaret(new TextPosition(0, s.ParagraphText(0).Length), true); + s.Type("/ (a _ ,"); + Headless.Pump(); + var at = s.PointAt(0, 2); + s.RightClick(at); + Save(s, "environment-fix-menu", FieldStrip(s, 365)); + s.View.CloseContextMenu(); + } + }); + + [ScreenshotFact] + public void Spelling_squiggles_and_menu() => Headless.Run(() => + { + using (var s = new Session(Scenarios.Spelling, W, H)) + { + Headless.Pump(); + Save(s, "spelling-squiggles", FieldStrip(s, 345)); + var at = s.PointAt(0, s.ParagraphText(0).IndexOf("jumpd", StringComparison.Ordinal) + 2); + s.RightClick(at); + Save(s, "spelling-context-menu", FieldStrip(s, 345)); + s.View.CloseContextMenu(); + } + }); + + [ScreenshotFact] + public void Block_features() => Headless.Run(async () => + { + using (var s = new Session(Scenarios.Empty, W, H)) + { + // Pasted in the clipboard's own interchange form, which keeps each paragraph's style. (Tabs, the cell separator, are not typed or pasted into a field yet, so the table is one column.) + Paragraph P(string style, string text) + { + var p = new Paragraph { Style = style, DefaultWs = "en" }; + p.Runs.Add(new Run { Ws = "en", Text = text }); + return p; + } + var paragraphs = new[] + { + P("Heading 1", "Block features"), + P("Normal", "A heading, a list and a table are paragraphs with a style the host names; the three switches turn each kind on or off."), + P("List Bullet", "Headings, lists and tables are all paragraph styles"), + P("List Bullet", "Switching a kind off hides its styles and flattens a paste"), + P("Table Header", "Style the switch controls"), + P("Table Row", "Heading 1 (Headings)"), + P("Table Row", "List Bullet, List Number and others (Lists)"), + P("Table Row", "Table Header and Table Row (Tables)"), + }; + await global::SIL.TextEditing.Avalonia.ClipboardCompat.SetAsync(TopLevel.GetTopLevel(s.Field), "x", + new[] { new System.Collections.Generic.KeyValuePair(Interchange.ClipboardFormat, Interchange.ToXml(paragraphs)) }); + s.Caret(0, 0); + s.Click(PlaygroundView.Names.Paste); + await s.WaitClipboard(); + s.Caret(7, s.ParagraphText(7).Length); + Save(s, "block-features", From(s, s.Find(PlaygroundView.Names.AllowTables), 330)); + } + }); + + [ScreenshotFact] + public void Long_document_and_many_fields() => Headless.Run(() => + { + using (var s = new Session(Scenarios.Long, W, H)) + { + s.Caret(120, 10); + Save(s, "long-document", FieldStrip(s, 420)); + } + using (var s = new Session(Scenarios.ManyFields, W, H)) + Save(s, "many-fields", FieldStrip(s, 440)); + }); + + [ScreenshotFact] + public void Copy_as_submenu() => Headless.Run(() => + { + using (var s = new Session(Scenarios.MixedBidi, W, H)) + { + Select(s, 0, "The word", "book"); + s.RightClick(s.PointAt(0, 4)); + var menu = s.Window.GetVisualDescendantsOf().FirstOrDefault(m => m.Header as string == PlaygroundView.CopyAsCaption); + Assert.NotNull(menu); + menu!.IsSubMenuOpen = true; + Headless.Pump(); + Save(s, "copy-as-submenu", FieldStrip(s, 345)); + s.View.CloseContextMenu(); + } + }); + } + + static class VisualExtensions + { + public static System.Collections.Generic.IEnumerable GetVisualDescendantsOf(this Control root) where T : Control => + global::Avalonia.VisualTree.VisualExtensions.GetVisualDescendants(root).OfType(); + } +}