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