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:
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.
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.
SelectAll(count): selection = [0, count). Focus and anchor are kept, or set to 0 when -1 and count > 0.
Clear() empties the selection and keeps focus and anchor.
ItemCountChanged(count) removes indices ≥ count. Focus and anchor ≥ count become count - 1 (or -1 when count is 0).
ColumnCount(w, tile, spacing) = max(1, (int)((w + spacing) / (tile + spacing))). It is 1 when any input is not finite or tile <= 0.
RowCount(count, columns) = count <= 0 ? 0 : (count + columns - 1) / columns.
RowOf(index, columns) = index / columns.
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
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
AssetBrowseris 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
itemCountitems and suppliesgetLabel(i)andresolveThumbnail(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, becausektsu.ImGui.Widgetsdoes not referenceImGui.App.It does not wrap
Grid:Gridlays out every cell every frame, and virtualization needs uniform rows under anImGuiListClipper.Files
New:
ImGui.Widgets/AssetBrowser.cs: the widget,AssetBrowserOptions,TryAcceptAssetPayload, and internalAssetBrowserImpl.ImGui.Widgets/AssetBrowserState.cs: publicImGuiWidgets.AssetBrowserState(selection, focus, grid geometry; no ImGui calls).tests/ImGui.Widgets.Tests/AssetBrowserStateTests.cstests/ImGui.Widgets.UITests/AssetBrowserTests.csexamples/ImGuiWidgetsDemo/AssetBrowserDemo.csChanged:
examples/ImGuiWidgetsDemo/ImGuiWidgetsDemo.cs: callAssetBrowserDemo.ResetState()andAssetBrowserDemo.Show()in the "Layout and Containers" area, after the ImageCanvas demo.tests/ImGuiWidgetsDemo.UITests/WidgetsDemoUITests.cs: one test.ImGui.Widgets/README.md: anAssetBrowserbullet under "Layout and Containers".CLAUDE.md: addAssetBrowserto the "layout and containers" group of the ImGui.Widgets entry.Public API
state.SelectedIndices.state,options(the widget writesTileSize), the items and the textures.options == null, the widget keeps a per-IDAssetBrowserOptionsso Ctrl+wheel resizing still persists across frames.State class
AssetBrowserStatehas no ImGui dependency. Internally it has aSortedSet<int> selected,FocusIndex(-1) andAnchorIndex(-1).Invariants: all indices are ≥ 0. After
ItemCountChanged(count)every index is belowcount.Rules:
Click(index, ctrl, shift)withindex < 0clears the selection (a click on empty space) and leaves focus and anchor. Otherwise:[min(anchor, index) .. max(anchor, index)], focus = index, anchor unchanged. With no anchor, it behaves as plain.Move(delta, count, shift):count <= 0.newFocus = clamp(focus + delta, 0, count - 1). When it equals focus, nothing changes.SelectAll(count): selection = [0, count). Focus and anchor are kept, or set to 0 when -1 and count > 0.Clear()empties the selection and keeps focus and anchor.ItemCountChanged(count)removes indices ≥ count. Focus and anchor ≥ count becomecount - 1(or -1 when count is 0).ColumnCount(w, tile, spacing)=max(1, (int)((w + spacing) / (tile + spacing))). It is 1 when any input is not finite ortile <= 0.RowCount(count, columns)=count <= 0 ? 0 : (count + columns - 1) / columns.RowOf(index, columns)=index / columns.ScrollToReveal(row, h, scrollY, viewHeight):top = row * h,bottom = top + h.top < scrollY, returnstop.bottom > scrollY + viewHeight, returnsbottom - viewHeight, but never belowtopwhenh > viewHeight.scrollY.Drawing and interaction
Layout:
BeginChild(label, options.Size, ImGuiChildFlags.Borders, ImGuiWindowFlags.None), thenImGuiProbes.MarkItem(label)afterEndChild.ScopedId(label)scopes probe names.spacing = style.ItemSpacing.X.tileWidth = TileSize, andtileHeight = TileSize + GetTextLineHeight() + style.ItemInnerSpacing.Y.rowHeight = tileHeight + style.ItemSpacing.Y.columns = ColumnCount(GetContentRegionAvail().X, tileWidth, spacing).Rows:
ImGuiListClipperoverRowCount(itemCount, columns)rows withrowHeight. For each visible row, each tileiin[row * columns, min(itemCount, (row + 1) * columns)):SetCursorScreenPosto the tile, thenInvisibleButton($"[{i}]", (tileWidth, tileHeight))andImGuiProbes.MarkItem($"[{i}]"), giving the probelabel/[i].style.FrameRounding) inImGuiCol.Headerwhen selected,ImGuiCol.HeaderHoveredwhen hovered and not selected, nothing otherwise.ImGuiCol.NavCursoroutline when the child is focused.TileSize - 2 * style.FramePadding.X, centred horizontally at the tile top plus padding. A non-zero resolver result draws withdrawList.AddImage(new ImTextureRef(texId: id), min, max); 0 drawsAddRectinImGuiCol.Borderover the same square.TextImpl.Clip(getLabel(i), (tileWidth, lineHeight)), centred under the thumbnail inImGuiCol.Text.Clicks:
IsItemClicked(Left)callsstate.Click(i, io.KeyCtrl, io.KeyShift).IsMouseDoubleClicked(Left)while hovered callsOnActivate(i).IsWindowHovered() && !IsAnyItemHovered()) callsstate.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.state.Click(i, false, false)first, so that tile alone is dragged; this counts as a selection change.SelectedIndicesas little-endian int32s, viaSetDragDropPayload(DragDropPayloadType, bytes, size)."N items"("1 item"for one).TryAcceptAssetPayloadcallsAcceptDragDropPayload(type)and decodes onIsDelivery(). 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:Move(∓1). Up/Down:Move(∓columns). Shift extends. All with key repeat.Move(-count). End:Move(+count).SelectAll.Clear.OnActivate(FocusIndex)whenFocusIndex >= 0.After a keyboard move,
SetScrollY(ScrollToReveal(RowOf(focus), rowHeight, GetScrollY(), GetWindowHeight())).Resize: Ctrl+wheel while the child is hovered:
ImGui.SetItemKeyOwner(ImGuiKey.MouseWheelY)so the child does not also scroll.TileSize = clamp(TileSize * 1.1^wheel, MinTileSize, MaxTileSize).RowOf(firstVisible, newColumns) * newRowHeight.Cursor: default.
Edge cases
getLabel,resolveThumbnail,stateorlabelnull:ArgumentNullException.itemCount <= 0: an empty bordered child. A click clears the selection, and keys do nothing.itemCountshrinks below selected indices: the widget callsstate.ItemCountChanged(itemCount)each frame before drawing, which reports a selection change when anything was dropped.TileSizeoutside 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.DragDropPayloadTypelonger than 32 characters:ArgumentExceptionon first use (Dear ImGui's limit).Sizesmaller 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: afterClick(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, thenItemCountChanged(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) == 1RowCount_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 fromWidgetTest)The fixture draws
changed |= ImGuiWidgets.AssetBrowser("assets", count, i => $"Item {i}", i => texture, state, options)withoptions.Size = (400, 300),TileSize = 64, andtexturefromCreateTestTexture(16).TextureIdcreated afterStart.AssetBrowser_DrawsTilesAndMarksThem: count 20, andassetsandassets/[0]are visible;assets/[19]is visible or below the fold per the geometry.AssetBrowser_ClickSelects:Click("assets/[3]")givesstate.SelectedIndices == [3]andchanged.AssetBrowser_CtrlClickAddsAndShiftClickExtends:Click("assets/[1]"), thenKeyDown(ModCtrl),Click("assets/[4]"),KeyUpgives [1, 4]KeyDown(ModShift),Click("assets/[6]"),KeyUpgives [4, 5, 6]AssetBrowser_ArrowKeysMoveByColumns:Click("assets/[0]"),Press(DownArrow)gives[columns], wherecolumns = AssetBrowserState.ColumnCount(RectOf("assets").Width - 2 * padding, 64, spacing).AssetBrowser_DoubleClickActivates:OnActivate = i => activated = i; twoClick("assets/[2]")calls within the double-click time giveactivated == 2.AssetBrowser_EnterActivatesTheFocus:Click("assets/[5]"),Press(Enter)givesactivated == 5.AssetBrowser_CtrlWheelResizesTiles:KeyDown(ModCtrl),Harness.Mouse.Wheeloverassetswith +2 clicks,KeyUpgivesoptions.TileSize == 64 * 1.21(±0.01).AssetBrowser_ScrollsOnlyTheVisibleRows: count 50 000. ThegetLabelcounter per frame is ≤columns * (visibleRows + 2).AssetBrowser_DragDeliversTheSelection: a second widget in the fixture is anImGui.Button("drop")with a drag-drop target callingTryAcceptAssetPayload("KTSU_ASSETS", out delivered). Select [1, 2] with a click and a shift-click, thenHarness.Mouse.Dragfrom the centre ofassets/[1]to the centre ofdrop, givingdelivered == [1, 2].AssetBrowser_DraggingAnUnselectedTileSelectsIt: selection [0], dragassets/[3]todropgivesstate.SelectedIndices == [3]anddelivered == [3].AssetBrowser_ZeroTextureDrawsAFrame: the resolver returns 0 andSnapshotdiffers from a count-0 frame insideRectOf("assets/[0]")(BoundsOfDifferenceis not null).AssetBrowser_ShrinkingTheCountDropsSelection: select [8], set count to 5,Step()gives[]andchanged.Demo
examples/ImGuiWidgetsDemo/AssetBrowserDemo.cs, underDemoProbe.Header("Asset Browser"):"mesh_0001.fbx"and so on across four kinds (mesh, texture, audio, script).ImGuiApp.CreateTextureon first show and returned by the resolver.SearchBoxabove 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).options.TileSize.TryAcceptAssetPayloadand listing the dropped names."{selected} selected · last activated: {name}".WidgetsDemoUITestsgainsAssetBrowserDemo_ClickSelects: open the header, click the first tile, and assert the demo's exposedState.SelectedIndices.Count == 1.Out of scope
TreeorBreadcrumb).Done when
AssetBrowserOptions,AssetBrowserState,AssetBrowserandTryAcceptAssetPayloadexist as specified.getLabelandresolveThumbnail.labelandlabel/[i].ImGui.Widgets/README.mdandCLAUDE.mdare updated.