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();