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: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<PackageVersion Include="ktsu.RoundTripStringJsonConverter" Version="1.0.47" />
<PackageVersion Include="ktsu.Semantics.Strings" Version="3.2.0" />
<PackageVersion Include="ktsu.Semantics.Paths" Version="3.2.0" />
<PackageVersion Include="ktsu.ImGui.App" Version="3.15.0" />
<PackageVersion Include="ktsu.ImGui.App" Version="3.16.0" />
<PackageVersion Include="ktsu.ImGui.Popups" Version="3.15.0" />
<PackageVersion Include="ktsu.ImGui.Widgets" Version="3.15.0" />
<PackageVersion Include="ktsu.ImGuiNodeEditor" Version="3.15.0" />
Expand Down
32 changes: 32 additions & 0 deletions SchemaEditor/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.SchemaEditor;

using ktsu.ImGui.App;

/// <summary>
/// The application entry point.
/// </summary>
/// <remarks>
/// Separate from <see cref="SchemaEditor"/> because starting the application is not part of editing
/// a schema: the configuration names a windowing framework and a delegate type for each callback,
/// and holding all of that in the editor class counts against its coupling budget without earning
/// anything. It also keeps the whole of the host's contract with ImGuiApp readable in one place.
/// </remarks>
internal static class Program
{
private static void Main(string[] _) =>
ImGuiApp.Start(new()
{
// 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,
OnUpdate = SchemaEditor.Instance.OnTick,
OnRender = SchemaEditor.Instance.OnRender,
OnAppMenu = SchemaEditor.Instance.OnMenu,

// Refuses a close that would discard unsaved work, so the editor can ask first.
OnClosing = SchemaEditor.Instance.ShouldClose,
});
}
103 changes: 89 additions & 14 deletions SchemaEditor/SchemaEditor.Files.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,9 @@ public partial class SchemaEditor
/// of the application menu bar.
/// </summary>
/// <remarks>
/// This is where the window title would normally carry the document name and a dirty marker.
/// <c>ImGuiAppConfig.Title</c> is init-only and ImGuiApp reads it once when it creates the
/// window, and the window itself is internal to that package, so the title cannot be changed
/// after startup from here. The menu bar is always visible, so the same information lives
/// there instead.
/// The window title carries the same information (see <see cref="UpdateWindowTitle"/>). This is
/// kept as well as, not instead of: a maximised window's title bar is easy to overlook, and on a
/// tiling window manager it may not be drawn at all.
/// </remarks>
private void ShowDocumentStatus()
{
Expand Down Expand Up @@ -101,7 +99,11 @@ private void ShowRecentFilesMenu()
/// unsaved changes.
/// </summary>
/// <param name="proceed">What to do once it is safe to discard the document.</param>
private void WithUnsavedChangesGuard(Action proceed)
/// <param name="onCancel">
/// Run if the user backs out. Only the close path needs this, to release the latch that stops a
/// second prompt stacking on the first; New and Open simply do nothing.
/// </param>
private void WithUnsavedChangesGuard(Action proceed, Action? onCancel = null)
{
if (!HasUnsavedChanges)
{
Expand All @@ -116,7 +118,7 @@ private void WithUnsavedChangesGuard(Action proceed)
{
["Save"] = () => SaveThen(proceed),
["Discard"] = proceed,
["Cancel"] = null,
["Cancel"] = onCancel,
});
}

Expand Down Expand Up @@ -224,17 +226,90 @@ private bool SaveToCurrentPath()
return true;
}

/// <summary>Set once the user has agreed to exit, so the confirmed exit is not vetoed again.</summary>
private bool exitConfirmed;

/// <summary>Set by <see cref="ShouldClose"/> so the prompt is raised from the render loop.</summary>
private bool closeRequested;

/// <summary>Set while the close prompt is on screen, so a second close cannot stack another.</summary>
private bool closePromptShowing;

/// <summary>
/// Quits, asking about unsaved changes first.
/// Consulted by ImGuiApp before the window closes; returning false keeps the application running.
/// </summary>
/// <remarks>
/// ImGuiApp exposes no cancellable close hook, so clicking the window's own close button
/// cannot be intercepted from here. This menu item is the route out that can ask first.
/// <para>
/// The prompt cannot be drawn from here - this returns before the next frame is rendered - so a
/// close with unsaved work only records the request and refuses. <see cref="ProcessCloseRequest"/>
/// raises the prompt on the next frame, and <see cref="ConfirmExit"/> closes for real.
/// </para>
/// <para>
/// This also fires for <see cref="ImGuiApp.Stop"/>, which is how the confirmed exit closes, so
/// without <see cref="exitConfirmed"/> the application would veto its own agreed exit forever:
/// discarding does not clear the unsaved flag, so the condition that refused the first close is
/// still true when the user has said to close anyway.
/// </para>
/// </remarks>
private void ExitWithUnsavedChangesGuard() =>
WithUnsavedChangesGuard(() =>
/// <returns>True to let the close proceed; false to cancel it.</returns>
internal bool ShouldClose()
{
if (exitConfirmed || !HasUnsavedChanges)
{
SaveOptionsInternal();
ImGuiApp.Stop();
});
return true;
}

