using System;
using System.Collections.Generic;
namespace AcDream.App.UI;
/// Cell order for a multi-column .
public enum UiItemListFlow
{
/// Retail listbox bit 0 set: index advances across columns, then down rows.
RowMajor,
/// Retail listbox bit 0 clear: index advances down rows, then across columns.
ColumnMajor,
}
///
/// A container of item cells (port of retail UIElement_ItemList, class 0x10000031).
/// Behavioral LEAF: it creates/owns its UiItemSlot children procedurally, so the
/// LayoutImporter must NOT build dat children. The toolbar uses single-cell
/// instances (one slot); the inventory phase will grow this to an N-cell grid.
///
public sealed class UiItemList : UiElement
{
private readonly List _cells = new();
private int _layoutDeferralDepth;
/// Vertical scroll model for grid mode (clip+scroll). Bound to the gutter
/// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell).
public UiScrollable Scroll { get; }
public UiItemList(
Func? spriteResolve = null,
UiScrollable? scroll = null)
{
Scroll = scroll ?? new UiScrollable();
SpriteResolve = spriteResolve;
// Single-cell default: every toolbar slot always shows one cell (empty or filled).
AddItem(new UiItemSlot { SpriteResolve = spriteResolve });
}
public override bool ConsumesDatChildren => true;
public Func? SpriteResolve { get; set; }
private IReadOnlyList? _cooldownSprites;
public IReadOnlyList? CooldownSprites
{
get => _cooldownSprites;
set
{
_cooldownSprites = value;
foreach (UiItemSlot cell in _cells)
cell.CooldownSprites = value;
}
}
private Func? _cooldownStepProvider;
public Func? CooldownStepProvider
{
get => _cooldownStepProvider;
set
{
_cooldownStepProvider = value;
foreach (UiItemSlot cell in _cells)
cell.CooldownStepProvider = value;
}
}
///
/// Optional non-weenie catalog drop target. Used by retail spell shortcuts;
/// physical-item drops continue through .
/// Coordinates are local to this list.
///
public Action? CatalogDropped { get; set; }
private uint _cellEmptySprite;
/// Empty-slot sprite for THIS list's cells, resolved from the dat cell template
/// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see
/// ). 0 = leave the
/// default (0x060074CF). Applied to every existing
/// AND future cell.
public uint CellEmptySprite
{
get => _cellEmptySprite;
set
{
_cellEmptySprite = value;
if (value != 0)
foreach (var c in _cells) c.EmptySprite = value;
}
}
/// The drag handler this list routes drops to (a panel controller).
/// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler.
public IItemListDragHandler? DragHandler { get; private set; }
/// Register the panel's drag handler on this list. Retail:
/// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461).
public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler;
/// Convenience for single-cell slots (the toolbar): the first cell.
/// Valid only while the list has at least one cell; after
/// (the inventory-phase rebuild path) the list is empty until
/// runs, so use there instead.
/// the list has no cells (e.g. after Flush).
public UiItemSlot Cell => _cells.Count > 0
? _cells[0]
: throw new InvalidOperationException("UiItemList has no cells; call AddItem first or use GetItem(index).");
public int GetNumUIItems() => _cells.Count;
/// Returns this cell's current retained-list position, matching
/// UIElement_ListBox::WhatNum. -1 means the cell is not owned by this list.
public int IndexOf(UiItemSlot cell) => _cells.IndexOf(cell);
public UiItemSlot? GetItem(int index)
=> index >= 0 && index < _cells.Count ? _cells[index] : null;
public void AddItem(UiItemSlot cell)
{
cell.SpriteResolve ??= SpriteResolve;
cell.CooldownSprites ??= _cooldownSprites;
cell.CooldownStepProvider ??= _cooldownStepProvider;
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
// The list lays cells out procedurally (grid offset + scroll clip), so cells must be
// EXEMPT from the parent anchor pass — UiElement.DrawSelfAndChildren runs ApplyAnchor
// on every child after OnDraw, which would otherwise reset the scroll offset to each
// cell's captured base position (the escaping-grid bug). Anchors=None makes LayoutCells
// the sole authority over cell rects.
cell.Anchors = AnchorEdges.None;
_cells.Add(cell);
AddChild(cell);
if (_layoutDeferralDepth == 0)
LayoutCells();
}
///
/// Batch a procedural list rebuild without repeatedly shrinking the scroll
/// extents as cells are re-added. The retained pixel offset is clamped once
/// against the completed list when the outermost scope ends.
///
public IDisposable DeferLayout()
{
_layoutDeferralDepth++;
return new LayoutDeferral(this);
}
/// Grid columns. 1 = single column. Ignored in fill mode.
public int Columns { get; set; } = 1;
///
/// Multi-column list flow. Retail UIElement_ListBox::UpdateLayout uses bit 0 of
/// m_bitField: set = row-major, clear = column-major. Default preserves the
/// existing toolbar/grid behavior; panel controllers select retail-specific flow.
///
public UiItemListFlow Flow { get; set; } = UiItemListFlow.RowMajor;
/// Fixed cell width in grid mode. 0 = "fill the list" — the single cell sizes
/// to the whole list (the toolbar single-slot legacy). Set >0 (with CellHeight) for a grid.
public float CellWidth { get; set; }
/// Fixed cell height in grid mode (pairs with CellWidth).
public float CellHeight { get; set; }
///
/// Lay cells left-to-right in one row. Retail favorite spell lists use the
/// horizontal UpdateEmptySlots branch (row-count attribute 0x5F is -1), so
/// the number of visible cells is determined by width rather than the nine
/// shortcut-number overlays.
///
public bool SingleRow { get; set; }
///
/// The single row uses the shared pixel scroll model as a horizontal offset.
/// Retail's external-container list (LayoutDesc 0x21000008) is the authored
/// horizontal ItemList + Scrollbar consumer.
///
public bool HorizontalScroll { get; set; }
///
/// Maintain a tail of empty UIItems that fills the visible horizontal extent.
/// Port of UIElement_ItemList::UpdateEmptySlots @ 0x004E3700.
///
public bool FillVisibleEmptySlots { get; set; }
/// Creates an empty cell for .
/// Null uses a plain physical UiItemSlot.
public Func? EmptySlotFactory { get; set; }
/// Whole rows needed for cells in a grid of
/// columns.
public static int RowCount(int cellCount, int columns)
{
int cols = columns < 1 ? 1 : columns;
return (cellCount + cols - 1) / cols;
}
/// Row-major pixel offset of cell in a grid of
/// columns at the given cell pitch.
internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH)
{
int col = index % columns, row = index / columns;
return (col * cellW, row * cellH);
}
///
/// Pixel offset matching retail UIElement_ListBox::CalculateColumn/CalculateRow:
/// row-major when bit 0 is set, column-major when it is clear.
///
internal static (float x, float y) CellOffset(
int index, int columns, int cellCount, UiItemListFlow flow, float cellW, float cellH)
{
int cols = columns < 1 ? 1 : columns;
if (flow == UiItemListFlow.ColumnMajor)
{
int rows = Math.Max(1, RowCount(cellCount, cols));
int col = index / rows, row = index % rows;
return (col * cellW, row * cellH);
}
return CellOffset(index, cols, cellW, cellH);
}
/// Position every cell per the current mode: fill (CellWidth<=0) sizes the single
/// cell to the list; grid (CellWidth>0) tiles cells by , offset by the scroll position
/// with pixel viewport clipping (partially visible edge rows are cropped).
internal void LayoutCells()
{
if (CellWidth <= 0f)
{
// Fill mode (the toolbar single cell): size the one cell to the list.
if (_cells.Count > 0)
{
var c = _cells[0];
c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height; c.Visible = true;
}
return;
}
UpdateEmptySlots();
int cols = SingleRow
? Math.Max(1, _cells.Count)
: Columns < 1 ? 1 : Columns;
int cellH = (int)MathF.Round(CellHeight);
if (SingleRow && HorizontalScroll)
{
int cellW = Math.Max(1, (int)MathF.Round(CellWidth));
Scroll.LineHeight = cellW;
Scroll.ContentHeight = _cells.Count * cellW;
Scroll.ViewHeight = (int)MathF.Floor(Width);
Scroll.SetScrollY(Scroll.ScrollY);
float scrollX = Scroll.ScrollY;
for (int i = 0; i < _cells.Count; i++)
{
float left = i * CellWidth - scrollX;
UiItemSlot cell = _cells[i];
cell.Left = left;
cell.Top = 0f;
cell.Width = CellWidth;
cell.Height = CellHeight;
cell.Visible = left < Width && left + CellWidth > 0f;
}
return;
}
// Drive the shared scroll model from the current geometry, then re-clamp the offset to
// the (possibly changed) max. ContentHeight/ViewHeight must be set BEFORE reading ScrollY
// so the clamp uses the right max.
Scroll.LineHeight = cellH > 0 ? cellH : 1;
Scroll.ContentHeight = RowCount(_cells.Count, cols) * cellH;
Scroll.ViewHeight = (int)MathF.Floor(Height);
Scroll.SetScrollY(Scroll.ScrollY); // re-clamp to the new max
float scrollY = Scroll.ScrollY;
for (int i = 0; i < _cells.Count; i++)
{
var (x, baseY) = CellOffset(i, cols, _cells.Count, Flow, CellWidth, CellHeight);
float top = baseY - scrollY;
var cell = _cells[i];
cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight;
// Retail listboxes retain intersecting edge rows at arbitrary pixel offsets.
// The UiElement child-clip traversal crops their visible portion.
cell.Visible = top < Height && top + CellHeight > 0f;
}
}
///
/// Retail's horizontal branch pads to floor(listWidth / cellWidth), and when
/// the viewport shrinks removes only empty cells from the tail. Occupied cells
/// are never discarded merely because they extend beyond the viewport.
///
private void UpdateEmptySlots()
{
if (!FillVisibleEmptySlots || !SingleRow || CellWidth <= 0f)
return;
int visibleCellCount = Math.Max(0, (int)MathF.Floor(Width / CellWidth));
while (_cells.Count < visibleCellCount)
{
UiItemSlot cell = EmptySlotFactory?.Invoke() ?? new UiItemSlot();
cell.SpriteResolve ??= SpriteResolve;
cell.CooldownSprites ??= _cooldownSprites;
cell.CooldownStepProvider ??= _cooldownStepProvider;
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
cell.Anchors = AnchorEdges.None;
_cells.Add(cell);
AddChild(cell);
}
while (_cells.Count > visibleCellCount && _cells[^1].IsEmptySlot)
{
UiItemSlot cell = _cells[^1];
_cells.RemoveAt(_cells.Count - 1);
RemoveChild(cell);
}
}
protected override bool ClipsChildren => CellWidth > 0f;
public void Flush()
{
foreach (var c in _cells) RemoveChild(c);
_cells.Clear();
}
public override bool OnEvent(in UiEvent e)
{
if (CatalogDropped is not null && e.Payload is not null)
{
if (e.Type is UiEventType.DragEnter or UiEventType.DragOver)
return true;
if (e.Type == UiEventType.DropReleased)
{
CatalogDropped(e.Payload, e.Data1, e.Data2);
return true;
}
}
if (e.Type == UiEventType.Scroll && CellWidth > 0f)
{
// Mirror UiText: Silk +Y wheel = up/older = decrease ScrollY; negate Data0.
Scroll.ScrollByLines(-e.Data0);
return true;
}
return base.OnEvent(e);
}
///
/// Port of the listbox expose-item behavior called by
/// gmSpellbookUI::SetSelected @ 0x0048B540 .
///
public void ScrollItemIntoView(int index)
{
if (CellWidth <= 0f || index < 0 || index >= _cells.Count) return;
if (SingleRow && HorizontalScroll)
{
float left = index * CellWidth;
float right = left + CellWidth;
if (left < Scroll.ScrollY)
Scroll.SetScrollY((int)MathF.Floor(left));
else if (right > Scroll.ScrollY + Width)
Scroll.SetScrollY((int)MathF.Ceiling(right - Width));
return;
}
int columns = Math.Max(1, Columns);
int row = Flow == UiItemListFlow.ColumnMajor
? index % Math.Max(1, RowCount(_cells.Count, columns))
: index / columns;
float top = row * CellHeight;
float bottom = top + CellHeight;
if (top < Scroll.ScrollY) Scroll.SetScrollY((int)MathF.Floor(top));
else if (bottom > Scroll.ScrollY + Height)
Scroll.SetScrollY((int)MathF.Ceiling(bottom - Height));
}
protected override void OnDraw(UiRenderContext ctx)
{
// The factory sets Width/Height AFTER construction, so re-layout each frame:
// fill mode keeps the single toolbar cell sized to the list; grid mode tiles cells.
LayoutCells();
}
private void EndLayoutDeferral()
{
if (_layoutDeferralDepth <= 0)
return;
_layoutDeferralDepth--;
if (_layoutDeferralDepth == 0)
LayoutCells();
}
private sealed class LayoutDeferral : IDisposable
{
private UiItemList? _owner;
public LayoutDeferral(UiItemList owner) => _owner = owner;
public void Dispose()
{
UiItemList? owner = _owner;
_owner = null;
owner?.EndLayoutDeferral();
}
}
}