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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ and follows [Semantic Versioning](https://semver.org/).
- **문단 레이아웃 캐시가 스크롤에 비례해 자랐다.** 뷰포트 크기일 거라던 캐시가 끝까지 스크롤하면 2,000문단 전부를
들고 있었다(2048에서 전부 비우는 안전망까지). 이제 그리기가 끝날 때마다 최근에 쓴 256개로 줄인다(텍스트 전용 2,000문단:
스크롤 뒤 152 → 136 MB). 줄어든 문단은 다시 보일 때 레이아웃을 새로 만든다 — CPU만 쓰고 화면엔 차이가 없다.
단 **방금 그린 것은 줄이지 않는다**(2026-09-29 감사에서 수정): 처음 모양은 그 그리기 자신이 쓴 레이아웃까지 잘라,
화면에 문단·셀이 384개를 넘게 보이면(예: 30×20 표) 아무것도 안 바뀐 캐럿 깜빡임마다 346개를 새로 만들고 버렸다.
- **시각 트리에서 빠진 에디터가 아무것도 놓지 않았다.** 이제 빠질 때 레이아웃을 모두 놓고, 화면 근처가 아닌 그림을
해제한다(화면에 있던 그림은 남겨 돌아올 때 바로 보인다).
- **그림을 해제한 뒤 Win2D 디바이스를 다듬는다**(`CanvasDevice.Trim`). 비트맵을 버려도 드라이버가 올리기에 쓴 메모리를
Expand Down
26 changes: 23 additions & 3 deletions Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트

---

## ✅ 현재 상태 (2026-09-24 · `1.2.0` 게시, 미게시 변경 누적 중)
## ✅ 현재 상태 (2026-09-29 · `1.2.0` 게시, 미게시 변경 누적 중)

| | |
|---|---|
| 릴리스 | 1.0.0(07-31, **API 동결**) → 1.1.0(08-07) → 1.1.1(09-06, AOT 결함 수정) → **1.2.0(09-20 게시)** |
| 빌드 | 라이브러리 0 warn / 0 err (테스트 프로젝트에 xUnit1031 1건 — 블로킹 대기, 기존) |
| 테스트 | **951**(2026-09-27, 1.2.0 게시 시 815, 1.0 시점 106). OS 클립보드 테스트는 경합으로 간헐적 빨강 — 단독 재실행으로 확인 |
| 테스트 | **953**(2026-09-29, 1.2.0 게시 시 815, 1.0 시점 106). OS 클립보드 테스트는 경합으로 간헐적 빨강 — 단독 재실행으로 확인 |
| AOT | self-contained 게시 성공(2026-09-20): 네이티브 exe **15.2MB**, 게시 **74.6MB**(pdb 제외), CoreCLR·관리 dll 없음 |
| 공개 표면 | **574** — `PublicAPI.Shipped.txt`로 추적, 1.2.0은 **추가 7**(breaking 없음) |

Expand Down Expand Up @@ -65,7 +65,8 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트
3. ~~**상류 백포트 잔여**~~ → **상류 PR #53까지 대조 끝**(2026-09-24). 라운드34(#53), 페이지 여백(#50·#52),
표 행·열 공개 API(#48), 단축키 표 공개(#49) — CHANGELOG `[Unreleased]`. (`Gesture(id)`는 Avalonia 전용이라 뺐다.
enum 순서는 포트 것 유지.)
4. **다음 게시(1.3.0) 전에 남은 검증** — 아래 "미게시 변경분 감사" 절의 남은 항목.
4. ~~**다음 게시(1.3.0) 전에 남은 검증**~~ → 미게시 변경분 감사(09-24), #59 감사와 AOT 동등성 스윕 재실행(09-29)
모두 끝났다. 다음은 1.3.0 게시 절차.
5. 아래 "알려진 한계"의 미수정 항목.

### 미게시 변경분 감사 (2026-09-24) — 결함 3(상류 공통), 테스트 910 → 930, 반증 완료
Expand Down Expand Up @@ -126,6 +127,25 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트
- **실기 확인 완료**(2026-09-28, 사용자, 문제 없음): 긴 사진 문서를 끝까지 스크롤했다 돌아올 때의 자리표시, 탭 전환 뒤
그림 표시, 툴바 글꼴 목록 전체. PR #59.

### PR #59 감사 (2026-09-29) — 결함 1(성능), 테스트 +2, 반증 3
09-24 감사 뒤에 들어온 자기 변경분(메모리 쥐어짜기, 라이브러리 ~250줄)만 골라 감사했다. 정독형 전수조사는 하지 않았다.
- **결함: 레이아웃 LRU가 그 패스 자신의 작업 집합을 잘랐다.** 한 패스가 384개(256 + 여유 128)를 넘게 쓰면, 먼저 그린 것이
패스 끝에는 가장 오래된 것이 되어 버려진다. 캐럿 깜빡임은 캔버스 전체를 다시 그리므로 **아무 변경 없이 0.5초마다 346개**를
만들고 버렸다(30×20 표, `LayoutBuilds` 훅으로 계측). 첫 프로브는 "재생성 0"으로 나왔다 — 남은 256개 집합만 비교해서다.
**남은 것 말고 만든 수를 셀 것.** 단서는 상류 대조였다: 상류 WIP(`perf/layout-cache-measure-split`)의 같은 LRU가
`renderStart` 이후 쓴 것은 건드리지 않는다. 같은 모양으로 고쳤다(`TrimLayoutCache(passStart)`). 반증: 보호를 끄면 346.
- 문제없음 확인(추적): 레이아웃을 await 너머로 쥐는 경로 없음(`BuildTextLayout` 호출부 전부 동기, 인라인 표 walk는 핀) ·
캔버스 무효화는 전부 전체 무효화라 보이는 그림의 위치 기록이 늘 새로 찍힘 · 인쇄/벡터 PDF는 D2D가 비트맵 참조를 따로 잡고
해제된 그림은 "한 번도 안 그린 그림"과 같은 상태(기존 인쇄 테스트가 덮음) · `CanvasDevice.Trim`은 Win2D가 D2D 리소스 잠금을
잡고 부른다(멀티스레드 팩토리 — 다른 스레드의 Win2D 렌더와 겹쳐도 안전).
- 새 테스트: 트리에서 빼기 → API 편집 → 다시 넣기 2회(반증 2: 표식/문단 레이아웃을 Dispose만 하고 남기면 다시 그릴 때 실패) ·
변경 없는 패스는 레이아웃을 만들지 않는다.
- 알고 둔 것: 같은 그림(내용 해시가 같은 키)이 멀리 떨어진 두 곳에 있으면 위치 기록은 마지막으로 그린 쪽 하나다. 화면에 있는
쪽이 해제되는 경우가 있어도 결과는 다음 그리기의 자리표시 한 번뿐이다.
- ~~**AOT 동등성 스윕 재실행**~~ → **2026-09-29 실측, 이 수정 포함**: `control`·`toolbar` 두 페이지 모두 JIT/AOT의 되읽은
문서가 **바이트 동일**(711자, 타이핑한 문장 포함), 여백·글꼴 프로브 동일(글꼴 236개 — 폴백이면 6개). AOT 전용 fault는
`LineMetricsOf` 하나(알려진 폴백, 1.2.0과 같음). AOT 게시: 네이티브 exe 15.2 MB, 게시 74.7 MB(pdb 제외).

### 클립보드 경합 재시도 (2026-09-24, PR #55)
클립보드 기록·관리자가 변경 직후 클립보드를 잠깐 연다(`CLIPBRD_E_CANT_OPEN`). 모든 호출부가 이 오류를 삼켜
붙여넣기 6%·복사 6%가 조용히 실패했다(개발 PC 실측). 그 오류만 **await**로 20 ms × 10회 재시도 — `Thread.Sleep`은
Expand Down
3 changes: 2 additions & 1 deletion src/WinUIRichEditor/Controls/RichEditor.Rendering.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public partial class RichEditor
// (plain paragraphs outside the clip advance by their cached height — see DrawContentWalk).
private void OnRegionsInvalidated(CanvasVirtualControl sender, CanvasRegionsInvalidatedEventArgs args)
{
long passStart = _layoutStamp; // layouts used from here on are this pass's own (TrimLayoutCache)
foreach (var region in args.InvalidatedRegions)
{
try
Expand All @@ -51,7 +52,7 @@ private void OnRegionsInvalidated(CanvasVirtualControl sender, CanvasRegionsInva
}
}
// The draw pass is over and nothing holds a layout or a bitmap: bound what scrolling left behind.
TrimLayoutCache();
TrimLayoutCache(passStart);
TrimOffscreenImages(ImageOffscreenBytes);
DrawPasses++;
}
Expand Down
20 changes: 14 additions & 6 deletions src/WinUIRichEditor/Controls/RichEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,27 @@ private sealed class LayoutEntry(long sig, double width, CanvasTextLayout layout
// (the slack keeps it from sorting on every frame). A trimmed paragraph that comes back into view rebuilds
// its layout — CPU only, nothing visible. Safe where the cap is not: at the end of a draw pass no walk is
// holding a layout, so no in-use layout can be disposed, whatever the number.
// ⚠ Never one the pass itself used (stamped after `passStart`): a visible table draws every cell, and trimming
// a pass's own working set had every caret blink (a full-canvas pass twice a second) rebuild and drop it —
// audit of 2026-09-29: a 30×20 table on screen built and disposed 346 native layouts per blink. The upstream
// peer's version of this trim (in progress 2026-09-28) guards the same way.
private const int LayoutKeep = 256, LayoutTrimSlack = 128;

private void TrimLayoutCache()
private void TrimLayoutCache(long passStart)
{
if (_layoutPinDepth != 0 || _layoutCache.Count <= LayoutKeep + LayoutTrimSlack) return;
var byAge = new List<KeyValuePair<Paragraph, LayoutEntry>>(_layoutCache);
byAge.Sort((a, b) => a.Value.Stamp.CompareTo(b.Value.Stamp));
for (int i = 0; i < byAge.Count - LayoutKeep; i++)
var old = new List<KeyValuePair<Paragraph, LayoutEntry>>();
foreach (var kv in _layoutCache) if (kv.Value.Stamp <= passStart) old.Add(kv);
old.Sort((a, b) => a.Value.Stamp.CompareTo(b.Value.Stamp));
for (int i = 0; i < old.Count && _layoutCache.Count > LayoutKeep; i++)
{
byAge[i].Value.Layout.Dispose();
_layoutCache.Remove(byAge[i].Key);
old[i].Value.Layout.Dispose();
_layoutCache.Remove(old[i].Key);
}
}

internal int LayoutBuilds; // test hook: layouts BuildTextLayout had to create (cache misses)

// ---- the per-paragraph side caches ------------------------------------
// Height, pagination lines and text statistics are cached per paragraph, keyed by identity and
// validated by ParagraphSig — so a stale entry is impossible. What a Dictionary<Paragraph, …> could
Expand Down Expand Up @@ -550,6 +557,7 @@ internal CanvasTextLayout BuildTextLayout(Paragraph p, double maxWidth)
}

var layout = CreateLayout(p, maxWidth);
LayoutBuilds++;
if (_layoutCache.TryGetValue(p, out var prev)) { prev.Layout.Dispose(); _layoutCache.Remove(p); }
// Don't evict while a walk holds a cached layout (see _layoutPinDepth): an inline-table walk keeps
// the HOST paragraph's layout live across building every cell layout, so a clear-all here would
Expand Down
94 changes: 93 additions & 1 deletion tests/WinUIRichEditor.Tests/ControlMemoryTrimTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ private static FlowDocument Paragraphs(int n)
}