closeRequested = true;
return false;
}

/// <summary>
/// Raises the unsaved-changes prompt for a close that <see cref="ShouldClose"/> refused.
/// </summary>
private void ProcessCloseRequest()
{
if (!closeRequested || closePromptShowing)
{
return;
}

closeRequested = false;
closePromptShowing = true;
WithUnsavedChangesGuard(ConfirmExit, () => closePromptShowing = false);
}

/// <summary>
/// Closes for real, once there is nothing left to ask about.
/// </summary>
private void ConfirmExit()
{
exitConfirmed = true;
SaveOptionsInternal();
ImGuiApp.Stop();
}

/// <summary>
/// Quits from the File menu, asking about unsaved changes first.
/// </summary>
private void ExitWithUnsavedChangesGuard() => WithUnsavedChangesGuard(ConfirmExit);

/// <summary>
/// Keeps the window title showing the open document and whether it has unsaved changes.
/// </summary>
/// <remarks>
/// Called every frame. <c>SetWindowTitle</c> skips the write when the title is unchanged, so
/// this costs a string comparison per frame rather than a window call.
/// </remarks>
private void UpdateWindowTitle()
{
if (CurrentSchema is null)
{
ImGuiApp.SetWindowTitle(nameof(SchemaEditor));
return;
}

string unsavedMarker = HasUnsavedChanges ? "*" : string.Empty;
ImGuiApp.SetWindowTitle($"{DocumentName}{unsavedMarker} - {nameof(SchemaEditor)}");
}
}
26 changes: 6 additions & 20 deletions SchemaEditor/SchemaEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

using Hexa.NET.ImGui;

using ktsu.ImGui.App;
using ktsu.ImGui.Styler;
using ktsu.ImGui.Widgets;
using ktsu.IntervalAction;
Expand All @@ -33,7 +32,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 35 in SchemaEditor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 35 in SchemaEditor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 35 in SchemaEditor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / 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 @@ -47,21 +46,6 @@
// Tab content delegates are parameterless, so the current frame's delta is stashed here for them.
private float currentDeltaTime;

private static void Main(string[] _)
{
// ImGuiAppConfig.Title is init-only and ImGuiApp reads it once, when it creates the
// window, so this is the only chance to set it. The open document and its unsaved state
// are shown in the menu bar instead - see ShowDocumentStatus.
ImGuiApp.Start(new()
{
Title = nameof(SchemaEditor),
OnStart = OnStart,
OnUpdate = Instance.OnTick,
OnRender = Instance.OnRender,
OnAppMenu = Instance.OnMenu
});
}

public SchemaEditor()
{
UndoRedo = new UndoRedoService(new StackManager(), new SaveBoundaryManager(), new CommandMerger());
Expand Down Expand Up @@ -119,7 +103,7 @@
}
}

private static void OnStart()
internal static void OnStart()
{
// Set up initial window state if needed
// Note: Window state handling may need to be implemented differently
Expand All @@ -139,7 +123,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 126 in SchemaEditor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this commented out code.

Check warning on line 126 in SchemaEditor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this commented out code.

Check warning on line 126 in SchemaEditor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this commented out code.

Check warning on line 126 in SchemaEditor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this commented out code.
Options.Popups = Popups;
Options.Save();
}
Expand Down Expand Up @@ -175,13 +159,15 @@
RequestValidation();
}

private void OnTick(float dt)
internal void OnTick(float dt)
{
ProcessKeyboardShortcuts();
UpdateValidation(dt);
UpdateWindowTitle();
ProcessCloseRequest();
}

private void ProcessKeyboardShortcuts()

Check warning on line 170 in SchemaEditor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 170 in SchemaEditor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 170 in SchemaEditor/SchemaEditor.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
{
ImGuiIOPtr io = ImGui.GetIO();
if (io.WantTextInput)
Expand Down Expand Up @@ -225,7 +211,7 @@
}
}

private void OnRender(float dt)
internal void OnRender(float dt)
{
// Stashed for the parameterless tab content delegates (the Class Graph needs the frame delta).
currentDeltaTime = dt;
Expand Down Expand Up @@ -263,7 +249,7 @@
}
}

private void OnMenu()
internal void OnMenu()
{
ShowFileMenu();
ShowEditMenu();
Expand Down