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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
5 changes: 4 additions & 1 deletion Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 무한 루프 — 엔진 버그라 상향이 유일한 수정).

**기능 (전부 동작·테스트 그린)**
Expand Down
4 changes: 4 additions & 0 deletions src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -269,13 +270,15 @@ 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;
}
if (turningOff)
{
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;
}
Expand Down Expand Up @@ -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();
}

Expand Down
88 changes: 56 additions & 32 deletions src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -59,16 +61,17 @@ 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)
{
foreach (var (r, c, rect) in t.AnchorRects)
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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
{
Expand All @@ -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)
{
Expand All @@ -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)
{
Expand All @@ -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;
}
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -559,20 +573,19 @@ 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)
{
double blkTop = oy + by;
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
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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);
}
}
Loading
Loading