feat(D.2b): chat text selection + Ctrl-C copy
Windows-like selection in the retail chat window: left-click-drag selects characters, Ctrl-C copies, Ctrl-A selects all. The selected span paints a translucent highlight behind the text. - UiElement.CapturesPointerDrag: a per-element opt-out so an interior drag is delivered to the widget (text selection) instead of moving/resizing the host window. UiRoot.OnMouseDown honours it AFTER edge-resize (a resizable window is still resizable from its frame) and BEFORE window-move. - UiChatView: AcceptsFocus + IsEditControl + CapturesPointerDrag; caches the OnDraw layout so OnEvent hit-tests the same geometry; HitChar maps a local point to (line,col) with glyph-midpoint caret snapping; SelectedText joins a multi-line span with \n; Ctrl-C writes to IKeyboard.ClipboardText (only when non-empty, so an empty copy never clobbers the clipboard). - UiHost exposes the wired IKeyboard (clipboard + Ctrl modifier state). Adversarial-review fix (the 99 tests would have stayed green without it): a coordinate-frame mismatch between MouseDown and MouseMove. UiRoot.OnMouseDown dispatched HitTestTopDown's coords, which are relative to the TOP-LEVEL child, while MouseMove/MouseUp use target.ScreenPosition. For the chat view inset at (8,8) inside its window the anchor landed ~8px off the click. OnMouseDown now delivers target-LOCAL coords like the other mouse events. Added a UiRoot regression test asserting MouseDown and MouseMove share the target-local frame for a nested child. Decomp ref: SurfaceWindow text/selection model; clipboard via Silk.NET IKeyboard.ClipboardText. Built with the chat-select-copy implement->review workflow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
36bd3522f4
commit
4e60c03a74
7 changed files with 507 additions and 12 deletions
|
|
@ -1812,6 +1812,9 @@ public sealed class GameWindow : IDisposable
|
||||||
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom,
|
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom,
|
||||||
Font = _debugFont,
|
Font = _debugFont,
|
||||||
LinesProvider = () => BuildRetailChatLines(retailChatVm),
|
LinesProvider = () => BuildRetailChatLines(retailChatVm),
|
||||||
|
// Drag-select + Ctrl+C copy need the keyboard for clipboard +
|
||||||
|
// modifier state. UiHost.Keyboard is set during WireKeyboard above.
|
||||||
|
Keyboard = _uiHost.Keyboard,
|
||||||
};
|
};
|
||||||
chatWindow.AddChild(chatView);
|
chatWindow.AddChild(chatView);
|
||||||
_uiHost.Root.AddChild(chatWindow);
|
_uiHost.Root.AddChild(chatWindow);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using System.Text;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
|
|
||||||
namespace AcDream.App.UI;
|
namespace AcDream.App.UI;
|
||||||
|
|
@ -12,10 +13,10 @@ namespace AcDream.App.UI;
|
||||||
/// text inside the window.
|
/// text inside the window.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This is the read-only foundation. A follow-up sub-step adds glScissor-based
|
/// Supports Windows-like text selection: a left-click-drag inside the transcript
|
||||||
/// clipping + word-wrap, drag-to-select, and Ctrl+C copy (which needs the
|
/// selects characters (the <see cref="UiElement.CapturesPointerDrag"/> opt-out
|
||||||
/// <see cref="UiElement.CapturesPointerDrag"/> opt-out so an interior drag
|
/// stops that interior drag from moving the host window), and Ctrl+C copies the
|
||||||
/// selects text instead of moving the window).
|
/// selected span to the clipboard. Ctrl+A selects everything.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UiChatView : UiElement
|
public sealed class UiChatView : UiElement
|
||||||
|
|
@ -23,15 +24,26 @@ public sealed class UiChatView : UiElement
|
||||||
/// <summary>One display line: pre-formatted text + its colour.</summary>
|
/// <summary>One display line: pre-formatted text + its colour.</summary>
|
||||||
public readonly record struct Line(string Text, Vector4 Color);
|
public readonly record struct Line(string Text, Vector4 Color);
|
||||||
|
|
||||||
|
/// <summary>A caret position: a line index into the cached line list plus a
|
||||||
|
/// character index (0..line.Text.Length, i.e. a caret slot between glyphs).</summary>
|
||||||
|
public readonly record struct Pos(int Line, int Col);
|
||||||
|
|
||||||
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
|
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
|
||||||
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
|
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
|
||||||
|
|
||||||
/// <summary>Font for the transcript; falls back to the context default.</summary>
|
/// <summary>Font for the transcript; falls back to the context default.</summary>
|
||||||
public BitmapFont? Font { get; set; }
|
public BitmapFont? Font { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by
|
||||||
|
/// the host from <see cref="UiHost.Keyboard"/>.</summary>
|
||||||
|
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
|
||||||
|
|
||||||
/// <summary>Backing fill behind the text (retail chat is a dark translucent box).</summary>
|
/// <summary>Backing fill behind the text (retail chat is a dark translucent box).</summary>
|
||||||
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f);
|
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f);
|
||||||
|
|
||||||
|
/// <summary>Highlight colour painted behind a selected character span.</summary>
|
||||||
|
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
|
||||||
|
|
||||||
/// <summary>Inner text inset from the view edges, px.</summary>
|
/// <summary>Inner text inset from the view edges, px.</summary>
|
||||||
public float Padding { get; set; } = 4f;
|
public float Padding { get; set; } = 4f;
|
||||||
|
|
||||||
|
|
@ -39,6 +51,25 @@ public sealed class UiChatView : UiElement
|
||||||
private float _scroll;
|
private float _scroll;
|
||||||
private const float WheelLines = 3f; // lines advanced per wheel notch
|
private const float WheelLines = 3f; // lines advanced per wheel 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 float _lastLineHeight = 16f;
|
||||||
|
private float _lastBaseY; // top Y of line 0 in local space
|
||||||
|
private float _lastPadding = 4f;
|
||||||
|
|
||||||
|
// ── Selection state ──────────────────────────────────────────────────
|
||||||
|
private Pos? _selAnchor; // where the drag started
|
||||||
|
private Pos? _selCaret; // where the drag currently is
|
||||||
|
private bool _selecting;
|
||||||
|
|
||||||
|
public UiChatView()
|
||||||
|
{
|
||||||
|
AcceptsFocus = true;
|
||||||
|
IsEditControl = true; // absorb keys (Ctrl+C) while focused
|
||||||
|
CapturesPointerDrag = true; // interior drag selects, doesn't move the window
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clamp a scroll offset to [0, max] where max = content-height - view-height
|
/// 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.
|
/// (never negative — when everything fits, scroll is pinned to 0). Exposed for tests.
|
||||||
|
|
@ -58,6 +89,14 @@ public sealed class UiChatView : UiElement
|
||||||
if (font is null) return;
|
if (font is null) return;
|
||||||
|
|
||||||
var lines = LinesProvider();
|
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;
|
||||||
|
_lastFont = font;
|
||||||
|
_lastLineHeight = font.LineHeight;
|
||||||
|
_lastPadding = Padding;
|
||||||
|
|
||||||
if (lines.Count == 0) return;
|
if (lines.Count == 0) return;
|
||||||
|
|
||||||
float lh = font.LineHeight;
|
float lh = font.LineHeight;
|
||||||
|
|
@ -69,23 +108,244 @@ public sealed class UiChatView : UiElement
|
||||||
// Bottom-pin: with _scroll==0 the LAST line ends at `bottom`; scrolling up
|
// Bottom-pin: with _scroll==0 the LAST line ends at `bottom`; scrolling up
|
||||||
// shifts the whole block down so older lines are revealed at the top.
|
// shifts the whole block down so older lines are revealed at the top.
|
||||||
float baseY = bottom - contentH + _scroll;
|
float baseY = bottom - contentH + _scroll;
|
||||||
|
_lastBaseY = baseY;
|
||||||
|
|
||||||
|
// Normalised selection span (start <= end), if any.
|
||||||
|
bool hasSel = TryGetOrderedSelection(out Pos selStart, out Pos selEnd);
|
||||||
|
|
||||||
for (int i = 0; i < lines.Count; i++)
|
for (int i = 0; i < lines.Count; i++)
|
||||||
{
|
{
|
||||||
float y = baseY + i * lh;
|
float y = baseY + i * lh;
|
||||||
if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet)
|
if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet)
|
||||||
ctx.DrawString(lines[i].Text, Padding, y, lines[i].Color, font);
|
|
||||||
|
string text = lines[i].Text;
|
||||||
|
|
||||||
|
// Selection highlight behind this line's selected character span.
|
||||||
|
if (hasSel && i >= selStart.Line && i <= selEnd.Line)
|
||||||
|
{
|
||||||
|
int c0 = i == selStart.Line ? selStart.Col : 0;
|
||||||
|
int c1 = i == selEnd.Line ? selEnd.Col : text.Length;
|
||||||
|
c0 = Math.Clamp(c0, 0, text.Length);
|
||||||
|
c1 = Math.Clamp(c1, 0, text.Length);
|
||||||
|
if (c1 > c0)
|
||||||
|
{
|
||||||
|
float hx = Padding + font.MeasureWidth(text.Substring(0, c0));
|
||||||
|
float hw = font.MeasureWidth(text.Substring(c0, c1 - c0));
|
||||||
|
ctx.DrawRect(hx, y, hw, lh, SelectionColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.DrawString(text, Padding, y, lines[i].Color, font);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool OnEvent(in UiEvent e)
|
public override bool OnEvent(in UiEvent e)
|
||||||
{
|
{
|
||||||
if (e.Type == UiEventType.Scroll)
|
switch (e.Type)
|
||||||
{
|
{
|
||||||
float lh = Font?.LineHeight ?? 16f;
|
case UiEventType.Scroll:
|
||||||
|
{
|
||||||
|
float lh = (Font ?? _lastFont)?.LineHeight ?? 16f;
|
||||||
// Silk wheel +Y = scroll up = reveal older = shift content down = larger _scroll.
|
// Silk wheel +Y = scroll up = reveal older = shift content down = larger _scroll.
|
||||||
_scroll += e.Data0 * WheelLines * lh; // re-clamped next OnDraw against live content
|
_scroll += e.Data0 * WheelLines * lh; // re-clamped next OnDraw against live content
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case UiEventType.MouseDown:
|
||||||
|
{
|
||||||
|
// Data1/Data2 = local-to-target coords (UiRoot.OnMouseDown).
|
||||||
|
var p = HitChar(e.Data1, e.Data2);
|
||||||
|
_selAnchor = p;
|
||||||
|
_selCaret = p;
|
||||||
|
_selecting = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case UiEventType.MouseMove:
|
||||||
|
{
|
||||||
|
if (_selecting)
|
||||||
|
{
|
||||||
|
// Data1/Data2 = local-to-target coords (DispatchMouseMove).
|
||||||
|
_selCaret = HitChar(e.Data1, e.Data2);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case UiEventType.MouseUp:
|
||||||
|
{
|
||||||
|
_selecting = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case UiEventType.KeyDown:
|
||||||
|
{
|
||||||
|
var key = (Silk.NET.Input.Key)e.Data0;
|
||||||
|
bool ctrl = Keyboard is not null
|
||||||
|
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft)
|
||||||
|
|| Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight));
|
||||||
|
if (ctrl && key == Silk.NET.Input.Key.C)
|
||||||
|
{
|
||||||
|
// Only touch the clipboard when there's a selection — an empty
|
||||||
|
// copy must NOT clobber what the user previously copied.
|
||||||
|
if (Keyboard is not null)
|
||||||
|
{
|
||||||
|
string sel = SelectedText();
|
||||||
|
if (sel.Length > 0) Keyboard.ClipboardText = sel;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ctrl && key == Silk.NET.Input.Key.A)
|
||||||
|
{
|
||||||
|
SelectAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selection helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Select the entire cached transcript (Ctrl+A).</summary>
|
||||||
|
private void SelectAll()
|
||||||
|
{
|
||||||
|
var lines = _lastLines;
|
||||||
|
if (lines.Count == 0)
|
||||||
|
{
|
||||||
|
_selAnchor = _selCaret = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int last = lines.Count - 1;
|
||||||
|
_selAnchor = new Pos(0, 0);
|
||||||
|
_selCaret = new Pos(last, lines[last].Text.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Normalise (anchor, caret) into ordered (start, end). False if no
|
||||||
|
/// selection or it is empty (anchor == caret).</summary>
|
||||||
|
private bool TryGetOrderedSelection(out Pos start, out Pos end)
|
||||||
|
{
|
||||||
|
start = default; end = default;
|
||||||
|
if (_selAnchor is not { } a || _selCaret is not { } c) return false;
|
||||||
|
(start, end) = Order(a, c);
|
||||||
|
return !(start.Line == end.Line && start.Col == end.Col);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The currently-selected text against the cached lines. Empty when
|
||||||
|
/// nothing is selected.</summary>
|
||||||
|
public string SelectedText()
|
||||||
|
{
|
||||||
|
if (!TryGetOrderedSelection(out var start, out var end)) return string.Empty;
|
||||||
|
return SelectedText(_lastLines, start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pure, testable logic (no GL / no font texture) ───────────────────
|
||||||
|
|
||||||
|
/// <summary>Order two caret positions so the first is <= the second (by line,
|
||||||
|
/// then column).</summary>
|
||||||
|
public static (Pos start, Pos end) Order(Pos a, Pos b)
|
||||||
|
{
|
||||||
|
if (a.Line < b.Line || (a.Line == b.Line && a.Col <= b.Col)) return (a, b);
|
||||||
|
return (b, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assemble the selected substring spanning <paramref name="start"/> ..
|
||||||
|
/// <paramref name="end"/> (inclusive of start.Col, exclusive of end.Col) from
|
||||||
|
/// <paramref name="lines"/>. Multi-line selections are joined with "\n":
|
||||||
|
/// the first line from start.Col to its end, whole middle lines, and the last
|
||||||
|
/// line up to end.Col. Pure — unit-testable without GL.
|
||||||
|
/// </summary>
|
||||||
|
public static string SelectedText(IReadOnlyList<Line> lines, Pos start, Pos end)
|
||||||
|
{
|
||||||
|
if (lines.Count == 0) return string.Empty;
|
||||||
|
(start, end) = Order(start, end);
|
||||||
|
|
||||||
|
int sl = Math.Clamp(start.Line, 0, lines.Count - 1);
|
||||||
|
int el = Math.Clamp(end.Line, 0, lines.Count - 1);
|
||||||
|
|
||||||
|
if (sl == el)
|
||||||
|
{
|
||||||
|
string t = lines[sl].Text;
|
||||||
|
int c0 = Math.Clamp(start.Col, 0, t.Length);
|
||||||
|
int c1 = Math.Clamp(end.Col, 0, t.Length);
|
||||||
|
if (c1 <= c0) return string.Empty;
|
||||||
|
return t.Substring(c0, c1 - c0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
// First line: from start.Col to its end.
|
||||||
|
{
|
||||||
|
string t = lines[sl].Text;
|
||||||
|
int c0 = Math.Clamp(start.Col, 0, t.Length);
|
||||||
|
sb.Append(t.AsSpan(c0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole middle lines.
|
||||||
|
for (int i = sl + 1; i < el; i++)
|
||||||
|
{
|
||||||
|
sb.Append('\n');
|
||||||
|
sb.Append(lines[i].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last line: up to end.Col.
|
||||||
|
{
|
||||||
|
sb.Append('\n');
|
||||||
|
string t = lines[el].Text;
|
||||||
|
int c1 = Math.Clamp(end.Col, 0, t.Length);
|
||||||
|
sb.Append(t.AsSpan(0, c1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert a local-space point to a caret <see cref="Pos"/> against the cached
|
||||||
|
/// layout from the last draw. line = floor((localY - baseY)/lineHeight) clamped
|
||||||
|
/// to the line range; col via <see cref="CharIndexAt"/>.
|
||||||
|
/// </summary>
|
||||||
|
private Pos HitChar(float localX, float localY)
|
||||||
|
{
|
||||||
|
var lines = _lastLines;
|
||||||
|
if (lines.Count == 0) return new Pos(0, 0);
|
||||||
|
|
||||||
|
float lh = _lastLineHeight <= 0f ? 16f : _lastLineHeight;
|
||||||
|
int line = (int)MathF.Floor((localY - _lastBaseY) / lh);
|
||||||
|
line = Math.Clamp(line, 0, lines.Count - 1);
|
||||||
|
|
||||||
|
string text = lines[line].Text;
|
||||||
|
var font = _lastFont;
|
||||||
|
int col = font is null
|
||||||
|
? 0
|
||||||
|
: CharIndexAt(text, ch => font.TryGetGlyph(ch, out var g) ? g.Advance : 0f,
|
||||||
|
localX - _lastPadding);
|
||||||
|
return new Pos(line, col);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The caret column for a horizontal position <paramref name="x"/> (already
|
||||||
|
/// adjusted for the left padding, so x=0 is the start of the text). Walks the
|
||||||
|
/// string accumulating each glyph's advance and snaps the caret to whichever
|
||||||
|
/// side of the glyph midpoint <paramref name="x"/> falls on — natural
|
||||||
|
/// Windows-like caret placement. Pure — unit-testable with a synthetic advance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">The line text.</param>
|
||||||
|
/// <param name="advanceOf">Per-character advance (pixels) lookup.</param>
|
||||||
|
/// <param name="x">Horizontal position relative to the text's left edge.</param>
|
||||||
|
public static int CharIndexAt(string text, Func<char, float> advanceOf, float x)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text) || x <= 0f) return 0;
|
||||||
|
|
||||||
|
float cursor = 0f;
|
||||||
|
for (int i = 0; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
float adv = advanceOf(text[i]);
|
||||||
|
float mid = cursor + adv * 0.5f;
|
||||||
|
if (x < mid) return i; // caret sits before this glyph
|
||||||
|
cursor += adv;
|
||||||
|
}
|
||||||
|
return text.Length; // past the last glyph → end caret
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,12 @@ public abstract class UiElement
|
||||||
/// resizes it (window resize). Intended for top-level panels.</summary>
|
/// resizes it (window resize). Intended for top-level panels.</summary>
|
||||||
public bool Resizable { get; set; }
|
public bool Resizable { get; set; }
|
||||||
|
|
||||||
|
/// <summary>If true, a left-drag starting on this element is delivered to the
|
||||||
|
/// element (e.g. text selection) instead of moving/resizing an ancestor window.
|
||||||
|
/// Edge resize on a resizable ancestor still wins — only the interior move /
|
||||||
|
/// drag-drop candidacy is suppressed in favour of the element's own handling.</summary>
|
||||||
|
public bool CapturesPointerDrag { get; set; }
|
||||||
|
|
||||||
/// <summary>Minimum size enforced while resizing.</summary>
|
/// <summary>Minimum size enforced while resizing.</summary>
|
||||||
public float MinWidth { get; set; } = 40f;
|
public float MinWidth { get; set; } = 40f;
|
||||||
public float MinHeight { get; set; } = 40f;
|
public float MinHeight { get; set; } = 40f;
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,13 @@ public sealed class UiHost : System.IDisposable
|
||||||
public UiRoot Root { get; } = new();
|
public UiRoot Root { get; } = new();
|
||||||
public TextRenderer TextRenderer { get; }
|
public TextRenderer TextRenderer { get; }
|
||||||
public BitmapFont? DefaultFont { get; set; }
|
public BitmapFont? DefaultFont { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The last wired keyboard. Exposed so widgets that need clipboard
|
||||||
|
/// access (<see cref="IKeyboard.ClipboardText"/>) or modifier-key state
|
||||||
|
/// (<see cref="IKeyboard.IsKeyPressed"/>) — e.g. <see cref="UiChatView"/>'s
|
||||||
|
/// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins.</summary>
|
||||||
|
public IKeyboard? Keyboard { get; private set; }
|
||||||
|
|
||||||
private long _startTicks = System.Environment.TickCount64;
|
private long _startTicks = System.Environment.TickCount64;
|
||||||
|
|
||||||
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
|
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
|
||||||
|
|
@ -82,6 +89,7 @@ public sealed class UiHost : System.IDisposable
|
||||||
|
|
||||||
public void WireKeyboard(IKeyboard kb)
|
public void WireKeyboard(IKeyboard kb)
|
||||||
{
|
{
|
||||||
|
Keyboard = kb; // last wired keyboard wins (one-keyboard desktop)
|
||||||
kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k);
|
kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k);
|
||||||
kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k);
|
kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k);
|
||||||
kb.KeyChar += (_, c) => Root.OnChar(c);
|
kb.KeyChar += (_, c) => Root.OnChar(c);
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ public sealed class UiRoot : UiElement
|
||||||
if (Modal is not null && !ContainsAbsolute(Modal, x, y))
|
if (Modal is not null && !ContainsAbsolute(Modal, x, y))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var (target, lx, ly) = HitTestTopDown(x, y);
|
var (target, _, _) = HitTestTopDown(x, y);
|
||||||
if (target is null)
|
if (target is null)
|
||||||
{
|
{
|
||||||
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
|
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
|
||||||
|
|
@ -218,6 +218,8 @@ public sealed class UiRoot : UiElement
|
||||||
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
|
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
|
||||||
if (edges != ResizeEdges.None)
|
if (edges != ResizeEdges.None)
|
||||||
{
|
{
|
||||||
|
// Edge resize still wins, even over a CapturesPointerDrag child:
|
||||||
|
// a resizable chat window can be resized from its frame.
|
||||||
_resizeTarget = window;
|
_resizeTarget = window;
|
||||||
_resizeEdges = edges;
|
_resizeEdges = edges;
|
||||||
_resizeStartX = window.Left; _resizeStartY = window.Top;
|
_resizeStartX = window.Left; _resizeStartY = window.Top;
|
||||||
|
|
@ -225,6 +227,14 @@ public sealed class UiRoot : UiElement
|
||||||
_resizeMouseX = x; _resizeMouseY = y;
|
_resizeMouseX = x; _resizeMouseY = y;
|
||||||
_dragCandidate = false;
|
_dragCandidate = false;
|
||||||
}
|
}
|
||||||
|
else if (target.CapturesPointerDrag)
|
||||||
|
{
|
||||||
|
// The pressed widget owns interior drags (e.g. text selection):
|
||||||
|
// do NOT move the ancestor window. The already-dispatched MouseDown
|
||||||
|
// event + SetCapture(target) let the target drive its own drag via
|
||||||
|
// the MouseMove events it receives while captured.
|
||||||
|
_dragCandidate = false;
|
||||||
|
}
|
||||||
else if (window.Draggable)
|
else if (window.Draggable)
|
||||||
{
|
{
|
||||||
_windowDragTarget = window;
|
_windowDragTarget = window;
|
||||||
|
|
@ -234,6 +244,11 @@ public sealed class UiRoot : UiElement
|
||||||
}
|
}
|
||||||
else { _dragCandidate = true; }
|
else { _dragCandidate = true; }
|
||||||
}
|
}
|
||||||
|
else if (target.CapturesPointerDrag)
|
||||||
|
{
|
||||||
|
// No window ancestor, but the target still owns its interior drag.
|
||||||
|
_dragCandidate = false;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_dragCandidate = true;
|
_dragCandidate = true;
|
||||||
|
|
@ -247,8 +262,13 @@ public sealed class UiRoot : UiElement
|
||||||
UiMouseButton.Middle => UiEventType.MiddleDown,
|
UiMouseButton.Middle => UiEventType.MiddleDown,
|
||||||
_ => UiEventType.MouseDown,
|
_ => UiEventType.MouseDown,
|
||||||
};
|
};
|
||||||
|
// Deliver TARGET-LOCAL coords (consistent with MouseMove/MouseUp, which use
|
||||||
|
// target.ScreenPosition). HitTestTopDown's lx/ly are relative to the TOP-LEVEL
|
||||||
|
// child, so for a nested target (e.g. the chat view inset inside its window)
|
||||||
|
// they'd be offset by the child's position — which mis-anchored drag-select.
|
||||||
|
var sp = target.ScreenPosition;
|
||||||
var e = new UiEvent(target.EventId, target, rawType,
|
var e = new UiEvent(target.EventId, target, rawType,
|
||||||
Data0: (int)flags, Data1: (int)lx, Data2: (int)ly);
|
Data0: (int)flags, Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
|
||||||
BubbleEvent(target, in e);
|
BubbleEvent(target, in e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.UI;
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
@ -25,4 +28,116 @@ public class UiChatViewTests
|
||||||
{
|
{
|
||||||
Assert.Equal(0f, UiChatView.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f));
|
Assert.Equal(0f, UiChatView.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Char-index hit-testing (x → col) with a synthetic 10px monospace advance ──
|
||||||
|
|
||||||
|
private static readonly Func<char, float> Mono10 = static _ => 10f;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharIndexAt_ZeroOrNegative_IsColumnZero()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, 0f));
|
||||||
|
Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, -5f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharIndexAt_SnapsToGlyphMidpoint()
|
||||||
|
{
|
||||||
|
// glyph[0] spans 0..10 (midpoint 5), glyph[1] 10..20 (midpoint 15), ...
|
||||||
|
Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, 4f)); // before mid of glyph 0
|
||||||
|
Assert.Equal(1, UiChatView.CharIndexAt("hello", Mono10, 6f)); // past mid of glyph 0
|
||||||
|
Assert.Equal(1, UiChatView.CharIndexAt("hello", Mono10, 14f)); // before mid of glyph 1
|
||||||
|
Assert.Equal(2, UiChatView.CharIndexAt("hello", Mono10, 16f)); // past mid of glyph 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharIndexAt_PastEnd_IsLength()
|
||||||
|
{
|
||||||
|
Assert.Equal(5, UiChatView.CharIndexAt("hello", Mono10, 1000f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharIndexAt_EmptyString_IsZero()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, UiChatView.CharIndexAt("", Mono10, 50f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SelectedText assembly ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static IReadOnlyList<UiChatView.Line> Lines(params string[] texts)
|
||||||
|
{
|
||||||
|
var list = new List<UiChatView.Line>(texts.Length);
|
||||||
|
foreach (var t in texts)
|
||||||
|
list.Add(new UiChatView.Line(t, new Vector4(1, 1, 1, 1)));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectedText_SingleLine_Substring()
|
||||||
|
{
|
||||||
|
var lines = Lines("hello world");
|
||||||
|
var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 6), new UiChatView.Pos(0, 11));
|
||||||
|
Assert.Equal("world", s);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectedText_SingleLine_ReversedAnchorCaret_IsNormalised()
|
||||||
|
{
|
||||||
|
var lines = Lines("hello world");
|
||||||
|
// caret BEFORE anchor — Order() must normalise.
|
||||||
|
var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 11), new UiChatView.Pos(0, 6));
|
||||||
|
Assert.Equal("world", s);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectedText_SamePosition_IsEmpty()
|
||||||
|
{
|
||||||
|
var lines = Lines("hello");
|
||||||
|
Assert.Equal("", UiChatView.SelectedText(lines, new UiChatView.Pos(0, 3), new UiChatView.Pos(0, 3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectedText_MultiLine_JoinsWithNewline()
|
||||||
|
{
|
||||||
|
var lines = Lines("first line", "second line", "third line");
|
||||||
|
// from col 6 of line 0 ("line") through col 5 of line 2 ("third")
|
||||||
|
var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 6), new UiChatView.Pos(2, 5));
|
||||||
|
Assert.Equal("line\nsecond line\nthird", s);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectedText_MultiLine_TwoLines_NoMiddle()
|
||||||
|
{
|
||||||
|
var lines = Lines("alpha", "bravo");
|
||||||
|
var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 2), new UiChatView.Pos(1, 3));
|
||||||
|
Assert.Equal("pha\nbra", s);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectedText_MultiLine_ReversedAnchorCaret_IsNormalised()
|
||||||
|
{
|
||||||
|
var lines = Lines("alpha", "bravo");
|
||||||
|
// end before start → Order() swaps them.
|
||||||
|
var s = UiChatView.SelectedText(lines, new UiChatView.Pos(1, 3), new UiChatView.Pos(0, 2));
|
||||||
|
Assert.Equal("pha\nbra", s);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectedText_EmptyLineList_IsEmpty()
|
||||||
|
{
|
||||||
|
Assert.Equal("", UiChatView.SelectedText(Array.Empty<UiChatView.Line>(),
|
||||||
|
new UiChatView.Pos(0, 0), new UiChatView.Pos(0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Order_SortsByLineThenColumn()
|
||||||
|
{
|
||||||
|
var (s1, e1) = UiChatView.Order(new UiChatView.Pos(2, 1), new UiChatView.Pos(0, 5));
|
||||||
|
Assert.Equal(new UiChatView.Pos(0, 5), s1);
|
||||||
|
Assert.Equal(new UiChatView.Pos(2, 1), e1);
|
||||||
|
|
||||||
|
var (s2, e2) = UiChatView.Order(new UiChatView.Pos(1, 8), new UiChatView.Pos(1, 2));
|
||||||
|
Assert.Equal(new UiChatView.Pos(1, 2), s2);
|
||||||
|
Assert.Equal(new UiChatView.Pos(1, 8), e2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,41 @@ public class UiRootInputTests
|
||||||
Assert.Equal(AnchorEdges.None, panel.Anchors);
|
Assert.Equal(AnchorEdges.None, panel.Anchors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class CoordRecorder : UiElement
|
||||||
|
{
|
||||||
|
public (int x, int y)? Down, Move;
|
||||||
|
public CoordRecorder() { CapturesPointerDrag = true; }
|
||||||
|
public override bool OnEvent(in UiEvent e)
|
||||||
|
{
|
||||||
|
if (e.Type == UiEventType.MouseDown) { Down = (e.Data1, e.Data2); return true; }
|
||||||
|
if (e.Type == UiEventType.MouseMove) { Move = (e.Data1, e.Data2); return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MouseDown_And_MouseMove_DeliverSameTargetLocalFrame_ForNestedChild()
|
||||||
|
{
|
||||||
|
// Regression (adversarial review): a nested child must receive target-LOCAL
|
||||||
|
// coords on MouseDown AND MouseMove for the same physical point — otherwise
|
||||||
|
// drag-select anchors ~(child offset) px off from where you click. Before the
|
||||||
|
// fix MouseDown used HitTestTopDown's window-relative coords (50,40) while
|
||||||
|
// MouseMove used target-local (42,32).
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
var panel = new UiPanel { Left = 50, Top = 60, Width = 200, Height = 100 };
|
||||||
|
var child = new CoordRecorder { Left = 8, Top = 8, Width = 150, Height = 80 };
|
||||||
|
panel.AddChild(child);
|
||||||
|
root.AddChild(panel);
|
||||||
|
|
||||||
|
// child ScreenPosition = (58,68). Click screen (100,100) -> local (42,32).
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 100, 100);
|
||||||
|
Assert.Equal((42, 32), child.Down);
|
||||||
|
|
||||||
|
// drag to (120,110) -> local (62,42); MUST share the MouseDown frame.
|
||||||
|
root.OnMouseMove(120, 110);
|
||||||
|
Assert.Equal((62, 42), child.Move);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ApplyAnchor_None_IsNoOp()
|
public void ApplyAnchor_None_IsNoOp()
|
||||||
{
|
{
|
||||||
|
|
@ -70,6 +105,54 @@ public class UiRootInputTests
|
||||||
Assert.Equal(10f, panel.Top);
|
Assert.Equal(10f, panel.Top);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CapturesPointerDragChild_DoesNotMoveDraggableAncestor_OnInteriorDrag()
|
||||||
|
{
|
||||||
|
// A child that captures pointer drags (text selection) must NOT move its
|
||||||
|
// draggable ancestor window when the user drags inside it.
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
var window = new UiPanel { Left = 10, Top = 10, Width = 200, Height = 100, Draggable = true };
|
||||||
|
var child = new UiPanel { Left = 20, Top = 20, Width = 120, Height = 60, CapturesPointerDrag = true };
|
||||||
|
window.AddChild(child);
|
||||||
|
root.AddChild(window);
|
||||||
|
|
||||||
|
// Press deep inside the child, then drag.
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 60, 60);
|
||||||
|
root.OnMouseMove(160, 160);
|
||||||
|
|
||||||
|
// Window stays put; the captured child receives the drag itself.
|
||||||
|
Assert.Equal(10f, window.Left);
|
||||||
|
Assert.Equal(10f, window.Top);
|
||||||
|
Assert.Same(child, root.Captured);
|
||||||
|
|
||||||
|
root.OnMouseUp(UiMouseButton.Left, 160, 160);
|
||||||
|
Assert.Equal(10f, window.Left);
|
||||||
|
Assert.Equal(10f, window.Top);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CapturesPointerDragChild_StillAllowsEdgeResizeOfResizableWindow()
|
||||||
|
{
|
||||||
|
// Edge resize must still win even when a CapturesPointerDrag child covers
|
||||||
|
// the frame: a resizable chat window can be resized from its border.
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
var window = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100,
|
||||||
|
Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 };
|
||||||
|
// Child fills the whole window (anchored) and captures interior drags.
|
||||||
|
var child = new UiPanel { Left = 0, Top = 0, Width = 200, Height = 100,
|
||||||
|
CapturesPointerDrag = true,
|
||||||
|
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom };
|
||||||
|
window.AddChild(child);
|
||||||
|
root.AddChild(window);
|
||||||
|
|
||||||
|
// Grab within ResizeGrip(5) of the right edge (x=298 of right edge x=300) → resize.
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 298, 150);
|
||||||
|
root.OnMouseMove(338, 150);
|
||||||
|
Assert.Equal(240f, window.Width);
|
||||||
|
Assert.Equal(100f, window.Left);
|
||||||
|
root.OnMouseUp(UiMouseButton.Left, 338, 150);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResizeRect_RightBottom_GrowsSizeOnly()
|
public void ResizeRect_RightBottom_GrowsSizeOnly()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue