acdream/src/AcDream.App/UI/Layout/ItemAppraisalReport.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

194 lines
5.8 KiB
C#

using System.Numerics;
using System.Text;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Font-color indices selected by retail <c>ItemExamineUI</c>. The concrete
/// colors come from the item report's LayoutDesc property <c>0x1B</c>.
/// </summary>
public enum ItemAppraisalFontStyle
{
Normal = 0,
Beneficial = 1,
Detrimental = 2,
}
/// <summary>
/// Separator inserted before an appraisal fragment. This is the retained
/// equivalent of <c>ItemExamineUI::AddItemInfo</c>'s final argument.
/// </summary>
public enum ItemAppraisalSeparator
{
None,
Line,
Paragraph,
}
/// <summary>
/// One retail appraisal append operation: prose, separator, and authored
/// font-color index. Keeping these separate until shaping preserves style
/// when a long fragment wraps.
/// </summary>
public readonly record struct ItemAppraisalFragment(
string Text,
ItemAppraisalSeparator Separator,
ItemAppraisalFontStyle Style);
/// <summary>
/// Immutable item appraisal report in retail append order.
/// </summary>
public sealed class ItemAppraisalReport
{
public static ItemAppraisalReport Empty { get; } = new([]);
public ItemAppraisalReport(IReadOnlyList<ItemAppraisalFragment> fragments)
{
ArgumentNullException.ThrowIfNull(fragments);
Fragments = fragments;
}
public IReadOnlyList<ItemAppraisalFragment> Fragments { get; }
public bool IsEmpty => Fragments.Count == 0;
public override string ToString()
{
var text = new StringBuilder();
foreach (ItemAppraisalFragment fragment in Fragments)
{
if (text.Length != 0)
{
text.Append(fragment.Separator == ItemAppraisalSeparator.Paragraph
? "\n\n"
: "\n");
}
text.Append(fragment.Text);
}
return text.ToString();
}
}
/// <summary>
/// Port of <c>ItemExamineUI::AddItemInfo @ 0x004AC050/0x004ADCA0</c>.
/// Retail appends one newline when <paramref name="sameParagraph"/> is true,
/// two otherwise, then applies font DID index zero and the supplied color index.
/// </summary>
internal sealed class ItemAppraisalReportBuilder
{
private readonly List<ItemAppraisalFragment> _fragments = [];
public void Line(
string value,
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> Add(value, sameParagraph: true, style);
public void Paragraph(
string value,
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> Add(value, sameParagraph: false, style);
/// <summary>
/// Preserve an empty retail <c>AddItemInfo("", ..., true)</c> append.
/// Once text exists this contributes one physical empty row; on an empty
/// glyph list retail has no preceding separator to append.
/// </summary>
public void BlankLine(
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
{
if (_fragments.Count == 0)
return;
_fragments.Add(new ItemAppraisalFragment(
string.Empty,
ItemAppraisalSeparator.Line,
style));
}
public ItemAppraisalReport Build()
=> _fragments.Count == 0
? ItemAppraisalReport.Empty
: new ItemAppraisalReport(_fragments.ToArray());
private void Add(
string value,
bool sameParagraph,
ItemAppraisalFontStyle style)
{
if (string.IsNullOrWhiteSpace(value))
return;
_fragments.Add(new ItemAppraisalFragment(
value,
_fragments.Count == 0
? ItemAppraisalSeparator.None
: sameParagraph
? ItemAppraisalSeparator.Line
: ItemAppraisalSeparator.Paragraph,
style));
}
}
/// <summary>
/// Width-aware projection of retail appraisal fragments into the retained
/// <see cref="UiText"/> line model.
/// </summary>
internal static class ItemAppraisalTextLayout
{
public static IReadOnlyList<UiText.Line> Shape(
UiText target,
ItemAppraisalReport report)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(report);
float maxWidth = Math.Max(1f, target.Width - (2f * target.Padding));
float Measure(string value)
=> target.DatFont?.MeasureWidth(value)
?? target.Font?.MeasureWidth(value)
?? value.Length * 8f;
var lines = new List<UiText.Line>();
foreach (ItemAppraisalFragment fragment in report.Fragments)
{
if (lines.Count != 0
&& fragment.Separator == ItemAppraisalSeparator.Paragraph)
{
lines.Add(new UiText.Line(string.Empty, ResolveColor(target, fragment.Style)));
}
Vector4 color = ResolveColor(target, fragment.Style);
string normalized = fragment.Text.Replace(
"\\n",
"\n",
StringComparison.Ordinal);
foreach (string logicalLine in normalized.Split('\n'))
{
if (logicalLine.Length == 0)
{
lines.Add(new UiText.Line(string.Empty, color));
continue;
}
foreach (string wrapped in ChatTranscriptRenderer.WrapText(
logicalLine,
maxWidth,
Measure))
{
lines.Add(new UiText.Line(wrapped, color));
}
}
}
return lines;
}
private static Vector4 ResolveColor(
UiText target,
ItemAppraisalFontStyle style)
{
int index = (int)style;
return index >= 0 && index < target.FontColorPalette.Count
? target.FontColorPalette[index]
: target.DefaultColor;
}
}