From f6395963b4de0dd85b3cb8daef3778f7736204a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 14:16:36 +0000 Subject: [PATCH] Tune the layout from a pane beside the graph The editor could arrange a graph but not adjust how: the eighteen settings the force-directed layout has were reachable only by recompiling. ktsu.ImGui.NodeEditor 3.26.0 supplies the whole tuning surface as a panel, so this is the wiring rather than another set of sliders that would drift from it. The tuning gets a pane of its own in the side column, under the properties and the generated code, rather than a section inside the properties: it is read while watching the graph move, so it has to be able to be tall while the properties are short, and it must not close itself every time a different node is selected. Its split is remembered between runs like the other two. One flag rather than two for whether the layout runs. This editor stops the simulation by not advancing it, in LayoutRunning, while the panel's own run toggle writes the engine's Enabled - so the panel is shown and written as LayoutRunning and the engine's flag is left on. Otherwise there would be an arrangement in which the graph sits still for a reason the toolbar does not show. The tests cover the wiring rather than the controls, which belong to the library and are covered there: that every group renders, that dragging a slider reaches this graph's own engine rather than a copy of its settings, and that the run toggle is the flag the toolbar holds. Directory.Packages.props moves the six ImGui suite packages 3.25.0 to 3.26.0, which is where the panel arrives. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D2KNUKr1xJPTEdDmeF2tQN --- Coder.Editor/CoderEditorApp.cs | 35 ++++- Coder.Editor/EditorSettings.cs | 11 +- Coder.Graph/AstGraphEditor.cs | 36 +++++ Coder.Test/Editor/EditorWiringTests.cs | 2 + .../Graph/AstGraphEditorLayoutPanelTests.cs | 124 ++++++++++++++++++ Directory.Packages.props | 12 +- 6 files changed, 204 insertions(+), 16 deletions(-) create mode 100644 Coder.Test/Graph/AstGraphEditorLayoutPanelTests.cs diff --git a/Coder.Editor/CoderEditorApp.cs b/Coder.Editor/CoderEditorApp.cs index e8c49cd..90cf89b 100644 --- a/Coder.Editor/CoderEditorApp.cs +++ b/Coder.Editor/CoderEditorApp.cs @@ -212,11 +212,17 @@ public void Draw(float deltaTime) /// /// 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. + /// Properties, code and layout tuning 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, generated source is lines that want to be read down, and the tuning is a long list of + /// sliders. 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 tuning gets a pane of its own rather than a section inside the properties, because it is + /// read while watching the graph move: it has to be able to be tall while the properties are + /// short, and it must not close itself every time a different node is selected. + /// /// /// The sizes are remembered between runs, so an arrangement the user settled on is the one they /// come back to. @@ -226,11 +232,16 @@ private ImGuiWidgets.DividerContainer BuildPanes() { ImGuiWidgets.DividerContainer side = new( "coder-side", - container => Settings.PropertiesSplit = container.GetSizes()[0], + container => + { + Settings.PropertiesSplit = container.GetSizes()[0]; + Settings.CodeSplit = container.GetSizes()[1]; + }, ImGuiWidgets.DividerLayout.Rows, [ new ImGuiWidgets.DividerZone("properties", Settings.PropertiesSplit, DrawPropertiesPane), - new ImGuiWidgets.DividerZone("code", 1f - Settings.PropertiesSplit, _ => DrawCodePane()), + new ImGuiWidgets.DividerZone("code", Settings.CodeSplit, _ => DrawCodePane()), + new ImGuiWidgets.DividerZone("layout", 1f - Settings.PropertiesSplit - Settings.CodeSplit, DrawLayoutPane), ]); return new ImGuiWidgets.DividerContainer( @@ -254,6 +265,16 @@ private void DrawPropertiesPane(float deltaTime) Editor.DrawInspector(ImGui.GetContentRegionAvail()); } + /// + /// Draws the layout tuning, which the editor supplies but does not place. + /// + /// Seconds since the last frame; the panel does not animate, so unused. + private void DrawLayoutPane(float deltaTime) + { + ImGui.TextUnformatted("Layout"); + Editor.DrawLayoutSettings(ImGui.GetContentRegionAvail()); + } + /// /// Applies the keyboard shortcuts the menu also offers. /// diff --git a/Coder.Editor/EditorSettings.cs b/Coder.Editor/EditorSettings.cs index a2f7409..53950fa 100644 --- a/Coder.Editor/EditorSettings.cs +++ b/Coder.Editor/EditorSettings.cs @@ -75,10 +75,15 @@ public sealed class EditorSettings 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. + /// Gets or sets the share of that panel's height the properties take. /// - public float PropertiesSplit { get; set; } = 0.4f; + public float PropertiesSplit { get; set; } = 0.3f; + + /// + /// Gets or sets the share of that panel's height the generated code takes, the rest going to the + /// layout tuning under it. + /// + public float CodeSplit { get; set; } = 0.4f; /// /// Records a file as the most recently opened, without letting the list grow or repeat. diff --git a/Coder.Graph/AstGraphEditor.cs b/Coder.Graph/AstGraphEditor.cs index 33d1b9a..8b09d2f 100644 --- a/Coder.Graph/AstGraphEditor.cs +++ b/Coder.Graph/AstGraphEditor.cs @@ -9,6 +9,7 @@ namespace ktsu.Coder.Graph; using Hexa.NET.ImGui; using Hexa.NET.ImNodes; using ktsu.Coder.Ast; +using ktsu.ForceDirectedLayout; using ktsu.ImGui.NodeEditor; using ktsu.UndoRedo; using ktsu.UndoRedo.Contracts; @@ -336,6 +337,41 @@ public void DrawInspector(Vector2 size) ImGui.EndChild(); } + /// + /// Draws the panel that tunes the force-directed layout, so the graph can be arranged while it is + /// on screen rather than by rebuilding. + /// + /// + /// The whole tuning surface comes from , which the node editor + /// library supplies: every setting the simulation has, grouped by the force it belongs to. It is + /// drawn beside the graph deliberately - the forces interact, so a change to any one of them is + /// only judgeable by watching what the graph does about it. + /// + /// The panel's run toggle is shown and written as , the same flag the + /// toolbar's checkbox holds, so the two agree whichever the user reaches for. This editor stops + /// the layout by not advancing it rather than by the engine's own Enabled, which stays on: + /// a step the engine is never given cannot run either way, and keeping one flag rather than two + /// means there is no arrangement where the graph is stopped for a reason the user cannot see. + /// + /// + /// The area to draw it in. + public void DrawLayoutSettings(Vector2 size) + { + ImGui.BeginChild("ast-layout-settings", size, ImGuiChildFlags.Borders, ImGuiWindowFlags.HorizontalScrollbar); + + PhysicsSettings settings = Graph.Engine.PhysicsSettings with { Enabled = LayoutRunning }; + if (PhysicsSettingsPanel.Draw(ref settings)) + { + LayoutRunning = settings.Enabled; + Graph.Engine.UpdatePhysicsSettings(settings with { Enabled = true }); + } + + ImGui.Separator(); + PhysicsSettingsPanel.DrawDiagnostics(Graph.Engine); + + ImGui.EndChild(); + } + /// /// Draws the menu of kinds the selected node could be turned into. /// diff --git a/Coder.Test/Editor/EditorWiringTests.cs b/Coder.Test/Editor/EditorWiringTests.cs index 1e55d45..8b58811 100644 --- a/Coder.Test/Editor/EditorWiringTests.cs +++ b/Coder.Test/Editor/EditorWiringTests.cs @@ -123,6 +123,7 @@ public async Task RunAsync_ReadsSettingsBeforeStartingAndWritesThemAfter() WindowMaximized = true, GraphSplit = 0.45f, PropertiesSplit = 0.7f, + CodeSplit = 0.2f, }; Assert.IsTrue(await store.SaveAsync(stored).ConfigureAwait(false)); @@ -155,6 +156,7 @@ public async Task RunAsync_ReadsSettingsBeforeStartingAndWritesThemAfter() 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); + Assert.AreEqual(0.2f, reread.CodeSplit, "the layout pane's split should round-trip too"); } /// diff --git a/Coder.Test/Graph/AstGraphEditorLayoutPanelTests.cs b/Coder.Test/Graph/AstGraphEditorLayoutPanelTests.cs new file mode 100644 index 0000000..1af9357 --- /dev/null +++ b/Coder.Test/Graph/AstGraphEditorLayoutPanelTests.cs @@ -0,0 +1,124 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Graph; + +using System.Numerics; + +using Hexa.NET.ImGui; + +using ktsu.Coder.Ast; +using ktsu.Coder.Graph; +using ktsu.ImGui.App; +using ktsu.ImGui.App.Testing; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Covers the panel that tunes the layout while the graph is on screen. +/// +/// +/// The controls themselves belong to ktsu.ImGui.NodeEditor and are covered there. What is this +/// editor's own is the wiring: that the panel reaches the graph's engine, that its run toggle is the +/// same flag the toolbar's is, and that a change made on it survives into the simulation rather than +/// being discarded when the frame ends. +/// +/// ImGui contexts are process-global, so only one harness can be live at a time and this class must +/// not run its methods in parallel. +/// +/// +[TestClass] +[DoNotParallelize] +public sealed class AstGraphEditorLayoutPanelTests +{ + private static readonly HarnessOptions Options = new() { Width = 900, Height = 1100 }; + + private static FunctionDeclaration SampleFunction() + { + FunctionDeclaration function = new("total") { ReturnType = "int" }; + function.Parameters.Add(new Parameter("a", "int")); + function.Body.Add(new ReturnStatement( + new BinaryExpression(new VariableReference("a"), BinaryOperator.Add, Literal.Number(1)))); + return function; + } + + /// Draws only the tuning panel, so nothing else can be what a probe finds. + private static ImGuiAppConfig ConfigFor(AstGraphEditor editor) => new() + { + Title = "Layout tuning", + OnRender = _ => + { + ImGui.Begin("layout"); + editor.DrawLayoutSettings(new Vector2(880, 1050)); + ImGui.End(); + }, + }; + + private static bool IsVisible(ImGuiAppHarness harness, string name) => + harness.Probe.WasSeenInFrame(name, harness.FrameCount - 1); + + [TestMethod] + public void Panel_OffersEveryGroupOfSettings() + { + AstGraphEditor editor = new(SampleFunction()); + + using ImGuiAppHarness harness = ImGuiAppHarness.Start(ConfigFor(editor), Options); + harness.Step(3); + + foreach (string group in new[] { "Repulsion", "Link springs", "Link shaping", "Gravity", "Overlap", "Motion and limits" }) + { + Assert.IsTrue(IsVisible(harness, group), $"The layout pane is missing its '{group}' group."); + } + + Assert.IsTrue(IsVisible(harness, "Run simulation"), "the run toggle"); + Assert.IsTrue(IsVisible(harness, "Energy"), "the diagnostics readout"); + } + + [TestMethod] + public void Panel_EditsTheGraphsOwnSimulation() + { + AstGraphEditor editor = new(SampleFunction()); + + using ImGuiAppHarness harness = ImGuiAppHarness.Start(ConfigFor(editor), Options); + harness.Step(3); + + // Reached through the panel's own group rather than set directly, so what is covered is that + // the pane is wired to this graph's engine and not to a copy of its settings. + harness.Click("Link shaping"); + harness.Step(2); + + Rectangle slider = harness.Probe.Rect("Untwisting") + ?? throw new AssertFailedException("The link shaping group should offer an untwisting slider."); + + double before = editor.Graph.Engine.PhysicsSettings.LinkUntwistStrength; + float y = slider.MinY + (slider.Height / 2f); + + // A slider's item rectangle spans the track and the label beside it, so the drag stays in the + // left portion to be sure it lands on the track. + harness.Mouse.Drag(slider.MinX + (slider.Width * 0.1f), y, slider.MinX + (slider.Width * 0.45f), y); + harness.Step(2); + + Assert.AreNotEqual(before, editor.Graph.Engine.PhysicsSettings.LinkUntwistStrength, 0.0001, + "Dragging the panel's slider should have reached the graph's own simulation."); + } + + [TestMethod] + public void Panel_RunToggleIsTheSameFlagTheToolbarHolds() + { + AstGraphEditor editor = new(SampleFunction()) { LayoutRunning = true }; + + using ImGuiAppHarness harness = ImGuiAppHarness.Start(ConfigFor(editor), Options); + harness.Step(3); + + harness.Click("Run simulation"); + harness.Step(2); + + Assert.IsFalse(editor.LayoutRunning, + "The panel's run toggle should stop the layout the same way the toolbar's checkbox does."); + + // The engine's own flag stays on: this editor stops the layout by not advancing it, and two + // flags for one visible behaviour is the arrangement that leaves a graph stopped for a reason + // the user cannot see. + Assert.IsTrue(editor.Graph.Engine.PhysicsSettings.Enabled, + "the engine's own Enabled should be left on"); + } +} diff --git a/Directory.Packages.props b/Directory.Packages.props index 3e794d8..f95ee0e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,14 +10,14 @@ - + - - - + + + - - + +