private static IDictionary LayoutCache(RichEditor ed) => (IDictionary)T.GetField("_layoutCache", NP)!.GetValue(ed)!;
private static void TrimLayoutCache(RichEditor ed) => T.GetMethod("TrimLayoutCache", NP)!.Invoke(ed, null);
// As a draw pass that used none of them: every entry predates `passStart`.
private static void TrimLayoutCache(RichEditor ed)
=> T.GetMethod("TrimLayoutCache", NP)!.Invoke(ed, [(long)T.GetField("_layoutStamp", NP)!.GetValue(ed)!]);

[Fact]
public void TheLayoutCache_IsTrimmedToTheMostRecentlyUsed_AndDisposesWhatItDrops()
Expand Down Expand Up @@ -239,6 +241,44 @@ public void ADrawPass_TrimsTheLayoutCacheItFilled()
finally { Unhost(ed); }
}

// A table whose cells are all on screen at once: every cell paragraph is a layout the draw pass needs.
private static FlowDocument VisibleTable(int rows, int cols)
{
var tb = new TableBlock(rows, cols);
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
tb.Cells[r][c] = new TableCell(new Paragraph { Inlines = { new Run { Text = $"{r}.{c}", FontSize = 6 } } });
var doc = new FlowDocument();
doc.Blocks.Add(new Paragraph());
doc.Blocks.Add(tb);
doc.Blocks.Add(new Paragraph());
return doc;
}

// A pass that changes nothing — what every caret blink is, twice a second — must build nothing. With the trim
// cutting the pass's own working set back to LayoutKeep, 600 visible cells built and disposed 346 layouts a
// pass: the ones drawn first were the oldest by the end of it. (Audit of PR #59, 2026-09-29.)
[Fact]
public void APassThatChangesNothing_BuildsNoLayout_EvenWhenMoreThanLayoutKeepAreOnScreen()
{
var ed = HostedWith(VisibleTable(30, 20));
try
{
UiThread.Run(() => ed.Zoom = 0.5); // the whole table in view, whatever the host window's size
WaitForDraw(ed);
WaitForDraw(ed);
int builds = UiThread.Run(() => ed.LayoutBuilds);
WaitForDraw(ed);
UiThread.Run(() =>
{
Assert.True(ed.LayoutCacheCount >= 600,
$"the visible cells were not all drawn ({ed.LayoutCacheCount}): nothing is tested");
Assert.Equal(0, ed.LayoutBuilds - builds);
});
}
finally { Unhost(ed); }
}

// ---- leaving the visual tree ----------------------------------------------------------------------------

private static ImageCache Cache(RichEditor ed) => (ImageCache)T.GetField("_images", NP)!.GetValue(ed)!;
Expand Down Expand Up @@ -326,6 +366,58 @@ public void Unloading_ReleasesTheLayouts_AndThePicturesFarFromView_KeepsTheNearO
WaitUntil(() => ed.DeviceTrims > trimsBefore, "the deferred device trim");
}

