diff --git a/CLAUDE.md b/CLAUDE.md index 0dbba7cd..1d4671f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De - **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 - **ForceDirectedLayout** (`ktsu.ForceDirectedLayout`) - Renderer-agnostic graph layout simulation, with no UI dependency and no runtime package dependencies. Bodies repel across the clear space between their bounding boxes (not between their centres — see [Layout benchmarking](#layout-benchmarking)), edges pull like springs between the pins they actually attach at, gravity holds the graph together, edges are pulled towards horizontal, an overlap pass separates any boxes left drawn over one another, and a recentring pass slides the whole arrangement so its drawn box sits on the world origin (see [Placement is not cohesion](#placement-is-not-cohesion)). Three surfaces over one `LayoutCore`: a generic facade over your own types, an id-based `ForceLayout` for bulk POD submission, and the flat core. Also published as a Native AOT shared library with a C ABI. `ImGui.NodeEditor` is one consumer. -- **ImGui.NodeEditor** (`ktsu.ImGui.NodeEditor`) - ImNodes-based visual node editor with `NodeEditorEngine`, `AttributeBasedNodeFactory`, physics-based layout, `NodeEditorRenderer`, `NodeEditorInputHandler`. `PhysicsSettingsPanel.Draw(ref PhysicsSettings)` draws every layout setting grouped by force and captioned, and `DrawDiagnostics(engine)` the live energy and settled state, so a consuming application gets the whole tuning surface rather than reimplementing a subset of it. 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. Hovering is answered by the renderer: `HighlightLinksOnNodeHover` (on) colours the links meeting the hovered node, `HighlightDownstreamOnNodeHover` (off) also colours everything that node's value reaches, and `DrawHoveredLinkOnTop` (on) redraws the hovered link over the nodes ImNodes drew on top of it. See [Hover highlighting](#hover-highlighting) below. How many links a pin accepts is the pin's own business: `Pin.AllowsMultipleConnections` defaults to many for an output and one for an input, `[InputPin(AllowMultipleConnections = true)]` / `[OutputPin(AllowMultipleConnections = false)]` override it through the factory, and `NodeEditorEngine.SetPinAllowsMultipleConnections` sets it directly. `GetOutgoingLinks`, `GetIncomingLinks`, `GetDownstream` and `GetUpstream` walk the graph. A node's parameters are editable, not only its pins' connections: `Pin.DataType` carries the .NET type a pin was declared with (null for an untyped pin, which is what the plain-name `CreateNode` overloads still produce), and `PinValueStore` holds a value per pin id, seeded from a `PinSpec.DefaultValue` when `NodeEditorEngine.CreateNodeFromSpecs(Vector2, string, IReadOnlyList, IReadOnlyList)` creates the node (`PinSpec` is `(Name, DataType, DefaultValue, AllowMultipleConnections)`). That method is deliberately not a third `CreateNode` overload: a `CreateNode` taking `IReadOnlyList` is ambiguous against the existing `List` overload for a `[]` collection-expression argument, which broke the build. `PinValueKinds.Classify(Type?)` is the single classifier both editing surfaces consult (`Boolean`, `Int32`, `Single`, `Double`, `String`, `Vector2`, `Vector3`, `Enum`, or `Unsupported`; `long` is deliberately `Unsupported`, since Dear ImGui has no `InputLong`), so a type has an editor on both surfaces or neither, never one and not the other. The two surfaces: `NodeEditorRenderer` draws one inline, on an unconnected input pin's own row, gated on `DrawInlinePinEditors` (on by default) and `InlineEditorWidth`; and `NodeInspectorPanel.Draw(engine, nodeId)` draws every input pin of one node as a `ktsu.ImGui.Widgets.PropertyGrid`, disabled where a pin is connected or unsupported rather than omitted, so the panel never shows less than the node has. The renderer also reports `SelectedNodeIds`, read back from ImNodes the same way hover state is, which is what a host hands to `NodeInspectorPanel` for "inspect whatever is selected." A parameter has exactly one home, and `GetPinValue`/`SetPinValue`/`ResetPinValue` reach it whichever that is. `AttributeBasedNodeFactory.CreateNode` constructs the declared type once per node (recorded as a `NodeBinding(NodeId, Definition, Instance)`, read back with `GetBinding`, `GetNodeDefinition(int)` and `TryGetNodeInstance`) and registers a `PinValueAccessor(Func Get, Func Set)` for each input pin backed by a writable property or field, so for those pins **the instance is the value** and an inline editor, an inspector row and `PinDefinition.GetValue` cannot disagree. Everything else falls back to `PinValueStore`: a method node (whose receiver arrives over its `Instance` input pin), a type with no parameterless constructor, a constructor or method parameter pin, an output pin, and any node built through the plain-name `CreateNode` overloads. `SetPinValue` type-checks against the pin's `DataType` first either way, so a refused value reaches neither home; `ResetPinValue` writes the seeded default back through the accessor; and `RemoveNode`/`Clear` unbind. See [Where a value lives](#editing-node-parameters) in the library's README. The engine learns nothing about reflection, `PinDefinition` or `ktsu.NodeGraph` — it holds delegates, and `BindPinValue`/`UnbindPinValue` are public, so a host with its own model can bind its own. One shared coercion (`PinValueStore.Coerce(Type?, object?)`) brings a declared default in line with the pin's type on both paths, so `[InputPin("X", DefaultValue = 50)]` on a `double` means `50.0` wherever it is read; a default that cannot convert at all leaves the member's own initializer standing. +- **ImGui.NodeEditor** (`ktsu.ImGui.NodeEditor`) - ImNodes-based visual node editor with `NodeEditorEngine`, `AttributeBasedNodeFactory`, physics-based layout, `NodeEditorRenderer`, `NodeEditorInputHandler`. `PhysicsSettingsPanel.Draw(ref PhysicsSettings)` draws every layout setting grouped by force and captioned, and `DrawDiagnostics(engine)` the live energy and settled state, so a consuming application gets the whole tuning surface rather than reimplementing a subset of it. ImNodes has no zoom of its own, so `NodeEditorRenderer.Zoom` supplies one and `FitToView` centres a graph and picks the zoom it fits at; `NodeEditorHistory` makes every edit undoable over `ktsu.UndoRedo`, `NodeEditorInputHandler` can take its keys from a `ktsu.Keybinding` keymap (`NodeEditorCommands`), `NodeEditorRenderer.SnapToGrid` snaps dragged nodes to the drawn grid, and `CommentBox`es label regions drawn behind the nodes and carry their contents when dragged — see [Undo, snapping and comment boxes](#undo-snapping-and-comment-boxes); the engine's positions and sizes stay at their own scale throughout, since that is the space the layout's lengths are measured in. Hovering is answered by the renderer: `HighlightLinksOnNodeHover` (on) colours the links meeting the hovered node, `HighlightDownstreamOnNodeHover` (off) also colours everything that node's value reaches, and `DrawHoveredLinkOnTop` (on) redraws the hovered link over the nodes ImNodes drew on top of it. See [Hover highlighting](#hover-highlighting) below. How many links a pin accepts is the pin's own business: `Pin.AllowsMultipleConnections` defaults to many for an output and one for an input, `[InputPin(AllowMultipleConnections = true)]` / `[OutputPin(AllowMultipleConnections = false)]` override it through the factory, and `NodeEditorEngine.SetPinAllowsMultipleConnections` sets it directly. `GetOutgoingLinks`, `GetIncomingLinks`, `GetDownstream` and `GetUpstream` walk the graph. A node's parameters are editable, not only its pins' connections: `Pin.DataType` carries the .NET type a pin was declared with (null for an untyped pin, which is what the plain-name `CreateNode` overloads still produce), and `PinValueStore` holds a value per pin id, seeded from a `PinSpec.DefaultValue` when `NodeEditorEngine.CreateNodeFromSpecs(Vector2, string, IReadOnlyList, IReadOnlyList)` creates the node (`PinSpec` is `(Name, DataType, DefaultValue, AllowMultipleConnections)`). That method is deliberately not a third `CreateNode` overload: a `CreateNode` taking `IReadOnlyList` is ambiguous against the existing `List` overload for a `[]` collection-expression argument, which broke the build. `PinValueKinds.Classify(Type?)` is the single classifier both editing surfaces consult (`Boolean`, `Int32`, `Single`, `Double`, `String`, `Vector2`, `Vector3`, `Enum`, or `Unsupported`; `long` is deliberately `Unsupported`, since Dear ImGui has no `InputLong`), so a type has an editor on both surfaces or neither, never one and not the other. The two surfaces: `NodeEditorRenderer` draws one inline, on an unconnected input pin's own row, gated on `DrawInlinePinEditors` (on by default) and `InlineEditorWidth`; and `NodeInspectorPanel.Draw(engine, nodeId)` draws every input pin of one node as a `ktsu.ImGui.Widgets.PropertyGrid`, disabled where a pin is connected or unsupported rather than omitted, so the panel never shows less than the node has. The renderer also reports `SelectedNodeIds`, read back from ImNodes the same way hover state is, which is what a host hands to `NodeInspectorPanel` for "inspect whatever is selected." A parameter has exactly one home, and `GetPinValue`/`SetPinValue`/`ResetPinValue` reach it whichever that is. `AttributeBasedNodeFactory.CreateNode` constructs the declared type once per node (recorded as a `NodeBinding(NodeId, Definition, Instance)`, read back with `GetBinding`, `GetNodeDefinition(int)` and `TryGetNodeInstance`) and registers a `PinValueAccessor(Func Get, Func Set)` for each input pin backed by a writable property or field, so for those pins **the instance is the value** and an inline editor, an inspector row and `PinDefinition.GetValue` cannot disagree. Everything else falls back to `PinValueStore`: a method node (whose receiver arrives over its `Instance` input pin), a type with no parameterless constructor, a constructor or method parameter pin, an output pin, and any node built through the plain-name `CreateNode` overloads. `SetPinValue` type-checks against the pin's `DataType` first either way, so a refused value reaches neither home; `ResetPinValue` writes the seeded default back through the accessor; and `RemoveNode`/`Clear` unbind. See [Where a value lives](#editing-node-parameters) in the library's README. The engine learns nothing about reflection, `PinDefinition` or `ktsu.NodeGraph` — it holds delegates, and `BindPinValue`/`UnbindPinValue` are public, so a host with its own model can bind its own. One shared coercion (`PinValueStore.Coerce(Type?, object?)`) brings a declared default in line with the pin's type on both paths, so `[InputPin("X", DefaultValue = 50)]` on a `double` means `50.0` wherever it is read; a default that cannot convert at all leaves the member's own initializer standing. - **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. @@ -61,6 +61,9 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De engine and factory ones need no context; `NodeRenderingTests`, `ZoomTests` and `HoverHighlightTests` drive real frames through `ImGuiAppHarness`, since zoom and hover are made of what the renderer writes into ImNodes and reads back out, and neither exists without drawing. + `NodeEditorHistoryTests` and `CommentBoxTests` cover undo/redo and comment boxes against the + engine alone; `NodeEditorGestureTests` drives drags, snapping, the undo keys, a keymap and the + comment box gestures through real frames. `PinValueBindingTests` covers the seam between the two homes a parameter can have, and `InlinePinEditorKindTests`/`NodeInspectorPanelKindTests` drive one test per `PinValueKind` on each editing surface, which is what turns "a type has an editor on both surfaces or on neither" into @@ -120,7 +123,10 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De - `ImGui.Styler/ScopedColor.cs` - RAII-pattern color styling (`ImColor`/`Color`/`Srgb` overloads; `ScopedTextColor` too) - `NodeGraph/NodeAttribute.cs` - Core node attributes - `NodeGraph/PinAttribute.cs` - Pin declaration attributes -- `ImGui.NodeEditor/NodeEditorEngine.cs` - Node graph business logic +- `ImGui.NodeEditor/NodeEditorEngine.cs` - Node graph business logic (`NodeEditorEngine.CommentBoxes.cs` holds comment boxes, `NodeEditorEngine.Snapshots.cs` the internal capture/restore primitives the history uses) +- `ImGui.NodeEditor/NodeEditorHistory.cs` - Undo/redo over `ktsu.UndoRedo`; see [Undo, snapping and comment boxes](#undo-snapping-and-comment-boxes) +- `ImGui.NodeEditor/NodeEditorRenderer.CommentBoxes.cs` - Comment box drawing and gestures +- `ImGui.NodeEditor/NodeEditorCommands.cs` - The keyboard commands as `ktsu.Keybinding` commands, and the chord-to-ImGui matcher ### Dependencies @@ -143,6 +149,8 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De - **Hexa.NET.ImGui.Widgets.Extras** (1.0.9) - Curve editor, bezier and text editor extras. Used by exactly two call sites, both under `ImGui.Widgets/Editors`: `CurveField.cs` (`ImGuiCurveEditor.Curve`) and `BezierEditor.cs` (`ImGuiBezierWidget.Bezier`). Pulls `Hexa.NET.Math` and `Microsoft.CodeAnalysis.CSharp.Scripting` into the dependency graph, and the latter brings the Roslyn compiler and scripting host with it — 5 packages, ~107 MB unpacked, which every consumer of `ktsu.ImGui.Widgets` restores and publishes whether or not it draws a curve. Roslyn is reachable only from Extras' `CSharpSyntaxHighlight`, a TextEditor type nothing here touches, so this is a restore and publish cost rather than a runtime one. Do not expect `ExcludeAssets`/`PrivateAssets` to remove it: the Roslyn dependency is declared in Extras' own nuspec, and a package cannot prune its dependency's dependencies for its consumers. Dropping the weight means splitting the two editors into an opt-in package or reimplementing them — tracked in #384. `ktsu.ImGui.NodeEditor` now carries this same weight at one remove: its inspector panel needs `PropertyGrid`, so `ImGui.NodeEditor.csproj` references `ImGui.Widgets`, which drags in `ImGui.Styler`, `ImGui.Color`, `ktsu.ThemeProvider`(+`.ImGui`) and this whole Extras/Roslyn graph behind it. Every consumer of `ktsu.ImGui.NodeEditor` restores and publishes it now too, whether or not they ever open the inspector. - **ktsu.Invoker** (1.1.2) - Delegate invocation utilities - **ktsu.ScopedAction** (1.1.6) - RAII-pattern scoped actions +- **ktsu.UndoRedo** (2.0.3) - Undo/redo stack backing `NodeEditorHistory` in `ImGui.NodeEditor` +- **ktsu.Keybinding** (2.0.3) - Keymap `NodeEditorInputHandler` can read its commands from (`NodeEditorCommands`) - **Polyfill** (9.7.7) - Backport newer .NET APIs - **Markdig** - CommonMark markdown parser backing `ImGui.Markdown` @@ -540,6 +548,44 @@ The node graph system follows a clean separation of concerns: 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. +### Undo, snapping and comment boxes + +`NodeEditorHistory` (issue #467) records onto a `ktsu.UndoRedo` `IUndoRedoService`. A step is the +*difference* a change made — captured before and after `Record(...)` runs it — never a copy of the +graph, so undoing a deletion restores that node and leaves every node the layout moved since where +it is. Things to know before changing it: + +- **Restoring is by original id.** `NodeEditorEngine.RestoreNode`/`RestoreLink` (internal, in + `NodeEditorEngine.Snapshots.cs`) put a node back under its old id with its pins, values, seeded + defaults, bound accessors and measured offsets, and raise the id counters past it. Anything keyed + by id — factory bindings, a host's selection — depends on that. `AttributeBasedNodeFactory.RestoreBinding` + reattaches the instance, which is why the history takes the factory. +- **`Clear` reissues ids.** Outside a recording it empties the history. Inside one (a "reset" + button) the diff is read as "everything before was removed, everything after was added", because + id 1 on both sides may name two unrelated nodes. +- **Pin value edits merge by gesture, not by time.** `SetPinValue(int, object?, long?)` carries an + edit gesture; `PinEditGestures` issues a new one each time the editing widget is *activated*, and + must be called on every frame the widget draws, not only on change — a checkbox activates on + press and changes on release. Enum picks carry no gesture. +- **The renderer forgets nodes it did not draw.** ImNodes drops a node it was not given for a frame, + so a node restored by an undo is new to ImNodes; remembering its last drawn position would skip + the position write and read ImNodes' default back as a drag. `ForgetNodesNotRendered` is that fix. +- **A drag is a selected node moving while the left button is down**, recorded once on release + (`CompletedNodeMoves`). A node moving any other way has been panned. + +Grid snapping (issue #468) is ImNodes' own `GridSnapping` style flag, pushed for the duration of +`Render`, so the lattice is the grid on screen and a selection keeps its shape. `SnapPositionToGrid` +reproduces the same lattice (grid space = editor space less panning, spacing scaled by zoom) for +comment boxes and `SnapNodesToGrid`. + +Comment boxes are drawn into ImNodes' background channel — right after `BeginNodeEditor`, before +the first node — which puts them over the grid and under every node and link; `ACommentBox_IsDrawnUnderTheNodes` +pins this with pixels. Their gestures are ImGui `InvisibleButton`s, which also stop ImNodes starting +a box selection (it checks `IsAnyItemHovered`); they are not submitted while ImNodes reports a +hovered node or link, so a node overlapping a title bar keeps its own drag. Containment is geometric +and decided when a drag starts. Screen position is `canvasOrigin + ToView(p)`, where `canvasOrigin` +is the cursor right after `BeginNodeEditor`. + ### Hover highlighting ImNodes only answers what the pointer is over once the editor has ended, so `NodeEditorRenderer` diff --git a/Directory.Packages.props b/Directory.Packages.props index fc51d725..b8fc512b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,6 +22,8 @@ + + diff --git a/ImGui.NodeEditor/AttributeBasedNodeFactory.cs b/ImGui.NodeEditor/AttributeBasedNodeFactory.cs index 726e9590..210967a2 100644 --- a/ImGui.NodeEditor/AttributeBasedNodeFactory.cs +++ b/ImGui.NodeEditor/AttributeBasedNodeFactory.cs @@ -42,6 +42,30 @@ public AttributeBasedNodeFactory(NodeEditorEngine engine) private void OnCleared(object? sender, EventArgs e) => bindings.Clear(); + /// + /// Reattach a binding to a node that was removed and has been put back. + /// + /// The binding the node had, as reported it before the removal. + /// True if the engine holds a node with the binding's id and the binding was attached. + /// + /// Removing a node drops its binding, because an id that names no node must not keep an instance + /// alive. undoes a removal by restoring the node under the same + /// id, and this is how the instance comes back with it — the same instance, so its pin accessors, + /// which the engine restores alongside the node, still read and write the object this binding + /// names. + /// + public bool RestoreBinding(NodeBinding binding) + { + Ensure.NotNull(binding); + if (!engine.Nodes.Any(n => n.Id == binding.NodeId)) + { + return false; + } + + bindings[binding.NodeId] = binding; + return true; + } + /// /// Registers a type as a node definition by scanning its attributes. /// diff --git a/ImGui.NodeEditor/DESCRIPTION.md b/ImGui.NodeEditor/DESCRIPTION.md index 53797c28..2909706d 100644 --- a/ImGui.NodeEditor/DESCRIPTION.md +++ b/ImGui.NodeEditor/DESCRIPTION.md @@ -1 +1 @@ -A visual node editor for Dear ImGui built on ImNodes, with the graph kept away from the drawing: the engine owns nodes, links and layout and knows nothing about ImGui, while the renderer draws what it holds and the input handler turns interactions into requests the engine can accept or refuse. Nodes can be declared as ordinary types decorated with ktsu.NodeGraph attributes and instantiated by reflection, with connections checked against the rules that metadata declares. Optional force-directed layout settles the graph, and the view zooms from quarter to double scale. +A visual node editor for Dear ImGui built on ImNodes, with the graph kept away from the drawing: the engine owns nodes, links and layout and knows nothing about ImGui, while the renderer draws what it holds and the input handler turns interactions into requests the engine can accept or refuse. Nodes can be declared as ordinary types decorated with ktsu.NodeGraph attributes and instantiated by reflection, with connections checked against the rules that metadata declares. Optional force-directed layout settles the graph, and the view zooms from quarter to double scale. Every edit can be undone through a ktsu.UndoRedo history, keyboard commands follow a ktsu.Keybinding keymap, dragged nodes can snap to the grid, and labelled comment boxes group regions of the graph and carry their nodes when moved. diff --git a/ImGui.NodeEditor/DomainModels.cs b/ImGui.NodeEditor/DomainModels.cs index 003deeb6..6897f902 100644 --- a/ImGui.NodeEditor/DomainModels.cs +++ b/ImGui.NodeEditor/DomainModels.cs @@ -122,3 +122,72 @@ public enum PinDirection Output } +/// +/// Says which pin's value changed, from what, to what, and as part of which edit. +/// +/// The pin whose value was written. +/// What it held before. +/// What it holds now. +/// +/// The continuous edit the write belongs to, or null for a write that stands alone. See +/// . +/// +public sealed class PinValueChangedEventArgs(int PinId, object? OldValue, object? NewValue, long? EditGesture) : EventArgs +{ + /// Gets the pin whose value was written. + public int PinId { get; } = PinId; + + /// Gets what it held before. + public object? OldValue { get; } = OldValue; + + /// Gets what it holds now. + public object? NewValue { get; } = NewValue; + + /// Gets the continuous edit the write belongs to, or null for a write that stands alone. + public long? EditGesture { get; } = EditGesture; +} + +/// +/// A labelled rectangle drawn behind the nodes, used to name and organise a region of the graph. +/// +/// The comment box's identifier, unique among comment boxes. It shares no space with node ids. +/// The label drawn in its title bar. +/// Its top-left corner, in the same space as node positions. +/// Its width and height, in the same space as node dimensions. +/// +/// The colour it is filled with, or null to take one from the editor's theme. The title bar and +/// border are drawn from the same colour at a higher opacity. +/// +/// +/// A comment box does not own the nodes inside it. What it contains is decided by geometry every +/// time it is asked — a node lying wholly within its rectangle is in it — so dragging a node out of +/// a box takes it out, and nothing has to be kept in step when a node is added, removed or moved by +/// the layout. is what carries the contents along. +/// +/// Comment boxes take no part in the force-directed layout. They are not bodies, and nothing pushes +/// a node out of one or pulls it in. +/// +/// +public sealed record CommentBox(int Id, string Title, Vector2 Position, Vector2 Size, Vector4? Color = null) +{ + /// The bottom-right corner. + public Vector2 Max => Position + Size; + + /// + /// Whether a rectangle lies wholly inside this box. + /// + /// The rectangle's top-left corner. + /// Its width and height. + /// True when no part of it is outside. + public bool Contains(Vector2 position, Vector2 size) => + position.X >= Position.X && position.Y >= Position.Y && + position.X + size.X <= Max.X && position.Y + size.Y <= Max.Y; +} + +/// +/// One node's move from where a gesture found it to where the gesture left it. +/// +/// The node. +/// Where it was before. +/// Where it is after. +public readonly record struct NodeMove(int NodeId, Vector2 From, Vector2 To); diff --git a/ImGui.NodeEditor/ImGui.NodeEditor.csproj b/ImGui.NodeEditor/ImGui.NodeEditor.csproj index 4371497b..a8bd30d1 100644 --- a/ImGui.NodeEditor/ImGui.NodeEditor.csproj +++ b/ImGui.NodeEditor/ImGui.NodeEditor.csproj @@ -14,6 +14,8 @@ + + diff --git a/ImGui.NodeEditor/NodeEditorCommands.cs b/ImGui.NodeEditor/NodeEditorCommands.cs new file mode 100644 index 00000000..88d6b194 --- /dev/null +++ b/ImGui.NodeEditor/NodeEditorCommands.cs @@ -0,0 +1,211 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.NodeEditor; + +using System; +using System.Collections.Generic; +using System.Linq; +using Hexa.NET.ImGui; +using ktsu.Keybinding.Core.Contracts; +using ktsu.Keybinding.Core.Models; + +/// +/// The node editor's keyboard commands, in the form ktsu.Keybinding registers and binds. +/// +/// +/// A given an reads each of +/// these commands' chords from the service's active profile, so the host's own keymap — whatever +/// profile the user picked, whatever they rebound — decides which keys drive the graph. Without a +/// service the handler uses , plus Backspace for delete and Ctrl+Shift+Z +/// for redo, which a profile holding one chord per command cannot express. +/// +public static class NodeEditorCommands +{ + /// The category the commands are registered under. + public const string Category = "Node Editor"; + + /// Undo the last change to the graph. + public const string Undo = "nodeeditor.undo"; + + /// Redo the last change undone. + public const string Redo = "nodeeditor.redo"; + + /// Delete the selected nodes and links. + public const string Delete = "nodeeditor.delete"; + + /// Duplicate the selected nodes. + public const string Duplicate = "nodeeditor.duplicate"; + + /// Every command, ready to register. + public static IReadOnlyList All { get; } = + [ + new(Undo, "Undo", "Undo the last change to the graph", Category), + new(Redo, "Redo", "Redo the last change undone", Category), + new(Delete, "Delete Selection", "Delete the selected nodes and links", Category), + new(Duplicate, "Duplicate Selection", "Duplicate the selected nodes", Category), + ]; + + /// The chord each command is bound to unless the user says otherwise. + public static IReadOnlyDictionary DefaultChords { get; } = new Dictionary + { + [Undo] = "Ctrl+Z", + [Redo] = "Ctrl+Y", + [Delete] = "Delete", + [Duplicate] = "Ctrl+D", + }; + + /// + /// Register the commands, and bind each one that has no chord yet to its default. + /// + /// Where the host's commands are registered, such as KeybindingManager.Commands. + /// The host's keybindings, such as KeybindingManager.Keybindings. + /// False to register the commands and leave every chord to the host. + /// How many chords were bound. + /// + /// Safe to call on every start-up: a command already registered is left as it is, and a chord the + /// user has already bound — loaded from their saved profile — is not overwritten. Chords are + /// bound in the active profile, so there has to be one for any to be bound. + /// + public static int Register(ICommandRegistry registry, IKeybindingService keybindings, bool bindDefaultChords = true) + { + Ensure.NotNull(registry); + Ensure.NotNull(keybindings); + + foreach (Command command in All.Where(c => !registry.IsCommandRegistered(c.Id))) + { + registry.RegisterCommand(command); + } + + if (!bindDefaultChords) + { + return 0; + } + + int bound = 0; + foreach ((string commandId, string chord) in DefaultChords) + { + if (!keybindings.HasChordBinding(commandId) && keybindings.BindChord(commandId, keybindings.ParseChord(chord))) + { + bound++; + } + } + + return bound; + } +} + +/// +/// Answers whether a ktsu.Keybinding chord was pressed this frame, in ImGui's terms. +/// +/// +/// A chord matches when its modifiers are exactly the ones held — so Ctrl+Z does not fire for +/// Ctrl+Shift+Z — every other key in it is down, and at least one of them went down this frame. Key +/// repeat is ignored, so holding a chord fires it once. +/// +internal static class KeyChordMatcher +{ + private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase) + { + ["ESC"] = ImGuiKey.Escape, + ["RETURN"] = ImGuiKey.Enter, + ["DEL"] = ImGuiKey.Delete, + ["INS"] = ImGuiKey.Insert, + ["UP"] = ImGuiKey.UpArrow, + ["DOWN"] = ImGuiKey.DownArrow, + ["LEFT"] = ImGuiKey.LeftArrow, + ["RIGHT"] = ImGuiKey.RightArrow, + ["ARROWUP"] = ImGuiKey.UpArrow, + ["ARROWDOWN"] = ImGuiKey.DownArrow, + ["ARROWLEFT"] = ImGuiKey.LeftArrow, + ["ARROWRIGHT"] = ImGuiKey.RightArrow, + ["PGUP"] = ImGuiKey.PageUp, + ["PGDN"] = ImGuiKey.PageDown, + ["SPACEBAR"] = ImGuiKey.Space, + }; + + public static bool IsPressed(Chord chord) + { + bool ctrl = false; + bool alt = false; + bool shift = false; + bool meta = false; + List keys = []; + + foreach (string name in chord.Notes.Select(note => note.ToString())) + { + switch (name) + { + case "CTRL" or "CONTROL": + ctrl = true; + break; + + case "ALT": + alt = true; + break; + + case "SHIFT": + shift = true; + break; + + case "META" or "WIN" or "WINDOWS" or "CMD" or "COMMAND" or "SUPER": + meta = true; + break; + + default: + if (!TryMapKey(name, out ImGuiKey key)) + { + // A key ImGui has no name for can never be pressed, so neither can the chord. + return false; + } + + keys.Add(key); + break; + } + } + + if (keys.Count == 0) + { + return false; + } + + ImGuiIOPtr io = ImGui.GetIO(); + if (io.KeyCtrl != ctrl || io.KeyAlt != alt || io.KeyShift != shift || io.KeySuper != meta) + { + return false; + } + + return keys.All(ImGui.IsKeyDown) && keys.Any(key => ImGui.IsKeyPressed(key, repeat: false)); + } + + /// Find the ImGui key a note names. + /// The note's name, upper-cased as ktsu.Keybinding stores it. + /// The key. + /// True if ImGui has such a key. + public static bool TryMapKey(string name, out ImGuiKey key) + { + if (name.Length == 1 && name[0] is >= 'A' and <= 'Z') + { + key = (ImGuiKey)((int)ImGuiKey.A + (name[0] - 'A')); + return true; + } + + if (name.Length == 1 && name[0] is >= '0' and <= '9') + { + key = (ImGuiKey)((int)ImGuiKey.Key0 + (name[0] - '0')); + return true; + } + + if (Aliases.TryGetValue(name, out key)) + { + return true; + } + + // Everything else by ImGui's own name — DELETE, BACKSPACE, F5, PAGEUP, COMMA — but never a + // modifier, a mouse button or one of the range markers, none of which is a key to press. + return Enum.TryParse(name, ignoreCase: true, out key) + && key > ImGuiKey.NamedKeyBegin + && key < ImGuiKey.GamepadStart + && key is not (ImGuiKey.LeftCtrl or ImGuiKey.RightCtrl or ImGuiKey.LeftShift or ImGuiKey.RightShift + or ImGuiKey.LeftAlt or ImGuiKey.RightAlt or ImGuiKey.LeftSuper or ImGuiKey.RightSuper); + } +} diff --git a/ImGui.NodeEditor/NodeEditorEngine.CommentBoxes.cs b/ImGui.NodeEditor/NodeEditorEngine.CommentBoxes.cs new file mode 100644 index 00000000..f18eccc8 --- /dev/null +++ b/ImGui.NodeEditor/NodeEditorEngine.CommentBoxes.cs @@ -0,0 +1,232 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.NodeEditor; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +/// +/// Comment boxes: labelled regions drawn behind the nodes. See . +/// +public partial class NodeEditorEngine +{ + /// The smallest a comment box may be made, so it always has a title bar to hold. + public static Vector2 MinimumCommentBoxSize => new(80f, 48f); + + /// How much room leaves between the nodes and the edge, when not told. + public const float DefaultCommentBoxPadding = 24f; + + /// + /// How much taller than its padding makes a box's top edge, + /// so the title bar sits above the nodes rather than over them. + /// + public const float CommentBoxTitleAllowance = 28f; + + private readonly List commentBoxes = []; + private int nextCommentBoxId = 1; + + /// The comment boxes, in the order they are drawn: later ones over earlier ones. + public IReadOnlyList CommentBoxes => commentBoxes.AsReadOnly(); + + /// + /// Add a comment box. + /// + /// Its top-left corner. + /// Its size, which is raised to if smaller. + /// The label for its title bar. + /// Its fill colour, or null to follow the theme. + /// The new comment box. + public CommentBox CreateCommentBox(Vector2 position, Vector2 size, string title, Vector4? color = null) + { + Ensure.NotNull(title); + CommentBox box = new(nextCommentBoxId++, title, position, Vector2.Max(size, MinimumCommentBoxSize), color); + commentBoxes.Add(box); + return box; + } + + /// + /// Add a comment box sized to enclose a set of nodes. + /// + /// The nodes to enclose. Ids naming no node are skipped. + /// The label for its title bar. + /// The room to leave between the nodes and the box's edge. + /// Its fill colour, or null to follow the theme. + /// The new comment box, or null if none of the ids named a node. + /// + /// The top edge is given on top of the padding, so the + /// title bar does not cover the topmost node. A node's size is measured the first time it is + /// drawn, so a box made around nodes that have never been drawn is made around their corners. + /// + public CommentBox? CreateCommentBoxAround(IEnumerable nodeIds, string title, float padding = DefaultCommentBoxPadding, Vector4? color = null) + { + Ensure.NotNull(nodeIds); + HashSet wanted = [.. nodeIds]; + List enclosed = [.. nodes.Where(n => wanted.Contains(n.Id))]; + if (enclosed.Count == 0) + { + return null; + } + + Vector2 lowest = new(float.MaxValue, float.MaxValue); + Vector2 highest = new(float.MinValue, float.MinValue); + foreach (Node node in enclosed) + { + lowest = Vector2.Min(lowest, node.Position); + highest = Vector2.Max(highest, node.Position + node.Dimensions); + } + + Vector2 topLeft = lowest - new Vector2(padding, padding + CommentBoxTitleAllowance); + Vector2 bottomRight = highest + new Vector2(padding, padding); + return CreateCommentBox(topLeft, bottomRight - topLeft, title, color); + } + + /// Find a comment box by id. + /// The comment box. + /// It, or null if there is none with that id. + public CommentBox? FindCommentBox(int commentBoxId) => commentBoxes.Find(b => b.Id == commentBoxId); + + /// + /// Remove a comment box. The nodes inside it are left where they are. + /// + /// The comment box. + /// True if there was one to remove. + public bool RemoveCommentBox(int commentBoxId) => commentBoxes.RemoveAll(b => b.Id == commentBoxId) > 0; + + /// Change a comment box's title. + /// The comment box. + /// Its new title. + /// True if the comment box exists. + public bool RenameCommentBox(int commentBoxId, string title) + { + Ensure.NotNull(title); + return ReplaceCommentBox(commentBoxId, box => box with { Title = title }); + } + + /// Change a comment box's fill colour. + /// The comment box. + /// Its new colour, or null to follow the theme. + /// True if the comment box exists. + public bool SetCommentBoxColor(int commentBoxId, Vector4? color) => + ReplaceCommentBox(commentBoxId, box => box with { Color = color }); + + /// + /// Change a comment box's size, keeping its top-left corner where it is. Its contents do not move. + /// + /// The comment box. + /// Its new size, raised to if smaller. + /// True if the comment box exists. + /// + /// Resizing is how a box takes in a node or lets one go: containment is geometric, so a node + /// is in the box as soon as the box grows to cover it. + /// + public bool ResizeCommentBox(int commentBoxId, Vector2 size) => + ReplaceCommentBox(commentBoxId, box => box with { Size = Vector2.Max(size, MinimumCommentBoxSize) }); + + /// + /// The nodes lying wholly inside a comment box. + /// + /// The comment box. + /// Their ids, or nothing if there is no such box. + /// + /// A node that has never been drawn has no measured size yet, so it counts as inside when its + /// top-left corner is. + /// + public IReadOnlyList GetNodesInCommentBox(int commentBoxId) + { + CommentBox? box = FindCommentBox(commentBoxId); + return box is null + ? [] + : [.. nodes.Where(n => box.Contains(n.Position, n.Dimensions)).Select(n => n.Id)]; + } + + /// + /// The other comment boxes lying wholly inside a comment box. + /// + /// The comment box. + /// Their ids, or nothing if there is no such box. + public IReadOnlyList GetCommentBoxesInCommentBox(int commentBoxId) + { + CommentBox? box = FindCommentBox(commentBoxId); + return box is null + ? [] + : [.. commentBoxes.Where(b => b.Id != commentBoxId && box.Contains(b.Position, b.Size)).Select(b => b.Id)]; + } + + /// + /// Move a comment box, carrying along everything inside it. + /// + /// The comment box. + /// How far to move it. + /// The moves made to the nodes it carried, or nothing if there is no such box. + /// + /// What is inside is decided before anything moves, so a node the box passes over on its way is + /// not picked up. Comment boxes nested inside this one move with it, and so do their nodes; a + /// node inside two nested boxes is moved once. + /// + /// The nodes are moved with , the same as a drag, so a physics + /// simulation left running is free to move them again afterwards. + /// + /// + public IReadOnlyList MoveCommentBox(int commentBoxId, Vector2 delta) + { + CommentBox? box = FindCommentBox(commentBoxId); + if (box is null) + { + return []; + } + + IReadOnlyList carriedNodes = GetNodesInCommentBox(commentBoxId); + IReadOnlyList carriedBoxes = GetCommentBoxesInCommentBox(commentBoxId); + + ReplaceCommentBox(commentBoxId, b => b with { Position = b.Position + delta }); + foreach (int nestedId in carriedBoxes) + { + ReplaceCommentBox(nestedId, b => b with { Position = b.Position + delta }); + } + + List moves = []; + foreach (int nodeId in carriedNodes) + { + Node node = nodes.Find(n => n.Id == nodeId)!; + Vector2 to = node.Position + delta; + UpdateNodePosition(nodeId, to); + moves.Add(new NodeMove(nodeId, node.Position, to)); + } + + return moves; + } + + private bool ReplaceCommentBox(int commentBoxId, Func change) + { + int index = commentBoxes.FindIndex(b => b.Id == commentBoxId); + if (index < 0) + { + return false; + } + + commentBoxes[index] = change(commentBoxes[index]); + return true; + } + + /// + /// Put a comment box back exactly as it was, id and all, or overwrite the one holding its id. + /// + /// The comment box as it should be. + /// Where in the drawing order it goes if it is not already present. + internal void RestoreCommentBox(CommentBox box, int index) + { + int existing = commentBoxes.FindIndex(b => b.Id == box.Id); + if (existing >= 0) + { + commentBoxes[existing] = box; + } + else + { + commentBoxes.Insert(Math.Clamp(index, 0, commentBoxes.Count), box); + } + + nextCommentBoxId = Math.Max(nextCommentBoxId, box.Id + 1); + } +} diff --git a/ImGui.NodeEditor/NodeEditorEngine.Snapshots.cs b/ImGui.NodeEditor/NodeEditorEngine.Snapshots.cs new file mode 100644 index 00000000..a822c729 --- /dev/null +++ b/ImGui.NodeEditor/NodeEditorEngine.Snapshots.cs @@ -0,0 +1,195 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.NodeEditor; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +/// +/// The capture and restore primitives is built on. +/// +/// +/// These are internal on purpose. Restoring a node by id is only safe for a node this engine issued +/// and then removed, which is exactly what the history knows and nothing else does. +/// +public partial class NodeEditorEngine +{ + /// + /// Capture everything needed to put a node back after it is removed. + /// + /// The node. + /// The node's state, or null if there is no such node. + /// + /// The pin lists are copied, since edits them in + /// place. A bound pin's accessor is kept rather than its instance, so restoring the node puts + /// its value back on the same object it lived on. + /// + internal NodeSnapshot? CaptureNode(int nodeId) + { + int index = nodes.FindIndex(n => n.Id == nodeId); + if (index < 0) + { + return null; + } + + Node node = nodes[index]; + Node copy = node with { InputPins = [.. node.InputPins], OutputPins = [.. node.OutputPins] }; + + List pins = []; + foreach (Pin pin in node.InputPins.Concat(node.OutputPins)) + { + pinValues.TryGetDefault(pin.Id, out bool hasDefault, out object? defaultValue); + pins.Add(new PinSnapshot( + pin.Id, + GetPinValue(pin.Id), + hasDefault, + defaultValue, + pinValueAccessors.TryGetValue(pin.Id, out PinValueAccessor? accessor) ? accessor : null, + pinIdToOffset.TryGetValue(pin.Id, out Vector2 offset) ? offset : null)); + } + + return new NodeSnapshot(copy, index, pins); + } + + /// + /// Put a removed node back exactly as it was captured: the same id, the same pins, the same + /// values. + /// + /// What returned before the node was removed. + /// True if it was put back, false if a node with its id is already present. + /// + /// The id counters are raised past the restored ids, so a node created afterwards cannot be + /// issued an id the restored one already holds. It comes back at rest: whatever velocity the + /// layout had given it when it was removed is not handed back to fling it across the view. + /// + internal bool RestoreNode(NodeSnapshot snapshot) + { + Node captured = snapshot.Node; + if (nodes.Exists(n => n.Id == captured.Id)) + { + return false; + } + + Node restored = captured with + { + InputPins = [.. captured.InputPins], + OutputPins = [.. captured.OutputPins], + Velocity = Vector2.Zero, + Force = Vector2.Zero, + }; + + nodes.Insert(Math.Clamp(snapshot.Index, 0, nodes.Count), restored); + nextNodeId = Math.Max(nextNodeId, restored.Id + 1); + + foreach (PinSnapshot pin in snapshot.Pins) + { + nextPinId = Math.Max(nextPinId, pin.PinId + 1); + + pinValues.Restore(pin.PinId, pin.Value, pin.HasDefault, pin.DefaultValue); + + if (pin.Accessor is PinValueAccessor accessor) + { + pinValueAccessors[pin.PinId] = accessor; + accessor.Set(pin.Value); + } + + if (pin.Offset is Vector2 offset) + { + pinIdToOffset[pin.PinId] = offset; + } + } + + return true; + } + + /// + /// Put a removed link back with the id it had. + /// + /// The link as it was. + /// True if it was put back, false if either pin is missing or the id is taken. + /// + /// This does not go through , whose rules decide whether a link may be + /// drawn; this link was drawn already, and putting it back is not a new decision. + /// + internal bool RestoreLink(Link link) + { + if (links.Exists(l => l.Id == link.Id) || FindPin(link.OutputPinId) is null || FindPin(link.InputPinId) is null) + { + return false; + } + + links.Add(link); + nextLinkId = Math.Max(nextLinkId, link.Id + 1); + return true; + } + + /// + /// Write a pin's value back as it was, bypassing the type check and not reporting it as an edit. + /// + /// The pin. + /// The value it held. + /// + /// The type check is bypassed because the value came off this pin: a value-typed pin that has + /// never been set holds null, and that null is a state an undo has to be able to go back to. + /// + internal void RestorePinValue(int pinId, object? value) + { + if (FindPin(pinId) is null) + { + return; + } + + if (pinValueAccessors.TryGetValue(pinId, out PinValueAccessor? accessor)) + { + accessor.Set(value); + return; + } + + pinValues.Restore(pinId, value); + } + + /// + /// Put a node's own fields back — where it is, what it is called, whether it is pinned, and its + /// pins' capacity — leaving what the layout and the renderer measure alone. + /// + /// The node as it should be. + /// True if the node exists. + internal bool RestoreNodeFields(Node state) + { + int index = nodes.FindIndex(n => n.Id == state.Id); + if (index < 0) + { + return false; + } + + Node current = nodes[index]; + nodes[index] = current with + { + Position = state.Position, + Name = state.Name, + IsPinned = state.IsPinned, + InputPins = [.. state.InputPins], + OutputPins = [.. state.OutputPins], + Velocity = Vector2.Zero, + }; + + return true; + } +} + +/// What a node was, so it can be put back after it is removed. +/// The node, with its pin lists copied. +/// Where it stood in the drawing order. +/// What each of its pins held. +internal sealed record NodeSnapshot(Node Node, int Index, IReadOnlyList Pins); + +/// What a pin held, so it can be put back. +/// The pin. +/// Its value. +/// Whether it had a seeded default. +/// That default. +/// Where its value lives, when not in the engine's own store. +/// Where the renderer last measured it, if it has. +internal sealed record PinSnapshot(int PinId, object? Value, bool HasDefault, object? DefaultValue, PinValueAccessor? Accessor, Vector2? Offset); diff --git a/ImGui.NodeEditor/NodeEditorEngine.cs b/ImGui.NodeEditor/NodeEditorEngine.cs index ddf73562..d984db72 100644 --- a/ImGui.NodeEditor/NodeEditorEngine.cs +++ b/ImGui.NodeEditor/NodeEditorEngine.cs @@ -14,7 +14,7 @@ namespace ktsu.ImGui.NodeEditor; /// which operates in double precision. Node positions remain float-precision /// to match the surrounding ImGui/ImNodes ecosystem; conversion happens at the accessor boundary. /// -public class NodeEditorEngine +public partial class NodeEditorEngine { private readonly List nodes = []; private readonly List links = []; @@ -47,6 +47,17 @@ public class NodeEditorEngine /// public event EventHandler? Cleared; + /// + /// Raised after a pin's value has been written through + /// or . + /// + /// + /// Values copied onto a duplicate and values put back by an undo are not reported here: the + /// first is part of creating a node, the second is part of undoing a change that was already + /// reported once. + /// + public event EventHandler? PinValueChanged; + /// /// Create a new node editor engine with default physics settings. /// @@ -175,7 +186,36 @@ public void BindPinValue(int pinId, PinValueAccessor accessor) /// reaches neither the accessor nor the store. A bound accessor can still report a write it did /// not make, which is what its own false return means. /// - public bool SetPinValue(int pinId, object? value) + public bool SetPinValue(int pinId, object? value) => SetPinValue(pinId, value, editGesture: null); + + /// + /// Write a value to a pin, saying which edit gesture the write belongs to. + /// + /// The pin. + /// The value. + /// + /// Identifies one continuous edit — one drag of a slider, one session of typing into a box — or + /// null for a write that stands alone. Writes sharing a gesture are one change as far as + /// listeners such as are concerned, + /// so dragging a value across a hundred frames undoes in one step rather than a hundred. + /// + /// True if it was written, false if there is no such pin or its type refused the value. + public bool SetPinValue(int pinId, object? value, long? editGesture) + { + object? previous = GetPinValue(pinId); + if (!WritePinValue(pinId, value)) + { + return false; + } + + PinValueChanged?.Invoke(this, new PinValueChangedEventArgs(pinId, previous, GetPinValue(pinId), editGesture)); + return true; + } + + /// + /// Write a value to a pin without reporting it, for the callers that are not an edit. + /// + private bool WritePinValue(int pinId, object? value) { Pin? pin = FindPin(pinId); if (pin is null || !PinValueStore.Accepts(pin.DataType, value)) @@ -204,13 +244,21 @@ public bool SetPinValue(int pinId, object? value) /// public bool ResetPinValue(int pinId) { + object? previous = GetPinValue(pinId); if (!pinValues.Reset(pinId)) { return false; } - return !pinValueAccessors.TryGetValue(pinId, out PinValueAccessor? accessor) + bool written = !pinValueAccessors.TryGetValue(pinId, out PinValueAccessor? accessor) || accessor.Set(pinValues.Get(pinId)); + + if (written) + { + PinValueChanged?.Invoke(this, new PinValueChangedEventArgs(pinId, previous, GetPinValue(pinId), EditGesture: null)); + } + + return written; } /// @@ -409,7 +457,7 @@ public Node CreateNodeFromSpecs(Vector2 position, string name, IReadOnlyList /// - /// A copied pin's value is written through and so lands + /// A copied pin's value is written the way writes one, and so lands /// in this engine's own store, even where the original's lives on an instance a factory bound /// through : a copy has no instance of its own. /// It has no seeded default either, so on a copied pin reports @@ -450,8 +498,8 @@ public IReadOnlyList DuplicateNodes(IEnumerable nodeIds, Vector2 offs // A copy of a tuned node is expected to arrive tuned: duplicating a node whose Threshold the // user set to 50 and getting one that reads 0 is the copy quietly computing something else. - // The value goes through the same front door a caller would use, so the copy's declared type - // vets it exactly as the original's did. Where the original kept its value on an instance the + // The value goes through the same type check a caller's write would, so the copy's declared + // type vets it exactly as the original's did, but it is not reported as an edit. Where the original kept its value on an instance the // factory bound, the copy has no instance and no accessor, so the same value lands in this // engine's own store under the new pin id - the same value, a different home. // @@ -460,7 +508,7 @@ public IReadOnlyList DuplicateNodes(IEnumerable nodeIds, Vector2 offs { if (GetPinValue(originalPinId) is object value) { - SetPinValue(copiedPinId, value); + WritePinValue(copiedPinId, value); } } @@ -796,7 +844,7 @@ private GraphReach Walk(int nodeId, bool forward) /// Set the world origin to the centroid of all current node positions. public void InitializeWorldOriginToCentroid() => layout.InitializeWorldOriginToCentroid(nodes); - /// Clear all nodes and links. + /// Clear all nodes, links and comment boxes. public void Clear() { nodes.Clear(); @@ -807,6 +855,8 @@ public void Clear() pinValues.Clear(); pinValueAccessors.Clear(); pinIdToOffset.Clear(); + commentBoxes.Clear(); + nextCommentBoxId = 1; layout.WorldOrigin = Vec2D.Zero; Cleared?.Invoke(this, EventArgs.Empty); } diff --git a/ImGui.NodeEditor/NodeEditorHistory.cs b/ImGui.NodeEditor/NodeEditorHistory.cs new file mode 100644 index 00000000..f23610e7 --- /dev/null +++ b/ImGui.NodeEditor/NodeEditorHistory.cs @@ -0,0 +1,680 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.NodeEditor; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using ktsu.UndoRedo; +using ktsu.UndoRedo.Contracts; +using ktsu.UndoRedo.Core.Services; +using ktsu.UndoRedo.Models; + +/// +/// Undo and redo for a , recorded onto a ktsu.UndoRedo stack. +/// +/// +/// Every change is stored as the difference it made rather than as a copy of the graph. A change is +/// recorded by running it inside : the graph is captured before +/// and after, and what differs — nodes added or removed with their pins, values and links, links, +/// comment boxes, and any node fields or pin values that changed — becomes one undoable step. +/// Undoing puts back exactly that and nothing else, so a node the layout moved in the meantime is +/// not dragged back to where it stood when some unrelated node was deleted. +/// +/// Three kinds of change are recorded without being asked: +/// +/// +/// Pin values written through , +/// which is what the inline editors and do. Writes sharing an edit +/// gesture merge into one step, so a slider dragged across a hundred frames undoes in one. +/// Node drags and comment box gestures, when this history is assigned to +/// . A drag is recorded once, when the mouse is released. +/// called outside a recording empties the history, since +/// it restarts the id counters and every recorded id would come to name a different node. +/// +/// +/// Everything else — creating, deleting and duplicating nodes, making and breaking links — has to +/// go through this class, either with the convenience methods here or by wrapping the call in +/// . A change made on the engine directly is simply not in +/// the history. +/// +/// +/// Pass the the nodes were made with, if any, so undoing the +/// removal of a factory-made node reattaches its instance: without it the node comes back with its +/// pins and values but no longer knows it. +/// +/// +/// The stack is an ordinary . A host that already keeps one for the +/// rest of its document can hand it in, and the graph's steps interleave with its own. +/// +/// +public sealed class NodeEditorHistory : IDisposable +{ + /// How many steps a history made with its own stack keeps, when not told. + public const int DefaultCapacity = 256; + + private readonly AttributeBasedNodeFactory? factory; + + /// Above zero while a change is being recorded or a step applied, when the engine's own events are ours. + private int suppressDepth; + + /// Whether the engine was cleared by the change being recorded, which reissues every id. + private bool clearedDuringRecord; + + private bool disposed; + + /// + /// Create a history with a stack of its own. + /// + /// The engine to record. + /// The factory its nodes are made with, if any. + /// How many steps to keep before the oldest is dropped. + public NodeEditorHistory(NodeEditorEngine engine, AttributeBasedNodeFactory? factory = null, int capacity = DefaultCapacity) + : this(engine, CreateService(capacity), factory) + { + } + + /// + /// Create a history that records onto an existing stack. + /// + /// The engine to record. + /// The stack to record onto. + /// The factory its nodes are made with, if any. + public NodeEditorHistory(NodeEditorEngine engine, IUndoRedoService service, AttributeBasedNodeFactory? factory = null) + { + Engine = Ensure.NotNull(engine); + Service = Ensure.NotNull(service); + this.factory = factory; + + Engine.PinValueChanged += OnPinValueChanged; + Engine.Cleared += OnCleared; + } + + private static UndoRedoService CreateService(int capacity) => + new(new StackManager(), new SaveBoundaryManager(), new CommandMerger(), UndoRedoOptions.Create(maxStackSize: capacity)); + + /// The stack the steps are recorded onto. + public IUndoRedoService Service { get; } + + /// The engine being recorded. + public NodeEditorEngine Engine { get; } + + /// Whether there is a step to undo. + public bool CanUndo => Service.CanUndo; + + /// Whether there is a step to redo. + public bool CanRedo => Service.CanRedo; + + /// What would undo, for a menu item or a tooltip. + public string? NextUndoDescription => CanUndo ? Service.Commands[Service.CurrentPosition].Description : null; + + /// What would redo, for a menu item or a tooltip. + public string? NextRedoDescription => CanRedo ? Service.Commands[Service.CurrentPosition + 1].Description : null; + + /// Whether the history is applying a step or recording a change right now. + /// + /// Engine events raised while this is true are part of the step, not new edits. A host listening + /// to the engine for its own bookkeeping can read this to tell the two apart. + /// + public bool IsApplying => suppressDepth > 0; + + /// Undo the most recent step. + /// True if there was one. + public bool Undo() => CanUndo && Service.Undo(); + + /// Redo the most recently undone step. + /// True if there was one. + public bool Redo() => CanRedo && Service.Redo(); + + /// Forget every step. + public void Clear() => Service.Clear(); + + /// + /// Run a change against the engine and record what it did as one step. + /// + /// What the change returns. + /// What to call the step in a menu. + /// The change. + /// Whatever the change returned. + /// + /// A change that turns out to change nothing — a link the engine refused, a removal of an id that + /// named nothing — records nothing. A change that throws is recorded as far as it got before + /// throwing, so what it did can still be undone. A recording nested inside another is part of + /// the outer one. + /// + public T Record(string description, Func change) + { + Ensure.NotNull(description); + Ensure.NotNull(change); + + if (suppressDepth > 0) + { + return change(); + } + + GraphState before = Capture(); + clearedDuringRecord = false; + suppressDepth++; + try + { + return change(); + } + finally + { + suppressDepth--; + GraphChange difference = GraphChange.Between(before, Capture(), clearedDuringRecord); + if (!difference.IsEmpty) + { + Push(new GraphChangeCommand(this, description, difference)); + } + } + } + + /// + /// Run a change against the engine and record what it did as one step. + /// + /// What to call the step in a menu. + /// The change. + public void Record(string description, Action change) + { + Ensure.NotNull(change); + Record(description, () => + { + change(); + return true; + }); + } + + /// + /// Record moves that have already been made, such as a drag the user has just finished. + /// + /// The moves. Those that go nowhere, or name no node, are skipped. + /// What to call the step, or null for "Move node" or "Move N nodes". + /// True if a step was recorded. + /// + /// Nothing is moved: the nodes are expected to be at already, which is + /// where a drag leaves them. calls this itself when it has a + /// . + /// + public bool RecordNodeMoves(IEnumerable moves, string? description = null) + { + Ensure.NotNull(moves); + List real = [.. moves.Where(m => m.From != m.To && Engine.Nodes.Any(n => n.Id == m.NodeId))]; + if (real.Count == 0) + { + return false; + } + + GraphChange change = new() { Moves = real }; + Push(new GraphChangeCommand(this, description ?? (real.Count == 1 ? "Move node" : $"Move {real.Count} nodes"), change)); + return true; + } + + /// + /// Record a finished gesture on comment boxes: the boxes as they were and are, and the nodes they + /// carried. + /// + internal bool RecordCommentBoxGesture(string description, IReadOnlyList<(CommentBox Before, CommentBox After)> boxes, IReadOnlyList moves) + { + GraphChange change = new() + { + ChangedBoxes = [.. boxes.Where(b => b.Before != b.After)], + Moves = [.. moves.Where(m => m.From != m.To)], + }; + + if (change.IsEmpty) + { + return false; + } + + Push(new GraphChangeCommand(this, description, change)); + return true; + } + + /// Create a link as one step. See . + /// One end. + /// The other end. + /// What the engine said. A refused link records nothing. + public LinkCreationResult TryCreateLink(int fromPinId, int toPinId) => + Record("Connect pins", () => Engine.TryCreateLink(fromPinId, toPinId)); + + /// Remove a link as one step. + /// The link. + /// True if there was one. + public bool RemoveLink(int linkId) => Record("Disconnect pins", () => Engine.RemoveLink(linkId)); + + /// Remove links as one step. + /// The links. + /// How many there were. + public int RemoveLinks(IEnumerable linkIds) + { + Ensure.NotNull(linkIds); + List ids = [.. linkIds]; + return Record(ids.Count == 1 ? "Disconnect pins" : $"Disconnect {ids.Count} links", () => ids.Count(Engine.RemoveLink)); + } + + /// Remove a node, and the links reaching it, as one step. + /// The node. + /// True if there was one. + public bool RemoveNode(int nodeId) => Record("Delete node", () => Engine.RemoveNode(nodeId)); + + /// Remove nodes, and the links reaching them, as one step. + /// The nodes. + /// How many there were. + public int RemoveNodes(IEnumerable nodeIds) + { + Ensure.NotNull(nodeIds); + List ids = [.. nodeIds.Distinct()]; + return Record(ids.Count == 1 ? "Delete node" : $"Delete {ids.Count} nodes", () => ids.Count(Engine.RemoveNode)); + } + + /// Duplicate nodes as one step. See . + /// The nodes. + /// Where each copy goes, relative to its original. + /// The copies. + public IReadOnlyList DuplicateNodes(IEnumerable nodeIds, Vector2 offset) => + Record("Duplicate", () => Engine.DuplicateNodes(nodeIds, offset)); + + /// Add a comment box as one step. See . + /// Its top-left corner. + /// Its size. + /// Its title. + /// Its colour, or null to follow the theme. + /// The new comment box. + public CommentBox CreateCommentBox(Vector2 position, Vector2 size, string title, Vector4? color = null) => + Record("Add comment", () => Engine.CreateCommentBox(position, size, title, color)); + + /// Add a comment box around nodes as one step. See . + /// The nodes to enclose. + /// Its title. + /// The room to leave around the nodes. + /// Its colour, or null to follow the theme. + /// The new comment box, or null if none of the ids named a node. + public CommentBox? CreateCommentBoxAround(IEnumerable nodeIds, string title, float padding = NodeEditorEngine.DefaultCommentBoxPadding, Vector4? color = null) => + Record("Add comment", () => Engine.CreateCommentBoxAround(nodeIds, title, padding, color)); + + /// Remove a comment box as one step. The nodes inside it stay. + /// The comment box. + /// True if there was one. + public bool RemoveCommentBox(int commentBoxId) => Record("Delete comment", () => Engine.RemoveCommentBox(commentBoxId)); + + /// Rename a comment box as one step. + /// The comment box. + /// Its new title. + /// True if there was one. + public bool RenameCommentBox(int commentBoxId, string title) => Record("Rename comment", () => Engine.RenameCommentBox(commentBoxId, title)); + + /// Move a comment box and its contents as one step. + /// The comment box. + /// How far to move it. + /// The moves made to the nodes it carried. + public IReadOnlyList MoveCommentBox(int commentBoxId, Vector2 delta) => + Record("Move comment", () => Engine.MoveCommentBox(commentBoxId, delta)); + + /// + public void Dispose() + { + if (disposed) + { + return; + } + + Engine.PinValueChanged -= OnPinValueChanged; + Engine.Cleared -= OnCleared; + disposed = true; + } + + private void OnPinValueChanged(object? sender, PinValueChangedEventArgs e) + { + if (suppressDepth > 0 || Equals(e.OldValue, e.NewValue)) + { + return; + } + + Push(new PinValueCommand(this, e.PinId, e.OldValue, e.NewValue, e.EditGesture, $"Edit {PinName(e.PinId)}", alreadyApplied: true)); + } + + private void OnCleared(object? sender, EventArgs e) + { + if (suppressDepth > 0) + { + clearedDuringRecord = true; + return; + } + + Service.Clear(); + } + + private string PinName(int pinId) + { + foreach (Node node in Engine.Nodes) + { + Pin? pin = node.InputPins.Find(p => p.Id == pinId) ?? node.OutputPins.Find(p => p.Id == pinId); + if (pin is not null) + { + return pin.EffectiveDisplayName; + } + } + + return "value"; + } + + /// + /// Hand a step to the stack. The step describes a change already made, so the stack's own call + /// to execute it is the one it skips. + /// + private void Push(ICommand command) => Applying(() => Service.Execute(command)); + + /// Run something with the engine's events treated as part of a step rather than as edits. + internal void Applying(Action apply) + { + suppressDepth++; + try + { + apply(); + } + finally + { + suppressDepth--; + } + } + + private GraphState Capture() + { + Dictionary capturedNodes = []; + foreach (Node node in Engine.Nodes) + { + NodeSnapshot snapshot = Engine.CaptureNode(node.Id)!; + capturedNodes[node.Id] = new NodeState(snapshot, factory?.GetBinding(node.Id)); + } + + List capturedLinks = [.. Engine.Links.Select((link, index) => new IndexedLink(link, index))]; + List capturedBoxes = [.. Engine.CommentBoxes.Select((box, index) => new IndexedCommentBox(box, index))]; + + return new GraphState(capturedNodes, capturedLinks, capturedBoxes, Engine.WorldOrigin); + } + + /// Remove what a step takes away, then put back what it brings, in that order. + internal void Apply(GraphChange change, bool forward) => + Applying(() => + { + RemoveTakenAway(change, forward); + PutBack(change, forward); + RestoreEdits(change, forward); + }); + + private void RemoveTakenAway(GraphChange change, bool forward) + { + foreach (IndexedLink link in forward ? change.RemovedLinks : change.AddedLinks) + { + Engine.RemoveLink(link.Link.Id); + } + + foreach (IndexedCommentBox box in forward ? change.RemovedBoxes : change.AddedBoxes) + { + Engine.RemoveCommentBox(box.Box.Id); + } + + foreach (NodeState node in forward ? change.RemovedNodes : change.AddedNodes) + { + Engine.RemoveNode(node.Snapshot.Node.Id); + } + } + + private void PutBack(GraphChange change, bool forward) + { + // In their old order, so each lands back at the index it was captured at. + foreach (NodeState node in (forward ? change.AddedNodes : change.RemovedNodes).OrderBy(n => n.Snapshot.Index)) + { + bool restored = Engine.RestoreNode(node.Snapshot); + if (restored && node.Binding is NodeBinding binding) + { + factory?.RestoreBinding(binding); + } + } + + foreach (IndexedLink link in (forward ? change.AddedLinks : change.RemovedLinks).OrderBy(l => l.Index)) + { + Engine.RestoreLink(link.Link); + } + + foreach (IndexedCommentBox box in (forward ? change.AddedBoxes : change.RemovedBoxes).OrderBy(b => b.Index)) + { + Engine.RestoreCommentBox(box.Box, box.Index); + } + } + + private void RestoreEdits(GraphChange change, bool forward) + { + foreach ((Node before, Node after) in change.ChangedNodes) + { + Engine.RestoreNodeFields(forward ? after : before); + } + + foreach (NodeMove move in change.Moves) + { + Engine.UpdateNodePosition(move.NodeId, forward ? move.To : move.From); + } + + foreach ((int pinId, object? before, object? after) in change.ChangedValues) + { + Engine.RestorePinValue(pinId, forward ? after : before); + } + + foreach ((CommentBox before, CommentBox after) in change.ChangedBoxes) + { + CommentBox target = forward ? after : before; + int index = Engine.CommentBoxes.ToList().FindIndex(b => b.Id == target.Id); + Engine.RestoreCommentBox(target, index < 0 ? Engine.CommentBoxes.Count : index); + } + + if (change.WorldOrigin is (Vector2 originBefore, Vector2 originAfter)) + { + Engine.WorldOrigin = forward ? originAfter : originBefore; + } + } + + /// Write a pin's value as part of a step. + internal void ApplyPinValue(int pinId, object? value) => Applying(() => Engine.RestorePinValue(pinId, value)); +} + +/// A node as captured, with whatever the factory had bound to it. +internal sealed record NodeState(NodeSnapshot Snapshot, NodeBinding? Binding); + +/// A link and where it stood in the engine's list. +internal readonly record struct IndexedLink(Link Link, int Index); + +/// A comment box and where it stood in the drawing order. +internal readonly record struct IndexedCommentBox(CommentBox Box, int Index); + +/// Everything a step can change, captured at one moment. +internal sealed record GraphState( + IReadOnlyDictionary Nodes, + IReadOnlyList Links, + IReadOnlyList Boxes, + Vector2 WorldOrigin); + +/// What one step changed, in both directions. +internal sealed class GraphChange +{ + public IReadOnlyList AddedNodes { get; init; } = []; + public IReadOnlyList RemovedNodes { get; init; } = []; + public IReadOnlyList AddedLinks { get; init; } = []; + public IReadOnlyList RemovedLinks { get; init; } = []; + public IReadOnlyList AddedBoxes { get; init; } = []; + public IReadOnlyList RemovedBoxes { get; init; } = []; + public IReadOnlyList<(Node Before, Node After)> ChangedNodes { get; init; } = []; + public IReadOnlyList Moves { get; init; } = []; + public IReadOnlyList<(int PinId, object? Before, object? After)> ChangedValues { get; init; } = []; + public IReadOnlyList<(CommentBox Before, CommentBox After)> ChangedBoxes { get; init; } = []; + public (Vector2 Before, Vector2 After)? WorldOrigin { get; init; } + + public bool IsEmpty => + AddedNodes.Count == 0 && RemovedNodes.Count == 0 && + AddedLinks.Count == 0 && RemovedLinks.Count == 0 && + AddedBoxes.Count == 0 && RemovedBoxes.Count == 0 && + ChangedNodes.Count == 0 && Moves.Count == 0 && + ChangedValues.Count == 0 && ChangedBoxes.Count == 0 && + WorldOrigin is null; + + /// The first node a step touches, for a host that navigates to changes. + public int? FirstNodeId => + AddedNodes.Concat(RemovedNodes).Select(n => (int?)n.Snapshot.Node.Id).FirstOrDefault() + ?? ChangedNodes.Select(c => (int?)c.After.Id).FirstOrDefault() + ?? Moves.Select(m => (int?)m.NodeId).FirstOrDefault(); + + /// + /// Work out what changed between two captures. + /// + /// The graph before. + /// The graph after. + /// + /// Whether the engine was cleared in between. Clearing restarts the id counters, so an id present + /// on both sides may name two unrelated things, and the only safe reading is that everything + /// before was removed and everything after was added. + /// + public static GraphChange Between(GraphState before, GraphState after, bool replacedEverything) + { + if (replacedEverything) + { + return new GraphChange + { + RemovedNodes = [.. before.Nodes.Values], + AddedNodes = [.. after.Nodes.Values], + RemovedLinks = before.Links, + AddedLinks = after.Links, + RemovedBoxes = before.Boxes, + AddedBoxes = after.Boxes, + WorldOrigin = before.WorldOrigin == after.WorldOrigin ? null : (before.WorldOrigin, after.WorldOrigin), + }; + } + + List<(Node, Node)> changedNodes = []; + List<(int, object?, object?)> changedValues = []; + + foreach ((int id, NodeState was) in before.Nodes) + { + if (!after.Nodes.TryGetValue(id, out NodeState? now)) + { + continue; + } + + if (FieldsDiffer(was.Snapshot.Node, now.Snapshot.Node)) + { + changedNodes.Add((was.Snapshot.Node, now.Snapshot.Node)); + } + + Dictionary nowValues = now.Snapshot.Pins.ToDictionary(p => p.PinId, p => p.Value); + changedValues.AddRange(was.Snapshot.Pins + .Where(pin => nowValues.TryGetValue(pin.PinId, out object? value) && !Equals(pin.Value, value)) + .Select(pin => (pin.PinId, pin.Value, nowValues[pin.PinId]))); + } + + HashSet linksBefore = [.. before.Links.Select(l => l.Link.Id)]; + HashSet linksAfter = [.. after.Links.Select(l => l.Link.Id)]; + + Dictionary boxesBefore = before.Boxes.ToDictionary(b => b.Box.Id, b => b.Box); + Dictionary boxesAfter = after.Boxes.ToDictionary(b => b.Box.Id, b => b.Box); + + return new GraphChange + { + RemovedNodes = [.. before.Nodes.Where(n => !after.Nodes.ContainsKey(n.Key)).Select(n => n.Value)], + AddedNodes = [.. after.Nodes.Where(n => !before.Nodes.ContainsKey(n.Key)).Select(n => n.Value)], + RemovedLinks = [.. before.Links.Where(l => !linksAfter.Contains(l.Link.Id))], + AddedLinks = [.. after.Links.Where(l => !linksBefore.Contains(l.Link.Id))], + RemovedBoxes = [.. before.Boxes.Where(b => !boxesAfter.ContainsKey(b.Box.Id))], + AddedBoxes = [.. after.Boxes.Where(b => !boxesBefore.ContainsKey(b.Box.Id))], + ChangedBoxes = [.. boxesBefore + .Where(b => boxesAfter.TryGetValue(b.Key, out CommentBox? now) && now != b.Value) + .Select(b => (b.Value, boxesAfter[b.Key]))], + ChangedNodes = changedNodes, + ChangedValues = changedValues, + WorldOrigin = before.WorldOrigin == after.WorldOrigin ? null : (before.WorldOrigin, after.WorldOrigin), + }; + } + + /// + /// Whether a node's own fields differ: the ones a user or a host sets, not the ones the layout and + /// the renderer keep writing. + /// + private static bool FieldsDiffer(Node was, Node now) => + was.Position != now.Position || + was.Name != now.Name || + was.IsPinned != now.IsPinned || + !was.InputPins.SequenceEqual(now.InputPins) || + !was.OutputPins.SequenceEqual(now.OutputPins); +} + +/// One step made of a . +internal sealed class GraphChangeCommand(NodeEditorHistory history, string description, GraphChange change) + : BaseCommand( + ChangeType.Composite, + [], + change.FirstNodeId is int nodeId ? $"node:{nodeId}" : null) +{ + /// The change was made before the step was pushed, so the stack's first execute is skipped. + private bool alreadyApplied = true; + + public override string Description => description; + + public override void Execute() + { + if (alreadyApplied) + { + alreadyApplied = false; + return; + } + + history.Apply(change, forward: true); + } + + public override void Undo() + { + alreadyApplied = false; + history.Apply(change, forward: false); + } +} + +/// One step made of a single pin's value, which merges with the next write in the same gesture. +internal sealed class PinValueCommand(NodeEditorHistory history, int pinId, object? before, object? after, long? gesture, string description, bool alreadyApplied) + : BaseCommand(ChangeType.Modify, [$"pin:{pinId}"]) +{ + private bool alreadyApplied = alreadyApplied; + + public int PinId => pinId; + + public long? Gesture => gesture; + + public object? After => after; + + public override string Description => description; + + public override void Execute() + { + if (alreadyApplied) + { + alreadyApplied = false; + return; + } + + history.ApplyPinValue(pinId, after); + } + + public override void Undo() + { + alreadyApplied = false; + history.ApplyPinValue(pinId, before); + } + + /// Only writes that share a gesture merge: two separate drags of one slider are two steps. + public override bool CanMergeWith(ICommand other) => + gesture is not null && other is PinValueCommand next && next.PinId == pinId && next.Gesture == gesture; + + /// + /// The merged step goes from where this one started to where the next one ended. The stack undoes + /// this step and then executes the merged one, so the merged one must really execute. + /// + public override ICommand MergeWith(ICommand other) => + new PinValueCommand(history, pinId, before, ((PinValueCommand)other).After, gesture, description, alreadyApplied: false); +} diff --git a/ImGui.NodeEditor/NodeEditorInputHandler.cs b/ImGui.NodeEditor/NodeEditorInputHandler.cs index 2e1ff2e0..96fe709d 100644 --- a/ImGui.NodeEditor/NodeEditorInputHandler.cs +++ b/ImGui.NodeEditor/NodeEditorInputHandler.cs @@ -7,16 +7,45 @@ namespace ktsu.ImGui.NodeEditor; using System.Linq; using Hexa.NET.ImGui; using Hexa.NET.ImNodes; +using ktsu.Keybinding.Core.Contracts; +using ktsu.Keybinding.Core.Models; /// /// Pure input handling class - only handles ImNodes input events, no business logic /// +/// +/// The keyboard commands — delete, duplicate, undo and redo — are read from a +/// ktsu.Keybinding service when one is given, so they follow the host's keymap; see +/// . Without one they are Delete or Backspace, Ctrl+D, Ctrl+Z, and +/// Ctrl+Y or Ctrl+Shift+Z. Either way none of them fires while a text field has the keyboard. +/// public class NodeEditorInputHandler { + /// Create a handler with the default keys. + public NodeEditorInputHandler() + { + } + + /// Create a handler that takes its keys from a keymap. + /// + /// The keymap. Register with it, or no command has a chord and + /// none of them fires. + /// + public NodeEditorInputHandler(IKeybindingService keybindings) => Keybindings = Ensure.NotNull(keybindings); + + /// + /// Where the keyboard commands' chords come from, or null for the default keys. + /// + /// + /// Read every frame, so a chord the user rebinds takes effect on the next one. A command with no + /// chord in the active profile does not fire, and nor does any command when the service has no + /// active profile. + /// + public IKeybindingService? Keybindings { get; set; } + /// /// Process all input events and return the actions that should be taken /// - [SuppressMessage("Major Code Smell", "S2325:Make 'ProcessInput' a static method.", Justification = "Public instance method; making it static would be a breaking API change.")] public InputEvents ProcessInput() { InputEvents events = new(); @@ -27,39 +56,61 @@ public InputEvents ProcessInput() // Check for link deletion ProcessLinkDeletion(events); + // A key aimed at a text field is not aimed at the graph. Without this, typing in any input + // box on the same frame would silently drop the user's selection, and Ctrl+Z in a text box + // would undo the graph rather than the typing. + if (ImGui.GetIO().WantTextInput) + { + return events; + } + // One press clears everything the user selected, links and nodes alike, so the key is read // once and both selections are drained from it. Reading it per selection kind would work // today but invites the two to drift apart, which is how "Delete removed my links but left // the node" happens. - if (IsDeleteSelectionPressed()) + if (IsCommandPressed(NodeEditorCommands.Delete)) { ProcessSelectedLinkDeletion(events); ProcessSelectedNodeDeletion(events); } // Check for nodes the user selected and asked to duplicate - ProcessSelectedNodeDuplication(events); + if (IsCommandPressed(NodeEditorCommands.Duplicate)) + { + ProcessSelectedNodeDuplication(events); + } + + events.UndoRequested = IsCommandPressed(NodeEditorCommands.Undo); + events.RedoRequested = IsCommandPressed(NodeEditorCommands.Redo); return events; } /// - /// Whether this frame carries the "remove what I have selected" gesture. + /// Whether this frame carries the gesture for one of the keyboard commands. /// - private static bool IsDeleteSelectionPressed() + /// One of the ids. + private bool IsCommandPressed(string commandId) { - // A Delete aimed at a text field is not aimed at the graph. Without this, typing in any - // input box on the same frame would silently drop the user's selection. - if (ImGui.GetIO().WantTextInput) + if (Keybindings is IKeybindingService keybindings) { - return false; + return keybindings.GetChord(commandId) is Chord chord && KeyChordMatcher.IsPressed(chord); } - // Backspace is included because on a Mac keyboard it is the key labelled Delete; the - // forward-delete key is a chord most users never reach for. - // repeat: false, so holding the key down deletes the selection once rather than firing - // again every repeat interval. - return ImGui.IsKeyPressed(ImGuiKey.Delete, repeat: false) || ImGui.IsKeyPressed(ImGuiKey.Backspace, repeat: false); + // No repeat, so holding the keys down acts once rather than again every repeat interval. A + // chord rather than a key plus a modifier test: a chord matches the modifiers exactly, so + // Ctrl+Shift+D stays available to whatever else wants it, and ImGuiKey.ModCtrl is the Command + // key on a Mac when the host sets ConfigMacOSXBehaviors. + return commandId switch + { + // Backspace is included because on a Mac keyboard it is the key labelled Delete; the + // forward-delete key is a chord most users never reach for. + NodeEditorCommands.Delete => ImGui.IsKeyPressed(ImGuiKey.Delete, repeat: false) || ImGui.IsKeyPressed(ImGuiKey.Backspace, repeat: false), + NodeEditorCommands.Duplicate => ImGui.IsKeyChordPressed((int)(ImGuiKey.ModCtrl | ImGuiKey.D)), + NodeEditorCommands.Undo => ImGui.IsKeyChordPressed((int)(ImGuiKey.ModCtrl | ImGuiKey.Z)), + NodeEditorCommands.Redo => ImGui.IsKeyChordPressed((int)(ImGuiKey.ModCtrl | ImGuiKey.Y)) || ImGui.IsKeyChordPressed((int)(ImGuiKey.ModCtrl | ImGuiKey.ModShift | ImGuiKey.Z)), + _ => false, + }; } /// @@ -74,21 +125,6 @@ private static bool IsDeleteSelectionPressed() [SuppressMessage("Major Code Smell", "S6640:Make sure that using \"unsafe\" is safe here.", Justification = "Required for native ImNodes interop; the buffer is pinned for the call and not retained.")] private static void ProcessSelectedNodeDuplication(InputEvents events) { - // A Ctrl+D aimed at a text field is not aimed at the graph. - if (ImGui.GetIO().WantTextInput) - { - return; - } - - // As a chord rather than a key plus a modifier test: a chord matches the modifiers exactly, - // so Ctrl+Shift+D stays available to whatever else wants it, and ImGuiKey.ModCtrl is the - // Command key on a Mac when the host sets ConfigMacOSXBehaviors. No repeat, so holding the - // keys down duplicates once rather than filling the graph. - if (!ImGui.IsKeyChordPressed((int)(ImGuiKey.ModCtrl | ImGuiKey.D))) - { - return; - } - int selectedCount = ImNodes.NumSelectedNodes(); if (selectedCount <= 0) { @@ -255,6 +291,12 @@ public class InputEvents /// application that decides where the copies land. /// public List NodeDuplicationRequests { get; } = []; + + /// Whether the user asked to undo the last change. See . + public bool UndoRequested { get; set; } + + /// Whether the user asked to redo the last change undone. See . + public bool RedoRequested { get; set; } } /// diff --git a/ImGui.NodeEditor/NodeEditorRenderer.CommentBoxes.cs b/ImGui.NodeEditor/NodeEditorRenderer.CommentBoxes.cs new file mode 100644 index 00000000..d911cd45 --- /dev/null +++ b/ImGui.NodeEditor/NodeEditorRenderer.CommentBoxes.cs @@ -0,0 +1,429 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.NodeEditor; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Hexa.NET.ImGui; +using Hexa.NET.ImNodes; + +/// +/// Drawing and handling comment boxes. See . +/// +/// +/// ImNodes has no notion of a group, so comment boxes are this renderer's own. They are drawn into +/// the editor's background channel before any node is submitted, which puts them over the grid and +/// under every node and link, and handled with ordinary ImGui items placed over their title bars, +/// resize handles and close buttons. An ImGui item under the pointer is also what stops ImNodes +/// starting a box selection when a title bar is clicked. +/// +public partial class NodeEditorRenderer +{ + /// How large a comment box's resize handle is, at the view's own scale. + private const float CommentResizeHandleSize = 14f; + + /// How long a title may be typed into the rename box. + private const int CommentTitleMaxLength = 256; + + /// Where each comment box was drawn this frame, in screen space. + private readonly Dictionary commentBoxScreenRects = []; + + private CommentGesture? commentGesture; + private string renameBuffer = string.Empty; + private int renameStartFrame; + + /// Whether comment boxes are drawn and can be handled. On by default. + public bool DrawCommentBoxes { get; set; } = true; + + /// + /// The fill colour for a comment box that has no of its own, or + /// null to take ImNodes' title bar colour at a low opacity so the boxes follow the theme. + /// + /// + /// A box is filled with its colour as given, alpha included; its title bar and border are drawn + /// from the same colour at a higher opacity, so a faint fill still has a legible edge. + /// + public Vector4? CommentBoxColor { get; set; } + + /// The comment box whose title bar the pointer was over in the last frame drawn, if any. + public int? HoveredCommentBoxId { get; private set; } + + /// The comment box whose title is being edited, if any. + public int? RenamingCommentBoxId { get; private set; } + + /// + /// Start editing a comment box's title in place, as a double-click on its title bar does. + /// + /// The comment box. + public void BeginRenamingCommentBox(int commentBoxId) + { + RenamingCommentBoxId = commentBoxId; + renameBuffer = string.Empty; + renameStartFrame = -1; + commentGesture = null; + } + + /// + /// Where a comment box was drawn in the last frame, in screen space. + /// + /// The comment box. + /// The rectangle it occupied, title bar included. + /// True when the box was drawn in the last frame. + public bool TryGetCommentBoxScreenRect(int commentBoxId, out ScreenRect rect) => + commentBoxScreenRects.TryGetValue(commentBoxId, out rect); + + /// The nodes a comment box drag in progress is carrying. + private IEnumerable CommentBoxCarriedNodes => + commentGesture is { Resizing: false } gesture ? gesture.Nodes.Select(n => n.NodeId) : []; + + /// + /// Draw every comment box, then let the user handle them. + /// + private void RenderCommentBoxes(NodeEditorEngine engine) + { + HoveredCommentBoxId = null; + commentBoxScreenRects.Clear(); + + if (commentGesture is not null && engine.FindCommentBox(commentGesture.BoxId) is null) + { + // Removed from under the gesture, by the host or an undo. There is nothing left to move. + commentGesture = null; + } + + if (!DrawCommentBoxes || engine.CommentBoxes.Count == 0) + { + commentGesture = null; + RenamingCommentBoxId = null; + return; + } + + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + float titleHeight = ImGui.GetFrameHeight(); + + foreach (CommentBox box in engine.CommentBoxes) + { + DrawCommentBox(drawList, box, titleHeight); + } + + // While the pointer is over a node or a link, ImNodes owns the click: a comment title bar a + // node overlaps must not steal the node's drag. A gesture in progress keeps its items, since + // a box being dragged carries nodes along under the pointer. + bool interactive = commentGesture is not null || (HoveredNodeId is null && HoveredLinkId is null); + if (!interactive) + { + return; + } + + Vector2 cursor = ImGui.GetCursorScreenPos(); + int? closing = null; + + // Topmost first: ImGui gives the pointer to the first item submitted under it, and a box + // drawn later is drawn on top. + foreach (CommentBox box in engine.CommentBoxes.Reverse().ToList()) + { + // Every box's items are submitted, whether or not an earlier one was closed, so the + // handling stays a statement rather than a filter. + bool closed = HandleCommentBox(engine, box, titleHeight); + closing = closed ? box.Id : closing; + } + + ImGui.SetCursorScreenPos(cursor); + + if (closing is int closedId) + { + if (History is NodeEditorHistory history) + { + history.RemoveCommentBox(closedId); + } + else + { + engine.RemoveCommentBox(closedId); + } + } + } + + /// Where a box's corners are on screen. + private (Vector2 Min, Vector2 Max) CommentBoxScreenRect(CommentBox box) => + (canvasOrigin + ToView(box.Position), canvasOrigin + ToView(box.Max)); + + /// The fill colour a box is drawn with. + private Vector4 CommentBoxFill(CommentBox box) + { + if (box.Color is Vector4 own) + { + return own; + } + + if (CommentBoxColor is Vector4 configured) + { + return configured; + } + + Vector4 titleBar = ImGui.ColorConvertU32ToFloat4(ImNodes.GetStyle().Colors[(int)ImNodesCol.TitleBar]); + return titleBar with { W = 0.25f }; + } + + private void DrawCommentBox(ImDrawListPtr drawList, CommentBox box, float titleHeight) + { + (Vector2 min, Vector2 max) = CommentBoxScreenRect(box); + commentBoxScreenRects[box.Id] = new ScreenRect(min, max); + Vector4 fill = CommentBoxFill(box); + Vector4 edge = fill with { W = Math.Min(1f, (fill.W * 2.5f) + 0.2f) }; + uint edgeColor = ImGui.ColorConvertFloat4ToU32(edge); + float rounding = ImNodes.GetStyle().NodeCornerRounding; + Vector2 titleMax = new(max.X, Math.Min(max.Y, min.Y + titleHeight)); + + drawList.AddRectFilled(min, max, ImGui.ColorConvertFloat4ToU32(fill), rounding); + drawList.AddRectFilled(min, titleMax, edgeColor, rounding, ImDrawFlags.RoundCornersTop); + drawList.AddRect(min, max, edgeColor, rounding, ImDrawFlags.None, Math.Max(1f, Zoom)); + + uint textColor = ImGui.GetColorU32(ImGuiCol.Text); + float padding = ImGui.GetStyle().FramePadding.X; + + if (RenamingCommentBoxId != box.Id) + { + drawList.PushClipRect(min, new Vector2(titleMax.X - titleHeight, titleMax.Y), true); + drawList.AddText(min + new Vector2(padding, ImGui.GetStyle().FramePadding.Y), textColor, box.Title); + drawList.PopClipRect(); + } + + // The close button: a cross in a square at the right of the title bar. + Vector2 closeCentre = new(max.X - (titleHeight * 0.5f), min.Y + (titleHeight * 0.5f)); + float arm = titleHeight * 0.2f; + drawList.AddLine(closeCentre - new Vector2(arm, arm), closeCentre + new Vector2(arm, arm), textColor, Math.Max(1f, Zoom)); + drawList.AddLine(closeCentre + new Vector2(-arm, arm), closeCentre + new Vector2(arm, -arm), textColor, Math.Max(1f, Zoom)); + + // The resize handle: a triangle in the bottom-right corner. + float handle = CommentResizeHandleSize * Zoom; + drawList.AddTriangleFilled(max, max - new Vector2(handle, 0f), max - new Vector2(0f, handle), edgeColor); + } + + /// + /// Submit the items that handle one box, and act on what they report. + /// + /// True if the box's close button was clicked. + private bool HandleCommentBox(NodeEditorEngine engine, CommentBox box, float titleHeight) + { + (Vector2 min, Vector2 max) = CommentBoxScreenRect(box); + Vector2 size = max - min; + float titleBarHeight = Math.Min(titleHeight, size.Y); + + ImGui.PushID($"comment{box.Id}"); + try + { + ImGui.SetCursorScreenPos(new Vector2(max.X - titleHeight, min.Y)); + bool closed = ImGui.InvisibleButton("close", new Vector2(titleHeight, titleBarHeight)); + if (ImGui.IsItemHovered()) + { + HoveredCommentBoxId = box.Id; + ImGui.SetTooltip("Remove this comment. The nodes inside it stay."); + } + + float handle = CommentResizeHandleSize * Zoom; + ImGui.SetCursorScreenPos(max - new Vector2(handle, handle)); + ImGui.InvisibleButton("resize", new Vector2(handle, handle)); + TrackCommentGesture(engine, box, resizing: true); + + if (RenamingCommentBoxId == box.Id) + { + HandleRename(engine, box, min, max.X - titleHeight, titleBarHeight); + } + else + { + ImGui.SetCursorScreenPos(min); + ImGui.InvisibleButton("title", new Vector2(Math.Max(1f, size.X - titleHeight), Math.Max(1f, titleBarHeight))); + if (ImGui.IsItemHovered()) + { + HoveredCommentBoxId = box.Id; + } + + if (ImGui.IsItemHovered() && ImGui.IsMouseDoubleClicked(ImGuiMouseButton.Left)) + { + BeginRenamingCommentBox(box.Id); + } + else + { + TrackCommentGesture(engine, box, resizing: false); + } + } + + return closed; + } + finally + { + ImGui.PopID(); + } + } + + /// + /// Start, continue or finish a drag of the item just submitted for a box. + /// + private void TrackCommentGesture(NodeEditorEngine engine, CommentBox box, bool resizing) + { + if (resizing && (ImGui.IsItemHovered() || ImGui.IsItemActive())) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.ResizeNwse); + } + + if (ImGui.IsItemActivated()) + { + commentGesture = BeginCommentGesture(engine, box, resizing); + return; + } + + if (commentGesture is not CommentGesture gesture || gesture.BoxId != box.Id || gesture.Resizing != resizing) + { + return; + } + + if (ImGui.IsItemActive()) + { + ApplyCommentGesture(engine, gesture, ImGui.GetMouseDragDelta(ImGuiMouseButton.Left, 0f) / Zoom); + } + else + { + FinishCommentGesture(engine, gesture); + commentGesture = null; + } + } + + private static CommentGesture BeginCommentGesture(NodeEditorEngine engine, CommentBox box, bool resizing) + { + if (resizing) + { + return new CommentGesture(box.Id, Resizing: true, [box], []); + } + + // What the box carries is decided once, as the drag starts, so it does not pick up whatever it + // is dragged across. + List boxes = [box, .. engine.GetCommentBoxesInCommentBox(box.Id).Select(id => engine.FindCommentBox(id)!)]; + List<(int, Vector2)> carried = [.. engine.GetNodesInCommentBox(box.Id) + .Select(id => (id, engine.Nodes.First(n => n.Id == id).Position))]; + + return new CommentGesture(box.Id, Resizing: false, boxes, carried); + } + + /// Put everything the gesture moves where the pointer has taken it. + /// The engine. + /// The gesture. + /// How far the pointer has moved since the gesture began, in engine space. + private static void ApplyCommentGesture(NodeEditorEngine engine, CommentGesture gesture, Vector2 delta) + { + if (gesture.Resizing) + { + CommentBox before = gesture.Boxes[0]; + engine.ResizeCommentBox(before.Id, before.Size + delta); + return; + } + + foreach (CommentBox before in gesture.Boxes) + { + Reposition(engine, before, before.Position + delta); + } + + foreach ((int nodeId, Vector2 from) in gesture.Nodes) + { + engine.UpdateNodePosition(nodeId, from + delta); + } + } + + private static void Reposition(NodeEditorEngine engine, CommentBox before, Vector2 position) + { + int index = engine.CommentBoxes.ToList().FindIndex(b => b.Id == before.Id); + if (index >= 0) + { + engine.RestoreCommentBox(engine.CommentBoxes[index] with { Position = position }, index); + } + } + + /// Snap what the gesture moved, if snapping is on, and record the gesture. + private void FinishCommentGesture(NodeEditorEngine engine, CommentGesture gesture) + { + CommentBox? primary = engine.FindCommentBox(gesture.BoxId); + if (primary is null) + { + return; + } + + if (SnapToGrid && gesture.Resizing) + { + engine.ResizeCommentBox(primary.Id, SnapPositionToGrid(primary.Max) - primary.Position); + } + else if (SnapToGrid) + { + Vector2 correction = SnapPositionToGrid(primary.Position) - primary.Position; + Vector2 total = primary.Position + correction - gesture.Boxes[0].Position; + ApplyCommentGesture(engine, gesture, total); + } + + List<(CommentBox Before, CommentBox After)> boxes = [.. gesture.Boxes + .Select(before => (before, engine.FindCommentBox(before.Id))) + .Where(pair => pair.Item2 is not null) + .Select(pair => (pair.before, pair.Item2!))]; + + List moves = [.. gesture.Nodes + .Select(n => (n, engine.Nodes.FirstOrDefault(node => node.Id == n.NodeId))) + .Where(pair => pair.Item2 is not null) + .Select(pair => new NodeMove(pair.n.NodeId, pair.n.From, pair.Item2!.Position))]; + + History?.RecordCommentBoxGesture(gesture.Resizing ? "Resize comment" : "Move comment", boxes, moves); + } + + /// + /// Draw the title bar's rename box, and commit or cancel when the user is done with it. + /// + private void HandleRename(NodeEditorEngine engine, CommentBox box, Vector2 titleMin, float titleRight, float titleBarHeight) + { + int frame = ImGui.GetFrameCount(); + if (renameStartFrame < 0) + { + // The first frame the rename box is drawn, which is when it asks for the keyboard. + renameStartFrame = frame; + renameBuffer = box.Title; + } + + float padding = ImGui.GetStyle().FramePadding.X; + ImGui.SetCursorScreenPos(titleMin + new Vector2(padding * 0.5f, Math.Max(0f, (titleBarHeight - ImGui.GetFrameHeight()) * 0.5f))); + ImGui.SetNextItemWidth(Math.Max(20f, titleRight - titleMin.X - padding)); + + bool justStarted = frame - renameStartFrame < 2; + if (frame == renameStartFrame) + { + ImGui.SetKeyboardFocusHere(); + } + + bool entered = ImGui.InputText("rename", ref renameBuffer, CommentTitleMaxLength, ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll); + bool cancelled = ImGui.IsKeyPressed(ImGuiKey.Escape, repeat: false); + bool finished = entered || cancelled || ImGui.IsItemDeactivated() || (!justStarted && !ImGui.IsItemActive()); + + if (!finished) + { + return; + } + + RenamingCommentBoxId = null; + string title = renameBuffer.Trim(); + if (cancelled || title.Length == 0 || title == box.Title) + { + return; + } + + if (History is NodeEditorHistory history) + { + history.RenameCommentBox(box.Id, title); + } + else + { + engine.RenameCommentBox(box.Id, title); + } + } + + /// A comment box drag or resize in progress. + /// The box the gesture started on. + /// Whether it is resizing the box rather than moving it. + /// The boxes it moves as they were when it started, its own first. + /// The nodes it carries and where each was when it started. + private sealed record CommentGesture(int BoxId, bool Resizing, List Boxes, List<(int NodeId, Vector2 From)> Nodes); +} diff --git a/ImGui.NodeEditor/NodeEditorRenderer.cs b/ImGui.NodeEditor/NodeEditorRenderer.cs index f816cb69..79af4b8d 100644 --- a/ImGui.NodeEditor/NodeEditorRenderer.cs +++ b/ImGui.NodeEditor/NodeEditorRenderer.cs @@ -13,7 +13,7 @@ namespace ktsu.ImGui.NodeEditor; /// /// Pure rendering class - only handles ImNodes display, no business logic /// -public class NodeEditorRenderer +public partial class NodeEditorRenderer { /// The smallest the view allows. public const float MinZoom = 0.25f; @@ -58,11 +58,89 @@ public class NodeEditorRenderer // read-backs can undo the same transform the render applied. private Vector2 zoomAnchor; + /// Where ImNodes' editor space starts on screen, as of the last Render. + private Vector2 canvasOrigin; + + /// The grid spacing in force during the last Render, before zoom scaled it. + private float gridSpacing = 24f; + + /// The nodes drawn in the last Render, so state kept for nodes that are gone can be dropped. + private readonly HashSet renderedNodeIds = []; + + /// Where each node the user is dragging was when the drag started, in engine space. + private readonly Dictionary dragStarts = []; + + /// The drag finished on the last call to , if one did. + private readonly List completedNodeMoves = []; + + /// Tells one edit of an inline editor's pin from the next. + private readonly PinEditGestures inlineEditGestures = new(); + /// /// Set of node IDs currently being dragged by the user /// + /// + /// This includes nodes being carried by a comment box the user is dragging, so a host that hands + /// this to keeps the layout off them too. + /// public IReadOnlySet CurrentlyDraggedNodes => currentlyDraggedNodes; + /// + /// Where the history of the user's gestures is recorded, or null to record nothing. + /// + /// + /// With a history, the renderer records what only it sees finish: a node drag when the mouse is + /// released, and a comment box moved, resized, renamed or closed. Pin values edited in the inline + /// editors reach the history through whether this + /// is set or not, since the history listens to the engine for those. + /// + public NodeEditorHistory? History { get; set; } + + /// + /// Whether a dragged node snaps to the grid ImNodes draws behind the graph. Off by default. + /// + /// + /// This turns on ImNodes' own grid snapping for the duration of , so the + /// lattice a node lands on is the grid on screen and snapping happens as the node is dragged, + /// with every selected node moved by the same amount as the one under the pointer so a selection + /// keeps its shape. A comment box snaps its corner to the same grid when released. + /// + /// Only a drag is snapped. A node the layout moves goes wherever the layout puts it, so for a + /// graph that is meant to stay on the grid turn the physics off or pin its nodes, and use + /// to bring nodes placed some other way onto it. + /// + /// + /// The grid ImNodes draws is scaled with about the middle of the editor, and + /// its lines stay where ImNodes' panning puts them, so nodes snapped at one zoom sit on a common + /// lattice with each other; nodes snapped at a different zoom may sit on a lattice offset from + /// it. + /// + /// + public bool SnapToGrid { get; set; } + + /// + /// The grid's spacing at the engine's own scale, or null to keep whatever ImNodes' style says + /// (24 unless the host has changed it). + /// + /// This sets the drawn grid as well as the snapping lattice, so the two stay one grid. + public float? GridSpacing + { + get; + set => field = value is float spacing ? Math.Max(spacing, 1f) : null; + } + + /// + /// The moves made by a node drag that finished during the last call to + /// , or nothing if no drag finished then. + /// + /// + /// A drag finishes when the left mouse button is released, so a drag that pauses for a few frames + /// is still one drag. Each move runs from where the node was when the drag started to where the + /// update just returned for it puts it — a position that has already been snapped when + /// is on. , when set, has already recorded them. + /// + public IReadOnlyList CompletedNodeMoves => completedNodeMoves; + /// /// Whether hovering a node draws the links that meet it in the highlight colour. /// @@ -217,6 +295,23 @@ public void Render(NodeEditorEngine engine, Vector2 editorSize) nodeScreenRects.Clear(); pinScreenPositions.Clear(); + // The grid settings go on before the zoom scales the style, so the spacing asked for is the + // spacing at the engine's scale, and come off after it is restored. + ImNodesStylePtr style = ImNodes.GetStyle(); + ImNodesStyleFlags previousFlags = style.Flags; + float previousGridSpacing = style.GridSpacing; + if (GridSpacing is float spacing) + { + style.GridSpacing = spacing; + } + + if (SnapToGrid) + { + style.Flags = previousFlags | ImNodesStyleFlags.GridSnapping; + } + + gridSpacing = style.GridSpacing; + bool scaled = !IsUnzoomed; ScaledStyle restore = default; if (scaled) @@ -229,12 +324,24 @@ public void Render(NodeEditorEngine engine, Vector2 editorSize) ImNodes.BeginNodeEditor(); + // ImNodes places editor space on screen from the cursor as the editor's child window begins, + // so this is where a comment box's editor-space rectangle lands on screen. + canvasOrigin = ImGui.GetCursorScreenPos(); + + // Before any node, so the boxes are drawn in the editor's background channel: over the grid, + // under every node, and under the links, which ImNodes draws into the same channel later. + RenderCommentBoxes(engine); + // Render all nodes + renderedNodeIds.Clear(); foreach (Node node in engine.Nodes) { RenderNode(engine, node, highlightColor); + renderedNodeIds.Add(node.Id); } + ForgetNodesNotRendered(); + // Render all links foreach (Link link in engine.Links) { @@ -258,6 +365,83 @@ public void Render(NodeEditorEngine engine, Vector2 editorSize) RestoreImNodesStyle(restore); ImGui.PopFont(); } + + style.Flags = previousFlags; + style.GridSpacing = previousGridSpacing; + } + + /// + /// Drop what is remembered about nodes that were not drawn this frame. + /// + /// + /// ImNodes forgets a node it was not given for a frame, so a node that comes back — an undone + /// deletion restores a node under its old id — is a new node to ImNodes and has to have its + /// position written again. Remembering where it was last drawn would make the renderer think + /// ImNodes already had it, skip the write, and read ImNodes' default position back as a drag. + /// + private void ForgetNodesNotRendered() + { + foreach (int stale in lastKnownNodePositions.Keys.Where(id => !renderedNodeIds.Contains(id)).ToList()) + { + lastKnownNodePositions.Remove(stale); + lastKnownNodeDimensions.Remove(stale); + dragStarts.Remove(stale); + } + } + + /// + /// Where a position lands when snapped to the grid ImNodes draws. + /// + /// A position in engine space. + /// The nearest grid point, in engine space. + /// + /// ImNodes snaps in its grid space — editor space less the panning — to multiples of the zoomed + /// spacing, and so does this, so a comment box and a node snapped at the same zoom share a + /// lattice. Uses the spacing and zoom of the last . + /// + public Vector2 SnapPositionToGrid(Vector2 position) + { + float spacing = gridSpacing * Zoom; + if (spacing <= 0f) + { + return position; + } + + Vector2 panning = ImNodes.EditorContextGetPanning(); + Vector2 grid = ToView(position) - panning; + Vector2 snapped = new(MathF.Round(grid.X / spacing) * spacing, MathF.Round(grid.Y / spacing) * spacing); + return ToEngine(snapped + panning); + } + + /// + /// Move nodes onto the grid, each to its nearest grid point. + /// + /// The engine holding the nodes. + /// The nodes to move. Ids naming no node are skipped. + /// How many nodes moved. + /// + /// For nodes that did not arrive by a drag, which does not touch: + /// created in code, placed by the layout, or loaded from a file. Recorded as one step when + /// is set. + /// + public int SnapNodesToGrid(NodeEditorEngine engine, IEnumerable nodeIds) + { + Ensure.NotNull(engine); + Ensure.NotNull(nodeIds); + + HashSet wanted = [.. nodeIds]; + List moves = [.. engine.Nodes + .Where(n => wanted.Contains(n.Id)) + .Select(n => new NodeMove(n.Id, n.Position, SnapPositionToGrid(n.Position))) + .Where(m => m.From != m.To)]; + + foreach (NodeMove move in moves) + { + engine.UpdateNodePosition(move.NodeId, move.To); + } + + History?.RecordNodeMoves(moves, "Snap to grid"); + return moves.Count; } /// @@ -510,131 +694,94 @@ private void DrawInlineEditor(NodeEditorEngine engine, Pin pin) string id = $"##pin{pin.Id}"; object? current = engine.GetPinValue(pin.Id); - switch (kind) + (bool changed, object? value) = kind switch { - case PinValueKind.Boolean: - DrawBooleanEditor(engine, pin, id, current); - break; - - case PinValueKind.Int32: - DrawInt32Editor(engine, pin, id, current); - break; - - case PinValueKind.Single: - DrawSingleEditor(engine, pin, id, current); - break; - - case PinValueKind.Double: - DrawDoubleEditor(engine, pin, id, current); - break; - - case PinValueKind.String: - DrawStringEditor(engine, pin, id, current); - break; - - case PinValueKind.Vector2: - DrawVector2Editor(engine, pin, id, current); - break; - - case PinValueKind.Vector3: - DrawVector3Editor(engine, pin, id, current); - break; - - case PinValueKind.Enum: - DrawEnumEditor(engine, pin, id, current); - break; - - // No Unsupported case: the early return above has already taken that path, and default - // covers every kind regardless. - default: - break; + PinValueKind.Boolean => EditBoolean(id, current), + PinValueKind.Int32 => EditInt32(id, current), + PinValueKind.Single => EditSingle(id, current), + PinValueKind.Double => EditDouble(id, current), + PinValueKind.String => EditString(id, current), + PinValueKind.Vector2 => EditVector2(id, current), + PinValueKind.Vector3 => EditVector3(id, current), + PinValueKind.Enum => EditEnum(pin, id, current), + + // No Unsupported case: the early return above has already taken that path. + _ => (false, current), + }; + + // Tracked every frame the widget is drawn rather than only when it changes, so the + // activation that starts a gesture is seen. A pick from an enum's list is a gesture of its + // own, and the item the list leaves behind is the combo rather than the pick, so it has none. + long gesture = inlineEditGestures.Track(pin.Id); + if (changed) + { + engine.SetPinValue(pin.Id, value, kind == PinValueKind.Enum ? null : gesture); } } - /// Draw a pin as a checkbox. - private static void DrawBooleanEditor(NodeEditorEngine engine, Pin pin, string id, object? current) + /// Edit a pin as a checkbox. + private static (bool Changed, object? Value) EditBoolean(string id, object? current) { bool value = current as bool? ?? false; - if (ImGui.Checkbox(id, ref value)) - { - engine.SetPinValue(pin.Id, value); - } + return (ImGui.Checkbox(id, ref value), value); } - /// Draw an pin as a drag box. - private static void DrawInt32Editor(NodeEditorEngine engine, Pin pin, string id, object? current) + /// Edit an pin as a drag box. + private static (bool Changed, object? Value) EditInt32(string id, object? current) { int value = current as int? ?? 0; - if (ImGui.DragInt(id, ref value)) - { - engine.SetPinValue(pin.Id, value); - } + return (ImGui.DragInt(id, ref value), value); } - /// Draw a pin as a drag box. - private static void DrawSingleEditor(NodeEditorEngine engine, Pin pin, string id, object? current) + /// Edit a pin as a drag box. + private static (bool Changed, object? Value) EditSingle(string id, object? current) { float value = current as float? ?? 0f; - if (ImGui.DragFloat(id, ref value)) - { - engine.SetPinValue(pin.Id, value); - } + return (ImGui.DragFloat(id, ref value), value); } - /// Draw a pin as an input box. - private static void DrawDoubleEditor(NodeEditorEngine engine, Pin pin, string id, object? current) + /// Edit a pin as an input box. + private static (bool Changed, object? Value) EditDouble(string id, object? current) { double value = current as double? ?? 0.0; - if (ImGui.InputDouble(id, ref value)) - { - engine.SetPinValue(pin.Id, value); - } + return (ImGui.InputDouble(id, ref value), value); } - /// Draw a pin as a text box. - private static void DrawStringEditor(NodeEditorEngine engine, Pin pin, string id, object? current) + /// Edit a pin as a text box. + private static (bool Changed, object? Value) EditString(string id, object? current) { string value = current as string ?? string.Empty; - if (ImGui.InputText(id, ref value, 256)) - { - engine.SetPinValue(pin.Id, value); - } + return (ImGui.InputText(id, ref value, 256), value); } - /// Draw a pin as a two-component input box. - private static void DrawVector2Editor(NodeEditorEngine engine, Pin pin, string id, object? current) + /// Edit a pin as a two-component input box. + private static (bool Changed, object? Value) EditVector2(string id, object? current) { Vector2 value = current as Vector2? ?? Vector2.Zero; - if (ImGui.InputFloat2(id, ref value)) - { - engine.SetPinValue(pin.Id, value); - } + return (ImGui.InputFloat2(id, ref value), value); } - /// Draw a pin as a three-component input box. - private static void DrawVector3Editor(NodeEditorEngine engine, Pin pin, string id, object? current) + /// Edit a pin as a three-component input box. + private static (bool Changed, object? Value) EditVector3(string id, object? current) { Vector3 value = current as Vector3? ?? Vector3.Zero; - if (ImGui.InputFloat3(id, ref value)) - { - engine.SetPinValue(pin.Id, value); - } + return (ImGui.InputFloat3(id, ref value), value); } /// - /// Draw an enum pin as a list of its names. + /// Edit an enum pin as a list of its names. /// - /// The engine holding the value. /// The pin. /// The widget's id. /// What the pin holds now. + /// Whether a name was picked, and the value it stands for. /// /// Matched by value, not by ToString() against the defined names: a value not defined in /// the type (a cast integer, a flags combination) has no matching name, and picking index 0 for /// it would display a name the pin does not actually hold, without writing it back. Such a value /// shows a blank preview instead until the user picks a defined one. /// - private static void DrawEnumEditor(NodeEditorEngine engine, Pin pin, string id, object? current) + private static (bool Changed, object? Value) EditEnum(Pin pin, string id, object? current) { Type enumType = Nullable.GetUnderlyingType(pin.DataType!) ?? pin.DataType!; string[] names = Enum.GetNames(enumType); @@ -643,6 +790,7 @@ private static void DrawEnumEditor(NodeEditorEngine engine, Pin pin, string id, int index = current is null ? -1 : Array.IndexOf(values, current); string preview = index >= 0 ? names[index] : string.Empty; + (bool Changed, object? Value) result = (false, current); if (ImGui.BeginCombo(id, preview)) { for (int i = 0; i < names.Length; i++) @@ -650,7 +798,7 @@ private static void DrawEnumEditor(NodeEditorEngine engine, Pin pin, string id, bool selected = i == index; if (ImGui.Selectable(names[i], selected)) { - engine.SetPinValue(pin.Id, values.GetValue(i)); + result = (true, values.GetValue(i)); } if (selected) @@ -661,6 +809,8 @@ private static void DrawEnumEditor(NodeEditorEngine engine, Pin pin, string id, ImGui.EndCombo(); } + + return result; } /// @@ -971,10 +1121,19 @@ private static float FittingZoom(Vector2 extent, Vector2 editorSize) /// /// Check for nodes that have moved and return their new positions /// + /// + /// Also notices when a drag ends, which is what reports and + /// records. A node counts as dragged when it moves while it is selected + /// and the left button is down, which is how ImNodes drags nodes; a node that moves otherwise has + /// been panned, and panning is not an edit. + /// public Dictionary GetNodePositionUpdates(NodeEditorEngine engine) { + Ensure.NotNull(engine); Dictionary updates = []; currentlyDraggedNodes.Clear(); + completedNodeMoves.Clear(); + bool leftDown = ImGui.IsMouseDown(ImGuiMouseButton.Left); foreach (Node node in engine.Nodes) { @@ -994,12 +1153,51 @@ public Dictionary GetNodePositionUpdates(NodeEditorEngine engine) updates[node.Id] = ToEngine(currentImNodesPos); lastKnownNodePositions[node.Id] = currentImNodesPos; currentlyDraggedNodes.Add(node.Id); + + if (leftDown && selectedNodes.Contains(node.Id)) + { + dragStarts.TryAdd(node.Id, node.Position); + } } } + if (!leftDown && dragStarts.Count > 0) + { + FinishDrag(engine, updates); + } + + foreach (int carried in CommentBoxCarriedNodes) + { + currentlyDraggedNodes.Add(carried); + } + return updates; } + /// + /// Turn the drag that just ended into moves, and record them. + /// + private void FinishDrag(NodeEditorEngine engine, Dictionary updates) + { + foreach ((int nodeId, Vector2 from) in dragStarts) + { + Node? node = engine.Nodes.FirstOrDefault(n => n.Id == nodeId); + if (node is null) + { + continue; + } + + Vector2 to = updates.TryGetValue(nodeId, out Vector2 updated) ? updated : node.Position; + if (from != to) + { + completedNodeMoves.Add(new NodeMove(nodeId, from, to)); + } + } + + dragStarts.Clear(); + History?.RecordNodeMoves(completedNodeMoves); + } + /// /// Check for nodes that have been resized and return their new dimensions /// diff --git a/ImGui.NodeEditor/NodeInspectorPanel.cs b/ImGui.NodeEditor/NodeInspectorPanel.cs index 90d57a10..9b7b82dd 100644 --- a/ImGui.NodeEditor/NodeInspectorPanel.cs +++ b/ImGui.NodeEditor/NodeInspectorPanel.cs @@ -113,63 +113,77 @@ private static void DrawRow(ImGuiWidgets.PropertyGrid grid, NodeEditorEngine eng private static void DrawBoolean(ImGuiWidgets.PropertyGrid grid, NodeEditorEngine engine, Pin pin, string label, object? current, bool editable) { bool value = current as bool? ?? false; - if (grid.Value(label, ref value) && editable) + bool changed = grid.Value(label, ref value); + long gesture = Gestures.Track(pin.Id); + if (changed && editable) { - engine.SetPinValue(pin.Id, value); + engine.SetPinValue(pin.Id, value, gesture); } } private static void DrawInt32(ImGuiWidgets.PropertyGrid grid, NodeEditorEngine engine, Pin pin, string label, object? current, bool editable) { int value = current as int? ?? 0; - if (grid.Value(label, ref value) && editable) + bool changed = grid.Value(label, ref value); + long gesture = Gestures.Track(pin.Id); + if (changed && editable) { - engine.SetPinValue(pin.Id, value); + engine.SetPinValue(pin.Id, value, gesture); } } private static void DrawSingle(ImGuiWidgets.PropertyGrid grid, NodeEditorEngine engine, Pin pin, string label, object? current, bool editable) { float value = current as float? ?? 0f; - if (grid.Value(label, ref value) && editable) + bool changed = grid.Value(label, ref value); + long gesture = Gestures.Track(pin.Id); + if (changed && editable) { - engine.SetPinValue(pin.Id, value); + engine.SetPinValue(pin.Id, value, gesture); } } private static void DrawDouble(ImGuiWidgets.PropertyGrid grid, NodeEditorEngine engine, Pin pin, string label, object? current, bool editable) { double value = current as double? ?? 0.0; - if (grid.Value(label, ref value) && editable) + bool changed = grid.Value(label, ref value); + long gesture = Gestures.Track(pin.Id); + if (changed && editable) { - engine.SetPinValue(pin.Id, value); + engine.SetPinValue(pin.Id, value, gesture); } } private static void DrawString(ImGuiWidgets.PropertyGrid grid, NodeEditorEngine engine, Pin pin, string label, object? current, bool editable) { string value = current as string ?? string.Empty; - if (grid.Value(label, ref value) && editable) + bool changed = grid.Value(label, ref value); + long gesture = Gestures.Track(pin.Id); + if (changed && editable) { - engine.SetPinValue(pin.Id, value); + engine.SetPinValue(pin.Id, value, gesture); } } private static void DrawVector2(ImGuiWidgets.PropertyGrid grid, NodeEditorEngine engine, Pin pin, string label, object? current, bool editable) { Vector2 value = current as Vector2? ?? Vector2.Zero; - if (grid.Value(label, ref value) && editable) + bool changed = grid.Value(label, ref value); + long gesture = Gestures.Track(pin.Id); + if (changed && editable) { - engine.SetPinValue(pin.Id, value); + engine.SetPinValue(pin.Id, value, gesture); } } private static void DrawVector3(ImGuiWidgets.PropertyGrid grid, NodeEditorEngine engine, Pin pin, string label, object? current, bool editable) { Vector3 value = current as Vector3? ?? Vector3.Zero; - if (grid.Value(label, ref value) && editable) + bool changed = grid.Value(label, ref value); + long gesture = Gestures.Track(pin.Id); + if (changed && editable) { - engine.SetPinValue(pin.Id, value); + engine.SetPinValue(pin.Id, value, gesture); } } @@ -180,6 +194,12 @@ private static void DrawUnsupported(ImGuiWidgets.PropertyGrid grid, string label } /// The open generic PropertyGrid.Enum<TEnum> method, resolved once. + /// + /// Tells one edit of a row's pin from the next, so a history folds a drag or a typing session + /// into one step. See . + /// + private static readonly PinEditGestures Gestures = new(); + private static readonly MethodInfo EnumRowMethod = typeof(ImGuiWidgets.PropertyGrid).GetMethod(nameof(ImGuiWidgets.PropertyGrid.Enum)) ?? throw new MissingMethodException(nameof(ImGuiWidgets.PropertyGrid), nameof(ImGuiWidgets.PropertyGrid.Enum)); diff --git a/ImGui.NodeEditor/PinEditGestures.cs b/ImGui.NodeEditor/PinEditGestures.cs new file mode 100644 index 00000000..cb1d1673 --- /dev/null +++ b/ImGui.NodeEditor/PinEditGestures.cs @@ -0,0 +1,56 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.NodeEditor; + +using System.Collections.Generic; +using System.Threading; +using Hexa.NET.ImGui; + +/// +/// Tells one continuous edit of a pin's value from the next, for +/// . +/// +/// +/// An edit gesture starts when the widget editing the pin is activated — the press on a checkbox or +/// a drag box, the click into a text field — and lasts until the next activation. Every write in +/// between shares one token, which is what lets fold a drag across +/// a hundred frames into one step while keeping two separate drags of the same slider apart. +/// +/// has to be called on every frame the widget is drawn, straight after it, not +/// only on the frames it reports a change: a checkbox is activated on the frame it is pressed and +/// changes on the frame it is released, and a drag box can be activated a frame before it moves. +/// +/// +/// Tokens come from one counter shared by every instance, so the inline editors and the inspector +/// cannot issue the same token for the same pin and have their edits merged into each other. +/// +/// +internal sealed class PinEditGestures +{ + private static long lastIssued; + + private readonly Dictionary current = []; + + /// + /// The gesture the last-submitted widget's edit of a pin belongs to. + /// + /// The pin the widget edits. + /// The token for the gesture in progress, which is a new one if the widget was just activated. + public long Track(int pinId) + { + if (ImGui.IsItemActivated() || !current.TryGetValue(pinId, out long gesture)) + { + gesture = IssueGesture(); + current[pinId] = gesture; + } + + return gesture; + } + + /// A token no instance has issued before. + private static long IssueGesture() => Interlocked.Increment(ref lastIssued); + + /// Forget a pin that is gone. + /// The pin. + public void Forget(int pinId) => current.Remove(pinId); +} diff --git a/ImGui.NodeEditor/PinValueStore.cs b/ImGui.NodeEditor/PinValueStore.cs index 06f99404..0b8ae5a1 100644 --- a/ImGui.NodeEditor/PinValueStore.cs +++ b/ImGui.NodeEditor/PinValueStore.cs @@ -97,6 +97,42 @@ public void Clear() defaults.Clear(); } + /// + /// Read a pin's seeded default, if it has one. + /// + /// The pin. + /// Whether it was seeded. + /// What it was seeded with. + internal void TryGetDefault(int pinId, out bool hasDefault, out object? defaultValue) => + hasDefault = defaults.TryGetValue(pinId, out defaultValue); + + /// + /// Put a pin back exactly as it was held, default and all, with no type check. + /// + /// The pin. + /// Its value. + /// Whether it had a seeded default. + /// That default. + internal void Restore(int pinId, object? value, bool hasDefault, object? defaultValue) + { + values[pinId] = value; + if (hasDefault) + { + defaults[pinId] = defaultValue; + } + else + { + defaults.Remove(pinId); + } + } + + /// + /// Put a pin's value back with no type check, leaving its default alone. + /// + /// The pin. + /// Its value. + internal void Restore(int pinId, object? value) => values[pinId] = value; + /// /// Whether a pin of this type will take this value. /// diff --git a/ImGui.NodeEditor/README.md b/ImGui.NodeEditor/README.md index 0d1c4f67..a234b2d0 100644 --- a/ImGui.NodeEditor/README.md +++ b/ImGui.NodeEditor/README.md @@ -15,6 +15,10 @@ ImGui.NodeEditor is a visual node editor built on ImNodes, with the graph itself - **Connections are checked**: `TryCreateLink` returns a result with a message rather than throwing, and refuses a link that joins two pins of the same direction, duplicates one that exists, exceeds what a pin will accept, or joins a node to itself. It does not yet compare the pins' declared types - **Physics-based layout**: nodes repel, links pull, and the graph settles; powered by [`ktsu.ForceDirectedLayout`](https://github.com/ktsu-dev/ImGuiApp), with per-frame stability and energy readings for debug overlays - **Drag-aware**: nodes being dragged are excluded from the simulation, and the renderer reports position and size changes back to the engine +- **Undo and redo**: `NodeEditorHistory` records every change as the difference it made, onto a [`ktsu.UndoRedo`](https://github.com/ktsu-dev/UndoRedo) stack — node creation, deletion and duplication, links, drags, comment boxes and pin values, with a slider dragged across many frames undoing in one step +- **Keymap-driven commands**: `NodeEditorInputHandler` reads delete, duplicate, undo and redo from a [`ktsu.Keybinding`](https://github.com/ktsu-dev/Keybinding) keymap when given one, so the host's profiles and rebinding apply to the graph; without one it uses Delete, Ctrl+D, Ctrl+Z and Ctrl+Y +- **Grid snapping**: `NodeEditorRenderer.SnapToGrid` snaps dragged nodes to the grid drawn behind them, and `SnapNodesToGrid` brings nodes placed in code onto it +- **Comment boxes**: labelled, coloured regions drawn behind the nodes; dragging a box's title carries the nodes inside it, and boxes can be resized, renamed in place and closed ## Installation @@ -224,6 +228,77 @@ float energy = engine.TotalSystemEnergy; renderer.RenderDebugOverlays(engine, editorPosition, editorSize, showDebug: true); ``` +### Undo and redo + +```csharp +NodeEditorHistory history = new(engine, factory); // the factory is optional +renderer.History = history; // records drags and comment box gestures + +// Structural edits go through the history, or are wrapped in Record +history.TryCreateLink(fromPin, toPin); +history.RemoveNodes(selectedIds); +Node made = history.Record("Add filter", () => factory.CreateNode(position)); + +// Keys arrive as requests, like every other gesture +InputEvents events = inputHandler.ProcessInput(); +if (events.UndoRequested) { history.Undo(); } +if (events.RedoRequested) { history.Redo(); } +``` + +A step is the difference a change made, not a copy of the graph. Undoing a deletion puts back the +node — under its old id, with its pins, values and links — and leaves every other node where the +layout has since moved it. Pin values written by the inline editors and `NodeInspectorPanel` are +recorded without being asked, and the writes of one gesture (a drag of a slider, one session of +typing) merge into a single step. Pass the `AttributeBasedNodeFactory` the nodes were made with so +an undone deletion reattaches the node's instance. + +Creating, deleting and linking nodes directly on the engine is not recorded — route those through +the history. `engine.Clear()` called outside `Record` empties the history, since it restarts the id +counters; inside `Record` it is an ordinary undoable step. + +The stack is an ordinary `IUndoRedoService`, so a host that already keeps one for the rest of its +document can pass it to `new NodeEditorHistory(engine, service, factory)` and the graph's steps +interleave with its own. Each graph step carries a `node:` navigation context for an +`INavigationProvider` to pan to. + +### Keyboard commands from a keymap + +```csharp +KeybindingManager keys = new("./keybindings"); +await keys.InitializeAsync(); +keys.CreateDefaultProfile(); +NodeEditorCommands.Register(keys.Commands, keys.Keybindings); // binds defaults only where unbound + +NodeEditorInputHandler inputHandler = new(keys.Keybindings); +``` + +`NodeEditorCommands` names the four commands (`nodeeditor.undo`, `nodeeditor.redo`, +`nodeeditor.delete`, `nodeeditor.duplicate`) and their default chords. The handler reads each +command's chord from the active profile every frame, so a chord the user rebinds takes effect at +once. A chord matches only when its modifiers are exactly the ones held, and none of the commands +fire while a text field has the keyboard. + +### Grid snapping and comment boxes + +```csharp +renderer.SnapToGrid = true; +renderer.GridSpacing = 16f; // the drawn grid follows it +renderer.SnapNodesToGrid(engine, renderer.SelectedNodeIds); // for nodes that did not arrive by drag + +CommentBox box = history.CreateCommentBoxAround(renderer.SelectedNodeIds, "Image Preprocessing")!; +engine.SetCommentBoxColor(box.Id, new Vector4(0.2f, 0.5f, 0.9f, 0.25f)); +``` + +Snapping is ImNodes' own, so the lattice is the grid on screen and a multi-node selection keeps its +shape as it snaps. Only a drag snaps: a node the layout moves goes where the layout puts it, so a +graph meant to stay on the grid wants physics off or its nodes pinned. + +A comment box is drawn behind every node and link. It does not own nodes: what it contains is +whatever lies wholly inside it when asked, so dragging a node out takes it out. Dragging a box's +title carries its nodes and any boxes nested in it, the handle in its bottom-right corner resizes +it, a double-click on its title renames it in place, and the cross closes it without touching its +nodes. Comment boxes take no part in the layout. + ## API Reference ### `NodeEditorEngine` @@ -253,6 +328,26 @@ The graph and its physics. No ImGui calls. | `UpdatePhysics(float)` | `void` | Advances the layout by a frame delta | | `NodeRemoved` | `event EventHandler` | Raised after a node is removed, so anything keyed by node id can drop its entry | | `Cleared` | `event EventHandler` | Raised after `Clear()`, which also restarts the id counters | +| `PinValueChanged` | `event EventHandler` | Raised after `SetPinValue` or `ResetPinValue` writes, with the old and new value and the edit gesture | +| `SetPinValue(int, object?, long?)` | `bool` | Writes a value as part of an edit gesture, so the writes of one drag are one change | +| `CommentBoxes` | `IReadOnlyList` | Every comment box, in drawing order | +| `CreateCommentBox(...)` / `CreateCommentBoxAround(...)` | `CommentBox` / `CommentBox?` | Adds a box at a rectangle, or around a set of nodes | +| `MoveCommentBox(int, Vector2)` | `IReadOnlyList` | Moves a box with everything inside it | +| `RenameCommentBox` / `ResizeCommentBox` / `SetCommentBoxColor` / `RemoveCommentBox` | `bool` | Edits or removes a box | +| `GetNodesInCommentBox(int)` | `IReadOnlyList` | The nodes lying wholly inside a box | + +### `NodeEditorHistory` + +| Name | Return Type | Description | +| ---- | ----------- | ----------- | +| `Record(string, Func)` / `Record(string, Action)` | `T` / `void` | Runs a change and records what it did as one step | +| `Undo()` / `Redo()` | `bool` | Undoes or redoes a step | +| `CanUndo` / `CanRedo` | `bool` | Whether there is one | +| `NextUndoDescription` / `NextRedoDescription` | `string?` | What it would be, for a menu | +| `TryCreateLink`, `RemoveLink(s)`, `RemoveNode(s)`, `DuplicateNodes` | as on the engine | The engine's structural edits, recorded | +| `CreateCommentBox(Around)`, `MoveCommentBox`, `RenameCommentBox`, `RemoveCommentBox` | as on the engine | The comment box edits, recorded | +| `RecordNodeMoves(IEnumerable, string?)` | `bool` | Records moves already made, such as a finished drag | +| `Service` | `IUndoRedoService` | The underlying `ktsu.UndoRedo` stack | ### `NodeEditorRenderer` @@ -264,6 +359,12 @@ The graph and its physics. No ImGui calls. | `RenderDebugOverlays(...)` | `void` | Force and stability overlays | | `CurrentlyDraggedNodes` | `IReadOnlySet` | Nodes the user is dragging this frame | | `DrawNodeBody` | `Action?` | Called inside each node, after its pins, to draw host content in the node body | +| `History` | `NodeEditorHistory?` | Where finished drags and comment box gestures are recorded | +| `SnapToGrid` / `GridSpacing` | `bool` / `float?` | Snaps dragged nodes to the drawn grid, and sets its spacing | +| `SnapNodesToGrid(NodeEditorEngine, IEnumerable)` | `int` | Moves nodes onto the grid | +| `CompletedNodeMoves` | `IReadOnlyList` | The moves made by a drag that finished this frame | +| `DrawCommentBoxes` / `CommentBoxColor` | `bool` / `Vector4?` | Whether comment boxes are drawn, and their default fill | +| `TryGetCommentBoxScreenRect(int, out ScreenRect)` | `bool` | Where a comment box was drawn | #### Host content in a node body @@ -311,7 +412,7 @@ PhysicsSettingsPanel.DrawDiagnostics(engine); ### `NodeEditorInputHandler` -`ProcessInput()` returns `InputEvents`, holding `LinkCreationRequests` (`LinkCreationRequest(FromPinId, ToPinId)`) and `LinkDeletionRequests`. +`ProcessInput()` returns `InputEvents`, holding `LinkCreationRequests` (`LinkCreationRequest(FromPinId, ToPinId)`), `LinkDeletionRequests`, `NodeDeletionRequests`, `NodeDuplicationRequests`, `UndoRequested` and `RedoRequested`. Construct it with an `IKeybindingService`, or set `Keybindings`, to take the keys from a keymap; see `NodeEditorCommands`. ### `AttributeBasedNodeFactory` @@ -331,6 +432,8 @@ PhysicsSettingsPanel.DrawDiagnostics(engine); `Node(Id, Position, Name, InputPins, OutputPins, Dimensions, Velocity, Force, IsPinned)`, `Link(Id, OutputPinId, InputPinId)` and `Pin(Id, Direction, Name, DisplayName)` are records; `PinDirection` is `Input` or `Output`. +`CommentBox(Id, Title, Position, Size, Color)` is a comment box, and `NodeMove(NodeId, From, To)` one node's move. + `NodeBinding(NodeId, Definition, Instance)` ties a node back to what it was created from; `NodeRemovedEventArgs` carries the `NodeId` of a removed node. `PinValueAccessor(Get, Set)` says where a pin's value lives when it does not live in the engine's store, and `PinSpec(Name, DataType, DefaultValue, AllowMultipleConnections)` is what a pin is created from. ## Acknowledgments diff --git a/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs b/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs index 00a87a1a..ec246955 100644 --- a/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs +++ b/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs @@ -6,6 +6,8 @@ namespace ktsu.ImGui.Examples.App.Demos; using Hexa.NET.ImGui; using ktsu.ForceDirectedLayout; using ktsu.ImGui.NodeEditor; +using ktsu.ImGui.Widgets; +using ktsu.Keybinding.Core.Services; using ktsu.NodeGraph.Library.Operations; using ktsu.NodeGraph.Library.Primitives; using ktsu.NodeGraph.Library.Utilities; @@ -13,7 +15,12 @@ namespace ktsu.ImGui.Examples.App.Demos; /// /// Clean architecture ImNodes demo with proper separation of concerns /// -internal sealed class CleanImNodesDemo : IDemoTab +/// +/// Every edit goes through a , so the whole tab is undoable with +/// Ctrl+Z and Ctrl+Y. The keys come from an in-memory ktsu.Keybinding keymap, the way an +/// application with its own keymap would supply them. +/// +internal sealed class CleanImNodesDemo : IDemoTab, IDisposable { public string TabName => "Clean ImNodes"; @@ -21,9 +28,13 @@ internal sealed class CleanImNodesDemo : IDemoTab private readonly NodeEditorEngine engine = new(); private readonly AttributeBasedNodeFactory nodeFactory; + // Undo and redo for everything below + private readonly NodeEditorHistory history; + // Presentation layers private readonly NodeEditorRenderer renderer = new(); - private readonly NodeEditorInputHandler inputHandler = new(); + private readonly NodeEditorInputHandler inputHandler; + private readonly KeybindingService keybindings; // UI state private bool showDebugVisualization; @@ -36,8 +47,22 @@ public CleanImNodesDemo() RegisterNodeTypes(); CreateDemoData(); engine.InitializeWorldOriginToCentroid(); + + // Created after the demo data, so the starting graph is where the history begins rather than + // something Ctrl+Z can take apart. + history = new NodeEditorHistory(engine, nodeFactory); + renderer.History = history; + + CommandRegistry commands = new(); + keybindings = new KeybindingService(commands, new ProfileManager()); + keybindings.CreateProfile("default", "Default"); + keybindings.SetActiveProfile("default"); + NodeEditorCommands.Register(commands, keybindings); + inputHandler = new NodeEditorInputHandler(keybindings); } + public void Dispose() => history.Dispose(); + public void Update(float deltaTime) { // Inform physics which nodes are being dragged so they're excluded from simulation @@ -110,13 +135,47 @@ private void ProcessInputEvents() ProcessLinkDeletionRequests(events); ProcessNodeDeletionRequests(events); ProcessNodeDuplicationRequests(events); + ProcessHistoryRequests(events); + } + + private void ProcessHistoryRequests(InputEvents events) + { + if (events.UndoRequested) + { + Undo(); + } + + if (events.RedoRequested) + { + Redo(); + } + } + + private void Undo() + { + string? description = history.NextUndoDescription; + if (history.Undo()) + { + lastActionMessage = $"Undid: {description}"; + lastActionColor = new Vector4(0.8f, 0.8f, 1.0f, 1.0f); + } + } + + private void Redo() + { + string? description = history.NextRedoDescription; + if (history.Redo()) + { + lastActionMessage = $"Redid: {description}"; + lastActionColor = new Vector4(0.8f, 0.8f, 1.0f, 1.0f); + } } private void ProcessLinkCreationRequests(InputEvents events) { foreach (LinkCreationRequest request in events.LinkCreationRequests) { - LinkCreationResult result = engine.TryCreateLink(request.FromPinId, request.ToPinId); + LinkCreationResult result = history.TryCreateLink(request.FromPinId, request.ToPinId); if (result.Success) { @@ -136,7 +195,7 @@ private void ProcessLinkDeletionRequests(InputEvents events) { foreach (int linkId in events.LinkDeletionRequests) { - if (engine.RemoveLink(linkId)) + if (history.RemoveLink(linkId)) { lastActionMessage = $"Link {linkId} deleted"; lastActionColor = new Vector4(1.0f, 0.7f, 0.0f, 1.0f); // Orange @@ -153,7 +212,7 @@ private void ProcessNodeDeletionRequests(InputEvents events) { foreach (int nodeId in events.NodeDeletionRequests) { - if (engine.RemoveNode(nodeId)) + if (history.RemoveNode(nodeId)) { lastActionMessage = $"Node {nodeId} deleted"; lastActionColor = new Vector4(1.0f, 0.7f, 0.0f, 1.0f); // Orange @@ -172,7 +231,7 @@ private void ProcessNodeDuplicationRequests(InputEvents events) return; } - IReadOnlyList copies = engine.DuplicateNodes(events.NodeDuplicationRequests, NodeEditorEngine.DefaultDuplicationOffset); + IReadOnlyList copies = history.DuplicateNodes(events.NodeDuplicationRequests, NodeEditorEngine.DefaultDuplicationOffset); if (copies.Count > 0) { lastActionMessage = copies.Count == 1 @@ -241,28 +300,32 @@ private void RenderControlsPanel() if (DemoProbe.Button("Add Input Node")) { Vector2 position = new(100, 100 + (engine.Nodes.Count * 50)); - engine.CreateNode(position, $"Input {engine.Nodes.Count + 1}", 0, 2); + history.Record("Add input node", () => engine.CreateNode(position, $"Input {engine.Nodes.Count + 1}", 0, 2)); } ImGui.SameLine(); if (DemoProbe.Button("Add Process Node")) { Vector2 position = new(300, 100 + (engine.Nodes.Count * 50)); - engine.CreateNode(position, $"Process {engine.Nodes.Count + 1}", 2, 2); + history.Record("Add process node", () => engine.CreateNode(position, $"Process {engine.Nodes.Count + 1}", 2, 2)); } ImGui.SameLine(); if (DemoProbe.Button("Add Output Node")) { Vector2 position = new(500, 100 + (engine.Nodes.Count * 50)); - engine.CreateNode(position, $"Output {engine.Nodes.Count + 1}", 2, 0); + history.Record("Add output node", () => engine.CreateNode(position, $"Output {engine.Nodes.Count + 1}", 2, 0)); } + // Recorded like any other edit, so a reset or a clear is one Ctrl+Z away from being undone. if (DemoProbe.Button("Reset Demo")) { - engine.Clear(); - CreateDemoData(); - engine.InitializeWorldOriginToCentroid(); + history.Record("Reset demo", () => + { + engine.Clear(); + CreateDemoData(); + engine.InitializeWorldOriginToCentroid(); + }); lastActionMessage = "Reset to demo data"; lastActionColor = new Vector4(0.0f, 0.8f, 1.0f, 1.0f); // Cyan } @@ -270,11 +333,17 @@ private void RenderControlsPanel() ImGui.SameLine(); if (DemoProbe.Button("Clear All")) { - engine.Clear(); + history.Record("Clear all", engine.Clear); lastActionMessage = "All nodes and links cleared"; lastActionColor = new Vector4(1.0f, 0.7f, 0.0f, 1.0f); // Orange } + ImGui.SeparatorText("History"); + RenderHistoryControls(); + + ImGui.SeparatorText("Layout Tools"); + RenderLayoutTools(); + // Physics settings ImGui.SeparatorText("Physics Simulation"); RenderPhysicsControls(); @@ -310,6 +379,73 @@ private void RenderControlsPanel() } } + /// + /// Draws undo and redo, with what each would do, and the keys the keymap has them on. + /// + private void RenderHistoryControls() + { + using (new ScopedDisable(!history.CanUndo)) + { + if (DemoProbe.Button("Undo")) + { + Undo(); + } + } + + ImGui.SameLine(); + using (new ScopedDisable(!history.CanRedo)) + { + if (DemoProbe.Button("Redo")) + { + Redo(); + } + } + + ImGui.TextDisabled($"Next undo: {history.NextUndoDescription ?? "nothing"}"); + ImGui.TextDisabled($"Next redo: {history.NextRedoDescription ?? "nothing"}"); + + foreach (ktsu.Keybinding.Core.Models.Command command in NodeEditorCommands.All) + { + ImGui.TextDisabled($"{command.Name}: {keybindings.GetChord(command.Id)?.ToString() ?? "unbound"}"); + } + } + + /// + /// Draws grid snapping and comment boxes, the tools for laying a pipeline out by hand. + /// + private void RenderLayoutTools() + { + bool snap = renderer.SnapToGrid; + if (DemoProbe.Checkbox("Snap to grid", ref snap)) + { + renderer.SnapToGrid = snap; + } + + ImGui.SameLine(); + float spacing = renderer.GridSpacing ?? 24f; + ImGui.SetNextItemWidth(120f); + if (DemoProbe.SliderFloat("Grid spacing", ref spacing, 8f, 64f, "%.0f")) + { + renderer.GridSpacing = spacing; + } + + using (new ScopedDisable(renderer.SelectedNodeIds.Count == 0)) + { + if (DemoProbe.Button("Snap Selection To Grid")) + { + renderer.SnapNodesToGrid(engine, renderer.SelectedNodeIds); + } + + ImGui.SameLine(); + if (DemoProbe.Button("Comment Selection")) + { + history.CreateCommentBoxAround(renderer.SelectedNodeIds, "Comment"); + } + } + + ImGui.TextDisabled("Drag a comment's title to move it with its nodes; double-click to rename."); + } + /// /// Draws the renderer's hover options, which are what a node under the pointer says about the /// rest of the graph. @@ -528,5 +664,9 @@ private void CreateDemoData() new PinSpec("Invert", typeof(bool), false), ], [new PinSpec("Count", typeof(int))]); + + // A comment box labelling a region of the graph, the way issue #468 asked for. It sits behind + // the nodes, and dragging its title carries whatever lies inside it. + engine.CreateCommentBox(new Vector2(20, 500), new Vector2(340, 240), "Parameters, not connections"); } } diff --git a/examples/ImGuiAppDemo/ImGuiAppDemo.csproj b/examples/ImGuiAppDemo/ImGuiAppDemo.csproj index b098b10f..cfeb14e4 100644 --- a/examples/ImGuiAppDemo/ImGuiAppDemo.csproj +++ b/examples/ImGuiAppDemo/ImGuiAppDemo.csproj @@ -28,6 +28,7 @@ + diff --git a/tests/ImGui.NodeEditor.Tests/CommentBoxTests.cs b/tests/ImGui.NodeEditor.Tests/CommentBoxTests.cs new file mode 100644 index 00000000..7b88583f --- /dev/null +++ b/tests/ImGui.NodeEditor.Tests/CommentBoxTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.NodeEditor.Tests; + +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +using ktsu.ImGui.NodeEditor; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Comment boxes in the engine: what they contain, and what moving one carries. +/// +/// +/// Containment is geometric and decided when asked, so these tests set node sizes by hand the way +/// the renderer would after measuring them. +/// +[TestClass] +public sealed class CommentBoxTests +{ + private readonly NodeEditorEngine engine = new(); + + private Node NodeAt(Vector2 position, Vector2 size, string name = "Node") + { + Node node = engine.CreateNode(position, name, 0, 0); + engine.UpdateNodeDimensions(node.Id, size); + return engine.Nodes.Single(n => n.Id == node.Id); + } + + [TestMethod] + public void GetNodesInCommentBox_CountsOnlyNodesWhollyInside() + { + Node inside = NodeAt(new Vector2(20, 40), new Vector2(60, 30), "Inside"); + NodeAt(new Vector2(180, 40), new Vector2(60, 30), "Straddling"); + NodeAt(new Vector2(400, 400), new Vector2(60, 30), "Outside"); + + CommentBox box = engine.CreateCommentBox(Vector2.Zero, new Vector2(200, 200), "Group"); + + CollectionAssert.AreEqual(new[] { inside.Id }, engine.GetNodesInCommentBox(box.Id).ToList(), "A node half out of the box is not in it."); + } + + [TestMethod] + public void MoveCommentBox_CarriesItsNodesAndNestedBoxes_ButNotItsNeighbours() + { + Node inside = NodeAt(new Vector2(20, 40), new Vector2(60, 30)); + Node outside = NodeAt(new Vector2(400, 400), new Vector2(60, 30)); + CommentBox outer = engine.CreateCommentBox(Vector2.Zero, new Vector2(300, 300), "Outer"); + CommentBox nested = engine.CreateCommentBox(new Vector2(10, 10), new Vector2(120, 120), "Nested"); + + IReadOnlyList moves = engine.MoveCommentBox(outer.Id, new Vector2(50, -10)); + + Assert.AreEqual(new Vector2(50, -10), engine.FindCommentBox(outer.Id)!.Position); + Assert.AreEqual(new Vector2(60, 0), engine.FindCommentBox(nested.Id)!.Position, "A box inside the one being moved goes with it."); + Assert.AreEqual(new Vector2(70, 30), engine.Nodes.Single(n => n.Id == inside.Id).Position); + Assert.AreEqual(new Vector2(400, 400), engine.Nodes.Single(n => n.Id == outside.Id).Position); + Assert.AreEqual(new NodeMove(inside.Id, new Vector2(20, 40), new Vector2(70, 30)), moves.Single(), "A node inside both boxes is moved once."); + } + + [TestMethod] + public void CreateCommentBoxAround_EnclosesTheNodes_WithRoomForTheTitle() + { + Node a = NodeAt(new Vector2(100, 100), new Vector2(80, 40)); + Node b = NodeAt(new Vector2(300, 220), new Vector2(60, 50)); + + CommentBox box = engine.CreateCommentBoxAround([a.Id, b.Id], "Preprocessing")!; + + CollectionAssert.AreEquivalent(new[] { a.Id, b.Id }, engine.GetNodesInCommentBox(box.Id).ToList()); + Assert.AreEqual(100 - NodeEditorEngine.DefaultCommentBoxPadding - NodeEditorEngine.CommentBoxTitleAllowance, box.Position.Y, "The top edge should leave room for the title bar above the nodes."); + Assert.IsNull(engine.CreateCommentBoxAround([999], "Nothing"), "No box around nothing."); + } + + [TestMethod] + public void ResizeCommentBox_NeverGoesBelowTheMinimum() + { + CommentBox box = engine.CreateCommentBox(Vector2.Zero, new Vector2(200, 200), "Group"); + + engine.ResizeCommentBox(box.Id, new Vector2(1, 1)); + + Assert.AreEqual(NodeEditorEngine.MinimumCommentBoxSize, engine.FindCommentBox(box.Id)!.Size); + } + + [TestMethod] + public void RemoveCommentBox_LeavesItsNodes_AndClearRemovesEveryBox() + { + NodeAt(new Vector2(20, 40), new Vector2(60, 30)); + CommentBox box = engine.CreateCommentBox(Vector2.Zero, new Vector2(200, 200), "Group"); + + Assert.IsTrue(engine.RemoveCommentBox(box.Id)); + Assert.HasCount(1, engine.Nodes); + Assert.IsFalse(engine.RemoveCommentBox(box.Id)); + + engine.CreateCommentBox(Vector2.Zero, new Vector2(200, 200), "Again"); + engine.Clear(); + Assert.IsEmpty(engine.CommentBoxes); + Assert.AreEqual(1, engine.CreateCommentBox(Vector2.Zero, Vector2.One, "First").Id, "Clear restarts comment box ids the way it restarts node ids."); + } + + [TestMethod] + public void RenameAndRecolour_ChangeOnlyWhatTheySay() + { + CommentBox box = engine.CreateCommentBox(new Vector2(5, 5), new Vector2(200, 100), "Old"); + + engine.RenameCommentBox(box.Id, "New"); + engine.SetCommentBoxColor(box.Id, new Vector4(1, 0, 0, 0.3f)); + + Assert.AreEqual(box with { Title = "New", Color = new Vector4(1, 0, 0, 0.3f) }, engine.FindCommentBox(box.Id)); + } +} diff --git a/tests/ImGui.NodeEditor.Tests/ImGui.NodeEditor.Tests.csproj b/tests/ImGui.NodeEditor.Tests/ImGui.NodeEditor.Tests.csproj index cadda79c..7d5c95dd 100644 --- a/tests/ImGui.NodeEditor.Tests/ImGui.NodeEditor.Tests.csproj +++ b/tests/ImGui.NodeEditor.Tests/ImGui.NodeEditor.Tests.csproj @@ -17,4 +17,9 @@ + + + + + diff --git a/tests/ImGui.NodeEditor.Tests/NodeEditorGestureTests.cs b/tests/ImGui.NodeEditor.Tests/NodeEditorGestureTests.cs new file mode 100644 index 00000000..7d7e74d8 --- /dev/null +++ b/tests/ImGui.NodeEditor.Tests/NodeEditorGestureTests.cs @@ -0,0 +1,384 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.NodeEditor.Tests; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +using Hexa.NET.ImGui; +using Hexa.NET.ImNodes; + +using ktsu.ImGui.App; +using ktsu.ImGui.App.Testing; +using ktsu.ImGui.NodeEditor; +using ktsu.Keybinding.Core.Services; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// The gestures only a live editor has — dragging, snapping, the undo keys, and comment boxes — +/// driven headlessly through the harness and recorded into a . +/// +/// +/// The physics is never stepped, so a node stays wherever a gesture or an undo puts it and every +/// position asserted is the gesture's doing. +/// +[TestClass] +public sealed class NodeEditorGestureTests : IDisposable +{ + private static readonly HarnessOptions Viewport = new() { Width = 900, Height = 700 }; + + private readonly NodeEditorEngine engine = new(); + private readonly NodeEditorRenderer renderer = new(); + private readonly NodeEditorInputHandler input = new(); + private readonly NodeEditorHistory history; + private ImGuiAppHarness harness = null!; + + public NodeEditorGestureTests() + { + history = new NodeEditorHistory(engine); + renderer.History = history; + } + + /// + public void Dispose() + { + harness?.Dispose(); + history.Dispose(); + } + + private void Start() + { + harness = ImGuiAppHarness.Start( + new ImGuiAppConfig + { + Title = nameof(NodeEditorGestureTests), + 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); + } + + InputEvents events = input.ProcessInput(); + if (events.UndoRequested) + { + history.Undo(); + } + + if (events.RedoRequested) + { + history.Redo(); + } + } + + private Node NodeById(int id) => engine.Nodes.Single(n => n.Id == id); + + /// A point on a node's title bar, which is where a drag has to start to move it. + private Vector2 TitleOf(int nodeId) + { + Assert.IsTrue(renderer.TryGetNodeScreenRect(nodeId, out ScreenRect rect), "The node has not been drawn."); + return new Vector2(rect.Centre.X, rect.Min.Y + 6f); + } + + private void DragBy(Vector2 from, Vector2 delta) => + harness.Mouse.Drag(from.X, from.Y, from.X + delta.X, from.Y + delta.Y); + + [TestMethod] + public void DraggingANode_RecordsOneStep_ThatPutsItBack() + { + Node node = engine.CreateNode(new Vector2(200, 200), "Dragged", 1, 1); + Start(); + + DragBy(TitleOf(node.Id), new Vector2(120, 40)); + harness.Step(2); + + Vector2 dropped = NodeById(node.Id).Position; + Assert.IsGreaterThan(100f, Vector2.Distance(new Vector2(200, 200), dropped), "The drag should have moved the node."); + Assert.AreEqual(1, history.Service.CommandCount, "A drag across sixteen frames should be one step."); + + history.Undo(); + harness.Step(2); + + Assert.AreEqual(new Vector2(200, 200), NodeById(node.Id).Position); + Assert.AreEqual(1, history.Service.CommandCount, "Putting the node back should not itself have been recorded as a drag."); + Assert.IsTrue(history.CanRedo); + } + + [TestMethod] + public void SnapToGrid_LandsADraggedNodeOnTheGrid() + { + renderer.SnapToGrid = true; + renderer.GridSpacing = 16f; + Node node = engine.CreateNode(new Vector2(203, 197), "Snapped", 1, 1); + Start(); + + DragBy(TitleOf(node.Id), new Vector2(37, 23)); + harness.Step(2); + + Vector2 dropped = NodeById(node.Id).Position; + Assert.AreEqual(renderer.SnapPositionToGrid(dropped), dropped, $"{dropped} is not a grid point."); + Assert.AreEqual(0f, MathF.IEEERemainder(dropped.X - ImNodes.EditorContextGetPanning().X, 16f), 0.01f); + } + + [TestMethod] + public void SnapNodesToGrid_MovesNodesPlacedInCode_AsOneStep() + { + renderer.GridSpacing = 16f; + Node a = engine.CreateNode(new Vector2(203, 197), "A", 0, 0); + Node b = engine.CreateNode(new Vector2(411, 305), "B", 0, 0); + Start(); + + Assert.AreEqual(2, renderer.SnapNodesToGrid(engine, [a.Id, b.Id])); + harness.Step(2); + + Assert.AreEqual(renderer.SnapPositionToGrid(NodeById(a.Id).Position), NodeById(a.Id).Position); + Assert.AreEqual(1, history.Service.CommandCount); + history.Undo(); + Assert.AreEqual(new Vector2(203, 197), NodeById(a.Id).Position); + } + + [TestMethod] + public void CtrlZ_Undoes_AndCtrlYAndCtrlShiftZ_Redo() + { + Start(); + history.Record("Add", () => engine.CreateNode(new Vector2(200, 200), "Made", 0, 0)); + harness.Step(2); + + harness.Keyboard.Press(ImGuiKey.Z, ctrl: true); + Assert.IsEmpty(engine.Nodes, "Ctrl+Z should have undone the creation."); + + harness.Keyboard.Press(ImGuiKey.Y, ctrl: true); + Assert.HasCount(1, engine.Nodes, "Ctrl+Y should have redone it."); + + harness.Keyboard.Press(ImGuiKey.Z, ctrl: true); + harness.Keyboard.Press(ImGuiKey.Z, ctrl: true, shift: true); + Assert.HasCount(1, engine.Nodes, "Ctrl+Shift+Z should redo as well."); + } + + [TestMethod] + public void AKeymap_DecidesWhichKeysUndo() + { + CommandRegistry registry = new(); + KeybindingService keybindings = new(registry, new ProfileManager()); + keybindings.CreateProfile("default", "Default"); + keybindings.SetActiveProfile("default"); + Assert.AreEqual(NodeEditorCommands.DefaultChords.Count, NodeEditorCommands.Register(registry, keybindings)); + Assert.AreEqual(0, NodeEditorCommands.Register(registry, keybindings), "Registering again should not rebind anything."); + + // The user rebinds undo. + keybindings.BindChord(NodeEditorCommands.Undo, keybindings.ParseChord("Ctrl+U")); + input.Keybindings = keybindings; + + Start(); + history.Record("Add", () => engine.CreateNode(new Vector2(200, 200), "Made", 0, 0)); + harness.Step(2); + + harness.Keyboard.Press(ImGuiKey.Z, ctrl: true); + Assert.HasCount(1, engine.Nodes, "Ctrl+Z is no longer bound to undo, so it should do nothing."); + + harness.Keyboard.Press(ImGuiKey.U, ctrl: true); + Assert.IsEmpty(engine.Nodes, "Ctrl+U is undo now."); + + harness.Keyboard.Press(ImGuiKey.Y, ctrl: true); + Assert.HasCount(1, engine.Nodes, "Redo keeps its default chord."); + } + + [TestMethod] + public void UndoingADeletion_DrawsTheNodeWhereItWas() + { + Node node = engine.CreateNode(new Vector2(260, 220), "Returning", 1, 1); + Start(); + Assert.IsTrue(renderer.TryGetNodeScreenRect(node.Id, out ScreenRect before)); + + history.RemoveNode(node.Id); + harness.Step(2); + history.Undo(); + harness.Step(3); + + Assert.AreEqual(new Vector2(260, 220), NodeById(node.Id).Position, "ImNodes forgot the node while it was gone, and must not hand back its own default position as a drag."); + Assert.IsTrue(renderer.TryGetNodeScreenRect(node.Id, out ScreenRect after)); + Assert.AreEqual(before.Min, after.Min); + } + + [TestMethod] + public void DraggingAnInlineEditor_IsOneStep() + { + Node node = engine.CreateNodeFromSpecs(new Vector2(200, 200), "Tuned", [new PinSpec("Sigma", typeof(float), 2f)], []); + Start(); + + Assert.IsTrue(renderer.TryGetNodeScreenRect(node.Id, out ScreenRect rect)); + Assert.IsTrue(renderer.TryGetPinScreenPosition(node.InputPins[0].Id, out Vector2 pin)); + float left = rect.Min.X + ImNodes.GetStyle().NodePadding.X + ImGui.CalcTextSize("Sigma").X + ImGui.GetStyle().ItemSpacing.X; + Vector2 start = new(left + (renderer.InlineEditorWidth * 0.25f), pin.Y); + + DragBy(start, new Vector2(40, 0)); + harness.Step(2); + + Assert.IsGreaterThan(2f, (float)engine.GetPinValue(node.InputPins[0].Id)!); + Assert.AreEqual(1, history.Service.CommandCount, "Scrubbing a value across many frames should undo in one step."); + history.Undo(); + Assert.AreEqual(2f, engine.GetPinValue(node.InputPins[0].Id)); + } + + /// A node inside a comment box, both drawn and measured. + private (Node Node, CommentBox Box) StartWithABoxedNode(Vector4? color = null) + { + Node node = engine.CreateNode(new Vector2(260, 240), "Boxed", 1, 1); + Start(); + CommentBox box = engine.CreateCommentBoxAround([node.Id], "Preprocessing", color: color)!; + harness.Step(2); + return (node, box); + } + + [TestMethod] + public void ACommentBox_IsDrawnAroundItsNodes() + { + (Node node, CommentBox box) = StartWithABoxedNode(); + + Assert.IsTrue(renderer.TryGetCommentBoxScreenRect(box.Id, out ScreenRect boxRect)); + Assert.IsTrue(renderer.TryGetNodeScreenRect(node.Id, out ScreenRect nodeRect)); + + // The box and the node are placed through different ImNodes calls, so this is what shows the + // box's editor-space rectangle lands on screen where the node's does. + Assert.AreEqual(boxRect.Min.X + NodeEditorEngine.DefaultCommentBoxPadding, nodeRect.Min.X, 0.5f); + Assert.AreEqual(boxRect.Max.Y - NodeEditorEngine.DefaultCommentBoxPadding, nodeRect.Max.Y, 0.5f); + + // The box's items move the cursor about inside the editor, which ImGui reports as misuse if + // it is left anywhere it should not be. + Assert.AreEqual(0, ImGui.GetCurrentContext().ErrorCountCurrentFrame, "ImGui reported the comment box's drawing as misuse."); + } + + [TestMethod] + public void ACommentBoxWithNoNodes_DrawsWithoutError() + { + Start(); + engine.CreateCommentBox(new Vector2(100, 100), new Vector2(300, 200), "Empty"); + harness.Step(3); + + Assert.AreEqual(0, ImGui.GetCurrentContext().ErrorCountCurrentFrame, "ImGui reported the comment box's drawing as misuse."); + } + + [TestMethod] + public void ACommentBox_IsDrawnUnderTheNodes() + { + (Node node, CommentBox box) = StartWithABoxedNode(new Vector4(1f, 0f, 0f, 1f)); + Assert.IsTrue(renderer.TryGetCommentBoxScreenRect(box.Id, out ScreenRect boxRect)); + Assert.IsTrue(renderer.TryGetNodeScreenRect(node.Id, out ScreenRect nodeRect)); + + CapturedFrame frame = harness.Capture(); + static bool IsRed(Rgba32 p) => p.R > 200 && p.G < 60 && p.B < 60; + + Rgba32 inBoxOnly = frame.GetPixel((int)(boxRect.Min.X + 6), (int)(boxRect.Max.Y - 6)); + Rgba32 inNode = frame.GetPixel((int)nodeRect.Centre.X, (int)(nodeRect.Max.Y - 4)); + + Assert.IsTrue(IsRed(inBoxOnly), $"The box's own fill should show where no node covers it, but found {inBoxOnly}."); + Assert.IsFalse(IsRed(inNode), $"The node should be drawn over the box, but its body is {inNode}."); + } + + [TestMethod] + public void DraggingACommentBoxTitle_CarriesItsNodes_AsOneStep() + { + (Node node, CommentBox box) = StartWithABoxedNode(); + Assert.IsTrue(renderer.TryGetCommentBoxScreenRect(box.Id, out ScreenRect rect)); + + DragBy(new Vector2(rect.Min.X + 30f, rect.Min.Y + 6f), new Vector2(100, 50)); + harness.Step(2); + + Vector2 boxMoved = engine.FindCommentBox(box.Id)!.Position - box.Position; + Vector2 nodeMoved = NodeById(node.Id).Position - new Vector2(260, 240); + Assert.AreEqual(100f, boxMoved.X, 1f); + Assert.AreEqual(50f, boxMoved.Y, 1f); + Assert.AreEqual(boxMoved, nodeMoved, "The node inside should have moved exactly as far as the box."); + Assert.AreEqual(1, history.Service.CommandCount, "The drag should be one step."); + } + + [TestMethod] + public void CommentBoxDrag_Undoes_BoxAndContentsTogether() + { + (Node node, CommentBox box) = StartWithABoxedNode(); + Assert.IsTrue(renderer.TryGetCommentBoxScreenRect(box.Id, out ScreenRect rect)); + DragBy(new Vector2(rect.Min.X + 30f, rect.Min.Y + 6f), new Vector2(100, 50)); + harness.Step(2); + + history.Undo(); + harness.Step(2); + + Assert.AreEqual(box.Position, engine.FindCommentBox(box.Id)!.Position); + Assert.AreEqual(new Vector2(260, 240), NodeById(node.Id).Position); + } + + [TestMethod] + public void ACommentBoxsCloseButton_RemovesIt_ButNotItsNodes() + { + (Node _, CommentBox box) = StartWithABoxedNode(); + Assert.IsTrue(renderer.TryGetCommentBoxScreenRect(box.Id, out ScreenRect rect)); + float titleHeight = ImGui.GetFrameHeight(); + + harness.Mouse.Click(rect.Max.X - (titleHeight * 0.5f), rect.Min.Y + (titleHeight * 0.5f)); + harness.Step(2); + + Assert.IsEmpty(engine.CommentBoxes); + Assert.HasCount(1, engine.Nodes); + history.Undo(); + Assert.HasCount(1, engine.CommentBoxes); + } + + [TestMethod] + public void ResizingACommentBox_ChangesItsSize_AsOneStep() + { + (Node _, CommentBox box) = StartWithABoxedNode(); + Assert.IsTrue(renderer.TryGetCommentBoxScreenRect(box.Id, out ScreenRect rect)); + + DragBy(rect.Max - new Vector2(4f, 4f), new Vector2(80, 60)); + harness.Step(2); + + Vector2 grown = engine.FindCommentBox(box.Id)!.Size - box.Size; + Assert.AreEqual(80f, grown.X, 1f); + Assert.AreEqual(60f, grown.Y, 1f); + Assert.AreEqual(box.Position, engine.FindCommentBox(box.Id)!.Position, "Resizing keeps the top-left corner where it is."); + + history.Undo(); + Assert.AreEqual(box.Size, engine.FindCommentBox(box.Id)!.Size); + } + + [TestMethod] + public void DoubleClickingACommentTitle_RenamesIt() + { + (Node _, CommentBox box) = StartWithABoxedNode(); + Assert.IsTrue(renderer.TryGetCommentBoxScreenRect(box.Id, out ScreenRect rect)); + Vector2 title = new(rect.Min.X + 30f, rect.Min.Y + 6f); + + harness.Mouse.Click(title.X, title.Y); + harness.Mouse.Click(title.X, title.Y); + harness.Step(2); + Assert.AreEqual(box.Id, renderer.RenamingCommentBoxId, "A double-click on the title should open it for editing."); + + harness.Keyboard.Press(ImGuiKey.A, ctrl: true); + harness.Keyboard.Type("Alignment"); + harness.Keyboard.Press(ImGuiKey.Enter); + harness.Step(2); + + Assert.AreEqual("Alignment", engine.FindCommentBox(box.Id)!.Title); + Assert.IsNull(renderer.RenamingCommentBoxId); + history.Undo(); + Assert.AreEqual("Preprocessing", engine.FindCommentBox(box.Id)!.Title); + } +} diff --git a/tests/ImGui.NodeEditor.Tests/NodeEditorHistoryTests.cs b/tests/ImGui.NodeEditor.Tests/NodeEditorHistoryTests.cs new file mode 100644 index 00000000..cb53e97a --- /dev/null +++ b/tests/ImGui.NodeEditor.Tests/NodeEditorHistoryTests.cs @@ -0,0 +1,339 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.NodeEditor.Tests; + +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +using ktsu.ImGui.NodeEditor; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Undo and redo through , against the engine alone. +/// +/// +/// The claim under test is that undoing a step puts back exactly what the step changed and nothing +/// else, and that redoing it puts the step back the same way — ids, pins, links and values +/// included, since everything else a host keeps is keyed by those. +/// +[TestClass] +public sealed class NodeEditorHistoryTests +{ + private static readonly string[] FreshNames = ["Fresh A", "Fresh B"]; + private static readonly string[] OriginalNames = ["Source", "Target"]; + + private readonly NodeEditorEngine engine = new(); + + private NodeEditorHistory NewHistory(AttributeBasedNodeFactory? factory = null) => new(engine, factory); + + /// A source and a target joined by one link, the smallest graph with something to lose. + private (Node Source, Node Target, Link Link) Pair() + { + Node source = engine.CreateNodeFromSpecs(new Vector2(0, 0), "Source", [], [new PinSpec("Value", typeof(double))]); + Node target = engine.CreateNodeFromSpecs( + new Vector2(300, 0), + "Target", + [new PinSpec("In", typeof(double), 1.0), new PinSpec("Gain", typeof(double), 2.0)], + []); + Link link = engine.TryCreateLink(source.OutputPins[0].Id, target.InputPins[0].Id).Link!; + return (source, target, link); + } + + [TestMethod] + public void Undo_AfterCreatingANode_RemovesIt_AndRedoPutsItBackUnderTheSameId() + { + using NodeEditorHistory history = NewHistory(); + + Node created = history.Record("Add node", () => engine.CreateNode(new Vector2(10, 20), "Made", 1, 1)); + Assert.IsTrue(history.CanUndo); + Assert.AreEqual("Add node", history.NextUndoDescription); + + Assert.IsTrue(history.Undo()); + Assert.IsEmpty(engine.Nodes); + + Assert.IsTrue(history.Redo()); + Node back = engine.Nodes.Single(); + Assert.AreEqual(created.Id, back.Id, "Redo should restore the node under the id it was created with, since anything the host keys by id depends on it."); + CollectionAssert.AreEqual(created.InputPins.Select(p => p.Id).ToList(), back.InputPins.Select(p => p.Id).ToList()); + Assert.AreEqual(new Vector2(10, 20), back.Position); + } + + [TestMethod] + public void Undo_AfterDeletingANode_RestoresItsLinksAndValues() + { + (Node _, Node target, Link link) = Pair(); + int gainPin = target.InputPins[1].Id; + engine.SetPinValue(gainPin, 7.5); + + using NodeEditorHistory history = NewHistory(); + Assert.IsTrue(history.RemoveNode(target.Id)); + Assert.IsEmpty(engine.Links, "Removing the node removes the link reaching it."); + + Assert.IsTrue(history.Undo()); + + Assert.IsTrue(engine.Nodes.Any(n => n.Id == target.Id)); + Assert.AreEqual(link, engine.Links.Single(), "The link should come back as it was, id and all."); + Assert.AreEqual(7.5, engine.GetPinValue(gainPin), "A value set before the deletion should survive the undo."); + Assert.IsTrue(engine.ResetPinValue(gainPin), "The pin's seeded default should survive the undo too."); + Assert.AreEqual(2.0, engine.GetPinValue(gainPin)); + } + + [TestMethod] + public void Undo_OfADeletion_DoesNotMoveNodesTheLayoutMovedSince() + { + (Node source, Node target, Link _) = Pair(); + using NodeEditorHistory history = NewHistory(); + history.RemoveNode(target.Id); + + // Something other than the history moves the surviving node, as the physics would. + engine.UpdateNodePosition(source.Id, new Vector2(-500, 40)); + + history.Undo(); + + Assert.AreEqual(new Vector2(-500, 40), engine.Nodes.Single(n => n.Id == source.Id).Position, "Undoing a deletion is about the deleted node, not about where everything else stood at the time."); + Assert.AreEqual(new Vector2(300, 0), engine.Nodes.Single(n => n.Id == target.Id).Position); + } + + [TestMethod] + public void Undo_AfterCreatingALink_RemovesIt_AndARefusedLinkRecordsNothing() + { + Node source = engine.CreateNode(Vector2.Zero, "Source", 0, 1); + Node target = engine.CreateNode(new Vector2(300, 0), "Target", 1, 0); + using NodeEditorHistory history = NewHistory(); + + Assert.IsTrue(history.TryCreateLink(source.OutputPins[0].Id, target.InputPins[0].Id).Success); + Assert.IsFalse(history.TryCreateLink(source.OutputPins[0].Id, target.InputPins[0].Id).Success, "The same pair twice is refused."); + + Assert.AreEqual(1, history.Service.CommandCount, "A refused link changed nothing, so it should not be a step."); + + history.Undo(); + Assert.IsEmpty(engine.Links); + history.Redo(); + Assert.HasCount(1, engine.Links); + } + + [TestMethod] + public void Undo_AfterRemovingALink_PutsItBack() + { + (Node _, Node _, Link link) = Pair(); + using NodeEditorHistory history = NewHistory(); + + Assert.IsTrue(history.RemoveLink(link.Id)); + history.Undo(); + + Assert.AreEqual(link, engine.Links.Single()); + } + + [TestMethod] + public void Undo_AfterDuplicating_RemovesTheCopies() + { + (Node source, Node target, Link _) = Pair(); + using NodeEditorHistory history = NewHistory(); + + IReadOnlyList copies = history.DuplicateNodes([source.Id, target.Id], new Vector2(40, 40)); + Assert.HasCount(2, copies); + Assert.HasCount(4, engine.Nodes); + Assert.HasCount(2, engine.Links); + + history.Undo(); + Assert.HasCount(2, engine.Nodes); + Assert.HasCount(1, engine.Links); + + history.Redo(); + Assert.HasCount(4, engine.Nodes); + Assert.HasCount(2, engine.Links, "The copied link should come back with the copies."); + } + + [TestMethod] + public void PinValueWrites_AreRecordedWithoutBeingAsked() + { + (Node _, Node target, Link _) = Pair(); + int gain = target.InputPins[1].Id; + using NodeEditorHistory history = NewHistory(); + + engine.SetPinValue(gain, 3.0); + engine.SetPinValue(gain, 4.0); + + Assert.AreEqual(2, history.Service.CommandCount, "Two writes with no gesture are two edits."); + history.Undo(); + Assert.AreEqual(3.0, engine.GetPinValue(gain)); + history.Undo(); + Assert.AreEqual(2.0, engine.GetPinValue(gain)); + history.Redo(); + history.Redo(); + Assert.AreEqual(4.0, engine.GetPinValue(gain)); + } + + [TestMethod] + public void PinValueWrites_SharingAGesture_UndoInOneStep() + { + (Node _, Node target, Link _) = Pair(); + int gain = target.InputPins[1].Id; + using NodeEditorHistory history = NewHistory(); + + // One drag of a slider: a write every frame, all under one gesture. + for (int frame = 1; frame <= 30; frame++) + { + engine.SetPinValue(gain, 2.0 + (frame * 0.1), editGesture: 900); + } + + Assert.AreEqual(1, history.Service.CommandCount, "A drag should be one step, not one per frame."); + history.Undo(); + Assert.AreEqual(2.0, engine.GetPinValue(gain), "Undoing the drag should go back to where it started."); + history.Redo(); + Assert.AreEqual(5.0, (double)engine.GetPinValue(gain)!, 1e-9); + } + + [TestMethod] + public void PinValueWrites_UnderDifferentGestures_StaySeparate() + { + (Node _, Node target, Link _) = Pair(); + int gain = target.InputPins[1].Id; + using NodeEditorHistory history = NewHistory(); + + engine.SetPinValue(gain, 3.0, editGesture: 1); + engine.SetPinValue(gain, 4.0, editGesture: 2); + + Assert.AreEqual(2, history.Service.CommandCount, "Two separate drags of the same slider are two steps."); + } + + [TestMethod] + public void NewStep_AfterUndo_DiscardsTheRedo() + { + using NodeEditorHistory history = NewHistory(); + history.Record("A", () => engine.CreateNode(Vector2.Zero, "A", 0, 0)); + history.Undo(); + Assert.IsTrue(history.CanRedo); + + history.Record("B", () => engine.CreateNode(Vector2.Zero, "B", 0, 0)); + + Assert.IsFalse(history.CanRedo); + Assert.AreEqual("B", engine.Nodes.Single().Name); + } + + [TestMethod] + public void NodeMoves_UndoToWhereTheDragStarted() + { + (Node source, Node _, Link _) = Pair(); + using NodeEditorHistory history = NewHistory(); + + // The drag has already happened by the time it is recorded. + engine.UpdateNodePosition(source.Id, new Vector2(80, 90)); + Assert.IsTrue(history.RecordNodeMoves([new NodeMove(source.Id, Vector2.Zero, new Vector2(80, 90))])); + Assert.IsFalse(history.RecordNodeMoves([new NodeMove(source.Id, new Vector2(80, 90), new Vector2(80, 90))]), "A move that goes nowhere is not a step."); + + history.Undo(); + Assert.AreEqual(Vector2.Zero, engine.Nodes.Single(n => n.Id == source.Id).Position); + history.Redo(); + Assert.AreEqual(new Vector2(80, 90), engine.Nodes.Single(n => n.Id == source.Id).Position); + } + + [TestMethod] + public void Undo_OfAFactoryNodesDeletion_ReattachesItsInstance() + { + AttributeBasedNodeFactory factory = new(engine); + factory.RegisterNodeType(); + Node node = factory.CreateNode(Vector2.Zero); + Assert.IsTrue(factory.TryGetNodeInstance(node.Id, out object? before)); + int threshold = node.InputPins.Single(p => p.EffectiveDisplayName == "Threshold").Id; + engine.SetPinValue(threshold, 42.0); + + using NodeEditorHistory history = NewHistory(factory); + history.RemoveNode(node.Id); + Assert.IsFalse(factory.TryGetNodeInstance(node.Id, out _)); + + history.Undo(); + + Assert.IsTrue(factory.TryGetNodeInstance(node.Id, out object? after), "The factory should know the node again once it is back."); + Assert.AreSame(before, after, "It should be the same instance, not a fresh one."); + Assert.IsTrue(engine.SetPinValue(threshold, 64.0)); + Assert.AreEqual(64.0, ((PinValueBindingTests.TunableNode)after).Threshold, "The restored pin should still write through to the instance."); + } + + [TestMethod] + public void Clear_OutsideARecording_EmptiesTheHistory() + { + using NodeEditorHistory history = NewHistory(); + history.Record("Add", () => engine.CreateNode(Vector2.Zero, "A", 0, 0)); + + engine.Clear(); + + Assert.IsFalse(history.CanUndo, "Clearing restarts the id counters, so every recorded id would name something else."); + } + + [TestMethod] + public void Clear_InsideARecording_IsUndoable_EvenThoughIdsAreReissued() + { + Pair(); + using NodeEditorHistory history = NewHistory(); + + // The shape of a "reset" button: clear, then build something new that reuses ids 1 and 2. + history.Record("Reset", () => + { + engine.Clear(); + engine.CreateNode(new Vector2(5, 5), "Fresh A", 0, 0); + engine.CreateNode(new Vector2(6, 6), "Fresh B", 0, 0); + }); + + CollectionAssert.AreEquivalent(FreshNames, engine.Nodes.Select(n => n.Name).ToList()); + + history.Undo(); + CollectionAssert.AreEquivalent(OriginalNames, engine.Nodes.Select(n => n.Name).ToList()); + Assert.HasCount(1, engine.Links); + + history.Redo(); + CollectionAssert.AreEquivalent(FreshNames, engine.Nodes.Select(n => n.Name).ToList()); + Assert.IsEmpty(engine.Links); + } + + [TestMethod] + public void RestoredIds_AreNeverReissued() + { + using NodeEditorHistory history = NewHistory(); + Node first = history.Record("Add", () => engine.CreateNode(Vector2.Zero, "First", 1, 1)); + history.Undo(); + history.Redo(); + + Node second = engine.CreateNode(Vector2.Zero, "Second", 1, 1); + + Assert.AreNotEqual(first.Id, second.Id); + Assert.IsFalse(first.InputPins.Select(p => p.Id).Intersect(second.InputPins.Select(p => p.Id)).Any()); + } + + [TestMethod] + public void CommentBoxes_CreateMoveAndRemove_AreUndoable() + { + Node inside = engine.CreateNode(new Vector2(20, 60), "Inside", 0, 0); + engine.UpdateNodeDimensions(inside.Id, new Vector2(50, 30)); + using NodeEditorHistory history = NewHistory(); + + CommentBox box = history.CreateCommentBox(Vector2.Zero, new Vector2(200, 200), "Group"); + history.MoveCommentBox(box.Id, new Vector2(100, 0)); + Assert.AreEqual(new Vector2(120, 60), engine.Nodes.Single().Position); + + history.Undo(); + Assert.AreEqual(Vector2.Zero, engine.FindCommentBox(box.Id)!.Position); + Assert.AreEqual(new Vector2(20, 60), engine.Nodes.Single().Position, "The node the box carried should go back with it."); + + history.RemoveCommentBox(box.Id); + Assert.IsEmpty(engine.CommentBoxes); + Assert.HasCount(1, engine.Nodes, "Removing a comment box leaves what was in it."); + + history.Undo(); + Assert.AreEqual(box, engine.CommentBoxes.Single()); + } + + [TestMethod] + public void Dispose_StopsRecording() + { + (Node _, Node target, Link _) = Pair(); + NodeEditorHistory history = NewHistory(); + history.Dispose(); + + engine.SetPinValue(target.InputPins[1].Id, 9.0); + + Assert.AreEqual(0, history.Service.CommandCount); + } +}