diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index ce0989f8..2e26a360 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1812,6 +1812,9 @@ public sealed class GameWindow : IDisposable
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom,
Font = _debugFont,
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);
_uiHost.Root.AddChild(chatWindow);
diff --git a/src/AcDream.App/UI/UiChatView.cs b/src/AcDream.App/UI/UiChatView.cs
index 5cf9a96b..a2039c08 100644
--- a/src/AcDream.App/UI/UiChatView.cs
+++ b/src/AcDream.App/UI/UiChatView.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Numerics;
+using System.Text;
using AcDream.App.Rendering;
namespace AcDream.App.UI;
@@ -12,10 +13,10 @@ namespace AcDream.App.UI;
/// text inside the window.
///
///
-/// This is the read-only foundation. A follow-up sub-step adds glScissor-based
-/// clipping + word-wrap, drag-to-select, and Ctrl+C copy (which needs the
-/// opt-out so an interior drag
-/// selects text instead of moving the window).
+/// Supports Windows-like text selection: a left-click-drag inside the transcript
+/// selects characters (the opt-out
+/// stops that interior drag from moving the host window), and Ctrl+C copies the
+/// selected span to the clipboard. Ctrl+A selects everything.
///
///
public sealed class UiChatView : UiElement
@@ -23,15 +24,26 @@ public sealed class UiChatView : UiElement
/// One display line: pre-formatted text + its colour.
public readonly record struct Line(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();
/// Font for the transcript; falls back to the context default.
public BitmapFont? Font { get; set; }
+ /// Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by
+ /// the host from .
+ public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
+
/// Backing fill behind the text (retail chat is a dark translucent box).
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f);
+ /// Highlight colour painted behind a selected character span.
+ public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
+
/// Inner text inset from the view edges, px.
public float Padding { get; set; } = 4f;
@@ -39,6 +51,25 @@ public sealed class UiChatView : UiElement
private float _scroll;
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 _lastLines = Array.Empty();
+ 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
+ }
+
///
/// 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.
@@ -58,6 +89,14 @@ public sealed class UiChatView : UiElement
if (font 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;
+ _lastFont = font;
+ _lastLineHeight = font.LineHeight;
+ _lastPadding = Padding;
+
if (lines.Count == 0) return;
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
// shifts the whole block down so older lines are revealed at the top.
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++)
{
float y = baseY + i * lh;
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)
{
- if (e.Type == UiEventType.Scroll)
+ switch (e.Type)
{
- float lh = Font?.LineHeight ?? 16f;
- // Silk wheel +Y = scroll up = reveal older = shift content down = larger _scroll.
- _scroll += e.Data0 * WheelLines * lh; // re-clamped next OnDraw against live content
- return true;
+ case UiEventType.Scroll:
+ {
+ float lh = (Font ?? _lastFont)?.LineHeight ?? 16f;
+ // Silk wheel +Y = scroll up = reveal older = shift content down = larger _scroll.
+ _scroll += e.Data0 * WheelLines * lh; // re-clamped next OnDraw against live content
+ 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;
+ }
+
+ 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 ────────────────────────────────────────────────
+
+ /// 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) ───────────────────
+
+ /// 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;
+ 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);
+ }
+
+ ///
+ /// 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
+ }
}
diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index e16c888f..937a52b2 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -102,6 +102,12 @@ public abstract class UiElement
/// resizes it (window resize). Intended for top-level panels.
public bool Resizable { get; set; }
+ /// 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.
+ public bool CapturesPointerDrag { get; set; }
+
/// Minimum size enforced while resizing.
public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f;
diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs
index 5f697cfb..a372f891 100644
--- a/src/AcDream.App/UI/UiHost.cs
+++ b/src/AcDream.App/UI/UiHost.cs
@@ -39,6 +39,13 @@ public sealed class UiHost : System.IDisposable
public UiRoot Root { get; } = new();
public TextRenderer TextRenderer { get; }
public BitmapFont? DefaultFont { get; set; }
+
+ /// The last wired keyboard. Exposed so widgets that need clipboard
+ /// access () or modifier-key state
+ /// () — e.g. 's
+ /// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins.
+ public IKeyboard? Keyboard { get; private set; }
+
private long _startTicks = System.Environment.TickCount64;
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
@@ -82,6 +89,7 @@ public sealed class UiHost : System.IDisposable
public void WireKeyboard(IKeyboard kb)
{
+ Keyboard = kb; // last wired keyboard wins (one-keyboard desktop)
kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k);
kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k);
kb.KeyChar += (_, c) => Root.OnChar(c);
diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs
index 6f836253..e57d02e3 100644
--- a/src/AcDream.App/UI/UiRoot.cs
+++ b/src/AcDream.App/UI/UiRoot.cs
@@ -197,7 +197,7 @@ public sealed class UiRoot : UiElement
if (Modal is not null && !ContainsAbsolute(Modal, x, y))
return;
- var (target, lx, ly) = HitTestTopDown(x, y);
+ var (target, _, _) = HitTestTopDown(x, y);
if (target is null)
{
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;
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;
_resizeEdges = edges;
_resizeStartX = window.Left; _resizeStartY = window.Top;
@@ -225,6 +227,14 @@ public sealed class UiRoot : UiElement
_resizeMouseX = x; _resizeMouseY = y;
_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)
{
_windowDragTarget = window;
@@ -234,6 +244,11 @@ public sealed class UiRoot : UiElement
}
else { _dragCandidate = true; }
}
+ else if (target.CapturesPointerDrag)
+ {
+ // No window ancestor, but the target still owns its interior drag.
+ _dragCandidate = false;
+ }
else
{
_dragCandidate = true;
@@ -247,8 +262,13 @@ public sealed class UiRoot : UiElement
UiMouseButton.Middle => UiEventType.MiddleDown,
_ => 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,
- 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);
}
diff --git a/tests/AcDream.App.Tests/UI/UiChatViewTests.cs b/tests/AcDream.App.Tests/UI/UiChatViewTests.cs
index 6dc9f22a..7a02b183 100644
--- a/tests/AcDream.App.Tests/UI/UiChatViewTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiChatViewTests.cs
@@ -1,3 +1,6 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
@@ -25,4 +28,116 @@ public class UiChatViewTests
{
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 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 Lines(params string[] texts)
+ {
+ var list = new List(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(),
+ 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);
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs
index 1adbffcd..c3160e66 100644
--- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs
@@ -14,6 +14,41 @@ public class UiRootInputTests
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]
public void ApplyAnchor_None_IsNoOp()
{
@@ -70,6 +105,54 @@ public class UiRootInputTests
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]
public void ResizeRect_RightBottom_GrowsSizeOnly()
{