diff --git a/Project_Roadmap.md b/Project_Roadmap.md
index 7089167..1576cc6 100644
--- a/Project_Roadmap.md
+++ b/Project_Roadmap.md
@@ -58,14 +58,35 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트
## 다음 우선순위 (1.3)
-1. **포인터 순서를 재현하는 상호작용 테스트.** 1.2.0의 회귀 하나(표 그리기 놓기가 캡처를 먼저 놓아 모든
- 삽입이 취소됨)를 좌표 단위 테스트가 못 잡고 **실기가 잡았다**. 상류는 `InteractionHost`(헤드리스 창에
- 실제 클릭·드래그·키 주입)로 이 계층을 덮는다 — 포트에는 아직 없다.
+1. ~~**포인터 순서를 재현하는 상호작용 테스트**~~ → **2026-09-20 1~3단계 완료**(아래 절). 남은 것은
+ 프레임워크의 라우팅·히트테스트·포커스뿐이고, 그건 여전히 `fault-sweep` + 실기의 몫이다.
2. **실기에만 있는 검증 항목**: AltGr 자판(처리기 배선은 `KeyRoutedEventArgs`를 만들 수 없어 자동 검증 밖),
IME 조합, 포커스·캐럿 깜빡임.
3. **상류 백포트 잔여**: 라운드31~32 이후 상류 변경분 대조.
4. 아래 "알려진 한계"의 미수정 항목.
+### 포인터 파이프라인 (2026-09-20) — 1~3단계 완료, 테스트 815 → 832, 결함 1건(포트 전용), 반증 16종
+포인터 처리기가 이벤트에서 읽는 것은 **위치·오른쪽 버튼·모디파이어·캡처** 넷뿐이다. 그것을 `PointerStep`으로
+묶고 캡처를 `IPointerCapture`로 추상화해(`RichEditor.PointerPipeline.cs`) 처리기를 어댑터로 줄였다. 테스트는
+`FakeCapture`로 **누르기→캡처→이동→놓기→캡처상실**을 실제 순서로 구동한다 — 해제가 캡처 상실을 **동기로**
+일으키는 것까지. 포인터 경로의 `Ctrl`/`Shift` 정적 키보드 읽기도 전부 step으로 옮겨, Ctrl 복사 끌기·Ctrl+클릭
+링크·Shift 선택·Ctrl+휠이 처음으로 자동 검증에 들어왔다. 공개 표면 변화 없음(전부 `internal`).
+- **결함(포트 전용)**: **캡처를 잃은 텍스트 끌기가 무장된 채 남아 다음 클릭이 드롭을 수행했다.** 옛 주석은
+ 이것을 의도라고 적고 있었다("끝내면 텍스트가 떨어진다") — *끝내기*와 *떨어뜨리기*는 다르다. 개체 끌기와
+ 같은 규칙으로 `CancelTextDrag()`를 만들어 캡처 상실·문서 교체에서 부른다. 상류엔 문서 내 텍스트 끌기가
+ 없어 대조할 선례가 없었다.
+- ⚠ **반증이 가르쳐 준 두 가지**: ① **열 끌기 놓기 순서를 뒤집어도 아무것도 안 빨개진다** — 캡처 상실이
+ 열 끌기는 *취소*가 아니라 *완료*하고 `FinishColumnResize`가 멱등이라 실제로 무해하다(상류와 같다).
+ 순서 규칙이 힘을 쓰는 곳은 **캡처 상실이 취소하는 경로**(표 그리기·개체 끌기)뿐이다. ② **가짜가 캡처
+ 상실을 동기로 안 쏘면 깨진 순서도 통과한다** — 보증이 가짜의 충실도에 얹혀 있으므로 호출 순서
+ `capture → release → lost`를 테스트가 직접 단정한다.
+- ⚠ **하네스 함정(제품 아님)**: 공유 에디터에 **같은 좌표를 밀리초 간격으로** 누르면 다중 클릭이 되어
+ 누르기가 끌기 무장 대신 단어 선택이 된다. 실패 메시지는 제품 결함처럼 보인다 — `Hosted`와 `NoRepeat`가
+ `_lastPressTime`/`_clickCount`를 초기화한다.
+- **테스트 seam 1개 추가**(`internal`): `RichEditor.LaunchOverride` — 어느 제스처가 링크를 여는지는
+ 브라우저를 띄우지 않고는 단정할 수 없었다. 결정(`IsLaunchableLink`)은 전부터 직접 시험돼 있었고, 이제
+ 거기 **닿는 경로**까지 덮는다.
+
## 알려진 한계 (의도적)
- **HWP는 RTF 중첩 표를 구현하지 않는다** — `\nestcell`을 버리고 중첩 셀 텍스트를 이어 붙인다. 우리 출력은
상류와 바이트 동일하고 Word는 정확히 읽는다. 포트 결함이 아니다.
diff --git a/src/WinUIRichEditor/Controls/RichEditor.Hyperlink.cs b/src/WinUIRichEditor/Controls/RichEditor.Hyperlink.cs
index d98b498..fe7021b 100644
--- a/src/WinUIRichEditor/Controls/RichEditor.Hyperlink.cs
+++ b/src/WinUIRichEditor/Controls/RichEditor.Hyperlink.cs
@@ -194,12 +194,22 @@ private TextPointer CaretJustPast(Paragraph p, int ch)
/// Launches the hyperlink at the caret in the system browser, if any.
public Task OpenLinkAtCaretAsync() => OpenUriAsync(CurrentLinkUri());
+ // Test seam: set, the URI goes here instead of the system browser. WHICH gesture launches a link
+ // (Ctrl+click when editable, a plain click in a viewer, and neither on a drag) had no automated test
+ // at all, because asserting it means launching a browser on the test machine. The decision itself
+ // (IsLaunchableLink) is tested directly; this covers the path that reaches it.
+ internal Func? LaunchOverride { get; set; }
+
// Launches a specific absolute URI. Callers that captured the link at press time (the read-only
// plain-click path) pass it in rather than re-reading the caret, which may have moved since.
internal async Task OpenUriAsync(string? url)
{
if (!IsLaunchableLink(url, out var uri)) return;
- try { await Windows.System.Launcher.LaunchUriAsync(uri); }
+ try
+ {
+ if (LaunchOverride is { } launch) await launch(uri);
+ else await Windows.System.Launcher.LaunchUriAsync(uri);
+ }
catch (Exception ex) { RichEditorDiagnostics.Report(ex); }
}
diff --git a/src/WinUIRichEditor/Controls/RichEditor.Input.cs b/src/WinUIRichEditor/Controls/RichEditor.Input.cs
index a3843cb..6654e80 100644
--- a/src/WinUIRichEditor/Controls/RichEditor.Input.cs
+++ b/src/WinUIRichEditor/Controls/RichEditor.Input.cs
@@ -505,12 +505,15 @@ private void SetCursorShape(InputSystemCursorShape shape)
// Ctrl+wheel zoom (Word/browser convention): ~10% per notch, clamped by SetZoom, which also leaves
// fit-to-width mode. Handled so the ScrollViewer doesn't scroll instead; a plain wheel falls through.
private void OnCanvasPointerWheel(object sender, PointerRoutedEventArgs e)
+ => e.Handled = PointerWheelCore(e.GetCurrentPoint(_canvas).Properties.MouseWheelDelta, Ctrl);
+
+ /// The wheel, driven by its two inputs. Returns whether it was handled — the ScrollViewer
+ /// scrolls when it was not (see RichEditor.PointerPipeline.cs for why the modifier is a parameter).
+ internal bool PointerWheelCore(int delta, bool ctrl)
{
- if (!Ctrl) return;
- int delta = e.GetCurrentPoint(_canvas).Properties.MouseWheelDelta;
- if (delta == 0) return;
+ if (!ctrl || delta == 0) return false;
SetZoom(EffectiveZoom * Math.Pow(1.1, delta / 120.0));
- e.Handled = true;
+ return true;
}
// Capture can end without a release reaching the canvas (the window deactivated mid-drag, another element
diff --git a/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs b/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs
index 9a75e45..5776dd2 100644
--- a/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs
+++ b/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
@@ -319,6 +319,14 @@ private static Point TableMoveBorder(RichEditor ed, TableBlock tb)
_ => "?",
}));
+ // Two gestures at the same point inside ONE test are a double-click unless the timing is cleared —
+ // the press then selects a word instead of doing what the test is about (see Hosted).
+ private static void NoRepeat(RichEditor ed)
+ {
+ T.GetField("_lastPressTime", NP)!.SetValue(ed, DateTime.MinValue);
+ T.GetField("_clickCount", NP)!.SetValue(ed, 0);
+ }
+
private static void Select(RichEditor ed, Paragraph p, int from, int to)
{
T.GetField("_selStart", NP)!.SetValue(ed, new TextPointer(p, from));
@@ -487,6 +495,171 @@ public void ReplacingTheDocumentMidTextDrag_DisarmsIt()
});
}
+ // ---- what the modifiers do (phase 3) ------------------------------------------------------------
+ // These gestures had NO automated test before the pipeline: Ctrl and Shift were static reads of the
+ // real keyboard (InputKeyboardSource), which a test cannot set. They ride on the step now.
+
+ [Fact]
+ public void CtrlAtTheDrop_CopiesTheTableInsteadOfMovingIt()
+ {
+ Hosted(ed =>
+ {
+ Load(ed, Para("top"), Table(), Para("end"));
+ var tb = ed.Document!.Blocks.OfType().Single();
+ var border = TableMoveBorder(ed, tb);
+ var cap = new FakeCapture(ed);
+ var drop = new Point(border.X, border.Y + 2000);
+
+ ed.PointerPressedCore(Step(border, cap));
+ ed.PointerMovedCore(Step(drop, cap, ctrl: true));
+ ed.PointerReleasedCore(Step(drop, cap, ctrl: true));
+
+ // Copied, not moved: there are two tables and the ORIGINAL instance is still in the document.
+ Assert.Equal(2, ed.Document!.Blocks.OfType().Count());
+ Assert.Contains(tb, ed.Document!.Blocks);
+ Assert.Equal("top,T,∅,T,end", Shape(ed)); // the drop's landing paragraph is left behind empty
+ });
+ }
+
+ [Fact]
+ public void CtrlAtTheDrop_CopiesTheDraggedTextInsteadOfMovingIt()
+ {
+ Hosted(ed =>
+ {
+ Load(ed, Para("drag this text"), Para("target line"));
+ var paras = ed.Document!.Blocks.OfType().ToArray();
+ Select(ed, paras[0], 0, 4); // "drag"
+ var inside = DocPointOf(ed, new TextPointer(paras[0], 2));
+ var target = DocPointOf(ed, new TextPointer(paras[1], 6));
+ var cap = new FakeCapture(ed);
+
+ ed.PointerPressedCore(Step(inside, cap));
+ Assert.True((bool)Field(ed, "_dragTextArmed")!, "the press inside the selection did not arm the drag");
+ ed.PointerMovedCore(Step(target, cap, ctrl: true));
+ ed.PointerReleasedCore(Step(target, cap, ctrl: true));
+
+ // A copy leaves the source intact and puts a second "drag" at the drop point.
+ string text = ed.GetPlainText();
+ Assert.Contains("drag this text", text);
+ Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(text, "drag").Count);
+ });
+ }
+
+ [Fact]
+ public void CtrlClickOpensALink_APlainClickDoesNot_AndADragNeverDoes()
+ {
+ Hosted(ed =>
+ {
+ var launched = new List();
+ ed.LaunchOverride = u => { launched.Add(u.ToString()); return Task.CompletedTask; };
+ try
+ {
+ var link = new Run { Text = "example", NavigateUri = "https://example.com/" };
+ var p = new Paragraph { Inlines = { link } };
+ Load(ed, p, Para("after"));
+ var para = ed.Document!.Blocks.OfType().First();
+ var onLink = DocPointOf(ed, new TextPointer(para, 3));
+ var cap = new FakeCapture(ed);
+
+ // Plain click: the caret moves, nothing opens.
+ ed.PointerPressedCore(Step(onLink, cap));
+ ed.PointerReleasedCore(Step(onLink, cap));
+ Assert.Empty(launched);
+
+ // Ctrl+click: opens the link under the POINTER.
+ NoRepeat(ed);
+ ed.PointerPressedCore(Step(onLink, cap, ctrl: true));
+ ed.PointerReleasedCore(Step(onLink, cap, ctrl: true));
+ Assert.Equal(new[] { "https://example.com/" }, launched);
+ }
+ finally { ed.LaunchOverride = null; }
+ });
+ }
+
+ [Fact]
+ public void InAViewer_APlainClickOpensTheLink_ButADragOverItOnlySelects()
+ {
+ Hosted(ed =>
+ {
+ var launched = new List();
+ ed.LaunchOverride = u => { launched.Add(u.ToString()); return Task.CompletedTask; };
+ try
+ {
+ var p = new Paragraph { Inlines = { new Run { Text = "example link", NavigateUri = "https://example.com/" } } };
+ Load(ed, p, Para("after"));
+ ed.IsReadOnly = true;
+ var para = ed.Document!.Blocks.OfType().First();
+ var start = DocPointOf(ed, new TextPointer(para, 1));
+ var far = DocPointOf(ed, new TextPointer(para, 10));
+ var cap = new FakeCapture(ed);
+
+ // A drag across the link selects it (browser convention) — and opens nothing.
+ ed.PointerPressedCore(Step(start, cap));
+ ed.PointerMovedCore(Step(far, cap));
+ ed.PointerReleasedCore(Step(far, cap));
+ Assert.Empty(launched);
+ Assert.NotEqual(((TextPointer)Field(ed, "_selStart")!).Offset,
+ ((TextPointer)Field(ed, "_selEnd")!).Offset); // it selected instead
+
+ // A click that stays put opens it. (Without this the second press at the same point is a
+ // DOUBLE-click, which selects the word — and a selection suppresses the launch.)
+ NoRepeat(ed);
+ ed.PointerPressedCore(Step(start, cap));
+ ed.PointerReleasedCore(Step(start, cap));
+ Assert.Equal(new[] { "https://example.com/" }, launched);
+ }
+ finally { ed.LaunchOverride = null; ed.IsReadOnly = false; }
+ });
+ }
+
+ [Fact]
+ public void ShiftPress_ExtendsTheSelectionFromWhereItWas()
+ {
+ Hosted(ed =>
+ {
+ Load(ed, Para("first line here"), Para("second line here"));
+ var paras = ed.Document!.Blocks.OfType().ToArray();
+ var a = DocPointOf(ed, new TextPointer(paras[0], 2));
+ var b = DocPointOf(ed, new TextPointer(paras[1], 6));
+ var cap = new FakeCapture(ed);
+
+ ed.PointerPressedCore(Step(a, cap));
+ ed.PointerReleasedCore(Step(a, cap));
+ var start = (TextPointer)Field(ed, "_selStart")!;
+ Assert.Same(paras[0], start.Paragraph);
+ Assert.Equal(start.Offset, ((TextPointer)Field(ed, "_selEnd")!).Offset); // a plain click selects nothing
+
+ ed.PointerPressedCore(Step(b, cap, shift: true));
+ ed.PointerReleasedCore(Step(b, cap, shift: true));
+
+ // Shift moved only the far end: the anchor is still where the first click put it.
+ var end = (TextPointer)Field(ed, "_selEnd")!;
+ Assert.Same(paras[0], ((TextPointer)Field(ed, "_selStart")!).Paragraph);
+ Assert.Same(paras[1], end.Paragraph);
+ Assert.Equal(6, end.Offset);
+ });
+ }
+
+ [Fact]
+ public void TheWheel_ZoomsOnlyWithCtrl()
+ {
+ Hosted(ed =>
+ {
+ Load(ed, Para("text"));
+ double at100 = ed.Zoom;
+
+ Assert.False(ed.PointerWheelCore(120, ctrl: false), "a plain wheel was handled (the view could not scroll)");
+ Assert.Equal(at100, ed.Zoom, 3);
+
+ Assert.True(ed.PointerWheelCore(120, ctrl: true));
+ Assert.True(ed.Zoom > at100, "Ctrl+wheel did not zoom in");
+ Assert.True(ed.PointerWheelCore(-120, ctrl: true));
+ Assert.Equal(at100, ed.Zoom, 2);
+
+ Assert.False(ed.PointerWheelCore(0, ctrl: true)); // no notch, nothing to do
+ });
+ }
+
// 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]