Skip to content
CyanideX edited this page Aug 5, 2026 · 1 revision

Lists

Scrollable list rendering with per-item callbacks, active-index tracking, disabled styling, auto-scroll, clipper optimization, and drag-drop reorder.

Quick Start

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)

API Reference

render(items, renderer, state, opts?)

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.

opts Table

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.

State Table

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.

Active Index Tracking

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

Renderer Callback

---@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.
end

Hover Detection

Hover 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.

Label-to-Value Display

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)

Examples

Basic List

local items = { "One", "Two", "Three" }
local state = {}

lists.render(items, function(item, index, state)
    ImGui.Text(index .. ". " .. item)
end, state)

Focus Tracking with Dimming

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 })

Hover Highlighting with Disabled Styling

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,
})

Auto-Scroll

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)

Clipper for Large Lists

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.

Drag-Drop Reorder

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,
})

Empty State

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.

Clone this wiki locally