acdream/src/AcDream.App/UI/UiText.cs
Erik 82789eea88 fix(ui): restore effect detail scrolling
Reflow selected Helpful/Harmful spell descriptions through the retained retail text shaper, bind their authored 0x10000127 information scrollbar independently from the list scrollbar, and allow display-only text to consume wheel scrolling without becoming selectable.

Format Vitae recovery experience through retail's grouped integer presentation. Release build succeeds and all 5,832 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-17 13:28:18 +02:00

884 lines
36 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using System.Text;
using AcDream.App.Rendering;
using AcDream.App.UI.Layout;
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>
/// When retail Selectable property `0x27` is enabled, left-click-drag selects
/// characters, Ctrl+C copies the selected span, and Ctrl+A selects everything.
/// Display-only text remains click-through and cannot steal focus or window drag.
/// </para>
/// </summary>
public sealed class UiText : UiElement, IUiDatStateful
{
/// <summary>Optional base-element click notice used by authored text tabs.</summary>
public Action? OnClick { get; set; }
public override bool HandlesClick
=> OnClick is not null || WheelScrollEnabled || base.HandlesClick;
/// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary>
public uint ElementId { get; set; }
/// <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>
/// Default line color used by controllers when they do not supply a per-line
/// <see cref="Vector4"/> color explicitly. Set by <c>DatWidgetFactory.BuildText</c>
/// from <c>ElementInfo.FontColor</c> when the dat carries a 0x1B ColorBaseProperty;
/// otherwise white (<see cref="Vector4.One"/>).
///
/// <para>Controllers that supply a per-line color via <see cref="LinesProvider"/>
/// (e.g. <c>new UiText.Line(text, explicitColor)</c>) are unaffected — they always
/// win over this default. This property is only a convenience starting point for
/// controllers that want to read the dat color rather than hard-code it.</para>
/// </summary>
public Vector4 DefaultColor { get; set; } = Vector4.One;
/// <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>
/// Uniform inner text inset in pixels. The retail default is zero:
/// <c>UIElement_Text::UIElement_Text @ 0x00468570</c> clears all four margins
/// before applying any authored margin properties.
/// </summary>
public float Padding { get; set; }
/// <summary>Retail property 0x20. Independent of horizontal/vertical
/// justification; false permits the normal multi-line layout path.</summary>
public bool OneLine { get; set; }
private bool _selectable;
/// <summary>
/// Retail property 0x27. A display-only text element does not claim the mouse,
/// focus, or pointer drag. Selection and clipboard behavior are enabled only
/// when this capability is true.
/// </summary>
public bool Selectable
{
get => _selectable;
set
{
if (_selectable == value) return;
_selectable = value;
ClickThrough = !value;
AcceptsFocus = value;
IsEditControl = value;
CapturesPointerDrag = value;
if (!value)
{
_selecting = false;
_selAnchor = null;
_selCaret = null;
}
}
}
/// <summary>Static centered single-line mode (retail <c>UIElement_Text</c> center
/// justification): draws the FIRST line centered horizontally AND vertically in the
/// element rect, with NO scroll/selection machinery. Used for static labels such as
/// the vitals cur/max numbers. The centering formula is IDENTICAL to
/// <see cref="UiMeter"/>'s former number overlay so those numbers stay pixel-identical
/// after the rewire. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
public bool Centered { get; set; }
/// <summary>Static right-aligned single-line mode: draws the FIRST line right-justified
/// within the element rect, vertically centered, with NO scroll/selection machinery.
/// Used for value labels in attribute/skill rows where the number must hug the right edge.
/// Mutually exclusive with <see cref="Centered"/> — if both are true, Centered takes
/// precedence. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
public bool RightAligned { get; set; }
/// <summary>
/// Vertical position of the text within the element rect in single-line mode
/// (<see cref="Centered"/> or <see cref="RightAligned"/>).
/// <list type="bullet">
/// <item><description><b>Center</b> (default) — vertically centered, matching the original
/// behavior of the centered/right-aligned paths.</description></item>
/// <item><description><b>Top</b> — text is placed at <c>y = Padding</c> (top of the content
/// area), so the text sits at the top of the element rather than centering in it.
/// Used for footer title elements whose dat box is the full footer height (55 px) but
/// the text should render near the top.</description></item>
/// <item><description><b>Bottom</b> — text is placed at <c>y = Height - lineHeight - Padding</c>.</description></item>
/// </list>
/// Only meaningful when <see cref="Centered"/> or <see cref="RightAligned"/> is true.
/// Has no effect on the scrollable multi-line path.
/// </summary>
public VJustify VerticalJustify { get; set; } = VJustify.Center;
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
public UiScrollable Scroll { get; } = new();
/// <summary>
/// Keeps a view that is already at the end pinned there when content or
/// geometry changes. Chat uses the default; top-oriented reports such as
/// Character Information disable it.
/// </summary>
public bool PreserveEndOnLayout { get; set; } = true;
/// <summary>
/// Allows a display-only text surface to consume mouse-wheel input without
/// making its text selectable/editable. Retail text scrolling and text
/// selection are independent capabilities; authored report/detail fields
/// commonly expose a scrollbar while remaining non-selectable.
/// </summary>
public bool WheelScrollEnabled { get; set; }
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;
private ElementInfo? _datInfo;
private uint _activeRetailStateId = UiStateInfo.DirectStateId;
private string _activeDatStateName = "";
private bool _drawTextAfterChildren;
private bool _honorDatVerticalJustification;
// ── Selection state ──────────────────────────────────────────────────
private Pos? _selAnchor; // where the drag started
private Pos? _selCaret; // where the drag currently is
private bool _selecting;
public UiText()
{
// UIElement_Text starts display-only (m_bitField = DIRTY|CURSOR_VISIBLE).
ClickThrough = true;
AcceptsFocus = false;
IsEditControl = false;
CapturesPointerDrag = false;
}
/// <summary>
/// Text widgets normally reproduce their own scroll/edit chrome. Retail tab text,
/// however, uses <c>PassToChildren</c> states whose child elements are the authored
/// Open/Closed cap sprites. Those children must remain in the retained tree so
/// <see cref="TrySetRetailState"/> can propagate the state exactly as retail does.
/// </summary>
public override bool ConsumesDatChildren
{
get
{
if (_datInfo is null) return true;
foreach (UiStateInfo state in _datInfo.States.Values)
if (state.PassToChildren)
return false;
return true;
}
}
public uint ActiveRetailStateId => _activeRetailStateId;
public override string ActiveCursorStateName => _activeDatStateName;
internal void ConfigureDatState(ElementInfo info)
{
_datInfo = info;
_honorDatVerticalJustification = true;
// Retail spellbook tabs are UIElement_Text parents whose Open/Closed
// PassToChildren states drive three authored chrome pieces. In retail's
// software surface those pieces form the tab background while the text
// remains the foreground. Our retained renderer submits parent and child
// sprites into one painter-ordered batch, so submit only the glyph content
// in the parent's foreground pass after those chrome children.
_drawTextAfterChildren = false;
foreach (UiStateInfo state in info.States.Values)
{
if (!state.PassToChildren) continue;
_drawTextAfterChildren = true;
break;
}
_activeRetailStateId = info.EffectiveDefaultStateId();
ApplyDatState(_activeRetailStateId, propagate: false);
}
internal bool DrawTextAfterChildren => _drawTextAfterChildren;
public bool TrySetRetailState(uint stateId)
=> ApplyDatState(stateId, propagate: true);
private bool ApplyDatState(uint stateId, bool propagate)
{
if (_datInfo is null) return false;
UiStateInfo? state = null;
string stateName;
if (stateId == UiStateInfo.DirectStateId)
{
if (!_datInfo.States.TryGetValue(stateId, out state)
&& !_datInfo.StateMedia.ContainsKey(""))
return false;
stateName = "";
}
else if (_datInfo.States.TryGetValue(stateId, out state))
{
stateName = state.Name;
}
else
{
stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (string.IsNullOrEmpty(stateName)
|| !_datInfo.StateMedia.ContainsKey(stateName))
return false;
}
_activeRetailStateId = stateId;
_activeDatStateName = stateName;
BackgroundSprite = _datInfo.StateMedia.TryGetValue(stateName, out var media)
? media.File
: _datInfo.StateMedia.TryGetValue("", out var direct) ? direct.File : 0u;
if (_datInfo.TryGetEffectiveProperty(0x1Bu, out UiPropertyValue color, stateId)
&& TryColor(color, out Vector4 resolvedColor))
DefaultColor = resolvedColor;
if (propagate && state?.PassToChildren == true)
{
foreach (UiElement child in Children)
if (child is IUiDatStateful stateful)
stateful.TrySetRetailState(stateId);
}
return true;
}
private static bool TryColor(UiPropertyValue property, out Vector4 color)
{
UiPropertyValue? value = property.Kind == UiPropertyKind.Color
? property
: property.Kind == UiPropertyKind.Array
&& property.ArrayValue.Count > 0
&& property.ArrayValue[0].Kind == UiPropertyKind.Color
? property.ArrayValue[0]
: null;
if (value is null)
{
color = default;
return false;
}
UiColorValue c = value.ColorValue;
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
color = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
return true;
}
/// <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);
if (!_drawTextAfterChildren)
DrawText(ctx);
}
protected override void OnDrawAfterChildren(UiRenderContext ctx)
{
if (_drawTextAfterChildren)
DrawText(ctx);
}
private void DrawText(UiRenderContext ctx)
{
// Retail UIElement_Text::DrawSelf @ 0x00467AA0 receives the text element's
// visible surface as arg3 and clips each glyph blit to that rectangle. This is
// observable in LayoutDesc 0x21000033: the owned component count is a 15px-high
// text element using a 16px DAT font, so rejecting a partially visible line makes
// the value disappear entirely. The shared render context clips both DAT and
// bitmap glyph quads and composes this bound with any list/window ancestor clip.
ctx.PushClip(0f, 0f, Width, Height);
try
{
DrawClippedText(ctx);
}
finally
{
ctx.PopClip();
}
}
private void DrawClippedText(UiRenderContext ctx)
{
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
// line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula
// UIElement_Meter used for its label, then skip the scroll/selection machinery entirely.
if (OneLine && Centered)
{
var cLines = LinesProvider();
if (cLines.Count == 0) return;
var line0 = cLines[0];
if (DatFont is { } cdf)
{
float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f;
float cy = VOffset(Height, cdf.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color);
}
else if ((Font ?? ctx.DefaultFont) is { } cbf)
{
float cx = (Width - cbf.MeasureWidth(line0.Text)) * 0.5f;
float cy = VOffset(Height, cbf.LineHeight, Padding, VerticalJustify);
ctx.DrawString(line0.Text, cx, cy, line0.Color, cbf);
}
return;
}
// Static right-aligned single-line mode: draw the first line flush with the right
// edge, vertical position per VerticalJustify, then skip the scroll/selection machinery.
if (OneLine && RightAligned)
{
var rLines = LinesProvider();
if (rLines.Count == 0) return;
var line0 = rLines[0];
if (DatFont is { } rdf)
{
float rx = Width - rdf.MeasureWidth(line0.Text) - Padding;
float ry = VOffset(Height, rdf.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color);
}
else if ((Font ?? ctx.DefaultFont) is { } rbf)
{
float rx = Width - rbf.MeasureWidth(line0.Text) - Padding;
float ry = VOffset(Height, rbf.LineHeight, Padding, VerticalJustify);
ctx.DrawString(line0.Text, rx, ry, line0.Color, rbf);
}
return;
}
// One-line is an independent text-layout property. Left justification uses
// the same vertical policy without turning alignment into a line-count flag.
if (OneLine)
{
var singleLines = LinesProvider();
if (singleLines.Count == 0) return;
var line0 = singleLines[0];
if (DatFont is { } datSingle)
{
float y = VOffset(Height, datSingle.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(datSingle, line0.Text, Padding, y, line0.Color);
}
else if ((Font ?? ctx.DefaultFont) is { } bitmapSingle)
{
float y = VOffset(Height, bitmapSingle.LineHeight, Padding, VerticalJustify);
ctx.DrawString(line0.Text, Padding, y, line0.Color, bitmapSingle);
}
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;
_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.SetExtents(
(int)MathF.Ceiling(contentH),
(int)MathF.Floor(innerH),
preserveEnd: PreserveEndOnLayout);
// Overflow keeps the UiScrollable convention: ScrollY=0 is TOP/oldest and
// ScrollY=MaxScroll is BOTTOM/newest. Fitting DAT-authored content instead
// uses retail CalcJustification (tabs center their one glyph line); synthesized
// transcript widgets retain the established bottom pin.
float baseY = ContentBaseY(
top,
bottom,
contentH,
Scroll.MaxScroll,
Scroll.ScrollY,
VerticalJustify,
_honorDatVerticalJustification);
_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 (!LineIntersectsViewport(y, lh, top, bottom)) continue;
string text = lines[i].Text;
float lineX = HorizontalOffset(text, datFont, bitmapFont);
// 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 = lineX + datFont.MeasureWidth(text.Substring(0, c0));
hw = datFont.MeasureWidth(text.Substring(c0, c1 - c0));
}
else
{
hx = lineX + 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, lineX, y, lines[i].Color);
else
ctx.DrawString(text, lineX, y, lines[i].Color, bitmapFont);
}
}
/// <summary>
/// True when any vertical portion of a line intersects a text viewport. Retail
/// clips the glyphs at the viewport edge; it does not require the full line box to fit.
/// </summary>
internal static bool LineIntersectsViewport(
float lineTop,
float lineHeight,
float viewportTop,
float viewportBottom)
=> lineHeight > 0f
&& lineTop < viewportBottom
&& lineTop + lineHeight > viewportTop;
private float HorizontalOffset(string text, UiDatFont? datFont, BitmapFont? bitmapFont)
{
float width = datFont is not null
? datFont.MeasureWidth(text)
: bitmapFont?.MeasureWidth(text) ?? 0f;
if (Centered)
return Math.Max(Padding, (Width - width) * 0.5f);
if (RightAligned)
return Math.Max(Padding, Width - Padding - width);
return Padding;
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && OnClick is not null)
{
OnClick();
return true;
}
switch (e.Type)
{
case UiEventType.Scroll:
{
if (!Selectable && !WheelScrollEnabled) return false;
// 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));
return true;
}
case UiEventType.MouseDown:
{
if (!Selectable) return false;
// 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 (!Selectable) return false;
if (_selecting)
{
// Data1/Data2 = local-to-target coords (DispatchMouseMove).
_selCaret = HitChar(e.Data1, e.Data2);
return true;
}
return false;
}
case UiEventType.MouseUp:
{
if (!Selectable) return false;
_selecting = false;
return true;
}
case UiEventType.KeyDown:
{
if (!Selectable) return false;
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>
/// Compute the Y offset (local space) for a single line in the Centered/RightAligned
/// single-line path, given the element height, font line-height, padding, and
/// vertical justification.
/// </summary>
/// <param name="height">Element height in pixels.</param>
/// <param name="lineHeight">Font line height in pixels.</param>
/// <param name="padding">Content padding.</param>
/// <param name="vj">Vertical justification.</param>
public static float VOffset(float height, float lineHeight, float padding, VJustify vj)
=> vj switch
{
VJustify.Top => padding,
VJustify.Bottom => height - lineHeight - padding,
_ => (height - lineHeight) * 0.5f, // Center (default)
};
/// <summary>
/// Resolve the first line's Y coordinate for the normal multi-line path.
/// Retail <c>UIElement_Text::CalcJustification @ 0x00467260</c> applies the
/// authored vertical justification when the text content fits the surface;
/// overflow continues to use the scroll offset. Synthesized transcript widgets
/// retain the historical bottom-pinned behavior by passing
/// <paramref name="honorJustification"/> as <see langword="false"/>.
/// </summary>
public static float ContentBaseY(
float top,
float bottom,
float contentHeight,
float maxScroll,
float scrollY,
VJustify justification,
bool honorJustification)
{
float viewHeight = Math.Max(0f, bottom - top);
if (!honorJustification || contentHeight > viewHeight)
return bottom - contentHeight + (maxScroll - scrollY);
return justification switch
{
VJustify.Top => top,
VJustify.Bottom => bottom - contentHeight,
_ => top + (viewHeight - contentHeight) * 0.5f,
};
}
/// <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;
float lineX = HorizontalOffset(text, _lastDatFont, _lastFont);
int col = _lastDatFont is { } df
? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f,
localX - lineX)
: (_lastFont is { } bf
? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f,
localX - lineX)
: 0);
return new Pos(line, col);
}
/// <summary>Word-wrap text to a measured pixel width, preserving explicit newlines.</summary>
public static IReadOnlyList<string> WrapWords(
string text,
Func<string, float> measureWidth,
float maximumWidth)
{
ArgumentNullException.ThrowIfNull(text);
ArgumentNullException.ThrowIfNull(measureWidth);
if (maximumWidth <= 0f) throw new ArgumentOutOfRangeException(nameof(maximumWidth));
var result = new List<string>();
string[] paragraphs = text.Replace("\r", string.Empty).Split('\n');
foreach (string paragraph in paragraphs)
{
if (paragraph.Length == 0)
{
result.Add(string.Empty);
continue;
}
string[] words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var line = new StringBuilder();
foreach (string word in words)
{
string candidate = line.Length == 0 ? word : $"{line} {word}";
if (measureWidth(candidate) <= maximumWidth)
{
if (line.Length != 0) line.Append(' ');
line.Append(word);
continue;
}
if (line.Length != 0 && measureWidth(word) <= maximumWidth)
{
result.Add(line.ToString());
line.Clear();
line.Append(word);
continue;
}
// Retail GlyphList wrapping can split an over-width glyph run.
// Pack as much of the long token as possible onto the current
// line, then continue at character boundaries without hyphens.
for (int i = 0; i < word.Length; i++)
{
string prefix = i == 0 && line.Length != 0 ? " " : string.Empty;
if (line.Length != 0
&& measureWidth(line + prefix + word[i]) > maximumWidth)
{
result.Add(line.ToString());
line.Clear();
prefix = string.Empty;
}
line.Append(prefix).Append(word[i]);
}
}
result.Add(line.ToString());
}
return result;
}
/// <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
}
}