Retail's ChatInterface::SetOpacity (0x004F3120) fades the WHOLE composited
window surface with one alpha; UiRenderContext.ApplyAlpha already gated
DrawSprite/DrawRect/DrawFill (since 1da697ec, pre-CH6) but DrawStringDat and
DrawString still passed applyAlpha:false, so text stayed sharp over a
translucent window. Both now route through the same chokepoint.
RetailWindowOpacityController (new) subscribes to a new
RetailWindowManager.WindowRegistered event and drives every registered
window's live Opacity from keyboard-focus state, applied to EVERY window
(chat, floaties, vitals, toolbar, ...) rather than retail's ChatInterface-only
scope — register row AP-190, retiring the stale AP-40 "fixed 0.75, no focus
transition" row in the same commit.
Verified retail's shipped opacity defaults from the decomp (constructor
literals, no cdb needed): the base ChatInterface ctor sets
DefaultOpacity=0.5/ActiveOpacity=1.0, kept unmodified by the four floating
windows; gmMainChatUI's own ctor overrides the main window to 1.0/1.0
(always fully opaque). acdream ships one shared global default (0.5/1.0)
rather than replicating the per-class override — also AP-190. The linking
invariant (raising default above active drags active UP; lowering active
below default drags default DOWN — never a clamp) is ported verbatim as
ChatOpacityLink in AcDream.UI.Abstractions, shared by the live controller
and the new Settings -> Chat tab's two linked opacity sliders.
Persistence: ChatSettings.DefaultOpacity/ActiveOpacity round-trip through
SettingsStore; Save pushes both through IRuntimeSettingsTargets.SetChatOpacity
into the live controller, no restart required.
Rider (CH6a/b re-review): strengthened the grip-media regression guard past
a bare SpriteFile != 0 check — ChatLayoutConformanceTests now drives each
live grip through a real UiRenderContext/TextRenderer (backed by the
in-memory RecordingGpuDevice test double) and asserts the draw call chain
actually queued sprite geometry, via a new TextRenderer.DebugSpriteSegments
test-only accessor.
Full Release suite 12,459 passed / 4 skipped / 0 failed (baseline
12,420/4/0). No subagents, no client launches (session hard constraints);
pending the next connected user gate for visual confirmation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
7.9 KiB
C#
188 lines
7.9 KiB
C#
using AcDream.App.UI;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
/// <summary>
|
|
/// Campaign CH slice CH6c: <see cref="RetailWindowOpacityController"/> — the live
|
|
/// per-window opacity mechanism ported from <c>ChatInterface::SetOpacity/
|
|
/// SetDefaultOpacity/SetActiveOpacity</c> (<c>0x004F3120</c>/<c>0x004F3BC0</c>/
|
|
/// <c>0x004F3C40</c>), extended to every <see cref="RetailWindowManager"/>-registered
|
|
/// window rather than retail's ChatInterface-only scope (register row AP-190).
|
|
/// </summary>
|
|
public sealed class RetailWindowOpacityControllerTests
|
|
{
|
|
private static UiRoot NewRoot() => new() { Width = 800f, Height = 600f };
|
|
|
|
/// <summary>Registers a bare window ("outer frame" + one focusable child) and
|
|
/// returns both — mirrors how a real window has a content descendant the
|
|
/// chat entry / any input field could take focus on.</summary>
|
|
private static (RetailWindowHandle handle, UiElement child) RegisterWindow(UiRoot root, string name)
|
|
{
|
|
var frame = new UiPanel { Width = 100f, Height = 100f };
|
|
var child = new UiPanel { Width = 10f, Height = 10f, AcceptsFocus = true };
|
|
frame.AddChild(child);
|
|
root.AddChild(frame);
|
|
RetailWindowHandle handle = root.WindowManager.Register(name, frame);
|
|
return (handle, child);
|
|
}
|
|
|
|
[Fact]
|
|
public void Construction_AttachesToAlreadyRegisteredWindows_AtDefaultOpacity()
|
|
{
|
|
UiRoot root = NewRoot();
|
|
(RetailWindowHandle handle, _) = RegisterWindow(root, "Vitals");
|
|
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
|
|
|
|
// No descendant has focus yet — every window starts at the DEFAULT
|
|
// (unfocused) opacity, matching retail's unfocused ChatInterface state.
|
|
Assert.Equal(0.5f, handle.Opacity);
|
|
Assert.Equal(0.5f, controller.DefaultOpacity);
|
|
Assert.Equal(1.0f, controller.ActiveOpacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void WindowRegisteredAfterConstruction_PicksUpLiveOpacityImmediately()
|
|
{
|
|
UiRoot root = NewRoot();
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.3f, activeOpacity: 0.9f);
|
|
|
|
// The window is mounted AFTER the controller exists — proves the
|
|
// RetailWindowManager.WindowRegistered subscription (not just the ctor's
|
|
// catch-up loop over already-registered windows) is what applies retail's
|
|
// GLOBAL opacity scope to every future Mount* call too.
|
|
(RetailWindowHandle handle, _) = RegisterWindow(root, "Toolbar");
|
|
|
|
Assert.Equal(0.3f, handle.Opacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void FocusEnteringAWindow_SwitchesToActiveOpacity_LeavingSwitchesBack()
|
|
{
|
|
UiRoot root = NewRoot();
|
|
(RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat");
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
|
|
Assert.Equal(0.5f, handle.Opacity);
|
|
|
|
root.SetKeyboardFocus(child);
|
|
Assert.Equal(1.0f, handle.Opacity);
|
|
|
|
root.SetKeyboardFocus(null);
|
|
Assert.Equal(0.5f, handle.Opacity);
|
|
GC.KeepAlive(controller);
|
|
}
|
|
|
|
[Fact]
|
|
public void OpacityFade_AppliesToEveryRegisteredWindow_NotJustChat()
|
|
{
|
|
// The CH6c scope extension: retail's ChatInterface::SetOpacity only ever
|
|
// runs on chat-derived windows. acdream applies the SAME mechanism to
|
|
// every RetailWindowManager window — vitals, toolbar, whatever else is
|
|
// mounted — matching the task's GLOBAL-option framing.
|
|
UiRoot root = NewRoot();
|
|
(RetailWindowHandle vitals, _) = RegisterWindow(root, "Vitals");
|
|
(RetailWindowHandle toolbar, UiElement toolbarChild) = RegisterWindow(root, "Toolbar");
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.4f, activeOpacity: 1.0f);
|
|
|
|
Assert.Equal(0.4f, vitals.Opacity);
|
|
Assert.Equal(0.4f, toolbar.Opacity);
|
|
|
|
root.SetKeyboardFocus(toolbarChild);
|
|
|
|
Assert.Equal(0.4f, vitals.Opacity); // unrelated window: still unfocused
|
|
Assert.Equal(1.0f, toolbar.Opacity); // the focused one: active
|
|
}
|
|
|
|
[Fact]
|
|
public void SetDefaultOpacity_AboveCurrentActive_DragsActiveUp_AndReappliesEverywhere()
|
|
{
|
|
// Decomp-verified linking (ChatInterface::SetDefaultOpacity @0x004F3BC0):
|
|
// raising DEFAULT above the current ACTIVE value drags active UP to
|
|
// match — it never clamps the default down instead.
|
|
UiRoot root = NewRoot();
|
|
(RetailWindowHandle unfocused, _) = RegisterWindow(root, "A");
|
|
(RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, "B");
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.3f, activeOpacity: 0.5f);
|
|
root.SetKeyboardFocus(focusedChild);
|
|
Assert.Equal(0.3f, unfocused.Opacity);
|
|
Assert.Equal(0.5f, focused.Opacity);
|
|
|
|
controller.SetDefaultOpacity(0.9f);
|
|
|
|
Assert.Equal(0.9f, controller.DefaultOpacity);
|
|
Assert.Equal(0.9f, controller.ActiveOpacity);
|
|
Assert.Equal(0.9f, unfocused.Opacity);
|
|
Assert.Equal(0.9f, focused.Opacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetActiveOpacity_BelowCurrentDefault_DragsDefaultDown_AndReappliesEverywhere()
|
|
{
|
|
// Symmetric case (ChatInterface::SetActiveOpacity @0x004F3C40).
|
|
UiRoot root = NewRoot();
|
|
(RetailWindowHandle unfocused, _) = RegisterWindow(root, "A");
|
|
(RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, "B");
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 0.7f);
|
|
root.SetKeyboardFocus(focusedChild);
|
|
|
|
controller.SetActiveOpacity(0.1f);
|
|
|
|
Assert.Equal(0.1f, controller.DefaultOpacity);
|
|
Assert.Equal(0.1f, controller.ActiveOpacity);
|
|
Assert.Equal(0.1f, unfocused.Opacity);
|
|
Assert.Equal(0.1f, focused.Opacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetOpacity_AppliesBothInRetailsUpdateFromPlayerModuleOrder()
|
|
{
|
|
// UpdateFromPlayerModule (0x004CE3F0) reads/applies Default first, then
|
|
// Active — the shape used to push a freshly loaded ChatSettings pair.
|
|
UiRoot root = NewRoot();
|
|
(RetailWindowHandle handle, _) = RegisterWindow(root, "Chat");
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
|
|
|
|
controller.SetOpacity(defaultOpacity: 0.2f, activeOpacity: 0.6f);
|
|
|
|
Assert.Equal(0.2f, controller.DefaultOpacity);
|
|
Assert.Equal(0.6f, controller.ActiveOpacity);
|
|
Assert.Equal(0.2f, handle.Opacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConstructorSeed_EnforcesTheActiveGreaterThanOrEqualDefaultInvariant()
|
|
{
|
|
// A corrupt/hand-edited settings.json could carry active < default.
|
|
// The seed collapses through the SAME link the live setters use.
|
|
UiRoot root = NewRoot();
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.8f, activeOpacity: 0.2f);
|
|
|
|
Assert.True(controller.ActiveOpacity >= controller.DefaultOpacity);
|
|
Assert.Equal(0.2f, controller.DefaultOpacity);
|
|
Assert.Equal(0.2f, controller.ActiveOpacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_UnsubscribesFromFocusChanges()
|
|
{
|
|
UiRoot root = NewRoot();
|
|
(RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat");
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
|
|
|
|
controller.Dispose();
|
|
root.SetKeyboardFocus(child);
|
|
|
|
// Still at the last value the controller applied before disposal — a
|
|
// focus change after Dispose is not observed anymore.
|
|
Assert.Equal(0.5f, handle.Opacity);
|
|
}
|
|
}
|