Skip to content

Domain widgets Tier 2: add AssetBrowser (virtualized thumbnail grid with selection and drag-out) #509

Description

@matt-edmondson

Part of the domain widget shortlist (docs/plans/2026-09-29-domain-widget-shortlist.md), ranked 11th of 13: high value for game engine tooling, medium effort.

Summary

AssetBrowser is the content browser of an engine or editor: a virtualized, resizable grid of thumbnail tiles with labels. It supports click, Ctrl and Shift multi-selection, keyboard navigation, Ctrl+wheel tile resizing, activation (double-click or Enter), and dragging the selection out as an ImGui drag-and-drop payload.

It is index-based: the caller has itemCount items and supplies getLabel(i) and resolveThumbnail(i). The widget never holds the items, so a folder of 50 000 assets costs only the visible rows, and filtering or sorting is the host's job (pass the filtered count and map indices).

Thumbnails come from a host resolver returning a texture id, the same contract as PropertyGridOptions.ThumbnailResolver, because ktsu.ImGui.Widgets does not reference ImGui.App.

It does not wrap Grid: Grid lays out every cell every frame, and virtualization needs uniform rows under an ImGuiListClipper.

Files

New:

  • ImGui.Widgets/AssetBrowser.cs: the widget, AssetBrowserOptions, TryAcceptAssetPayload, and internal AssetBrowserImpl.
  • ImGui.Widgets/AssetBrowserState.cs: public ImGuiWidgets.AssetBrowserState (selection, focus, grid geometry; no ImGui calls).
  • tests/ImGui.Widgets.Tests/AssetBrowserStateTests.cs
  • tests/ImGui.Widgets.UITests/AssetBrowserTests.cs
  • examples/ImGuiWidgetsDemo/AssetBrowserDemo.cs

Changed:

  • examples/ImGuiWidgetsDemo/ImGuiWidgetsDemo.cs: call AssetBrowserDemo.ResetState() and AssetBrowserDemo.Show() in the "Layout and Containers" area, after the ImageCanvas demo.
  • tests/ImGuiWidgetsDemo.UITests/WidgetsDemoUITests.cs: one test.
  • ImGui.Widgets/README.md: an AssetBrowser bullet under "Layout and Containers".
  • CLAUDE.md: add AssetBrowser to the "layout and containers" group of the ImGui.Widgets entry.

Public API

namespace ktsu.ImGui.Widgets;

public static partial class ImGuiWidgets
{
	/// <summary>Options for <see cref="AssetBrowser"/>.</summary>
	public sealed class AssetBrowserOptions
	{
		/// <summary>Tile edge length in pixels (thumbnail square; the label sits below it). Ctrl+wheel writes it. Default 96.</summary>
		public float TileSize { get; set; } = 96f;
		/// <summary>Smallest TileSize Ctrl+wheel reaches. Default 32.</summary>
		public float MinTileSize { get; init; } = 32f;
		/// <summary>Largest TileSize Ctrl+wheel reaches. Default 256.</summary>
		public float MaxTileSize { get; init; } = 256f;
		/// <summary>Size of the browser; 0 on an axis fills the available space.</summary>
		public Vector2 Size { get; init; }
		/// <summary>ImGui drag-and-drop payload type for dragged selections. At most 32 characters. Default "KTSU_ASSETS".</summary>
		public string DragDropPayloadType { get; init; } = "KTSU_ASSETS";
		/// <summary>Tooltip text for a hovered tile. Null shows the full label only when it was clipped.</summary>
		public Func<int, string>? Tooltip { get; init; }
		/// <summary>Called with the index of a tile double-clicked, or of the focused tile when Enter is pressed.</summary>
		public Action<int>? OnActivate { get; init; }
	}

