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
|
|
@ -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<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);
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue