acdream/src/AcDream.App/UI/UiResizeGrip.cs
Erik 1aa7709988 fix(chat): CH6a/b rework — grip media, retail window-id model, floaty fixture
Applies docs/research/2026-08-10-ch6ab-review-findings.md in full:

- BLOCKER 1: UiResizeGrip now carries its ElementInfo/resolve pair and
  draws its own authored DirectState media (a synthetic parameterless
  grip still draws nothing, preserving existing resize-drag tests).
  DatWidgetFactory.BuildResizeGrip threads resolve through. All seven
  live grips on the main chat window now resolve a non-zero sprite,
  restoring the visible borders/corners CH6a silently dropped.

- SHOULD-FIX 2: ChatWindowState gains BroadcastTargetWindow, a sentinel
  distinct from every real window id (0-4), fixing the bug where the
  main window's explicit-addressing branch coincided with the broadcast
  check (both were literal 0). SetFilter's main-window no-op is dropped
  — the main window's filter is now genuinely settable. ChatWindowController
  .Bind takes a ChatWindowState (the same canonical instance the floating
  windows already share) and GetTranscriptLines builds a real accept
  predicate instead of accept:null. Verified safe: ClientLocal (0x1A)
  never reaches ChatLog (AddText routes it to the SpewBox and returns),
  so nothing observable regresses.

- SHOULD-FIX 3: UiButton.SuppressSelfToggle stops the four chat-window
  indicator buttons (DAT property 0x0B=true, no retail click handler)
  from flipping their own Selected mirror on a stray click.