	/// <summary>Caller-owned selection and focus for an AssetBrowser, plus the grid arithmetic.</summary>
	public sealed class AssetBrowserState
	{
		/// <summary>Selected indices, ascending.</summary>
		public IReadOnlyList<int> SelectedIndices { get; }
		/// <summary>Index keyboard navigation moves from, or -1.</summary>
		public int FocusIndex { get; set; }
		/// <summary>Index shift-selection extends from, or -1.</summary>
		public int AnchorIndex { get; }
		/// <summary>Whether an index is selected.</summary>
		public bool IsSelected(int index);
		/// <summary>Applies a click on a tile with the modifier keys held. Returns true when the selection changed.</summary>
		public bool Click(int index, bool ctrl, bool shift);
		/// <summary>Selects 0..count-1. Returns true when the selection changed.</summary>
		public bool SelectAll(int count);
		/// <summary>Empties the selection. Returns true when it was not already empty.</summary>
		public bool Clear();
		/// <summary>Moves focus by delta (±1 or ±columns), clamped; extends the selection from the anchor with shift, otherwise selects the new focus alone. Returns true when the selection changed.</summary>
		public bool Move(int delta, int count, bool shift);
		/// <summary>Drops selected, focus and anchor indices at or above count. Returns true when the selection changed.</summary>
		public bool ItemCountChanged(int count);
		/// <summary>Tiles per row that fit in a width.</summary>
		public static int ColumnCount(float availableWidth, float tileWidth, float spacing);
		/// <summary>Rows needed for count items.</summary>
		public static int RowCount(int count, int columns);
		/// <summary>Row an index sits on.</summary>
		public static int RowOf(int index, int columns);
		/// <summary>The scroll position that brings a row fully into view, or the current one when it already is.</summary>
		public static float ScrollToReveal(int row, float rowHeight, float scrollY, float viewHeight);
	}

	/// <summary>A virtualized grid of thumbnail tiles with multi-selection, keyboard navigation, resizing and drag-out.</summary>
	/// <param name="label">ImGui id and probe scope.</param>
	/// <param name="itemCount">Number of items.</param>
	/// <param name="getLabel">Label of item i. Called for visible tiles only.</param>
	/// <param name="resolveThumbnail">Texture id for item i, or 0 for none. Called for visible tiles only, every frame; cache on the host side. Must not throw.</param>
	/// <param name="state">Caller-owned selection.</param>
	/// <param name="options">Null uses the defaults; TileSize is written back by Ctrl+wheel, so keep one instance.</param>
	/// <returns>True when the selection changed this frame.</returns>
	public static bool AssetBrowser(string label, int itemCount, Func<int, string> getLabel, Func<int, nint> resolveThumbnail, AssetBrowserState state, AssetBrowserOptions? options = null);

	/// <summary>Inside BeginDragDropTarget/EndDragDropTarget, accepts an AssetBrowser payload of the given type.</summary>
	/// <param name="payloadType">The DragDropPayloadType the source used.</param>
	/// <param name="indices">The dragged indices, ascending, when delivered.</param>
	/// <returns>True on the frame the payload is dropped.</returns>
	public static bool TryAcceptAssetPayload(string payloadType, out IReadOnlyList<int> indices);
}
  • Return value: true on the frame a click, key or drag start changed state.SelectedIndices.
  • Ownership:
    • The caller owns state, options (the widget writes TileSize), the items and the textures.
    • The widget holds no per-item data and never deletes a texture.
    • With options == null, the widget keeps a per-ID AssetBrowserOptions so Ctrl+wheel resizing still persists across frames.

State class

AssetBrowserState has no ImGui dependency. Internally it has a SortedSet<int> selected, FocusIndex (-1) and AnchorIndex (-1).

Invariants: all indices are ≥ 0. After ItemCountChanged(count) every index is below count.

