diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dac05a..2a1ae7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed — the layout cache holds what is on screen, not the whole document (2026-09-30) + +Measure, pagination and every walk that only steps past a paragraph (hit-tests, link lookup, block lookup) +now read its **height** from a separate, weak-keyed height cache instead of taking its text layout, and a +render builds layouts only for the paragraphs it draws. After each render the layout cache is trimmed back +to the 256 most recently used — never evicting one the frame itself drew, since a visible table draws every +cell. Measured with `LayoutCacheProbe` (800×600 window, real Skia): + +| | before | after | +|---|---|---| +| 8,000 paragraphs, loaded | 8,000 layouts, 270 MB managed | 29 layouts, 39 MB | +| 8,000 paragraphs, scrolled to the end | 8,000 layouts, 276 MB | 343 layouts, 45 MB | +| scrolling, per page (8,000 / 2,000 paragraphs) | 29–36 / 10–11 ms | 29–41 / 14–15 ms | + +The cost is that a paragraph scrolling back into view is shaped again (the 2,000-paragraph scroll above); +its height stays cached, so nothing moves. The per-frame walk over all blocks is unchanged and still +dominates large documents. + +Fixed along the way, found by a new fuzz oracle (an edited editor measures what a fresh one given the same +document measures): +- **Toggling a bullet or numbered list kept the old height.** The list gutter narrows the wrap width, but the + list commands only repainted, so the editor measured as before until something else invalidated it. +- **A paragraph's signature could miss two changes made in one edit.** Select all + line spacing set both a + paragraph's and its inline table cell's line spacing from "default" (NaN) to a value; those differ only in + a double's top bits, and the signature hash carried differences only upward, so the two cancelled (1 in 28 + value/heading pairs) and the paragraph kept its old layout and height. Wide values now go through a + full-avalanche mix first. + ### Fixed — memory held by paragraphs and pictures an edit removed (2026-09-28) The per-paragraph layout cache pruned only once it passed 10,000 entries, and it is keyed by paragraph, so a @@ -22,9 +50,9 @@ paragraphs and ~3–5 ms at 8,000 — too much per keystroke, nothing once per p for paragraphs no longer in the document, so nothing reshapes. Found by measuring what the WinUI port asked about after bounding its own caches. -Not changed here: the cache still holds a layout for EVERY paragraph of the document (Measure, Render and -hit-testing share it) — ~20–40 KB each, 6–9× the document model; 8,000 paragraphs ≈ 240–300 MB. Bounding that -needs measurement to stop using the cache (the port measures with throwaway layouts and caches heights). +Not changed here: the cache still held a layout for EVERY paragraph of the document (Measure, Render and +hit-testing shared it) — ~20–40 KB each, 6–9× the document model; 8,000 paragraphs ≈ 240–300 MB. Bounded by +the entry above (2026-09-30). ### Fixed — found by the WinUIRichEditor port's audit (2026-09-24) diff --git a/Project_Roadmap.md b/Project_Roadmap.md index f323e44..a2ad84a 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -15,7 +15,10 @@ WPF `RichTextBox`/`FlowDocument`를 **순수 C# + Avalonia `TextLayout`**로 바 - **게시**: `v1.2.1`(2026-09-08) — nuget.org. 이력: 0.9.0(07-12) → 1.0.0(07-31, API 동결) → 1.1.0(08-08) → 1.2.0/1.2.1(09-08). - **미릴리스**: 09-09 이후 라운드(대체 텍스트 ~ 라운드34), **호스트 API 3건**, 여백 실기 후속(PR #52)이 전부 `CHANGELOG.md` `[Unreleased]`. 공개 API **추가만**(`PublicAPI.Unshipped.txt`, 제거·시그니처 변경 없음) → 다음은 **minor(1.3.0)**. -- **검증**: 빌드 0 warn · unit **1225** + render **51** 그린 · 3-OS CI(Windows/Linux/macOS) 그린(라운드34 전 기준). +- **검증**: 빌드 0 warn · unit **1255** + render **55** 그린(2026-09-30, 레이아웃 캐시 상한 브랜치) · 3-OS CI(Windows/Linux/macOS) 그린(라운드34 전 기준). +- **레이아웃 캐시 = 화면에 있는 것만** (2026-09-30): 측정·페이지 나눔·지나가는 워크는 높이 캐시를 읽고, 렌더 뒤 LRU 256개로 트림. + 8,000문단 270 → 39 MB. 대가: 다시 화면에 들어온 문단은 재셰이핑(2,000문단 스크롤 +4 ms/쪽). 퍼즈에 **높이 오라클** + (편집한 편집기 = 새 편집기 측정 높이)이 생겨 목록 토글 측정 누락·서명 해시 상쇄를 잡았다. 포트에 같은 두 결함이 있는지는 미측정. - **의존성**: Avalonia **12.1** 이상(12.0.x는 Shift+Enter 2회로 `TextLayout` 무한 루프 — 엔진 버그라 상향이 유일한 수정). **기능 (전부 동작·테스트 그린)** diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs index 12c0279..19c52fb 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs @@ -238,6 +238,7 @@ public void RemoveList() p.ListLevel = 0; } UpdateParents(Document); + InvalidateMeasure(); // the list gutter moves the wrap width, and so the height InvalidateVisual(); } @@ -269,6 +270,7 @@ private void SetListType(ListKind kind, ListMarkerStyle? marker = null) } if (targets.Count == 0) { + InvalidateMeasure(); // the list gutter moves the wrap width, and so the height InvalidateVisual(); return; } @@ -276,6 +278,7 @@ private void SetListType(ListKind kind, ListMarkerStyle? marker = null) { foreach (var tp in targets) tp.ListType = ListKind.None; UpdateParents(Document); + InvalidateMeasure(); // the list gutter moves the wrap width, and so the height InvalidateVisual(); return; } @@ -313,6 +316,7 @@ private void SetListType(ListKind kind, ListMarkerStyle? marker = null) if (nSe != null) _selectionEnd = nSe; if (nCp != null) _caretPosition = nCp; UpdateParents(Document); + InvalidateMeasure(); // the list gutter moves the wrap width, and so the height InvalidateVisual(); } diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs b/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs index 9ee0eb9..8c5dcf8 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs @@ -24,14 +24,16 @@ public partial class RichEditor if (cb is Paragraph bp) { double pl = CellParaLeft(bp); // list/indent gutter, as every other cell walk applies - var bl = BuildTextLayout(bp, Math.Max(10, innerW - pl)); - if (p.Y <= blkTop + bl.Height) + double bw = Math.Max(10, innerW - pl); + double bh = ParagraphHeight(bp, bw); // the layout only for the paragraph the point is in + if (p.Y <= blkTop + bh) { + var bl = BuildTextLayout(bp, bw); if (InlineTableLinkDescent(bp, bl, ox + pl, blkTop, p) is { } inlineLink) return inlineLink; var hit = bl.HitTestPoint(new Point(p.X - ox - pl, p.Y - blkTop)); return hit.IsInside ? RunAtOffset(bp, hit.TextPosition) : null; } - by += bl.Height; + by += bh; } else if (cb is ImageBlock cim) { by += CellImageSize(cim, innerW).h; } else if (cb is DividerBlock) { by += DividerHeight; } @@ -59,7 +61,7 @@ public partial class RichEditor { yOffset += TopGapOf(block); double top = yOffset; - double h = BlockExtent(block, maxWidth, top, out var ft, out var tl); + double h = BlockExtent(block, maxWidth, top, out var tl); yOffset += h + block.MarginBottom; if (block is TableBlock tb && tl is { } t) { @@ -67,8 +69,9 @@ public partial class RichEditor if (rect.Contains(p)) return LinkRunInBlockList(tb.Cells[r][c].Blocks, rect.X + 5, rect.Y + 5 + CellContentOffsetY(tb.Cells[r][c], rect), Math.Max(10, rect.Width - 10), p); } - else if (block is Paragraph paragraph && ft != null && p.Y >= top && p.Y <= top + h) + else if (block is Paragraph paragraph && GetParagraphLength(paragraph) > 0 && p.Y >= top && p.Y <= top + h) { + var ft = ParaLayout(paragraph, maxWidth); double plink = ParaLeft(paragraph); // Mirrors the caret walk's inline-table descent, so a link inside an inline table is // hoverable and clickable rather than reading as plain text. @@ -138,7 +141,7 @@ private double MeasureCellContentHeight(TableCell cell, double innerWidth) // including the IME composition, which the render walk splices in (DrawCellBlockList). // Without it the row is sized for the text without the composition and the composed // glyphs spill past the cell's bottom border on every wrap. - h += PreeditAwareLayout(p, Math.Max(10, w - CellParaLeft(p))).Height; + h += PreeditAwareHeight(p, Math.Max(10, w - CellParaLeft(p))); break; case ImageBlock im: h += CellImageSize(im, w).h; @@ -278,10 +281,12 @@ internal double TopGapOf(Block block) // drift). Pagination also advances through this (it adds only the within-block row/line atom split // on top). Render still computes its own advance (it needs the draw/cull logic); migrating it is the // last G1 phase. - private double BlockExtent(Block block, double maxWidth, double top, - out Avalonia.Media.TextFormatting.TextLayout? paraLayout, out TableLayout? tableLayout) + // + // A paragraph's HEIGHT, not its layout: every one of these walks advances past almost every block it visits, + // and handing each one the layout is what made the layout cache hold the whole document (see _layoutCache). + // A walker that stops at a paragraph builds that one (ParaLayout). + private double BlockExtent(Block block, double maxWidth, double top, out TableLayout? tableLayout) { - paraLayout = null; tableLayout = null; switch (block) { @@ -301,16 +306,19 @@ private double BlockExtent(Block block, double maxWidth, double top, double basePt = hd ? HeadingFontSize(p.HeadingLevel) : DefaultFontSize; return (double.IsNaN(p.LineSpacing) ? DefaultLineSpacing : p.LineSpacing) * PtToPx(basePt); } - // Deliberately the PLAIN layout even while the IME composes: `paraLayout` is handed to the - // caret and link hit-tests, whose indices must stay logical offsets, and to pagination. - // The measure walk applies the composition height on top (MeasureContentHeight). - paraLayout = BuildTextLayout(p, ParagraphWrapWidth(p, maxWidth)); - return paraLayout.Height; + // Deliberately the PLAIN height even while the IME composes: the caret and link hit-tests + // index the plain layout (logical offsets), and pagination splits it. The measure walk applies + // the composition height on top (MeasureContentHeight). + return ParagraphHeight(p, ParagraphWrapWidth(p, maxWidth)); default: return 0; } } + // The layout of a top-level paragraph, at the width BlockExtent measured it at. + private Avalonia.Media.TextFormatting.TextLayout ParaLayout(Paragraph p, double maxWidth) + => BuildTextLayout(p, ParagraphWrapWidth(p, maxWidth)); + // The block (image or table) whose rendered rectangle contains the point, or null. private Block? GetBlockAtPoint(Point p) { @@ -320,7 +328,7 @@ private double BlockExtent(Block block, double maxWidth, double top, { yOffset += TopGapOf(block); double top = yOffset; - double h = BlockExtent(block, maxWidth, top, out _, out var tl); + double h = BlockExtent(block, maxWidth, top, out var tl); yOffset += h + block.MarginBottom; if (block is TableBlock tb && tl is { } t) { @@ -344,7 +352,7 @@ private double BlockExtent(Block block, double maxWidth, double top, { yOffset += TopGapOf(block); double top = yOffset; - double h = BlockExtent(block, maxWidth, top, out _, out var tl); + double h = BlockExtent(block, maxWidth, top, out var tl); if (block == target && tl is { } t) return (top, t); yOffset += h + block.MarginBottom; } @@ -393,7 +401,7 @@ private bool IsOnTableLeftOrTopBorder(TableBlock tb, Point p) { yOffset += TopGapOf(block); double top = yOffset; - double h = BlockExtent(block, maxWidth, top, out _, out var tl); + double h = BlockExtent(block, maxWidth, top, out var tl); yOffset += h + block.MarginBottom; if (block is TableBlock tb && tl is { } t) { @@ -522,10 +530,16 @@ internal static Rect FixCaretAfterTrailingImage(Avalonia.Media.TextFormatting.Te // The height a paragraph is DRAWN at: its layout height, plus whatever an active IME composition // adds. Hit-test walks must advance by this or they drift below the composing paragraph — the cell // rect already grows with the composition, so only the walk inside it was left behind. - private double DrawnHeight(Paragraph p, double width, Avalonia.Media.TextFormatting.TextLayout plain) + private double DrawnHeight(Paragraph p, double width, double plainHeight) => !string.IsNullOrEmpty(_preeditText) && ReferenceEquals(_caretPosition.Paragraph, p) ? BuildTextLayout(p, width, _caretPosition.Offset, _preeditText).Height - : plain.Height; + : plainHeight; + + // PreeditAwareLayout's height, without keeping a layout for the paragraphs no composition is in. + private double PreeditAwareHeight(Paragraph p, double width) + => !string.IsNullOrEmpty(_preeditText) && ReferenceEquals(_caretPosition.Paragraph, p) + ? BuildTextLayout(p, width, _caretPosition.Offset, _preeditText).Height + : ParagraphHeight(p, width); // A point inside a paragraph -> a LOGICAL caret offset, with an IME composition accounted for. // @@ -559,7 +573,6 @@ private double DrawnHeight(Paragraph p, double width, Avalonia.Media.TextFormatt { double by = 0; Paragraph? lastPara = null; - Avalonia.Media.TextFormatting.TextLayout? lastLayout = null; double lastTop = 0, lastLeft = ox, lastWidth = innerW; foreach (var cb in blocks) { @@ -567,12 +580,12 @@ private double DrawnHeight(Paragraph p, double width, Avalonia.Media.TextFormatt if (cb is Paragraph bp) { double pl = CellParaLeft(bp); // list/indent gutter, as every other cell walk applies - var bl = BuildTextLayout(bp, Math.Max(10, innerW - pl)); double bw = Math.Max(10, innerW - pl); - double bh = DrawnHeight(bp, bw, bl); // the composition grows what's painted here - lastPara = bp; lastLayout = bl; lastTop = blkTop; lastLeft = ox + pl; lastWidth = bw; + double bh = DrawnHeight(bp, bw, ParagraphHeight(bp, bw)); // the composition grows what's painted here + lastPara = bp; lastTop = blkTop; lastLeft = ox + pl; lastWidth = bw; if (p.Y <= blkTop + bh) { + var bl = BuildTextLayout(bp, bw); // only the paragraph the point is in // A cell paragraph can host an inline table too (paste a paragraph containing one into // a cell). The top-level paragraph walk descended into it; this one didn't, so the // click stopped at the host paragraph's ObjChar — the table rendered but its cells @@ -597,8 +610,8 @@ private double DrawnHeight(Paragraph p, double width, Avalonia.Media.TextFormatt } } // Below all blocks (or in a nested table's border gap): snap to the last paragraph seen. - if (lastPara == null || lastLayout == null) return null; - var snapped = HitTestLogicalIndex(lastPara, lastWidth, lastLayout, new Point(p.X - lastLeft, p.Y - lastTop)); + if (lastPara == null) return null; + var snapped = HitTestLogicalIndex(lastPara, lastWidth, BuildTextLayout(lastPara, lastWidth), new Point(p.X - lastLeft, p.Y - lastTop)); return new TextPointer(lastPara, snapped.Index) { AtLineEnd = snapped.AtLineEnd }; } @@ -669,12 +682,17 @@ private TextPointer GetPositionFromPoint(Point p) Paragraph? bestPara = null; int bestLocalIndex = 0; bool bestAtLineEnd = false; // affinity of the winning candidate; reset with it below + // The winning text paragraph is hit-tested once, after the walk: each nearer paragraph used to be + // hit-tested as it took the lead, which for a point low in the document meant building the layout of + // every paragraph above it (see _layoutCache). Only the last winner's result was ever returned. + double bestTop = 0; + bool bestNeedsHit = false; foreach (var block in Document.Blocks) { yOffset += TopGapOf(block); double top = yOffset; - double h = BlockExtent(block, maxWidth, top, out var ft, out var tl); + double h = BlockExtent(block, maxWidth, top, out var tl); yOffset += h + block.MarginBottom; if (block is TableBlock tb && tl is { } t) { @@ -702,34 +720,40 @@ private TextPointer GetPositionFromPoint(Point p) bestDistY = distY; bestPara = tb.Cells[r][c].Para; bestLocalIndex = GetParagraphLength(bestPara); bestAtLineEnd = false; + bestNeedsHit = false; } } } else if (block is Paragraph paragraph) { - if (ft == null) // empty paragraph: extent is a single line height + if (GetParagraphLength(paragraph) == 0) // empty paragraph: extent is a single line height { double dY = p.Y < top ? top - p.Y : (p.Y > top + h ? p.Y - (top + h) : 0); - if (dY < bestDistY) { bestDistY = dY; bestPara = paragraph; bestLocalIndex = 0; bestAtLineEnd = false; } + if (dY < bestDistY) { bestDistY = dY; bestPara = paragraph; bestLocalIndex = 0; bestAtLineEnd = false; bestNeedsHit = false; } } else { - double ppos = ParaLeft(paragraph); // A point on an inline table descends into its cells (returns immediately). if (p.Y >= top && p.Y <= top + h && - InlineTableHitDescent(paragraph, ft, ppos, top, p) is { } descended) + InlineTableHitDescent(paragraph, ParaLayout(paragraph, maxWidth), ParaLeft(paragraph), top, p) is { } descended) return descended; double distY2 = p.Y < top ? top - p.Y : (p.Y > top + h ? p.Y - (top + h) : 0); if (distY2 < bestDistY) { bestDistY = distY2; bestPara = paragraph; - (bestLocalIndex, bestAtLineEnd) = HitTestLogicalIndex(paragraph, ParagraphWrapWidth(paragraph, maxWidth), - ft, new Point(p.X - ppos, p.Y - top)); + bestTop = top; + bestNeedsHit = true; } } } } + if (bestNeedsHit && bestPara != null) + { + double ppos = ParaLeft(bestPara); + (bestLocalIndex, bestAtLineEnd) = HitTestLogicalIndex(bestPara, ParagraphWrapWidth(bestPara, maxWidth), + ParaLayout(bestPara, maxWidth), new Point(p.X - ppos, p.Y - bestTop)); + } return bestPara != null ? new TextPointer(bestPara, bestLocalIndex) { AtLineEnd = bestAtLineEnd } : new TextPointer(Document.Blocks[0] as Paragraph, 0); } } diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs index 655103b..63a19e3 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs @@ -1807,7 +1807,7 @@ private bool TryEnterTableRow(TableBlock tb, bool firstRow) { yOffset += TopGapOf(block); if (found) return yOffset; - double h = BlockExtent(block, maxWidth, yOffset, out _, out _); + double h = BlockExtent(block, maxWidth, yOffset, out _); found = ReferenceEquals(block, p); yOffset += h + block.MarginBottom; } @@ -1822,7 +1822,7 @@ private bool TryEnterTableRow(TableBlock tb, bool firstRow) { yOffset += TopGapOf(block); double top = yOffset; - double h = BlockExtent(block, maxWidth, top, out _, out _); + double h = BlockExtent(block, maxWidth, top, out _); // Only image/table blocks are "entered" by Up/Down arrow navigation. if ((block is TableBlock || block is ImageBlock) && y >= top && y <= top + h) return block; yOffset += h + block.MarginBottom; diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs index cc9086f..bd5eb78 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs @@ -539,7 +539,7 @@ void PlaceAtom(double height) // Block height + layout objects come from the single source (G1 BlockExtent), so the // vertical advance here can never drift from MeasureContentHeight / the hit-tests. Only the // *within-block* atom split (table rows, paragraph lines) is pagination-specific and stays. - double height = BlockExtent(block, contentWidth, y, out var paraLayout, out var tableLayout); + double height = BlockExtent(block, contentWidth, y, out var tableLayout); if (tableLayout is { } tl) { // Rows are the atoms (Word's default): a table taller than the remaining page @@ -550,28 +550,31 @@ void PlaceAtom(double height) for (int r = 0; r < ((TableBlock)block).Rows; r++) PlaceAtom(tl.RowY[r + 1] - tl.RowY[r]); } - else if (paraLayout is { } layout) + else if (block is Paragraph para && GetParagraphLength(para) > 0) { double paraTop = y; - // Line atom boundaries come from the layout's own line-top positions (the same - // geometry Render draws at), NOT from summing TextLine.Height — line spacing / - // LineHeight overrides make height sums drift from real line tops, which sliced - // glyphs in half at page boundaries. - var lines = layout.TextLines; - double atomTop = 0; - for (int li = 1; li <= lines.Count; li++) + // A paragraph that ends on the current page adds no break whatever its lines are (every line + // ends at or above its bottom), so only one that crosses the boundary needs them — about one per + // page. Reading the lines of every paragraph kept a layout for each in the cache. + if (paraTop + height > pageStart + pageContentHeight + eps) { - double atomBottom = li < lines.Count - ? layout.HitTestTextPosition(lines[li].FirstTextSourceIndex).Y - : layout.Height; - if (paraTop + atomBottom > pageStart + pageContentHeight + eps && paraTop + atomTop > pageStart) + // Line atom boundaries come from the layout's own line-top positions (the same + // geometry Render draws at), NOT from summing TextLine.Height — line spacing / + // LineHeight overrides make height sums drift from real line tops, which sliced + // glyphs in half at page boundaries. + var bottoms = LineBottoms(para, ParagraphWrapWidth(para, contentWidth)); + double atomTop = 0; + foreach (double atomBottom in bottoms) { - breaks.Add(paraTop + atomTop); - pageStart = paraTop + atomTop; + if (paraTop + atomBottom > pageStart + pageContentHeight + eps && paraTop + atomTop > pageStart) + { + breaks.Add(paraTop + atomTop); + pageStart = paraTop + atomTop; + } + atomTop = atomBottom; } - atomTop = atomBottom; } - y = paraTop + layout.Height; + y = paraTop + height; } else { diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs index 79331a6..5734064 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs @@ -44,7 +44,7 @@ private double MeasureContentHeight(double width) foreach (var block in Document.Blocks) { yOffset += TopGapOf(block); - double h = BlockExtent(block, width, yOffset, out _, out _); + double h = BlockExtent(block, width, yOffset, out _); // While the IME composes, the render walk advances by the caret paragraph's height WITH the // preedit spliced in, so the extent has to as well — otherwise the scrollable range stays a // line short of what is actually drawn and the composition can't be scrolled to at the end @@ -150,6 +150,7 @@ public override void Render(DrawingContext context) if (Document == null) return; _trustLayoutCache = !contentChanged; + long renderStart = _layoutStamp; // layouts used from here on are this frame's (TrimLayoutCache) _imagePixelScale = ScreenPixelScale(); // once per pass: pictures decode for the pixels they cover try { @@ -273,7 +274,14 @@ public override void Render(DrawingContext context) if (_tableDrawStart is { } ds && _tableDrawCurrent is { } dc) context.DrawRectangle(AccentFill50, AccentPen2, DrawnTableRect(ds, dc)); } - finally { _trustLayoutCache = false; } // never leak the trusted state past this render pass + finally + { + _trustLayoutCache = false; // never leak the trusted state past this render pass + // Bound what this frame and the ones before it left behind (see TrimLayoutCache). Nothing holds a + // layout across a render, and an evicted layout is never disposed, so a reference kept elsewhere + // stays valid — it is only no longer shared. + TrimLayoutCache(renderStart); + } } private static readonly Avalonia.Media.Immutable.ImmutableSolidColorBrush DeskBrush = new(Color.FromRgb(158, 158, 158)); @@ -367,7 +375,7 @@ private static Rect InsetTableEdges(Rect cell, Rect table) // same one measure/hit-tests/pagination consume — so the render walk can never drift from // them on a block's height. Drawing, culling, caret/selection and the per-cell IME-preedit // layout below all stay in render; only the geometry source is unified. - double beHeight = BlockExtent(block, maxWidth, yOffset, out var beParaLayout, out var beTableLayout); + double beHeight = BlockExtent(block, maxWidth, yOffset, out var beTableLayout); if (block is TableBlock tb) { orderedIndex = 0; @@ -474,21 +482,32 @@ private static Rect InsetTableEdges(Rect cell, Rect table) } double pWidth = ParagraphWrapWidth(paragraph, maxWidth); - // Non-preedit: reuse the cached layout BlockExtent already built (same width). The IME path - // rebuilds with the inline composition text, which is transient and never cached. - var layout = hasPreedit - ? BuildTextLayout(paragraph, pWidth, _caretPosition!.Offset, _preeditText) - : (beParaLayout ?? BuildTextLayout(paragraph, pWidth)); - // Cull check: the layout above is still built (cached; its height advances yOffset), - // but off-screen paragraphs issue no draw commands. The caret paragraph always draws. - bool pVisible = (yOffset + layout.Height >= visTop && yOffset <= visBottom) + // Cull check, on the measured height: an off-screen paragraph issues no draw commands and + // builds no layout — it advances by its height (BlockExtent). It used to build (and cache) one + // anyway, so every render kept the whole document's layouts alive. The caret paragraph always + // draws, and it is the only one an IME composition can make taller than its height. + bool pVisible = (yOffset + beHeight >= visTop && yOffset <= visBottom) || (_caretPosition != null && _caretPosition.Paragraph == paragraph); + if (!pVisible) + { + // The ordered counter still advances, one number per hard line, so visible numbering holds. + if (paragraph.ListType == ListKind.Ordered) + foreach (char ch in fullText) if (ch == '\n') orderedIndex++; + if (paragraph.ListType == ListKind.Ordered) orderedIndex++; + yOffset += beHeight + paragraph.MarginBottom; + continue; + } + + // The IME path rebuilds with the inline composition text, which is transient and never cached. + var layout = hasPreedit + ? BuildTextLayout(paragraph, pWidth, _caretPosition!.Offset, _preeditText) + : BuildTextLayout(paragraph, pWidth); // One marker per hard line (\n): each line of a list paragraph is an item. Ordered lists // number each line; this is what makes "press Enter -> next bullet/number" work given the // editor's "Enter inserts \n in a Run" model (lines aren't separate paragraphs). - // The ordered counter must advance even for culled paragraphs so visible numbering holds. + // (Culled paragraphs advanced the counter above.) if (paragraph.ListType != ListKind.None) { int segStart = 0; @@ -497,22 +516,13 @@ private static Rect InsetTableEdges(Rect cell, Rect table) if (i == fullText.Length || fullText[i] == '\n') { int marker = paragraph.ListType == ListKind.Ordered ? ++orderedIndex : 0; - if (pVisible) - { - var lcr = layout.HitTestTextPosition(Math.Min(segStart, fullText.Length)); - DrawListMarker(context, paragraph, marker, px, yOffset + lcr.Y); - } + var lcr = layout.HitTestTextPosition(Math.Min(segStart, fullText.Length)); + DrawListMarker(context, paragraph, marker, px, yOffset + lcr.Y); segStart = i + 1; } } } - if (!pVisible) - { - yOffset += layout.Height + paragraph.MarginBottom; - continue; - } - DrawParagraphDecor(context, paragraph, px, yOffset, pWidth, layout.Height, 0); if (selectedParagraphs?.Contains(paragraph) == true) diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs index 3c0cefa..7d241e2 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs @@ -63,7 +63,11 @@ private void InvalidateTableChain(TableBlock t) for (var cur = t; cur != null; cur = EnclosingTableOf(cur)) { _tableLayoutCache.Remove(cur); - if (cur.Parent is InlineTable it && it.Parent is Paragraph host) _layoutCache.Remove(host); + if (cur.Parent is InlineTable it && it.Parent is Paragraph host) + { + _layoutCache.Remove(host); + _heightCache.Remove(host); // a trusted pass reads the height without the signature, too + } } } diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.cs b/src/AvaloniaRichEditor/Controls/RichEditor.cs index fe57cc0..6567e1e 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.cs @@ -256,10 +256,37 @@ static RichEditor() private string? _preeditText; // IME composition text shown inline at the caret while composing. // Per-paragraph TextLayout cache. Building a TextLayout shapes/line-breaks text (the most expensive - // step), and Render + Measure + every hit-test would otherwise rebuild every paragraph each frame — - // crippling for large documents and the 2 Hz caret blink. Keyed by paragraph; reused while the - // paragraph's content signature and wrap width are unchanged. - private readonly Dictionary _layoutCache = new(); + // step), and Render + every hit-test would otherwise rebuild the paragraphs they touch each frame — + // crippling for the 2 Hz caret blink. Keyed by paragraph; reused while the paragraph's content + // signature and wrap width are unchanged. + // + // It holds what was DRAWN or hit-tested, not the document. Measure, pagination and every walk that only + // advances past a paragraph read its height from _heightCache instead. They used to take the layout, so + // the cache held one for every paragraph: measured 2026-09-28 (Tests.Render LayoutCacheProbe, real Skia) + // ~28–39 KB each, 6–9x the document model — 8,000 paragraphs, 236–306 MB of managed heap. And a render + // trims it back to the LayoutKeep most recently used (TrimLayoutCache), or scrolling to the end would + // fill it all over again. + private sealed class LayoutEntry(long sig, double width, Avalonia.Media.TextFormatting.TextLayout layout) + { + public readonly long Sig = sig; public readonly double Width = width; + public readonly Avalonia.Media.TextFormatting.TextLayout Layout = layout; + public long Stamp; // last use, for TrimLayoutCache + } + private readonly Dictionary _layoutCache = new(); + private long _layoutStamp; + + // A paragraph's height at a wrap width — exactly BuildTextLayout(p, width).Height, under the same + // signature and trusted-pass rules — and, once pagination asks, where its lines end. Weak-keyed: a + // paragraph an edit removes takes its entry with it (the layout cache needed a prune for that). + private sealed class HeightEntry(long sig, double width, double height) + { + public readonly long Sig = sig; public readonly double Width = width; public readonly double Height = height; + public double[]? LineBottoms; // filled lazily by LineBottoms() + } + private readonly System.Runtime.CompilerServices.ConditionalWeakTable _heightCache = new(); + + // Kept after a render; the slack keeps the trim from sorting on every frame. + private const int LayoutKeep = 256, LayoutTrimSlack = 128; // When set, BuildTextLayout trusts a same-width cache entry without recomputing ParagraphSig. // Only enabled for passes that provably don't mutate content (caret blink / scroll repaint via @@ -333,8 +360,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang _caretPen = null; // rebuild the cached caret pen with the new brush if (change.Property == DocumentProperty) { - _layoutCache.Clear(); - _tableLayoutCache.Clear(); + ClearLayoutCaches(); ResetInteractionState(); // selections/modes point into the document being replaced // A host's own model is trusted no more than a file: spans describing no grid crashed the editor or // hid cells (see TableBlock.EnsureSpanConsistency). Undo swaps in clones, consistent already. @@ -349,8 +375,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } if (change.Property == DefaultFontFamilyProperty || change.Property == DefaultFontSizeProperty) { - _layoutCache.Clear(); // default font feeds the cached layouts - _tableLayoutCache.Clear(); + ClearLayoutCaches(); // default font feeds the cached layouts (and heights) InvalidateMeasure(); InvalidateVisual(); } @@ -360,8 +385,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang RecordHostPageSetup(change.Property); // a page property set by code is the host's default (see there) CapturePageSetupToDocument(); // persist the page change into the document model _pageBreaks = null; // wrap width / paper changes between modes -> stale break positions - _layoutCache.Clear(); // cached layouts were shaped at the other mode's width - _tableLayoutCache.Clear(); + ClearLayoutCaches(); // cached layouts were shaped at the other mode's width InvalidateMeasure(); InvalidateVisual(); } @@ -1345,9 +1369,14 @@ internal static string ToRoman(int n) // The marker takes the item's own text styling (its first run: size, family, weight, colour) so a // heading / coloured / enlarged list item gets a matching bullet or number instead of a fixed // small black default. Instance method so it can fall back to the editor's default font/size. + // Test hook: when set, every marker drawn is recorded — numbering is decided by a walk that skips the + // paragraphs it culls, and nothing else observable says which number a visible item got. + internal List<(Paragraph p, int num)>? DrawnListMarkers; + private void DrawListMarker(DrawingContext context, Paragraph p, int num, double textLeft, double y) { if (p.ListType == ListKind.None) return; + DrawnListMarkers?.Add((p, num)); string m = ListMarkerText(p.ListType, p.ListMarker, num); Run? first = null; foreach (var inl in p.Inlines) if (inl is Run r) { first = r; break; } @@ -1382,11 +1411,13 @@ private static long ParagraphSig(Paragraph p) long h = 1469598103934665603; // FNV-1a 64-bit offset basis void Mix(long v) { h = (h ^ v) * 1099511628211; } void MixStr(string? s) { if (s == null) { Mix(0); return; } foreach (char ch in s) Mix(ch); Mix(s.Length + 1); } + // Wide values (a double's bits, a nested signature) through Avalanche first — see there. + void MixWide(long v) => Mix(Avalanche(v)); Mix((long)p.TextAlignment); - Mix(BitConverter.DoubleToInt64Bits(p.LineHeight)); - Mix(BitConverter.DoubleToInt64Bits(p.LineSpacing)); - Mix(BitConverter.DoubleToInt64Bits(p.Indent)); + MixWide(BitConverter.DoubleToInt64Bits(p.LineHeight)); + MixWide(BitConverter.DoubleToInt64Bits(p.LineSpacing)); + MixWide(BitConverter.DoubleToInt64Bits(p.Indent)); Mix((long)p.ListType); Mix((long)p.ListMarker); Mix(p.ListLevel); @@ -1398,7 +1429,7 @@ private static long ParagraphSig(Paragraph p) MixStr(r.Text); MixStr(r.FontFamily); MixStr(r.NavigateUri); - Mix(BitConverter.DoubleToInt64Bits(r.FontSize)); + MixWide(BitConverter.DoubleToInt64Bits(r.FontSize)); Mix((long)r.FontWeight); Mix((long)r.FontStyle); Mix(r.Foreground?.GetHashCode() ?? 0); @@ -1409,8 +1440,8 @@ private static long ParagraphSig(Paragraph p) else if (inl is InlineImage img) { Mix(7); - Mix(BitConverter.DoubleToInt64Bits(img.Width)); - Mix(BitConverter.DoubleToInt64Bits(img.Height)); + MixWide(BitConverter.DoubleToInt64Bits(img.Width)); + MixWide(BitConverter.DoubleToInt64Bits(img.Height)); // Identity only — never the Image getter here: it lazily decodes RawBytes, and // the signature must stay cheap (and decode-free) on every cache lookup (N6-2). Mix(img.RawBytes?.GetHashCode() ?? img.Image?.GetHashCode() ?? 0); @@ -1424,13 +1455,13 @@ private static long ParagraphSig(Paragraph p) Mix(13); Mix(it.Table.Rows); Mix(it.Table.Columns); - foreach (var w in it.Table.ColumnWidths) Mix(BitConverter.DoubleToInt64Bits(w)); - foreach (var rh in it.Table.RowHeights) Mix(BitConverter.DoubleToInt64Bits(rh)); + foreach (var w in it.Table.ColumnWidths) MixWide(BitConverter.DoubleToInt64Bits(w)); + foreach (var rh in it.Table.RowHeights) MixWide(BitConverter.DoubleToInt64Bits(rh)); // Every block a cell holds, not just its paragraphs: a block image, divider or // nested table in there sizes the cell too, so leaving them out served the host // paragraph's cached layout at the old box after such a block changed. foreach (var (_, _, cell) in it.Table.LogicalCells()) - foreach (var b in cell.Blocks) Mix(BlockSig(b)); + foreach (var b in cell.Blocks) MixWide(BlockSig(b)); } } return h; @@ -1444,43 +1475,83 @@ private static long BlockSig(Block b) { unchecked { + long h = 1469598103934665603; + void Mix(long v) { h = (h ^ Avalanche(v)) * 1099511628211; } switch (b) { case Paragraph p: return ParagraphSig(p); case ImageBlock img: - return 31 ^ BitConverter.DoubleToInt64Bits(img.Width) * 3 - ^ BitConverter.DoubleToInt64Bits(img.Height) * 5 - ^ (img.RawBytes?.GetHashCode() ?? img.Image?.GetHashCode() ?? 0); + Mix(31); + Mix(BitConverter.DoubleToInt64Bits(img.Width)); + Mix(BitConverter.DoubleToInt64Bits(img.Height)); + Mix(img.RawBytes?.GetHashCode() ?? img.Image?.GetHashCode() ?? 0); + return h; case DividerBlock: return 37; case TableBlock tb: - long h = 41 ^ (tb.Rows * 397L) ^ tb.Columns; - foreach (var w in tb.ColumnWidths) h = h * 31 + BitConverter.DoubleToInt64Bits(w); - foreach (var rh in tb.RowHeights) h = h * 31 + BitConverter.DoubleToInt64Bits(rh); + Mix(41); Mix(tb.Rows); Mix(tb.Columns); + foreach (var w in tb.ColumnWidths) Mix(BitConverter.DoubleToInt64Bits(w)); + foreach (var rh in tb.RowHeights) Mix(BitConverter.DoubleToInt64Bits(rh)); foreach (var (_, _, cell) in tb.LogicalCells()) - foreach (var cb in cell.Blocks) h = h * 31 + BlockSig(cb); + foreach (var cb in cell.Blocks) Mix(BlockSig(cb)); return h; default: return 43; } } } + // SplitMix64's finalizer: every input bit reaches every output bit. FNV's step — xor, then multiply by an + // odd constant — only carries a difference UPWARD, so two values that differ only in their high bits (a + // double's sign and exponent: NaN vs 1.5 differ in bits 62-63 alone) left the signatures differing only + // there, and two such changes in one edit could cancel. Measured 2026-09-28 by the fuzz's height oracle: + // select all + line spacing turned a paragraph's AND its inline table's cell LineSpacing from NaN to 1.5, + // the host's signature came out unchanged (1 in 28 value/heading pairs), and it kept its old layout and + // height. Characters are small and go in plain; everything wide goes through here first. + private static long Avalanche(long x) + { + unchecked + { + ulong z = (ulong)x; + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL; + return (long)(z ^ (z >> 31)); + } + } + private Avalonia.Media.TextFormatting.TextLayout BuildTextLayout(Paragraph p, double maxWidth, int preeditOffset = -1, string? preeditText = null) { // IME composition is transient and paragraph-local; never serve/store it from the cache. bool hasPreedit = !string.IsNullOrEmpty(preeditText) && preeditOffset >= 0; - long sig = 0; - if (!hasPreedit) + if (hasPreedit) return CreateTextLayout(p, maxWidth, preeditOffset, preeditText); + + // Trusted pass (no content change since the last build): skip the signature hash and reuse + // the same-width cached layout directly. Falls through to the verified path on a width/miss. + if (_trustLayoutCache && _layoutCache.TryGetValue(p, out var trusted) && trusted.Width == maxWidth) + { + trusted.Stamp = ++_layoutStamp; + return trusted.Layout; + } + long sig = ParagraphSig(p); + if (_layoutCache.TryGetValue(p, out var cached) && cached.Width == maxWidth && cached.Sig == sig) { - // Trusted pass (no content change since the last build): skip the signature hash and reuse - // the same-width cached layout directly. Falls through to the verified path on a width/miss. - if (_trustLayoutCache && _layoutCache.TryGetValue(p, out var trusted) && trusted.width == maxWidth) - return trusted.layout; - sig = ParagraphSig(p); - if (_layoutCache.TryGetValue(p, out var cached) && cached.width == maxWidth && cached.sig == sig) - return cached.layout; + cached.Stamp = ++_layoutStamp; + return cached.Layout; } + var layout = CreateTextLayout(p, maxWidth); + // Entries for deleted paragraphs are never re-accessed and would linger; past a generous cap, + // drop the ones no longer in the document (live entries survive and aren't reshaped) instead + // of clearing wholesale. (The per-render bound is TrimLayoutCache; this covers walks with no render.) + if (_layoutCache.Count > 10000) PruneLayoutCaches(); + _layoutCache[p] = new LayoutEntry(sig, maxWidth, layout) { Stamp = ++_layoutStamp }; + return layout; + } + + // Builds a paragraph's TextLayout — NOT cached. BuildTextLayout caches it; ParagraphHeight measures with it + // and lets it go, which is what keeps the cache from holding the whole document (see _heightCache). + private Avalonia.Media.TextFormatting.TextLayout CreateTextLayout(Paragraph p, double maxWidth, + int preeditOffset = -1, string? preeditText = null) + { var defaultFamily = DefaultFontFamily; double defaultSize = DefaultFontSize; // pt; kept in pt for the run-size fallback below var defaultProps = new Avalonia.Media.TextFormatting.GenericTextRunProperties( @@ -1587,18 +1658,8 @@ private Avalonia.Media.TextFormatting.TextLayout BuildTextLayout(Paragraph p, do 0, 0); - var layout = new Avalonia.Media.TextFormatting.TextLayout( + return new Avalonia.Media.TextFormatting.TextLayout( new ParagraphTextSource(segs), paraProps, null, Math.Max(1, maxWidth)); - - if (!hasPreedit) - { - // Entries for deleted paragraphs are never re-accessed and would linger; past a generous cap, - // drop the ones no longer in the document (live entries survive and aren't reshaped) instead - // of clearing wholesale. - if (_layoutCache.Count > 10000) PruneLayoutCaches(); - _layoutCache[p] = (sig, maxWidth, layout); - } - return layout; } // The layout a paragraph is actually RENDERED with. While the IME composes, the preedit text is @@ -1612,6 +1673,67 @@ private Avalonia.Media.TextFormatting.TextLayout PreeditAwareLayout(Paragraph p, ? BuildTextLayout(p, width, _caretPosition.Offset, _preeditText) : BuildTextLayout(p, width); + // A paragraph's layout height at `width` without keeping its layout: what Measure, pagination and the + // walks that only step past a paragraph need. Always equal to BuildTextLayout(p, width).Height — the same + // trusted-pass shortcut, the same signature check, and a cached layout's own height when there is one. + private double ParagraphHeight(Paragraph p, double width) => HeightEntryFor(p, width).Height; + + private HeightEntry HeightEntryFor(Paragraph p, double width) + { + if (_trustLayoutCache && _heightCache.TryGetValue(p, out var trusted) && trusted.Width == width) return trusted; + long sig = ParagraphSig(p); + if (_heightCache.TryGetValue(p, out var e) && e.Width == width && e.Sig == sig) return e; + double h; + if (_layoutCache.TryGetValue(p, out var c) && c.Width == width && c.Sig == sig) h = c.Layout.Height; + // A cached layout gone stale is almost always the paragraph being edited, on screen: rebuild it into + // the cache, or the render right after this measure would shape it a second time. + else if (_layoutCache.ContainsKey(p)) h = BuildTextLayout(p, width).Height; + else h = CreateTextLayout(p, width).Height; + var entry = new HeightEntry(sig, width, h); + _heightCache.AddOrUpdate(p, entry); + return entry; + } + + // Where each line of the paragraph ends, from its top (the last is the paragraph's height): pagination's + // line atoms, from the layout's own line tops — the same geometry Render draws at. Only paragraphs that + // straddle a page boundary ask, about one per page, so it is cached with the height rather than per call. + private double[] LineBottoms(Paragraph p, double width) + { + var entry = HeightEntryFor(p, width); + if (entry.LineBottoms is { } cached) return cached; + var layout = BuildTextLayout(p, width); + var lines = layout.TextLines; + var bottoms = new double[lines.Count]; + for (int li = 1; li <= lines.Count; li++) + bottoms[li - 1] = li < lines.Count ? layout.HitTestTextPosition(lines[li].FirstTextSourceIndex).Y : layout.Height; + return entry.LineBottoms = bottoms; + } + + // Everything shaped for the document: layouts, heights and table geometry. For changes the signatures + // can't see (the default font, the page width mode) and for a new document. + private void ClearLayoutCaches() + { + _layoutCache.Clear(); + _heightCache.Clear(); + _tableLayoutCache.Clear(); + } + + // After a render: drop the least recently used layouts until LayoutKeep remain — but never one this render + // used (stamped after `renderStart`): a visible table draws every cell, and a frame's own working set + // evicted would be reshaped on the next caret blink. Without it the cache held every paragraph ever DRAWN, + // and scrolling once to the end draws all of them. A dropped paragraph that comes back into view is shaped + // again — CPU only; its height is still cached, so nothing moves. + private void TrimLayoutCache(long renderStart) + { + if (_layoutCache.Count <= LayoutKeep + LayoutTrimSlack) return; + var old = new List>(); + foreach (var kv in _layoutCache) if (kv.Value.Stamp <= renderStart) 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++) _layoutCache.Remove(old[i].Key); + } + + internal int LayoutCacheCount => _layoutCache.Count; // test hook + // The 10,000-entry cap above was the only thing that ever pruned, and a Dictionary keyed by paragraph keeps a // paragraph an edit removed — with its TextLayout, ~30 KB each, and through the layout's picture callback its // InlineImage's bytes — until then. Measured 2026-09-28 (Tests.Render LayoutCacheProbe, real Skia): a @@ -1646,7 +1768,7 @@ internal void RunScheduledLayoutCachePrune() // keeping the live ones so nothing reshapes on the next frame. private void PruneLayoutCaches() { - if (Document == null) { _layoutCache.Clear(); _tableLayoutCache.Clear(); return; } + if (Document == null) { ClearLayoutCaches(); return; } var liveParas = new HashSet(GetAllParagraphsInOrder()); foreach (var key in _layoutCache.Keys.ToList()) if (!liveParas.Contains(key)) _layoutCache.Remove(key); diff --git a/tests/AvaloniaRichEditor.Tests.Render/AvaloniaRichEditor.Tests.Render.csproj b/tests/AvaloniaRichEditor.Tests.Render/AvaloniaRichEditor.Tests.Render.csproj index f20f2b5..7b8e043 100644 --- a/tests/AvaloniaRichEditor.Tests.Render/AvaloniaRichEditor.Tests.Render.csproj +++ b/tests/AvaloniaRichEditor.Tests.Render/AvaloniaRichEditor.Tests.Render.csproj @@ -17,6 +17,8 @@ + + diff --git a/tests/AvaloniaRichEditor.Tests.Render/LayoutCacheProbeTests.cs b/tests/AvaloniaRichEditor.Tests.Render/LayoutCacheProbeTests.cs index b868aad..982ed15 100644 --- a/tests/AvaloniaRichEditor.Tests.Render/LayoutCacheProbeTests.cs +++ b/tests/AvaloniaRichEditor.Tests.Render/LayoutCacheProbeTests.cs @@ -107,6 +107,59 @@ public void A_WhatTheCacheCosts_ByDocumentSize() } } + // What an app sees: the editor in a ScrollViewer in an 800x600 window, which is what render culling keys on + // (A renders with no scroller, so every paragraph counts as visible). Loaded at the top, then paged to the end. + [AvaloniaFact] + public void D_InAWindow_LoadedThenScrolledToTheEnd() + { + if (!Enabled) return; + // This app has no theme (the pixel tests want none), and an untemplated ScrollViewer has no viewport, + // so culling would see nothing. Only for this probe, and taken out again. + var theme = new Avalonia.Themes.Fluent.FluentTheme(); + Application.Current!.Styles.Add(theme); + try { RunWindowScenario(); } + finally { Application.Current!.Styles.Remove(theme); } + } + + private void RunWindowScenario() + { + foreach (int n in new[] { 2000, 8000 }) + { + long empty = Managed(); + var ed = new RichEditor { Document = HtmlDocumentFormatter.ParseHtml(BigHtml(n)) }; + var scroller = new Avalonia.Controls.ScrollViewer { Content = ed }; + var window = new Avalonia.Controls.Window { Width = 800, Height = 600, Content = scroller }; + window.Show(); + void Frame() + { + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + using var rtb = new RenderTargetBitmap(new PixelSize(800, 600)); + rtb.Render(window); + } + var sw = Stopwatch.StartNew(); + Frame(); + double loadMs = sw.Elapsed.TotalMilliseconds; + long loaded = Managed() - empty; + int atTop = Cache(ed).Count; + Log($" (scroller extent {scroller.Extent.Height:F0}, viewport {scroller.Viewport.Height:F0}, editor {ed.Bounds.Height:F0})"); + + sw.Restart(); + int pages = 0; + while (scroller.Offset.Y + scroller.Viewport.Height < scroller.Extent.Height - 1 && pages < 100_000) + { + scroller.Offset = new Vector(0, scroller.Offset.Y + scroller.Viewport.Height * 0.9); + Frame(); + pages++; + } + double scrollMs = sw.Elapsed.TotalMilliseconds; + long scrolled = Managed() - empty; + Log($" window {n,5} paras: load+first frame {loadMs,6:F0} ms, managed {loaded / Mb,6:F1} MB, cache {atTop,5} | " + + $"scrolled {pages} pages in {scrollMs:F0} ms ({scrollMs / Math.Max(1, pages):F1} ms/page), managed {scrolled / Mb,6:F1} MB, cache {Cache(ed).Count,5}"); + window.Content = null; // never Close(): the last headless top-level takes the dispatcher with it + } + } + [AvaloniaFact] public void B_ParagraphsAnEditRemoved_StayInTheCache() { diff --git a/tests/AvaloniaRichEditor.Tests/DocumentInvariantFuzzTests.cs b/tests/AvaloniaRichEditor.Tests/DocumentInvariantFuzzTests.cs index 3042c98..484924e 100644 --- a/tests/AvaloniaRichEditor.Tests/DocumentInvariantFuzzTests.cs +++ b/tests/AvaloniaRichEditor.Tests/DocumentInvariantFuzzTests.cs @@ -230,6 +230,14 @@ public void RandomEditSequence_KeepsEveryStructuralInvariant(int seed) // Measure between steps: layout is where a broken grid actually throws. ed.Measure(new Avalonia.Size(700, double.PositiveInfinity)); AssertAllInvariants(ed, $"seed {seed}, step {i} ({op}); history: {string.Join(" -> ", log.TakeLast(8))}"); + // The layout and height caches are invisible: an editor that lived through the edits measures + // what one given the same document fresh measures. This oracle found a signature that two + // changes in one edit cancelled, and list commands that never invalidated the measure (2026-09-28). + var fresh = new RichEditor { Document = (FlowDocument)ed.Document!.Clone(), PageSize = RichEditorPageSize.Continuous }; + fresh.Measure(new Avalonia.Size(700, double.PositiveInfinity)); + Assert.True(Math.Abs(fresh.DesiredSize.Height - ed.DesiredSize.Height) < 0.01, + $"seed {seed}, step {i} ({op}): edited editor measures {ed.DesiredSize.Height}, a fresh one {fresh.DesiredSize.Height}; " + + $"history: {string.Join(" -> ", log.TakeLast(8))}"); } } diff --git a/tests/AvaloniaRichEditor.Tests/LayoutCacheBoundTests.cs b/tests/AvaloniaRichEditor.Tests/LayoutCacheBoundTests.cs new file mode 100644 index 0000000..0d01b47 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/LayoutCacheBoundTests.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// The layout cache holds what was DRAWN or hit-tested, not the document (2026-09-28). Measure, pagination and +// every walk that only steps past a paragraph read its height (_heightCache) instead of taking its layout, +// which made the cache hold one per paragraph: measured with the Tests.Render LayoutCacheProbe in an 800x600 +// window, 8,000 paragraphs held 8,000 layouts and 270 MB of managed heap at load; now 29 layouts, 39 MB. A render +// trims it back to the most recently used, or scrolling to the end would fill it again. +// +// What these guard: the height is the layout's height exactly (and forgets what the layout forgets); the +// walks that now skip layouts still number, split and hit-test as before; the cache stays bounded — and never +// evicts what the frame drawing it is using. +public class LayoutCacheBoundTests +{ + private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; + private static readonly Type T = typeof(RichEditor); + + private static Avalonia.Media.TextFormatting.TextLayout Layout(RichEditor ed, Paragraph p, double w) + => (Avalonia.Media.TextFormatting.TextLayout)T.GetMethod("BuildTextLayout", NP)!.Invoke(ed, [p, w, -1, null])!; + private static double Height(RichEditor ed, Paragraph p, double w) + => (double)T.GetMethod("ParagraphHeight", NP)!.Invoke(ed, [p, w])!; + + private static string Words(int i) => $"paragraph {i} — " + string.Concat(Enumerable.Repeat("some words that wrap the line ", 1 + i % 5)); + + private static FlowDocument Paragraphs(int n, ListKind list = ListKind.None) + { + var doc = new FlowDocument(); + for (int i = 0; i < n; i++) + doc.Blocks.Add(new Paragraph { ListType = list, Inlines = { new Run { Text = list == ListKind.None ? Words(i) : $"item {i}" } } }); + return doc; + } + + // The editor in a ScrollViewer in a window: the only arrangement in which render culling (and so the + // bound) applies, since culling keys on the scroller's viewport. + private sealed class Host + { + public readonly Window Window; + public readonly ScrollViewer Scroller; + public readonly RichEditor Editor; + public Host(FlowDocument doc, double w = 800, double h = 600) + { + Editor = new RichEditor { Document = doc }; + Scroller = new ScrollViewer { Content = Editor }; + Window = new Window { Width = w, Height = h, Content = Scroller }; + Window.Show(); + Frame(); + } + public void Frame() + { + Window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + using var rtb = new RenderTargetBitmap(new PixelSize((int)Window.Width, (int)Window.Height)); + rtb.Render(Window); + } + public void ScrollTo(double y) { Scroller.Offset = new Vector(0, y); Frame(); } + // Content only: closing the last headless top-level shuts the dispatcher down for the tests after it. + public void Close() { Window.Content = null; Dispatcher.UIThread.RunJobs(); } + } + + // ---- the height is the layout's height ------------------------------------------------------------ + + [AvaloniaFact] + public void TheCachedHeight_IsTheLayoutsHeight_ForEveryKindOfParagraph() + { + var img = new InlineImage { Width = 30, Height = 44 }; + img.SetImageData([1, 2, 3], "image/png"); + var paras = new List + { + new() { Inlines = { new Run { Text = Words(3) } } }, + new() { HeadingLevel = 1, Inlines = { new Run { Text = "a heading that is long enough to wrap at a narrow width" } } }, + new() { ListType = ListKind.Ordered, Inlines = { new Run { Text = "one\ntwo\nthree" } } }, + new() { LineSpacing = 2.5, Inlines = { new Run { Text = Words(2) } } }, + new() { Inlines = { new Run { Text = "with a picture " }, img, new Run { Text = " after it", FontSize = 18 } } }, + new() { Inlines = { new Run { Text = "" } } }, + }; + var doc = new FlowDocument(); + doc.Blocks.AddRange(paras); + var ed = new RichEditor { Document = doc }; + foreach (var w in new[] { 80.0, 250, 700 }) + foreach (var p in paras) + { + double measured = Height(ed, p, w); + Assert.Equal(Layout(ed, p, w).Height, measured, 6); + } + } + + [AvaloniaFact] + public void TheCachedHeight_FollowsAnEdit_AndADefaultFontChange() + { + var p = new Paragraph { Inlines = { new Run { Text = Words(4), FontSize = 0 } } }; // 0 = the default size + var doc = new FlowDocument(); + doc.Blocks.Add(p); + var ed = new RichEditor { Document = doc }; + double before = Height(ed, p, 200); + + ed.DefaultFontSize = 24; // not part of any signature: the caches are cleared for it + double bigger = Height(ed, p, 200); + Assert.True(bigger > before, $"height {before} -> {bigger} after the default size grew"); + var fresh = new RichEditor { Document = new FlowDocument(), DefaultFontSize = 24 }; + Assert.Equal(Height(fresh, p, 200), bigger, 6); + + ((Run)p.Inlines[0]).Text += " and a good deal more text to push it onto further lines of the paragraph"; + Assert.Equal(Layout(ed, p, 200).Height, Height(ed, p, 200), 6); + } + + // Select all + line spacing sets a paragraph's LineSpacing AND its inline table's cell's, NaN -> value. + // NaN and 1.5 differ only in a double's top bits, and FNV's xor-multiply carries a difference only + // upward, so the two changes cancelled in the host's signature (1 in 28 of these pairs) and it kept its + // old layout and height. Every pair here must move the signature. + [Fact] + public void TwoWideChangesInOneEdit_NeverCancelInTheSignature() + { + var sig = T.GetMethod("ParagraphSig", BindingFlags.NonPublic | BindingFlags.Static)!; + var tb = new TableBlock(1, 1); + var cell = tb.Cells[0][0].Para; + cell.Inlines.Add(new Run { Text = "cell" }); + var host = new Paragraph { Inlines = { new Run { Text = "ab" }, new InlineTable { Table = tb }, new Run { Text = "cdef" } } }; + var cancelled = new List(); + foreach (var v in new[] { 1.0, 1.5, 2.0, 2.5, 3.0, 1.6, 0.8 }) + foreach (var heading in new[] { 0, 1, 2, 3 }) + { + host.HeadingLevel = heading; + host.LineSpacing = cell.LineSpacing = double.NaN; + long before = (long)sig.Invoke(null, [host])!; + host.LineSpacing = cell.LineSpacing = v; + if ((long)sig.Invoke(null, [host])! == before) cancelled.Add($"{v}/h{heading}"); + } + Assert.Empty(cancelled); + } + + // Bullets and numbers take a gutter from the wrap width, so toggling a list changes the height; the list + // commands repainted without invalidating the measure, and the editor kept the height it had before. + [AvaloniaFact] + public void TogglingAList_ChangesTheMeasuredHeight() + { + var doc = new FlowDocument(); + doc.Blocks.Clear(); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = string.Concat(Enumerable.Repeat("word ", 400)) } } }); + var ed = new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }; + var size = new Size(300, double.PositiveInfinity); + ed.Measure(size); + double plain = ed.DesiredSize.Height; + T.GetMethod("SelectAll", NP, Type.EmptyTypes)!.Invoke(ed, null); + + ed.ToggleBullet(); + ed.Measure(size); + double bulleted = ed.DesiredSize.Height; + Assert.True(bulleted > plain, $"height {plain} -> {bulleted}: the gutter narrowed the lines, so there are more of them"); + + ed.ToggleBullet(); + ed.Measure(size); + Assert.Equal(plain, ed.DesiredSize.Height, 2); + } + + // ---- the walks that stopped taking layouts --------------------------------------------------------- + + // Pagination reads line ends only for a paragraph that crosses a page, from the cached LineBottoms. + // Oracle: the old loop over the layout's own line tops, for every page height in a range, so a break can + // land at every line of every paragraph (a line-parity test would miss an atom made two lines tall). + [AvaloniaFact] + public void PageBreaks_MatchTheLayoutsOwnLineTops_AtEveryPageHeight() + { + var doc = new FlowDocument(); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "short opener" } } }); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = string.Concat(Enumerable.Repeat("a long paragraph of many lines ", 40)) } } }); + doc.Blocks.Add(new Paragraph { LineSpacing = 2, Inlines = { new Run { Text = string.Concat(Enumerable.Repeat("spaced out lines here ", 25)) } } }); + var ed = new RichEditor { Document = doc }; + const double width = 300; + + for (double pch = 40; pch <= 400; pch += 7) + { + var got = ed.ComputePageBreaks(width, pch); + Assert.Equal(Oracle(ed, doc, width, pch), got); + } + } + + private static List Oracle(RichEditor ed, FlowDocument doc, double contentWidth, double pch) + { + var breaks = new List { 0 }; + double y = 0, pageStart = 0; + const double eps = 0.01; + var wrap = T.GetMethod("ParagraphWrapWidth", BindingFlags.NonPublic | BindingFlags.Static)!; + foreach (Paragraph p in doc.Blocks.Cast()) + { + y += ed.TopGapOf(p); + var layout = Layout(ed, p, (double)wrap.Invoke(null, [p, contentWidth])!); + double paraTop = y, atomTop = 0; + var lines = layout.TextLines; + for (int li = 1; li <= lines.Count; li++) + { + double atomBottom = li < lines.Count ? layout.HitTestTextPosition(lines[li].FirstTextSourceIndex).Y : layout.Height; + if (paraTop + atomBottom > pageStart + pch + eps && paraTop + atomTop > pageStart) + { + breaks.Add(paraTop + atomTop); + pageStart = paraTop + atomTop; + } + atomTop = atomBottom; + } + y = paraTop + layout.Height + p.MarginBottom; + } + return breaks; + } + + // Culled paragraphs build no layout now, but still advance the ordered numbering, one per hard line. + [AvaloniaFact] + public void OrderedNumbering_CountsTheParagraphsCulledAboveTheViewport() + { + var doc = Paragraphs(300, ListKind.Ordered); + ((Run)((Paragraph)doc.Blocks[5]).Inlines[0]).Text = "two\nlines"; // a culled item holding two numbers + var host = new Host(doc); + try + { + host.ScrollTo(host.Scroller.Extent.Height); // the end: everything above is culled + host.Editor.DrawnListMarkers = new(); + host.Editor.InvalidateVisual(); + host.Frame(); + + var drawn = host.Editor.DrawnListMarkers; + Assert.NotEmpty(drawn); + var blocks = host.Editor.Document!.Blocks; + foreach (var (p, num) in drawn) + { + int i = blocks.IndexOf(p); + Assert.True(i > 5, $"paragraph {i} was drawn — the test needs the item with two lines culled"); + Assert.Equal(i + 2, num); // item i is number i + 1, plus the extra line of item 5 + } + } + finally { host.Close(); } + } + + // A link in a cell's SECOND paragraph: the cell link walk now steps past the first on its height alone. + [AvaloniaFact] + public void ALinkInACellsSecondParagraph_IsFound() + { + var link = new Run { Text = "the link", NavigateUri = "https://example.com" }; + var tb = new TableBlock(1, 1); + tb.Cells[0][0].Blocks.Clear(); + tb.Cells[0][0].Blocks.Add(new Paragraph { Inlines = { new Run { Text = Words(2) } } }); + var second = new Paragraph { Inlines = { link } }; + tb.Cells[0][0].Blocks.Add(second); + var doc = new FlowDocument(); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "before" } } }); + doc.Blocks.Add(tb); + var host = new Host(doc); + try + { + T.GetField("_caretPosition", NP)!.SetValue(host.Editor, new TextPointer(second, 2)); + host.Editor.InvalidateVisual(); + host.Frame(); + var caret = (Point)T.GetField("_lastCaretPoint", NP)!.GetValue(host.Editor)!; + var hit = T.GetMethod("GetLinkRunAtPoint", NP)!.Invoke(host.Editor, [new Point(caret.X + 1, caret.Y + 4)]); + Assert.Same(link, hit); + } + finally { host.Close(); } + } + + // ---- the bound ------------------------------------------------------------------------------------------- + + [AvaloniaFact] + public void Loading_KeepsLayoutsForWhatIsOnScreen_NotTheDocument() + { + var host = new Host(Paragraphs(2000)); + try + { + Assert.True(host.Scroller.Extent.Height > 10 * host.Scroller.Viewport.Height, "the document must be much taller than the window"); + Assert.InRange(host.Editor.LayoutCacheCount, 1, 100); + } + finally { host.Close(); } + } + + [AvaloniaFact] + public void ScrollingToTheEnd_KeepsTheCacheBounded_AndTheScreenCached() + { + var host = new Host(Paragraphs(2000)); + try + { + var sv = host.Scroller; + while (sv.Offset.Y + sv.Viewport.Height < sv.Extent.Height - 1) + host.ScrollTo(sv.Offset.Y + sv.Viewport.Height * 0.9); + Assert.InRange(host.Editor.LayoutCacheCount, 1, 256 + 128); + var cache = (IDictionary)T.GetField("_layoutCache", NP)!.GetValue(host.Editor)!; + Assert.True(cache.Contains(host.Editor.Document!.Blocks[^1]), "the last paragraph, on screen, is not cached"); + Assert.False(cache.Contains(host.Editor.Document!.Blocks[0]), "the first paragraph, 2,000 back, is still cached"); + } + finally { host.Close(); } + } + + // A visible table draws every cell; its cells are one frame's working set and must all survive the trim, + // or each caret blink would shape them again. + [AvaloniaFact] + public void ATrim_NeverEvictsWhatTheFrameDrawingItUsed() + { + var tb = new TableBlock(400, 1); + for (int r = 0; r < 400; r++) tb.Cells[r][0].Para.Inlines.Add(new Run { Text = $"cell {r}" }); + var doc = new FlowDocument(); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "before" } } }); + doc.Blocks.Add(tb); + var host = new Host(doc); + try + { + host.Editor.InvalidateVisual(); + host.Frame(); + Assert.True(host.Editor.LayoutCacheCount >= 400, $"{host.Editor.LayoutCacheCount} layouts after drawing a 400-cell table"); + } + finally { host.Close(); } + } +}