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 @@
-
+
-
-
-
+
+
+
-
-
+
+