fix(ui): complete retail inventory scroll polish
Preserve pixel scroll offsets across inventory rebuilds, crop partially visible rows with nested geometry/UV clips, and replace the obsolete 560px resize ceiling with available screen height. Keep retail's row-sized wheel step while allowing continuous scrollbar thumb positions. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
ea72c395c9
commit
15aa3b9aff
15 changed files with 379 additions and 34 deletions
|
|
@ -1933,7 +1933,7 @@ its open/closed sprites when picked up.
|
||||||
|
|
||||||
## #147 — Inventory item-grid scrolling polish
|
## #147 — Inventory item-grid scrolling polish
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** IMPLEMENTED 2026-07-11 — live visual gate pending
|
||||||
**Severity:** LOW
|
**Severity:** LOW
|
||||||
**Filed:** 2026-06-22
|
**Filed:** 2026-06-22
|
||||||
|
|
||||||
|
|
@ -1942,7 +1942,7 @@ whole-row clip via `UiScrollable`) works but needs visual/UX polish — smoothne
|
||||||
wheel step, row-clip edges. Confirm the exact symptoms with the user when picked up. Filed from the
|
wheel step, row-clip edges. Confirm the exact symptoms with the user when picked up. Filed from the
|
||||||
D.2b inventory visual gate.
|
D.2b inventory visual gate.
|
||||||
|
|
||||||
**2026-07-09 triage:** investigated, verdict STILL_OPEN — `UiItemList.LayoutCells` still does whole-row visibility clipping and `UiScrollable.cs` is still an unanimated integer-pixel scroll model; no polish commit has landed since filing, and the roadmap/memory crib both still list it as pending.
|
**2026-07-11 implementation:** retained list rebuilds now defer layout until all cells are restored, preserving the exact `ScrollY` across inventory changes. Whole-row visibility clipping is replaced by intersecting-row visibility plus a nested child viewport clip; sprite geometry and UVs are cropped together, so thumb dragging shows clean partially visible rows instead of black gaps. Named retail confirms the position itself is an integer pixel coordinate (`ScrollToY`/`SetScrollableXY`), while wheel/arrow line steps remain one row (`InqScrollDelta = scrollableHeight / rowCount`). The inventory's old 560 px maximum-height cap was also the reason a persisted 560 px window could not expand further; its maximum now uses available screen height (AP-54 remains for exact keystone bounds). Automated tests cover partial top/bottom rows, UV crop, scroll preservation through refresh, and resize geometry; warning-free App Release plus all 4,702 tests pass. Research: `docs/research/2026-07-11-retail-inventory-scroll-pseudocode.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -2050,7 +2050,7 @@ See divergence register **AP-59**.
|
||||||
|
|
||||||
## #141 — Toolbar interactivity — selected-object display
|
## #141 — Toolbar interactivity — selected-object display
|
||||||
|
|
||||||
**Status:** IN PROGRESS (health/name/flash DONE and visually confirmed 2026-06-20; formatted stack count + entry/slider visually confirmed 2026-07-11; partial stack-transfer live gate pending; mana still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140.
|
**Status:** IN PROGRESS (health/name/flash DONE and visually confirmed 2026-06-20; formatted stack count, entry/slider, and partial stack transfers visually confirmed 2026-07-11; mana still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140.
|
||||||
**Severity:** MEDIUM
|
**Severity:** MEDIUM
|
||||||
**Filed:** 2026-06-17
|
**Filed:** 2026-06-17
|
||||||
**Component:** ui — D.5 toolbar / selection
|
**Component:** ui — D.5 toolbar / selection
|
||||||
|
|
@ -2060,7 +2060,7 @@ See divergence register **AP-59**.
|
||||||
**Root cause / status:** The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port.
|
**Root cause / status:** The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port.
|
||||||
- **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet). Divergence row AP-46.
|
- **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet). Divergence row AP-46.
|
||||||
- **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #141:** Mana meter (0x100001A2).
|
- **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #141:** Mana meter (0x100001A2).
|
||||||
- **D.5.3b implementation (2026-07-11):** stacked selection formats the retail `"%d %hs"` count/name, now preserving the wire `PluralName` selected by `NAME_APPROPRIATE`; reveals authored entry `0x100001A3` and horizontal DAT slider `0x100001A4`; initializes to the full stack; clamps entry edits; honors the entry's DAT right alignment; and uses the exact 1000-step slider conversion. The thumb retains its raw pointer position so minimum/maximum reach both endpoints. One Core `StackSplitQuantityState` feeds the controls plus selected-source merge, container split (`0x0055`), and ground split (`0x0056`). `GetObjectSplitSize @ 0x00586F00` ensures unselected objects still move their full stack. Partial transfers do not optimistically move the original because ACE creates the destination object with a new guid. Warning-free App Release + 4,697 tests; controls/polish visually confirmed, partial-transfer live action gate pending.
|
- **D.5.3b implementation (2026-07-11):** stacked selection formats the retail `"%d %hs"` count/name, now preserving the wire `PluralName` selected by `NAME_APPROPRIATE`; reveals authored entry `0x100001A3` and horizontal DAT slider `0x100001A4`; initializes to the full stack; clamps entry edits; honors the entry's DAT right alignment; and uses the exact 1000-step slider conversion. The thumb retains its raw pointer position so minimum/maximum reach both endpoints. One Core `StackSplitQuantityState` feeds the controls plus selected-source merge, container split (`0x0055`), and ground split (`0x0056`). `GetObjectSplitSize @ 0x00586F00` ensures unselected objects still move their full stack. Partial transfers do not optimistically move the original because ACE creates the destination object with a new guid. Warning-free App Release + 4,697 tests; controls/polish, partial inventory split, and partial ground drop visually confirmed.
|
||||||
|
|
||||||
**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).
|
**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).
|
||||||
|
|
||||||
|
|
@ -6720,9 +6720,9 @@ Lessons: `memory/feedback_render_perf_measurement.md`.
|
||||||
**Component:** ui — D.2b inventory window (UiItemList scroll, UiNineSlicePanel frame, vertical resize)
|
**Component:** ui — D.2b inventory window (UiItemList scroll, UiNineSlicePanel frame, vertical resize)
|
||||||
|
|
||||||
The inventory window now matches retail's 2D presentation (minus the 3D paperdoll doll = Stage 2). Shipped `366af0c`→`1be7e65` (build + full suite green: Core.Net 334 / App 543 / UI 425 / Core 1530; spec/plan `docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-window-finish*.md`):
|
The inventory window now matches retail's 2D presentation (minus the 3D paperdoll doll = Stage 2). Shipped `366af0c`→`1be7e65` (build + full suite green: Core.Net 334 / App 543 / UI 425 / Core 1530; spec/plan `docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-window-finish*.md`):
|
||||||
- **Scroll** — `UiItemList` clip+scroll via the shared `UiScrollable` (whole-row clip; cells exempted from the per-frame anchor pass — that was the "grid escapes the window" root cause); gutter scrollbar `0x100001C7` bound like ChatWindowController; mouse-wheel.
|
- **Scroll** — `UiItemList` clip+scroll via the shared `UiScrollable`; cells remain exempt from the per-frame anchor pass (the original "grid escapes the window" root cause). Issue #147 later completed pixel-clipped partial rows and refresh offset preservation; gutter scrollbar `0x100001C7` remains bound like ChatWindowController; mouse-wheel line steps remain one row.
|
||||||
- **Frame** — wrapped in the 8-piece bevel chrome (`UiNineSlicePanel`) like vitals/chat/toolbar.
|
- **Frame** — wrapped in the 8-piece bevel chrome (`UiNineSlicePanel`) like vitals/chat/toolbar.
|
||||||
- **Vertical resize** — bottom-edge drag, horizontal blocked (`ResizeX=false`); grid/sub-window/scrollbar/backdrop stretch (`Left|Top|Bottom`), paperdoll + side-bags pinned; scrollbar thumb reflects view/content. Expand-only Min=default..Max=560 (AP-54).
|
- **Vertical resize** — bottom-edge drag, horizontal blocked (`ResizeX=false`); grid/sub-window/scrollbar/backdrop stretch (`Left|Top|Bottom`), paperdoll + side-bags pinned; scrollbar thumb reflects view/content. Expand-only Min=default; issue #147 replaced the obsolete fixed 560 px maximum with available screen height (AP-54).
|
||||||
- **102-slot grid** — contents grid pads empty frames to the main-pack capacity (default 102, AP-53); side-bag column pads to 7 (AP-52).
|
- **102-slot grid** — contents grid pads empty frames to the main-pack capacity (default 102, AP-53); side-bag column pads to 7 (AP-52).
|
||||||
**Visually confirmed** (scroll, frame, resize, 102 slots). **Remaining (handoff `docs/research/2026-06-21-d2b-inventory-finish-handoff.md`):** wrong empty-slot background art (inventory + equip slots — the OPEN issue above) + main-pack backpack icon (AP-51); Stage 2 = paperdoll `UiViewport` doll + per-slot equip silhouettes.
|
**Visually confirmed** (scroll, frame, resize, 102 slots). **Remaining (handoff `docs/research/2026-06-21-d2b-inventory-finish-handoff.md`):** wrong empty-slot background art (inventory + equip slots — the OPEN issue above) + main-pack backpack icon (AP-51); Stage 2 = paperdoll `UiViewport` doll + per-slot equip silhouettes.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
|
||||||
| AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) draws the constant backpack icon via a hardcoded base-icon literal `0x0600127E` + `ItemType.Container` (composited over the Container type-underlay), rather than resolving it at runtime the way retail does. Sprite VISUALLY CONFIRMED (2026-06-22 live gate). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`, main-pack cell) | Retail's `IconData::RenderIcons` IsThePlayer branch draws a CONSTANT backpack with `m_itemType = TYPE_CONTAINER` (the original AP-51 "equipped-pack weenie icon" premise was wrong). The exact runtime resolution is unconfirmed: a research dat-dump misreported `GetDIDByEnum(0x10000004,7)` as the green tile `0x060011F4` (which rendered green-no-pack at the gate); the real backpack `0x0600127E` was identified by dat export + user confirmation. We pin the visually-verified literal (cf. AP-55). Retire when the runtime resolve is reproduced + confirmed. | If the pinned sprite diverges from what retail resolves at runtime, the main-pack icon goes stale. | `IconData::RenderIcons` IsThePlayer branch 0x0058d1ee (decomp 407546-407549); sprite `0x0600127E` dat-exported + visually confirmed |
|
| AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) draws the constant backpack icon via a hardcoded base-icon literal `0x0600127E` + `ItemType.Container` (composited over the Container type-underlay), rather than resolving it at runtime the way retail does. Sprite VISUALLY CONFIRMED (2026-06-22 live gate). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`, main-pack cell) | Retail's `IconData::RenderIcons` IsThePlayer branch draws a CONSTANT backpack with `m_itemType = TYPE_CONTAINER` (the original AP-51 "equipped-pack weenie icon" premise was wrong). The exact runtime resolution is unconfirmed: a research dat-dump misreported `GetDIDByEnum(0x10000004,7)` as the green tile `0x060011F4` (which rendered green-no-pack at the gate); the real backpack `0x0600127E` was identified by dat export + user confirmation. We pin the visually-verified literal (cf. AP-55). Retire when the runtime resolve is reproduced + confirmed. | If the pinned sprite diverges from what retail resolves at runtime, the main-pack icon goes stale. | `IconData::RenderIcons` IsThePlayer branch 0x0058d1ee (decomp 407546-407549); sprite `0x0600127E` dat-exported + visually confirmed |
|
||||||
| AP-52 | Side-bag column slot count defaults to 7 (the dat column height) when the player's `ContainersCapacity` is absent/0. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ContainersCapacity` is not reliably on the player ClientObject yet; 7 is the standard side-pack cap + the dat column (`0x100001CA`, 36×252) holds exactly 7. | An 8th-pack (Shadow of the Seventh Mule aug) character shows one too few side-bag slots — cosmetic. | retail gmBackpackUI side-pack column; ACE `ContainersCapacity` |
|
| AP-52 | Side-bag column slot count defaults to 7 (the dat column height) when the player's `ContainersCapacity` is absent/0. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ContainersCapacity` is not reliably on the player ClientObject yet; 7 is the standard side-pack cap + the dat column (`0x100001CA`, 36×252) holds exactly 7. | An 8th-pack (Shadow of the Seventh Mule aug) character shows one too few side-bag slots — cosmetic. | retail gmBackpackUI side-pack column; ACE `ContainersCapacity` |
|
||||||
| AP-53 | Contents grid slot count defaults to 102 (main pack) or 24 (side bag) when the open container's `ItemsCapacity` is absent/0. Container-switching is now wired (D.2b): clicking a side bag sends `Use 0x0036` and the grid repopulates from `ViewContents 0x0196`. This row covers only the capacity *default* when the server omits `ItemsCapacity`. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on every ClientObject yet; 102/24 are the retail standard capacities. Retire when `ItemsCapacity` is confirmed delivered for all containers. | A non-102/24 pack shows the wrong empty-slot count — cosmetic only. | retail main-pack capacity 102; side-pack 24; ACE `ItemsCapacity`; `ViewContents 0x0196` |
|
| AP-53 | Contents grid slot count defaults to 102 (main pack) or 24 (side bag) when the open container's `ItemsCapacity` is absent/0. Container-switching is now wired (D.2b): clicking a side bag sends `Use 0x0036` and the grid repopulates from `ViewContents 0x0196`. This row covers only the capacity *default* when the server omits `ItemsCapacity`. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on every ClientObject yet; 102/24 are the retail standard capacities. Retire when `ItemsCapacity` is confirmed delivered for all containers. | A non-102/24 pack shows the wrong empty-slot count — cosmetic only. | retail main-pack capacity 102; side-pack 24; ACE `ItemsCapacity`; `ViewContents 0x0196` |
|
||||||
| AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize |
|
| AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = available screen height), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/UI/RetailUiRuntime.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); using the screen rather than the former arbitrary 560 px cap keeps the full vertical resize usable at every persisted size. | Which elements reflow or whether retail permits shrink-below-default could differ; shrink below the authored default remains disabled. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize |
|
||||||
| AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` |
|
| AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` |
|
||||||
| AP-57 | The open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by `_openContainer` plus Core `SelectionState.SelectedObjectId`, reproducing the retail keying (`item.itemID == openContainerId / selectedID` per `UpdateOpenContainerIndicator 0x004e3070` / `SetSelectedObject 0x0058c2e0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | A cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ACCWeenieObject::SetSelectedObject` 0x0058c2e0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 |
|
| AP-57 | The open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by `_openContainer` plus Core `SelectionState.SelectedObjectId`, reproducing the retail keying (`item.itemID == openContainerId / selectedID` per `UpdateOpenContainerIndicator 0x004e3070` / `SetSelectedObject 0x0058c2e0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | A cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ACCWeenieObject::SetSelectedObject` 0x0058c2e0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 |
|
||||||
| AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` |
|
| AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` |
|
||||||
|
|
|
||||||
|
|
@ -490,7 +490,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
||||||
- **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut.
|
- **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut.
|
||||||
- **✓ SHIPPED — Wave 4.3b source-aware toolbar transactions.** Pure Core `ShortcutDropPlanner` ports `gmToolbarUI::HandleDropRelease @ 0x004BE7C0`: fresh inventory drops displace cyclically right (with wrap), shortcut aliases restore to their vacated source, duplicate objects are removed before placement, full bars intentionally lose the displaced entry when retail finds no empty slot, and every transaction is planned before local mutation then emitted in exact Remove/Add order. Full-stack rekey planning preserves the raw spell word. AP-102 retired; 4,646 automated tests pass; live inventory-to-occupied displacement and toolbar reorder gate passed 2026-07-11.
|
- **✓ SHIPPED — Wave 4.3b source-aware toolbar transactions.** Pure Core `ShortcutDropPlanner` ports `gmToolbarUI::HandleDropRelease @ 0x004BE7C0`: fresh inventory drops displace cyclically right (with wrap), shortcut aliases restore to their vacated source, duplicate objects are removed before placement, full bars intentionally lose the displaced entry when retail finds no empty slot, and every transaction is planned before local mutation then emitted in exact Remove/Add order. Full-stack rekey planning preserves the raw spell word. AP-102 retired; 4,646 automated tests pass; live inventory-to-occupied displacement and toolbar reorder gate passed 2026-07-11.
|
||||||
- **✓ SHIPPED — Wave 4.3c remaining drop branches + stack merge.** `UiButton` now has a reusable retained item-drop-target seam; gmToolbarUI's inventory/backpack button accepts only fresh physical items, draws the exact authored green-arrow overlay `0x060011F7`, and sends them to the player container while shortcut aliases remain neutral. Pure Core `StackMergePlanner` ports `IsMergeAttemptLegal @ 0x00586F30` and `AttemptMerge @ 0x005878F0`; inventory drops try legal same-WCID stack merges before normal insertion, clamp `0x0054` amount to destination capacity, immediately broadcast retail's misleadingly named `FullMergingItem` notice to rekey the toolbar, and select the destination. Existing destination shortcuts are removed before rekey. Warning-free Release build and 4,660 tests pass; live stack-merge gate passed 2026-07-11. Backpack-button drop gate remains pending.
|
- **✓ SHIPPED — Wave 4.3c remaining drop branches + stack merge.** `UiButton` now has a reusable retained item-drop-target seam; gmToolbarUI's inventory/backpack button accepts only fresh physical items, draws the exact authored green-arrow overlay `0x060011F7`, and sends them to the player container while shortcut aliases remain neutral. Pure Core `StackMergePlanner` ports `IsMergeAttemptLegal @ 0x00586F30` and `AttemptMerge @ 0x005878F0`; inventory drops try legal same-WCID stack merges before normal insertion, clamp `0x0054` amount to destination capacity, immediately broadcast retail's misleadingly named `FullMergingItem` notice to rekey the toolbar, and select the destination. Existing destination shortcuts are removed before rekey. Warning-free Release build and 4,660 tests pass; live stack-merge gate passed 2026-07-11. Backpack-button drop gate remains pending.
|
||||||
- **✓ IMPLEMENTED — Wave 4.4a selected-stack controls and transfers.** `SelectedObjectController` formats retail's count-first `NAME_APPROPRIATE` stack name from the wire plural, reveals the authored right-aligned numeric entry and horizontal DAT slider, initializes both to the full stack, refreshes them on selected-object stack changes, and shares one Core split quantity with every item-holder transfer path. The thumb owns its raw pointer position so quantity 1 and maximum reach the exact endpoints; entry writes retain retail's `split/max` attribute behavior. Exact `ItemHolder::GetObjectSplitSize` selection scoping now drives merge, split-to-container `0x0055`, and split-to-ground `0x0056`; partial splits wait for the server-created object instead of moving the source optimistically. Exact cdb-pinned 1000-step rounding, plural fallback, real-DAT media/alignment, pointer drag, and wire bytes have conformance tests. Warning-free App Release build and 4,697 tests pass; controls/polish are live-confirmed and partial-transfer live gate is pending 2026-07-11. Selected-item mana and remaining toolbar buttons stay under AP-101.
|
- **✓ SHIPPED — Wave 4.4a selected-stack controls and transfers.** `SelectedObjectController` formats retail's count-first `NAME_APPROPRIATE` stack name from the wire plural, reveals the authored right-aligned numeric entry and horizontal DAT slider, initializes both to the full stack, refreshes them on selected-object stack changes, and shares one Core split quantity with every item-holder transfer path. The thumb owns its raw pointer position so quantity 1 and maximum reach the exact endpoints; entry writes retain retail's `split/max` attribute behavior. Exact `ItemHolder::GetObjectSplitSize` selection scoping drives merge, split-to-container `0x0055`, and split-to-ground `0x0056`; partial splits wait for the server-created object instead of moving the source optimistically. Exact cdb-pinned 1000-step rounding, plural fallback, real-DAT media/alignment, pointer drag, and wire bytes have conformance tests. Warning-free App Release build and 4,697 tests passed; controls, partial inventory split, and partial ground drop were live-confirmed 2026-07-11. Selected-item mana and remaining toolbar buttons stay under AP-101.
|
||||||
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
|
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
|
||||||
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
|
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
|
||||||
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
|
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Retail inventory listbox scrolling pseudocode
|
||||||
|
|
||||||
|
## Named-retail anchors
|
||||||
|
|
||||||
|
- `UIElement_ListBox::ScrollToY @ 0x0046D550`
|
||||||
|
- `UIElement_ListBox::InqScrollDelta @ 0x0046DC10`
|
||||||
|
- `UIElement_ListBox::ScrollToView @ 0x0046E9F0`
|
||||||
|
- `UIElement_Scrollable::SetScrollableXY @ 0x004740C0`
|
||||||
|
- `UIElement_ItemList::ItemList_SetParentContainer @ 0x004E49D0`
|
||||||
|
- `UIElement_ItemList::RefreshList @ 0x004E4C00`
|
||||||
|
|
||||||
|
## Scroll model
|
||||||
|
|
||||||
|
```text
|
||||||
|
ScrollToY(pixelY):
|
||||||
|
SetScrollableXY(currentX, pixelY, false)
|
||||||
|
|
||||||
|
InqScrollDelta(vertical, negativeDirection, page):
|
||||||
|
if page:
|
||||||
|
delta = viewportHeight
|
||||||
|
else if rowCount != 0:
|
||||||
|
delta = min(scrollableHeight / rowCount, viewportHeight)
|
||||||
|
else:
|
||||||
|
delta = 0
|
||||||
|
return negativeDirection ? -delta : delta
|
||||||
|
```
|
||||||
|
|
||||||
|
The stored position is an integer pixel coordinate, not a row index. Scrollbar
|
||||||
|
thumb movement can therefore stop with a row partially visible. Wheel/arrow line
|
||||||
|
steps remain one row high because `InqScrollDelta` derives the line delta from
|
||||||
|
`scrollableHeight / rowCount`.
|
||||||
|
|
||||||
|
`ScrollToView(index)` sums preceding row heights and compares that pixel rectangle
|
||||||
|
against `[scrollY, scrollY + viewportHeight]`; it adjusts only when the item lies
|
||||||
|
outside the visible pixel interval.
|
||||||
|
|
||||||
|
## Refresh behavior
|
||||||
|
|
||||||
|
```text
|
||||||
|
RefreshList():
|
||||||
|
ItemList_SetParentContainer(parentContainerID, refresh=true, notify=true)
|
||||||
|
|
||||||
|
ItemList_SetParentContainer(...):
|
||||||
|
update capacity
|
||||||
|
flush item elements
|
||||||
|
add current contained objects in order
|
||||||
|
// no ScrollToHome / SetScrollableXY(0) in this path
|
||||||
|
```
|
||||||
|
|
||||||
|
The item-element rebuild does not explicitly home the list. A client-side retained
|
||||||
|
rebuild must therefore preserve `m_iScrollableY` and clamp it only after the complete
|
||||||
|
replacement content establishes its final extent.
|
||||||
|
|
||||||
|
## acdream port shape
|
||||||
|
|
||||||
|
- `UiItemList.DeferLayout` prevents transient one-cell extents from clamping the
|
||||||
|
retained pixel offset during `InventoryController.Populate`.
|
||||||
|
- Intersecting edge cells remain visible at their true `rowY - ScrollY` coordinate.
|
||||||
|
- `UiElement.ClipsChildren` pushes a nested screen-space clip; `UiRenderContext`
|
||||||
|
crops sprite geometry and matching UVs, so a partial row paints only inside the
|
||||||
|
contents viewport and cannot receive input outside it.
|
||||||
|
- The inventory window's maximum height is the available screen height rather than
|
||||||
|
the former arbitrary 560 px cap. Exact keystone resize bounds remain AP-54.
|
||||||
|
|
@ -540,12 +540,12 @@ build and 4,697-test suite are green. Gate polish preserves CreateObject
|
||||||
`PluralName` through Core.Net into `ClientObject.GetAppropriateName`, honors the
|
`PluralName` through Core.Net into `ClientObject.GetAppropriateName`, honors the
|
||||||
entry's authored right alignment, and lets the pointer-owned thumb remain at exact
|
entry's authored right alignment, and lets the pointer-owned thumb remain at exact
|
||||||
minimum/maximum endpoints instead of snapping back from the quantized amount. The
|
minimum/maximum endpoints instead of snapping back from the quantized amount. The
|
||||||
controls/polish live gate passed. `ItemHolder::GetObjectSplitSize @ 0x00586F00`
|
controls/polish and partial-transfer live gates passed. `ItemHolder::GetObjectSplitSize @ 0x00586F00`
|
||||||
now scopes that quantity to the selected object for all transfers. Partial inventory
|
now scopes that quantity to the selected object for all transfers. Partial inventory
|
||||||
moves send `StackableSplitToContainer 0x0055`; partial drag-out drops send
|
moves send `StackableSplitToContainer 0x0055`; partial drag-out drops send
|
||||||
`StackableSplitTo3D 0x0056`; neither path moves the original object optimistically
|
`StackableSplitTo3D 0x0056`; neither path moves the original object optimistically
|
||||||
because the authoritative destination stack receives a server-assigned guid. The
|
because the authoritative destination stack receives a server-assigned guid. The
|
||||||
partial-transfer live action gate follows.
|
partial inventory and ground transfers were live-confirmed 2026-07-11.
|
||||||
|
|
||||||
**Wave 4.1 implementation (2026-07-11):** bare `1..9` now use slots 0..8,
|
**Wave 4.1 implementation (2026-07-11):** bare `1..9` now use slots 0..8,
|
||||||
Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a
|
Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,9 @@ internal static class MockupDesktop
|
||||||
ResizeX = false,
|
ResizeX = false,
|
||||||
ResizeY = true,
|
ResizeY = true,
|
||||||
ResizableEdges = ResizeEdges.Bottom,
|
ResizableEdges = ResizeEdges.Bottom,
|
||||||
MaxHeight = 560f,
|
MaxHeight = Math.Max(
|
||||||
|
contentH + 2f * RetailChromeSprites.Border,
|
||||||
|
stack.UiHost.Root.Height - 18f),
|
||||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
|
|
||||||
public void Populate()
|
public void Populate()
|
||||||
{
|
{
|
||||||
|
// Retail refreshes listbox contents without resetting m_iScrollableY.
|
||||||
|
// Defer our procedural rebuild so the first re-added cell cannot collapse
|
||||||
|
// MaxScroll temporarily and clamp the user's offset back to zero.
|
||||||
|
using IDisposable? contentsLayout = _contentsGrid?.DeferLayout();
|
||||||
|
using IDisposable? containersLayout = _containerList?.DeferLayout();
|
||||||
|
|
||||||
uint p = _playerGuid();
|
uint p = _playerGuid();
|
||||||
uint open = EffectiveOpen();
|
uint open = EffectiveOpen();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -581,7 +581,11 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
ResizeX = false,
|
ResizeX = false,
|
||||||
ResizeY = true,
|
ResizeY = true,
|
||||||
ResizableEdges = ResizeEdges.Bottom,
|
ResizableEdges = ResizeEdges.Bottom,
|
||||||
MaxHeight = 560f,
|
// The old 560px cap left a previously expanded window permanently
|
||||||
|
// parked at its maximum. Let it grow to the available screen height.
|
||||||
|
MaxHeight = Math.Max(
|
||||||
|
root.Height + 2f * RetailChromeSprites.Border,
|
||||||
|
Host.Root.Height - root.Top),
|
||||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,13 @@ public abstract class UiElement
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected virtual void OnDrawOverlay(UiRenderContext ctx) { }
|
protected virtual void OnDrawOverlay(UiRenderContext ctx) { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, descendant drawing and hit-testing are clipped to this element's
|
||||||
|
/// local bounds. Scrollable listboxes use this so edge rows can remain visible
|
||||||
|
/// at arbitrary pixel offsets without painting or receiving input outside the viewport.
|
||||||
|
/// </summary>
|
||||||
|
protected virtual bool ClipsChildren => false;
|
||||||
|
|
||||||
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
|
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
|
||||||
protected virtual void OnTick(double deltaSeconds) { }
|
protected virtual void OnTick(double deltaSeconds) { }
|
||||||
|
|
||||||
|
|
@ -378,11 +385,22 @@ public abstract class UiElement
|
||||||
// Children painted back-to-front (lowest ZOrder first).
|
// Children painted back-to-front (lowest ZOrder first).
|
||||||
if (_children.Count > 0)
|
if (_children.Count > 0)
|
||||||
{
|
{
|
||||||
// Avoid LINQ allocation by copying to a temp array and sorting.
|
bool clipsChildren = ClipsChildren;
|
||||||
var ordered = _children.ToArray();
|
if (clipsChildren)
|
||||||
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
|
ctx.PushClip(0f, 0f, Width, Height);
|
||||||
for (int i = 0; i < ordered.Length; i++)
|
try
|
||||||
ordered[i].DrawSelfAndChildren(ctx);
|
{
|
||||||
|
// Avoid LINQ allocation by copying to a temp array and sorting.
|
||||||
|
var ordered = _children.ToArray();
|
||||||
|
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
|
||||||
|
for (int i = 0; i < ordered.Length; i++)
|
||||||
|
ordered[i].DrawSelfAndChildren(ctx);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (clipsChildren)
|
||||||
|
ctx.PopClip();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Foreground pass for this element (e.g. a window frame's border drawn
|
// Foreground pass for this element (e.g. a window frame's border drawn
|
||||||
|
|
@ -411,10 +429,21 @@ public abstract class UiElement
|
||||||
OnDrawOverlay(ctx);
|
OnDrawOverlay(ctx);
|
||||||
if (_children.Count > 0)
|
if (_children.Count > 0)
|
||||||
{
|
{
|
||||||
var ordered = _children.ToArray();
|
bool clipsChildren = ClipsChildren;
|
||||||
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
|
if (clipsChildren)
|
||||||
for (int i = 0; i < ordered.Length; i++)
|
ctx.PushClip(0f, 0f, Width, Height);
|
||||||
ordered[i].DrawOverlays(ctx);
|
try
|
||||||
|
{
|
||||||
|
var ordered = _children.ToArray();
|
||||||
|
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
|
||||||
|
for (int i = 0; i < ordered.Length; i++)
|
||||||
|
ordered[i].DrawOverlays(ctx);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (clipsChildren)
|
||||||
|
ctx.PopClip();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
@ -440,6 +469,9 @@ public abstract class UiElement
|
||||||
internal UiElement? HitTest(float localX, float localY)
|
internal UiElement? HitTest(float localX, float localY)
|
||||||
{
|
{
|
||||||
if (!Visible || !Enabled) return null;
|
if (!Visible || !Enabled) return null;
|
||||||
|
if (ClipsChildren
|
||||||
|
&& (localX < 0f || localX >= Width || localY < 0f || localY >= Height))
|
||||||
|
return null;
|
||||||
|
|
||||||
// Children first, in reverse Z-order (topmost first). ClickThrough means
|
// Children first, in reverse Z-order (topmost first). ClickThrough means
|
||||||
// THIS element is transparent to the pointer — but its children are NOT.
|
// THIS element is transparent to the pointer — but its children are NOT.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ public enum UiItemListFlow
|
||||||
public sealed class UiItemList : UiElement
|
public sealed class UiItemList : UiElement
|
||||||
{
|
{
|
||||||
private readonly List<UiItemSlot> _cells = new();
|
private readonly List<UiItemSlot> _cells = new();
|
||||||
|
private int _layoutDeferralDepth;
|
||||||
|
|
||||||
/// <summary>Vertical scroll model for grid mode (clip+scroll). Bound to the gutter
|
/// <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>
|
/// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell).</summary>
|
||||||
|
|
@ -89,7 +90,19 @@ public sealed class UiItemList : UiElement
|
||||||
cell.Anchors = AnchorEdges.None;
|
cell.Anchors = AnchorEdges.None;
|
||||||
_cells.Add(cell);
|
_cells.Add(cell);
|
||||||
AddChild(cell);
|
AddChild(cell);
|
||||||
LayoutCells();
|
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>
|
/// <summary>Grid columns. 1 = single column. Ignored in fill mode.</summary>
|
||||||
|
|
@ -143,7 +156,7 @@ public sealed class UiItemList : UiElement
|
||||||
|
|
||||||
/// <summary>Position every cell per the current mode: fill (CellWidth<=0) sizes the single
|
/// <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
|
/// cell to the list; grid (CellWidth>0) tiles cells by <see cref="Flow"/>, offset by the scroll position
|
||||||
/// with whole-row vertical clipping (cells fully outside the view are hidden).</summary>
|
/// with pixel viewport clipping (partially visible edge rows are cropped).</summary>
|
||||||
internal void LayoutCells()
|
internal void LayoutCells()
|
||||||
{
|
{
|
||||||
if (CellWidth <= 0f)
|
if (CellWidth <= 0f)
|
||||||
|
|
@ -175,12 +188,14 @@ public sealed class UiItemList : UiElement
|
||||||
float top = baseY - scrollY;
|
float top = baseY - scrollY;
|
||||||
var cell = _cells[i];
|
var cell = _cells[i];
|
||||||
cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight;
|
cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight;
|
||||||
// Whole-row vertical clip (no scissor — mirrors UiText.cs:198). A row fully inside
|
// Retail listboxes retain intersecting edge rows at arbitrary pixel offsets.
|
||||||
// [0, Height] draws; a partially-scrolled row is hidden.
|
// The UiElement child-clip traversal crops their visible portion.
|
||||||
cell.Visible = top >= -0.5f && top + CellHeight <= Height + 0.5f;
|
cell.Visible = top < Height && top + CellHeight > 0f;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override bool ClipsChildren => CellWidth > 0f;
|
||||||
|
|
||||||
public void Flush()
|
public void Flush()
|
||||||
{
|
{
|
||||||
foreach (var c in _cells) RemoveChild(c);
|
foreach (var c in _cells) RemoveChild(c);
|
||||||
|
|
@ -204,4 +219,27 @@ public sealed class UiItemList : UiElement
|
||||||
// fill mode keeps the single toolbar cell sized to the list; grid mode tiles cells.
|
// fill mode keeps the single toolbar cell sized to the list; grid mode tiles cells.
|
||||||
LayoutCells();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,51 @@ using AcDream.App.Rendering;
|
||||||
|
|
||||||
namespace AcDream.App.UI;
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
internal readonly record struct UiClipRect(float Left, float Top, float Right, float Bottom)
|
||||||
|
{
|
||||||
|
public bool IsEmpty => Right <= Left || Bottom <= Top;
|
||||||
|
|
||||||
|
public static UiClipRect Intersect(UiClipRect a, UiClipRect b)
|
||||||
|
=> new(
|
||||||
|
MathF.Max(a.Left, b.Left),
|
||||||
|
MathF.Max(a.Top, b.Top),
|
||||||
|
MathF.Min(a.Right, b.Right),
|
||||||
|
MathF.Min(a.Bottom, b.Bottom));
|
||||||
|
|
||||||
|
public static bool TryClipSprite(
|
||||||
|
UiClipRect clip,
|
||||||
|
ref float x, ref float y, ref float w, ref float h,
|
||||||
|
ref float u0, ref float v0, ref float u1, ref float v1)
|
||||||
|
{
|
||||||
|
if (clip.IsEmpty || w <= 0f || h <= 0f)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
float left = MathF.Max(x, clip.Left);
|
||||||
|
float top = MathF.Max(y, clip.Top);
|
||||||
|
float right = MathF.Min(x + w, clip.Right);
|
||||||
|
float bottom = MathF.Min(y + h, clip.Bottom);
|
||||||
|
if (right <= left || bottom <= top)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
float oldX = x, oldY = y, oldW = w, oldH = h;
|
||||||
|
float oldU0 = u0, oldV0 = v0, oldU1 = u1, oldV1 = v1;
|
||||||
|
float tx0 = (left - oldX) / oldW;
|
||||||
|
float tx1 = (right - oldX) / oldW;
|
||||||
|
float ty0 = (top - oldY) / oldH;
|
||||||
|
float ty1 = (bottom - oldY) / oldH;
|
||||||
|
|
||||||
|
x = left;
|
||||||
|
y = top;
|
||||||
|
w = right - left;
|
||||||
|
h = bottom - top;
|
||||||
|
u0 = oldU0 + (oldU1 - oldU0) * tx0;
|
||||||
|
u1 = oldU0 + (oldU1 - oldU0) * tx1;
|
||||||
|
v0 = oldV0 + (oldV1 - oldV0) * ty0;
|
||||||
|
v1 = oldV0 + (oldV1 - oldV0) * ty1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-frame drawing context passed through the <see cref="UiElement"/>
|
/// Per-frame drawing context passed through the <see cref="UiElement"/>
|
||||||
/// tree. Wraps a <see cref="TextRenderer"/> (our 2D sprite batcher) and a
|
/// tree. Wraps a <see cref="TextRenderer"/> (our 2D sprite batcher) and a
|
||||||
|
|
@ -21,6 +66,8 @@ public sealed class UiRenderContext
|
||||||
// Transform stack — simple 2D translate (no rotation/scale for UI).
|
// Transform stack — simple 2D translate (no rotation/scale for UI).
|
||||||
private readonly System.Collections.Generic.List<Vector2> _stack = new();
|
private readonly System.Collections.Generic.List<Vector2> _stack = new();
|
||||||
private Vector2 _current;
|
private Vector2 _current;
|
||||||
|
private readonly System.Collections.Generic.List<UiClipRect?> _clipStack = new();
|
||||||
|
private UiClipRect? _clip;
|
||||||
|
|
||||||
// Alpha (opacity) stack — a window pushes its Opacity so its background/sprite
|
// Alpha (opacity) stack — a window pushes its Opacity so its background/sprite
|
||||||
// draws fade (retail's translucent-chat effect). Text draws bypass this (they go
|
// draws fade (retail's translucent-chat effect). Text draws bypass this (they go
|
||||||
|
|
@ -68,6 +115,27 @@ public sealed class UiRenderContext
|
||||||
|
|
||||||
public Vector2 CurrentOrigin => _current;
|
public Vector2 CurrentOrigin => _current;
|
||||||
|
|
||||||
|
/// <summary>Intersect descendant drawing with a local-space viewport.</summary>
|
||||||
|
public void PushClip(float x, float y, float w, float h)
|
||||||
|
{
|
||||||
|
_clipStack.Add(_clip);
|
||||||
|
var next = new UiClipRect(
|
||||||
|
_current.X + x,
|
||||||
|
_current.Y + y,
|
||||||
|
_current.X + x + MathF.Max(0f, w),
|
||||||
|
_current.Y + y + MathF.Max(0f, h));
|
||||||
|
_clip = _clip is { } current
|
||||||
|
? UiClipRect.Intersect(current, next)
|
||||||
|
: next;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PopClip()
|
||||||
|
{
|
||||||
|
if (_clipStack.Count == 0) return;
|
||||||
|
_clip = _clipStack[^1];
|
||||||
|
_clipStack.RemoveAt(_clipStack.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Route subsequent draws to the overlay layer (flushed on top of the whole
|
/// <summary>Route subsequent draws to the overlay layer (flushed on top of the whole
|
||||||
/// UI). Used by the root for the popup/overlay traversal. Pair with <see cref="EndOverlayLayer"/>.</summary>
|
/// UI). Used by the root for the popup/overlay traversal. Pair with <see cref="EndOverlayLayer"/>.</summary>
|
||||||
public void BeginOverlayLayer() => TextRenderer.OverlayMode = true;
|
public void BeginOverlayLayer() => TextRenderer.OverlayMode = true;
|
||||||
|
|
@ -76,22 +144,64 @@ public sealed class UiRenderContext
|
||||||
// ── Pass-through draw helpers (add current translate) ──────────────
|
// ── Pass-through draw helpers (add current translate) ──────────────
|
||||||
|
|
||||||
public void DrawRect(float x, float y, float w, float h, Vector4 color)
|
public void DrawRect(float x, float y, float w, float h, Vector4 color)
|
||||||
=> TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color));
|
{
|
||||||
|
x += _current.X;
|
||||||
|
y += _current.Y;
|
||||||
|
if (!ClipRect(ref x, ref y, ref w, ref h)) return;
|
||||||
|
TextRenderer.DrawRect(x, y, w, h, ApplyAlpha(color));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Solid-colour fill drawn in the SPRITE bucket (painter order with text), for
|
/// <summary>Solid-colour fill drawn in the SPRITE bucket (painter order with text), for
|
||||||
/// a panel BACKGROUND that text draws on top of. <see cref="DrawRect"/> composites after
|
/// a panel BACKGROUND that text draws on top of. <see cref="DrawRect"/> composites after
|
||||||
/// all sprites and would cover the text — use this for backgrounds, that for foreground
|
/// all sprites and would cover the text — use this for backgrounds, that for foreground
|
||||||
/// fills (carets, vital bars).</summary>
|
/// fills (carets, vital bars).</summary>
|
||||||
public void DrawFill(float x, float y, float w, float h, Vector4 color)
|
public void DrawFill(float x, float y, float w, float h, Vector4 color)
|
||||||
=> TextRenderer.DrawFill(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color));
|
{
|
||||||
|
x += _current.X;
|
||||||
|
y += _current.Y;
|
||||||
|
if (!ClipRect(ref x, ref y, ref w, ref h)) return;
|
||||||
|
TextRenderer.DrawFill(x, y, w, h, ApplyAlpha(color));
|
||||||
|
}
|
||||||
|
|
||||||
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
|
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
|
||||||
=> TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color), thickness);
|
{
|
||||||
|
if (thickness <= 0f || w <= 0f || h <= 0f) return;
|
||||||
|
float t = MathF.Min(thickness, MathF.Min(w, h) * 0.5f);
|
||||||
|
DrawRect(x, y, w, t, color);
|
||||||
|
DrawRect(x, y + h - t, w, t, color);
|
||||||
|
DrawRect(x, y + t, t, h - 2f * t, color);
|
||||||
|
DrawRect(x + w - t, y + t, t, h - 2f * t, color);
|
||||||
|
}
|
||||||
|
|
||||||
public void DrawSprite(uint texture, float x, float y, float w, float h,
|
public void DrawSprite(uint texture, float x, float y, float w, float h,
|
||||||
float u0, float v0, float u1, float v1, Vector4 tint)
|
float u0, float v0, float u1, float v1, Vector4 tint)
|
||||||
=> TextRenderer.DrawSprite(texture,
|
{
|
||||||
_current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, ApplyAlpha(tint));
|
x += _current.X;
|
||||||
|
y += _current.Y;
|
||||||
|
DrawSpriteAbsolute(texture, x, y, w, h, u0, v0, u1, v1, tint, applyAlpha: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawSpriteAbsolute(
|
||||||
|
uint texture, float x, float y, float w, float h,
|
||||||
|
float u0, float v0, float u1, float v1, Vector4 tint, bool applyAlpha)
|
||||||
|
{
|
||||||
|
if (_clip is { } clip
|
||||||
|
&& !UiClipRect.TryClipSprite(
|
||||||
|
clip, ref x, ref y, ref w, ref h, ref u0, ref v0, ref u1, ref v1))
|
||||||
|
return;
|
||||||
|
TextRenderer.DrawSprite(
|
||||||
|
texture, x, y, w, h, u0, v0, u1, v1,
|
||||||
|
applyAlpha ? ApplyAlpha(tint) : tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ClipRect(ref float x, ref float y, ref float w, ref float h)
|
||||||
|
{
|
||||||
|
if (_clip is not { } clip)
|
||||||
|
return w > 0f && h > 0f;
|
||||||
|
float u0 = 0f, v0 = 0f, u1 = 1f, v1 = 1f;
|
||||||
|
return UiClipRect.TryClipSprite(
|
||||||
|
clip, ref x, ref y, ref w, ref h, ref u0, ref v0, ref u1, ref v1);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Multiply the current window opacity into a draw color's alpha.</summary>
|
/// <summary>Multiply the current window opacity into a draw color's alpha.</summary>
|
||||||
private Vector4 ApplyAlpha(Vector4 c) => _alpha >= 1f ? c : new Vector4(c.X, c.Y, c.Z, c.W * _alpha);
|
private Vector4 ApplyAlpha(Vector4 c) => _alpha >= 1f ? c : new Vector4(c.X, c.Y, c.Z, c.W * _alpha);
|
||||||
|
|
@ -172,14 +282,18 @@ public sealed class UiRenderContext
|
||||||
var (bu0, bv0, bu1, bv1) = AtlasUv(
|
var (bu0, bv0, bu1, bv1) = AtlasUv(
|
||||||
g.OffsetX, g.OffsetY, g.Width, g.Height,
|
g.OffsetX, g.OffsetY, g.Width, g.Height,
|
||||||
font.BackgroundWidth, font.BackgroundHeight);
|
font.BackgroundWidth, font.BackgroundHeight);
|
||||||
TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outlineTint);
|
DrawSpriteAbsolute(
|
||||||
|
font.BackgroundTexture, gx, gy, gw, gh,
|
||||||
|
bu0, bv0, bu1, bv1, outlineTint, applyAlpha: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Foreground (fill) atlas pass, tinted with the requested color.
|
// Foreground (fill) atlas pass, tinted with the requested color.
|
||||||
var (fu0, fv0, fu1, fv1) = AtlasUv(
|
var (fu0, fv0, fu1, fv1) = AtlasUv(
|
||||||
g.OffsetX, g.OffsetY, g.Width, g.Height,
|
g.OffsetX, g.OffsetY, g.Width, g.Height,
|
||||||
font.ForegroundWidth, font.ForegroundHeight);
|
font.ForegroundWidth, font.ForegroundHeight);
|
||||||
TextRenderer.DrawSprite(font.ForegroundTexture, gx, gy, gw, gh, fu0, fv0, fu1, fv1, color);
|
DrawSpriteAbsolute(
|
||||||
|
font.ForegroundTexture, gx, gy, gw, gh,
|
||||||
|
fu0, fv0, fu1, fv1, color, applyAlpha: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
pen += UiDatFont.GlyphAdvance(g);
|
pen += UiDatFont.GlyphAdvance(g);
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,21 @@ public class InventoryControllerTests
|
||||||
Assert.Same(grid.Scroll, bar.Model); // the bar drives the grid's scroll
|
Assert.Same(grid.Scroll, bar.Model); // the bar drives the grid's scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Inventory_refresh_preserves_contents_pixel_scroll_offset()
|
||||||
|
{
|
||||||
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = Player, ItemsCapacity = 102 });
|
||||||
|
Bind(layout, objects);
|
||||||
|
grid.Scroll.SetScrollY(40);
|
||||||
|
|
||||||
|
objects.UpdateIntProperty(Player, 5u, 1000);
|
||||||
|
|
||||||
|
Assert.Equal(40, grid.Scroll.ScrollY);
|
||||||
|
Assert.Equal(-8f, grid.GetItem(6)!.Top);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Contents_grid_pads_empty_slots_to_main_pack_capacity()
|
public void Contents_grid_pads_empty_slots_to_main_pack_capacity()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,8 @@ public sealed class RetailWindowFrameTests
|
||||||
grid.LayoutCells();
|
grid.LayoutCells();
|
||||||
|
|
||||||
Assert.Equal(251f, grid.Height);
|
Assert.Equal(251f, grid.Height);
|
||||||
Assert.Equal(42, grid.Children.Count(child => child.Visible));
|
Assert.Equal(48, grid.Children.Count(child => child.Visible));
|
||||||
|
Assert.Equal(224f, grid.GetItem(42)!.Top); // final row intersects 251px view by 27px
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplyAnchors(UiElement parent)
|
private static void ApplyAnchors(UiElement parent)
|
||||||
|
|
|
||||||
36
tests/AcDream.App.Tests/UI/UiClipRectTests.cs
Normal file
36
tests/AcDream.App.Tests/UI/UiClipRectTests.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
public sealed class UiClipRectTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TryClipSprite_cropsGeometryAndMatchingUvs()
|
||||||
|
{
|
||||||
|
var clip = new UiClipRect(0f, 0f, 100f, 100f);
|
||||||
|
float x = -8f, y = 88f, w = 32f, h = 32f;
|
||||||
|
float u0 = 0f, v0 = 0f, u1 = 1f, v1 = 1f;
|
||||||
|
|
||||||
|
bool visible = UiClipRect.TryClipSprite(
|
||||||
|
clip, ref x, ref y, ref w, ref h, ref u0, ref v0, ref u1, ref v1);
|
||||||
|
|
||||||
|
Assert.True(visible);
|
||||||
|
Assert.Equal((0f, 88f, 24f, 12f), (x, y, w, h));
|
||||||
|
Assert.Equal((0.25f, 0f, 1f, 0.375f), (u0, v0, u1, v1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Intersection_andFullyOutsideSprite_areEmpty()
|
||||||
|
{
|
||||||
|
UiClipRect intersection = UiClipRect.Intersect(
|
||||||
|
new UiClipRect(0f, 0f, 20f, 20f),
|
||||||
|
new UiClipRect(30f, 30f, 40f, 40f));
|
||||||
|
float x = 0f, y = 0f, w = 10f, h = 10f;
|
||||||
|
float u0 = 0f, v0 = 0f, u1 = 1f, v1 = 1f;
|
||||||
|
|
||||||
|
Assert.True(intersection.IsEmpty);
|
||||||
|
Assert.False(UiClipRect.TryClipSprite(
|
||||||
|
intersection, ref x, ref y, ref w, ref h, ref u0, ref v0, ref u1, ref v1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,40 @@ public sealed class UiItemListScrollTests
|
||||||
Assert.True(list.GetItem(18)!.Visible); // row 3 now visible
|
Assert.True(list.GetItem(18)!.Visible); // row 3 now visible
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pixel_scroll_keeps_partiallyVisible_edge_rows()
|
||||||
|
{
|
||||||
|
var list = Grid(30);
|
||||||
|
|
||||||
|
list.Scroll.SetScrollY(8);
|
||||||
|
list.LayoutCells();
|
||||||
|
|
||||||
|
Assert.True(list.GetItem(0)!.Visible);
|
||||||
|
Assert.Equal(-8f, list.GetItem(0)!.Top);
|
||||||
|
Assert.True(list.GetItem(18)!.Visible);
|
||||||
|
Assert.Equal(88f, list.GetItem(18)!.Top);
|
||||||
|
Assert.False(list.GetItem(24)!.Visible);
|
||||||
|
Assert.Null(list.HitTest(5f, -1f));
|
||||||
|
Assert.IsType<UiItemSlot>(list.HitTest(5f, 1f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deferred_rebuild_preserves_pixel_scroll_offset()
|
||||||
|
{
|
||||||
|
var list = Grid(30);
|
||||||
|
list.Scroll.SetScrollY(40);
|
||||||
|
|
||||||
|
using (list.DeferLayout())
|
||||||
|
{
|
||||||
|
list.Flush();
|
||||||
|
for (int i = 0; i < 30; i++)
|
||||||
|
list.AddItem(new UiItemSlot());
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(40, list.Scroll.ScrollY);
|
||||||
|
Assert.Equal(-8f, list.GetItem(6)!.Top);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Scroll_survives_the_per_frame_anchor_pass()
|
public void Scroll_survives_the_per_frame_anchor_pass()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue