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..59ebf6f6
--- /dev/null
+++ b/tests/ImGui.Widgets.Tests/ReorderableTreeStateTests.cs
@@ -0,0 +1,242 @@
+// 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 ImGuiWidgets.ReorderableTreeDrop AssertDrop(ImGuiWidgets.ReorderableTreeDrop? drop, int parent, int 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]
+ 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 = AssertDrop(Drop(B, At(C, 0.5f)), C, 0);
+
+ Assert.AreEqual(ImGuiWidgets.ReorderableTreeDropKind.Inside, drop.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 = AssertDrop(Drop(B, At(A, 0.9f)), A, 0);
+
+ Assert.AreEqual(1, drop.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 = AssertDrop(Drop(C, At(A2, 0.9f, depth: 0)), -1, 1);
+
+ Assert.AreEqual(0, drop.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);