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