acdream/src/AcDream.App/UI/UiField.cs
Erik ceec3bc440 feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache onto IGpuDevice
TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.

Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.

Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):

- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
  The cache assumes it is the sole writer of GL program/blend/depth/cull
  state, which was true while it had zero real consumers, but every
  still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
  mutates that same GL state directly and never informs the cache. Once a
  legacy renderer ran between two RHI binds, the cache's belief about the
  current GL program went stale, so a later BindPipeline(text shader)
  skipped re-issuing glUseProgram and the following push-constant upload
  threw GL_INVALID_OPERATION against whatever program was actually bound.
  Reset() at the frame boundary is the same defensive move BeginPass
  already makes after a forced clear (see its comment); it costs one
  redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
  GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
  computed from GpuPipelineDescription.SampleCount at BindPipeline time -
  mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
  toggle.

Collateral, scoped to keep the port real rather than a stub:

- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
  every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
  TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
  Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
  every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
  check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
  slot (the device's default white texture), so the old sentinel would
  have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
  public AcDream.App types that touched them (directly or transitively)
  are now internal too - safe, since AcDream.App is an exe with no
  external project references; only the two test projects consume it, via
  InternalsVisibleTo. A handful of unrelated types the sweep caught
  (ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
  as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
  were reverted back to public where making them internal would have
  either cascaded into unrelated files or broken xUnit's public-member
  discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
  color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
  produce (V4g's scope) into the device's texture table for
  UiViewport.TextureHandle, via a temporary
  GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
  part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
  now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
  conformance tests keyed to TextRenderer's old multi-resource
  construction shape (Shader + per-flight FrameBufferSet array + white
  texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
  - that shape is gone, replaced by one IGpuPipeline created through
    IGpuDevice. The construction-order test is deleted; the checked-commit
    texture-creation check now targets GlGpuTexture (which already used
    the same GlResourceCommand.CreateName primitive before this slice).

Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
  TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
  skipped (was 3,843/3 entering this slice - net 3 fewer tests:
  TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
  TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
  method). Full solution: 8,908 passed / 5 skipped across all nine test
  projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
  vs this commit): differing fraction 0.318% (1,791/563,200 compared
  pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
  than waved through: a diff heatmap plus 4x crops at the differing
  clusters show zero differences anywhere in the retained UI, terrain,
  scenery, or static meshes - every differing pixel sits on continuously-
  animated ambient content (flying-insect sprites over the swamp, foliage
  sparkle/dew glints) whose exact phase depends on elapsed wall-clock
  time, the same category the gate's own sky-masking rationale already
  documents and the campaign doc's coverage table explicitly excludes
  ("Not covered - particles"). Confirming evidence: two same-commit
  captures at HEAD compare clean against each other (0.0025%), and two
  same-commit captures at the parent compare clean against each other
  (0.0044%) - only base-vs-head is consistently elevated, which is what
  frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
  ring resets, the render-state reset above) would produce against a
  fixed wall-clock capture deadline, not a rendering defect. Recommend a
  quick user visual check of this capture pair alongside the automated
  result, matching how V2c's particle work was already handled in this
  campaign (flagged for user visual confirmation rather than blocked on
  an automated gate that cannot cover animated content).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:22:08 +02:00

773 lines
31 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Generic editable field widget. Port of retail <c>UIElement_Field</c>
/// (<c>RegisterElementClass(3)</c> @ acclient_2013_pseudo_c.txt:126190). Carries
/// retail <c>Field</c>'s drag-drop hooks (<c>CatchDroppedItem</c>/<c>MouseOverTop</c>)
/// as stubs for future item-window use.
///
/// <para>
/// Caret is a glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the
/// caret. Supports mouse + Shift-arrow SELECTION, clipboard cut/copy/paste, and
/// held-key auto-repeat (hold Backspace deletes continuously). Submit (Enter / Send)
/// fires <see cref="OnSubmit"/>, clears, and pushes history (100-entry cap,
/// sentinel 0xFFFFFFFF — port of <c>ChatInterface::ProcessCommand @0x4f5100</c>).
/// </para>
///
/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40.
/// </summary>
internal sealed class UiField : UiElement
{
private readonly record struct WrappedLine(int Start, int Length, string Text);
public uint ElementId { get; set; }
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);
/// <summary>Selected-span highlight (translucent blue, behind the text).</summary>
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
public float Padding { get; set; } = 4f;
public int MaxCharacters { get; set; } = 0xFFFF;
public bool OneLine { get; set; } = true;
public bool Selectable { get; set; }
public bool Centered { get; set; }
public bool RightAligned { get; set; }
public bool ClearOnSubmit { get; set; } = true;
public bool RecordHistory { get; set; } = true;
public Func<char, bool>? CharacterFilter { get; set; }
public bool SelectAllOnFocus { get; set; }
public UiScrollable Scroll { get; } = new();
public Action? OnReadOnlyClick { get; set; }
private bool _editable = true;
/// <summary>
/// Retail property 0x16. Read-only fields remain mouse-visible so their
/// authored background can report why editing is unavailable, but they do
/// not take keyboard focus or accept mutations.
/// </summary>
public bool Editable
{
get => _editable;
set
{
if (_editable == value) return;
_editable = value;
AcceptsFocus = value;
IsEditControl = value;
if (!value && _focused)
FindRoot()?.SetKeyboardFocus(null);
if (!value)
_repeatKey = null;
}
}
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
/// <summary>Dat sprite resolver (id → GL texture + size) for the focused-field
/// background. Null = fall back to the flat <see cref="BackgroundColor"/> rect.</summary>
public Func<uint, (GpuTextureSlot tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Unfocused/default state sprite imported from the DAT.</summary>
public uint BackgroundSprite { get; set; }
/// <summary>Gold "lit" field background drawn when focused (retail Normal_focussed
/// state, RenderSurface 0x060011AB). 0 = no focus sprite.</summary>
public uint FocusFieldSprite { get; set; }
public Action<string>? OnSubmit { get; set; }
public Action? OnFocusGained { get; set; }
public Action<string>? OnFocusLost { get; set; }
private string _text = "";
private int _caret;
private int? _selAnchor; // selection fixed end (null = no selection); span = [min,max] with _caret
public string Text => _text;
public bool IsFocused => _focused;
public int CaretPos => _caret;
private readonly List<string> _history = new();
private int _historyIndex = -1;
public int HistoryCount => _history.Count;
private bool _focused;
private bool _selecting; // mouse drag in progress
private bool _preserveFocusSelectionOnMouseDown;
private float _scrollX; // horizontal pixel scroll so the caret stays in the field
private IReadOnlyList<WrappedLine> _wrappedLines = Array.Empty<WrappedLine>();
private float _wrappedLineHeight = 14f;
private bool _suppressNextNewlineChar;
// Held-key auto-repeat (Silk delivers one KeyDown per physical press).
private Silk.NET.Input.Key? _repeatKey;
private double _repeatTimer;
private const double RepeatDelay = 0.40; // s before the first repeat
private const double RepeatRate = 0.04; // s between repeats (~25/s)
public UiField()
{
AcceptsFocus = true;
IsEditControl = true;
CapturesPointerDrag = true; // interior drag selects, doesn't move the window
}
/// <summary>The field draws its own background + caret + caps; its dat cap sub-elements
/// are reproduced procedurally, so the importer must not build them as widgets.</summary>
public override bool ConsumesDatChildren => true;
// ── Editing primitives ──────────────────────────────────────────────
public void InsertChar(char c)
{
if (!Editable) return;
if (!OneLine && (c == '\r' || c == '\n'))
c = '\n';
else if (c < 0x20 || c == 0x7F)
return;
if (CharacterFilter is not null && !CharacterFilter(c)) return;
DeleteSelection();
if (_text.Length >= MaxCharacters) return;
_text = _text.Insert(_caret, c.ToString());
_caret++;
_historyIndex = -1;
}
public void Backspace()
{
if (!Editable) return;
if (DeleteSelection()) return;
if (_caret == 0) return;
_text = _text.Remove(_caret - 1, 1);
_caret--;
}
public void DeleteForward()
{
if (!Editable) return;
if (DeleteSelection()) return;
if (_caret >= _text.Length) return;
_text = _text.Remove(_caret, 1);
}
private void MoveCaretTo(int target, bool shift)
{
target = Math.Clamp(target, 0, _text.Length);
if (shift) _selAnchor ??= _caret; // begin/extend selection from the old caret
else _selAnchor = null; // plain move collapses any selection
_caret = target;
_historyIndex = -1;
}
/// <summary>Move the caret left (negative) or right (positive) by <paramref name="delta"/>
/// glyph positions without extending a selection. Public for test access.</summary>
public void MoveCaret(int delta) => MoveCaretTo(_caret + delta, false);
private void MoveCaret(int delta, bool shift) => MoveCaretTo(_caret + delta, shift);
// ── Selection ────────────────────────────────────────────────────────
private (int lo, int hi) SelSpan()
{
if (_selAnchor is not { } a || a == _caret) return (_caret, _caret);
return (Math.Min(a, _caret), Math.Max(a, _caret));
}
private bool HasSelection => _selAnchor is { } a && a != _caret;
private string SelectedText()
{
var (lo, hi) = SelSpan();
return hi > lo ? _text.Substring(lo, hi - lo) : "";
}
/// <summary>Remove the selected span (if any). Returns true if it removed anything.</summary>
private bool DeleteSelection()
{
if (!HasSelection) { _selAnchor = null; return false; }
var (lo, hi) = SelSpan();
_text = _text.Remove(lo, hi - lo);
_caret = lo;
_selAnchor = null;
return true;
}
public void SelectAllText()
{
if (_text.Length == 0) { _selAnchor = null; return; }
_selAnchor = 0;
_caret = _text.Length;
}
public void SetText(string? text)
{
_text = text ?? "";
if (_text.Length > MaxCharacters)
_text = _text[..MaxCharacters];
_caret = _text.Length;
_selAnchor = null;
_historyIndex = -1;
}
private void CopySelection()
{
var s = SelectedText();
if (s.Length > 0 && Keyboard is not null) Keyboard.ClipboardText = s;
}
private void CutSelection()
{
if (!Editable) return;
if (!HasSelection) return;
CopySelection();
DeleteSelection();
_historyIndex = -1;
}
private void Paste()
{
if (!Editable) return;
if (Keyboard is null) return;
string clip = Keyboard.ClipboardText ?? "";
if (clip.Length == 0) return;
// Retail one-line fields strip controls. Multi-line fields preserve CR/LF
// as one normalized newline and still reject other controls.
var sb = new System.Text.StringBuilder(clip.Length);
bool previousCr = false;
foreach (char ch in clip)
{
if (!OneLine && (ch == '\r' || ch == '\n'))
{
if (ch == '\n' && previousCr)
{
previousCr = false;
continue;
}
sb.Append('\n');
previousCr = ch == '\r';
continue;
}
previousCr = false;
if (ch >= 0x20 && ch != 0x7F
&& (CharacterFilter is null || CharacterFilter(ch)))
sb.Append(ch);
}
if (sb.Length == 0) return;
DeleteSelection();
int room = MaxCharacters - _text.Length;
if (room <= 0) return;
string ins = sb.Length > room ? sb.ToString(0, room) : sb.ToString();
_text = _text.Insert(_caret, ins);
_caret += ins.Length;
_historyIndex = -1;
}
// ── Submit + history ─────────────────────────────────────────────────
public void Submit()
{
if (!Editable) return;
var t = _text;
if (t.Trim().Length == 0)
{
if (ClearOnSubmit) Clear();
return;
}
OnSubmit?.Invoke(t);
if (RecordHistory) PushHistory(t);
if (ClearOnSubmit) Clear();
}
private void Clear() { _text = ""; _caret = 0; _selAnchor = null; _historyIndex = -1; }
private void PushHistory(string t)
{
_history.Add(t);
if (_history.Count > 100) _history.RemoveAt(0);
_historyIndex = -1;
}
public void HistoryPrev()
{
if (_history.Count == 0) return;
_historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1);
SetTextFromHistory();
}
public void HistoryNext()
{
if (_historyIndex < 0) return;
_historyIndex++;
if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; }
SetTextFromHistory();
}
private void SetTextFromHistory()
{
_text = _history[_historyIndex];
_caret = _text.Length;
_selAnchor = null;
}
// ── Geometry ─────────────────────────────────────────────────────────
/// <summary>Pixel-X of the caret (Σ glyph advances to <paramref name="i"/>).</summary>
private float MeasureTo(int i)
{
if (i <= 0) return 0f;
string s = _text.Substring(0, Math.Min(i, _text.Length));
return DatFont is { } df ? df.MeasureWidth(s)
: Font is { } bf ? bf.MeasureWidth(s) : 0f;
}
public float CaretPixelX() => MeasureTo(_caret);
/// <summary>Map a local X (click) to the nearest caret index — retail
/// FindPixelsFromPos inverse. Accounts for the horizontal scroll offset.</summary>
private int HitCharX(float localX)
{
float target = localX - Padding - TextAlignmentOffset() + _scrollX;
if (target <= 0f) return 0;
int best = 0;
float bestDist = float.MaxValue;
for (int i = 0; i <= _text.Length; i++)
{
float d = MathF.Abs(MeasureTo(i) - target);
if (d < bestDist) { bestDist = d; best = i; }
}
return best;
}
// ── Draw ─────────────────────────────────────────────────────────────
protected override void OnDraw(UiRenderContext ctx)
{
// Focused = "write mode": draw the gold lit field sprite (retail Normal_focussed).
// Unfocused: draw the imported default state, then the flat translucent fallback.
// Both go through the sprite bucket
// (DrawFill / DrawSprite) so the text — also sprite-bucket — draws on top.
bool lit = _focused && SpriteResolve is not null && FocusFieldSprite != 0;
if (lit)
{
var (tex, tw, th) = SpriteResolve!(FocusFieldSprite);
if (tex.IsAssigned && tw > 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
else lit = false;
}
if (!lit && SpriteResolve is not null && BackgroundSprite != 0)
{
var (tex, tw, th) = SpriteResolve(BackgroundSprite);
if (tex.IsAssigned && tw > 0 && th > 0)
{
ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f,
Width / tw, Height / th, Vector4.One);
lit = true;
}
}
if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
if (!OneLine)
{
DrawMultiLine(ctx);
return;
}
float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
float ty = (Height - lh) * 0.5f;
float visibleW = MathF.Max(1f, Width - 2f * Padding);
// Horizontal scroll: keep the caret inside the field; clamp so we never scroll past
// the text. Then draw only the glyph window that lands inside the field — a single-
// line text box clips + scrolls (retail UIElement_Text) rather than overflowing the
// field (which previously spilled the text out into the 3D world).
float caretX = MeasureTo(_caret);
float fullW = MeasureTo(_text.Length);
if (caretX - _scrollX > visibleW) _scrollX = caretX - visibleW;
if (caretX < _scrollX) _scrollX = caretX;
_scrollX = Math.Clamp(_scrollX, 0f, MathF.Max(0f, fullW - visibleW));
float alignX = TextAlignmentOffset();
// Visible character window [start, end).
int start = 0;
while (start < _text.Length && MeasureTo(start + 1) <= _scrollX) start++;
int end = start;
while (end < _text.Length && MeasureTo(end + 1) - _scrollX <= visibleW) end++;
// Selection highlight BEHIND the text, clipped to the field.
if (HasSelection)
{
var (lo, hi) = SelSpan();
float h0 = MathF.Max(alignX + MeasureTo(lo) - _scrollX, 0f);
float h1 = MathF.Min(alignX + MeasureTo(hi) - _scrollX, visibleW);
if (h1 > h0) ctx.DrawFill(Padding + h0, ty, h1 - h0, lh, SelectionColor);
}
if (end > start)
{
string vis = _text.Substring(start, end - start);
float vx = Padding + alignX + (MeasureTo(start) - _scrollX);
if (DatFont is { } df2) ctx.DrawStringDat(df2, vis, vx, ty, TextColor);
else ctx.DrawString(vis, vx, ty, TextColor, Font);
}
if (_focused)
{
// Caret on TOP of the text → submitted after the text in the same bucket.
float cx = Padding + alignX + (caretX - _scrollX);
if (cx >= Padding - 1f && cx <= Width - Padding + 1f)
ctx.DrawFill(cx, ty, 1f, lh, TextColor);
}
}
private float TextAlignmentOffset()
{
float visibleW = MathF.Max(1f, Width - 2f * Padding);
float spare = MathF.Max(0f, visibleW - MeasureTo(_text.Length));
if (RightAligned) return spare;
if (Centered) return spare * 0.5f;
return 0f;
}
private void DrawMultiLine(UiRenderContext ctx)
{
float lineHeight = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
float visibleWidth = MathF.Max(1f, Width - (2f * Padding));
float visibleHeight = MathF.Max(1f, Height - (2f * Padding));
IReadOnlyList<WrappedLine> lines = BuildWrappedLines(visibleWidth);
_wrappedLines = lines;
_wrappedLineHeight = lineHeight;
Scroll.LineHeight = Math.Max(1, (int)MathF.Round(lineHeight));
Scroll.SetExtents(
Math.Max(1, (int)MathF.Ceiling(lines.Count * lineHeight)),
Math.Max(1, (int)MathF.Floor(visibleHeight)),
preserveEnd: false);
int caretLine = FindCaretLine(lines, _caret);
if (_focused)
{
float caretTop = caretLine * lineHeight;
float caretBottom = caretTop + lineHeight;
if (caretTop < Scroll.ScrollY)
Scroll.SetScrollY((int)MathF.Floor(caretTop));
else if (caretBottom > Scroll.ScrollY + visibleHeight)
Scroll.SetScrollY((int)MathF.Ceiling(caretBottom - visibleHeight));
}
ctx.PushClip(0f, 0f, Width, Height);
try
{
var (selectionLow, selectionHigh) = SelSpan();
for (int i = 0; i < lines.Count; i++)
{
WrappedLine line = lines[i];
float y = Padding + (i * lineHeight) - Scroll.ScrollY;
if (y + lineHeight <= Padding || y >= Height - Padding)
continue;
int lineEnd = line.Start + line.Length;
int highlightLow = Math.Max(selectionLow, line.Start);
int highlightHigh = Math.Min(selectionHigh, lineEnd);
if (HasSelection && highlightHigh > highlightLow)
{
float x0 = Padding + MeasureRange(
line.Start,
highlightLow - line.Start);
float x1 = Padding + MeasureRange(
line.Start,
highlightHigh - line.Start);
ctx.DrawFill(
x0,
y,
MathF.Max(0f, x1 - x0),
lineHeight,
SelectionColor);
}
if (DatFont is { } dat)
ctx.DrawStringDat(dat, line.Text, Padding, y, TextColor);
else if (Font is { } bitmap)
ctx.DrawString(line.Text, Padding, y, TextColor, bitmap);
}
if (_focused && lines.Count > 0)
{
WrappedLine line = lines[caretLine];
int lineColumn = Math.Clamp(
_caret - line.Start,
0,
line.Length);
float x = Padding + MeasureRange(line.Start, lineColumn);
float y = Padding + (caretLine * lineHeight) - Scroll.ScrollY;
ctx.DrawFill(x, y, 1f, lineHeight, TextColor);
}
}
finally
{
ctx.PopClip();
}
}
private IReadOnlyList<WrappedLine> BuildWrappedLines(float maximumWidth)
{
var lines = new List<WrappedLine>();
if (_text.Length == 0)
{
lines.Add(new WrappedLine(0, 0, string.Empty));
return lines;
}
int start = 0;
while (start < _text.Length)
{
if (_text[start] == '\n')
{
lines.Add(new WrappedLine(start, 0, string.Empty));
start++;
continue;
}
int paragraphEnd = _text.IndexOf('\n', start);
if (paragraphEnd < 0)
paragraphEnd = _text.Length;
int end = start;
int lastWhitespaceEnd = -1;
while (end < paragraphEnd)
{
int candidateEnd = end + 1;
if (MeasureRange(start, candidateEnd - start) > maximumWidth
&& end > start)
break;
end = candidateEnd;
if (char.IsWhiteSpace(_text[end - 1]))
lastWhitespaceEnd = end;
}
if (end < paragraphEnd && lastWhitespaceEnd > start)
end = lastWhitespaceEnd;
if (end == start)
end++;
int length = end - start;
lines.Add(new WrappedLine(
start,
length,
_text.Substring(start, length)));
start = end;
if (start == paragraphEnd && start < _text.Length)
start++;
}
if (_text.EndsWith('\n'))
lines.Add(new WrappedLine(_text.Length, 0, string.Empty));
return lines;
}
private int FindCaretLine(IReadOnlyList<WrappedLine> lines, int caret)
{
for (int i = 0; i < lines.Count; i++)
{
WrappedLine line = lines[i];
int end = line.Start + line.Length;
if (caret < end || caret == end && i == lines.Count - 1)
return i;
if (caret == end && i + 1 < lines.Count
&& lines[i + 1].Start > caret)
return i;
}
return Math.Max(0, lines.Count - 1);
}
private float MeasureRange(int start, int length)
{
if (length <= 0) return 0f;
string value = _text.Substring(start, length);
return DatFont?.MeasureWidth(value)
?? Font?.MeasureWidth(value)
?? value.Length * 8f;
}
private int HitChar(float localX, float localY)
{
if (OneLine)
return HitCharX(localX);
if (_wrappedLines.Count == 0)
return _text.Length;
int lineIndex = Math.Clamp(
(int)MathF.Floor(
(localY - Padding + Scroll.ScrollY)
/ MathF.Max(1f, _wrappedLineHeight)),
0,
_wrappedLines.Count - 1);
WrappedLine line = _wrappedLines[lineIndex];
float target = MathF.Max(0f, localX - Padding);
int best = 0;
float bestDistance = float.MaxValue;
for (int col = 0; col <= line.Length; col++)
{
float distance = MathF.Abs(
MeasureRange(line.Start, col) - target);
if (distance >= bestDistance) continue;
bestDistance = distance;
best = col;
}
return line.Start + best;
}
// ── Auto-repeat ──────────────────────────────────────────────────────
protected override void OnTick(double deltaSeconds)
{
if (!Editable || _repeatKey is not { } k) return;
_repeatTimer -= deltaSeconds;
if (_repeatTimer > 0) return;
_repeatTimer = RepeatRate;
bool shift = ShiftHeld();
switch (k)
{
case Silk.NET.Input.Key.Backspace: Backspace(); break;
case Silk.NET.Input.Key.Delete: DeleteForward(); break;
case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); break;
case Silk.NET.Input.Key.Right: MoveCaret(1, shift); break;
default: _repeatKey = null; break;
}
}
private void StartRepeat(Silk.NET.Input.Key k) { _repeatKey = k; _repeatTimer = RepeatDelay; }
private bool CtrlHeld() => Keyboard is not null
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft)
|| Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight));
private bool ShiftHeld() => Keyboard is not null
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftLeft)
|| Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftRight));
// ── Events ───────────────────────────────────────────────────────────
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.FocusGained:
_focused = true;
if (SelectAllOnFocus)
{
SelectAllText();
// UiRoot assigns focus before delivering the initiating MouseDown.
// Retail's activation notice selects all after that press, so preserve
// this selection across the one MouseDown that caused focus.
_preserveFocusSelectionOnMouseDown = true;
}
OnFocusGained?.Invoke();
return true;
case UiEventType.FocusLost:
OnFocusLost?.Invoke(_text);
_focused = false; _historyIndex = -1;
_selAnchor = null; _selecting = false; _repeatKey = null;
_preserveFocusSelectionOnMouseDown = false;
return true;
case UiEventType.Char:
{
char value = (char)e.Data0;
if (_suppressNextNewlineChar
&& (value == '\r' || value == '\n'))
{
_suppressNextNewlineChar = false;
return true;
}
_suppressNextNewlineChar = false;
InsertChar(value);
return true;
}
case UiEventType.MouseDown:
if (_preserveFocusSelectionOnMouseDown)
{
_preserveFocusSelectionOnMouseDown = false;
_selecting = false;
return true;
}
_caret = HitChar(e.Data1, e.Data2);
_selAnchor = Selectable ? _caret : null;
_selecting = Selectable;
return true;
case UiEventType.MouseMove:
if (Selectable && _selecting)
_caret = HitChar(e.Data1, e.Data2);
return true;
case UiEventType.MouseUp:
_selecting = false;
return true;
case UiEventType.KeyUp:
if ((Silk.NET.Input.Key)e.Data0 == _repeatKey) _repeatKey = null;
return true;
case UiEventType.KeyDown:
{
if (!Editable)
return true;
var key = (Silk.NET.Input.Key)e.Data0;
if (CtrlHeld())
{
switch (key)
{
case Silk.NET.Input.Key.A when Selectable: SelectAllText(); return true;
case Silk.NET.Input.Key.C when Selectable: CopySelection(); return true;
case Silk.NET.Input.Key.X when Selectable: CutSelection(); return true;
case Silk.NET.Input.Key.V: Paste(); return true;
}
return true; // swallow other Ctrl combos while typing
}
bool shift = Selectable && ShiftHeld();
switch (key)
{
case Silk.NET.Input.Key.Enter:
case Silk.NET.Input.Key.KeypadEnter:
if (!OneLine)
{
InsertChar('\n');
_suppressNextNewlineChar = true;
return true;
}
Submit();
FindRoot()?.SetKeyboardFocus(null); // exit write mode after sending
return true;
case Silk.NET.Input.Key.Backspace: Backspace(); StartRepeat(key); return true;
case Silk.NET.Input.Key.Delete: DeleteForward(); StartRepeat(key); return true;
case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); StartRepeat(key); return true;
case Silk.NET.Input.Key.Right: MoveCaret(1, shift); StartRepeat(key); return true;
case Silk.NET.Input.Key.Home: MoveCaretTo(0, shift); return true;
case Silk.NET.Input.Key.End: MoveCaretTo(_text.Length, shift); return true;
case Silk.NET.Input.Key.Up: HistoryPrev(); return true;
case Silk.NET.Input.Key.Down: HistoryNext(); return true;
}
return false;
}
case UiEventType.Scroll:
if (!OneLine)
{
Scroll.ScrollByLines(-Math.Sign(e.Data0));
return true;
}
return false;
case UiEventType.Click:
if (!Editable)
{
OnReadOnlyClick?.Invoke();
return true;
}
return false;
}
return false;
}
}