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; /// /// Scrollable text view for retail UIElement_Text elements /// (RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655). /// Renders the lines from bottom-pinned (newest at the bottom, /// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps /// text inside the window. /// /// /// 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. /// /// public sealed class UiText : UiElement, IUiDatStateful { /// Optional base-element click notice used by authored text tabs. /// Assigning a handler also clears — /// 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 says /// (the 2026-08-13 social gate's unclickable fellowship roster rows, /// probe-proven in ProbeSocialClickRouting). 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; /// Dat element id for imported UIElement_Text widgets. 0 for synthesized text. public uint ElementId { get; set; } /// One display line: pre-formatted text + its colour. public readonly record struct Line(string Text, Vector4 Color); /// /// One inline fragment in a retail AppendTextWithFont line. /// public readonly record struct TextRun(string Text, Vector4 Color); /// 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). public readonly record struct Pos(int Line, int Col); /// Provider of the lines to show, oldest-first. Polled each frame. public Func> LinesProvider { get; set; } = static () => Array.Empty(); /// /// 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. /// public Func>? RunsProvider { get; set; } /// Font for the transcript; falls back to the context default. public BitmapFont? Font { get; set; } /// 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. public UiDatFont? DatFont { get; set; } /// Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by /// the host from . public Silk.NET.Input.IKeyboard? Keyboard { get; set; } /// /// Default line color used by controllers when they do not supply a per-line /// color explicitly. Set by DatWidgetFactory.BuildText /// from ElementInfo.FontColor when the dat carries a 0x1B ColorBaseProperty; /// otherwise white (). /// /// Controllers that supply a per-line color via /// (e.g. new UiText.Line(text, explicitColor)) 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. /// public Vector4 DefaultColor { get; set; } = Vector4.One; /// /// Authored UIElement_Text font-color list from LayoutDesc property /// 0x1B. Retail AppendTextWithFont @ 0x00469D70 selects an /// entry by index for every appended fragment. Controllers that port that /// API use this palette instead of hard-coded colors. /// public IReadOnlyList FontColorPalette { get; set; } = Array.Empty(); /// 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. ChatWindowController). public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); /// /// Retail LayoutDesc property 0x21 (UIElement_Text::SetOutline /// @0x0046a81c, m_bitField & 0x10). When true, every dat-font draw /// on this element runs retail's two-pass outline+fill model /// (). Default false, matching the /// ctor bitfield (0x300) which clears the outline bit — outlining is /// opt-in per element. Set by /// from for DAT-imported /// text, or directly by a synthesized controller (e.g. the SpewBox). /// public bool Outline { get; set; } /// /// Retail LayoutDesc property 0x22 (m_curOutlineColor). Only /// meaningful when is true. Default black, matching the /// ctor default (RGBAColor_Black, /// UIElement_Text::UIElement_Text @0x004686cb) — only 9 elements in the /// whole DAT set author a non-black outline color. /// public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor; /// Optional dat state-sprite background (the element's own media), drawn /// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none. public uint BackgroundSprite { get; set; } /// Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height). /// Required when is non-zero. public Func? SpriteResolve { get; set; } /// Highlight colour painted behind a selected character span. public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); /// /// Uniform inner text inset in pixels. The retail default is zero: /// UIElement_Text::UIElement_Text @ 0x00468570 clears all four margins /// before applying any authored margin properties. /// public float Padding { get; set; } /// /// Campaign CC gate round 1 Batch E (R2-1): the four independent retail /// text-inset margins (dat properties 0x23/0x24/0x25/ /// 0x26's own doc /// comment has the full decomp citation). Additive with /// (every existing controller that sets /// explicitly keeps behaving identically, since /// these four default to 0 unless /// seeds them from the DAT). Applied ONLY to the scrollable multi-line /// path ( == 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 ) to keep this /// fix's blast radius to the mechanism that actually regressed. /// public float MarginLeft { get; set; } public float MarginRight { get; set; } public float MarginTop { get; set; } public float MarginBottom { get; set; } /// Retail property 0x20. Independent of horizontal/vertical /// justification; false permits the normal multi-line layout path. public bool OneLine { get; set; } private bool _selectable; /// /// 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. /// 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; } } } /// Static centered single-line mode (retail UIElement_Text 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 /// 's former number overlay so those numbers stay pixel-identical /// after the rewire. Pair with ClickThrough = true for non-interactive labels. public bool Centered { get; set; } /// 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 — if both are true, Centered takes /// precedence. Pair with ClickThrough = true for non-interactive labels. public bool RightAligned { get; set; } /// /// Vertical position of the text within the element rect in single-line mode /// ( or ). /// /// Center (default) — vertically centered, matching the original /// behavior of the centered/right-aligned paths. /// Top — text is placed at y = Padding (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. /// Bottom — text is placed at y = Height - lineHeight - Padding. /// /// Only meaningful when or is true. /// Has no effect on the scrollable multi-line path. /// public VJustify VerticalJustify { get; set; } = VJustify.Center; /// /// Opts the SCROLLABLE multi-line path (i.e. == /// ) into without /// requiring a full LayoutDesc binding. /// sets the equivalent internal flag /// (_honorDatVerticalJustification) 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 /// (docs/plans/2026-08-09-chat-parity-campaign.md): added for /// SpewBoxController's top-aligned, newest-line-on-top flow. /// public bool HonorVerticalJustification { get; set; } /// The scroll model — also read by the linked UiScrollbar. public UiScrollable Scroll { get; } = new(); /// /// 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. /// public bool PreserveEndOnLayout { get; set; } = true; /// /// 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. /// 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 _lastLines = Array.Empty(); 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; } /// /// Text widgets normally reproduce their own scroll/edit chrome. Retail tab text, /// however, uses PassToChildren states whose child elements are the authored /// Open/Closed cap sprites. Those children must remain in the retained tree so /// can propagate the state exactly as retail does. /// 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? _authoredStateStrings; /// Per-state authored strings (dat property 0x17 on the /// element's OWN states, resolved at build by DatWidgetFactory). /// swaps the displayed line when the /// incoming state authors one — retail's state-cascade text swap. internal void SetAuthoredStateStrings(Dictionary 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; } /// /// 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. /// 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 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); } } } /// /// 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. /// 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); } /// /// 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 /// have their own inline math and are /// deliberately left on bare (see /// 's own doc comment). Here, both /// 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 /// / above. /// 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 ──────────────────────────────────────────────── /// Select the entire cached transcript (Ctrl+A). 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); } /// Normalise (anchor, caret) into ordered (start, end). False if no /// selection or it is empty (anchor == caret). 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); } /// The currently-selected text against the cached lines. Empty when /// nothing is selected. 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) ─────────────────── /// /// 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. /// /// Element height in pixels. /// Font line height in pixels. /// Content padding. /// Vertical justification. 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) }; /// /// Resolve the first line's Y coordinate for the normal multi-line path. /// Retail UIElement_Text::CalcJustification @ 0x00467260 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 /// as . /// 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, }; } /// Order two caret positions so the first is <= the second (by line, /// then column). 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); } /// /// Assemble the selected substring spanning .. /// (inclusive of start.Col, exclusive of end.Col) from /// . 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. /// public static string SelectedText(IReadOnlyList 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(); } /// /// Convert a local-space point to a caret against the cached /// layout from the last draw. line = floor((localY - baseY)/lineHeight) clamped /// to the line range; col via . /// 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); } /// Word-wrap text to a measured pixel width, preserving explicit newlines. public static IReadOnlyList WrapWords( string text, Func measureWidth, float maximumWidth) { ArgumentNullException.ThrowIfNull(text); ArgumentNullException.ThrowIfNull(measureWidth); if (maximumWidth <= 0f) throw new ArgumentOutOfRangeException(nameof(maximumWidth)); var result = new List(); 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; } /// /// The caret column for a horizontal position (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 falls on — natural /// Windows-like caret placement. Pure — unit-testable with a synthetic advance. /// /// The line text. /// Per-character advance (pixels) lookup. /// Horizontal position relative to the text's left edge. public static int CharIndexAt(string text, Func 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 } }