diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 85413580..dd732a10 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 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 **Status:** OPEN — found 2026-09-07 by the Opus re-check of the headless console (`738111239`). diff --git a/docs/plans/2026-09-06-campaign-vt-vtank-oracle.md b/docs/plans/2026-09-06-campaign-vt-vtank-oracle.md index 9e04092c..4a2365f1 100644 --- a/docs/plans/2026-09-06-campaign-vt-vtank-oracle.md +++ b/docs/plans/2026-09-06-campaign-vt-vtank-oracle.md @@ -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 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-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. diff --git a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md index dc0e2a4e..3fdc6647 100644 --- a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md +++ b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md @@ -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 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 fix round B item 16 landed on the panel worktree at `8d3c6ad7c` (dedupe, tab-switch popup cleanup, slider validation). -- 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 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 ``, 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 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 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 `` 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 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 `` popups and overflowing ``s (chat/inventory skin ids `0x06004C5F`/`6C`/`69`/`60`/`63`/`66`; markup menus are one-column scrollable), `2e63391cc` `` + `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 ``, 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. diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index 9b490892..93d51d8b 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -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. Every registered window gets a stable persisted key -(`plugin:{pluginId}:{windowId}`), drag, resize (where the markup opts in), -the global UI lock, and a button in the shared plugin shelf +(`plugin:{pluginId}:{windowId}`), drag, resize (where the markup opts in — +``, 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 disables the plugin or pauses its `Tick`. @@ -98,18 +99,18 @@ vanishing from the built tree. | Element | Purpose | Key attributes | |---|---|---| -| `panel` (root) | The window itself | `x y w h title resize visible` | -| `group` | Transparent layout container | `x y w h background border visible` | -| `label` | Static or bound text | `x y text color` | -| `button` | Clickable rect + caption (+ Slice B icon) | `x y w h text color background border onclick icon iconkind` | -| `icon` | Slice B: a standalone DAT icon | `x y w h did spell item tooltip` | +| `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 anchor` | +| `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 anchor` | +| `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` | -| `tab` | Selectable tab button | `x y w h text selected onclick` | -| `toggle` | Lamp-style checkbox | `x y w h text checked onclick color` | -| `slider` | Horizontal scalar | `x y w h value onchange min max style` | -| `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background` | -| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward scroll style` | -| `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 `` children (see "Columns" below) — never both | +| `tab` | Selectable tab button | `x y w h text selected onclick anchor` | +| `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 anchor` | +| `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 anchor` | +| `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 `` children (see "Columns" below) — never both | `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 @@ -125,10 +126,17 @@ scrollbar, and a baked checkmark glyph on the current entry even with `style="plain"`. The open popup now matches ``'s own chrome too: a 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 -lighter fill, and no checkmark; more entries than the row cap show a plain -1px-bordered scrollbar track with a flat thumb, no DAT scrollbar art. -`style="retail"` keeps the sprite popup (gradient panel, checkmark-bearing -row art, ornate scrollbar) exactly as before, unchanged. +lighter fill, and no checkmark. A `` popup always scrolls a single +column (rather than wrapping into more grid columns) once its item count +exceeds `rows`; a further owner directive (still 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") 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, 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 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 -tooltip). The root `` is the one exception: it does **not** go -through `ApplyCommon` (no `name`/`enabled`/`tooltip`), 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). +tooltip), and `anchor` (which edges of the element's PARENT it keeps a fixed +margin to on resize — see "Resizable panels and anchors" below). The root +`` is the one exception: it does **not** go through `ApplyCommon` (no +`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 `` with `` 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"` 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 ``, 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 +``'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 +`` 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 + + + + +``` + +Here the outer `` stretches with the panel on every edge, and the +`` 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) 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 `VScrollBarButtonSize=16px` (scrollbar width, reserved on the right). It also forces every `CheckColumn` to a fixed 13px regardless of its declared -`fixedwidth`. acdream's column model has no separate gap/scrollbar/ -forced-width concept — every column's declared `width` is its full cell -width, columns sit directly adjacent with no gap, and a check column uses -whatever `width` it's given like any other column. +`fixedwidth`. acdream's column model has no separate gap/forced-width +concept — every column's declared `width` is its full cell width, columns +sit directly adjacent with no gap, and a check column uses whatever `width` +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 `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 no separate gap). For a `CheckColumn`, use VVS's forced 13px as the `fixedwidth` regardless of whatever `fixedwidth` the source XML declares -(`16 -> 13 + 7 = 20`, not `16 + 7 = 23`). Reserve VVS's 16px scrollbar width -on the LAST column specifically (add it to that column's own pitch, or fold -it into the list's total declared `w`) — acdream's list draws no scrollbar -of its own today, but reserving the space keeps the transcribed proportions -matching what a real VVS `HudList` would show once one exists. +(`16 -> 13 + 7 = 20`, not `16 + 7 = 23`). Do NOT also fold VVS's 16px +scrollbar width into the last column's pitch or the list's total `w` — the +list reserves that width itself, automatically, only while its rows +actually overflow (see "Scrollbar" below); doing both would double-reserve +it and starve the last column once the list has few enough rows to hide +the bar. + +### Scrollbar + +Once a ``'s rows overflow its own height (either the single-column or +the `` 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 @@ -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), a backward-compatibility proof that a column-less `` is unaffected, 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). diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index 12e9a06d..90d29eac 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -56,7 +56,31 @@ public static class MarkupDocument 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". + // 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"); if (resize is not null) { @@ -141,7 +165,8 @@ public static class MarkupDocument BarColor = Color((string?)el.Attribute("color")), Fill = BindFloat((string?)el.Attribute("fill"), binding), 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, BackLeft = Hex((string?)el.Attribute("backleft")), BackTile = Hex((string?)el.Attribute("backtile")), @@ -512,13 +537,6 @@ public static class MarkupDocument RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)), ColumnWidth = Math.Max(20f, F(el, "w")), 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 instead - // wraps overflow into extra columns unless the author opts - // into UiMenu.Scrollable. Default false keeps every existing - // (none of which set scroll=) wrapping exactly as - // before. - Scrollable = B(el, "scroll", false), // Same track/thumb/arrow chrome ConfigOptionsPageController // and VendorUiController already apply to their own // Scrollable menus — harmless to set unconditionally since @@ -535,6 +553,14 @@ public static class MarkupDocument ItemNormalSprite = 0x0600124Eu, ItemHighlightSprite = 0x0600124Du, 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, OnSelect = payload => { @@ -542,6 +568,12 @@ public static class MarkupDocument 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() { menu.Items = menuItems() @@ -598,6 +630,11 @@ public static class MarkupDocument Height = F(el, "h"), RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)), DatFont = datFont, + // Owner live-client report 2026-09-07: an overflowing + // 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( (string?)el.Attribute("selected"), binding, @@ -1191,6 +1228,18 @@ public static class MarkupDocument { element.Name = (string?)source.Attribute("name") ?? (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 '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, value => element.Visible = value, sourceReader => element.VisibleSource = sourceReader); @@ -1341,19 +1390,48 @@ public static class MarkupDocument System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u; } - private static AnchorEdges Anchor(string? csv) + /// + /// Parses anchor="left top right bottom" (space-separated, any + /// subset of the four tokens, case-insensitive) into . + /// Absent/blank defaults to Left | Top — 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. ) — the + /// message names the offending element via + /// so a plugin author with several anchored siblings can find which one + /// is wrong. + /// + private static AnchorEdges ParseAnchor(string? tokens, XElement source) { - if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top; - var a = AnchorEdges.None; - foreach (var part in csv.Split(',', System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries)) - a |= part.ToLowerInvariant() switch + if (string.IsNullOrWhiteSpace(tokens)) + return AnchorEdges.Left | AnchorEdges.Top; + + var edges = AnchorEdges.None; + foreach (string token in tokens.Split( + (char[]?)null, System.StringSplitOptions.RemoveEmptyEntries)) + { + edges |= token.ToLowerInvariant() switch { "left" => AnchorEdges.Left, "top" => AnchorEdges.Top, "right" => AnchorEdges.Right, "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; + } + + /// Identifies a markup element for a Build-time error message: + /// <button name="Foo"> when it carries a name/id, + /// else just <button>. + 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}\">"; } } diff --git a/src/AcDream.App/UI/RetailScrollbarChrome.cs b/src/AcDream.App/UI/RetailScrollbarChrome.cs index 0dc3623d..5c427027 100644 --- a/src/AcDream.App/UI/RetailScrollbarChrome.cs +++ b/src/AcDream.App/UI/RetailScrollbarChrome.cs @@ -84,6 +84,27 @@ internal static class RetailScrollbarChrome bar.ThumbBotPressedSprite = ThumbBotPressed; } + /// + /// Wires the retail vertical skin onto a popup's own + /// procedural scrollbar properties ( + /// 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 DrawPopupScrollbar draws a + /// simpler Normal-only chrome (no hover/pressed states — matching how + /// VendorUiController/ConfigOptionsPageController already + /// wire these exact ids), so only the Normal-state constants are needed + /// here. + /// + internal static void ApplyToMenuPopup(UiMenu menu) + { + menu.ScrollTrackSprite = Track; + menu.ScrollThumbTopSprite = ThumbTopNormal; + menu.ScrollThumbSprite = ThumbMidNormal; + menu.ScrollThumbBottomSprite = ThumbBotNormal; + menu.ScrollUpSprite = UpNormal; + menu.ScrollDownSprite = DownNormal; + } + /// Wires the full retail horizontal skin onto . /// The leading () slot is the LEFT edge. internal static void ApplyHorizontal(UiScrollbar bar) diff --git a/src/AcDream.App/UI/UiMarkupList.cs b/src/AcDream.App/UI/UiMarkupList.cs index ef8fce20..ddc39745 100644 --- a/src/AcDream.App/UI/UiMarkupList.cs +++ b/src/AcDream.App/UI/UiMarkupList.cs @@ -73,9 +73,46 @@ public sealed class UiMarkupList : UiElement 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); + /// + /// 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 + /// () 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. + /// + public Func? SpriteResolve { get; set; } + + /// Reserved width of the retail scrollbar column — VVS's own + /// convention (right edge, 16px), matching 's + /// authored value and 's vertical skin. + private const float ScrollbarWidth = 16f; + + /// Button extent along the scrollbar's own axis — same authored + /// 16px convention uses. + private const float ScrollButtonExtent = 16f; + private int _topRow; private IReadOnlyList? _columns; + /// + /// Pixel-based scroll projection used ONLY to feed + /// 's geometry math (thumb + /// size/position) from this list's own — 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 immediately before every draw or + /// scrollbar hit-test, never driven independently. + /// + 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, // sized to Columns.Count by the Columns setter above) ──────────────────── private IReadOnlyList?[] _cachedTextRows = Array.Empty?>(); @@ -117,6 +154,9 @@ public sealed class UiMarkupList : UiElement } 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.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f); int end = Math.Min(items.Count, _topRow + visibleRows); @@ -124,7 +164,7 @@ public sealed class UiMarkupList : UiElement { float y = (index - _topRow) * RowHeight; 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) { @@ -160,6 +200,9 @@ public sealed class UiMarkupList : UiElement else context.DrawString(text, textX, textY, textColor); } + + if (showScrollbar) + DrawScrollbar(context, contentWidth, items.Count, visibleRows); } public override bool OnEvent(in UiEvent e) @@ -168,17 +211,24 @@ public sealed class UiMarkupList : UiElement return OnEventColumns(e, columns); IReadOnlyList 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) { _topRow -= Math.Sign(e.Data0); - ClampTop(items.Count, VisibleRows); + ClampTop(items.Count, visibleRows); return true; } if (e.Type != UiEventType.MouseDown || !Enabled) return false; int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight)); 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); return true; } @@ -304,9 +354,15 @@ public sealed class UiMarkupList : UiElement } _cachedRowCount = rowCount; - ComputeColumnLayout(columns, Width); - 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(); if (selected >= 0 && selected < rowCount) { @@ -367,6 +423,9 @@ public sealed class UiMarkupList : UiElement } } } + + if (showScrollbar) + DrawScrollbar(context, contentWidth, rowCount, visibleRows); } 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 // crash. 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) { _topRow -= Math.Sign(e.Data0); - ClampTop(rowCount, VisibleRows); + ClampTop(rowCount, visibleRows); return true; } 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 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 float localX = e.Data1; @@ -520,4 +585,162 @@ public sealed class UiMarkupList : UiElement } 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. + + /// Re-syncs 's content/view extents and + /// offset from this list's own — call immediately + /// before reading its geometry. + 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); + } + } + + /// Draw a sprite stretched 1:1 to the dest rect — same + /// convention / use for + /// their own button/thumb-cap art. + private static void DrawFlatSprite( + UiRenderContext ctx, Func 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); + } + + /// 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. + private static void DrawTiledSprite( + UiRenderContext ctx, Func 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); + } + + /// + /// Scrollbar hit-testing shared by the legacy single-column + /// and — arrows, track + /// paging, and thumb drag all work exactly as they do in + /// /the chat window's own docked bar, driving + /// this list's own . 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. + /// + 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); + } } diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 95a03f66..f183310b 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -884,9 +884,19 @@ public sealed class UiMenu : UiElement } /// Plain counterpart of — same - /// -sliced single column, plain - /// selected/hover row fills, and a plain scrollbar - /// () instead of the sprite chrome. + /// -sliced single column and plain + /// selected/hover row fills, but the SCROLLBAR itself draws retail's own + /// 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 + /// helper — the exact ids + /// wires onto etc, the SAME sprite ids the + /// chat SpewBox/inventory scrollbar uses. Rows + /// stay plain by design (the owner accepted the flat dark list; only the + /// bar was objectionable). A menu built with no + /// at all (a hand-built test/legacy fixture) falls back to the fully + /// flat rather than silently + /// drawing nothing. private void DrawScrollablePopupPlain(UiRenderContext ctx) { ConfigurePopupScroll(); @@ -918,17 +928,26 @@ public sealed class UiMenu : UiElement avail ? PlainTextColor : TextColorGhosted); } - DrawPopupScrollbarPlain(ctx, inX + ColumnWidth, inY); + if (SpriteResolve is { } resolve) + DrawPopupScrollbar(ctx, resolve, inX + ColumnWidth, inY); + else + DrawPopupScrollbarPlain(ctx, inX + ColumnWidth, inY); } /// - /// Plain counterpart of : a 1px-bordered - /// track and a flat thumb, both in — no DAT - /// thumb/track/arrow-button art at all. Shares the exact same - /// geometry (so the thumb's drawn - /// position matches '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 + /// NO-RESOLVER FALLBACK ONLY (see 's + /// own doc comment — the 2026-09-07 owner directive moved the normal + /// plain-popup scrollbar to retail's own chrome via + /// ). This draws a 1px-bordered track and + /// a flat thumb, both in — no DAT + /// thumb/track/arrow-button art at all — for the rare case a + /// is built with true but no + /// at all (a hand-built test/legacy fixture). + /// Shares the exact same geometry (so + /// the thumb's drawn position matches + /// '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 /// ( is unchanged). /// private void DrawPopupScrollbarPlain(UiRenderContext ctx, float x, float y) diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs index 3f713612..1c33d7cf 100644 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -496,24 +496,16 @@ public class MarkupDocumentTests } // ── Campaign VT slice 7 S7.2: (KB 08 §3 gap) ──── - - [Fact] - public void Build_MenuWithNoScrollAttribute_KeepsScrollableFalse() - { - const string xml = """ - - - - """; - var binding = new EditorBinding(); - - UiNineSlicePanel panel = MarkupDocument.Build( - xml, binding, _ => (1u, 32, 32)); - var menu = Assert.IsType(panel.Children[0]); - - Assert.False(menu.Scrollable); - } + // + // Superseded by the retail-scrollbar-chrome merge (`b71a8ea37`, slice 7 + // round D): a markup is now ALWAYS Scrollable (VTank's HudCombo + // is always a single scrolling column, never a wrapping grid), so the + // `scroll` attribute this comment used to gate no longer changes + // anything — see Menu_Markup_IsAlwaysScrollable_WithRetailScrollbarChromeWired + // below for the current contract. `Build_MenuWithNoScrollAttribute_ + // KeepsScrollableFalse` (which asserted the opt-in default) was removed + // here in the same commit that folded the two branches together, since + // it now directly contradicts the shipped behavior. [Fact] public void Build_MenuWithScrollAttribute_SetsUiMenuScrollableAndItsChromeSprites() @@ -581,71 +573,17 @@ public class MarkupDocumentTests s => s.Texture == menu.ScrollThumbSprite); } - // Plain sibling (default style, no style attribute): the flat thumb fill - // is an untextured DrawFill quad (UiTextureTableHandle.None == 0) sized - // ScrollbarWidth-2 wide and tinted PlainBorderColor — see - // UiMenu.DrawPopupScrollbarPlain. This is what actually renders today for - // any markup menu that doesn't opt into style="retail". - [Fact] - public void Menu_Scroll_DrawsAPlainFlatThumbFillWhenTheMarkupItemCountOverflowsTheVisibleRows() - { - const string xml = """ - - - - """; - var binding = new ManyChoicesBinding(); - - UiNineSlicePanel panel = MarkupDocument.Build( - xml, binding, id => (id, 16, 16)); - var menu = Assert.IsType(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"); - } + // Plain-style scrollbar chrome (default style, no style attribute): this + // used to assert a flat untextured DrawFill thumb (UiMenu. + // DrawPopupScrollbarPlain). Superseded by the retail-scrollbar-chrome + // merge (`b71a8ea37`, slice 7 round D, owner: "we use the same assets as + // we do in for example chat or inventory window") — a plain-style + // popup's scrollbar now draws the SAME sprite chrome (track/up/down/ + // three-part thumb) the chat/inventory scrollbar resolves through, + // regardless of RetailButtonArt; only the ROW fills stay plain. See + // UiMenuPlainStyleTests.Plain_OpenPopup_ScrollableOverflow_ + // DrawsRetailScrollbarChrome_RowsStayPlain for the current coverage of + // this exact scenario (plain menu, overflowing markup item count). private sealed class ManyChoicesBinding { @@ -713,4 +651,101 @@ public class MarkupDocumentTests Assert.Contains("menu", 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 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 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(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(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 = + "" + + "" + + ""; + var panel = MarkupDocument.Build(xml, binding, id => (id, 8, 8)); + var menu = Assert.IsType(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(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; + } } diff --git a/tests/AcDream.App.Tests/UI/MarkupResizableAnchorTests.cs b/tests/AcDream.App.Tests/UI/MarkupResizableAnchorTests.cs new file mode 100644 index 00000000..27feb01f --- /dev/null +++ b/tests/AcDream.App.Tests/UI/MarkupResizableAnchorTests.cs @@ -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; + +/// +/// 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 <panel resizable="true" minw= minh=> grammar and the +/// anchor="left top right bottom" attribute on <group>, +/// <list>, <menu>, <field>, +/// <label>, <button>, <icon>. +/// Semantics are the existing / +/// / 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). +/// +public sealed class MarkupResizableAnchorTests +{ + private sealed class ListBinding + { + public IReadOnlyList Items => ["A", "B", "C"]; + public int Selected { get; set; } = -1; + public Action 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 = ""; + 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 = ""; + 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 = + ""; + 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 = + ""; + 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 = + "" + + "