Applies all 11 items from the Opus dual-lens review of989f6652(0 blockers, 7 SHOULD-FIX, 4 NOTE): - S2: UiElement.DrawSelfAndChildren now pushes the ambient clip right after PushAlpha and wraps OnDraw + the children walk + OnDrawAfterChildren in ONE block — the literal UIRegion::DrawHere @0x0069FA30 shape, which clips an element's OWN DrawSelf too, not just its children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph blits to its own clipped surface rect; UIRegion::DrawSelf @0x0069F1A0 blits per clip rect). Deleted the two now-redundant ad-hoc self-clips this supersedes: UiText.DrawText and UiField.DrawMultiLine both pushed their own (0,0,Width,Height) — exactly what the new ambient clip already provides one level up. Kept UiButton.DrawBlockLabel's clip: it clips to LabelBox/ValueBox, an authored INNER sub-rect that can be smaller than and offset from the button's own full rect — a genuine narrower viewport, not a redundant duplicate. - S3: deleted UiItemList's `ClipsChildren => CellWidth > 0f` override — correct under the old opt-in-false default, inverted under the new default-true (an unconfigured list would stop clipping instead of clipping like everything else). - S4: pinned the escaped-popup input path end to end. New UiAncestorClipTests test mounts a menu inside a short window on a real UiRoot, opens it, and proves a click in the escaped popup region reaches the menu through UiRoot.PopupHit (a plain top-down walk is proven to reject the same point first). UiRoot.WantsMouse now also checks PopupHit — it previously only checked Captured/ HitTestTopDown, so a game action could fire underneath an open dropdown's escaped region. OnMouseDown/OnScroll already routed through PopupHit first (#374); unchanged. - S5: strengthened the Titles-divider regression test's positive half. The old assertion only checked SOME quad's Y fell in a band — vacuously true given other same-band content. Now asserts the divider's exact rect (X and Y), then diffs against the same rect with the divider hidden (Visible=false) to prove the quad was actually attributable to it. - S1: added UiWindowDrawCaptureSweepTests — Character/Chat/Vendor/ Options mounted through their real production Bind entry points with a non-zero sprite resolver, drawn via RecordingGpuDevice, asserting a per-window vertex floor (~40-45% of this session's observed baseline: Character 588, Chat 162, Vendor 54, Options 240) plus one key sprite id read LIVE off the bound controller/element (never hardcoded). Character's key sprite (RetailChromeSprites. TopEdge) specifically exercises OnDrawAfterChildren, the exact path S2's caution note flagged. Inventory/Paperdoll/social/map-house skipped — no single fixture-driven top-level Bind entry point. - S6: added the CT-GF1 subsection to the campaign plan's ledger (989f6652+ this fix round; CT7 re-gate still owed). - S7: UiRenderContext.PushClipUnbounded now resets to the CANVAS rect (0,0,ScreenSize), not null — retail's own popup region is SCREEN-clipped (UIElement_Menu::MakePopup spawns a top-level region bounded by the screen), not truly unbounded. AD-113 amended. - N1: UiRoot overrides ClipsChildren => false — the root's own region IS the screen (the viewport already scissors it), so this is a safety net against a momentarily zero-sized root silently blanking the whole UI tree under the new ancestor-clip default. - N2: added the empty-clip subtree cull (retail's var_24 gate @0x0069FB8E) to DrawSelfAndChildren only — DrawOverlays is a wholly separate traversal untouched by this change. New test proves a menu inside a fully-clipped (zero-width) window still draws its open popup via the overlay pass while the main pass draws nothing. - N3: CT7 script §5 now names the collapsed-toolbar check and the four highest-overflow windows (combat/vitals bar, Options bottom-button row, map/house page, floaty chat) as explicit eyeball items for the re-gate. - N4: verification below covers both the working tree and the clean committed tree. Decomp anchors: UIRegion::DrawHere @0x0069FA30 (var_24 gate @0x0069FB8E); UIElement_Text::DrawSelf @0x00467AA0 (self-clip); UIRegion::DrawSelf @0x0069F1A0; UIElement_Menu::MakePopup (screen- clipped popup region). Verification (both runs green, --filter "Lane!=InstalledDat& Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing& Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic& Status!=KnownFailure"): full Release solution build green; working tree 14,900+ tests across every project (one LandblockPresentation PipelineTests flake reproduced ONLY under full-solution parallel load, passes standalone and on rerun — unrelated to this change, streaming domain); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT _TESTS=1, Status!=KnownFailure, 205+34+3+172 App/Content/Bake/Core tests). Clean committed tree (git stash push -u the uncommitted owner probe + docs files, rerun, stash pop) reported in the session summary. src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) — staged selectively (git add -p) so only this commit's own two hunks (ClipsChildren override, WantsMouse) landed; the probe hunk is untouched and stays uncommitted, same as before this fix round. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
453 lines
18 KiB
C#
453 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>Cell order for a multi-column <see cref="UiItemList"/>.</summary>
|
|
public enum UiItemListFlow
|
|
{
|
|
/// <summary>Retail listbox bit 0 set: index advances across columns, then down rows.</summary>
|
|
RowMajor,
|
|
|
|
/// <summary>Retail listbox bit 0 clear: index advances down rows, then across columns.</summary>
|
|
ColumnMajor,
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class UiItemList : UiElement
|
|
{
|
|
private readonly List<UiItemSlot> _cells = new();
|
|
private int _layoutDeferralDepth;
|
|
|
|
/// <summary>Vertical scroll model for grid mode (clip+scroll). Bound to the gutter
|
|
/// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell).</summary>
|
|
public UiScrollable Scroll { get; }
|
|
|
|
public UiItemList(
|
|
Func<uint, (uint tex, int w, int h)>? 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<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
|
|
|
private IReadOnlyList<uint>? _cooldownSprites;
|
|
public IReadOnlyList<uint>? CooldownSprites
|
|
{
|
|
get => _cooldownSprites;
|
|
set
|
|
{
|
|
_cooldownSprites = value;
|
|
foreach (UiItemSlot cell in _cells)
|
|
cell.CooldownSprites = value;
|
|
}
|
|
}
|
|
|
|
private Func<uint, int>? _cooldownStepProvider;
|
|
public Func<uint, int>? CooldownStepProvider
|
|
{
|
|
get => _cooldownStepProvider;
|
|
set
|
|
{
|
|
_cooldownStepProvider = value;
|
|
foreach (UiItemSlot cell in _cells)
|
|
cell.CooldownStepProvider = value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Optional non-weenie catalog drop target. Used by retail spell shortcuts;
|
|
/// physical-item drops continue through <see cref="DragHandler"/>.
|
|
/// Coordinates are local to this list.
|
|
/// </summary>
|
|
public Action<object, int, int>? CatalogDropped { get; set; }
|
|
|
|
/// <summary>
|
|
/// Physical-item left-button-down command supplied by the owning panel.
|
|
/// Return <see langword="true"/> when a target mode consumed the press so
|
|
/// the slot suppresses drag promotion and the completed Click/DoubleClick
|
|
/// gesture.
|
|
///
|
|
/// Retail references:
|
|
/// <c>UIElement_ListBox::MouseDown @ 0x0046E3A0</c> selects the item under
|
|
/// the pointer during MouseDown, before optional drag selection;
|
|
/// <c>UIElement_ItemList::ListenToElementMessage @ 0x004E4D50</c> handles
|
|
/// target mode before ordinary physical-item selection.
|
|
/// </summary>
|
|
public Func<uint, bool>? PrimaryItemPressed { get; set; }
|
|
|
|
/// <summary>
|
|
/// Appraisal command supplied by the owning panel controller for physical
|
|
/// item cells. Retail <c>UIElement_ItemList::ListenToElementMessage
|
|
/// @ 0x004E4D50</c> handles its right-click branch at <c>0x004E4ECA</c>:
|
|
/// select the occupied UIItem's itemID, then call
|
|
/// <c>ClientUISystem::ExamineObject</c>.
|
|
/// </summary>
|
|
public Action<uint>? ExamineItemRequested { get; set; }
|
|
|
|
/// <summary>
|
|
/// Non-weenie catalog-entry left-button-down command supplied by the owning
|
|
/// panel. Spell favorites use this distinct path so a spell id is never
|
|
/// published as the globally selected object GUID.
|
|
///
|
|
/// Retail <c>gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0</c>
|
|
/// handles UIItem message parameter 7 (left press) by calling
|
|
/// <c>SpellCastSubMenu::SetSelected</c>.
|
|
/// </summary>
|
|
public Action<uint>? PrimaryCatalogEntryPressed { get; set; }
|
|
|
|
/// <summary>
|
|
/// Local examination command for non-weenie catalog entries. Retail
|
|
/// <c>UIElement_ItemList::ListenToElementMessage @ 0x004E4D50</c>
|
|
/// routes right-clicked <c>spellID</c> values to
|
|
/// <c>ClientUISystem::ExamineSpell</c> without changing the selected object.
|
|
/// </summary>
|
|
public Action<uint>? ExamineCatalogEntryRequested { get; set; }
|
|
|
|
private uint _cellEmptySprite;
|
|
/// <summary>Empty-slot sprite for THIS list's cells, resolved from the dat cell template
|
|
/// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see
|
|
/// <see cref="Layout.ItemListCellTemplate.ResolveEmptySprite"/>). 0 = leave the
|
|
/// <see cref="UiItemSlot.EmptySprite"/> default (0x060074CF). Applied to every existing
|
|
/// AND future cell.</summary>
|
|
public uint CellEmptySprite
|
|
{
|
|
get => _cellEmptySprite;
|
|
set
|
|
{
|
|
_cellEmptySprite = value;
|
|
if (value != 0)
|
|
foreach (var c in _cells) c.EmptySprite = value;
|
|
}
|
|
}
|
|
|
|
/// <summary>The drag handler this list routes drops to (a panel controller).
|
|
/// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler.</summary>
|
|
public IItemListDragHandler? DragHandler { get; private set; }
|
|
|
|
/// <summary>Register the panel's drag handler on this list. Retail:
|
|
/// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461).</summary>
|
|
public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler;
|
|
|
|
/// <summary>Convenience for single-cell slots (the toolbar): the first cell.
|
|
/// Valid only while the list has at least one cell; after <see cref="Flush"/>
|
|
/// (the inventory-phase rebuild path) the list is empty until <see cref="AddItem"/>
|
|
/// runs, so use <see cref="GetItem"/> there instead.</summary>
|
|
/// <exception cref="InvalidOperationException">the list has no cells (e.g. after Flush).</exception>
|
|
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;
|
|
|
|
/// <summary>Returns this cell's current retained-list position, matching
|
|
/// UIElement_ListBox::WhatNum. -1 means the cell is not owned by this list.</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public IDisposable DeferLayout()
|
|
{
|
|
_layoutDeferralDepth++;
|
|
return new LayoutDeferral(this);
|
|
}
|
|
|
|
/// <summary>Grid columns. 1 = single column. Ignored in fill mode.</summary>
|
|
public int Columns { get; set; } = 1;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public UiItemListFlow Flow { get; set; } = UiItemListFlow.RowMajor;
|
|
|
|
/// <summary>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.</summary>
|
|
public float CellWidth { get; set; }
|
|
/// <summary>Fixed cell height in grid mode (pairs with CellWidth).</summary>
|
|
public float CellHeight { get; set; }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public bool SingleRow { get; set; }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public bool HorizontalScroll { get; set; }
|
|
|
|
/// <summary>
|
|
/// Maintain a tail of empty UIItems that fills the visible horizontal extent.
|
|
/// Port of UIElement_ItemList::UpdateEmptySlots @ 0x004E3700.
|
|
/// </summary>
|
|
public bool FillVisibleEmptySlots { get; set; }
|
|
|
|
/// <summary>Creates an empty cell for <see cref="FillVisibleEmptySlots"/>.
|
|
/// Null uses a plain physical UiItemSlot.</summary>
|
|
public Func<UiItemSlot>? EmptySlotFactory { get; set; }
|
|
|
|
/// <summary>Whole rows needed for <paramref name="cellCount"/> cells in a grid of
|
|
/// <paramref name="columns"/> columns.</summary>
|
|
public static int RowCount(int cellCount, int columns)
|
|
{
|
|
int cols = columns < 1 ? 1 : columns;
|
|
return (cellCount + cols - 1) / cols;
|
|
}
|
|
|
|
/// <summary>Row-major pixel offset of cell <paramref name="index"/> in a grid of
|
|
/// <paramref name="columns"/> columns at the given cell pitch.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pixel offset matching retail UIElement_ListBox::CalculateColumn/CalculateRow:
|
|
/// row-major when bit 0 is set, column-major when it is clear.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Position every cell per the current mode: fill (CellWidth<=0) sizes the single
|
|
/// cell to the list; grid (CellWidth>0) tiles cells by <see cref="Flow"/>, offset by the scroll position
|
|
/// with pixel viewport clipping (partially visible edge rows are cropped).</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
// CT-GF1 fix round (S3): the former `ClipsChildren => CellWidth > 0f` override is
|
|
// DELETED. It made sense under the PRE-CT-GF1 opt-in default (false): clip only
|
|
// once CellWidth is configured. Under the new client-wide default (true), that
|
|
// same expression is INVERTED — an unconfigured list (CellWidth<=0, e.g. before
|
|
// LayoutCells first runs) would evaluate to false and stop clipping, the opposite
|
|
// of every other element's new default. Deleting the override restores the
|
|
// uniform default (always clip to this element's own (0,0,Width,Height)).
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Port of the listbox expose-item behavior called by
|
|
/// <c>gmSpellbookUI::SetSelected @ 0x0048B540</c>.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
}
|