feat(D.2b): UiText (Type 12) -- generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5)
Rename UiChatView -> UiText (the retail UIElement_Text class, RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655). Factory changes (DatWidgetFactory.cs): - Remove the Type-12 skip (was: no-media -> null, with-media -> UiDatElement). - Add Type 12 -> BuildText() -> UiText in the switch. - BuildText extracts the element's Direct/Normal sprite as BackgroundSprite so any dat-media the element carried keeps rendering under the text. UiText changes (renamed from UiChatView.cs): - BackgroundColor default: (0,0,0,0.35) -> (0,0,0,0) (transparent). An unbound UiText draws nothing; the controller opts in to the translucent bg. - New BackgroundSprite + SpriteResolve: optional dat state-sprite background drawn UNDER DrawFill+text (faithful UIElement_Text media support). ChatWindowController.cs (Task 5 Step 8): - Transcript property: UiChatView -> UiText. - Bind() now uses layout.FindElement(TranscriptId) as UiText (factory-built) instead of manually constructing + AddChild-ing a new UiChatView. - Sets BackgroundColor = (0,0,0,0.35) on the found widget (retail translucent bg). - Removes the tInfo null-check from the early guard (transcript is factory-built; iInfo lookup kept for the input widget which is still manually constructed). - BuildLines: UiChatView.Line -> UiText.Line throughout. Vitals frozen: the Type-12 vitals number elements are meter children and are never recursed by BuildWidget (the `if (w is not UiMeter)` gate), so they are not built as widgets and keep rendering via UiMeter.Label. Vitals fixture vitals_2100006C.json unchanged; LayoutConformanceTests + VitalsBindingTests green. Tests: - UiChatViewTests.cs -> UiTextTests.cs (class: UiTextTests, all UiChatView.* -> UiText.*) - UiChatViewDatFontTests.cs -> UiTextDatFontTests.cs (same) - DatWidgetFactoryTests: delete Type12_StylePrototype_ReturnsNull + DatWidgetFactory_Type12WithMedia_Renders; add Type12_Text_MakesUiText + DatWidgetFactory_Type12_AlwaysMakesUiText. - LayoutImporterTests: BuildFromInfos_Type12Child_IsSkipped_Type3Present updated to assert IsType<UiText> (element is now in tree, transparent, not skipped). Divergence register: AP-37 amended -- removed the "standalone Type-0 text elements skipped / dat-text widget is Plan 2" clause (now shipped as UiText); kept the meter-collapse clause and the vitals-numbers-via-UiMeter.Label clause. AP-38/AP-39/AD-28 file references updated UiChatView.cs -> UiText.cs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
67e5b8cff2
commit
cb082b59e4
10 changed files with 127 additions and 118 deletions
413
src/AcDream.App/UI/UiText.cs
Normal file
413
src/AcDream.App/UI/UiText.cs
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Scrollable text view for retail UIElement_Text elements
|
||||
/// (<c>RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655</c>).
|
||||
/// Renders the lines from <see cref="LinesProvider"/> bottom-pinned (newest at the bottom,
|
||||
/// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps
|
||||
/// text inside the window.
|
||||
///
|
||||
/// <para>
|
||||
/// Supports Windows-like text selection: a left-click-drag inside the transcript
|
||||
/// selects characters (the <see cref="UiElement.CapturesPointerDrag"/> opt-out
|
||||
/// stops that interior drag from moving the host window), and Ctrl+C copies the
|
||||
/// selected span to the clipboard. Ctrl+A selects everything.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiText : UiElement
|
||||
{
|
||||
/// <summary>One display line: pre-formatted text + its colour.</summary>
|
||||
public readonly record struct Line(string Text, Vector4 Color);
|
||||
|
||||
/// <summary>A caret position: a line index into the cached line list plus a
|
||||
/// character index (0..line.Text.Length, i.e. a caret slot between glyphs).</summary>
|
||||
public readonly record struct Pos(int Line, int Col);
|
||||
|
||||
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
|
||||
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
|
||||
|
||||
/// <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; }
|
||||
|
||||
/// <summary>Backing fill behind the text. Defaults to transparent so an unbound
|
||||
/// UiText (no controller) draws nothing. Set to the retail translucent value by
|
||||
/// the controller (e.g. <c>ChatWindowController</c>).</summary>
|
||||
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);
|
||||
|
||||
/// <summary>Optional dat state-sprite background (the element's own media), drawn
|
||||
/// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
|
||||
public uint BackgroundSprite { get; set; }
|
||||
|
||||
/// <summary>Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height).
|
||||
/// Required when <see cref="BackgroundSprite"/> is non-zero.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
/// <summary>Highlight colour painted behind a selected character span.</summary>
|
||||
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
|
||||
|
||||
/// <summary>Inner text inset from the view edges, px.</summary>
|
||||
public float Padding { get; set; } = 4f;
|
||||
|
||||
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
|
||||
public UiScrollable Scroll { get; } = new();
|
||||
|
||||
/// <summary>True while the view is pinned to the newest line (auto-scrolls as content grows).</summary>
|
||||
private bool _pinBottom = true;
|
||||
|
||||
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;
|
||||
|
||||
// ── Selection state ──────────────────────────────────────────────────
|
||||
private Pos? _selAnchor; // where the drag started
|
||||
private Pos? _selCaret; // where the drag currently is
|
||||
private bool _selecting;
|
||||
|
||||
public UiText()
|
||||
{
|
||||
AcceptsFocus = true;
|
||||
IsEditControl = true; // absorb keys (Ctrl+C) while focused
|
||||
CapturesPointerDrag = true; // interior drag selects, doesn't move the window
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clamp a scroll offset to [0, max] where max = content-height - view-height
|
||||
/// (never negative — when everything fits, scroll is pinned to 0). Exposed for tests.
|
||||
/// </summary>
|
||||
public static float ClampScroll(float scroll, float contentHeight, float viewHeight)
|
||||
{
|
||||
float max = Math.Max(0f, contentHeight - viewHeight);
|
||||
if (scroll < 0f) return 0f;
|
||||
return scroll > max ? max : scroll;
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// Optional dat state-sprite background drawn UNDER everything else.
|
||||
if (BackgroundSprite != 0 && SpriteResolve is { } sr)
|
||||
{
|
||||
var (tex, tw, th) = sr(BackgroundSprite);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
|
||||
// Background must draw UNDER the transcript text. DrawStringDat emits into the
|
||||
// sprite bucket which flushes BEFORE rects, so a DrawRect background would wash
|
||||
// over the text. DrawFill routes the background through the sprite bucket too,
|
||||
// submitted first → text on top.
|
||||
ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
||||
|
||||
// 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;
|
||||
_lastDatFont = datFont;
|
||||
_lastFont = bitmapFont;
|
||||
_lastLineHeight = datFont is not null ? datFont.LineHeight : bitmapFont!.LineHeight;
|
||||
_lastPadding = Padding;
|
||||
|
||||
if (lines.Count == 0) return;
|
||||
|
||||
float lh = _lastLineHeight;
|
||||
float top = Padding, bottom = Height - Padding;
|
||||
float innerH = bottom - top;
|
||||
float contentH = lines.Count * lh;
|
||||
|
||||
// Drive the shared scroll model with the current geometry.
|
||||
Scroll.LineHeight = (int)MathF.Round(lh);
|
||||
Scroll.ContentHeight = (int)MathF.Ceiling(contentH);
|
||||
Scroll.ViewHeight = (int)MathF.Floor(innerH);
|
||||
if (_pinBottom) Scroll.ScrollToEnd();
|
||||
|
||||
// UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest.
|
||||
// Visual layout: newest at bottom → baseY = bottom - contentH (ScrollY at max).
|
||||
// Invert: baseY = bottom - contentH + (MaxScroll - ScrollY).
|
||||
// With _pinBottom: ScrollY=MaxScroll → baseY=bottom-contentH → last line ends at bottom. ✓
|
||||
// Scrolled to top: ScrollY=0 → baseY=bottom-contentH+MaxScroll=bottom-innerH=top. ✓
|
||||
float baseY = bottom - contentH + (Scroll.MaxScroll - Scroll.ScrollY);
|
||||
_lastBaseY = baseY;
|
||||
|
||||
// Normalised selection span (start <= end), if any.
|
||||
bool hasSel = TryGetOrderedSelection(out Pos selStart, out Pos selEnd);
|
||||
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
float y = baseY + i * lh;
|
||||
if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet)
|
||||
|
||||
string text = lines[i].Text;
|
||||
|
||||
// Selection highlight behind this line's selected character span.
|
||||
if (hasSel && i >= selStart.Line && i <= selEnd.Line)
|
||||
{
|
||||
int c0 = i == selStart.Line ? selStart.Col : 0;
|
||||
int c1 = i == selEnd.Line ? selEnd.Col : text.Length;
|
||||
c0 = Math.Clamp(c0, 0, text.Length);
|
||||
c1 = Math.Clamp(c1, 0, text.Length);
|
||||
if (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));
|
||||
}
|
||||
// Highlight sits BEHIND the line's text → sprite bucket, submitted
|
||||
// before this line's DrawStringDat.
|
||||
ctx.DrawFill(hx, y, hw, lh, SelectionColor);
|
||||
}
|
||||
}
|
||||
|
||||
if (datFont is not null)
|
||||
ctx.DrawStringDat(datFont, text, Padding, y, lines[i].Color);
|
||||
else
|
||||
ctx.DrawString(text, Padding, y, lines[i].Color, bitmapFont);
|
||||
}
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.Scroll:
|
||||
{
|
||||
// Silk wheel +Y = scroll up = reveal older = toward the TOP = decrease ScrollY.
|
||||
// ScrollByLines sign: +down/newer, -up/older.
|
||||
// e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines.
|
||||
Scroll.ScrollByLines((int)(-e.Data0 * WheelLines));
|
||||
_pinBottom = Scroll.AtEnd;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseDown:
|
||||
{
|
||||
// Data1/Data2 = local-to-target coords (UiRoot.OnMouseDown).
|
||||
var p = HitChar(e.Data1, e.Data2);
|
||||
_selAnchor = p;
|
||||
_selCaret = p;
|
||||
_selecting = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseMove:
|
||||
{
|
||||
if (_selecting)
|
||||
{
|
||||
// Data1/Data2 = local-to-target coords (DispatchMouseMove).
|
||||
_selCaret = HitChar(e.Data1, e.Data2);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
case UiEventType.MouseUp:
|
||||
{
|
||||
_selecting = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.KeyDown:
|
||||
{
|
||||
var key = (Silk.NET.Input.Key)e.Data0;
|
||||
bool ctrl = Keyboard is not null
|
||||
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft)
|
||||
|| Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight));
|
||||
if (ctrl && key == Silk.NET.Input.Key.C)
|
||||
{
|
||||
// Only touch the clipboard when there's a selection — an empty
|
||||
// copy must NOT clobber what the user previously copied.
|
||||
if (Keyboard is not null)
|
||||
{
|
||||
string sel = SelectedText();
|
||||
if (sel.Length > 0) Keyboard.ClipboardText = sel;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (ctrl && key == Silk.NET.Input.Key.A)
|
||||
{
|
||||
SelectAll();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Selection helpers ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Select the entire cached transcript (Ctrl+A).</summary>
|
||||
private void SelectAll()
|
||||
{
|
||||
var lines = _lastLines;
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
_selAnchor = _selCaret = null;
|
||||
return;
|
||||
}
|
||||
int last = lines.Count - 1;
|
||||
_selAnchor = new Pos(0, 0);
|
||||
_selCaret = new Pos(last, lines[last].Text.Length);
|
||||
}
|
||||
|
||||
/// <summary>Normalise (anchor, caret) into ordered (start, end). False if no
|
||||
/// selection or it is empty (anchor == caret).</summary>
|
||||
private bool TryGetOrderedSelection(out Pos start, out Pos end)
|
||||
{
|
||||
start = default; end = default;
|
||||
if (_selAnchor is not { } a || _selCaret is not { } c) return false;
|
||||
(start, end) = Order(a, c);
|
||||
return !(start.Line == end.Line && start.Col == end.Col);
|
||||
}
|
||||
|
||||
/// <summary>The currently-selected text against the cached lines. Empty when
|
||||
/// nothing is selected.</summary>
|
||||
public string SelectedText()
|
||||
{
|
||||
if (!TryGetOrderedSelection(out var start, out var end)) return string.Empty;
|
||||
return SelectedText(_lastLines, start, end);
|
||||
}
|
||||
|
||||
// ── Pure, testable logic (no GL / no font texture) ───────────────────
|
||||
|
||||
/// <summary>Order two caret positions so the first is <= the second (by line,
|
||||
/// then column).</summary>
|
||||
public static (Pos start, Pos end) Order(Pos a, Pos b)
|
||||
{
|
||||
if (a.Line < b.Line || (a.Line == b.Line && a.Col <= b.Col)) return (a, b);
|
||||
return (b, a);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assemble the selected substring spanning <paramref name="start"/> ..
|
||||
/// <paramref name="end"/> (inclusive of start.Col, exclusive of end.Col) from
|
||||
/// <paramref name="lines"/>. Multi-line selections are joined with "\n":
|
||||
/// the first line from start.Col to its end, whole middle lines, and the last
|
||||
/// line up to end.Col. Pure — unit-testable without GL.
|
||||
/// </summary>
|
||||
public static string SelectedText(IReadOnlyList<Line> lines, Pos start, Pos end)
|
||||
{
|
||||
if (lines.Count == 0) return string.Empty;
|
||||
(start, end) = Order(start, end);
|
||||
|
||||
int sl = Math.Clamp(start.Line, 0, lines.Count - 1);
|
||||
int el = Math.Clamp(end.Line, 0, lines.Count - 1);
|
||||
|
||||
if (sl == el)
|
||||
{
|
||||
string t = lines[sl].Text;
|
||||
int c0 = Math.Clamp(start.Col, 0, t.Length);
|
||||
int c1 = Math.Clamp(end.Col, 0, t.Length);
|
||||
if (c1 <= c0) return string.Empty;
|
||||
return t.Substring(c0, c1 - c0);
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// First line: from start.Col to its end.
|
||||
{
|
||||
string t = lines[sl].Text;
|
||||
int c0 = Math.Clamp(start.Col, 0, t.Length);
|
||||
sb.Append(t.AsSpan(c0));
|
||||
}
|
||||
|
||||
// Whole middle lines.
|
||||
for (int i = sl + 1; i < el; i++)
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(lines[i].Text);
|
||||
}
|
||||
|
||||
// Last line: up to end.Col.
|
||||
{
|
||||
sb.Append('\n');
|
||||
string t = lines[el].Text;
|
||||
int c1 = Math.Clamp(end.Col, 0, t.Length);
|
||||
sb.Append(t.AsSpan(0, c1));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a local-space point to a caret <see cref="Pos"/> against the cached
|
||||
/// layout from the last draw. line = floor((localY - baseY)/lineHeight) clamped
|
||||
/// to the line range; col via <see cref="CharIndexAt"/>.
|
||||
/// </summary>
|
||||
private Pos HitChar(float localX, float localY)
|
||||
{
|
||||
var lines = _lastLines;
|
||||
if (lines.Count == 0) return new Pos(0, 0);
|
||||
|
||||
float lh = _lastLineHeight <= 0f ? 16f : _lastLineHeight;
|
||||
int line = (int)MathF.Floor((localY - _lastBaseY) / lh);
|
||||
line = Math.Clamp(line, 0, lines.Count - 1);
|
||||
|
||||
string text = lines[line].Text;
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The caret column for a horizontal position <paramref name="x"/> (already
|
||||
/// adjusted for the left padding, so x=0 is the start of the text). Walks the
|
||||
/// string accumulating each glyph's advance and snaps the caret to whichever
|
||||
/// side of the glyph midpoint <paramref name="x"/> falls on — natural
|
||||
/// Windows-like caret placement. Pure — unit-testable with a synthetic advance.
|
||||
/// </summary>
|
||||
/// <param name="text">The line text.</param>
|
||||
/// <param name="advanceOf">Per-character advance (pixels) lookup.</param>
|
||||
/// <param name="x">Horizontal position relative to the text's left edge.</param>
|
||||
public static int CharIndexAt(string text, Func<char, float> advanceOf, float x)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || x <= 0f) return 0;
|
||||
|
||||
float cursor = 0f;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
float adv = advanceOf(text[i]);
|
||||
float mid = cursor + adv * 0.5f;
|
||||
if (x < mid) return i; // caret sits before this glyph
|
||||
cursor += adv;
|
||||
}
|
||||
return text.Length; // past the last glyph → end caret
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue