feat(chat): Campaign CH slice CH6a — retail chat-window layout + 8-grip resize
Swap ChatWindowController's imported main-chat LayoutDesc from the wrong 0x21000006 (an unrelated layout whose root and 800px resize bar appear nowhere in the EoR gameplay UI) to retail's ACTUAL main chat window, 0x2100006F (window root 0x10000600, authored 410x100 — confirmed by a direct DAT dump, found in dats.Local not dats.Portal). Every downstream compensation that existed only to paper over the wrong import is deleted: the hand-cropped 490px content width, the dropped 800px resize bar, the 9px transcript patch, the orphan-sibling pruning, the max/min-vs-scrollbar overlap shift, and the scrollbar top-reclaim. The window now mounts with RetailWindowChrome.Imported (0x2100006F's own 8 border/corner elements are its complete chrome) instead of the universal nine-slice wrapper. LayoutImporter/DatWidgetFactory gain a Type-9 (UIElement_Resizebar) case: UiResizeGrip decodes retail's exact four-bool BorderLocation algorithm (0x2A=bottom/0x2B=left/0x2C=right/0x2D=top, UIElement_Resizebar::StartMouseResizing @0x0046B7E0) into a ResizeEdges bitmask. A direct DAT dump established the true shape: only 7 of the 8 grip-position ids are Type 9 — the straight top-EDGE strip (0x1000069C) is a Type-2 Dragbar (move handle), not a Resizebar, because the main window has no title bar. UiRoot now gives a directly-hit grip's own edges priority over its generic proximity heuristic, and a directly-hit move handle the same priority over ambient proximity — so the plain top strip moves the window while its two corner grips resize it including the Y axis, and all 4 edges + 4 corners work everywhere else. This also fixes the reported "no diagonal cursor at corners" (CursorFeedbackController's existing RetailCursorCatalog cursor ids already matched the DAT exactly; they just never received a genuine diagonal edge combination) and "cannot grow in Y from the bottom-right corner" (the old NineSlice+crop mount's indirection is gone; the Imported mount uses the DAT's real minH=100/maxH=2000/minW=300/maxW=2000 directly). The 8 cosmetic "_Locked" border-art twins default hidden (register row AP-185 — retail's UiLocked-driven art swap between the two skins is not ported; UiRoot.UiLocked continues to gate the underlying interaction correctly either way). The 4 chat-window-1..4 indicator buttons import generically (visible, inert) for CH6b to wire. The two hand-drawn translucent-black tints on the transcript/input are removed now that their parent panels draw their own authored background sprites. Filed #366 (chat window's new-unseen-text indicator 0x1000048C is swallowed by UiText.ConsumesDatChildren, pre-existing and out of scope). Corrected the research doc's "all eight grips" claim against the direct DAT dump. Full Release suite: 12,317 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ab82347d42
commit
1fd515436c
19 changed files with 6734 additions and 5694 deletions
|
|
@ -166,6 +166,32 @@ headless when the graphical host hydrates fine) BEFORE touching
|
|||
`CanAdvancePlayer` — the #357 closeout warns that dormant-activation
|
||||
classification changes have a strict test matrix.
|
||||
|
||||
## #366 — Chat window's new-unseen-text indicator (0x1000048C) imports but is never independently wired
|
||||
|
||||
**Status:** OPEN — filed 2026-08-10, Campaign CH slice CH6a. The retail main
|
||||
chat window authors a 16×16 "new unseen text" indicator button
|
||||
(`0x1000048C`, base `0x10000527`/`0x21000040`) as a CHILD of the transcript
|
||||
text element `0x10000011` (position `(0,57)` relative to the transcript,
|
||||
i.e. bottom-left of the transcript pane), confirmed in the `0x2100006F`
|
||||
LayoutDesc dump. `UiText.ConsumesDatChildren` is `true` (Type-12 behavioral
|
||||
widgets reproduce their dat sub-elements procedurally per
|
||||
`DatWidgetFactory`'s own doc comment), so `LayoutImporter.BuildWidget`
|
||||
never builds `0x1000048C` as a separate widget — it is silently swallowed,
|
||||
same as under the wrong `0x21000006` layout before it (not a CH6a
|
||||
regression). No controller anywhere binds or drives its visible state.
|
||||
**Not in CH6a's scope** (transcript/input/scrollbar/1-4-buttons only) and
|
||||
not obviously CH6b/CH6c's either — files here as a standalone gap. Fix
|
||||
shape: either give `UiText` an opt-in mechanism to keep specific named
|
||||
non-Type-3 children (mirroring `UiMeter`'s existing text-overlay carve-out
|
||||
in `DatWidgetFactory.BuildWidget`), or handle `0x1000048C` as a special
|
||||
case the same way. Needs research first: what triggers retail's "new text"
|
||||
indicator (unread-since-scroll-position?) and what it visually does on
|
||||
click — not decoded by CH6a.
|
||||
|
||||
**Where:** `src/AcDream.App/UI/Layout/ChatWindowController.cs`;
|
||||
`src/AcDream.App/UI/Layout/LayoutImporter.cs`
|
||||
(`BuildWidget`/`ConsumesDatChildren` handling); `src/AcDream.App/UI/UiText.cs`.
|
||||
|
||||
## #364 — Three `/help` group topics still partial: HelpStupidChannelHack unresolved
|
||||
|
||||
**Status:** OPEN — filed 2026-08-09, Campaign CH user-gate round 2, item 3.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -30,9 +30,13 @@ regression; item 6 is folded into CH6a's existing scope. Item 1
|
|||
fixed — round 1's press-edge logic is provably correct by construction
|
||||
from source, a live headless repro was attempted and blocked (no
|
||||
character on the probe account), and two temporary graphical-only probes
|
||||
are left behind for the next round. Status stays CODE-COMPLETE pending
|
||||
the next user gate round (still needed for item 1, CH6, and a final
|
||||
in-client visual pass on everything fixed so far).
|
||||
are left behind for the next round. **CH6a (main-window layout + 8-grip
|
||||
resize) landed CODE-COMPLETE the same day** — see its ledger row and the
|
||||
updated round-1 items H/I and round-2 item 6 dispositions below; CH6b
|
||||
(floating windows 1–4) and CH6c (opacity) remain not started. Status
|
||||
stays CODE-COMPLETE pending the next user gate round (still needed for
|
||||
item 1, CH6a's own visual confirmation, CH6b/CH6c, and a final in-client
|
||||
visual pass on everything fixed so far).
|
||||
|
||||
**Why now:** first track of the alpha-release program (chat is the most
|
||||
visible daily surface for the friend-alpha). User-directed 2026-08-09.
|
||||
|
|
@ -135,6 +139,26 @@ implementer per slice against a pinned contract (per
|
|||
bottom-right corner (a corner-grip axis-composition bug, likely the
|
||||
same resize-mask gap already scoped for the Top edge above, now
|
||||
confirmed to also affect corner grips specifically).
|
||||
**CODE-COMPLETE (this commit).** The wrong layout swap, the 8-grip
|
||||
`LayoutImporter`/`UiResizeGrip` port, and both round-2 symptoms landed
|
||||
together: the real DAT ground-truth (`0x2100006F` lives in
|
||||
`dats.Local`, not `dats.Portal`) showed the top strip is authored as a
|
||||
Type-2 Dragbar, not an 8th Resizebar grip as the research doc's
|
||||
decomp-only reading assumed — `UiRoot` now gives a directly-hit grip's
|
||||
own edges priority over its generic proximity heuristic, and a
|
||||
directly-hit move handle the same priority over ambient proximity, so
|
||||
the plain top strip moves the window while its two corners (which ARE
|
||||
grips) resize it including the Y axis, matching retail exactly. The
|
||||
diagonal-cursor gap needed no new cursor plumbing —
|
||||
`CursorFeedbackController`/`RetailCursorCatalog` already had the exact
|
||||
DAT-matching Type-9 cursor ids pinned; they just never received a
|
||||
genuine diagonal edge combination before. The corner-grow gap
|
||||
dissolved with the `RetailWindowChrome.Imported` mount (0x2100006F's
|
||||
own border art IS the window chrome — no nine-slice wrapper, no
|
||||
content crop, frame==content) using the DAT's real
|
||||
minH=100/maxH=2000/minW=300/maxW=2000. Register row AP-185 files the
|
||||
one accepted simplification (the `_Locked` cosmetic border-art swap on
|
||||
`UiLocked` is not ported; the live grip skin shows unconditionally).
|
||||
- **CH6b — floating windows 1–4.** Mount `0x2100005B` ×4 as
|
||||
always-resident children per `gmGamePlayUI::SetupChildren
|
||||
@0x004E9EC0` (ids 0x10000505/0x1000050E/0x1000050F/0x10000510);
|
||||
|
|
@ -174,7 +198,8 @@ implementer per slice against a pinned contract (per
|
|||
| CH4 commands | `090825e7` | 12,221 passed / 4 skipped / 0 failed | REJECT; fixed `724ef2d3`; re-review APPROVE-WITH-FIXES; closed `5d247d55` | pending |
|
||||
| CH5 closeout | (this commit) | — (docs/memory only, no build) | — | pending (connected gate — see test script) |
|
||||
| User gate round 1 | (this commit) | 12,221 passed / 4 skipped / 0 failed (baseline; items A–G fixed this commit) | — | items A–G user-gate round 1 fixed; ten findings total, see "User gate — round 1" below |
|
||||
| CH6 chat-window shell parity | not started | — | — | filed 2026-08-09 at user gate round 1; research first |
|
||||
| CH6a main-window layout + 8-grip resize | (this commit) | 12,317 passed / 4 skipped / 0 failed | pending (no subagent review pass this session — implementer-only) | pending — needs the next in-client round (items H/I round 1, item 6 round 2) |
|
||||
| CH6b/CH6c floating windows + opacity | not started | — | — | not started |
|
||||
| User gate round 2 | (this commit) | 12,267 passed / 4 skipped / 0 failed | — | items 2/4/5 fixed this commit, item 3 confirmed-fixed, item 6 folded into CH6a's spec, item 1 NOT reproduced (see "User gate — round 2" below) |
|
||||
|
||||
### CH4 closeout (2026-08-09)
|
||||
|
|
@ -514,9 +539,9 @@ round's scope and filed as slice CH6.
|
|||
| E | `/hslist villas` (and the other three CH4-added request commands) is accepted server-side but produces no visible response. | **FIXED this SHA, closes #362.** New `ClientCommandResponses.cs` parses and renders `ChannelIndex`/`ChannelList`/`AvailableHouses`/`AllegianceInfoResponse`, each ported line-for-line from the named-retail decomp's inbound handlers. Register row TS-70 retired. |
|
||||
| F | Multi-line server text (e.g. `/help`'s reply) doesn't split on embedded `\n` — "probably broken in many places." | **FIXED this SHA.** `ChatWindowController.WrapText` now splits on `\n`/`\r\n` first, then word-wraps each segment independently; the confirmed-correct single-line early-out is unchanged for text with no embedded newline. |
|
||||
| G | The chat input line overflows the window's right edge when the window is resized. | **FIXED this SHA.** The input field's right edge no longer holds a fixed absolute pixel position across a resize (retail edge-mode 0's "frozen at current" fallback, or the `AnchorEdges` default with no `Right` bit) — `ChatWindowController.Bind` now upgrades it to retail edge-mode 1 (`UiLayoutPolicy`) or the equivalent `AnchorEdges.Right` stretch, so the right edge tracks every resize instead of only the bind-time/channel-change recompute. |
|
||||
| H | Extra/duplicate chat windows appear on number keys 1/2/3/4. | **NEW SLICE CH6** — see the slice list above. Retail has real multi-window chat with per-window type filters; this needs research before a fix, not a quick patch. |
|
||||
| I | Resizing the chat window only works from one corner, not every corner. | **NEW SLICE CH6** — bundled with H and J as "all-corner resize." |
|
||||
| J | The chat window has transparency issues / visual artifacts, and the user wants a transparency setting eventually. | **NEW SLICE CH6** — bundled as "opacity"; the future user-facing transparency setting is noted in the slice's own scope, not promised for CH6's first cut. |
|
||||
| H | Extra/duplicate chat windows appear on number keys 1/2/3/4. | **STILL CH6b** (not this commit) — retail's real floating windows 1–4 (`0x2100005B` ×4) and their `ToggleFloatingChatWindow1..4` keybinds are a separate slice; CH6a only fixed the shell (import/resize) of the main window. |
|
||||
| I | Resizing the chat window only works from one corner, not every corner. | **FIXED at CH6a (this commit).** Root cause: the main window imported the WRONG LayoutDesc (`0x21000006`, an unrelated layout whose root/resize-bar appear nowhere in the EoR gameplay UI) instead of retail's real `0x2100006F`; every symptom (crop hacks, the dropped resize bar, the one-corner-only resize) was downstream of that. The swap + a new `LayoutImporter` case for element type 9 (`UIElement_Resizebar`, `UiResizeGrip`) + `UiRoot` grip-priority hit-testing now resize from all 4 edges and all 4 corners, while the top strip (a Type-2 Dragbar, not a grip) correctly remains a move-only affordance. |
|
||||
| J | The chat window has transparency issues / visual artifacts, and the user wants a transparency setting eventually. | **Artifacts FIXED at CH6a (this commit)** — the reported visual glitches were downstream of importing the wrong LayoutDesc (stray unparented siblings, the hand-cropped content width, the 9px patch); all retired with the correct import, and the two hard-coded translucent-black tints on the transcript/input are removed now that their parent panels draw their own authored background sprites. **Real opacity (the future transparency SETTING) remains CH6c** — `UiRenderContext.AlphaMod` still has no draw-path consumer. |
|
||||
|
||||
Findings A–G's evidence: this commit's diff + the new/updated tests in
|
||||
`tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs`,
|
||||
|
|
@ -541,7 +566,7 @@ The user tested round 1's fixes live and reported six more findings.
|
|||
| 2 | Retail shows "In Portal Space…" at the TOP of the screen, SMALL font, tell-yellow (the SpewBox) — acdream renders a big centered white/yellow overlay instead. | **FIXED this SHA.** Verified in the decomp: `gmSmartBoxUI::UseTime`'s notice emits via `ECM_UI::SendNotice_DisplayStringInfo(0x1A, ...)`, which forwards to `AddTextToScroll(str, 0x1A, 1, 0)` — type `0x1A` is HARDCODED to the SpewBox (`docs/research/2026-08-09-chat-retail-interface-text.md` §1.1/§4.2), the same surface every other `ClientLocal` refusal (jump-in-air, etc.) already uses. `PortalWaitNoticeController` (the dedicated centered-overlay presentation) and its lease are DELETED outright; `PortalTunnelPresentation`'s round-1 per-rotation-segment emission cadence is unchanged, now writing straight into `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)` — the SpewBox's own dedupe-at-index-0 (`SpewBoxState.Tick`) collapses the per-segment repetition exactly as retail's `gmSpewBoxUI::Update` does. No conflict with the decomp — the fix and the user's report agree exactly. |
|
||||
| 3 | (Round 1 item B, `[System] ` prefix) | **CONFIRMED FIXED** — no regression, no further action. |
|
||||
| 4+5 | `/help` output differs from retail; `/help death` prints an acdream META-MESSAGE ("This is a retail help-topic group; acdream has not yet extracted its exact retail listing text…"); spacing/alignment is off. | **FIXED this SHA via verbatim extraction, not authorship.** `tools/pdb-extract/sweep_weenie_strings.py` generalized to decode narrow `PStringBase<char>` literals (the Help* family's shape) alongside its original UTF-16LE support, then swept every `ClientCommunicationSystem::HelpXxxGroup` function's exact byte extent (read from the pseudo-C's own function-header addresses) against the PDB-paired `C:\Users\erikn\Downloads\acclient.exe` (verified MATCH). 4 of 7 groups (death/status/text/allegiances) are now COMPLETE verbatim listings — `/help death` now prints retail's real 8-line text, byte-exact including the retail-authentic trailing space on the `@day` line. The other 3 (channels/chatting/commands) delegate part or all of their detail text to `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which builds its output from BN-mislabeled data fragments around a live channel-name lookup — genuinely not decodable with confidence; each keeps its own verbatim summary line plus an explicit UNVERIFIED note citing the address, never a fabricated meta-message. 7 of the ~35 channel one-liners are also now verbatim (a/guild/gu, general/cg, trade/ct, lfg/clfg, roleplay/crp, society/soc, olthoi/o), each including retail's own "Also: @alias" text — this is also the alignment/spacing fix, since the fabricated summaries never matched retail's exact wording. Register row AP-184 filed; ISSUES.md #364 tracks the remaining 3-group gap. `RetailCommandHelpTableTests.cs` (new) pins every complete listing and the partial/UNVERIFIED shape byte-exact. |
|
||||
| 6 | Resize is still buggy: missing diagonal (corner) cursor feedback, and the window cannot grow in the Y axis when dragging from the bottom-right corner. | **FOLDED into CH6a's spec** (not fixed this SHA — CH6a itself has not started; see the slice list above, now carrying these two symptoms alongside the pre-existing resize-mask/LayoutDesc-import scope). |
|
||||
| 6 | Resize is still buggy: missing diagonal (corner) cursor feedback, and the window cannot grow in the Y axis when dragging from the bottom-right corner. | **FIXED at CH6a (this commit).** The 4 corner grips (`0x1000069B`/`D`/`F`, `0x100006A1`) decode retail's exact per-grip `BorderLocation` bools (`0x2A`-`0x2D`) and `UiRoot` gives a directly-hit grip's own edges priority, so `CursorFeedbackController`'s existing (already retail-pinned) `KindForResize` now actually receives a diagonal edge combination when hovering a corner — no new cursor ids were invented; `RetailCursorCatalog`'s pinned Type-9 cursor ids already matched the DAT exactly. The corner-grow gap traced to the same wrong-LayoutDesc root cause as item I: mounting through the universal nine-slice wrapper + a 490px content crop against 0x21000006's own (irrelevant) constraints created an indirection prone to stale-baseline growth bugs; 0x2100006F's root IS the complete retail chrome, so CH6a mounts it with `RetailWindowChrome.Imported` (no wrapper, no crop, frame==content) using the DAT's real `minH=100/maxH=2000/minW=300/maxW=2000`, collapsing the whole class. `ChatLayoutConformanceTests.MountedChatWindow_BottomRightGrip_GrowsBothAxes_NotOnlyShrinks` exercises the exact reported gesture end-to-end. |
|
||||
|
||||
Evidence for items 2, 4, and 5: this commit's diff + `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs` (new) + the deletion of
|
||||
`tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs` (its subject
|
||||
|
|
@ -553,3 +578,15 @@ diagnostics; the two temporary probes and the `jump-probe` headless bot
|
|||
policy are the round's deliverable for item 1, pending either a successful
|
||||
next-round repro or a character created on `probeaccount2`. Full Release
|
||||
suite green (see the commit message for the exact count).
|
||||
|
||||
Item 6's evidence landed in a LATER commit, the CH6a slice itself (see the
|
||||
ledger row above): `src/AcDream.App/UI/UiResizeGrip.cs` (new),
|
||||
`src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (Type-9 factory case),
|
||||
`src/AcDream.App/UI/UiRoot.cs` (grip-priority hit-testing in `OnMouseDown` /
|
||||
`HoverResizeEdges`), `src/AcDream.App/UI/Layout/ChatWindowController.cs` and
|
||||
`src/AcDream.App/UI/RetailUiRuntime.cs` (the `0x2100006F` swap + `Imported`
|
||||
chrome mount), plus new/updated tests in
|
||||
`tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`,
|
||||
`tests/AcDream.App.Tests/UI/UiRootInputTests.cs`,
|
||||
`tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs`, and
|
||||
`tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs`.
|
||||
|
|
|
|||
|
|
@ -321,6 +321,26 @@ vtable+0x64 + `StopMouseResizing` → `UIElement::StopResizing @0x0045FD60`.
|
|||
edge and every corner, including the top and the top corners, on both the main
|
||||
and the floating chat windows.
|
||||
|
||||
**CORRECTED 2026-08-10 at Campaign CH slice CH6a implementation.** This claim
|
||||
is wrong for the main window's plain TOP EDGE specifically, established by a
|
||||
direct `ElementDesc.Type` dump of the installed DAT (ground truth, not a
|
||||
decomp reading) of the 8 ids `0x1000069B`-`0x100006A2`: 7 of them are
|
||||
Type 9 (`UIElement_Resizebar`) as claimed, but `0x1000069C` — the straight
|
||||
top-edge strip between the two top corners — is Type **2**
|
||||
(`UIElement_Dragbar`), not Type 9. So retail's main chat window is
|
||||
resizable from every edge and corner EXCEPT the plain top strip, which is a
|
||||
MOVE handle instead (there is no title bar, so the top strip does double
|
||||
duty as the drag affordance). The two top CORNERS (`0x1000069B` UL,
|
||||
`0x1000069D` UR) are still genuine Resizebar grips carrying the top bool, so
|
||||
dragging from a corner still resizes the Y/top axis — only the straight
|
||||
edge in between does not. This is fully consistent with, and explains, the
|
||||
`UIElement_Resizebar::StartMouseResizing` cursor media dump: `0x1000069C`
|
||||
carries cursor `0x06006119` (the four-arrow MOVE cursor, matching
|
||||
`WindowMove`), not one of the two diagonal or the vertical resize cursor
|
||||
ids the seven true grips carry. Not independently re-verified for the
|
||||
floating-window layout `0x2100005B` (CH6b's scope); assume it needs the
|
||||
same direct-dump check rather than trusting this row for that layout too.
|
||||
|
||||
### 2.4 Min/max extents
|
||||
|
||||
Retail reads them as ordinary integer attributes on the window element:
|
||||
|
|
@ -539,6 +559,15 @@ blob. Consequences for acdream:
|
|||
|
||||
### 5.2 Gap list
|
||||
|
||||
**G1 and G2 are CLOSED as of Campaign CH slice CH6a (2026-08-10).**
|
||||
`ChatWindowController.LayoutId`/`RootId` now import `0x2100006F`/`0x10000600`;
|
||||
the crop/rebase/orphan-pruning compensations are deleted; `LayoutImporter`
|
||||
gained a Type-9 case (`UiResizeGrip`); `UiRoot` gives a directly-hit grip's
|
||||
own edges priority over its generic proximity heuristic; the main window
|
||||
mounts with `RetailWindowChrome.Imported` (0x2100006F's own border art is
|
||||
its chrome). The two subsections below are kept verbatim as the historical
|
||||
diagnosis — do not re-run this investigation.
|
||||
|
||||
**G1 — Wrong LayoutDesc for the main window.**
|
||||
`ChatWindowController.cs:29` imports `0x21000006` with root `0x1000000E`.
|
||||
Retail's EoR main chat window is **LayoutDesc `0x2100006F`**, window element
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ using AcDream.UI.Abstractions.Panels.Chat;
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
|
||||
/// analogue of retail <c>ChatInterface</c> + <c>gmMainChatUI::PostInit @0x4ce130</c>.
|
||||
/// Binds the imported chat LayoutDesc (<c>0x2100006F</c>, retail's ACTUAL main chat
|
||||
/// window — see <c>docs/research/2026-08-09-chat-retail-window-shell.md</c> §2.1/§5,
|
||||
/// Campaign CH slice CH6a) to live behavior — the acdream analogue of retail
|
||||
/// <c>ChatInterface</c> + <c>gmMainChatUI::PostInit @0x4ce130</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// The transcript (<c>0x10000011</c>) is Type-12 and is built as a <see cref="UiText"/>
|
||||
|
|
@ -23,15 +25,31 @@ namespace AcDream.App.UI.Layout;
|
|||
/// place. The channel menu (<c>0x10000014</c>) is built as <see cref="UiMenu"/> (Type 6)
|
||||
/// and bound in place.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// CH6a retired the wrong <c>0x21000006</c> import (a different, unrelated chat
|
||||
/// layout whose root <c>0x1000000E</c> and 800px resize bar <c>0x1000000F</c>
|
||||
/// appear nowhere in the EoR gameplay UI) along with every downstream compensation
|
||||
/// that existed only to paper over it: the hand-cropped 490px content width, the
|
||||
/// dropped resize bar, the 9px transcript-panel patch, the orphan-sibling pruning,
|
||||
/// the max/min-vs-scrollbar overlap shift, and the scrollbar top-reclaim. All of
|
||||
/// those elements/gaps do not exist in <c>0x2100006F</c>'s authored 410×100 tree —
|
||||
/// nothing was ever missing; the wrong layout was imported. The eight authored
|
||||
/// resize grips (<c>0x1000069B</c>-<c>0x100006A2</c>, Type 9
|
||||
/// <c>UIElement_Resizebar</c> minus the top strip which is a Type-2
|
||||
/// <c>UIElement_Dragbar</c> move handle) import generically via
|
||||
/// <see cref="DatWidgetFactory"/>/<see cref="UiResizeGrip"/> and need no
|
||||
/// controller-side binding; <see cref="UiRoot"/> gives a directly-hit grip's own
|
||||
/// edges priority over its generic proximity heuristic.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChatWindowController : IRetainedWindowStateController, IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x21000006u;
|
||||
public const uint LayoutId = 0x2100006Fu;
|
||||
private bool _disposed;
|
||||
|
||||
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
|
||||
private const uint RootId = 0x1000000Eu;
|
||||
private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px — dropped; nine-slice grips replace it)
|
||||
// Element ids from chat LayoutDesc 0x2100006F (Campaign CH slice CH6a).
|
||||
private const uint RootId = 0x10000600u; // gmFloatyMainChatUI window root, 410x100
|
||||
private const uint TranscriptPanelId = 0x10000010u;
|
||||
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
|
||||
private const uint TrackId = 0x10000012u;
|
||||
|
|
@ -41,6 +59,31 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
private const uint SendId = 0x10000019u;
|
||||
private const uint MaxMinId = 0x1000046Fu;
|
||||
|
||||
// Chat-window 1-4 state-mirror indicator buttons
|
||||
// (gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80): the button lights
|
||||
// up iff the corresponding floating chat window is visible. The mechanism is
|
||||
// one-directional (window visibility drives the button, not the reverse) and
|
||||
// the floating windows themselves are CH6b's scope — these import generically
|
||||
// (visible, inert) here and are left for CH6b to wire.
|
||||
private const uint Indicator1Id = 0x10000522u;
|
||||
private const uint Indicator2Id = 0x10000523u;
|
||||
private const uint Indicator3Id = 0x10000524u;
|
||||
private const uint Indicator4Id = 0x10000525u;
|
||||
|
||||
// The 8 cosmetic "_Locked" border-art twins
|
||||
// (gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0 swaps these in for the 8
|
||||
// live Resizebar/Dragbar grips above when PlayerModule::LockUI is true — see
|
||||
// research doc §1.6). CH6a does not implement the lock-state art swap (register
|
||||
// row AP-185 — UiRoot.UiLocked already gates INTERACTION generically, independent
|
||||
// of which art is shown); default to the unlocked visual (hide these, show the
|
||||
// live grips) to match UiRoot's own UiLocked=false default and avoid double
|
||||
// rendering two overlapping border-art layers.
|
||||
private static readonly uint[] LockedTwinIds =
|
||||
{
|
||||
0x10000693u, 0x10000694u, 0x10000695u, 0x10000696u,
|
||||
0x10000697u, 0x10000698u, 0x10000699u, 0x1000069Au,
|
||||
};
|
||||
|
||||
// Channel menu sprite ids (confirmed in chat element dump).
|
||||
private const uint MenuNormal = 0x06004D65u; // button face
|
||||
private const uint MenuPressed = 0x06004D66u; // button pressed
|
||||
|
|
@ -182,15 +225,10 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
return null;
|
||||
}
|
||||
|
||||
// LayoutDesc 0x21000006 has SEVERAL top-level elements: the gmMainChatUI window
|
||||
// (RootId 0x1000000E) PLUS stray auxiliary elements that are NOT part of the docked
|
||||
// window — a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the
|
||||
// talk-focus highlight strip (0x1000001E), and a scroll-button prototype (0x10000526).
|
||||
// LayoutImporter.ImportInfos wraps all top-level elements in a synthetic Type-3 root,
|
||||
// so using layout.Root would render the strays overlapping the real window (the
|
||||
// red-striped garbage in the first live render). Use the gmMainChatUI window itself:
|
||||
// GameWindow adds this to the host, which re-parents it out of the synthetic wrapper,
|
||||
// orphaning the strays so they never draw.
|
||||
// LayoutDesc 0x2100006F has exactly ONE top-level element — the
|
||||
// gmFloatyMainChatUI window root itself (RootId 0x10000600, authored
|
||||
// 410x100). No stray auxiliary siblings, no synthetic-wrapper orphaning
|
||||
// needed: FindElement/layout.Root agree.
|
||||
var window = layout.FindElement(RootId) ?? layout.Root;
|
||||
var c = new ChatWindowController
|
||||
{
|
||||
|
|
@ -198,17 +236,13 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo,
|
||||
};
|
||||
|
||||
// Drop the dat top resize bar (0x1000000F): it is authored 800px wide and
|
||||
// juts out of the content-width window. The host wraps this content in the
|
||||
// universal nine-slice chrome, whose grips provide the resize affordance.
|
||||
if (layout.FindElement(ResizeBarId) is { Parent: { } rbParent } resizeBar)
|
||||
rbParent.RemoveChild(resizeBar);
|
||||
|
||||
// Reclaim the 9px strip the dropped resize bar occupied (rows 0-8 of the root):
|
||||
// grow the transcript panel up to the window top so its dark bg fills the strip.
|
||||
// Otherwise the root element's brown bg shows through as a sliver along the top.
|
||||
transcriptPanel.Top = 0f;
|
||||
transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9)
|
||||
// The 8 cosmetic "_Locked" border-art twins default HIDDEN — CH6a does not
|
||||
// implement retail's UiLocked-driven art swap (see the class doc + the
|
||||
// LockedTwinIds field comment); the 8 live grip/dragbar elements (which
|
||||
// occupy the SAME rects one ReadOrder layer above) are the ones shown.
|
||||
foreach (uint id in LockedTwinIds)
|
||||
if (layout.FindElement(id) is { } twin)
|
||||
twin.Visible = false;
|
||||
|
||||
// ── Transcript ───────────────────────────────────────────────────
|
||||
// The factory now builds the Type-12 transcript element (0x10000011) as a UiText.
|
||||
|
|
@ -225,7 +259,11 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
c.Transcript.RightAligned = false;
|
||||
c.Transcript.OneLine = false;
|
||||
c.Transcript.Selectable = true;
|
||||
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
||||
// No hand-drawn tint (Campaign CH slice CH6a, item 5): the transcript's
|
||||
// PARENT panel (0x10000010) carries its own authored background sprite
|
||||
// (0x06004CC2) that draws automatically via the generic UiDatElement
|
||||
// fallback — a flat overlay here only mismatched it under the wrong
|
||||
// (0x21000006) layout, whose transcript panel lacked one.
|
||||
c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm);
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────
|
||||
|
|
@ -234,7 +272,9 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
c.Input = input;
|
||||
c.Input.DatFont = datFont;
|
||||
c.Input.Font = debugFont;
|
||||
c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f);
|
||||
// No hand-drawn tint here either — the input ROW panel (0x10000013) carries
|
||||
// its own authored background sprite (0x0600113A); same reasoning as the
|
||||
// transcript above.
|
||||
c.Input.SpriteResolve = resolve;
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||
|
||||
|
|
@ -272,14 +312,12 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
|
||||
// ── Scrollbar — bind the factory-built Type-11 track element ────────
|
||||
// The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar
|
||||
// directly. Find it, bind it in place — no remove/add needed.
|
||||
// directly. Find it, bind it in place — no remove/add needed. The dat authors it
|
||||
// flush with the transcript panel's top (Top=0, Height=73 == the panel's own
|
||||
// height) — no top-reclaim adjustment needed under the correct layout.
|
||||
var track = layout.FindElement(TrackId);
|
||||
if (track is UiScrollbar bar)
|
||||
{
|
||||
float oldTop = bar.Top;
|
||||
bar.Top = 0f; // pull up to the panel top (resize-bar reclaim)
|
||||
bar.Height = bar.Height + oldTop;
|
||||
bar.ResetAnchorCapture();
|
||||
bar.Model = c.Transcript.Scroll;
|
||||
bar.SpriteResolve ??= resolve;
|
||||
c.Scrollbar = bar;
|
||||
|
|
@ -343,14 +381,11 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
}
|
||||
|
||||
// ── Max/min toggle — gmMainChatUI::HandleMaximizeButton ──
|
||||
// The dat already authors max/min (368,5,16,16) just left of the scrollbar
|
||||
// column (389,5,16,73 abs) with a natural 5px gap — no overlap under the
|
||||
// correct layout, so no positional shift is needed.
|
||||
if (layout.FindElement(MaxMinId) is UiButton maxMinEl)
|
||||
{
|
||||
// The dat puts max/min and the scrollbar up-button at the SAME X (both
|
||||
// right-anchored), so at content width they overlap. Retail shows max/min
|
||||
// just LEFT of the scrollbar column — shift it one button-width left.
|
||||
if (track is not null)
|
||||
maxMinEl.Left = track.Left - maxMinEl.Width;
|
||||
maxMinEl.ResetAnchorCapture();
|
||||
c._maxMinButton = maxMinEl;
|
||||
maxMinEl.OnClick = c.ToggleMaximize;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ public static class DatWidgetFactory
|
|||
Func<UiStringInfoValue, string?>? stringResolve = null)
|
||||
{
|
||||
// Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts
|
||||
// (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome +
|
||||
// (vitals 0x2100006C / chat 0x2100006F) Type-3 elements are sprite-bearing chrome +
|
||||
// containers (the 8-piece bevel corners/edges, the transcript/input panels), NOT
|
||||
// editable fields — retail draws those as inert media-bearing Fields, which our
|
||||
// UiDatElement reproduces pixel-for-pixel (and without the spurious focus/edit
|
||||
|
|
@ -95,6 +95,7 @@ public static class DatWidgetFactory
|
|||
// gmUIElement_*Indicator custom button classes
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
||||
9 => BuildResizeGrip(info), // UIElement_Resizebar (reg 0x0046B920)
|
||||
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
||||
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
|
||||
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
|
||||
|
|
@ -255,6 +256,29 @@ public static class DatWidgetFactory
|
|||
return bar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="UiResizeGrip"/> from a Type-9 <c>UIElement_Resizebar</c>
|
||||
/// element: retail <c>StartMouseResizing @0x0046B7E0</c> reads four BOOL
|
||||
/// attributes — <c>0x2A</c>=bottom, <c>0x2B</c>=left, <c>0x2C</c>=right,
|
||||
/// <c>0x2D</c>=top — and decodes them into a <c>BorderLocation</c>. A grip with
|
||||
/// no true bool (the inert <c>_Locked</c> cosmetic twins are a DIFFERENT
|
||||
/// element type entirely and never reach this factory case, but an
|
||||
/// all-false Type-9 element is handled defensively the same way retail's own
|
||||
/// BORDER_NONE fallback does) decodes to <see cref="UiResizeGrip.Border.None"/>
|
||||
/// — <see cref="UiRoot"/> then treats it as contributing no resize edges.
|
||||
/// </summary>
|
||||
private static UiResizeGrip BuildResizeGrip(ElementInfo info)
|
||||
{
|
||||
bool bottom = info.TryGetEffectiveBool(0x2Au, out bool bottomValue) && bottomValue;
|
||||
bool left = info.TryGetEffectiveBool(0x2Bu, out bool leftValue) && leftValue;
|
||||
bool right = info.TryGetEffectiveBool(0x2Cu, out bool rightValue) && rightValue;
|
||||
bool top = info.TryGetEffectiveBool(0x2Du, out bool topValue) && topValue;
|
||||
return new UiResizeGrip
|
||||
{
|
||||
BorderLocation = UiResizeGrip.DecodeBorderLocation(bottom, left, right, top),
|
||||
};
|
||||
}
|
||||
|
||||
private static UiLayoutPolicy? CreateLayoutPolicy(ElementInfo info)
|
||||
{
|
||||
if (!info.HasOriginalParentSize) return null;
|
||||
|
|
|
|||
|
|
@ -684,7 +684,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
if (info is null || layout is null)
|
||||
{
|
||||
Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found.");
|
||||
Console.WriteLine("[D.2b] chat: LayoutDesc 0x2100006F not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -698,7 +698,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Assets.ResolveSprite);
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006.");
|
||||
Console.WriteLine("[D.2b] chat: required role elements missing in 0x2100006F.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -712,27 +712,39 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Chat,
|
||||
Chrome = RetailWindowChrome.NineSlice,
|
||||
// 0x2100006F's root already IS the complete retail window — its own
|
||||
// 8 border/corner elements (the live Resizebar/Dragbar grips, or their
|
||||
// _Locked cosmetic twins) are its chrome, at its authored 410x100
|
||||
// extent. No universal nine-slice wrapper or content crop needed
|
||||
// (Campaign CH slice CH6a; the wrapper + 490px crop were compensations
|
||||
// for importing the wrong 0x21000006 layout).
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Left = 10f,
|
||||
Top = 440f,
|
||||
ContentWidth = 490f,
|
||||
ContentHeight = root.Height,
|
||||
RebaseContentLayout = true,
|
||||
DatConstraintSource = controller.DatWindowInfo,
|
||||
MinWidth = 200f,
|
||||
// Bumping this resets any locally-persisted width/height captured
|
||||
// under the old, wrong-layout mount (490px-cropped content, patched
|
||||
// 9px height) to the newly authored 410x100 default on next load.
|
||||
AuthoredGeometryRevision = 1,
|
||||
ResizeX = true,
|
||||
ResizeY = true,
|
||||
// The imported 9px top Resizebar is deliberately removed by
|
||||
// ChatWindowController. Its replacement top chrome is the move
|
||||
// affordance; only the other three wrapper edges resize.
|
||||
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Bottom,
|
||||
Opacity = 0.75f,
|
||||
// All 4 edges + 4 corners resize via the authored Resizebar grips
|
||||
// (UiRoot gives a directly-hit grip's own edges priority over this
|
||||
// blanket mask); the default already covers every edge, and the
|
||||
// plain top-STRIP correctly remains a move-only affordance because
|
||||
// its own element is a Type-2 Dragbar, not a grip (research doc §2.1,
|
||||
// §2.3 — the top edge authors NO Resizebar of its own; only its two
|
||||
// corners do).
|
||||
// Opacity: retail's whole-window alpha (ChatInterface::SetOpacity
|
||||
// @0x004F3120) is CH6c's scope — UiRenderContext.AlphaMod currently has
|
||||
// no draw-path consumer, so any value here is presentation-inert; leave
|
||||
// the frame at the default 1f rather than assert a value with no effect.
|
||||
Controller = controller,
|
||||
StateController = controller,
|
||||
});
|
||||
controller.AttachWindow(handle);
|
||||
Host.Root.DefaultTextInput = controller.Input;
|
||||
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006).");
|
||||
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x2100006F).");
|
||||
}
|
||||
|
||||
private void MountToolbar()
|
||||
|
|
|
|||
85
src/AcDream.App/UI/UiResizeGrip.cs
Normal file
85
src/AcDream.App/UI/UiResizeGrip.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIElement_Resizebar</c> (element class 9,
|
||||
/// <c>UIElement_Resizebar::Register @0x0046B920</c> registers class 9 with the
|
||||
/// LayoutDesc element factory). A leaf grip authored at exactly one edge or
|
||||
/// corner of a window; retail authors 4 edge grips + 4 corner grips per
|
||||
/// resizable window (main chat: <c>0x1000069B</c>-<c>0x100006A2</c>, minus the
|
||||
/// straight top strip which is a retail <c>UIElement_Dragbar</c> move handle
|
||||
/// instead — see the class doc on <see cref="AcDream.App.UI.Layout.DatWidgetFactory"/>
|
||||
/// and <c>docs/research/2026-08-09-chat-retail-window-shell.md</c> §2.1/§2.3).
|
||||
///
|
||||
/// <para>
|
||||
/// <c>UIElement_Resizebar::StartMouseResizing @0x0046B7E0</c> reads four BOOL
|
||||
/// attributes off the element — <c>0x2A</c>=bottom, <c>0x2B</c>=left,
|
||||
/// <c>0x2C</c>=right, <c>0x2D</c>=top — and combines them into a
|
||||
/// <c>BorderLocation</c> (<c>acclient.h:4327</c>). <see cref="DecodeBorderLocation"/>
|
||||
/// is a verbatim port of that branch order. acdream's engine expresses resize
|
||||
/// direction as an edge-flag bitmask (<see cref="ResizeEdges"/>) rather than
|
||||
/// retail's nine-case enum; <see cref="Edges"/> is the lossless projection —
|
||||
/// every <see cref="Border"/> value maps to exactly one <see cref="ResizeEdges"/>
|
||||
/// combination and back.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// A grip is a small (5px) leaf with no children and no authored background
|
||||
/// media beyond its own cursor-feedback sprite; it exists purely as a precise
|
||||
/// hit region. <see cref="AcDream.App.UI.UiRoot"/> gives a directly-hit grip's
|
||||
/// own <see cref="Edges"/> priority over the generic proximity-based
|
||||
/// <see cref="AcDream.App.UI.UiRoot.HitEdges"/> heuristic, so e.g. the chat
|
||||
/// window's top-left/top-right CORNER grips resize (including the Y/top axis)
|
||||
/// while the plain top EDGE strip in between remains a pure move handle.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiResizeGrip : UiElement
|
||||
{
|
||||
/// <summary>Retail <c>BorderLocation</c> (<c>acclient.h:4327</c>):
|
||||
/// <c>BORDER_NONE=0, BORDER_UL=1, BORDER_TOP=2, BORDER_UR=3, BORDER_RIGHT=4,
|
||||
/// BORDER_LR=5, BORDER_BOTTOM=6, BORDER_LL=7, BORDER_LEFT=8</c>.</summary>
|
||||
public enum Border
|
||||
{
|
||||
None = 0,
|
||||
UpperLeft = 1,
|
||||
Top = 2,
|
||||
UpperRight = 3,
|
||||
Right = 4,
|
||||
LowerRight = 5,
|
||||
Bottom = 6,
|
||||
LowerLeft = 7,
|
||||
Left = 8,
|
||||
}
|
||||
|
||||
/// <summary>Decoded retail BorderLocation for this grip.</summary>
|
||||
public Border BorderLocation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Exact port of <c>UIElement_Resizebar::StartMouseResizing @0x0046B7E0</c>'s
|
||||
/// if/else-if chain (right beats left beats top beats bottom when more than
|
||||
/// two bools are set, which retail's authored data never does — corners set
|
||||
/// exactly two adjacent bools, edges set exactly one).
|
||||
/// </summary>
|
||||
public static Border DecodeBorderLocation(bool bottom, bool left, bool right, bool top)
|
||||
{
|
||||
if (right) return top ? Border.UpperRight : bottom ? Border.LowerRight : Border.Right;
|
||||
if (left) return top ? Border.UpperLeft : bottom ? Border.LowerLeft : Border.Left;
|
||||
if (top) return Border.Top;
|
||||
if (bottom) return Border.Bottom;
|
||||
return Border.None;
|
||||
}
|
||||
|
||||
/// <summary>The <see cref="ResizeEdges"/> projection <see cref="UiRoot"/>'s resize
|
||||
/// drag state machine consumes — a lossless re-encoding of <see cref="BorderLocation"/>.</summary>
|
||||
public ResizeEdges Edges => BorderLocation switch
|
||||
{
|
||||
Border.UpperLeft => ResizeEdges.Left | ResizeEdges.Top,
|
||||
Border.Top => ResizeEdges.Top,
|
||||
Border.UpperRight => ResizeEdges.Right | ResizeEdges.Top,
|
||||
Border.Right => ResizeEdges.Right,
|
||||
Border.LowerRight => ResizeEdges.Right | ResizeEdges.Bottom,
|
||||
Border.Bottom => ResizeEdges.Bottom,
|
||||
Border.LowerLeft => ResizeEdges.Left | ResizeEdges.Bottom,
|
||||
Border.Left => ResizeEdges.Left,
|
||||
_ => ResizeEdges.None,
|
||||
};
|
||||
}
|
||||
|
|
@ -91,11 +91,47 @@ public sealed class UiRoot : UiElement
|
|||
{
|
||||
var target = Pick(MouseX, MouseY);
|
||||
var window = FindWindow(target);
|
||||
return !UiLocked && window is { Resizable: true }
|
||||
? HitEdges(window, MouseX, MouseY, ResizeGrip)
|
||||
: ResizeEdges.None;
|
||||
if (UiLocked || window is not { Resizable: true })
|
||||
return ResizeEdges.None;
|
||||
|
||||
// A directly-hovered authored Resizebar grip (retail element class 9)
|
||||
// is precise geometry, not a proximity heuristic — it wins outright.
|
||||
var gripEdges = EffectiveGripEdges(target, window);
|
||||
if (gripEdges != ResizeEdges.None)
|
||||
return gripEdges;
|
||||
|
||||
// An authored move handle (retail UIElement_Dragbar, class 2) directly
|
||||
// under the cursor takes precedence over ambient edge-proximity — e.g.
|
||||
// the main chat window's top strip moves the window even though its
|
||||
// own top-left/top-right CORNERS (separate Resizebar grips) resize it
|
||||
// (docs/research/2026-08-09-chat-retail-window-shell.md §2.1/§2.3).
|
||||
if (FindDragHandleWindow(target) is not null)
|
||||
return ResizeEdges.None;
|
||||
|
||||
return HitEdges(window, MouseX, MouseY, ResizeGrip);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the resize edges a literal, directly-hit <see cref="UiResizeGrip"/>
|
||||
/// contributes, masked by the window's own <see cref="UiElement.ResizeX"/> /
|
||||
/// <see cref="UiElement.ResizeY"/> axis gates and <see cref="UiElement.ResizableEdges"/>
|
||||
/// mask (the same gates <see cref="HitEdges"/> applies to its proximity heuristic).
|
||||
/// Returns <see cref="ResizeEdges.None"/> when <paramref name="target"/> is not
|
||||
/// itself an authored grip.
|
||||
/// </summary>
|
||||
private static ResizeEdges EffectiveGripEdges(UiElement? target, UiElement? window)
|
||||
{
|
||||
if (window is null || target is not UiResizeGrip grip)
|
||||
return ResizeEdges.None;
|
||||
|
||||
ResizeEdges edges = grip.Edges;
|
||||
if (!window.ResizeX) edges &= ~(ResizeEdges.Left | ResizeEdges.Right);
|
||||
if (!window.ResizeY) edges &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
|
||||
edges &= window.ResizableEdges;
|
||||
return edges;
|
||||
}
|
||||
|
||||
public bool HoverWindowMove
|
||||
{
|
||||
get
|
||||
|
|
@ -469,9 +505,18 @@ public sealed class UiRoot : UiElement
|
|||
if (raiseWindow is not null) BringToFront(raiseWindow);
|
||||
if (btn == UiMouseButton.Left && raiseWindow is not null && !UiLocked)
|
||||
{
|
||||
var edges = window is { Resizable: true }
|
||||
? HitEdges(window, x, y, ResizeGrip)
|
||||
: ResizeEdges.None;
|
||||
// A directly-pressed authored Resizebar grip is precise geometry and
|
||||
// wins outright, regardless of an overlapping move handle or the
|
||||
// generic proximity heuristic. Failing that, a directly-pressed move
|
||||
// handle (dragbar) wins over ambient edge-proximity — the chat
|
||||
// window's top strip must move the window even though it sits
|
||||
// within ResizeGrip px of the window's own top edge.
|
||||
var gripEdges = EffectiveGripEdges(target, window);
|
||||
var edges = gripEdges != ResizeEdges.None
|
||||
? gripEdges
|
||||
: (handleWindow is null && window is { Resizable: true }
|
||||
? HitEdges(window, x, y, ResizeGrip)
|
||||
: ResizeEdges.None);
|
||||
if (edges != ResizeEdges.None)
|
||||
{
|
||||
// Edge resize still wins, even over a CapturesPointerDrag child:
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ namespace AcDream.App.UI;
|
|||
/// thumb size = trackLen * ThumbRatio (min 8px); step ±1 line.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68),
|
||||
/// thumb 0x1000048C. The track is instanced from base layout 0x2100003E which contains
|
||||
/// Dat element ids (chat LayoutDesc 0x2100006F, Campaign CH slice CH6a — retired the
|
||||
/// wrong 0x21000006 import): track 0x10000012 (X=384 Y=0 W=16 H=73 relative to the
|
||||
/// transcript panel). The track is instanced from base layout 0x2100003E which contains
|
||||
/// the full scrollbar widget with distinct up/down button children:
|
||||
/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69.
|
||||
/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C.
|
||||
|
|
|
|||
|
|
@ -97,6 +97,63 @@ public sealed class CursorFeedbackControllerTests
|
|||
Assert.Equal(new UiCursorMedia(0x06006128u, 16, 16), active.Cursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CornerGrip_ShowsTheDiagonalResizeCursor_NotTheStraightOne()
|
||||
{
|
||||
// Campaign CH slice CH6a, user-gate round 2 item 6: hovering a directly
|
||||
// authored corner Resizebar grip (not just the generic proximity edge)
|
||||
// must resolve to the retail diagonal cursor, matching the same pinned
|
||||
// ids as ResizeEdges_chooseExpectedRetailCursor (0x06006126 for the
|
||||
// NW-SE UL/LR diagonal).
|
||||
var root = new UiRoot { Width = 400, Height = 300 };
|
||||
var window = new UiPanel
|
||||
{
|
||||
Left = 50, Top = 40, Width = 150, Height = 100,
|
||||
Draggable = true, Resizable = true,
|
||||
};
|
||||
var grip = new UiResizeGrip
|
||||
{
|
||||
BorderLocation = UiResizeGrip.Border.LowerRight,
|
||||
Left = 145, Top = 95, Width = 5, Height = 5,
|
||||
};
|
||||
window.AddChild(grip);
|
||||
root.AddChild(window);
|
||||
var controller = new CursorFeedbackController();
|
||||
|
||||
var gs = grip.ScreenPosition;
|
||||
root.OnMouseMove((int)(gs.X + 2), (int)(gs.Y + 2));
|
||||
|
||||
CursorFeedback feedback = controller.Update(root);
|
||||
Assert.Equal(CursorFeedbackKind.ResizeDiagonalNwse, feedback.Kind);
|
||||
Assert.Equal(new UiCursorMedia(0x06006126u, 16, 16), feedback.Cursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeGrip_ShowsTheStraightResizeCursor()
|
||||
{
|
||||
var root = new UiRoot { Width = 400, Height = 300 };
|
||||
var window = new UiPanel
|
||||
{
|
||||
Left = 50, Top = 40, Width = 150, Height = 100,
|
||||
Draggable = true, Resizable = true,
|
||||
};
|
||||
var grip = new UiResizeGrip
|
||||
{
|
||||
BorderLocation = UiResizeGrip.Border.Bottom,
|
||||
Left = 5, Top = 95, Width = 140, Height = 5,
|
||||
};
|
||||
window.AddChild(grip);
|
||||
root.AddChild(window);
|
||||
var controller = new CursorFeedbackController();
|
||||
|
||||
var gs = grip.ScreenPosition;
|
||||
root.OnMouseMove((int)(gs.X + 2), (int)(gs.Y + 2));
|
||||
|
||||
CursorFeedback feedback = controller.Update(root);
|
||||
Assert.Equal(CursorFeedbackKind.ResizeVertical, feedback.Kind);
|
||||
Assert.Equal(new UiCursorMedia(0x06005E66u, 16, 16), feedback.Cursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredWidgetCursor_winsUntilSyntheticWindowMoveStarts()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ using AcDream.UI.Abstractions.Panels.Chat;
|
|||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Dat-free conformance tests for the committed chat_21000006.json golden fixture.
|
||||
/// Verifies that LayoutImporter.ImportInfos correctly resolves the BaseElement /
|
||||
/// BaseLayoutId inheritance chain for the chat window (LayoutDesc 0x21000006).
|
||||
/// Dat-free conformance tests for the committed chat_2100006f.json golden fixture —
|
||||
/// retail's ACTUAL main chat window (LayoutDesc <c>0x2100006F</c>, window root
|
||||
/// <c>0x10000600</c>, authored 410x100; Campaign CH slice CH6a). Verifies that
|
||||
/// LayoutImporter.ImportInfos correctly resolves the BaseElement / BaseLayoutId
|
||||
/// inheritance chain.
|
||||
/// </summary>
|
||||
public class ChatLayoutConformanceTests
|
||||
{
|
||||
|
|
@ -50,6 +52,72 @@ public class ChatLayoutConformanceTests
|
|||
Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input)
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x10000522u)]
|
||||
[InlineData(0x10000523u)]
|
||||
[InlineData(0x10000524u)]
|
||||
[InlineData(0x10000525u)]
|
||||
public void ChatFixture_ChatWindowIndicatorButtons_ImportFromTheAuthoredTree(uint indicatorId)
|
||||
{
|
||||
// gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80 writes these
|
||||
// four state-mirror buttons for floating chat windows 1-4. CH6a imports
|
||||
// them generically (visible, inert) and leaves wiring their live/pressed
|
||||
// state to CH6b's floating-window slice.
|
||||
var root = FixtureLoader.LoadChatInfos();
|
||||
Assert.NotNull(Find(root, indicatorId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x10000522u)]
|
||||
[InlineData(0x10000523u)]
|
||||
[InlineData(0x10000524u)]
|
||||
[InlineData(0x10000525u)]
|
||||
public void MountedChatWindow_IndicatorButtons_ImportVisibleAndInert(uint indicatorId)
|
||||
{
|
||||
var infos = FixtureLoader.LoadChatInfos();
|
||||
var layout = LayoutImporter.Build(infos, NoTex, null);
|
||||
var controller = ChatWindowController.Bind(
|
||||
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, null, null, NoTex);
|
||||
Assert.NotNull(controller);
|
||||
|
||||
UiElement? indicator = layout.FindElement(indicatorId);
|
||||
Assert.NotNull(indicator);
|
||||
Assert.True(indicator!.Visible);
|
||||
// Inert: CH6a does not wire a click handler (CH6b's job — the
|
||||
// one-directional visibility mirror has no button-press behavior of
|
||||
// its own in retail either, per the research doc §1.4). UiButton
|
||||
// structurally always reports HandlesClick=true (it can receive the
|
||||
// event), but with no OnClick/OnClickAt bound, a click is a no-op.
|
||||
var button = Assert.IsType<UiButton>(indicator);
|
||||
Assert.Null(button.OnClick);
|
||||
Assert.Null(button.OnClickAt);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x10000693u)]
|
||||
[InlineData(0x10000694u)]
|
||||
[InlineData(0x10000695u)]
|
||||
[InlineData(0x10000696u)]
|
||||
[InlineData(0x10000697u)]
|
||||
[InlineData(0x10000698u)]
|
||||
[InlineData(0x10000699u)]
|
||||
[InlineData(0x1000069Au)]
|
||||
public void MountedChatWindow_LockedTwinBorderArt_DefaultsHidden(uint lockedTwinId)
|
||||
{
|
||||
// Register row AP-185: CH6a shows only the live (unlocked) grip/dragbar
|
||||
// border-art set by default, matching UiRoot.UiLocked's own false
|
||||
// default and avoiding a double-rendered border.
|
||||
var infos = FixtureLoader.LoadChatInfos();
|
||||
var layout = LayoutImporter.Build(infos, NoTex, null);
|
||||
var controller = ChatWindowController.Bind(
|
||||
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, null, null, NoTex);
|
||||
Assert.NotNull(controller);
|
||||
|
||||
UiElement? twin = layout.FindElement(lockedTwinId);
|
||||
Assert.NotNull(twin);
|
||||
Assert.False(twin!.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatFixture_BuildsSelectableTranscriptAndEditableInputInPlace()
|
||||
{
|
||||
|
|
@ -82,16 +150,39 @@ public class ChatLayoutConformanceTests
|
|||
public void ChatFixture_WindowRootCarriesRetailHeightConstraints()
|
||||
{
|
||||
var root = FixtureLoader.LoadChatInfos();
|
||||
var window = Find(root, 0x1000000Eu)!;
|
||||
var window = Find(root, 0x10000600u)!;
|
||||
|
||||
Assert.True(window.TryGetEffectiveInteger(0x3Eu, out int minHeight));
|
||||
Assert.True(window.TryGetEffectiveInteger(0x3Cu, out int maxHeight));
|
||||
Assert.Equal(100, minHeight);
|
||||
Assert.Equal(360, maxHeight);
|
||||
Assert.Equal(2000, maxHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatFixture_CroppedDockedRoot_ReflowsContentAcrossMountedWidth()
|
||||
public void ChatFixture_WindowRootCarriesRetailWidthConstraints()
|
||||
{
|
||||
var root = FixtureLoader.LoadChatInfos();
|
||||
var window = Find(root, 0x10000600u)!;
|
||||
|
||||
Assert.True(window.TryGetEffectiveInteger(0x3Fu, out int minWidth));
|
||||
Assert.True(window.TryGetEffectiveInteger(0x3Du, out int maxWidth));
|
||||
Assert.Equal(300, minWidth);
|
||||
Assert.Equal(2000, maxWidth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatFixture_RootIsAuthored410x100_NoCropNeeded()
|
||||
{
|
||||
// Campaign CH slice CH6a: 0x2100006F's window root is already the exact
|
||||
// mounted extent — the 490px content-width crop that the wrong
|
||||
// 0x21000006 import needed no longer applies to anything.
|
||||
var root = FixtureLoader.LoadChatInfos();
|
||||
Assert.Equal(410f, root.Width);
|
||||
Assert.Equal(100f, root.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatFixture_DockedRoot_ReflowsContentAcrossMountedWidth()
|
||||
{
|
||||
var infos = FixtureLoader.LoadChatInfos();
|
||||
var layout = LayoutImporter.Build(infos, NoTex, null);
|
||||
|
|
@ -105,19 +196,24 @@ public class ChatLayoutConformanceTests
|
|||
NoTex);
|
||||
Assert.NotNull(controller);
|
||||
|
||||
// No crop-then-rebase dance needed: the root's OWN authored extent
|
||||
// (410x100) already matches the design width children's imported
|
||||
// LayoutPolicy captured, so growing it directly exercises the same
|
||||
// raw-edge reflow the crop workaround needed a rebase step for.
|
||||
var root = controller!.Root;
|
||||
root.Width = 490;
|
||||
root.Height = 100;
|
||||
root.RebaseChildLayoutBaselines();
|
||||
Assert.Equal(410f, root.Width);
|
||||
root.Width = 615;
|
||||
|
||||
// Both panels are authored with a 5px margin to BOTH the left and right
|
||||
// window edges (X=5, right-edge width = 410-(5+400) = 5) — a stretch
|
||||
// grows the panel by the delta MINUS both margins: 615 - 5 - 5 = 605.
|
||||
var transcriptPanel = layout.FindElement(0x10000010u)!;
|
||||
var inputBar = layout.FindElement(0x10000013u)!;
|
||||
transcriptPanel.ApplyAnchor(root.Width, root.Height);
|
||||
inputBar.ApplyAnchor(root.Width, root.Height);
|
||||
|
||||
Assert.Equal(615f, transcriptPanel.Width);
|
||||
Assert.Equal(615f, inputBar.Width);
|
||||
Assert.Equal(605f, transcriptPanel.Width);
|
||||
Assert.Equal(605f, inputBar.Width);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -158,6 +254,11 @@ public class ChatLayoutConformanceTests
|
|||
null,
|
||||
NoTex)!;
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
// Campaign CH slice CH6a: 0x2100006F's own border art IS the window
|
||||
// chrome — Chrome=Imported, no content-width crop, no MinWidth override
|
||||
// (the DAT's own 0x3D/0x3F=2000/300 govern). Frame == content, so the
|
||||
// maximize math below operates directly on the authored 410x100 extent
|
||||
// with the DAT's real minH=100/maxH=2000 (not the wrong layout's 360).
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
root,
|
||||
controller.Root,
|
||||
|
|
@ -165,36 +266,126 @@ public class ChatLayoutConformanceTests
|
|||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Chat,
|
||||
Chrome = RetailWindowChrome.NineSlice,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Left = 12f,
|
||||
Top = 390f,
|
||||
ContentWidth = 490f,
|
||||
ContentHeight = controller.Root.Height,
|
||||
RebaseContentLayout = true,
|
||||
DatConstraintSource = controller.DatWindowInfo,
|
||||
MinWidth = 200f,
|
||||
});
|
||||
controller.AttachWindow(handle);
|
||||
var maxMin = Assert.IsType<UiButton>(layout.FindElement(0x1000046Fu));
|
||||
|
||||
// frame.Height=100, parentHeight=600, expansion=300 → targetHeight =
|
||||
// clamp(min(400,600),100,2000) = 400. growUp because Top(390)+400>600.
|
||||
// targetTop = max(0, 390-(400-100)) = 90. Lower edge fixed at 490.
|
||||
maxMin.OnClick!();
|
||||
|
||||
Assert.True(controller.IsMaximized);
|
||||
Assert.Equal(130f, handle.Top);
|
||||
Assert.Equal(370f, handle.Height); // DAT max 360 + 2*5px shared chrome
|
||||
Assert.Equal(500f, handle.Top + handle.Height); // lower edge stays fixed while growing upward
|
||||
Assert.Equal(90f, handle.Top);
|
||||
Assert.Equal(400f, handle.Height);
|
||||
Assert.Equal(490f, handle.Top + handle.Height); // lower edge stays fixed while growing upward
|
||||
Assert.Equal(RetailUiStateIds.Maximized, maxMin.ActiveRetailStateId);
|
||||
controller.Root.ApplyAnchor(handle.Width, handle.Height);
|
||||
Assert.Equal(360f, controller.Root.Height);
|
||||
|
||||
maxMin.OnClick!();
|
||||
|
||||
Assert.False(controller.IsMaximized);
|
||||
Assert.Equal(390f, handle.Top);
|
||||
Assert.Equal(110f, handle.Height);
|
||||
Assert.Equal(100f, handle.Height);
|
||||
Assert.Equal(RetailUiStateIds.Minimized, maxMin.ActiveRetailStateId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x1000069Bu, UiResizeGrip.Border.UpperLeft)]
|
||||
[InlineData(0x1000069Du, UiResizeGrip.Border.UpperRight)]
|
||||
[InlineData(0x1000069Fu, UiResizeGrip.Border.LowerLeft)]
|
||||
[InlineData(0x100006A1u, UiResizeGrip.Border.LowerRight)]
|
||||
public void MountedChatWindow_CornerGrip_ImportsWithCorrectBorderLocation(
|
||||
uint elementId, UiResizeGrip.Border expected)
|
||||
{
|
||||
// Campaign CH slice CH6a, user-gate round 2 item 6: pins that the 4
|
||||
// corner grips from 0x2100006F import with the RIGHT BorderLocation —
|
||||
// the prerequisite for the reported "no diagonal cursor" / "can't grow
|
||||
// in Y from the bottom-right corner" symptoms to be fixed at all.
|
||||
var layout = FixtureLoader.LoadChat();
|
||||
var grip = Assert.IsType<UiResizeGrip>(layout.FindElement(elementId));
|
||||
Assert.Equal(expected, grip.BorderLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MountedChatWindow_TopStrip_IsAMoveHandleNotAGrip()
|
||||
{
|
||||
// 0x1000069C (the plain top edge, between the two top corners) is a
|
||||
// Type-2 Dragbar in the real DAT, not a Resizebar — retail's main chat
|
||||
// window has no title bar, so the top strip moves the window while its
|
||||
// two corners (above) resize it.
|
||||
var layout = FixtureLoader.LoadChat();
|
||||
var topStrip = layout.FindElement(0x1000069Cu);
|
||||
Assert.NotNull(topStrip);
|
||||
Assert.IsNotType<UiResizeGrip>(topStrip);
|
||||
Assert.True(topStrip!.WindowMoveHandle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MountedChatWindow_BottomRightGrip_GrowsBothAxes_NotOnlyShrinks()
|
||||
{
|
||||
// The literal user-gate round 2 report: "the window cannot grow in the
|
||||
// Y axis when dragging from the bottom-right corner." Exercises the
|
||||
// FULL production path (ChatWindowController.Bind + RetailWindowFrame.Mount
|
||||
// with the real Chrome=Imported options and the real DAT min/max
|
||||
// constraints from 0x2100006F, minH=100/maxH=2000/minW=300/maxW=2000)
|
||||
// rather than just the pure ResizeRect math.
|
||||
var infos = FixtureLoader.LoadChatInfos();
|
||||
var layout = LayoutImporter.Build(infos, NoTex, null);
|
||||
var controller = ChatWindowController.Bind(
|
||||
infos,
|
||||
layout,
|
||||
new ChatVM(new ChatLog()),
|
||||
() => NullCommandBus.Instance,
|
||||
null,
|
||||
null,
|
||||
NoTex)!;
|
||||
var root = new UiRoot { Width = 1600, Height = 1200 };
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
root,
|
||||
controller.Root,
|
||||
NoTex,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Chat,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Left = 10f,
|
||||
Top = 440f,
|
||||
DatConstraintSource = controller.DatWindowInfo,
|
||||
});
|
||||
controller.AttachWindow(handle);
|
||||
|
||||
var brGrip = Assert.IsType<UiResizeGrip>(layout.FindElement(0x100006A1u));
|
||||
Assert.Equal(UiResizeGrip.Border.LowerRight, brGrip.BorderLocation);
|
||||
|
||||
var gs = brGrip.ScreenPosition;
|
||||
int pressX = (int)(gs.X + 2), pressY = (int)(gs.Y + 2);
|
||||
|
||||
// Shrink first (this direction was never in question per the report).
|
||||
// Height is already AT the DAT's authored minimum (minH=100 == the
|
||||
// authored 100px height), so it clamps rather than shrinking further —
|
||||
// width has headroom (minW=300 < 410) and does shrink.
|
||||
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
|
||||
root.OnMouseMove(pressX - 20, pressY - 20);
|
||||
root.OnMouseUp(UiMouseButton.Left, pressX - 20, pressY - 20);
|
||||
Assert.Equal(390f, handle.Width);
|
||||
Assert.Equal(100f, handle.Height);
|
||||
|
||||
// Now grow from the shrunken state — this is the reported-broken direction.
|
||||
var brGripAfterShrink = Assert.IsType<UiResizeGrip>(layout.FindElement(0x100006A1u));
|
||||
var gs2 = brGripAfterShrink.ScreenPosition;
|
||||
int pressX2 = (int)(gs2.X + 2), pressY2 = (int)(gs2.Y + 2);
|
||||
root.OnMouseDown(UiMouseButton.Left, pressX2, pressY2);
|
||||
root.OnMouseMove(pressX2 + 70, pressY2 + 70);
|
||||
root.OnMouseUp(UiMouseButton.Left, pressX2 + 70, pressY2 + 70);
|
||||
|
||||
Assert.Equal(460f, handle.Width);
|
||||
Assert.Equal(170f, handle.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MountedChatScrollbar_ButtonsAndThumbDragDriveTranscriptModel()
|
||||
{
|
||||
|
|
@ -216,14 +407,10 @@ public class ChatLayoutConformanceTests
|
|||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Chat,
|
||||
Chrome = RetailWindowChrome.NineSlice,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Left = 10f,
|
||||
Top = 440f,
|
||||
ContentWidth = 490f,
|
||||
ContentHeight = controller.Root.Height,
|
||||
RebaseContentLayout = true,
|
||||
DatConstraintSource = controller.DatWindowInfo,
|
||||
MinWidth = 200f,
|
||||
});
|
||||
controller.AttachWindow(handle);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,18 @@ namespace AcDream.App.Tests.UI.Layout;
|
|||
/// <summary>
|
||||
/// One-off generator for the committed chat golden fixture. Skipped by default —
|
||||
/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate
|
||||
/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made.
|
||||
/// chat_2100006f.json, then commit it. Mirrors how vitals_2100006C.json was made.
|
||||
///
|
||||
/// <para>
|
||||
/// Campaign CH slice CH6a swapped the imported main-chat LayoutDesc from the
|
||||
/// wrong <c>0x21000006</c> (a different, unrelated chat layout whose root
|
||||
/// <c>0x1000000E</c> and 800px resize bar <c>0x1000000F</c> appear nowhere in
|
||||
/// the EoR gameplay UI) to retail's actual main chat window,
|
||||
/// <c>0x2100006F</c> (window element <c>0x10000601</c>, layout root
|
||||
/// <c>0x10000600</c>, authored 410x100 — see
|
||||
/// docs/research/2026-08-09-chat-retail-window-shell.md §2.1 and §5). The old
|
||||
/// <c>chat_21000006.json</c> fixture is retired with it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ChatLayoutFixtureGenerator
|
||||
{
|
||||
|
|
@ -22,7 +33,7 @@ public class ChatLayoutFixtureGenerator
|
|||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var info = LayoutImporter.ImportInfos(dats, 0x21000006u);
|
||||
var info = LayoutImporter.ImportInfos(dats, 0x2100006Fu);
|
||||
Assert.NotNull(info);
|
||||
|
||||
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
|
||||
|
|
@ -35,5 +46,5 @@ public class ChatLayoutFixtureGenerator
|
|||
|
||||
// Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path.
|
||||
private static string FixturePath([CallerFilePath] string thisFile = "")
|
||||
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json");
|
||||
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_2100006f.json");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ public class ChatWindowControllerTests
|
|||
|
||||
/// <summary>
|
||||
/// Build a minimal synthetic ElementInfo tree that mirrors the real chat
|
||||
/// layout (0x21000006) with enough fidelity for Bind to succeed:
|
||||
/// layout (0x2100006F) with enough fidelity for Bind to succeed:
|
||||
/// root (Type-3)
|
||||
/// transcriptPanel (Type-3) [0x10000010]
|
||||
/// transcript (Type-12, no media) [0x10000011] ← built as UiText by factory; Bind binds in place
|
||||
|
|
@ -111,7 +111,7 @@ public class ChatWindowControllerTests
|
|||
|
||||
var root = new ElementInfo
|
||||
{
|
||||
Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100,
|
||||
Id = 0x10000600u, Type = 3, Width = 490, Height = 100,
|
||||
};
|
||||
root.Children.Add(transcriptPanel);
|
||||
root.Children.Add(inputBar);
|
||||
|
|
@ -299,7 +299,7 @@ public class ChatWindowControllerTests
|
|||
public void Bind_Returns_Null_WhenTranscriptPanelMissing()
|
||||
{
|
||||
// Build a layout that is missing the transcript panel entirely.
|
||||
var root = new ElementInfo { Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100 };
|
||||
var root = new ElementInfo { Id = 0x10000600u, Type = 3, Width = 490, Height = 100 };
|
||||
// No children → TranscriptPanelId and InputBarId are absent from the widget tree.
|
||||
|
||||
var layout = LayoutImporter.Build(root, NoTex, null);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,91 @@ public class DatWidgetFactoryTests
|
|||
Assert.False(e.ClickThrough);
|
||||
}
|
||||
|
||||
// ── Type 9 → UiResizeGrip (retail UIElement_Resizebar) ───────────────────
|
||||
// Campaign CH slice CH6a: LayoutDesc 0x2100006F's main chat window authors
|
||||
// 7 live Type-9 grips (corners + 3 straight edges — the top edge is a
|
||||
// separate Type-2 Dragbar move handle, not a grip) via property bools
|
||||
// 0x2A=bottom/0x2B=left/0x2C=right/0x2D=top. Real element ids + their
|
||||
// decoded bools, confirmed against the installed DAT:
|
||||
// 0x1000069B (TL) bottom=F left=T right=F top=T -> UpperLeft
|
||||
// 0x1000069D (TR) bottom=F left=F right=T top=T -> UpperRight
|
||||
// 0x1000069E (L) bottom=F left=T right=F top=F -> Left
|
||||
// 0x1000069F (BL) bottom=T left=T right=F top=F -> LowerLeft
|
||||
// 0x100006A0 (B) bottom=T left=F right=F top=F -> Bottom
|
||||
// 0x100006A1 (BR) bottom=T left=F right=T top=F -> LowerRight
|
||||
// 0x100006A2 (R) bottom=F left=F right=T top=F -> Right
|
||||
|
||||
[Fact]
|
||||
public void Type9_Resizebar_NoBoolsSet_DecodesToNoneEdges()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(GripInfo(), NoTex, null);
|
||||
var grip = Assert.IsType<UiResizeGrip>(e);
|
||||
Assert.Equal(UiResizeGrip.Border.None, grip.BorderLocation);
|
||||
Assert.Equal(ResizeEdges.None, grip.Edges);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// (bottom, left, right, top) -> (Border, Edges) — the 8 authored grip shapes
|
||||
// (edges set exactly one bool; corners set exactly two adjacent bools).
|
||||
[InlineData(false, false, false, true, UiResizeGrip.Border.Top, ResizeEdges.Top)]
|
||||
[InlineData(true, false, false, false, UiResizeGrip.Border.Bottom, ResizeEdges.Bottom)]
|
||||
[InlineData(false, true, false, false, UiResizeGrip.Border.Left, ResizeEdges.Left)]
|
||||
[InlineData(false, false, true, false, UiResizeGrip.Border.Right, ResizeEdges.Right)]
|
||||
[InlineData(false, true, false, true, UiResizeGrip.Border.UpperLeft, ResizeEdges.Left | ResizeEdges.Top)]
|
||||
[InlineData(false, false, true, true, UiResizeGrip.Border.UpperRight, ResizeEdges.Right | ResizeEdges.Top)]
|
||||
[InlineData(true, true, false, false, UiResizeGrip.Border.LowerLeft, ResizeEdges.Left | ResizeEdges.Bottom)]
|
||||
[InlineData(true, false, true, false, UiResizeGrip.Border.LowerRight, ResizeEdges.Right | ResizeEdges.Bottom)]
|
||||
public void Type9_Resizebar_DecodesEachOfTheEightAuthoredGrips(
|
||||
bool bottom, bool left, bool right, bool top,
|
||||
UiResizeGrip.Border expectedBorder, ResizeEdges expectedEdges)
|
||||
{
|
||||
var e = DatWidgetFactory.Create(GripInfo(bottom, left, right, top), NoTex, null);
|
||||
var grip = Assert.IsType<UiResizeGrip>(e);
|
||||
Assert.Equal(expectedBorder, grip.BorderLocation);
|
||||
Assert.Equal(expectedEdges, grip.Edges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Type9_Resizebar_IsNotClickThrough_SoItCanCaptureTheResizeDrag()
|
||||
{
|
||||
// A grip is a precise hit region — it must NOT default to UiDatElement's
|
||||
// decoration ClickThrough=true, or UiRoot's hit-test would skip straight
|
||||
// past it to whatever's behind.
|
||||
var e = DatWidgetFactory.Create(GripInfo(bottom: true), NoTex, null);
|
||||
var grip = Assert.IsType<UiResizeGrip>(e);
|
||||
Assert.False(grip.ClickThrough);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeBorderLocation_MatchesRetailBranchOrder_RightBeatsLeftBeatsTopBeatsBottom()
|
||||
{
|
||||
// UIElement_Resizebar::StartMouseResizing @0x0046B7E0's if/else-if chain —
|
||||
// retail's authored data never sets more than 2 bools, but the branch
|
||||
// ORDER is still part of the port: right wins over left, and within a
|
||||
// branch top wins over bottom.
|
||||
Assert.Equal(
|
||||
UiResizeGrip.Border.UpperRight,
|
||||
UiResizeGrip.DecodeBorderLocation(bottom: true, left: true, right: true, top: true));
|
||||
Assert.Equal(
|
||||
UiResizeGrip.Border.UpperLeft,
|
||||
UiResizeGrip.DecodeBorderLocation(bottom: true, left: true, right: false, top: true));
|
||||
Assert.Equal(
|
||||
UiResizeGrip.Border.Top,
|
||||
UiResizeGrip.DecodeBorderLocation(bottom: true, left: false, right: false, top: true));
|
||||
}
|
||||
|
||||
private static ElementInfo GripInfo(bool bottom = false, bool left = false, bool right = false, bool top = false)
|
||||
{
|
||||
var info = new ElementInfo { Id = 0x1000069Bu, Type = 9, Width = 5, Height = 5 };
|
||||
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
||||
if (bottom) state.Properties.Values[0x2Au] = Bool(true);
|
||||
if (left) state.Properties.Values[0x2Bu] = Bool(true);
|
||||
if (right) state.Properties.Values[0x2Cu] = Bool(true);
|
||||
if (top) state.Properties.Values[0x2Du] = Bool(true);
|
||||
info.States[UiStateInfo.DirectStateId] = state;
|
||||
return info;
|
||||
}
|
||||
|
||||
// ── Test 3: Type 12 → UiText (behavioral text widget) ────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -40,22 +40,25 @@ public static class FixtureLoader
|
|||
=> LoadInfos("vitals_2100006C.json");
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the committed <c>chat_21000006.json</c> fixture into a raw
|
||||
/// <see cref="ElementInfo"/> tree and builds the <see cref="ImportedLayout"/>
|
||||
/// using a null-returning sprite resolver and no dat font — sufficient for
|
||||
/// conformance checks on tree structure and resolved types.
|
||||
/// Deserializes the committed <c>chat_2100006f.json</c> fixture (retail's
|
||||
/// ACTUAL main chat window, LayoutDesc <c>0x2100006F</c> — Campaign CH slice
|
||||
/// CH6a; the old <c>chat_21000006.json</c> fixture imported the WRONG,
|
||||
/// unrelated layout) into a raw <see cref="ElementInfo"/> tree and builds the
|
||||
/// <see cref="ImportedLayout"/> using a null-returning sprite resolver and no
|
||||
/// dat font — sufficient for conformance checks on tree structure and
|
||||
/// resolved types.
|
||||
/// </summary>
|
||||
public static ImportedLayout LoadChat()
|
||||
=> LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the committed <c>chat_21000006.json</c> fixture into a raw
|
||||
/// Deserializes the committed <c>chat_2100006f.json</c> fixture into a raw
|
||||
/// <see cref="ElementInfo"/> tree WITHOUT calling <see cref="LayoutImporter.Build"/>.
|
||||
/// Use this when the test needs to inspect the resolved <see cref="ElementInfo"/>
|
||||
/// tree directly (e.g. resolved Type values per element id).
|
||||
/// </summary>
|
||||
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
|
||||
=> LoadInfos("chat_21000006.json");
|
||||
=> LoadInfos("chat_2100006f.json");
|
||||
|
||||
/// <summary>Builds the committed retail radar LayoutDesc 0x21000074 fixture.</summary>
|
||||
public static ImportedLayout LoadRadar()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
{
|
||||
private static readonly (uint Id, string FileName)[] Layouts =
|
||||
{
|
||||
(0x21000006u, "chat_21000006.json"),
|
||||
(ChatWindowController.LayoutId, "chat_2100006f.json"),
|
||||
(0x21000016u, "toolbar_21000016.json"),
|
||||
(0x2100001Du, "link_status_2100001D.json"),
|
||||
(0x21000020u, "vitae_21000020.json"),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,4 @@
|
|||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
|
||||
|
|
@ -590,6 +591,195 @@ public class UiRootInputTests
|
|||
Assert.True((UiRoot.HitEdges(panel, 200, 200, 5) & ResizeEdges.Bottom) == 0);
|
||||
}
|
||||
|
||||
// ── Literal Resizebar grip priority (Campaign CH slice CH6a) ─────────────
|
||||
// Retail authors 8 grip/dragbar elements at a window's edges/corners
|
||||
// (docs/research/2026-08-09-chat-retail-window-shell.md §2.1/§2.3). A
|
||||
// directly-hit grip's own edges must win over the generic proximity
|
||||
// heuristic, and a directly-hit move handle (dragbar) must win over an
|
||||
// overlapping proximity match — this is what lets the main chat window's
|
||||
// top STRIP move the window while its top-left/top-right CORNERS resize it.
|
||||
|
||||
private static UiPanel WindowWithCornerGrips(
|
||||
float left = 100, float top = 100, float width = 200, float height = 100)
|
||||
{
|
||||
var window = new UiPanel
|
||||
{
|
||||
Left = left, Top = top, Width = width, Height = height,
|
||||
Draggable = true, Resizable = true,
|
||||
MinWidth = 40, MinHeight = 40, MaxWidth = 2000, MaxHeight = 2000,
|
||||
};
|
||||
window.AddChild(new UiResizeGrip { BorderLocation = UiResizeGrip.Border.UpperLeft, Left = 0, Top = 0, Width = 5, Height = 5 });
|
||||
window.AddChild(new UiResizeGrip { BorderLocation = UiResizeGrip.Border.UpperRight, Left = width - 5, Top = 0, Width = 5, Height = 5 });
|
||||
window.AddChild(new UiResizeGrip { BorderLocation = UiResizeGrip.Border.LowerLeft, Left = 0, Top = height - 5, Width = 5, Height = 5 });
|
||||
window.AddChild(new UiResizeGrip { BorderLocation = UiResizeGrip.Border.LowerRight, Left = width - 5, Top = height - 5, Width = 5, Height = 5 });
|
||||
return window;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(UiResizeGrip.Border.UpperLeft)]
|
||||
[InlineData(UiResizeGrip.Border.UpperRight)]
|
||||
[InlineData(UiResizeGrip.Border.LowerLeft)]
|
||||
[InlineData(UiResizeGrip.Border.LowerRight)]
|
||||
public void CornerGrip_GrowsBothAxes_WhenDraggedOutward(UiResizeGrip.Border corner)
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var window = WindowWithCornerGrips();
|
||||
root.AddChild(window);
|
||||
var grip = window.Children.OfType<UiResizeGrip>().Single(g => g.BorderLocation == corner);
|
||||
|
||||
var gs = grip.ScreenPosition;
|
||||
int pressX = (int)(gs.X + 2), pressY = (int)(gs.Y + 2);
|
||||
bool growsLeft = corner is UiResizeGrip.Border.UpperLeft or UiResizeGrip.Border.LowerLeft;
|
||||
bool growsUp = corner is UiResizeGrip.Border.UpperLeft or UiResizeGrip.Border.UpperRight;
|
||||
int dx = growsLeft ? -30 : 30;
|
||||
int dy = growsUp ? -30 : 30;
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
|
||||
Assert.True(root.ActiveResizeEdges != ResizeEdges.None);
|
||||
root.OnMouseMove(pressX + dx, pressY + dy);
|
||||
root.OnMouseUp(UiMouseButton.Left, pressX + dx, pressY + dy);
|
||||
|
||||
Assert.Equal(230f, window.Width);
|
||||
Assert.Equal(130f, window.Height);
|
||||
Assert.Equal(growsLeft ? 70f : 100f, window.Left);
|
||||
Assert.Equal(growsUp ? 70f : 100f, window.Top);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(UiResizeGrip.Border.UpperLeft)]
|
||||
[InlineData(UiResizeGrip.Border.UpperRight)]
|
||||
[InlineData(UiResizeGrip.Border.LowerLeft)]
|
||||
[InlineData(UiResizeGrip.Border.LowerRight)]
|
||||
public void CornerGrip_ShrinksBothAxes_WhenDraggedInward(UiResizeGrip.Border corner)
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var window = WindowWithCornerGrips();
|
||||
root.AddChild(window);
|
||||
var grip = window.Children.OfType<UiResizeGrip>().Single(g => g.BorderLocation == corner);
|
||||
|
||||
var gs = grip.ScreenPosition;
|
||||
int pressX = (int)(gs.X + 2), pressY = (int)(gs.Y + 2);
|
||||
bool growsLeft = corner is UiResizeGrip.Border.UpperLeft or UiResizeGrip.Border.LowerLeft;
|
||||
bool growsUp = corner is UiResizeGrip.Border.UpperLeft or UiResizeGrip.Border.UpperRight;
|
||||
// Shrink: drag the opposite direction from "grow".
|
||||
int dx = growsLeft ? 30 : -30;
|
||||
int dy = growsUp ? 30 : -30;
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
|
||||
root.OnMouseMove(pressX + dx, pressY + dy);
|
||||
root.OnMouseUp(UiMouseButton.Left, pressX + dx, pressY + dy);
|
||||
|
||||
Assert.Equal(170f, window.Width);
|
||||
Assert.Equal(70f, window.Height);
|
||||
Assert.Equal(growsLeft ? 130f : 100f, window.Left);
|
||||
Assert.Equal(growsUp ? 130f : 100f, window.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CornerGrip_TakesPriorityOverBlanketResizableEdgesMask()
|
||||
{
|
||||
// A directly-hit grip's own edges win outright — even if the window's
|
||||
// blanket ResizableEdges mask would otherwise exclude Top (the exact
|
||||
// shape retail's main chat window needs: Top excluded from ambient
|
||||
// proximity so the move-handle strip works, but the TL/TR corner grips
|
||||
// still resize the Y/top axis).
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var window = WindowWithCornerGrips();
|
||||
window.ResizableEdges = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
|
||||
root.AddChild(window);
|
||||
var grip = window.Children.OfType<UiResizeGrip>().Single(g => g.BorderLocation == UiResizeGrip.Border.UpperLeft);
|
||||
|
||||
var gs = grip.ScreenPosition;
|
||||
int pressX = (int)(gs.X + 2), pressY = (int)(gs.Y + 2);
|
||||
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
|
||||
|
||||
Assert.Equal(ResizeEdges.Left | ResizeEdges.Top, root.ActiveResizeEdges);
|
||||
root.OnMouseUp(UiMouseButton.Left, pressX, pressY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GripEdges_AreMaskedByResizableEdges_WhenTheWindowExcludesAnAxis()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var window = WindowWithCornerGrips();
|
||||
window.ResizableEdges = ResizeEdges.Left | ResizeEdges.Bottom; // no Right, no Top
|
||||
root.AddChild(window);
|
||||
var grip = window.Children.OfType<UiResizeGrip>().Single(g => g.BorderLocation == UiResizeGrip.Border.UpperRight);
|
||||
|
||||
var gs = grip.ScreenPosition;
|
||||
int pressX = (int)(gs.X + 2), pressY = (int)(gs.Y + 2);
|
||||
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
|
||||
|
||||
// Border.UpperRight decodes to Right|Top; masked by Left|Bottom leaves nothing.
|
||||
Assert.Equal(ResizeEdges.None, root.ActiveResizeEdges);
|
||||
root.OnMouseUp(UiMouseButton.Left, pressX, pressY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveHandle_TakesPriorityOverOverlappingAmbientResizeProximity()
|
||||
{
|
||||
// The main chat window's top strip (a Type-2 Dragbar) sits WITHIN
|
||||
// ResizeGrip proximity of the window's own top edge — if the blanket
|
||||
// mask includes Top, the generic proximity heuristic would otherwise
|
||||
// also match there and steal the move. A directly-hit move handle must
|
||||
// win over that ambient match.
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var window = new UiPanel
|
||||
{
|
||||
Left = 100, Top = 100, Width = 200, Height = 100,
|
||||
Draggable = true, Resizable = true,
|
||||
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom,
|
||||
};
|
||||
var handle = new UiPanel { Left = 5, Top = 0, Width = 190, Height = 5, WindowMoveHandle = true };
|
||||
window.AddChild(handle);
|
||||
root.AddChild(window);
|
||||
|
||||
// Press well inside the handle strip but still within ResizeGrip(5) of
|
||||
// the window's own top edge (y=100..105).
|
||||
root.OnMouseDown(UiMouseButton.Left, 150, 102);
|
||||
|
||||
Assert.Equal(ResizeEdges.None, root.ActiveResizeEdges);
|
||||
Assert.True(root.IsWindowMoveActive);
|
||||
root.OnMouseMove(200, 152);
|
||||
Assert.Equal(150f, window.Left);
|
||||
Assert.Equal(150f, window.Top);
|
||||
root.OnMouseUp(UiMouseButton.Left, 200, 152);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HoverResizeEdges_OverAGrip_ReturnsTheGripsOwnEdges()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var window = WindowWithCornerGrips();
|
||||
root.AddChild(window);
|
||||
var grip = window.Children.OfType<UiResizeGrip>().Single(g => g.BorderLocation == UiResizeGrip.Border.LowerRight);
|
||||
|
||||
var gs = grip.ScreenPosition;
|
||||
root.OnMouseMove((int)(gs.X + 2), (int)(gs.Y + 2));
|
||||
|
||||
Assert.Equal(ResizeEdges.Right | ResizeEdges.Bottom, root.HoverResizeEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HoverResizeEdges_OverAMoveHandle_IsNoneEvenWithinProximityOfTheTopEdge()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var window = new UiPanel
|
||||
{
|
||||
Left = 100, Top = 100, Width = 200, Height = 100,
|
||||
Draggable = true, Resizable = true,
|
||||
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom,
|
||||
};
|
||||
var handle = new UiPanel { Left = 5, Top = 0, Width = 190, Height = 5, WindowMoveHandle = true };
|
||||
window.AddChild(handle);
|
||||
root.AddChild(window);
|
||||
|
||||
root.OnMouseMove(150, 102);
|
||||
|
||||
Assert.Equal(ResizeEdges.None, root.HoverResizeEdges);
|
||||
Assert.True(root.HoverWindowMove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAnchoredRect_LeftRight_StretchesWidth()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue