diff --git a/Directory.Packages.props b/Directory.Packages.props
index 82ba20a..0e203bd 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -17,7 +17,7 @@
-
+
diff --git a/SchemaEditor/Program.cs b/SchemaEditor/Program.cs
new file mode 100644
index 0000000..de6a2ad
--- /dev/null
+++ b/SchemaEditor/Program.cs
@@ -0,0 +1,32 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.SchemaEditor;
+
+using ktsu.ImGui.App;
+
+///
+/// The application entry point.
+///
+///
+/// Separate from 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.
+///
+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,
+ });
+}
diff --git a/SchemaEditor/SchemaEditor.Files.cs b/SchemaEditor/SchemaEditor.Files.cs
index 9f43496..c62b2ed 100644
--- a/SchemaEditor/SchemaEditor.Files.cs
+++ b/SchemaEditor/SchemaEditor.Files.cs
@@ -42,11 +42,9 @@ public partial class SchemaEditor
/// of the application menu bar.
///
///
- /// This is where the window title would normally carry the document name and a dirty marker.
- /// ImGuiAppConfig.Title 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 ). 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.
///
private void ShowDocumentStatus()
{
@@ -101,7 +99,11 @@ private void ShowRecentFilesMenu()
/// unsaved changes.
///
/// What to do once it is safe to discard the document.
- private void WithUnsavedChangesGuard(Action proceed)
+ ///
+ /// 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.
+ ///
+ private void WithUnsavedChangesGuard(Action proceed, Action? onCancel = null)
{
if (!HasUnsavedChanges)
{
@@ -116,7 +118,7 @@ private void WithUnsavedChangesGuard(Action proceed)
{
["Save"] = () => SaveThen(proceed),
["Discard"] = proceed,
- ["Cancel"] = null,
+ ["Cancel"] = onCancel,
});
}
@@ -224,17 +226,90 @@ private bool SaveToCurrentPath()
return true;
}
+ /// Set once the user has agreed to exit, so the confirmed exit is not vetoed again.
+ private bool exitConfirmed;
+
+ /// Set by so the prompt is raised from the render loop.
+ private bool closeRequested;
+
+ /// Set while the close prompt is on screen, so a second close cannot stack another.
+ private bool closePromptShowing;
+
///
- /// Quits, asking about unsaved changes first.
+ /// Consulted by ImGuiApp before the window closes; returning false keeps the application running.
///
///
- /// 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.
+ ///
+ /// 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.
+ /// raises the prompt on the next frame, and closes for real.
+ ///
+ ///
+ /// This also fires for , which is how the confirmed exit closes, so
+ /// without 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.
+ ///
///
- private void ExitWithUnsavedChangesGuard() =>
- WithUnsavedChangesGuard(() =>
+ /// True to let the close proceed; false to cancel it.
+ internal bool ShouldClose()
+ {
+ if (exitConfirmed || !HasUnsavedChanges)
{
SaveOptionsInternal();
- ImGuiApp.Stop();
- });
+ return true;
+ }
+
+ closeRequested = true;
+ return false;
+ }
+
+ ///
+ /// Raises the unsaved-changes prompt for a close that refused.
+ ///
+ private void ProcessCloseRequest()
+ {
+ if (!closeRequested || closePromptShowing)
+ {
+ return;
+ }
+
+ closeRequested = false;
+ closePromptShowing = true;
+ WithUnsavedChangesGuard(ConfirmExit, () => closePromptShowing = false);
+ }
+
+ ///
+ /// Closes for real, once there is nothing left to ask about.
+ ///
+ private void ConfirmExit()
+ {
+ exitConfirmed = true;
+ SaveOptionsInternal();
+ ImGuiApp.Stop();
+ }
+
+ ///
+ /// Quits from the File menu, asking about unsaved changes first.
+ ///
+ private void ExitWithUnsavedChangesGuard() => WithUnsavedChangesGuard(ConfirmExit);
+
+ ///
+ /// Keeps the window title showing the open document and whether it has unsaved changes.
+ ///
+ ///
+ /// Called every frame. SetWindowTitle skips the write when the title is unchanged, so
+ /// this costs a string comparison per frame rather than a window call.
+ ///
+ private void UpdateWindowTitle()
+ {
+ if (CurrentSchema is null)
+ {
+ ImGuiApp.SetWindowTitle(nameof(SchemaEditor));
+ return;
+ }
+
+ string unsavedMarker = HasUnsavedChanges ? "*" : string.Empty;
+ ImGuiApp.SetWindowTitle($"{DocumentName}{unsavedMarker} - {nameof(SchemaEditor)}");
+ }
}
diff --git a/SchemaEditor/SchemaEditor.cs b/SchemaEditor/SchemaEditor.cs
index b28827a..38d20ca 100644
--- a/SchemaEditor/SchemaEditor.cs
+++ b/SchemaEditor/SchemaEditor.cs
@@ -9,7 +9,6 @@ namespace ktsu.SchemaEditor;
using Hexa.NET.ImGui;
-using ktsu.ImGui.App;
using ktsu.ImGui.Styler;
using ktsu.ImGui.Widgets;
using ktsu.IntervalAction;
@@ -47,21 +46,6 @@ public partial class SchemaEditor
// 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());
@@ -119,7 +103,7 @@ public SchemaEditor()
}
}
- 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
@@ -175,10 +159,12 @@ private void Redo()
RequestValidation();
}
- private void OnTick(float dt)
+ internal void OnTick(float dt)
{
ProcessKeyboardShortcuts();
UpdateValidation(dt);
+ UpdateWindowTitle();
+ ProcessCloseRequest();
}
private void ProcessKeyboardShortcuts()
@@ -225,7 +211,7 @@ private void ProcessKeyboardShortcuts()
}
}
- 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;
@@ -263,7 +249,7 @@ private void ShowEditorPanel()
}
}
- private void OnMenu()
+ internal void OnMenu()
{
ShowFileMenu();
ShowEditMenu();