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.