diff --git a/README.md b/README.md index b6588cd..eb459ed 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,8 @@ Use `SchemaSerializer` for JSON serialization with `System.Text.Json`. The seria 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. +[![The member grid with a member's unit, range, default and interpolation open](docs/gallery/member-metadata.png)](docs/gallery/README.md) + ### Running the Editor ```shell diff --git a/Schema.Editor/CodeGeneratorPanel.cs b/Schema.Editor/CodeGeneratorPanel.cs index 0088851..1fa7dc9 100644 --- a/Schema.Editor/CodeGeneratorPanel.cs +++ b/Schema.Editor/CodeGeneratorPanel.cs @@ -134,7 +134,7 @@ private void ShowGenerateButton(Schema schema, SchemaCodeGenerator codeGenerator { if (!schema.CanResolvePaths) { - using (EditorTheme.Warning()) + using (EditorTheme.SeverityText(SchemaValidationSeverity.Warning)) { ImGui.TextUnformatted("Save the schema before generating: output paths are relative to it."); } diff --git a/Schema.Editor/MemberSemanticsPanel.cs b/Schema.Editor/MemberSemanticsPanel.cs index eec0ac3..bf63a94 100644 --- a/Schema.Editor/MemberSemanticsPanel.cs +++ b/Schema.Editor/MemberSemanticsPanel.cs @@ -114,13 +114,13 @@ private void ShowUnitField(SchemaMember member) { ShowMetadataLabel("Unit:"); - bool clicked = ImGui.Button(member.Unit is null ? SchemaEditor.NoneOption : member.Unit.ToString(), new Vector2(FieldWidth, 0)); + bool clicked = ImGui.Button(member.Unit is null ? SchemaEditor.NoneOption : UnitText.ForDisplay(member.Unit), new Vector2(FieldWidth, 0)); ImGuiProbes.MarkItem("Unit"); if (member.Unit is not null && ImGui.IsItemHovered()) { ImGui.SetTooltip(UnitRegistry.TryResolve(member.Unit, out IUnit? resolved, out string error) - ? $"{resolved!.Name} ({resolved.Symbol})" + ? UnitText.ForDisplay($"{resolved!.Name} ({resolved.Symbol})") : error); } diff --git a/Schema.Editor/Popups.cs b/Schema.Editor/Popups.cs index fe60e9b..7bc9ad6 100644 --- a/Schema.Editor/Popups.cs +++ b/Schema.Editor/Popups.cs @@ -94,7 +94,7 @@ internal void OpenUnitList(string title, string label, IEnumerable units, { List choices = [.. units]; IUnit? selected = UnitRegistry.TryResolve(current, out IUnit? resolved, out _) ? resolved : null; - PopupUnitList.Open(title, label, choices, selected, unit => $"{unit.Name} ({unit.Symbol})", onConfirm!); + PopupUnitList.Open(title, label, choices, selected, unit => UnitText.ForDisplay($"{unit.Name} ({unit.Symbol})"), onConfirm!); }); internal void Update() diff --git a/Schema.Editor/SchemaEditor.Diagnostics.cs b/Schema.Editor/SchemaEditor.Diagnostics.cs index 136dd75..2a7b0a1 100644 --- a/Schema.Editor/SchemaEditor.Diagnostics.cs +++ b/Schema.Editor/SchemaEditor.Diagnostics.cs @@ -110,7 +110,7 @@ private void ShowDiagnostic(SchemaValidationIssue issue) { bool isError = issue.Severity == SchemaValidationSeverity.Error; - using (EditorTheme.Severity(issue.Severity)) + using (EditorTheme.SeverityText(issue.Severity)) { ImGui.TextUnformatted(isError ? "Error" : "Warning"); } diff --git a/Schema.Editor/SchemaEditor.cs b/Schema.Editor/SchemaEditor.cs index 112e3bf..3880e51 100644 --- a/Schema.Editor/SchemaEditor.cs +++ b/Schema.Editor/SchemaEditor.cs @@ -37,7 +37,7 @@ public partial class SchemaEditor #pragma warning disable IDE0052 // Remove unread private member - reference needed to prevent GC private readonly IntervalAction? autoSaveOptionsAction; #pragma warning restore IDE0052 - private ImGuiWidgets.DividerContainer DividerContainerCols { get; init; } + internal ImGuiWidgets.DividerContainer DividerContainerCols { get; init; } internal IUndoRedoService UndoRedo { get; } diff --git a/Schema.Editor/UnitText.cs b/Schema.Editor/UnitText.cs new file mode 100644 index 0000000..7e4f413 --- /dev/null +++ b/Schema.Editor/UnitText.cs @@ -0,0 +1,49 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Editor; + +using Hexa.NET.ImGui; + +/// +/// Spells a unit's text in characters the editor's font can draw. +/// +/// +/// +/// Unit symbols come from ktsu.Semantics.Quantities and write a negative exponent with the +/// superscript minus, U+207B: s⁻¹, m·s⁻². The font ImGuiApp ships, JetBrains Mono +/// Nerd Font, has every superscript digit and the superscript plus but not that one character, so +/// it drew as the fallback glyph and a frequency read s?¹. Widening the glyph ranges cannot +/// help, because the glyph is missing from the font rather than from the ranges. +/// +/// +/// Where the font has no glyph for it, the minus is drawn as a macron, U+00AF, which sits at the +/// height of the superscript digits beside it and is the usual stand-in for a raised minus. This is +/// display only: what is written to the schema file is untouched, and as soon as the font gains the +/// real glyph it is drawn instead. +/// +/// +internal static class UnitText +{ + /// The superscript minus unit symbols use for negative exponents. + internal const char SuperscriptMinus = '⁻'; + + /// The raised bar drawn in its place when the font has no superscript minus. + internal const char RaisedBar = '¯'; + + /// + /// Returns as it should be drawn in the current font. + /// + /// Call during a frame: whether the font has the glyph is asked of the font in use. + /// A unit's symbol, or text containing one. + /// The text, with the superscript minus replaced when the font cannot draw it. + internal static string ForDisplay(string text) => + text.Contains(SuperscriptMinus) && !FontHasSuperscriptMinus() + ? text.Replace(SuperscriptMinus, RaisedBar) + : text; + + private static bool FontHasSuperscriptMinus() + { + ImFontPtr font = ImGui.GetFont(); + return !font.IsNull && font.IsGlyphInFont(SuperscriptMinus); + } +} diff --git a/tests/Schema.Editor.UITests/Gallery/EditorGallery.cs b/tests/Schema.Editor.UITests/Gallery/EditorGallery.cs index 9ac9d2b..8b79663 100644 --- a/tests/Schema.Editor.UITests/Gallery/EditorGallery.cs +++ b/tests/Schema.Editor.UITests/Gallery/EditorGallery.cs @@ -31,8 +31,18 @@ public sealed class EditorGallery /// The environment variable naming the directory the gallery is written to. internal const string OutputVariable = "SCHEMA_GALLERY_OUT"; - /// The display the gallery is drawn at: larger than a test's, so the panels have room. - internal static readonly HarnessOptions Display = new() { Width = 1440, Height = 900 }; + /// + /// The display the gallery is drawn at: larger than a test's, so the panels have room, and no + /// taller than the sample schema's tree, so a picture is of the editor rather than of the empty + /// space under it. + /// + internal static readonly HarnessOptions Display = new() { Width = 1480, Height = 620 }; + + /// + /// The share of the window the schema tree is given in the gallery: enough that the tree's + /// tab labels, Code Generators included, are drawn whole rather than elided. + /// + private const float LeftPaneShare = 0.27f; private static readonly Lazy TemporaryOutput = new(() => Path.Join(Path.GetTempPath(), $"schema-gallery-{Guid.NewGuid():N}")); @@ -66,10 +76,11 @@ public void Photograph(string name) GalleryEntry entry = GalleryCatalog.Entries.Single(candidate => candidate.Name == name); EditField.Reset(); - using EditorHarness harness = EditorHarness.Start(Display); + using EditorHarness harness = EditorHarness.Start(entry.Display ?? Display); try { Assert.IsTrue(GalleryFonts.Load(), "ImGuiApp's own font could not be found, so the pictures would not look like the editor."); + harness.Editor.DividerContainerCols.SetSizesFromList([LeftPaneShare, 1f - LeftPaneShare]); harness.App.Mouse.MoveTo(-100f, -100f); harness.App.Step(2); diff --git a/tests/Schema.Editor.UITests/Gallery/GalleryCatalog.cs b/tests/Schema.Editor.UITests/Gallery/GalleryCatalog.cs index 72402e8..7d45ef1 100644 --- a/tests/Schema.Editor.UITests/Gallery/GalleryCatalog.cs +++ b/tests/Schema.Editor.UITests/Gallery/GalleryCatalog.cs @@ -28,11 +28,14 @@ internal static class GalleryCatalog "The editor opens on whatever was open last, or on nothing. The File menu creates, opens and saves `.schema.json` files and keeps the recent ones to hand.", harness => { + // Open Recent is disabled until something has been opened, so the picture starts + // from an editor that has been used rather than one just installed. + harness.Editor.Options.RecordRecentFile(GallerySchema.Path); harness.OpenMenu("File"); Park(harness); }) { - Crop = _ => new Rectangle(0, 0, 720, 450), + Crop = _ => new Rectangle(0, 0, 560, 200), }, new( "Editing a class", @@ -126,7 +129,11 @@ internal static class GalleryCatalog harness.StepUntil(() => harness.IsOnScreen("theme-card/Dracula"), "the theme browser opening"); harness.App.Step(3); Park(harness); - }), + }) + { + // The browser is a fixed size, taller than the editor's own pictures. + Display = new() { Width = 1480, Height = 800 }, + }, ]; /// Opens the sample schema with its main class selected, as a user would find it. diff --git a/tests/Schema.Editor.UITests/Gallery/GalleryEntry.cs b/tests/Schema.Editor.UITests/Gallery/GalleryEntry.cs index 34ef35b..27a2275 100644 --- a/tests/Schema.Editor.UITests/Gallery/GalleryEntry.cs +++ b/tests/Schema.Editor.UITests/Gallery/GalleryEntry.cs @@ -24,6 +24,11 @@ internal sealed record GalleryEntry(string Name, string Description, Action public Func? Crop { get; init; } + /// + /// Gets the display this picture is drawn at, or null for . + /// + public HarnessOptions? Display { get; init; } + /// Gets the file name the picture is written under, without its extension. public string Slug => MakeSlug(Name); diff --git a/tests/Schema.Editor.UITests/UnitTextTests.cs b/tests/Schema.Editor.UITests/UnitTextTests.cs new file mode 100644 index 0000000..a5a0b0f --- /dev/null +++ b/tests/Schema.Editor.UITests/UnitTextTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Editor.UITests; + +using Hexa.NET.ImGui; + +/// +/// A unit's negative exponent is drawn in a glyph the editor's font has. +/// +/// +/// The bundled font has every superscript digit but not the superscript minus, so a frequency used +/// to draw as s?¹. These ask the font in a real frame, because that is what the editor asks. +/// +[TestClass] +public sealed class UnitTextTests +{ + private WidgetHarness harness = null!; + + [TestInitialize] + public void StartHarness() => harness = WidgetHarness.Start(); + + [TestCleanup] + public void StopHarness() => harness.Dispose(); + + [TestMethod] + public void ANegativeExponentIsDrawnInAGlyphTheFontHas() + { + string? drawn = null; + bool fontHasDrawnMinus = false; + harness.Draw = () => + { + drawn = UnitText.ForDisplay("s⁻¹"); + char minus = drawn[1]; + fontHasDrawnMinus = ImGui.GetFont().IsGlyphInFont(minus); + }; + harness.App.Step(2); + + Assert.IsNotNull(drawn); + Assert.IsTrue(fontHasDrawnMinus, $"'{drawn}' is drawn with a minus the font has no glyph for."); + } + + [TestMethod] + public void TextWithoutASuperscriptMinusIsUnchanged() + { + string? drawn = null; + harness.Draw = () => drawn = UnitText.ForDisplay("m/s"); + harness.App.Step(2); + + Assert.AreEqual("m/s", drawn); + } +}