R2-1/R2-6 (description-box text clipped left of the frame, regressed from
Batch C's frame un-consume): root cause was never the un-consume change
itself — the Heritage/Profession/Town/Summary description boxes
(0x100003C4/0x100003E0/0x10000409/0x10000404) all author retail's four
independent text-inset margins (dat properties 0x23-0x26,
UIElement_Text::OnSetAttribute cases 0xf-0x12: margL=9/margR=26/margU=15/
margD=15), which this codebase never read at all, before or after Batch C.
Un-consuming the gold-frame children just made the pre-existing missing-
margin bug visible for the first time (the frame's own left border now
draws around the same x=0 origin text always used). Fixed end to end:
ElementInfo.MarginLeft/Right/Top/Bottom (read in
ApplyCanonicalLegacyProjection, propagated in Merge), UiText.MarginLeft/
Right/Top/Bottom (additive with the pre-existing Padding), a new pure
UiText.ContentOffsetX static consumed by the multi-line draw path's
per-line placement, and matching wrap-width shrinkage in
DatRichText.Compose and BuildText's own authored-multiline path. Scoped to
the multi-line (non-OneLine) path only.
R2-2/R2-3 (Attribute\n Credits renders the literal backslash-n; the live
credit value overlaps mid-caption): two stacked gaps. (1) UiButton
captions never escape-normalized the DAT's literal "\n" — centralized the
normalize into DatWidgetFactory's ResolveAuthoredString (the one choke
point every P0x17 resolution already shares) plus a NormalizeEscapes
helper for the per-state caption loop, so every caller normalizes
identically. (2) UiButton.Label only ever drew one line — retail's
UIElement_Button IS a UIElement_Text with OneLine=false on these buttons,
so a caption should word-wrap/stack like any other Type-12 box. Added
UiButton.DrawBlockLabel + the pure, unit-tested WrapBlockLines. The
value-overlap itself: ValueBox was never wrong (live-DAT-measured correct
child rects) — the caption was drawing unconfined across the button's
full width ("Available Skill Credits" measures 193px in a 231px button
whose value box starts at x=116). Fixed by confining the caption's own
drawable width to stop before ValueBox.X whenever a ValueLabel coexists.
R2-7a (Summary overview listbox missing its scrollbar): pure wiring gap —
the listbox authors a linked scrollbar via dat property 0x72
(ScrollbarElementId=0x10000401) that CharacterCreationSummaryPage's
constructor never resolved, unlike every other UiTemplateListBox owner in
the codebase. Fixed with the same resolve-and-wire pattern.
R2-7b (how-to box scrollbar overlaps text, no thumb): traced to a
downstream symptom of R2-1, not an independent bug — UiScrollbar only
paints its thumb when the linked model has overflow, and the pre-fix wrap
width (un-inset) produced fewer/shorter lines than fit the view. Pinned
directly against the real installed strings/font (Aluvian's how-to text)
that the margin-correct width overflows. No UiScrollbar code changed.
R2-8 (name field should show "[ Name ]"): re-checked the one hypothesis
Batch A's GF-15 closure left open — an authored initial-text string on
the field's own P0x17. Confirmed absent on every state in the installed
DAT. No code change; Batch A's closure stands, now pinned as a live-DAT
regression test.
App suite 5334/3 (was 5321/3, +13, zero regressions). Runtime 1735/0
unchanged. Full solution Release build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1123 lines
47 KiB
C#
1123 lines
47 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.
|
|
/// Assigning a handler also clears <see cref="UiElement.ClickThrough"/> —
|
|
/// display text is click-through by default (the class doc's contract),
|
|
/// which otherwise makes the handler unreachable: the hit-test walk skips
|
|
/// click-through elements no matter what <see cref="HandlesClick"/> says
|
|
/// (the 2026-08-13 social gate's unclickable fellowship roster rows,
|
|
/// probe-proven in <c>ProbeSocialClickRouting</c>).</summary>
|
|
public Action? OnClick
|
|
{
|
|
get => _onClick;
|
|
set
|
|
{
|
|
_onClick = value;
|
|
if (value is not null)
|
|
ClickThrough = false;
|
|
}
|
|
}
|
|
|
|
private Action? _onClick;
|
|
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>
|
|
/// One inline fragment in a retail <c>AppendTextWithFont</c> line.
|
|
/// </summary>
|
|
public readonly record struct TextRun(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>
|
|
/// Optional inline fragments for a static one-line element. When present
|
|
/// this reproduces retail's per-append font-state colors while preserving
|
|
/// the element's authored alignment as one composed line.
|
|
/// </summary>
|
|
public Func<IReadOnlyList<TextRun>>? RunsProvider { get; set; }
|
|
|
|
/// <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>
|
|
/// Authored <c>UIElement_Text</c> font-color list from LayoutDesc property
|
|
/// <c>0x1B</c>. Retail <c>AppendTextWithFont @ 0x00469D70</c> selects an
|
|
/// entry by index for every appended fragment. Controllers that port that
|
|
/// API use this palette instead of hard-coded colors.
|
|
/// </summary>
|
|
public IReadOnlyList<Vector4> FontColorPalette { get; set; }
|
|
= Array.Empty<Vector4>();
|
|
|
|
/// <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>
|
|
/// Retail LayoutDesc property <c>0x21</c> (<c>UIElement_Text::SetOutline
|
|
/// @0x0046a81c</c>, <c>m_bitField & 0x10</c>). When true, every dat-font draw
|
|
/// on this element runs retail's two-pass outline+fill model
|
|
/// (<see cref="UiRenderContext.DrawStringDat"/>). Default false, matching the
|
|
/// ctor bitfield (<c>0x300</c>) which clears the outline bit — outlining is
|
|
/// opt-in per element. Set by <see cref="AcDream.App.UI.Layout.DatWidgetFactory"/>
|
|
/// from <see cref="AcDream.App.UI.Layout.ElementInfo.Outline"/> for DAT-imported
|
|
/// text, or directly by a synthesized controller (e.g. the SpewBox).
|
|
/// </summary>
|
|
public bool Outline { get; set; }
|
|
|
|
/// <summary>
|
|
/// Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>). Only
|
|
/// meaningful when <see cref="Outline"/> is true. Default black, matching the
|
|
/// ctor default (<c>RGBAColor_Black</c>,
|
|
/// <c>UIElement_Text::UIElement_Text @0x004686cb</c>) — only 9 elements in the
|
|
/// whole DAT set author a non-black outline color.
|
|
/// </summary>
|
|
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
|
|
|
|
/// <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>
|
|
/// Campaign CC gate round 1 Batch E (R2-1): the four independent retail
|
|
/// text-inset margins (dat properties <c>0x23</c>/<c>0x24</c>/<c>0x25</c>/
|
|
/// <c>0x26</c> — <see cref="Layout.ElementInfo.MarginLeft"/>'s own doc
|
|
/// comment has the full decomp citation). Additive with
|
|
/// <see cref="Padding"/> (every existing controller that sets
|
|
/// <see cref="Padding"/> explicitly keeps behaving identically, since
|
|
/// these four default to 0 unless <see cref="Layout.DatWidgetFactory"/>
|
|
/// seeds them from the DAT). Applied ONLY to the scrollable multi-line
|
|
/// path (<see cref="OneLine"/> == false) — the chargen description boxes
|
|
/// that regressed in Batch C are all multi-line, and every authored
|
|
/// nonzero-margin box measured against the installed DAT so far is also
|
|
/// multi-line. The static Centered/RightAligned/OneLine single-line
|
|
/// paths are unchanged (still bare <see cref="Padding"/>) to keep this
|
|
/// fix's blast radius to the mechanism that actually regressed.
|
|
/// </summary>
|
|
public float MarginLeft { get; set; }
|
|
public float MarginRight { get; set; }
|
|
public float MarginTop { get; set; }
|
|
public float MarginBottom { 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>
|
|
/// Opts the SCROLLABLE multi-line path (i.e. <see cref="OneLine"/> ==
|
|
/// <see langword="false"/>) into <see cref="VerticalJustify"/> without
|
|
/// requiring a full <see cref="ConfigureDatState"/> LayoutDesc binding.
|
|
/// <see cref="ConfigureDatState"/> sets the equivalent internal flag
|
|
/// (<c>_honorDatVerticalJustification</c>) for DAT-imported text such as
|
|
/// spellbook tabs; synthesized (non-DAT) controllers that still want
|
|
/// top/bottom/center content flow instead of the historical bottom-
|
|
/// pinned transcript behavior set this directly. CH2 re-review nit 2
|
|
/// (<c>docs/plans/2026-08-09-chat-parity-campaign.md</c>): added for
|
|
/// <c>SpewBoxController</c>'s top-aligned, newest-line-on-top flow.
|
|
/// </summary>
|
|
public bool HonorVerticalJustification { get; set; }
|
|
|
|
/// <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;
|
|
|
|
private Dictionary<uint, string>? _authoredStateStrings;
|
|
|
|
/// <summary>Per-state authored strings (dat property 0x17 on the
|
|
/// element's OWN states, resolved at build by DatWidgetFactory).
|
|
/// <see cref="TrySetRetailState"/> swaps the displayed line when the
|
|
/// incoming state authors one — retail's state-cascade text swap.</summary>
|
|
internal void SetAuthoredStateStrings(Dictionary<uint, string> strings)
|
|
=> _authoredStateStrings = strings;
|
|
|
|
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;
|
|
|
|
// Retail's state cascade also swaps the element's AUTHORED string
|
|
// when the incoming state carries its own 0x17 (the friends row's
|
|
// status cell: 'Online'/'Offline' with per-state colors). Resolved
|
|
// at build by DatWidgetFactory; single-line — no per-state multiline
|
|
// template exists in the authored set today. DefaultColor is read
|
|
// per call so THIS state's 0x1B (applied just above) colors it.
|
|
if (_authoredStateStrings is { } stateStrings
|
|
&& stateStrings.TryGetValue(stateId, out string? authoredLine))
|
|
{
|
|
LinesProvider = () => [new Line(authoredLine, DefaultColor)];
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (OneLine && RunsProvider is { } runsProvider)
|
|
{
|
|
DrawSingleLineRuns(ctx, runsProvider());
|
|
return;
|
|
}
|
|
|
|
// 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, Outline, OutlineColor);
|
|
}
|
|
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, Outline, OutlineColor);
|
|
}
|
|
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, Outline, OutlineColor);
|
|
}
|
|
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;
|
|
// R2-1: the multi-line viewport insets by BOTH Padding (the pre-
|
|
// existing uniform inset controllers already set) AND the four
|
|
// retail-authored margins (additive — see MarginTop's own doc).
|
|
float top = Padding + MarginTop, bottom = Height - Padding - MarginBottom;
|
|
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 || HonorVerticalJustification);
|
|
_lastBaseY = baseY;
|
|
|
|
// Normalised selection span (start <= end), if any.
|
|
bool hasSel = TryGetOrderedSelection(out Pos selStart, out Pos selEnd);
|
|
|
|
// Gather each visible line's draw geometry first, rather than drawing text
|
|
// inline in this loop. Retail's UIElement_Text::DrawSelf @0x00467aa0 walks
|
|
// EVERY glyph of the whole BLOCK in the outline pass before any fill; calling
|
|
// DrawStringDat once per line here (outline+fill, outline+fill, ...) would let
|
|
// line N+1's outline draw AFTER line N's fill and notch a descender that pokes
|
|
// up into the line above (round-5 review S1). The DAT-font branch below instead
|
|
// submits every visible line's outline pass, THEN every visible line's fill
|
|
// pass. The bitmap-font branch has no outline concept and draws inline as before.
|
|
List<(string Text, float X, float Y, Vector4 Color)>? datLines = null;
|
|
|
|
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 text (still true: this happens before either
|
|
// pass below runs for ANY line).
|
|
ctx.DrawFill(hx, y, hw, lh, SelectionColor);
|
|
}
|
|
}
|
|
|
|
if (datFont is not null)
|
|
{
|
|
(datLines ??= new()).Add((text, lineX, y, lines[i].Color));
|
|
}
|
|
else
|
|
{
|
|
ctx.DrawString(text, lineX, y, lines[i].Color, bitmapFont);
|
|
}
|
|
}
|
|
|
|
if (datLines is not null)
|
|
{
|
|
// Outline-OFF stays a single fill-only pass per line — byte-identical to
|
|
// the pre-S1 per-line DrawStringDat(outline:false) submission order.
|
|
if (Outline)
|
|
foreach (var line in datLines)
|
|
ctx.DrawStringDatPass(datFont!, line.Text, line.X, line.Y, OutlineColor, isOutlinePass: true);
|
|
foreach (var line in datLines)
|
|
ctx.DrawStringDatPass(datFont!, line.Text, line.X, line.Y, line.Color, isOutlinePass: false);
|
|
}
|
|
}
|
|
|
|
private void DrawSingleLineRuns(
|
|
UiRenderContext ctx,
|
|
IReadOnlyList<TextRun> runs)
|
|
{
|
|
if (runs.Count == 0) return;
|
|
|
|
UiDatFont? datFont = DatFont;
|
|
BitmapFont? bitmapFont = datFont is null
|
|
? Font ?? ctx.DefaultFont
|
|
: null;
|
|
if (datFont is null && bitmapFont is null) return;
|
|
|
|
float totalWidth = 0f;
|
|
foreach (TextRun run in runs)
|
|
{
|
|
totalWidth += datFont is not null
|
|
? datFont.MeasureWidth(run.Text)
|
|
: bitmapFont!.MeasureWidth(run.Text);
|
|
}
|
|
|
|
float x = Centered
|
|
? Math.Max(Padding, (Width - totalWidth) * 0.5f)
|
|
: RightAligned
|
|
? Math.Max(Padding, Width - Padding - totalWidth)
|
|
: Padding;
|
|
float lineHeight = datFont?.LineHeight ?? bitmapFont!.LineHeight;
|
|
float y = VOffset(
|
|
Height,
|
|
lineHeight,
|
|
Padding,
|
|
VerticalJustify);
|
|
|
|
if (datFont is not null)
|
|
{
|
|
// Same BLOCK-level outline-then-fill batching as the multi-line path above
|
|
// (round-5 review S1) — several colored runs on ONE line share one Outline
|
|
// flag, and retail's outline pass covers the whole block before any fill.
|
|
var runGeometry = new List<(string Text, float X, Vector4 Color)>();
|
|
float penX = x;
|
|
foreach (TextRun run in runs)
|
|
{
|
|
if (run.Text.Length == 0) continue;
|
|
runGeometry.Add((run.Text, penX, run.Color));
|
|
penX += datFont.MeasureWidth(run.Text);
|
|
}
|
|
|
|
if (Outline)
|
|
foreach (var run in runGeometry)
|
|
ctx.DrawStringDatPass(datFont, run.Text, run.X, y, OutlineColor, isOutlinePass: true);
|
|
foreach (var run in runGeometry)
|
|
ctx.DrawStringDatPass(datFont, run.Text, run.X, y, run.Color, isOutlinePass: false);
|
|
}
|
|
else
|
|
{
|
|
foreach (TextRun run in runs)
|
|
{
|
|
if (run.Text.Length == 0) continue;
|
|
ctx.DrawString(run.Text, x, y, run.Color, bitmapFont);
|
|
x += bitmapFont!.MeasureWidth(run.Text);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
return ContentOffsetX(Width, Padding, MarginLeft, MarginRight, width, Centered, RightAligned);
|
|
}
|
|
|
|
/// <summary>
|
|
/// R2-1 (Campaign CC gate round 1 Batch E): pure per-line horizontal
|
|
/// placement for the MULTI-LINE (scrollable) path — the static
|
|
/// single-line Centered/RightAligned/OneLine branches in
|
|
/// <see cref="DrawClippedText"/> have their own inline math and are
|
|
/// deliberately left on bare <see cref="Padding"/> (see
|
|
/// <see cref="MarginLeft"/>'s own doc comment). Here, both
|
|
/// <see cref="Padding"/> and the four retail margins inset the content
|
|
/// box a line lays out within. Pure/static so it is unit-testable
|
|
/// without a font or draw context — the same shape as
|
|
/// <see cref="VOffset"/>/<see cref="ContentBaseY"/> above.
|
|
/// </summary>
|
|
public static float ContentOffsetX(
|
|
float elementWidth,
|
|
float padding,
|
|
float marginLeft,
|
|
float marginRight,
|
|
float lineWidth,
|
|
bool centered,
|
|
bool rightAligned)
|
|
{
|
|
float contentLeft = padding + marginLeft;
|
|
float contentRight = elementWidth - padding - marginRight;
|
|
if (centered)
|
|
return Math.Max(contentLeft, contentLeft + (contentRight - contentLeft - lineWidth) * 0.5f);
|
|
if (rightAligned)
|
|
return Math.Max(contentLeft, contentRight - lineWidth);
|
|
return contentLeft;
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|