Skip to content

Commit bd96c86

Browse files
Merge pull request #21 from ktsu-dev/claude/auto-layout-expression-editor-ugcdvj
Take the zoom from the node editor rather than doing it here
2 parents e40603d + 1665efc commit bd96c86

2 files changed

Lines changed: 29 additions & 226 deletions

File tree

Coder.Graph/AstGraphEditor.cs

Lines changed: 15 additions & 201 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,6 @@ public sealed class AstGraphEditor(AstNode root)
4242

4343
private bool fitted;
4444

45-
private bool fontScaled;
46-
47-
private readonly Dictionary<int, ViewState> viewSpace = [];
48-
4945
private string? editingField;
5046

5147
/// <summary>
@@ -82,21 +78,16 @@ public sealed class AstGraphEditor(AstNode root)
8278
/// Gets or sets how large the graph is drawn, as a multiplier: 1 draws it at its own size.
8379
/// </summary>
8480
/// <remarks>
85-
/// A view setting, not a document one. The graph's positions and the layout that arranges them
86-
/// are kept at their own scale whatever this is, and the zoom is applied and undone around the
87-
/// one frame that draws them — so the simulation is never asked to work in a space that changes
88-
/// under it, and a node dragged while zoomed out lands where the pointer was.
89-
/// <para>
90-
/// The node editor underneath has no zoom of its own, so this is what there is: it scales the
91-
/// distances between nodes and the text inside them together, which is what makes a whole graph
92-
/// fit on screen rather than merely spreading it out.
93-
/// </para>
81+
/// The node editor's own, forwarded rather than held here. It is the renderer that scales node
82+
/// positions on their way into ImNodes and unscales them on the way back, which is the only seam
83+
/// a zoom can sit at when ImNodes has none of its own; a second copy of the value here would only
84+
/// be something to keep in step with it.
9485
/// </remarks>
9586
public float Zoom
9687
{
97-
get;
98-
set => field = Math.Clamp(value, MinZoom, MaxZoom);
99-
} = 1f;
88+
get => renderer.Zoom;
89+
set => renderer.Zoom = value;
90+
}
10091

10192
/// <summary>
10293
/// Gets or sets a value indicating whether <see cref="Draw"/> puts the inspector panel beside the
@@ -173,8 +164,6 @@ public void Draw(Vector2 size, float deltaTime)
173164
Vector2 origin = ImGui.GetCursorScreenPos();
174165
ImGui.BeginChild("ast-graph-canvas", graphSize, ImGuiChildFlags.None, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse);
175166

176-
EnterViewSpace();
177-
178167
renderer.Render(Graph.Engine, graphSize);
179168

180169
ApplyNodeMovement();
@@ -188,8 +177,6 @@ public void Draw(Vector2 size, float deltaTime)
188177
renderer.RenderDebugOverlays(Graph.Engine, origin, graphSize, showDebug: true);
189178
}
190179

191-
LeaveViewSpace();
192-
193180
ImGui.EndChild();
194181

195182
if (ShowInspector)
@@ -220,21 +207,6 @@ public void Draw(Vector2 size, float deltaTime)
220207
/// </summary>
221208
private const float MinimumGraphWidth = 160f;
222209

223-
/// <summary>
224-
/// The smallest the graph is drawn, as a multiplier of its own size.
225-
/// </summary>
226-
private const float MinZoom = 0.25f;
227-
228-
/// <summary>
229-
/// The largest the graph is drawn, as a multiplier of its own size.
230-
/// </summary>
231-
private const float MaxZoom = 2f;
232-
233-
/// <summary>
234-
/// How much of the canvas a fitted graph is asked to fill, leaving a margin around it.
235-
/// </summary>
236-
private const float FitMargin = 0.9f;
237-
238210
/// <summary>
239211
/// The width of the toolbar's zoom slider.
240212
/// </summary>
@@ -287,7 +259,7 @@ private void DrawToolbar()
287259
ImGui.SameLine();
288260
ImGui.SetNextItemWidth(ZoomSliderWidth);
289261
float percent = Zoom * 100f;
290-
if (ImGui.SliderFloat("##zoom", ref percent, MinZoom * 100f, MaxZoom * 100f, "%.0f%%"))
262+
if (ImGui.SliderFloat("##zoom", ref percent, NodeEditorRenderer.MinZoom * 100f, NodeEditorRenderer.MaxZoom * 100f, "%.0f%%"))
291263
{
292264
Zoom = percent / 100f;
293265
}
@@ -561,113 +533,6 @@ public void Redo()
561533
}
562534
}
563535