- SHOULD-FIX 4: generated and committed chat_floaty_2100005b.json from
  the real installed dats; added the permanent RetailLayoutFixtureGenerator
  entry. All three flagged FloatingChatWindowController assumptions
  (input field, title bar, close button) are confirmed correct against
  real data — no controller code changes needed. New finding: unlike the
  main window, ALL EIGHT floaty border/corner elements are live Type-9
  grips (the floaty's own title bar is its move handle), so a floaty
  window resizes from every edge and corner.

- SHOULD-FIX 5: register row AP-189 documents the shared-500-entry/
  200-line-tail vs retail's per-window 10,000-line scrollback depth gap.

- NITs 1-5: documented the filter-persistence-only-on-/saveautoui
  asymmetry and the reconnect-preserves-filters intent; corrected the
  research doc's modifier-mask mislabel and the "ONLY function" false
  superlative; moved WrapText off ChatWindowController onto
  ChatTranscriptRenderer, closing the circular dependency.

Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at 22020ef2; net +28 tests, zero regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:09:32 +02:00

149 lines
6.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
/// <summary>
/// Retail <c>UIElement_Resizebar</c> (element class 9,
/// <c>UIElement_Resizebar::Register @0x0046B920</c> registers class 9 with the
/// LayoutDesc element factory). A leaf grip authored at exactly one edge or
/// corner of a window; retail authors 4 edge grips + 4 corner grips per
/// resizable window (main chat: <c>0x1000069B</c>-<c>0x100006A2</c>, minus the
/// straight top strip which is a retail <c>UIElement_Dragbar</c> move handle
/// instead — see the class doc on <see cref="AcDream.App.UI.Layout.DatWidgetFactory"/>
/// and <c>docs/research/2026-08-09-chat-retail-window-shell.md</c> §2.1/§2.3).
///
/// <para>
/// <c>UIElement_Resizebar::StartMouseResizing @0x0046B7E0</c> reads four BOOL
/// attributes off the element — <c>0x2A</c>=bottom, <c>0x2B</c>=left,
/// <c>0x2C</c>=right, <c>0x2D</c>=top — and combines them into a
/// <c>BorderLocation</c> (<c>acclient.h:4327</c>). <see cref="DecodeBorderLocation"/>
/// is a verbatim port of that branch order. acdream's engine expresses resize
/// direction as an edge-flag bitmask (<see cref="ResizeEdges"/>) rather than
/// retail's nine-case enum; <see cref="Edges"/> is the lossless projection —
/// every <see cref="Border"/> value maps to exactly one <see cref="ResizeEdges"/>
/// combination and back.
/// </para>
///
/// <para>
/// A grip is a small (5px) leaf with no children; it exists primarily as a
/// precise hit region, but it IS authored with real border/corner media (the
/// <c>0x06006129</c>-family sprites — CH6a/b REJECT-review BLOCKER 1,
/// <c>docs/research/2026-08-10-ch6ab-review-findings.md</c>: every one of the
/// seven live grips on the main chat window carries a non-zero DirectState
/// sprite, and drawing nothing left the window with only its 400×5 top strip
/// visible). A factory-built grip (<see cref="AcDream.App.UI.Layout.DatWidgetFactory.Create"/>,
/// Type 9) therefore draws its own DirectState media exactly like
/// <see cref="AcDream.App.UI.Layout.UiDatElement"/> would; a synthetic grip
/// built with the parameterless constructor (unit tests exercising only the
/// resize-drag behavior) carries no media and draws nothing, matching its
/// prior behavior. <see cref="AcDream.App.UI.UiRoot"/> gives a directly-hit
/// grip's own <see cref="Edges"/> priority over the generic proximity-based
/// <see cref="AcDream.App.UI.UiRoot.HitEdges"/> heuristic, so e.g. the chat
/// window's top-left/top-right CORNER grips resize (including the Y/top axis)
/// while the plain top EDGE strip in between remains a pure move handle.
/// </para>
/// </summary>
public sealed class UiResizeGrip : UiElement
{
private readonly ElementInfo? _info;
private readonly Func<uint, (uint tex, int w, int h)>? _resolve;
/// <summary>Synthetic grip with no authored media — used by unit tests that
/// exercise only the resize-drag hit-testing/edge behavior.</summary>
public UiResizeGrip()
{
}
/// <summary>
/// Factory-built grip (CH6a/b REJECT-review BLOCKER 1): carries its resolved
/// <see cref="ElementInfo"/> and sprite resolver so it draws its own
/// authored DirectState media, the same way every other dat-imported leaf
/// does. <see cref="UiElement.ClickThrough"/> is left at the base
/// <see langword="false"/> default — unlike <see cref="UiDatElement"/>'s
/// decoration default of <see langword="true"/> — because a grip must stay
/// hit-testable to capture the resize drag.
/// </summary>
public UiResizeGrip(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
{
_info = info;
_resolve = resolve;
}
/// <summary>
/// This grip's authored DirectState sprite file id (0 for a synthetic grip,
/// or an authored grip with no media). Exposed for conformance tests —
/// BLOCKER 1's regression guard is that every one of the seven live Type-9
/// grips resolves a non-zero value here.
/// </summary>
public uint SpriteFile => _info is not null && _info.StateMedia.TryGetValue("", out var media)
? media.File
: 0u;
/// <summary>
/// Draws this grip's own DirectState media exactly like
/// <see cref="UiDatElement.OnDraw"/> — tiled at native size (Normal), same
/// blend for Overlay/Alphablend since the sprite shader already
/// alpha-blends. A synthetic (parameterless-constructed) grip has no
/// resolver and draws nothing, matching its pre-BLOCKER-1 behavior.
/// </summary>
protected override void OnDraw(UiRenderContext ctx)
{
if (_resolve is null) return;
uint file = SpriteFile;
if (file == 0u) return;
var (tex, tw, th) = _resolve(file);
if (tex == 0 || tw == 0 || th == 0) return;
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
}
/// <summary>Retail <c>BorderLocation</c> (<c>acclient.h:4327</c>):
/// <c>BORDER_NONE=0, BORDER_UL=1, BORDER_TOP=2, BORDER_UR=3, BORDER_RIGHT=4,
/// BORDER_LR=5, BORDER_BOTTOM=6, BORDER_LL=7, BORDER_LEFT=8</c>.</summary>
public enum Border
{
None = 0,
UpperLeft = 1,
Top = 2,
UpperRight = 3,
Right = 4,
LowerRight = 5,
Bottom = 6,
LowerLeft = 7,
Left = 8,
}
/// <summary>Decoded retail BorderLocation for this grip.</summary>
public Border BorderLocation { get; set; }
/// <summary>
/// Exact port of <c>UIElement_Resizebar::StartMouseResizing @0x0046B7E0</c>'s
/// if/else-if chain (right beats left beats top beats bottom when more than
/// two bools are set, which retail's authored data never does — corners set
/// exactly two adjacent bools, edges set exactly one).
/// </summary>
public static Border DecodeBorderLocation(bool bottom, bool left, bool right, bool top)
{
if (right) return top ? Border.UpperRight : bottom ? Border.LowerRight : Border.Right;
if (left) return top ? Border.UpperLeft : bottom ? Border.LowerLeft : Border.Left;
if (top) return Border.Top;
if (bottom) return Border.Bottom;
return Border.None;
}
/// <summary>The <see cref="ResizeEdges"/> projection <see cref="UiRoot"/>'s resize
/// drag state machine consumes — a lossless re-encoding of <see cref="BorderLocation"/>.</summary>
public ResizeEdges Edges => BorderLocation switch
{
Border.UpperLeft => ResizeEdges.Left | ResizeEdges.Top,
Border.Top => ResizeEdges.Top,
Border.UpperRight => ResizeEdges.Right | ResizeEdges.Top,
Border.Right => ResizeEdges.Right,
Border.LowerRight => ResizeEdges.Right | ResizeEdges.Bottom,
Border.Bottom => ResizeEdges.Bottom,
Border.LowerLeft => ResizeEdges.Left | ResizeEdges.Bottom,
Border.Left => ResizeEdges.Left,
_ => ResizeEdges.None,
};
}