Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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는 정확히 읽는다. 포트 결함이 아니다.
Expand Down
12 changes: 11 additions & 1 deletion src/WinUIRichEditor/Controls/RichEditor.Hyperlink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,22 @@ private TextPointer CaretJustPast(Paragraph p, int ch)
/// <summary>Launches the hyperlink at the caret in the system browser, if any.</summary>
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<Uri, Task>? 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); }
}

Expand Down
11 changes: 7 additions & 4 deletions src/WinUIRichEditor/Controls/RichEditor.Input.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>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).</summary>
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
Expand Down
175 changes: 174 additions & 1 deletion tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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<TableBlock>().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<TableBlock>().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<Paragraph>().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<string>();
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<Paragraph>().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<string>();
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<Paragraph>().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<Paragraph>().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]
Expand Down
Loading