Rules:

  1. Click(index, ctrl, shift) with index < 0 clears the selection (a click on empty space) and leaves focus and anchor. Otherwise:
    • plain: selection = {index}, focus = anchor = index
    • ctrl: toggle index, focus = anchor = index
    • shift: selection = [min(anchor, index) .. max(anchor, index)], focus = index, anchor unchanged. With no anchor, it behaves as plain.
    • ctrl+shift: adds that range to the existing selection.
  2. Move(delta, count, shift):
    • Does nothing when count <= 0.
    • When focus is -1, focus becomes 0 and the selection becomes {0}.
    • Otherwise newFocus = clamp(focus + delta, 0, count - 1). When it equals focus, nothing changes.
    • Without shift: selection = {newFocus} and anchor = newFocus. With shift: selection = the range from anchor to newFocus.
  3. SelectAll(count): selection = [0, count). Focus and anchor are kept, or set to 0 when -1 and count > 0.
  4. Clear() empties the selection and keeps focus and anchor.
  5. ItemCountChanged(count) removes indices ≥ count. Focus and anchor ≥ count become count - 1 (or -1 when count is 0).
  6. ColumnCount(w, tile, spacing) = max(1, (int)((w + spacing) / (tile + spacing))). It is 1 when any input is not finite or tile <= 0.
  7. RowCount(count, columns) = count <= 0 ? 0 : (count + columns - 1) / columns.
  8. RowOf(index, columns) = index / columns.
  9. ScrollToReveal(row, h, scrollY, viewHeight):
    • top = row * h, bottom = top + h.
    • If top < scrollY, returns top.
    • If bottom > scrollY + viewHeight, returns bottom - viewHeight, but never below top when h > viewHeight.
    • Otherwise returns scrollY.

Drawing and interaction

Layout:

  • BeginChild(label, options.Size, ImGuiChildFlags.Borders, ImGuiWindowFlags.None), then ImGuiProbes.MarkItem(label) after EndChild.
  • Inside the child, ScopedId(label) scopes probe names.
  • spacing = style.ItemSpacing.X.
  • tileWidth = TileSize, and tileHeight = TileSize + GetTextLineHeight() + style.ItemInnerSpacing.Y.
  • rowHeight = tileHeight + style.ItemSpacing.Y.
  • columns = ColumnCount(GetContentRegionAvail().X, tileWidth, spacing).

Rows: ImGuiListClipper over RowCount(itemCount, columns) rows with rowHeight. For each visible row, each tile i in [row * columns, min(itemCount, (row + 1) * columns)):

  • SetCursorScreenPos to the tile, then InvisibleButton($"[{i}]", (tileWidth, tileHeight)) and ImGuiProbes.MarkItem($"[{i}]"), giving the probe label/[i].
  • Background: a rounded rect (style.FrameRounding) in ImGuiCol.Header when selected, ImGuiCol.HeaderHovered when hovered and not selected, nothing otherwise.
  • Keyboard focus: a 1 px ImGuiCol.NavCursor outline when the child is focused.
  • Thumbnail: a square of TileSize - 2 * style.FramePadding.X, centred horizontally at the tile top plus padding. A non-zero resolver result draws with drawList.AddImage(new ImTextureRef(texId: id), min, max); 0 draws AddRect in ImGuiCol.Border over the same square.
  • Label: TextImpl.Clip(getLabel(i), (tileWidth, lineHeight)), centred under the thumbnail in ImGuiCol.Text.

Clicks:

  • IsItemClicked(Left) calls state.Click(i, io.KeyCtrl, io.KeyShift).
  • IsMouseDoubleClicked(Left) while hovered calls OnActivate(i).
  • A left click in the child that hits no tile (IsWindowHovered() && !IsAnyItemHovered()) calls state.Click(-1, …).

Tooltip: after 0.5 s hover (IsItemHovered(ImGuiHoveredFlags.DelayNormal)), Tooltip(i) when set, otherwise the full label only when it was clipped.

Drag-out:

  • BeginDragDropSource(ImGuiDragDropFlags.None) on a tile.
  • If the tile is not selected, state.Click(i, false, false) first, so that tile alone is dragged; this counts as a selection change.
  • The payload is SelectedIndices as little-endian int32s, via SetDragDropPayload(DragDropPayloadType, bytes, size).
  • The drag tooltip shows the thumbnail of the grabbed tile at 48 px and "N items" ("1 item" for one).
  • TryAcceptAssetPayload calls AcceptDragDropPayload(type) and decodes on IsDelivery(). It returns false with an empty list when there is no payload or its size is not a multiple of 4.

Keyboard, when IsWindowFocused() and !io.WantTextInput:

  • Left/Right: Move(∓1). Up/Down: Move(∓columns). Shift extends. All with key repeat.
  • Home: Move(-count). End: Move(+count).
  • Ctrl+A: SelectAll.
  • Escape: Clear.
  • Enter or KeypadEnter: OnActivate(FocusIndex) when FocusIndex >= 0.

