From 8f4bd1f203e8fbd678057a15aceb551e009bd255 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 23:27:47 +0000 Subject: [PATCH 1/2] Add ReorderableTree, a drag-reorderable tree of caller-drawn rows [minor] The caller flattens its tree into rows and draws each row's content; the widget lays them out, shows where a drag would land, and reports the move on release as a ReorderableTreeMove whose index counts the new parent's children before the move. ReorderableTreeState resolves drops without ImGui: the quarters of a container drop beside it and the middle half inside, a leaf splits at the middle, the pointer's x picks how far out a drop below the end of a subtree lands, and drops into the dragged row's own subtree or back where it was are refused. A press in a text field is left to the field. Ships with state tests, an isolation suite, a demo section, a demo UI test and a gallery tile. Asked for by ImageGui's layers panel (M5b). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RU5HC3D44CgGTP97XE4WQz --- CLAUDE.md | 2 +- ImGui.Widgets/README.md | 37 ++ ImGui.Widgets/ReorderableTree.cs | 245 ++++++++++++++ ImGui.Widgets/ReorderableTreeMove.cs | 26 ++ ImGui.Widgets/ReorderableTreeRow.cs | 15 + ImGui.Widgets/ReorderableTreeState.cs | 306 +++++++++++++++++ README.md | 1 + examples/ImGuiWidgetsDemo/DemoSections.cs | 1 + .../ImGuiWidgetsDemo/ReorderableTreeDemo.cs | 134 ++++++++ .../ReorderableTreeStateTests.cs | 244 ++++++++++++++ .../ReorderableTreeTests.cs | 316 ++++++++++++++++++ .../DemoSectionRegistryTests.cs | 2 +- .../WidgetsDemoUITests.cs | 25 +- .../Catalog/LayoutAndContainers.cs | 12 + 14 files changed, 1363 insertions(+), 3 deletions(-) create mode 100644 ImGui.Widgets/ReorderableTree.cs create mode 100644 ImGui.Widgets/ReorderableTreeMove.cs create mode 100644 ImGui.Widgets/ReorderableTreeRow.cs create mode 100644 ImGui.Widgets/ReorderableTreeState.cs create mode 100644 examples/ImGuiWidgetsDemo/ReorderableTreeDemo.cs create mode 100644 tests/ImGui.Widgets.Tests/ReorderableTreeStateTests.cs create mode 100644 tests/ImGui.Widgets.UITests/ReorderableTreeTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 40404667..d8d03578 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De ### Libraries - **ImGui.App** (`ktsu.ImGui.App`) - Application foundation with windowing, rendering, font/texture management, PID frame limiting, DPI awareness. Image decoding is self-contained (`ImGui.App/Images/`), so the package carries no imaging dependency; see [Image decoding](#image-decoding) below. -- **ImGui.Widgets** (`ktsu.ImGui.Widgets`) - Custom UI components, grouped as the README's feature list groups them: input and controls (`Switch`, `SegmentedControl`, `Stepper`, `TimecodeField` over the ImGui-free `Timecode`/`TimecodeRate`, `RangeSlider`, `ChannelFader`/`FaderTaper`, `XYPad`, `ColorWheel`/`LiftGammaGain`, `Knob`/`KnobWithDrag`, `Rating`, `Chip`/`ChipGroup`, `PinInput`, `SearchBox`/`SearchBoxRanked`, `Combo`, `StepGrid`, `PianoKeyboard` over a caller-owned `PianoKeyboardLayout`, `TransportScrubber`); display and status (`Avatar`, `Badge`/`BadgeDot`, `ColorIndicator`, `Icon`, `Text`, `Image`, `PageIndicator`); progress and loading (`RadialProgressBar` with `RadialCountdown`/`RadialCountUp`, `SkeletonLine`/`SkeletonRect`/`SkeletonCircle`); data and signals (`Histogram`, `FrameTimeGraph` over a caller-owned `FrameTimeHistory`, `HandleTrack`, `CurveTrack`, `DbMeter`, `GainReductionMeter`, `CorrelationMeter`/`StereoMetersState`, `Goniometer`, `Scope`, `Waveform` (zoomable over a caller-owned `TimelineView` and a `WaveformPeakSource`/`WaveformPeakCache`), `SpectrumAnalyzer` over a caller-owned `SpectrumAnalyzerState`, `EnvelopeEditor` over an `Envelope`, `ParametricEq` over `EqBand`s with `EqResponse` for the RBJ biquad response, and image and colour (`LevelsControl` over a caller-owned `LevelsAdjustment`, `AssetBrowser` over a caller-owned `AssetBrowserState`, whose `Apply` is the transfer function the widget draws, `GradientEditor` over a caller-owned list of `GradientStop`, with `SampleGradient` as the evaluator the bar `Toolbar`/`ToolbarSeparator` with `ToolbarButton`/`ToolbarToggleButton` (glyph and label in one frame, `ToolbarButtonLayout` picking leading, above-and-centred, glyph-only or label-only, the arithmetic in the ImGui-free `ToolbarGeometry`; `GlyphSize` on the toolbar or a button draws icons at their own pixel size beside normal-size labels), is drawn with, `SwatchPalette`, `ImageCompare`, `CropOverlay` over an `ImageCanvas`, `PixelLoupe` beside an `ImageCanvas`); the `LogFrequencyAxis` both share); layout and containers (`DividerContainer`/`DividerZone`, `Grid`, `TabPanel`, `Card`, `Tree`, `ImageCanvas`, `PropertyGrid`, `OverlayHost`/`OverlayLayer`, `ScopedDisable`, `ScopedId`); and motion and gestures (`Tween`, `Spring`, `Easing`, `InertialScroll`, `GestureDetector`/`GestureMachine`). Also thin adapters delegating to `Hexa.NET.ImGui.Widgets`: `Spinner`, `BufferingBar`, `HorizontalSplitter`/`VerticalSplitter`, `ToggleSwitch`/`ToggleButton`/`TransparentButton`/`InlineButton`, `IconTreeNode`, `EnumCombo`, `TextCenteredV`/`TextCenteredH`/`TextCenteredVH`, `ImageCenteredV`/`ImageCenteredH`/`ImageCenteredVH`/`ImageScaleTo`, `Tooltip`, `Breadcrumb`, `DatePicker`/`YearPicker`, `FlameGraph`, `FileTreeView`, `OpenFileDialog`/`SaveFileDialog`/`OpenFolderDialog`, `RenameDialog`, `DialogMessageBox`/`ShowMessageBox`, `DockedWindow`. Seven of these look like duplicates of an existing ktsu widget; most are not, and the two that are have a recommended survivor — see [Hexa-backed vs ktsu widgets](#hexa-backed-vs-ktsu-widgets) below for the pair-by-pair verdict. `DatePicker` and `FileTreeView` need a Material Icons font registered via `FontHelper.AddCustomFont(io, data, size, FontHelper.GetMaterialIconRanges(), mergeWithPrevious: true)` (not `ImGuiAppConfig.Fonts`, which applies the Nerd Font mapping); see `examples/ImGuiAppDemo`. `YearPicker` needs no icon font. `OpenFileDialog`, `SaveFileDialog` and `OpenFolderDialog` need the same Material Icons font, for their toolbar, breadcrumb and file-tree glyphs; `RenameDialog`, `DialogMessageBox` and `ShowMessageBox` need none. `DockedWindow` composes Hexa's `ImWindow` internally rather than inheriting it — subclass it, override `Title` and `DrawContent()`, then call `Show()`/`Close()`. All of the dialogs and `DockedWindow` require a per-frame deferred-drawing pump; see [Deferred Drawing](#deferred-drawing-dialogs-and-docked-windows) below. Also includes callback-driven editors: `Sequencer`, `SequenceSource`, `CurveEditor`, `CurveSource`, `CurveData`, `BezierEditor`. Unlike the dialogs above, none of these need a deferred-drawing pump; see [Callback-driven editors](#callback-driven-editors) below. +- **ImGui.Widgets** (`ktsu.ImGui.Widgets`) - Custom UI components, grouped as the README's feature list groups them: input and controls (`Switch`, `SegmentedControl`, `Stepper`, `TimecodeField` over the ImGui-free `Timecode`/`TimecodeRate`, `RangeSlider`, `ChannelFader`/`FaderTaper`, `XYPad`, `ColorWheel`/`LiftGammaGain`, `Knob`/`KnobWithDrag`, `Rating`, `Chip`/`ChipGroup`, `PinInput`, `SearchBox`/`SearchBoxRanked`, `Combo`, `StepGrid`, `PianoKeyboard` over a caller-owned `PianoKeyboardLayout`, `TransportScrubber`); display and status (`Avatar`, `Badge`/`BadgeDot`, `ColorIndicator`, `Icon`, `Text`, `Image`, `PageIndicator`); progress and loading (`RadialProgressBar` with `RadialCountdown`/`RadialCountUp`, `SkeletonLine`/`SkeletonRect`/`SkeletonCircle`); data and signals (`Histogram`, `FrameTimeGraph` over a caller-owned `FrameTimeHistory`, `HandleTrack`, `CurveTrack`, `DbMeter`, `GainReductionMeter`, `CorrelationMeter`/`StereoMetersState`, `Goniometer`, `Scope`, `Waveform` (zoomable over a caller-owned `TimelineView` and a `WaveformPeakSource`/`WaveformPeakCache`), `SpectrumAnalyzer` over a caller-owned `SpectrumAnalyzerState`, `EnvelopeEditor` over an `Envelope`, `ParametricEq` over `EqBand`s with `EqResponse` for the RBJ biquad response, and image and colour (`LevelsControl` over a caller-owned `LevelsAdjustment`, `AssetBrowser` over a caller-owned `AssetBrowserState`, whose `Apply` is the transfer function the widget draws, `GradientEditor` over a caller-owned list of `GradientStop`, with `SampleGradient` as the evaluator the bar `Toolbar`/`ToolbarSeparator` with `ToolbarButton`/`ToolbarToggleButton` (glyph and label in one frame, `ToolbarButtonLayout` picking leading, above-and-centred, glyph-only or label-only, the arithmetic in the ImGui-free `ToolbarGeometry`; `GlyphSize` on the toolbar or a button draws icons at their own pixel size beside normal-size labels), is drawn with, `SwatchPalette`, `ImageCompare`, `CropOverlay` over an `ImageCanvas`, `PixelLoupe` beside an `ImageCanvas`); the `LogFrequencyAxis` both share); layout and containers (`DividerContainer`/`DividerZone`, `Grid`, `TabPanel`, `Card`, `Tree`, `ReorderableTree` (rows the caller draws, dragged to reorder and re-parent; reports a `ReorderableTreeMove` whose index counts the parent's children before the move, resolved by the ImGui-free `ReorderableTreeState`), `ImageCanvas`, `PropertyGrid`, `OverlayHost`/`OverlayLayer`, `ScopedDisable`, `ScopedId`); and motion and gestures (`Tween`, `Spring`, `Easing`, `InertialScroll`, `GestureDetector`/`GestureMachine`). Also thin adapters delegating to `Hexa.NET.ImGui.Widgets`: `Spinner`, `BufferingBar`, `HorizontalSplitter`/`VerticalSplitter`, `ToggleSwitch`/`ToggleButton`/`TransparentButton`/`InlineButton`, `IconTreeNode`, `EnumCombo`, `TextCenteredV`/`TextCenteredH`/`TextCenteredVH`, `ImageCenteredV`/`ImageCenteredH`/`ImageCenteredVH`/`ImageScaleTo`, `Tooltip`, `Breadcrumb`, `DatePicker`/`YearPicker`, `FlameGraph`, `FileTreeView`, `OpenFileDialog`/`SaveFileDialog`/`OpenFolderDialog`, `RenameDialog`, `DialogMessageBox`/`ShowMessageBox`, `DockedWindow`. Seven of these look like duplicates of an existing ktsu widget; most are not, and the two that are have a recommended survivor — see [Hexa-backed vs ktsu widgets](#hexa-backed-vs-ktsu-widgets) below for the pair-by-pair verdict. `DatePicker` and `FileTreeView` need a Material Icons font registered via `FontHelper.AddCustomFont(io, data, size, FontHelper.GetMaterialIconRanges(), mergeWithPrevious: true)` (not `ImGuiAppConfig.Fonts`, which applies the Nerd Font mapping); see `examples/ImGuiAppDemo`. `YearPicker` needs no icon font. `OpenFileDialog`, `SaveFileDialog` and `OpenFolderDialog` need the same Material Icons font, for their toolbar, breadcrumb and file-tree glyphs; `RenameDialog`, `DialogMessageBox` and `ShowMessageBox` need none. `DockedWindow` composes Hexa's `ImWindow` internally rather than inheriting it — subclass it, override `Title` and `DrawContent()`, then call `Show()`/`Close()`. All of the dialogs and `DockedWindow` require a per-frame deferred-drawing pump; see [Deferred Drawing](#deferred-drawing-dialogs-and-docked-windows) below. Also includes callback-driven editors: `Sequencer`, `SequenceSource`, `CurveEditor`, `CurveSource`, `CurveData`, `BezierEditor`. Unlike the dialogs above, none of these need a deferred-drawing pump; see [Callback-driven editors](#callback-driven-editors) below. - **ImGui.Popups** (`ktsu.ImGui.Popups`) - Modal dialogs: MessageOK, Prompt, InputString/Int/Float, FilesystemBrowser, SearchableList - **ImGui.Color** (`ktsu.ImGui.Color`) - Bridge between `ktsu.Semantics.Color` and ImGui. Colors are held as the semantic `Color` (linear) and `Srgb` types and converted only at the ImGui seam: `ColorImGuiExtensions` (`ToImColor`/`FromImColor`, `ToImGuiVector4`, `ToImGuiU32`) and `SrgbImGuiExtensions` (`Srgb` → `ImColor`/`ImGuiVector4`/`ImU32`, packed directly with no linear round-trip). The `ImColor` and `Srgb` `ToImGuiU32` apply the global style alpha like `ImGui.GetColorU32`; the linear `Color.ToImGuiU32` is a pure pack matching `ColorConvertFloat4ToU32`. `ImColor` extension operations: adjustments (lighten/darken, saturate/desaturate, hue offset, grayscale, invert, alpha), analysis (relative luminance, contrast ratio, perceptual distance), and contrast heuristics (`MostReadableTextColor`, `AdjustForSufficientContrast`). All color math delegates to `ktsu.Semantics.Color`. (There is no `ImColor` factory class — construct via `Color`/`Srgb` and convert.) - **ImGui.Styler** (`ktsu.ImGui.Styler`) - Theming system with 50+ built-in themes, scoped styling, Button.Alignment, Text.Color semantic colors, Indent utilities, Alignment helpers, theme-aware color palette (`Palette`, e.g. `Palette.Basic.Red`, `Palette.Semantic.Error`), and interactive theme browser. Color construction and manipulation live in `ImGui.Color`. diff --git a/ImGui.Widgets/README.md b/ImGui.Widgets/README.md index eaad8278..02f34615 100644 --- a/ImGui.Widgets/README.md +++ b/ImGui.Widgets/README.md @@ -95,6 +95,7 @@ The widgets below are grouped by what they are for. Everything is a static metho - **`TabPanel`**: Tabbed interface with closable, reorderable tabs and dirty indicators - **`Card`**: A scoped elevated panel that draws its shadow and rounded background behind whatever the `using` block renders - **`Tree`**: Collapsible `Branch` and terminal `Leaf` nodes, with connector lines drawn between them +- **`ReorderableTree`**: Rows the caller draws, laid out as a tree and dragged to reorder them or move them in and out of containers. Reports the move rather than applying it. See [Reorderable Tree](#reorderable-tree) below - **`ImageCanvas`**: A pannable, zoomable image canvas with a checkerboard backing for transparency - **`AssetBrowser`**: A virtualized grid of thumbnail tiles over a caller-owned `AssetBrowserState`, with click, Ctrl+click and Shift+click selection, arrow-key focus, Ctrl+wheel tile resizing, double-click or Enter to activate, and dragging the selection out as a payload a drop target reads with `TryAcceptAssetPayload`. Only the visible rows ask for a label or thumbnail, so fifty thousand assets cost a screenful - **`Toolbar`** / **`ToolbarButton`** / **`ToolbarToggleButton`** / **`ToolbarSeparator`**: A horizontal strip of buttons, each holding a glyph and a label inside one frame — glyph leading, glyph above with the label centred beneath, glyph only (the label becomes the tooltip) or label only. See [Toolbar](#toolbar) below @@ -585,6 +586,42 @@ using (var tree = new ImGuiWidgets.Tree()) } ``` +### Reorderable Tree + +A tree whose rows can be dragged. The caller flattens its own tree into rows, top first, leaving out +the children of anything collapsed, and draws each row's content itself; the widget lays the rows +out, draws where a drag would land, and reports the move on release. It never applies it, so the move +goes through the caller's own model and undo history: + +```csharp +ReorderableTreeRow[] rows = [new(0, false), new(0, true), new(1, false), new(1, false)]; + +if (ImGuiWidgets.ReorderableTree("layers", rows, i => ImGui.Selectable(names[i])) is ReorderableTreeMove move) +{ + // move.Row moves under move.ParentRow (-1 for the top level), into the gap move.Index. +} +``` + +The top quarter of a container row drops above it, the bottom quarter below it, and the middle half +inside it. A leaf splits at the middle. Below the last row of a subtree, how far left the pointer is +picks how far out the drop lands. A drop onto the dragged row or anywhere in its subtree is refused, +and so is one that would leave the row where it is. + +`Index` counts the new parent's children **before the move**, which is the gap the insertion line was +drawn in. When a row moves down within its own parent, its old place is among those children, so a +caller whose insert takes the final position subtracts one in exactly that case: + +```csharp +if (sameParent && oldIndex < move.Index) +{ + index = move.Index - 1; +} +``` + +A drag starts from a press anywhere on a row once the mouse moves past ImGui's drag threshold, so a +click that does not move is left to the row's own items. Each row is marked for probes as +`row{index}` inside the tree's label. + ### Scoped Id The scoped ID utility class helps in creating scoped IDs for ImGui elements and ensuring they get popped appropriately: diff --git a/ImGui.Widgets/ReorderableTree.cs b/ImGui.Widgets/ReorderableTree.cs new file mode 100644 index 00000000..cc629b5d --- /dev/null +++ b/ImGui.Widgets/ReorderableTree.cs @@ -0,0 +1,245 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.Widgets; + +using System; +using System.Collections.Generic; +using System.Numerics; + +using Hexa.NET.ImGui; + +using ktsu.ImGui.Probes; + +/// +/// Provides custom ImGui widgets. +/// +public static partial class ImGuiWidgets +{ + /// + /// Draws a tree whose rows can be dragged to reorder them and to move them between containers. + /// + /// A unique label, used for the ImGui ID and as the probe scope of the rows. + /// The rows to draw, top first, as the caller has laid them out. + /// + /// Draws the content of one row, given its index in . Called once per row per + /// frame, already indented to the row's depth and inside the row's own ID scope, so two rows can + /// submit identically labelled items. + /// + /// The move the user dropped this frame, or . + /// + /// + /// The tree draws no content and owns no data. The caller flattens its own tree into rows, + /// leaving out the children of anything collapsed, and draws each row's content itself: a + /// disclosure arrow, a visibility toggle, a selectable name. The widget lays the rows out, turns a + /// press and drag on one into a drag, draws where it would land, and on release reports the move. + /// It never applies it, so a caller's move goes through its own model and its own undo history. + /// + /// + /// The top quarter of a container row drops above it, the bottom quarter below it, and the middle + /// half inside it. A leaf splits at the middle. Below the last row of a subtree, how far left the + /// pointer is picks how far out the drop lands. A drop onto the dragged row or anywhere in its + /// subtree is refused, and so is one that would leave it where it is. + /// + /// + /// The reported index counts the new parent's children before the move; see + /// for the one case where that differs from the position the + /// row ends up at. + /// + /// + /// A drag starts from a press anywhere on a row, including on an item the row drew, once the mouse + /// has moved ImGui's drag threshold. A click that never moves that far is left to the row's items, + /// and so is a press in a text field, which is selecting text rather than picking the row up. + /// Each row is marked for probes as row{index} inside the tree's label. + /// + /// + public static ReorderableTreeMove? ReorderableTree(string label, IReadOnlyList rows, Action drawRow) => + ReorderableTreeImpl.Draw(label, rows, drawRow); + + internal static class ReorderableTreeImpl + { + private static readonly Dictionary States = []; + + public static ReorderableTreeMove? Draw(string label, IReadOnlyList rows, Action drawRow) + { + Ensure.NotNull(label); + Ensure.NotNull(rows); + Ensure.NotNull(drawRow); + + // Checked before anything is drawn, so a list that is not a tree throws without leaving the + // ID stack or the probe scopes unbalanced. + ReorderableTreeState.Analyze(rows); + + uint id = ImGui.GetID(label); + if (!States.TryGetValue(id, out ReorderableTreeState? state)) + { + state = new ReorderableTreeState(); + States[id] = state; + } + + float indent = ImGui.GetStyle().IndentSpacing; + float left = ImGui.GetCursorScreenPos().X; + float right = left + MathF.Max(ImGui.GetContentRegionAvail().X, 1f); + + ReorderableTreeRowSpan[] spans = new ReorderableTreeRowSpan[rows.Count]; + + using (new ScopedId(label)) + { + for (int i = 0; i < rows.Count; i++) + { + spans[i] = DrawRow(rows[i], i, drawRow, left, right, indent); + } + } + + return Interact(state, rows, spans, left, right, indent); + } + + private static ReorderableTreeRowSpan DrawRow( + ReorderableTreeRow row, + int index, + Action drawRow, + float left, + float right, + float indent) + { + float offset = row.Depth * indent; + + ImGui.PushID(index); + try + { + if (offset > 0f) + { + ImGui.Indent(offset); + } + + float top = ImGui.GetCursorScreenPos().Y; + + ImGui.BeginGroup(); + drawRow(index); + ImGui.EndGroup(); + + // A row the caller drew nothing into still occupies a line, so it can still be dragged + // and dropped on rather than collapsing to a zero-height target nothing can hit. + float bottom = MathF.Max(ImGui.GetItemRectMax().Y, top + ImGui.GetFrameHeight()); + + if (offset > 0f) + { + ImGui.Unindent(offset); + } + + // The whole width, not the content's: a short name is still a full row to drop on. + ImGuiProbes.MarkRegion($"row{index}", new Vector2(left, top), new Vector2(right, bottom)); + return new ReorderableTreeRowSpan(top, bottom); + } + finally + { + ImGui.PopID(); + } + } + + private static ReorderableTreeMove? Interact( + ReorderableTreeState state, + IReadOnlyList rows, + ReorderableTreeRowSpan[] spans, + float left, + float right, + float indent) + { + // An undo or a collapse can shorten the rows under a held press. The row index the press + // remembers would then name a different row, or none, so the gesture is dropped. + if (state.PressedRow >= rows.Count) + { + state.Release(); + } + + Vector2 mouse = ImGui.GetIO().MousePos; + + // Blocked-by-active-item is allowed because the press lands on the row's own items, which + // become active on that same frame. + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left) + && ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem)) + { + int pressed = RowAt(spans, mouse, left, right); + if (pressed >= 0) + { + state.Press(pressed); + } + else + { + state.Release(); + } + } + + if (!state.IsDragging && state.PressedRow >= 0 && ImGui.IsMouseDragging(ImGuiMouseButton.Left)) + { + // A press that landed in a text field is selecting text, not picking the row up. The + // field became active on the press, so by the time the mouse has moved far enough to + // be a drag, ImGui reports it as wanting the keyboard. + if (ImGui.GetIO().WantTextInput) + { + state.Release(); + } + else + { + state.BeginDrag(); + } + } + + ReorderableTreeDrop? drop = state.IsDragging + ? ReorderableTreeState.Resolve(rows, spans, left, indent, state.DraggedRow, mouse) + : null; + + if (drop is ReorderableTreeDrop shown) + { + DrawDrop(shown, spans, left, right, indent); + } + + if (!ImGui.IsMouseDown(ImGuiMouseButton.Left)) + { + int dragged = state.DraggedRow; + state.Release(); + + if (dragged >= 0 && drop is ReorderableTreeDrop landed) + { + return new ReorderableTreeMove(dragged, landed.ParentRow, landed.Index); + } + } + + return null; + } + + private static int RowAt(ReorderableTreeRowSpan[] spans, Vector2 point, float left, float right) + { + if (point.X < left || point.X >= right) + { + return -1; + } + + for (int i = 0; i < spans.Length; i++) + { + if (point.Y >= spans[i].Top && point.Y < spans[i].Bottom) + { + return i; + } + } + + return -1; + } + + private static void DrawDrop(ReorderableTreeDrop drop, ReorderableTreeRowSpan[] spans, float left, float right, float indent) + { + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + uint color = ImGui.GetColorU32(ImGuiCol.DragDropTarget); + + if (drop.Kind == ReorderableTreeDropKind.Inside) + { + ReorderableTreeRowSpan span = spans[drop.AnchorRow]; + drawList.AddRect(new Vector2(left, span.Top), new Vector2(right, span.Bottom), color, 0f, ImDrawFlags.None, 2f); + return; + } + + float x = left + (drop.LineDepth * indent); + drawList.AddLine(new Vector2(x, drop.LineY), new Vector2(right, drop.LineY), color, 2f); + drawList.AddCircleFilled(new Vector2(x, drop.LineY), 3f, color); + } + } +} diff --git a/ImGui.Widgets/ReorderableTreeMove.cs b/ImGui.Widgets/ReorderableTreeMove.cs new file mode 100644 index 00000000..4c91358c --- /dev/null +++ b/ImGui.Widgets/ReorderableTreeMove.cs @@ -0,0 +1,26 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.Widgets; + +/// A drop a reports: which row moves, and where to. +/// The index of the dragged row in the rows the tree was drawn from. +/// +/// The index of the row the dragged one becomes a child of, or -1 for the top level of the tree. +/// +/// +/// +/// Where among the new parent's children it goes, counted in the rows' own top-to-bottom order and +/// against the children as they stand before the move. It names the gap the insertion line was +/// drawn in: 0 is above the first child, and the number of children is below the last. +/// +/// +/// When the row moves down within its own parent, its old place is among the children counted, so +/// the index it ends up at once removed is one less than this. A caller whose move operation takes +/// the final position subtracts one in exactly that case; a caller whose operation takes the gap, +/// as this does, passes it through. +/// +/// +/// A drop inside a container is index 0, the top of its children, whether it is expanded or not. +/// +/// +public readonly record struct ReorderableTreeMove(int Row, int ParentRow, int Index); diff --git a/ImGui.Widgets/ReorderableTreeRow.cs b/ImGui.Widgets/ReorderableTreeRow.cs new file mode 100644 index 00000000..c68ff7a2 --- /dev/null +++ b/ImGui.Widgets/ReorderableTreeRow.cs @@ -0,0 +1,15 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.Widgets; + +/// One row of a , as the caller has laid it out. +/// +/// How many ancestors the row has. The first row is at depth 0, and each row is at most one deeper +/// than the row above it, which is what a tree flattened top to bottom always is. +/// +/// +/// Whether the row can hold children, which is what lets a drop land inside it. A container's +/// children are the rows directly beneath it at one more depth; a collapsed container simply has +/// none listed. +/// +public readonly record struct ReorderableTreeRow(int Depth, bool CanHaveChildren); diff --git a/ImGui.Widgets/ReorderableTreeState.cs b/ImGui.Widgets/ReorderableTreeState.cs new file mode 100644 index 00000000..9b66eba3 --- /dev/null +++ b/ImGui.Widgets/ReorderableTreeState.cs @@ -0,0 +1,306 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.Widgets; + +using System; +using System.Collections.Generic; +using System.Numerics; + +/// +/// Provides custom ImGui widgets. +/// +public static partial class ImGuiWidgets +{ + /// Where a row was drawn, top and bottom, in screen pixels. + /// The row's top edge. + /// The row's bottom edge. + internal readonly record struct ReorderableTreeRowSpan(float Top, float Bottom); + + /// How a resolved drop is shown. + internal enum ReorderableTreeDropKind + { + /// An insertion line between rows. + Line, + + /// A highlight around the container the row drops into. + Inside, + } + + /// A drop resolved from the pointer, with what the widget needs to draw it. + /// The row that becomes the parent, or -1 for the top level. + /// The gap among the parent's children, counted before the move. + /// Whether it is drawn as a line or a container highlight. + /// The row the line is drawn against, or the container highlighted. + /// Where the insertion line is drawn, in screen pixels. + /// How far in the insertion line starts, in levels of indent. + internal readonly record struct ReorderableTreeDrop( + int ParentRow, + int Index, + ReorderableTreeDropKind Kind, + int AnchorRow, + float LineY, + int LineDepth); + + /// + /// The interaction behind : which row was pressed, whether it is being + /// dragged, and where a drag would drop. + /// + /// + /// Deliberately free of ImGui, as and + /// are. The widget hands in the rows, where it drew them, and the pointer, which is what lets every + /// rule about where a drop lands be tested without a graphics context. + /// + internal sealed class ReorderableTreeState + { + /// + /// The fraction of a container row, from each edge, that drops beside it rather than inside it. + /// A leaf has no inside, so it splits at the middle instead. + /// + internal const float ContainerEdgeFraction = 0.25f; + + /// Gets the row the mouse was pressed on, or -1 when it was not pressed on a row. + public int PressedRow { get; private set; } = -1; + + /// Gets the row being dragged, or -1 when nothing is. + public int DraggedRow { get; private set; } = -1; + + /// Gets whether a row is being dragged. + public bool IsDragging => DraggedRow >= 0; + + /// Records a press on , which becomes a drag once the mouse moves far enough. + public void Press(int row) + { + PressedRow = row; + DraggedRow = -1; + } + + /// Turns the pending press into a drag, if there is one. + /// if a drag started. + public bool BeginDrag() + { + if (PressedRow < 0 || IsDragging) + { + return false; + } + + DraggedRow = PressedRow; + return true; + } + + /// Forgets the press and any drag. + public void Release() + { + PressedRow = -1; + DraggedRow = -1; + } + + /// The tree the flat rows describe: each row's parent, its place among its siblings, and where its subtree ends. + internal readonly record struct Structure(int[] Parents, int[] SiblingIndices, int[] SubtreeEnds); + + /// Recovers the tree from the rows' depths. + /// + /// The first row is not at depth 0, a depth is negative, or a row is more than one deeper than + /// the row above it. Each of those is a list no tree flattens to, so there is no parent to give + /// the row rather than a parent to guess. + /// + internal static Structure Analyze(IReadOnlyList rows) + { + Ensure.NotNull(rows); + + int count = rows.Count; + int[] parents = new int[count]; + int[] siblings = new int[count]; + int[] ends = new int[count]; + + // The open ancestors of the current row, outermost first, and how many children each has + // been given so far. The top level is the entry at depth 0 with no row of its own. + List ancestors = []; + List childCounts = [0]; + + for (int i = 0; i < count; i++) + { + int depth = rows[i].Depth; + if (depth < 0 || depth > ancestors.Count) + { + throw new ArgumentException( + $"Row {i} is at depth {depth}, but the row above it allows at most {ancestors.Count}. The rows must be a tree flattened top to bottom.", + nameof(rows)); + } + + // Leaving a subtree closes every ancestor deeper than this row, and each one's subtree + // ends here. + while (ancestors.Count > depth) + { + ends[ancestors[^1]] = i; + ancestors.RemoveAt(ancestors.Count - 1); + childCounts.RemoveAt(childCounts.Count - 1); + } + + parents[i] = depth == 0 ? -1 : ancestors[^1]; + siblings[i] = childCounts[^1]++; + + ancestors.Add(i); + childCounts.Add(0); + } + + foreach (int open in ancestors) + { + ends[open] = count; + } + + return new Structure(parents, siblings, ends); + } + + /// Resolves where would drop if released at . + /// The rows, top first. + /// Where each row was drawn. + /// The left edge of depth 0, where an unindented row starts. + /// The width of one level of indent. + /// The row being dragged. + /// The pointer, in the same space as . + /// The drop, or where none is allowed or it would change nothing. + /// + /// + /// The top quarter of a container row drops above it, the bottom quarter below it, and the middle + /// half inside it. A leaf splits at the middle. Above the first row is above it, and below the + /// last row is below it. + /// + /// + /// Below a row whose children are showing is the top of those children, since that is the gap the + /// line is drawn in. Below the last row of one or more subtrees, the pointer's horizontal position + /// picks how far out the drop lands, from the row's own depth out to the depth of the row beneath. + /// + /// + /// The pointer over the dragged row or anywhere in its subtree drops nowhere, since a row cannot + /// become its own descendant. A drop that would leave the row exactly where it is drops nowhere + /// either, so the widget never draws a line that means nothing. + /// + /// + internal static ReorderableTreeDrop? Resolve( + IReadOnlyList rows, + IReadOnlyList spans, + float left, + float indent, + int dragged, + Vector2 pointer) + { + Ensure.NotNull(rows); + Ensure.NotNull(spans); + + if (rows.Count == 0 || spans.Count != rows.Count || dragged < 0 || dragged >= rows.Count) + { + return null; + } + + Structure tree = Analyze(rows); + (int row, Zone zone) = Locate(rows, spans, pointer.Y); + + if (row >= dragged && row < tree.SubtreeEnds[dragged]) + { + return null; + } + + ReorderableTreeDrop drop = zone switch + { + Zone.Before => new ReorderableTreeDrop( + tree.Parents[row], tree.SiblingIndices[row], ReorderableTreeDropKind.Line, row, spans[row].Top, rows[row].Depth), + Zone.Inside => new ReorderableTreeDrop( + row, 0, ReorderableTreeDropKind.Inside, row, spans[row].Bottom, rows[row].Depth + 1), + _ => After(rows, spans, tree, row, left, indent, pointer.X), + }; + + // Unreachable while the subtree test above holds, since every parent a drop can name is the + // anchor row or one of its ancestors. Kept so a later zone cannot quietly break the rule. + bool intoOwnSubtree = drop.ParentRow >= dragged && drop.ParentRow < tree.SubtreeEnds[dragged]; + + bool inPlace = drop.ParentRow == tree.Parents[dragged] + && (drop.Index == tree.SiblingIndices[dragged] || drop.Index == tree.SiblingIndices[dragged] + 1); + + return intoOwnSubtree || inPlace ? null : drop; + } + + /// Which part of a row the pointer is over. + private enum Zone + { + Before, + Inside, + After, + } + + /// Finds the row the pointer is over, and which part of it. + /// + /// The space between two rows (ImGui's item spacing) belongs below the row above it, which is + /// the same gap the line would be drawn in either way. + /// + private static (int Row, Zone Zone) Locate( + IReadOnlyList rows, + IReadOnlyList spans, + float y) + { + for (int i = 0; i < spans.Count; i++) + { + ReorderableTreeRowSpan span = spans[i]; + if (y >= span.Bottom) + { + continue; + } + + if (y < span.Top) + { + return i == 0 ? (0, Zone.Before) : (i - 1, Zone.After); + } + + float height = span.Bottom - span.Top; + float fraction = height > 0f ? (y - span.Top) / height : 0.5f; + + if (!rows[i].CanHaveChildren) + { + return (i, fraction < 0.5f ? Zone.Before : Zone.After); + } + + return fraction < ContainerEdgeFraction + ? (i, Zone.Before) + : fraction >= 1f - ContainerEdgeFraction ? (i, Zone.After) : (i, Zone.Inside); + } + + return (spans.Count - 1, Zone.After); + } + + private static ReorderableTreeDrop After( + IReadOnlyList rows, + IReadOnlyList spans, + Structure tree, + int row, + float left, + float indent, + float x) + { + int depth = rows[row].Depth; + int next = row + 1; + float lineY = spans[row].Bottom; + + if (next < rows.Count && rows[next].Depth > depth) + { + // The row's children are showing, so the gap below it is above the first of them. + return new ReorderableTreeDrop(row, 0, ReorderableTreeDropKind.Line, row, lineY, depth + 1); + } + + // Below the end of a subtree there is more than one gap in one place: after the row itself, + // or after any ancestor it is the last descendant of, out to the depth of the row beneath. + // The pointer's horizontal position says which. An indent of zero leaves nothing to measure + // against, so the row's own depth stands. + int outermost = next < rows.Count ? rows[next].Depth : 0; + int pointed = indent > 0f ? (int)MathF.Floor((x - left) / indent) : depth; + int chosen = Math.Clamp(pointed, outermost, depth); + + int anchor = row; + while (rows[anchor].Depth > chosen) + { + anchor = tree.Parents[anchor]; + } + + return new ReorderableTreeDrop( + tree.Parents[anchor], tree.SiblingIndices[anchor] + 1, ReorderableTreeDropKind.Line, row, lineY, chosen); + } + } +} diff --git a/README.md b/README.md index 2f51bd82..567da7d2 100644 --- a/README.md +++ b/README.md @@ -554,6 +554,7 @@ Custom UI components. | `ScopedDisable` | RAII wrapper to disable UI elements | | `ScopedId` | RAII wrapper to push ImGui IDs | | `Tree` | Tree view with nested children | +| `ReorderableTree` | Drag-reorderable tree of caller-drawn rows | ### `ImGuiPopups` Classes diff --git a/examples/ImGuiWidgetsDemo/DemoSections.cs b/examples/ImGuiWidgetsDemo/DemoSections.cs index e9cd0d91..39605e24 100644 --- a/examples/ImGuiWidgetsDemo/DemoSections.cs +++ b/examples/ImGuiWidgetsDemo/DemoSections.cs @@ -27,6 +27,7 @@ internal static class DemoSections new(TextUtilitiesDemo.Show, TextUtilitiesDemo.ResetState), new(ScopedUtilitiesDemo.Show, ScopedUtilitiesDemo.ResetState), new(TreeViewDemo.Show, TreeViewDemo.ResetState), + new(ReorderableTreeDemo.Show, ReorderableTreeDemo.ResetState), new(MobileDecoratorsDemo.Show, MobileDecoratorsDemo.ResetState), new(MobileContainersDemo.Show, MobileContainersDemo.ResetState), new(HistogramAndHandleTrackDemo.Show, HistogramAndHandleTrackDemo.ResetState), diff --git a/examples/ImGuiWidgetsDemo/ReorderableTreeDemo.cs b/examples/ImGuiWidgetsDemo/ReorderableTreeDemo.cs new file mode 100644 index 00000000..62e4d7e3 --- /dev/null +++ b/examples/ImGuiWidgetsDemo/ReorderableTreeDemo.cs @@ -0,0 +1,134 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.Examples.Widgets; + +using Hexa.NET.ImGui; +using ktsu.ImGui.Widgets; + +/// +/// The reorderable tree, over a small tree of layers the demo owns. +/// +internal static class ReorderableTreeDemo +{ + private static List roots = Initial(); + + private static string lastMove = "Drag a row to move it."; + + /// Gets the tree's shape, top first, with one dot per level of depth, for tests to read. + internal static string Shape + { + get + { + List<(DemoNode Node, int Depth, DemoNode? Parent)> flat = []; + Flatten(roots, 0, null, flat); + return string.Join(" ", flat.Select(entry => new string('.', entry.Depth) + entry.Node.Name)); + } + } + + /// + /// Returns this section's state to its starting values. The demo keeps its state in statics, which + /// outlive a harness. + /// + internal static void ResetState() + { + roots = Initial(); + lastMove = "Drag a row to move it."; + } + + private static List Initial() => + [ + new("Background", false), + new("Photos", true) { Children = { new("Beach", false), new("Forest", false) } }, + new("Notes", false), + new("Archive", true), + ]; + + private static void Flatten(List siblings, int depth, DemoNode? parent, List<(DemoNode, int, DemoNode?)> into) + { + foreach (DemoNode node in siblings) + { + into.Add((node, depth, parent)); + if (node.Expanded) + { + Flatten(node.Children, depth + 1, node, into); + } + } + } + + /// Draws the demo section. + internal static void Show() + { + if (!DemoProbe.Header("Reorderable Tree")) + { + return; + } + + ImGui.TextUnformatted("Rows the caller draws, dragged to reorder them and to move them in and out of groups:"); + ImGui.Separator(); + + List<(DemoNode Node, int Depth, DemoNode? Parent)> flat = []; + Flatten(roots, 0, null, flat); + ReorderableTreeRow[] rows = [.. flat.Select(entry => new ReorderableTreeRow(entry.Depth, entry.Node.IsGroup))]; + + ReorderableTreeMove? move = ImGuiWidgets.ReorderableTree("demoTree", rows, i => DrawRow(flat[i].Node)); + + if (move is ReorderableTreeMove landed) + { + Apply(flat, landed); + } + + ImGui.Separator(); + ImGui.TextUnformatted("The top quarter of a group drops above it, the middle half inside, the bottom quarter below."); + ImGui.TextUnformatted("Below the last row of a group, how far left the pointer is picks how far out the row lands."); + ImGui.TextUnformatted(lastMove); + } + + private static void DrawRow(DemoNode node) + { + if (node.IsGroup) + { + if (ImGui.ArrowButton("##expand", node.Expanded ? ImGuiDir.Down : ImGuiDir.Right)) + { + node.Expanded = !node.Expanded; + } + + ImGui.SameLine(); + } + + ImGui.TextUnformatted(node.Name); + } + + /// Applies a move to the demo's tree, as an application would through its own model. + private static void Apply(List<(DemoNode Node, int Depth, DemoNode? Parent)> flat, ReorderableTreeMove move) + { + (DemoNode node, _, DemoNode? oldParent) = flat[move.Row]; + DemoNode? newParent = move.ParentRow < 0 ? null : flat[move.ParentRow].Node; + List from = oldParent?.Children ?? roots; + List to = newParent?.Children ?? roots; + + int oldIndex = from.IndexOf(node); + int index = move.Index; + + // The index is the gap among the children before the move. A list insert takes the position + // after it, which is one less when the row leaves a place above the gap in the same list. + if (ReferenceEquals(from, to) && oldIndex < index) + { + index--; + } + + from.RemoveAt(oldIndex); + to.Insert(index, node); + lastMove = $"Moved {node.Name} into {newParent?.Name ?? "the top level"} at {index}."; + } + + private sealed class DemoNode(string name, bool isGroup) + { + public string Name { get; } = name; + + public bool IsGroup { get; } = isGroup; + + public bool Expanded { get; set; } = true; + + public List Children { get; } = []; + } +} diff --git a/tests/ImGui.Widgets.Tests/ReorderableTreeStateTests.cs b/tests/ImGui.Widgets.Tests/ReorderableTreeStateTests.cs new file mode 100644 index 00000000..2a26e5f7 --- /dev/null +++ b/tests/ImGui.Widgets.Tests/ReorderableTreeStateTests.cs @@ -0,0 +1,244 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.Widgets.Tests; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests where a drag in ReorderableTree lands. All pure — no ImGui context required. +/// +/// +/// The fixture is laid out as the widget lays rows out: each row 20 pixels tall, 4 pixels of item +/// spacing between rows, and 20 pixels of indent per level, starting at x = 0. +/// +/// 0 A group, expanded +/// 1 a1 leaf +/// 2 a2 leaf +/// 3 B leaf +/// 4 C group, collapsed +/// +/// +[TestClass] +public class ReorderableTreeStateTests +{ + private const float RowHeight = 20f; + private const float Spacing = 4f; + private const float Indent = 20f; + + private const int A = 0; + private const int A1 = 1; + private const int A2 = 2; + private const int B = 3; + private const int C = 4; + + private static readonly ReorderableTreeRow[] Rows = + [ + new(0, true), + new(1, false), + new(1, false), + new(0, false), + new(0, true), + ]; + + private static readonly int[] ExpectedParents = [-1, A, A, -1, -1]; + private static readonly int[] ExpectedSiblings = [0, 0, 1, 1, 2]; + private static readonly int[] ExpectedSubtreeEnds = [3, 2, 3, 4, 5]; + + private static List SpansFor(int count) => + [.. Enumerable.Range(0, count).Select(i => new ImGuiWidgets.ReorderableTreeRowSpan(i * (RowHeight + Spacing), (i * (RowHeight + Spacing)) + RowHeight))]; + + /// A point a given fraction of the way down a row, at a given depth of indent. + private static Vector2 At(int row, float fraction, int depth = 0) => + new((depth * Indent) + 5f, (row * (RowHeight + Spacing)) + (fraction * RowHeight)); + + private static ImGuiWidgets.ReorderableTreeDrop? Drop(int dragged, Vector2 pointer) => + ImGuiWidgets.ReorderableTreeState.Resolve(Rows, SpansFor(Rows.Length), 0f, Indent, dragged, pointer); + + private static void AssertDrop(ImGuiWidgets.ReorderableTreeDrop? drop, int parent, int index) + { + Assert.IsNotNull(drop); + Assert.AreEqual(parent, drop.Value.ParentRow, "parent"); + Assert.AreEqual(index, drop.Value.Index, "index"); + } + + [TestMethod] + public void Analyze_RecoversParentsSiblingsAndSubtrees() + { + ImGuiWidgets.ReorderableTreeState.Structure tree = ImGuiWidgets.ReorderableTreeState.Analyze(Rows); + + CollectionAssert.AreEqual(ExpectedParents, tree.Parents); + CollectionAssert.AreEqual(ExpectedSiblings, tree.SiblingIndices); + CollectionAssert.AreEqual(ExpectedSubtreeEnds, tree.SubtreeEnds); + } + + [TestMethod] + public void Analyze_RefusesRowsThatAreNotAFlattenedTree() + { + Assert.ThrowsExactly(() => ImGuiWidgets.ReorderableTreeState.Analyze([new(1, false)])); + Assert.ThrowsExactly(() => ImGuiWidgets.ReorderableTreeState.Analyze([new(0, true), new(2, false)])); + Assert.ThrowsExactly(() => ImGuiWidgets.ReorderableTreeState.Analyze([new(0, true), new(-1, false)])); + } + + [TestMethod] + public void Analyze_AcceptsNoRows() + { + ImGuiWidgets.ReorderableTreeState.Structure tree = ImGuiWidgets.ReorderableTreeState.Analyze([]); + + Assert.IsEmpty(tree.Parents); + } + + [TestMethod] + public void Leaf_TopHalfDropsAboveIt() => AssertDrop(Drop(B, At(A1, 0.4f, 1)), A, 0); + + [TestMethod] + public void Leaf_BottomHalfDropsBelowIt() => AssertDrop(Drop(B, At(A1, 0.6f, 1)), A, 1); + + [TestMethod] + public void Container_TopQuarterDropsAboveIt() => AssertDrop(Drop(A1, At(C, 0.2f)), -1, 2); + + [TestMethod] + public void Container_MiddleHalfDropsInsideIt() + { + ImGuiWidgets.ReorderableTreeDrop? drop = Drop(B, At(C, 0.5f)); + + AssertDrop(drop, C, 0); + Assert.AreEqual(ImGuiWidgets.ReorderableTreeDropKind.Inside, drop!.Value.Kind); + } + + [TestMethod] + public void Container_MiddleHalfStartsAndEndsAtTheQuarters() + { + AssertDrop(Drop(B, At(C, 0.25f)), C, 0); + AssertDrop(Drop(B, At(C, 0.74f)), C, 0); + AssertDrop(Drop(A1, At(C, 0.75f)), -1, 3); + } + + [TestMethod] + public void CollapsedContainer_BottomQuarterDropsBelowIt() => AssertDrop(Drop(A1, At(C, 0.9f)), -1, 3); + + [TestMethod] + public void ExpandedContainer_BottomQuarterDropsAboveItsFirstChild() + { + // The gap below an expanded group's row is the gap above its first child, so that is where the + // line is drawn and where the drop lands. + ImGuiWidgets.ReorderableTreeDrop? drop = Drop(B, At(A, 0.9f)); + + AssertDrop(drop, A, 0); + Assert.AreEqual(1, drop!.Value.LineDepth); + } + + [TestMethod] + public void EndOfSubtree_PointerIndentedStaysInside() => AssertDrop(Drop(C, At(A2, 0.9f, depth: 1)), A, 2); + + [TestMethod] + public void EndOfSubtree_PointerAtTheLeftDropsAfterTheAncestor() + { + ImGuiWidgets.ReorderableTreeDrop? drop = Drop(C, At(A2, 0.9f, depth: 0)); + + AssertDrop(drop, -1, 1); + Assert.AreEqual(0, drop!.Value.LineDepth); + } + + [TestMethod] + public void EndOfSubtree_PointerFurtherInThanTheRowIsClampedToIt() => AssertDrop(Drop(C, At(A2, 0.9f, depth: 5)), A, 2); + + [TestMethod] + public void MidSubtree_PointerAtTheLeftCannotLeaveIt() + { + // Below a1 the next row is a2, a sibling, so there is only one gap there however far left the + // pointer is. + AssertDrop(Drop(C, At(A1, 0.9f, depth: 0)), A, 1); + } + + [TestMethod] + public void AboveTheFirstRow_DropsAboveIt() => AssertDrop(Drop(C, new Vector2(5f, -30f)), -1, 0); + + [TestMethod] + public void BelowTheLastRow_DropsBelowIt() => AssertDrop(Drop(A1, new Vector2(5f, 500f)), -1, 3); + + [TestMethod] + public void TheGapBetweenRows_BelongsBelowTheRowAbove() + { + // Between A's bottom (20) and a1's top (24). Below A, which is expanded, is a1's gap. + AssertDrop(Drop(B, new Vector2(25f, 22f)), A, 0); + } + + [TestMethod] + public void OntoTheDraggedRowItself_IsRefused() + { + Assert.IsNull(Drop(A, At(A, 0.5f))); + Assert.IsNull(Drop(B, At(B, 0.1f))); + } + + [TestMethod] + public void IntoTheDraggedRowsSubtree_IsRefused() + { + Assert.IsNull(Drop(A, At(A1, 0.5f, 1))); + Assert.IsNull(Drop(A, At(A2, 0.9f, 0))); + } + + [TestMethod] + public void ADropThatChangesNothing_IsRefused() + { + // Directly above B, from below a2 at the left: B's own gap. + Assert.IsNull(Drop(B, At(A2, 0.9f, depth: 0))); + + // Directly below B, from the top of C: B's other gap. + Assert.IsNull(Drop(B, At(C, 0.1f))); + } + + [TestMethod] + public void TheIndexCountsTheChildrenBeforeTheMove() + { + // a1 is the first of A's two children. Dropped below a2 it ends up second, at position 1 once + // it has left position 0 — but the gap it was dropped in is the one after both, which is 2. + // This is the convention ReorderableTreeMove.Index documents, and the one a caller moving down + // within a parent has to adjust for if its move takes the final position. + AssertDrop(Drop(A1, At(A2, 0.9f, depth: 1)), A, 2); + } + + [TestMethod] + public void MovingUpWithinAParent_NeedsNoAdjustment() => AssertDrop(Drop(A2, At(A1, 0.1f, depth: 1)), A, 0); + + [TestMethod] + public void ARowCanBeDroppedIntoAContainerAboveIt() => AssertDrop(Drop(C, At(A, 0.5f)), A, 0); + + [TestMethod] + public void AnUnknownDraggedRow_DropsNowhere() + { + Assert.IsNull(Drop(-1, At(B, 0.5f))); + Assert.IsNull(Drop(Rows.Length, At(B, 0.5f))); + } + + [TestMethod] + public void State_APressBecomesADragOnlyWhenAsked() + { + ImGuiWidgets.ReorderableTreeState state = new(); + + state.Press(B); + Assert.AreEqual(B, state.PressedRow); + Assert.IsFalse(state.IsDragging); + + Assert.IsTrue(state.BeginDrag()); + Assert.AreEqual(B, state.DraggedRow); + Assert.IsFalse(state.BeginDrag(), "a drag already under way does not start again"); + + state.Release(); + Assert.AreEqual(-1, state.PressedRow); + Assert.IsFalse(state.IsDragging); + } + + [TestMethod] + public void State_NoPressMeansNoDrag() + { + ImGuiWidgets.ReorderableTreeState state = new(); + + Assert.IsFalse(state.BeginDrag()); + Assert.IsFalse(state.IsDragging); + } +} diff --git a/tests/ImGui.Widgets.UITests/ReorderableTreeTests.cs b/tests/ImGui.Widgets.UITests/ReorderableTreeTests.cs new file mode 100644 index 00000000..debae548 --- /dev/null +++ b/tests/ImGui.Widgets.UITests/ReorderableTreeTests.cs @@ -0,0 +1,316 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.Widgets.UITests; + +using System.Collections.Generic; +using System.Linq; + +using Hexa.NET.ImGui; + +using ktsu.ImGui.App.Testing; +using ktsu.ImGui.Probes; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// Drives on its own, over a small tree the test owns. +/// +/// The tree starts as +/// +/// Photos group +/// Beach +/// Forest +/// Notes +/// Archive empty group +/// +/// and every reported move is applied to it, the way a caller would, so the assertions read the tree +/// rather than the move. +/// +[TestClass] +public sealed class ReorderableTreeTests : WidgetTest +{ + private const string Label = "layers"; + + private List roots = []; + private string editing = string.Empty; + private bool editNotes; + private readonly List moves = []; + private readonly List clicked = []; + + [TestInitialize] + public void SetUp() + { + roots = + [ + new TreeNode("Photos", true, [new TreeNode("Beach", false, []), new TreeNode("Forest", false, [])]), + new TreeNode("Notes", false, []), + new TreeNode("Archive", true, []), + ]; + moves.Clear(); + clicked.Clear(); + editing = "Notes"; + editNotes = false; + } + + private void Draw() + { + List<(TreeNode Node, TreeNode? Parent)> flat = []; + Flatten(roots, null, flat); + ReorderableTreeRow[] rows = [.. flat.Select(entry => new ReorderableTreeRow(Depth(entry.Node), entry.Node.IsGroup))]; + + ReorderableTreeMove? move = ImGuiWidgets.ReorderableTree(Label, rows, i => + { + if (editNotes && flat[i].Node.Name == "Notes") + { + ImGui.SetNextItemWidth(200f); + ImGui.InputText("##edit", ref editing, 64u); + ImGuiProbes.MarkItem("edit"); + return; + } + + if (ImGui.Selectable(flat[i].Node.Name)) + { + clicked.Add(flat[i].Node.Name); + } + + ImGuiProbes.MarkItem(flat[i].Node.Name); + }); + + if (move is ReorderableTreeMove landed) + { + moves.Add(landed); + Apply(flat, landed); + } + } + + private int Depth(TreeNode node) + { + int depth = 0; + for (TreeNode? parent = ParentOf(roots, node); parent is not null; parent = ParentOf(roots, parent)) + { + depth++; + } + + return depth; + } + + private static TreeNode? ParentOf(List siblings, TreeNode node) + { + foreach (TreeNode sibling in siblings) + { + if (sibling.Children.Contains(node)) + { + return sibling; + } + + if (ParentOf(sibling.Children, node) is TreeNode found) + { + return found; + } + } + + return null; + } + + private static void Flatten(List siblings, TreeNode? parent, List<(TreeNode, TreeNode?)> into) + { + foreach (TreeNode node in siblings) + { + into.Add((node, parent)); + Flatten(node.Children, node, into); + } + } + + /// Applies a move as a caller whose list insert takes the final position would. + private void Apply(List<(TreeNode Node, TreeNode? Parent)> flat, ReorderableTreeMove move) + { + (TreeNode node, TreeNode? oldParent) = flat[move.Row]; + List from = oldParent?.Children ?? roots; + List to = move.ParentRow < 0 ? roots : flat[move.ParentRow].Node.Children; + + int oldIndex = from.IndexOf(node); + int index = move.Index; + + // The index names a gap among the children before the move, so leaving a place above that + // gap in the same list moves the gap up by one. + if (ReferenceEquals(from, to) && oldIndex < index) + { + index--; + } + + from.RemoveAt(oldIndex); + to.Insert(index, node); + } + + private string Shape() + { + List<(TreeNode Node, TreeNode? Parent)> flat = []; + Flatten(roots, null, flat); + return string.Join(" ", flat.Select(entry => new string('.', Depth(entry.Node)) + entry.Node.Name)); + } + + /// Drags from a fraction of the way down one row to a fraction of the way down another. + private void DragRow(int fromRow, int toRow, float toFraction, float toX = 30f) + { + Rectangle from = RectOf($"{Label}/row{fromRow}"); + Rectangle to = RectOf($"{Label}/row{toRow}"); + Harness.Mouse.Drag( + from.MinX + 30f, + from.MinY + (from.Height / 2f), + to.MinX + toX, + to.MinY + (to.Height * toFraction), + steps: 8, + button: 0); + Step(2); + } + + [TestMethod] + public void ReorderableTree_DrawsAndMarksEveryRow() + { + Start(Draw); + + for (int i = 0; i < 5; i++) + { + Assert.IsTrue(IsVisible($"{Label}/row{i}"), $"row {i} was not marked"); + } + + Assert.IsTrue(IsVisible($"{Label}/Beach"), "the row's own content was not drawn inside the tree's scope"); + } + + [TestMethod] + public void ReorderableTree_IndentsChildrenByTheirDepth() + { + Start(Draw); + + float indent = RectOf($"{Label}/Beach").MinX - RectOf($"{Label}/Photos").MinX; + + Assert.AreEqual(ImGui.GetStyle().IndentSpacing, indent, 0.5f); + } + + [TestMethod] + public void ReorderableTree_DragIntoTheMiddleOfAGroupMovesTheRowInside() + { + Start(Draw); + + DragRow(fromRow: 3, toRow: 4, toFraction: 0.5f); + + Assert.AreEqual("Photos .Beach .Forest Archive .Notes", Shape()); + } + + [TestMethod] + public void ReorderableTree_DragAboveTheFirstRowMovesTheRowToTheTop() + { + Start(Draw); + + DragRow(fromRow: 3, toRow: 0, toFraction: 0.1f); + + Assert.AreEqual("Notes Photos .Beach .Forest Archive", Shape()); + } + + [TestMethod] + public void ReorderableTree_DragOutOfAGroupAtTheLeftLeavesIt() + { + Start(Draw); + + // Below Forest, the last child of Photos, with the pointer at depth 0: after Photos. + DragRow(fromRow: 1, toRow: 2, toFraction: 0.9f, toX: 2f); + + Assert.AreEqual("Photos .Forest Beach Notes Archive", Shape()); + } + + [TestMethod] + public void ReorderableTree_DragDownWithinAGroupReportsTheGapBeforeTheMove() + { + Start(Draw); + + DragRow(fromRow: 1, toRow: 2, toFraction: 0.9f, toX: ImGui.GetStyle().IndentSpacing + 10f); + + Assert.HasCount(1, moves); + Assert.AreEqual(new ReorderableTreeMove(1, 0, 2), moves[0]); + Assert.AreEqual("Photos .Forest .Beach Notes Archive", Shape()); + } + + [TestMethod] + public void ReorderableTree_DragOntoItsOwnChildDoesNothing() + { + Start(Draw); + + DragRow(fromRow: 0, toRow: 1, toFraction: 0.5f); + + Assert.IsEmpty(moves); + Assert.AreEqual("Photos .Beach .Forest Notes Archive", Shape()); + } + + [TestMethod] + public void ReorderableTree_AClickWithoutMovingIsLeftToTheRow() + { + Start(Draw); + + Click($"{Label}/Notes"); + + Assert.IsEmpty(moves); + Assert.HasCount(1, clicked); + Assert.AreEqual("Notes", clicked[0]); + } + + [TestMethod] + public void ReorderableTree_ShowsWhereTheDragWouldLandWhileHeld() + { + Start(Draw); + + Rectangle notes = RectOf($"{Label}/row3"); + Rectangle archive = RectOf($"{Label}/row4"); + + Harness.Mouse.MoveTo(notes.MinX + 30f, notes.MinY + (notes.Height / 2f)); + Step(); + HarnessMouse.Down(0); + Step(); + Harness.Mouse.MoveTo(notes.MinX + 30f, notes.MinY + (notes.Height / 2f) + 3f); + Step(); + byte[] held = Snapshot(); + + Harness.Mouse.MoveTo(archive.MinX + 30f, archive.MinY + (archive.Height / 2f)); + Step(2); + + Assert.IsGreaterThan(0, PixelsChangedSince(held), "nothing was drawn to show the drop into Archive"); + Assert.IsEmpty(moves, "a move was reported before the button was released"); + + HarnessMouse.Up(0); + Step(2); + + Assert.HasCount(1, moves); + } + + [TestMethod] + public void ReorderableTree_DraggingInATextFieldSelectsTextRatherThanMovingTheRow() + { + editNotes = true; + Start(Draw); + + Rectangle field = RectOf($"{Label}/edit"); + Rectangle archive = RectOf($"{Label}/row4"); + Harness.Mouse.Drag( + field.MinX + 4f, + field.MinY + (field.Height / 2f), + archive.MinX + 30f, + archive.MinY + (archive.Height / 2f), + steps: 8, + button: 0); + Step(2); + + Assert.IsEmpty(moves); + Assert.AreEqual("Photos .Beach .Forest Notes Archive", Shape()); + } + + [TestMethod] + public void ReorderableTree_ReleasingOutsideAnyDropReportsNothing() + { + Start(Draw); + + // Onto Notes itself: a drop that would leave it where it is. + DragRow(fromRow: 3, toRow: 3, toFraction: 0.9f); + + Assert.IsEmpty(moves); + } + + private sealed record TreeNode(string Name, bool IsGroup, List Children); +} diff --git a/tests/ImGuiWidgetsDemo.UITests/DemoSectionRegistryTests.cs b/tests/ImGuiWidgetsDemo.UITests/DemoSectionRegistryTests.cs index c18d5753..039f35d7 100644 --- a/tests/ImGuiWidgetsDemo.UITests/DemoSectionRegistryTests.cs +++ b/tests/ImGuiWidgetsDemo.UITests/DemoSectionRegistryTests.cs @@ -24,7 +24,7 @@ public sealed class DemoSectionRegistryTests [ nameof(MobileFormControlsDemo), nameof(PropertyGridDemo), nameof(KnobDemo), nameof(RadialProgressBarDemo), nameof(ColorIndicatorDemo), nameof(ComboDemo), - nameof(TextUtilitiesDemo), nameof(ScopedUtilitiesDemo), nameof(TreeViewDemo), + nameof(TextUtilitiesDemo), nameof(ScopedUtilitiesDemo), nameof(TreeViewDemo), nameof(ReorderableTreeDemo), nameof(MobileDecoratorsDemo), nameof(MobileContainersDemo), nameof(HistogramAndHandleTrackDemo), nameof(CurveTrackDemo), nameof(TimecodeFieldDemo), nameof(WaveformDemo), nameof(TransportScrubberDemo), nameof(SpectrumAnalyzerDemo), nameof(FrameTimeGraphDemo), nameof(EnvelopeEditorDemo), nameof(ParametricEqDemo), nameof(StereoMetersDemo), nameof(ChannelFaderDemo), nameof(PianoKeyboardDemo), nameof(StepGridDemo), nameof(ColorWheelDemo), diff --git a/tests/ImGuiWidgetsDemo.UITests/WidgetsDemoUITests.cs b/tests/ImGuiWidgetsDemo.UITests/WidgetsDemoUITests.cs index c43abc88..84a9002b 100644 --- a/tests/ImGuiWidgetsDemo.UITests/WidgetsDemoUITests.cs +++ b/tests/ImGuiWidgetsDemo.UITests/WidgetsDemoUITests.cs @@ -42,7 +42,7 @@ public sealed class WidgetsDemoUITests private static readonly string[] WidgetDemoSections = [ "Mobile - Form Controls", "Property Grid", "Knobs", "Radial Progress Bar", "Color Indicators", - "Combo Boxes", "Text Utilities", "Scoped Utilities", "Tree View", + "Combo Boxes", "Text Utilities", "Scoped Utilities", "Tree View", "Reorderable Tree", "Mobile - Decorators", "Mobile - Containers & Loaders", "Waveform", "Transport Scrubber", "Spectrum Analyzer", "Frame Time Graph", "Stereo Meters", "Channel Fader", "Color wheels", "Diff view", "Toolbar", "Piano Keyboard", "Parametric EQ", @@ -379,6 +379,29 @@ public void ScopedUtilities_ShowDisabledControlsAndScopedIds() } } + [TestMethod] + public void ReorderableTree_DragsARowIntoAGroup() + { + OpenSection(WidgetDemosTab, "Reorderable Tree"); + + // Rows: Background, Photos, Beach, Forest, Notes, Archive. Notes onto the middle of Archive. + Rectangle notes = harness.Probe.Rect("demoTree/row4") + ?? throw new InvalidOperationException("The reorderable tree marked no row 4."); + Rectangle archive = harness.Probe.Rect("demoTree/row5") + ?? throw new InvalidOperationException("The reorderable tree marked no row 5."); + + harness.Mouse.Drag( + notes.MinX + 40f, + notes.MinY + (notes.Height / 2f), + archive.MinX + 40f, + archive.MinY + (archive.Height / 2f), + steps: 8, + button: 0); + harness.Step(SettleFrames); + + Assert.AreEqual("Background Photos .Beach .Forest Archive .Notes", ReorderableTreeDemo.Shape); + } + [TestMethod] public void TreeView_ShowsItsNodes() { diff --git a/tools/WidgetGallery/Catalog/LayoutAndContainers.cs b/tools/WidgetGallery/Catalog/LayoutAndContainers.cs index ed928952..d94661b8 100644 --- a/tools/WidgetGallery/Catalog/LayoutAndContainers.cs +++ b/tools/WidgetGallery/Catalog/LayoutAndContainers.cs @@ -94,6 +94,8 @@ public static IEnumerable Build() }); }); + yield return ReorderableTreeTile(Category); + yield return new("IconTreeNode", Category, [nameof(ImGuiWidgets.IconTreeNode)], _ => { if (ImGuiWidgets.IconTreeNode("Project", "\uE2C7", Color.FromHex("#e6b333"), ImGuiTreeNodeFlags.DefaultOpen)) @@ -228,6 +230,16 @@ private static GalleryEntry ImageCompareEntry(GalleryCategory category) /// /// The group the tile belongs to. /// The tile. + private static GalleryEntry ReorderableTreeTile(GalleryCategory category) + { + // A still frame shows the rows and their indent; the drag affordances only exist while a row is + // held, which a tile cannot show. + string[] names = ["Background", "Photos", "Beach", "Forest", "Notes"]; + ReorderableTreeRow[] rows = [new(0, false), new(0, true), new(1, false), new(1, false), new(0, false)]; + return new("ReorderableTree", category, [nameof(ImGuiWidgets.ReorderableTree)], _ => + ImGuiWidgets.ReorderableTree("##reorderable", rows, i => ImGui.Selectable(names[i], i == 2))); + } + private static GalleryEntry CropOverlayTile(GalleryCategory category) { Vector2 imageSize = new(SampleImage.Size, SampleImage.Size); From 69315da1e01a7c306fa07c10cea04c8714c9ac63 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 23:33:05 +0000 Subject: [PATCH 2/2] Read the resolved drop through a non-null local in the tree state tests Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RU5HC3D44CgGTP97XE4WQz --- .../ReorderableTreeStateTests.cs | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/tests/ImGui.Widgets.Tests/ReorderableTreeStateTests.cs b/tests/ImGui.Widgets.Tests/ReorderableTreeStateTests.cs index 2a26e5f7..59ebf6f6 100644 --- a/tests/ImGui.Widgets.Tests/ReorderableTreeStateTests.cs +++ b/tests/ImGui.Widgets.Tests/ReorderableTreeStateTests.cs @@ -59,11 +59,12 @@ private static Vector2 At(int row, float fraction, int depth = 0) => private static ImGuiWidgets.ReorderableTreeDrop? Drop(int dragged, Vector2 pointer) => ImGuiWidgets.ReorderableTreeState.Resolve(Rows, SpansFor(Rows.Length), 0f, Indent, dragged, pointer); - private static void AssertDrop(ImGuiWidgets.ReorderableTreeDrop? drop, int parent, int index) + private static ImGuiWidgets.ReorderableTreeDrop AssertDrop(ImGuiWidgets.ReorderableTreeDrop? drop, int parent, int index) { - Assert.IsNotNull(drop); - Assert.AreEqual(parent, drop.Value.ParentRow, "parent"); - Assert.AreEqual(index, drop.Value.Index, "index"); + ImGuiWidgets.ReorderableTreeDrop landed = drop ?? throw new AssertFailedException("The drag dropped nowhere."); + Assert.AreEqual(parent, landed.ParentRow, "parent"); + Assert.AreEqual(index, landed.Index, "index"); + return landed; } [TestMethod] @@ -104,10 +105,9 @@ public void Analyze_AcceptsNoRows() [TestMethod] public void Container_MiddleHalfDropsInsideIt() { - ImGuiWidgets.ReorderableTreeDrop? drop = Drop(B, At(C, 0.5f)); + ImGuiWidgets.ReorderableTreeDrop drop = AssertDrop(Drop(B, At(C, 0.5f)), C, 0); - AssertDrop(drop, C, 0); - Assert.AreEqual(ImGuiWidgets.ReorderableTreeDropKind.Inside, drop!.Value.Kind); + Assert.AreEqual(ImGuiWidgets.ReorderableTreeDropKind.Inside, drop.Kind); } [TestMethod] @@ -126,10 +126,9 @@ public void ExpandedContainer_BottomQuarterDropsAboveItsFirstChild() { // The gap below an expanded group's row is the gap above its first child, so that is where the // line is drawn and where the drop lands. - ImGuiWidgets.ReorderableTreeDrop? drop = Drop(B, At(A, 0.9f)); + ImGuiWidgets.ReorderableTreeDrop drop = AssertDrop(Drop(B, At(A, 0.9f)), A, 0); - AssertDrop(drop, A, 0); - Assert.AreEqual(1, drop!.Value.LineDepth); + Assert.AreEqual(1, drop.LineDepth); } [TestMethod] @@ -138,10 +137,9 @@ public void ExpandedContainer_BottomQuarterDropsAboveItsFirstChild() [TestMethod] public void EndOfSubtree_PointerAtTheLeftDropsAfterTheAncestor() { - ImGuiWidgets.ReorderableTreeDrop? drop = Drop(C, At(A2, 0.9f, depth: 0)); + ImGuiWidgets.ReorderableTreeDrop drop = AssertDrop(Drop(C, At(A2, 0.9f, depth: 0)), -1, 1); - AssertDrop(drop, -1, 1); - Assert.AreEqual(0, drop!.Value.LineDepth); + Assert.AreEqual(0, drop.LineDepth); } [TestMethod]