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
155 changes: 155 additions & 0 deletions .github/workflows/editor-gallery.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
name: Editor Gallery

# Regenerates docs/gallery after CI passes 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 scenarios in tests/Schema.Editor.UITests, run with
# SCHEMA_GALLERY_OUT pointing at docs/gallery. Pull requests already run those scenarios with the
# rest of the UI suite, 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 scenarios draw
# on the CPU rasterizer at a pinned display size, over a sample schema built in code with a made-up
# path, with 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.
#
# 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, which starts no workflow run, and it carries the [bot][skip ci] prefix KtsuBuild's
# metadata commits use, so KtsuBuild leaves it out of the version 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 that ever stops being true, the push step fails and says so.
#
# This workflow never runs for a pull request, so it does not belong in dependabot-merge.yml's list
# of pull-request workflows.

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
# Only on main, and never on a fork, where there is nothing of ours to commit to. ci.yml only
# calls this for a push to main, so this is what keeps a manual run off other branches.
if: github.repository == 'ktsu-dev/Schema' && 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 scenarios build from is in LFS, and every picture
# they write 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, which by now carries CI's release commit, not the commit CI ran on.
# 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/Schema.Editor.UITests/Schema.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:
SCHEMA_GALLERY_OUT: ${{ github.workspace }}/docs/gallery
run: |
rm -f docs/gallery/*.png docs/gallery/README.md
tests/Schema.Editor.UITests/bin/Release/net10.0/ktsu.Schema.Editor.UITests \
--filter "FullyQualifiedName~ktsu.Schema.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:
# A rebase below checks files out, and nothing here reads a picture's content, so there
# is no reason to download LFS objects for it. Pointers stay pointers.
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

# Main may still have moved on since checkout, a manual dispatch racing a release being
# the likely way. The commits touch different files, so a rebase is clean.
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: 15 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,10 +548,21 @@ The names are qualified by the ImGui window and any pushed scope, and a test mat
part: `menu/New`, `field/ClassNameUser`, `memberId/Delete`, `diagnostic/Error:Users`. Rows that share
a label push a probe scope alongside `ImGui.PushID`, so two members' fields do not collide.

One thing has no name to click: the right-hand tab bar comes from a widget library that neither
records its tabs nor takes a selection from outside. A panel behind it - the class graph, the
diagnostics list - is tested by drawing it directly in a `WidgetHarness`, which is what the tab's
own delegate does.
The right-hand tab bar comes from a widget library that neither records its tabs nor takes a
selection from outside, so the editor measures them itself: `MarkMainTabs` records each one as
`main-tab/<name>`, sized the way Dear ImGui sizes a tab, and only when a probe is listening. That is
what lets the gallery reach the class graph and the diagnostics list by clicking. The tests of those
panels predate it and draw them directly in a `WidgetHarness`, which is what the tab's own delegate
does; that is still the quicker route to a panel's behaviour.

### The gallery

`tests/Schema.Editor.UITests/Gallery` stages the editor over a sample schema and photographs it.
The scenarios are ordinary tests, so a pull request proves every picture can still be staged; they
write to a temporary directory unless `SCHEMA_GALLERY_OUT` names one. `editor-gallery.yml` runs them
into `docs/gallery` after CI passes on `main` and commits the result, so neither the pictures nor
their `README.md` are written by hand. A picture has to come out byte-identical run to run: no
clock, no machine path (the sample schema's path is made up), nothing random.

### What a Linux-only suite stops seeing

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ Use `SchemaSerializer` for JSON serialization with `System.Text.Json`. The seria
## Schema Editor

The **Schema.Editor** is an ImGui-based desktop application for visually creating and editing `.schema.json` files.
The [editor gallery](docs/gallery/README.md) shows what it looks like, regenerated from `main` on every build.

### Running the Editor

Expand Down
20 changes: 20 additions & 0 deletions Schema.Editor/ClassGraphView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
// changes. Rebuilding every frame would reset the force-directed layout and node positions.
private string lastSignature = string.Empty;

// Set by RequestFitToView and spent on the next frame drawn, which is the first moment the size
// of the canvas is known.
private bool fitToViewRequested;

public ClassGraphView() =>
// The force-directed layout is disabled by default; enable it so the graph self-arranges.
engine.UpdatePhysicsSettings(engine.PhysicsSettings with { Enabled = true });
Expand Down Expand Up @@ -75,6 +79,12 @@
engine.SetDraggedNodes(renderer.CurrentlyDraggedNodes);
engine.UpdatePhysics(deltaTime);

if (fitToViewRequested)
{
renderer.FitToView(engine, editorSize);
fitToViewRequested = false;
}

renderer.Render(engine, editorSize);

// Node transforms must be read back after rendering, while the editor context is active.
Expand All @@ -95,7 +105,7 @@
/// </summary>
/// <param name="schema">The schema to build the graph from.</param>
/// <param name="centre">Where on the canvas to lay the nodes out around.</param>
private void Rebuild(Schema schema, Vector2 centre)

Check warning on line 108 in Schema.Editor/ClassGraphView.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 26 to the 15 allowed.

Check warning on line 108 in Schema.Editor/ClassGraphView.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 26 to the 15 allowed.
{
engine.Clear();

Expand All @@ -103,7 +113,7 @@
List<NodeDescriptor> descriptors = [];
HashSet<string> knownKeys = [];

foreach (SchemaClass schemaClass in schema.Classes)

Check warning on line 116 in Schema.Editor/ClassGraphView.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loop should be simplified by calling Select(schemaClass => schemaClass.Name)

Check warning on line 116 in Schema.Editor/ClassGraphView.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loop should be simplified by calling Select(schemaClass => schemaClass.Name)
{
string key = ClassKey(schemaClass.Name);
if (knownKeys.Add(key))
Expand All @@ -112,7 +122,7 @@
}
}

foreach (SchemaEnum schemaEnum in schema.Enums)

Check warning on line 125 in Schema.Editor/ClassGraphView.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loop should be simplified by calling Select(schemaEnum => schemaEnum.Name)

Check warning on line 125 in Schema.Editor/ClassGraphView.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loop should be simplified by calling Select(schemaEnum => schemaEnum.Name)
{
string key = EnumKey(schemaEnum.Name);
if (knownKeys.Add(key))
Expand Down Expand Up @@ -221,6 +231,16 @@
return roomOnCanvas > 0.0f ? MathF.Min(wanted, roomOnCanvas) : wanted;
}

/// <summary>
/// Brings the whole graph into view on the next frame drawn: centred, and zoomed out far enough
/// to fit.
/// </summary>
/// <remarks>
/// Nothing in the editor asks for this yet; the gallery does, so that a picture of the graph
/// shows all of it rather than whatever part of a wide layout happens to fall on the canvas.
/// </remarks>
internal void RequestFitToView() => fitToViewRequested = true;

/// <summary>
/// Gets where the nodes sit, measured from the canvas's top-left corner.
/// </summary>
Expand Down
5 changes: 2 additions & 3 deletions Schema.Editor/SchemaEditor.Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,8 @@ internal void UpdateValidation(float dt)
/// Draws the issue list behind the diagnostics tab.
/// </summary>
/// <remarks>
/// Internal so a test can draw it directly. The tab bar hosting it comes from a widget library
/// that neither records its tabs for a probe nor takes a selection from outside, so there is no
/// tab for a test to click; drawing the panel is what the tab delegate does either way.
/// Internal so a test can draw it directly, which is what the tab delegate does either way and
/// is quicker than reaching it through the tab, recorded by <see cref="MarkMainTabs"/>.
/// </remarks>
internal void ShowDiagnosticsPanel()
{
Expand Down
54 changes: 50 additions & 4 deletions Schema.Editor/SchemaEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
namespace ktsu.Schema.Editor;

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Numerics;

using Hexa.NET.ImGui;

Expand All @@ -32,7 +35,7 @@
internal static float FieldWidth => ImGui.GetIO().DisplaySize.X * 0.15f;
private bool OptionsDirty { get; set; }
#pragma warning disable IDE0052 // Remove unread private member - reference needed to prevent GC
private readonly IntervalAction? autoSaveOptionsAction;

Check warning on line 38 in Schema.Editor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this unread private field 'autoSaveOptionsAction' or refactor the code to use its value.

Check warning on line 38 in Schema.Editor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this unread private field 'autoSaveOptionsAction' or refactor the code to use its value.
#pragma warning restore IDE0052
private ImGuiWidgets.DividerContainer DividerContainerCols { get; init; }

Expand All @@ -48,12 +51,16 @@
private CodeGeneratorPanel CodeGeneratorPanel { get; init; }

private MemberGridPanel MemberGrid { get; init; }
private ClassGraphView ClassGraph { get; } = new();
internal ClassGraphView ClassGraph { get; } = new();
private ImGuiWidgets.TabPanel MainTabs { get; }

// The tab whose label carries the validation counts.
private readonly string diagnosticsTabId;

// The name each main tab is recorded under, by tab id: its first label, so the diagnostics tab
// is found by what it is rather than by the counts its label carries at the time.
private readonly Dictionary<string, string> mainTabNames = [];

// Tab content delegates are parameterless, so the current frame's delta is stashed here for them.
private float currentDeltaTime;

Expand All @@ -77,9 +84,9 @@
// The tab bar lives inside the right divider zone, which is already a child window, so the tab
// content flows from the cursor without overlapping the bar. The left zone keeps the schema tree.
MainTabs = new ImGuiWidgets.TabPanel("MainViews", closable: false, reorderable: false);
MainTabs.AddTab("Editor", ShowEditorPanel);
MainTabs.AddTab("Class Graph", () => ClassGraph.Show(CurrentSchema, currentDeltaTime));
diagnosticsTabId = MainTabs.AddTab(DiagnosticsTab.Name, ShowDiagnosticsPanel);
AddMainTab("Editor", ShowEditorPanel);
AddMainTab("Class Graph", () => ClassGraph.Show(CurrentSchema, currentDeltaTime));
diagnosticsTabId = AddMainTab(DiagnosticsTab.Name, ShowDiagnosticsPanel);

Options = AppData.LoadOrCreate();
Popups = Options.Popups;
Expand Down Expand Up @@ -146,7 +153,7 @@
Options.CurrentClassName = CurrentClass?.Name ?? new();
Options.DividerStates[DividerContainerCols.Id] = DividerContainerCols.GetSizes();
// Note: WindowState property access needs to be updated for the current ImGuiApp version
// Options.WindowState = ImGuiApp.WindowState;

Check warning on line 156 in Schema.Editor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this commented out code.

Check warning on line 156 in Schema.Editor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this commented out code.
Options.Popups = Popups;
Options.Save();
}
Expand Down Expand Up @@ -260,9 +267,48 @@
private void ShowRightPanel(float dt)
{
DiagnosticsTab.ShowCounts(MainTabs, diagnosticsTabId, ErrorCount, WarningCount);
MarkMainTabs();
MainTabs.Draw();
}

private string AddMainTab(string name, Action content)
{
string id = MainTabs.AddTab(name, content);
mainTabNames[id] = name;
return id;
}

/// <summary>
/// Records where each of the main tabs is about to be drawn.
/// </summary>
/// <remarks>
/// The tab bar comes from a widget library that neither records its tabs nor takes a selection
/// from outside, so without this nothing behind the Class Graph or Diagnostics tab could be
/// reached the way a user reaches it. Each tab is measured the way Dear ImGui sizes a tab with no
/// close button - its label, the frame padding either side and one pixel - and the tabs sit end to
/// end, the inner item spacing apart. A tab bar only shrinks its tabs when they do not fit, which
/// three short labels across three quarters of the window do not need. Nothing is measured when no
/// probe is listening, which is every run that is not a test.
/// </remarks>
private void MarkMainTabs()
{
if (!ImGuiProbes.IsRecording)
{
return;
}

ImGuiStylePtr style = ImGui.GetStyle();
Vector2 tabMin = ImGui.GetCursorScreenPos();
float tabHeight = ImGui.GetFrameHeight();

foreach (ImGuiWidgets.Tab tab in MainTabs.Tabs.Where(tab => tab.IsVisible))
{
float tabWidth = ImGui.CalcTextSize(tab.Label).X + (style.FramePadding.X * 2f) + 1f;
ImGuiProbes.MarkRegion($"main-tab/{mainTabNames[tab.Id]}", tabMin, tabMin + new Vector2(tabWidth, tabHeight));
tabMin.X += tabWidth + style.ItemInnerSpacing.X;
}
}

private void ShowEditorPanel()
{
ShowSchemaConfig();
Expand Down
8 changes: 4 additions & 4 deletions tests/Schema.Editor.UITests/ClassGraphTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ namespace ktsu.Schema.Editor.UITests;
/// The class graph, which draws the schema's classes and the references between them.
/// </summary>
/// <remarks>
/// Driven through <see cref="WidgetHarness"/> rather than through the editor, because the graph
/// lives behind a tab and the tab bar comes from a widget library that does not record its tabs -
/// so there is no name for a test to click. Drawing the view directly reaches the same code, and
/// is the same thing the editor's tab delegate does.
/// Driven through <see cref="WidgetHarness"/> rather than through the editor. The graph lives
/// behind a tab, which the editor records as <c>main-tab/Class Graph</c> and the gallery clicks,
/// but drawing the view directly reaches the same code with nothing else on screen, and is the same
/// thing the editor's tab delegate does.
/// </remarks>
[TestClass]
public sealed class ClassGraphTests
Expand Down
Loading
Loading