feat(ui): port retail selected-stack quantity
Bind the authored stack count entry and horizontal slider to one Core split-quantity owner, preserve retail count-first naming and exact 1000-step rounding, refresh on stack changes, and consume the selected amount during merges. Conformance covers the production DAT fixture and retained pointer/focus paths. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
dc1649c493
commit
a20e5c68c7
19 changed files with 595 additions and 51 deletions
|
|
@ -2050,7 +2050,7 @@ See divergence register **AP-59**.
|
|||
|
||||
## #141 — Toolbar interactivity — selected-object display
|
||||
|
||||
**Status:** IN PROGRESS (D.5.3a health + name + flash — DONE & visually confirmed 2026-06-20; mana + stack slider 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 implemented 2026-07-11, visual 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.
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-06-17
|
||||
**Component:** ui — D.5 toolbar / selection
|
||||
|
|
@ -2058,8 +2058,9 @@ See divergence register **AP-59**.
|
|||
**Description:** The action bar (D.5.1) is the retail "selected object" display. Wire the B.4 WorldPicker/selection state to the toolbar's currently-hidden elements: the two meters 0x100001A1 (selected-object Health) / 0x100001A2 (selected-object Mana) + the stack slider 0x100001A4 + the object-name line, so the bar shows what the player has selected in the world. Click-to-use + the peace/war stance indicator already shipped in D.5.1. Promote to roadmap D.5.3 (already listed there).
|
||||
|
||||
**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) and the stack entry/slider (0x100001A3/A4 — stack-split UI). 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) + stack entry/slider (0x100001A3/A4).
|
||||
- **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.3b implementation (2026-07-11):** stacked selection formats the retail `"%d %hs"` count/name, reveals authored entry `0x100001A3` and horizontal DAT slider `0x100001A4`, initializes to the full stack, clamps entry edits, and uses the exact 1000-step slider conversion. One Core `StackSplitQuantityState` feeds both controls and the selected-source merge amount. Live visual gate pending.
|
||||
|
||||
**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).
|
||||
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
|
|||
| ~~AP-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` |
|
||||
| ~~AP-98~~ | **RETIRED 2026-07-10 (Wave 2.3)** — typed handles/manager own lifecycle; every production/Studio window uses `RetailWindowFrame`; schema-v2 `UiWindowLayout` persists outer bounds, visibility, toolbar collapse, and chat maximize by character/resolution with legacy-radar migration, nearest-resolution fallback, clamping, and a pre-login `default` write guard. Chat maximize uses DAT 100–360 limits and preserves its lower edge when growing upward. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowLayoutPersistence.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs`; `SettingsStore.cs` | — | — | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail `saveui/loadui` behavior |
|
||||
| ~~AP-99~~ | **RETIRED 2026-07-11 (Wave 3.2)** — Core `ItemInteractionPolicy` ports the complete ordered `DetermineUseResult`/`UseObject`/`AttemptPlaceIn3D` matrix; PWD flags and `CombatUse` survive CreateObject; component-pack membership comes from portal.dat; App owns throttle, UseDone-balanced busy state, target mode, wire/optimistic dispatch, and typed confirmation/auxiliary seams. The two corrupt decompiler operands were pinned from matching x86 as `BF_REQUIRES_PACKSLOT` and `BF_VENDOR`. | `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/UI/ItemInteractionController.cs` | — | — | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` |
|
||||
| AP-101 | Most toolbar panel/Use/Examine controls are not fully dispatched; selected-object mana and stack entry/slider are absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, health/name, quick-slot use/select/create actions, some panel toggles, and all four combat indicator buttons work; combat clicks and the bound key share `ToggleLiveCombatMode` | Remaining bound retail buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` |
|
||||
| AP-101 | Most toolbar panel/Use/Examine controls are not fully dispatched and selected-object mana is absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, health/name, exact stack name/entry/slider, shared split quantity, quick-slot actions, some panel toggles, and all four combat indicators work | Remaining bound retail buttons do nothing and mana-bearing item selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` |
|
||||
| AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` |
|
||||
| AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods |
|
||||
| AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
|
||||
|
|
|
|||
|
|
@ -490,10 +490,11 @@ 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.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.
|
||||
- **✓ IMPLEMENTED — Wave 4.4a selected-stack controls.** `SelectedObjectController` now formats retail's count-first stack name, reveals the authored 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 inventory merge. Exact cdb-pinned 1000-step slider rounding, entry clamping, real-DAT fixture media, and pointer drag have conformance tests. Warning-free App Release build and 4,683 tests pass; live visual gate pending 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 — 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.
|
||||
- **☐ D.5.3 — Toolbar selected-object display (issue #141).** Wire unified selection to the hidden mana meter (`0x100001A2`), stack entry (`0x100001A3`), stack slider (`0x100001A4`), and complete health/name behavior. Exact raw `ShortCutData` preservation is already shipped in Wave 4.2; the eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase. (Click-to-use + peace/war + health/name already landed.)
|
||||
- **☐ D.5.3 — Toolbar selected-object display (issue #141).** Wire unified selection to the hidden mana meter (`0x100001A2`) and complete residual health policy. Stack entry (`0x100001A3`), stack slider (`0x100001A4`), formatted count/name, shared split quantity, click-to-use, peace/war, and health/name are implemented. Exact raw `ShortCutData` preservation is already shipped in Wave 4.2; the eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase.
|
||||
- **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire.
|
||||
- **D.6 — HUD.** **Radar/compass IMPLEMENTED 2026-07-10; live-world visual gate pending.** It is the retained retail `gmRadarUI` (`LayoutDesc 0x21000074`): a 120×140 circular radar (`0x06004CC1`) with N/E/S/W token sprites orbiting the face, a fixed bright-green player marker, and the coordinate footer (`0x06004CC0`). Projection is `radarPixelRadius / radarRange` with 75 m outdoor / 25 m indoor ranges — there is no 1.18 factor or scrolling compass strip. It lives in `AcDream.App/UI` beside the other imported retail panels. Remaining D.6: target name plate, damage floaters, and other world-space HUD elements through the raw `TextRenderer` path. See slice 06 §A.2, `docs/research/2026-07-10-retail-radar-pseudocode.md`, and the named `gmRadarUI` decomp.
|
||||
- **✓ SHIPPED — D.7 — Cursor manager (user-confirmed 2026-07-11).** All reachable `ClientUISystem::UpdateCursorState @ 0x00564630` branches resolve through production DAT EnumIDMap table 6, with event-ordered global-default versus captured/hovered `MediaDescCursor` layering from `UIElementManager::CheckCursor @ 0x0045ABF0`. OS fallback remains only AP-72 and emits a visible diagnostic. The live gate covered world/object, combat, targeted-use, text, move/resize, and drag contexts; combat-indicator mouse clicks and the quick key share the same toggle command.
|
||||
|
|
|
|||
|
|
@ -463,10 +463,10 @@ AttemptMerge(sourceId, targetId, quiet): // 0x005878F0
|
|||
return true
|
||||
```
|
||||
|
||||
acdream's selected-object stack entry/slider is still absent under AP-101, so the
|
||||
current inventory merge path requests the whole source stack and lets the same
|
||||
retail capacity clamp limit the transfer. Once the shared split-quantity owner
|
||||
lands, it supplies `requested` without changing legality or wire ordering.
|
||||
acdream now has one Core-owned split-quantity state shared by the selected-object
|
||||
entry/slider and inventory merge. A selected source supplies that quantity to
|
||||
`AttemptMerge`; an unselected source retains retail's full-source default. Legality,
|
||||
capacity clamping, and wire ordering are unchanged.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -507,7 +507,7 @@ HandleSelectionChanged(): // 0x004BF380
|
|||
if object missing: return
|
||||
|
||||
set selected name:
|
||||
normal object: name, or formatted name + count when stacked
|
||||
normal object: name, or `"%d %hs"` (count then name) when stacked
|
||||
owned coinstack: formatted coin total/name path
|
||||
|
||||
splitSize = 1
|
||||
|
|
@ -567,13 +567,22 @@ RecvNotice_UpdateItemMana(id, fraction, valid): // 0x004BD0C0
|
|||
- On entry deactivation, parse unsigned text, clamp to `1..maxSplitSize`, rewrite
|
||||
the text if clamped, update `GenItemHolder::splitSize`, update slider attribute
|
||||
`0x86 = splitSize/maxSplitSize`, and broadcast the stack-slider notice.
|
||||
- Slider message `0xA` maps its normalized value to an integer in
|
||||
`1..maxSplitSize`, stores it, rewrites the entry, and broadcasts the notice.
|
||||
- `UIElement_Scrollbar::SetScrollbarPosition @ 0x00470EC0` clamps the normalized
|
||||
position to `[0,1]`, multiplies by `1000.0f`, truncates through `_ftol2`, and
|
||||
broadcasts that integer as message `0xA` parameter 1.
|
||||
- `gmToolbarUI::ListenToElementMessage @ 0x004BEFE0..0x004BF041` converts the
|
||||
message exactly as follows, stores it, rewrites the entry, and broadcasts the
|
||||
stack-slider notice:
|
||||
|
||||
**Unresolved:** Binary Ninja lost the exact floating expression immediately before
|
||||
the slider's `1 - _ftol2()` conversion at `0x004BF02D`. The clamp, endpoints, and
|
||||
text update are exact, but the intermediate rounding direction must be recovered
|
||||
from Ghidra/disassembly before writing conformance vectors.
|
||||
```text
|
||||
positionMillis = truncate(clamp01(sliderRatio) * 1000)
|
||||
splitSize = clamp(1 + floor(positionMillis * maxSplitSize / 1000),
|
||||
1, maxSplitSize)
|
||||
```
|
||||
|
||||
The apparently odd extra one is verbatim retail behavior. Static cdb disassembly
|
||||
of the matching v11.4186 binary also pins the stack-name format string at
|
||||
`0x007B4738` to `"%d %hs"` and the numeric-entry format at `0x007A0184` to `"%d"`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -505,12 +505,12 @@ overlay, while aliases stay neutral. Inventory now tries the pure named-retail
|
|||
stack-merge legality/amount planner before ordinary insertion, sends `0x0054`,
|
||||
broadcasts `FullMergingItem` immediately from the attempt owner, rekeys the toolbar
|
||||
with existing-destination removal, and selects the target. The notice is not a
|
||||
server confirmation despite its name. AP-101 still covers the absent shared split
|
||||
entry/slider; until it lands, merge requests use the whole source stack before the
|
||||
retail destination-capacity clamp. Release build is warning-free and 4,660 tests
|
||||
pass. The compatible-stack live gate passed 2026-07-11; it exposed the expected
|
||||
Wave 4.4/AP-101 gap that the selected strip still displays one instead of the
|
||||
stack count and has no split slider. The inventory-button live gate remains pending.
|
||||
server confirmation despite its name. Release build is warning-free and 4,660 tests
|
||||
pass. The compatible-stack live gate passed 2026-07-11 and exposed the expected
|
||||
Wave 4.4/AP-101 selected-stack gap. Wave 4.4a now implements that gap: exact count-first
|
||||
name formatting, authored entry/slider media, one Core split-quantity owner, live
|
||||
stack-size refresh, and selected-source merge amount. The inventory-button and
|
||||
selected-stack visual gates remain pending.
|
||||
|
||||
### 4.4 Finish toolbar controls
|
||||
|
||||
|
|
@ -518,12 +518,24 @@ stack count and has no split slider. The inventory-button live gate remains pend
|
|||
Use, and Examine actions; dispatch panel buttons through the window registry,
|
||||
ghosting unavailable panels rather than constructing placeholders.
|
||||
- [ ] Make open/closed states event-driven from `RetailWindowManager`.
|
||||
- [ ] Complete selected-object item mana, formatted stack count, stack entry and
|
||||
split slider; port `RecvNotice_UpdateItemMana`, `RecvNotice_SplitStack`, and
|
||||
`HandleSelectionChanged` precedence.
|
||||
- [ ] Complete selected-object item mana and port `RecvNotice_UpdateItemMana`.
|
||||
- [x] Complete formatted stack count, stack entry and split slider, the applicable
|
||||
`RecvNotice_SplitStack` activation behavior, and `HandleSelectionChanged` stack
|
||||
precedence. Vendor-specific initial quantity remains naturally dormant until a
|
||||
vendor panel owns an active vendor id.
|
||||
- [ ] Replace the PWD-only health gate with the exact attackable/player/pet policy.
|
||||
- [ ] Add item-mana query/cancel state, health query cancellation, and one shared
|
||||
split-quantity owner for the entry, slider, and item drag payload.
|
||||
- [ ] Add item-mana query/cancel state and health query cancellation.
|
||||
- [x] Add one shared split-quantity owner for the entry, slider, and selected-source
|
||||
item-holder merge payload.
|
||||
|
||||
**Wave 4.4a implementation (2026-07-11):** static cdb against the matching retail
|
||||
binary pinned stack-name `"%d %hs"`, numeric entry `"%d"`, and the slider's exact
|
||||
1000-step truncation formula. `StackSplitQuantityState` is the single Core owner;
|
||||
`SelectedObjectController` binds authored elements `0x100001A3/A4`, and
|
||||
`InventoryController` consumes the value only when the dragged source is selected.
|
||||
The horizontal scrollbar remains the generic retained `UiScrollbar` in scalar mode,
|
||||
using the DAT track/thumb rather than panel-local drawing. The warning-free App Release
|
||||
build and 4,683-test suite are green; the live visual gate follows.
|
||||
|
||||
**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
|
||||
|
|
|
|||
|
|
@ -142,8 +142,27 @@ public static class DatWidgetFactory
|
|||
{
|
||||
SpriteResolve = resolve,
|
||||
TrackSprite = DefaultImage(info),
|
||||
Horizontal = info.Width > info.Height,
|
||||
};
|
||||
|
||||
if (bar.Horizontal)
|
||||
{
|
||||
// gmToolbarUI stack slider (0x100001A4) has two direct Type-3 media
|
||||
// children: a full-width 90x14 track (element 4) and a 16x14 thumb
|
||||
// (element 1). Their geometry, not a button class, defines the roles.
|
||||
ElementInfo? track = info.Children
|
||||
.Where(child => DefaultImage(child) != 0u)
|
||||
.OrderByDescending(child => child.Width)
|
||||
.FirstOrDefault();
|
||||
ElementInfo? scalarThumb = info.Children
|
||||
.Where(child => !ReferenceEquals(child, track) && DefaultImage(child) != 0u)
|
||||
.OrderBy(child => child.Width)
|
||||
.FirstOrDefault();
|
||||
bar.TrackSprite = track is null ? 0u : DefaultImage(track);
|
||||
bar.ThumbSprite = scalarThumb is null ? 0u : DefaultImage(scalarThumb);
|
||||
return bar;
|
||||
}
|
||||
|
||||
uint incrementId = ReferencedElementId(info, 0x77u);
|
||||
uint decrementId = ReferencedElementId(info, 0x78u);
|
||||
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId);
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
private readonly Action<uint, uint, uint>? _sendStackableMerge;
|
||||
private readonly Action<uint, uint>? _notifyMergeAttempt;
|
||||
private readonly ItemInteractionController? _itemInteraction;
|
||||
private readonly StackSplitQuantityState? _stackSplitQuantity;
|
||||
private bool _disposed;
|
||||
|
||||
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
|
||||
|
|
@ -85,7 +86,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
Action<uint, uint, uint>? sendStackableMerge,
|
||||
Action<uint, uint>? notifyMergeAttempt,
|
||||
ItemInteractionController? itemInteraction,
|
||||
Action? onClose)
|
||||
Action? onClose,
|
||||
StackSplitQuantityState? stackSplitQuantity)
|
||||
{
|
||||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
|
|
@ -98,6 +100,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_sendStackableMerge = sendStackableMerge;
|
||||
_notifyMergeAttempt = notifyMergeAttempt;
|
||||
_itemInteraction = itemInteraction;
|
||||
_stackSplitQuantity = stackSplitQuantity;
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
|
||||
WindowChromeController.BindCloseButton(layout, onClose);
|
||||
|
|
@ -206,13 +209,14 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
Action<uint, uint, uint>? sendStackableMerge = null,
|
||||
Action<uint, uint>? notifyMergeAttempt = null,
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
Action? onClose = null)
|
||||
Action? onClose = null,
|
||||
StackSplitQuantityState? stackSplitQuantity = null)
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, selection,
|
||||
ownerName, datFont,
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer,
|
||||
sendStackableMerge, notifyMergeAttempt, itemInteraction,
|
||||
onClose);
|
||||
onClose, stackSplitQuantity);
|
||||
|
||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||
private void OnObjectRemoved(ClientObject o)
|
||||
|
|
@ -463,6 +467,10 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
|| _objects.Get(targetId) is not { } target)
|
||||
return false;
|
||||
|
||||
int requestedAmount = _selection.SelectedObjectId == sourceId
|
||||
&& _stackSplitQuantity is not null
|
||||
? (int)Math.Min(_stackSplitQuantity.Value, int.MaxValue)
|
||||
: Math.Max(1, source.StackSize);
|
||||
StackMergePlan? plan = StackMergePlanner.Plan(
|
||||
new StackMergeItem(
|
||||
source.ObjectId,
|
||||
|
|
@ -477,7 +485,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
target.StackSizeMax,
|
||||
target.TradeState),
|
||||
_itemInteraction?.CanMakeInventoryRequest ?? true,
|
||||
requestedAmount: Math.Max(1, source.StackSize));
|
||||
requestedAmount);
|
||||
if (plan is not { } merge)
|
||||
return false;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
|
@ -53,6 +54,10 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
public const uint OverlayId = 0x100001A0;
|
||||
/// <summary>Selected-object health meter element id (retail m_pSelObjectHealthMeter).</summary>
|
||||
public const uint HealthMeterId = 0x100001A1;
|
||||
/// <summary>Editable stack quantity (retail m_pStackSizeEntryBox).</summary>
|
||||
public const uint StackSizeEntryId = 0x100001A3;
|
||||
/// <summary>Horizontal stack quantity slider (retail m_pStackSizeSlider).</summary>
|
||||
public const uint StackSizeSliderId = 0x100001A4;
|
||||
|
||||
/// <summary>Selection-overlay flash duration — retail's container ObjectSelected state is a
|
||||
/// Pause(0.25s)→Normal transition (toolbar dump, element 0x1000019E).</summary>
|
||||
|
|
@ -75,6 +80,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
private readonly UiElement? _name;
|
||||
private readonly UiDatElement? _overlay;
|
||||
private readonly UiMeter? _healthMeter;
|
||||
private readonly UiField? _stackSizeEntry;
|
||||
private readonly UiScrollbar? _stackSizeSlider;
|
||||
|
||||
// ── Captured delegates ───────────────────────────────────────────────────
|
||||
private readonly Func<uint, bool> _isHealthTarget;
|
||||
|
|
@ -83,8 +90,10 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
private readonly Func<uint, bool> _hasHealth;
|
||||
private readonly Func<uint, uint> _stackSize;
|
||||
private readonly Action<uint> _sendQueryHealth;
|
||||
private readonly StackSplitQuantityState _splitQuantity;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
|
||||
private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated;
|
||||
|
||||
// ── Live state (read by closures on the per-frame draw path) ────────────
|
||||
private uint? _current;
|
||||
|
|
@ -106,7 +115,10 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
Func<uint, bool> hasHealth,
|
||||
Func<uint, uint> stackSize,
|
||||
Action<uint> sendQueryHealth,
|
||||
UiDatFont? datFont)
|
||||
UiDatFont? datFont,
|
||||
StackSplitQuantityState splitQuantity,
|
||||
Action<Action<ClientObject>> subscribeObjectUpdated,
|
||||
Action<Action<ClientObject>> unsubscribeObjectUpdated)
|
||||
{
|
||||
_isHealthTarget = isHealthTarget;
|
||||
_resolveName = name;
|
||||
|
|
@ -114,13 +126,17 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
_hasHealth = hasHealth;
|
||||
_stackSize = stackSize;
|
||||
_sendQueryHealth = sendQueryHealth;
|
||||
_splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity));
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_unsubscribeHealthChanged = unsubscribeHealthChanged;
|
||||
_unsubscribeObjectUpdated = unsubscribeObjectUpdated;
|
||||
|
||||
// Find elements — silently skip absent ones (partial/test layouts).
|
||||
_name = layout.FindElement(NameId);
|
||||
_overlay = layout.FindElement(OverlayId) as UiDatElement;
|
||||
_healthMeter = layout.FindElement(HealthMeterId) as UiMeter;
|
||||
_stackSizeEntry = layout.FindElement(StackSizeEntryId) as UiField;
|
||||
_stackSizeSlider = layout.FindElement(StackSizeSliderId) as UiScrollbar;
|
||||
|
||||
// The selection-flash overlay must draw OVER the health meter (which spans the whole
|
||||
// strip) — otherwise the meter hides the green flash whenever a bar is visible (i.e.
|
||||
|
|
@ -135,6 +151,25 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
_healthMeter.Fill = () => _current is uint g ? _healthPercent(g) : (float?)0f;
|
||||
}
|
||||
|
||||
if (_stackSizeEntry is not null)
|
||||
{
|
||||
_stackSizeEntry.Visible = false;
|
||||
_stackSizeEntry.Selectable = true;
|
||||
_stackSizeEntry.ClearOnSubmit = false;
|
||||
_stackSizeEntry.RecordHistory = false;
|
||||
_stackSizeEntry.CharacterFilter = static c => c is >= '0' and <= '9';
|
||||
_stackSizeEntry.SelectAllOnFocus = true;
|
||||
_stackSizeEntry.OnSubmit = CommitStackEntry;
|
||||
_stackSizeEntry.OnFocusLost = CommitStackEntry;
|
||||
}
|
||||
if (_stackSizeSlider is not null)
|
||||
{
|
||||
_stackSizeSlider.Visible = false;
|
||||
_stackSizeSlider.Horizontal = true;
|
||||
_stackSizeSlider.ScalarValue = () => _splitQuantity.Ratio;
|
||||
_stackSizeSlider.ScalarChanged = _splitQuantity.SetFromSliderRatio;
|
||||
}
|
||||
|
||||
// Attach a centered UiText child to the name element for the object name display.
|
||||
// Mirrors VitalsController.BindMeter's number attach. The name is floated to the
|
||||
// top of the strip's z-order so it draws OVER the overlay frame and the health bar
|
||||
|
|
@ -172,7 +207,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
|
||||
// Register the handlers LAST so the initial state is fully set up first.
|
||||
_selection.Changed += OnSelectionTransition;
|
||||
_splitQuantity.Changed += OnSplitQuantityChanged;
|
||||
subscribeHealthChanged(OnHealthChanged);
|
||||
subscribeObjectUpdated(OnObjectUpdated);
|
||||
if (_selection.SelectedObjectId is { } initial)
|
||||
ApplySelection(initial);
|
||||
}
|
||||
|
|
@ -205,11 +242,15 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
Func<uint, bool> hasHealth,
|
||||
Func<uint, uint> stackSize,
|
||||
Action<uint> sendQueryHealth,
|
||||
UiDatFont? datFont)
|
||||
UiDatFont? datFont,
|
||||
StackSplitQuantityState splitQuantity,
|
||||
Action<Action<ClientObject>> subscribeObjectUpdated,
|
||||
Action<Action<ClientObject>> unsubscribeObjectUpdated)
|
||||
=> new SelectedObjectController(
|
||||
layout, selection,
|
||||
subscribeHealthChanged, unsubscribeHealthChanged,
|
||||
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont);
|
||||
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont,
|
||||
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated);
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
|
||||
|
|
@ -220,6 +261,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
// ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0)
|
||||
// + SetVisible(0) on the meters). ──────────────────────────────────────
|
||||
if (_healthMeter is not null) _healthMeter.Visible = false;
|
||||
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = false;
|
||||
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = false;
|
||||
_splitQuantity.Reset(1u);
|
||||
_currentName = null;
|
||||
_current = guid;
|
||||
|
||||
|
|
@ -234,15 +278,30 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
uint g = guid.Value;
|
||||
|
||||
// ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ──
|
||||
_currentName = _resolveName(g);
|
||||
uint stackSize = _stackSize(g);
|
||||
string? objectName = _resolveName(g);
|
||||
_currentName = stackSize > 1u && !string.IsNullOrEmpty(objectName)
|
||||
? $"{stackSize} {objectName}"
|
||||
: objectName;
|
||||
|
||||
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
|
||||
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
|
||||
SetOverlayState(_stackSize(g) > 1u
|
||||
SetOverlayState(stackSize > 1u
|
||||
? RetailUiStateIds.StackedItemSelected
|
||||
: RetailUiStateIds.ObjectSelected);
|
||||
_flashRemaining = FlashSeconds;
|
||||
|
||||
// gmToolbarUI::HandleSelectionChanged @ 0x004BF52D..0x004BF666:
|
||||
// stacks initialize to the full stack, show the numeric entry + horizontal
|
||||
// slider, and set the stacked selection state. Vendor-owned stack precedence
|
||||
// is intentionally absent until the vendor panel owns an active vendor id.
|
||||
if (stackSize > 1u)
|
||||
{
|
||||
_splitQuantity.Reset(stackSize);
|
||||
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true;
|
||||
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true;
|
||||
}
|
||||
|
||||
// ── 4. Health: query, and show the meter only if real health is already known.
|
||||
// Otherwise the meter appears when OnHealthChanged fires for this guid
|
||||
// (retail RecvNotice_UpdateObjectHealth :196213). ──────────────────────────
|
||||
|
|
@ -279,6 +338,18 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
_overlay?.TrySetRetailState(state);
|
||||
}
|
||||
|
||||
private void CommitStackEntry(string text)
|
||||
=> _splitQuantity.SetFromText(text);
|
||||
|
||||
private void OnSplitQuantityChanged()
|
||||
=> _stackSizeEntry?.SetText(_splitQuantity.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
private void OnObjectUpdated(ClientObject updated)
|
||||
{
|
||||
if (_current == updated.ObjectId && _stackSize(updated.ObjectId) != _splitQuantity.Maximum)
|
||||
ApplySelection(updated.ObjectId);
|
||||
}
|
||||
|
||||
private void OnSelectionTransition(SelectionTransition transition)
|
||||
=> ApplySelection(transition.SelectedObjectId);
|
||||
|
||||
|
|
@ -287,6 +358,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_selection.Changed -= OnSelectionTransition;
|
||||
_splitQuantity.Changed -= OnSplitQuantityChanged;
|
||||
_unsubscribeHealthChanged(OnHealthChanged);
|
||||
_unsubscribeObjectUpdated(OnObjectUpdated);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
// render as stray bars / a black box on the toolbar. Retail hides A3/A4 in
|
||||
// gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198660/198742),
|
||||
// showing them only for a stacked-item selection.
|
||||
private static readonly uint[] HiddenIds = { 0x100001A2, 0x100001A3, 0x100001A4 };
|
||||
private static readonly uint[] HiddenIds = { 0x100001A2 };
|
||||
|
||||
// Four mutually-exclusive combat-mode indicator elements — exactly one visible at a time.
|
||||
// Index 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic.
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ public sealed record RetailUiRuntimeBindings(
|
|||
public sealed class RetailUiRuntime : IDisposable
|
||||
{
|
||||
private readonly RetailUiRuntimeBindings _bindings;
|
||||
private readonly StackSplitQuantityState _stackSplitQuantity = new();
|
||||
private readonly RetailWindowLayoutPersistence? _persistence;
|
||||
private readonly RetailUiAutomationScriptRunner? _automation;
|
||||
private bool _disposed;
|
||||
|
|
@ -383,7 +384,10 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
b.HasHealth,
|
||||
b.StackSize,
|
||||
b.SendQueryHealth,
|
||||
_bindings.Assets.DefaultFont);
|
||||
_bindings.Assets.DefaultFont,
|
||||
_stackSplitQuantity,
|
||||
handler => b.Objects.ObjectUpdated += handler,
|
||||
handler => b.Objects.ObjectUpdated -= handler);
|
||||
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
|
|
@ -597,7 +601,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
|
||||
b.SendPutItemInContainer, b.SendStackableMerge,
|
||||
notifyMergeAttempt, b.ItemInteraction,
|
||||
() => CloseWindow(WindowNames.Inventory));
|
||||
() => CloseWindow(WindowNames.Inventory),
|
||||
_stackSplitQuantity);
|
||||
PaperdollController paperdoll = PaperdollController.Bind(
|
||||
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield,
|
||||
contents, _bindings.Assets.DefaultFont, b.ItemInteraction);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ public sealed class UiField : UiElement
|
|||
public int MaxCharacters { get; set; } = 0xFFFF;
|
||||
public bool OneLine { get; set; } = true;
|
||||
public bool Selectable { get; set; }
|
||||
public bool ClearOnSubmit { get; set; } = true;
|
||||
public bool RecordHistory { get; set; } = true;
|
||||
public Func<char, bool>? CharacterFilter { get; set; }
|
||||
public bool SelectAllOnFocus { get; set; }
|
||||
|
||||
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
|
||||
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
|
||||
|
|
@ -48,6 +52,8 @@ public sealed class UiField : UiElement
|
|||
public uint FocusFieldSprite { get; set; }
|
||||
|
||||
public Action<string>? OnSubmit { get; set; }
|
||||
public Action? OnFocusGained { get; set; }
|
||||
public Action<string>? OnFocusLost { get; set; }
|
||||
|
||||
private string _text = "";
|
||||
private int _caret;
|
||||
|
|
@ -61,6 +67,7 @@ public sealed class UiField : UiElement
|
|||
|
||||
private bool _focused;
|
||||
private bool _selecting; // mouse drag in progress
|
||||
private bool _preserveFocusSelectionOnMouseDown;
|
||||
private float _scrollX; // horizontal pixel scroll so the caret stays in the field
|
||||
|
||||
// Held-key auto-repeat (Silk delivers one KeyDown per physical press).
|
||||
|
|
@ -84,7 +91,7 @@ public sealed class UiField : UiElement
|
|||
|
||||
public void InsertChar(char c)
|
||||
{
|
||||
if (c < 0x20 || c == 0x7F) return;
|
||||
if (c < 0x20 || c == 0x7F || (CharacterFilter is not null && !CharacterFilter(c))) return;
|
||||
DeleteSelection();
|
||||
if (_text.Length >= MaxCharacters) return;
|
||||
_text = _text.Insert(_caret, c.ToString());
|
||||
|
|
@ -149,13 +156,23 @@ public sealed class UiField : UiElement
|
|||
return true;
|
||||
}
|
||||
|
||||
private void SelectAll()
|
||||
public void SelectAllText()
|
||||
{
|
||||
if (_text.Length == 0) { _selAnchor = null; return; }
|
||||
_selAnchor = 0;
|
||||
_caret = _text.Length;
|
||||
}
|
||||
|
||||
public void SetText(string? text)
|
||||
{
|
||||
_text = text ?? "";
|
||||
if (_text.Length > MaxCharacters)
|
||||
_text = _text[..MaxCharacters];
|
||||
_caret = _text.Length;
|
||||
_selAnchor = null;
|
||||
_historyIndex = -1;
|
||||
}
|
||||
|
||||
private void CopySelection()
|
||||
{
|
||||
var s = SelectedText();
|
||||
|
|
@ -179,7 +196,8 @@ public sealed class UiField : UiElement
|
|||
// Single-line field: strip control chars (newlines/tabs) from pasted text.
|
||||
var sb = new System.Text.StringBuilder(clip.Length);
|
||||
foreach (char ch in clip)
|
||||
if (ch >= 0x20 && ch != 0x7F) sb.Append(ch);
|
||||
if (ch >= 0x20 && ch != 0x7F
|
||||
&& (CharacterFilter is null || CharacterFilter(ch))) sb.Append(ch);
|
||||
if (sb.Length == 0) return;
|
||||
|
||||
DeleteSelection();
|
||||
|
|
@ -196,10 +214,14 @@ public sealed class UiField : UiElement
|
|||
public void Submit()
|
||||
{
|
||||
var t = _text;
|
||||
if (t.Trim().Length == 0) { Clear(); return; }
|
||||
if (t.Trim().Length == 0)
|
||||
{
|
||||
if (ClearOnSubmit) Clear();
|
||||
return;
|
||||
}
|
||||
OnSubmit?.Invoke(t);
|
||||
PushHistory(t);
|
||||
Clear();
|
||||
if (RecordHistory) PushHistory(t);
|
||||
if (ClearOnSubmit) Clear();
|
||||
}
|
||||
|
||||
private void Clear() { _text = ""; _caret = 0; _selAnchor = null; _historyIndex = -1; }
|
||||
|
|
@ -370,10 +392,23 @@ public sealed class UiField : UiElement
|
|||
{
|
||||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.FocusGained: _focused = true; return true;
|
||||
case UiEventType.FocusGained:
|
||||
_focused = true;
|
||||
if (SelectAllOnFocus)
|
||||
{
|
||||
SelectAllText();
|
||||
// UiRoot assigns focus before delivering the initiating MouseDown.
|
||||
// Retail's activation notice selects all after that press, so preserve
|
||||
// this selection across the one MouseDown that caused focus.
|
||||
_preserveFocusSelectionOnMouseDown = true;
|
||||
}
|
||||
OnFocusGained?.Invoke();
|
||||
return true;
|
||||
case UiEventType.FocusLost:
|
||||
OnFocusLost?.Invoke(_text);
|
||||
_focused = false; _historyIndex = -1;
|
||||
_selAnchor = null; _selecting = false; _repeatKey = null;
|
||||
_preserveFocusSelectionOnMouseDown = false;
|
||||
return true;
|
||||
|
||||
case UiEventType.Char:
|
||||
|
|
@ -381,6 +416,12 @@ public sealed class UiField : UiElement
|
|||
return true;
|
||||
|
||||
case UiEventType.MouseDown:
|
||||
if (_preserveFocusSelectionOnMouseDown)
|
||||
{
|
||||
_preserveFocusSelectionOnMouseDown = false;
|
||||
_selecting = false;
|
||||
return true;
|
||||
}
|
||||
_caret = HitCharX(e.Data1);
|
||||
_selAnchor = Selectable ? _caret : null;
|
||||
_selecting = Selectable;
|
||||
|
|
@ -403,7 +444,7 @@ public sealed class UiField : UiElement
|
|||
{
|
||||
switch (key)
|
||||
{
|
||||
case Silk.NET.Input.Key.A when Selectable: SelectAll(); return true;
|
||||
case Silk.NET.Input.Key.A when Selectable: SelectAllText(); return true;
|
||||
case Silk.NET.Input.Key.C when Selectable: CopySelection(); return true;
|
||||
case Silk.NET.Input.Key.X when Selectable: CutSelection(); return true;
|
||||
case Silk.NET.Input.Key.V: Paste(); return true;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ public sealed class UiScrollbar : UiElement
|
|||
/// <summary>The scroll model this bar reflects + drives (shared with the transcript).</summary>
|
||||
public UiScrollable? Model { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional scalar mode used by retail's horizontal stack-split control. When set,
|
||||
/// the bar reflects/drives one normalized value rather than a <see cref="UiScrollable"/>.
|
||||
/// </summary>
|
||||
public Func<float>? ScalarValue { get; set; }
|
||||
public Action<float>? ScalarChanged { get; set; }
|
||||
public bool Horizontal { get; set; }
|
||||
|
||||
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
|
|
@ -58,6 +66,7 @@ public sealed class UiScrollbar : UiElement
|
|||
|
||||
private bool _draggingThumb;
|
||||
private float _dragOffsetY;
|
||||
private float _dragOffsetX;
|
||||
|
||||
public UiScrollbar() { CapturesPointerDrag = true; }
|
||||
|
||||
|
|
@ -85,7 +94,18 @@ public sealed class UiScrollbar : UiElement
|
|||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
if (Model is not { } m || SpriteResolve is not { } resolve) return;
|
||||
if (SpriteResolve is not { } resolve) return;
|
||||
if (Horizontal && ScalarValue is not null)
|
||||
{
|
||||
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
|
||||
float thumbWidth = ScalarThumbWidth(resolve);
|
||||
float travel = MathF.Max(0f, Width - thumbWidth);
|
||||
float x = travel * Math.Clamp(ScalarValue(), 0f, 1f);
|
||||
DrawSprite(ctx, resolve, ThumbSprite, x, 0f, thumbWidth, Height);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Model is not { } m) return;
|
||||
|
||||
// Track background — TILED vertically (retail DrawMode=Normal). The native track
|
||||
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
|
||||
|
|
@ -142,6 +162,9 @@ public sealed class UiScrollbar : UiElement
|
|||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (Horizontal && ScalarValue is not null && ScalarChanged is not null)
|
||||
return OnScalarEvent(e);
|
||||
|
||||
if (Model is not { } m) return false;
|
||||
|
||||
switch (e.Type)
|
||||
|
|
@ -196,4 +219,53 @@ public sealed class UiScrollbar : UiElement
|
|||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool OnScalarEvent(in UiEvent e)
|
||||
{
|
||||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.MouseDown:
|
||||
{
|
||||
float thumbWidth = ScalarThumbWidth(SpriteResolve);
|
||||
float travel = MathF.Max(1f, Width - thumbWidth);
|
||||
float thumbX = travel * Math.Clamp(ScalarValue!(), 0f, 1f);
|
||||
float x = e.Data1;
|
||||
if (x >= thumbX && x <= thumbX + thumbWidth)
|
||||
{
|
||||
_dragOffsetX = x - thumbX;
|
||||
}
|
||||
else
|
||||
{
|
||||
_dragOffsetX = thumbWidth * 0.5f;
|
||||
ScalarChanged!(Math.Clamp((x - _dragOffsetX) / travel, 0f, 1f));
|
||||
}
|
||||
_draggingThumb = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseMove when _draggingThumb:
|
||||
{
|
||||
float thumbWidth = ScalarThumbWidth(SpriteResolve);
|
||||
float travel = MathF.Max(1f, Width - thumbWidth);
|
||||
ScalarChanged!(Math.Clamp(((float)e.Data1 - _dragOffsetX) / travel, 0f, 1f));
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseUp:
|
||||
_draggingThumb = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private float ScalarThumbWidth(Func<uint, (uint tex, int w, int h)>? resolve)
|
||||
{
|
||||
if (resolve is not null && ThumbSprite != 0)
|
||||
{
|
||||
var (_, width, _) = resolve(ThumbSprite);
|
||||
if (width > 0) return MathF.Min(width, Width);
|
||||
}
|
||||
return MathF.Min(16f, Width);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
src/AcDream.Core/Items/StackSplitQuantityState.cs
Normal file
62
src/AcDream.Core/Items/StackSplitQuantityState.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Shared owner for retail <c>GenItemHolder::splitSize</c> and
|
||||
/// <c>GenItemHolder::maxSplitSize</c>. The toolbar entry/slider edits this state and
|
||||
/// item-holder operations consume the same value.
|
||||
/// </summary>
|
||||
public sealed class StackSplitQuantityState
|
||||
{
|
||||
public uint Value { get; private set; } = 1u;
|
||||
public uint Maximum { get; private set; } = 1u;
|
||||
public float Ratio => Value / (float)Maximum;
|
||||
|
||||
public event Action? Changed;
|
||||
|
||||
public void Reset(uint stackSize, uint? initialValue = null)
|
||||
{
|
||||
uint maximum = Math.Max(stackSize, 1u);
|
||||
Set(initialValue ?? maximum, maximum);
|
||||
}
|
||||
|
||||
public void SetFromText(string? text)
|
||||
{
|
||||
// Retail uses wcstoul, whose invalid/empty result is zero, then clamps to one.
|
||||
uint parsed = uint.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out uint value)
|
||||
? value
|
||||
: 0u;
|
||||
SetValue(parsed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ports <c>gmToolbarUI::ListenToElementMessage @ 0x004BEFE0</c> and
|
||||
/// <c>UIElement_Scrollbar::SetScrollbarPosition @ 0x00470EC0</c>. The scrollbar
|
||||
/// first truncates its normalized position to an integer in [0,1000], then the
|
||||
/// toolbar converts it to <c>1 + floor(position * max / 1000)</c> and clamps.
|
||||
/// </summary>
|
||||
public void SetFromSliderRatio(float ratio)
|
||||
{
|
||||
float clamped = Math.Clamp(ratio, 0f, 1f);
|
||||
uint positionMillis = (uint)MathF.Truncate(clamped * 1000f);
|
||||
ulong scaled = (ulong)positionMillis * Maximum;
|
||||
uint value = 1u + (uint)(scaled / 1000u);
|
||||
SetValue(value);
|
||||
}
|
||||
|
||||
public void SetValue(uint value) => Set(value, Maximum);
|
||||
|
||||
private void Set(uint value, uint maximum)
|
||||
{
|
||||
maximum = Math.Max(maximum, 1u);
|
||||
value = Math.Clamp(value, 1u, maximum);
|
||||
if (Value == value && Maximum == maximum)
|
||||
return;
|
||||
|
||||
Value = value;
|
||||
Maximum = maximum;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
|
@ -199,6 +199,42 @@ public class DatWidgetFactoryTests
|
|||
Assert.IsType<UiScrollbar>(e);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Type11_HorizontalScrollbar_usesDirectRetailTrackAndThumbChildren()
|
||||
{
|
||||
const uint Track = 0x06004CF6u;
|
||||
const uint Thumb = 0x06005DC3u;
|
||||
var info = new ElementInfo { Type = 11, Id = 0x100001A4u, Width = 90, Height = 14 };
|
||||
var track = new ElementInfo { Id = 4u, Type = 3u, Width = 90, Height = 14 };
|
||||
track.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
||||
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(Track, 1) };
|
||||
var thumb = new ElementInfo { Id = 1u, Type = 3u, Width = 16, Height = 14 };
|
||||
thumb.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
||||
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(Thumb, 1) };
|
||||
info.Children.Add(track);
|
||||
info.Children.Add(thumb);
|
||||
|
||||
var bar = Assert.IsType<UiScrollbar>(DatWidgetFactory.Create(info, NoTex, null));
|
||||
|
||||
Assert.True(bar.Horizontal);
|
||||
Assert.Equal(Track, bar.TrackSprite);
|
||||
Assert.Equal(Thumb, bar.ThumbSprite);
|
||||
Assert.Equal(0u, bar.UpSprite);
|
||||
Assert.Equal(0u, bar.DownSprite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailToolbarFixture_buildsEditableStackEntry_andAuthoredHorizontalSlider()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadToolbar();
|
||||
|
||||
Assert.IsType<UiField>(layout.FindElement(0x100001A3u));
|
||||
var bar = Assert.IsType<UiScrollbar>(layout.FindElement(0x100001A4u));
|
||||
Assert.True(bar.Horizontal);
|
||||
Assert.Equal(0x06004CF6u, bar.TrackSprite);
|
||||
Assert.Equal(0x06005DC3u, bar.ThumbSprite);
|
||||
}
|
||||
|
||||
// ── Test 5e: Type 3 is NOT registered — chrome/containers stay generic ────
|
||||
//
|
||||
// Retail Type 3 = UIElement_Field, but acdream's Type-3 dat elements (vitals/chat
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ public class InventoryControllerTests
|
|||
List<(uint source, uint target)>? mergeNotices = null,
|
||||
string? ownerName = null,
|
||||
Action? onClose = null,
|
||||
SelectionState? selection = null)
|
||||
SelectionState? selection = null,
|
||||
StackSplitQuantityState? stackSplitQuantity = null)
|
||||
=> InventoryController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
strength: () => strength, datFont: null,
|
||||
|
|
@ -71,7 +72,8 @@ public class InventoryControllerTests
|
|||
sendStackableMerge: merges is null ? null : (s, t, a) => merges.Add((s, t, a)),
|
||||
notifyMergeAttempt: mergeNotices is null ? null : (s, t) => mergeNotices.Add((s, t)),
|
||||
onClose: onClose,
|
||||
selection: selection ?? new SelectionState());
|
||||
selection: selection ?? new SelectionState(),
|
||||
stackSplitQuantity: stackSplitQuantity);
|
||||
|
||||
private static UiButton MakeButton(uint id)
|
||||
{
|
||||
|
|
@ -604,6 +606,35 @@ public class InventoryControllerTests
|
|||
Assert.Equal(0xBu, selection.SelectedObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_selectedStack_usesSharedToolbarSplitQuantity()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0xA, WeenieClassId = 0x1234, StackSize = 40, StackSizeMax = 100,
|
||||
});
|
||||
objects.MoveItem(0xA, Player, 0);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0xB, WeenieClassId = 0x1234, StackSize = 50, StackSizeMax = 100,
|
||||
});
|
||||
objects.MoveItem(0xB, Player, 1);
|
||||
var selection = new SelectionState();
|
||||
selection.Select(0xAu, SelectionChangeSource.Inventory);
|
||||
var split = new StackSplitQuantityState();
|
||||
split.Reset(40u);
|
||||
split.SetValue(7u);
|
||||
var merges = new List<(uint source, uint target, uint amount)>();
|
||||
var ctrl = Bind(layout, objects, merges: merges, selection: selection,
|
||||
stackSplitQuantity: split);
|
||||
|
||||
ctrl.HandleDropRelease(grid, grid.GetItem(1)!, Payload(0xAu));
|
||||
|
||||
Assert.Equal(new[] { (0xAu, 0xBu, 7u) }, merges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_differentStackType_fallsThroughToNormalInventoryInsert()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using System.Linq;
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -59,6 +60,14 @@ public class SelectedObjectControllerTests
|
|||
dict[SelectedObjectController.HealthMeterId] = healthMeterEl;
|
||||
root.AddChild(healthMeterEl);
|
||||
|
||||
var stackEntry = new UiField { Width = 50, Height = 14, Visible = true };
|
||||
dict[SelectedObjectController.StackSizeEntryId] = stackEntry;
|
||||
root.AddChild(stackEntry);
|
||||
|
||||
var stackSlider = new UiScrollbar { Width = 90, Height = 14, Visible = true };
|
||||
dict[SelectedObjectController.StackSizeSliderId] = stackSlider;
|
||||
root.AddChild(stackSlider);
|
||||
|
||||
return (new ImportedLayout(root, dict), nameEl, overlayEl, healthMeterEl);
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +76,9 @@ public class SelectedObjectControllerTests
|
|||
private sealed class Harness
|
||||
{
|
||||
public readonly SelectionState Selection = new();
|
||||
public readonly StackSplitQuantityState SplitQuantity = new();
|
||||
public Action<uint, float>? HealthHandler;
|
||||
public Action<ClientObject>? ObjectUpdatedHandler;
|
||||
public readonly List<uint> QueryHealthCalls = new();
|
||||
|
||||
public readonly Dictionary<uint, bool> HealthTargetMap = new();
|
||||
|
|
@ -100,7 +111,13 @@ public class SelectedObjectControllerTests
|
|||
hasHealth: g => HasHealthMap.TryGetValue(g, out var v) && v,
|
||||
stackSize: g => StackMap.TryGetValue(g, out var v) ? v : 0u,
|
||||
sendQueryHealth: g => QueryHealthCalls.Add(g),
|
||||
datFont: datFont);
|
||||
datFont: datFont,
|
||||
splitQuantity: SplitQuantity,
|
||||
subscribeObjectUpdated: h => ObjectUpdatedHandler = h,
|
||||
unsubscribeObjectUpdated: h =>
|
||||
{
|
||||
if (ObjectUpdatedHandler == h) ObjectUpdatedHandler = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ── B1: Bind initialisation ──────────────────────────────────────────────
|
||||
|
|
@ -247,6 +264,58 @@ public class SelectedObjectControllerTests
|
|||
Assert.False(healthMeterEl.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectStackedItem_showsRetailCountEntryAndSlider_atFullStack()
|
||||
{
|
||||
const uint Guid = 0xBB03u;
|
||||
var (layout, nameEl, _, _) = FakeLayout();
|
||||
var h = new Harness();
|
||||
h.NameMap[Guid] = "Healing Kits";
|
||||
h.StackMap[Guid] = 17u;
|
||||
h.Bind(layout);
|
||||
|
||||
h.FireSelection(Guid);
|
||||
|
||||
var entry = Assert.IsType<UiField>(layout.FindElement(SelectedObjectController.StackSizeEntryId));
|
||||
var slider = Assert.IsType<UiScrollbar>(layout.FindElement(SelectedObjectController.StackSizeSliderId));
|
||||
Assert.True(entry.Visible);
|
||||
Assert.True(slider.Visible);
|
||||
Assert.Equal("17", entry.Text);
|
||||
Assert.Equal(17u, h.SplitQuantity.Value);
|
||||
Assert.Equal(17u, h.SplitQuantity.Maximum);
|
||||
Assert.Equal(1f, slider.ScalarValue!());
|
||||
Assert.Equal("17 Healing Kits", nameEl.Children.OfType<UiText>().Single().LinesProvider().Single().Text);
|
||||
|
||||
slider.ScalarChanged!(0.5f);
|
||||
Assert.Equal(9u, h.SplitQuantity.Value);
|
||||
Assert.Equal("9", entry.Text);
|
||||
|
||||
entry.SetText("999");
|
||||
entry.Submit();
|
||||
Assert.Equal(17u, h.SplitQuantity.Value);
|
||||
Assert.Equal("17", entry.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StackSizeUpdate_refreshesSelectedControls_andHidesThemAtOne()
|
||||
{
|
||||
const uint Guid = 0xBB04u;
|
||||
var (layout, _, _, _) = FakeLayout();
|
||||
var h = new Harness();
|
||||
h.NameMap[Guid] = "Healing Kits";
|
||||
h.StackMap[Guid] = 5u;
|
||||
h.Bind(layout);
|
||||
h.FireSelection(Guid);
|
||||
|
||||
h.StackMap[Guid] = 1u;
|
||||
h.ObjectUpdatedHandler!(new ClientObject { ObjectId = Guid, StackSize = 1 });
|
||||
|
||||
Assert.False(layout.FindElement(SelectedObjectController.StackSizeEntryId)!.Visible);
|
||||
Assert.False(layout.FindElement(SelectedObjectController.StackSizeSliderId)!.Visible);
|
||||
Assert.Equal(1u, h.SplitQuantity.Value);
|
||||
Assert.Equal(1u, h.SplitQuantity.Maximum);
|
||||
}
|
||||
|
||||
// ── H3: Non-health target (friendly NPC / scenery / Door) ───────────────
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -69,4 +69,29 @@ public class UiFieldTests
|
|||
for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); }
|
||||
Assert.True(input.HistoryCount <= 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterFilter_rejectsDisallowedInput()
|
||||
{
|
||||
var input = new UiField { CharacterFilter = static c => char.IsAsciiDigit(c) };
|
||||
|
||||
input.InsertChar('4');
|
||||
input.InsertChar('x');
|
||||
input.InsertChar('2');
|
||||
|
||||
Assert.Equal("42", input.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectAllOnFocus_survivesInitiatingMouseDown_andTypingReplacesValue()
|
||||
{
|
||||
var input = new UiField { SelectAllOnFocus = true, Selectable = true };
|
||||
input.SetText("17");
|
||||
|
||||
input.OnEvent(new UiEvent(0u, input, UiEventType.FocusGained));
|
||||
input.OnEvent(new UiEvent(0u, input, UiEventType.MouseDown, Data1: 2));
|
||||
input.InsertChar('5');
|
||||
|
||||
Assert.Equal("5", input.Text);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,4 +78,26 @@ public class UiScrollbarTests
|
|||
Assert.Equal(100f, h, 3f);
|
||||
Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HorizontalScalar_clickAndDrag_updatesNormalizedValue()
|
||||
{
|
||||
float value = 1f;
|
||||
var bar = new UiScrollbar
|
||||
{
|
||||
Width = 90f,
|
||||
Height = 14f,
|
||||
Horizontal = true,
|
||||
ScalarValue = () => value,
|
||||
ScalarChanged = next => value = next,
|
||||
};
|
||||
|
||||
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8)));
|
||||
Assert.Equal(0f, value, 3);
|
||||
|
||||
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 45)));
|
||||
Assert.Equal(0.5f, value, 3);
|
||||
|
||||
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public sealed class StackSplitQuantityStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Reset_initializesToFullStack_andClampsZeroMaximumToOne()
|
||||
{
|
||||
var state = new StackSplitQuantityState();
|
||||
|
||||
state.Reset(17u);
|
||||
Assert.Equal(17u, state.Value);
|
||||
Assert.Equal(17u, state.Maximum);
|
||||
Assert.Equal(1f, state.Ratio);
|
||||
|
||||
state.Reset(0u);
|
||||
Assert.Equal(1u, state.Value);
|
||||
Assert.Equal(1u, state.Maximum);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 1u)]
|
||||
[InlineData("", 1u)]
|
||||
[InlineData("invalid", 1u)]
|
||||
[InlineData("0", 1u)]
|
||||
[InlineData("7", 7u)]
|
||||
[InlineData("999", 10u)]
|
||||
public void Text_usesWcstoulStyleParse_thenClamps(string? text, uint expected)
|
||||
{
|
||||
var state = new StackSplitQuantityState();
|
||||
state.Reset(10u);
|
||||
|
||||
state.SetFromText(text);
|
||||
|
||||
Assert.Equal(expected, state.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1f, 1u)]
|
||||
[InlineData(0f, 1u)]
|
||||
[InlineData(0.099f, 1u)]
|
||||
[InlineData(0.1f, 2u)]
|
||||
[InlineData(0.5f, 6u)]
|
||||
[InlineData(0.999f, 10u)]
|
||||
[InlineData(1f, 10u)]
|
||||
[InlineData(2f, 10u)]
|
||||
public void Slider_matchesRetailThousandStepConversion(float ratio, uint expected)
|
||||
{
|
||||
var state = new StackSplitQuantityState();
|
||||
state.Reset(10u);
|
||||
|
||||
state.SetFromSliderRatio(ratio);
|
||||
|
||||
Assert.Equal(expected, state.Value);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue