Skip to content
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
148 changes: 148 additions & 0 deletions .github/workflows/editor-gallery.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions Coder.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions tests/Coder.Editor.UITests/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -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]
32 changes: 32 additions & 0 deletions tests/Coder.Editor.UITests/Coder.Editor.UITests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Project>
<Sdk Name="MSTest.Sdk" />
<Sdk Name="ktsu.Sdk" />

<PropertyGroup>
<IsTestProject>true</IsTestProject>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks></TargetFrameworks>
<!-- The SDK folds the tests/ folder into the namespace, deriving ktsu.tests.Coder.*, so the name
is given outright until ktsu-dev/Sdk#37 teaches it to skip scaffolding folders. -->
<RootNamespace>ktsu.Coder.Editor.UITests</RootNamespace>
<AssemblyName>$(RootNamespace)</AssemblyName>
<EnableSourceLink>false</EnableSourceLink>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

<ItemGroup>
<!-- Starts the editor headlessly, through the CPU rasterizer. -->
<PackageReference Include="ktsu.ImGui.App" />
<PackageReference Include="ktsu.ImGui.App.Testing" />
<!-- The gallery loads ImGuiApp's own font through Dear ImGui's IO. -->
<PackageReference Include="Hexa.NET.ImGui" />
<!-- The editor is built from the same container Program.Main builds it from. -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Coder\Coder.csproj" />
<ProjectReference Include="..\..\Coder.Graph\Coder.Graph.csproj" />
<ProjectReference Include="..\..\Coder.Editor\Coder.Editor.csproj" />
</ItemGroup>
</Project>
136 changes: 136 additions & 0 deletions tests/Coder.Editor.UITests/Gallery/EditorGallery.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Photographs the editor for <c>docs/gallery</c>: one picture per <see cref="GalleryCatalog"/>
/// entry, and an index that captions them.
/// </summary>
/// <remarks>
/// <para>
/// 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
/// <c>CODER_GALLERY_OUT</c> names one, which is how the gallery workflow, and anyone regenerating by
/// hand, sends them to <c>docs/gallery</c>.
/// </para>
/// <para>
/// Each entry builds its own application from <see cref="Program.BuildServices"/>, 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.
/// </para>
/// </remarks>
[TestClass]
public sealed class EditorGallery
{
/// <summary>The environment variable naming the directory the gallery is written to.</summary>
internal const string OutputVariable = "CODER_GALLERY_OUT";

/// <summary>The display the gallery is drawn at.</summary>
internal static readonly (int Width, int Height) Display = (1600, 900);

/// <summary>
/// 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.
/// </summary>
private const int SettleFrames = 240;

private static readonly Lazy<string> TemporaryOutput = new(() =>
Path.Combine(Path.GetTempPath(), $"coder-gallery-{Guid.NewGuid():N}"));

/// <summary>Gets or sets the test context, for reporting where each picture went.</summary>
public TestContext TestContext { get; set; } = null!;

/// <summary>Gets every entry's name, one test case each.</summary>
public static IEnumerable<object[]> EntryNames => GalleryCatalog.Entries.Select(entry => new object[] { entry.Name });

/// <summary>Gets the directory pictures are written to.</summary>
internal static string OutputDirectory =>
Environment.GetEnvironmentVariable(OutputVariable) is string output && output.Length > 0
? Path.GetFullPath(output)
: TemporaryOutput.Value;

/// <summary>Removes the temporary directory, when pictures went there rather than to a named one.</summary>
[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<DocumentStore>(),
services.GetServices<ILanguageGenerator>(),
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));
}

/// <summary>Copies a rectangle out of a frame, clamped to its edges.</summary>
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;
}
}
Loading
Loading