diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f3bd1f..bb261e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,13 @@ jobs: with: # `inputs` is empty on push, pull_request and schedule; only a dispatch sets it. version-bump: ${{ inputs.version-bump || 'auto' }} + + # Regenerates docs/gallery once the pipeline has released, and commits it back to main. Only for + # a push to main, so nothing from a pull request, and so nothing from a fork, is ever built with + # this job's write access. See editor-gallery.yml. + editor-gallery: + needs: ci + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: ./.github/workflows/editor-gallery.yml + permissions: + contents: write diff --git a/.github/workflows/editor-gallery.yml b/.github/workflows/editor-gallery.yml new file mode 100644 index 0000000..bb0b291 --- /dev/null +++ b/.github/workflows/editor-gallery.yml @@ -0,0 +1,148 @@ +name: Editor Gallery + +# Regenerates docs/gallery after CI finishes on main, and commits the pictures back to main when they +# differ. Modelled on ImGuiApp's widget-gallery.yml and ImageGui's feature-gallery.yml; the reasoning +# there holds here too, and is repeated where it decides something. +# +# The pictures come from the Gallery tests in tests/Coder.Editor.UITests, run with CODER_GALLERY_OUT +# pointing at docs/gallery. Pull requests already run those tests in the Linux UI test leg, writing +# to a temporary directory, so a stage that no longer works fails the pull request; this workflow +# only ever builds main, and only to publish the result. +# +# Nothing is committed when the output is byte-identical, which it is run to run: the editor draws +# on the CPU rasterizer at a pinned display size, opens nothing from disk, starts from the document +# built in code, steps its layout simulation at the harness's fixed frame time, and draws in the font +# ImGuiApp ships. The only thing that moves a picture is a change to the code that draws it. Always +# on the same runner image, because the pictures are compared with the last ones committed. +# +# ci.yml calls this as a job that needs its pipeline, only for a push to main; it can also be run by +# hand on main. It is deliberately not a workflow_run workflow. workflow_run runs with the +# repository's own token and secrets whatever started the run it follows, including a pull request +# from a fork, so building checked-out code there is the pattern githubactions:S7631 exists to catch. +# Here the only things that can start a run are a push to main, which needs write access, and a +# dispatch by someone with write access, so nothing a fork wrote is ever checked out or built with +# this job's permissions. +# +# Why after CI rather than beside it: CI's release job commits VERSION.md and friends back to main +# with a plain `git push` from the commit it was started on. A gallery commit landing on main first +# makes that push non-fast-forward, and the release fails. Needing the pipeline means main already +# carries the release commit. A failed pipeline skips this job. +# +# Why the commit back cannot loop or cut a release: +# - It is pushed with the workflow's own GITHUB_TOKEN, and GitHub starts no workflow run for a +# push made with that token. Neither CI nor, through it, this workflow sees it. +# - It carries `[bot][skip ci]`, the prefix KtsuBuild's own metadata commits use, so KtsuBuild +# leaves it out of the version calculation and the changelog either way. +# +# Why it can push to a protected main: the main ruleset lists github-actions[bot] (the identity +# behind GITHUB_TOKEN) as an always-bypass actor, which is also how KtsuBuild pushes its metadata +# commits. If it ever stops doing so, the push step fails and says so. + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +env: + DOTNET_VERSION: "10.0" + +jobs: + regenerate: + name: Regenerate editor gallery + runs-on: ubuntu-latest + timeout-minutes: 20 + if: github.repository == 'ktsu-dev/Coder' && github.ref == 'refs/heads/main' + # A newer run supersedes an older one: both regenerate from the current main. On the job rather + # than the workflow, because a called workflow's github.workflow is the caller's name. + concurrency: + group: editor-gallery + cancel-in-progress: true + permissions: + contents: write # To push the regenerated pictures to main + + steps: + # LFS content is not fetched: nothing the gallery builds from is in LFS, and every picture it + # writes replaces the pointer that was there. The LFS filters are installed below instead, + # which is what makes git compare and commit the pictures as LFS objects. + - name: Checkout Repository + uses: actions/checkout@v7 + with: + # The current main, never the triggering event's ref or SHA. + ref: main + fetch-depth: 1 + lfs: false + persist-credentials: true + + - name: Setup .NET SDK ${{ env.DOTNET_VERSION }} + uses: actions/setup-dotnet@v6 + with: + dotnet-version: ${{ env.DOTNET_VERSION }}.x + cache: true + cache-dependency-path: | + **/*.csproj + **/Directory.Packages.props + **/global.json + + - name: Install Git LFS filters + run: git lfs install --local + + - name: Build the UI tests + run: dotnet build tests/Coder.Editor.UITests/Coder.Editor.UITests.csproj -c Release + + # The old pictures are removed first so that one whose entry was renamed or removed goes with + # it. A stage that fails fails the job before anything is committed. + - name: Regenerate the gallery + env: + CODER_GALLERY_OUT: ${{ github.workspace }}/docs/gallery + run: | + rm -f docs/gallery/*.png docs/gallery/README.md + tests/Coder.Editor.UITests/bin/Release/net10.0/ktsu.Coder.Editor.UITests \ + --filter "FullyQualifiedName~ktsu.Coder.Editor.UITests.Gallery" + + # Only docs/gallery is staged. The build also rewrites some tracked files (ktsu.Sdk syncs + # .gitignore and friends from its own copy), and none of that belongs in this commit. + - name: Commit and push the pictures + env: + GIT_LFS_SKIP_SMUDGE: "1" + run: | + source_sha=$(git rev-parse HEAD) + git add --all docs/gallery + + if git diff --cached --quiet; then + echo "The editor gallery is up to date." + exit 0 + fi + + git diff --cached --stat + + # A PNG committed as a blob rather than as an LFS pointer would bloat the history for good. + for path in $(git diff --cached --name-only --diff-filter=AM -- '*.png'); do + header=$(git cat-file blob ":$path" | head -c 42 | tr -d '\0' || true) + if [[ "$header" != "version https://git-lfs.github.com/spec/v1" ]]; then + echo "::error file=$path::Staged as a raw blob rather than an LFS pointer." + exit 1 + fi + done + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit --quiet --message "[bot][skip ci] Regenerate the editor gallery" \ + --message "Rendered by .github/workflows/editor-gallery.yml from $source_sha." + + # Drop what the build rewrote and was left unstaged, which a rebase refuses to run over. + git reset --hard --quiet HEAD + + for attempt in 1 2 3 4 5; do + if git push origin HEAD:main; then + exit 0 + fi + echo "Push rejected (attempt $attempt); rebasing onto the current main." + sleep $((attempt * 5)) + git fetch --depth=50 origin main + git rebase FETCH_HEAD + done + + echo "::error::Could not push the regenerated gallery to main. If main's ruleset does not let github-actions[bot] bypass it, that is why." + exit 1 diff --git a/Coder.sln b/Coder.sln index 6278f54..7da5c1d 100644 --- a/Coder.sln +++ b/Coder.sln @@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coder.Graph", "Coder.Graph\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coder.Editor", "Coder.Editor\Coder.Editor.csproj", "{90A3A9A1-F2ED-46EB-934C-1EF4632E2C1D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coder.Editor.UITests", "tests\Coder.Editor.UITests\Coder.Editor.UITests.csproj", "{1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -83,10 +87,25 @@ Global {90A3A9A1-F2ED-46EB-934C-1EF4632E2C1D}.Release|x64.Build.0 = Release|Any CPU {90A3A9A1-F2ED-46EB-934C-1EF4632E2C1D}.Release|x86.ActiveCfg = Release|Any CPU {90A3A9A1-F2ED-46EB-934C-1EF4632E2C1D}.Release|x86.Build.0 = Release|Any CPU + {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Debug|x64.ActiveCfg = Debug|Any CPU + {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Debug|x64.Build.0 = Debug|Any CPU + {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Debug|x86.ActiveCfg = Debug|Any CPU + {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Debug|x86.Build.0 = Debug|Any CPU + {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Release|Any CPU.Build.0 = Release|Any CPU + {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Release|x64.ActiveCfg = Release|Any CPU + {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Release|x64.Build.0 = Release|Any CPU + {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Release|x86.ActiveCfg = Release|Any CPU + {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {1BAE9C60-4FDE-4C83-8646-1AEF8A9835FF} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {449DC655-FEAD-4DD8-9AD1-5F731B7A8DFA} EndGlobalSection diff --git a/README.md b/README.md index 3612db6..e07a4e4 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,8 @@ keyboard focus is. dotnet run --project Coder.Editor ``` +Pictures of the editor as it draws today are in the [editor gallery](docs/gallery/README.md). + Documents are `.coder.yaml` files — the same YAML the serializer already round-trips, so anything the library can write, the editor can open. The code pane follows the document live and lists what is outstanding instead of generating while an operand is unfilled; clicking one of those selects the diff --git a/tests/Coder.Editor.UITests/AssemblyInfo.cs b/tests/Coder.Editor.UITests/AssemblyInfo.cs new file mode 100644 index 0000000..8d47424 --- /dev/null +++ b/tests/Coder.Editor.UITests/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +// ImGui keeps its context in global state, so only one harness can exist per process and two +// tests running at once would corrupt each other. Sequential execution is a correctness +// requirement here, not a performance preference. +[assembly: DoNotParallelize] diff --git a/tests/Coder.Editor.UITests/Coder.Editor.UITests.csproj b/tests/Coder.Editor.UITests/Coder.Editor.UITests.csproj new file mode 100644 index 0000000..a850ed4 --- /dev/null +++ b/tests/Coder.Editor.UITests/Coder.Editor.UITests.csproj @@ -0,0 +1,32 @@ + + + + + + true + net10.0 + + + ktsu.Coder.Editor.UITests + $(RootNamespace) + false + true + + + + + + + + + + + + + + + + + + diff --git a/tests/Coder.Editor.UITests/Gallery/EditorGallery.cs b/tests/Coder.Editor.UITests/Gallery/EditorGallery.cs new file mode 100644 index 0000000..1de843a --- /dev/null +++ b/tests/Coder.Editor.UITests/Gallery/EditorGallery.cs @@ -0,0 +1,136 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Editor.UITests.Gallery; + +using ktsu.Coder.Editor; +using ktsu.Coder.Languages; +using ktsu.ImGui.App.Testing; + +using Microsoft.Extensions.DependencyInjection; + +/// +/// Photographs the editor for docs/gallery: one picture per +/// entry, and an index that captions them. +/// +/// +/// +/// These run as ordinary tests, so every pull request proves each picture can still be staged: a +/// stage that reaches for a node, a pane or a language that has gone fails here rather than at the +/// next regeneration on main. Pictures are written to a temporary directory unless +/// CODER_GALLERY_OUT names one, which is how the gallery workflow, and anyone regenerating by +/// hand, sends them to docs/gallery. +/// +/// +/// Each entry builds its own application from , the container the +/// real editor runs with, and opens nothing from disk, so the status bar never shows a path from the +/// machine that drew it. The layout simulation steps at the harness's fixed frame time, which is what +/// makes a settled graph come out the same on every run. +/// +/// +[TestClass] +public sealed class EditorGallery +{ + /// The environment variable naming the directory the gallery is written to. + internal const string OutputVariable = "CODER_GALLERY_OUT"; + + /// The display the gallery is drawn at. + internal static readonly (int Width, int Height) Display = (1600, 900); + + /// + /// How many frames the layout runs before the picture is staged: about four seconds of the + /// simulation, which is long enough for the default document to come to rest. + /// + private const int SettleFrames = 240; + + private static readonly Lazy TemporaryOutput = new(() => + Path.Combine(Path.GetTempPath(), $"coder-gallery-{Guid.NewGuid():N}")); + + /// Gets or sets the test context, for reporting where each picture went. + public TestContext TestContext { get; set; } = null!; + + /// Gets every entry's name, one test case each. + public static IEnumerable EntryNames => GalleryCatalog.Entries.Select(entry => new object[] { entry.Name }); + + /// Gets the directory pictures are written to. + internal static string OutputDirectory => + Environment.GetEnvironmentVariable(OutputVariable) is string output && output.Length > 0 + ? Path.GetFullPath(output) + : TemporaryOutput.Value; + + /// Removes the temporary directory, when pictures went there rather than to a named one. + [ClassCleanup] + public static void DeleteTemporaryOutput() + { + if (TemporaryOutput.IsValueCreated && Directory.Exists(TemporaryOutput.Value)) + { + Directory.Delete(TemporaryOutput.Value, recursive: true); + } + } + + [TestMethod] + [DynamicData(nameof(EntryNames))] + public void Photograph(string name) + { + GalleryEntry entry = GalleryCatalog.Entries.Single(candidate => candidate.Name == name); + + using ServiceProvider services = Program.BuildServices(); + CoderEditorApp app = new( + services.GetRequiredService(), + services.GetServices(), + new EditorSettings()); + entry.Prepare?.Invoke(app); + + using ImGuiAppHarness harness = ImGuiAppHarness.Start(app.BuildConfig(), new HarnessOptions { Width = Display.Width, Height = Display.Height }); + Assert.IsTrue(GalleryFonts.Load(), "ImGuiApp's own font could not be found, so the pictures would not look like the application."); + harness.Mouse.MoveTo(-100f, -100f); + harness.Step(SettleFrames); + + // The editor fits the graph to the canvas on its first frames, before the layout has moved + // anything; fitting again once it has settled is what puts the whole document in the picture. + app.Editor.FitView(); + harness.Step(2); + + EditorUnderTest editor = new(app, harness); + entry.Stage(editor); + harness.Step(2); + + Bitmap32 frame = harness.Target; + Rectangle region = entry.Crop?.Invoke(editor) ?? new Rectangle(0, 0, frame.Width, frame.Height); + Bitmap32 picture = Crop(frame, region); + + Directory.CreateDirectory(OutputDirectory); + string path = Path.Combine(OutputDirectory, entry.Slug + ".png"); + picture.SavePng(path); + TestContext.WriteLine($"Wrote {path} ({picture.Width}x{picture.Height})."); + } + + [TestMethod] + public void WriteTheIndex() + { + string[] slugs = [.. GalleryCatalog.Entries.Select(entry => entry.Slug)]; + Assert.HasCount(slugs.Length, slugs.Distinct(StringComparer.Ordinal), "Two gallery entries would write the same file."); + + Directory.CreateDirectory(OutputDirectory); + File.WriteAllText(Path.Combine(OutputDirectory, "README.md"), GalleryIndex.Render(GalleryCatalog.Entries)); + } + + /// Copies a rectangle out of a frame, clamped to its edges. + internal static Bitmap32 Crop(Bitmap32 source, Rectangle region) + { + int minX = Math.Clamp(region.MinX, 0, source.Width); + int minY = Math.Clamp(region.MinY, 0, source.Height); + int maxX = Math.Clamp(region.MaxX, minX, source.Width); + int maxY = Math.Clamp(region.MaxY, minY, source.Height); + Assert.IsTrue(maxX > minX && maxY > minY, $"The crop {region} leaves nothing of a {source.Width}x{source.Height} frame."); + + Bitmap32 cropped = new(maxX - minX, maxY - minY); + int rowBytes = cropped.Width * 4; + for (int y = minY; y < maxY; y++) + { + source.Pixels.Slice(((y * source.Width) + minX) * 4, rowBytes) + .CopyTo(cropped.Pixels.Slice((y - minY) * rowBytes, rowBytes)); + } + + return cropped; + } +} diff --git a/tests/Coder.Editor.UITests/Gallery/EditorUnderTest.cs b/tests/Coder.Editor.UITests/Gallery/EditorUnderTest.cs new file mode 100644 index 0000000..7d847e2 --- /dev/null +++ b/tests/Coder.Editor.UITests/Gallery/EditorUnderTest.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Editor.UITests.Gallery; + +using ktsu.Coder.Ast; +using ktsu.Coder.Editor; +using ktsu.ImGui.App.Testing; + +/// The editor a gallery entry stages, and the harness drawing it. +/// The application, built the way Program builds it. +/// The headless harness the application is running under. +internal sealed record EditorUnderTest(CoderEditorApp App, ImGuiAppHarness Harness) +{ + /// Selects the one node in the graph matching a predicate, so the inspector shows it. + /// The kind of node to look for. + /// Picks the node out from the others of its kind. + internal void Select(Func match) + where TNode : AstNode + { + TNode node = App.Editor.Graph.Nodes.Values.OfType().Single(match); + Assert.IsTrue(App.Editor.Select(node), $"The {typeof(TNode).Name} to select is not in the graph."); + } + + /// Shows the generated source in another language, as its radio button would. + /// The generator's language id. + internal void Preview(string languageId) + { + App.Settings.PreviewLanguageId = languageId; + App.Regenerate(); + } + + /// The height of the main menu bar, which a column picture leaves out. + private const int MenuBarHeight = 24; + + /// The height of the status bar, which a column picture leaves out. + private const int StatusBarHeight = 34; + + /// Gets the right-hand column: properties, generated code and layout tuning. + /// The column, from the divider to the window's right edge, between the menu bar and the status bar. + internal Rectangle SideColumn() + { + Bitmap32 frame = Harness.Target; + int left = (int)(frame.Width * App.Settings.GraphSplit); + return new Rectangle(left, MenuBarHeight, frame.Width, frame.Height - StatusBarHeight); + } +} diff --git a/tests/Coder.Editor.UITests/Gallery/GalleryCatalog.cs b/tests/Coder.Editor.UITests/Gallery/GalleryCatalog.cs new file mode 100644 index 0000000..4225bdf --- /dev/null +++ b/tests/Coder.Editor.UITests/Gallery/GalleryCatalog.cs @@ -0,0 +1,105 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Editor.UITests.Gallery; + +using System.Numerics; + +using ktsu.Coder.Ast; +using ktsu.Coder.Editor; +using ktsu.Coder.Graph; +using ktsu.ImGui.App.Testing; + +/// Every picture in the editor gallery, in the order the index shows them. +/// +/// Each stage starts from the document a new editor opens with, , +/// its layout settled and fitted to the canvas. A new feature earns a picture by adding an entry here; +/// nothing else needs to change, because the runner, the index and the workflow all read this list. +/// +internal static class GalleryCatalog +{ + /// Gets the entries. + internal static IReadOnlyList Entries { get; } = + [ + new( + "Editing a document", + "The document is an abstract syntax tree drawn as a node graph, laid out by a force-directed simulation. Beside it, the selected node's properties and the source the document generates, here in C#.", + editor => editor.Select(declaration => declaration.Name == "Counter")), + new( + "Inspecting a node", + "Selecting a node shows its own properties in the inspector, each with the control that suits it: a text field, a checkbox or a picker. Every edit is one step on the undo stack.", + editor => editor.Select(function => function.Name == "Add")) + { + Prepare = app => app.Settings.PropertiesSplit = 0.6f, + Crop = editor => editor.SideColumn(), + }, + Language("python", "Python", "One document, seven languages. The preview regenerates as the graph is edited; Python writes the receiver as `self` and the types as annotations."), + Language("cpp", "C++", "C++ gathers the members under an access section and closes the class with the semicolon the language requires."), + Language("rust", "Rust", "Rust puts the data in a `struct` and the behaviour in an `impl` block beside it."), + Language("go", "Go", "Go writes a struct with its methods beside it, laid out exactly as `gofmt` would."), + new( + "Problems before generating", + "A document with an operand nobody has filled in, and a node left detached from the tree, does not generate. The code pane lists what is outstanding instead, and clicking a problem selects the node it is about.", + _ => { }) + { + Prepare = Unfinished, + }, + new( + "Layout tuning", + "Every force in the layout simulation can be tuned while watching the graph move, grouped by what it does and captioned with its effect, with the simulation's own state below.", + editor => + { + // The Repulsion header, the first of the layout panel's groups. + editor.Harness.Mouse.Click(1092f, 354f); + editor.Harness.Step(2); + editor.Harness.Mouse.MoveTo(-100f, -100f); + }) + { + Prepare = app => + { + app.Settings.PropertiesSplit = 0.12f; + app.Settings.CodeSplit = 0.18f; + }, + Crop = editor => editor.SideColumn(), + }, + new( + "File menu", + "A document starts as a function or a class, is saved and opened as YAML, and exports the generated source in the language being previewed.", + editor => + { + editor.Harness.Mouse.Click(24, 10); + editor.Harness.Step(2); + editor.Harness.Mouse.MoveTo(-100f, -100f); + }) + { + Crop = _ => new Rectangle(0, 0, 400, 160), + }, + ]; + + /// Builds an entry showing the generated source in one language. + /// The generator's language id. + /// The language's name, as the radio button shows it. + /// What to say about it. + private static GalleryEntry Language(string languageId, string displayName, string description) => + new($"Generated {displayName}", description, editor => editor.Preview(languageId)) + { + Prepare = app => + { + app.Settings.PropertiesSplit = 0.12f; + app.Settings.CodeSplit = 0.55f; + }, + Crop = editor => editor.SideColumn(), + }; + + /// + /// Takes the right-hand operand out of Next's addition and leaves it beside the tree, as a + /// user disconnecting the link would. + /// + private static void Unfinished(CoderEditorApp app) + { + BinaryExpression addition = app.Editor.Graph.Nodes.Values.OfType() + .Single(expression => expression.Right is VariableReference { Name: "step" }); + Expression step = addition.Right; + addition.Right = AstSchema.Unfilled(); + app.Editor.Graph.AddDetached(step, new Vector2(200f, 0f)); + } +} diff --git a/tests/Coder.Editor.UITests/Gallery/GalleryEntry.cs b/tests/Coder.Editor.UITests/Gallery/GalleryEntry.cs new file mode 100644 index 0000000..8cb1ef4 --- /dev/null +++ b/tests/Coder.Editor.UITests/Gallery/GalleryEntry.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Editor.UITests.Gallery; + +using System.Text; + +using ktsu.ImGui.App.Testing; + +/// One picture in the editor gallery: how to stage it, and what to say about it. +/// The caption, which also names the picture's file. +/// One or two sentences under the picture in the gallery's index. +/// +/// Drives the editor into the state worth photographing, starting from the document open, its +/// layout settled and the graph fitted to the canvas. +/// +internal sealed record GalleryEntry(string Name, string Description, Action Stage) +{ + /// + /// Gets what to do to the application before its first frame: arranging the panes, which are + /// built on that frame, or editing the document so the layout settles around the edit. + /// + public Action? Prepare { get; init; } + + /// + /// Gets the part of the window to keep, or null for all of it. Asked after + /// has run, so it can measure what the stage put on screen. + /// + public Func? Crop { get; init; } + + /// Gets the file name the picture is written under, without its extension. + public string Slug => MakeSlug(Name); + + /// + public override string ToString() => Name; + + /// Turns a caption into a lower-case, hyphenated file name. + /// + /// A language's punctuation is spelled out first, so that "C++" names a file of its own rather than + /// the one "C" would. + /// + internal static string MakeSlug(string text) + { + string spelled = text.Replace("++", "pp", StringComparison.Ordinal).Replace("#", "sharp", StringComparison.Ordinal); + StringBuilder slug = new(spelled.Length); + foreach (char character in spelled) + { + if (char.IsAsciiLetterOrDigit(character)) + { + slug.Append(char.ToLowerInvariant(character)); + } + else if (slug.Length > 0 && slug[^1] != '-') + { + slug.Append('-'); + } + } + + return slug.ToString().TrimEnd('-'); + } +} diff --git a/tests/Coder.Editor.UITests/Gallery/GalleryFonts.cs b/tests/Coder.Editor.UITests/Gallery/GalleryFonts.cs new file mode 100644 index 0000000..f0edf30 --- /dev/null +++ b/tests/Coder.Editor.UITests/Gallery/GalleryFonts.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Editor.UITests.Gallery; + +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Resources; + +using Hexa.NET.ImGui; + +using ktsu.ImGui.App; + +/// Gives the gallery the font a running application draws in. +/// +/// The harness never builds ImGuiApp's own fonts, so a test draws in Dear ImGui's built-in +/// bitmap font. That is right for tests, which measure layout and must not move when a font +/// does, and wrong for pictures of the application. This loads the Nerd Font ImGuiApp ships +/// and makes it the default, which is what the widget gallery in ImGuiApp does for the same reason. +/// +internal static class GalleryFonts +{ + /// The size ImGuiApp draws its interface at, at a scale of one. + private const float Pixels = 14f; + + private static readonly Lazy NerdFont = new(() => + { + try + { + ResourceManager resources = new("ktsu.ImGui.App.Resources.Resources", typeof(ImGuiApp).Assembly); + return resources.GetObject("NerdFont", CultureInfo.InvariantCulture) as byte[]; + } + catch (MissingManifestResourceException) + { + return null; + } + }); + + /// Gets a value indicating whether the application's font could be found. + internal static bool Available => NerdFont.Value is not null; + + /// Adds the application's font and makes it the default. Call between frames. + /// True when the font was loaded; false leaves the built-in font in place. + [SuppressMessage("Major Code Smell", "S6640:Make sure that using \"unsafe\" is safe here", Justification = "FontHelper's glyph range helper returns a pointer ImGui owns for the atlas's lifetime; it is passed straight through and never dereferenced here.")] + internal static bool Load() + { + if (NerdFont.Value is not byte[] data) + { + return false; + } + + ImGuiIOPtr io = ImGui.GetIO(); + unsafe + { + ImFontPtr? font = FontHelper.AddCustomFont(io, data, Pixels, FontHelper.GetExtendedUnicodeRanges(io.Fonts)); + if (font is not ImFontPtr loaded) + { + return false; + } + + io.FontDefault = loaded; + } + + return true; + } +} diff --git a/tests/Coder.Editor.UITests/Gallery/GalleryIndex.cs b/tests/Coder.Editor.UITests/Gallery/GalleryIndex.cs new file mode 100644 index 0000000..53928ad --- /dev/null +++ b/tests/Coder.Editor.UITests/Gallery/GalleryIndex.cs @@ -0,0 +1,28 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Editor.UITests.Gallery; + +using System.Text; + +/// Writes the gallery's README.md, which captions each picture. +internal static class GalleryIndex +{ + /// Renders the index for the given entries, in catalog order. + internal static string Render(IReadOnlyList entries) + { + StringBuilder text = new(); + text.Append("# Editor gallery\n\n"); + text.Append("\n\n"); + text.Append("Coder.Editor as it draws today, staged and photographed headlessly by the UI test harness. "); + text.Append("The pictures are regenerated on every successful build of `main`, so they show the current editor.\n\n"); + + foreach (GalleryEntry entry in entries) + { + text.Append("## ").Append(entry.Name).Append("\n\n"); + text.Append(entry.Description).Append("\n\n"); + text.Append("![").Append(entry.Name).Append("](").Append(entry.Slug).Append(".png)\n\n"); + } + + return text.ToString(); + } +}