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

View file

@ -0,0 +1,30 @@
using AcDream.App.UI;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.App.Tests.UI;
public class UiChatViewDatFontTests
{
// Synthetic per-char advance: each glyph 10px (Before=2,Width=6,After=2).
private static FontCharDesc Glyph(char c) => new()
{
Unicode = c, HorizontalOffsetBefore = 2, Width = 6, HorizontalOffsetAfter = 2,
OffsetX = 0, OffsetY = 0, Height = 12, VerticalOffsetBefore = 0,
};
[Fact]
public void CharIndexAt_UsesDatGlyphAdvance()
{
float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c));
Assert.Equal(0, UiChatView.CharIndexAt("abc", Adv, 4f));
Assert.Equal(1, UiChatView.CharIndexAt("abc", Adv, 12f));
Assert.Equal(3, UiChatView.CharIndexAt("abc", Adv, 100f));
}
[Fact]
public void GlyphAdvance_MatchesRetailFormula()
{
Assert.Equal(10f, UiDatFont.GlyphAdvance(Glyph('x')));
}
}