merge(vt): bring in retail scrollbar chrome + resizable/anchor markup
Merges claude/latest-main-sync-497549 into the slice-7 panel worktree so round D can build on both:b71a8ea37(retail scrollbar chrome on plain <menu> popups and overflowing <list>s — chat/inventory sprite ids, always-scrollable single-column menu) and2e63391cc(<panel resizable minw minh> + anchor="left top right bottom" markup). Conflicts resolved keeping both intents: - Ledger (docs/plans/2026-09-07-campaign-vt-slice7-tabs.md): unioned both branches' entries into one chronological timeline instead of picking a side. - docs/plugin-ui-markup.md: kept both attribute additions per element (slider min/max/style, menu scroll/style) AND anchor on every row. - src/AcDream.App/UI/UiMarkupList.cs: kept this branch's fix round B item 10 (VVS HudList grids have no row-selection highlight) over the sync branch's older SelectedColor band draw in the <column> grid path — the legacy single-column list path is unaffected either way. - src/AcDream.App/UI/MarkupDocument.cs: the auto-merge left two `Scrollable =` initializers on the same <menu> object (CS1912). Kept the sync branch's `Scrollable = true` (VTank's HudCombo is always a single scrolling column, never a wrapping grid) and dropped this branch's `Scrollable = B(el, "scroll", false)` opt-in, since the owner-driven always-scrollable design supersedes the S7.2 opt-in one. Updated MarkupDocumentTests.cs to match: removed Build_MenuWithNoScrollAttribute_KeepsScrollableFalse (asserted the now-false opt-in default) and Menu_Scroll_DrawsAPlainFlatThumbFillWhenTheMarkupItemCountOverflowsTheVisibleRows (asserted a flat DrawFill thumb; the scrollbar is sprite-chrome for every menu style now) — both fully superseded by Menu_Markup_IsAlwaysScrollable_WithRetailScrollbarChromeWired and UiMenuPlainStyleTests.Plain_OpenPopup_ScrollableOverflow_ DrawsRetailScrollbarChrome_RowsStayPlain. Verified: dotnet build AcDream.slnx -c Release green; MossTank suite 678/678; App markup/plugin filter 242/242 (241 before this commit's test-file trim, +1 net from the merge's own new tests, 0 red). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
4ba0a557f6
14 changed files with 1431 additions and 166 deletions
|
|
@ -74,6 +74,22 @@ after each deliberate `Top` write for the imported-layout element. Precedent:
|
||||||
`MapPageController.cs:235-249` (the same fix already landed for other
|
`MapPageController.cs:235-249` (the same fix already landed for other
|
||||||
runtime-repositioned imported/programmatic elements).
|
runtime-repositioned imported/programmatic elements).
|
||||||
|
|
||||||
|
## #491 — MossTank extra/blacklisted buff lists are shown but not consumed by BuffPlan.Build
|
||||||
|
|
||||||
|
**Status:** OPEN — found 2026-09-07 at the slice 7 architecture review.
|
||||||
|
**Severity:** LOW (honest UI after fix round B; behavior owed)
|
||||||
|
**Component:** `src/AcDream.Plugins.MossTank/BuffPlan.cs` (`Build`), `BuffSettings.ExtraBuffSpellNames`/`BlacklistedBuffFamilyNames`
|
||||||
|
|
||||||
|
**Description.** Slice 7 added VTank's "Extra Buff Spells" and "Blacklisted Buff Families" lists to the Buffs tab. They are stored (persisted after fix round B) but `BuffPlan.Build` never reads them: extra exemplars are not cast, blacklisted families are not skipped. Belongs to VT2 slice 4 (buffs); wire `Build` to add the "best similar" spell per extra exemplar and to skip blacklisted families, with plan tests.
|
||||||
|
|
||||||
|
## #490 — Plugin panel host: `StartVisible=false` + `ShowInSidePanel=false` is permanently unshowable; layout persistence has no revision bump
|
||||||
|
|
||||||
|
**Status:** OPEN — found live 2026-09-07 at slice 7 fix round A (`78b42a519`), workaround in the plugin.
|
||||||
|
**Severity:** LOW/MEDIUM (host defect; silent)
|
||||||
|
**Component:** `src/AcDream.App/UI/RetailUiRuntime.cs` (~5735-5757, `PluginWindowVisibilityController`), `RetailWindowLayoutPersistence`
|
||||||
|
|
||||||
|
**Description.** (1) `_requestedVisible = startVisible` and only `OnShown()` ever sets it true; a window registered with `ShowInSidePanel=false` has no shelf entry to raise `OnShown`, so `StartVisible=false` can never become visible, with no error. MossTank works around it by registering its popups `StartVisible=true` and gating on its own visibility bindings. Fix shape: validate the combination at `AddPanel` (throw, or coerce). (2) Plugin windows restore a stale persisted position over a changed authored default with no revision bump — the slice 7 screenshots needed an isolated `ACDREAM_CONFIG_DIR` to open at the authored 28,42. Fix shape: key the persisted layout by the panel's authored geometry hash (or a plugin-declared layout revision) so a redesign resets the stored position once.
|
||||||
|
|
||||||
## #489 — Headless: SpewBox pending queue grows unbounded when no console ticks it; console polish
|
## #489 — Headless: SpewBox pending queue grows unbounded when no console ticks it; console polish
|
||||||
|
|
||||||
**Status:** OPEN — found 2026-09-07 by the Opus re-check of the headless console (`738111239`).
|
**Status:** OPEN — found 2026-09-07 by the Opus re-check of the headless console (`738111239`).
|
||||||
|
|
|
||||||
|
|
@ -86,3 +86,4 @@ Linux-clean by construction; the Ubuntu CI closure runs the MossTank tests.
|
||||||
- 2026-09-06 VT0 complete. VT1 docs 01/02/03/08 dispatched first, 04–07/09 next.
|
- 2026-09-06 VT0 complete. VT1 docs 01/02/03/08 dispatched first, 04–07/09 next.
|
||||||
- 2026-09-06 VT1: all nine catalog docs landed; index written; Opus citation pass in flight.
|
- 2026-09-06 VT1: all nine catalog docs landed; index written; Opus citation pass in flight.
|
||||||
- 2026-09-06 Files decision amended by the owner: metas and navs are metaf `.af`, not `.met`/`.nav` (see Decisions). Docs 06 §1 and 07 §1 stay as the binary-format record (they describe what the metaf tool converts from); the semantics sections (06 §2-5, 07 §2-4) remain the behavior oracle. VT2 doc: `2026-09-06-campaign-vt-vt2-gap-audit.md`; slice-1 contract: `2026-09-06-campaign-vt-slice1-files-and-columns.md`.
|
- 2026-09-06 Files decision amended by the owner: metas and navs are metaf `.af`, not `.met`/`.nav` (see Decisions). Docs 06 §1 and 07 §1 stay as the binary-format record (they describe what the metaf tool converts from); the semantics sections (06 §2-5, 07 §2-4) remain the behavior oracle. VT2 doc: `2026-09-06-campaign-vt-vt2-gap-audit.md`; slice-1 contract: `2026-09-06-campaign-vt-slice1-files-and-columns.md`.
|
||||||
|
- 2026-09-07 owner asked whether every VTank format should get a human-readable twin like `.af`. Lead's answer, recorded as a post-campaign idea: VTank's `.usd`/`.ugd`/`.utl`/`.met` are already text (the one-value-per-line table grammar) and `.nav` is plain lines; only metas/navs had a community-authored readable format (metaf) with a converter both ways, which is why `.af` is safe as storage. For settings/loot a second format would be our own invention with two sources of truth. Instead: a one-way readable EXPORT + a profile DIFF command (`/vt settings export`, `key = value` lines) for inspection and version control, VTank's format staying the only storage. Revisit only if hand-editing loot rules becomes routine.
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,10 @@ re-review, merge to the campaign branch, then the owner's visual gate.
|
||||||
- 2026-09-07 09:10 S7.3 Monsters landed on the panel worktree (`57ced0aff`, `c3b4f7862`; MossTank suite 645 → 651): the 23-column grid with VTank's exact cycle lists (P −1…4; Dmg type 14 values; Ex. Vuln 9; PetDmg 10; name click deletes; arrows reorder with DEFAULT pinned). Implementer deviations for the review: Weapon/Offhand cycle MossTank's registered item roster instead of VTank's opaque weapon-type ids (MossTank models concrete owned items); the move-up/down DEFAULT guard is symmetric. Fix round A (grid scaling, Profiles leftovers, 260-tall window, Advanced Options / Loot Editor as their own panels, blank trailing slots, fresh screenshots) dispatched on the same worktree after merging the plain-menu style in. S7.4–S7.6 follow.
|
- 2026-09-07 09:10 S7.3 Monsters landed on the panel worktree (`57ced0aff`, `c3b4f7862`; MossTank suite 645 → 651): the 23-column grid with VTank's exact cycle lists (P −1…4; Dmg type 14 values; Ex. Vuln 9; PetDmg 10; name click deletes; arrows reorder with DEFAULT pinned). Implementer deviations for the review: Weapon/Offhand cycle MossTank's registered item roster instead of VTank's opaque weapon-type ids (MossTank models concrete owned items); the move-up/down DEFAULT guard is symmetric. Fix round A (grid scaling, Profiles leftovers, 260-tall window, Advanced Options / Loot Editor as their own panels, blank trailing slots, fresh screenshots) dispatched on the same worktree after merging the plain-menu style in. S7.4–S7.6 follow.
|
||||||
- 2026-09-07 10:10 fix round A landed on the panel worktree (`045cd0a19` merge of the plain menu, `565a33d78` column shifts + Profiles cleanup + 236-tall window + popup panel files, `e414b2f56` csproj plugin-copy fix, `78b42a519` popups actually render (`StartVisible` gotcha) + fresh screenshots, `66b070def` ledger; MossTank suite 651 → 654). Owner's two complaints verified fixed on the new screenshots. Deviation for the review: Macro/Nav CopyTo lost their in-UI target-name field with the deleted block (VTank has none either). S7.4–S7.6 dispatched on the same worktree.
|
- 2026-09-07 10:10 fix round A landed on the panel worktree (`045cd0a19` merge of the plain menu, `565a33d78` column shifts + Profiles cleanup + 236-tall window + popup panel files, `e414b2f56` csproj plugin-copy fix, `78b42a519` popups actually render (`StartVisible` gotcha) + fresh screenshots, `66b070def` ledger; MossTank suite 651 → 654). Owner's two complaints verified fixed on the new screenshots. Deviation for the review: Macro/Nav CopyTo lost their in-UI target-name field with the deleted block (VTank has none either). S7.4–S7.6 dispatched on the same worktree.
|
||||||
- 2026-09-07 10:20 owner, live: "Drop down menus look horrible, there is also a checkmark on the text there." — the OPEN popup still draws retail art (tan gradient panel, ornate gold scrollbar, checkmark on the selected row). Plain open state (dark list rows, selected fill, plain scrollbar, no checkmark) dispatched on the plain-menu worktree; merges to the campaign branch, then into the panel worktree at fix round B.
|
- 2026-09-07 10:20 owner, live: "Drop down menus look horrible, there is also a checkmark on the text there." — the OPEN popup still draws retail art (tan gradient panel, ornate gold scrollbar, checkmark on the selected row). Plain open state (dark list rows, selected fill, plain scrollbar, no checkmark) dispatched on the plain-menu worktree; merges to the campaign branch, then into the panel worktree at fix round B.
|
||||||
- 2026-09-07 fix round B item 16 landed on the panel worktree at `8d3c6ad7c` (dedupe, tab-switch popup cleanup, slider validation).
|
- 2026-09-07 11:20 S7.4–S7.6 landed on the panel worktree (`f5409530f` Items/Consumables/Buffs + buff picker popup, `6118062a7` Route grid, `cc323f6a5` Meta grid, `41fc1d88d` screenshots; MossTank suite 654 → 660); the plain popup merged in at `dbdde0783`. Both Opus lenses dispatched on the whole slice. Lead's read of the new Route and Meta screenshots for fix round B: VTank's Route tab is the waypoint list + one small row under it (nav-type menu, insert-mode menu, three icon buttons) + the right-hand Add grid — ours still carries MossTank-only controls on the tab (Checkpoint / Jump / Remove / Set Follow Target / Follow target / Nav Priority / Follow Corners / Open Doors / Use NPC / Follow-Nav Min Distance spinners; "Add to End" rendered as a button instead of the insert-mode menu) that VTank does not show there — move them out (Advanced Options popup, or drop what VTank has no equivalent for). VTank's Meta tab is the six-column grid + Create + "Current State:" menu — ours adds a profile row (By char / New / CopyTo / Clear / Delete / Enable Meta / State) that belongs on Profiles, and an INLINE rule editor (state, condition/action menus, three fields, N/N2 spinners, Apply/Add/Remove, arrows) where VTank opens a separate rule-editor view on click — make it a popup panel like the buff picker. Implementer deviations to judge: Hands column session-local; extra/blacklisted buff names stored but not consumed by the plan; recall menu 4 kinds vs VTank's 27; "Select Nearest Point" moves the edit selection; Meta delete cell is a text "X".
|
||||||
- 2026-09-07 fix round C (architecture re-check of fix round B `8d3c6ad7c`) landed on the panel worktree, seven commits: `466fac426` (D1 — Advanced Options popup's four bindings, previously re-filtering the 163-entry VtankOptionCatalog and re-running GetMetaOption/ToDisplayString per row on every draw, materialized once by a new RefreshAdvancedOptions() called from category toggle/edit-apply/selection-change/profile-load), `23d4376cc` (D4 — AdvancedOptionCategoryEnabled no longer hands out the mutable backing bool[] directly; a ReadOnlyCollection view built once over the same array), `f15667db5` (D2 — `/mt refresh` now calls RefreshMonsterEditor after EnsureDefaultMonsterRule, the one gap found auditing every `_combatSettings.Rules` mutation site in the plugin), `3b8d02194` (D3 — AssertWithinParent's bottom-band check now counts each widget kind's own implicit default height when `h` is absent, label/field 16 toggle 20 button 16, instead of reading an absent `h` as zero), `37055bddc` (F7+F12 — ItemHandsColumn materialized once in RefreshItemEditors from the SAME base-name array that builds the decorated `_itemRows`, replacing a per-frame `.Select().ToArray()` plus a suffix-reparsing BaseItemName helper with one forward-only decoration; CycleItemHandsAtCore now refreshes the cache it mutates), `0724761ad` (F3 — SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves discovers popup files with the same glob the sibling-overlap and arrow-glyph tests already use instead of a hand-maintained InlineData list), `d20ad47c5` (F4 — the ExtraBuffSpellNames/BlacklistedBuffFamilyNames field comments and their two Buffs-tab tooltips now cite issue #491). D3's grep audit of every real `mosstank*.xml` file found no label/toggle/field/button missing `h`, so the stricter bottom-band check introduced zero new failures against shipped markup. Every new/changed pin was shown to fail against a targeted mutation (property/getter reverted, or the assertion target broken) before the fix was restored; D1's own fix broke one PRE-EXISTING test (`AdvancedOptionValueColumnMirrorsTheLiveSettingValue`, which expected a setting toggled OUTSIDE the popup's own mutators to appear on the very next read) — updated it to exercise the "selection change" mutator afterward, with a comment explaining the narrowed contract. MossTank suite 677 (D1) → 678 (D4) → 679 (D2) → 680 (D3) → 681 (F7+F12) → 678 (F3, four InlineData cases collapsed into one Fact, no coverage loss) → 678 (F4, comment/tooltip only) → 678 (F13, no new test, an assertion removed). Full solution build green after every commit; App markup/plugin filter held 203/203 throughout.
|
- 2026-09-07 12:00 both Opus lenses on `dbdde0783`. Resemblance: APPROVE-WITH-FIXES — Options, Vitals and the buff picker read as VTank; Monsters one header fix away; Buffs (VTank's two lists shrunk for a MossTank block, a new "+"/"Add…" overlap) and Meta (grid buried under a toolbar and an inline editor; "Current State:" not settable) do not read as VTank; clipped status lines on three tabs + the Loot Editor; "CopyTo" overflowing its button; Route's grid re-flowed 3-across with "Add NPC Talk" renamed and the nav icons moved; the gold slider track; the 4-vs-27 recall menu is a data hole (slice 6). Owner complaints 1 and 2 closed on the screenshots; 3 closed in code but needs a dropdown-open re-capture. Architecture: APPROVE-WITH-FIXES — one RED App test at HEAD (the `<menu scroll>` sprite pin invalidated by the plain popup merge; the ledger's 192/192 was stale), `ExcludedComponentIcons` captures the whole inventory per row per frame, derived columns allocate per frame, the markup file list hardcoded in four places, the buff lists look live but are neither consumed nor persisted, two host defects found live (filed as #490), the `MarkupDocument.Build`-over-real-files seam untested. Lead decisions for round B: VTank's own controls never shrunk/moved for MossTank extras (extras → Advanced Options popup or dropped); plain sliders; no grid selection band; the Meta rule editor as its own popup; the four Deletes stay, aligned. #491 filed for the buff-list wiring (slice 4). Fix round B (18 items) dispatched.
|
||||||
- 2026-09-07 fix round C item F13 folded the `EveryInteractiveControlDeclaresARealHandlerBinding` test's exact-control-count history here, in the same commit that removed it from the test file: Round 3 item 10 added +3 for the Route/Loot/Meta Delete buttons (Settings already had one from round 2 step 5). Campaign VT slice 7 S7.1/S7.2 transcribed Options/Profiles/Vitals control-for-control from VTank's own tabs (docs/research/vtank-kb/08-ui-views.md §1) and repositioned the Advanced Options/Loot Editor popups to VTank's exact 392x300/268x300 geometry — net 194 → 205. The Options tab gained four direct checkboxes for settings that already existed but were only reachable through the generic Advanced Options key-value editor (Enable Autostack, Fastcast Buffs, Don't Shoot at Walls, Fallback Debuffs if Blocked); the Profiles tab gained a real Meta-profile combo/CopyTo row (previously a stub label) and per-type name fields/New buttons, and lost the Loot Priority Boost toggle as a duplicate of the Options-tab control of the same name; the Loot Editor popup lost LootEditorNotice (a status label, not an action). S7.3 replaced the whole Monsters tab body with VTank's own 23-column grid: the select-then-edit surface (1 list + 1 field + 8 buttons + 3 menus + 14 toggles + 3 equip buttons = 30 controls) is gone, replaced by 1 list + 23 columns + 1 field + 2 buttons (27) — net 205 → 202. Fix round A (2026-09-07) moved the Advanced Options and Loot Editor groups OUT of mosstank.xml entirely into their own plugin panels and deleted the Profiles tab's three stacked name-draft field+"New"-button rows and the acdream-only "Loot engine" classifier menu — net 202 → 167. S7.4 replaced the Items tab's single-column list with VTank's own clWeaponName/clHandedness 2-column grid (+2), added the Consumables tab's "Excluded Scarab Types" 2-column grid plus its "Add Selected" button (+4), and added the Buffs tab's Extra Buff Spells / Blacklisted Buff Families lists plus their two "Add..." buttons (+4) — net 167 → 177. S7.5 replaced the Route tab's single-column waypoint list with VTank's own clWP/clWPc 2-column grid (+2) and added the "Select Nearest Point" button (+1) — net 177 → 180. S7.6 replaced the Meta tab's single-column rules list with VTank's own 6-column lstMetaRules grid (+6) — net 180 → 186. Fix round B item 5: VTank's real Meta tab is only 5 controls (the grid, "Create", a caption, and the settable current-state menu). Removed entirely: the profile toolbar (menu/name-draft/New/CopyTo/Clear/Delete, 6) and "Enable Meta" (1) — 7 controls gone for good. Moved into mosstank-metaeditor.xml (a separate file this test doesn't scan): the State/Condition/Action editor (2 fields + 2 menus + 1 field for state + 1 secondary-text field + 4 numeric-stepper buttons + Apply/Remove/MoveUp/MoveDown, 14). Added: the settable cmbMetaCurrentState menu (+1). Net 186 → 166 (-7 -14 +1). Fix round B item 8: the Route tab collapses to VTank's own 2-across button grid + bottom nav-control row, 28 → 18 interactive controls within mosstank.xml. Checkpoint/Jump/Remove/Set Follow Target/Follow Corners/Open Doors/Nav Priority/the Follow-Nav-Min-Distance stepper moved to mosstank-advanced.xml's own new "MossTank Extras" section. The 2-state ToggleRouteAddPosition button became the real 3-option cmbNavInsertMode `<menu>`, and the pause "-"/"+" stepper became one editable field. Net 166 → 156 (the count the test asserted immediately before this fix round removed the assertion). The test's own per-control handler/enabled loop remains the live guarantee; a bare `Assert.NotEmpty(controls)` guards the selector itself.
|
- 2026-09-07 14:50 fix round B landed 16/18 on the panel worktree (`040d5f3d8`…`8d3c6ad7c`; MossTank suite → 676, App markup/plugin filter 285/3 skipped); the agent was stopped after three hours failing the screenshot step (malformed session config, disk-wide search) — nothing lost, tree clean. Item 16 was the dedupe, tab-switch popup cleanup, and slider validation. Architecture narrow re-check at `8d3c6ad7c`: NOT merge-ready — D1 the Advanced Options popup re-materializes the 163-entry catalog per frame (the class item 12 killed elsewhere), D2 `/mt refresh` mutates Rules without `RefreshMonsterEditor()`, D3 the bottom-band pin skips unsized children; carried nits (hands column per frame, popup-size theory hardcoded, #491 not cited, mutable bool[]). Fix round C dispatched. Screenshots: replaced by the owner testing LIVE from the gate build at `8d3c6ad7c` (all five plugin windows load); the owner's findings feed round C/D; the resemblance re-check runs on the final state.
|
||||||
|
- 2026-09-07 15:20 OWNER LIVE LOOK at `8d3c6ad7c` (gate build): "Looks mostly fine, a lot of polish can be done though. But we do that later." Findings: (1) Advanced Options' category filter shows the raw bitmask values (0x1, 0x2, …) with lamps instead of the category NAMES (Misc, Recharge, MeleeCombat, SpellCombat, Ranges, Navigation, Buffing, Crafting, Looting); (2) Advanced Options must follow VTank's model (owner's screenshots 2/3): name + value columns, clicking a bool/enum VALUE flips it in place, only NUMBERS open the edit field at the bottom (Enter applies) with the description box under it, no Apply/Back buttons; (3) scrollable dropdowns and the popup windows use the SAME scrollbar/window assets as the chat and inventory windows (not the flat scrollbar); (4) the whole window: larger default size AND resizable (window-manager resize like chat, lists stretching); (5) Route's Add Recall is missing most of VTank's recalls and recalls do not work in routes yet. Dispatched now (App side, own worktrees): retail scrollbar assets for plain popups/lists; resizable plugin panels + `anchor` markup. Queued on the panel worktree after fix round C: Advanced Options model + category names; the full VTank recall table with spell ids (metaf `NRecall`, py:10981-11008 — 26 spells: Primary Portal Recall 48, Secondary 2647, Lifestone 1635, Lifestone Sending 1636, Portal Recall 2645, Aphus 2931, Sanctuary 2023, Singularity Caul 2943, Glenden Wood 3865, Aerlinthe 2041, Mount Lethe 2813, Ulgrim's 2941, Bur 4084, PtOIA 4198, Mhoire Forge 4128, Colosseum 4213, Facility Hub 5175, Gear Knight Camp 5330, Neftet 5541, Return to the Keep 4214, Rynthid 6150, Viridian Rise 6321, Viridian Rise Great Tree 6322, Celestial Hand 6325, Radiant Blood 6327, Eldrytch Web 6326) wired to the existing recall execution; adopt anchors + the larger default.
|
||||||
|
- 2026-09-07 16:10 App-side pieces merged into the campaign branch: `b71a8ea37` retail scrollbar chrome on plain `<menu>` popups and overflowing `<list>`s (chat/inventory skin ids `0x06004C5F`/`6C`/`69`/`60`/`63`/`66`; markup menus are one-column scrollable), `2e63391cc` `<panel resizable minw minh>` + `anchor="left top right bottom"` on every element (window-manager resize + persistence already generic). App markup/menu/list/window/anchor filter 294/1 skipped. Panel round D (after fix round C on the panel worktree): merge the campaign branch in, Advanced Options model + category names, VTank's 26-recall table wired, `resizable="true"` + anchors on the main panel with a larger default.
|
||||||
|
- 2026-09-07 16:30 fix round C (architecture re-check of fix round B `8d3c6ad7c`) landed on the panel worktree, seven commits: `466fac426` (D1 — Advanced Options popup's four bindings, previously re-filtering the 163-entry VtankOptionCatalog and re-running GetMetaOption/ToDisplayString per row on every draw, materialized once by a new RefreshAdvancedOptions() called from category toggle/edit-apply/selection-change/profile-load), `23d4376cc` (D4 — AdvancedOptionCategoryEnabled no longer hands out the mutable backing bool[] directly; a ReadOnlyCollection view built once over the same array), `f15667db5` (D2 — `/mt refresh` now calls RefreshMonsterEditor after EnsureDefaultMonsterRule, the one gap found auditing every `_combatSettings.Rules` mutation site in the plugin), `3b8d02194` (D3 — AssertWithinParent's bottom-band check now counts each widget kind's own implicit default height when `h` is absent, label/field 16 toggle 20 button 16, instead of reading an absent `h` as zero), `37055bddc` (F7+F12 — ItemHandsColumn materialized once in RefreshItemEditors from the SAME base-name array that builds the decorated `_itemRows`, replacing a per-frame `.Select().ToArray()` plus a suffix-reparsing BaseItemName helper with one forward-only decoration; CycleItemHandsAtCore now refreshes the cache it mutates), `0724761ad` (F3 — SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves discovers popup files with the same glob the sibling-overlap and arrow-glyph tests already use instead of a hand-maintained InlineData list), `d20ad47c5` (F4 — the ExtraBuffSpellNames/BlacklistedBuffFamilyNames field comments and their two Buffs-tab tooltips now cite issue #491). D3's grep audit of every real `mosstank*.xml` file found no label/toggle/field/button missing `h`, so the stricter bottom-band check introduced zero new failures against shipped markup. Every new/changed pin was shown to fail against a targeted mutation (property/getter reverted, or the assertion target broken) before the fix was restored; D1's own fix broke one PRE-EXISTING test (`AdvancedOptionValueColumnMirrorsTheLiveSettingValue`, which expected a setting toggled OUTSIDE the popup's own mutators to appear on the very next read) — updated it to exercise the "selection change" mutator afterward, with a comment explaining the narrowed contract. MossTank suite 677 (D1) → 678 (D4) → 679 (D2) → 680 (D3) → 681 (F7+F12) → 678 (F3, four InlineData cases collapsed into one Fact, no coverage loss) → 678 (F4, comment/tooltip only) → 678 (F13, no new test, an assertion removed). Full solution build green after every commit; App markup/plugin filter held 203/203 throughout.
|
||||||
|
- 2026-09-07 fix round C item F13 folded the `EveryInteractiveControlDeclaresARealHandlerBinding` test's exact-control-count history here, in the same commit that removed it from the test file: Round 3 item 10 added +3 for the Route/Loot/Meta Delete buttons (Settings already had one from round 2 step 5). Campaign VT slice 7 S7.1/S7.2 transcribed Options/Profiles/Vitals control-for-control from VTank's own tabs (docs/research/vtank-kb/08-ui-views.md §1) and repositioned the Advanced Options/Loot Editor popups to VTank's exact 392x300/268x300 geometry — net 194 → 205. The Options tab gained four direct checkboxes for settings that already existed but were only reachable through the generic Advanced Options key-value editor (Enable Autostack, Fastcast Buffs, Don't Shoot at Walls, Fallback Debuffs if Blocked); the Profiles tab gained a real Meta-profile combo/CopyTo row (previously a stub label) and per-type name fields/New buttons, and lost the Loot Priority Boost toggle as a duplicate of the Options-tab control of the same name; the Loot Editor popup lost LootEditorNotice (a status label, not an action). S7.3 replaced the whole Monsters tab body with VTank's own 23-column grid: the select-then-edit surface (1 list + 1 field + 8 buttons + 3 menus + 14 toggles + 3 equip buttons = 30 controls) is gone, replaced by 1 list + 23 columns + 1 field + 2 buttons (27) — net 205 → 202. Fix round A (2026-09-07) moved the Advanced Options and Loot Editor groups OUT of mosstank.xml entirely into their own plugin panels and deleted the Profiles tab's three stacked name-draft field+"New"-button rows and the acdream-only "Loot engine" classifier menu — net 202 → 167. S7.4 replaced the Items tab's single-column list with VTank's own clWeaponName/clHandedness 2-column grid (+2), added the Consumables tab's "Excluded Scarab Types" 2-column grid plus its "Add Selected" button (+4), and added the Buffs tab's Extra Buff Spells / Blacklisted Buff Families lists plus their two "Add..." buttons (+4) — net 167 → 177. S7.5 replaced the Route tab's single-column waypoint list with VTank's own clWP/clWPc 2-column grid (+2) and added the "Select Nearest Point" button (+1) — net 177 → 180. S7.6 replaced the Meta tab's single-column rules list with VTank's own 6-column lstMetaRules grid (+6) — net 180 → 186. Fix round B item 5: VTank's real Meta tab is only 5 controls (the grid, "Create", a caption, and the settable current-state menu). Removed entirely: the profile toolbar (menu/name-draft/New/CopyTo/Clear/Delete, 6) and "Enable Meta" (1) — 7 controls gone for good. Moved into mosstank-metaeditor.xml (a separate file this test doesn't scan): the State/Condition/Action editor (2 fields + 2 menus + 1 field for state + 1 secondary-text field + 4 numeric-stepper buttons + Apply/Remove/MoveUp/MoveDown, 14). Added: the settable cmbMetaCurrentState menu (+1). Net 186 → 166 (-7 -14 +1). Fix round B item 8: the Route tab collapses to VTank's own 2-across button grid + bottom nav-control row, 28 → 18 interactive controls within mosstank.xml. Checkpoint/Jump/Remove/Set Follow Target/Follow Corners/Open Doors/Nav Priority/the Follow-Nav-Min-Distance stepper moved to mosstank-advanced.xml's own new "MossTank Extras" section. The 2-state ToggleRouteAddPosition button became the real 3-option cmbNavInsertMode `<menu>`, and the pause "-"/"+" stepper became one editable field. Net 166 → 156 (the count the test asserted immediately before this fix round removed the assertion). The test's own per-control handler/enabled loop remains the live guarantee; a bare `Assert.NotEmpty(controls)` guards the selector itself. Round D dispatched on the same worktree (merges the campaign branch first): category names + VTank's Advanced Options interaction model, the 26-recall table wired to route execution, resizable main panel at a ~15% larger default with anchored lists.
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,9 @@ in-memory KSML string instead of a file path — the route to reach for when a
|
||||||
panel is small enough not to need its own shipped `.xml` asset.
|
panel is small enough not to need its own shipped `.xml` asset.
|
||||||
|
|
||||||
Every registered window gets a stable persisted key
|
Every registered window gets a stable persisted key
|
||||||
(`plugin:{pluginId}:{windowId}`), drag, resize (where the markup opts in),
|
(`plugin:{pluginId}:{windowId}`), drag, resize (where the markup opts in —
|
||||||
the global UI lock, and a button in the shared plugin shelf
|
`<panel resizable="true">`, see "Resizable panels and anchors" below), the
|
||||||
|
global UI lock, and a button in the shared plugin shelf
|
||||||
(`ShowInSidePanel = true`, the default). Hiding or minimizing a window never
|
(`ShowInSidePanel = true`, the default). Hiding or minimizing a window never
|
||||||
disables the plugin or pauses its `Tick`.
|
disables the plugin or pauses its `Tick`.
|
||||||
|
|
||||||
|
|
@ -98,18 +99,18 @@ vanishing from the built tree.
|
||||||
|
|
||||||
| Element | Purpose | Key attributes |
|
| Element | Purpose | Key attributes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `panel` (root) | The window itself | `x y w h title resize visible` |
|
| `panel` (root) | The window itself | `x y w h title resize resizable minw minh visible` |
|
||||||
| `group` | Transparent layout container | `x y w h background border visible` |
|
| `group` | Transparent layout container | `x y w h background border visible anchor` |
|
||||||
| `label` | Static or bound text | `x y text color` |
|
| `label` | Static or bound text | `x y text color anchor` |
|
||||||
| `button` | Clickable rect + caption (+ Slice B icon) | `x y w h text color background border onclick icon iconkind` |
|
| `button` | Clickable rect + caption (+ Slice B icon) | `x y w h text color background border onclick icon iconkind anchor` |
|
||||||
| `icon` | Slice B: a standalone DAT icon | `x y w h did spell item tooltip` |
|
| `icon` | Slice B: a standalone DAT icon | `x y w h did spell item tooltip anchor` |
|
||||||
| `meter` | Retail-style nine-slice bar | `x y w h fill cur max color anchor backleft/backtile/backright frontleft/fronttile/frontright` |
|
| `meter` | Retail-style nine-slice bar | `x y w h fill cur max color anchor backleft/backtile/backright frontleft/fronttile/frontright` |
|
||||||
| `tab` | Selectable tab button | `x y w h text selected onclick` |
|
| `tab` | Selectable tab button | `x y w h text selected onclick anchor` |
|
||||||
| `toggle` | Lamp-style checkbox | `x y w h text checked onclick color` |
|
| `toggle` | Lamp-style checkbox | `x y w h text checked onclick color anchor` |
|
||||||
| `slider` | Horizontal scalar | `x y w h value onchange min max style` |
|
| `slider` | Horizontal scalar | `x y w h value onchange min max style anchor` |
|
||||||
| `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background` |
|
| `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background anchor` |
|
||||||
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward scroll style` |
|
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward scroll style anchor` |
|
||||||
| `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight` + either the single-column `items colors icons iconkind`, or one-to-many `<column>` children (see "Columns" below) — never both |
|
| `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight anchor` + either the single-column `items colors icons iconkind`, or one-to-many `<column>` children (see "Columns" below) — never both |
|
||||||
|
|
||||||
`menu style` is `plain` (the default) or `retail`: retail's gold pushbutton
|
`menu style` is `plain` (the default) or `retail`: retail's gold pushbutton
|
||||||
art read as an out-of-place "big yellow button" next to a plugin's own dark
|
art read as an out-of-place "big yellow button" next to a plugin's own dark
|
||||||
|
|
@ -125,10 +126,17 @@ scrollbar, and a baked checkmark glyph on the current entry even with
|
||||||
`style="plain"`. The open popup now matches `<list>`'s own chrome too: a
|
`style="plain"`. The open popup now matches `<list>`'s own chrome too: a
|
||||||
flat fill + 1px border, one row per entry in the list text color, the
|
flat fill + 1px border, one row per entry in the list text color, the
|
||||||
current entry filled like a list selection, the hovered entry a slightly
|
current entry filled like a list selection, the hovered entry a slightly
|
||||||
lighter fill, and no checkmark; more entries than the row cap show a plain
|
lighter fill, and no checkmark. A `<menu>` popup always scrolls a single
|
||||||
1px-bordered scrollbar track with a flat thumb, no DAT scrollbar art.
|
column (rather than wrapping into more grid columns) once its item count
|
||||||
`style="retail"` keeps the sprite popup (gradient panel, checkmark-bearing
|
exceeds `rows`; a further owner directive (still 2026-09-07 — "For
|
||||||
row art, ornate scrollbar) exactly as before, unchanged.
|
scrollable dropdown or the meta window we use the same assets as we do in
|
||||||
|
for example chat or inventory window") moved that overflow scrollbar to
|
||||||
|
retail's own chrome — the exact sprites the chat window's transcript and
|
||||||
|
the inventory list already use — while the rows themselves stay the flat
|
||||||
|
style described above; a menu with too few items to overflow shows no bar
|
||||||
|
at all. `style="retail"` keeps the sprite popup rows (gradient panel,
|
||||||
|
checkmark-bearing row art) exactly as before, unchanged — only the
|
||||||
|
scrollbar chrome is shared between the two styles.
|
||||||
|
|
||||||
`slider style` is the same `plain`/`retail` grammar (fix round B item 11,
|
`slider style` is the same `plain`/`retail` grammar (fix round B item 11,
|
||||||
2026-09-07): `plain` (the default) draws a flat dark track, a 1px border,
|
2026-09-07): `plain` (the default) draws a flat dark track, a 1px border,
|
||||||
|
|
@ -140,12 +148,15 @@ same rule as `menu style`.
|
||||||
|
|
||||||
Common to every element via `ApplyCommon`: `name`/`id` (a stable control
|
Common to every element via `ApplyCommon`: `name`/`id` (a stable control
|
||||||
name), `visible` (literal `true`/`false` or a bound `bool` property),
|
name), `visible` (literal `true`/`false` or a bound `bool` property),
|
||||||
`enabled` (same rule), and `tooltip` (a literal string or `{Binding}` shown
|
`enabled` (same rule), `tooltip` (a literal string or `{Binding}` shown
|
||||||
through retail's own runtime tooltip popup, empty/whitespace treated as no
|
through retail's own runtime tooltip popup, empty/whitespace treated as no
|
||||||
tooltip). The root `<panel>` is the one exception: it does **not** go
|
tooltip), and `anchor` (which edges of the element's PARENT it keeps a fixed
|
||||||
through `ApplyCommon` (no `name`/`enabled`/`tooltip`), and its `visible`
|
margin to on resize — see "Resizable panels and anchors" below). The root
|
||||||
attribute accepts a `{Binding}` only — a literal `visible="true"` on the
|
`<panel>` is the one exception: it does **not** go through `ApplyCommon` (no
|
||||||
root is not parsed (unlike every child element, where a literal is fine).
|
`name`/`enabled`/`tooltip`/`anchor` — a top-level window is never anchored to
|
||||||
|
its own parent, only dragged/resized directly), and its `visible` attribute
|
||||||
|
accepts a `{Binding}` only — a literal `visible="true"` on the root is not
|
||||||
|
parsed (unlike every child element, where a literal is fine).
|
||||||
|
|
||||||
Multi-column lists are real (Campaign VT slice 1 Part B, below) — a `<list>`
|
Multi-column lists are real (Campaign VT slice 1 Part B, below) — a `<list>`
|
||||||
with `<column>` children is no longer limited to one padded text column. A
|
with `<column>` children is no longer limited to one padded text column. A
|
||||||
|
|
@ -162,6 +173,67 @@ the `0x` prefix to parse as hex; an all-digit string with no prefix
|
||||||
(`did="165"`) parses as **decimal**, not hex — `did="165"` and `did="0x165"`
|
(`did="165"`) parses as **decimal**, not hex — `did="165"` and `did="0x165"`
|
||||||
are different ids.
|
are different ids.
|
||||||
|
|
||||||
|
## Resizable panels and anchors
|
||||||
|
|
||||||
|
A plugin panel is **fixed-size by default** — this matches every panel
|
||||||
|
shipped before 2026-09-07 (e.g. `mosstank.xml`'s `resize="none"`). A window
|
||||||
|
opts into real user drag-resize with `<panel resizable="true">`, and every
|
||||||
|
non-root element opts its OWN geometry into following that resize with
|
||||||
|
`anchor="..."`. The two attributes are independent: a resizable panel whose
|
||||||
|
children have no `anchor` just gets bigger/smaller with empty space at the
|
||||||
|
bottom-right (today's default placement, `Left|Top`); a panel with anchored
|
||||||
|
children but `resizable` left at its default `false` never actually resizes,
|
||||||
|
so the anchors never have anything to react to.
|
||||||
|
|
||||||
|
| Attribute | Element | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `resizable` | `panel` (root) | `"true"` arms the window for user drag-resize on both axes (edges + corners, same mechanism chat windows use); default `false` — fixed size, exactly as before this attribute existed |
|
||||||
|
| `minw` / `minh` | `panel` (root) | The floor a drag-resize (and a persisted-layout restore) will not shrink below. Default: the panel's own authored `w`/`h` — a resizable panel never shrinks past the layout its author actually tested. Only meaningful when `resizable="true"` |
|
||||||
|
| `resize` | `panel` (root) | Pre-existing per-axis lock (`x`/`y`/`both`/`none`) that narrows `resizable="true"` to one axis; has no effect on its own now that `resizable` (default `false`) is the master switch |
|
||||||
|
| `anchor` | `group` `list` `menu` `field` `label` `button` `icon` (and `meter`/`tab`/`toggle`/`slider`) | Space-separated subset of `left top right bottom` (case-insensitive), naming which edges of the element's **direct parent** it keeps a fixed margin to as that parent resizes. Default (attribute absent) is `left top` — today's fixed placement, unchanged |
|
||||||
|
|
||||||
|
`anchor` semantics are exactly `AcDream.App.UI.UiElement.Anchors`/
|
||||||
|
`AnchorEdges`/`ApplyAnchor` (already used by every retail-imported window):
|
||||||
|
|
||||||
|
- `left top` (the default) — pinned top-left at a fixed size; never stretches.
|
||||||
|
- `left right` — stretches WIDTH to track the parent (both side margins stay
|
||||||
|
fixed).
|
||||||
|
- `top bottom` — stretches HEIGHT the same way, vertically.
|
||||||
|
- `left top right bottom` — stretches on both axes.
|
||||||
|
- `right` alone (no `left`) — pins to the parent's right edge at a FIXED
|
||||||
|
width, moving as the parent resizes rather than stretching. `bottom` alone
|
||||||
|
is the same, vertically.
|
||||||
|
|
||||||
|
An element's parent is whatever markup element directly contains it — for a
|
||||||
|
`<group>`'s children, that is the GROUP, not the panel. This is how a group
|
||||||
|
propagates resize to its own contents: give the group
|
||||||
|
`anchor="left top right bottom"` so it stretches with the panel, and give a
|
||||||
|
`<list>` inside it `anchor="left right"` so the list stretches with the
|
||||||
|
GROUP's width in turn. An unrecognized token (a typo like
|
||||||
|
`anchor="left rihgt"`) throws `FormatException` at `Build`, naming the
|
||||||
|
offending element by its `name`/`id` — the same "malformed markup throws"
|
||||||
|
rule every other attribute in this grammar follows.
|
||||||
|
|
||||||
|
No other markup or host wiring is needed to make a panel resizable: once
|
||||||
|
`resizable="true"` sets the window's `Resizable`/`ResizeX`/`ResizeY`/
|
||||||
|
`MinWidth`/`MinHeight`, the SAME drag-resize, persistence (save/restore
|
||||||
|
across sessions, clamped to `minw`/`minh`), and UI-lock behavior every other
|
||||||
|
retained window already has just applies.
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<panel x="0" y="0" w="420" h="320" title="MossTank" resizable="true" minw="360" minh="260">
|
||||||
|
<group anchor="left top right bottom" x="8" y="8" w="404" h="304" border="#FF4A3A14">
|
||||||
|
<label x="4" y="4" text="Monsters"/>
|
||||||
|
<list anchor="left right top bottom" x="4" y="24" w="396" h="276"
|
||||||
|
items="{MonsterNames}" selected="{SelectedMonster}" onchange="{SelectMonster}"/>
|
||||||
|
</group>
|
||||||
|
</panel>
|
||||||
|
```
|
||||||
|
|
||||||
|
Here the outer `<group>` stretches with the panel on every edge, and the
|
||||||
|
`<list>` inside it stretches with the GROUP on every edge in turn — dragging
|
||||||
|
the window's corner grows the whole list, not just empty panel background.
|
||||||
|
|
||||||
## The icon-id grammar (Slice B)
|
## The icon-id grammar (Slice B)
|
||||||
|
|
||||||
Decal/VirindiViewService plugins (the reference usage this ported:
|
Decal/VirindiViewService plugins (the reference usage this ported:
|
||||||
|
|
@ -469,10 +541,12 @@ separate concept for: `WPaddingOuter=3px` (the list's own left/right
|
||||||
margin), `WPadding=7px` (a gap BETWEEN columns), and a themed
|
margin), `WPadding=7px` (a gap BETWEEN columns), and a themed
|
||||||
`VScrollBarButtonSize=16px` (scrollbar width, reserved on the right). It
|
`VScrollBarButtonSize=16px` (scrollbar width, reserved on the right). It
|
||||||
also forces every `CheckColumn` to a fixed 13px regardless of its declared
|
also forces every `CheckColumn` to a fixed 13px regardless of its declared
|
||||||
`fixedwidth`. acdream's column model has no separate gap/scrollbar/
|
`fixedwidth`. acdream's column model has no separate gap/forced-width
|
||||||
forced-width concept — every column's declared `width` is its full cell
|
concept — every column's declared `width` is its full cell width, columns
|
||||||
width, columns sit directly adjacent with no gap, and a check column uses
|
sit directly adjacent with no gap, and a check column uses whatever `width`
|
||||||
whatever `width` it's given like any other column.
|
it's given like any other column. The 16px scrollbar column IS now
|
||||||
|
automatic (owner directive, 2026-09-07 — see "Scrollbar" below): a plugin
|
||||||
|
author never reserves it by hand.
|
||||||
|
|
||||||
To transcribe a real VTank column table (as in
|
To transcribe a real VTank column table (as in
|
||||||
`refs/vtank/uTank2.ViewXML.mainView.xml`) faithfully, declare each column's
|
`refs/vtank/uTank2.ViewXML.mainView.xml`) faithfully, declare each column's
|
||||||
|
|
@ -480,11 +554,26 @@ To transcribe a real VTank column table (as in
|
||||||
VVS's inter-column `WPadding` into the cell width itself, since acdream has
|
VVS's inter-column `WPadding` into the cell width itself, since acdream has
|
||||||
no separate gap). For a `CheckColumn`, use VVS's forced 13px as the
|
no separate gap). For a `CheckColumn`, use VVS's forced 13px as the
|
||||||
`fixedwidth` regardless of whatever `fixedwidth` the source XML declares
|
`fixedwidth` regardless of whatever `fixedwidth` the source XML declares
|
||||||
(`16 -> 13 + 7 = 20`, not `16 + 7 = 23`). Reserve VVS's 16px scrollbar width
|
(`16 -> 13 + 7 = 20`, not `16 + 7 = 23`). Do NOT also fold VVS's 16px
|
||||||
on the LAST column specifically (add it to that column's own pitch, or fold
|
scrollbar width into the last column's pitch or the list's total `w` — the
|
||||||
it into the list's total declared `w`) — acdream's list draws no scrollbar
|
list reserves that width itself, automatically, only while its rows
|
||||||
of its own today, but reserving the space keeps the transcribed proportions
|
actually overflow (see "Scrollbar" below); doing both would double-reserve
|
||||||
matching what a real VVS `HudList` would show once one exists.
|
it and starve the last column once the list has few enough rows to hide
|
||||||
|
the bar.
|
||||||
|
|
||||||
|
### Scrollbar
|
||||||
|
|
||||||
|
Once a `<list>`'s rows overflow its own height (either the single-column or
|
||||||
|
the `<column>` form), it reserves a 16px column at its right edge — VVS's
|
||||||
|
own `VScrollBarButtonSize` placement — and draws retail's scrollbar chrome
|
||||||
|
there: the same sprite ids the chat window's transcript and the inventory
|
||||||
|
list already draw through (owner live-client report 2026-09-07: "For
|
||||||
|
scrollable dropdown or the meta window we use the same assets as we do in
|
||||||
|
for example chat or inventory window"). Mouse wheel keeps working as
|
||||||
|
before; the bar itself is also fully interactive (up/down arrow clicks,
|
||||||
|
track paging, and thumb drag). A list whose rows all fit reserves no width
|
||||||
|
and draws no bar at all — the reservation and the chrome both come and go
|
||||||
|
together with actual overflow, never present "just in case."
|
||||||
|
|
||||||
### Backward compatibility
|
### Backward compatibility
|
||||||
|
|
||||||
|
|
@ -587,4 +676,14 @@ apparatus, hit-test routing (text selects unless it has its own `onclick`;
|
||||||
check/icon/onclick-text fire their own callback and never touch selection),
|
check/icon/onclick-text fire their own callback and never touch selection),
|
||||||
a backward-compatibility proof that a column-less `<list>` is unaffected,
|
a backward-compatibility proof that a column-less `<list>` is unaffected,
|
||||||
and two full `MarkupDocument.Build` end-to-end tests transcribing VTank's
|
and two full `MarkupDocument.Build` end-to-end tests transcribing VTank's
|
||||||
real Monsters- and Meta-tab column shapes.
|
real Monsters- and Meta-tab column shapes. `MarkupResizableAnchorTests`
|
||||||
|
covers `resizable`/`minw`/`minh` parsing, the `anchor` grammar (default,
|
||||||
|
every token combination, the unknown-token throw) across every element
|
||||||
|
listed above, live re-layout against the same recording-renderer apparatus
|
||||||
|
(a stretching list, a right-anchored button that moves, a group whose resize
|
||||||
|
propagates to its own anchored children), and a golden proving a panel with
|
||||||
|
none of these attributes draws byte-identically to itself across repeated
|
||||||
|
builds. `RetailWindowManagerTests`/`RetailWindowLayoutPersistenceTests`
|
||||||
|
cover a resizable markup panel through the real `ResizeTo`/save-restore
|
||||||
|
paths (accepts within `minw`/`minh`, a fixed panel refuses, a restored size
|
||||||
|
below the CURRENT floor clamps up to it).
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,31 @@ public static class MarkupDocument
|
||||||
Height = F(root, "h"),
|
Height = F(root, "h"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 2026-09-07 (docs/plans — owner direction "the size of the entire
|
||||||
|
// window needs to be enlarged for default and should also be
|
||||||
|
// resizeable"): a plugin panel is FIXED-SIZE by default —
|
||||||
|
// resizable="true" is the opt-in that arms real user drag-resize
|
||||||
|
// (both axes; UiRoot's generic edge/grip-drag mechanism already
|
||||||
|
// exists for every UiElement with Resizable=true — see
|
||||||
|
// UiElement.Resizable/ResizeX/ResizeY and RetailWindowManager.ResizeTo).
|
||||||
|
// minw/minh set the floor UiRoot's live drag and
|
||||||
|
// RetailWindowLayoutPersistence's restore clamp both already honor
|
||||||
|
// (UiElement.MinWidth/MinHeight); they default to the AUTHORED w/h so
|
||||||
|
// a resizable panel never shrinks below the layout its author tested.
|
||||||
|
bool resizable = B(root, "resizable", false);
|
||||||
|
panel.Resizable = resizable;
|
||||||
|
panel.MinWidth = FOr(root, "minw", panel.Width);
|
||||||
|
panel.MinHeight = FOr(root, "minh", panel.Height);
|
||||||
|
panel.ResizeX = resizable;
|
||||||
|
panel.ResizeY = resizable;
|
||||||
|
|
||||||
// Optional per-window resize-axis lock: resize="x" | "y" | "both" | "none".
|
// Optional per-window resize-axis lock: resize="x" | "y" | "both" | "none".
|
||||||
|
// Only meaningful once resizable="true" already armed the master
|
||||||
|
// switch above — Resizable=false (the default) blocks any drag-resize
|
||||||
|
// regardless of these axis flags, so this attribute alone can no
|
||||||
|
// longer make a panel resizable the way it silently could before
|
||||||
|
// resizable="true" existed (UiNineSlicePanel's own Resizable=true
|
||||||
|
// constructor default used to make the master switch a no-op).
|
||||||
string? resize = (string?)root.Attribute("resize");
|
string? resize = (string?)root.Attribute("resize");
|
||||||
if (resize is not null)
|
if (resize is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -141,7 +165,8 @@ public static class MarkupDocument
|
||||||
BarColor = Color((string?)el.Attribute("color")),
|
BarColor = Color((string?)el.Attribute("color")),
|
||||||
Fill = BindFloat((string?)el.Attribute("fill"), binding),
|
Fill = BindFloat((string?)el.Attribute("fill"), binding),
|
||||||
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
|
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
|
||||||
Anchors = Anchor((string?)el.Attribute("anchor")),
|
// anchor= is applied uniformly for every element by
|
||||||
|
// ApplyCommon below; no per-element handling needed here.
|
||||||
SpriteResolve = resolve,
|
SpriteResolve = resolve,
|
||||||
BackLeft = Hex((string?)el.Attribute("backleft")),
|
BackLeft = Hex((string?)el.Attribute("backleft")),
|
||||||
BackTile = Hex((string?)el.Attribute("backtile")),
|
BackTile = Hex((string?)el.Attribute("backtile")),
|
||||||
|
|
@ -512,13 +537,6 @@ public static class MarkupDocument
|
||||||
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
||||||
ColumnWidth = Math.Max(20f, F(el, "w")),
|
ColumnWidth = Math.Max(20f, F(el, "w")),
|
||||||
OpenUpward = B(el, "openupward", false),
|
OpenUpward = B(el, "openupward", false),
|
||||||
// KB 08 §3 gap: VVS's HudCombo is always a single scrolling
|
|
||||||
// column (HudCombo.cs:35,102-146); acdream's <menu> instead
|
|
||||||
// wraps overflow into extra columns unless the author opts
|
|
||||||
// into UiMenu.Scrollable. Default false keeps every existing
|
|
||||||
// <menu> (none of which set scroll=) wrapping exactly as
|
|
||||||
// before.
|
|
||||||
Scrollable = B(el, "scroll", false),
|
|
||||||
// Same track/thumb/arrow chrome ConfigOptionsPageController
|
// Same track/thumb/arrow chrome ConfigOptionsPageController
|
||||||
// and VendorUiController already apply to their own
|
// and VendorUiController already apply to their own
|
||||||
// Scrollable menus — harmless to set unconditionally since
|
// Scrollable menus — harmless to set unconditionally since
|
||||||
|
|
@ -535,6 +553,14 @@ public static class MarkupDocument
|
||||||
ItemNormalSprite = 0x0600124Eu,
|
ItemNormalSprite = 0x0600124Eu,
|
||||||
ItemHighlightSprite = 0x0600124Du,
|
ItemHighlightSprite = 0x0600124Du,
|
||||||
RetailButtonArt = menuRetailButtonArt,
|
RetailButtonArt = menuRetailButtonArt,
|
||||||
|
// Owner live-client report 2026-09-07: a plugin dropdown
|
||||||
|
// scrolls a single column (VTank's own HudCombo shape)
|
||||||
|
// rather than wrapping into more grid columns once it
|
||||||
|
// overflows its "rows" window; the scrollbar itself is
|
||||||
|
// hidden entirely (0x79 semantics) while everything
|
||||||
|
// fits, matching retail's vendor category popup.
|
||||||
|
Scrollable = true,
|
||||||
|
PopupScrollbarHideWhenDisabled = true,
|
||||||
ButtonLabelProvider = () => menuSelected() ?? string.Empty,
|
ButtonLabelProvider = () => menuSelected() ?? string.Empty,
|
||||||
OnSelect = payload =>
|
OnSelect = payload =>
|
||||||
{
|
{
|
||||||
|
|
@ -542,6 +568,12 @@ public static class MarkupDocument
|
||||||
menuChanged?.Invoke(value);
|
menuChanged?.Invoke(value);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// The popup's own scrollbar always draws retail's chrome —
|
||||||
|
// "we use the same assets as we do in for example chat or
|
||||||
|
// inventory window" — regardless of RetailButtonArt (the
|
||||||
|
// owner's earlier plain-row directive only ever covered the
|
||||||
|
// ROWS, never this bar).
|
||||||
|
RetailScrollbarChrome.ApplyToMenuPopup(menu);
|
||||||
void RefreshMenu()
|
void RefreshMenu()
|
||||||
{
|
{
|
||||||
menu.Items = menuItems()
|
menu.Items = menuItems()
|
||||||
|
|
@ -598,6 +630,11 @@ public static class MarkupDocument
|
||||||
Height = F(el, "h"),
|
Height = F(el, "h"),
|
||||||
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
||||||
DatFont = datFont,
|
DatFont = datFont,
|
||||||
|
// Owner live-client report 2026-09-07: an overflowing
|
||||||
|
// <list> draws the same retail scrollbar chrome the chat
|
||||||
|
// window and inventory use — resolved through the same
|
||||||
|
// sprite resolver every other markup sink already uses.
|
||||||
|
SpriteResolve = resolve,
|
||||||
SelectedIndexSource = BindRequiredIntReader(
|
SelectedIndexSource = BindRequiredIntReader(
|
||||||
(string?)el.Attribute("selected"),
|
(string?)el.Attribute("selected"),
|
||||||
binding,
|
binding,
|
||||||
|
|
@ -1191,6 +1228,18 @@ public static class MarkupDocument
|
||||||
{
|
{
|
||||||
element.Name = (string?)source.Attribute("name")
|
element.Name = (string?)source.Attribute("name")
|
||||||
?? (string?)source.Attribute("id");
|
?? (string?)source.Attribute("id");
|
||||||
|
|
||||||
|
// 2026-09-07: anchor="left top right bottom" (space-separated; any
|
||||||
|
// subset; default "left top" — today's fixed placement) on ANY
|
||||||
|
// markup element. Semantics are identical to UiElement.Anchors/
|
||||||
|
// AnchorEdges/ApplyAnchor: "left right" stretches width with the
|
||||||
|
// parent, "top bottom" stretches height, "right" alone pins to the
|
||||||
|
// right edge at fixed width. A <group>'s own children resolve their
|
||||||
|
// anchor relative to the GROUP (their direct Parent), not the panel,
|
||||||
|
// because UiElement.ApplyAnchor always measures against Parent.Width/
|
||||||
|
// Height — no extra propagation code is needed for that.
|
||||||
|
element.Anchors = ParseAnchor((string?)source.Attribute("anchor"), source);
|
||||||
|
|
||||||
BindBool((string?)source.Attribute("visible"), binding,
|
BindBool((string?)source.Attribute("visible"), binding,
|
||||||
value => element.Visible = value,
|
value => element.Visible = value,
|
||||||
sourceReader => element.VisibleSource = sourceReader);
|
sourceReader => element.VisibleSource = sourceReader);
|
||||||
|
|
@ -1341,19 +1390,48 @@ public static class MarkupDocument
|
||||||
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
|
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AnchorEdges Anchor(string? csv)
|
/// <summary>
|
||||||
|
/// Parses <c>anchor="left top right bottom"</c> (space-separated, any
|
||||||
|
/// subset of the four tokens, case-insensitive) into <see cref="AnchorEdges"/>.
|
||||||
|
/// Absent/blank defaults to <c>Left | Top</c> — today's fixed top-left
|
||||||
|
/// placement, unchanged. An unrecognized token is a Build-time author
|
||||||
|
/// error, same "malformed markup throws" rule every other attribute in
|
||||||
|
/// this grammar follows (see e.g. <see cref="ValidateIconKind"/>) — the
|
||||||
|
/// message names the offending element via <see cref="ElementIdentity"/>
|
||||||
|
/// so a plugin author with several anchored siblings can find which one
|
||||||
|
/// is wrong.
|
||||||
|
/// </summary>
|
||||||
|
private static AnchorEdges ParseAnchor(string? tokens, XElement source)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top;
|
if (string.IsNullOrWhiteSpace(tokens))
|
||||||
var a = AnchorEdges.None;
|
return AnchorEdges.Left | AnchorEdges.Top;
|
||||||
foreach (var part in csv.Split(',', System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries))
|
|
||||||
a |= part.ToLowerInvariant() switch
|
var edges = AnchorEdges.None;
|
||||||
|
foreach (string token in tokens.Split(
|
||||||
|
(char[]?)null, System.StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
edges |= token.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"left" => AnchorEdges.Left,
|
"left" => AnchorEdges.Left,
|
||||||
"top" => AnchorEdges.Top,
|
"top" => AnchorEdges.Top,
|
||||||
"right" => AnchorEdges.Right,
|
"right" => AnchorEdges.Right,
|
||||||
"bottom" => AnchorEdges.Bottom,
|
"bottom" => AnchorEdges.Bottom,
|
||||||
_ => AnchorEdges.None,
|
_ => throw new FormatException(
|
||||||
|
$"{ElementIdentity(source)} anchor=\"{tokens}\" has unknown token "
|
||||||
|
+ $"\"{token}\" (expected left, top, right, bottom)"),
|
||||||
};
|
};
|
||||||
return a == AnchorEdges.None ? AnchorEdges.Left | AnchorEdges.Top : a;
|
}
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Identifies a markup element for a Build-time error message:
|
||||||
|
/// <c><button name="Foo"></c> when it carries a <c>name</c>/<c>id</c>,
|
||||||
|
/// else just <c><button></c>.</summary>
|
||||||
|
private static string ElementIdentity(XElement source)
|
||||||
|
{
|
||||||
|
string? name = (string?)source.Attribute("name") ?? (string?)source.Attribute("id");
|
||||||
|
return name is null
|
||||||
|
? $"<{source.Name.LocalName}>"
|
||||||
|
: $"<{source.Name.LocalName} name=\"{name}\">";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,27 @@ internal static class RetailScrollbarChrome
|
||||||
bar.ThumbBotPressedSprite = ThumbBotPressed;
|
bar.ThumbBotPressedSprite = ThumbBotPressed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wires the retail vertical skin onto a <see cref="UiMenu"/> popup's own
|
||||||
|
/// procedural scrollbar properties (<see cref="UiMenu.ScrollTrackSprite"/>
|
||||||
|
/// etc). Owner live-client report 2026-09-07 ("For scrollable dropdown or
|
||||||
|
/// the meta window we use the same assets as we do in for example chat or
|
||||||
|
/// inventory window"): the popup's own <c>DrawPopupScrollbar</c> draws a
|
||||||
|
/// simpler Normal-only chrome (no hover/pressed states — matching how
|
||||||
|
/// <c>VendorUiController</c>/<c>ConfigOptionsPageController</c> already
|
||||||
|
/// wire these exact ids), so only the Normal-state constants are needed
|
||||||
|
/// here.
|
||||||
|
/// </summary>
|
||||||
|
internal static void ApplyToMenuPopup(UiMenu menu)
|
||||||
|
{
|
||||||
|
menu.ScrollTrackSprite = Track;
|
||||||
|
menu.ScrollThumbTopSprite = ThumbTopNormal;
|
||||||
|
menu.ScrollThumbSprite = ThumbMidNormal;
|
||||||
|
menu.ScrollThumbBottomSprite = ThumbBotNormal;
|
||||||
|
menu.ScrollUpSprite = UpNormal;
|
||||||
|
menu.ScrollDownSprite = DownNormal;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Wires the full retail horizontal skin onto <paramref name="bar"/>.
|
/// <summary>Wires the full retail horizontal skin onto <paramref name="bar"/>.
|
||||||
/// The leading (<see cref="UiScrollbar.UpSprite"/>) slot is the LEFT edge.</summary>
|
/// The leading (<see cref="UiScrollbar.UpSprite"/>) slot is the LEFT edge.</summary>
|
||||||
internal static void ApplyHorizontal(UiScrollbar bar)
|
internal static void ApplyHorizontal(UiScrollbar bar)
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,46 @@ public sealed class UiMarkupList : UiElement
|
||||||
public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
|
public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
|
||||||
public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
|
public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owner live-client report 2026-09-07 ("For scrollable dropdown or the
|
||||||
|
/// meta window we use the same assets as we do in for example chat or
|
||||||
|
/// inventory window"): resolves the retail scrollbar chrome
|
||||||
|
/// (<see cref="RetailScrollbarChrome"/>) drawn at the list's right edge
|
||||||
|
/// once its rows overflow the viewport — the same resolver every other
|
||||||
|
/// markup sink (icons, DAT fonts) already uses. Null (a hand-built list
|
||||||
|
/// with no host resolver wired) keeps the list wheel-scrollable with no
|
||||||
|
/// visible bar at all, exactly as before this fix.
|
||||||
|
/// </summary>
|
||||||
|
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Reserved width of the retail scrollbar column — VVS's own
|
||||||
|
/// convention (right edge, 16px), matching <see cref="UiMenu.ScrollbarWidth"/>'s
|
||||||
|
/// authored value and <see cref="UiScrollbar"/>'s vertical skin.</summary>
|
||||||
|
private const float ScrollbarWidth = 16f;
|
||||||
|
|
||||||
|
/// <summary>Button extent along the scrollbar's own axis — same authored
|
||||||
|
/// 16px convention <see cref="UiScrollbar.DecrementButtonExtent"/> uses.</summary>
|
||||||
|
private const float ScrollButtonExtent = 16f;
|
||||||
|
|
||||||
private int _topRow;
|
private int _topRow;
|
||||||
private IReadOnlyList<UiMarkupListColumn>? _columns;
|
private IReadOnlyList<UiMarkupListColumn>? _columns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pixel-based scroll projection used ONLY to feed
|
||||||
|
/// <see cref="UiScrollbar.ThumbRect"/>'s geometry math (thumb
|
||||||
|
/// size/position) from this list's own <see cref="_topRow"/> — the list
|
||||||
|
/// itself remains the single source of truth for scroll position (every
|
||||||
|
/// existing selection-follow/ClampTop/wheel path is unchanged); this is
|
||||||
|
/// re-synced from <see cref="_topRow"/> immediately before every draw or
|
||||||
|
/// scrollbar hit-test, never driven independently.
|
||||||
|
/// </summary>
|
||||||
|
private readonly UiScrollable _scroll = new();
|
||||||
|
|
||||||
|
private bool _draggingThumb;
|
||||||
|
private float _thumbDragOffset;
|
||||||
|
|
||||||
|
public UiMarkupList() { CapturesPointerDrag = true; }
|
||||||
|
|
||||||
// ── Fix round item 6: per-column caches (reused between Draw and OnEvent,
|
// ── Fix round item 6: per-column caches (reused between Draw and OnEvent,
|
||||||
// sized to Columns.Count by the Columns setter above) ────────────────────
|
// sized to Columns.Count by the Columns setter above) ────────────────────
|
||||||
private IReadOnlyList<string>?[] _cachedTextRows = Array.Empty<IReadOnlyList<string>?>();
|
private IReadOnlyList<string>?[] _cachedTextRows = Array.Empty<IReadOnlyList<string>?>();
|
||||||
|
|
@ -117,6 +154,9 @@ public sealed class UiMarkupList : UiElement
|
||||||
}
|
}
|
||||||
ClampTop(items.Count, visibleRows);
|
ClampTop(items.Count, visibleRows);
|
||||||
|
|
||||||
|
bool showScrollbar = items.Count > visibleRows;
|
||||||
|
float contentWidth = showScrollbar ? MathF.Max(0f, Width - ScrollbarWidth) : Width;
|
||||||
|
|
||||||
context.DrawFill(0f, 0f, Width, Height, BackgroundColor);
|
context.DrawFill(0f, 0f, Width, Height, BackgroundColor);
|
||||||
context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f);
|
context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f);
|
||||||
int end = Math.Min(items.Count, _topRow + visibleRows);
|
int end = Math.Min(items.Count, _topRow + visibleRows);
|
||||||
|
|
@ -124,7 +164,7 @@ public sealed class UiMarkupList : UiElement
|
||||||
{
|
{
|
||||||
float y = (index - _topRow) * RowHeight;
|
float y = (index - _topRow) * RowHeight;
|
||||||
if (index == selected)
|
if (index == selected)
|
||||||
context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor);
|
context.DrawFill(1f, y + 1f, contentWidth - 2f, RowHeight - 1f, SelectedColor);
|
||||||
|
|
||||||
if (iconIds is not null && index < iconIds.Count && IconResolve is { } resolve)
|
if (iconIds is not null && index < iconIds.Count && IconResolve is { } resolve)
|
||||||
{
|
{
|
||||||
|
|
@ -160,6 +200,9 @@ public sealed class UiMarkupList : UiElement
|
||||||
else
|
else
|
||||||
context.DrawString(text, textX, textY, textColor);
|
context.DrawString(text, textX, textY, textColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showScrollbar)
|
||||||
|
DrawScrollbar(context, contentWidth, items.Count, visibleRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool OnEvent(in UiEvent e)
|
public override bool OnEvent(in UiEvent e)
|
||||||
|
|
@ -168,17 +211,24 @@ public sealed class UiMarkupList : UiElement
|
||||||
return OnEventColumns(e, columns);
|
return OnEventColumns(e, columns);
|
||||||
|
|
||||||
IReadOnlyList<string> items = ItemsSource();
|
IReadOnlyList<string> items = ItemsSource();
|
||||||
|
int visibleRows = VisibleRows;
|
||||||
|
float contentWidth = items.Count > visibleRows
|
||||||
|
? MathF.Max(0f, Width - ScrollbarWidth)
|
||||||
|
: Width;
|
||||||
|
if (TryHandleScrollbarEvent(e, contentWidth, items.Count, visibleRows))
|
||||||
|
return true;
|
||||||
|
|
||||||
if (e.Type == UiEventType.Scroll)
|
if (e.Type == UiEventType.Scroll)
|
||||||
{
|
{
|
||||||
_topRow -= Math.Sign(e.Data0);
|
_topRow -= Math.Sign(e.Data0);
|
||||||
ClampTop(items.Count, VisibleRows);
|
ClampTop(items.Count, visibleRows);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (e.Type != UiEventType.MouseDown || !Enabled)
|
if (e.Type != UiEventType.MouseDown || !Enabled)
|
||||||
return false;
|
return false;
|
||||||
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
|
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
|
||||||
int index = _topRow + row;
|
int index = _topRow + row;
|
||||||
if (row >= 0 && row < VisibleRows && index >= 0 && index < items.Count)
|
if (row >= 0 && row < visibleRows && index >= 0 && index < items.Count)
|
||||||
SelectionChanged?.Invoke(index);
|
SelectionChanged?.Invoke(index);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -304,9 +354,15 @@ public sealed class UiMarkupList : UiElement
|
||||||
}
|
}
|
||||||
_cachedRowCount = rowCount;
|
_cachedRowCount = rowCount;
|
||||||
|
|
||||||
ComputeColumnLayout(columns, Width);
|
|
||||||
|
|
||||||
int visibleRows = VisibleRows;
|
int visibleRows = VisibleRows;
|
||||||
|
// The reserved scrollbar column only exists once rows actually
|
||||||
|
// overflow the viewport (owner directive: reserve 16px only when
|
||||||
|
// the bar is shown) — computed BEFORE ComputeColumnLayout so the
|
||||||
|
// last (always-auto) column's remainder already accounts for it.
|
||||||
|
bool showScrollbar = rowCount > visibleRows;
|
||||||
|
float contentWidth = showScrollbar ? MathF.Max(0f, Width - ScrollbarWidth) : Width;
|
||||||
|
ComputeColumnLayout(columns, contentWidth);
|
||||||
|
|
||||||
int selected = SelectedIndexSource();
|
int selected = SelectedIndexSource();
|
||||||
if (selected >= 0 && selected < rowCount)
|
if (selected >= 0 && selected < rowCount)
|
||||||
{
|
{
|
||||||
|
|
@ -367,6 +423,9 @@ public sealed class UiMarkupList : UiElement
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showScrollbar)
|
||||||
|
DrawScrollbar(context, contentWidth, rowCount, visibleRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawTextCell(
|
private void DrawTextCell(
|
||||||
|
|
@ -453,11 +512,17 @@ public sealed class UiMarkupList : UiElement
|
||||||
// event arriving before any Draw is a harmless no-op rather than a
|
// event arriving before any Draw is a harmless no-op rather than a
|
||||||
// crash.
|
// crash.
|
||||||
int rowCount = _cachedRowCount;
|
int rowCount = _cachedRowCount;
|
||||||
|
int visibleRows = VisibleRows;
|
||||||
|
float contentWidth = rowCount > visibleRows
|
||||||
|
? MathF.Max(0f, Width - ScrollbarWidth)
|
||||||
|
: Width;
|
||||||
|
if (TryHandleScrollbarEvent(e, contentWidth, rowCount, visibleRows))
|
||||||
|
return true;
|
||||||
|
|
||||||
if (e.Type == UiEventType.Scroll)
|
if (e.Type == UiEventType.Scroll)
|
||||||
{
|
{
|
||||||
_topRow -= Math.Sign(e.Data0);
|
_topRow -= Math.Sign(e.Data0);
|
||||||
ClampTop(rowCount, VisibleRows);
|
ClampTop(rowCount, visibleRows);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (e.Type != UiEventType.MouseDown || !Enabled)
|
if (e.Type != UiEventType.MouseDown || !Enabled)
|
||||||
|
|
@ -465,7 +530,7 @@ public sealed class UiMarkupList : UiElement
|
||||||
|
|
||||||
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
|
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
|
||||||
int index = _topRow + row;
|
int index = _topRow + row;
|
||||||
if (row < 0 || row >= VisibleRows || index < 0 || index >= rowCount)
|
if (row < 0 || row >= visibleRows || index < 0 || index >= rowCount)
|
||||||
return true; // swallow the press; clicks past the last row do nothing
|
return true; // swallow the press; clicks past the last row do nothing
|
||||||
|
|
||||||
float localX = e.Data1;
|
float localX = e.Data1;
|
||||||
|
|
@ -520,4 +585,162 @@ public sealed class UiMarkupList : UiElement
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Retail scrollbar chrome (owner live-client report 2026-09-07) ───────
|
||||||
|
//
|
||||||
|
// "For scrollable dropdown or the meta window we use the same assets as
|
||||||
|
// we do in for example chat or inventory window": once a list's rows
|
||||||
|
// overflow its own viewport, it draws the SAME sprite ids
|
||||||
|
// (RetailScrollbarChrome's vertical skin) as the chat SpewBox and the
|
||||||
|
// inventory UiItemList, at VVS's own placement (right edge, 16px wide).
|
||||||
|
// Geometry reuses UiScrollbar.ThumbRect via a small UiScrollable
|
||||||
|
// projection kept in sync with this list's own _topRow — the list stays
|
||||||
|
// the single source of truth for scroll position; the projection only
|
||||||
|
// feeds the shared thumb-size/position math.
|
||||||
|
|
||||||
|
/// <summary>Re-syncs <see cref="_scroll"/>'s content/view extents and
|
||||||
|
/// offset from this list's own <see cref="_topRow"/> — call immediately
|
||||||
|
/// before reading its <see cref="UiScrollbar.ThumbRect"/> geometry.</summary>
|
||||||
|
private void ConfigureScroll(int rowCount, int visibleRows)
|
||||||
|
{
|
||||||
|
int lineHeight = Math.Max(1, (int)MathF.Round(RowHeight));
|
||||||
|
_scroll.LineHeight = lineHeight;
|
||||||
|
_scroll.SetExtents(rowCount * lineHeight, visibleRows * lineHeight);
|
||||||
|
_scroll.SetScrollY(_topRow * lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawScrollbar(UiRenderContext ctx, float x, int rowCount, int visibleRows)
|
||||||
|
{
|
||||||
|
if (SpriteResolve is not { } resolve) return;
|
||||||
|
ConfigureScroll(rowCount, visibleRows);
|
||||||
|
|
||||||
|
float decExtent = Math.Clamp(ScrollButtonExtent, 0f, Height);
|
||||||
|
float incExtent = Math.Clamp(ScrollButtonExtent, 0f, Height - decExtent);
|
||||||
|
|
||||||
|
DrawTiledSprite(ctx, resolve, RetailScrollbarChrome.Track, x, 0f, ScrollbarWidth, Height);
|
||||||
|
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.UpNormal, x, 0f, ScrollbarWidth, decExtent);
|
||||||
|
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.DownNormal, x, Height - incExtent, ScrollbarWidth, incExtent);
|
||||||
|
|
||||||
|
float trackTop = decExtent;
|
||||||
|
float trackLen = MathF.Max(0f, Height - decExtent - incExtent);
|
||||||
|
var (ty, th) = UiScrollbar.ThumbRect(_scroll, trackTop, trackLen);
|
||||||
|
const float capH = 3f;
|
||||||
|
if (th >= 2f * capH)
|
||||||
|
{
|
||||||
|
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.ThumbTopNormal, x, ty, ScrollbarWidth, capH);
|
||||||
|
DrawTiledSprite(ctx, resolve, RetailScrollbarChrome.ThumbMidNormal, x, ty + capH, ScrollbarWidth, th - 2f * capH);
|
||||||
|
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.ThumbBotNormal, x, ty + th - capH, ScrollbarWidth, th <= 0f ? 0f : capH);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.ThumbMidNormal, x, ty, ScrollbarWidth, th);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Draw a sprite stretched 1:1 to the dest rect — same
|
||||||
|
/// convention <see cref="UiMenu"/>/<see cref="UiScrollbar"/> use for
|
||||||
|
/// their own button/thumb-cap art.</summary>
|
||||||
|
private static void DrawFlatSprite(
|
||||||
|
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||||
|
uint id, float x, float y, float w, float h)
|
||||||
|
{
|
||||||
|
if (id == 0 || w <= 0f || h <= 0f) return;
|
||||||
|
var (tex, _, _) = resolve(id);
|
||||||
|
if (tex == 0) return;
|
||||||
|
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Draw a sprite tiled (UV-repeat at native size) to fill the
|
||||||
|
/// dest rect — same convention as the track/thumb-middle draws
|
||||||
|
/// elsewhere in the retail scrollbar chrome.</summary>
|
||||||
|
private static void DrawTiledSprite(
|
||||||
|
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||||
|
uint id, float x, float y, float w, float h)
|
||||||
|
{
|
||||||
|
if (id == 0 || w <= 0f || h <= 0f) return;
|
||||||
|
var (tex, tw, th) = resolve(id);
|
||||||
|
if (tex == 0 || tw == 0 || th == 0) return;
|
||||||
|
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scrollbar hit-testing shared by the legacy single-column
|
||||||
|
/// <see cref="OnEvent"/> and <see cref="OnEventColumns"/> — arrows, track
|
||||||
|
/// paging, and thumb drag all work exactly as they do in
|
||||||
|
/// <see cref="UiScrollbar"/>/the chat window's own docked bar, driving
|
||||||
|
/// this list's own <see cref="_topRow"/>. Returns false (never handled)
|
||||||
|
/// once the rows fit the viewport — a non-overflowing list has no bar
|
||||||
|
/// and its area is ordinary row/content space.
|
||||||
|
/// </summary>
|
||||||
|
private bool TryHandleScrollbarEvent(in UiEvent e, float contentWidth, int rowCount, int visibleRows)
|
||||||
|
{
|
||||||
|
if (_draggingThumb)
|
||||||
|
{
|
||||||
|
if (e.Type == UiEventType.MouseMove)
|
||||||
|
{
|
||||||
|
DragThumb(e.Data2, rowCount, visibleRows);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.Type is UiEventType.MouseUp or UiEventType.CaptureChanged)
|
||||||
|
{
|
||||||
|
_draggingThumb = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowCount <= visibleRows) return false;
|
||||||
|
if (e.Type != UiEventType.MouseDown || !Enabled) return false;
|
||||||
|
if (e.Data1 < contentWidth) return false; // click landed in row content, not the bar
|
||||||
|
|
||||||
|
ConfigureScroll(rowCount, visibleRows);
|
||||||
|
float decExtent = Math.Clamp(ScrollButtonExtent, 0f, Height);
|
||||||
|
float incExtent = Math.Clamp(ScrollButtonExtent, 0f, Height - decExtent);
|
||||||
|
float ly = e.Data2;
|
||||||
|
|
||||||
|
if (ly < decExtent) { StepRow(-1, rowCount, visibleRows); return true; }
|
||||||
|
if (ly >= Height - incExtent) { StepRow(1, rowCount, visibleRows); return true; }
|
||||||
|
|
||||||
|
float trackTop = decExtent;
|
||||||
|
float trackLen = MathF.Max(0f, Height - decExtent - incExtent);
|
||||||
|
var (ty, th) = UiScrollbar.ThumbRect(_scroll, trackTop, trackLen);
|
||||||
|
if (ly >= ty && ly <= ty + th)
|
||||||
|
{
|
||||||
|
_draggingThumb = true;
|
||||||
|
_thumbDragOffset = ly - ty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PageRow(ly < ty ? -1 : 1, rowCount, visibleRows);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DragThumb(float ly, int rowCount, int visibleRows)
|
||||||
|
{
|
||||||
|
ConfigureScroll(rowCount, visibleRows);
|
||||||
|
float decExtent = Math.Clamp(ScrollButtonExtent, 0f, Height);
|
||||||
|
float incExtent = Math.Clamp(ScrollButtonExtent, 0f, Height - decExtent);
|
||||||
|
float trackTop = decExtent;
|
||||||
|
float trackLen = MathF.Max(0f, Height - decExtent - incExtent);
|
||||||
|
var (_, thumbH) = UiScrollbar.ThumbRect(_scroll, trackTop, trackLen);
|
||||||
|
float travel = MathF.Max(1f, trackLen - thumbH);
|
||||||
|
float ratio = (ly - _thumbDragOffset - trackTop) / travel;
|
||||||
|
_scroll.SetPositionRatio(ratio);
|
||||||
|
|
||||||
|
int lineHeight = Math.Max(1, (int)MathF.Round(RowHeight));
|
||||||
|
_topRow = (int)MathF.Round((float)_scroll.ScrollY / lineHeight);
|
||||||
|
ClampTop(rowCount, visibleRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StepRow(int lines, int rowCount, int visibleRows)
|
||||||
|
{
|
||||||
|
_topRow += lines;
|
||||||
|
ClampTop(rowCount, visibleRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PageRow(int pages, int rowCount, int visibleRows)
|
||||||
|
{
|
||||||
|
_topRow += pages * visibleRows;
|
||||||
|
ClampTop(rowCount, visibleRows);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -884,9 +884,19 @@ public sealed class UiMenu : UiElement
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Plain counterpart of <see cref="DrawScrollablePopup"/> — same
|
/// <summary>Plain counterpart of <see cref="DrawScrollablePopup"/> — same
|
||||||
/// <see cref="VisibleTopRow"/>-sliced single column, plain
|
/// <see cref="VisibleTopRow"/>-sliced single column and plain
|
||||||
/// selected/hover row fills, and a plain scrollbar
|
/// selected/hover row fills, but the SCROLLBAR itself draws retail's own
|
||||||
/// (<see cref="DrawPopupScrollbarPlain"/>) instead of the sprite chrome.</summary>
|
/// chrome (owner live-client report 2026-09-07: "For scrollable dropdown
|
||||||
|
/// or the meta window we use the same assets as we do in for example
|
||||||
|
/// chat or inventory window") via the shared <see cref="DrawPopupScrollbar"/>
|
||||||
|
/// helper — the exact ids <see cref="RetailScrollbarChrome.ApplyToMenuPopup"/>
|
||||||
|
/// wires onto <see cref="ScrollTrackSprite"/> etc, the SAME sprite ids the
|
||||||
|
/// chat SpewBox/inventory <see cref="UiItemList"/> scrollbar uses. Rows
|
||||||
|
/// stay plain by design (the owner accepted the flat dark list; only the
|
||||||
|
/// bar was objectionable). A menu built with no <see cref="SpriteResolve"/>
|
||||||
|
/// at all (a hand-built test/legacy fixture) falls back to the fully
|
||||||
|
/// flat <see cref="DrawPopupScrollbarPlain"/> rather than silently
|
||||||
|
/// drawing nothing.</summary>
|
||||||
private void DrawScrollablePopupPlain(UiRenderContext ctx)
|
private void DrawScrollablePopupPlain(UiRenderContext ctx)
|
||||||
{
|
{
|
||||||
ConfigurePopupScroll();
|
ConfigurePopupScroll();
|
||||||
|
|
@ -918,17 +928,26 @@ public sealed class UiMenu : UiElement
|
||||||
avail ? PlainTextColor : TextColorGhosted);
|
avail ? PlainTextColor : TextColorGhosted);
|
||||||
}
|
}
|
||||||
|
|
||||||
DrawPopupScrollbarPlain(ctx, inX + ColumnWidth, inY);
|
if (SpriteResolve is { } resolve)
|
||||||
|
DrawPopupScrollbar(ctx, resolve, inX + ColumnWidth, inY);
|
||||||
|
else
|
||||||
|
DrawPopupScrollbarPlain(ctx, inX + ColumnWidth, inY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plain counterpart of <see cref="DrawPopupScrollbar"/>: a 1px-bordered
|
/// NO-RESOLVER FALLBACK ONLY (see <see cref="DrawScrollablePopupPlain"/>'s
|
||||||
/// track and a flat thumb, both in <see cref="PlainBorderColor"/> — no DAT
|
/// own doc comment — the 2026-09-07 owner directive moved the normal
|
||||||
/// thumb/track/arrow-button art at all. Shares the exact same
|
/// plain-popup scrollbar to retail's own chrome via
|
||||||
/// <see cref="UiScrollbar.ThumbRect"/> geometry (so the thumb's drawn
|
/// <see cref="DrawPopupScrollbar"/>). This draws a 1px-bordered track and
|
||||||
/// position matches <see cref="HandleScrollablePopupMouseDown"/>'s hit-test
|
/// a flat thumb, both in <see cref="PlainBorderColor"/> — no DAT
|
||||||
/// math), but draws no separate up/down button glyphs — plain mode has no
|
/// thumb/track/arrow-button art at all — for the rare case a
|
||||||
/// art for them and the click regions already work through geometry alone
|
/// <see cref="UiMenu"/> is built with <see cref="Scrollable"/> true but no
|
||||||
|
/// <see cref="SpriteResolve"/> at all (a hand-built test/legacy fixture).
|
||||||
|
/// Shares the exact same <see cref="UiScrollbar.ThumbRect"/> geometry (so
|
||||||
|
/// the thumb's drawn position matches
|
||||||
|
/// <see cref="HandleScrollablePopupMouseDown"/>'s hit-test math), but
|
||||||
|
/// draws no separate up/down button glyphs — plain mode has no art for
|
||||||
|
/// them and the click regions already work through geometry alone
|
||||||
/// (<see cref="HandleScrollablePopupMouseDown"/> is unchanged).
|
/// (<see cref="HandleScrollablePopupMouseDown"/> is unchanged).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void DrawPopupScrollbarPlain(UiRenderContext ctx, float x, float y)
|
private void DrawPopupScrollbarPlain(UiRenderContext ctx, float x, float y)
|
||||||
|
|
|
||||||
|
|
@ -496,24 +496,16 @@ public class MarkupDocumentTests
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Campaign VT slice 7 S7.2: <menu scroll="true"> (KB 08 §3 gap) ────
|
// ── Campaign VT slice 7 S7.2: <menu scroll="true"> (KB 08 §3 gap) ────
|
||||||
|
//
|
||||||
[Fact]
|
// Superseded by the retail-scrollbar-chrome merge (`b71a8ea37`, slice 7
|
||||||
public void Build_MenuWithNoScrollAttribute_KeepsScrollableFalse()
|
// round D): a markup <menu> is now ALWAYS Scrollable (VTank's HudCombo
|
||||||
{
|
// is always a single scrolling column, never a wrapping grid), so the
|
||||||
const string xml = """
|
// `scroll` attribute this comment used to gate no longer changes
|
||||||
<panel x="0" y="0" w="160" h="40">
|
// anything — see Menu_Markup_IsAlwaysScrollable_WithRetailScrollbarChromeWired
|
||||||
<menu x="4" y="4" w="120" h="20" items="{Choices}"
|
// below for the current contract. `Build_MenuWithNoScrollAttribute_
|
||||||
selected="{Selected}" onchange="{SelectChoice}" />
|
// KeepsScrollableFalse` (which asserted the opt-in default) was removed
|
||||||
</panel>
|
// here in the same commit that folded the two branches together, since
|
||||||
""";
|
// it now directly contradicts the shipped behavior.
|
||||||
var binding = new EditorBinding();
|
|
||||||
|
|
||||||
UiNineSlicePanel panel = MarkupDocument.Build(
|
|
||||||
xml, binding, _ => (1u, 32, 32));
|
|
||||||
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
|
|
||||||
|
|
||||||
Assert.False(menu.Scrollable);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_MenuWithScrollAttribute_SetsUiMenuScrollableAndItsChromeSprites()
|
public void Build_MenuWithScrollAttribute_SetsUiMenuScrollableAndItsChromeSprites()
|
||||||
|
|
@ -581,71 +573,17 @@ public class MarkupDocumentTests
|
||||||
s => s.Texture == menu.ScrollThumbSprite);
|
s => s.Texture == menu.ScrollThumbSprite);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain sibling (default style, no style attribute): the flat thumb fill
|
// Plain-style scrollbar chrome (default style, no style attribute): this
|
||||||
// is an untextured DrawFill quad (UiTextureTableHandle.None == 0) sized
|
// used to assert a flat untextured DrawFill thumb (UiMenu.
|
||||||
// ScrollbarWidth-2 wide and tinted PlainBorderColor — see
|
// DrawPopupScrollbarPlain). Superseded by the retail-scrollbar-chrome
|
||||||
// UiMenu.DrawPopupScrollbarPlain. This is what actually renders today for
|
// merge (`b71a8ea37`, slice 7 round D, owner: "we use the same assets as
|
||||||
// any markup menu that doesn't opt into style="retail".
|
// we do in for example chat or inventory window") — a plain-style
|
||||||
[Fact]
|
// popup's scrollbar now draws the SAME sprite chrome (track/up/down/
|
||||||
public void Menu_Scroll_DrawsAPlainFlatThumbFillWhenTheMarkupItemCountOverflowsTheVisibleRows()
|
// three-part thumb) the chat/inventory scrollbar resolves through,
|
||||||
{
|
// regardless of RetailButtonArt; only the ROW fills stay plain. See
|
||||||
const string xml = """
|
// UiMenuPlainStyleTests.Plain_OpenPopup_ScrollableOverflow_
|
||||||
<panel x="0" y="0" w="160" h="40">
|
// DrawsRetailScrollbarChrome_RowsStayPlain for the current coverage of
|
||||||
<menu x="4" y="4" w="120" h="18" items="{ManyChoices}"
|
// this exact scenario (plain menu, overflowing markup item count).
|
||||||
selected="{Selected}" onchange="{SelectChoice}"
|
|
||||||
rows="6" rowheight="18" scroll="true" />
|
|
||||||
</panel>
|
|
||||||
""";
|
|
||||||
var binding = new ManyChoicesBinding();
|
|
||||||
|
|
||||||
UiNineSlicePanel panel = MarkupDocument.Build(
|
|
||||||
xml, binding, id => (id, 16, 16));
|
|
||||||
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
|
|
||||||
Assert.False(menu.RetailButtonArt);
|
|
||||||
|
|
||||||
var device = new RecordingGpuDevice();
|
|
||||||
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
|
|
||||||
renderer.Begin(new Vector2(800f, 600f));
|
|
||||||
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
|
|
||||||
|
|
||||||
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5));
|
|
||||||
menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0));
|
|
||||||
Assert.True(menu.PopupScroll.HasOverflow);
|
|
||||||
|
|
||||||
menu.DrawOverlays(ctx);
|
|
||||||
|
|
||||||
// An untextured segment batches every DrawFill call in submission order
|
|
||||||
// together (same texture=0 for background/border/row-fills/thumb), so
|
|
||||||
// scan per-QUAD (6 verts x 8 floats = 48 floats) inside each segment
|
|
||||||
// rather than treating a whole segment as one quad.
|
|
||||||
const int floatsPerQuad = 6 * 8;
|
|
||||||
float expectedThumbWidth = menu.ScrollbarWidth - 2f;
|
|
||||||
bool foundThumb = renderer.DebugSpriteSegmentVerts.Any(s =>
|
|
||||||
{
|
|
||||||
if (s.Texture != 0u) return false;
|
|
||||||
for (int q = 0; q + floatsPerQuad <= s.Verts.Count; q += floatsPerQuad)
|
|
||||||
{
|
|
||||||
float xMin = float.MaxValue, xMax = float.MinValue;
|
|
||||||
for (int v = 0; v < 6; v++)
|
|
||||||
{
|
|
||||||
float x = s.Verts[q + v * 8];
|
|
||||||
if (x < xMin) xMin = x;
|
|
||||||
if (x > xMax) xMax = x;
|
|
||||||
}
|
|
||||||
float width = xMax - xMin;
|
|
||||||
if (MathF.Abs(width - expectedThumbWidth) > 0.5f) continue;
|
|
||||||
float r = s.Verts[q + 4], g = s.Verts[q + 5], b = s.Verts[q + 6], a = s.Verts[q + 7];
|
|
||||||
if (MathF.Abs(r - menu.PlainBorderColor.X) < 0.01f
|
|
||||||
&& MathF.Abs(g - menu.PlainBorderColor.Y) < 0.01f
|
|
||||||
&& MathF.Abs(b - menu.PlainBorderColor.Z) < 0.01f
|
|
||||||
&& MathF.Abs(a - menu.PlainBorderColor.W) < 0.01f)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
Assert.True(foundThumb, "expected a plain flat thumb fill (untextured quad, "
|
|
||||||
+ $"width~{expectedThumbWidth}, tinted PlainBorderColor) among the drawn segments");
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class ManyChoicesBinding
|
private sealed class ManyChoicesBinding
|
||||||
{
|
{
|
||||||
|
|
@ -713,4 +651,101 @@ public class MarkupDocumentTests
|
||||||
Assert.Contains("menu", ex.Message);
|
Assert.Contains("menu", ex.Message);
|
||||||
Assert.Contains("chrome", ex.Message);
|
Assert.Contains("chrome", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Owner live-client report 2026-09-07 ("For scrollable dropdown or
|
||||||
|
// the meta window we use the same assets as we do in for example chat
|
||||||
|
// or inventory window"): a markup <menu> scrolls a single column
|
||||||
|
// (rather than wrapping into grid columns) once it overflows its "rows"
|
||||||
|
// window, and that popup's scrollbar draws the SAME chrome ids the chat
|
||||||
|
// window/inventory scrollbar resolves through.
|
||||||
|
|
||||||
|
private sealed class OverflowMenuBinding
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string> Choices { get; } =
|
||||||
|
Enumerable.Range(0, 12).Select(i => $"row{i}").ToList();
|
||||||
|
public string Selected { get; } = "row0";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Menu_Markup_IsAlwaysScrollable_WithRetailScrollbarChromeWired()
|
||||||
|
{
|
||||||
|
var panel = MarkupDocument.Build(
|
||||||
|
MenuXml(styleAttribute: ""), new MenuStyleBinding(), _ => (1u, 32, 32));
|
||||||
|
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
|
||||||
|
|
||||||
|
Assert.True(menu.Scrollable);
|
||||||
|
Assert.True(menu.PopupScrollbarHideWhenDisabled);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.Track, menu.ScrollTrackSprite);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.ThumbTopNormal, menu.ScrollThumbTopSprite);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.ThumbMidNormal, menu.ScrollThumbSprite);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.ThumbBotNormal, menu.ScrollThumbBottomSprite);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.UpNormal, menu.ScrollUpSprite);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.DownNormal, menu.ScrollDownSprite);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Menu_Markup_StyleRetail_IsAlsoScrollable_WithTheSameChrome()
|
||||||
|
{
|
||||||
|
var panel = MarkupDocument.Build(
|
||||||
|
MenuXml(" style=\"retail\""), new MenuStyleBinding(), _ => (1u, 32, 32));
|
||||||
|
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
|
||||||
|
|
||||||
|
Assert.True(menu.RetailButtonArt);
|
||||||
|
Assert.True(menu.Scrollable);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.Track, menu.ScrollTrackSprite);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Menu_Markup_OverflowingItems_DrawsRetailScrollbarChrome_OnOpen()
|
||||||
|
{
|
||||||
|
var binding = new OverflowMenuBinding();
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||||
|
"<menu x=\"4\" y=\"4\" w=\"120\" h=\"20\" items=\"{Choices}\" " +
|
||||||
|
"selected=\"{Selected}\" openupward=\"false\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
var panel = MarkupDocument.Build(xml, binding, id => (id, 8, 8));
|
||||||
|
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
|
||||||
|
|
||||||
|
// Default rows=7, 12 items -> overflow.
|
||||||
|
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, Data1: 10, Data2: 10)));
|
||||||
|
Assert.True(menu.IsOpen);
|
||||||
|
|
||||||
|
var device = new RecordingGpuDevice();
|
||||||
|
var renderer = new TextRenderer(device, new NullGpuFrameSourceForMenuTests(), "unused");
|
||||||
|
renderer.Begin(new Vector2(200f, 200f));
|
||||||
|
var ctx = new UiRenderContext(renderer, new Vector2(200f, 200f));
|
||||||
|
menu.DrawOverlays(ctx);
|
||||||
|
|
||||||
|
int TrackQuads() => renderer.DebugSpriteSegmentVerts
|
||||||
|
.Where(s => s.Texture == RetailScrollbarChrome.Track)
|
||||||
|
.Sum(s => s.Verts.Count) / 48;
|
||||||
|
Assert.True(TrackQuads() > 0, "expected the overflowing popup to draw the retail scrollbar track");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Menu_Markup_FewItems_DrawsNoScrollbarChrome_OnOpen()
|
||||||
|
{
|
||||||
|
var panel = MarkupDocument.Build(MenuXml(styleAttribute: ""), new MenuStyleBinding(), id => (id, 8, 8));
|
||||||
|
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
|
||||||
|
|
||||||
|
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, Data1: 10, Data2: 10)));
|
||||||
|
Assert.True(menu.IsOpen);
|
||||||
|
|
||||||
|
var device = new RecordingGpuDevice();
|
||||||
|
var renderer = new TextRenderer(device, new NullGpuFrameSourceForMenuTests(), "unused");
|
||||||
|
renderer.Begin(new Vector2(200f, 200f));
|
||||||
|
var ctx = new UiRenderContext(renderer, new Vector2(200f, 200f));
|
||||||
|
menu.DrawOverlays(ctx);
|
||||||
|
|
||||||
|
int TrackQuads() => renderer.DebugSpriteSegmentVerts
|
||||||
|
.Where(s => s.Texture == RetailScrollbarChrome.Track)
|
||||||
|
.Sum(s => s.Verts.Count) / 48;
|
||||||
|
Assert.Equal(0, TrackQuads());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullGpuFrameSourceForMenuTests : ICurrentGpuFrameSource
|
||||||
|
{
|
||||||
|
public IGpuFrame? CurrentFrame => null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
321
tests/AcDream.App.Tests/UI/MarkupResizableAnchorTests.cs
Normal file
321
tests/AcDream.App.Tests/UI/MarkupResizableAnchorTests.cs
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Gpu;
|
||||||
|
using AcDream.App.Tests.Rendering.Gpu;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2026-09-07 (owner direction: "The size of the entire window needs to be
|
||||||
|
/// enlarged for default and should also be resizeable"): plugin markup's
|
||||||
|
/// new <c><panel resizable="true" minw= minh=></c> grammar and the
|
||||||
|
/// <c>anchor="left top right bottom"</c> attribute on <c><group></c>,
|
||||||
|
/// <c><list></c>, <c><menu></c>, <c><field></c>,
|
||||||
|
/// <c><label></c>, <c><button></c>, <c><icon></c>.
|
||||||
|
/// Semantics are the existing <see cref="UiElement.Anchors"/>/
|
||||||
|
/// <see cref="AnchorEdges"/>/<see cref="UiElement.ApplyAnchor"/> machinery —
|
||||||
|
/// these tests prove MarkupDocument wires the two new attribute grammars
|
||||||
|
/// into that machinery correctly, not the machinery itself (already covered
|
||||||
|
/// by other UiElement anchor/resize tests).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MarkupResizableAnchorTests
|
||||||
|
{
|
||||||
|
private sealed class ListBinding
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string> Items => ["A", "B", "C"];
|
||||||
|
public int Selected { get; set; } = -1;
|
||||||
|
public Action<int> OnSelect => value => Selected = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── resizable / minw / minh parse tests ─────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_PanelWithoutResizableAttribute_IsFixedSizeByDefault()
|
||||||
|
{
|
||||||
|
// The golden default: a panel that predates this feature (no
|
||||||
|
// resizable/minw/minh anywhere) must end up with the master switch
|
||||||
|
// OFF and both axes locked — this is the "exactly as today" contract
|
||||||
|
// item 2 of the plan requires.
|
||||||
|
const string xml = "<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\"></panel>";
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
|
||||||
|
|
||||||
|
Assert.False(panel.Resizable);
|
||||||
|
Assert.False(panel.ResizeX);
|
||||||
|
Assert.False(panel.ResizeY);
|
||||||
|
Assert.Equal(300f, panel.MinWidth);
|
||||||
|
Assert.Equal(200f, panel.MinHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_PanelResizableTrue_ArmsBothAxesAndDefaultsMinToAuthoredSize()
|
||||||
|
{
|
||||||
|
const string xml = "<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\"></panel>";
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
|
||||||
|
|
||||||
|
Assert.True(panel.Resizable);
|
||||||
|
Assert.True(panel.ResizeX);
|
||||||
|
Assert.True(panel.ResizeY);
|
||||||
|
Assert.Equal(300f, panel.MinWidth);
|
||||||
|
Assert.Equal(200f, panel.MinHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_PanelResizableTrueWithMinwMinh_OverridesTheAuthoredSizeFloor()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\" minw=\"150\" minh=\"90\"></panel>";
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
|
||||||
|
|
||||||
|
Assert.Equal(150f, panel.MinWidth);
|
||||||
|
Assert.Equal(90f, panel.MinHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_PanelResizableTrueWithResizeAxisLock_NarrowsToOneAxis()
|
||||||
|
{
|
||||||
|
// The pre-existing resize="x"|"y"|"both"|"none" attribute still
|
||||||
|
// layers on top of resizable="true" to narrow which axis actually
|
||||||
|
// drags — it just can no longer be the SOLE switch (resizable is).
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\" resize=\"x\"></panel>";
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
|
||||||
|
|
||||||
|
Assert.True(panel.Resizable);
|
||||||
|
Assert.True(panel.ResizeX);
|
||||||
|
Assert.False(panel.ResizeY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── anchor grammar parse tests ───────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("group")]
|
||||||
|
[InlineData("list")]
|
||||||
|
[InlineData("menu")]
|
||||||
|
[InlineData("field")]
|
||||||
|
[InlineData("label")]
|
||||||
|
[InlineData("button")]
|
||||||
|
[InlineData("icon")]
|
||||||
|
public void Build_ElementWithoutAnchorAttribute_DefaultsToLeftTop(string tag)
|
||||||
|
{
|
||||||
|
string xml = WrapSingle(tag, anchor: null);
|
||||||
|
var panel = MarkupDocument.Build(xml, new ListBinding(), _ => (1u, 32, 32));
|
||||||
|
|
||||||
|
UiElement element = panel.Children[0];
|
||||||
|
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, element.Anchors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("group")]
|
||||||
|
[InlineData("list")]
|
||||||
|
[InlineData("menu")]
|
||||||
|
[InlineData("field")]
|
||||||
|
[InlineData("label")]
|
||||||
|
[InlineData("button")]
|
||||||
|
[InlineData("icon")]
|
||||||
|
public void Build_ElementAnchorLeftRight_SetsBothHorizontalEdges(string tag)
|
||||||
|
{
|
||||||
|
string xml = WrapSingle(tag, anchor: "left right");
|
||||||
|
var panel = MarkupDocument.Build(xml, new ListBinding(), _ => (1u, 32, 32));
|
||||||
|
|
||||||
|
UiElement element = panel.Children[0];
|
||||||
|
Assert.Equal(AnchorEdges.Left | AnchorEdges.Right, element.Anchors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_AnchorAllFourTokens_SetsEveryEdge()
|
||||||
|
{
|
||||||
|
string xml = WrapSingle("group", anchor: "left top right bottom");
|
||||||
|
var panel = MarkupDocument.Build(xml, new ListBinding(), _ => (1u, 32, 32));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||||
|
panel.Children[0].Anchors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_AnchorIsCaseInsensitiveAndOrderIndependent()
|
||||||
|
{
|
||||||
|
string xml = WrapSingle("button", anchor: "BOTTOM Right");
|
||||||
|
var panel = MarkupDocument.Build(xml, new ListBinding(), _ => (1u, 32, 32));
|
||||||
|
|
||||||
|
Assert.Equal(AnchorEdges.Bottom | AnchorEdges.Right, panel.Children[0].Anchors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_UnknownAnchorToken_ThrowsNamingTheElement()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<button name=\"Fire1\" x=\"0\" y=\"0\" w=\"40\" h=\"20\" text=\"Go\" anchor=\"left frotz\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
|
||||||
|
FormatException ex = Assert.Throws<FormatException>(
|
||||||
|
() => MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32)));
|
||||||
|
|
||||||
|
Assert.Contains("Fire1", ex.Message);
|
||||||
|
Assert.Contains("frotz", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WrapSingle(string tag, string? anchor)
|
||||||
|
{
|
||||||
|
string anchorAttr = anchor is null ? string.Empty : $" anchor=\"{anchor}\"";
|
||||||
|
string inner = tag switch
|
||||||
|
{
|
||||||
|
"group" => $"<group x=\"10\" y=\"10\" w=\"100\" h=\"60\"{anchorAttr}></group>",
|
||||||
|
"list" => $"<list x=\"10\" y=\"10\" w=\"100\" h=\"60\" items=\"{{Items}}\" " +
|
||||||
|
$"selected=\"{{Selected}}\" onchange=\"{{OnSelect}}\"{anchorAttr}/>",
|
||||||
|
"menu" => $"<menu x=\"10\" y=\"10\" w=\"100\" h=\"20\" items=\"{{Items}}\"{anchorAttr}/>",
|
||||||
|
"field" => $"<field x=\"10\" y=\"10\" w=\"100\" h=\"20\"{anchorAttr}/>",
|
||||||
|
"label" => $"<label x=\"10\" y=\"10\" text=\"Hi\"{anchorAttr}/>",
|
||||||
|
"button" => $"<button x=\"10\" y=\"10\" w=\"40\" h=\"20\" text=\"Go\"{anchorAttr}/>",
|
||||||
|
"icon" => $"<icon x=\"10\" y=\"10\" w=\"32\" h=\"32\" did=\"0x06000001\"{anchorAttr}/>",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(tag)),
|
||||||
|
};
|
||||||
|
return "<panel x=\"0\" y=\"0\" w=\"200\" h=\"150\">" + inner + "</panel>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── dynamic anchor re-layout tests (recording renderer) ─────────────────
|
||||||
|
|
||||||
|
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||||||
|
{
|
||||||
|
public IGpuFrame? CurrentFrame => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UiRenderContext MakeContext(float w, float h)
|
||||||
|
{
|
||||||
|
var device = new RecordingGpuDevice();
|
||||||
|
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
|
||||||
|
renderer.Begin(new Vector2(w, h));
|
||||||
|
return new UiRenderContext(renderer, new Vector2(w, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResizingPanel_LeftRightList_WidensWithThePanel()
|
||||||
|
{
|
||||||
|
const string xml = """
|
||||||
|
<panel x="0" y="0" w="300" h="200" resizable="true" minw="200" minh="150">
|
||||||
|
<list anchor="left right" x="10" y="10" w="280" h="150"
|
||||||
|
items="{Items}" selected="{Selected}" onchange="{OnSelect}"/>
|
||||||
|
</panel>
|
||||||
|
""";
|
||||||
|
var panel = MarkupDocument.Build(xml, new ListBinding(), _ => (1u, 32, 32));
|
||||||
|
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
||||||
|
|
||||||
|
UiRenderContext ctx = MakeContext(600f, 400f);
|
||||||
|
// First draw at the authored width captures the list's 10px margin to
|
||||||
|
// each side (10 = 300 - (10 + 280)) as its anchor baseline.
|
||||||
|
panel.DrawSelfAndChildren(ctx);
|
||||||
|
Assert.Equal(280f, list.Width);
|
||||||
|
|
||||||
|
// A live drag-resize (RetailWindowManager.ResizeTo) mutates Width
|
||||||
|
// directly; the next draw re-applies the captured 10px margins
|
||||||
|
// against the NEW panel width.
|
||||||
|
panel.Width = 500f;
|
||||||
|
panel.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
Assert.Equal(480f, list.Width); // 500 - 10 - 10
|
||||||
|
Assert.Equal(10f, list.Left); // left margin preserved
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResizingPanel_RightAnchoredButton_MovesWithTheRightEdge()
|
||||||
|
{
|
||||||
|
const string xml = """
|
||||||
|
<panel x="0" y="0" w="300" h="200" resizable="true" minw="200" minh="150">
|
||||||
|
<button anchor="right" x="250" y="10" w="40" h="20" text="X"/>
|
||||||
|
</panel>
|
||||||
|
""";
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
|
||||||
|
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
|
||||||
|
|
||||||
|
UiRenderContext ctx = MakeContext(600f, 400f);
|
||||||
|
panel.DrawSelfAndChildren(ctx);
|
||||||
|
Assert.Equal(250f, button.Left); // unchanged: 10px margin to the right edge
|
||||||
|
Assert.Equal(40f, button.Width); // fixed width — right-only anchor never stretches
|
||||||
|
|
||||||
|
panel.Width = 500f;
|
||||||
|
panel.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
Assert.Equal(450f, button.Left); // 500 - 10 - 40: follows the right edge
|
||||||
|
Assert.Equal(40f, button.Width); // still fixed width
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResizingPanel_GroupStretchesBothAxesAndNestedListFollowsGroupWidth()
|
||||||
|
{
|
||||||
|
// Group anchors to every edge (generalizing "top bottom stretches" to
|
||||||
|
// both axes so this one test can drive BOTH panel dimensions), and
|
||||||
|
// its nested list anchors left+right RELATIVE TO THE GROUP — proving
|
||||||
|
// "groups propagate to children" (item 1): the list's own margins are
|
||||||
|
// captured against the group's Width, not the panel's.
|
||||||
|
const string xml = """
|
||||||
|
<panel x="0" y="0" w="300" h="200" resizable="true" minw="200" minh="150">
|
||||||
|
<group anchor="left top right bottom" x="10" y="10" w="280" h="180">
|
||||||
|
<list anchor="left right" x="5" y="5" w="270" h="170"
|
||||||
|
items="{Items}" selected="{Selected}" onchange="{OnSelect}"/>
|
||||||
|
</group>
|
||||||
|
</panel>
|
||||||
|
""";
|
||||||
|
var panel = MarkupDocument.Build(xml, new ListBinding(), _ => (1u, 32, 32));
|
||||||
|
var group = Assert.IsType<UiPanel>(panel.Children[0]);
|
||||||
|
var list = Assert.IsType<UiMarkupList>(group.Children[0]);
|
||||||
|
|
||||||
|
UiRenderContext ctx = MakeContext(600f, 400f);
|
||||||
|
panel.DrawSelfAndChildren(ctx);
|
||||||
|
Assert.Equal(280f, group.Width);
|
||||||
|
Assert.Equal(180f, group.Height);
|
||||||
|
Assert.Equal(270f, list.Width);
|
||||||
|
|
||||||
|
panel.Width = 500f;
|
||||||
|
panel.Height = 300f;
|
||||||
|
panel.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
// Group stretches on both axes (10px margin preserved on every side).
|
||||||
|
Assert.Equal(480f, group.Width); // 500 - 10 - 10
|
||||||
|
Assert.Equal(280f, group.Height); // 300 - 10 - 10
|
||||||
|
// The nested list follows the GROUP's new width (5px margin to each
|
||||||
|
// side of the group, not the panel).
|
||||||
|
Assert.Equal(470f, list.Width); // 480 - 5 - 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── golden: a plain panel with none of the new attributes is unaffected ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_PlainPanel_DrawsIdenticallyAcrossRepeatedBuilds()
|
||||||
|
{
|
||||||
|
// No resizable/minw/minh/anchor anywhere — the pre-existing markup
|
||||||
|
// shape every current plugin panel uses today. Two independent
|
||||||
|
// builds + draws of the identical markup must produce byte-identical
|
||||||
|
// recorded GPU call sequences and geometry, proving the new
|
||||||
|
// ApplyCommon anchor-parsing code path is a no-op on the default
|
||||||
|
// path this feature must not disturb.
|
||||||
|
const string xml = """
|
||||||
|
<panel x="0" y="0" w="200" h="120" title="V">
|
||||||
|
<group x="4" y="4" w="180" h="40" background="#FF102030">
|
||||||
|
<button x="4" y="4" w="60" h="20" text="Go"/>
|
||||||
|
<label x="4" y="28" text="Hi"/>
|
||||||
|
</group>
|
||||||
|
<list x="4" y="48" w="180" h="60" items="{Items}" selected="{Selected}" onchange="{OnSelect}"/>
|
||||||
|
</panel>
|
||||||
|
""";
|
||||||
|
|
||||||
|
var panelA = MarkupDocument.Build(xml, new ListBinding(), _ => (7u, 32, 32));
|
||||||
|
var panelB = MarkupDocument.Build(xml, new ListBinding(), _ => (7u, 32, 32));
|
||||||
|
|
||||||
|
var deviceA = new RecordingGpuDevice();
|
||||||
|
var rendererA = new TextRenderer(deviceA, new NullGpuFrameSource(), "unused");
|
||||||
|
rendererA.Begin(new Vector2(400f, 300f));
|
||||||
|
panelA.DrawSelfAndChildren(new UiRenderContext(rendererA, new Vector2(400f, 300f)));
|
||||||
|
|
||||||
|
var deviceB = new RecordingGpuDevice();
|
||||||
|
var rendererB = new TextRenderer(deviceB, new NullGpuFrameSource(), "unused");
|
||||||
|
rendererB.Begin(new Vector2(400f, 300f));
|
||||||
|
panelB.DrawSelfAndChildren(new UiRenderContext(rendererB, new Vector2(400f, 300f)));
|
||||||
|
|
||||||
|
Assert.Equal(deviceA.Calls, deviceB.Calls);
|
||||||
|
Assert.False(panelA.Resizable);
|
||||||
|
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, panelA.Children[0].Anchors);
|
||||||
|
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, panelB.Children[0].Anchors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -424,6 +424,81 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
|
||||||
Assert.Equal((77f, 88f), (saved.X, saved.Y));
|
Assert.Equal((77f, 88f), (saved.X, saved.Y));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 2026-09-07: a resizable plugin markup panel's resized geometry
|
||||||
|
// persists and restores exactly like any other registered window — no
|
||||||
|
// persistence-layer change was needed for this, since RetailWindowLayoutPersistence
|
||||||
|
// already clamps against the frame's own MinWidth/MinHeight/MaxWidth/
|
||||||
|
// MaxHeight (Apply's ClampDimension) for every registered handle. ────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResizableMarkupPluginPanel_ResizedSize_RoundTripsThroughPersistence()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\" minw=\"200\" minh=\"150\"></panel>";
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
|
||||||
|
var store = new SettingsStore(PathName);
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
root.AddChild(panel);
|
||||||
|
RetailWindowHandle handle = root.RegisterWindow("plugin-resizable-panel", panel);
|
||||||
|
using var persistence = new RetailWindowLayoutPersistence(
|
||||||
|
root.WindowManager, store, () => "Alice", () => (800, 600));
|
||||||
|
|
||||||
|
handle.MoveTo(60f, 70f);
|
||||||
|
handle.ResizeTo(420f, 260f);
|
||||||
|
|
||||||
|
UiWindowLayout saved = Assert.IsType<UiWindowLayout>(
|
||||||
|
store.LoadWindowLayout("Alice", "800x600", "plugin-resizable-panel", default));
|
||||||
|
Assert.Equal((60f, 70f, 420f, 260f), (saved.X, saved.Y, saved.Width, saved.Height));
|
||||||
|
|
||||||
|
// Simulate a fresh session: rebuild the same markup (a fresh, un-resized
|
||||||
|
// panel) and restore — the saved 420x260 must come back, not the
|
||||||
|
// authored 300x200.
|
||||||
|
var freshPanel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
|
||||||
|
var freshRoot = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
freshRoot.AddChild(freshPanel);
|
||||||
|
RetailWindowHandle freshHandle = freshRoot.RegisterWindow(
|
||||||
|
"plugin-resizable-panel", freshPanel);
|
||||||
|
using var freshPersistence = new RetailWindowLayoutPersistence(
|
||||||
|
freshRoot.WindowManager, store, () => "Alice", () => (800, 600));
|
||||||
|
|
||||||
|
freshPersistence.RestoreAll();
|
||||||
|
|
||||||
|
Assert.Equal((420f, 260f), (freshPanel.Width, freshPanel.Height));
|
||||||
|
Assert.Equal((60f, 70f), (freshPanel.Left, freshPanel.Top));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResizableMarkupPluginPanel_RestoreClampsBelowFloorToAuthoredMin()
|
||||||
|
{
|
||||||
|
// A legacy save from before this feature's minw/minh existed (or one
|
||||||
|
// from a plugin update that raised its floor) can carry a size below
|
||||||
|
// the CURRENT authored minimum. Restore must clamp up to that floor —
|
||||||
|
// this is what RetailWindowLayoutPersistence.Apply's ClampDimension
|
||||||
|
// already does against frame.MinWidth/MinHeight for every registered
|
||||||
|
// window; it only bites here because MarkupDocument now sets those
|
||||||
|
// fields from minw/minh instead of leaving them at UiElement's
|
||||||
|
// generic 40x40 default.
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\" minw=\"200\" minh=\"150\"></panel>";
|
||||||
|
var store = new SettingsStore(PathName);
|
||||||
|
store.SaveWindowLayout(
|
||||||
|
"Alice",
|
||||||
|
"800x600",
|
||||||
|
"plugin-resizable-floor",
|
||||||
|
new UiWindowLayout(60f, 70f, 80f, 60f, true, false, false));
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
root.AddChild(panel);
|
||||||
|
root.RegisterWindow("plugin-resizable-floor", panel);
|
||||||
|
using var persistence = new RetailWindowLayoutPersistence(
|
||||||
|
root.WindowManager, store, () => "Alice", () => (800, 600));
|
||||||
|
|
||||||
|
persistence.RestoreAll();
|
||||||
|
|
||||||
|
Assert.Equal((200f, 150f), (panel.Width, panel.Height));
|
||||||
|
}
|
||||||
|
|
||||||
private static RetailWindowHandle Mount(
|
private static RetailWindowHandle Mount(
|
||||||
UiRoot root,
|
UiRoot root,
|
||||||
string name,
|
string name,
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,47 @@ public sealed class RetailWindowManagerTests
|
||||||
transitions);
|
transitions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 2026-09-07: plugin markup's resizable="true" panel through the real
|
||||||
|
// window-manager ResizeTo path (no host wiring beyond what MarkupDocument
|
||||||
|
// already sets on the panel — Resizable/ResizeX/ResizeY/MinWidth/MinHeight
|
||||||
|
// are ordinary UiElement properties RetailWindowManager.ResizeTo already
|
||||||
|
// respects for any registered window). ───────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResizableMarkupPluginWindow_AcceptsResizeWithinMinAndParentConstraints()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\" minw=\"250\" minh=\"150\"></panel>";
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
root.AddChild(panel);
|
||||||
|
RetailWindowHandle handle = root.RegisterWindow("plugin-resizable", panel);
|
||||||
|
|
||||||
|
Assert.True(handle.ResizeTo(500f, 400f));
|
||||||
|
Assert.Equal((500f, 400f), (panel.Width, panel.Height));
|
||||||
|
|
||||||
|
// Below the authored floor clamps to minw/minh rather than shrinking further.
|
||||||
|
Assert.True(handle.ResizeTo(50f, 50f));
|
||||||
|
Assert.Equal((250f, 150f), (panel.Width, panel.Height));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonResizableMarkupPluginWindow_RefusesResize()
|
||||||
|
{
|
||||||
|
const string xml = "<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\"></panel>";
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
root.AddChild(panel);
|
||||||
|
RetailWindowHandle handle = root.RegisterWindow("plugin-fixed", panel);
|
||||||
|
int resized = 0;
|
||||||
|
handle.Resized += _ => resized++;
|
||||||
|
|
||||||
|
handle.ResizeTo(500f, 400f);
|
||||||
|
|
||||||
|
Assert.Equal((300f, 200f), (panel.Width, panel.Height));
|
||||||
|
Assert.Equal(0, resized);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class RecordingController : IRetainedPanelController
|
private sealed class RecordingController : IRetainedPanelController
|
||||||
{
|
{
|
||||||
public int ShownCount { get; private set; }
|
public int ShownCount { get; private set; }
|
||||||
|
|
|
||||||
302
tests/AcDream.App.Tests/UI/UiMarkupListScrollbarTests.cs
Normal file
302
tests/AcDream.App.Tests/UI/UiMarkupListScrollbarTests.cs
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Gpu;
|
||||||
|
using AcDream.App.Tests.Rendering.Gpu;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owner live-client report 2026-09-07 ("For scrollable dropdown or the meta
|
||||||
|
/// window we use the same assets as we do in for example chat or inventory
|
||||||
|
/// window"): a plugin-markup <c><list></c> (<see cref="UiMarkupList"/>)
|
||||||
|
/// that overflows its own row viewport now draws the same
|
||||||
|
/// <see cref="RetailScrollbarChrome"/> sprites the chat SpewBox and the
|
||||||
|
/// inventory <c>UiItemList</c> use, docked at the list's own right edge
|
||||||
|
/// (VVS's own placement: 16px wide). Before this, an overflowing list only
|
||||||
|
/// scrolled by mouse wheel with no visible bar at all.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Covers: sprite emission gated on actual overflow (a fitting list emits
|
||||||
|
/// none), the reserved 16px column shrink applying ONLY when the bar shows
|
||||||
|
/// (both single-column and multi-column layout), and that the bar is fully
|
||||||
|
/// interactive (up/down arrow clicks and a thumb drag both move
|
||||||
|
/// <c>_topRow</c>, provable the same way <c>MarkupListColumnsTests</c>'
|
||||||
|
/// own <c>Scroll_OffsetIsRespectedBySubsequentHitTests</c> proves wheel
|
||||||
|
/// scrolling: read the moved position back through a subsequent row
|
||||||
|
/// click/select).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UiMarkupListScrollbarTests
|
||||||
|
{
|
||||||
|
private static (TextRenderer renderer, UiRenderContext ctx) MakeContext(float w, float h)
|
||||||
|
{
|
||||||
|
var device = new RecordingGpuDevice();
|
||||||
|
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
|
||||||
|
renderer.Begin(new Vector2(w, h));
|
||||||
|
var ctx = new UiRenderContext(renderer, new Vector2(w, h));
|
||||||
|
return (renderer, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||||||
|
{
|
||||||
|
public IGpuFrame? CurrentFrame => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int FloatsPerQuad = 48; // 6 vertices/quad x 8 floats/vertex (AppendQuad).
|
||||||
|
|
||||||
|
private static int QuadCount(
|
||||||
|
System.Collections.Generic.IReadOnlyList<(uint Texture, System.Collections.Generic.IReadOnlyList<float> Verts)> segs,
|
||||||
|
uint texture)
|
||||||
|
=> segs.Where(s => s.Texture == texture).Sum(s => s.Verts.Count) / FloatsPerQuad;
|
||||||
|
|
||||||
|
// A distinctive resolver: every sprite id resolves to itself as the
|
||||||
|
// texture (so QuadCount(segs, id) proves that EXACT chrome id drew),
|
||||||
|
// with a non-zero native size so DrawTiled/DrawSprite never no-op.
|
||||||
|
private static (uint tex, int w, int h) Resolve(uint id) => (id, 16, 16);
|
||||||
|
|
||||||
|
// ── Single-column mode ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleColumn_Overflowing_DrawsRetailScrollbarChromeAtRightEdge()
|
||||||
|
{
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 visible rows
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
ItemsSource = () => Enumerable.Range(0, 10).Select(i => $"row{i}").ToArray(),
|
||||||
|
};
|
||||||
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
var segs = renderer.DebugSpriteSegmentVerts;
|
||||||
|
|
||||||
|
Assert.Equal(1, QuadCount(segs, RetailScrollbarChrome.Track));
|
||||||
|
Assert.Equal(1, QuadCount(segs, RetailScrollbarChrome.UpNormal));
|
||||||
|
Assert.Equal(1, QuadCount(segs, RetailScrollbarChrome.DownNormal));
|
||||||
|
|
||||||
|
// Track drawn at the list's own right edge, reserving 16px, full height.
|
||||||
|
var trackSeg = Assert.Single(segs, s => s.Texture == RetailScrollbarChrome.Track);
|
||||||
|
Assert.Equal(84f, trackSeg.Verts[0], 2); // x = Width(100) - 16
|
||||||
|
Assert.Equal(0f, trackSeg.Verts[1], 2);
|
||||||
|
Assert.Equal(100f, trackSeg.Verts[8], 2); // x + w = Width
|
||||||
|
Assert.Equal(40f, trackSeg.Verts[9], 2); // y + h = Height
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleColumn_ContentFits_DrawsNoScrollbarChromeAtAll()
|
||||||
|
{
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 visible rows
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
ItemsSource = () => new[] { "only-one-row" },
|
||||||
|
};
|
||||||
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
var segs = renderer.DebugSpriteSegmentVerts;
|
||||||
|
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.Track));
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.UpNormal));
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.DownNormal));
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.ThumbMidNormal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleColumn_NoSpriteResolveWired_DrawsNoScrollbar_NoCrash()
|
||||||
|
{
|
||||||
|
// A hand-built list with no host resolver (SpriteResolve stays null)
|
||||||
|
// must not throw and must draw no chrome at all — matches the
|
||||||
|
// pre-existing "wheel-only, no visible bar" contract for that case.
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
ItemsSource = () => Enumerable.Range(0, 10).Select(i => $"row{i}").ToArray(),
|
||||||
|
};
|
||||||
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
var segs = renderer.DebugSpriteSegmentVerts;
|
||||||
|
|
||||||
|
// The background fill/border still draw (untextured, id 0) — only
|
||||||
|
// the scrollbar chrome itself is gated on a resolver being wired.
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.Track));
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.UpNormal));
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.DownNormal));
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.ThumbMidNormal));
|
||||||
|
Assert.DoesNotContain(segs, s => s.Texture != 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleColumn_UpArrowClick_ScrollsUpByOneRow()
|
||||||
|
{
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 visible rows of 10
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
ItemsSource = () => Enumerable.Range(0, 10).Select(i => $"row{i}").ToArray(),
|
||||||
|
};
|
||||||
|
var (_, ctx) = MakeContext(200f, 200f);
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
// Scroll down 3 rows via wheel first (proven convention from
|
||||||
|
// MarkupListColumnsTests), then click the up arrow once and confirm
|
||||||
|
// a row click resolves one row higher.
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.Scroll, Data0 = -1 });
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
int? selected = null;
|
||||||
|
list.SelectionChanged = row => selected = row;
|
||||||
|
|
||||||
|
// Up-arrow button occupies the scrollbar's own top 16px, x in
|
||||||
|
// [84,100).
|
||||||
|
Assert.True(list.OnEvent(new UiEvent
|
||||||
|
{
|
||||||
|
Type = UiEventType.MouseDown, Data1 = 90, Data2 = 5,
|
||||||
|
}));
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
// Row 0 of the (now one-row-higher) view is absolute row 2.
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 2 });
|
||||||
|
Assert.Equal(2, selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleColumn_ThumbDrag_MovesTopRowAndIsReadableByASubsequentClick()
|
||||||
|
{
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 of 10 rows visible
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
ItemsSource = () => Enumerable.Range(0, 10).Select(i => $"row{i}").ToArray(),
|
||||||
|
};
|
||||||
|
var (_, ctx) = MakeContext(200f, 200f);
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
// Track spans y in [16,24) (Height 40 - 16 up - 16 down = 8px track,
|
||||||
|
// thumb ratio 2/10=0.2 but floored to the 8px MinThumb). Press
|
||||||
|
// squarely inside the thumb (drawn at the very top initially) then
|
||||||
|
// drag to the bottom of the track to scroll to the end.
|
||||||
|
Assert.True(list.OnEvent(new UiEvent
|
||||||
|
{
|
||||||
|
Type = UiEventType.MouseDown, Data1 = 90, Data2 = 18,
|
||||||
|
}));
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseMove, Data1 = 90, Data2 = 40 });
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 90, Data2 = 40 });
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
int? selected = null;
|
||||||
|
list.SelectionChanged = row => selected = row;
|
||||||
|
// Click the FIRST visible row after dragging to the end — must
|
||||||
|
// resolve to the last possible top row (10-2=8), not row 0.
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 2 });
|
||||||
|
Assert.Equal(8, selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-column mode ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Columns_Overflowing_ReservesSixteenPixels_LastColumnShrinksAccordingly()
|
||||||
|
{
|
||||||
|
// 100px-wide list, one fixed 20px column + one auto (last) column
|
||||||
|
// that would otherwise absorb 80px; with the bar reserved it must
|
||||||
|
// absorb only 80-16=64px.
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 visible rows
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
Columns = new[]
|
||||||
|
{
|
||||||
|
UiMarkupListColumn.Text(20f, () => Enumerable.Range(0, 10).Select(i => $"a{i}").ToArray(), null),
|
||||||
|
UiMarkupListColumn.Icon(
|
||||||
|
0f, () => Enumerable.Range(0, 10).Select(i => (uint)(i + 1)).ToArray(),
|
||||||
|
id => (id, 16, 16), _ => { }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
var segs = renderer.DebugSpriteSegmentVerts;
|
||||||
|
|
||||||
|
Assert.Equal(1, QuadCount(segs, RetailScrollbarChrome.Track));
|
||||||
|
|
||||||
|
// The icon column's cell now runs [20,84) (100-16 scrollbar): 62px
|
||||||
|
// usable extent (cellW-2), scale=1 (16px icon fits), centered ->
|
||||||
|
// x = 20 + 1 + (62-16)/2 = 44.
|
||||||
|
var iconQuad = Assert.Single(segs, s => s.Texture == 1u);
|
||||||
|
Assert.Equal(44f, iconQuad.Verts[0], 2);
|
||||||
|
Assert.True(iconQuad.Verts[8] <= 84f + 0.01f,
|
||||||
|
$"expected the icon column's cell to shrink for the reserved scrollbar, got right edge {iconQuad.Verts[8]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Columns_ContentFits_NoReservation_LastColumnKeepsFullRemainder()
|
||||||
|
{
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 visible rows, 1 row of data
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
Columns = new[]
|
||||||
|
{
|
||||||
|
UiMarkupListColumn.Text(20f, () => new[] { "a" }, null),
|
||||||
|
UiMarkupListColumn.Icon(0f, () => new uint[] { 1u }, id => (id, 16, 16), _ => { }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
var segs = renderer.DebugSpriteSegmentVerts;
|
||||||
|
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.Track));
|
||||||
|
// No reservation: the cell is the full [20,100) 80px (78px usable
|
||||||
|
// extent), scale still clamps to 1 (16px icon), but centered in the
|
||||||
|
// WIDER cell it lands further right than the reserved case's x=44:
|
||||||
|
// x = 20 + 1 + (78-16)/2 = 52.
|
||||||
|
var iconQuad = Assert.Single(segs, s => s.Texture == 1u);
|
||||||
|
Assert.Equal(52f, iconQuad.Verts[0], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Columns_Overflowing_UpArrowClick_ScrollsUpByOneRow()
|
||||||
|
{
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 of 10 rows visible
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
Columns = new[]
|
||||||
|
{
|
||||||
|
UiMarkupListColumn.Text(100f, () => Enumerable.Range(0, 10).Select(i => $"a{i}").ToArray(), null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var (_, ctx) = MakeContext(200f, 200f);
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.Scroll, Data0 = -1 });
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
int? selected = null;
|
||||||
|
list.SelectionChanged = row => selected = row;
|
||||||
|
|
||||||
|
Assert.True(list.OnEvent(new UiEvent
|
||||||
|
{
|
||||||
|
Type = UiEventType.MouseDown, Data1 = 90, Data2 = 5,
|
||||||
|
}));
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 2 });
|
||||||
|
Assert.Equal(2, selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -359,8 +359,17 @@ public sealed class UiMenuPlainStyleTests
|
||||||
Assert.Equal(RetailChromeSprites.Border + UiMenu.PlainPadding, glyphSeg.Verts[0], 3);
|
Assert.Equal(RetailChromeSprites.Border + UiMenu.PlainPadding, glyphSeg.Verts[0], 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Owner live-client report 2026-09-07 ("For scrollable dropdown or the
|
||||||
|
// meta window we use the same assets as we do in for example chat or
|
||||||
|
// inventory window"): the plain popup's SCROLLBAR now draws retail's own
|
||||||
|
// chrome (the exact sprite ids RetailScrollbarChrome wires onto
|
||||||
|
// chat/inventory's own bar) — only the ROWS stayed plain. These two tests
|
||||||
|
// used to pin a fully flat/untextured scrollbar; they now pin the
|
||||||
|
// opposite: real sprite draws for the bar, untouched plain fills for the
|
||||||
|
// rows, and no visible bar at all once the content fits.
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Plain_OpenPopup_ScrollableOverflow_DrawsPlainTrackAndFlatThumb_NoDatArt()
|
public void Plain_OpenPopup_ScrollableOverflow_DrawsRetailScrollbarChrome_RowsStayPlain()
|
||||||
{
|
{
|
||||||
int resolveCalls = 0;
|
int resolveCalls = 0;
|
||||||
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 12, rowsPerColumn: 5, scrollable: true,
|
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 12, rowsPerColumn: 5, scrollable: true,
|
||||||
|
|
@ -373,27 +382,39 @@ public sealed class UiMenuPlainStyleTests
|
||||||
var segs = renderer.DebugSpriteSegmentVerts;
|
var segs = renderer.DebugSpriteSegmentVerts;
|
||||||
|
|
||||||
Assert.True(menu.PopupScroll.HasOverflow);
|
Assert.True(menu.PopupScroll.HasOverflow);
|
||||||
Assert.Equal(0, resolveCalls);
|
// Track + up + down + thumb top/mid/bottom = 6 resolved sprite ids —
|
||||||
Assert.Equal(0, QuadCount(segs, menu.ScrollTrackSprite));
|
// the SAME chrome ids the chat/inventory scrollbar resolves through
|
||||||
Assert.Equal(0, QuadCount(segs, menu.ScrollThumbSprite));
|
// the same SpriteResolve seam, no longer the flat DrawFill-only path.
|
||||||
|
Assert.Equal(6, resolveCalls);
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollTrackSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollUpSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollDownSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollThumbTopSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollThumbSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollThumbBottomSprite));
|
||||||
|
|
||||||
float outerTop = menu.Height;
|
float outerTop = menu.Height;
|
||||||
float inX = RetailChromeSprites.Border, inY = outerTop + RetailChromeSprites.Border;
|
float inX = RetailChromeSprites.Border, inY = outerTop + RetailChromeSprites.Border;
|
||||||
float scrollbarX = inX + PlainColumnWidth;
|
|
||||||
|
|
||||||
|
// The rows are untouched by the chrome swap: still a plain fill, no
|
||||||
|
// DAT row/checkbox art at all (RetailButtonArt=false's own contract).
|
||||||
Assert.True(HasFillQuad(segs, inX, inY, PlainColumnWidth, PlainRowHeight, menu.PlainSelectedColor),
|
Assert.True(HasFillQuad(segs, inX, inY, PlainColumnWidth, PlainRowHeight, menu.PlainSelectedColor),
|
||||||
"expected visible row 0 (selected/current) filled with PlainSelectedColor");
|
"expected visible row 0 (selected/current) still filled with PlainSelectedColor");
|
||||||
Assert.True(HasFillQuad(segs, scrollbarX, inY, menu.ScrollbarWidth, 5 * PlainRowHeight, menu.PlainBackgroundColor),
|
Assert.Equal(0, QuadCount(segs, menu.ItemHighlightSprite));
|
||||||
"expected the scrollbar track background fill");
|
Assert.Equal(0, QuadCount(segs, menu.ItemNormalSprite));
|
||||||
|
|
||||||
// popup bg(1)+outline(4) + selected row(1) + scrollbar bg(1)+outline(4) + thumb(1) = 12.
|
// popup bg(1)+outline(4) + selected row(1) = 6 untextured quads;
|
||||||
Assert.Equal(12, QuadCount(segs, 0u));
|
// the scrollbar itself no longer contributes any (it is all sprite
|
||||||
|
// draws now).
|
||||||
|
Assert.Equal(6, QuadCount(segs, 0u));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Plain_ScrollablePopup_ContentFits_DrawsTrackWithNoThumb()
|
public void Plain_ScrollablePopup_ContentFits_DrawsNoScrollbarAtAll()
|
||||||
{
|
{
|
||||||
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 5, scrollable: true);
|
int resolveCalls = 0;
|
||||||
|
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 5, scrollable: true,
|
||||||
|
countResolveCall: n => resolveCalls += n);
|
||||||
OpenAndHover(menu);
|
OpenAndHover(menu);
|
||||||
|
|
||||||
var (renderer, ctx) = MakeContext(200f, 200f);
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
@ -402,9 +423,18 @@ public sealed class UiMenuPlainStyleTests
|
||||||
|
|
||||||
Assert.False(menu.PopupScroll.HasOverflow);
|
Assert.False(menu.PopupScroll.HasOverflow);
|
||||||
|
|
||||||
// popup bg(1)+outline(4) + scrollbar bg(1)+outline(4) = 10, no thumb quad
|
// Content-fits still draws the track + up/down buttons (retail's own
|
||||||
// (nothing selected/hovered here either).
|
// proportion-0x88-defaults-to-1.0 rule — a content-fits bar shows a
|
||||||
Assert.Equal(10, QuadCount(segs, 0u));
|
// full-track thumb elsewhere in this class), but no thumb: 3 resolves.
|
||||||
|
Assert.Equal(3, resolveCalls);
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollTrackSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollUpSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollDownSprite));
|
||||||
|
Assert.Equal(0, QuadCount(segs, menu.ScrollThumbSprite));
|
||||||
|
|
||||||
|
// popup bg(1)+outline(4) = 5 untextured quads (nothing
|
||||||
|
// selected/hovered here either, and the scrollbar draws no fills).
|
||||||
|
Assert.Equal(5, QuadCount(segs, 0u));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue