Repository navigation
lists
Scrollable list rendering with per-item callbacks, active-index tracking, disabled styling, auto-scroll, clipper optimization, and drag-drop reorder.
local wu = GetMod("WindowUtils")
local lists = wu.Lists
local items = { "Alpha", "Beta", "Gamma" }
local state = {}
lists.render(items, function(item, index, state)
ImGui.Text(item)
end, state)Render a scrollable list of items with per-item callbacks.
| Parameter | Type | Default | Description |
|---|---|---|---|
| items | table|nil | - | Array of items to render (nil or empty shows placeholder) |
| renderer | function | - |
function(item, index, state) called for each visible item |
| state | table | - | Caller-owned state table (mutated each frame) |
| opts | table|nil | {} |
Configuration options (see below) |
Returns: nothing. All results are communicated through the state table you pass in and through the onReorder / onActiveChange callbacks.
If renderer or state is nil, a warning is logged and the function returns immediately.
| Field | Type | Default | Description |
|---|---|---|---|
| id | string | "##list" |
ImGui child region ID |
| height | number|"fill"
|
"fill" |
Fixed pixel height, or "fill" to consume remaining space |
| footerHeight | number | 0 | Space to reserve below the list (fill mode only) |
| bg | table|false|nil | see below | Background color {r,g,b,a} for the child region |
| placeholder | string | "No items" |
Text shown when items is nil or empty |
| dimAlpha | number | 0.4 | Alpha for non-focused items when focusIndex is set |
| itemHeight | number|nil | nil | Fixed item height for ImGuiListClipper (nil = no clipper) |
| showCount | boolean | false | Show item count above the list |
| countFormat | string | "%d items" |
Format string for count display |
| reorderable | boolean | false | Enable drag-drop reordering via handle |
| onReorder | function|nil | nil |
function(fromIndex, toIndex) called after reorder |
| dragHandle | string | "DragHorizontalVariant" |
IconGlyph name for the drag handle |
| unhighlightDelay | number | 0.3 | Seconds before clearing activeIndex after hover exit |
| onActiveChange | function|nil | nil |
function(index|nil) called when activeIndex changes |
bg default: in "fill" height mode, nil means the standard panel background and false means transparent. In fixed-pixel height mode there is no default background at all, so nil and false both give a transparent region. Pass an explicit {r,g,b,a} table if you want the same look in both modes.
reorderable and itemHeight do not combine. The clipper only lays out visible rows, but the reorder logic walks every index from 1 up to the highest index it has ever seen and expects a rect for each one, which off-screen rows do not have. Passing both logs a debug message and ignores itemHeight, so the list falls back to rendering every row and reorder keeps working. Drop reorderable if you need the clipper for a long list.
The caller creates a plain table and passes it every frame. The module never replaces the table, only its fields.
Caller-owned (you write, module reads):
| Field | Type | Description |
|---|---|---|
| focusIndex | number|nil | Focused item index. Drives the dim-everything-else effect. The module clamps it into range (and nils it when the list is empty) but never sets it, so focus selection is entirely yours |
| scrollTarget | number|nil | Item index to scroll into view. The module clears it once the scroll has been issued, or immediately if it is out of range |
Module-owned (module writes, you read):
| Field | Type | Description |
|---|---|---|
| hoveredIndex | number|nil | Item hovered this frame. Reset to nil at the start of every render call |
| activeIndex | number|nil | Highlighted item, set from hover or an in-progress drag and cleared after unhighlightDelay. Assigning it yourself works but only survives until the next hover update, so treat it as read-only |
| _hoverExitTime | number|nil | Unhighlight timer. Do not modify |
| _dragdrop | table|nil | Drag-drop state, created on demand when reorderable is true. Do not modify |
The module does not read anything else from state, so you are free to keep your own fields on the same table. The renderer receives it as its third argument.
When hovering any part of an item (including the drag handle), activeIndex is set immediately. When the mouse leaves, a delay timer starts (default 0.3s). If the mouse re-enters any item or a drag is active before the timer expires, the timer resets. Non-active items receive styles.PushDragDisabled() styling so the active item stands out.
Renderers should check state.activeIndex to conditionally skip their own style pushes (e.g. PushDragColor, PushOutlined) on non-active items, letting the disabled style show through:
local isDisabled = state.activeIndex ~= nil and state.activeIndex ~= index
if not isDisabled then styles.PushDragColor(color) end
ImGui.DragFloat(...)
if not isDisabled then styles.PopDragColor() end---@param item any The item value from the items array
---@param index number 1-based index of the item
---@param state table The state table (read focusIndex, activeIndex, etc.)
function renderer(item, index, state)
-- Render ImGui content for this item.
-- PushID/PopID is handled by the list module.
-- BeginGroup/EndGroup wraps the renderer output.
-- The drag handle (if reorderable) is rendered before this callback.
endHover detection uses bounding-rect math rather than IsItemHovered(), so it works reliably even when interactive children (DragFloat, Button, etc.) capture ImGui hover. The entire item area, including the drag handle column, is covered.
When using controls.DragFloatRow or controls.DragIntRow with label fields on drag elements, labels show when idle and switch to numeric values on hover/active. Additionally:
- Hold Ctrl to reveal all values at once (without hovering each drag individually)
- Hold Shift for precision drag mode (slower drag speed)
local items = { "One", "Two", "Three" }
local state = {}
lists.render(items, function(item, index, state)
ImGui.Text(index .. ". " .. item)
end, state)local items = { "Alpha", "Beta", "Gamma" }
local state = {}
lists.render(items, function(item, index, state)
if ImGui.Selectable(item, state.focusIndex == index, 0, 0, 0) then
state.focusIndex = index
end
end, state, { dimAlpha = 0.3 })local items = { {x=0, y=0, z=0}, {x=1, y=2, z=3} }
local state = {}
lists.render(items, function(item, index, state)
local isDisabled = state.activeIndex ~= nil and state.activeIndex ~= index
if not isDisabled then styles.PushDragColor(styles.dragColors.x) end
ImGui.SetNextItemWidth(100)
local nx, xC = ImGui.DragFloat("##x", item.x, 0.1, -999, 999, "%.2f")
if not isDisabled then styles.PopDragColor() end
if xC then item.x = nx end
end, state, {
onActiveChange = function(idx)
-- React to hover changes (e.g. highlight a 3D marker)
end,
})local items = {}
for i = 1, 100 do items[i] = "Item " .. i end
local state = {}
if ImGui.Button("Scroll to #50") then
state.scrollTarget = 50
end
lists.render(items, function(item, index, state)
ImGui.Text(item)
end, state)local items = {}
for i = 1, 1000 do items[i] = "Row " .. i end
local state = {}
lists.render(items, function(item, index, state)
ImGui.Text(item)
end, state, { itemHeight = 24 })itemHeight must match the real row height, since the clipper uses it to decide which rows exist. Pairing it with reorderable disables the clipper (a debug message is logged), so every row renders.
local items = { "First", "Second", "Third" }
local state = {}
lists.render(items, function(item, index, state)
ImGui.Text(item)
end, state, {
reorderable = true,
onReorder = function(from, to)
print("Moved from " .. from .. " to " .. to)
end,
})lists.render({}, function() end, {}, {
placeholder = "No saved positions. Click 'Add' to create one.",
})With an empty list the module clears activeIndex (firing onActiveChange(nil) if it was set), draws the placeholder, and stops. showCount is skipped, so a "0 items" line never appears.