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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Schema.Editor/CodeGeneratorPanel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Expand Down
4 changes: 2 additions & 2 deletions Schema.Editor/MemberSemanticsPanel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion Schema.Editor/Popups.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ internal void OpenUnitList(string title, string label, IEnumerable<IUnit> units,
{
List<IUnit> 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()
Expand Down
2 changes: 1 addition & 1 deletion Schema.Editor/SchemaEditor.Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
2 changes: 1 addition & 1 deletion Schema.Editor/SchemaEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@
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.

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; }
internal ImGuiWidgets.DividerContainer DividerContainerCols { get; init; }

internal IUndoRedoService UndoRedo { get; }

Expand Down Expand Up @@ -153,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.

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
49 changes: 49 additions & 0 deletions Schema.Editor/UnitText.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Schema.Editor;

using Hexa.NET.ImGui;

/// <summary>
/// Spells a unit's text in characters the editor's font can draw.
/// </summary>
/// <remarks>
/// <para>
/// Unit symbols come from ktsu.Semantics.Quantities and write a negative exponent with the
/// superscript minus, U+207B: <c>s⁻¹</c>, <c>m·s⁻²</c>. 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 <c>s?¹</c>. Widening the glyph ranges cannot
/// help, because the glyph is missing from the font rather than from the ranges.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal static class UnitText
{
/// <summary>The superscript minus unit symbols use for negative exponents.</summary>
internal const char SuperscriptMinus = '⁻';

/// <summary>The raised bar drawn in its place when the font has no superscript minus.</summary>
internal const char RaisedBar = '¯';

/// <summary>
/// Returns <paramref name="text"/> as it should be drawn in the current font.
/// </summary>
/// <remarks>Call during a frame: whether the font has the glyph is asked of the font in use.</remarks>
/// <param name="text">A unit's symbol, or text containing one.</param>
/// <returns>The text, with the superscript minus replaced when the font cannot draw it.</returns>
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);
}
}
17 changes: 14 additions & 3 deletions tests/Schema.Editor.UITests/Gallery/EditorGallery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,18 @@ public sealed class EditorGallery
/// <summary>The environment variable naming the directory the gallery is written to.</summary>
internal const string OutputVariable = "SCHEMA_GALLERY_OUT";

/// <summary>The display the gallery is drawn at: larger than a test's, so the panels have room.</summary>
internal static readonly HarnessOptions Display = new() { Width = 1440, Height = 900 };
/// <summary>
/// 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.
/// </summary>
internal static readonly HarnessOptions Display = new() { Width = 1480, Height = 620 };

/// <summary>
/// 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.
/// </summary>
private const float LeftPaneShare = 0.27f;

private static readonly Lazy<string> TemporaryOutput = new(() =>
Path.Join(Path.GetTempPath(), $"schema-gallery-{Guid.NewGuid():N}"));
Expand Down Expand Up @@ -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);

Expand Down
11 changes: 9 additions & 2 deletions tests/Schema.Editor.UITests/Gallery/GalleryCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 },
},
];

/// <summary>Opens the sample schema with its main class selected, as a user would find it.</summary>
Expand Down
5 changes: 5 additions & 0 deletions tests/Schema.Editor.UITests/Gallery/GalleryEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ internal sealed record GalleryEntry(string Name, string Description, Action<Edit
/// </summary>
public Func<EditorHarness, Rectangle?>? Crop { get; init; }

/// <summary>
/// Gets the display this picture is drawn at, or null for <see cref="EditorGallery.Display"/>.
/// </summary>
public HarnessOptions? Display { get; init; }

/// <summary>Gets the file name the picture is written under, without its extension.</summary>
public string Slug => MakeSlug(Name);

Expand Down
51 changes: 51 additions & 0 deletions tests/Schema.Editor.UITests/UnitTextTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Schema.Editor.UITests;

using Hexa.NET.ImGui;

/// <summary>
/// A unit's negative exponent is drawn in a glyph the editor's font has.
/// </summary>
/// <remarks>
/// The bundled font has every superscript digit but not the superscript minus, so a frequency used
/// to draw as <c>s?¹</c>. These ask the font in a real frame, because that is what the editor asks.
/// </remarks>
[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);
}
}
Loading