diff --git a/CHANGELOG.md b/CHANGELOG.md index c144242..e4ee766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ and follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### 메모리 — 스크롤·숨김·글꼴 목록이 남기던 것 (2026-09-27) + +지금까지의 메모리 측정은 전부 **맨 위에 놓인 문서**를 쟀다. 렌더 경로는 **그린 것**을 캐시하는데, 문서를 끝까지 한 번 +넘기면 전부를 그린다. `samples/MemBaseline`에 스크롤 시나리오를 넣어 처음 쟀다(Release, Intel UHD, Private bytes). + +| 2,000문단 + 사진 100장 | 전 | 후 | +|---|---|---| +| 끝까지 스크롤 | 501 MB | **257 MB** | +| 다시 맨 위로 | 499 MB | **217 MB** | +| 에디터를 시각 트리에서 뺌(숨긴 탭 등) | 482 MB | **185 MB** | +| 다시 넣음 | 481 MB | **150 MB** | + +- **화면에서 먼 그림은 디코드를 놓는다.** 문서에 있는 그림은 한 번 그려지면 문서가 열려 있는 한 계속 디코드된 채였다. + 이제 화면과 위아래 한 화면씩을 뺀 곳의 그림은 가장 오래전에 그린 것부터 해제하고, 남는 양을 32 MB로 묶는다. 다시 + 화면에 들어온 그림은 다시 디코드되며, 그동안 자리표시가 보인다. 편집으로 지운 그림은 전처럼 되돌리기용 예산을 따로 쓴다. +- **문단 레이아웃 캐시가 스크롤에 비례해 자랐다.** 뷰포트 크기일 거라던 캐시가 끝까지 스크롤하면 2,000문단 전부를 + 들고 있었다(2048에서 전부 비우는 안전망까지). 이제 그리기가 끝날 때마다 최근에 쓴 256개로 줄인다(텍스트 전용 2,000문단: + 스크롤 뒤 152 → 136 MB). 줄어든 문단은 다시 보일 때 레이아웃을 새로 만든다 — CPU만 쓰고 화면엔 차이가 없다. +- **시각 트리에서 빠진 에디터가 아무것도 놓지 않았다.** 이제 빠질 때 레이아웃을 모두 놓고, 화면 근처가 아닌 그림을 + 해제한다(화면에 있던 그림은 남겨 돌아올 때 바로 보인다). +- **그림을 해제한 뒤 Win2D 디바이스를 다듬는다**(`CanvasDevice.Trim`). 비트맵을 버려도 드라이버가 올리기에 쓴 메모리를 + 쥐고 있었다 — 맨 위로 돌아온 뒤 Private 38 MB·GPU 70 MB를 더 돌려준다. 호출은 마지막 해제 2초 뒤 한 번(약 0.7 ms)이다. +- **글꼴 목록이 설치된 글꼴을 전부 열었다.** 툴바가 처음 글꼴 목록을 물을 때 +31.6 MB·UI 스레드 843 ms였고, 관리 힙이 + 거의 안 늘어 GC가 돌 이유가 없는 앱에서는 그대로 남았다. 이제 +0.7 MB·140 ms이고 이름은 같다(이전 방식과 이름 단위 비교). +- 그림 이미지 교체 뒤 레이아웃 캐시를 비우던 곳이 네이티브 레이아웃을 Dispose하지 않았다(파이널라이저 몫) — 고쳤다. +- 참고: 빈 에디터 하나의 고정비(~37 MB)는 **전부 Win2D 디바이스**다 — 맨 `CanvasVirtualControl` 하나(111.6 MB)가 빈 + 에디터(108.3 MB)보다 크다. 라이브러리 자체의 고정비는 없다. + ### 다른 앱에서 보이는 HTML — 상류 라운드 9 백포트 (2026-09-24) 상류가 2026-08-08에 브라우저로 **측정**해서 고친 HTML 결함 중 두 건이 이 포트에 없었다. 둘 다 이 편집기의 HTML diff --git a/Project_Roadmap.md b/Project_Roadmap.md index 1d53c89..055aac7 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -18,7 +18,7 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트 |---|---| | 릴리스 | 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건 — 블로킹 대기, 기존) | -| 테스트 | **930**(2026-09-24, 1.2.0 게시 시 815, 1.0 시점 106). OS 클립보드 테스트는 경합으로 간헐적 빨강 — 단독 재실행으로 확인 | +| 테스트 | **951**(2026-09-27, 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 없음) | @@ -104,6 +104,28 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트 기하 표기, RTF 용지)은 이미 있었다. `HtmlExternalRenderingTests`(8), 반증 3종, 퍼즈 20,000시드 통과. **실기 확인 완료**(2026-09-24, 사용자): 빈 줄을 복사해 Word·HWP에 붙이면 한 줄로 들어가고, 셀 속 그림은 캡션 아래에 온다. PR #57 머지. +### 메모리 쥐어짜기 (2026-09-27) — 테스트 +12, 반증 15/15, AOT 프로브 동일 +처음으로 **읽는 동안**의 메모리를 쟀다(`MemBaseline` `MEMTEST_MODE=scroll`: 끝까지 넘김 → 맨 위 → 트리에서 뺌 → 다시 +넣음, `win2d`: 맨 `CanvasVirtualControl`, `fonts`: 글꼴 목록). 수치는 CHANGELOG. 순서대로 효과가 컸다: +1. **화면에서 먼 그림 해제**(`ImageCache.TrimOffscreen`, 드로 패스 끝) — 그림은 마지막으로 그려진 **컨트롤 좌표**와 + 사용 순서를 기록하고, 뷰포트 ±1화면 밖을 LRU로 32 MB(`ImageOffscreenBytes`)까지 줄인다. 보호 판정을 "이번 패스에 + 그렸나"로 하면 안 된다 — 스크롤은 새로 드러난 띠만 다시 그리므로, 화면에 있는 그림도 여러 패스 동안 안 그려진다. +2. **레이아웃 캐시 LRU**(`TrimLayoutCache`, 256 + 여유 128) — 드로 패스 끝에서만 한다. 그때는 어느 걷기도 레이아웃을 + 쥐고 있지 않아 쓰는 중인 것을 Dispose할 수 없다(2048 clear-all은 그 보장이 없어서 캡을 크게 둔 것). +3. **Unloaded 해제** + **디바이스 Trim**(해제 2초 뒤 디바운스). 텍스트만 스크롤 뒤의 GPU 증가분(~90 MB)은 대부분 XAML + 가상 표면 타일이라 Trim으로 안 준다(4 MB) — 스크롤 멈춤마다 Trim하지 않는 이유. +4. **글꼴 목록** — `CanvasTextFormat.GetSystemFontFamilies(string[])`. ⚠ **AOT 함정 두 개를 프로브가 잡았다**(JIT 테스트는 + 통과): `CanvasFontSet.GetPropertyValues`는 비blittable 구조체 배열(`CanvasFontProperty[]`)이라 AOT에서 실패, 로캘 목록을 + 컬렉션 식 `[..]`으로 넘기면 컴파일러 합성 형식이라 CCW 생성 실패. 둘 다 조용히 기본 글꼴 6개로 폴백했다. + 데모 `--pageprobe`에 `fonts` 줄(개수 + FNV 해시)을 넣어 `fault-sweep`이 JIT/AOT를 diff한다. + +- 레버가 아닌 것(측정으로 확인): **고정비 ~37 MB는 전부 Win2D**(맨 CanvasVirtualControl 111.6 > 빈 에디터 108.3 MB) · + 되돌리기(이미 요소당 155 B로 실측 보정) · 모델 객체(런 ~100 B) · 텍스트 로드의 네이티브 증가분(DirectWrite 몫, 문단 수에 준선형). +- **상류 후속 후보**: 상류 `_layoutCache`는 **측정 경로도** 같은 캐시를 써서 문서 전체(최대 10,000)를 들고 있다 — 포트가 + 2026-06에 높이/레이아웃을 분리하기 전 모양. Avalonia `TextLayout`은 관리 객체라 규모는 따로 재야 한다(계측 먼저). +- **실기 확인 완료**(2026-09-28, 사용자, 문제 없음): 긴 사진 문서를 끝까지 스크롤했다 돌아올 때의 자리표시, 탭 전환 뒤 + 그림 표시, 툴바 글꼴 목록 전체. PR #59. + ### 클립보드 경합 재시도 (2026-09-24, PR #55) 클립보드 기록·관리자가 변경 직후 클립보드를 잠깐 연다(`CLIPBRD_E_CANT_OPEN`). 모든 호출부가 이 오류를 삼켜 붙여넣기 6%·복사 6%가 조용히 실패했다(개발 PC 실측). 그 오류만 **await**로 20 ms × 10회 재시도 — `Thread.Sleep`은 @@ -234,7 +256,8 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트 (우회로: 7-Zip AES / BitLocker·VeraCrypt로 감싸면 된다). - **미측정 성능 후보 2건**(옛 `implementation_plan.md`에서 살아남은 것 — 그 파일은 2026-09-20에 지웠다. 나머지 제안은 이미 반영됐거나(`FindCell`은 `Parent` 기반) 근거가 없었다): - ① `EvictLayouts()`가 캐시를 **전부** 비운다 — 바로 위 주석은 "가장 오래된 것만"이라고 말한다(주석/코드 불일치). + ~~① `EvictLayouts()`가 캐시를 전부 비운다~~ → 2026-09-27: 주석을 "안전망"으로 고치고, 평상시 경계는 드로 패스 끝의 + LRU(`TrimLayoutCache`)가 맡는다. ② `GetImageCount()`가 호출마다 트리를 훑는다(`AfterEdit` 경로). ⚠ 둘 다 **계측부터** 할 것 — 이 리포에서 읽어서 세운 성능 가설은 전부 빗나갔다. diff --git a/samples/MemBaseline/MainWindow.cs b/samples/MemBaseline/MainWindow.cs index b2d4d6a..e061f61 100644 --- a/samples/MemBaseline/MainWindow.cs +++ b/samples/MemBaseline/MainWindow.cs @@ -20,6 +20,9 @@ namespace MemBaseline; /// reclaim — load big doc, then clear it + GC, logging WS before/after (reclaim/leak check) /// images — insert photos / delete them by EDITING / swap / drop history, logging Priv AND GPU memory /// (see ImageScenario) +/// scroll page through a long document to its end, then unload/reload the editor (see ScrollScenario) +/// win2d the bare CanvasVirtualControl the editor draws on — editor minus this is the library's own cost +/// fonts what the toolbar's font list costs (MEMTEST_FONTPROBE=1 also compares two ways of reading it) /// Results are read externally via Get-Process; reclaim mode also writes a small text log. /// public sealed class MainWindow : Window @@ -38,6 +41,23 @@ public MainWindow() return; } + // Win2D alone: the bare CanvasVirtualControl the editor is built on, in a ScrollViewer, drawing one + // line of text. editor − win2d = what the library itself adds on top of its render backend. + if (mode == "win2d") + { + var cvc = new Microsoft.Graphics.Canvas.UI.Xaml.CanvasVirtualControl { Width = 800, Height = 600 }; + cvc.RegionsInvalidated += (s, e) => + { + foreach (var r in e.InvalidatedRegions) + { + using var ds = s.CreateDrawingSession(r); + ds.DrawText("win2d baseline", 10, 10, Microsoft.UI.Colors.Black); + } + }; + root.Children.Add(new ScrollViewer { Content = cvc }); + return; + } + // Multi-instance: N empty RichEditors in the visual tree, each sized so it loads and // initializes its Win2D render path. Reveals whether the CanvasDevice is shared across // instances (small per-instance increment) or per-control (large increment). @@ -84,6 +104,54 @@ public MainWindow() editor.Document = new FlowDocument(); editor.Loaded += async (_, _) => await new ImageScenario(editor).RunAsync(); break; + case "scroll": + AppWindow.Resize(new Windows.Graphics.SizeInt32(1100, 900)); + editor.Document = new FlowDocument(); + RoutedEventHandler? once = null; + once = async (_, _) => { editor.Loaded -= once; await new ScrollScenario(editor, root).RunAsync(); }; + editor.Loaded += once; + break; + case "fonts": + // What the toolbar's font list costs: FontFamilyChoices enumerates the system font set once. + editor.Document = new FlowDocument(); + editor.Loaded += async (_, _) => + { + var path = Path.Combine(Path.GetTempPath(), "membaseline_fonts.txt"); + void L(string s) + { + var pr = Process.GetCurrentProcess(); pr.Refresh(); + File.AppendAllText(path, $"{s,-10} Priv={pr.PrivateMemorySize64 / 1048576.0,7:N1}MB managed={GC.GetTotalMemory(false) / 1048576.0,6:N1}MB\n"); + } + async System.Threading.Tasks.Task Settle() { await System.Threading.Tasks.Task.Delay(1500); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); await System.Threading.Tasks.Task.Delay(500); } + File.AppendAllText(path, $"\n=== fonts {DateTime.Now:HH:mm:ss}\n"); + await Settle(); L("before"); + if (Environment.GetEnvironmentVariable("MEMTEST_FONTPROBE") == "1") + { + var psw = Stopwatch.StartNew(); + string locale = System.Globalization.CultureInfo.CurrentUICulture.Name.ToLowerInvariant(); + string lang = locale.Length >= 2 ? locale[..2] : locale; + using var set = Microsoft.Graphics.Canvas.Text.CanvasFontSet.GetSystemFontSet(); + var props = set.GetPropertyValues(Microsoft.Graphics.Canvas.Text.CanvasFontPropertyIdentifier.FamilyName, $"{locale};{lang};en-us"); + var viaProps = new System.Collections.Generic.SortedSet(StringComparer.OrdinalIgnoreCase); + foreach (var pv in props) if (!string.IsNullOrWhiteSpace(pv.Value)) viaProps.Add(pv.Value); + double pms = psw.Elapsed.TotalMilliseconds; + L("props"); File.AppendAllText(path, $" {viaProps.Count} via GetPropertyValues in {pms:N0}ms; locales seen: {string.Join(",", System.Linq.Enumerable.Distinct(System.Linq.Enumerable.Select(props, x => x.Locale)))}\n"); + await Settle(); L("props-gc"); + var viaFaces = new System.Collections.Generic.SortedSet(editor.FontFamilyChoices, StringComparer.OrdinalIgnoreCase); + var onlyFaces = new System.Collections.Generic.List(viaFaces); onlyFaces.RemoveAll(viaProps.Contains); + var onlyProps = new System.Collections.Generic.List(viaProps); onlyProps.RemoveAll(viaFaces.Contains); + File.AppendAllText(path, $" faces={viaFaces.Count} props={viaProps.Count}\n only in faces: {string.Join(" | ", onlyFaces)}\n only in props: {string.Join(" | ", onlyProps)}\n"); + File.AppendAllText(path, "DONE\n"); + return; + } + var sw = Stopwatch.StartNew(); + int n = editor.FontFamilyChoices.Count; + double ms = sw.Elapsed.TotalMilliseconds; + L("listed"); File.AppendAllText(path, $" {n} families in {ms:N0}ms\n"); + await Settle(); L("gc"); + File.AppendAllText(path, "DONE\n"); + }; + break; case "reclaim": editor.Document = HtmlDocumentFormatter.ParseHtml(BigHtml(200)); ScheduleReclaim(editor); diff --git a/samples/MemBaseline/ScrollScenario.cs b/samples/MemBaseline/ScrollScenario.cs new file mode 100644 index 0000000..ba7723b --- /dev/null +++ b/samples/MemBaseline/ScrollScenario.cs @@ -0,0 +1,198 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Graphics.Canvas; +using Microsoft.UI.Xaml.Controls; +using Windows.Storage.Streams; +using WinUIRichEditor.Controls; +using WinUIRichEditor.Documents; +using WinUIRichEditor.Formatters; + +namespace MemBaseline; + +/// +/// MEMTEST_MODE=scroll — what READING a long document leaves behind. +/// +/// Every earlier mode measured a document that sat at the top of the window. The render path caches what it +/// DRAWS (the CanvasTextLayout per paragraph, the decoded bitmap per picture), and nothing scrolled off screen +/// was ever measured: a user who pages to the end of a long document has drawn all of it. +/// +/// Sequence (one log line per step, to %TEMP%\membaseline_scroll.txt): +/// loaded MEMTEST_COUNT paragraphs (+ a block photo every MEMTEST_IMGEVERY paragraphs), top of the window +/// scrolled paged down to the end one viewport at a time, waiting for each page's decodes +/// top scrolled back to the top +/// unloaded the editor taken out of the visual tree (a hidden tab, a navigated-away page), still alive +/// reloaded put back +/// Knobs: MEMTEST_COUNT (default 2000), MEMTEST_IMGEVERY (default 0 = no pictures), MEMTEST_IMGPX (default +/// 1600x1200, the JPEG's own pixel size; each is drawn 480 DIPs wide). +/// +internal sealed class ScrollScenario +{ + private static readonly string LogPath = Path.Combine(Path.GetTempPath(), "membaseline_scroll.txt"); + private const BindingFlags Priv = BindingFlags.Instance | BindingFlags.NonPublic; + + private readonly RichEditor _editor; + private readonly Grid _host; + private readonly int _paras, _imgEvery, _pxW, _pxH; + + public ScrollScenario(RichEditor editor, Grid host) + { + _editor = editor; + _host = host; + _paras = EnvInt("MEMTEST_COUNT", 2000); + _imgEvery = EnvInt("MEMTEST_IMGEVERY", 0); + (_pxW, _pxH) = ParsePx(Environment.GetEnvironmentVariable("MEMTEST_IMGPX"), 1600, 1200); + } + + public async Task RunAsync() + { + try + { + File.AppendAllText(LogPath, + $"\n=== scroll paras={_paras} imgEvery={_imgEvery} px={_pxW}x{_pxH} {DateTime.Now:yyyy-MM-dd HH:mm:ss}\n" + + $" adapters: {GpuMemory.Describe()}\n"); + await Settle(); + Log("empty"); + + var doc = HtmlDocumentFormatter.ParseHtml(BigHtml(_paras)); + if (_imgEvery > 0) + { + // Distinct pixels per picture, so the content-hash cache can't share one decode between them. + int n = 0; + for (int i = doc.Blocks.Count - 1; i > 0; i -= _imgEvery) n++; + var photos = new byte[n][]; + for (int i = 0; i < n; i++) photos[i] = await MakePhotoAsync(i); + int k = 0; + for (int i = doc.Blocks.Count - 1; i > 0 && k < n; i -= _imgEvery) + { + var img = new ImageBlock { Width = 480, Height = 480.0 * _pxH / _pxW }; + img.SetImageData(photos[k++], "image/jpeg"); + doc.Blocks.Insert(i, img); + } + } + _editor.Document = doc; + await Settle(); + await WaitForDecodes(); + Log("loaded"); + + var scroll = (ScrollViewer)typeof(RichEditor).GetField("_scroll", Priv)!.GetValue(_editor)!; + var sw = Stopwatch.StartNew(); + int pages = 0; + while (scroll.VerticalOffset + scroll.ViewportHeight < scroll.ExtentHeight - 1 && pages < 100_000) + { + scroll.ChangeView(null, scroll.VerticalOffset + scroll.ViewportHeight * 0.9, null, disableAnimation: true); + await Task.Delay(30); + pages++; + if (_imgEvery > 0) await WaitForDecodes(); + } + await Settle(); + Log("scrolled", $"pages={pages} in {sw.Elapsed.TotalSeconds:N1}s"); + + scroll.ChangeView(null, 0, null, disableAnimation: true); + await Settle(); + Log("top"); + + if (Environment.GetEnvironmentVariable("MEMTEST_DEVTRIM") == "1") + { + var tsw = Stopwatch.StartNew(); + CanvasDevice.GetSharedDevice().Trim(); + double ms = tsw.Elapsed.TotalMilliseconds; + await Settle(); + Log("devtrim", $"Trim() took {ms:N2}ms"); + } + + _host.Children.Clear(); + await Settle(); + await Settle(); + Log("unloaded"); + + _host.Children.Add(_editor); + await Settle(); + await WaitForDecodes(); + Log("reloaded"); + File.AppendAllText(LogPath, "DONE\n"); + } + catch (Exception ex) + { + File.AppendAllText(LogPath, $"FAILED: {ex}\n"); + } + } + + private static string BigHtml(int paras) + { + var sb = new StringBuilder(); + for (int i = 0; i < paras; i++) + sb.Append("