564-
/// <summary>
565-
/// Scales the graph into the space it is drawn in, for the duration of one frame's drawing.
566-
/// </summary>
567-
/// <remarks>
568-
/// The node editor underneath has no zoom, and it takes each node's position straight out of the
569-
/// engine, so the only place a zoom can be applied is the engine itself. Doing that permanently
570-
/// would put the simulation in a space that changes whenever the user drags the slider — its rest
571-
/// length, its repulsion distance and its overlap margin are all lengths, and none of them would
572-
/// mean the same thing afterwards.
573-
/// <para>
574-
/// So the scaling is put on before the frame is drawn and taken off again after, by
575-
/// <see cref="LeaveViewSpace"/>. Between those two calls the engine holds view positions, which is
576-
/// what the renderer draws and what the user's drag is read back in; outside them it holds the
577-
/// graph's own, which is what the layout runs on and what a fit is measured against.
578-
/// </para>
579-
/// <para>
580-
/// Scaled about the world origin — the middle of the canvas — so zooming keeps whatever is in the
581-
/// middle of the view in the middle of the view, rather than sending the graph towards a corner.
582-
/// The text is scaled to match, because a node's box is sized from the text inside it: without
583-
/// that, zooming out would only move the nodes closer together while they stayed the same size,
584-
/// which packs them tighter instead of showing more.
585-
/// </para>
586-
/// </remarks>
587-
private void EnterViewSpace()
588-
{
589-
fontScaled = false;
590-
viewSpace.Clear();
591-
592-
if (IsUnzoomed)
593-
{
594-
return;
595-
}
596-
597-
Vector2 centre = Graph.Engine.WorldOrigin;
598-
foreach (Node node in Graph.Engine.Nodes.ToArray())
599-
{
600-
Vector2 view = ((node.Position - centre) * Zoom) + centre;
601-
viewSpace[node.Id] = new ViewState(node.Position, node.Dimensions, view);
602-
Graph.Engine.UpdateNodePosition(node.Id, view);
603-
}
604-
605-
ImGui.PushFont(ImGui.GetFont(), ImGui.GetFontSize() * Zoom);
606-
fontScaled = true;
607-
}
608-
609-
/// <summary>
610-
/// Takes the scaling back off, returning the engine to the graph's own space.
611-
/// </summary>
612-
/// <remarks>
613-
/// A node the frame did not touch is put back to exactly the value it had rather than divided by
614-
/// the zoom it was multiplied by, and its size is left alone rather than divided at all. That is
615-
/// the difference between a transform and its inverse being applied once and being applied every
616-
/// frame: a size only arrives from the renderer when it has measured a new one, so dividing them
617-
/// unconditionally would divide the same value again on every frame it did not change, and a
618-
/// graph left alone for a second would have nodes thousands of times their real size.
619-
/// </remarks>
620-
private void LeaveViewSpace()
621-
{
622-
if (fontScaled)
623-
{
624-
ImGui.PopFont();
625-
fontScaled = false;
626-
}
627-
628-
if (IsUnzoomed)
629-
{
630-
return;
631-
}
632-
633-
Vector2 centre = Graph.Engine.WorldOrigin;
634-
foreach (Node node in Graph.Engine.Nodes.ToArray())
635-
{
636-
// A node the frame replaced — a rebuild reassigns every identifier — is not one this frame
637-
// put into view space, so it is converted rather than restored.
638-
bool known = viewSpace.TryGetValue(node.Id, out ViewState state);
639-
640-
Graph.Engine.UpdateNodePosition(
641-
node.Id,
642-
known && state.View == node.Position
643-
? state.Position
644-
: ((node.Position - centre) / Zoom) + centre);
645-
646-
// Untouched means the renderer reported no new measurement, so what is there is still the
647-
// size from before the frame and is already in the graph's own space.
648-
if (!known || state.Dimensions != node.Dimensions)
649-
{
650-
Graph.Engine.UpdateNodeDimensions(node.Id, node.Dimensions / Zoom);
651-
}
652-
}
653-
654-
viewSpace.Clear();
655-
}
656-
657-
/// <summary>
658-
/// Gets a value indicating whether the view is at the graph's own scale, where the transform is
659-
/// the identity and is skipped rather than applied as one.
660-
/// </summary>
661-
private bool IsUnzoomed => Math.Abs(Zoom - 1f) < 0.0001f;
662-
663-
/// <summary>
664-
/// What a node looked like before the frame scaled it, and what it was scaled to.
665-
/// </summary>
666-
/// <param name="Position">Its position in the graph's own space.</param>
667-
/// <param name="Dimensions">Its size in the graph's own space.</param>
668-
/// <param name="View">The position it was drawn at, so a value still equal to it is one nothing moved.</param>
669-
private readonly record struct ViewState(Vector2 Position, Vector2 Dimensions, Vector2 View);
670-
671536
/// <summary>
672537
/// Writes back the positions the user dragged nodes to, and the sizes ImNodes measured.
673538
/// </summary>
@@ -781,77 +646,26 @@ public void Add(AstNode node, Vector2 position)
781646
/// </summary>
782647
/// <returns>True if there was anything to bring into view.</returns>
783648
/// <remarks>
784-
/// The layout arranges nodes wherever the forces take them, and a user can drag one anywhere, so
785-
/// a document can end up off the edge of the view with no clue which way to scroll back. Worse,
786-
/// a graph can simply be bigger than the canvas, which no amount of centring fixes.
649+
/// The layout arranges nodes wherever the forces take them, and a user can drag one anywhere, so a
650+
/// document can end up off the edge of the view with no clue which way to scroll back. Worse, a
651+
/// graph can simply be bigger than the canvas, which no amount of centring fixes.
787652
/// <para>
788-
/// Centring moves the arrangement rather than the view: the renderer writes each node's position
789-
/// into the node editor every frame, so panning the editor is undone as soon as it is read back —
790-
/// the positions are the only thing that decides where a node is drawn. The whole arrangement is
791-
/// translated, so the shape the layout settled into is preserved rather than being disturbed by
792-
/// the act of looking at it.
793-
/// </para>
794-
/// <para>
795-
/// The zoom is then whatever makes the arrangement's own extent fit inside the canvas, with a
796-
/// margin so nothing sits against an edge, and never more than <see cref="MaxZoom"/> — a graph
797-
/// small enough to be magnified is shown at its own size rather than blown up to fill the room.
798-
/// A graph too big even at <see cref="MinZoom"/> is shown as small as the view goes, which is the
799-
/// most of it that can be had.
653+
/// The node editor does both parts — it owns the zoom, and centring means moving the nodes, which
654+
/// is its business rather than this application's. What is decided here is the canvas: the world
655+
/// origin is kept on the middle of it, so twice the origin is the whole of it.
800656
/// </para>
801657
/// </remarks>
802658
public bool FitView()
803659
{
804-
Node[] nodes = [.. Graph.Engine.Nodes];
805-
if (nodes.Length == 0)
660+
if (!renderer.FitToView(Graph.Engine, Graph.Engine.WorldOrigin * 2f))
806661
{
807662
return false;
808663
}
809664

810-
// Measured across each node's whole extent rather than its top-left corner, so a wide node on
811-
// one edge does not pull the arrangement off centre by half its width.
812-
Vector2 lowest = new(float.MaxValue, float.MaxValue);
813-
Vector2 highest = new(float.MinValue, float.MinValue);
814-
815-
foreach (Node node in nodes)
816-
{
817-
lowest = Vector2.Min(lowest, node.Position);
818-
highest = Vector2.Max(highest, node.Position + node.Dimensions);
819-
}
820-
821-
Vector2 offset = Graph.Engine.WorldOrigin - ((lowest + highest) * 0.5f);
822-
foreach (Node node in nodes)
823-
{
824-
Graph.Engine.UpdateNodePosition(node.Id, node.Position + offset);
825-
}
826-
827-
// The origin is kept on the middle of the canvas, so the canvas is twice it.
828-
Zoom = FittingZoom(highest - lowest, Graph.Engine.WorldOrigin * 2f);
829-
830665
statusMessage = "Brought the graph into view.";
831666
return true;
832667
}
833668

