From 8af1d48530eb49b09112f6393df10623d6beefb4 Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Tue, 4 Aug 2026 12:34:42 +0800 Subject: [PATCH] refactor(tooltips): extract native paired tooltip host Move the business-agnostic "custom content inside the native auxiliary tooltip, pinned beside the native card tooltip" plumbing out of the Combat Impact view into GameInterop/Tooltips. Behavior, visuals, layout and log semantics are unchanged; the view keeps all Combat Impact content, perspective, trimming policy and reason codes. Moved to the shared host: native state capture/restore, CanvasGroupGate, native background cloning, the paired fade, side selection, coordinate conversion, native width/height and layout rebuilds. Shaped by an independent red-team review of the plan (see the design draft for file:line evidence): - Release is two-phase, not once-only. restoreNativeContent is a real two-state contract; an idempotent cleanup would leave the game's own auxiliary tooltip permanently blank, with no log. - One generation counter, bumped before the owner releases its resources. Splitting it would let a cancelled preview continuation re-attach and strand IsReadyToReveal until the 120-frame timeout. - Position takes an IPairedContentBudget so the fit loop stays inside the host instead of re-entering the feature per trimmed row. - Arbitration stays with the feature: the host only answers "is this controller mine". The native auxiliary controller is a game-wide singleton other code drives directly, so one active session serializes this host's own usage only. - ForceSettle exists because the fade coroutine is hosted on the native controller and dies silently when that MonoBehaviour is deactivated. - Perspective rollback stays in the view; the host only supplies a masked layout scope and a rebuild primitive. The rollback pass must reach the same reason-code logic, whose latches reset on recovery and therefore decide how many records are emitted. PostCombatImpactController, PostCombatImpactModule, PostCombatImpactRecapPatch and IPostCombatImpactTooltipView are unchanged. Known debts, recorded in the design draft: the unconditional SetLockedFlag(false) is preserved as-is; the view stays under Patches/ despite not being a Harmony patch; and a paper adaptation of a second consumer showed the host is generic over features, not over anchor kinds, so its generality remains unverified. Refs #204 --- docs/ARCHITECTURE.md | 2 + ...e-paired-tooltip-host-extraction-design.md | 416 ++++++ src/BazaarPlusPlus/BppComposition.cs | 9 +- .../Tooltips/NativePairedTooltipContracts.cs | 143 ++ .../Tooltips/NativePairedTooltipHost.cs | 1183 ++++++++++++++++ .../NativePairedTooltipPlacementMath.cs | 149 +++ .../NativePostCombatImpactTooltipView.cs | 1190 +++-------------- .../Properties/AssemblyAttributes.cs | 1 + .../NativePairedTooltipArchitectureTests.cs | 160 +++ .../NativePairedTooltipHost.Tests.csproj | 36 + .../NativePairedTooltipPlacementMathTests.cs | 409 ++++++ 11 files changed, 2685 insertions(+), 1013 deletions(-) create mode 100644 docs/drafts/2026-08-04-native-paired-tooltip-host-extraction-design.md create mode 100644 src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipContracts.cs create mode 100644 src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipHost.cs create mode 100644 src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipPlacementMath.cs create mode 100644 tests/Architecture.Tests/NativePairedTooltipArchitectureTests.cs create mode 100644 tests/NativePairedTooltipHost.Tests/NativePairedTooltipHost.Tests.csproj create mode 100644 tests/NativePairedTooltipHost.Tests/NativePairedTooltipPlacementMathTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0fa399d9..e7b171c4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -120,6 +120,8 @@ Settings-dock rows themselves are data: every cycling or boolean row is a `Cycli `TooltipPreviewModePolicy` owns preview priority. Upgrade hotkey wins first, then enchant hotkey, then enchant `Always`, then enchant `AutoOnPedestalChoice` only when the current choice pedestal is an enchant pedestal (`src/BazaarPlusPlus/Game/Tooltips/TooltipPreviewModePolicy.cs:52-74`). A `TooltipModifierRefreshController` mountable re-resolves open preview tooltips when the Ctrl/Shift hold state changes; both ordinary game cards and hovered synthetic previews keep the existing native tooltip controller alive, replace its `CardTooltipData` through `ApplyPreviewTooltip`, and synchronously settle layout behind a temporary visibility gate instead of replaying native hide/show positioning and fades (`src/BazaarPlusPlus/Game/Tooltips/TooltipModifierRefreshController.cs:110-173`, `src/BazaarPlusPlus/Game/Tooltips/TooltipPreviewContentRefresh.cs:12-65`, `src/BazaarPlusPlus/GameInterop/Tooltips/NativeCardTooltipContentRefresher.cs:12-52`). The tooltip patch surface also covers encounter/event tooltips (gated by the `Game/EventPreview` settings toggle), quest reward previews and aggregate-item missing types (separate implementations sharing the default-off `QuestPreview/Enabled` gate), and hero level rewards (`src/BazaarPlusPlus/Patches/Tooltips/`). Disabling Quest Preview immediately restores tracked native quest text/layout and hides its pooled aggregate-item sections. Mod-appended text is rendered through the shared `BppTooltipSections` helper. Sections keep the cloned native donor typography and attach the game's own zh-CN fallback chain only when BPP-authored content contains CJK (`src/BazaarPlusPlus/Patches/Tooltips/BppTooltipSections.cs:12-15`, `src/BazaarPlusPlus/Patches/Tooltips/BppTooltipSections.cs:55-76`). +`GameInterop/Tooltips` holds two unrelated native-tooltip adapters that must not be merged. `NativeCardTooltipContentRefresher` replaces the `CardTooltipData` of an already-visible primary tooltip and settles its layout (`src/BazaarPlusPlus/GameInterop/Tooltips/NativeCardTooltipContentRefresher.cs:12-52`). `NativePairedTooltipHost` owns the separate "custom content inside the native auxiliary tooltip, pinned beside the native card tooltip" presentation: native state capture/restore, the `CanvasGroupGate` visibility gates, the cloned native background, the paired fade, side selection, and coordinate conversion (`src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipHost.cs`). Composition creates one plugin-lifetime host; it is not `IDisposable`, and the releasable unit is the `NativePairedTooltipSession` a feature acquires from it (`src/BazaarPlusPlus/BppComposition.cs:237-252`). The host reports placement outcomes through `PlacementResult` and never logs: reason codes, their once-only latches, and native show/hide arbitration stay with the consuming feature, because that latch behavior decides how many records reach the log. The host also does not arbitrate the native auxiliary singleton — other code drives it directly (`src/BazaarPlusPlus/Game/CombatReplay/CurrentReplayRecordingButtonController.cs:257-261`), so a single active session serializes only this host's own usage. Purely geometric decisions are the unit-tested `NativePairedTooltipPlacementMath`; the shared `NativePairedTooltipMetrics.Epsilon` deliberately serves both as a geometry tolerance and as the CanvasGroup alpha interactivity threshold. Combat Impact is currently the only consumer, and it supplies its own trim strategy through `IPairedContentBudget` (`src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs`). `NativePairedTooltipArchitectureTests` ratchets the plumbing into this module. + BPP hotkeys are user-rebindable and persisted in config per action. `BppHotkeyActionId` covers five actions: the two hold-preview hotkeys (Ctrl enchant / Shift upgrade defaults) plus toggles for CollectionPanel, LiveBuildPanel, and HistoryPanel (`src/BazaarPlusPlus/Game/Input/BppHotkeyActionId.cs`); rebinding rows are cloned from native settings rows (`src/BazaarPlusPlus/Game/Input/BppKeyBindRowController.cs`), and panel toggle presses are resolved per frame by the Overlay Panel Host. Binding-path normalization, ctrl/shift alias expansion, conflict detection, and the default/display data tables are the pure `HotkeyBindingPathCore` (`src/BazaarPlusPlus/Game/Input/HotkeyBindingPathCore.cs`, compile-linked into the zero-ManagedPath `tests/HotkeyBindingPath.Tests/`); `BppHotkeyService` remains the Unity/config facade. Binding paths in `BazaarPlusPlus.cfg` `[Hotkeys]` are untrusted input: junk normalizes to empty and falls back to the action default. The conflict check compares BPP actions only against other BPP actions, not native `Gameplay/*` bindings. The settings dock registers all feature rows through `SettingsDockEntryRegistry` with order constants centralized in `BppSettingsDockOrder` (`src/BazaarPlusPlus/Game/Settings/BppSettingsDockOrder.cs`); the roster spans history, name override, bilingual item names, legendary position, enchant preview, event and quest previews, combat status bar, Chinese locale, supporter list, voice subtitles, end-of-run screenshot, and BazaarDB upload (`src/BazaarPlusPlus/BppComposition.cs:162-180`). There is no BPP font selector because all BPP-owned game UI follows the game's font assets. diff --git a/docs/drafts/2026-08-04-native-paired-tooltip-host-extraction-design.md b/docs/drafts/2026-08-04-native-paired-tooltip-host-extraction-design.md new file mode 100644 index 00000000..60127404 --- /dev/null +++ b/docs/drafts/2026-08-04-native-paired-tooltip-host-extraction-design.md @@ -0,0 +1,416 @@ +# Native Paired Tooltip Host 抽取设计(修订版,待确认) + +日期:2026-08-04 +状态:**待用户确认,确认前不开始实现** +基线:`master` @ `952d8b23` +来源:用户初版计划 + 独立 red-team review(只读)+ 主 agent 抽验 + +> 本文是修订版。凡与初版计划冲突之处,以本文为准,冲突原因逐条给出 `file:line` 证据。 +> 除特别标注外,所有代码引用均相对仓库根目录;`NativePostCombatImpactTooltipView.cs` 简写为 **View** +> (完整路径 `src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs`,2362 行)。 + +--- + +## 0. 目标与范围(不变) + +只把 Combat Impact 自绘 Tooltip 中与业务无关的**原生双 Tooltip 宿主基建**抽到 `GameInterop/Tooltips/`。 + +**硬约束**:用户可见行为、视觉、布局、日志语义保持不变。 + +**明确不做**:不抽 `BuildCaused`/`BuildReceived`、分组/行/图标/文案;不抽 `CombatImpactPerspective` 与 Combat Impact DTO; +不改 hover/锁定/等待 native tooltip/重试/requeue 流程;不合并 `BppTooltipSections` 或 `NativeCardTooltipContentRefresher`; +不移动 View 文件本身;不新增其他 consumer;不接管主 Card Tooltip 的 native 定位权。 + +--- + +## 1. 执行位置与既有事实(已核实,修正初版) + +| 初版假设 | 实际情况 | 证据 | +|---|---|---| +| 工作区在 `.codex/worktrees/2f14/` | 该 worktree 处于 `952d8b23` detached HEAD 且**完全干净**,与 master 同 commit。**在主 checkout 施工**,无需 installer/decompiled symlink | `git worktree list` | +| `GameInterop/Tooltips/` 为新增目录 | **已存在**,含 `NativeCardTooltipContentRefresher.cs`。实为往已有目录加 3 个文件 | `src/BazaarPlusPlus/GameInterop/Tooltips/` | +| — | `PostCombatImpact.Tests`、`Architecture.Tests` 均存在且带 `Microsoft.NET.Test.Sdk`,`dotnet test` 是正确跑法 | 各自 `.csproj` | +| — | 架构测试惯例是**源码文本扫描**(扫 `using` 行 / forbidden token)+ 文件路径白名单 | `tests/Architecture.Tests/CoreLayeringTests.cs:18-69`、`NativeCardPreviewArchitectureTests.cs:26-40` | + +### 1.1 一处被推翻的判断(主 agent 自查有误,review 纠正) + +初版分析曾担心 "`ComponentMount` 每次挂载都 new 一个 View,跨场景累积 stale session"。**该判断错误**: + +- `MountAll` 只在 `Plugin.Awake` 调用一次(`src/BazaarPlusPlus/Plugin.cs:98`),View 是单实例。 +- teardown 顺序是先 `UnmountAll`(`Plugin.cs:160`)后 `_composition.Dispose()`,且 `ComponentMount.Unmount` 用 + `DestroyImmediate`(`src/BazaarPlusPlus/Core/Runtime/ComponentMount.cs:27-32`),`OnDestroy` 同步执行 + `_view?.Dispose()`(`src/BazaarPlusPlus/Game/PostCombatImpact/PostCombatImpactController.cs:1055-1062`)。 + +结论:**session 一定先于 host 释放,生命周期顺序安全**。原"风险 6"降级为 §4 末尾的一条 host 无状态化约定。 + +--- + +## 2. 必须推翻初版文字的 6 处(阻断项) + +### B1. "cleanup 必须 once-only" 与现状直接冲突 —— 清理是**两阶段**的 + +`restoreNativeContentVisibility` 是二态语义,不是一个可有可无的开关: + +- `CleanupCustomContent` 的入口闸门把 `_preparedNativeHost != null` 也算作"有活内容"(**View:620-627**)。 +- `RestorePreparedNativeHost` 在 `restoreContentVisibility == false` 时**不清空** `_preparedNativeHost`(**View:939-941**), + 字段清零块(**View:650-667**)里也没有它。 +- 因此 `CleanupCustomContent(false)` 之后,下一次调用的闸门仍为真,会**完整再跑一遍**: + `_renderGeneration++` → `RestorePreparedNativeHost(true)` → `SetLockedFlag(false)`。 +- 这正是 `Dispose()` 的设计(**View:2357-2361**):`Hide()` 可能已走完 `CompleteAnimatedHide → CleanupCustomContent(false)` + (**View:607**),随后 `Dispose` 的第二次调用才真正恢复 native header/body/divider 可见性。 +- **11 条清理调用点**中只有 2 条传 `true`(走默认参数):`OnNativeAuxiliaryTooltipShowing`(**View:508**)与 + `Dispose`(**View:2360**);其余 **9 条**显式传 `false`:**View:112, 131, 139, 162, 189, 231, 240, 521, 607**。 + (**View:618** 是方法定义,不是调用点。) + +**若按初版字面实现幂等 cleanup**:`Dispose` 的第二遍变 no-op,native 辅助 tooltip 的 header/body/divider 永久停在 +`SetActive(false)`(`Show` 在 **View:222-224** 关掉它们)。症状是插件卸载或场景切换后游戏自己的辅助 tooltip 变空框, +**且不产生任何日志**。 + +**修订**:契约改为 +```csharp +session.Release(bool restoreNativeContent); +// false: 释放呈现,保留 native host 快照 +// true : 归还快照并解除 session +``` +实现时附一张 **11 个调用点**各传什么值的核对表,逐点比对迁移前后。 + +--- + +### B2. `_renderGeneration` 是跨 host/view 的**单一**世代计数器,且递增顺序是载荷 + +唯一计数器 `_renderGeneration`(**View:84**)被四类使用者共享: + +| 使用者 | 位置 | 归属 | +|---|---|---| +| `Show` 开新一代 `++_renderGeneration` | View:214 | host | +| fade 协程晚到拒绝 | View:537, 554, 602 | host | +| `CleanupCustomContent` 作废一切 | View:632 | host | +| preview/hero 异步任务身份 + `_pendingPreviewCount` 记账 | View:1605, 1681-1682, 1691, 1715-1716 | **view** | + +现状清理顺序(**View:631-648**): +`StopVisibilityFade()` → `_renderGeneration++` → `DisposeNativePreviews()` → 销毁 UI → 恢复 native。 +`_pendingPreviewCount` 只在 `generation == _renderGeneration` 时递减(**View:1681-1682**),清理时直接置 0(**View:663**)。 + +**若按初版 "View 先 → Host 后"**:`_renderGeneration++` 的位置丢失。在 `scope.AcquireAsync` 的 continuation 与 +`Cancel()` 竞争的窗口里,已取消的 preview 仍会通过 generation 检查、把 session 加进 `_previewSessions`(**View:1661**) +并 `owner.Reveal`(**View:1662**),而 `_previewSessions.Clear()`(**View:989**)已经跑过 —— 泄漏一个 native card preview。 +更糟的表现:`_pendingPreviewCount` 不归零 → `IsReadyToReveal` 永久为假 → tooltip 直到 120 帧超时才放弃 +(`PostCombatImpactController.cs:560-576`),**只留一条 `EntityPreviewCreateTimedOut`,极难归因**。 + +**修订**: +- session 暴露只读世代 token `session.Generation`;view 的 preview 任务改为与该 token 比较,**不再自持 generation**。 +- 清理契约改为 **host → view → host 三段**: + 1. host:`StopVisibilityFade()` + bump generation + 2. view:取消 preview task、释放 preview session/scope、清业务引用 + 3. host:销毁 content/background、恢复 native 状态与锁 + +--- + +### B3. `Position(anchor) -> placementResult` 形状错误 + +现状 `Position`(**View:332-451**)的实际时序: + +1. host:双重强制重建 + `ApplyNativeHeight`(View:346-351) +2. host:canvasBounds / primaryBounds / `CanvasUnitsPerLocalUnit`(View:356-363) +3. host:左右侧选择(View:366-381) +4. host + **view**:`ApplyTooltipWidth`(View:385-388,其中 `_metricColumns` 属 view) +5. host:重建 + `ApplyNativeHeight`(View:389-392) +6. **view:`FitActiveContentToCanvas`(View:393)** —— 循环体内回调 host 的 + `RebuildAfterHeightBudgetChange`(View:773, 788, 798)与 `FitsCanvasHeight`(View:782, 791), + 后者读 `auxiliary.backgroundImage.rectTransform` 的 canvas 局部包围盒(View:808-814) +7. host:`impactDelta` + `TranslateRect`(View:395-407) +8. host:竖直修正(View:409-418) +9. host:overflow / 碰撞 / 窄宽判定(View:420-434) +10. view:三条 reason code(View:435-449) + +第 6 步夹在 host 定位流程正中间,且是 **view→host→view→host** 的跨界重入循环 +(最坏迭代次数 = 所有 group 与 detail row 之和)。初版把 `FitActiveContentToCanvas` 整体划归 view, +同时声称 `Position` 是 host 的 "measure → choose side → shrink → position → return result",两者不能同时成立。 + +**修订**: +```csharp +PlacementResult session.Position(Transform anchor, IPairedContentBudget content); + +interface IPairedContentBudget +{ + void RestoreAll(); // view: block.Restore() + 隐藏 more row + bool TryShrinkOneStep(); // view: 隐藏下一行/下一块 + 更新 more 文案;false = 无可裁剪 +} +``` +host 在第 6 步内部驱动 `while (!FitsCanvasHeight(...) && content.TryShrinkOneStep()) Relayout();`。 +host 保有 measure/relayout,view 只回答"下一步裁什么",消除双向重入。 + +--- + +### B4. "host 一个 active session 就串行化了 native 单例" —— 不成立 + +- `AuxiliaryTooltipController` 确是游戏级单例:`TooltipParentComponent` 只持一个 `_auxiliaryTooltipController` + (`decompiled/TheBazaarRuntime/TheBazaar.UI.Tooltips/TooltipParentComponent.cs:61, 142-156`), + 且 `TooltipParentComponent` 自身 `DontDestroyOnLoad` + 单实例守卫(同文件 `:180-183, 187, 210-213`)。 +- **但 BPP 内部存在第二个绕过 view 的驱动方**: + `CurrentReplayRecordingButtonController.ShowTooltip()` 直接调 + `Data.TooltipParentComponent?.ShowAuxiliaryTooltipController(...)` + (`src/BazaarPlusPlus/Game/CombatReplay/CurrentReplayRecordingButtonController.cs:257-261`), + `HideTooltip()` 直接 `HideAuxiliaryTooltipController()`(同文件 `:276`), + 并且**每帧** `tooltip.PositionOverUI(_cloneRect)` 重定位同一个单例(同文件 `:297`)。 + 它挂在 `FightMenuDialog` 设置按钮旁(`src/BazaarPlusPlus/Patches/Combat/CurrentReplayRecordingButtonPatch.cs:60-70`)。 +- 今天真正的仲裁**不在 view,而在 Harmony patch 链**: + `PostCombatImpactRecapPatch.cs:123-151` → `PostCombatImpactModule` → `PostCombatImpactController.OnNativeAuxiliaryTooltipShowing/Hiding` + (`PostCombatImpactController.cs:750-821`)→ view。这些 patch 是 PostCombatImpact 私有的; + plugin-lifetime 的 host 收不到任何 native 事件,除非新增通用 patch 注册 —— 而这被范围明确排除。 + +**修订**:边界表该项改为 +- **host**:`session.OwnsPrimary(controller)` / `session.OwnsAuxiliary(controller)` / `session.ReleasePrepared(controller)` +- **view/feature**:三个 `OnNative*` 的 bool 返回语义与全部后续动作(`NativeAuxiliaryDisplaced` 日志、 + `HideCardTooltipController()`、requeue 决策,`PostCombatImpactController.cs:777-787, 797-821`)**原样保留** + +**极易丢失的副作用(必须显式保留)**:`OnNativeAuxiliaryTooltipShowing` 在"**不是**我的 controller"分支上, +仍然会调 `RestorePreparedNativeHost(controller)`(**View:502-506**)—— 即把一个已 prepare 但未激活的 controller +快照归还并清空。漏掉这行的后果是 native 布局的 padding/anchors 被**永久改写**。 + +同时在设计文档中记录:`CurrentReplayRecordingButtonController` 是同一 native 单例的第二个非受管驱动方, +host **无法也不打算**串行化它;今天靠 patch 链兜底,重构后仍靠它。 + +--- + +### B5. fade 协程挂在 native MonoBehaviour 上;generation 防不了"永不到达" + +- `StartVisibilityFade` 用 `auxiliary.StartCoroutine(...)`(**View:538**),宿主是游戏的 `AuxiliaryTooltipController`。 +- 启动前有保护(`auxiliary == null || !auxiliary.isActiveAndEnabled` 走同步路径,**View:529-535**), + 但**运行中**宿主被失活/销毁没有任何保护 —— 协程静默终止,`_visibilityFade` 悬空, + `CompleteAnimatedHide(generation)`(**View:581**)永不执行,因而 `HideAuxiliaryTooltipController()`(**View:609**)也不执行。 +- `StopVisibilityFade` 在 `_activeAuxiliary == null` 时只丢句柄不 Stop(**View:590-598**),依赖 generation 让协程自杀。 +- 现状兜底在 patch 链:`AuxiliaryTooltipController.StartTooltipFadeOut` 的 prefix + (`PostCombatImpactRecapPatch.cs:142-151`)→ `OnNativeAuxiliaryTooltipHiding` → cleanup。 + +初版第 4 条只说"generation/identity 拒绝晚到回调",解决的是"回调来了但过期";此处失败模式是"回调永远不来", +恢复信号从 feature 的 patch 进来。host 若拥有 fade 却不暴露强制收尾入口,这条兜底会断。 + +**修订**:session 暴露 `session.ForceSettle(reason)`(同步跳过 fade、直接执行 `CompleteAnimatedHide` 等价物), +由 view 的 `OnNativeAuxiliaryTooltipHiding` / `OnNativeTooltipChanging` 调用。 +并把"协程宿主是 native MonoBehaviour、其生命周期不受 host 控制"作为显式契约写进 `NativePairedTooltipContracts.cs` 注释。 + +--- + +### B6. `RelayoutAtomically(anchor, mutation)` 表达不了"失败即回滚并二次定位" + +`SetPerspective`(**View:289-330**)的实际结构: +- 遮罩:`visibleAlpha = _preparedAuxiliaryGate?.Alpha ?? 1f` → `SetAlpha(0f)`(View:306-307),`finally` 恢复(View:328) +- 主路径:切换 → `ApplyPerspectiveVisibility` → 重建 → `ApplyNativeHeight` → `Position`(View:310-315) +- **失败路径**:还原 perspective → 再 `ApplyPerspectiveVisibility` → 再重建 → 再 `ApplyNativeHeight` → + **再跑一次完整 `Position` 且丢弃返回值**(View:317-322) + +即一次 `SetPerspective` 最多跑两次 `Position`,两次都会重跑 `FitActiveContentToCanvas`(改内容可见性) +和三条 `LogPlacementDegradationOnce`(View:435-449)。而后者在 `degraded == false` 时会复位 `wasLogged` +(**View:1959-1978**)—— **日志条数是可观测的**。 + +**修订**(已按用户复核收窄边界): + +初稿曾提出 `session.TryRelayout(anchor, apply, revert, out result)`。**该形状越界并已废弃** —— 它把 +"失败判定 → 是否回滚 → 回滚成什么" 这条纯 Combat Impact 的决策链交给了通用 host 编排。 +host 不该知道"perspective",更不该知道"回滚"。 + +正确切法:**host 只提供遮蔽期间的 atomic layout primitive 与 placement result;apply/revert 与二次 Position 全部留 View。** + +```csharp +// host 侧只新增两个原语 +IDisposable session.BeginMaskedLayout(); // 进入时置 alpha=0;Dispose 时恢复原 alpha +void session.RebuildLayout(); // ForceRebuildLayout + ApplyNativeHeight +``` + +View 的 `SetPerspective` 变成(结构与 View:303-329 逐行对应): + +```csharp +using (session.BeginMaskedLayout()) // 取代 View:306-307 + finally View:324-329 +{ + _activePerspective = perspective; // View:310 业务 + ApplyPerspectiveVisibility(perspective); // View:311 业务 + session.RebuildLayout(); // View:312-313 + var result = session.Position(anchor, budget); // View:314 + if (result.Positioned) + { + LogPlacementDegradations(result); // View:435-449 的三条 reason code + return true; + } + + _activePerspective = previousPerspective; // View:317 业务 + ApplyPerspectiveVisibility(previousPerspective); // View:318 业务 + session.RebuildLayout(); // View:319-320 + var rollback = session.Position(anchor, budget); // View:321 + LogPlacementDegradations(rollback); // 关键:回滚那次的 result 也必须过日志 + return false; +} +``` + +契约必须写明的两点: + +1. **回滚那次 `Position` 的 `PlacementResult` 也要交给 View 记日志。** 现状 View:321 丢弃返回值, + 但 `LogPlacementDegradationOnce` 在 `degraded == false` 时会复位 `wasLogged`(**View:1959-1978**), + 所以第二次 `Position` 的结果**已经**通过复位影响了后续日志条数。迁移后必须显式把它喂回同一套 reason code 逻辑, + 否则条数变化 —— 而条数是可观测的。 +2. **`BeginMaskedLayout` 的 alpha 恢复必须覆盖 body 抛异常的情形**(即 `Dispose` 走 `finally` 语义)。 + `PostCombatImpactController.cs:113-119` 依赖 `SetPerspective` 抛出后自行 `HideActiveSelection`, + 但 alpha 必须已复原,否则整对 tooltip 残留在 alpha=0。 + +--- + +## 3. 边界表修订 + +### 3.1 归 host(业务无关,已核实零 Combat Impact 耦合) + +`CanvasGroupGate`(View:2197-2259,构造签名已是 `object controller`)、`NativeAuxiliaryHostState`(View:2261-2355)、 +`TryCreateNativeBackground`/`CopyRectTransform`/`CopyImage`(View:834-924)、`PrepareNativePresentation`(View:679-709)、 +`ApplyNativeHeight`(View:744-754)、`ForceRebuildLayout`(View:822-832)、`FindDescendant`(View:1916-1933)、 +`GetCanvasLocalBounds`(View:1870-1887)、`GetPrimaryVisibleBounds`/`GetPrimaryFrameBounds`(View:1889-1914)、 +`CanvasUnitsPerLocalUnit`(View:1935-1946)、`TranslateRect`(View:1980-1992)、`Union`(View:1994-2000)、 +`Inset`(View:2002-2012)、`ResolveVerticalAdjustment`(View:1948-1957)。 + +以上**没有任何一个**引用 `CombatImpact*` 类型或 `PostCombatImpact*` 日志。 + +### 3.2 留在 View(不变) + +Header、`IsReadyToReveal`、`BuildCaused`/`BuildReceived`、Caused/Received 视角状态、`SetPerspective` 业务选择、 +native preview 创建与释放、`_metricColumns` 调整、`FitActiveContentToCanvas` 的裁剪策略、`ImpactContentBlock`、 +`+N more`、标签/颜色/图标/CJK 文案、PostCombatImpact reason-code 日志。 + +### 3.3 初版遗漏、本次补入的 4 项 + +| 项 | 现状证据 | 归属决定 | +|---|---|---| +| `HideAuxiliaryTooltipController()` | View 内两处直接调游戏全局 API:`PrepareNativePrimary` 位移分支(View:114)、`CompleteAnimatedHide`(View:609) | 归 host;但 **View:607-609 的"cleanup 返回 true 才 hide"条件原样保留** | +| `PlacementEpsilon = 0.5f` 被三种语义复用 | 几何(View:366, 370, 411, 424-432, 814, 1950)/CanvasGroup 交互阈值 `_group.alpha >= 1f - PlacementEpsilon`(View:2241)/Hide 是否需淡出 `visibleAlpha <= PlacementEpsilon`(View:482) | 在 `NativePairedTooltipContracts.cs` 定**单一** `const float Epsilon = 0.5f`,两侧共用;注释写明它同时承担几何与 alpha 两种语义(**历史事实,非设计**) | +| `SetLockedFlag` set/clear 不对称且无条件清除 | set 在 feature(`PostCombatImpactController.cs:332`);clear 在 View:476、View:648 与 feature `:917, 942`。`isLocked` 是游戏自己的锁模式标志,`TooltipParentComponent.HideCardTooltipController` 会因它早退(`TooltipParentComponent.cs:350-353`),`DesktopLockModeController` 也会写它 | **迁移不改行为**,但不得包装成"generic host restores locks";记为行为债(§6) | +| host 对主 tooltip 的介入范围 | host 还会:给 `Tooltip_Main` 后代装 `CanvasGroupGate`(View:117-119)、fade 中同时淡入主 gate(View:564)、清主 tooltip lock(View:476, 648)、强制重建主 tooltip 定位 rect(View:347, 349) | 范围表述改为"host 拥有主 tooltip 的**可见性门控与布局重建**,但不接管其 native 定位" | + +### 3.4 顺序敏感点(必须冻结,不得在迁移中挪动) + +`NativeAuxiliaryHostState.Capture` 相对 feature 的 `AssignTooltipFrame` 是顺序敏感的: +Controller 先调 `auxiliary.AssignTooltipFrame(request.Card.Tier)`(`PostCombatImpactController.cs:460`),它会写 +`backgroundImage.sprite`;`NativeAuxiliaryHostState` 捕获 `_backgroundSprite`(View:2306)。 +走 `PrepareNativeAuxiliary` 路径时捕获在 `AssignTooltipFrame` **之前**(View:142),走 Show 补捕时在**之后**(View:195), +随后 `Show` 又用 primary 的 sprite 覆盖(View:234-235)。 + +这是既有的不一致(恢复的 sprite 取决于走哪条路)。**迁移中若改变这两个 capture 点的相对顺序,会产生"辅助 tooltip +边框 tier 错乱"的视觉回归**。两个 capture 点原地冻结。 + +--- + +## 4. Session API(修订后完整形状) + +```csharp +// GameInterop/Tooltips/NativePairedTooltipContracts.cs +internal const float Epsilon = 0.5f; // 同时承担几何判定与 CanvasGroup alpha 阈值(历史事实) + +internal interface IPairedContentBudget +{ + void RestoreAll(); + bool TryShrinkOneStep(); +} + +internal readonly struct PlacementResult { /* side, overflowed, tooNarrow, topAdjusted, ... */ } +internal readonly struct PairedTooltipOptions { /* width、gap、canvas margin、fade duration */ } +internal enum PairSide { None, Left, Right } +``` + +```csharp +// GameInterop/Tooltips/NativePairedTooltipHost.cs +NativePairedTooltipSession Acquire(object owner); + +// session +int Generation { get; } // B2:view 的 preview 任务与之比较 +void PreparePrimary(CardTooltipController primary); +void PrepareAuxiliary(AuxiliaryTooltipController auxiliary); +bool TryOpen(primary, auxiliary, PairedTooltipOptions options); // R7:失败返回 false,不抛异常 +bool AttachContent(GameObject contentRoot, Action onContentWidthChanged); +PlacementResult Position(Transform anchor, IPairedContentBudget content); // B3 +IDisposable BeginMaskedLayout(); // B6:遮蔽作用域,Dispose/异常均恢复原 alpha +void RebuildLayout(); // B6:ForceRebuildLayout + ApplyNativeHeight +void Reveal(); +void Hide(); +void ForceSettle(string reason); // B5 +bool OwnsPrimary(CardTooltipController c); // B4 +bool OwnsAuxiliary(AuxiliaryTooltipController c); // B4 +void ReleasePrepared(AuxiliaryTooltipController c); // B4(含 View:502-506 副作用) +void Release(bool restoreNativeContent); // B1 +``` + +**R7(失败语义必须保留)**:Controller 对两类失败的日志不同 —— `Show` 返回 false → `AuxiliaryTooltipContentUnavailable` +(`PostCombatImpactController.cs:482-490`);`Show` 抛异常 → `TooltipRenderException`(同文件 `:471-480`)。 +现状 `Show` 有三个 false 出口(View:180-186 前置校验、View:225-233 背景图缺失、View:238-242 背景克隆失败)。 +host 的所有 open/attach 失败**一律以 `false` + 已完成的自清理返回**,不得改用异常。 + +**host 不实现 `IDisposable`**(修订初版的"最终 Dispose 共享 Host"): +`BppComposition.Dispose()`(`src/BazaarPlusPlus/BppComposition.cs:307-318`)不 dispose 任何 GameInterop host, +`_nativeCardPreviewHost` 即先例,`NativeCardPreviewHost` 也不是 `IDisposable`。可释放单元是 session。 + +**host 必须无状态化到只持 `_activeSession`**(由 §1.1 推导):session 释放后 host 不得残留任何 Unity 引用, +否则会跨场景持有已销毁的 `AuxiliaryTooltipController`。生命周期顺序本身是安全的(`Plugin.cs:98/160`、 +`ComponentMount.cs:27-32`、`PostCombatImpactController.cs:1055-1062`),这条约束防的是引用滞留而非顺序错乱。 + +--- + +## 5. 执行清单与验收标准 → GitHub issue #204 + +执行清单(Step 1–8)与全部验收标准已迁至 +**[#204 refactor(tooltips): extract native paired tooltip host](https://github.com/BazaarPlusPlus/bazaarplusplus-mod/issues/204)**, +本文档不再保留副本,避免两处漂移。 + +- **本文档负责**:目标与范围(§0)、既有事实核实(§1)、六条阻断项的根因与证据(§2)、边界表(§3)、Session API 契约(§4)、已知债务(§6)、评审覆盖缺口(§7)。 +- **issue 负责**:Step 1–8 执行清单、几何/场景/日志三类验收标准、必测场景清单。 + +> 设计复核(原 Step 0)**已完成**:独立 red-team review(只读、`file:line` 证据)已执行; +> 主 agent 抽验 B1 / B2 顺序 / B4 / §1.1 共 4 处,全部核实。本文档即修订结果。 + +## 6. 已知债务(本次不修,登记备查) + +1. **`SetLockedFlag(false)` 无条件清除**(View:648):玩家自锁的主 tooltip 会被 BPP 清理解锁。既有行为,迁移原样保留。 + 风险在于把它描述成"generic host restores locks"会给未来 consumer 埋雷 —— 文档层面已澄清。 +2. **View 文件位置违反分层**:`NativePostCombatImpactTooltipView.cs` 在 `Patches/PostCombatImpact/` + (namespace `BazaarPlusPlus.Patches.PostCombatImpact`,View:21),但它不是 Harmony patch,而是实现 + `Game/PostCombatImpact/Ui/IPostCombatImpactTooltipView` 的 feature UI 适配器,按 `CLAUDE.md` 分层规则应在 `Game/`。 + 本次为控制 diff 规模不移动,登记为遗留债。 +3. **通用性未验证**:host 只有一个 consumer,架构测试证明不了 API 对第二个 consumer 可用。#204 Step 4 的纸面推演是部分缓解, + 不是证明。文档需明确写"通用性在引入第二个 consumer 前是未验证的假设"。 +4. **未验证项**:`Tooltip_Main` 后代节点名是否在所有 tier / 平台(Desktop vs Mobile tooltip prefab, + `TooltipParentComponent.cs:191-193`)下都存在。`FindDescendant` 找不到时 `_preparedPrimaryGate` 静默为 null + (View:117-119),主 tooltip 不被遮罩。 + +--- + +## 7A. 二方适配纸面推演结果(#204 Step 4,已完成) + +候选二方:`CurrentReplayRecordingButtonController` 的辅助 tooltip 定位 +(`src/BazaarPlusPlus/Game/CombatReplay/CurrentReplayRecordingButtonController.cs:250-319`)。 +**只做纸面检查,未写代码、未新增 consumer。** + +**结论:当前 API 形状不适配该 consumer,且这是设计边界问题而非签名细节问题。** + +证据与原因: + +| 维度 | paired host 的假设 | 该 consumer 的实际情况 | +|---|---|---| +| 是否成对 | 必须有 primary `CardTooltipController` + auxiliary 面板两个宿主 | **没有 primary**。它只驱动 auxiliary 单体(`:257-261`),锚点是一个普通按钮 `RectTransform`(`_cloneRect`) | +| 内容来源 | feature 自建 content root 注入 `auxParent`,host 关掉 native header/body | 直接用 native 文本通道 `ShowAuxiliaryTooltipController(rect, offset, text)`,**不注入自绘内容** | +| 定位模型 | 一次性放置在 primary 左/右侧,`ResolvePairOffset` 全部相对 `primaryBounds` | **每帧**重定位到按钮正上方居中(`:297-315`),并调 native 的 `KeepTooltipWithinBounds()` | +| 背景 | 克隆 primary 的渐变遮罩层叠 | 用 native 原始外观,不克隆 | + +也就是说,本次抽出来的 host 是"**对 feature 通用**",不是"**对锚点类型通用**":它的整个模型是 +"auxiliary 贴在一个 primary card tooltip 旁边"。要覆盖上面这个 consumer,至少需要 +(a) 锚点从 `CardTooltipController` 放宽到 `RectTransform`, +(b) primary gate / lock 清除 / 背景克隆全部变成可选, +(c) 增加"跟随锚点持续重定位"这一模式。 + +这三项都不是本次范围,**不做**。价值在于把假设证伪并写下来: + +> `NativePairedTooltipHost` 的通用性目前是**"同一种成对形态下的多 feature 通用"**, +> 不是"任意 native tooltip 宿主通用"。在出现第二个**成对形态**的 consumer 之前, +> 通用性仍是未验证假设(§6 债务 3)。 + +--- + +## 7. 本次评审未覆盖的部分 + +给 red-team reviewer 的简报**遗漏了执行清单**,因此 #204 的 **Step 1(行为基线)与 Step 7(游戏内回归)未经独立评审**。 +reviewer 仅就"三条不变量能否被测量"给出了意见(已并入 #204 的验收标准)。 +如需补评,可将 #204 全文发回同一 reviewer 做一次针对性复核。 +如需补评,可将 §5 全文发回同一 reviewer 做一次针对性复核。 diff --git a/src/BazaarPlusPlus/BppComposition.cs b/src/BazaarPlusPlus/BppComposition.cs index eee629b9..19ed2165 100644 --- a/src/BazaarPlusPlus/BppComposition.cs +++ b/src/BazaarPlusPlus/BppComposition.cs @@ -35,6 +35,7 @@ using BazaarPlusPlus.GameInterop.Encounter; using BazaarPlusPlus.GameInterop.RunSnapshot; using BazaarPlusPlus.GameInterop.StaticCards; +using BazaarPlusPlus.GameInterop.Tooltips; using BazaarPlusPlus.GameInterop.VoiceSubtitles; using BazaarPlusPlus.Infrastructure.RemoteEmbeddedCatalog; using BazaarPlusPlus.ModApi.Clients; @@ -234,12 +235,18 @@ or UnityEngine.RuntimePlatform.WindowsPlayer new ComponentMount((c, s) => c.Initialize(s)) ); _mountables.Register(new ComponentMount((c, s) => c.Initialize(s))); + // Plugin-lifetime, like _nativeCardPreviewHost: composition does not dispose GameInterop + // hosts, and the releasable unit is the session the view acquires from it. + var pairedTooltipHost = new NativePairedTooltipHost(); _mountables.Register( new ComponentMount( (c, _) => c.Initialize( _postCombatImpactModule, - new NativePostCombatImpactTooltipView(_nativeCardPreviewHost) + new NativePostCombatImpactTooltipView( + _nativeCardPreviewHost, + pairedTooltipHost + ) ) ) ); diff --git a/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipContracts.cs b/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipContracts.cs new file mode 100644 index 00000000..70a708c3 --- /dev/null +++ b/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipContracts.cs @@ -0,0 +1,143 @@ +#nullable enable +using UnityEngine; + +namespace BazaarPlusPlus.GameInterop.Tooltips; + +/// +/// Which side of the primary tooltip the auxiliary panel is placed on. +/// +internal enum PairSide +{ + None, + Right, + Left, +} + +/// +/// Shared tolerance for the paired-tooltip host. +/// +/// +/// This single constant deliberately carries two different meanings, because the code it was +/// extracted from used one value for both and the extraction must not change behavior: +/// +/// a geometry tolerance in canvas units (side selection, overflow, vertical fit), and +/// a alpha threshold — a gate counts as interactive once +/// alpha >= 1 - Epsilon, and a hide skips the fade once alpha <= Epsilon. +/// +/// That overlap is a historical fact, not a design decision. Keep both consumers pointed at this +/// one constant: splitting it into two independently-drifting values changes when a half-faded +/// tooltip becomes clickable. +/// +internal static class NativePairedTooltipMetrics +{ + internal const float Epsilon = 0.5f; +} + +/// +/// Numeric presentation parameters supplied by the consuming feature. +/// +/// +/// These are passed in rather than defaulted inside the host so one feature's visual design does +/// not silently become the global default for the next consumer. +/// +internal readonly struct NativePairedTooltipOptions +{ + internal NativePairedTooltipOptions( + float preferredContentWidth, + float readableContentWidth, + float gap, + float canvasMargin, + float fadeDuration, + int nativeBottomPaddingReduction + ) + { + PreferredContentWidth = preferredContentWidth; + ReadableContentWidth = readableContentWidth; + Gap = gap; + CanvasMargin = canvasMargin; + FadeDuration = fadeDuration; + NativeBottomPaddingReduction = nativeBottomPaddingReduction; + } + + /// Content width the panel is laid out at when space allows. + internal float PreferredContentWidth { get; } + + /// Below this content width the placement reports . + internal float ReadableContentWidth { get; } + + /// Horizontal gap between the primary tooltip and the auxiliary panel. + internal float Gap { get; } + + /// Inset applied to the canvas rect before fitting. + internal float CanvasMargin { get; } + + /// Duration of the paired reveal/hide fade, in unscaled seconds. + internal float FadeDuration { get; } + + /// Rows trimmed from the native auxiliary layout's bottom padding. + internal int NativeBottomPaddingReduction { get; } +} + +/// +/// Outcome of a placement pass. The host reports; the consuming feature decides what — if +/// anything — to log about it. +/// +/// +/// The host deliberately does not log placement degradations itself. The consumer owns its own +/// reason codes and their once-only/reset semantics, and duplicating that here would change the +/// number of emitted log records. +/// +internal readonly struct PlacementResult +{ + internal PlacementResult( + bool positioned, + PairSide side, + float contentWidth, + bool overflowed, + bool widthBelowReadable, + bool topAlignmentAdjusted + ) + { + Positioned = positioned; + Side = side; + ContentWidth = contentWidth; + Overflowed = overflowed; + WidthBelowReadable = widthBelowReadable; + TopAlignmentAdjusted = topAlignmentAdjusted; + } + + /// False when the pair could not be positioned at all. + internal bool Positioned { get; } + + internal PairSide Side { get; } + + internal float ContentWidth { get; } + + internal bool Overflowed { get; } + + internal bool WidthBelowReadable { get; } + + internal bool TopAlignmentAdjusted { get; } + + internal static PlacementResult Unplaced => new(false, PairSide.None, 0f, false, false, false); +} + +/// +/// The consuming feature's content-trimming strategy, driven by the host while it fits the panel +/// to the canvas. +/// +/// +/// The host owns measurement and layout rebuilds; the feature only answers "what do I drop next". +/// This keeps the fit loop inside the host instead of having the feature call back into host +/// measurement mid-placement. +/// +internal interface IPairedContentBudget +{ + /// Make every trimmable element visible again, before a fresh fit pass. + void RestoreAll(); + + /// + /// Hide the next element in trim order. Returns false when nothing further can be dropped. + /// + bool TryShrinkOneStep(); +} diff --git a/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipHost.cs b/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipHost.cs new file mode 100644 index 00000000..c6673d15 --- /dev/null +++ b/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipHost.cs @@ -0,0 +1,1183 @@ +#nullable enable +using TheBazaar; +using TheBazaar.UI.Tooltips; +using TheBazaar.Utilities; +using UnityEngine; +using UnityEngine.UI; +using Object = UnityEngine.Object; + +namespace BazaarPlusPlus.GameInterop.Tooltips; + +/// +/// Plugin-lifetime owner of the "custom content inside the native auxiliary tooltip, pinned beside +/// the native card tooltip" presentation. +/// +/// +/// +/// The host holds at most one active . It is deliberately +/// not : composition does not dispose GameInterop hosts (the native +/// card-preview host is the precedent), and the releasable unit here is the session, not the host. +/// Once a session is released the host keeps no Unity references, so it cannot carry a destroyed +/// across a scene load. +/// +/// +/// The host does not observe native tooltip events and does not arbitrate between features +/// competing for the native singleton. It can only answer "is this controller mine" +/// ( / +/// ); the decision of what to do about a +/// conflict — and any logging about it — belongs to the consuming feature's patch chain. Note that +/// the native auxiliary controller is a game-wide singleton that other code can drive directly +/// without going through this host, so a single active session serializes this host's own usage +/// only. +/// +/// +internal sealed class NativePairedTooltipHost +{ + private NativePairedTooltipSession? _activeSession; + + /// + /// Acquires the single active session. An existing session belonging to another owner is + /// released first. + /// + /// Identity used to detect re-entrant acquisition. + /// + /// Invoked in the middle of , after the host has + /// stopped the fade and invalidated the generation but before it destroys UI or restores native + /// state. The owner must cancel in-flight async work and drop its own references here. + /// + internal NativePairedTooltipSession Acquire(object owner, Action releaseOwnerResources) + { + if (_activeSession != null && !_activeSession.HasOwner(owner)) + { + _activeSession.Release(restoreNativeContent: false); + _activeSession = null; + } + + return _activeSession ??= new NativePairedTooltipSession(owner, releaseOwnerResources); + } +} + +/// +/// One feature's hold on the paired native tooltips. +/// +internal sealed class NativePairedTooltipSession +{ + private readonly object _owner; + private readonly Action _releaseOwnerResources; + + private AuxiliaryTooltipController? _activeAuxiliary; + private CardTooltipController? _activePrimary; + private NativeAuxiliaryHostState? _preparedNativeHost; + private CanvasGroupGate? _preparedPrimaryGate; + private CanvasGroupGate? _preparedAuxiliaryGate; + private RectTransform? _nativeAuxParentRect; + private GameObject? _contentRoot; + private GameObject? _nativeBackgroundRoot; + private Coroutine? _visibilityFade; + private Action? _onContentWidthChanged; + private NativePairedTooltipOptions _options; + private float _currentContentWidth; + private float _frameHorizontalBleed; + private bool _hidePending; + private int _generation; + + internal NativePairedTooltipSession(object owner, Action releaseOwnerResources) + { + _owner = owner; + _releaseOwnerResources = releaseOwnerResources; + } + + /// + /// Monotonic identity of the current presentation. + /// + /// + /// This is the single generation counter for the whole presentation — the host's fade + /// callbacks and the owner's async content work must both compare against it. Do not keep a + /// second counter on the owner side: the increment happens inside + /// before the owner releases its resources, and that ordering is what stops an already + /// cancelled async continuation from re-attaching to a torn-down presentation. + /// + internal int Generation => _generation; + + internal bool HasOwner(object owner) => ReferenceEquals(_owner, owner); + + internal bool OwnsPrimary(CardTooltipController controller) => + ReferenceEquals(_activePrimary, controller); + + internal bool OwnsAuxiliary(AuxiliaryTooltipController controller) => + ReferenceEquals(_activeAuxiliary, controller); + + internal bool IsContentActive => _contentRoot?.activeInHierarchy == true; + + // ── Preparation ──────────────────────────────────────────────────────────────────────── + + internal void PreparePrimary(CardTooltipController primary) + { + if (primary == null) + return; + if ( + _preparedPrimaryGate != null + && ReferenceEquals(_preparedPrimaryGate.Controller, primary) + ) + return; + + if (_activePrimary != null || _hidePending) + { + var displacedAuxiliary = _activeAuxiliary; + ConcealNativeAuxiliary(displacedAuxiliary); + Release(restoreNativeContent: false); + if (displacedAuxiliary != null) + Data.TooltipParentComponent?.HideAuxiliaryTooltipController(); + } + RestorePreparedPrimaryGate(); + var target = FindDescendant(primary.CanvasContentRectTransform, "Tooltip_Main"); + if (target != null) + _preparedPrimaryGate = CanvasGroupGate.Create(primary, target.gameObject); + } + + internal void CancelPreparedPrimary(CardTooltipController primary) + { + if ( + _preparedPrimaryGate == null + || !ReferenceEquals(_preparedPrimaryGate.Controller, primary) + ) + return; + + if (ReferenceEquals(_activePrimary, primary)) + Release(restoreNativeContent: false); + else + RestorePreparedPrimaryGate(); + } + + internal void PrepareAuxiliary(AuxiliaryTooltipController auxiliary) + { + if (_activeAuxiliary != null || _contentRoot != null) + Release(restoreNativeContent: false); + RestorePreparedNativeHost(); + RestorePreparedAuxiliaryGate(); + _preparedNativeHost = NativeAuxiliaryHostState.Capture(auxiliary); + if (auxiliary.auxParent != null) + { + _preparedAuxiliaryGate = CanvasGroupGate.Create( + auxiliary, + auxiliary.auxParent.gameObject, + forceNonInteractive: true + ); + } + } + + internal void CancelPreparedAuxiliary(AuxiliaryTooltipController auxiliary) + { + if ( + _preparedNativeHost == null + || !ReferenceEquals(_preparedNativeHost.Controller, auxiliary) + ) + return; + + if (ReferenceEquals(_activeAuxiliary, auxiliary)) + Release(restoreNativeContent: false); + else + { + RestorePreparedNativeHost(); + RestorePreparedAuxiliaryGate(); + } + } + + /// + /// Hands back a prepared-but-not-active native host snapshot. + /// + /// + /// Called when some other code is about to show the native auxiliary tooltip this session had + /// only prepared. Skipping this leaves the native layout's padding and anchors permanently + /// rewritten. + /// + internal void ReleasePrepared(AuxiliaryTooltipController controller) => + RestorePreparedNativeHost(restoreContentVisibility: true, expectedController: controller); + + // ── Open / content ───────────────────────────────────────────────────────────────────── + + /// + /// Takes over the native pair: hides native auxiliary content, mirrors the primary frame, and + /// clones the native background. Returns false (never throws) when the native shape is not + /// usable, having already undone any partial work. + /// + internal bool TryOpen( + AuxiliaryTooltipController auxiliary, + CardTooltipController primary, + NativePairedTooltipOptions options + ) + { + if ( + auxiliary.auxParent == null + || auxiliary.headerText == null + || auxiliary.bodyText == null + ) + return false; + + if (_activeAuxiliary != null || _contentRoot != null) + Release(restoreNativeContent: false); + + _options = options; + if ( + _preparedNativeHost == null + || !ReferenceEquals(_preparedNativeHost.Controller, auxiliary) + ) + { + _preparedNativeHost = NativeAuxiliaryHostState.Capture(auxiliary); + } + + if ( + _preparedPrimaryGate == null + || !ReferenceEquals(_preparedPrimaryGate.Controller, primary) + ) + PreparePrimary(primary); + if ( + _preparedAuxiliaryGate == null + || !ReferenceEquals(_preparedAuxiliaryGate.Controller, auxiliary) + ) + { + _preparedAuxiliaryGate = CanvasGroupGate.Create( + auxiliary, + auxiliary.auxParent.gameObject, + forceNonInteractive: true + ); + } + + _generation++; + _hidePending = false; + _activeAuxiliary = auxiliary; + _activePrimary = primary; + auxiliary.headerText.gameObject.SetActive(false); + auxiliary.bodyText.gameObject.SetActive(false); + auxiliary.dividerParent?.SetActive(false); + if ( + auxiliary.backgroundImage == null + || primary.backgroundImage == null + || primary.backgroundImage.sprite == null + ) + { + Release(restoreNativeContent: false); + return false; + } + + auxiliary.backgroundImage.sprite = primary.backgroundImage.sprite; + auxiliary.backgroundImage.enabled = primary.backgroundImage.enabled; + PrepareNativePresentation(auxiliary); + ApplyContentWidth(auxiliary, options.PreferredContentWidth); + if (!TryCreateNativeBackground(auxiliary, primary)) + { + Release(restoreNativeContent: false); + return false; + } + + return true; + } + + /// + /// Registers the owner's content root and commits the layout for it. + /// + /// Root the host will size and, on release, destroy. + /// + /// Invoked whenever the host resizes the content, so the owner can retune width-dependent + /// pieces of its own layout. Deliberately a plain float callback: nothing about the owner's + /// content model crosses this boundary. + /// + internal bool AttachContent(GameObject contentRoot, Action? onContentWidthChanged) + { + var auxiliary = _activeAuxiliary; + if (auxiliary == null || contentRoot == null) + return false; + + _contentRoot = contentRoot; + _onContentWidthChanged = onContentWidthChanged; + ForceRebuildLayout(auxiliary); + ApplyContentWidth(auxiliary, _options.PreferredContentWidth); + ForceRebuildLayout(auxiliary); + ApplyNativeHeight(auxiliary); + return true; + } + + // ── Placement ────────────────────────────────────────────────────────────────────────── + + /// + /// Measures, chooses a side, shrinks the content to fit, positions the pair, and reports what + /// happened. The host performs no logging: the caller owns its reason codes. + /// + internal PlacementResult Position(Transform anchor, IPairedContentBudget content) + { + var auxiliary = _activeAuxiliary; + var primary = _activePrimary; + if ( + auxiliary == null + || primary == null + || _contentRoot == null + || primary.RootCanvasComponent == null + ) + return PlacementResult.Unplaced; + + Canvas.ForceUpdateCanvases(); + TempoUIUtility.ForceRebuildRecursive(primary.PositioningRectTransform); + TempoUIUtility.ForceRebuildRecursive(auxiliary.PositioningRectTransform); + LayoutRebuilder.ForceRebuildLayoutImmediate(primary.PositioningRectTransform); + LayoutRebuilder.ForceRebuildLayoutImmediate(auxiliary.PositioningRectTransform); + ApplyNativeHeight(auxiliary); + + if (primary.RootCanvasComponent.transform is not RectTransform canvasRect) + return PlacementResult.Unplaced; + + var canvasBounds = NativePairedTooltipPlacementMath.Inset( + canvasRect.rect, + _options.CanvasMargin + ); + var primaryBounds = GetPrimaryVisibleBounds(primary, canvasRect); + var availableRight = NativePairedTooltipPlacementMath.AvailableRight( + canvasBounds, + primaryBounds, + _options.Gap + ); + var availableLeft = NativePairedTooltipPlacementMath.AvailableLeft( + canvasBounds, + primaryBounds, + _options.Gap + ); + var canvasUnitsPerLocalUnit = CanvasUnitsPerLocalUnit( + auxiliary.PositioningRectTransform, + canvasRect + ); + var preferredFrameWidth = + (_options.PreferredContentWidth + _frameHorizontalBleed) * canvasUnitsPerLocalUnit; + var side = NativePairedTooltipPlacementMath.ChooseSide( + availableRight, + availableLeft, + preferredFrameWidth + ); + + ApplyContentWidth( + auxiliary, + NativePairedTooltipPlacementMath.ResolveContentWidth( + side, + availableRight, + availableLeft, + canvasUnitsPerLocalUnit, + _frameHorizontalBleed, + _options.PreferredContentWidth + ) + ); + Canvas.ForceUpdateCanvases(); + TempoUIUtility.ForceRebuildRecursive(auxiliary.PositioningRectTransform); + LayoutRebuilder.ForceRebuildLayoutImmediate(auxiliary.PositioningRectTransform); + ApplyNativeHeight(auxiliary); + FitContentToCanvas(auxiliary, canvasRect, canvasBounds, content); + + var panelRect = auxiliary.backgroundImage.rectTransform; + var panelBounds = GetCanvasLocalBounds(panelRect, canvasRect); + var offset = NativePairedTooltipPlacementMath.ResolvePairOffset( + side, + primaryBounds, + panelBounds, + _options.Gap + ); + TranslateRect(auxiliary.PositioningRectTransform, canvasRect, offset); + + panelBounds = GetCanvasLocalBounds(panelRect, canvasRect); + var verticalAdjustment = NativePairedTooltipPlacementMath.ResolveVerticalAdjustment( + panelBounds, + canvasBounds + ); + var topAlignmentAdjusted = + Mathf.Abs(verticalAdjustment) > NativePairedTooltipMetrics.Epsilon; + if (!Mathf.Approximately(verticalAdjustment, 0f)) + { + TranslateRect( + auxiliary.PositioningRectTransform, + canvasRect, + new Vector2(0f, verticalAdjustment) + ); + panelBounds = GetCanvasLocalBounds(panelRect, canvasRect); + } + + return new PlacementResult( + positioned: true, + side: side, + contentWidth: _currentContentWidth, + overflowed: NativePairedTooltipPlacementMath.Overflows( + side, + primaryBounds, + panelBounds, + canvasRect.rect, + _options.Gap + ), + widthBelowReadable: _currentContentWidth < _options.ReadableContentWidth, + topAlignmentAdjusted: topAlignmentAdjusted + ); + } + + /// + /// Masks the pair for the duration of a content swap, so intermediate + /// passes never reach the screen. + /// + /// + /// Disposal restores the pre-mask alpha even when the body throws — callers rely on the pair + /// not being stranded at alpha 0 after a failed swap. The host does not orchestrate what + /// happens inside the scope: applying, reverting and re-positioning are the caller's business. + /// + internal IDisposable BeginMaskedLayout() => new MaskedLayoutScope(this); + + /// Rebuilds the native auxiliary layout and re-applies its height. + internal void RebuildLayout() + { + var auxiliary = _activeAuxiliary; + if (auxiliary == null) + return; + + ForceRebuildLayout(auxiliary); + ApplyNativeHeight(auxiliary); + } + + // ── Visibility ───────────────────────────────────────────────────────────────────────── + + internal void Reveal() + { + if (_activeAuxiliary == null || _activePrimary == null || _contentRoot == null) + return; + + _hidePending = false; + StartVisibilityFade(targetAlpha: 1f, cleanupOnComplete: false); + } + + internal void Hide() + { + if ( + _activeAuxiliary == null + && _contentRoot == null + && _preparedPrimaryGate == null + && _preparedAuxiliaryGate == null + ) + return; + + if (_hidePending) + return; + _hidePending = true; + if (_activePrimary != null) + _activePrimary.SetLockedFlag(false); + + var visibleAlpha = Mathf.Max( + _preparedPrimaryGate?.Alpha ?? 0f, + _preparedAuxiliaryGate?.Alpha ?? 0f + ); + if (visibleAlpha <= NativePairedTooltipMetrics.Epsilon || _activeAuxiliary == null) + { + CompleteAnimatedHide(_generation); + return; + } + + StartVisibilityFade(targetAlpha: 0f, cleanupOnComplete: true); + } + + /// + /// Finishes a hide synchronously, skipping any in-flight fade. + /// + /// + /// The fade coroutine is hosted on the native , whose + /// lifetime this host does not control: if that MonoBehaviour is deactivated or destroyed + /// mid-fade the coroutine dies silently and the completion callback never arrives. Generation + /// checks reject late callbacks but cannot rescue one that never fires, so the consuming + /// feature must call this from whatever native signal tells it the tooltip is going away. + /// + internal void ForceSettle() + { + // Deliberately not CompleteAnimatedHide: that also asks the tooltip parent to hide the + // native auxiliary controller, which is right when *we* decided to hide but wrong here — + // the native side is already tearing itself down and re-entering it changes behavior. + // Release() stops any in-flight fade on its own. + ConcealNativeAuxiliary(_activeAuxiliary); + Release(restoreNativeContent: false); + } + + private void StartVisibilityFade(float targetAlpha, bool cleanupOnComplete) + { + StopVisibilityFade(); + var auxiliary = _activeAuxiliary; + if (auxiliary == null || !auxiliary.isActiveAndEnabled) + { + SetVisibilityAlpha(targetAlpha); + if (cleanupOnComplete) + CompleteAnimatedHide(_generation); + return; + } + + var generation = _generation; + _visibilityFade = auxiliary.StartCoroutine( + FadeVisibility(targetAlpha, cleanupOnComplete, generation) + ); + } + + private System.Collections.IEnumerator FadeVisibility( + float targetAlpha, + bool cleanupOnComplete, + int generation + ) + { + var primaryStart = _preparedPrimaryGate?.Alpha ?? targetAlpha; + var auxiliaryStart = _preparedAuxiliaryGate?.Alpha ?? targetAlpha; + var elapsed = 0f; + while (elapsed < _options.FadeDuration) + { + if (generation != _generation) + { + _visibilityFade = null; + yield break; + } + + elapsed += Time.unscaledDeltaTime; + var progress = Mathf.Clamp01(elapsed / _options.FadeDuration); + if (!cleanupOnComplete) + { + _preparedPrimaryGate?.SetAlpha(Mathf.Lerp(primaryStart, targetAlpha, progress)); + } + _preparedAuxiliaryGate?.SetAlpha(Mathf.Lerp(auxiliaryStart, targetAlpha, progress)); + yield return null; + } + + if (cleanupOnComplete) + { + _preparedPrimaryGate?.SetAlpha(0f); + _preparedAuxiliaryGate?.SetAlpha(0f); + } + else + { + SetVisibilityAlpha(targetAlpha); + } + _visibilityFade = null; + if (cleanupOnComplete) + CompleteAnimatedHide(generation); + } + + private void SetVisibilityAlpha(float alpha) + { + _preparedPrimaryGate?.SetAlpha(alpha); + _preparedAuxiliaryGate?.SetAlpha(alpha); + } + + private void StopVisibilityFade() + { + if (_visibilityFade == null) + return; + + if (_activeAuxiliary != null) + _activeAuxiliary.StopCoroutine(_visibilityFade); + _visibilityFade = null; + } + + private void CompleteAnimatedHide(int generation) + { + if (generation != _generation) + return; + + ConcealNativeAuxiliary(_activeAuxiliary); + _visibilityFade = null; + if (!Release(restoreNativeContent: false)) + return; + Data.TooltipParentComponent?.HideAuxiliaryTooltipController(); + } + + private static void ConcealNativeAuxiliary(AuxiliaryTooltipController? auxiliary) + { + if (auxiliary?.tooltipCanvasGroup != null) + auxiliary.tooltipCanvasGroup.alpha = 0f; + } + + // ── Release ──────────────────────────────────────────────────────────────────────────── + + /// + /// Tears the presentation down. Returns false when there was nothing to release. + /// + /// + /// + /// Release is two-phase, not once-only. With + /// = false the presentation is dropped but the native + /// host snapshot is deliberately kept, so a later call with true still has something to hand + /// back. That second pass is what restores the native auxiliary tooltip's header, body and + /// divider; making this idempotent leaves the game's own auxiliary tooltip permanently blank. + /// + /// + /// The ordering is load-bearing: stop the fade and bump the generation first, then let + /// the owner drop its async work, and only then destroy UI and restore native state. Releasing + /// owner resources before the generation is invalidated lets an in-flight continuation slip + /// through its own identity check and re-attach to a presentation that is being destroyed. + /// + /// + internal bool Release(bool restoreNativeContent) + { + var hadActiveContent = + _activeAuxiliary != null + || _contentRoot != null + || _nativeBackgroundRoot != null + || _preparedNativeHost != null + || _preparedPrimaryGate != null + || _preparedAuxiliaryGate != null; + if (!hadActiveContent) + return false; + + StopVisibilityFade(); + _generation++; + _releaseOwnerResources(); + if (_contentRoot != null) + { + _contentRoot.SetActive(false); + Object.Destroy(_contentRoot); + } + if (_nativeBackgroundRoot != null) + { + _nativeBackgroundRoot.SetActive(false); + Object.Destroy(_nativeBackgroundRoot); + } + RestorePreparedNativeHost(restoreNativeContent); + RestorePreparedAuxiliaryGate(); + RestorePreparedPrimaryGate(); + if (_activePrimary != null) + _activePrimary.SetLockedFlag(false); + + _activeAuxiliary = null; + _activePrimary = null; + _contentRoot = null; + _nativeBackgroundRoot = null; + _nativeAuxParentRect = null; + _onContentWidthChanged = null; + _currentContentWidth = _options.PreferredContentWidth; + _frameHorizontalBleed = 0f; + _hidePending = false; + return true; + } + + private void RestorePreparedNativeHost( + bool restoreContentVisibility = true, + AuxiliaryTooltipController? expectedController = null + ) + { + if (_preparedNativeHost == null) + return; + if ( + expectedController != null + && !ReferenceEquals(_preparedNativeHost.Controller, expectedController) + ) + return; + + _preparedNativeHost.Restore(restoreContentVisibility); + if (restoreContentVisibility) + _preparedNativeHost = null; + } + + private void RestorePreparedAuxiliaryGate() + { + _preparedAuxiliaryGate?.Restore(); + _preparedAuxiliaryGate = null; + } + + private void RestorePreparedPrimaryGate() + { + _preparedPrimaryGate?.Restore(); + _preparedPrimaryGate = null; + } + + // ── Native layout plumbing ───────────────────────────────────────────────────────────── + + private void PrepareNativePresentation(AuxiliaryTooltipController auxiliary) + { + _nativeAuxParentRect = auxiliary.auxParent.transform as RectTransform; + if (_nativeAuxParentRect == null) + return; + + var frameRect = auxiliary.backgroundImage.rectTransform; + _frameHorizontalBleed = Mathf.Max( + 0f, + frameRect.rect.width - _nativeAuxParentRect.rect.width + ); + _nativeAuxParentRect.anchorMin = new Vector2(0.5f, 0.5f); + _nativeAuxParentRect.anchorMax = new Vector2(0.5f, 0.5f); + _nativeAuxParentRect.pivot = new Vector2(0.5f, 0.5f); + // auxParent and the frame are sibling rects in the native Auxiliary Tooltip prefab. + // Match the frame's center instead of mirroring its serialized offset; mirroring doubles + // any inset and makes the left/right content padding visibly asymmetric. + _nativeAuxParentRect.anchoredPosition = frameRect.anchoredPosition; + + var nativeLayout = auxiliary.auxParent.GetComponent(); + if (nativeLayout != null) + { + var padding = nativeLayout.padding; + nativeLayout.padding = new RectOffset( + padding.left, + padding.right, + padding.top, + Mathf.Max(0, padding.bottom - _options.NativeBottomPaddingReduction) + ); + } + } + + private void ApplyContentWidth(AuxiliaryTooltipController auxiliary, float contentWidth) + { + _currentContentWidth = Mathf.Max(1f, contentWidth); + if (_contentRoot != null) + { + var contentRect = (RectTransform)_contentRoot.transform; + var contentLayout = _contentRoot.GetComponent(); + contentLayout.preferredWidth = _currentContentWidth; + contentLayout.minWidth = _currentContentWidth; + contentRect.SetSizeWithCurrentAnchors( + RectTransform.Axis.Horizontal, + _currentContentWidth + ); + } + + _onContentWidthChanged?.Invoke(_currentContentWidth); + + _nativeAuxParentRect?.SetSizeWithCurrentAnchors( + RectTransform.Axis.Horizontal, + _currentContentWidth + ); + auxiliary.PositioningRectTransform.SetSizeWithCurrentAnchors( + RectTransform.Axis.Horizontal, + _currentContentWidth + _frameHorizontalBleed + ); + } + + private void ApplyNativeHeight(AuxiliaryTooltipController auxiliary) + { + var contentHeight = _nativeAuxParentRect?.rect.height ?? 0f; + if (contentHeight <= 0f) + return; + + auxiliary.PositioningRectTransform.SetSizeWithCurrentAnchors( + RectTransform.Axis.Vertical, + contentHeight + ); + } + + /// + /// Drives the owner's trim strategy until the panel fits the canvas height. + /// + /// + /// Measurement and relayout stay here; the owner only decides what to drop next. That keeps the + /// fit loop from bouncing back and forth across the host boundary once per trimmed row. + /// + private void FitContentToCanvas( + AuxiliaryTooltipController auxiliary, + RectTransform canvasRect, + Rect canvasBounds, + IPairedContentBudget content + ) + { + content.RestoreAll(); + RebuildAfterHeightBudgetChange(auxiliary); + + while (!FitsCanvasHeight(auxiliary, canvasRect, canvasBounds)) + { + if (!content.TryShrinkOneStep()) + return; + RebuildAfterHeightBudgetChange(auxiliary); + } + } + + private bool FitsCanvasHeight( + AuxiliaryTooltipController auxiliary, + RectTransform canvasRect, + Rect canvasBounds + ) => + GetCanvasLocalBounds(auxiliary.backgroundImage.rectTransform, canvasRect).height + <= canvasBounds.height + NativePairedTooltipMetrics.Epsilon; + + private void RebuildAfterHeightBudgetChange(AuxiliaryTooltipController auxiliary) + { + ForceRebuildLayout(auxiliary); + ApplyNativeHeight(auxiliary); + } + + private static void ForceRebuildLayout(AuxiliaryTooltipController auxiliary) + { + // A newly activated nested ContentSizeFitter can expose its previous preferred height on + // the first pass. Two bounded passes resolve the child and parent sizes in this frame. + for (var pass = 0; pass < 2; pass++) + { + Canvas.ForceUpdateCanvases(); + TempoUIUtility.ForceRebuildRecursive(auxiliary.PositioningRectTransform); + LayoutRebuilder.ForceRebuildLayoutImmediate(auxiliary.PositioningRectTransform); + } + } + + private bool TryCreateNativeBackground( + AuxiliaryTooltipController auxiliary, + CardTooltipController primary + ) + { + var sourceContainer = primary.gradientImage?.transform.parent as RectTransform; + var sourceMaskImage = sourceContainer?.GetComponent(); + var sourceMask = sourceContainer?.GetComponent(); + var frameRect = auxiliary.backgroundImage?.rectTransform; + if ( + sourceContainer == null + || sourceMaskImage == null + || sourceMask == null + || frameRect == null + || frameRect.parent == null + ) + return false; + + var background = new GameObject( + "BppPairedTooltipNativeBackground", + typeof(RectTransform), + typeof(CanvasRenderer), + typeof(Image), + typeof(Mask), + typeof(LayoutElement) + ); + var backgroundRect = (RectTransform)background.transform; + backgroundRect.SetParent(frameRect.parent, worldPositionStays: false); + CopyRectTransform(frameRect, backgroundRect); + backgroundRect.SetSiblingIndex(frameRect.GetSiblingIndex()); + background.GetComponent().ignoreLayout = true; + CopyImage(sourceMaskImage, background.GetComponent()); + background.GetComponent().enabled = true; + var mask = background.GetComponent(); + mask.enabled = sourceMask.enabled; + mask.showMaskGraphic = sourceMask.showMaskGraphic; + + for (var childIndex = 0; childIndex < sourceContainer.childCount; childIndex++) + { + if ( + sourceContainer.GetChild(childIndex) is not RectTransform sourceRect + || !sourceRect.TryGetComponent(out var sourceImage) + ) + continue; + + var layer = new GameObject( + $"BppPairedTooltipNativeLayer_{childIndex}", + typeof(RectTransform), + typeof(CanvasRenderer), + typeof(Image) + ); + var layerRect = (RectTransform)layer.transform; + layerRect.SetParent(backgroundRect, worldPositionStays: false); + CopyRectTransform(sourceRect, layerRect); + CopyImage(sourceImage, layer.GetComponent()); + layer.SetActive(sourceRect.gameObject.activeSelf); + } + + _nativeBackgroundRoot = background; + return true; + } + + private static void CopyRectTransform(RectTransform source, RectTransform destination) + { + destination.anchorMin = source.anchorMin; + destination.anchorMax = source.anchorMax; + destination.pivot = source.pivot; + destination.sizeDelta = source.sizeDelta; + destination.anchoredPosition3D = source.anchoredPosition3D; + destination.localRotation = source.localRotation; + destination.localScale = source.localScale; + } + + private static void CopyImage(Image source, Image destination) + { + destination.sprite = source.sprite; + destination.material = source.material; + destination.color = source.color; + destination.type = source.type; + destination.fillCenter = source.fillCenter; + destination.fillMethod = source.fillMethod; + destination.fillAmount = source.fillAmount; + destination.fillClockwise = source.fillClockwise; + destination.fillOrigin = source.fillOrigin; + destination.pixelsPerUnitMultiplier = source.pixelsPerUnitMultiplier; + destination.preserveAspect = source.preserveAspect; + destination.useSpriteMesh = source.useSpriteMesh; + destination.maskable = source.maskable; + destination.raycastTarget = false; + destination.enabled = source.enabled; + } + + // ── Canvas-space measurement ─────────────────────────────────────────────────────────── + + private readonly Vector3[] _worldCorners = new Vector3[4]; + + private Rect GetCanvasLocalBounds(RectTransform rect, RectTransform canvasRect) + { + rect.GetWorldCorners(_worldCorners); + var minX = float.PositiveInfinity; + var minY = float.PositiveInfinity; + var maxX = float.NegativeInfinity; + var maxY = float.NegativeInfinity; + foreach (var corner in _worldCorners) + { + var local = canvasRect.InverseTransformPoint(corner); + minX = Mathf.Min(minX, local.x); + minY = Mathf.Min(minY, local.y); + maxX = Mathf.Max(maxX, local.x); + maxY = Mathf.Max(maxY, local.y); + } + + return Rect.MinMaxRect(minX, minY, maxX, maxY); + } + + private Rect GetPrimaryVisibleBounds(CardTooltipController primary, RectTransform canvasRect) + { + var bounds = GetPrimaryFrameBounds(primary, canvasRect); + if ( + primary.cooldownClock != null + && primary.cooldownClock.gameObject.activeInHierarchy + && primary.cooldownClock.transform is RectTransform cooldownRect + ) + { + bounds = NativePairedTooltipPlacementMath.Union( + bounds, + GetCanvasLocalBounds(cooldownRect, canvasRect) + ); + } + return bounds; + } + + private Rect GetPrimaryFrameBounds(CardTooltipController primary, RectTransform canvasRect) + { + var bounds = GetCanvasLocalBounds(primary.PositioningRectTransform, canvasRect); + if (primary.CanvasContentRectTransform != null) + { + bounds = NativePairedTooltipPlacementMath.Union( + bounds, + GetCanvasLocalBounds(primary.CanvasContentRectTransform, canvasRect) + ); + } + return bounds; + } + + private static float CanvasUnitsPerLocalUnit(RectTransform rect, RectTransform canvasRect) + { + var origin = canvasRect.InverseTransformPoint(rect.TransformPoint(Vector3.zero)); + var horizontalUnit = canvasRect.InverseTransformPoint(rect.TransformPoint(Vector3.right)); + return Mathf.Max( + 0.0001f, + Vector2.Distance( + new Vector2(origin.x, origin.y), + new Vector2(horizontalUnit.x, horizontalUnit.y) + ) + ); + } + + private static void TranslateRect( + RectTransform rect, + RectTransform canvasRect, + Vector2 canvasLocalDelta + ) + { + if (canvasLocalDelta == Vector2.zero) + return; + + rect.position += canvasRect.TransformVector( + new Vector3(canvasLocalDelta.x, canvasLocalDelta.y) + ); + } + + internal static RectTransform? FindDescendant(Transform? root, string childName) + { + if (root == null) + return null; + for (var index = 0; index < root.childCount; index++) + { + var child = root.GetChild(index); + if ( + string.Equals(child.name, childName, StringComparison.Ordinal) + && child is RectTransform matching + ) + return matching; + var nested = FindDescendant(child, childName); + if (nested != null) + return nested; + } + return null; + } + + // ── Nested helpers ───────────────────────────────────────────────────────────────────── + + private sealed class MaskedLayoutScope : IDisposable + { + private readonly NativePairedTooltipSession _session; + private readonly float _visibleAlpha; + private bool _disposed; + + internal MaskedLayoutScope(NativePairedTooltipSession session) + { + _session = session; + _visibleAlpha = session._preparedAuxiliaryGate?.Alpha ?? 1f; + session._preparedAuxiliaryGate?.SetAlpha(0f); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + // Content swaps are atomic. Do not expose the intermediate ContentSizeFitter passes + // that otherwise make the paired tooltips jump for a frame. + _session._preparedAuxiliaryGate?.SetAlpha(_visibleAlpha); + } + } + + private sealed class CanvasGroupGate + { + private readonly CanvasGroup _group; + private readonly bool _ownedGroup; + private readonly float _originalAlpha; + private readonly bool _originalInteractable; + private readonly bool _originalBlocksRaycasts; + private readonly bool _originalIgnoreParentGroups; + private readonly bool _forceNonInteractive; + private bool _restored; + + private CanvasGroupGate(object controller, GameObject target, bool forceNonInteractive) + { + Controller = controller; + _forceNonInteractive = forceNonInteractive; + _group = target.GetComponent(); + if (_group == null) + { + _group = target.AddComponent(); + _ownedGroup = true; + } + _originalAlpha = _group.alpha; + _originalInteractable = _group.interactable; + _originalBlocksRaycasts = _group.blocksRaycasts; + _originalIgnoreParentGroups = _group.ignoreParentGroups; + SetAlpha(0f); + } + + internal object Controller { get; } + + internal float Alpha => _group == null ? 0f : _group.alpha; + + internal static CanvasGroupGate Create( + object controller, + GameObject target, + bool forceNonInteractive = false + ) => new(controller, target, forceNonInteractive); + + internal void SetAlpha(float alpha) + { + if (_group == null || _restored) + return; + + _group.alpha = Mathf.Clamp01(alpha); + var interactive = _group.alpha >= 1f - NativePairedTooltipMetrics.Epsilon; + _group.interactable = !_forceNonInteractive && interactive && _originalInteractable; + _group.blocksRaycasts = !_forceNonInteractive && interactive && _originalBlocksRaycasts; + } + + internal void Restore() + { + if (_restored || _group == null) + return; + + _restored = true; + _group.alpha = _originalAlpha; + _group.interactable = _originalInteractable; + _group.blocksRaycasts = _originalBlocksRaycasts; + _group.ignoreParentGroups = _originalIgnoreParentGroups; + if (_ownedGroup) + Object.Destroy(_group); + } + } + + private sealed class NativeAuxiliaryHostState + { + private readonly RectTransform? _auxParentRect; + private readonly Vector2 _auxParentSizeDelta; + private readonly Vector2 _auxParentAnchorMin; + private readonly Vector2 _auxParentAnchorMax; + private readonly Vector2 _auxParentPivot; + private readonly Vector3 _auxParentAnchoredPosition; + private readonly Vector2 _positioningSizeDelta; + private readonly VerticalLayoutGroup? _layout; + private readonly RectOffset? _padding; + private readonly float _spacing; + private readonly bool _headerWasActive; + private readonly bool _bodyWasActive; + private readonly bool _dividerWasActive; + private readonly Sprite? _backgroundSprite; + private readonly bool _backgroundImageWasEnabled; + + private NativeAuxiliaryHostState(AuxiliaryTooltipController controller) + { + Controller = controller; + _auxParentRect = controller.auxParent?.transform as RectTransform; + _auxParentSizeDelta = _auxParentRect?.sizeDelta ?? Vector2.zero; + _auxParentAnchorMin = _auxParentRect?.anchorMin ?? Vector2.zero; + _auxParentAnchorMax = _auxParentRect?.anchorMax ?? Vector2.zero; + _auxParentPivot = _auxParentRect?.pivot ?? Vector2.zero; + _auxParentAnchoredPosition = _auxParentRect?.anchoredPosition3D ?? Vector3.zero; + _positioningSizeDelta = controller.PositioningRectTransform.sizeDelta; + _layout = controller.auxParent?.GetComponent(); + _padding = + _layout == null + ? null + : new RectOffset( + _layout.padding.left, + _layout.padding.right, + _layout.padding.top, + _layout.padding.bottom + ); + _spacing = _layout?.spacing ?? 0f; + _headerWasActive = + controller.headerText != null && controller.headerText.gameObject.activeSelf; + _bodyWasActive = + controller.bodyText != null && controller.bodyText.gameObject.activeSelf; + _dividerWasActive = + controller.dividerParent != null && controller.dividerParent.activeSelf; + _backgroundSprite = controller.backgroundImage?.sprite; + _backgroundImageWasEnabled = + controller.backgroundImage != null && controller.backgroundImage.enabled; + } + + internal AuxiliaryTooltipController Controller { get; } + + internal static NativeAuxiliaryHostState Capture(AuxiliaryTooltipController controller) => + new(controller); + + internal void Restore(bool restoreContentVisibility) + { + if (Controller == null) + return; + + Controller.PositioningRectTransform.sizeDelta = _positioningSizeDelta; + if (_auxParentRect != null) + { + _auxParentRect.anchorMin = _auxParentAnchorMin; + _auxParentRect.anchorMax = _auxParentAnchorMax; + _auxParentRect.pivot = _auxParentPivot; + _auxParentRect.anchoredPosition3D = _auxParentAnchoredPosition; + _auxParentRect.sizeDelta = _auxParentSizeDelta; + } + if (_layout != null && _padding != null) + { + _layout.padding = new RectOffset( + _padding.left, + _padding.right, + _padding.top, + _padding.bottom + ); + _layout.spacing = _spacing; + } + if (restoreContentVisibility) + { + if (Controller.headerText != null) + Controller.headerText.gameObject.SetActive(_headerWasActive); + if (Controller.bodyText != null) + Controller.bodyText.gameObject.SetActive(_bodyWasActive); + if (Controller.dividerParent != null) + Controller.dividerParent.SetActive(_dividerWasActive); + } + if (Controller.backgroundImage != null) + { + Controller.backgroundImage.sprite = _backgroundSprite; + Controller.backgroundImage.enabled = _backgroundImageWasEnabled; + } + } + } +} diff --git a/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipPlacementMath.cs b/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipPlacementMath.cs new file mode 100644 index 00000000..da1e7b4e --- /dev/null +++ b/src/BazaarPlusPlus/GameInterop/Tooltips/NativePairedTooltipPlacementMath.cs @@ -0,0 +1,149 @@ +#nullable enable +using UnityEngine; + +namespace BazaarPlusPlus.GameInterop.Tooltips; + +/// +/// The purely geometric decisions behind paired-tooltip placement. +/// +/// +/// Every method here is a pure function of the rects and scalars it is given — nothing reads a +/// live , , or any other Unity runtime state — so +/// the placement rules are unit-testable without starting the game. +/// +/// The formulas and the tolerance are preserved +/// verbatim from the pre-extraction implementation. This file intentionally keeps its +/// UnityEngine dependency for // +/// rather than restating the maths over bespoke primitives: rewriting the expressions would put +/// the "behavior is unchanged" guarantee at risk for no test-harness benefit. +/// +/// +internal static class NativePairedTooltipPlacementMath +{ + /// + /// Picks the side for the auxiliary panel: prefer the right when the preferred width fits, + /// then the left, otherwise whichever side has more room (ties keep the right). + /// + internal static PairSide ChooseSide( + float availableRight, + float availableLeft, + float preferredFrameWidth + ) + { + if (availableRight + NativePairedTooltipMetrics.Epsilon >= preferredFrameWidth) + return PairSide.Right; + if (availableLeft + NativePairedTooltipMetrics.Epsilon >= preferredFrameWidth) + return PairSide.Left; + return availableRight >= availableLeft ? PairSide.Right : PairSide.Left; + } + + /// Horizontal room to the right of the primary tooltip, inside the canvas bounds. + internal static float AvailableRight(Rect canvasBounds, Rect primaryBounds, float gap) => + Mathf.Max(0f, canvasBounds.xMax - (primaryBounds.xMax + gap)); + + /// Horizontal room to the left of the primary tooltip, inside the canvas bounds. + internal static float AvailableLeft(Rect canvasBounds, Rect primaryBounds, float gap) => + Mathf.Max(0f, primaryBounds.xMin - gap - canvasBounds.xMin); + + /// + /// Content width that fits on , in the panel's own local units. + /// + internal static float ResolveContentWidth( + PairSide side, + float availableRight, + float availableLeft, + float canvasUnitsPerLocalUnit, + float frameHorizontalBleed, + float preferredContentWidth + ) + { + var available = side == PairSide.Right ? availableRight : availableLeft; + var availableContentWidth = available / canvasUnitsPerLocalUnit - frameHorizontalBleed; + return Mathf.Min(preferredContentWidth, Mathf.Max(1f, availableContentWidth)); + } + + /// + /// Offset that moves the panel beside the primary tooltip and top-aligns the two. + /// + internal static Vector2 ResolvePairOffset( + PairSide side, + Rect primaryBounds, + Rect panelBounds, + float gap + ) => + side == PairSide.Right + ? new Vector2( + primaryBounds.xMax + gap - panelBounds.xMin, + primaryBounds.yMax - panelBounds.yMax + ) + : new Vector2( + primaryBounds.xMin - gap - panelBounds.xMax, + primaryBounds.yMax - panelBounds.yMax + ); + + /// + /// Vertical correction that pulls back inside . + /// A rect taller than the bounds is top-aligned rather than centered. + /// + internal static float ResolveVerticalAdjustment(Rect rect, Rect bounds) + { + if (rect.height > bounds.height + NativePairedTooltipMetrics.Epsilon) + return bounds.yMax - rect.yMax; + + var adjustment = rect.yMax > bounds.yMax ? bounds.yMax - rect.yMax : 0f; + if (rect.yMin + adjustment < bounds.yMin) + adjustment += bounds.yMin - (rect.yMin + adjustment); + return adjustment; + } + + /// + /// True when the panel has crept back into the primary tooltip's gap on the chosen side. + /// + internal static bool Collides(PairSide side, Rect primaryBounds, Rect panelBounds, float gap) => + side == PairSide.Right + ? panelBounds.xMin < primaryBounds.xMax + gap - NativePairedTooltipMetrics.Epsilon + : panelBounds.xMax > primaryBounds.xMin - gap + NativePairedTooltipMetrics.Epsilon; + + /// + /// True when the primary/panel pair does not fit the screen, or the two overlap. + /// + internal static bool Overflows( + PairSide side, + Rect primaryBounds, + Rect panelBounds, + Rect screenBounds, + float gap + ) + { + var pairBounds = Union(primaryBounds, panelBounds); + return pairBounds.width > screenBounds.width + || pairBounds.height > screenBounds.height + || pairBounds.xMin < screenBounds.xMin - NativePairedTooltipMetrics.Epsilon + || pairBounds.xMax > screenBounds.xMax + NativePairedTooltipMetrics.Epsilon + || pairBounds.yMin < screenBounds.yMin - NativePairedTooltipMetrics.Epsilon + || pairBounds.yMax > screenBounds.yMax + NativePairedTooltipMetrics.Epsilon + || Collides(side, primaryBounds, panelBounds, gap); + } + + /// Smallest rect containing both inputs. + internal static Rect Union(Rect first, Rect second) => + Rect.MinMaxRect( + Mathf.Min(first.xMin, second.xMin), + Mathf.Min(first.yMin, second.yMin), + Mathf.Max(first.xMax, second.xMax), + Mathf.Max(first.yMax, second.yMax) + ); + + /// Shrinks a rect on all sides, never past its own center. + internal static Rect Inset(Rect rect, float inset) + { + var horizontalInset = Mathf.Min(inset, rect.width * 0.5f); + var verticalInset = Mathf.Min(inset, rect.height * 0.5f); + return Rect.MinMaxRect( + rect.xMin + horizontalInset, + rect.yMin + verticalInset, + rect.xMax - horizontalInset, + rect.yMax - verticalInset + ); + } +} diff --git a/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs b/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs index 00688298..a75b3eb1 100644 --- a/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs +++ b/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs @@ -8,11 +8,11 @@ using BazaarPlusPlus.GameInterop.Fonts; using BazaarPlusPlus.GameInterop.HeroPortraits; using BazaarPlusPlus.GameInterop.TagTypography; +using BazaarPlusPlus.GameInterop.Tooltips; using BazaarPlusPlus.Infrastructure; using BazaarPlusPlus.Localization; using TheBazaar; using TheBazaar.UI.Tooltips; -using TheBazaar.Utilities; using TMPro; using UnityEngine; using UnityEngine.UI; @@ -47,125 +47,68 @@ internal sealed class NativePostCombatImpactTooltipView : IPostCombatImpactToolt private static readonly Color32 ReceivedAccentColor = new(83, 197, 222, 255); private static readonly Color32 ShiftAccentColor = new(250, 211, 105, 255); private static readonly Color32 DisclosureColor = new(211, 190, 157, 255); + private static readonly NativePairedTooltipOptions PairedOptions = new( + preferredContentWidth: TooltipPreferredWidth, + readableContentWidth: TooltipReadableWidth, + gap: TooltipGap, + canvasMargin: CanvasMargin, + fadeDuration: VisibilityFadeDuration, + nativeBottomPaddingReduction: NativeBottomPaddingReduction + ); + private readonly INativeCardPreviewHost _previewHost; - private readonly Vector3[] _worldCorners = new Vector3[4]; + private readonly NativePairedTooltipSession _session; private readonly List _metricColumns = []; private readonly List _previewSessions = []; private readonly List _causedBlocks = []; private readonly List _receivedBlocks = []; - private AuxiliaryTooltipController? _activeAuxiliary; - private CardTooltipController? _activePrimary; private GameObject? _contentRoot; private GameObject? _causedRoot; private GameObject? _receivedRoot; - private GameObject? _nativeBackgroundRoot; private TMP_Text? _causedMoreText; private TMP_Text? _receivedMoreText; private NativePreviewOwner? _previewOwner; private INativeCardPreviewScope? _previewScope; private CancellationTokenSource? _previewCancellation; - private NativeAuxiliaryHostState? _preparedNativeHost; - private CanvasGroupGate? _preparedPrimaryGate; - private CanvasGroupGate? _preparedAuxiliaryGate; - private RectTransform? _nativeAuxParentRect; - private Coroutine? _visibilityFade; - private float _currentTooltipWidth = TooltipPreferredWidth; - private float _frameHorizontalBleed; - private PairSide _pairSide; - private bool _pairOverflowed; - private bool _widthBelowReadable; - private bool _topAlignmentAdjusted; private bool _overflowDegradedLogged; private bool _widthDegradedLogged; private bool _topAlignmentDegradedLogged; - private bool _hidePending; private bool _receivedPerspectiveAvailable; private int _pendingPreviewCount; - private int _renderGeneration; private CombatImpactPerspective _activePerspective = CombatImpactPerspective.Caused; - internal NativePostCombatImpactTooltipView(INativeCardPreviewHost previewHost) => + internal NativePostCombatImpactTooltipView( + INativeCardPreviewHost previewHost, + NativePairedTooltipHost tooltipHost + ) + { _previewHost = previewHost ?? throw new ArgumentNullException(nameof(previewHost)); + if (tooltipHost == null) + throw new ArgumentNullException(nameof(tooltipHost)); + // The session's generation is bumped before this callback runs, so an async preview + // continuation that races cleanup can no longer pass its own identity check. + _session = tooltipHost.Acquire(this, ReleaseOwnerResources); + } public string Header => T("本场影响", "Combat Impact"); public bool IsReadyToReveal => _pendingPreviewCount == 0; - public bool IsContentActive => _contentRoot?.activeInHierarchy == true; + public bool IsContentActive => _session.IsContentActive; public bool CanSwitchPerspective => IsContentActive && _receivedPerspectiveAvailable; - public void PrepareNativePrimary(CardTooltipController primary) - { - if (primary == null) - return; - if ( - _preparedPrimaryGate != null - && ReferenceEquals(_preparedPrimaryGate.Controller, primary) - ) - return; + public void PrepareNativePrimary(CardTooltipController primary) => + _session.PreparePrimary(primary); - if (_activePrimary != null || _hidePending) - { - var displacedAuxiliary = _activeAuxiliary; - ConcealNativeAuxiliary(displacedAuxiliary); - CleanupCustomContent(restoreNativeContentVisibility: false); - if (displacedAuxiliary != null) - Data.TooltipParentComponent?.HideAuxiliaryTooltipController(); - } - RestorePreparedPrimaryGate(); - var target = FindDescendant(primary.CanvasContentRectTransform, "Tooltip_Main"); - if (target != null) - _preparedPrimaryGate = CanvasGroupGate.Create(primary, target.gameObject); - } + public void CancelPreparedNativePrimary(CardTooltipController primary) => + _session.CancelPreparedPrimary(primary); - public void CancelPreparedNativePrimary(CardTooltipController primary) - { - if ( - _preparedPrimaryGate == null - || !ReferenceEquals(_preparedPrimaryGate.Controller, primary) - ) - return; + public void PrepareNativeAuxiliary(AuxiliaryTooltipController auxiliary) => + _session.PrepareAuxiliary(auxiliary); - if (ReferenceEquals(_activePrimary, primary)) - CleanupCustomContent(restoreNativeContentVisibility: false); - else - RestorePreparedPrimaryGate(); - } - - public void PrepareNativeAuxiliary(AuxiliaryTooltipController auxiliary) - { - if (_activeAuxiliary != null || _contentRoot != null) - CleanupCustomContent(restoreNativeContentVisibility: false); - RestorePreparedNativeHost(); - RestorePreparedAuxiliaryGate(); - _preparedNativeHost = NativeAuxiliaryHostState.Capture(auxiliary); - if (auxiliary.auxParent != null) - { - _preparedAuxiliaryGate = CanvasGroupGate.Create( - auxiliary, - auxiliary.auxParent.gameObject, - forceNonInteractive: true - ); - } - } - - public void CancelPreparedNativeAuxiliary(AuxiliaryTooltipController auxiliary) - { - if ( - _preparedNativeHost == null - || !ReferenceEquals(_preparedNativeHost.Controller, auxiliary) - ) - return; - - if (ReferenceEquals(_activeAuxiliary, auxiliary)) - CleanupCustomContent(restoreNativeContentVisibility: false); - else - { - RestorePreparedNativeHost(); - RestorePreparedAuxiliaryGate(); - } - } + public void CancelPreparedNativeAuxiliary(AuxiliaryTooltipController auxiliary) => + _session.CancelPreparedAuxiliary(auxiliary); public bool Show( AuxiliaryTooltipController auxiliary, @@ -177,69 +120,20 @@ public bool Show( CombatImpactPerspective perspective ) { - if ( - auxiliary.auxParent == null - || auxiliary.headerText == null - || auxiliary.bodyText == null - || !IsTypographyReadyForCurrentLocale() - ) + if (!IsTypographyReadyForCurrentLocale()) return false; - if (_activeAuxiliary != null || _contentRoot != null) - CleanupCustomContent(restoreNativeContentVisibility: false); - if ( - _preparedNativeHost == null - || !ReferenceEquals(_preparedNativeHost.Controller, auxiliary) - ) - { - _preparedNativeHost = NativeAuxiliaryHostState.Capture(auxiliary); - } + // The host owns taking over the native pair; it self-cleans and returns false (never + // throws) so the caller keeps reporting AuxiliaryTooltipContentUnavailable rather than + // TooltipRenderException. + if (!_session.TryOpen(auxiliary, primary, PairedOptions)) + return false; - if ( - _preparedPrimaryGate == null - || !ReferenceEquals(_preparedPrimaryGate.Controller, primary) - ) - PrepareNativePrimary(primary); - if ( - _preparedAuxiliaryGate == null - || !ReferenceEquals(_preparedAuxiliaryGate.Controller, auxiliary) - ) - { - _preparedAuxiliaryGate = CanvasGroupGate.Create( - auxiliary, - auxiliary.auxParent.gameObject, - forceNonInteractive: true - ); - } - var generation = ++_renderGeneration; - _hidePending = false; + var generation = _session.Generation; _receivedPerspectiveAvailable = received != null; - _activeAuxiliary = auxiliary; - _activePrimary = primary; _activePerspective = _receivedPerspectiveAvailable ? perspective : CombatImpactPerspective.Caused; - auxiliary.headerText.gameObject.SetActive(false); - auxiliary.bodyText.gameObject.SetActive(false); - auxiliary.dividerParent?.SetActive(false); - if ( - auxiliary.backgroundImage == null - || primary.backgroundImage == null - || primary.backgroundImage.sprite == null - ) - { - CleanupCustomContent(restoreNativeContentVisibility: false); - return false; - } - auxiliary.backgroundImage.sprite = primary.backgroundImage.sprite; - auxiliary.backgroundImage.enabled = primary.backgroundImage.enabled; - PrepareNativePresentation(auxiliary); - ApplyTooltipWidth(auxiliary, TooltipPreferredWidth); - if (!TryCreateNativeBackground(auxiliary, primary)) - { - CleanupCustomContent(restoreNativeContentVisibility: false); - return false; - } var root = CreateVertical("BppPostCombatImpactContent", auxiliary.auxParent.transform, 8f); _contentRoot = root.gameObject; @@ -248,10 +142,10 @@ CombatImpactPerspective perspective AddLayout( root.gameObject, preferredHeight: -1f, - preferredWidth: _currentTooltipWidth, - minWidth: _currentTooltipWidth + preferredWidth: TooltipPreferredWidth, + minWidth: TooltipPreferredWidth ); - root.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, _currentTooltipWidth); + root.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, TooltipPreferredWidth); var causedRoot = CreateVertical("ImpactCausedPerspective", root, 8f); var receivedRoot = CreateVertical("ImpactReceivedPerspective", root, 8f); _causedRoot = causedRoot.gameObject; @@ -278,12 +172,25 @@ CombatImpactPerspective perspective generation ); ApplyPerspectiveVisibility(_activePerspective); + return _session.AttachContent(_contentRoot, ApplyMetricColumnWidth); + } - ForceRebuildLayout(auxiliary); - ApplyTooltipWidth(auxiliary, TooltipPreferredWidth); - ForceRebuildLayout(auxiliary); - ApplyNativeHeight(auxiliary); - return true; + /// + /// Retunes the metric columns whenever the host resizes the panel. + /// + /// + /// The column ratio and its clamps are Combat Impact design parameters, so they stay here + /// instead of becoming defaults inside the shared host. Only a float crosses the boundary. + /// + private void ApplyMetricColumnWidth(float contentWidth) + { + var metricWidth = Mathf.Clamp( + contentWidth * 0.3f, + MetricColumnMinWidth, + MetricColumnPreferredWidth + ); + foreach (var metricColumn in _metricColumns) + metricColumn.minWidth = metricWidth; } public bool SetPerspective(CombatImpactPerspective perspective, Transform anchor) @@ -291,42 +198,33 @@ public bool SetPerspective(CombatImpactPerspective perspective, Transform anchor if (perspective == CombatImpactPerspective.Received && !_receivedPerspectiveAvailable) return false; - if ( - _activeAuxiliary == null - || _activePrimary == null - || _contentRoot == null - || _causedRoot == null - || _receivedRoot == null - ) + if (_contentRoot == null || _causedRoot == null || _receivedRoot == null) return false; - var auxiliary = _activeAuxiliary; - var primary = _activePrimary; var previousPerspective = _activePerspective; - var visibleAlpha = _preparedAuxiliaryGate?.Alpha ?? 1f; - _preparedAuxiliaryGate?.SetAlpha(0f); - try + // The host only masks and rebuilds; choosing the perspective, deciding the swap failed, + // and rolling it back are Combat Impact decisions and stay here. + using (_session.BeginMaskedLayout()) { _activePerspective = perspective; ApplyPerspectiveVisibility(perspective); - ForceRebuildLayout(auxiliary); - ApplyNativeHeight(auxiliary); - if (Position(auxiliary, primary, anchor)) + _session.RebuildLayout(); + var result = _session.Position(anchor, ResolveContentBudget()); + if (result.Positioned) + { + LogPlacementDegradations(result); return true; + } _activePerspective = previousPerspective; ApplyPerspectiveVisibility(previousPerspective); - ForceRebuildLayout(auxiliary); - ApplyNativeHeight(auxiliary); - Position(auxiliary, primary, anchor); + _session.RebuildLayout(); + // The rollback pass must reach the same reason-code logic as the forward pass: + // LogPlacementDegradationOnce clears its latch whenever a dimension stops being + // degraded, so discarding this result changes how many records later passes emit. + LogPlacementDegradations(_session.Position(anchor, ResolveContentBudget())); return false; } - finally - { - // Perspective changes are an atomic content swap. Do not expose the intermediate - // ContentSizeFitter passes that otherwise make the paired tooltips jump for a frame. - _preparedAuxiliaryGate?.SetAlpha(visibleAlpha); - } } public bool Position( @@ -335,469 +233,120 @@ public bool Position( Transform anchor ) { - if ( - !ReferenceEquals(_activeAuxiliary, auxiliary) - || !ReferenceEquals(_activePrimary, primary) - || _contentRoot == null - || primary.RootCanvasComponent == null - ) + if (!_session.OwnsAuxiliary(auxiliary) || !_session.OwnsPrimary(primary)) return false; - Canvas.ForceUpdateCanvases(); - TempoUIUtility.ForceRebuildRecursive(primary.PositioningRectTransform); - TempoUIUtility.ForceRebuildRecursive(auxiliary.PositioningRectTransform); - LayoutRebuilder.ForceRebuildLayoutImmediate(primary.PositioningRectTransform); - LayoutRebuilder.ForceRebuildLayoutImmediate(auxiliary.PositioningRectTransform); - ApplyNativeHeight(auxiliary); - - if (primary.RootCanvasComponent.transform is not RectTransform canvasRect) - return false; - - var canvasBounds = Inset(canvasRect.rect, CanvasMargin); - var primaryBounds = GetPrimaryVisibleBounds(primary, canvasRect); - var availableRight = Mathf.Max(0f, canvasBounds.xMax - (primaryBounds.xMax + TooltipGap)); - var availableLeft = Mathf.Max(0f, primaryBounds.xMin - TooltipGap - canvasBounds.xMin); - var canvasUnitsPerImpactUnit = CanvasUnitsPerLocalUnit( - auxiliary.PositioningRectTransform, - canvasRect - ); - var preferredFrameWidth = - (TooltipPreferredWidth + _frameHorizontalBleed) * canvasUnitsPerImpactUnit; - if (availableRight + PlacementEpsilon >= preferredFrameWidth) - { - _pairSide = PairSide.ImpactRight; - } - else if (availableLeft + PlacementEpsilon >= preferredFrameWidth) - { - _pairSide = PairSide.ImpactLeft; - } - else if (availableRight >= availableLeft) - { - _pairSide = PairSide.ImpactRight; - } - else - { - _pairSide = PairSide.ImpactLeft; - } - - var available = _pairSide == PairSide.ImpactRight ? availableRight : availableLeft; - var availableContentWidth = available / canvasUnitsPerImpactUnit - _frameHorizontalBleed; - ApplyTooltipWidth( - auxiliary, - Mathf.Min(TooltipPreferredWidth, Mathf.Max(1f, availableContentWidth)) - ); - Canvas.ForceUpdateCanvases(); - TempoUIUtility.ForceRebuildRecursive(auxiliary.PositioningRectTransform); - LayoutRebuilder.ForceRebuildLayoutImmediate(auxiliary.PositioningRectTransform); - ApplyNativeHeight(auxiliary); - FitActiveContentToCanvas(auxiliary, canvasRect); - - var impactRect = auxiliary.backgroundImage.rectTransform; - var impactBounds = GetCanvasLocalBounds(impactRect, canvasRect); - var impactDelta = - _pairSide == PairSide.ImpactRight - ? new Vector2( - primaryBounds.xMax + TooltipGap - impactBounds.xMin, - primaryBounds.yMax - impactBounds.yMax - ) - : new Vector2( - primaryBounds.xMin - TooltipGap - impactBounds.xMax, - primaryBounds.yMax - impactBounds.yMax - ); - TranslateRect(auxiliary.PositioningRectTransform, canvasRect, impactDelta); + var result = _session.Position(anchor, ResolveContentBudget()); + LogPlacementDegradations(result); + return result.Positioned; + } - impactBounds = GetCanvasLocalBounds(impactRect, canvasRect); - var verticalAdjustment = ResolveVerticalAdjustment(impactBounds, canvasBounds); - _topAlignmentAdjusted = Mathf.Abs(verticalAdjustment) > PlacementEpsilon; - if (!Mathf.Approximately(verticalAdjustment, 0f)) - { - var verticalDelta = new Vector2(0f, verticalAdjustment); - TranslateRect(auxiliary.PositioningRectTransform, canvasRect, verticalDelta); - impactDelta += verticalDelta; - impactBounds = GetCanvasLocalBounds(impactRect, canvasRect); - } + /// + /// Turns a placement outcome into this feature's reason codes. + /// + /// + /// Kept here rather than in the host: the once-only latches and their reset-on-recovery + /// behavior decide how many records reach the log, and that is Combat Impact's contract. + /// A pass that never positioned logs nothing and leaves the latches untouched. + /// + private void LogPlacementDegradations(PlacementResult result) + { + if (!result.Positioned) + return; - var pairBounds = Union(primaryBounds, impactBounds); - var screenBounds = canvasRect.rect; - var collides = - _pairSide == PairSide.ImpactRight - ? impactBounds.xMin < primaryBounds.xMax + TooltipGap - PlacementEpsilon - : impactBounds.xMax > primaryBounds.xMin - TooltipGap + PlacementEpsilon; - _pairOverflowed = - pairBounds.width > screenBounds.width - || pairBounds.height > screenBounds.height - || pairBounds.xMin < screenBounds.xMin - PlacementEpsilon - || pairBounds.xMax > screenBounds.xMax + PlacementEpsilon - || pairBounds.yMin < screenBounds.yMin - PlacementEpsilon - || pairBounds.yMax > screenBounds.yMax + PlacementEpsilon - || collides; - _widthBelowReadable = _currentTooltipWidth < TooltipReadableWidth; LogPlacementDegradationOnce( - _pairOverflowed, + result.Overflowed, ref _overflowDegradedLogged, PostCombatImpactReasonCode.PairPlacementOverflowed ); LogPlacementDegradationOnce( - _widthBelowReadable, + result.WidthBelowReadable, ref _widthDegradedLogged, PostCombatImpactReasonCode.PairPlacementTooNarrow ); LogPlacementDegradationOnce( - _topAlignmentAdjusted, + result.TopAlignmentAdjusted, ref _topAlignmentDegradedLogged, PostCombatImpactReasonCode.PairTopAlignmentAdjusted ); - return true; } - public void Reveal() - { - if (_activeAuxiliary == null || _activePrimary == null || _contentRoot == null) - return; - - _hidePending = false; - StartVisibilityFade(targetAlpha: 1f, cleanupOnComplete: false); - } + public void Reveal() => _session.Reveal(); - public void Hide() - { - if ( - _activeAuxiliary == null - && _contentRoot == null - && _preparedPrimaryGate == null - && _preparedAuxiliaryGate == null - ) - return; - - if (_hidePending) - return; - _hidePending = true; - if (_activePrimary != null) - _activePrimary.SetLockedFlag(false); - - var visibleAlpha = Mathf.Max( - _preparedPrimaryGate?.Alpha ?? 0f, - _preparedAuxiliaryGate?.Alpha ?? 0f - ); - if (visibleAlpha <= PlacementEpsilon || _activeAuxiliary == null) - { - CompleteAnimatedHide(_renderGeneration); - return; - } - - StartVisibilityFade(targetAlpha: 0f, cleanupOnComplete: true); - } + public void Hide() => _session.Hide(); public bool OnNativeTooltipChanging(CardTooltipController controller) { - if (!ReferenceEquals(_activePrimary, controller)) + if (!_session.OwnsPrimary(controller)) return false; - Hide(); + _session.Hide(); return true; } public bool OnNativeAuxiliaryTooltipShowing(AuxiliaryTooltipController controller) { - if (!ReferenceEquals(_activeAuxiliary, controller)) + if (!_session.OwnsAuxiliary(controller)) { - RestorePreparedNativeHost(controller); + // Someone else is about to show the native auxiliary tooltip this session had only + // prepared. Handing the snapshot back here is what keeps the native layout's padding + // and anchors from staying permanently rewritten. + _session.ReleasePrepared(controller); return false; } - CleanupCustomContent(); + _session.Release(restoreNativeContent: true); return true; } public bool OnNativeAuxiliaryTooltipHiding(AuxiliaryTooltipController controller) { - if (!ReferenceEquals(_activeAuxiliary, controller)) + if (!_session.OwnsAuxiliary(controller)) return false; // Restore the reusable host geometry for native fade-out, but keep its original content // inactive until the next real native show. Reactivating the header here lets a later // native fade/tween expose a detached title at this host's old paired position. - ConcealNativeAuxiliary(controller); - CleanupCustomContent(restoreNativeContentVisibility: false); + // + // This is also the recovery path for a fade coroutine that never completes: it is hosted + // on the native controller, so deactivating that MonoBehaviour kills it silently and no + // generation check can rescue a callback that never fires. + _session.ForceSettle(); return true; } - private void StartVisibilityFade(float targetAlpha, bool cleanupOnComplete) - { - StopVisibilityFade(); - var auxiliary = _activeAuxiliary; - if (auxiliary == null || !auxiliary.isActiveAndEnabled) - { - SetVisibilityAlpha(targetAlpha); - if (cleanupOnComplete) - CompleteAnimatedHide(_renderGeneration); - return; - } - - var generation = _renderGeneration; - _visibilityFade = auxiliary.StartCoroutine( - FadeVisibility(targetAlpha, cleanupOnComplete, generation) - ); - } - - private System.Collections.IEnumerator FadeVisibility( - float targetAlpha, - bool cleanupOnComplete, - int generation - ) - { - var primaryStart = _preparedPrimaryGate?.Alpha ?? targetAlpha; - var auxiliaryStart = _preparedAuxiliaryGate?.Alpha ?? targetAlpha; - var elapsed = 0f; - while (elapsed < VisibilityFadeDuration) - { - if (generation != _renderGeneration) - { - _visibilityFade = null; - yield break; - } - - elapsed += Time.unscaledDeltaTime; - var progress = Mathf.Clamp01(elapsed / VisibilityFadeDuration); - if (!cleanupOnComplete) - { - _preparedPrimaryGate?.SetAlpha(Mathf.Lerp(primaryStart, targetAlpha, progress)); - } - _preparedAuxiliaryGate?.SetAlpha(Mathf.Lerp(auxiliaryStart, targetAlpha, progress)); - yield return null; - } - - if (cleanupOnComplete) - { - _preparedPrimaryGate?.SetAlpha(0f); - _preparedAuxiliaryGate?.SetAlpha(0f); - } - else - { - SetVisibilityAlpha(targetAlpha); - } - _visibilityFade = null; - if (cleanupOnComplete) - CompleteAnimatedHide(generation); - } - - private void SetVisibilityAlpha(float alpha) - { - _preparedPrimaryGate?.SetAlpha(alpha); - _preparedAuxiliaryGate?.SetAlpha(alpha); - } - - private void StopVisibilityFade() + /// + /// Releases this feature's own resources during . + /// + /// + /// Invoked by the host after it has stopped the fade and bumped the generation, but before it + /// destroys UI or restores native state. Relying on that ordering is what keeps a cancelled + /// preview continuation from re-attaching to a presentation that is being torn down. + /// + private void ReleaseOwnerResources() { - if (_visibilityFade == null) - return; - - if (_activeAuxiliary != null) - _activeAuxiliary.StopCoroutine(_visibilityFade); - _visibilityFade = null; - } - - private void CompleteAnimatedHide(int generation) - { - if (generation != _renderGeneration) - return; - - ConcealNativeAuxiliary(_activeAuxiliary); - _visibilityFade = null; - if (!CleanupCustomContent(restoreNativeContentVisibility: false)) - return; - Data.TooltipParentComponent?.HideAuxiliaryTooltipController(); - } - - private static void ConcealNativeAuxiliary(AuxiliaryTooltipController? auxiliary) - { - if (auxiliary?.tooltipCanvasGroup != null) - auxiliary.tooltipCanvasGroup.alpha = 0f; - } - - private bool CleanupCustomContent(bool restoreNativeContentVisibility = true) - { - var hadActiveContent = - _activeAuxiliary != null - || _contentRoot != null - || _nativeBackgroundRoot != null - || _previewScope != null - || _preparedNativeHost != null - || _preparedPrimaryGate != null - || _preparedAuxiliaryGate != null; - if (!hadActiveContent) - return false; - - StopVisibilityFade(); - _renderGeneration++; DisposeNativePreviews(); - if (_contentRoot != null) - { - _contentRoot.SetActive(false); - Object.Destroy(_contentRoot); - } - if (_nativeBackgroundRoot != null) - { - _nativeBackgroundRoot.SetActive(false); - Object.Destroy(_nativeBackgroundRoot); - } - RestorePreparedNativeHost(restoreNativeContentVisibility); - RestorePreparedAuxiliaryGate(); - RestorePreparedPrimaryGate(); - if (_activePrimary != null) - _activePrimary.SetLockedFlag(false); - - _activeAuxiliary = null; - _activePrimary = null; _contentRoot = null; _causedRoot = null; _receivedRoot = null; - _nativeBackgroundRoot = null; _causedMoreText = null; _receivedMoreText = null; _previewOwner = null; - _nativeAuxParentRect = null; _metricColumns.Clear(); _causedBlocks.Clear(); _receivedBlocks.Clear(); _pendingPreviewCount = 0; - _currentTooltipWidth = TooltipPreferredWidth; - _frameHorizontalBleed = 0f; - _pairSide = PairSide.None; - _pairOverflowed = false; - _widthBelowReadable = false; - _topAlignmentAdjusted = false; _overflowDegradedLogged = false; _widthDegradedLogged = false; _topAlignmentDegradedLogged = false; - _hidePending = false; _receivedPerspectiveAvailable = false; _activePerspective = CombatImpactPerspective.Caused; - return true; - } - - private void PrepareNativePresentation(AuxiliaryTooltipController auxiliary) - { - _nativeAuxParentRect = auxiliary.auxParent.transform as RectTransform; - if (_nativeAuxParentRect == null) - return; - - var frameRect = auxiliary.backgroundImage.rectTransform; - _frameHorizontalBleed = Mathf.Max( - 0f, - frameRect.rect.width - _nativeAuxParentRect.rect.width - ); - _nativeAuxParentRect.anchorMin = new Vector2(0.5f, 0.5f); - _nativeAuxParentRect.anchorMax = new Vector2(0.5f, 0.5f); - _nativeAuxParentRect.pivot = new Vector2(0.5f, 0.5f); - // auxParent and the frame are sibling rects in the native Auxiliary Tooltip prefab. - // Match the frame's center instead of mirroring its serialized offset; mirroring doubles - // any inset and makes the left/right content padding visibly asymmetric. - _nativeAuxParentRect.anchoredPosition = frameRect.anchoredPosition; - - var nativeLayout = auxiliary.auxParent.GetComponent(); - if (nativeLayout != null) - { - var padding = nativeLayout.padding; - nativeLayout.padding = new RectOffset( - padding.left, - padding.right, - padding.top, - Mathf.Max(0, padding.bottom - NativeBottomPaddingReduction) - ); - } - } - - private void ApplyTooltipWidth(AuxiliaryTooltipController auxiliary, float contentWidth) - { - _currentTooltipWidth = Mathf.Max(1f, contentWidth); - if (_contentRoot != null) - { - var contentRect = (RectTransform)_contentRoot.transform; - var contentLayout = _contentRoot.GetComponent(); - contentLayout.preferredWidth = _currentTooltipWidth; - contentLayout.minWidth = _currentTooltipWidth; - contentRect.SetSizeWithCurrentAnchors( - RectTransform.Axis.Horizontal, - _currentTooltipWidth - ); - } - - var metricWidth = Mathf.Clamp( - _currentTooltipWidth * 0.3f, - MetricColumnMinWidth, - MetricColumnPreferredWidth - ); - foreach (var metricColumn in _metricColumns) - metricColumn.minWidth = metricWidth; - - _nativeAuxParentRect?.SetSizeWithCurrentAnchors( - RectTransform.Axis.Horizontal, - _currentTooltipWidth - ); - auxiliary.PositioningRectTransform.SetSizeWithCurrentAnchors( - RectTransform.Axis.Horizontal, - _currentTooltipWidth + _frameHorizontalBleed - ); } - private void ApplyNativeHeight(AuxiliaryTooltipController auxiliary) - { - var contentHeight = _nativeAuxParentRect?.rect.height ?? 0f; - if (contentHeight <= 0f) - return; - - auxiliary.PositioningRectTransform.SetSizeWithCurrentAnchors( - RectTransform.Axis.Vertical, - contentHeight - ); - } - - private void FitActiveContentToCanvas( - AuxiliaryTooltipController auxiliary, - RectTransform canvasRect - ) - { - var blocks = - _activePerspective == CombatImpactPerspective.Caused ? _causedBlocks : _receivedBlocks; - var moreText = - _activePerspective == CombatImpactPerspective.Caused - ? _causedMoreText - : _receivedMoreText; - if (moreText == null) - return; - - foreach (var block in blocks) - block.Restore(); - moreText.transform.parent.gameObject.SetActive(false); - RebuildAfterHeightBudgetChange(auxiliary); - - var canvasBounds = Inset(canvasRect.rect, CanvasMargin); - var hiddenCount = 0; - for (var blockIndex = blocks.Count - 1; blockIndex >= 0; blockIndex--) - { - var block = blocks[blockIndex]; - for (var rowIndex = block.DetailRows.Count - 1; rowIndex >= 0; rowIndex--) - { - if (FitsCanvasHeight(auxiliary, canvasRect, canvasBounds)) - return; - - block.DetailRows[rowIndex].SetActive(false); - hiddenCount++; - ShowMoreRow(moreText, hiddenCount); - RebuildAfterHeightBudgetChange(auxiliary); - } - - if (FitsCanvasHeight(auxiliary, canvasRect, canvasBounds)) - return; - - block.Root.SetActive(false); - block.LeadingDivider?.SetActive(false); - hiddenCount++; - ShowMoreRow(moreText, hiddenCount); - RebuildAfterHeightBudgetChange(auxiliary); - } - } + private IPairedContentBudget ResolveContentBudget() => + _activePerspective == CombatImpactPerspective.Caused + ? new ImpactContentBudget(_causedBlocks, _causedMoreText) + : new ImpactContentBudget(_receivedBlocks, _receivedMoreText); private static void ShowMoreRow(TMP_Text moreText, int hiddenCount) { @@ -805,158 +354,65 @@ private static void ShowMoreRow(TMP_Text moreText, int hiddenCount) moreText.transform.parent.gameObject.SetActive(true); } - private bool FitsCanvasHeight( - AuxiliaryTooltipController auxiliary, - RectTransform canvasRect, - Rect canvasBounds - ) => - GetCanvasLocalBounds(auxiliary.backgroundImage.rectTransform, canvasRect).height - <= canvasBounds.height + PlacementEpsilon; - - private void RebuildAfterHeightBudgetChange(AuxiliaryTooltipController auxiliary) + /// + /// Combat Impact's trim order, driven by the host while it fits the panel to the canvas. + /// + /// + /// Reproduces the original nested loop exactly: walk the blocks from last to first, dropping + /// each block's detail rows from last to first, then the block itself (with its leading + /// divider). The host decides whether to keep trimming; this type only decides + /// what goes next, so no measurement crosses back over the boundary. + /// + private sealed class ImpactContentBudget : IPairedContentBudget { - ForceRebuildLayout(auxiliary); - ApplyNativeHeight(auxiliary); - } + private readonly List _blocks; + private readonly TMP_Text? _moreText; + private int _blockIndex = -1; + private int _rowIndex = -1; + private int _hiddenCount; + private bool _started; - private static void ForceRebuildLayout(AuxiliaryTooltipController auxiliary) - { - // A newly activated nested ContentSizeFitter can expose its previous preferred height on - // the first pass. Two bounded passes resolve the child and parent sizes in this frame. - for (var pass = 0; pass < 2; pass++) + internal ImpactContentBudget(List blocks, TMP_Text? moreText) { - Canvas.ForceUpdateCanvases(); - TempoUIUtility.ForceRebuildRecursive(auxiliary.PositioningRectTransform); - LayoutRebuilder.ForceRebuildLayoutImmediate(auxiliary.PositioningRectTransform); + _blocks = blocks; + _moreText = moreText; } - } - - private bool TryCreateNativeBackground( - AuxiliaryTooltipController auxiliary, - CardTooltipController primary - ) - { - var sourceContainer = primary.gradientImage?.transform.parent as RectTransform; - var sourceMaskImage = sourceContainer?.GetComponent(); - var sourceMask = sourceContainer?.GetComponent(); - var frameRect = auxiliary.backgroundImage?.rectTransform; - if ( - sourceContainer == null - || sourceMaskImage == null - || sourceMask == null - || frameRect == null - || frameRect.parent == null - ) - return false; - var background = new GameObject( - "BppPostCombatImpactNativeBackground", - typeof(RectTransform), - typeof(CanvasRenderer), - typeof(Image), - typeof(Mask), - typeof(LayoutElement) - ); - var backgroundRect = (RectTransform)background.transform; - backgroundRect.SetParent(frameRect.parent, worldPositionStays: false); - CopyRectTransform(frameRect, backgroundRect); - backgroundRect.SetSiblingIndex(frameRect.GetSiblingIndex()); - background.GetComponent().ignoreLayout = true; - CopyImage(sourceMaskImage, background.GetComponent()); - background.GetComponent().enabled = true; - var mask = background.GetComponent(); - mask.enabled = sourceMask.enabled; - mask.showMaskGraphic = sourceMask.showMaskGraphic; - - for (var childIndex = 0; childIndex < sourceContainer.childCount; childIndex++) + public void RestoreAll() { - if ( - sourceContainer.GetChild(childIndex) is not RectTransform sourceRect - || !sourceRect.TryGetComponent(out var sourceImage) - ) - continue; + if (_moreText == null) + return; - var layer = new GameObject( - $"BppPostCombatImpactNativeLayer_{childIndex}", - typeof(RectTransform), - typeof(CanvasRenderer), - typeof(Image) - ); - var layerRect = (RectTransform)layer.transform; - layerRect.SetParent(backgroundRect, worldPositionStays: false); - CopyRectTransform(sourceRect, layerRect); - CopyImage(sourceImage, layer.GetComponent()); - layer.SetActive(sourceRect.gameObject.activeSelf); + foreach (var block in _blocks) + block.Restore(); + _moreText.transform.parent.gameObject.SetActive(false); + _blockIndex = _blocks.Count - 1; + _rowIndex = _blockIndex >= 0 ? _blocks[_blockIndex].DetailRows.Count - 1 : -1; + _hiddenCount = 0; + _started = true; } - _nativeBackgroundRoot = background; - return true; - } - - private static void CopyRectTransform(RectTransform source, RectTransform destination) - { - destination.anchorMin = source.anchorMin; - destination.anchorMax = source.anchorMax; - destination.pivot = source.pivot; - destination.sizeDelta = source.sizeDelta; - destination.anchoredPosition3D = source.anchoredPosition3D; - destination.localRotation = source.localRotation; - destination.localScale = source.localScale; - } - - private static void CopyImage(Image source, Image destination) - { - destination.sprite = source.sprite; - destination.material = source.material; - destination.color = source.color; - destination.type = source.type; - destination.fillCenter = source.fillCenter; - destination.fillMethod = source.fillMethod; - destination.fillAmount = source.fillAmount; - destination.fillClockwise = source.fillClockwise; - destination.fillOrigin = source.fillOrigin; - destination.pixelsPerUnitMultiplier = source.pixelsPerUnitMultiplier; - destination.preserveAspect = source.preserveAspect; - destination.useSpriteMesh = source.useSpriteMesh; - destination.maskable = source.maskable; - destination.raycastTarget = false; - destination.enabled = source.enabled; - } - - private void RestorePreparedNativeHost( - bool restoreContentVisibility = true, - AuxiliaryTooltipController? expectedController = null - ) - { - if (_preparedNativeHost == null) - return; - if ( - expectedController != null - && !ReferenceEquals(_preparedNativeHost.Controller, expectedController) - ) - return; - - _preparedNativeHost.Restore(restoreContentVisibility); - if (restoreContentVisibility) - _preparedNativeHost = null; - } - - private void RestorePreparedNativeHost(AuxiliaryTooltipController expectedController) => - RestorePreparedNativeHost( - restoreContentVisibility: true, - expectedController: expectedController - ); + public bool TryShrinkOneStep() + { + if (_moreText == null || !_started || _blockIndex < 0) + return false; - private void RestorePreparedPrimaryGate() - { - _preparedPrimaryGate?.Restore(); - _preparedPrimaryGate = null; - } + var block = _blocks[_blockIndex]; + if (_rowIndex >= 0) + { + block.DetailRows[_rowIndex].SetActive(false); + _rowIndex--; + ShowMoreRow(_moreText, ++_hiddenCount); + return true; + } - private void RestorePreparedAuxiliaryGate() - { - _preparedAuxiliaryGate?.Restore(); - _preparedAuxiliaryGate = null; + block.Root.SetActive(false); + block.LeadingDivider?.SetActive(false); + _blockIndex--; + _rowIndex = _blockIndex >= 0 ? _blocks[_blockIndex].DetailRows.Count - 1 : -1; + ShowMoreRow(_moreText, ++_hiddenCount); + return true; + } } private void DisposeNativePreviews() @@ -1602,7 +1058,7 @@ CancellationToken cancellationToken var outcome = await scope.AcquireAsync(subject, cancellationToken); var session = outcome.Session; if ( - generation != _renderGeneration + generation != _session.Generation || slot == null || cancellationToken.IsCancellationRequested ) @@ -1678,7 +1134,7 @@ out var visibleWidth } finally { - if (generation == _renderGeneration) + if (generation == _session.Generation) _pendingPreviewCount = Mathf.Max(0, _pendingPreviewCount - 1); } } @@ -1688,19 +1144,19 @@ private async Task LoadHero(Image image, RectTransform slot, EHero hero, int gen try { var outcome = await HeroPortraitSpriteProvider.LoadDefaultPortraitAsync(hero); - if (generation == _renderGeneration && image != null && outcome?.Sprite != null) + if (generation == _session.Generation && image != null && outcome?.Sprite != null) { image.sprite = outcome.Sprite; image.enabled = true; } - else if (generation == _renderGeneration) + else if (generation == _session.Generation) { HidePreviewSlot(slot); } } catch (Exception ex) { - if (generation == _renderGeneration) + if (generation == _session.Generation) HidePreviewSlot(slot); BppLog.WarnEvent( PostCombatImpactLogEvents.InteractionDegraded, @@ -1712,7 +1168,7 @@ private async Task LoadHero(Image image, RectTransform slot, EHero hero, int gen } finally { - if (generation == _renderGeneration) + if (generation == _session.Generation) _pendingPreviewCount = Mathf.Max(0, _pendingPreviewCount - 1); } } @@ -1867,95 +1323,6 @@ private static LayoutElement AddLayout( return layout; } - private Rect GetCanvasLocalBounds(RectTransform rect, RectTransform canvasRect) - { - rect.GetWorldCorners(_worldCorners); - var minX = float.PositiveInfinity; - var minY = float.PositiveInfinity; - var maxX = float.NegativeInfinity; - var maxY = float.NegativeInfinity; - foreach (var corner in _worldCorners) - { - var local = canvasRect.InverseTransformPoint(corner); - minX = Mathf.Min(minX, local.x); - minY = Mathf.Min(minY, local.y); - maxX = Mathf.Max(maxX, local.x); - maxY = Mathf.Max(maxY, local.y); - } - - return Rect.MinMaxRect(minX, minY, maxX, maxY); - } - - private Rect GetPrimaryVisibleBounds(CardTooltipController primary, RectTransform canvasRect) - { - var bounds = GetPrimaryFrameBounds(primary, canvasRect); - if ( - primary.cooldownClock != null - && primary.cooldownClock.gameObject.activeInHierarchy - && primary.cooldownClock.transform is RectTransform cooldownRect - ) - { - bounds = Union(bounds, GetCanvasLocalBounds(cooldownRect, canvasRect)); - } - return bounds; - } - - private Rect GetPrimaryFrameBounds(CardTooltipController primary, RectTransform canvasRect) - { - var bounds = GetCanvasLocalBounds(primary.PositioningRectTransform, canvasRect); - if (primary.CanvasContentRectTransform != null) - { - bounds = Union( - bounds, - GetCanvasLocalBounds(primary.CanvasContentRectTransform, canvasRect) - ); - } - return bounds; - } - - private static RectTransform? FindDescendant(Transform? root, string childName) - { - if (root == null) - return null; - for (var index = 0; index < root.childCount; index++) - { - var child = root.GetChild(index); - if ( - string.Equals(child.name, childName, StringComparison.Ordinal) - && child is RectTransform matching - ) - return matching; - var nested = FindDescendant(child, childName); - if (nested != null) - return nested; - } - return null; - } - - private static float CanvasUnitsPerLocalUnit(RectTransform rect, RectTransform canvasRect) - { - var origin = canvasRect.InverseTransformPoint(rect.TransformPoint(Vector3.zero)); - var horizontalUnit = canvasRect.InverseTransformPoint(rect.TransformPoint(Vector3.right)); - return Mathf.Max( - 0.0001f, - Vector2.Distance( - new Vector2(origin.x, origin.y), - new Vector2(horizontalUnit.x, horizontalUnit.y) - ) - ); - } - - private static float ResolveVerticalAdjustment(Rect rect, Rect bounds) - { - if (rect.height > bounds.height + PlacementEpsilon) - return bounds.yMax - rect.yMax; - - var adjustment = rect.yMax > bounds.yMax ? bounds.yMax - rect.yMax : 0f; - if (rect.yMin + adjustment < bounds.yMin) - adjustment += bounds.yMin - (rect.yMin + adjustment); - return adjustment; - } - private static void LogPlacementDegradationOnce( bool degraded, ref bool wasLogged, @@ -1977,40 +1344,6 @@ PostCombatImpactReasonCode reasonCode ); } - private static void TranslateRect( - RectTransform rect, - RectTransform canvasRect, - Vector2 canvasLocalDelta - ) - { - if (canvasLocalDelta == Vector2.zero) - return; - - rect.position += canvasRect.TransformVector( - new Vector3(canvasLocalDelta.x, canvasLocalDelta.y) - ); - } - - private static Rect Union(Rect first, Rect second) => - Rect.MinMaxRect( - Mathf.Min(first.xMin, second.xMin), - Mathf.Min(first.yMin, second.yMin), - Mathf.Max(first.xMax, second.xMax), - Mathf.Max(first.yMax, second.yMax) - ); - - private static Rect Inset(Rect rect, float inset) - { - var horizontalInset = Mathf.Min(inset, rect.width * 0.5f); - var verticalInset = Mathf.Min(inset, rect.height * 0.5f); - return Rect.MinMaxRect( - rect.xMin + horizontalInset, - rect.yMin + verticalInset, - rect.xMax - horizontalInset, - rect.yMax - verticalInset - ); - } - private static (string Label, string IconKey) ResolveEffect( CombatImpactKind kind, string nativeAttributeKey, @@ -2123,13 +1456,6 @@ internal void Restore() } } - private enum PairSide - { - None, - ImpactRight, - ImpactLeft, - } - private sealed class NativePreviewOwner : INativeCardPreviewOwner { private readonly Dictionary _parents = new(); @@ -2194,169 +1520,9 @@ internal void Reveal(GameObject root) } } - private sealed class CanvasGroupGate - { - private readonly CanvasGroup _group; - private readonly bool _ownedGroup; - private readonly float _originalAlpha; - private readonly bool _originalInteractable; - private readonly bool _originalBlocksRaycasts; - private readonly bool _originalIgnoreParentGroups; - private readonly bool _forceNonInteractive; - private bool _restored; - - private CanvasGroupGate(object controller, GameObject target, bool forceNonInteractive) - { - Controller = controller; - _forceNonInteractive = forceNonInteractive; - _group = target.GetComponent(); - if (_group == null) - { - _group = target.AddComponent(); - _ownedGroup = true; - } - _originalAlpha = _group.alpha; - _originalInteractable = _group.interactable; - _originalBlocksRaycasts = _group.blocksRaycasts; - _originalIgnoreParentGroups = _group.ignoreParentGroups; - SetAlpha(0f); - } - - internal object Controller { get; } - - internal float Alpha => _group == null ? 0f : _group.alpha; - - internal static CanvasGroupGate Create( - object controller, - GameObject target, - bool forceNonInteractive = false - ) => new(controller, target, forceNonInteractive); - - internal void SetAlpha(float alpha) - { - if (_group == null || _restored) - return; - - _group.alpha = Mathf.Clamp01(alpha); - var interactive = _group.alpha >= 1f - PlacementEpsilon; - _group.interactable = !_forceNonInteractive && interactive && _originalInteractable; - _group.blocksRaycasts = !_forceNonInteractive && interactive && _originalBlocksRaycasts; - } - - internal void Restore() - { - if (_restored || _group == null) - return; - - _restored = true; - _group.alpha = _originalAlpha; - _group.interactable = _originalInteractable; - _group.blocksRaycasts = _originalBlocksRaycasts; - _group.ignoreParentGroups = _originalIgnoreParentGroups; - if (_ownedGroup) - Object.Destroy(_group); - } - } - - private sealed class NativeAuxiliaryHostState - { - private readonly RectTransform? _auxParentRect; - private readonly Vector2 _auxParentSizeDelta; - private readonly Vector2 _auxParentAnchorMin; - private readonly Vector2 _auxParentAnchorMax; - private readonly Vector2 _auxParentPivot; - private readonly Vector3 _auxParentAnchoredPosition; - private readonly Vector2 _positioningSizeDelta; - private readonly VerticalLayoutGroup? _layout; - private readonly RectOffset? _padding; - private readonly float _spacing; - private readonly bool _headerWasActive; - private readonly bool _bodyWasActive; - private readonly bool _dividerWasActive; - private readonly Sprite? _backgroundSprite; - private readonly bool _backgroundImageWasEnabled; - - private NativeAuxiliaryHostState(AuxiliaryTooltipController controller) - { - Controller = controller; - _auxParentRect = controller.auxParent?.transform as RectTransform; - _auxParentSizeDelta = _auxParentRect?.sizeDelta ?? Vector2.zero; - _auxParentAnchorMin = _auxParentRect?.anchorMin ?? Vector2.zero; - _auxParentAnchorMax = _auxParentRect?.anchorMax ?? Vector2.zero; - _auxParentPivot = _auxParentRect?.pivot ?? Vector2.zero; - _auxParentAnchoredPosition = _auxParentRect?.anchoredPosition3D ?? Vector3.zero; - _positioningSizeDelta = controller.PositioningRectTransform.sizeDelta; - _layout = controller.auxParent?.GetComponent(); - _padding = - _layout == null - ? null - : new RectOffset( - _layout.padding.left, - _layout.padding.right, - _layout.padding.top, - _layout.padding.bottom - ); - _spacing = _layout?.spacing ?? 0f; - _headerWasActive = - controller.headerText != null && controller.headerText.gameObject.activeSelf; - _bodyWasActive = - controller.bodyText != null && controller.bodyText.gameObject.activeSelf; - _dividerWasActive = - controller.dividerParent != null && controller.dividerParent.activeSelf; - _backgroundSprite = controller.backgroundImage?.sprite; - _backgroundImageWasEnabled = - controller.backgroundImage != null && controller.backgroundImage.enabled; - } - - internal AuxiliaryTooltipController Controller { get; } - - internal static NativeAuxiliaryHostState Capture(AuxiliaryTooltipController controller) => - new(controller); - - internal void Restore(bool restoreContentVisibility) - { - if (Controller == null) - return; - - Controller.PositioningRectTransform.sizeDelta = _positioningSizeDelta; - if (_auxParentRect != null) - { - _auxParentRect.anchorMin = _auxParentAnchorMin; - _auxParentRect.anchorMax = _auxParentAnchorMax; - _auxParentRect.pivot = _auxParentPivot; - _auxParentRect.anchoredPosition3D = _auxParentAnchoredPosition; - _auxParentRect.sizeDelta = _auxParentSizeDelta; - } - if (_layout != null && _padding != null) - { - _layout.padding = new RectOffset( - _padding.left, - _padding.right, - _padding.top, - _padding.bottom - ); - _layout.spacing = _spacing; - } - if (restoreContentVisibility) - { - if (Controller.headerText != null) - Controller.headerText.gameObject.SetActive(_headerWasActive); - if (Controller.bodyText != null) - Controller.bodyText.gameObject.SetActive(_bodyWasActive); - if (Controller.dividerParent != null) - Controller.dividerParent.SetActive(_dividerWasActive); - } - if (Controller.backgroundImage != null) - { - Controller.backgroundImage.sprite = _backgroundSprite; - Controller.backgroundImage.enabled = _backgroundImageWasEnabled; - } - } - } - public void Dispose() { Hide(); - CleanupCustomContent(); + _session.Release(restoreNativeContent: true); } } diff --git a/src/BazaarPlusPlus/Properties/AssemblyAttributes.cs b/src/BazaarPlusPlus/Properties/AssemblyAttributes.cs index 8e0d17e8..06d3d973 100644 --- a/src/BazaarPlusPlus/Properties/AssemblyAttributes.cs +++ b/src/BazaarPlusPlus/Properties/AssemblyAttributes.cs @@ -18,3 +18,4 @@ [assembly: InternalsVisibleTo("GhostBattleSync.Tests")] [assembly: InternalsVisibleTo("HistoryPanelRepository.Tests")] [assembly: InternalsVisibleTo("SupporterCatalogModule.Tests")] +[assembly: InternalsVisibleTo("NativePairedTooltipHost.Tests")] diff --git a/tests/Architecture.Tests/NativePairedTooltipArchitectureTests.cs b/tests/Architecture.Tests/NativePairedTooltipArchitectureTests.cs new file mode 100644 index 00000000..0724491c --- /dev/null +++ b/tests/Architecture.Tests/NativePairedTooltipArchitectureTests.cs @@ -0,0 +1,160 @@ +#nullable enable +using Xunit; + +namespace Architecture.Tests; + +/// +/// Guards the seam between the shared paired-tooltip host and the features that consume it. +/// +public sealed class NativePairedTooltipArchitectureTests +{ + /// + /// Ratchet. Before the extraction these types lived inside + /// Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs, so this test failed; + /// it passes only because the native paired-tooltip plumbing now has exactly one home. + /// + [Fact] + public void Native_paired_tooltip_plumbing_lives_only_in_the_shared_host() + { + var sourceRoot = MainSourceRoot(RepoRoot()); + var moduleRoot = Path.Combine(sourceRoot, "GameInterop", "Tooltips"); + var forbidden = new[] + { + "CanvasGroupGate", + "NativeAuxiliaryHostState", + "PairSide", + "TryCreateNativeBackground", + "PrepareNativePresentation", + }; + var violations = new List(); + + foreach ( + var file in Directory.EnumerateFiles(sourceRoot, "*.cs", SearchOption.AllDirectories) + ) + { + if (file.StartsWith(moduleRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + continue; + + var source = File.ReadAllText(file); + foreach (var token in forbidden.Where(source.Contains)) + violations.Add($"{Path.GetRelativePath(sourceRoot, file)}: {token}"); + } + + Assert.True( + violations.Count == 0, + "Native paired-tooltip host plumbing must stay in GameInterop/Tooltips instead of " + + "being re-implemented inside a feature:\n" + + string.Join("\n", violations) + ); + } + + /// + /// Guardrail, not a ratchet: this already held before the extraction, so passing proves nothing + /// about the migration. It exists to stop feature vocabulary from leaking into the shared host + /// later. + /// + [Fact] + public void Shared_tooltip_adapters_do_not_reference_features_or_patches() + { + var sourceRoot = MainSourceRoot(RepoRoot()); + var moduleRoot = Path.Combine(sourceRoot, "GameInterop", "Tooltips"); + var violations = new List(); + + foreach ( + var file in Directory.EnumerateFiles(moduleRoot, "*.cs", SearchOption.AllDirectories) + ) + { + var relative = Path.GetRelativePath(sourceRoot, file); + foreach (var line in File.ReadAllLines(file)) + { + if ( + line.StartsWith("using BazaarPlusPlus.Game.", StringComparison.Ordinal) + || line.StartsWith("using BazaarPlusPlus.Patches", StringComparison.Ordinal) + ) + violations.Add($"{relative}: {line.Trim()}"); + } + + var source = File.ReadAllText(file); + foreach ( + var token in new[] { "PostCombatImpact", "CombatImpact" }.Where(source.Contains) + ) + violations.Add($"{relative}: mentions {token}"); + } + + Assert.True( + violations.Count == 0, + "GameInterop/Tooltips must stay feature-agnostic — no Game/Patches imports and no " + + "feature vocabulary:\n" + + string.Join("\n", violations) + ); + } + + /// + /// The Combat Impact view must consume the shared session rather than owning the native pair + /// itself. + /// + [Fact] + public void Combat_impact_tooltip_view_consumes_the_shared_session() + { + var sourceRoot = MainSourceRoot(RepoRoot()); + var view = File.ReadAllText( + Path.Combine( + sourceRoot, + "Patches", + "PostCombatImpact", + "NativePostCombatImpactTooltipView.cs" + ) + ); + + Assert.Contains( + "using BazaarPlusPlus.GameInterop.Tooltips;", + view, + StringComparison.Ordinal + ); + Assert.Contains("NativePairedTooltipSession", view, StringComparison.Ordinal); + Assert.Contains("IPairedContentBudget", view, StringComparison.Ordinal); + Assert.DoesNotContain("_renderGeneration", view, StringComparison.Ordinal); + Assert.DoesNotContain("StartVisibilityFade", view, StringComparison.Ordinal); + Assert.DoesNotContain("CleanupCustomContent", view, StringComparison.Ordinal); + } + + /// + /// The pre-existing native card-tooltip content refresher is a different concern and must not + /// be folded into the paired host. + /// + [Fact] + public void Card_tooltip_content_refresher_stays_a_separate_adapter() + { + var moduleRoot = Path.Combine(MainSourceRoot(RepoRoot()), "GameInterop", "Tooltips"); + var refresher = Path.Combine(moduleRoot, "NativeCardTooltipContentRefresher.cs"); + + Assert.True( + File.Exists(refresher), + "NativeCardTooltipContentRefresher.cs must remain its own adapter." + ); + Assert.DoesNotContain( + "NativePairedTooltip", + File.ReadAllText(refresher), + StringComparison.Ordinal + ); + } + + private static string RepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current != null) + { + if ( + File.Exists(Path.Combine(current.FullName, "CLAUDE.md")) + && Directory.Exists(Path.Combine(current.FullName, "src")) + ) + return current.FullName; + current = current.Parent; + } + + throw new InvalidOperationException("Could not locate repository root."); + } + + private static string MainSourceRoot(string repoRoot) => + Path.Combine(repoRoot, "src", "BazaarPlusPlus"); +} diff --git a/tests/NativePairedTooltipHost.Tests/NativePairedTooltipHost.Tests.csproj b/tests/NativePairedTooltipHost.Tests/NativePairedTooltipHost.Tests.csproj new file mode 100644 index 00000000..2da07f80 --- /dev/null +++ b/tests/NativePairedTooltipHost.Tests/NativePairedTooltipHost.Tests.csproj @@ -0,0 +1,36 @@ + + + net10.0 + enable + enable + false + NativePairedTooltipHost.Tests + + + + $(MSBuildThisFileDirectory)../../ + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + $(ManagedPath)/UnityEngine.CoreModule.dll + + + PreserveNewest + + + diff --git a/tests/NativePairedTooltipHost.Tests/NativePairedTooltipPlacementMathTests.cs b/tests/NativePairedTooltipHost.Tests/NativePairedTooltipPlacementMathTests.cs new file mode 100644 index 00000000..fd38c76d --- /dev/null +++ b/tests/NativePairedTooltipHost.Tests/NativePairedTooltipPlacementMathTests.cs @@ -0,0 +1,409 @@ +using BazaarPlusPlus.GameInterop.Tooltips; +using UnityEngine; +using Xunit; + +namespace BazaarPlusPlus.Tests.NativePairedTooltipHost; + +/// +/// Locks the placement rules that moved out of the Combat Impact tooltip view. +/// +/// +/// These assert the pre-extraction behavior, including the deliberate right-side tie-break and the +/// shared 0.5 epsilon. A change here is a change to what the user sees. +/// +public class NativePairedTooltipPlacementMathTests +{ + private const float Epsilon = NativePairedTooltipMetrics.Epsilon; + + // ── Side selection ───────────────────────────────────────────────────────────────────── + + [Fact] + public void Prefers_the_right_side_when_the_preferred_width_fits_there() + { + var side = NativePairedTooltipPlacementMath.ChooseSide( + availableRight: 700f, + availableLeft: 700f, + preferredFrameWidth: 660f + ); + + Assert.Equal(PairSide.Right, side); + } + + [Fact] + public void Falls_back_to_the_left_side_when_only_the_left_fits() + { + var side = NativePairedTooltipPlacementMath.ChooseSide( + availableRight: 100f, + availableLeft: 700f, + preferredFrameWidth: 660f + ); + + Assert.Equal(PairSide.Left, side); + } + + [Fact] + public void Picks_the_roomier_side_when_neither_fits() + { + var side = NativePairedTooltipPlacementMath.ChooseSide( + availableRight: 200f, + availableLeft: 300f, + preferredFrameWidth: 660f + ); + + Assert.Equal(PairSide.Left, side); + } + + [Fact] + public void Keeps_the_right_side_when_neither_fits_and_the_room_is_equal() + { + var side = NativePairedTooltipPlacementMath.ChooseSide( + availableRight: 300f, + availableLeft: 300f, + preferredFrameWidth: 660f + ); + + Assert.Equal(PairSide.Right, side); + } + + [Fact] + public void Accepts_a_side_that_is_short_by_less_than_epsilon() + { + // The epsilon is applied to the fit test, not just to the comparisons downstream. + var side = NativePairedTooltipPlacementMath.ChooseSide( + availableRight: 660f - (Epsilon * 0.5f), + availableLeft: 5000f, + preferredFrameWidth: 660f + ); + + Assert.Equal(PairSide.Right, side); + } + + // ── Available room ───────────────────────────────────────────────────────────────────── + + [Fact] + public void Available_room_never_goes_negative() + { + var canvasBounds = Rect.MinMaxRect(-100f, -100f, 100f, 100f); + var primaryBounds = Rect.MinMaxRect(-200f, -50f, 200f, 50f); + + Assert.Equal( + 0f, + NativePairedTooltipPlacementMath.AvailableRight(canvasBounds, primaryBounds, 18f) + ); + Assert.Equal( + 0f, + NativePairedTooltipPlacementMath.AvailableLeft(canvasBounds, primaryBounds, 18f) + ); + } + + [Fact] + public void Available_room_subtracts_the_gap_on_both_sides() + { + var canvasBounds = Rect.MinMaxRect(-500f, -300f, 500f, 300f); + var primaryBounds = Rect.MinMaxRect(-100f, -150f, 100f, 150f); + + Assert.Equal( + 382f, + NativePairedTooltipPlacementMath.AvailableRight(canvasBounds, primaryBounds, 18f), + 3 + ); + Assert.Equal( + 382f, + NativePairedTooltipPlacementMath.AvailableLeft(canvasBounds, primaryBounds, 18f), + 3 + ); + } + + // ── Width clamping ───────────────────────────────────────────────────────────────────── + + [Fact] + public void Content_width_is_capped_at_the_preferred_width() + { + var width = NativePairedTooltipPlacementMath.ResolveContentWidth( + PairSide.Right, + availableRight: 5000f, + availableLeft: 0f, + canvasUnitsPerLocalUnit: 1f, + frameHorizontalBleed: 0f, + preferredContentWidth: 660f + ); + + Assert.Equal(660f, width, 3); + } + + [Fact] + public void Content_width_never_drops_below_one() + { + var width = NativePairedTooltipPlacementMath.ResolveContentWidth( + PairSide.Left, + availableRight: 0f, + availableLeft: 0f, + canvasUnitsPerLocalUnit: 1f, + frameHorizontalBleed: 40f, + preferredContentWidth: 660f + ); + + Assert.Equal(1f, width, 3); + } + + [Fact] + public void Content_width_discounts_the_frame_bleed_and_the_canvas_scale() + { + var width = NativePairedTooltipPlacementMath.ResolveContentWidth( + PairSide.Right, + availableRight: 400f, + availableLeft: 0f, + canvasUnitsPerLocalUnit: 2f, + frameHorizontalBleed: 40f, + preferredContentWidth: 660f + ); + + // 400 / 2 - 40 + Assert.Equal(160f, width, 3); + } + + [Fact] + public void Content_width_reads_the_side_it_is_given() + { + var width = NativePairedTooltipPlacementMath.ResolveContentWidth( + PairSide.Left, + availableRight: 5000f, + availableLeft: 200f, + canvasUnitsPerLocalUnit: 1f, + frameHorizontalBleed: 0f, + preferredContentWidth: 660f + ); + + Assert.Equal(200f, width, 3); + } + + // ── Pair offset ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void Right_placement_puts_the_panel_one_gap_past_the_primary_and_tops_align() + { + var primary = Rect.MinMaxRect(0f, 0f, 100f, 200f); + var panel = Rect.MinMaxRect(0f, 0f, 300f, 150f); + + var offset = NativePairedTooltipPlacementMath.ResolvePairOffset( + PairSide.Right, + primary, + panel, + gap: 18f + ); + + Assert.Equal(118f, offset.x, 3); + Assert.Equal(50f, offset.y, 3); + } + + [Fact] + public void Left_placement_puts_the_panel_one_gap_before_the_primary() + { + var primary = Rect.MinMaxRect(0f, 0f, 100f, 200f); + var panel = Rect.MinMaxRect(0f, 0f, 300f, 150f); + + var offset = NativePairedTooltipPlacementMath.ResolvePairOffset( + PairSide.Left, + primary, + panel, + gap: 18f + ); + + Assert.Equal(-318f, offset.x, 3); + Assert.Equal(50f, offset.y, 3); + } + + // ── Vertical fit ─────────────────────────────────────────────────────────────────────── + + [Fact] + public void A_rect_already_inside_the_bounds_is_not_moved() + { + var rect = Rect.MinMaxRect(0f, 10f, 100f, 90f); + var bounds = Rect.MinMaxRect(0f, 0f, 100f, 100f); + + Assert.Equal( + 0f, + NativePairedTooltipPlacementMath.ResolveVerticalAdjustment(rect, bounds), + 3 + ); + } + + [Fact] + public void A_rect_past_the_top_is_pulled_down() + { + var rect = Rect.MinMaxRect(0f, 40f, 100f, 130f); + var bounds = Rect.MinMaxRect(0f, 0f, 100f, 100f); + + Assert.Equal( + -30f, + NativePairedTooltipPlacementMath.ResolveVerticalAdjustment(rect, bounds), + 3 + ); + } + + [Fact] + public void A_rect_past_the_bottom_is_pushed_up() + { + var rect = Rect.MinMaxRect(0f, -30f, 100f, 60f); + var bounds = Rect.MinMaxRect(0f, 0f, 100f, 100f); + + Assert.Equal( + 30f, + NativePairedTooltipPlacementMath.ResolveVerticalAdjustment(rect, bounds), + 3 + ); + } + + [Fact] + public void A_rect_taller_than_the_bounds_is_top_aligned_rather_than_centered() + { + var rect = Rect.MinMaxRect(0f, -100f, 100f, 150f); + var bounds = Rect.MinMaxRect(0f, 0f, 100f, 100f); + + // Top-align: yMax 150 -> 100. Bottom is left overflowing on purpose. + Assert.Equal( + -50f, + NativePairedTooltipPlacementMath.ResolveVerticalAdjustment(rect, bounds), + 3 + ); + } + + // ── Overflow / collision diagnostics ─────────────────────────────────────────────────── + + [Fact] + public void A_pair_that_fits_the_screen_reports_no_overflow() + { + var primary = Rect.MinMaxRect(-200f, -100f, -50f, 100f); + var panel = Rect.MinMaxRect(-32f, -100f, 200f, 100f); + var screen = Rect.MinMaxRect(-300f, -200f, 300f, 200f); + + Assert.False( + NativePairedTooltipPlacementMath.Overflows( + PairSide.Right, + primary, + panel, + screen, + gap: 18f + ) + ); + } + + [Fact] + public void A_panel_that_crept_into_the_gap_reports_a_collision() + { + var primary = Rect.MinMaxRect(-200f, -100f, -50f, 100f); + var panel = Rect.MinMaxRect(-45f, -100f, 200f, 100f); + var screen = Rect.MinMaxRect(-300f, -200f, 300f, 200f); + + Assert.True( + NativePairedTooltipPlacementMath.Collides(PairSide.Right, primary, panel, gap: 18f) + ); + Assert.True( + NativePairedTooltipPlacementMath.Overflows( + PairSide.Right, + primary, + panel, + screen, + gap: 18f + ) + ); + } + + [Fact] + public void A_pair_wider_than_the_screen_reports_overflow() + { + var primary = Rect.MinMaxRect(-400f, -100f, -50f, 100f); + var panel = Rect.MinMaxRect(-32f, -100f, 400f, 100f); + var screen = Rect.MinMaxRect(-300f, -200f, 300f, 200f); + + Assert.True( + NativePairedTooltipPlacementMath.Overflows( + PairSide.Right, + primary, + panel, + screen, + gap: 18f + ) + ); + } + + [Fact] + public void A_pair_taller_than_the_screen_reports_overflow() + { + var primary = Rect.MinMaxRect(-200f, -300f, -50f, 300f); + var panel = Rect.MinMaxRect(-32f, -100f, 200f, 100f); + var screen = Rect.MinMaxRect(-300f, -200f, 300f, 200f); + + Assert.True( + NativePairedTooltipPlacementMath.Overflows( + PairSide.Right, + primary, + panel, + screen, + gap: 18f + ) + ); + } + + [Fact] + public void Left_side_collision_is_measured_against_the_primarys_left_edge() + { + var primary = Rect.MinMaxRect(50f, -100f, 200f, 100f); + var panel = Rect.MinMaxRect(-200f, -100f, 45f, 100f); + + Assert.True( + NativePairedTooltipPlacementMath.Collides(PairSide.Left, primary, panel, gap: 18f) + ); + + var clearedPanel = Rect.MinMaxRect(-200f, -100f, 30f, 100f); + Assert.False( + NativePairedTooltipPlacementMath.Collides( + PairSide.Left, + primary, + clearedPanel, + gap: 18f + ) + ); + } + + // ── Rect helpers ─────────────────────────────────────────────────────────────────────── + + [Fact] + public void Union_covers_both_rects() + { + var union = NativePairedTooltipPlacementMath.Union( + Rect.MinMaxRect(-10f, -20f, 5f, 5f), + Rect.MinMaxRect(0f, 0f, 30f, 40f) + ); + + Assert.Equal(-10f, union.xMin, 3); + Assert.Equal(-20f, union.yMin, 3); + Assert.Equal(30f, union.xMax, 3); + Assert.Equal(40f, union.yMax, 3); + } + + [Fact] + public void Inset_shrinks_on_every_side() + { + var inset = NativePairedTooltipPlacementMath.Inset( + Rect.MinMaxRect(0f, 0f, 100f, 100f), + 16f + ); + + Assert.Equal(16f, inset.xMin, 3); + Assert.Equal(16f, inset.yMin, 3); + Assert.Equal(84f, inset.xMax, 3); + Assert.Equal(84f, inset.yMax, 3); + } + + [Fact] + public void Inset_never_collapses_a_rect_past_its_own_centre() + { + var inset = NativePairedTooltipPlacementMath.Inset(Rect.MinMaxRect(0f, 0f, 10f, 4f), 16f); + + Assert.Equal(5f, inset.xMin, 3); + Assert.Equal(5f, inset.xMax, 3); + Assert.Equal(2f, inset.yMin, 3); + Assert.Equal(2f, inset.yMax, 3); + } +}