diff --git a/CLAUDE.md b/CLAUDE.md index c2612d1..b58e15e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De - **ImGui.Color** (`ktsu.ImGui.Color`) - Bridge between `ktsu.Semantics.Color` and ImGui. Colors are held as the semantic `Color` (linear) and `Srgb` types and converted only at the ImGui seam: `ColorImGuiExtensions` (`ToImColor`/`FromImColor`, `ToImGuiVector4`, `ToImGuiU32`) and `SrgbImGuiExtensions` (`Srgb` → `ImColor`/`ImGuiVector4`/`ImU32`, packed directly with no linear round-trip). The `ImColor` and `Srgb` `ToImGuiU32` apply the global style alpha like `ImGui.GetColorU32`; the linear `Color.ToImGuiU32` is a pure pack matching `ColorConvertFloat4ToU32`. `ImColor` extension operations: adjustments (lighten/darken, saturate/desaturate, hue offset, grayscale, invert, alpha), analysis (relative luminance, contrast ratio, perceptual distance), and contrast heuristics (`MostReadableTextColor`, `AdjustForSufficientContrast`). All color math delegates to `ktsu.Semantics.Color`. (There is no `ImColor` factory class — construct via `Color`/`Srgb` and convert.) - **ImGui.Styler** (`ktsu.ImGui.Styler`) - Theming system with 50+ built-in themes, scoped styling, Button.Alignment, Text.Color semantic colors, Indent utilities, Alignment helpers, theme-aware color palette (`Palette`, e.g. `Palette.Basic.Red`, `Palette.Semantic.Error`), and interactive theme browser. Color construction and manipulation live in `ImGui.Color`. - **NodeGraph** (`ktsu.NodeGraph`) - UI-agnostic attribute-based node graph metadata: `[Node]`, `[InputPin]`, `[OutputPin]`, `[NodeExecute]`, `[NodeBehavior]`, pin type utilities -- **ImGui.NodeEditor** (`ktsu.ImGui.NodeEditor`) - ImNodes-based visual node editor with `NodeEditorEngine`, `AttributeBasedNodeFactory`, physics-based layout, `NodeEditorRenderer`, `NodeEditorInputHandler` +- **ImGui.NodeEditor** (`ktsu.ImGui.NodeEditor`) - ImNodes-based visual node editor with `NodeEditorEngine`, `AttributeBasedNodeFactory`, physics-based layout, `NodeEditorRenderer`, `NodeEditorInputHandler`. ImNodes has no zoom of its own, so `NodeEditorRenderer.Zoom` supplies one and `FitToView` centres a graph and picks the zoom it fits at; the engine's positions and sizes stay at their own scale throughout, since that is the space the layout's lengths are measured in - **ImGui.Markdown** (`ktsu.ImGui.Markdown`) - CommonMark markdown renderer built on Markdig (pipe tables, task lists, autolinks), layered on `ImGui.Color` only, with no dependency on `ImGui.App`. Static `ImGuiMarkdown.Render(string, MarkdownConfig?)` parses with an internal source-keyed cache; `MarkdownDocument` parses once for hot render paths. `MarkdownConfig` exposes `FontResolver`, `OnLinkClicked`, `ImageResolver`, `HeadingScales`, `WrapWidth`, `ListIndentPixels`, `ParagraphSpacingPixels`, and `LinkColor`. Heading sizes derive from the live font size, so DPI and `ImGuiApp.GlobalScale` are respected automatically. Bold/italic use real glyphs when the host app registers named font variants via `FontResolver`, otherwise faux styling (faux-bold double-draw, faux-italic renders upright). Fenced and indented code blocks go to `MarkdownConfig.CodeBlockRenderer` (`Action?` — the fence's info string and the block text) when one is supplied, which takes over drawing *and* reserving the block's layout space; `ImGui.SyntaxHighlighting` plugs into it, and neither library references the other. v1 has no built-in code-block syntax highlighting, no async remote image download, and renders HTML as escaped text. - **SyntaxHighlighting** (`ktsu.SyntaxHighlighting`) - Renderer-agnostic tokenizing: no ImGui, no graphics API, no third-party parser, so it can move to its own repository unchanged. `SyntaxHighlighter.Highlight(code, language, tabWidth)` returns the classified `HighlightedLine`/`HighlightedToken` runs; `SyntaxHighlighter.HighlightCached` goes through a bounded cache keyed by source, language and tab width; `HighlightedCode` tokenizes once for hot render paths. Languages are data (`LanguageDefinition`: line/block comment, string, keyword, type, constant, operator, identifier and embedded-language rules) held in `LanguageRegistry`, which resolves names and aliases case-insensitively and falls back to plain text for unknown names rather than throwing. Fifteen built-ins in `BuiltInLanguages`: text, csharp, c, cpp, javascript, typescript, python, json, yaml, xml, html, css, sql, shell, lua. Two tokenizers back them — the general `CodeTokenizer`, and `MarkupTokenizer` for definitions with `IsMarkup` (XML/HTML), which classify structurally rather than by keyword. `SyntaxTheme` holds one `ktsu.Semantics.Color.Color` per `TokenKind`, with `Dark`/`Light` built in and `Background`/`Plain`/`LineNumber` left unset for the host to fill. Comments and strings are searched for an embedded language; see [Embedded languages](#embedded-languages) below. Highlighting is lexical. - **ImGui.SyntaxHighlighting** (`ktsu.ImGui.SyntaxHighlighting`) - The Dear ImGui drawing layer over `ktsu.SyntaxHighlighting`, layered on `ImGui.Color` only, with no dependency on `ImGui.App`. Static `ImGuiSyntaxHighlighting.Render(code, language, SyntaxHighlightConfig?)` tokenizes through the shared cache and draws; `Render(HighlightedCode, config)` draws pre-tokenized code, and `HighlightedCodeExtensions` re-adds `code.Render(config)` as an extension since the tokenized type itself knows nothing about ImGui. `Highlight` forwards to `SyntaxHighlighter.Highlight`. Leaving `SyntaxHighlightConfig.Theme` null picks between `SyntaxTheme.Dark`/`Light` per frame from the window background's luminance, and unset `Background`/`Plain`/`LineNumber` come from `FrameBg`/`Text`/`TextDisabled`. Code is never wrapped, and there is no scrolling, selection or editing. `ImGui.Markdown`'s `CodeBlockRenderer` plugs into this, and neither library references the other. @@ -345,9 +345,14 @@ The node graph system follows a clean separation of concerns: - **ImGui.NodeEditor**: Renders and interacts with the graph using ImNodes. Split into: - `NodeEditorEngine` - Business logic (nodes, links, physics) - `AttributeBasedNodeFactory` - Creates nodes from attribute-decorated types - - `NodeEditorRenderer` - Pure ImNodes rendering + - `NodeEditorRenderer` - Pure ImNodes rendering, and the view: `Zoom` and `FitToView` - `NodeEditorInputHandler` - Input event processing + The renderer writes each node's position into ImNodes on the frame it draws it, so the view is + made of node positions rather than of panning: a pan is undone as soon as it is read back, which + is why `FitToView` moves the nodes. Zoom is applied on the way into ImNodes and undone on the way + back out, so nothing zoomed ever reaches the engine. + ### Key Technical Details - **PID frame limiter** with auto-tuning (Coarse/Fine/Precision phases) diff --git a/ImGui.NodeEditor/NodeEditorRenderer.cs b/ImGui.NodeEditor/NodeEditorRenderer.cs index c2b0013..eff62d7 100644 --- a/ImGui.NodeEditor/NodeEditorRenderer.cs +++ b/ImGui.NodeEditor/NodeEditorRenderer.cs @@ -15,6 +15,15 @@ namespace ktsu.ImGui.NodeEditor; /// public class NodeEditorRenderer { + /// The smallest the view allows. + public const float MinZoom = 0.25f; + + /// The largest the view allows. + public const float MaxZoom = 2.0f; + + /// How much of the editor a fitted graph is asked to fill, leaving a margin around it. + private const float FitMargin = 0.9f; + private readonly Dictionary lastKnownNodePositions = []; private readonly Dictionary lastKnownNodeDimensions = []; private readonly HashSet currentlyDraggedNodes = []; @@ -24,16 +33,58 @@ public class NodeEditorRenderer private Vector2 editorToScreenBase; private bool hasEditorTransform; + // The point zoom scales about, cached from the last Render so the position and dimension + // read-backs can undo the same transform the render applied. + private Vector2 zoomAnchor; + /// /// Set of node IDs currently being dragged by the user /// public IReadOnlySet CurrentlyDraggedNodes => currentlyDraggedNodes; + /// + /// How large the graph is drawn, as a multiplier: 1 draws it at the engine's own scale. + /// + /// + /// ImNodes has no zoom of its own, so this is applied here: node positions are scaled on their + /// way into ImNodes and unscaled on the way back out, and the font is scaled to match so a node's + /// box — which ImNodes sizes from its content — grows and shrinks with the distances between + /// nodes. Scaling positions alone would only pack the nodes tighter while they stayed the same + /// size, which is not what anyone means by zooming out. + /// + /// The engine never sees the zoomed values. Its positions and dimensions stay at their own scale, + /// which is what the force-directed layout runs on: rest length, repulsion distance and overlap + /// margin are all lengths, and none of them would mean the same thing in a space that changed + /// whenever the user zoomed. + /// + /// + public float Zoom + { + get; + set => field = Math.Clamp(value, MinZoom, MaxZoom); + } = 1.0f; + /// /// Render the entire node editor /// public void Render(NodeEditorEngine engine, Vector2 editorSize) { + Ensure.NotNull(engine); + + // Scaled about the middle of the editor, so zooming keeps whatever is in the middle of the + // view in the middle of it rather than sending the graph towards a corner. Cached because the + // read-backs have to undo exactly the transform this render applied, and they are not told + // how big the editor is. + zoomAnchor = editorSize * 0.5f; + + bool scaled = !IsUnzoomed; + ScaledStyle restore = default; + if (scaled) + { + ImGui.PushFont(ImGui.GetFont(), ImGui.GetFontSize() * Zoom); + restore = ScaleImNodesStyle(Zoom); + } + ImNodes.BeginNodeEditor(); // Render all nodes @@ -54,29 +105,145 @@ public void Render(NodeEditorEngine engine, Vector2 editorSize) CacheEditorTransform(engine); ImNodes.EndNodeEditor(); + + if (scaled) + { + RestoreImNodesStyle(restore); + ImGui.PopFont(); + } } + /// + /// Scale the lengths in the node editor's style, returning the values to put back afterwards. + /// + /// The multiplier to apply. + /// The style as it was, for . + /// + /// Scaling the font alone leaves a node's padding, its corner rounding and its pin circles the + /// size they were, so a node zoomed out is not a smaller node — it is the same chrome around + /// smaller text, and its box does not shrink in proportion. Everything in the style that is a + /// length is scaled with the text so that it does. + /// + /// Grid spacing is scaled too, which is what makes the background move with the graph rather than + /// staying put underneath it. + /// + /// + private static ScaledStyle ScaleImNodesStyle(float zoom) + { + ImNodesStylePtr style = ImNodes.GetStyle(); + ScaledStyle previous = new( + style.GridSpacing, + style.NodeCornerRounding, + style.NodePadding, + style.NodeBorderThickness, + style.LinkThickness, + style.LinkHoverDistance, + style.PinCircleRadius, + style.PinQuadSideLength, + style.PinTriangleSideLength, + style.PinLineThickness, + style.PinHoverRadius, + style.PinOffset); + + style.GridSpacing = previous.GridSpacing * zoom; + style.NodeCornerRounding = previous.NodeCornerRounding * zoom; + style.NodePadding = previous.NodePadding * zoom; + style.NodeBorderThickness = previous.NodeBorderThickness * zoom; + style.LinkThickness = previous.LinkThickness * zoom; + style.LinkHoverDistance = previous.LinkHoverDistance * zoom; + style.PinCircleRadius = previous.PinCircleRadius * zoom; + style.PinQuadSideLength = previous.PinQuadSideLength * zoom; + style.PinTriangleSideLength = previous.PinTriangleSideLength * zoom; + style.PinLineThickness = previous.PinLineThickness * zoom; + style.PinHoverRadius = previous.PinHoverRadius * zoom; + style.PinOffset = previous.PinOffset * zoom; + + return previous; + } + + /// + /// Put the node editor's style back the way found it. + /// + /// The style to restore. + /// + /// Restored field by field rather than by writing the struct back whole, which would need the + /// project to allow unsafe code for the sake of one assignment. + /// + private static void RestoreImNodesStyle(ScaledStyle previous) + { + ImNodesStylePtr style = ImNodes.GetStyle(); + + style.GridSpacing = previous.GridSpacing; + style.NodeCornerRounding = previous.NodeCornerRounding; + style.NodePadding = previous.NodePadding; + style.NodeBorderThickness = previous.NodeBorderThickness; + style.LinkThickness = previous.LinkThickness; + style.LinkHoverDistance = previous.LinkHoverDistance; + style.PinCircleRadius = previous.PinCircleRadius; + style.PinQuadSideLength = previous.PinQuadSideLength; + style.PinTriangleSideLength = previous.PinTriangleSideLength; + style.PinLineThickness = previous.PinLineThickness; + style.PinHoverRadius = previous.PinHoverRadius; + style.PinOffset = previous.PinOffset; + } + + /// + /// The lengths in the node editor's style that a zoom scales, as they were before it did. + /// + private readonly record struct ScaledStyle( + float GridSpacing, + float NodeCornerRounding, + Vector2 NodePadding, + float NodeBorderThickness, + float LinkThickness, + float LinkHoverDistance, + float PinCircleRadius, + float PinQuadSideLength, + float PinTriangleSideLength, + float PinLineThickness, + float PinHoverRadius, + float PinOffset); + + /// + /// Convert a position in the engine's space to the one it is drawn at. + /// + private Vector2 ToView(Vector2 position) => ((position - zoomAnchor) * Zoom) + zoomAnchor; + + /// + /// Convert a position read back out of ImNodes to the engine's space. + /// + private Vector2 ToEngine(Vector2 position) => ((position - zoomAnchor) / Zoom) + zoomAnchor; + + /// + /// Whether the view is at the engine's own scale, where the transform is the identity. + /// + private bool IsUnzoomed => Math.Abs(Zoom - 1.0f) < 0.0001f; + /// /// Render a single node /// private void RenderNode(Node node) { // Apply engine position to ImNodes BEFORE rendering the node - // This ensures physics-calculated positions are reflected immediately + // This ensures physics-calculated positions are reflected immediately. + // Held in the space ImNodes works in, so a zoom change moves every node here and the + // read-back can tell a user's drag apart from what this wrote. + Vector2 viewPos = ToView(node.Position); + if (lastKnownNodePositions.TryGetValue(node.Id, out Vector2 lastPos)) { // Check if engine position differs from what we last set in ImNodes - if (Vector2.Distance(lastPos, node.Position) > 0.1f) + if (Vector2.Distance(lastPos, viewPos) > 0.1f) { - ImNodes.SetNodeEditorSpacePos(node.Id, node.Position); - lastKnownNodePositions[node.Id] = node.Position; + ImNodes.SetNodeEditorSpacePos(node.Id, viewPos); + lastKnownNodePositions[node.Id] = viewPos; } } else { // First render - set initial position - ImNodes.SetNodeEditorSpacePos(node.Id, node.Position); - lastKnownNodePositions[node.Id] = node.Position; + ImNodes.SetNodeEditorSpacePos(node.Id, viewPos); + lastKnownNodePositions[node.Id] = viewPos; } ImNodes.BeginNode(node.Id); @@ -136,6 +303,80 @@ private void RenderNode(Node node) ImNodes.EndNode(); } + /// + /// Bring the whole graph into view: centred in the editor, and zoomed out far enough to fit. + /// + /// The engine holding the nodes. + /// The area the graph is drawn in. + /// True if there was anything to bring into view. + /// + /// Centring moves the nodes rather than panning the editor. It has to: writes + /// every node's position into ImNodes on the frame it is drawn, so a pan is undone as soon as it + /// is read back — the positions are the only thing that decides where a node appears. The whole + /// arrangement is translated by one offset, so the shape a layout settled into is preserved rather + /// than being disturbed by the act of looking at it. + /// + /// The zoom is then whatever makes the arrangement fit, with a margin so nothing sits against an + /// edge, and never above 1: a graph small enough to be magnified is shown at its own size, since + /// magnifying it is not what "fit" means to someone who asked to see all of it. A graph too big + /// even at is shown as small as the view goes, which is the most of it there + /// is to be had. + /// + /// + /// A node's size is measured when it is drawn, so a graph fitted before its first frame is fitted + /// against sizes that are still zero. Callers that fit on opening should fit again once the + /// dimensions have arrived. + /// + /// + public bool FitToView(NodeEditorEngine engine, Vector2 editorSize) + { + Ensure.NotNull(engine); + + if (engine.Nodes.Count == 0) + { + return false; + } + + // Measured across each node's whole extent rather than its top-left corner, so a wide node on + // one edge does not pull the arrangement off centre by half its width. + Vector2 lowest = new(float.MaxValue, float.MaxValue); + Vector2 highest = new(float.MinValue, float.MinValue); + + foreach (Node node in engine.Nodes) + { + lowest = Vector2.Min(lowest, node.Position); + highest = Vector2.Max(highest, node.Position + node.Dimensions); + } + + Vector2 centre = editorSize * 0.5f; + Vector2 offset = centre - ((lowest + highest) * 0.5f); + + foreach (Node node in engine.Nodes.ToArray()) + { + engine.UpdateNodePosition(node.Id, node.Position + offset); + } + + Zoom = FittingZoom(highest - lowest, editorSize); + return true; + } + + /// + /// Work out the largest zoom an arrangement of the given extent still fits at. + /// + /// How much room the arrangement takes at the engine's scale. + /// The room there is to show it in. + /// The zoom to use, within the range the view allows. + private static float FittingZoom(Vector2 extent, Vector2 editorSize) + { + if (extent.X <= 0 || extent.Y <= 0 || editorSize.X <= 0 || editorSize.Y <= 0) + { + return 1.0f; + } + + float fitting = Math.Min(editorSize.X / extent.X, editorSize.Y / extent.Y) * FitMargin; + return Math.Clamp(Math.Min(fitting, 1.0f), MinZoom, MaxZoom); + } + /// /// Check for nodes that have moved and return their new positions /// @@ -154,11 +395,12 @@ public Dictionary GetNodePositionUpdates(NodeEditorEngine engine) Vector2 currentImNodesPos = ImNodes.GetNodeEditorSpacePos(node.Id); - // Only report a change if the ImNodes position differs from the ENGINE position - // This means the user dragged the node (ImNodes changed independently of us) - if (Vector2.Distance(node.Position, currentImNodesPos) > 0.1f) + // Only report a change if the ImNodes position differs from where this renderer drew the + // node. That means the user dragged it — ImNodes changed independently of us — and the + // position is reported back in the engine's space, not the one it was drawn in. + if (Vector2.Distance(ToView(node.Position), currentImNodesPos) > 0.1f) { - updates[node.Id] = currentImNodesPos; + updates[node.Id] = ToEngine(currentImNodesPos); lastKnownNodePositions[node.Id] = currentImNodesPos; currentlyDraggedNodes.Add(node.Id); } @@ -170,6 +412,19 @@ public Dictionary GetNodePositionUpdates(NodeEditorEngine engine) /// /// Check for nodes that have been resized and return their new dimensions /// + /// + /// A size measured while the view is zoomed is not the node's size: the text, the padding and the + /// pin circles scale with the zoom, but ImGui's own spacing inside the node does not, and the + /// font size is rounded to whole pixels. Dividing such a measurement by the zoom gives a value + /// that depends on how far the user happened to be zoomed out, and handing that to a layout that + /// keeps node boxes apart would re-space the graph every time the view changed. + /// + /// So a node that has already been measured keeps the size it was measured at, and only a node + /// that has never been measured takes a zoomed measurement — an approximate size being better + /// than none for a node created while zoomed out. It is corrected the next time the view is at + /// its own scale. + /// + /// [SuppressMessage("Major Code Smell", "S3267:Loops should be simplified with \"LINQ\" expressions.", Justification = "Explicit loop is clearer; the loop contains a continue and dictionary mutation that would not simplify cleanly.")] public Dictionary GetNodeDimensionUpdates(NodeEditorEngine engine) { @@ -190,9 +445,9 @@ public Dictionary GetNodeDimensionUpdates(NodeEditorEngine engine) { // Initialize with current dimensions for new nodes lastKnownNodeDimensions[node.Id] = currentImNodesDims; - updates[node.Id] = currentImNodesDims; + updates[node.Id] = currentImNodesDims / Zoom; } - else if (Vector2.Distance(lastDims, currentImNodesDims) > 0.1f) + else if (Vector2.Distance(lastDims, currentImNodesDims) > 0.1f && IsUnzoomed) { updates[node.Id] = currentImNodesDims; lastKnownNodeDimensions[node.Id] = currentImNodesDims; @@ -264,7 +519,7 @@ public void RenderDebugOverlays(NodeEditorEngine engine, Vector2 editorAreaPos, /// Convert an editor-space position to screen-space using the cached transform /// private Vector2 EditorToScreen(Vector2 editorPos) => - editorToScreenBase + editorPos; + editorToScreenBase + ToView(editorPos); private void RenderOrigin(ImDrawListPtr drawList, NodeEditorEngine engine) { diff --git a/tests/ImGui.NodeEditor.Tests/ZoomTests.cs b/tests/ImGui.NodeEditor.Tests/ZoomTests.cs new file mode 100644 index 0000000..e915212 --- /dev/null +++ b/tests/ImGui.NodeEditor.Tests/ZoomTests.cs @@ -0,0 +1,261 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.NodeEditor.Tests; + +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +using Hexa.NET.ImGui; + +using ktsu.ImGui.App; +using ktsu.ImGui.App.Testing; +using ktsu.ImGui.NodeEditor; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Drives through real frames, because that is the only place +/// it can be judged: ImNodes has no zoom, so this one is made of what the renderer writes into it +/// and what it reads back, and neither is visible without drawing. +/// +/// +/// The invariant these are built around is that the engine never sees the zoom. Its positions and +/// sizes are what the force-directed layout runs on, and a space that changed whenever the user +/// zoomed would change what the layout's rest length, repulsion distance and overlap margin mean. +/// A transform applied on the way in and undone on the way out is easy to get subtly wrong in a way +/// that only shows up after many frames, so these run the frames. +/// +[TestClass] +public sealed class ZoomTests +{ + private static readonly HarnessOptions Viewport = new() { Width = 900, Height = 700 }; + + private readonly NodeEditorEngine engine = new(); + private readonly NodeEditorRenderer renderer = new(); + + private ImGuiAppHarness harness = null!; + + [TestCleanup] + public void TearDown() => harness?.Dispose(); + + /// Starts a harness whose whole content is the graph the engine holds. + private void Start() + { + harness = ImGuiAppHarness.Start( + new ImGuiAppConfig + { + Title = nameof(ZoomTests), + OnRender = _ => DrawGraph(), + SaveIniSettings = false, + }, + Viewport); + + harness.Step(3); + } + + private void DrawGraph() + { + renderer.Render(engine, ImGui.GetContentRegionAvail()); + + foreach (KeyValuePair update in renderer.GetNodePositionUpdates(engine)) + { + engine.UpdateNodePosition(update.Key, update.Value); + } + + foreach (KeyValuePair update in renderer.GetNodeDimensionUpdates(engine)) + { + engine.UpdateNodeDimensions(update.Key, update.Value); + } + } + + /// Builds a graph of three linked nodes spread across the editor. + private void BuildGraph() + { + Node source = engine.CreateNode(new Vector2(150, 150), "Source", [], ["Value"]); + Node middle = engine.CreateNode(new Vector2(400, 300), "Middle", ["In"], ["Out"]); + Node target = engine.CreateNode(new Vector2(650, 450), "Target", ["In"], []); + + engine.TryCreateLink(source.OutputPins[0].Id, middle.InputPins[0].Id); + engine.TryCreateLink(middle.OutputPins[0].Id, target.InputPins[0].Id); + } + + private Vector2[] Positions() => [.. engine.Nodes.Select(node => node.Position)]; + + private Vector2[] Dimensions() => [.. engine.Nodes.Select(node => node.Dimensions)]; + + /// + /// Tests that zooming leaves the engine's positions exactly as they were, however many frames go + /// by, which is what keeps the simulation in a space that does not move under it. + /// + [TestMethod] + public void Zooming_LeavesTheEnginesPositionsAlone() + { + BuildGraph(); + Start(); + + Vector2[] before = Positions(); + + renderer.Zoom = 0.5f; + harness.Step(30); + + Vector2[] after = Positions(); + for (int i = 0; i < before.Length; i++) + { + Assert.AreEqual(before[i].X, after[i].X, 0.5f, $"node {i} moved in the engine's space while only the view changed"); + Assert.AreEqual(before[i].Y, after[i].Y, 0.5f, $"node {i} moved in the engine's space while only the view changed"); + } + } + + /// + /// Tests that a node's size in the engine's space does not drift as the view is zoomed, which is + /// the failure a transform undone once per frame instead of once per application invites. + /// + /// + /// A size only arrives from ImNodes when it has measured a new one, so a renderer that unscaled + /// sizes unconditionally would unscale the same value again every frame: at a zoom of one half + /// the nodes double every frame, and a graph left alone for a second is thousands of times its + /// real size — enough geometry, in practice, to trip ImGui's assertion on 16-bit vertex indices. + /// + /// A measurement taken while zoomed is not the node's size either, however it is scaled back: + /// the font size is rounded to whole pixels and ImGui's own spacing inside the node does not + /// scale, so what comes back depends on how far out the user was. A node already measured + /// therefore keeps its size, and this asserts it exactly rather than within a tolerance. + /// + /// + [TestMethod] + public void Zooming_LeavesTheEnginesDimensionsAlone() + { + BuildGraph(); + Start(); + harness.Step(10); + + Vector2[] before = Dimensions(); + Assert.IsTrue(before.All(size => size.X > 0f), "the nodes should have been measured"); + + renderer.Zoom = 0.5f; + harness.Step(60); + + CollectionAssert.AreEqual(before, Dimensions(), "a node's size in the engine changed while only the view did"); + } + + /// + /// Tests that zooming out and back in leaves the graph exactly where it started, so looking at + /// something is not an edit to it. + /// + [TestMethod] + public void ZoomingOutAndBackIn_LeavesTheGraphWhereItWas() + { + BuildGraph(); + Start(); + harness.Step(10); + + Vector2[] before = Positions(); + + renderer.Zoom = 0.25f; + harness.Step(20); + renderer.Zoom = 2f; + harness.Step(20); + renderer.Zoom = 1f; + harness.Step(20); + + Vector2[] after = Positions(); + for (int i = 0; i < before.Length; i++) + { + Assert.AreEqual(before[i].X, after[i].X, 0.5f, $"node {i} did not come back to where it was"); + Assert.AreEqual(before[i].Y, after[i].Y, 0.5f, $"node {i} did not come back to where it was"); + } + } + + /// + /// Tests that the zoom stays inside the range the renderer allows, however it is set. + /// + [TestMethod] + public void Zoom_IsHeldWithinItsRange() + { + renderer.Zoom = 50f; + Assert.AreEqual(NodeEditorRenderer.MaxZoom, renderer.Zoom); + + renderer.Zoom = -1f; + Assert.AreEqual(NodeEditorRenderer.MinZoom, renderer.Zoom); + } + + /// + /// Tests that fitting a graph too big for the editor centres it and zooms out until it fits. + /// + [TestMethod] + public void FitToView_CentresAndZoomsOutUntilTheGraphFits() + { + engine.CreateNode(new Vector2(0, 0), "First", [], ["Out"]); + engine.CreateNode(new Vector2(2000, 1200), "Second", ["In"], []); + Start(); + harness.Step(10); + + Vector2 editorSize = new(600, 400); + Assert.IsTrue(renderer.FitToView(engine, editorSize)); + + Vector2 lowest = new(float.MaxValue, float.MaxValue); + Vector2 highest = new(float.MinValue, float.MinValue); + foreach (Node node in engine.Nodes) + { + lowest = Vector2.Min(lowest, node.Position); + highest = Vector2.Max(highest, node.Position + node.Dimensions); + } + + Vector2 centre = (lowest + highest) * 0.5f; + Assert.AreEqual(300f, centre.X, 0.01f, "the arrangement should be centred in the editor"); + Assert.AreEqual(200f, centre.Y, 0.01f, "the arrangement should be centred in the editor"); + + Vector2 extent = highest - lowest; + Assert.IsTrue(renderer.Zoom < 1f, $"a graph {extent.X} wide should not fit a 600 editor at {renderer.Zoom}"); + Assert.IsTrue(extent.X * renderer.Zoom <= editorSize.X, $"the graph still overflows at {renderer.Zoom}"); + Assert.IsTrue(extent.Y * renderer.Zoom <= editorSize.Y, $"the graph still overflows at {renderer.Zoom}"); + } + + /// + /// Tests that fitting a graph the editor already has room for leaves it at its own size, since + /// magnifying it is not what "fit" means to someone who asked to see all of it. + /// + [TestMethod] + public void FitToView_DoesNotMagnifyAGraphThatAlreadyFits() + { + engine.CreateNode(new Vector2(0, 0), "First", [], ["Out"]); + engine.CreateNode(new Vector2(40, 30), "Second", ["In"], []); + Start(); + harness.Step(10); + + renderer.Zoom = 0.5f; + Assert.IsTrue(renderer.FitToView(engine, new Vector2(2000, 1600))); + + Assert.AreEqual(1f, renderer.Zoom, 0.0001f); + } + + /// + /// Tests that fitting an empty graph reports that there was nothing to fit rather than choosing + /// a zoom from no extent at all. + /// + [TestMethod] + public void FitToView_ReportsAnEmptyGraph() + { + Start(); + + Assert.IsFalse(renderer.FitToView(engine, new Vector2(600, 400))); + Assert.AreEqual(1f, renderer.Zoom, 0.0001f); + } + + /// + /// Tests that drawing a zoomed graph is not reported by ImGui as misuse, which is the check the + /// sibling rendering tests are built around. + /// + [TestMethod] + public void AZoomedGraphDrawsWithoutError() + { + BuildGraph(); + Start(); + + renderer.Zoom = 0.4f; + harness.Step(10); + + Assert.AreEqual(0, ImGui.GetCurrentContext().ErrorCountCurrentFrame, "ImGui reported the zoomed drawing as misuse"); + } +}