Skip to content
This repository was archived by the owner on Nov 7, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/images/README.md
Original file line number Diff line number Diff line change
@@ -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).
Binary file added docs/images/bidi-selection.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/block-features.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
80 changes: 80 additions & 0 deletions docs/images/capture-hosts.ps1
Original file line number Diff line number Diff line change
@@ -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 <target> as process <id>"; 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)

Binary file added docs/images/copy-as-submenu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/desktop-window.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/environment-box.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/environment-fix-menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/host-winforms-fieldworks-shape.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/indic-thai-shaping.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/inline-pictures.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/long-document.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/many-fields.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/nfd-surrogates.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/spelling-context-menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/spelling-squiggles.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/unknown-properties-inspector.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
263 changes: 263 additions & 0 deletions tests/SIL.TextEditing.Playground.Tests/ScreenshotGenerator.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>A fact that is opt-in: it runs only when the environment variable <see cref="Switch"/> 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.</summary>
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
}
}

/// <summary>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
/// <c>$env:TEXTEDITING_SCREENSHOTS=1; dotnet test tests\SIL.TextEditing.Playground.Tests -c Release -f net10.0 --filter ScreenshotGenerator</c>.</summary>
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;
}

/// <summary>Renders the window (open menus included) and writes the whole of it, or <paramref name="crop"/> of it, as a PNG.</summary>
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
}
}
}

/// <summary>A full-width strip of the window of <paramref name="height"/> pixels that starts a little above <paramref name="control"/>, so each shot starts at
/// the field (or at the switches above it) however many lines the scenario description takes.</summary>
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));
}

/// <summary>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).</summary>
static Rect FieldStrip(Session s, double height) => From(s, s.Find<TextBox>(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<string, object>(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<CheckBox>(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<MenuItem>().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<T> GetVisualDescendantsOf<T>(this Control root) where T : Control =>
global::Avalonia.VisualTree.VisualExtensions.GetVisualDescendants(root).OfType<T>();
}
}
Loading