diff --git a/Coder.Editor/Coder.Editor.csproj b/Coder.Editor/Coder.Editor.csproj index 3771817..ee8a977 100644 --- a/Coder.Editor/Coder.Editor.csproj +++ b/Coder.Editor/Coder.Editor.csproj @@ -14,6 +14,11 @@ + + + + diff --git a/Coder.Editor/CoderEditorApp.cs b/Coder.Editor/CoderEditorApp.cs index 183d597..7550844 100644 --- a/Coder.Editor/CoderEditorApp.cs +++ b/Coder.Editor/CoderEditorApp.cs @@ -12,6 +12,8 @@ namespace ktsu.Coder.Editor; using ktsu.Coder.Languages; using ktsu.ImGui.App; using ktsu.ImGui.SyntaxHighlighting; +using ktsu.ImGui.Widgets; +using Silk.NET.Windowing; /// /// The editor: a graph of the document on the left, the code it generates on the right. @@ -41,7 +43,7 @@ public sealed class CoderEditorApp( /// /// Gets the editor over the current document. /// - public AstGraphEditor Editor { get; private set; } = new(NewDocument()); + public AstGraphEditor Editor { get; private set; } = EditorFor(NewDocument()); /// /// Gets the file the document was last read from or written to, or null for an unsaved one. @@ -78,6 +80,16 @@ public sealed class CoderEditorApp( OnStart = () => Editor.LayoutRunning = Settings.LayoutRunning, OnRender = Draw, + // Opened where it was left. The window is part of what a user arranges, and an application + // that forgets it is one they rearrange on every run. + InitialWindowState = new ImGuiAppWindowState + { + Size = new Vector2(Settings.WindowWidth, Settings.WindowHeight), + Pos = new Vector2(Settings.WindowX, Settings.WindowY), + LayoutState = Settings.WindowMaximized ? WindowState.Maximized : WindowState.Normal, + }, + OnMoveOrResize = RememberWindow, + // The menu goes here rather than inside OnRender: ImGuiApp's main window carries no // ImGuiWindowFlags.MenuBar, so an ImGui.BeginMenuBar() call in the render delegate always // returns false and the menu silently never appears. OnAppMenu runs inside the application's @@ -85,6 +97,36 @@ public sealed class CoderEditorApp( OnAppMenu = DrawMenu, }; + /// + /// Notes where the window is, so the next run opens there. + /// + /// + /// The size and position read back are the ones the window has when it is not maximized, which is + /// what should be restored when a maximized window is un-maximized. Settings are written to disk + /// when the application exits, so this only has to keep them current. + /// + private void RememberWindow() + { + ImGuiAppWindowState state = ImGuiApp.WindowState; + + Settings.WindowWidth = state.Size.X; + Settings.WindowHeight = state.Size.Y; + Settings.WindowX = state.Pos.X; + Settings.WindowY = state.Pos.Y; + Settings.WindowMaximized = state.LayoutState == WindowState.Maximized; + } + + /// + /// Builds an editor over a document, set up the way this application hosts one. + /// + /// The document to edit. + /// The editor. + /// + /// The properties panel is one of this application's own panes, so the editor is told not to draw + /// a second one of its own beside the canvas. + /// + private static AstGraphEditor EditorFor(AstNode document) => new(document) { ShowInspector = false }; + /// /// Builds the document a fresh editor opens with. /// @@ -126,19 +168,72 @@ public void Draw(float deltaTime) HandleShortcuts(); Vector2 available = ImGui.GetContentRegionAvail(); - float graphWidth = available.X * 0.62f; - ImGui.BeginChild("graph-pane", new Vector2(graphWidth, available.Y - StatusBarHeight)); - Editor.Draw(new Vector2(graphWidth - PaneInset, available.Y - StatusBarHeight - PaneInset), deltaTime); + // The container measures itself from the remaining content region, so it is given a child of + // the height that is actually the panes' — otherwise it would take the status bar's row too + // and draw the bar over its own bottom edge. + ImGui.BeginChild("panes", new Vector2(0, available.Y - StatusBarHeight)); + Panes.Tick(deltaTime); ImGui.EndChild(); - ImGui.SameLine(); + DrawStatusBar(); + } - ImGui.BeginChild("code-pane", new Vector2(0, available.Y - StatusBarHeight)); - DrawCodePane(); - ImGui.EndChild(); + /// + /// Gets the pane layout, built on first use. + /// + /// + /// A divider container holds the sizes the user has dragged the panes to, so it has to outlive + /// the frame. It is built lazily rather than in a field initializer because its zones call back + /// into this instance, which a field initializer cannot refer to. + /// + private ImGuiWidgets.DividerContainer Panes => field ??= BuildPanes(); - DrawStatusBar(); + /// + /// Builds the resizable pane layout: the graph, and beside it the properties above the code. + /// + /// The container to tick each frame. + /// + /// Properties and code are stacked rather than placed side by side because they are read at + /// different times and want different shapes: properties are a short column of labelled rows, + /// while generated source is lines that want to be read down. Sharing one column gives each of + /// them the full width and lets the user decide how the height is split between them — which is + /// the point of making these panes rather than fixed regions. + /// + /// The sizes are remembered between runs, so an arrangement the user settled on is the one they + /// come back to. + /// + /// + private ImGuiWidgets.DividerContainer BuildPanes() + { + ImGuiWidgets.DividerContainer side = new( + "coder-side", + container => Settings.PropertiesSplit = container.GetSizes()[0], + ImGuiWidgets.DividerLayout.Rows, + [ + new ImGuiWidgets.DividerZone("properties", Settings.PropertiesSplit, DrawPropertiesPane), + new ImGuiWidgets.DividerZone("code", 1f - Settings.PropertiesSplit, _ => DrawCodePane()), + ]); + + return new ImGuiWidgets.DividerContainer( + "coder-panes", + container => Settings.GraphSplit = container.GetSizes()[0], + ImGuiWidgets.DividerLayout.Columns, + [ + new ImGuiWidgets.DividerZone("graph", Settings.GraphSplit, deltaTime => + Editor.Draw(ImGui.GetContentRegionAvail(), deltaTime)), + new ImGuiWidgets.DividerZone("side", 1f - Settings.GraphSplit, side.Tick), + ]); + } + + /// + /// Draws the selected node's properties, which the editor supplies but does not place. + /// + /// Seconds since the last frame; the panel does not animate, so unused. + private void DrawPropertiesPane(float deltaTime) + { + ImGui.TextUnformatted("Properties"); + Editor.DrawInspector(ImGui.GetContentRegionAvail()); } /// @@ -185,11 +280,6 @@ private void HandleShortcuts() /// private const float StatusBarHeight = 28f; - /// - /// The margin between a pane's edge and what it contains. - /// - private const float PaneInset = 12f; - /// /// Draws the application's File menu. /// @@ -433,7 +523,8 @@ private void DrawStatusBar() /// private void Replace(AstNode document, string status) { - Editor = new AstGraphEditor(document) { LayoutRunning = Settings.LayoutRunning }; + Editor = EditorFor(document); + Editor.LayoutRunning = Settings.LayoutRunning; DocumentPath = null; Status = status; GeneratedCode = string.Empty; @@ -453,7 +544,8 @@ public bool Open(string path) return false; } - Editor = new AstGraphEditor(result.Root) { LayoutRunning = Settings.LayoutRunning }; + Editor = EditorFor(result.Root); + Editor.LayoutRunning = Settings.LayoutRunning; DocumentPath = result.Path; Settings.Remember(result.Path!); Status = $"Opened {result.Path}."; diff --git a/Coder.Editor/EditorSettings.cs b/Coder.Editor/EditorSettings.cs index 7fbc326..a2f7409 100644 --- a/Coder.Editor/EditorSettings.cs +++ b/Coder.Editor/EditorSettings.cs @@ -34,6 +34,52 @@ public sealed class EditorSettings /// public bool LayoutRunning { get; set; } = true; + /// + /// Gets or sets the window's width when it is not maximized. + /// + public float WindowWidth { get; set; } = 1280f; + + /// + /// Gets or sets the window's height when it is not maximized. + /// + public float WindowHeight { get; set; } = 720f; + + /// + /// Gets or sets the window's horizontal position when it is not maximized. + /// + /// + /// Defaulted to the windowing layer's own "no position yet" value, which is deliberately far off + /// screen and means the platform should place the window. A first run therefore opens wherever + /// the system would have put it rather than in a corner this application chose. + /// + public float WindowX { get; set; } = -short.MinValue; + + /// + /// Gets or sets the window's vertical position when it is not maximized. + /// + public float WindowY { get; set; } = -short.MinValue; + + /// + /// Gets or sets a value indicating whether the window was maximized. + /// + /// + /// Kept alongside the size rather than instead of it: a maximized window still has a size to go + /// back to when it is restored, and that is the one worth remembering. + /// + public bool WindowMaximized { get; set; } + + /// + /// Gets or sets the share of the window's width the graph takes, the rest going to the panel + /// beside it. + /// + public float GraphSplit { get; set; } = 0.62f; + + /// + /// Gets or sets the share of that panel's height the properties take, the rest going to the code + /// preview under them. + /// + public float PropertiesSplit { get; set; } = 0.4f; + /// /// Records a file as the most recently opened, without letting the list grow or repeat. /// diff --git a/Coder.Graph/AstGraph.cs b/Coder.Graph/AstGraph.cs index bb4336e..1cb46c4 100644 --- a/Coder.Graph/AstGraph.cs +++ b/Coder.Graph/AstGraph.cs @@ -43,25 +43,6 @@ public sealed class AstGraph /// private const float SiblingSpacing = 110f; - /// - /// The clear space left between the boxes of two nodes. - /// - private const float NodeMargin = 24f; - - /// - /// The furthest a pair of overlapping nodes is moved apart in one step, in pixels. - /// - /// - /// The overlap is otherwise resolved in full each step, because a partial correction loses: - /// between two linked nodes the spring pulls them back together by more, every frame, than a - /// fraction of the overlap pushes them apart, and they come to rest still overlapping. Resolving - /// it in full and capping the step keeps the correction decisive and still lets a deep overlap — - /// two nodes dropped on the same spot — slide apart over several frames rather than jumping. - /// - private const float MaxSeparationStep = 40f; - - private const float SeparationTolerance = 0.5f; - private readonly Dictionary positions = new(ReferenceEqualityComparer.Instance); private readonly Dictionary nodesById = []; private readonly Dictionary idsByNode = new(ReferenceEqualityComparer.Instance); @@ -215,10 +196,9 @@ public int AddDetached(AstNode node, Vector2 position) /// Where the node was asked to go. /// That position, or the nearest free one along a diagonal from it. /// - /// Two nodes at exactly the same point stay there for ever: the layout's repulsion is computed - /// from the direction between them, and coincident points have no direction. Creating two nodes - /// from the palette without moving the mouse is enough to hit that, so the offset is applied when - /// the node is placed rather than left for the simulation to sort out. + /// Creating two nodes from the palette without moving the mouse puts them at the same point. The + /// simulation does pull them apart from there, but a node that appears exactly on top of the last + /// one and then slides out is a worse answer than one that is placed clear to begin with. /// private Vector2 Separated(Vector2 position) { @@ -233,72 +213,6 @@ private Vector2 Separated(Vector2 position) return candidate; } - /// - /// Eases apart any nodes whose boxes are on top of one another, by one step's worth. - /// - /// The deepest overlap found, in pixels, or zero when nothing overlapped. - /// - /// The force-directed layout treats every node as a point: repulsion is computed between centres - /// and the link spring pulls to a fixed length, neither of which knows how wide a node is. Two - /// nodes can therefore sit at a perfectly comfortable distance by that measure and still have - /// their boxes squarely on top of each other, which is what a user sees. This resolves the - /// overlap the layout cannot see, working on the drawn rectangles rather than on centres. - /// - /// Each overlap is resolved along the axis it is shallowest on, which is both the shorter push - /// and the one that leaves the arrangement the layout worked out most nearly as it was, and by no - /// more than at a time, so a deep overlap slides apart over a few - /// frames rather than jumping. - /// - /// - public float SeparateOverlaps() - { - Node[] nodes = [.. Engine.Nodes]; - Vector2[] moved = [.. nodes.Select(node => node.Position)]; - float deepest = 0f; - - for (int i = 0; i < nodes.Length; i++) - { - for (int j = i + 1; j < nodes.Length; j++) - { - Vector2 clearance = ((nodes[i].Dimensions + nodes[j].Dimensions) * 0.5f) + new Vector2(NodeMargin); - Vector2 first = moved[i] + (nodes[i].Dimensions * 0.5f); - Vector2 second = moved[j] + (nodes[j].Dimensions * 0.5f); - Vector2 between = second - first; - Vector2 overlap = clearance - Vector2.Abs(between); - - if (overlap.X <= SeparationTolerance || overlap.Y <= SeparationTolerance) - { - continue; - } - - // Never further than the overlap itself, so the pair cannot be driven past each other and - // back again, and shared equally between them so the arrangement's centre stays put. - float depth = Math.Min(overlap.X, overlap.Y); - float amount = Math.Min(depth, MaxSeparationStep) * 0.5f; - - // A zero component has no side to be on, so the later node is pushed the positive way: - // an arbitrary choice, but a consistent one, which is what stops the pair jittering. - Vector2 push = overlap.X < overlap.Y - ? new Vector2(amount * (between.X < 0f ? -1f : 1f), 0f) - : new Vector2(0f, amount * (between.Y < 0f ? -1f : 1f)); - - moved[j] += push; - moved[i] -= push; - deepest = Math.Max(deepest, depth); - } - } - - for (int i = 0; i < nodes.Length; i++) - { - if (moved[i] != nodes[i].Position) - { - Engine.UpdateNodePosition(nodes[i].Id, moved[i]); - } - } - - return deepest; - } - /// /// Reports where a node currently sits. /// diff --git a/Coder.Graph/AstGraphEditor.cs b/Coder.Graph/AstGraphEditor.cs index d5b1ff0..a74bfb7 100644 --- a/Coder.Graph/AstGraphEditor.cs +++ b/Coder.Graph/AstGraphEditor.cs @@ -42,6 +42,10 @@ public sealed class AstGraphEditor(AstNode root) private bool fitted; + private bool fontScaled; + + private readonly Dictionary viewSpace = []; + private string? editingField; /// @@ -75,8 +79,34 @@ public sealed class AstGraphEditor(AstNode root) public bool ShowDebugOverlays { get; set; } /// - /// Gets or sets a value indicating whether the inspector panel is drawn. + /// Gets or sets how large the graph is drawn, as a multiplier: 1 draws it at its own size. /// + /// + /// A view setting, not a document one. The graph's positions and the layout that arranges them + /// are kept at their own scale whatever this is, and the zoom is applied and undone around the + /// one frame that draws them — so the simulation is never asked to work in a space that changes + /// under it, and a node dragged while zoomed out lands where the pointer was. + /// + /// The node editor underneath has no zoom of its own, so this is what there is: it scales the + /// distances between nodes and the text inside them together, which is what makes a whole graph + /// fit on screen rather than merely spreading it out. + /// + /// + public float Zoom + { + get; + set => field = Math.Clamp(value, MinZoom, MaxZoom); + } = 1f; + + /// + /// Gets or sets a value indicating whether puts the inspector panel beside the + /// canvas itself. + /// + /// + /// On for a host that just wants an editor. A host with a layout of its own turns it off and + /// calls wherever it wants the panel, which is what the Coder + /// application does — there it is a pane the user can resize rather than a fixed column. + /// public bool ShowInspector { get; set; } = true; /// @@ -128,15 +158,23 @@ public void Draw(Vector2 size, float deltaTime) // around an origin that is still zero. The first frame is where that becomes knowable, and // fitting once there is what puts a freshly opened document in the middle of the view // immediately rather than leaving the simulation to drag it in from the corner. + // Fitted on the first frame so a freshly opened document is in the middle of the view + // immediately rather than being dragged in from a corner, and then again until the nodes have + // been measured: how big a node is drawn is only known once it has been, and the zoom a fit + // chooses depends on that. Latching on the first measured frame is what stops it from + // overriding a view the user has since changed. if (!fitted) { - fitted = FitView(); + FitView(); + fitted = Graph.Engine.Nodes.Count > 0 && Graph.Engine.Nodes.All(node => node.Dimensions.X > 0f); statusMessage = string.Empty; } Vector2 origin = ImGui.GetCursorScreenPos(); ImGui.BeginChild("ast-graph-canvas", graphSize, ImGuiChildFlags.None, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse); + EnterViewSpace(); + renderer.Render(Graph.Engine, graphSize); ApplyNodeMovement(); @@ -150,6 +188,8 @@ public void Draw(Vector2 size, float deltaTime) renderer.RenderDebugOverlays(Graph.Engine, origin, graphSize, showDebug: true); } + LeaveViewSpace(); + ImGui.EndChild(); if (ShowInspector) @@ -162,12 +202,9 @@ public void Draw(Vector2 size, float deltaTime) { // Gravity pulls towards the world origin, which is what keeps an arrangement the user has // not touched in the middle of the view rather than drifting out of it. + // The simulation resolves overlapping node boxes itself, since ktsu.ForceDirectedLayout + // 3.18.0, so nothing here has to undo them afterwards. Graph.Engine.UpdatePhysics(deltaTime); - - // The simulation has no idea how big a node is drawn, so it is content to leave two of - // them sitting on top of one another. Undoing that afterwards is what keeps every node's - // caption readable, which is the whole reason the layout runs. - Graph.SeparateOverlaps(); } Problems = Graph.Validate(); @@ -183,6 +220,26 @@ public void Draw(Vector2 size, float deltaTime) /// private const float MinimumGraphWidth = 160f; + /// + /// The smallest the graph is drawn, as a multiplier of its own size. + /// + private const float MinZoom = 0.25f; + + /// + /// The largest the graph is drawn, as a multiplier of its own size. + /// + private const float MaxZoom = 2f; + + /// + /// How much of the canvas a fitted graph is asked to fill, leaving a margin around it. + /// + private const float FitMargin = 0.9f; + + /// + /// The width of the toolbar's zoom slider. + /// + private const float ZoomSliderWidth = 120f; + /// /// Draws the row of controls above the graph. /// @@ -220,16 +277,19 @@ private void DrawToolbar() ImGui.EndDisabled(); ImGui.SameLine(); - if (ImGui.Button("Fit")) + if (ImGui.Button("Fit to canvas")) { FitView(); } + // Shown and dragged as a percentage, which is how every other application spells zoom, while + // the property itself is the multiplier everything is measured in. ImGui.SameLine(); - bool showInspector = ShowInspector; - if (ImGui.Checkbox("Inspector", ref showInspector)) + ImGui.SetNextItemWidth(ZoomSliderWidth); + float percent = Zoom * 100f; + if (ImGui.SliderFloat("##zoom", ref percent, MinZoom * 100f, MaxZoom * 100f, "%.0f%%")) { - ShowInspector = showInspector; + Zoom = percent / 100f; } ImGui.SameLine(); @@ -273,8 +333,8 @@ private void TrackSelection() /// are edited by dragging; everything else about it is a value, and a value needs somewhere to be /// typed. /// - /// The column to draw it in. - private void DrawInspector(Vector2 size) + /// The area to draw it in. + public void DrawInspector(Vector2 size) { // Bordered rather than separated: down the side of the graph a rule is what tells the panel // apart from the canvas it sits beside, where along the bottom a single line would do. @@ -501,6 +561,113 @@ public void Redo() } } + /// + /// Scales the graph into the space it is drawn in, for the duration of one frame's drawing. + /// + /// + /// The node editor underneath has no zoom, and it takes each node's position straight out of the + /// engine, so the only place a zoom can be applied is the engine itself. Doing that permanently + /// would put the simulation in a space that changes whenever the user drags the slider — its rest + /// length, its repulsion distance and its overlap margin are all lengths, and none of them would + /// mean the same thing afterwards. + /// + /// So the scaling is put on before the frame is drawn and taken off again after, by + /// . Between those two calls the engine holds view positions, which is + /// what the renderer draws and what the user's drag is read back in; outside them it holds the + /// graph's own, which is what the layout runs on and what a fit is measured against. + /// + /// + /// Scaled about the world origin — the middle of the canvas — so zooming keeps whatever is in the + /// middle of the view in the middle of the view, rather than sending the graph towards a corner. + /// The text is scaled to match, because a node's box is sized from the text inside it: without + /// that, zooming out would only move the nodes closer together while they stayed the same size, + /// which packs them tighter instead of showing more. + /// + /// + private void EnterViewSpace() + { + fontScaled = false; + viewSpace.Clear(); + + if (IsUnzoomed) + { + return; + } + + Vector2 centre = Graph.Engine.WorldOrigin; + foreach (Node node in Graph.Engine.Nodes.ToArray()) + { + Vector2 view = ((node.Position - centre) * Zoom) + centre; + viewSpace[node.Id] = new ViewState(node.Position, node.Dimensions, view); + Graph.Engine.UpdateNodePosition(node.Id, view); + } + + ImGui.PushFont(ImGui.GetFont(), ImGui.GetFontSize() * Zoom); + fontScaled = true; + } + + /// + /// Takes the scaling back off, returning the engine to the graph's own space. + /// + /// + /// A node the frame did not touch is put back to exactly the value it had rather than divided by + /// the zoom it was multiplied by, and its size is left alone rather than divided at all. That is + /// the difference between a transform and its inverse being applied once and being applied every + /// frame: a size only arrives from the renderer when it has measured a new one, so dividing them + /// unconditionally would divide the same value again on every frame it did not change, and a + /// graph left alone for a second would have nodes thousands of times their real size. + /// + private void LeaveViewSpace() + { + if (fontScaled) + { + ImGui.PopFont(); + fontScaled = false; + } + + if (IsUnzoomed) + { + return; + } + + Vector2 centre = Graph.Engine.WorldOrigin; + foreach (Node node in Graph.Engine.Nodes.ToArray()) + { + // A node the frame replaced — a rebuild reassigns every identifier — is not one this frame + // put into view space, so it is converted rather than restored. + bool known = viewSpace.TryGetValue(node.Id, out ViewState state); + + Graph.Engine.UpdateNodePosition( + node.Id, + known && state.View == node.Position + ? state.Position + : ((node.Position - centre) / Zoom) + centre); + + // Untouched means the renderer reported no new measurement, so what is there is still the + // size from before the frame and is already in the graph's own space. + if (!known || state.Dimensions != node.Dimensions) + { + Graph.Engine.UpdateNodeDimensions(node.Id, node.Dimensions / Zoom); + } + } + + viewSpace.Clear(); + } + + /// + /// Gets a value indicating whether the view is at the graph's own scale, where the transform is + /// the identity and is skipped rather than applied as one. + /// + private bool IsUnzoomed => Math.Abs(Zoom - 1f) < 0.0001f; + + /// + /// What a node looked like before the frame scaled it, and what it was scaled to. + /// + /// Its position in the graph's own space. + /// Its size in the graph's own space. + /// The position it was drawn at, so a value still equal to it is one nothing moved. + private readonly record struct ViewState(Vector2 Position, Vector2 Dimensions, Vector2 View); + /// /// Writes back the positions the user dragged nodes to, and the sizes ImNodes measured. /// @@ -610,19 +777,26 @@ public void Add(AstNode node, Vector2 position) } /// - /// Brings the whole graph back into view, centred on the origin. + /// Brings the whole graph into view: centred on the canvas, and zoomed out far enough to fit. /// /// True if there was anything to bring into view. /// /// The layout arranges nodes wherever the forces take them, and a user can drag one anywhere, so - /// a document can end up off the edge of the view with no clue which way to scroll back. This - /// moves the arrangement rather than the view: the renderer writes each node's position into the - /// node editor every frame, so panning the editor is undone as soon as it is read back — the - /// positions are the only thing that decides where a node is drawn. + /// a document can end up off the edge of the view with no clue which way to scroll back. Worse, + /// a graph can simply be bigger than the canvas, which no amount of centring fixes. /// - /// Centred on the world origin, which is the middle of the canvas, so fitting puts the graph - /// where gravity is going to hold it anyway. The whole arrangement is translated, so the shape - /// the layout settled into is preserved rather than being disturbed by the act of looking at it. + /// Centring moves the arrangement rather than the view: the renderer writes each node's position + /// into the node editor every frame, so panning the editor is undone as soon as it is read back — + /// the positions are the only thing that decides where a node is drawn. The whole arrangement is + /// translated, so the shape the layout settled into is preserved rather than being disturbed by + /// the act of looking at it. + /// + /// + /// The zoom is then whatever makes the arrangement's own extent fit inside the canvas, with a + /// margin so nothing sits against an edge, and never more than — a graph + /// small enough to be magnified is shown at its own size rather than blown up to fill the room. + /// A graph too big even at is shown as small as the view goes, which is the + /// most of it that can be had. /// /// public bool FitView() @@ -650,10 +824,34 @@ public bool FitView() Graph.Engine.UpdateNodePosition(node.Id, node.Position + offset); } - statusMessage = "Brought the graph back into view."; + // The origin is kept on the middle of the canvas, so the canvas is twice it. + Zoom = FittingZoom(highest - lowest, Graph.Engine.WorldOrigin * 2f); + + statusMessage = "Brought the graph into view."; return true; } + /// + /// Works out the largest zoom an arrangement still fits the canvas at. + /// + /// How much room the arrangement takes at its own scale. + /// The room there is to show it in. + /// The zoom to use, within the range the view allows. + /// + /// Never above one, so fitting only ever zooms out. Magnifying a small graph to fill the canvas + /// would be a surprising answer to "fit": the user asked to see all of it, and they already can. + /// + private static float FittingZoom(Vector2 extent, Vector2 canvas) + { + if (extent.X <= 0f || extent.Y <= 0f || canvas.X <= 0f || canvas.Y <= 0f) + { + return 1f; + } + + float fitting = Math.Min(canvas.X / extent.X, canvas.Y / extent.Y) * FitMargin; + return Math.Clamp(Math.Min(fitting, 1f), MinZoom, MaxZoom); + } + /// /// Selects a node, so the inspector shows it and the graph highlights it. /// diff --git a/Coder.Test/Coder.Test.csproj b/Coder.Test/Coder.Test.csproj index f814d5b..2e4ca26 100644 --- a/Coder.Test/Coder.Test.csproj +++ b/Coder.Test/Coder.Test.csproj @@ -19,6 +19,8 @@ + + diff --git a/Coder.Test/Editor/EditorWiringTests.cs b/Coder.Test/Editor/EditorWiringTests.cs index e89c23d..1e55d45 100644 --- a/Coder.Test/Editor/EditorWiringTests.cs +++ b/Coder.Test/Editor/EditorWiringTests.cs @@ -113,7 +113,17 @@ public async Task RunAsync_ReadsSettingsBeforeStartingAndWritesThemAfter() { using ServiceProvider services = Program.BuildServices(); EditorSettingsStore store = services.GetRequiredService(); - EditorSettings stored = new() { PreviewLanguageId = "cpp" }; + EditorSettings stored = new() + { + PreviewLanguageId = "cpp", + WindowWidth = 1600f, + WindowHeight = 900f, + WindowX = 120f, + WindowY = 80f, + WindowMaximized = true, + GraphSplit = 0.45f, + PropertiesSplit = 0.7f, + }; Assert.IsTrue(await store.SaveAsync(stored).ConfigureAwait(false)); ImGuiAppConfig? handed = null; @@ -130,8 +140,40 @@ public async Task RunAsync_ReadsSettingsBeforeStartingAndWritesThemAfter() Assert.AreEqual("Coder", handed.Title); Assert.AreEqual("cpp", after.PreviewLanguageId, "the stored settings should have been read before starting"); + // The window and the panes are arrangements the user made, so they come back with everything + // else rather than being reset on every run. + Assert.AreEqual(1600f, handed.InitialWindowState.Size.X); + Assert.AreEqual(900f, handed.InitialWindowState.Size.Y); + Assert.AreEqual(120f, handed.InitialWindowState.Pos.X); + Assert.AreEqual(80f, handed.InitialWindowState.Pos.Y); + Assert.AreEqual(Silk.NET.Windowing.WindowState.Maximized, handed.InitialWindowState.LayoutState); + EditorSettings reread = await store.LoadAsync().ConfigureAwait(false); Assert.AreEqual("cpp", reread.PreviewLanguageId, "the settings should have been written back on exit"); + Assert.AreEqual(1600f, reread.WindowWidth, "the window size should have been written back on exit"); + Assert.AreEqual(120f, reread.WindowX); + Assert.IsTrue(reread.WindowMaximized); + Assert.AreEqual(0.45f, reread.GraphSplit, "the pane split should have been written back on exit"); + Assert.AreEqual(0.7f, reread.PropertiesSplit); + } + + /// + /// Tests that a fresh run opens a window the platform places, rather than one this application + /// put in a corner. + /// + [TestMethod] + public void BuildConfig_LeavesAFirstRunsPositionToThePlatform() + { + using ServiceProvider services = Program.BuildServices(); + CoderEditorApp app = new( + services.GetRequiredService(), + services.GetServices(), + new EditorSettings()); + + ImGuiAppConfig config = app.BuildConfig(); + + Assert.AreEqual(new ImGuiAppWindowState().Pos, config.InitialWindowState.Pos); + Assert.AreEqual(Silk.NET.Windowing.WindowState.Normal, config.InitialWindowState.LayoutState); } /// diff --git a/Coder.Test/Graph/AstGraphEditorTests.cs b/Coder.Test/Graph/AstGraphEditorTests.cs index 3f3c535..ed5de4c 100644 --- a/Coder.Test/Graph/AstGraphEditorTests.cs +++ b/Coder.Test/Graph/AstGraphEditorTests.cs @@ -9,6 +9,7 @@ namespace ktsu.Coder.Test.Graph; using ktsu.Coder.Graph; using ktsu.ImGui.App; using ktsu.ImGui.App.Testing; +using ktsu.ImGuiNodeEditor; using Microsoft.VisualStudio.TestTools.UnitTesting; /// @@ -218,9 +219,11 @@ public void Editor_CentresTheDocumentOnTheFirstFrame() /// caption is never hidden behind the node in front of it. /// /// - /// Drawn for real rather than asserted headlessly, because how big a node is drawn is only known - /// once it has been: the layout is told a node's size by the renderer that measured it, and this - /// covers that whole path rather than the arithmetic alone. + /// The separation is the layout library's, not this application's, and it is asserted here rather + /// than left to that library's own tests because it only works when a node's measured size reaches + /// the simulation. That happens by the renderer reading the size back out of the node editor and + /// writing it into the engine, which is this application's wiring and is only exercised by drawing + /// real frames. /// [TestMethod] public void Editor_PullsOverlappingNodesApartAsItRuns() @@ -235,7 +238,22 @@ public void Editor_PullsOverlappingNodesApartAsItRuns() using ImGuiAppHarness harness = ImGuiAppHarness.Start(ConfigFor(editor), Options); harness.Step(180); - Assert.AreEqual(0f, editor.Graph.SeparateOverlaps(), "three seconds of layout left nodes drawn over one another"); + Node[] nodes = [.. editor.Graph.Engine.Nodes]; + Assert.IsTrue(nodes.All(node => node.Dimensions.X > 0f), "the renderer should have measured every node"); + + for (int i = 0; i < nodes.Length; i++) + { + for (int j = i + 1; j < nodes.Length; j++) + { + bool apart = + nodes[i].Position.X + nodes[i].Dimensions.X <= nodes[j].Position.X || + nodes[j].Position.X + nodes[j].Dimensions.X <= nodes[i].Position.X || + nodes[i].Position.Y + nodes[i].Dimensions.Y <= nodes[j].Position.Y || + nodes[j].Position.Y + nodes[j].Dimensions.Y <= nodes[i].Position.Y; + + Assert.IsTrue(apart, $"three seconds of layout left {nodes[i].Name} drawn over {nodes[j].Name}"); + } + } } /// diff --git a/Coder.Test/Graph/AstGraphLayoutTests.cs b/Coder.Test/Graph/AstGraphLayoutTests.cs index 36f92ad..867d3be 100644 --- a/Coder.Test/Graph/AstGraphLayoutTests.cs +++ b/Coder.Test/Graph/AstGraphLayoutTests.cs @@ -116,79 +116,6 @@ public void Graph_SeparatesNodes() } } - /// - /// Tests that two nodes drawn on top of one another are pushed apart until their boxes no longer - /// overlap, which the simulation on its own will not do. - /// - /// - /// The layout treats a node as a point: repulsion is measured between centres and the link spring - /// pulls to a fixed length, so two wide nodes can sit at a distance it is perfectly happy with and - /// still be squarely on top of each other. Node dimensions are measured while rendering, so they - /// are set here the way a frame would set them. - /// - [TestMethod] - public void SeparateOverlaps_PushesOverlappingBoxesApart() - { - AstGraph graph = new(new FunctionDeclaration("total") { ReturnType = "int" }); - graph.AddDetached(new VariableReference("a"), new Vector2(500, 300)); - graph.AddDetached(new VariableReference("b"), new Vector2(520, 310)); - - Vector2 dimensions = new(180, 90); - foreach (Node node in graph.Engine.Nodes.ToArray()) - { - graph.Engine.UpdateNodeDimensions(node.Id, dimensions); - } - - Assert.IsTrue(graph.SeparateOverlaps() > 0f, "the nodes start on top of each other"); - - // Enough steps for a correction that is capped per step to finish. - for (int step = 0; step < 200 && graph.SeparateOverlaps() > 0f; step++) - { - // The separation is the work; the loop only has to run it until it reports nothing left. - } - - Assert.AreEqual(0f, graph.SeparateOverlaps(), "the boxes should no longer overlap"); - - Node[] separated = [.. graph.Engine.Nodes]; - for (int i = 0; i < separated.Length; i++) - { - for (int j = i + 1; j < separated.Length; j++) - { - bool apart = separated[i].Position.X + dimensions.X <= separated[j].Position.X - || separated[j].Position.X + dimensions.X <= separated[i].Position.X - || separated[i].Position.Y + dimensions.Y <= separated[j].Position.Y - || separated[j].Position.Y + dimensions.Y <= separated[i].Position.Y; - - Assert.IsTrue(apart, $"nodes {i} and {j} are still drawn over one another"); - } - } - } - - /// - /// Tests that a graph nothing overlaps in is left exactly as it is, so the separation cannot - /// unsettle an arrangement the layout has already finished. - /// - [TestMethod] - public void SeparateOverlaps_LeavesAClearArrangementAlone() - { - AstGraph graph = new(new FunctionDeclaration("total") { ReturnType = "int" }); - graph.AddDetached(new VariableReference("a"), new Vector2(0, 0)); - graph.AddDetached(new VariableReference("b"), new Vector2(600, 400)); - - foreach (Node node in graph.Engine.Nodes.ToArray()) - { - graph.Engine.UpdateNodeDimensions(node.Id, new Vector2(120, 60)); - } - - // The document's own node was seeded near the origin, so it is moved well clear of both. - graph.Engine.UpdateNodePosition(graph.Engine.Nodes[0].Id, new Vector2(-600, -400)); - - Vector2[] before = [.. graph.Engine.Nodes.Select(node => node.Position)]; - - Assert.AreEqual(0f, graph.SeparateOverlaps()); - CollectionAssert.AreEqual(before, graph.Engine.Nodes.Select(node => node.Position).ToArray()); - } - /// /// Measures how far the furthest node has moved from a remembered arrangement. /// @@ -239,6 +166,67 @@ public void FitView_CentresTheGraphOnTheOrigin() } } + /// + /// Tests that fitting a graph too big for the canvas zooms out until it fits, rather than only + /// centring the part of it that happens to be on screen. + /// + [TestMethod] + public void FitView_ZoomsOutUntilTheGraphFits() + { + AstGraphEditor editor = new(SampleFunction()) { LayoutRunning = false }; + + // A 600x400 canvas, with the origin on the middle of it. + editor.Graph.Engine.WorldOrigin = new Vector2(300, 200); + + // Spread the arrangement well past the canvas's width. + Node[] nodes = [.. editor.Graph.Engine.Nodes]; + for (int i = 0; i < nodes.Length; i++) + { + editor.Graph.Engine.UpdateNodePosition(nodes[i].Id, new Vector2(i * 400f, 0f)); + editor.Graph.Engine.UpdateNodeDimensions(nodes[i].Id, new Vector2(120f, 60f)); + } + + Assert.IsTrue(editor.FitView()); + + float extent = ((nodes.Length - 1) * 400f) + 120f; + Assert.IsTrue(editor.Zoom < 1f, $"a graph {extent} wide should not fit a 600 canvas at {editor.Zoom}"); + Assert.IsTrue(extent * editor.Zoom <= 600f, $"the graph still overflows the canvas at {editor.Zoom}"); + } + + /// + /// Tests that fitting a graph the canvas already has room for leaves it at its own size, since + /// magnifying it is not what "fit" means to anyone who asked to see all of it. + /// + [TestMethod] + public void FitView_DoesNotMagnifyAGraphThatAlreadyFits() + { + AstGraphEditor editor = new(SampleFunction()) { LayoutRunning = false, Zoom = 0.5f }; + editor.Graph.Engine.WorldOrigin = new Vector2(1000, 800); + + Node[] nodes = [.. editor.Graph.Engine.Nodes]; + for (int i = 0; i < nodes.Length; i++) + { + editor.Graph.Engine.UpdateNodePosition(nodes[i].Id, new Vector2(i * 20f, 0f)); + editor.Graph.Engine.UpdateNodeDimensions(nodes[i].Id, new Vector2(40f, 20f)); + } + + Assert.IsTrue(editor.FitView()); + Assert.AreEqual(1f, editor.Zoom, 0.0001f); + } + + /// + /// Tests that the zoom stays within the range the view offers, however it is set. + /// + [TestMethod] + public void Zoom_IsHeldWithinTheRangeTheSliderOffers() + { + AstGraphEditor editor = new(SampleFunction()) { Zoom = 50f }; + Assert.IsTrue(editor.Zoom is > 1f and <= 2f, $"{editor.Zoom} is not a zoom the view offers"); + + editor.Zoom = 0f; + Assert.IsTrue(editor.Zoom is > 0f and < 1f, $"{editor.Zoom} is not a zoom the view offers"); + } + /// /// Tests that a rebuild leaves the world origin where the caller put it, since every structural /// edit rebuilds and clearing the engine resets it. diff --git a/Directory.Packages.props b/Directory.Packages.props index c2450fb..2ebd623 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,8 @@ + +