From b9e31c2c53a58cbd377d3bef8693b2c96daad6d5 Mon Sep 17 00:00:00 2001 From: centwon Date: Sun, 20 Sep 2026 12:47:38 +0900 Subject: [PATCH] =?UTF-8?q?test:=20=ED=8F=AC=EC=9D=B8=ED=84=B0=20=EC=A0=9C?= =?UTF-8?q?=EC=8A=A4=EC=B2=98=EB=A5=BC=20=EC=88=9C=EC=84=9C=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=AC=EB=8F=99=ED=95=98=EB=8A=94=20seam=20?= =?UTF-8?q?=E2=80=94=20=EC=BA=A1=EC=B2=98=20=ED=95=B4=EC=A0=9C=EA=B0=80=20?= =?UTF-8?q?=EC=8A=A4=EC=8A=A4=EB=A1=9C=EB=A5=BC=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=ED=95=98=EB=8D=98=20=EA=B3=84=EC=97=B4=20(1.3=201=EB=8B=A8?= =?UTF-8?q?=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.2.0에서 표 그리기가 "모든 드래그가 아무것도 넣지 않는" 상태로 잠깐 나갔었다. `ReleasePointerCapture`가 `PointerCaptureLost`를 **동기로** 일으키는데 놓기 처리가 해제를 먼저 불렀기 때문이고, 좌표 단위 테스트는 전부 초록이었다(캡처가 테스트 밖이라 — `PointerRoutedEventArgs`는 생성할 수 없다). 사람이 실기에서 잡았다. **seam**: 처리기가 이벤트에서 실제로 읽는 것은 위치·오른쪽 버튼·모디파이어·캡처 넷뿐이므로 이것을 `PointerStep`으로 묶고, 캡처를 `IPointerCapture`로 추상화했다(`RichEditor.PointerPipeline.cs`). `OnCanvasPointer*`는 어댑터만 남고 본문은 `PointerPressedCore`/`MovedCore`/`ReleasedCore`/`CaptureLostCore`로 간다. 하위 처리기 9개(표 그리기·열/행 끌기·그림 손잡이·개체/텍스트 끌기)도 같은 타입을 받는다. 포인터 경로의 `Ctrl`/`Shift` 정적 키보드 읽기도 step으로 옮겼다 — 이제 테스트가 모디파이어를 정할 수 있다 (휠 처리기 하나만 남겨 뒀다: 3단계). 공개 표면 변화 없음, 동작 불변(기존 815 그대로 통과). **테스트** `ControlPointerSequenceTests` 6 — 누르기→캡처→이동→놓기→캡처상실을 실제 순서로 구동한다. `FakeCapture`가 WinUI처럼 해제 시 캡처 상실을 **동기로** 되쏜다. 반증 5종: - 놓기 순서 뒤집기 → 표 그리기 제스처 2건 빨강 (이 릴리스의 회귀가 재현된다) - 캡처 상실의 `CancelTableDraw` 제거 → 캡처 상실 테스트 + 기존 좌표 테스트 빨강 - 캡처 상실의 `FinishColumnResize` 제거 → 열 끌기 캡처 상실 테스트 + 기존 테스트 빨강 - ⚠ **열 끌기 놓기 순서 뒤집기 → 아무것도 빨개지지 않는다.** 캡처 상실이 열 끌기는 *취소*가 아니라 *완료*하고 `FinishColumnResize`가 멱등이라 실제로 무해하다(상류와 같다). 테스트 주석에 적어 뒀다. - ⚠ **가짜가 캡처 상실을 동기로 안 쏘면 뒤집힌 순서도 통과**(측정). 보증이 가짜의 충실도에 얹혀 있다는 뜻이라, 호출 순서 `capture → release → lost`를 테스트가 직접 단정하게 했다 — 가짜를 약화시키면 그 테스트가 빨개진다(확인). 여전히 자동 검증 밖: 프레임워크의 라우팅·히트테스트·포커스, WinUI의 진짜 캡처 의미. 그쪽 게이트는 `tools/fault-sweep.ps1`과 실기 확인이다. Co-Authored-By: Claude Opus 5 --- .../Controls/RichEditor.BlockSelection.cs | 14 +- .../Controls/RichEditor.DragBlock.cs | 18 +- .../Controls/RichEditor.DragText.cs | 14 +- .../Controls/RichEditor.Input.cs | 76 +++-- .../Controls/RichEditor.PointerPipeline.cs | 65 ++++ .../Controls/RichEditor.TableDraw.cs | 8 +- .../Controls/RichEditor.TableResize.cs | 8 +- .../ControlContextMenuTests.cs | 4 +- .../ControlDragObjectTests.cs | 8 +- .../ControlPointerSequenceTests.cs | 313 ++++++++++++++++++ 10 files changed, 462 insertions(+), 66 deletions(-) create mode 100644 src/WinUIRichEditor/Controls/RichEditor.PointerPipeline.cs create mode 100644 tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs diff --git a/src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs b/src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs index 3fad324..09fa2d7 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs @@ -157,7 +157,7 @@ internal static PointerTarget ChoosePointerTarget( return PointerTarget.None; } - private bool TryBeginImageInteraction(Point pt, PointerRoutedEventArgs e) + private bool TryBeginImageInteraction(Point pt, PointerStep s) { // Hit-test everything first, then let ChoosePointerTarget decide. The drag is seeded from the // rect the handle was DRAWN at, for both registries — see _cellImageRects for why. @@ -193,12 +193,12 @@ void SelectObject(ImageBlock? block, (Paragraph p, InlineImage img)? inline) case PointerTarget.SelectedBlockImageHandle: case PointerTarget.SelectedInlineImageHandle: BeginImageResize(handle.grip, handle.rect, handle.inline, pt); - _canvas.CapturePointer(e.Pointer); + s.Capture.Capture(); return true; // A press on the picture itself selects it and arms dragging it (RichEditor.DragBlock.cs). - case PointerTarget.CellImage: SelectObject(cellImage, null); ArmObjectDrag(cellImage, pt, e); return true; - case PointerTarget.InlineImage: SelectObject(null, inlineImage); ArmObjectDrag(inlineImage!.Value.img, pt, e); return true; - case PointerTarget.BlockImage: SelectObject(blockImage, null); ArmObjectDrag(blockImage, pt, e); return true; + case PointerTarget.CellImage: SelectObject(cellImage, null); ArmObjectDrag(cellImage, pt, s); return true; + case PointerTarget.InlineImage: SelectObject(null, inlineImage); ArmObjectDrag(inlineImage!.Value.img, pt, s); return true; + case PointerTarget.BlockImage: SelectObject(blockImage, null); ArmObjectDrag(blockImage, pt, s); return true; default: return false; } } @@ -275,11 +275,11 @@ private bool TryResizeImage(Point pt) return true; } - private bool EndImageResize(PointerRoutedEventArgs e) + private bool EndImageResize(PointerStep s) { if (_resizingImage == null && _resizingInline == null) return false; FinishImageResize(); // before the release (see EndColumnResize) - _canvas.ReleasePointerCapture(e.Pointer); + s.Capture.Release(); return true; } diff --git a/src/WinUIRichEditor/Controls/RichEditor.DragBlock.cs b/src/WinUIRichEditor/Controls/RichEditor.DragBlock.cs index 0c3cc9b..21d5d81 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.DragBlock.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.DragBlock.cs @@ -23,9 +23,9 @@ public partial class RichEditor private bool DropPreviewActive => _dragTextActive || _dragObjectActive; // A press that has just selected an object: arm its drag and take the pointer. - private void ArmObjectDrag(object? obj, Point docPt, PointerRoutedEventArgs e) + private void ArmObjectDrag(object? obj, Point docPt, PointerStep s) { - if (ArmObjectDragAt(obj, docPt)) _canvas.CapturePointer(e.Pointer); + if (ArmObjectDragAt(obj, docPt)) s.Capture.Capture(); } // The press minus its pointer capture. Editing only: a viewer's press selects the object for Copy. @@ -41,7 +41,7 @@ internal bool ArmObjectDragAt(object? obj, Point docPt) // Pointer move while armed: past the slop the drag goes live and the drop caret follows the pointer. A // point the object may not go to (a move into its own cells) shows no caret and the no-drop cursor. - internal void DragObjectMoved(Point docPt) + internal void DragObjectMoved(Point docPt, bool ctrl) { if (_dragObject == null) return; _dragObjectLast = docPt; @@ -51,15 +51,15 @@ internal void DragObjectMoved(Point docPt) _dragObjectActive = true; } var tp = GetPositionFromPoint(docPt); - _dropPreview = tp != null && CanDropObject(_dragObject, tp, copy: Ctrl) ? tp : null; + _dropPreview = tp != null && CanDropObject(_dragObject, tp, copy: ctrl) ? tp : null; SetCursorShape(_dropPreview != null ? InputSystemCursorShape.Arrow : InputSystemCursorShape.UniversalNo); InvalidateCanvas(); } - private void EndObjectDrag(PointerRoutedEventArgs e) + private void EndObjectDrag(PointerStep s) { - FinishObjectDrag(copy: Ctrl); - _canvas.ReleasePointerCapture(e.Pointer); // after the drag is cleared, so CaptureLost finds nothing live + FinishObjectDrag(copy: s.Ctrl); + s.Capture.Release(); // after the drag is cleared, so CaptureLost finds nothing live } // The release minus its pointer capture. Ctrl is read at the DROP, as for text. Returns whether the @@ -90,7 +90,9 @@ private void CancelObjectDrag() // the point is a valid drop at all (a copy may go into its own cells, a move may not). private void OnDragModifierChanged() { - if (_dragObjectActive) DragObjectMoved(_dragObjectLast); + // Driven by the KEY handlers (Ctrl pressed/released mid-drag), so the live keyboard state is the + // right source here — unlike the pointer path, which carries the modifiers on its PointerStep. + if (_dragObjectActive) DragObjectMoved(_dragObjectLast, Ctrl); else if (_dragTextActive) InvalidateCanvas(); } diff --git a/src/WinUIRichEditor/Controls/RichEditor.DragText.cs b/src/WinUIRichEditor/Controls/RichEditor.DragText.cs index 68fb0db..5fdf041 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.DragText.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.DragText.cs @@ -46,14 +46,14 @@ private bool CanArmTextDragAt(TextPointer tp) } // Arms the drag; the caller has checked CanArmTextDragAt (via ChooseTextPress). - private void ArmTextDrag(Point docPt, PointerRoutedEventArgs e) + private void ArmTextDrag(Point docPt, PointerStep s) { _dragTextArmed = true; _dragTextActive = false; _dragTextStart = docPt; - _dragTextPressCtrl = Ctrl; + _dragTextPressCtrl = s.Ctrl; _dropPreview = null; - _canvas.CapturePointer(e.Pointer); + s.Capture.Capture(); } // Pointer move while armed: past the slop the drag activates and the drop preview follows the pointer. @@ -70,21 +70,21 @@ private void DragTextMoved(Point docPt) } // Pointer release: a real drag performs the move/copy; an unmoved press is the deferred plain click. - private void EndTextDrag(PointerRoutedEventArgs e) + private void EndTextDrag(PointerStep s) { bool wasDrag = _dragTextActive; var drop = _dropPreview; _dragTextArmed = false; _dragTextActive = false; _dropPreview = null; - _canvas.ReleasePointerCapture(e.Pointer); + s.Capture.Release(); SetCursorShape(InputSystemCursorShape.IBeam); if (!wasDrag) { // The press deferred the usual click handling (so the selection survived a potential drag); // do it now: caret to the click point, selection collapsed. - var pt = ViewToDoc(e.GetCurrentPoint(_canvas).Position); + var pt = ViewToDoc(s.ViewPos); if (GetPositionFromPoint(pt) is { Paragraph: not null } tp) { _caret = tp; @@ -106,7 +106,7 @@ private void EndTextDrag(PointerRoutedEventArgs e) } if (drop?.Paragraph == null) { InvalidateCanvas(); return; } - PerformTextDrop(drop, copy: Ctrl); + PerformTextDrop(drop, copy: s.Ctrl); } // Moves (or, with copy, duplicates) the selected content to `drop`. The move deletes the selection diff --git a/src/WinUIRichEditor/Controls/RichEditor.Input.cs b/src/WinUIRichEditor/Controls/RichEditor.Input.cs index 515eb18..ad4fb1c 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Input.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Input.cs @@ -301,19 +301,23 @@ private static void WireBlockParents(Block block, object parent) internal bool BeginResizeDragAt(Point pt) => BeginImageResizeAt(pt) || BeginColumnResizeAt(pt) || BeginRowResizeAt(pt); - private void OnCanvasPointerPressed(object sender, PointerRoutedEventArgs e) + private void OnCanvasPointerPressed(object sender, PointerRoutedEventArgs e) => PointerPressedCore(StepFrom(e)); + + /// The press, driven by a so a test can run a whole gesture in + /// order (see RichEditor.PointerPipeline.cs). The handler above only adapts the event. + internal void PointerPressedCore(PointerStep s) { _pressLink = null; // every press re-arms; a stale value must never fire on a later release _canvas.Focus(FocusState.Pointer); // Right button is handled by RightTapped (context menu); don't collapse the selection here. - if (e.GetCurrentPoint(_canvas).Properties.IsRightButtonPressed) return; - var ptp = ViewToDoc(e.GetCurrentPoint(_canvas).Position); // doc space (identity unless paged) - if (TableDrawPointerPressed(ptp, e)) return; // "draw table" mode: drag from the caret to size it - if (BeginResizeDragAt(ptp)) { _canvas.CapturePointer(e.Pointer); return; } // picture handle / table boundary + if (s.RightButton) return; + var ptp = ViewToDoc(s.ViewPos); // doc space (identity unless paged) + if (TableDrawPointerPressed(ptp, s)) return; // "draw table" mode: drag from the caret to size it + if (BeginResizeDragAt(ptp)) { s.Capture.Capture(); return; } // picture handle / table boundary // Table left/top border -> select the whole table, and arm dragging it (RichEditor.DragBlock.cs). - if (TrySelectTableBlock(ptp)) { ArmObjectDrag(_selectedBlock, ptp, e); return; } - if (TrySelectInlineTable(ptp)) { ArmObjectDrag(_selectedInlineTable?.it, ptp, e); return; } - if (TryBeginImageInteraction(ptp, e)) return; // image resize handle or selection + if (TrySelectTableBlock(ptp)) { ArmObjectDrag(_selectedBlock, ptp, s); return; } + if (TrySelectInlineTable(ptp)) { ArmObjectDrag(_selectedInlineTable?.it, ptp, s); return; } + if (TryBeginImageInteraction(ptp, s)) return; // image resize handle or selection ClearObjectSelection(); // any other press clears an image selection var tp = GetPositionFromPoint(ptp); if (tp == null) return; @@ -321,7 +325,7 @@ private void OnCanvasPointerPressed(object sender, PointerRoutedEventArgs e) var now = DateTime.UtcNow; bool repeat = (now - _lastPressTime).TotalMilliseconds < _multiClickMs && Math.Abs(ptp.X - _lastPressPos.X) + Math.Abs(ptp.Y - _lastPressPos.Y) < MultiClickSlop; - var press = ChooseTextPress(Ctrl, Shift, repeat, CanArmTextDragAt(tp)); + var press = ChooseTextPress(s.Ctrl, s.Shift, repeat, CanArmTextDragAt(tp)); // Ctrl+click on a hyperlink opens it (Word/browser convention); the caret still moves there. if (press == TextPress.CtrlClick && tp.Paragraph != null) @@ -342,7 +346,7 @@ private void OnCanvasPointerPressed(object sender, PointerRoutedEventArgs e) // Read-only viewer: a PLAIN click on a link opens it (no Ctrl needed — browser convention). // Armed here, launched on release only when no drag-selection happened in between. - if (IsReadOnly && !Shift && LinkRunAtPoint(ptp)?.NavigateUri is { Length: > 0 } roUri) + if (IsReadOnly && !s.Shift && LinkRunAtPoint(ptp)?.NavigateUri is { Length: > 0 } roUri) _pressLink = (roUri, new Point(ptp.X, ptp.Y)); _clickCount = repeat ? _clickCount + 1 : 1; @@ -351,17 +355,17 @@ private void OnCanvasPointerPressed(object sender, PointerRoutedEventArgs e) // A single (non-repeat) press strictly inside the existing selection arms text drag & drop — // caret/selection stay put; release decides click vs move/copy (RichEditor.DragText.cs). - if (press == TextPress.ArmDrag) { ArmTextDrag(new Point(ptp.X, ptp.Y), e); return; } + if (press == TextPress.ArmDrag) { ArmTextDrag(new Point(ptp.X, ptp.Y), s); return; } _caret = tp; _desiredCaretX = ptp.X; _coalesceKey = null; // click starts a fresh undo group for subsequent typing _pendingCaretStyles = null; - _canvas.CapturePointer(e.Pointer); + s.Capture.Capture(); // Double-click selects the word under the caret; triple-click (or more) selects the paragraph. // No drag-select in these modes — keep the word/paragraph selection intact. - if (_clickCount >= 2 && !Shift && tp.Paragraph != null) + if (_clickCount >= 2 && !s.Shift && tp.Paragraph != null) { if (_clickCount == 2) { @@ -385,7 +389,7 @@ private void OnCanvasPointerPressed(object sender, PointerRoutedEventArgs e) return; } - if (Shift) { _selEnd = Clone(tp); } + if (s.Shift) { _selEnd = Clone(tp); } else { _selStart = Clone(tp); _selEnd = Clone(tp); } _isSelecting = true; RestartBlink(); @@ -394,17 +398,20 @@ private void OnCanvasPointerPressed(object sender, PointerRoutedEventArgs e) RaiseStatusChanged(); } - private void OnCanvasPointerMoved(object sender, PointerRoutedEventArgs e) + private void OnCanvasPointerMoved(object sender, PointerRoutedEventArgs e) => PointerMovedCore(StepFrom(e)); + + /// The move, driven by a (see RichEditor.PointerPipeline.cs). + internal void PointerMovedCore(PointerStep s) { - var vpos = e.GetCurrentPoint(_canvas).Position; // canvas (physical) coords + var vpos = s.ViewPos; // canvas (physical) coords var pt = ViewToDoc(vpos); // doc space (identity unless paged) if (TableDrawPointerMoved(pt)) return; // "draw table" mode: extend the rubber-band if (_resizingColumn) { ResizeColumn(pt); return; } if (_resizingRow) { ResizeRow(pt); return; } if (_resizingImage != null || _resizingInline != null) { TryResizeImage(pt); return; } - if (_dragObject != null) { DragObjectMoved(pt); return; } + if (_dragObject != null) { DragObjectMoved(pt, s.Ctrl); return; } if (_dragTextArmed) { DragTextMoved(pt); return; } - UpdateHoverCursor(pt); + UpdateHoverCursor(pt, s.Ctrl); if (!_isSelecting) return; var tp = GetPositionFromPoint(pt); if (tp == null) return; @@ -463,11 +470,11 @@ private void OnAutoScrollTick(object? sender, object e) // ProtectedCursor is set on the editor (this control); it resolves up the tree for the inner canvas. private InputSystemCursorShape _cursorShape = InputSystemCursorShape.IBeam; - private void UpdateHoverCursor(Point pt) + private void UpdateHoverCursor(Point pt, bool ctrl) { // Hand cursor over a hyperlink: always in a read-only viewer (plain click opens — browser // convention); with Ctrl held when editable (Ctrl+click opens — Word convention). - if ((Ctrl || IsReadOnly) && LinkAtPoint(pt)) { SetCursorShape(InputSystemCursorShape.Hand); return; } + if ((ctrl || IsReadOnly) && LinkAtPoint(pt)) { SetCursorShape(InputSystemCursorShape.Hand); return; } // Same order as the press: a selected picture's handle before a table boundary under it. var grip = IsReadOnly ? ResizeGrip.None : SelectedGripAt(pt).grip; if (grip != ResizeGrip.None) { SetCursorShape(GripCursor(grip)); return; } @@ -507,7 +514,11 @@ private void OnCanvasPointerWheel(object sender, PointerRoutedEventArgs e) // A normal release clears its own drag first (see EndColumnResize), so arriving after one is a no-op. // Text drag & drop is left alone: ending it drops the text, and a lost capture is not a drop. An object // drag is cancelled for the same reason — cancelled, not finished. - private void OnCanvasPointerCaptureLost(object sender, PointerRoutedEventArgs e) => EndPointerDrags(); + private void OnCanvasPointerCaptureLost(object sender, PointerRoutedEventArgs e) => PointerCaptureLostCore(); + + /// What a lost capture runs. A test's IPointerCapture.Release calls this the way WinUI + /// does — synchronously (see RichEditor.PointerPipeline.cs). + internal void PointerCaptureLostCore() => EndPointerDrags(); private void EndPointerDrags() { @@ -519,24 +530,29 @@ private void EndPointerDrags() if (_isSelecting) { _isSelecting = false; StopAutoScroll(); } } - private void OnCanvasPointerReleased(object sender, PointerRoutedEventArgs e) + private void OnCanvasPointerReleased(object sender, PointerRoutedEventArgs e) => PointerReleasedCore(StepFrom(e)); + + /// The release, driven by a . Each branch below finishes its work + /// BEFORE s.Capture.Release(), because that raises capture-lost synchronously and capture-lost + /// abandons whatever is live (see RichEditor.PointerPipeline.cs). + internal void PointerReleasedCore(PointerStep s) { - if (TableDrawPointerReleased(e)) return; // "draw table" mode: insert at the caret, dragged size - if (EndColumnResize(e)) return; - if (EndRowResize(e)) return; - if (EndImageResize(e)) return; - if (_dragObject != null) { EndObjectDrag(e); return; } - if (_dragTextArmed) { EndTextDrag(e); return; } + if (TableDrawPointerReleased(s)) return; // "draw table" mode: insert at the caret, dragged size + if (EndColumnResize(s)) return; + if (EndRowResize(s)) return; + if (EndImageResize(s)) return; + if (_dragObject != null) { EndObjectDrag(s); return; } + if (_dragTextArmed) { EndTextDrag(s); return; } _isSelecting = false; StopAutoScroll(); - _canvas.ReleasePointerCapture(e.Pointer); + s.Capture.Release(); if (IsFormatPainterActive) ApplyFormatPainterToSelection(); // Read-only link click: launch only if the press stayed a CLICK (no selection was dragged out // and the pointer didn't travel) — a drag over link text selects, exactly like a browser. if (_pressLink is { } pl) { _pressLink = null; - var rp = ViewToDoc(e.GetCurrentPoint(_canvas).Position); + var rp = ViewToDoc(s.ViewPos); if (!HasSelection && Math.Abs(rp.X - pl.pos.X) + Math.Abs(rp.Y - pl.pos.Y) < MultiClickSlop) _ = OpenUriAsync(pl.uri); // the URI captured at press, not a re-read of the caret } diff --git a/src/WinUIRichEditor/Controls/RichEditor.PointerPipeline.cs b/src/WinUIRichEditor/Controls/RichEditor.PointerPipeline.cs new file mode 100644 index 0000000..e47f0e3 --- /dev/null +++ b/src/WinUIRichEditor/Controls/RichEditor.PointerPipeline.cs @@ -0,0 +1,65 @@ +using Windows.Foundation; +using Microsoft.UI.Xaml.Input; + +namespace WinUIRichEditor.Controls; + +// The pointer pipeline: the seam that lets a test drive a whole gesture in the ORDER the framework +// produces it (press → capture → move → release → capture-lost), instead of calling the pieces. +// +// WHY THIS EXISTS. The pointer handlers used to take `PointerRoutedEventArgs` all the way down, and that +// type cannot be constructed — neither can `Pointer`. So the control exposed "the handler minus its +// pointer capture" (TableDrawPressAt, BeginImageResizeAt, ArmObjectDragAt …) and the tests called those. +// What that leaves untested is the capture itself, and capture is where the defects were: +// `ReleasePointerCapture` raises `PointerCaptureLost` SYNCHRONOUSLY, so a release handler that lets go of +// the pointer before it finishes its work is cancelled by its own release. That shipped once — every table +// draw silently inserted nothing (2026-09-19, regression of the fix that added the capture-lost cancel), +// with a full green suite; a person found it. Three other handlers carry the same ordering rule in a +// comment (EndColumnResize, EndRowResize, EndObjectDrag) and nothing enforced any of them. +// +// THE RULE. Everything a pointer handler needs from the framework is exactly this: where the pointer is, +// which button, which modifiers, and the ability to take and release the capture. `PointerStep` carries +// those four, so every handler below the event boundary takes a `PointerStep` and the event handlers +// themselves shrink to adapters that build one. The adapters must stay adapters — logic that lives in +// them is logic no test can reach, which is the hole this file closes. +// +// MODIFIERS. `Ctrl`/`Shift` in the pointer path used to be static reads of the real keyboard +// (InputKeyboardSource), which no test can set. They are read ONCE per event, in the adapter, and travel +// on the step. The keyboard handlers still read them directly — they are driven by key events, not here. +public partial class RichEditor +{ + /// The pointer capture, as the handlers use it. The real implementation is the canvas; + /// a test substitutes one that records the order and raises capture-lost the way WinUI does. + internal interface IPointerCapture + { + void Capture(); + /// Releases the capture. Like WinUI, this raises capture-lost SYNCHRONOUSLY when the + /// pointer was held — which is why every release handler finishes its work first. + void Release(); + } + + /// One pointer event, reduced to what the handlers actually read from it: + /// ViewPos is in canvas (physical) coordinates — the handlers call ViewToDoc themselves, + /// so the zoom/page mapping stays inside the tested path; RightButton, Ctrl and + /// Shift are the state at the moment of the event; Capture is the pointer capture. + internal readonly record struct PointerStep( + Point ViewPos, + bool RightButton, + bool Ctrl, + bool Shift, + IPointerCapture Capture); + + // The real capture: the canvas and the pointer that the event carried. + private sealed class CanvasCapture(Microsoft.UI.Xaml.UIElement canvas, Pointer pointer) : IPointerCapture + { + public void Capture() => canvas.CapturePointer(pointer); + public void Release() => canvas.ReleasePointerCapture(pointer); + } + + // The one place `PointerRoutedEventArgs` is unpacked. + private PointerStep StepFrom(PointerRoutedEventArgs e) + { + var p = e.GetCurrentPoint(_canvas); + return new PointerStep(p.Position, p.Properties.IsRightButtonPressed, Ctrl, Shift, + new CanvasCapture(_canvas, e.Pointer)); + } +} diff --git a/src/WinUIRichEditor/Controls/RichEditor.TableDraw.cs b/src/WinUIRichEditor/Controls/RichEditor.TableDraw.cs index 60643a1..ce9a9e9 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.TableDraw.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.TableDraw.cs @@ -48,10 +48,10 @@ private Point ClampDocPoint(Point p) => new(Math.Clamp(p.X, 0, Math.Max(0, _layoutWidth)), Math.Clamp(p.Y, 0, Math.Max(0, _measuredHeight))); // Pointer hooks, called from the main handlers. Return true when draw mode consumed the event. - private bool TableDrawPointerPressed(Point docPt, PointerRoutedEventArgs e) + private bool TableDrawPointerPressed(Point docPt, PointerStep s) { if (!TableDrawPressAt(docPt)) return false; - _canvas.CapturePointer(e.Pointer); + s.Capture.Capture(); return true; } @@ -98,7 +98,7 @@ private bool TableDrawPointerMoved(Point docPt) return true; // consume all moves while armed (keep the cross cursor, skip hover/selection) } - private bool TableDrawPointerReleased(PointerRoutedEventArgs e) + private bool TableDrawPointerReleased(PointerStep s) { if (_pendingTableDraw == null || _tableDrawStart == null) return false; // Insert BEFORE releasing the capture: ReleasePointerCapture raises PointerCaptureLost synchronously, and @@ -106,7 +106,7 @@ private bool TableDrawPointerReleased(PointerRoutedEventArgs e) // before it could insert (live check 2026-09-19: no drag inserted anything). The column drag has the same // order for the same reason (EndColumnResize). bool inserted = TableDrawReleaseAt(); - _canvas.ReleasePointerCapture(e.Pointer); + s.Capture.Release(); return inserted; } diff --git a/src/WinUIRichEditor/Controls/RichEditor.TableResize.cs b/src/WinUIRichEditor/Controls/RichEditor.TableResize.cs index 8cb202a..0a08ee2 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.TableResize.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.TableResize.cs @@ -224,11 +224,11 @@ internal static double ClampColumnDelta(double diff, double initColW, double ini } // Finish BEFORE releasing: the release raises PointerCaptureLost, whose handler must find nothing live. - private bool EndColumnResize(PointerRoutedEventArgs e) + private bool EndColumnResize(PointerStep s) { if (!_resizingColumn) return false; FinishColumnResize(); - _canvas.ReleasePointerCapture(e.Pointer); + s.Capture.Release(); return true; } @@ -282,11 +282,11 @@ private void ResizeRow(Point pt) RelayoutToViewport(); } - private bool EndRowResize(PointerRoutedEventArgs e) + private bool EndRowResize(PointerStep s) { if (!_resizingRow) return false; FinishRowResize(); // before the release (see EndColumnResize) - _canvas.ReleasePointerCapture(e.Pointer); + s.Capture.Release(); return true; } diff --git a/tests/WinUIRichEditor.Tests/ControlContextMenuTests.cs b/tests/WinUIRichEditor.Tests/ControlContextMenuTests.cs index 7f5e50c..13e4578 100644 --- a/tests/WinUIRichEditor.Tests/ControlContextMenuTests.cs +++ b/tests/WinUIRichEditor.Tests/ControlContextMenuTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -312,7 +312,7 @@ public void AViewersTableBorder_ShowsTheMoveCursor_AndAClickSelectsTheTable() object Cursor(Windows.Foundation.Point pt) { - typeof(RichEditor).GetMethod("UpdateHoverCursor", NP)!.Invoke(ed, new object[] { pt }); + typeof(RichEditor).GetMethod("UpdateHoverCursor", NP)!.Invoke(ed, new object[] { pt, false }); return typeof(RichEditor).GetField("_cursorShape", NP)!.GetValue(ed)!; } bool Select(Windows.Foundation.Point pt) => (bool)typeof(RichEditor).GetMethod("TrySelectTableBlock", NP)!.Invoke(ed, new object[] { pt })!; diff --git a/tests/WinUIRichEditor.Tests/ControlDragObjectTests.cs b/tests/WinUIRichEditor.Tests/ControlDragObjectTests.cs index 6af32c8..f2bf567 100644 --- a/tests/WinUIRichEditor.Tests/ControlDragObjectTests.cs +++ b/tests/WinUIRichEditor.Tests/ControlDragObjectTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Linq; @@ -360,13 +360,13 @@ public void TheGesture_AClickOnlySelects_ADragMoves() var press = new Point(r.Left, r.Top + r.Height / 2); Assert.True((bool)Call(ed, "ArmObjectDragAt", tb, press)!); - Call(ed, "DragObjectMoved", new Point(press.X + 1, press.Y + 1)); // inside the slop + Call(ed, "DragObjectMoved", new Point(press.X + 1, press.Y + 1), false); // inside the slop Assert.False((bool)Call(ed, "FinishObjectDrag", false)!); Assert.Equal("top,T,mid,end", Shape(ed)); Assert.False(ed.CanUndo); Assert.True((bool)Call(ed, "ArmObjectDragAt", tb, press)!); - Call(ed, "DragObjectMoved", new Point(press.X, r.Bottom + 2000)); + Call(ed, "DragObjectMoved", new Point(press.X, r.Bottom + 2000), false); Assert.True((bool)Call(ed, "FinishObjectDrag", false)!); Assert.Equal("top,mid,T,end", Shape(ed)); Assert.Null(Field(ed, "_dragObject")); @@ -398,7 +398,7 @@ public void ADocumentSwapMidDrag_CancelsTheDrag() var r = DrawnTableRect(ed, tb); var press = new Point(r.Left, r.Top + r.Height / 2); Assert.True((bool)Call(ed, "ArmObjectDragAt", tb, press)!); - Call(ed, "DragObjectMoved", new Point(press.X, r.Bottom + 2000)); + Call(ed, "DragObjectMoved", new Point(press.X, r.Bottom + 2000), false); Load(ed, P("other"), Table(), P("doc")); Assert.False((bool)Call(ed, "FinishObjectDrag", false)!); diff --git a/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs b/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs new file mode 100644 index 0000000..81ff8be --- /dev/null +++ b/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Threading; +using Microsoft.Graphics.Canvas; +using Windows.Foundation; +using WinUIRichEditor.Controls; +using WinUIRichEditor.Documents; +using WinUIRichEditor.Formatters; +using Xunit; + +namespace WinUIRichEditor.Tests; + +/// Whole pointer GESTURES, in the order the framework produces them: press → capture → move → +/// release → capture-lost (RichEditor.PointerPipeline.cs). +/// Every other control test drives "the handler minus its pointer capture" (TableDrawPressAt, +/// BeginColumnResizeAt …), because PointerRoutedEventArgs cannot be constructed. That leaves +/// the capture untested, and the capture is where the defects were: ReleasePointerCapture raises +/// PointerCaptureLost SYNCHRONOUSLY, so a release handler that lets go before finishing its work is +/// cancelled by its own release. That shipped once — every table draw inserted nothing (2026-09-19), with a +/// green suite; a person found it. These tests run the cores with a fake capture that keeps that timing. +/// ⚠ What is still outside automated reach: the framework's own routing, hit-testing and focus, and +/// whether WinUI really raises capture-lost the way does (measured in the app, and +/// the reason the fake is kept this small). The release gate for that is tools/fault-sweep.ps1 plus a live +/// check. +[Collection(UiTests.Collection)] +public class ControlPointerSequenceTests +{ + private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; + private static readonly Type T = typeof(RichEditor); + + /// The pointer capture as WinUI behaves: Release raises capture-lost synchronously, and + /// only when the pointer was actually held. It also records the call order, so a test can state it. + private sealed class FakeCapture(RichEditor editor) : RichEditor.IPointerCapture + { + public List Order { get; } = []; + public bool Held { get; private set; } + + public void Capture() { Held = true; Order.Add("capture"); } + + public void Release() + { + Order.Add("release"); + if (!Held) return; + Held = false; + Order.Add("lost"); // recorded so a test can state that the release really did raise it + editor.PointerCaptureLostCore(); + } + + /// Capture taken away from the outside (another window, a touch cancel) — no release. + public void Lose() + { + Order.Add("lost"); + Held = false; + editor.PointerCaptureLostCore(); + } + } + + // One hosted editor (coordinates need a real layout), documents swapped per test. + private static readonly Lazy Shared = new(() => + { + var ed = UiThread.Run(() => new RichEditor { Document = new FlowDocument(), PageSize = RichEditorPageSize.Continuous }); + UiThread.Host(ed); + return ed; + }, LazyThreadSafetyMode.ExecutionAndPublication); + + // Shared.Value OUTSIDE UiThread.Run: hosting waits on the UI thread, so resolving it inside deadlocks. + private static void Hosted(Action body) + { + var ed = Shared.Value; + UiThread.Run(() => + { + try { body(ed); } + finally { Call(ed, "CancelTableDraw"); Call(ed, "EndPointerDrags"); ed.IsReadOnly = false; } + }); + } + + private static object? Call(RichEditor ed, string name, params object?[] args) + { + try { return T.GetMethod(name, NP)!.Invoke(ed, args); } + catch (TargetInvocationException tie) when (tie.InnerException != null) + { + ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + throw; + } + } + + private static object? Field(RichEditor ed, string name) => T.GetField(name, NP)!.GetValue(ed); + + private static void Draw(RichEditor ed) + { + Call(ed, "RelayoutToViewport"); + using var rt = new CanvasRenderTarget(CanvasDevice.GetSharedDevice(), 1000, 2000, 96); + using var ds = rt.CreateDrawingSession(); + Call(ed, "DrawDocument", ds, new Rect(0, 0, 1000, 2000)); + } + + private static void Load(RichEditor ed, params Block[] blocks) + { + var doc = new FlowDocument(); + foreach (var b in blocks) doc.Blocks.Add(b); + ed.LoadJson(DocumentSerializer.Serialize(doc)); + Draw(ed); + } + + private static Paragraph Para(string text) => new() { Inlines = { new Run { Text = text } } }; + + private static TableBlock Table(int rows = 2, int cols = 2) + { + var tb = new TableBlock { Rows = rows, Columns = cols }; + for (int r = 0; r < rows; r++) + { + var row = new List(); + for (int c = 0; c < cols; c++) row.Add(new TableCell { Blocks = { Para($"r{r}c{c}") } }); + tb.Cells.Add(row); + } + for (int c = 0; c < cols; c++) tb.ColumnWidths.Add(120); + return tb; + } + + // The editor is at zoom 1 in Continuous mode, so view coordinates ARE document coordinates here — the + // cores still run ViewToDoc, which ControlZoomTests covers at 200/300 %. + private static RichEditor.PointerStep Step(Point at, FakeCapture cap, bool ctrl = false, bool shift = false, bool right = false) + => new(at, right, ctrl, shift, cap); + + private static Point CaretPoint(RichEditor ed) => DocPointOf(ed, Field(ed, "_caret")!); + + // Where a text position draws, in document space (the caret's top-left). + private static Point DocPointOf(RichEditor ed, object textPointer) + { + object boxed = Call(ed, "CaretToDocPoint", textPointer)!; + var ty = boxed.GetType(); + return new Point((double)ty.GetField("Item1")!.GetValue(boxed)!, (double)ty.GetField("Item2")!.GetValue(boxed)!); + } + + private static (int i, Rect rect)[] Bands(RichEditor ed, string field, TableBlock tb) + { + var dict = (IDictionary)Field(ed, field)!; + Assert.True(dict.Contains(tb), $"{field} has no bands for the table — did it draw?"); + return ((IList)dict[tb]!).Cast().Select(o => + { + var f = o.GetType().GetFields(); + return ((int)f[0].GetValue(o)!, (Rect)f[^1].GetValue(o)!); + }).ToArray(); + } + + private static Point ColumnEdge(RichEditor ed, TableBlock tb, int col) + { + var r = Bands(ed, "_columnBoundaries", tb)[col].rect; + return new Point(r.X + r.Width / 2, r.Y + r.Height / 2); + } + + private static int Tables(RichEditor ed) => ed.Document!.Blocks.OfType().Count(); + + // ---- the gesture that shipped broken ------------------------------------------------------------ + + // The release hands the table to the document BEFORE it lets go of the pointer. Reverse the two and the + // synchronous capture-lost cancels the draw a line before it can insert — which is exactly what shipped: + // every drag drew a preview and left nothing behind. + [Fact] + public void ADrawGesture_InsertsTheTable_AlthoughTheReleaseAlsoDropsTheCapture() + { + Hosted(ed => + { + Load(ed, Para("before"), Para("a"), Para("b"), Para("c"), Para("d"), Para("after")); + ed.BeginTableDraw(2, 3); + var cap = new FakeCapture(ed); + var at = CaretPoint(ed); + + ed.PointerPressedCore(Step(at, cap)); + Assert.True(cap.Held, "the press did not take the pointer"); + ed.PointerMovedCore(Step(new Point(at.X + 300, at.Y + 80), cap)); + ed.PointerReleasedCore(Step(new Point(at.X + 300, at.Y + 80), cap)); + + Assert.Equal(1, Tables(ed)); + // The "lost" is what makes this test able to fail at all: with a fake that released without + // raising capture-lost, the reversed (broken) order passes — measured. Assert it here so a + // weakened fake breaks THIS test instead of quietly making it vacuous. + Assert.Equal(new[] { "capture", "release", "lost" }, cap.Order); + Assert.False(cap.Held, "the pointer was not released"); + Assert.Null(Field(ed, "_tableDrawStart")); + }); + } + + // A lost capture is not a release: the drag is abandoned, not completed with the rectangle it had reached. + // Same rule as the coordinate-level test, but reached through the gesture, capture and all. + [Fact] + public void LosingTheCaptureMidDraw_InsertsNothing_AndALaterReleaseStaysQuiet() + { + Hosted(ed => + { + Load(ed, Para("before"), Para("after")); + ed.BeginTableDraw(2, 2); + var cap = new FakeCapture(ed); + var at = CaretPoint(ed); + + ed.PointerPressedCore(Step(at, cap)); + ed.PointerMovedCore(Step(new Point(at.X + 200, at.Y + 60), cap)); + cap.Lose(); + + Assert.Equal(0, Tables(ed)); + ed.PointerReleasedCore(Step(new Point(at.X + 200, at.Y + 60), cap)); + Assert.Equal(0, Tables(ed)); + Assert.False(ed.IsModified); + }); + } + + // ---- column drag, through the same gesture ------------------------------------------------------ + + // ⚠ This does NOT guard the column release's ORDER: reversing it (release, then finish) was measured + // to change nothing, because capture-lost FINISHES a column drag rather than cancelling it and + // FinishColumnResize is idempotent. The order rule is load-bearing only where capture-lost cancels — + // the table draw above, and the object drag (phase 2). + [Fact] + public void AColumnDragGesture_ResizesAndEndsWithNothingLive() + { + Hosted(ed => + { + Load(ed, Para("top"), Table(), Para("end")); + var tb = ed.Document!.Blocks.OfType().Single(); + double before = tb.ColumnWidths[0]; + var edge = ColumnEdge(ed, tb, 0); + var cap = new FakeCapture(ed); + + ed.PointerPressedCore(Step(edge, cap)); + Assert.True(cap.Held, "the press on the column boundary did not take the pointer"); + ed.PointerMovedCore(Step(new Point(edge.X + 40, edge.Y), cap)); + ed.PointerReleasedCore(Step(new Point(edge.X + 40, edge.Y), cap)); + + Assert.Equal(before + 40, tb.ColumnWidths[0], 1); + Assert.False((bool)Field(ed, "_resizingColumn")!, "still resizing after the release"); + Assert.False(cap.Held); + Assert.True(ed.CanUndo, "the drag left no undo step"); + }); + } + + // A lost capture ENDS a column drag rather than cancelling it — the width the user dragged to is what + // they saw, and there is no drop to undo (EndPointerDrags: FinishColumnResize, not a revert). The point + // of stating it here is the second half: nothing stays live, so the next hover does not go on resizing. + [Fact] + public void LosingTheCaptureMidColumnDrag_KeepsTheWidthAndEndsTheDrag() + { + Hosted(ed => + { + Load(ed, Para("top"), Table(), Para("end")); + var tb = ed.Document!.Blocks.OfType().Single(); + double before = tb.ColumnWidths[0]; + var edge = ColumnEdge(ed, tb, 0); + var cap = new FakeCapture(ed); + + ed.PointerPressedCore(Step(edge, cap)); + ed.PointerMovedCore(Step(new Point(edge.X + 30, edge.Y), cap)); + cap.Lose(); + + Assert.Equal(before + 30, tb.ColumnWidths[0], 1); + Assert.False((bool)Field(ed, "_resizingColumn")!, "still resizing after the capture was lost"); + + // The hover that follows must not resize anything (this is what an unended drag did). + double after = tb.ColumnWidths[0]; + ed.PointerMovedCore(Step(new Point(edge.X + 300, edge.Y), cap)); + Assert.Equal(after, tb.ColumnWidths[0], 1); + }); + } + + // Capture-lost arriving after a normal release is a no-op — the release cleared its own drag first. + // Without that, the pair "release then lost" would run the end path twice. + [Fact] + public void ACaptureLostAfterANormalRelease_ChangesNothing() + { + Hosted(ed => + { + Load(ed, Para("before"), Para("a"), Para("b"), Para("c"), Para("after")); + ed.BeginTableDraw(2, 2); + var cap = new FakeCapture(ed); + var at = CaretPoint(ed); + + ed.PointerPressedCore(Step(at, cap)); + ed.PointerMovedCore(Step(new Point(at.X + 200, at.Y + 60), cap)); + ed.PointerReleasedCore(Step(new Point(at.X + 200, at.Y + 60), cap)); + string after = ed.ToJson(); + + ed.PointerCaptureLostCore(); + + Assert.Equal(1, Tables(ed)); + Assert.Equal(after, ed.ToJson()); + }); + } + + // Control: the press core is the ordinary press too, not just the drag branches — a plain click moves + // the caret and takes the pointer, so these tests are running the real path and not a drag-only corner. + [Fact] + public void APlainPress_MovesTheCaret_AndTakesThePointer() + { + Hosted(ed => + { + Load(ed, Para("first line"), Para("second line")); + var second = ed.Document!.Blocks.OfType().Last(); + var at = DocPointOf(ed, new TextPointer(second, 0)); + var cap = new FakeCapture(ed); + + ed.PointerPressedCore(Step(new Point(at.X + 2, at.Y + 4), cap)); + + Assert.True(cap.Held, "a plain press did not take the pointer"); + Assert.Same(second, ((TextPointer)Field(ed, "_caret")!).Paragraph); + + ed.PointerReleasedCore(Step(new Point(at.X + 2, at.Y + 4), cap)); + Assert.False(cap.Held, "the release did not let go of the pointer"); + }); + } +}