diff --git a/CLAUDE.md b/CLAUDE.md
index 8133b52..68b89ba 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -82,6 +82,7 @@ Schema elements maintain parent references via `AssociateWith()` methods. After
- `Schema/Models/SchemaClass.cs` - Class definitions containing `SchemaMember` collections
- `SchemaEditor/SchemaEditor.cs` - Main editor application using `ktsu.ImGui.App`
- `SchemaEditor/EditorHost.cs` - Builds the `ImGuiAppConfig`; `CreateConfig` is what the tests drive too
+- `SchemaEditor/EditorTheme.cs` - The ktsu.ThemeProvider theme, and the one definition of how a validation issue is coloured
- `SchemaEditor/Program.cs` - The entry point, and the only file excluded from coverage measurement
- `SchemaEditor.Test/EditorHarness.cs` - Runs a real editor headlessly, frames advanced by the test
- `SchemaEditor.Test/WidgetHarness.cs` - A headless frame containing only the widget under test
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 38cbeb5..55ecff2 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,6 +11,7 @@
+
diff --git a/SchemaEditor.Test/EditorHarness.cs b/SchemaEditor.Test/EditorHarness.cs
index 7be0342..ec1910f 100644
--- a/SchemaEditor.Test/EditorHarness.cs
+++ b/SchemaEditor.Test/EditorHarness.cs
@@ -55,13 +55,25 @@ private EditorHarness(SchemaEditor editor, ImGuiAppHarness app)
/// Starts an editor with empty settings and advances the frames it needs to be drawing.
///
/// The running harness. Dispose it to release the ImGui context.
- internal static EditorHarness Start()
+ internal static EditorHarness Start() => Start(new HarnessOptions());
+
+ ///
+ /// Starts an editor at a chosen display size.
+ ///
+ ///
+ /// Size matters to more than layout: the editor derives its field and column widths from the
+ /// display width, so a narrow window is what puts a long label past the width its column
+ /// gives it.
+ ///
+ /// Determinism settings, including the display size.
+ /// The running harness. Dispose it to release the ImGui context.
+ internal static EditorHarness Start(HarnessOptions options)
{
// Must precede the constructor: it is the constructor that loads the settings.
ktsu.AppDataStorage.AppData.ConfigureForTesting(() => new MockFileSystem());
SchemaEditor editor = new();
- ImGuiAppHarness app = ImGuiAppHarness.Start(EditorHost.CreateConfig(editor), new HarnessOptions());
+ ImGuiAppHarness app = ImGuiAppHarness.Start(EditorHost.CreateConfig(editor), options);
// The first frame builds the font atlas and lays the panels out; nothing is measurable
// before it has run.
diff --git a/SchemaEditor.Test/SchemaEditor.Test.csproj b/SchemaEditor.Test/SchemaEditor.Test.csproj
index e6a5226..4dcc9dd 100644
--- a/SchemaEditor.Test/SchemaEditor.Test.csproj
+++ b/SchemaEditor.Test/SchemaEditor.Test.csproj
@@ -14,6 +14,8 @@
+
+
diff --git a/SchemaEditor.Test/ThemeBrowserTests.cs b/SchemaEditor.Test/ThemeBrowserTests.cs
new file mode 100644
index 0000000..55d0ae5
--- /dev/null
+++ b/SchemaEditor.Test/ThemeBrowserTests.cs
@@ -0,0 +1,70 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.SchemaEditor.Test;
+
+///
+/// Picking a theme from the browser the Theme menu opens.
+///
+///
+/// The themes named here are ones near the top of the browser's grid. The grid scrolls, and a card
+/// below the fold is recorded by the probe at a position that is clipped away, so clicking it hits
+/// the modal behind rather than the card.
+///
+[TestClass]
+public sealed class ThemeBrowserTests
+{
+ private EditorHarness harness = null!;
+
+ [TestInitialize]
+ public void StartEditor()
+ {
+ harness = EditorHarness.Start();
+ harness.Editor.Options.ThemeName = string.Empty;
+ harness.Editor.OnStart();
+ }
+
+ [TestCleanup]
+ public void StopEditor() => harness.Dispose();
+
+ [TestMethod]
+ public void ChoosingAThemeAppliesIt()
+ {
+ EditorTheme.OpenBrowser();
+
+ harness.Click("theme-card/Dracula");
+
+ Assert.AreEqual("Dracula", EditorTheme.CurrentName);
+ }
+
+ ///
+ /// The choice has to reach the settings, or it is gone at the next launch - which is the whole
+ /// point of storing it.
+ ///
+ [TestMethod]
+ public void ChoosingAThemeRemembersIt()
+ {
+ EditorTheme.OpenBrowser();
+
+ harness.Click("theme-card/Gruvbox Dark");
+
+ Assert.AreEqual("Gruvbox Dark", harness.Editor.Options.ThemeName);
+ }
+
+ ///
+ /// A theme chosen now is the theme applied on the next start, which is the round trip the
+ /// setting exists for.
+ ///
+ [TestMethod]
+ public void ARememberedThemeComesBackOnTheNextStart()
+ {
+ EditorTheme.OpenBrowser();
+ harness.Click("theme-card/Dracula");
+
+ EditorTheme.Apply("Nord");
+ Assert.AreEqual("Nord", EditorTheme.CurrentName);
+
+ harness.Editor.OnStart();
+
+ Assert.AreEqual("Dracula", EditorTheme.CurrentName);
+ }
+}
diff --git a/SchemaEditor.Test/ThemeTests.cs b/SchemaEditor.Test/ThemeTests.cs
new file mode 100644
index 0000000..c96efae
--- /dev/null
+++ b/SchemaEditor.Test/ThemeTests.cs
@@ -0,0 +1,80 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.SchemaEditor.Test;
+
+///
+/// Which ktsu.ThemeProvider theme the editor runs under, and where that choice comes from.
+///
+///
+/// The editor used to have no theme at all. It wrapped every frame in
+/// Theme.FromColor(Palette.Semantic.Primary) - a scoped colour meant for one widget -
+/// which tinted the entire interface with the primary colour and left an ordinary button looking
+/// the same as one marked with an error.
+///
+[TestClass]
+public sealed class ThemeTests
+{
+ private EditorHarness harness = null!;
+
+ [TestInitialize]
+ public void StartEditor() => harness = EditorHarness.Start();
+
+ [TestCleanup]
+ public void StopEditor() => harness.Dispose();
+
+ ///
+ /// Settings that name no theme still get one, rather than falling back to unstyled ImGui.
+ ///
+ [TestMethod]
+ public void SettingsWithNoThemeGetTheDefault()
+ {
+ EditorTheme.Apply(string.Empty);
+
+ Assert.AreEqual("VSCode Dark", EditorTheme.CurrentName);
+ }
+
+ [TestMethod]
+ public void ANamedThemeIsApplied()
+ {
+ EditorTheme.Apply("Nord");
+
+ Assert.AreEqual("Nord", EditorTheme.CurrentName);
+ }
+
+ ///
+ /// A theme that has left the registry - renamed upstream, or dropped - must not leave the
+ /// editor unstyled, because the name is read from settings written by an older build.
+ ///
+ [TestMethod]
+ public void AThemeThatIsNoLongerRegisteredFallsBack()
+ {
+ EditorTheme.Apply("A Theme That Does Not Exist");
+
+ Assert.AreEqual("VSCode Dark", EditorTheme.CurrentName);
+ }
+
+ [TestMethod]
+ public void TheSavedThemeIsAppliedWhenTheEditorStarts()
+ {
+ harness.Editor.Options.ThemeName = "Gruvbox Dark";
+
+ harness.Editor.OnStart();
+
+ Assert.AreEqual("Gruvbox Dark", EditorTheme.CurrentName);
+ }
+
+ ///
+ /// Starting with whatever the previous test left applied must still end with a theme, which is
+ /// the property that stops the blanket-tint approach coming back as "no theme at all".
+ ///
+ [TestMethod]
+ public void TheEditorAlwaysRunsUnderSomeTheme()
+ {
+ harness.Editor.Options.ThemeName = string.Empty;
+
+ harness.Editor.OnStart();
+ harness.App.Step(3);
+
+ Assert.IsFalse(string.IsNullOrEmpty(EditorTheme.CurrentName), "The editor drew a frame with no theme applied.");
+ }
+}
diff --git a/SchemaEditor.Test/TreeRowWidthTests.cs b/SchemaEditor.Test/TreeRowWidthTests.cs
new file mode 100644
index 0000000..a66e594
--- /dev/null
+++ b/SchemaEditor.Test/TreeRowWidthTests.cs
@@ -0,0 +1,80 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.SchemaEditor.Test;
+
+using ktsu.ImGui.App.Testing;
+using ktsu.Schema.Models;
+using ktsu.Schema.Models.Names;
+using ktsu.Semantics.Strings;
+
+///
+/// How wide a tree row is drawn.
+///
+///
+/// The rows shared one fixed width, and ImGui clips a button's label to its frame, so the longest
+/// label in the tree - "Code Generators (0)" - was drawn without its count. The width is a minimum
+/// now: short labels still line up as a column, and a long one grows to fit.
+///
+[TestClass]
+public sealed class TreeRowWidthTests
+{
+ private EditorHarness harness = null!;
+
+ [TestCleanup]
+ public void StopEditor() => harness?.Dispose();
+
+ private int WidthOf(string item)
+ {
+ harness.StepUntil(() => harness.App.Probe.Matches(item).Count > 0, $"'{item}' appearing");
+ Rectangle rect = harness.App.Probe.Rect(item) ?? throw new AssertFailedException($"'{item}' was not recorded.");
+ return rect.Width;
+ }
+
+ ///
+ /// The four tree headings are drawn from the same code with labels of very different lengths,
+ /// which is where the clipping showed up.
+ ///
+ [TestMethod]
+ public void ALongerHeadingIsDrawnWiderThanSpareColumnWidthAllows()
+ {
+ // Narrow, because the column is 15% of the display width: at a wide display every heading
+ // fits and there is nothing to prove. This is the shape of window the clipping was
+ // reported from.
+ harness = EditorHarness.Start(new HarnessOptions { Width = 700, Height = 600 });
+ harness.Editor.CurrentSchema = new Schema();
+
+ Assert.IsTrue(
+ WidthOf("RootCode Generators") > WidthOf("RootEnums"),
+ "'Code Generators (0)' is the longest heading in the tree; drawn at the same width as 'Enums (0)' it loses its count.");
+ }
+
+ [TestMethod]
+ public void ALongerClassNameIsDrawnWider()
+ {
+ harness = EditorHarness.Start();
+ Schema schema = new();
+ schema.AddClass("A".As());
+ schema.AddClass("AClassNameLongEnoughToNeedMoreRoomThanTheColumnGives".As());
+ harness.Editor.CurrentSchema = schema;
+
+ Assert.IsTrue(
+ WidthOf("BtnAClassNameLongEnoughToNeedMoreRoomThanTheColumnGives") > WidthOf("BtnA"),
+ "A class name longer than the column was clipped instead of widening its row.");
+ }
+
+ ///
+ /// The width is a minimum, not a per-row measurement: rows whose labels both fit still line up,
+ /// or the tree would be a ragged edge.
+ ///
+ [TestMethod]
+ public void ShortLabelsShareOneColumnWidth()
+ {
+ harness = EditorHarness.Start();
+ Schema schema = new();
+ schema.AddClass("A".As());
+ schema.AddClass("Bee".As());
+ harness.Editor.CurrentSchema = schema;
+
+ Assert.AreEqual(WidthOf("BtnA"), WidthOf("BtnBee"));
+ }
+}
diff --git a/SchemaEditor.Test/ValidationMarkingTests.cs b/SchemaEditor.Test/ValidationMarkingTests.cs
new file mode 100644
index 0000000..b254ed5
--- /dev/null
+++ b/SchemaEditor.Test/ValidationMarkingTests.cs
@@ -0,0 +1,116 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.SchemaEditor.Test;
+
+using ktsu.ImGui.App.Testing;
+using ktsu.Schema.Models;
+using ktsu.Schema.Models.Names;
+using ktsu.Semantics.Strings;
+
+using SchemaTypes = ktsu.Schema.Models.Types;
+
+///
+/// That an element carrying a validation error is actually drawn in the error colour.
+///
+///
+/// This is what the theme change was for. Colour is the editor's way of saying "this one is
+/// wrong", and it only says anything if the rest of the interface is not already wearing it - the
+/// editor used to tint every widget with the primary colour, leaving the marking nothing to stand
+/// out against.
+///
+/// Measured on screen rather than through the model, because the model side is covered elsewhere
+/// and it is the drawing that this is about. The default theme is VSCode Dark, whose error colour
+/// is red; a test that pinned no theme could not ask this question.
+///
+[TestClass]
+public sealed class ValidationMarkingTests
+{
+ private EditorHarness harness = null!;
+
+ [TestInitialize]
+ public void StartEditor()
+ {
+ harness = EditorHarness.Start();
+ harness.Editor.Options.ThemeName = string.Empty;
+ harness.Editor.OnStart();
+ }
+
+ [TestCleanup]
+ public void StopEditor() => harness.Dispose();
+
+ ///
+ /// Counts pixels that are clearly red rather than any particular shade, so the test does not
+ /// depend on the exact value the theme picks for an error.
+ ///
+ private int RedPixels()
+ {
+ CapturedFrame frame = harness.App.Capture();
+ int count = 0;
+
+ for (int y = 0; y < frame.Height; y++)
+ {
+ for (int x = 0; x < frame.Width; x++)
+ {
+ Rgba32 pixel = frame.GetPixel(x, y);
+ if (pixel.R > 120 && pixel.R > pixel.G + 40 && pixel.R > pixel.B + 40)
+ {
+ count++;
+ }
+ }
+ }
+
+ return count;
+ }
+
+ private void Revalidate()
+ {
+ harness.Editor.RequestValidation();
+ harness.Editor.UpdateValidation(SchemaEditor.ValidationDebounceSeconds);
+ harness.App.Step(4);
+ }
+
+ [TestMethod]
+ public void AMemberWhoseTypeIsBrokenIsDrawnInTheErrorColour()
+ {
+ Schema schema = new();
+ SchemaClass user = schema.AddClass("User".As())!;
+ SchemaMember id = user.AddMember("Id".As())!;
+ id.SetType(new SchemaTypes.Int());
+
+ harness.Editor.CurrentSchema = schema;
+ harness.Editor.EditClass(user);
+ Revalidate();
+
+ Assert.AreEqual(0, harness.Editor.Diagnostics.Count, "This schema was supposed to start clean.");
+ int before = RedPixels();
+
+ // Point the member at a class that is not there: an error, reported against the member.
+ id.SetType(new SchemaTypes.Object() { ClassName = "NoSuchClass".As() });
+ Revalidate();
+
+ Assert.IsNotNull(harness.Editor.GetIssueFor(id), "The broken reference should have been reported against the member.");
+ Assert.IsTrue(RedPixels() > before, "The member carrying an error was drawn no differently from one without.");
+ }
+
+ ///
+ /// The menu bar carries the counts as well, so a schema's health is visible without opening the
+ /// diagnostics tab.
+ ///
+ [TestMethod]
+ public void TheMenuBarIsDrawnInTheErrorColourWhileThereAreErrors()
+ {
+ Schema schema = new();
+ schema.AddClass("User".As());
+ harness.Editor.CurrentSchema = schema;
+ Revalidate();
+ int before = RedPixels();
+
+ // An empty class name is an error, and nothing is selected, so the only thing that can
+ // draw in the error colour is the summary in the menu bar.
+ schema.AddClass(new ClassName());
+ Revalidate();
+
+ Assert.IsTrue(harness.Editor.Diagnostics.Count > 0);
+ Assert.IsTrue(RedPixels() > before, "The menu bar did not report the errors in the error colour.");
+ }
+}
diff --git a/SchemaEditor/AppData.cs b/SchemaEditor/AppData.cs
index e3de098..aaf8c68 100644
--- a/SchemaEditor/AppData.cs
+++ b/SchemaEditor/AppData.cs
@@ -25,6 +25,15 @@ internal sealed class AppData : AppData
public Dictionary> DividerStates { get; set; } = [];
public Popups Popups { get; set; } = new();
+ ///
+ /// Gets or sets the name of the ktsu.ThemeProvider theme to apply, or empty for the default.
+ ///
+ ///
+ /// Stored by name rather than as colours so a theme that is revised upstream is picked up
+ /// rather than frozen at whatever it looked like when the setting was written.
+ ///
+ public string ThemeName { get; set; } = string.Empty;
+
///
/// Gets or sets the most recently opened schema files, newest first.
///
diff --git a/SchemaEditor/ButtonTree.cs b/SchemaEditor/ButtonTree.cs
index eaf50a9..60bcacc 100644
--- a/SchemaEditor/ButtonTree.cs
+++ b/SchemaEditor/ButtonTree.cs
@@ -40,6 +40,20 @@ internal sealed class Config
public Action? OnTreeEnd { get; set; }
}
+ ///
+ /// The width to draw a tree row at.
+ ///
+ ///
+ /// The rows share a width so the tree reads as a column rather than a ragged edge, but that
+ /// width has to be a minimum rather than a fixed size: ImGui clips a button's label to its
+ /// frame, so "Code Generators (0)" - the longest label in the tree - lost its count entirely
+ /// at the sizes the editor actually runs at.
+ ///
+ /// The label that has to fit.
+ /// The column width, or the width the text needs when that is more.
+ private static float RowWidth(string text) =>
+ MathF.Max(SchemaEditor.FieldWidth, ImGui.CalcTextSize(text).X + (ImGui.GetStyle().FramePadding.X * 2));
+
internal static void ShowTree(string id, string text, IEnumerable items) => ShowTree(id, text, items, new(), null);
internal static void ShowTree(string id, string text, IEnumerable items, Config config, ImGuiWidgets.Tree? parent)
{
@@ -50,7 +64,8 @@ internal static void ShowTree(string id, string text, IEnumerable items,
{
using (Button.Alignment.Left())
{
- ImGui.Button(text, new(SchemaEditor.FieldWidth, 0));
+ ImGui.Button(text, new(RowWidth(text), 0));
+ ImGuiProbes.MarkItem($"Root{id}");
}
ImGui.SameLine();
@@ -111,13 +126,9 @@ private static void ShowTreeItem(string id, Config config, ImGuiWidgets.Tree tre
SchemaValidationIssue? issue = config.GetIssue?.Invoke(item);
using (Button.Alignment.Left())
- using (issue is null
- ? null
- : Theme.FromColor(issue.Severity == SchemaValidationSeverity.Error
- ? Palette.Semantic.Error
- : Palette.Semantic.Warning))
+ using (issue is null ? null : EditorTheme.Severity(issue.Severity))
{
- ImGui.Button($"{buttonText}##Btn{itemId}", new(SchemaEditor.FieldWidth, 0));
+ ImGui.Button($"{buttonText}##Btn{itemId}", new(RowWidth(buttonText), 0));
// Every tree row in the editor is drawn here, so marking it here is what lets a
// test address any of them - a class, a member, an enum value, a data source -
diff --git a/SchemaEditor/CodeGeneratorPanel.cs b/SchemaEditor/CodeGeneratorPanel.cs
index 20add58..bd49c9b 100644
--- a/SchemaEditor/CodeGeneratorPanel.cs
+++ b/SchemaEditor/CodeGeneratorPanel.cs
@@ -6,7 +6,6 @@ namespace ktsu.SchemaEditor;
using Hexa.NET.ImGui;
-using ktsu.ImGui.Styler;
using ktsu.Schema.Generation;
using ktsu.Schema.Models;
using ktsu.Schema.Models.Names;
@@ -129,7 +128,7 @@ private void ShowGenerateButton(Schema schema, SchemaCodeGenerator codeGenerator
{
if (!schema.CanResolvePaths)
{
- using (Theme.FromColor(Palette.Semantic.Warning))
+ using (EditorTheme.Warning())
{
ImGui.TextUnformatted("Save the schema before generating: output paths are relative to it.");
}
diff --git a/SchemaEditor/EditorHost.cs b/SchemaEditor/EditorHost.cs
index 242fd62..472c5dc 100644
--- a/SchemaEditor/EditorHost.cs
+++ b/SchemaEditor/EditorHost.cs
@@ -36,7 +36,7 @@ internal static ImGuiAppConfig CreateConfig(SchemaEditor editor) =>
// The startup title only; SchemaEditor keeps it current from there, showing the open
// document and whether it has unsaved changes.
Title = nameof(SchemaEditor),
- OnStart = SchemaEditor.OnStart,
+ OnStart = editor.OnStart,
OnUpdate = editor.OnTick,
OnRender = editor.OnRender,
OnAppMenu = editor.OnMenu,
diff --git a/SchemaEditor/EditorTheme.cs b/SchemaEditor/EditorTheme.cs
new file mode 100644
index 0000000..418492d
--- /dev/null
+++ b/SchemaEditor/EditorTheme.cs
@@ -0,0 +1,92 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.SchemaEditor;
+
+using ktsu.ImGui.Styler;
+using ktsu.Schema.Models;
+using ktsu.ThemeProvider;
+
+///
+/// The application-wide look, as a ktsu.ThemeProvider theme.
+///
+///
+///
+/// A theme is applied once and then left alone. It is not the same thing as
+/// , which pushes one colour around one widget and is what marks an
+/// element carrying an error or a warning. The editor used to wrap every frame in
+/// FromColor(Palette.Semantic.Primary), which tinted the whole interface with the primary
+/// colour and left an ordinary button indistinguishable from an emphasised one.
+///
+///
+/// Separate from so that the registry and its types do not count
+/// against that class's coupling budget, which they otherwise push past the analyzer's limit.
+///
+///
+internal static class EditorTheme
+{
+ ///
+ /// The theme applied when the settings name none, or name one no longer registered.
+ ///
+ private const string DefaultThemeName = "VSCode Dark";
+
+ ///
+ /// Gets the name of the theme currently applied, or empty when none is.
+ ///
+ internal static string CurrentName => Theme.CurrentThemeName ?? string.Empty;
+
+ ///
+ /// Applies a theme by name, falling back when the name resolves to nothing.
+ ///
+ ///
+ /// Both names are looked up rather than trusted, because the saved one was written by an
+ /// earlier build and the theme may since have been renamed or dropped. Neither resolving
+ /// leaves ImGui's own styling in place, which is a readable neutral rather than something to
+ /// fail on.
+ ///
+ /// The theme to apply, or empty for the default.
+ internal static void Apply(string themeName) =>
+ Theme.CurrentThemeName = Resolve(themeName) ?? Resolve(DefaultThemeName);
+
+ private static string? Resolve(string themeName) =>
+ !string.IsNullOrEmpty(themeName) && ThemeRegistry.FindTheme(themeName) is ThemeRegistry.ThemeInfo found
+ ? found.Name
+ : null;
+
+ ///
+ /// Scopes the colour that marks an element carrying a validation issue.
+ ///
+ ///
+ /// One definition rather than the same severity ternary written out at each of the places an
+ /// issue is drawn - the tree row, the diagnostics list, the summary and the member row - so
+ /// they cannot drift apart.
+ ///
+ /// The severity to colour for.
+ /// A scope that reverts the colour when disposed.
+ internal static ScopedThemeColor Severity(SchemaValidationSeverity severity) =>
+ severity == SchemaValidationSeverity.Error ? Error() : Warning();
+
+ ///
+ /// Scopes the colour for something that is wrong.
+ ///
+ internal static ScopedThemeColor Error() => Theme.FromColor(Palette.Semantic.Error);
+
+ ///
+ /// Scopes the colour for something that needs attention but is not wrong.
+ ///
+ internal static ScopedThemeColor Warning() => Theme.FromColor(Palette.Semantic.Warning);
+
+ ///
+ /// Draws the theme menu. Returns true when the user picked a different theme.
+ ///
+ internal static bool ShowMenu() => Theme.RenderThemeSelectorMenu();
+
+ ///
+ /// Draws the theme browser if it is open. Returns true when the user picked a different theme.
+ ///
+ internal static bool ShowBrowser() => Theme.RenderThemeSelector();
+
+ ///
+ /// Opens the theme browser. The menu does this for the user; a test does it directly.
+ ///
+ internal static void OpenBrowser() => Theme.ShowThemeSelector();
+}
diff --git a/SchemaEditor/SchemaEditor.Diagnostics.cs b/SchemaEditor/SchemaEditor.Diagnostics.cs
index 64ef597..9c6807d 100644
--- a/SchemaEditor/SchemaEditor.Diagnostics.cs
+++ b/SchemaEditor/SchemaEditor.Diagnostics.cs
@@ -9,7 +9,6 @@ namespace ktsu.SchemaEditor;
using Hexa.NET.ImGui;
-using ktsu.ImGui.Styler;
using ktsu.Schema.Models;
///
@@ -81,7 +80,7 @@ private void ShowValidationSummary()
int warnings = WarningCount;
ImGui.Separator();
- using (Theme.FromColor(errors > 0 ? Palette.Semantic.Error : Palette.Semantic.Warning))
+ using (EditorTheme.Severity(errors > 0 ? SchemaValidationSeverity.Error : SchemaValidationSeverity.Warning))
{
ImGui.TextUnformatted(FormatSummary(errors, warnings));
}
@@ -122,7 +121,7 @@ private void ShowDiagnostic(SchemaValidationIssue issue)
{
bool isError = issue.Severity == SchemaValidationSeverity.Error;
- using (Theme.FromColor(isError ? Palette.Semantic.Error : Palette.Semantic.Warning))
+ using (EditorTheme.Severity(issue.Severity))
{
ImGui.TextUnformatted(isError ? "Error" : "Warning");
}
diff --git a/SchemaEditor/SchemaEditor.Panels.cs b/SchemaEditor/SchemaEditor.Panels.cs
index 9e35e46..eeeeb6e 100644
--- a/SchemaEditor/SchemaEditor.Panels.cs
+++ b/SchemaEditor/SchemaEditor.Panels.cs
@@ -10,7 +10,6 @@ namespace ktsu.SchemaEditor;
using Hexa.NET.ImGui;
using ktsu.ImGui.Probes;
-using ktsu.ImGui.Styler;
using ktsu.Schema.Models;
using ktsu.Schema.Models.Names;
using ktsu.Semantics.Paths;
@@ -395,7 +394,7 @@ private void ShowMemberIssueMarker(SchemaMember member)
}
ImGui.SameLine();
- using (Theme.FromColor(issue.Severity == SchemaValidationSeverity.Error ? Palette.Semantic.Error : Palette.Semantic.Warning))
+ using (EditorTheme.Severity(issue.Severity))
{
ImGui.TextUnformatted(issue.Severity == SchemaValidationSeverity.Error ? "!" : "?");
}
diff --git a/SchemaEditor/SchemaEditor.cs b/SchemaEditor/SchemaEditor.cs
index 38d20ca..977891e 100644
--- a/SchemaEditor/SchemaEditor.cs
+++ b/SchemaEditor/SchemaEditor.cs
@@ -9,7 +9,6 @@ namespace ktsu.SchemaEditor;
using Hexa.NET.ImGui;
-using ktsu.ImGui.Styler;
using ktsu.ImGui.Widgets;
using ktsu.IntervalAction;
using ktsu.Schema.Models;
@@ -103,11 +102,22 @@ public SchemaEditor()
}
}
- internal static void OnStart()
+ ///
+ /// Applies the saved theme once ImGui exists to receive it.
+ ///
+ ///
+ /// A theme has to be applied here rather than in the constructor: it writes ImGui's style
+ /// colours, and there is no ImGui context until the application has started.
+ ///
+ internal void OnStart() => EditorTheme.Apply(Options.ThemeName);
+
+ ///
+ /// Notes a theme the user chose, so it is there again next time.
+ ///
+ private void RecordThemeChoice()
{
- // Set up initial window state if needed
- // Note: Window state handling may need to be implemented differently
- // with the current version of ImGuiApp
+ Options.ThemeName = EditorTheme.CurrentName;
+ QueueSaveOptions();
}
private void DividerResized(ImGuiWidgets.DividerContainer container)
@@ -215,10 +225,18 @@ internal void OnRender(float dt)
{
// Stashed for the parameterless tab content delegates (the Class Graph needs the frame delta).
currentDeltaTime = dt;
- using (Theme.FromColor(Palette.Semantic.Primary))
+
+ // No theme colour is pushed around the whole application. Theme.FromColor is a scoped
+ // colour for one widget - it is what marks an element that has an error or a warning - and
+ // wrapping every frame in the primary colour tinted the entire interface with it, leaving
+ // an ordinary button indistinguishable from an emphasised one. The application-wide look
+ // belongs to the ktsu.ThemeProvider theme applied in OnStart.
+ DividerContainerCols.Tick(dt);
+ Popups.Update();
+
+ if (EditorTheme.ShowBrowser())
{
- DividerContainerCols.Tick(dt);
- Popups.Update();
+ RecordThemeChoice();
}
}
@@ -253,6 +271,12 @@ internal void OnMenu()
{
ShowFileMenu();
ShowEditMenu();
+
+ if (EditorTheme.ShowMenu())
+ {
+ RecordThemeChoice();
+ }
+
ShowDocumentStatus();
}
@@ -378,7 +402,7 @@ private void ShowSchemaConfig()
if (string.IsNullOrEmpty(CurrentSchemaPath))
{
- using (Theme.FromColor(Palette.Semantic.Error))
+ using (EditorTheme.Error())
{
ImGui.TextUnformatted("Schema has not been saved. Save it before configuring relative paths.");
diff --git a/SchemaEditor/SchemaEditor.csproj b/SchemaEditor/SchemaEditor.csproj
index 6757848..f03af9c 100644
--- a/SchemaEditor/SchemaEditor.csproj
+++ b/SchemaEditor/SchemaEditor.csproj
@@ -23,6 +23,7 @@
+
diff --git a/docs/development/README.md b/docs/development/README.md
index 8f4f766..8b2a0f7 100644
--- a/docs/development/README.md
+++ b/docs/development/README.md
@@ -96,6 +96,9 @@ rows' controls apart the same way `PushID` does for ImGui itself. A test then cl
Frames are advanced by the test, never by wall-clock time — `Step(n)` for a fixed number and
`StepUntil(condition, budget)` for a wait — so a loaded runner is slower rather than flakier.
+`App.Capture().SavePng(path)` writes the rendered frame to disk, which is how a visual change is
+checked without a display: render before, render after, and look at the two images.
+
Where a regression is only visible on screen,
`App.Capture()` gives the rendered pixels: `TwoRowsSharingALabelDoNotShareABuffer` compares one
row's pixels before and during an edit of its sibling, which is the only place the shared-buffer
diff --git a/docs/features/schema-editor.md b/docs/features/schema-editor.md
index 5e37c41..955a953 100644
--- a/docs/features/schema-editor.md
+++ b/docs/features/schema-editor.md
@@ -62,6 +62,17 @@ The layout is split into two resizable panels. Panel sizes are persisted across
- **Save** - Saves the current schema (prompts for location if unsaved)
- **Open Externally** - Opens the schema file's directory in the system file explorer
+### Theme Menu
+
+Opens a browser of the themes ktsu.ThemeProvider registers - Catppuccin, Dracula, Everforest,
+Gruvbox, Kanagawa, Monokai, Nord, One Dark, Tokyo Night, VSCode and others, in dark and light
+variants. The editor starts on VSCode Dark, and the choice is remembered by name so a theme revised
+upstream is picked up rather than frozen at whatever it looked like when the setting was written.
+
+Colour carries meaning elsewhere in the editor, so the theme deliberately does not: an element with
+a validation error or warning is tinted, and everything else takes the theme's own colours. Marking
+every widget would leave nothing for the marking to stand out against.
+
### Left Panel - Schema Tree
The left panel shows four collapsible tree sections:
@@ -117,6 +128,8 @@ The editor automatically persists:
- Last selected class
- Panel divider positions
- Tree node expand/collapse state
+- Recently opened files
+- The selected theme
These settings are stored via `ktsu.AppDataStorage` and restored on next launch.