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() {