feat(D.2b): UiChatView dat-font transcript + 1-line wheel quantum

- Add `DatFont` property (UiDatFont?): when set, OnDraw uses
  ctx.DrawStringDat + datFont.MeasureWidth for all transcript lines;
  BitmapFont path unchanged as fallback when DatFont is null.
- Cache `_lastDatFont` alongside `_lastFont` so HitChar hit-tests the
  same advance source that drew the last frame.
- HitChar prefers `_lastDatFont` (via UiDatFont.GlyphAdvance) over
  `_lastFont` (via bf.Advance) for column resolution, keeping
  drag-select and Ctrl+C accurate with the dat font.
- Scroll event handler uses DatFont?.LineHeight first, so the wheel
  quantum stays correct when the dat font has a different line height.
- WheelLines 3f → 1f: retail UIElement_Text::HandleMouseWheel
  (@0x471450) advances one line per notch; our 3-line quantum was
  wrong.
- Add UiChatViewDatFontTests: pins GlyphAdvance formula
  (Before+Width+After = 10) and CharIndexAt dat-advance integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 22:14:56 +02:00
parent 50883e445b
commit 7552dcba39
2 changed files with 68 additions and 15 deletions

View file

@ -34,6 +34,11 @@ public sealed class UiChatView : UiElement
/// <summary>Font for the transcript; falls back to the context default.</summary>
public BitmapFont? Font { get; set; }
/// <summary>Retail dat font (0x40000000) for the transcript. When set, glyphs
/// render via the two-pass dat-font blit and measure/hit-test use the dat glyph
/// advance; when null, the debug BitmapFont path is used. Set by the controller.</summary>
public UiDatFont? DatFont { get; set; }
/// <summary>Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by
/// the host from <see cref="UiHost.Keyboard"/>.</summary>
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
@ -49,11 +54,12 @@ public sealed class UiChatView : UiElement
// Pixels the transcript is scrolled UP from the newest line (0 = pinned to bottom).
private float _scroll;
private const float WheelLines = 3f; // lines advanced per wheel notch
private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch)
// ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ──
private IReadOnlyList<Line> _lastLines = Array.Empty<Line>();
private BitmapFont? _lastFont;
private UiDatFont? _lastDatFont;
private float _lastLineHeight = 16f;
private float _lastBaseY; // top Y of line 0 in local space
private float _lastPadding = 4f;
@ -85,21 +91,24 @@ public sealed class UiChatView : UiElement
{
ctx.DrawRect(0, 0, Width, Height, BackgroundColor);
var font = Font ?? ctx.DefaultFont;
if (font is null) return;
// Prefer the retail dat font when set; fall back to BitmapFont.
var datFont = DatFont;
var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null;
if (datFont is null && bitmapFont is null) return;
var lines = LinesProvider();
// Cache the geometry OnEvent will hit-test against. Even when there are no
// lines we record the font/padding so a stray hit-test is harmless.
_lastLines = lines;
_lastFont = font;
_lastLineHeight = font.LineHeight;
_lastDatFont = datFont;
_lastFont = bitmapFont;
_lastLineHeight = datFont is not null ? datFont.LineHeight : bitmapFont!.LineHeight;
_lastPadding = Padding;
if (lines.Count == 0) return;
float lh = font.LineHeight;
float lh = _lastLineHeight;
float top = Padding, bottom = Height - Padding;
float innerH = bottom - top;
float contentH = lines.Count * lh;
@ -129,13 +138,25 @@ public sealed class UiChatView : UiElement
c1 = Math.Clamp(c1, 0, text.Length);
if (c1 > c0)
{
float hx = Padding + font.MeasureWidth(text.Substring(0, c0));
float hw = font.MeasureWidth(text.Substring(c0, c1 - c0));
float hx, hw;
if (datFont is not null)
{
hx = Padding + datFont.MeasureWidth(text.Substring(0, c0));
hw = datFont.MeasureWidth(text.Substring(c0, c1 - c0));
}
else
{
hx = Padding + bitmapFont!.MeasureWidth(text.Substring(0, c0));
hw = bitmapFont.MeasureWidth(text.Substring(c0, c1 - c0));
}
ctx.DrawRect(hx, y, hw, lh, SelectionColor);
}
}
ctx.DrawString(text, Padding, y, lines[i].Color, font);
if (datFont is not null)
ctx.DrawStringDat(datFont, text, Padding, y, lines[i].Color);
else
ctx.DrawString(text, Padding, y, lines[i].Color, bitmapFont);
}
}
@ -145,7 +166,7 @@ public sealed class UiChatView : UiElement
{
case UiEventType.Scroll:
{
float lh = (Font ?? _lastFont)?.LineHeight ?? 16f;
float lh = DatFont?.LineHeight ?? (Font ?? _lastFont)?.LineHeight ?? 16f;
// Silk wheel +Y = scroll up = reveal older = shift content down = larger _scroll.
_scroll += e.Data0 * WheelLines * lh; // re-clamped next OnDraw against live content
return true;
@ -316,11 +337,13 @@ public sealed class UiChatView : UiElement
line = Math.Clamp(line, 0, lines.Count - 1);
string text = lines[line].Text;
var font = _lastFont;
int col = font is null
? 0
: CharIndexAt(text, ch => font.TryGetGlyph(ch, out var g) ? g.Advance : 0f,
localX - _lastPadding);
int col = _lastDatFont is { } df
? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f,
localX - _lastPadding)
: (_lastFont is { } bf
? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f,
localX - _lastPadding)
: 0);
return new Pos(line, col);
}