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