834-
/// <summary>
835-
/// Works out the largest zoom an arrangement still fits the canvas at.
836-
/// </summary>
837-
/// <param name="extent">How much room the arrangement takes at its own scale.</param>
838-
/// <param name="canvas">The room there is to show it in.</param>
839-
/// <returns>The zoom to use, within the range the view allows.</returns>
840-
/// <remarks>
841-
/// Never above one, so fitting only ever zooms out. Magnifying a small graph to fill the canvas
842-
/// would be a surprising answer to "fit": the user asked to see all of it, and they already can.
843-
/// </remarks>
844-
private static float FittingZoom(Vector2 extent, Vector2 canvas)
845-
{
846-
if (extent.X <= 0f || extent.Y <= 0f || canvas.X <= 0f || canvas.Y <= 0f)
847-
{
848-
return 1f;
849-
}
850-
851-
float fitting = Math.Min(canvas.X / extent.X, canvas.Y / extent.Y) * FitMargin;
852-
return Math.Clamp(Math.Min(fitting, 1f), MinZoom, MaxZoom);
853-
}
854-
855669
/// <summary>
856670
/// Selects a node, so the inspector shows it and the graph highlights it.
857671
/// </summary>

Coder.Test/Graph/AstGraphLayoutTests.cs

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,12 @@ public void FitView_CentresTheGraphOnTheOrigin()
170170
/// Tests that fitting a graph too big for the canvas zooms out until it fits, rather than only
171171
/// centring the part of it that happens to be on screen.
172172
/// </summary>
173+
/// <remarks>
174+
/// The zooming is the node editor's, and covered by its own tests. What is this application's — and
175+
/// what this covers — is the canvas it hands over: the world origin is kept on the middle of the
176+
/// canvas, so the editor passes twice the origin. Getting that wrong would fit the graph to the
177+
/// wrong rectangle, which no test in the library could catch.
178+
/// </remarks>
173179
[TestMethod]
174180
public void FitView_ZoomsOutUntilTheGraphFits()
175181
{
@@ -194,37 +200,20 @@ public void FitView_ZoomsOutUntilTheGraphFits()
194200
}
195201