private static IDictionary MarkerLayouts(RichEditor ed) => (IDictionary)T.GetField("_markerLayouts", NP)!.GetValue(ed)!;

// The Unloaded release disposes every layout (paragraphs AND list markers) while the editor still has a
// document a host can edit through the API. Anything that kept a layout past the release — a side cache,
// a field — would hand the next draw a disposed one. Two cycles: the second releases what the edits made
// while hidden. A disposed layout throws out of the draw handler, so the pass never completes and WaitForDraw
// times out — falsified both ways (markers or paragraph layouts disposed but left cached: red on the reload
// draw). (Audit of PR #59, 2026-09-29.)
[Fact]
public void EditingWhileUnloaded_AndComingBack_DrawsFromFreshLayouts_ThroughTwoCycles()
{
var doc = Paragraphs(400);
foreach (var p in doc.Blocks.OfType<Paragraph>().Take(5)) p.ListType = ListKind.Bullet;
var ed = HostedWith(doc);
var faults = new List<Exception>();
void OnFault(object? _, RichEditorFaultEventArgs e) { lock (faults) faults.Add(e.Exception); }
RichEditorDiagnostics.Reset();
RichEditorDiagnostics.Fault += OnFault;
try
{
UiThread.Run(() => Assert.True(ed.LayoutCacheCount > 0 && MarkerLayouts(ed).Count > 0,
"nothing was drawn: the release would have nothing to dispose"));
for (int cycle = 0; cycle < 2; cycle++)
{
Unhost(ed);
WaitUntil(() => !ed.IsLoaded && ed.LayoutCacheCount == 0, "the Unloaded release");
UiThread.Run(() =>
{
Assert.Empty(MarkerLayouts(ed));
ed.InsertText($"typed while hidden {cycle} ");
ed.ToggleBold();
ed.InsertTable(2, 2);
Assert.True(ed.LayoutCacheCount > 0, "the edits built no layout: the next cycle releases nothing new");
});
UiThread.Host(ed);
WaitForDraw(ed);
UiThread.Run(() =>
{
Assert.True(MarkerLayouts(ed).Count > 0, "the list markers were not drawn again");
Assert.Contains($"typed while hidden {cycle}", ed.GetPlainText());
});
}
lock (faults) Assert.DoesNotContain(faults, e => e is ObjectDisposedException);
}
finally
{
RichEditorDiagnostics.Fault -= OnFault;
RichEditorDiagnostics.Reset();
UiThread.Run(() => { if (ed.Parent is Microsoft.UI.Xaml.Controls.Panel p) p.Children.Remove(ed); });
}
}

// ---- the font list ----------------------------------------------------------------------------------------

[Fact]
Expand Down
Loading