문단 ").Append(i) + .Append(" — 굵게 기울임 밑줄 취소선 ") + .Append("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod ") + .Append("tempor incididunt ut labore et dolore magna aliqua. ") + .Append("한글과 영문이 충분히 섞인 긴 문장으로 컨트롤 폭에서 자동 줄바꿈을 유발합니다.

"); + return sb.ToString(); + } + + private async Task MakePhotoAsync(int seed) + { + var rnd = new Random(seed); + using var rt = new CanvasRenderTarget(CanvasDevice.GetSharedDevice(), _pxW, _pxH, 96); + using (var ds = rt.CreateDrawingSession()) + { + ds.Clear(Windows.UI.Color.FromArgb(255, (byte)rnd.Next(256), (byte)rnd.Next(256), (byte)rnd.Next(256))); + for (int k = 0; k < 200; k++) + ds.FillEllipse(rnd.Next(_pxW), rnd.Next(_pxH), rnd.Next(10, _pxW / 6), rnd.Next(10, _pxH / 6), + Windows.UI.Color.FromArgb((byte)rnd.Next(60, 200), (byte)rnd.Next(256), (byte)rnd.Next(256), (byte)rnd.Next(256))); + } + using var stream = new InMemoryRandomAccessStream(); + await rt.SaveAsync(stream, CanvasBitmapFileFormat.Jpeg, 0.9f); + var bytes = new byte[stream.Size]; + using var reader = new DataReader(stream.GetInputStreamAt(0)); + await reader.LoadAsync((uint)stream.Size); + reader.ReadBytes(bytes); + return bytes; + } + + private async Task WaitForDecodes() + { + var sw = Stopwatch.StartNew(); + while (sw.Elapsed < TimeSpan.FromSeconds(30) && InflightCount() > 0) await Task.Delay(20); + } + + private static async Task Settle() + { + await Task.Delay(1500); + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + await Task.Delay(500); + } + + private void Log(string step, string extra = "") + { + var p = Process.GetCurrentProcess(); + p.Refresh(); + File.AppendAllText(LogPath, + $"{step,-9} Priv={p.PrivateMemorySize64 / 1048576.0,7:N1}MB WS={p.WorkingSet64 / 1048576.0,7:N1}MB " + + $"managed={GC.GetTotalMemory(false) / 1048576.0,6:N1}MB gpu[{GpuMemory.QueryText()}] " + + $"layouts={LayoutCount(),5} decoded={CountOf("_decoded"),4} {extra}\n"); + } + + private int LayoutCount() + => typeof(RichEditor).GetProperty("LayoutCacheCount", Priv)?.GetValue(_editor) is int n ? n : -1; + + private object? ImageCache => typeof(RichEditor).GetField("_images", Priv)!.GetValue(_editor); + private int InflightCount() => CountOf("_inflight"); + private int CountOf(string field) + { + var value = ImageCache?.GetType().GetField(field, Priv)?.GetValue(ImageCache); + return value?.GetType().GetProperty("Count")?.GetValue(value) is int n ? n : -1; + } + + private static int EnvInt(string name, int fallback) + => int.TryParse(Environment.GetEnvironmentVariable(name), out var v) && v >= 0 ? v : fallback; + + private static (int, int) ParsePx(string? s, int w, int h) + { + var parts = s?.Split('x', 'X'); + return parts is { Length: 2 } && int.TryParse(parts[0], out var pw) && int.TryParse(parts[1], out var ph) ? (pw, ph) : (w, h); + } +} diff --git a/samples/WinUIRichEditor.Demo/PageMarginProbe.cs b/samples/WinUIRichEditor.Demo/PageMarginProbe.cs index 43b7a14..035c742 100644 --- a/samples/WinUIRichEditor.Demo/PageMarginProbe.cs +++ b/samples/WinUIRichEditor.Demo/PageMarginProbe.cs @@ -53,9 +53,26 @@ void Step(string name, Func read) }); // The toolbar reads Target.PageMargin in its sync and matches it against its presets. Step("toolbar", () => { _ = new RichEditorToolbar { Target = ed }; ed.PageMargin = new PageMargins(10); return ed.PageMargin; }); + // Not a margin, but the same kind of risk, so it rides this probe: the font list is read with + // CanvasFontSet.GetPropertyValues, which returns a WinRT STRUCT ARRAY (CanvasFontProperty carries two + // strings). A non-blittable struct array is exactly what broke LineMetrics under AOT — and a failure + // here is silent, falling back to six stock names. Count + a stable hash of the whole list (FNV-1a, not + // string.GetHashCode, which is randomized per process), so any drift diffs. + Step("fonts", () => + { + var names = ed.FontFamilyChoices; + return $"{names.Count} names, fnv {Fnv(string.Join("\n", names)):X16}, first {(names.Count > 0 ? names[0] : "-")}"; + }); Write(path, lines); } + private static ulong Fnv(string s) + { + ulong h = 14695981039346656037; + foreach (char c in s) h = (h ^ c) * 1099511628211; + return h; + } + private static void Write(string path, List lines) { try { File.WriteAllLines(path, lines); } diff --git a/src/WinUIRichEditor/Controls/ImageCache.cs b/src/WinUIRichEditor/Controls/ImageCache.cs index 3d7ad25..0cf1213 100644 --- a/src/WinUIRichEditor/Controls/ImageCache.cs +++ b/src/WinUIRichEditor/Controls/ImageCache.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Graphics.Canvas; +using Windows.Foundation; using WinUIRichEditor.Documents; namespace WinUIRichEditor.Controls; @@ -55,10 +56,11 @@ private object KeyFor(byte[]? rawBytes, object element) /// × is the device-pixel size it is about to /// be drawn at: a cached bitmap smaller than that is still returned, and a sharper one is decoded behind it. public CanvasBitmap? Get(ICanvasResourceCreator device, object key, byte[]? rawBytes, CanvasBitmap? already, - int pixelWidth, int pixelHeight) + int pixelWidth, int pixelHeight, Rect where = default) { if (already != null) return already; object k = KeyFor(rawBytes, key); + if (rawBytes != null) _seen[k] = (where, ++_stamp); if (_decoded.TryGetValue(k, out var bmp)) { if (bmp != null && rawBytes != null && NeedsMore(k, bmp, pixelWidth, pixelHeight) && _inflight.Add(k)) @@ -150,8 +152,10 @@ private static bool Smaller(CanvasBitmap bmp, int w, int h) /// Disposes the bitmaps decoded for the print that just finished. public void EndPrint() { - foreach (var (bmp, _) in _print.Values) bmp?.Dispose(); + bool any = false; + foreach (var (bmp, _) in _print.Values) if (bmp != null) { bmp.Dispose(); any = true; } _print.Clear(); + if (any) OnReleased?.Invoke(); // print-resolution pictures are the largest this cache ever holds } /// Drops the cached bitmap for one element so its (changed) bytes are re-decoded. @@ -165,14 +169,76 @@ public void Invalidate(object key) _inflight.Remove(k); } + /// Raised after a decoded bitmap is disposed (the control asks the device to trim — see + /// RichEditor.ScheduleDeviceTrim). + public event Action? OnReleased; + private void Evict(object key) { - if (_decoded.TryGetValue(key, out var bmp)) bmp?.Dispose(); + if (_decoded.TryGetValue(key, out var bmp) && bmp != null) + { + bmp.Dispose(); + OnReleased?.Invoke(); + } _decoded.Remove(key); _atSourceSize.Remove(key); + _seen.Remove(key); Unretire(key); } + // ---- pictures scrolled out of view -------------------------------------------------------------------- + + // Where each picture was last drawn (control DIPs) and a use stamp that orders the draws, for TrimOffscreen. + private readonly Dictionary _seen = new(); + private long _stamp; + + /// Disposes the bitmaps of pictures still IN the document but drawn nowhere near + /// (the viewport plus a margin, in control DIPs), least recently drawn first, until + /// what is left out there totals at most . A picture scrolled back into view + /// re-decodes; the loading placeholder shows until it lands. + /// Why this exists (measured 2026-09-27, MemBaseline scroll mode): the cache held every picture ever + /// DRAWN, and paging once to the end of a document draws all of them. 2,000 paragraphs with 100 photos: + /// Private bytes 183 MB at the top, 501 MB after scrolling to the end and back, and it never came down while + /// the document was open. Prune can't help — every one of those pictures is still in the document. + /// Pictures an edit removed are not counted here; the retired list has its own budget (Prune). + public void TrimOffscreen(Rect keep, long keepBytes) + { + List<(object key, long stamp, long bytes)>? far = null; + long farBytes = 0; + foreach (var (k, bmp) in _decoded) + { + if (bmp == null || _retiredIndex.ContainsKey(k)) continue; + // No recorded draw (a test seed): nothing says where it is, so it is the first to go. + var seen = _seen.TryGetValue(k, out var s) ? s : (where: Rect.Empty, stamp: 0L); + if (Overlaps(seen.where, keep)) continue; + long bytes = PixelBytes(bmp); + (far ??= new()).Add((k, seen.stamp, bytes)); + farBytes += bytes; + } + if (far != null && farBytes > keepBytes) + { + far.Sort((a, b) => a.stamp.CompareTo(b.stamp)); + foreach (var (k, _, bytes) in far) + { + if (farBytes <= keepBytes) break; + Evict(k); + farBytes -= bytes; + } + } + // A position recorded for a key that neither holds a bitmap nor is decoding (a decode that was pruned + // in flight) would otherwise sit here until the same picture is drawn again. + if (_seen.Count > _decoded.Count + _inflight.Count) + { + List? stale = null; + foreach (var k in _seen.Keys) + if (!_decoded.ContainsKey(k) && !_inflight.Contains(k)) (stale ??= new()).Add(k); + if (stale != null) foreach (var k in stale) _seen.Remove(k); + } + } + + private static bool Overlaps(Rect a, Rect b) + => !a.IsEmpty && !b.IsEmpty && a.Left < b.Right && a.Right > b.Left && a.Top < b.Bottom && a.Bottom > b.Top; + // Entries the document no longer references but that are kept decoded anyway, oldest first, so undoing // the edit that removed a picture finds it warm. Bounded by the retainBytes each Prune passes. // @@ -230,6 +296,8 @@ private static long PixelBytes(CanvasBitmap? bmp) // ---- test seams: decodes are async and device-bound, so tests place entries directly ---- internal int DecodedCount => _decoded.Count; + internal int SeenCount => _seen.Count; + internal Rect? SeenAt(byte[] rawBytes) => _seen.TryGetValue(KeyFor(rawBytes, rawBytes), out var s) ? s.where : null; internal long RetiredBytes => _retiredBytes; internal bool IsCached(byte[] rawBytes) => _decoded.ContainsKey(KeyFor(rawBytes, rawBytes)); internal bool IsDecoding(byte[] rawBytes) => _inflight.Contains(KeyFor(rawBytes, rawBytes)); diff --git a/src/WinUIRichEditor/Controls/RichEditor.Fonts.cs b/src/WinUIRichEditor/Controls/RichEditor.Fonts.cs index 5bf56df..31dec0a 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Fonts.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Fonts.cs @@ -21,6 +21,19 @@ internal static string SystemDefaultFontFamily() // names resolve back through the same font set, so they're used as-is for both display and application. private static IReadOnlyList? _systemFontChoices; + // The names come from the system collection's family list, in the UI language when a family has it, then + // the bare language, then en-us. The list used to be read face by face (CanvasFontSet.Fonts → FamilyNames), + // which opens every installed font: measured 2026-09-27 (MemBaseline fonts mode), +31.6 MB of Private + // bytes and 843 ms on the UI thread the first time a toolbar asked, held until a GC finalized the + // undisposed faces — and a quiet app, whose managed heap grew by 0.6 MB, has no reason to run one. This + // way: +0.7 MB, 140 ms, and the same names (TheFontList_… compares them with that walk, name by name). + // ⚠ Two things that look equivalent FAIL UNDER NATIVE AOT, silently (the catch below falls back to six + // stock names), both found by the demo's --pageprobe "fonts" line diffed between JIT and AOT: + // · CanvasFontSet.GetPropertyValues — returns CanvasFontProperty[], a non-blittable struct array + // (two strings); the same marshalling gap as LineMetrics. + // · a collection expression `[locale, lang, "en-us"]` for the locale list — the compiler synthesizes a + // read-only list type CsWinRT has no CCW for (InvalidCastException). A real string[] marshals. + private static IReadOnlyList SystemFontChoices() { if (_systemFontChoices != null) return _systemFontChoices; @@ -30,11 +43,8 @@ private static IReadOnlyList SystemFontChoices() string locale = CultureInfo.CurrentUICulture.Name.ToLowerInvariant(); // e.g. "ko-kr" string lang = locale.Length >= 2 ? locale.Substring(0, 2) : locale; // e.g. "ko" var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var face in CanvasFontSet.GetSystemFontSet().Fonts) - { - string? name = LocalizedFamilyName(face.FamilyNames, locale, lang); - if (!string.IsNullOrWhiteSpace(name) && seen.Add(name!)) names.Add(name!); - } + foreach (var name in CanvasTextFormat.GetSystemFontFamilies(new string[] { locale, lang, "en-us" })) + if (!string.IsNullOrWhiteSpace(name) && seen.Add(name)) names.Add(name); names.Sort(StringComparer.Create(CultureInfo.CurrentUICulture, ignoreCase: true)); } catch (Exception ex) @@ -48,18 +58,6 @@ private static IReadOnlyList SystemFontChoices() return _systemFontChoices = names; } - // Picks the family name for the UI language: exact locale → language-only → en-us → first available. - private static string? LocalizedFamilyName(IReadOnlyDictionary familyNames, string locale, string lang) - { - if (familyNames.Count == 0) return null; - if (familyNames.TryGetValue(locale, out var exact)) return exact; - foreach (var kv in familyNames) - if (kv.Key.StartsWith(lang, StringComparison.OrdinalIgnoreCase)) return kv.Value; - if (familyNames.TryGetValue("en-us", out var en)) return en; - foreach (var kv in familyNames) return kv.Value; - return null; - } - /// Font families offered in the font pickers (toolbar combo and right-click submenu). Defaults /// to the installed system fonts, with names localized by — and sorted for — the OS UI language. Assign a /// non-empty list to curate the offered set. diff --git a/src/WinUIRichEditor/Controls/RichEditor.Images.cs b/src/WinUIRichEditor/Controls/RichEditor.Images.cs index 200a817..ab7824e 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Images.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Images.cs @@ -132,7 +132,39 @@ private async Task ReplaceImageBytesAsync(object imageElement) int w = (int)Math.Ceiling(Math.Max(1, rect.Width) * scale), h = (int)Math.Ceiling(Math.Max(1, rect.Height) * scale); return _printMode ? _images.GetForPrint(element, rawBytes, already, w, h) - : _images.Get(_canvas, element, rawBytes, already, w, h); + : _images.Get(_canvas, element, rawBytes, already, w, h, ControlRect(m, rect)); + } + + // `rect` (drawing-session coordinates) in the control's own DIPs — the space a CanvasVirtualControl + // session starts in before the zoom and page transforms are applied — for ImageCache.TrimOffscreen. + private static Windows.Foundation.Rect ControlRect(System.Numerics.Matrix3x2 m, Windows.Foundation.Rect rect) + { + var a = System.Numerics.Vector2.Transform(new((float)rect.Left, (float)rect.Top), m); + var b = System.Numerics.Vector2.Transform(new((float)rect.Right, (float)rect.Bottom), m); + return new Windows.Foundation.Rect(new Windows.Foundation.Point(a.X, a.Y), new Windows.Foundation.Point(b.X, b.Y)); + } + + // Decoded pictures still in the document but drawn nowhere near the viewport are kept up to this many pixel + // bytes (ImageCache.TrimOffscreen); beyond it the least recently drawn are released and re-decode when they + // come back into view. internal, not const: tests shrink it. + internal long ImageOffscreenBytes = 32L * 1024 * 1024; + + // After every screen draw pass: release pictures far from the viewport beyond ImageOffscreenBytes (see + // ImageCache.TrimOffscreen). The protected band is the viewport plus one viewport above and below, so a + // short scroll back never meets a placeholder, and the scroll offset lagging the draw can't matter. + private void TrimOffscreenImages(long keepBytes) + { + if (_images.IsEmpty) return; + _images.TrimOffscreen(NearViewport(), keepBytes); + } + + // The viewport in control DIPs (the canvas sits DocContentLeft below the top of the scroll content), + // grown by one viewport on each side. + private Windows.Foundation.Rect NearViewport() + { + double vw = Math.Max(1, _scroll.ViewportWidth), vh = Math.Max(1, _scroll.ViewportHeight); + return new Windows.Foundation.Rect(_scroll.HorizontalOffset - _canvas.Margin.Left - vw, + _scroll.VerticalOffset - _canvas.Margin.Top - vh, vw * 3, vh * 3); } // Every picture draw. The bitmap is decoded to COVER its rect with the source's aspect (ImageDecoder.CoverBox), @@ -239,7 +271,7 @@ private async Task SaveImageBytesAsync(byte[]? raw, string? mime) // bitmap can differ; clearing the layout cache for the affected paragraph forces a reshape. private void ClearLayoutCacheFor(object imageElement) { - if (imageElement is InlineImage) _layoutCache.Clear(); + if (imageElement is InlineImage) EvictLayouts(); // disposes: a bare Clear() left the natives to the finalizer } /// Inserts an image (from its encoded bytes) as a block at the caret. The natural pixel size diff --git a/src/WinUIRichEditor/Controls/RichEditor.Rendering.cs b/src/WinUIRichEditor/Controls/RichEditor.Rendering.cs index f93b7e7..2605f92 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Rendering.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Rendering.cs @@ -50,8 +50,14 @@ private void OnRegionsInvalidated(CanvasVirtualControl sender, CanvasRegionsInva RichEditorDiagnostics.Report(ex); } } + // The draw pass is over and nothing holds a layout or a bitmap: bound what scrolling left behind. + TrimLayoutCache(); + TrimOffscreenImages(ImageOffscreenBytes); + DrawPasses++; } + internal int DrawPasses; // test hook: lets a test wait for a real draw pass + // The cell-block selection, computed ONCE per draw pass. CellBlockSelection() walks the whole // document twice (FindCell per endpoint); calling it per drawn paragraph made selection rendering // O(paragraphs × document). Print paths bypass DrawDocument, but every consumer gates on _printMode. diff --git a/src/WinUIRichEditor/Controls/RichEditor.cs b/src/WinUIRichEditor/Controls/RichEditor.cs index 5a34db3..8e6004e 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.cs @@ -95,7 +95,34 @@ internal static string ListMarkerText(ListKind kind, ListMarkerStyle style, int // memory no longer scales with document length. LayoutCacheCap is a safety net (clear-all when // exceeded) far larger than any single draw's working set, so it never disposes an in-use layout. private const int LayoutCacheCap = 2048; - private readonly Dictionary _layoutCache = new(); + private sealed class LayoutEntry(long sig, double width, CanvasTextLayout layout) + { + public readonly long Sig = sig; public readonly double Width = width; public readonly CanvasTextLayout Layout = layout; + public long Stamp; // last use, for TrimLayoutCache + } + private readonly Dictionary _layoutCache = new(); + private long _layoutStamp; + + // "Viewport-sized" held only while nothing scrolled: the render path caches every paragraph it DRAWS, and + // paging once to the end of a document draws all of them — measured 2026-09-27 (MemBaseline scroll mode), + // 2,000 paragraphs left 2,000 native layouts resident until the 2048 clear-all. After each screen draw pass + // the cache is trimmed back to the LayoutKeep most recently used once it passes LayoutKeep + LayoutTrimSlack + // (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. + private const int LayoutKeep = 256, LayoutTrimSlack = 128; + + private void TrimLayoutCache() + { + if (_layoutPinDepth != 0 || _layoutCache.Count <= LayoutKeep + LayoutTrimSlack) return; + var byAge = new List>(_layoutCache); + byAge.Sort((a, b) => a.Value.Stamp.CompareTo(b.Value.Stamp)); + for (int i = 0; i < byAge.Count - LayoutKeep; i++) + { + byAge[i].Value.Layout.Dispose(); + _layoutCache.Remove(byAge[i].Key); + } + } // ---- the per-paragraph side caches ------------------------------------ // Height, pagination lines and text statistics are cached per paragraph, keyed by identity and @@ -172,9 +199,59 @@ public RichEditor() RelayoutToViewport(); }; + Unloaded += (_, _) => ReleaseWhileUnloaded(); + _images.OnReleased += ScheduleDeviceTrim; + SetupInput(); } + // Disposing a CanvasBitmap releases the texture, but the driver keeps the memory it staged the upload + // through (and its pools) until the device is trimmed. Measured 2026-09-27 (MemBaseline scroll mode, + // Intel UHD): after scrolling back from the end of a 100-photo document, CanvasDevice.Trim() released a + // further 38 MB of Private bytes and 70 MB of GPU memory. It is a hint that costs a stall, so it runs once, + // two seconds after the last release (a scroll through photos releases every few frames), never per frame. + // The device is shared by every Win2D control in the process; trimming only drops what nothing is using. + private Microsoft.UI.Dispatching.DispatcherQueueTimer? _deviceTrimTimer; + + private void ScheduleDeviceTrim() + { + if (_deviceTrimTimer == null) + { + if (DispatcherQueue is not { } queue) return; + _deviceTrimTimer = queue.CreateTimer(); + _deviceTrimTimer.Interval = TimeSpan.FromSeconds(2); + _deviceTrimTimer.IsRepeating = false; + _deviceTrimTimer.Tick += (_, _) => TrimDevice(); + } + _deviceTrimTimer.Stop(); + _deviceTrimTimer.Start(); + } + + internal int DeviceTrims; // test hook + + private void TrimDevice() + { + try + { + if (_canvas.Device is { } device) { device.Trim(); DeviceTrims++; } + } + catch (Exception ex) { RichEditorDiagnostics.Report(ex); } // a lost device: nothing to trim + } + + // Out of the visual tree (a hidden tab, a page navigated away from) nothing is drawn, yet the editor kept + // every native layout and decoded picture it had — measured 2026-09-27 (MemBaseline scroll mode), nothing + // came back on unload. The layouts rebuild on the next draw (CPU only); the pictures near the viewport are + // kept so coming back shows them at once, and everything else re-decodes if it is scrolled to again. + // IsLoaded: a move within the tree can raise the old Unloaded after the new Loaded. + private void ReleaseWhileUnloaded() + { + if (IsLoaded) return; + EvictLayouts(); + ClearMarkerLayouts(); + TrimOffscreenImages(0); + ScheduleDeviceTrim(); // even with no picture released: the text and surfaces it drew staged through it too + } + // ---- accessibility ---------------------------------------------------- private RichEditorAutomationPeer? _automationPeer; @@ -187,7 +264,12 @@ protected override Microsoft.UI.Xaml.Automation.Peers.AutomationPeer OnCreateAut public static readonly DependencyProperty DocumentProperty = DependencyProperty.Register( nameof(Document), typeof(FlowDocument), typeof(RichEditor), new PropertyMetadata(null, OnDocumentChanged)); - /// The document model being rendered. + /// The document model being rendered. + /// Assigning it (in code or through a binding) counts as an edit: becomes true, + /// since the editor cannot tell a document read from a file from one built in code. To open a file as + /// unmodified, use , , , + /// or (which also clear the undo history), or call + /// after assigning. public FlowDocument? Document { get => (FlowDocument?)GetValue(DocumentProperty); @@ -309,7 +391,7 @@ private void RelayoutToViewport() private void ClearLayoutCache() { - foreach (var entry in _layoutCache.Values) entry.layout.Dispose(); + foreach (var entry in _layoutCache.Values) entry.Layout.Dispose(); _layoutCache.Clear(); _heightCache.Clear(); _lineCache.Clear(); @@ -319,12 +401,12 @@ private void ClearLayoutCache() ClearMarkerLayouts(); // device-bound like the layouts above (device recreate path) } - // Bound the heavy layout cache: dispose + drop the oldest entries so only ~viewport-worth of native - // CanvasTextLayouts stay resident. Safe because no caller holds a returned layout across building - // LayoutCacheCap other layouts (each use is synchronous within one measure/draw step). + // The heavy layout cache's safety net: dispose + drop EVERY entry (the per-draw-pass bound is + // TrimLayoutCache). Safe because no caller holds a returned layout across building LayoutCacheCap other + // layouts (each use is synchronous within one measure/draw step), and pinned walks defer it. private void EvictLayouts() { - foreach (var entry in _layoutCache.Values) entry.layout.Dispose(); + foreach (var entry in _layoutCache.Values) entry.Layout.Dispose(); _layoutCache.Clear(); } @@ -461,17 +543,20 @@ private static string BuildPlain(Paragraph p) internal CanvasTextLayout BuildTextLayout(Paragraph p, double maxWidth) { long sig = ParagraphSig(p); - if (_layoutCache.TryGetValue(p, out var cached) && cached.width == maxWidth && cached.sig == sig) - return cached.layout; + if (_layoutCache.TryGetValue(p, out var cached) && cached.Width == maxWidth && cached.Sig == sig) + { + cached.Stamp = ++_layoutStamp; + return cached.Layout; + } var layout = CreateLayout(p, maxWidth); - if (_layoutCache.TryGetValue(p, out var prev)) { prev.layout.Dispose(); _layoutCache.Remove(p); } + 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 // dispose the layout still in use and the next GetCharacterRegions on it would throw. Deferring // lets the cache overshoot the cap briefly; the next unpinned build evicts it. if (_layoutPinDepth == 0 && _layoutCache.Count >= LayoutCacheCap) EvictLayouts(); - _layoutCache[p] = (sig, maxWidth, layout); + _layoutCache[p] = new LayoutEntry(sig, maxWidth, layout) { Stamp = ++_layoutStamp }; return layout; } diff --git a/tests/WinUIRichEditor.Tests/ControlMemoryTrimTests.cs b/tests/WinUIRichEditor.Tests/ControlMemoryTrimTests.cs new file mode 100644 index 0000000..0ad82c0 --- /dev/null +++ b/tests/WinUIRichEditor.Tests/ControlMemoryTrimTests.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Graphics.Canvas; +using Microsoft.Graphics.Canvas.Text; +using Windows.Foundation; +using WinUIRichEditor.Controls; +using WinUIRichEditor.Documents; +using Xunit; + +namespace WinUIRichEditor.Tests; + +/// What READING a long document leaves behind (2026-09-27). Every earlier memory guard measured a +/// document that sat at the top of the window; the render path caches what it DRAWS, and paging once to the end +/// draws everything. Measured with MemBaseline's scroll mode, 2,000 paragraphs with 100 photos: +/// +/// every picture stayed decoded — Private bytes 183 MB at the top, 501 MB after scrolling to the end and +/// back, and nothing came down while the document stayed open (now 217 MB); +/// every paragraph kept its native layout — 2,000 of them, until the 2048 clear-all (now ~260); +/// taking the editor out of the visual tree released nothing (now 185 MB, and 150 MB once reloaded, from +/// 481 MB). +/// +/// The toolbar's font list is here too: it opened every installed font to read the names. +[Collection(UiTests.Collection)] +public class ControlMemoryTrimTests +{ + private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; + private const BindingFlags NPS = BindingFlags.NonPublic | BindingFlags.Static; + private static readonly Type T = typeof(RichEditor); + + private static CanvasBitmap Bitmap4x4() + => CanvasBitmap.CreateFromBytes(CanvasDevice.GetSharedDevice(), new byte[4 * 4 * 4], 4, 4, + Windows.Graphics.DirectX.DirectXPixelFormat.B8G8R8A8UIntNormalized); + + private static bool IsDisposed(CanvasBitmap bmp) + { + try { _ = bmp.SizeInPixels; return false; } + catch (ObjectDisposedException) { return true; } + } + + private static readonly Rect Viewport = new(0, 0, 800, 600); + + // A seeded picture drawn at `y` (control DIPs): what a draw pass records. + private static (byte[] raw, CanvasBitmap bmp) DrawnAt(ImageCache cache, byte seed, double y) + { + byte[] raw = [seed, 1, 2, 3]; + var bmp = Bitmap4x4(); + cache.Seed(raw, bmp); + Assert.Same(bmp, cache.Get(CanvasDevice.GetSharedDevice(), raw, raw, null, 4, 4, new Rect(10, y, 40, 40))); + return (raw, bmp); + } + + // ---- ImageCache.TrimOffscreen -------------------------------------------------------------------------- + + [Fact] + public void FarPicturesBeyondTheBudget_GoLeastRecentlyDrawnFirst_AndANearOneStaysWhateverItsAge() + { + UiThread.Run(() => + { + var cache = new ImageCache(); + var near = DrawnAt(cache, 1, 0); // drawn FIRST: the oldest of all, but on screen + var b = DrawnAt(cache, 2, 5000); + var c = DrawnAt(cache, 3, 9000); + var d = DrawnAt(cache, 4, 7000); + + cache.TrimOffscreen(Viewport, keepBytes: 128); // far = b, c, d = 192 bytes: one has to go + + Assert.True(cache.IsCached(near.raw) && !IsDisposed(near.bmp)); + Assert.False(cache.IsCached(b.raw)); + Assert.True(IsDisposed(b.bmp)); + Assert.True(cache.IsCached(c.raw) && cache.IsCached(d.raw)); + + cache.TrimOffscreen(Viewport, keepBytes: 0); + Assert.Equal(1, cache.DecodedCount); + Assert.True(cache.IsCached(near.raw)); + Assert.Equal(1, cache.SeenCount); // the evicted pictures' positions went with them + }); + } + + [Fact] + public void WithinTheBudget_NothingIsReleased() + { + UiThread.Run(() => + { + var cache = new ImageCache(); + var a = DrawnAt(cache, 1, 5000); + var b = DrawnAt(cache, 2, 9000); + cache.TrimOffscreen(Viewport, keepBytes: 128); + Assert.True(cache.IsCached(a.raw) && cache.IsCached(b.raw)); + }); + } + + [Fact] + public void PicturesAnEditRemoved_AreLeftToPrunesOwnBudget() + { + UiThread.Run(() => + { + var cache = new ImageCache(); + var a = DrawnAt(cache, 1, 5000); + cache.Prune(new HashSet(), retainBytes: 1 << 20); // out of the document, kept for undo + + cache.TrimOffscreen(Viewport, keepBytes: 0); + + Assert.True(cache.IsCached(a.raw)); + Assert.Equal(64, cache.RetiredBytes); + }); + } + + [Fact] + public void AReleasedPicture_DecodesAgainWhenItIsDrawnAgain_AndTheReleaseIsAnnounced() + { + UiThread.Run(() => + { + var cache = new ImageCache(); + int released = 0; + cache.OnReleased += () => released++; + var a = DrawnAt(cache, 1, 5000); + + cache.TrimOffscreen(Viewport, keepBytes: 0); + Assert.Equal(1, released); + + // Scrolled back into view: no bitmap, so the draw shows the placeholder and a decode starts. + Assert.Null(cache.Get(CanvasDevice.GetSharedDevice(), a.raw, a.raw, null, 4, 4, new Rect(10, 10, 40, 40))); + Assert.True(cache.IsDecoding(a.raw)); + }); + } + + [Fact] + public void TheRecordedRect_IsInControlDips_ThroughZoomAndThePageTransform() + { + var m = typeof(RichEditor).GetMethod("ControlRect", NPS)!; + var transform = System.Numerics.Matrix3x2.CreateTranslation(30, 1000) * System.Numerics.Matrix3x2.CreateScale(2); + var r = (Rect)m.Invoke(null, [transform, new Rect(10, 20, 100, 50)])!; + Assert.Equal(new Rect(80, 2040, 200, 100), r); + } + + // ---- the layout cache --------------------------------------------------------------------------------- + + private static FlowDocument Paragraphs(int n) + { + var doc = new FlowDocument(); + for (int i = 0; i < n; i++) + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = $"paragraph {i} with a little text", FontSize = 10 } } }); + return doc; + } + + 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); + + [Fact] + public void TheLayoutCache_IsTrimmedToTheMostRecentlyUsed_AndDisposesWhatItDrops() + { + UiThread.Run(() => + { + var ed = new RichEditor { Document = Paragraphs(600) }; + var paras = ed.Document!.Blocks.OfType().ToList(); + var layouts = paras.Select(p => ed.BuildTextLayout(p, 300)).ToList(); + ed.BuildTextLayout(paras[0], 300); // used again: the newest now, though built first + + TrimLayoutCache(ed); + + Assert.Equal(256, ed.LayoutCacheCount); + var cache = LayoutCache(ed); + Assert.True(cache.Contains(paras[0])); + Assert.False(cache.Contains(paras[1])); + Assert.True(cache.Contains(paras[599]) && cache.Contains(paras[345])); + Assert.False(cache.Contains(paras[344])); + Assert.Throws(() => layouts[1].LayoutBounds); + _ = layouts[599].LayoutBounds; // kept ones still work + }); + } + + [Fact] + public void TheLayoutCache_IsLeftAloneWithinTheSlack_AndWhileAWalkIsPinned() + { + UiThread.Run(() => + { + var ed = new RichEditor { Document = Paragraphs(600) }; + var paras = ed.Document!.Blocks.OfType().ToList(); + foreach (var p in paras.Take(384)) ed.BuildTextLayout(p, 300); + TrimLayoutCache(ed); + Assert.Equal(384, ed.LayoutCacheCount); // LayoutKeep + LayoutTrimSlack: no sort per frame + + foreach (var p in paras.Skip(384)) ed.BuildTextLayout(p, 300); + var pin = T.GetField("_layoutPinDepth", NP)!; + pin.SetValue(ed, 1); + try { TrimLayoutCache(ed); } + finally { pin.SetValue(ed, 0); } + Assert.Equal(600, ed.LayoutCacheCount); + }); + } + + // A hosted editor with `doc`, waited on until it has drawn at least once more. + private static RichEditor HostedWith(FlowDocument doc) + { + var ed = UiThread.Run(() => new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + UiThread.Host(ed); + WaitForDraw(ed); + return ed; + } + + private static void WaitForDraw(RichEditor ed) + { + int before = UiThread.Run(() => { T.GetMethod("InvalidateCanvas", NP)!.Invoke(ed, null); return ed.DrawPasses; }); + WaitUntil(() => ed.DrawPasses > before, "a draw pass"); + } + + private static void WaitUntil(Func condition, string what, int timeoutMs = 8000) + { + bool met = false; + UiThread.RunAsync(async () => + { + var until = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (!(met = condition()) && DateTime.UtcNow < until) await Task.Delay(25); + }); + Assert.True(met, $"timed out waiting for {what}"); + } + + private static void Unhost(RichEditor ed) + => UiThread.Run(() => ((Microsoft.UI.Xaml.Controls.Panel)ed.Parent).Children.Remove(ed)); + + [Fact] + public void ADrawPass_TrimsTheLayoutCacheItFilled() + { + var ed = HostedWith(Paragraphs(600)); + try + { + UiThread.Run(() => + { + foreach (var p in ed.Document!.Blocks.OfType()) ed.BuildTextLayout(p, 300); + Assert.True(ed.LayoutCacheCount >= 600); + }); + WaitForDraw(ed); + Assert.Equal(256, UiThread.Run(() => ed.LayoutCacheCount)); + } + finally { Unhost(ed); } + } + + // ---- leaving the visual tree ---------------------------------------------------------------------------- + + private static ImageCache Cache(RichEditor ed) => (ImageCache)T.GetField("_images", NP)!.GetValue(ed)!; + + // A hosted editor with a picture at the top (drawn by the real draw pass, so its position is recorded) + // and one 400 paragraphs down (never drawn). Distinct bytes: the cache keys by content. Never decoded — + // both are seeded before the editor is hosted. + private static (RichEditor ed, byte[] nearRaw, CanvasBitmap nearBmp, byte[] farRaw, CanvasBitmap farBmp) + HostedWithTwoPictures(Action? configure = null) + { + byte[] nearRaw = [1, 2, 3], farRaw = [4, 5, 6]; + var doc = Paragraphs(400); + var nearImg = new ImageBlock { Width = 40, Height = 40 }; + nearImg.SetImageData(nearRaw, "image/png"); + var farImg = new ImageBlock { Width = 40, Height = 40 }; + farImg.SetImageData(farRaw, "image/png"); + doc.Blocks.Insert(0, nearImg); + doc.Blocks.Add(farImg); + + var ed = UiThread.Run(() => new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + CanvasBitmap nearBmp = null!, farBmp = null!; + UiThread.Run(() => + { + configure?.Invoke(ed); + nearBmp = Bitmap4x4(); farBmp = Bitmap4x4(); + Cache(ed).Seed(nearRaw, nearBmp); + Cache(ed).Seed(farRaw, farBmp); + }); + UiThread.Host(ed); + WaitForDraw(ed); + return (ed, nearRaw, nearBmp, farRaw, farBmp); + } + + [Fact] + public void ADrawPass_ReleasesPicturesFarFromView_BeyondTheBudget() + { + var (ed, nearRaw, nearBmp, farRaw, farBmp) = HostedWithTwoPictures(e => e.ImageOffscreenBytes = 0); + try + { + UiThread.Run(() => + { + Assert.True(Cache(ed).IsCached(nearRaw) && !IsDisposed(nearBmp)); + Assert.False(Cache(ed).IsCached(farRaw)); + Assert.True(IsDisposed(farBmp)); + }); + } + finally { Unhost(ed); } + } + + [Fact] + public void ThePositionADrawRecords_IsWhereItLandsOnTheControl_UnderZoom() + { + var (ed, nearRaw, _, _, _) = HostedWithTwoPictures(e => e.Zoom = 2); + try + { + var at = UiThread.Run(() => Cache(ed).SeenAt(nearRaw)); + Assert.NotNull(at); + Assert.Equal(80, at!.Value.Width, 3); // 40 DIPs drawn at 200% + Assert.Equal(80, at.Value.Height, 3); + } + finally { Unhost(ed); } + } + + [Fact] + public void Unloading_ReleasesTheLayouts_AndThePicturesFarFromView_KeepsTheNearOne_AndTrimsTheDevice() + { + var (ed, nearRaw, nearBmp, farRaw, farBmp) = HostedWithTwoPictures(); + int trimsBefore = UiThread.Run(() => + { + Assert.True(ed.LayoutCacheCount > 0); + Assert.True(Cache(ed).IsCached(nearRaw) && Cache(ed).IsCached(farRaw)); + return ed.DeviceTrims; + }); + + Unhost(ed); + WaitUntil(() => !ed.IsLoaded && ed.LayoutCacheCount == 0, "the Unloaded release"); + + UiThread.Run(() => + { + Assert.True(Cache(ed).IsCached(nearRaw), "the picture in view must survive: coming back shows it at once"); + Assert.False(IsDisposed(nearBmp)); + Assert.False(Cache(ed).IsCached(farRaw)); + Assert.True(IsDisposed(farBmp)); + }); + WaitUntil(() => ed.DeviceTrims > trimsBefore, "the deferred device trim"); + } + + // ---- the font list ---------------------------------------------------------------------------------------- + + [Fact] + public void TheFontList_ReadFromTheFontSetIndex_NamesExactlyWhatAFaceByFaceWalkNames() + { + UiThread.Run(() => + { + var choices = new RichEditor().FontFamilyChoices; + + // The old walk, verbatim in effect: every face's family names, in the UI language → the bare + // language → en-us → the first. Kept here as the oracle, not in the library: it opens every font. + string locale = System.Globalization.CultureInfo.CurrentUICulture.Name.ToLowerInvariant(); + string lang = locale.Length >= 2 ? locale[..2] : locale; + var expected = new SortedSet(StringComparer.OrdinalIgnoreCase); + using (var set = CanvasFontSet.GetSystemFontSet()) + foreach (var face in set.Fonts) + using (face) + { + var names = face.FamilyNames; + string? name = names.TryGetValue(locale, out var exact) ? exact + : names.FirstOrDefault(kv => kv.Key.StartsWith(lang, StringComparison.OrdinalIgnoreCase)).Value + ?? (names.TryGetValue("en-us", out var en) ? en : names.Values.FirstOrDefault()); + if (!string.IsNullOrWhiteSpace(name)) expected.Add(name); + } + + Assert.True(expected.Count > 10, "no fonts enumerated: the comparison would be vacuous"); + Assert.Equal(expected.ToList(), new SortedSet(choices, StringComparer.OrdinalIgnoreCase).ToList()); + }); + } +}