196202
/// <summary>
197-
/// Tests that fitting a graph the canvas already has room for leaves it at its own size, since
198-
/// magnifying it is not what "fit" means to anyone who asked to see all of it.
199-
/// </summary>
200-
[TestMethod]
201-
public void FitView_DoesNotMagnifyAGraphThatAlreadyFits()
202-
{
203-
AstGraphEditor editor = new(SampleFunction()) { LayoutRunning = false, Zoom = 0.5f };
204-
editor.Graph.Engine.WorldOrigin = new Vector2(1000, 800);
205-
206-
Node[] nodes = [.. editor.Graph.Engine.Nodes];
207-
for (int i = 0; i < nodes.Length; i++)
208-
{
209-
editor.Graph.Engine.UpdateNodePosition(nodes[i].Id, new Vector2(i * 20f, 0f));
210-
editor.Graph.Engine.UpdateNodeDimensions(nodes[i].Id, new Vector2(40f, 20f));
211-
}
212-
213-
Assert.IsTrue(editor.FitView());
214-
Assert.AreEqual(1f, editor.Zoom, 0.0001f);
215-
}
216-
217-
/// <summary>
218-
/// Tests that the zoom stays within the range the view offers, however it is set.
203+
/// Tests that the editor's zoom is the node editor's, rather than a second value beside it.
219204
/// </summary>
205+
/// <remarks>
206+
/// Asserted through the clamping, which is the renderer's: a value it would refuse coming back
207+
/// changed is what says the property forwarded rather than storing what it was given.
208+
/// </remarks>
220209
[TestMethod]
221-
public void Zoom_IsHeldWithinTheRangeTheSliderOffers()
210+
public void Zoom_IsTheRenderersOwn()
222211
{
223212
AstGraphEditor editor = new(SampleFunction()) { Zoom = 50f };
224-
Assert.IsTrue(editor.Zoom is > 1f and <= 2f, $"{editor.Zoom} is not a zoom the view offers");
213+
Assert.AreEqual(NodeEditorRenderer.MaxZoom, editor.Zoom);
225214

226215
editor.Zoom = 0f;
227-
Assert.IsTrue(editor.Zoom is > 0f and < 1f, $"{editor.Zoom} is not a zoom the view offers");
216+
Assert.AreEqual(NodeEditorRenderer.MinZoom, editor.Zoom);
228217
}
229218

230219
/// <summary>

0 commit comments

Comments
 (0)