After a keyboard move, SetScrollY(ScrollToReveal(RowOf(focus), rowHeight, GetScrollY(), GetWindowHeight())).

Resize: Ctrl+wheel while the child is hovered:

  • Calls ImGui.SetItemKeyOwner(ImGuiKey.MouseWheelY) so the child does not also scroll.
  • TileSize = clamp(TileSize * 1.1^wheel, MinTileSize, MaxTileSize).
  • The top visible item keeps its row in view: after resizing, scroll to RowOf(firstVisible, newColumns) * newRowHeight.

Cursor: default.

Edge cases

  • getLabel, resolveThumbnail, state or label null: ArgumentNullException.
  • itemCount <= 0: an empty bordered child. A click clears the selection, and keys do nothing.
  • itemCount shrinks below selected indices: the widget calls state.ItemCountChanged(itemCount) each frame before drawing, which reports a selection change when anything was dropped.
  • The available width is narrower than one tile: one column, and the tile is clipped by the child.
  • TileSize outside the min/max (set by the caller): used as given until the next Ctrl+wheel clamps it. Non-finite or ≤ 0 is treated as the default 96.
  • MinTileSize > MaxTileSize: the two are swapped for clamping.
  • The resolver returns 0: an empty frame. The resolver throwing is the host's bug and is not caught.
  • An empty label: no text, and the tile height is unchanged.
  • A DragDropPayloadType longer than 32 characters: ArgumentException on first use (Dear ImGui's limit).
  • Size smaller than one row: the child scrolls.

Tests

Unit tests (tests/ImGui.Widgets.Tests/AssetBrowserStateTests.cs)

  • Click_SelectsOnlyThatItem: Click(2, false, false) gives [2], focus 2, anchor 2, and true.
  • CtrlClick_Toggles: Click(2), Click(5, ctrl) gives [2, 5]; Click(2, ctrl) gives [5].
  • ShiftClick_SelectsTheRangeFromTheAnchor: Click(3), Click(6, shift) gives [3, 4, 5, 6]; Click(1, shift) gives [1, 2, 3].
  • CtrlShiftClick_AddsTheRange: Click(0), Click(5, ctrl), Click(7, ctrl, shift) gives [0, 5, 6, 7].
  • ClickOnNothing_Clears: after Click(2), Click(-1, false, false) gives [] and true.
  • Move_WithNoFocus_StartsAtTheFirstItem: Move(1, 10, false) gives focus 0 and [0].
  • Move_ClampsAndSelectsTheNewFocus: focus 8, Move(4, 10, false) gives focus 9 and [9]; again gives false.
  • ShiftMove_ExtendsFromTheAnchor: Click(2), Move(1, 10, true), Move(4, 10, true) gives [2 … 7] and focus 7.
  • SelectAll_SelectsEveryItem: SelectAll(4) gives [0, 1, 2, 3]; again gives false.
  • ItemCountChanged_DropsIndicesPastTheEnd: [1, 5, 8] with focus 8, then ItemCountChanged(6), gives [1, 5], focus 5, and true.
  • ColumnCount_FitsWholeTilesWithSpacing:
    • (400, 96, 8) == 3
    • (416, 96, 8) == 4
    • (10, 96, 8) == 1
    • (NaN, 96, 8) == 1
  • RowCount_RoundsUp: (10, 3) == 4, (9, 3) == 3, (0, 3) == 0.
  • ScrollToReveal_ScrollsOnlyWhenNeeded:
    • (0, 100, 250, 300) == 0
    • (5, 100, 0, 300) == 300
    • (1, 100, 50, 300) == 50
    • (2, 500, 0, 300) == 1000 (a row taller than the view aligns its top)

UI isolation tests (tests/ImGui.Widgets.UITests/AssetBrowserTests.cs, deriving from WidgetTest)

The fixture draws changed |= ImGuiWidgets.AssetBrowser("assets", count, i => $"Item {i}", i => texture, state, options) with options.Size = (400, 300), TileSize = 64, and texture from CreateTestTexture(16).TextureId created after Start.

  • AssetBrowser_DrawsTilesAndMarksThem: count 20, and assets and assets/[0] are visible; assets/[19] is visible or below the fold per the geometry.
  • AssetBrowser_ClickSelects: Click("assets/[3]") gives state.SelectedIndices == [3] and changed.
  • AssetBrowser_CtrlClickAddsAndShiftClickExtends:
    • Click("assets/[1]"), then KeyDown(ModCtrl), Click("assets/[4]"), KeyUp gives [1, 4]
    • then KeyDown(ModShift), Click("assets/[6]"), KeyUp gives [4, 5, 6]
  • AssetBrowser_ArrowKeysMoveByColumns: Click("assets/[0]"), Press(DownArrow) gives [columns], where columns = AssetBrowserState.ColumnCount(RectOf("assets").Width - 2 * padding, 64, spacing).
  • AssetBrowser_DoubleClickActivates: OnActivate = i => activated = i; two Click("assets/[2]") calls within the double-click time give activated == 2.
  • AssetBrowser_EnterActivatesTheFocus: Click("assets/[5]"), Press(Enter) gives activated == 5.
  • AssetBrowser_CtrlWheelResizesTiles: KeyDown(ModCtrl), Harness.Mouse.Wheel over assets with +2 clicks, KeyUp gives options.TileSize == 64 * 1.21 (±0.01).
  • AssetBrowser_ScrollsOnlyTheVisibleRows: count 50 000. The getLabel counter per frame is ≤ columns * (visibleRows + 2).
  • AssetBrowser_DragDeliversTheSelection: a second widget in the fixture is an ImGui.Button("drop") with a drag-drop target calling TryAcceptAssetPayload("KTSU_ASSETS", out delivered). Select [1, 2] with a click and a shift-click, then Harness.Mouse.Drag from the centre of assets/[1] to the centre of drop, giving delivered == [1, 2].
  • AssetBrowser_DraggingAnUnselectedTileSelectsIt: selection [0], drag assets/[3] to drop gives state.SelectedIndices == [3] and delivered == [3].
  • AssetBrowser_ZeroTextureDrawsAFrame: the resolver returns 0 and Snapshot differs from a count-0 frame inside RectOf("assets/[0]") (BoundsOfDifference is not null).
  • AssetBrowser_ShrinkingTheCountDropsSelection: select [8], set count to 5, Step() gives [] and changed.

Demo

examples/ImGuiWidgetsDemo/AssetBrowserDemo.cs, under DemoProbe.Header("Asset Browser"):

  • 5 000 synthetic assets named "mesh_0001.fbx" and so on across four kinds (mesh, texture, audio, script).
  • Four 64×64 coloured textures, one per kind, created with ImGuiApp.CreateTexture on first show and returned by the resolver.
  • A SearchBox above the browser: the demo keeps a filtered index list and passes its count, mapping indices through it (showing that filtering is the host's job).
  • A tile-size slider bound to options.TileSize.
  • A "Drop here" box beside the browser, accepting TryAcceptAssetPayload and listing the dropped names.
  • A status line: "{selected} selected · last activated: {name}".

WidgetsDemoUITests gains AssetBrowserDemo_ClickSelects: open the header, click the first tile, and assert the demo's exposed State.SelectedIndices.Count == 1.

Out of scope

  • Folder trees, breadcrumbs or navigation (compose with Tree or Breadcrumb).
  • List or details views; this is the grid only.
  • Rename in place, context menus on tiles, and drop targets inside the browser (the host can add them around it).
  • Preserving a thumbnail's aspect ratio: the host supplies square thumbnails.
  • Loading or generating thumbnails. The host owns textures.
  • Sorting and filtering. The host passes the count and maps indices.

Done when

  • AssetBrowserOptions, AssetBrowserState, AssetBrowser and TryAcceptAssetPayload exist as specified.
  • Only visible tiles call getLabel and resolveThumbnail.
  • Probes are label and label/[i].
  • All the unit and UI tests above pass.
  • The demo section and its demo UI test are in place.
  • ImGui.Widgets/README.md and CLAUDE.md are updated.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

readyFully specified; implement as written

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions