From 2bb04120d35343f60dffb030032f48226c8d073c Mon Sep 17 00:00:00 2001 From: token <61819790+239573049@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:37:28 +0800 Subject: [PATCH 1/5] Fetch https images with the platform handler in browsers SocketsHttpHandler does not exist in the browser: setting UseCookies or AllowAutoRedirect throws PlatformNotSupportedException, so a MarkdownServices created in a WebAssembly app failed in its static initializer. The browser now uses HttpClientHandler; the fetch API follows redirects itself, so the per-hop policy check applies on desktop only. Desktop behavior is unchanged. Co-Authored-By: Claude Haiku 5.5 --- src/StreamMark.Avalonia/Images/HttpsFetcher.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/StreamMark.Avalonia/Images/HttpsFetcher.cs b/src/StreamMark.Avalonia/Images/HttpsFetcher.cs index 5435dcc..dc931fc 100644 --- a/src/StreamMark.Avalonia/Images/HttpsFetcher.cs +++ b/src/StreamMark.Avalonia/Images/HttpsFetcher.cs @@ -16,7 +16,7 @@ public sealed class HttpsFetcher : IImageFetcher, IDisposable /// 使用内置的 (无 Cookie、不自动重定向、自动解压)。 public HttpsFetcher() - : this(CreateDefaultHandler(), disposeHandler: true) + : this(CreatePlatformHandler(), disposeHandler: true) { } @@ -28,6 +28,13 @@ public HttpsFetcher(HttpMessageHandler handler, bool disposeHandler) _ownsClient = true; } + /// + /// 浏览器没有 (它的 Cookie、自动重定向等设置会抛 ), + /// 改用平台处理器。浏览器的 fetch 自动跟随重定向,因此逐跳策略检查只在桌面生效。 + /// + private static HttpMessageHandler CreatePlatformHandler() => + OperatingSystem.IsBrowser() ? new HttpClientHandler() : CreateDefaultHandler(); + /// 创建默认处理器。 public static SocketsHttpHandler CreateDefaultHandler() => new() { From 2f02144eca867ebf81a9d59e3f5c1fb6545c8bd7 Mon Sep 17 00:00:00 2001 From: token <61819790+239573049@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:44:27 +0800 Subject: [PATCH 2/5] Navigation: source-offset jumps, rendered-point lookup and reading anchors MarkdownView gets block-level mapping between Markdown source offsets and rendered positions, so an editor preview can sync its caret and scroll: - ScrollToSourceOffset(offset) scrolls to the deepest block containing the offset (list items and paragraphs inside quotes included). The result says why nothing moved: NoDocument, OutOfRange or LayoutPending. - GetSourceRange(TextPoint) and GetSelectionSourceRange() map a rendered point or the selection back to the source range of the blocks it covers. TextPoint.Offset is an offset in the segment's plain text, never a source offset, so emphasis and links cannot be mistaken for source columns. - CaptureAnchor() / RestoreAnchor() save the character at the top of the outer ScrollViewer plus its pixel displacement, so width, font or theme changes keep the reading position. Anchors are tied to the new MarkdownView.DocumentId (not the snapshot Generation, which also changes on Complete()), so an anchor never restores onto a different document. - SourceText.ToLocation / ToOffset define the line/column convention: UTF-16 code units, CRLF counts as 2 and is not normalised. Closes #6 Co-Authored-By: Claude Sonnet 5.5 --- .agents/codemap/modules/avalonia-selection.md | 6 + .../codemap/modules/core-parsing-and-model.md | 1 + README.md | 5 + src/StreamMark.Avalonia/MarkdownView.cs | 126 ++++++ .../Selection/DocumentNavigation.cs | 118 +++++- .../Selection/SourceNavigation.cs | 73 ++++ src/StreamMark.Core/Model/SourceText.cs | 86 +++++ .../Rendering/SourceNavigationTests.cs | 361 ++++++++++++++++++ .../Parsing/SourceTextTests.cs | 71 ++++ 9 files changed, 846 insertions(+), 1 deletion(-) create mode 100644 src/StreamMark.Avalonia/Selection/SourceNavigation.cs create mode 100644 src/StreamMark.Core/Model/SourceText.cs create mode 100644 tests/StreamMark.Avalonia.Tests/Rendering/SourceNavigationTests.cs create mode 100644 tests/StreamMark.Core.Tests/Parsing/SourceTextTests.cs diff --git a/.agents/codemap/modules/avalonia-selection.md b/.agents/codemap/modules/avalonia-selection.md index af9ff55..04e8a6a 100644 --- a/.agents/codemap/modules/avalonia-selection.md +++ b/.agents/codemap/modules/avalonia-selection.md @@ -18,6 +18,9 @@ updated: 2026-10-16 | `ISelectionHost` | src/StreamMark.Avalonia/Selection/SelectionController.cs:13 | 宿主契约:`RealizedSegments` / `EntryCount` / `GetSnapshot` / `GetSource` / `OnSelectionDrag` / `Plugins` | | `ISelectableSegment` | src/StreamMark.Avalonia/Selection/ISelectableSegment.cs:7 | 片段视图契约:`Key` / `Text` / `View` / `HitTest` / `GetCharacterBounds` | | `BlockText` | src/StreamMark.Avalonia/Selection/BlockText.cs:11 | 唯一"块模型 → 片段列表/纯文本"的提取器,复制与视图编号共用 | +| `DocumentNavigation.ResolveSourceOffset` / `.SourceRangeOf` | src/StreamMark.Avalonia/Selection/DocumentNavigation.cs:71、:107 | 源码偏移 ↔ 渲染位置的块级换算(纯函数,输入是快照)。偏移 → `SourceTarget`(含 `TextPoint` 与块区间);`TextPoint` → 承载该片段的最深块的源码区间 | +| `MarkdownView.ScrollToSourceOffset` / `.GetSourceRange` / `.GetSelectionSourceRange` | src/StreamMark.Avalonia/MarkdownView.cs:257、:276、:280 | 宿主入口:按源码偏移滚动(返回 `SourceNavigationResult`,状态说明为什么没动)、渲染点 / 选区反查源码区间 | +| `MarkdownView.CaptureAnchor` / `.RestoreAnchor` / `.DocumentId` | src/StreamMark.Avalonia/MarkdownView.cs:295、:328、:110 | 阅读锚点:外层 `ScrollViewer` 可视区顶部的字符 + 像素位移;绑定视图级 `DocumentId`,跨文档不恢复 | ## Key types and functions | Symbol | File | Role | @@ -70,6 +73,9 @@ updated: 2026-10-16 - To change 拖拽时自动滚动的速度/边界:改 `TranscriptPanel.OnSelectionDrag`(`Transcript/TranscriptPanel.cs:924`),本模块只负责调用(`Selection/SelectionController.cs:106`)。 ## Invariants and traps +- **`TextPoint.Offset` 不是源码偏移**:它是片段纯文本里的下标(粗体、链接的标记不在里面)。源码位置只有 `BlockKey`(块起点)与块的 `Range` 是真的,所以反查只给块级区间(`SourceRangeOf`),不要把片段偏移加到 `BlockKey` 上冒充源码偏移 — see `Selection/DocumentNavigation.cs:107`。 +- **阅读锚点的身份是 `MarkdownView.DocumentId`,不是快照的 `Generation`**:`Generation` 在 `Complete()` 时也会递增(`DocumentComposer.Finish`),同一份流式源码完成前后 `Generation` 不同。`DocumentId` 只在 `Markdown` / `Document` / `Stream` 被赋值时递增;外部对已绑定的 `StreamingDocument` 直接 `Reset` 它察觉不到,此时锚点按块 `Key` 找不到会退到最近的块(`Approximate`)— see `MarkdownView.cs:110`、`:328`。 +- 源码导航与锚点恢复在版面未稳定(`IsMeasureValid` / `IsArrangeValid` 为假)时直接返回 `LayoutPending` 而不滚动,不要改成"尽力而为"——位置会按旧版面算错 — see `MarkdownView.cs`(`IsLayoutReady`)。 - 选区坐标绝不持有视图引用 — 否则虚拟化回收后选区指向已丢弃的控件 — see `Selection/SelectionModel.cs:9`、`Selection/SelectionController.cs:189`。 - `GetSelectedText` 的片段区间按**片段下标**从 `BlockText` 重算,不依赖任何视图是否实例化;而视图侧编号只在 `Update` 时分配,因此"顺序一致"是唯一粘合剂 — see `Selection/SelectionController.cs:204`、`Rendering/BlockView.cs:43`。 - 指针判定顺序:`ClickCount == 2` 选词、`>= 3` 选整段、默认分支才看 Shift 扩展,否则 anchor=active 定位(`Selection/SelectionController.cs:72-91`)。注意 `case 2` 里的 `WordAt` 调用在 `switch` 分支内声明,`default` 分支不会执行它;拖拽只在 `ClickCount == 1` 时成立(`:92`),双击/三击不会进入拖拽状态。 diff --git a/.agents/codemap/modules/core-parsing-and-model.md b/.agents/codemap/modules/core-parsing-and-model.md index 21839bb..bfdd233 100644 --- a/.agents/codemap/modules/core-parsing-and-model.md +++ b/.agents/codemap/modules/core-parsing-and-model.md @@ -19,6 +19,7 @@ updated: 2026-10-16 | `BackslashMathExtension` | `src/StreamMark.Core/Parsing/BackslashMathExtension.cs:BackslashMathExtension` | 公开扩展:`\(…\)` / `\[…\]` 数学定界符 | | `MarkdigMapper.MapDocument` | `src/StreamMark.Core/Parsing/MarkdigMapper.cs:MapDocument` | `internal`,整篇 Markdig 文档 → 块模型;`DocumentComposer.Parse` 是唯一生产调用方(`DocumentComposer.cs:115`) | | `OffsetMap.Create` / `ToSource` | `src/StreamMark.Core/Parsing/OffsetMap.cs:Create` | `internal`,修补后位置 → 原始源码位置,块区间与 key 都经它落回原文 | +| `SourceText.ToLocation` / `ToOffset` | `src/StreamMark.Core/Model/SourceText.cs:SourceText` | public,源码偏移 ↔ 行 / 列(0 起始)的约定:偏移 = UTF-16 下标,`\r\n` 占 2 个单位且不归一化,换行符为 `\n` / `\r\n` / 单独 `\r`;与 `SourceRange` 同一套单位 | | `HtmlElements` | `src/StreamMark.Core/Html/HtmlTree.cs:HtmlElements` | 支持元素集合(Block / Inline / Void / Containers),是"未知元素按源码显示"的判定依据 | | `HtmlTreeBuilder` / `HtmlMapper` | `src/StreamMark.Core/Html/HtmlTree.cs:HtmlTreeBuilder`、`src/StreamMark.Core/Html/HtmlMapper.cs:HtmlMapper` | public,但当前只有 `MarkdigMapper` 调用(`MarkdigMapper.cs:86`、`:125`);测试直接驱动构建器(`tests/StreamMark.Core.Tests/Html/HtmlTests.cs:190`) | | `CssStyle` | `src/StreamMark.Core/Html/CssStyle.cs:CssStyle` | public,`FromAttributes` / `Apply` / `TryParseLength` / `TryParseColor` / `ImageSize`;渲染层也读它的钳制常量(`TextSegmentView.cs:929`) | diff --git a/README.md b/README.md index 133b21f..ac79bcd 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,11 @@ Raw HTML in Markdown (README style) is converted into the block model, so it sup - `TranscriptView.GetOutline()` and `MarkdownView.GetOutline()` return the headings as `OutlineItem(Target, Level, Text, Id)`. Pass `Target` to `ScrollToPoint`. - `TranscriptView.ScrollToPoint(TextPoint)` parses deferred entries if needed and places the target at about 30% from the top. `MarkdownView.ScrollToPoint` relies on the outer `ScrollViewer`. - In-document links (`#heading-id`, footnote references `#fn:…`) jump inside the document and are not passed to the host. +- **Source positions and reading anchors** (`MarkdownView`, block-level precision): + - Offsets are UTF-16 code units, the same as `string` indexes and `SourceRange`: a CJK character counts as 1, a surrogate pair as 2, and `\r\n` as 2 (the text is not normalised). `SourceText.ToLocation` and `SourceText.ToOffset` convert to and from 0-based line and column with the same rules. + - `ScrollToSourceOffset(offset)` scrolls to the deepest block that contains the offset: a list item, a paragraph inside a quote, or a code block as a whole. It returns a `SourceNavigationResult`; when nothing moved, the status says why: `NoDocument` (no snapshot applied yet), `OutOfRange`, or `LayoutPending` (try again next frame). + - `GetSourceRange(TextPoint)` and `GetSelectionSourceRange()` map a rendered point or the selection back to the source range of the blocks it covers. `TextPoint.Offset` is an offset inside the segment's plain text, not a source offset, so text with emphasis or links never lines up with the source. + - `CaptureAnchor()` and `RestoreAnchor(anchor)` save and restore the reading position: the character at the top of the outer `ScrollViewer` plus its pixel displacement. A width, font or theme change keeps the place. An anchor is tied to `DocumentId`, which changes whenever `Markdown`, `Document` or `Stream` is assigned; an anchor from another document returns `WrongDocument`. If the anchored block is gone, the view scrolls to the next block (`Approximate`). - **Search**: `view.Search` is a `SearchModel`. - `Find(query, matchCase)` highlights every match, including content scrolled off-screen. - `Next()`, `Previous()` and `MoveTo(i)` jump to a match. `Clear()` removes the highlights. diff --git a/src/StreamMark.Avalonia/MarkdownView.cs b/src/StreamMark.Avalonia/MarkdownView.cs index 25fba03..b9b8e6e 100644 --- a/src/StreamMark.Avalonia/MarkdownView.cs +++ b/src/StreamMark.Avalonia/MarkdownView.cs @@ -2,9 +2,11 @@ using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Threading; +using Avalonia.VisualTree; using StreamMark.Avalonia.Extensions; using StreamMark.Avalonia.Rendering; using StreamMark.Avalonia.Selection; +using StreamMark.Core.Model; using StreamMark.Core.Streaming; namespace StreamMark.Avalonia; @@ -100,8 +102,16 @@ public event EventHandler? LinkClicked private StreamingDocument? EffectiveDocument => Stream?.Document ?? Document ?? _ownDocument; + /// + /// 视图里文档的标识:每次设置 、 或 都会递增 + /// (这些赋值会清空选区与块状态,相当于换了一个文档)。源码位置与 只在同一个标识内有意义。 + /// 外部直接对已绑定的 调用 Reset 不会改变它,需要宿主重新赋值。 + /// + public int DocumentId { get; private set; } + private void OnSourceChanged() { + DocumentId++; if (Stream is null && Document is null) { _ownDocument ??= new StreamingDocument(MarkdownServices.GetServices(this).Profile); @@ -238,6 +248,122 @@ public bool ScrollToPoint(TextPoint point) /// 滚动到某个顶层块的开头。 public bool ScrollToBlock(int blockKey) => ScrollToPoint(new TextPoint(0, blockKey, 0, 0)); + /// + /// 滚动到源码偏移处(UTF-16 下标,行 / 列换算见 ),块级精度:列表项、引用等容器里的段落也能命中, + /// 但不会细分到块内的行。偏移是相对当前已应用快照的源码而言的, 或文本变了以后请重新换算。 + /// 还没有应用快照(刚设置 时)返回 , + /// 目标块没有排版完成时返回 ,两种情况都没有滚动,等下一帧再试。 + /// + public SourceNavigationResult ScrollToSourceOffset(int offset) + { + if (_presenter.Applied is not { Count: > 0 } snapshot) + { + return new SourceNavigationResult(SourceNavigationStatus.NoDocument); + } + if (DocumentNavigation.ResolveSourceOffset(snapshot, 0, offset) is not { } target) + { + return new SourceNavigationResult(SourceNavigationStatus.OutOfRange); + } + if (!IsLayoutReady || !_presenter.Views.Any(v => v.TopKey == target.Point.BlockKey)) + { + return new SourceNavigationResult(SourceNavigationStatus.LayoutPending, target); + } + ScrollToPoint(target.Point); + return new SourceNavigationResult(SourceNavigationStatus.Navigated, target); + } + + /// 渲染位置 → 源码区间,块级精度:返回承载该文本片段的最深的块的完整区间。快照没有对应的块时返回 null。 + public SourceRange? GetSourceRange(TextPoint point) => + _presenter.Applied is { } snapshot ? DocumentNavigation.SourceRangeOf(snapshot, point) : null; + + /// 当前选区覆盖的源码区间(两端各扩展到完整的块);没有选区时返回 null。 + public SourceRange? GetSelectionSourceRange() + { + if (Selection.Range is not { } range || _presenter.Applied is not { } snapshot + || DocumentNavigation.SourceRangeOf(snapshot, range.Start) is not { } first + || DocumentNavigation.SourceRangeOf(snapshot, range.End) is not { } last) + { + return null; + } + return new SourceRange(first.Start, Math.Max(first.End, last.End) - first.Start); + } + + /// + /// 记录当前阅读位置:外层 可视区域顶部的那个字符,以及它相对顶边的像素位移。 + /// 没有外层滚动容器或还没有内容时返回 null。 + /// + public ReadingAnchor? CaptureAnchor() + { + if (_presenter.Applied is null || this.FindAncestorOfType() is not { } scroll || scroll.TranslatePoint(default, this) is not { } viewport) + { + return null; + } + var top = viewport.Y; + foreach (var view in _presenter.Views) + { + if (view.Bounds.Bottom <= top) + { + continue; + } + foreach (var segment in view.Segments()) + { + if (segment.View.TranslatePoint(default, this) is not { } origin || origin.Y + segment.View.Bounds.Height <= top) + { + continue; + } + var offset = segment.HitTest(new Point(0, Math.Max(0, top - origin.Y))); + var rect = segment.GetCharacterBounds(offset); + return new ReadingAnchor(DocumentId, view.TopKey, segment.Key.Segment, offset, top - (origin.Y + rect.Top)); + } + return new ReadingAnchor(DocumentId, view.TopKey, -1, 0, top - view.Bounds.Top); + } + return null; + } + + /// + /// 把 记录的位置滚回可视区域顶部。锚点来自另一个文档时不恢复; + /// 锚点所在的块已不存在时滚动到它之后最近的块()。 + /// 版面还没有稳定(刚改过宽度、字体或内容,需要等一次布局)时返回 。 + /// + public AnchorRestoreStatus RestoreAnchor(ReadingAnchor anchor) + { + if (anchor.DocumentId != DocumentId) + { + return AnchorRestoreStatus.WrongDocument; + } + var views = _presenter.Views; + if (_presenter.Applied is not { Count: > 0 } || views.Count == 0) + { + return AnchorRestoreStatus.NoDocument; + } + if (this.FindAncestorOfType() is not { } scroll) + { + return AnchorRestoreStatus.NoViewport; + } + if (!IsLayoutReady || scroll.TranslatePoint(default, this) is not { } viewport) + { + return AnchorRestoreStatus.LayoutPending; + } + var exact = views.FirstOrDefault(v => v.TopKey == anchor.BlockKey); + var view = exact ?? views.FirstOrDefault(v => v.TopKey >= anchor.BlockKey) ?? views[^1]; + var key = new SegmentKey(0, anchor.BlockKey, anchor.Segment); + double target; + if (exact is not null && anchor.Segment >= 0 && view.Segments().FirstOrDefault(s => s.Key == key) is { } segment + && segment.View.TranslatePoint(default, this) is { } origin) + { + target = origin.Y + segment.GetCharacterBounds(anchor.Offset).Top + anchor.Displacement; + } + else + { + target = view.Bounds.Top + (exact is null ? 0 : anchor.Displacement); + } + var maxOffset = Math.Max(0, scroll.Extent.Height - scroll.Viewport.Height); + scroll.Offset = new Vector(scroll.Offset.X, Math.Clamp(scroll.Offset.Y + target - viewport.Y, 0, maxOffset)); + return exact is null ? AnchorRestoreStatus.Approximate : AnchorRestoreStatus.Restored; + } + + private bool IsLayoutReady => IsMeasureValid && IsArrangeValid && Bounds.Height > 0; + // 文内锚点(#标题、#fn:脚注)在文档内跳转,找不到时交给宿主 private void OnAnchorLinkClicked(object? sender, LinkClickedEventArgs e) { diff --git a/src/StreamMark.Avalonia/Selection/DocumentNavigation.cs b/src/StreamMark.Avalonia/Selection/DocumentNavigation.cs index 8d02a39..a60a455 100644 --- a/src/StreamMark.Avalonia/Selection/DocumentNavigation.cs +++ b/src/StreamMark.Avalonia/Selection/DocumentNavigation.cs @@ -6,7 +6,7 @@ namespace StreamMark.Avalonia.Selection; /// 目录中的一项:标题所在位置(可直接交给 ScrollToPoint)、级别、文字与锚点 id。 public sealed record OutlineItem(TextPoint Target, int Level, string Text, string? Id); -/// 基于块模型的导航:目录、文内锚点(#标题id、脚注 #fn:标签)解析为选区坐标。 +/// 基于块模型的导航:目录、文内锚点(#标题id、脚注 #fn:标签)解析为选区坐标,以及源码偏移与渲染位置的互相换算。 public static class DocumentNavigation { /// 文档中的标题(含 HTML 盒子、引用、列表等容器内的标题),按阅读顺序。 @@ -62,6 +62,122 @@ public static List Outline(DocumentSnapshot snapshot, int entry) return match?.Target; } + /// + /// 源码偏移(UTF-16 下标,见 )→ 渲染位置,块级精度:返回包含该偏移的最深的块, + /// 列表项、引用、提示块等容器里的段落也能单独命中;偏移落在列表项的项目符号上时指向该项的第一个子块。 + /// 偏移在块与块之间(空行)或末尾之后时返回它之后最近的块()。 + /// 快照没有块,或偏移不在 [0, ] 内时返回 null。 + /// + public static SourceTarget? ResolveSourceOffset(DocumentSnapshot snapshot, int entry, int offset) + { + if (snapshot.Count == 0 || offset < 0 || offset > snapshot.SourceLength) + { + return null; + } + // 最后一个起点不超过偏移的顶层块(块按起点升序排列) + int low = 0, high = snapshot.Count - 1, found = -1; + while (low <= high) + { + var mid = (low + high) / 2; + if (snapshot[mid].Range.Start <= offset) + { + found = mid; + low = mid + 1; + } + else + { + high = mid - 1; + } + } + if (found >= 0 && Contains(snapshot[found].Range, offset)) + { + var top = snapshot[found]; + var (block, range) = Locate(top, offset); + var segment = SegmentOf(top, block) ?? 0; + return new SourceTarget(new TextPoint(entry, top.Key, segment, 0), range, SourcePrecision.Block); + } + var nearest = snapshot[Math.Min(found + 1, snapshot.Count - 1)]; + return new SourceTarget(new TextPoint(entry, nearest.Key, 0, 0), nearest.Range, SourcePrecision.Nearest); + } + + /// + /// 渲染位置 → 源码区间,块级精度:返回承载该文本片段的最深的块的完整区间,不细分到行或字符 + /// ( 是片段纯文本里的偏移,不能直接当源码偏移)。找不到对应的顶层块时返回 null。 + /// + public static SourceRange? SourceRangeOf(DocumentSnapshot snapshot, TextPoint point) + { + MarkdownBlock? top = null; + for (var i = 0; i < snapshot.Count; i++) + { + if (snapshot[i].Key == point.BlockKey) + { + top = snapshot[i]; + break; + } + } + if (top is null) + { + return null; + } + var result = top.Range; + if (point.Segment >= 0) + { + // 先序遍历里越靠后的命中越深(兄弟块的片段区间互不重叠) + foreach (var block in Descendants(top)) + { + if (SegmentOf(top, block) is { } first && point.Segment >= first && point.Segment < first + BlockText.Segments(block).Count && block.Range.Length > 0) + { + result = block.Range; + } + } + } + return result; + } + + private static bool Contains(SourceRange range, int offset) => range.Length > 0 && offset >= range.Start && offset < range.End; + + // 偏移所在的最深的块及其区间。列表多一层列表项:偏移在项目符号上(不在任何子块里)时用项的第一个子块代表,区间取整个列表项 + private static (MarkdownBlock Block, SourceRange Range) Locate(MarkdownBlock top, int offset) + { + var current = top; + var range = top.Range; + while (true) + { + if (current is ListBlock list) + { + var item = list.Items.FirstOrDefault(i => Contains(i.Range, offset)); + if (item is null || item.Children.Length == 0) + { + return (current, range); + } + var inner = item.Children.FirstOrDefault(c => Contains(c.Range, offset)); + if (inner is null) + { + return (item.Children[0], item.Range); + } + current = inner; + range = inner.Range; + continue; + } + IEnumerable children = current switch + { + HtmlBoxBlock box => box.Children, + QuoteBlock quote => quote.Children, + AlertBlock alert => alert.Children, + ComponentBlock component => component.Children, + CustomContainerBlock container => container.Children, + _ => [], + }; + var next = children.FirstOrDefault(c => Contains(c.Range, offset)); + if (next is null) + { + return (current, range); + } + current = next; + range = next.Range; + } + } + /// GitHub 风格的锚点:小写,空白变连字符,去掉标点(保留字母、数字、CJK、连字符与下划线)。 public static string Slug(string text) { diff --git a/src/StreamMark.Avalonia/Selection/SourceNavigation.cs b/src/StreamMark.Avalonia/Selection/SourceNavigation.cs new file mode 100644 index 0000000..7c1e05e --- /dev/null +++ b/src/StreamMark.Avalonia/Selection/SourceNavigation.cs @@ -0,0 +1,73 @@ +using StreamMark.Core.Model; + +namespace StreamMark.Avalonia.Selection; + +/// 源码位置与渲染位置的对应精度。目前只有块级:不细分到块内的行或字符。 +public enum SourcePrecision +{ + /// 偏移落在返回的块区间内(块级精度)。 + Block, + + /// 偏移落在块与块之间(空行等)或文档末尾之后,结果是它之后最近的块(末尾之后则为最后一块)。 + Nearest, +} + +/// +/// 源码偏移解析出的渲染位置: 可直接交给 ScrollToPoint; +/// 是命中块(容器内取最深的块)的源码区间。 +/// +public readonly record struct SourceTarget(TextPoint Point, SourceRange Range, SourcePrecision Precision); + +public enum SourceNavigationStatus +{ + /// 已滚动到目标。 + Navigated, + + /// 还没有应用任何快照(刚设置 Markdown 时就是这样),或文档没有块。 + NoDocument, + + /// 偏移为负,或超过当前快照的源码长度。 + OutOfRange, + + /// 目标块的视图还没有完成排版,没有滚动;等下一帧再试。 + LayoutPending, +} + +public readonly record struct SourceNavigationResult(SourceNavigationStatus Status, SourceTarget? Target = null); + +/// +/// 阅读锚点:重新排版(宽度、字体、主题变化)后回到同一处阅读位置所需的信息。 +/// 位置以文本坐标记录( 是块的源码起始偏移),所以内容重新折行后仍然指向同一个字; +/// 是该位置相对可视区域顶部的像素位移。 +/// +/// 捕获时 的值;不相等的视图文档不会恢复。 +/// 顶层块的源码起始偏移。 +/// 块内文本片段序号;块里没有文本片段时为 -1。 +/// 片段内的字符偏移(不是源码偏移)。 +/// 锚点位置的顶边到可视区域顶边的距离,向下为正;位置在可视区域上方时为负。 +public sealed record ReadingAnchor(int DocumentId, int BlockKey, int Segment, int Offset, double Displacement) +{ + /// 锚点所在块在源码中的起始偏移(等于 )。 + public int SourceOffset => BlockKey; +} + +public enum AnchorRestoreStatus +{ + /// 已恢复到捕获时的位置。 + Restored, + + /// 锚点所在的块已不存在,改为滚动到它之后最近的块(没有则最后一块)。 + Approximate, + + /// 锚点来自另一个文档( 不同),没有恢复。 + WrongDocument, + + /// 还没有应用任何快照,或文档没有块。 + NoDocument, + + /// 找不到外层滚动容器(ScrollViewer),没有恢复。 + NoViewport, + + /// 目标块的视图还没有完成排版,没有恢复;等下一帧再试。 + LayoutPending, +} diff --git a/src/StreamMark.Core/Model/SourceText.cs b/src/StreamMark.Core/Model/SourceText.cs new file mode 100644 index 0000000..9fc9b07 --- /dev/null +++ b/src/StreamMark.Core/Model/SourceText.cs @@ -0,0 +1,86 @@ +namespace StreamMark.Core.Model; + +/// 源码中的行 / 列位置,均从 0 开始。 +public readonly record struct SourceLocation(int Line, int Column); + +/// +/// 源码偏移与行 / 列之间的换算约定: +/// 偏移是 UTF-16 code unit 的下标(即 的索引,与 一致), +/// 所以中文按 1 个单位、代理对(emoji 等)按 2 个单位;\r\n 是一个换行符但占 2 个单位,不做归一化; +/// 换行符有 \n、\r\n 和单独的 \r 三种;列 = 偏移 − 该行起点,同样按 UTF-16 单位。 +/// 每次调用都从头扫描文本,需要大量换算时请自行建立行起点表。 +/// +public static class SourceText +{ + /// + /// 偏移 → 行 / 列。偏移落在 \r\n 中间时算作该行行尾(列 = 行长度); + /// 超出范围的偏移钳到 [0, text.Length]。 + /// + public static SourceLocation ToLocation(string text, int offset) + { + offset = Math.Clamp(offset, 0, text.Length); + var line = 0; + var lineStart = 0; + for (var i = 0; i < offset; i++) + { + if (!IsBreak(text, i, out var length)) + { + continue; + } + if (i + length > offset) + { + // 偏移在 \r 与 \n 之间 + return new SourceLocation(line, i - lineStart); + } + line++; + lineStart = i + length; + i += length - 1; + } + return new SourceLocation(line, offset - lineStart); + } + + /// + /// 行 / 列 → 偏移。行号超过最后一行时返回文本末尾;列超过该行长度(不含换行符)时钳到行尾;负数按 0 处理。 + /// + public static int ToOffset(string text, SourceLocation location) + { + var line = 0; + var lineStart = 0; + var wanted = Math.Max(location.Line, 0); + for (var i = 0; i < text.Length && line < wanted; i++) + { + if (IsBreak(text, i, out var length)) + { + line++; + lineStart = i + length; + i += length - 1; + } + } + if (line < wanted) + { + return text.Length; + } + var lineEnd = lineStart; + while (lineEnd < text.Length && !IsBreak(text, lineEnd, out _)) + { + lineEnd++; + } + return lineStart + Math.Min(Math.Max(location.Column, 0), lineEnd - lineStart); + } + + private static bool IsBreak(string text, int index, out int length) + { + length = 0; + switch (text[index]) + { + case '\n': + length = 1; + return true; + case '\r': + length = index + 1 < text.Length && text[index + 1] == '\n' ? 2 : 1; + return true; + default: + return false; + } + } +} diff --git a/tests/StreamMark.Avalonia.Tests/Rendering/SourceNavigationTests.cs b/tests/StreamMark.Avalonia.Tests/Rendering/SourceNavigationTests.cs new file mode 100644 index 0000000..9c56b4c --- /dev/null +++ b/tests/StreamMark.Avalonia.Tests/Rendering/SourceNavigationTests.cs @@ -0,0 +1,361 @@ +using System.Text; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.VisualTree; +using StreamMark.Avalonia.Rendering; +using StreamMark.Avalonia.Selection; +using StreamMark.Core.Model; +using StreamMark.Core.Streaming; +using Xunit; + +namespace StreamMark.Avalonia.Tests.Rendering; + +public sealed class SourceNavigationTests +{ + private const string Sample = + "# Intro\n\nFirst **bold** [link](https://x.y) end.\n\n- one\n- two item\n\n> quote para\n\n```js\nlet a = 1;\n```\n\n# Intro\n"; + + private static async Task Parse(string text) + { + var document = new StreamingDocument(); + document.Load(text); + await document.WhenIdle(); + return document.Latest; + } + + [Fact] + public async Task ResolveSourceOffset_FindsDeepestBlock() + { + var snapshot = await Parse(Sample); + + // 段落中间(粗体标记之内) + var paragraph = DocumentNavigation.ResolveSourceOffset(snapshot, 0, Sample.IndexOf("bold", StringComparison.Ordinal))!.Value; + Assert.Equal(SourcePrecision.Block, paragraph.Precision); + Assert.Equal(new TextPoint(0, snapshot[1].Key, 0, 0), paragraph.Point); + Assert.Equal(snapshot[1].Range, paragraph.Range); + + // 列表第二项的文字:命中那一项的段落(片段序号 1),而不是整个列表 + var twoItem = Sample.IndexOf("two item", StringComparison.Ordinal); + var item = DocumentNavigation.ResolveSourceOffset(snapshot, 0, twoItem)!.Value; + Assert.Equal(new TextPoint(0, snapshot[2].Key, 1, 0), item.Point); + Assert.True(item.Range.Start <= twoItem && twoItem < item.Range.End); + Assert.True(item.Range.Length < snapshot[2].Range.Length); + + // 项目符号上:同样指向第二项,区间是整个列表项 + var marker = Sample.IndexOf("- two", StringComparison.Ordinal); + var onMarker = DocumentNavigation.ResolveSourceOffset(snapshot, 0, marker)!.Value; + Assert.Equal(new TextPoint(0, snapshot[2].Key, 1, 0), onMarker.Point); + Assert.Equal(marker, onMarker.Range.Start); + + // 引用里的段落 + var quote = DocumentNavigation.ResolveSourceOffset(snapshot, 0, Sample.IndexOf("para", StringComparison.Ordinal))!.Value; + Assert.Equal(snapshot[3].Key, quote.Point.BlockKey); + Assert.True(quote.Range.Length < snapshot[3].Range.Length || snapshot[3].Range == quote.Range); + + // 代码块内部:块级精度,指向整个代码块 + var code = DocumentNavigation.ResolveSourceOffset(snapshot, 0, Sample.IndexOf("let a", StringComparison.Ordinal))!.Value; + Assert.Equal(SourcePrecision.Block, code.Precision); + Assert.Equal(snapshot[4].Key, code.Point.BlockKey); + Assert.Equal(snapshot[4].Range, code.Range); + } + + [Fact] + public async Task ResolveSourceOffset_GapsAndEnds_ResolveToNearestBlock() + { + var snapshot = await Parse(Sample); + + // 段落与列表之间的空行 + var gap = Sample.IndexOf("end.\n\n- one", StringComparison.Ordinal) + "end.\n".Length; + var between = DocumentNavigation.ResolveSourceOffset(snapshot, 0, gap)!.Value; + Assert.Equal(SourcePrecision.Nearest, between.Precision); + Assert.Equal(snapshot[2].Key, between.Point.BlockKey); + + // 文档末尾(最后一个换行之后):最后一块 + var end = DocumentNavigation.ResolveSourceOffset(snapshot, 0, Sample.Length)!.Value; + Assert.Equal(SourcePrecision.Nearest, end.Precision); + Assert.Equal(snapshot[^1].Key, end.Point.BlockKey); + + Assert.Null(DocumentNavigation.ResolveSourceOffset(snapshot, 0, -1)); + Assert.Null(DocumentNavigation.ResolveSourceOffset(snapshot, 0, Sample.Length + 1)); + Assert.Null(DocumentNavigation.ResolveSourceOffset(DocumentSnapshot.Empty, 0, 0)); + } + + [Fact] + public async Task ResolveSourceOffset_RepeatedHeadings_AreDistinguishedByOffset() + { + var snapshot = await Parse(Sample); + var first = DocumentNavigation.ResolveSourceOffset(snapshot, 0, Sample.IndexOf("# Intro", StringComparison.Ordinal) + 3)!.Value; + var second = DocumentNavigation.ResolveSourceOffset(snapshot, 0, Sample.LastIndexOf("# Intro", StringComparison.Ordinal) + 3)!.Value; + Assert.NotEqual(first.Point.BlockKey, second.Point.BlockKey); + Assert.Equal(snapshot[0].Key, first.Point.BlockKey); + Assert.Equal(snapshot[^1].Key, second.Point.BlockKey); + } + + [Theory] + [InlineData("\n")] + [InlineData("\r\n")] + public async Task ResolveSourceOffset_OffsetsStayInSourceCoordinates_ForCrlfAndCjk(string newline) + { + // 中文按 1 个单位、emoji(代理对)按 2 个单位;\r\n 占 2 个单位 + var text = "# 标题😀" + newline + newline + "第一段 **粗体😀** 与 [链接](https://x.y) 结尾。" + newline + newline + + "- 项目一" + newline + "- 项目二😀 内容" + newline + newline + "最后一段"; + var snapshot = await Parse(text); + foreach (var needle in new[] { "标题", "粗体", "链接", "项目二", "内容", "最后一段" }) + { + var offset = text.IndexOf(needle, StringComparison.Ordinal); + var target = DocumentNavigation.ResolveSourceOffset(snapshot, 0, offset)!.Value; + Assert.Equal(SourcePrecision.Block, target.Precision); + Assert.True(target.Range.Start <= offset && offset < target.Range.End, $"{needle}: {offset} not in {target.Range}"); + Assert.Equal(target.Range, DocumentNavigation.SourceRangeOf(snapshot, target.Point)); + } + } + + [Fact] + public async Task SourceRangeOf_PlainTextOffsetIsNotTakenAsSourceOffset() + { + var snapshot = await Parse(Sample); + var paragraph = (ParagraphBlock)snapshot[1]; + + // 段落纯文本比源码短(粗体与链接的标记不在纯文本里),所以片段内偏移不能当源码偏移 + Assert.True(BlockText.PlainText(paragraph.Content).Length < paragraph.Range.Length); + var range = DocumentNavigation.SourceRangeOf(snapshot, new TextPoint(0, paragraph.Key, 0, 3)); + Assert.Equal(paragraph.Range, range); + Assert.Equal(paragraph.Range, DocumentNavigation.SourceRangeOf(snapshot, new TextPoint(0, paragraph.Key, 0, 12))); + + // 列表第二项的片段 → 那一项的段落,而不是整个列表 + var list = snapshot[2]; + var second = DocumentNavigation.SourceRangeOf(snapshot, new TextPoint(0, list.Key, 1, 0))!.Value; + Assert.True(second.Length < list.Range.Length); + Assert.Equal(Sample.IndexOf("two item", StringComparison.Ordinal), second.Start); + + Assert.Null(DocumentNavigation.SourceRangeOf(snapshot, new TextPoint(0, 99999, 0, 0))); + } + + private static string LongDocument() + { + var text = new StringBuilder("# Title\n\n"); + for (var i = 0; i < 40; i++) + { + text.Append($"## Section {i}\n\nParagraph {i} with **bold** and [link](https://example.com) text, ") + .Append(string.Concat(Enumerable.Repeat("lorem ipsum dolor sit amet ", 12))) + .Append("\n\n"); + } + text.Append("- first item\n- second item with more words\n- third item\n\n```csharp\nvar x = 1;\nvar y = 2;\n```\n"); + return text.ToString(); + } + + private static async Task<(Window Window, ScrollViewer Scroll, MarkdownView View)> Show(string markdown, double width = 420, double height = 320) + { + var view = new MarkdownView { Markdown = markdown, Margin = new(12) }; + var scroll = new ScrollViewer { Content = view }; + var window = new Window { Width = width, Height = height, Content = scroll, Background = Brushes.Black }; + window.Show(); + await HeadlessApp.Settle(); + view.ApplyLatest(); + await HeadlessApp.Settle(); + return (window, scroll, view); + } + + private static bool IsVisible(ScrollViewer scroll, MarkdownView view, string textStart) => + view.GetVisualDescendants().OfType().Where(s => s.Content.Text.StartsWith(textStart, StringComparison.Ordinal)) + .Any(s => s.TranslatePoint(default, scroll) is { } p && p.Y >= -1 && p.Y < scroll.Viewport.Height); + + [Fact] + public async Task ScrollToSourceOffset_NavigatesToParagraphsAndListItems_NotOnlyHeadings() + { + await HeadlessApp.Run(async () => + { + var text = LongDocument(); + var view = new MarkdownView { Markdown = text, Margin = new(12) }; + var scroll = new ScrollViewer { Content = view }; + var window = new Window { Width = 420, Height = 320, Content = scroll, Background = Brushes.Black }; + window.Show(); + + // 还没有应用快照 + Assert.Equal(SourceNavigationStatus.NoDocument, view.ScrollToSourceOffset(0).Status); + await HeadlessApp.Settle(); + view.ApplyLatest(); + await HeadlessApp.Settle(); + + var inParagraph = text.IndexOf("Paragraph 30 with", StringComparison.Ordinal) + 20; + var result = view.ScrollToSourceOffset(inParagraph); + Assert.Equal(SourceNavigationStatus.Navigated, result.Status); + Assert.Equal(SourcePrecision.Block, result.Target!.Value.Precision); + await HeadlessApp.Settle(); + Assert.True(scroll.Offset.Y > 0); + Assert.True(IsVisible(scroll, view, "Paragraph 30")); + + // 列表第二项 + var item = view.ScrollToSourceOffset(text.IndexOf("second item", StringComparison.Ordinal) + 3); + Assert.Equal(SourceNavigationStatus.Navigated, item.Status); + await HeadlessApp.Settle(); + Assert.True(IsVisible(scroll, view, "second item")); + + // 代码块内部(块级) + var code = view.ScrollToSourceOffset(text.IndexOf("var y", StringComparison.Ordinal)); + Assert.Equal(SourceNavigationStatus.Navigated, code.Status); + Assert.Equal(view.Snapshot![^1].Range, code.Target!.Value.Range); + + Assert.Equal(SourceNavigationStatus.OutOfRange, view.ScrollToSourceOffset(-1).Status); + Assert.Equal(SourceNavigationStatus.OutOfRange, view.ScrollToSourceOffset(text.Length + 1).Status); + window.Close(); + }); + } + + [Fact] + public async Task ScrollToSourceOffset_BeforeLayout_ReportsLayoutPending_WithoutScrolling() + { + await HeadlessApp.Run(async () => + { + var (window, scroll, view) = await Show(LongDocument()); + view.Width = 300; + var result = view.ScrollToSourceOffset(5); + Assert.Equal(SourceNavigationStatus.LayoutPending, result.Status); + Assert.NotNull(result.Target); + Assert.Equal(0, scroll.Offset.Y); + window.Close(); + }); + } + + [Fact] + public async Task GetSourceRange_FromRenderedPointAndSelection() + { + await HeadlessApp.Run(async () => + { + var text = Sample; + var (window, _, view) = await Show(text); + var snapshot = view.Snapshot!; + + Assert.Equal(snapshot[1].Range, view.GetSourceRange(new TextPoint(0, snapshot[1].Key, 0, 4))); + Assert.Null(view.GetSourceRange(new TextPoint(0, 99999, 0, 0))); + Assert.Null(view.GetSelectionSourceRange()); + + // 选区从第一段中间到代码块中间:覆盖第一段起点到代码块终点 + view.Selection.Set(new TextPoint(0, snapshot[1].Key, 0, 2), new TextPoint(0, snapshot[4].Key, 0, 3)); + var range = view.GetSelectionSourceRange()!.Value; + Assert.Equal(snapshot[1].Range.Start, range.Start); + Assert.Equal(snapshot[4].Range.End, range.End); + window.Close(); + }); + } + + [Fact] + public async Task ReadingAnchor_SurvivesWidthChange_WithinSameDocument() + { + await HeadlessApp.Run(async () => + { + var (window, scroll, view) = await Show(LongDocument()); + view.ScrollToSourceOffset(LongDocument().IndexOf("Paragraph 25 with", StringComparison.Ordinal)); + await HeadlessApp.Settle(); + scroll.Offset = new Vector(0, scroll.Offset.Y + 37); + await HeadlessApp.Settle(); + + var anchor = view.CaptureAnchor()!; + Assert.NotNull(anchor); + Assert.Equal(view.DocumentId, anchor.DocumentId); + Assert.True(anchor.Segment >= 0); + var before = scroll.Offset.Y; + + // 变窄后重新折行,滚动位置若不修正就会指向别处 + window.Width = 260; + await HeadlessApp.Settle(); + Assert.Equal(AnchorRestoreStatus.Restored, view.RestoreAnchor(anchor)); + await HeadlessApp.Settle(); + Assert.NotEqual(before, scroll.Offset.Y); + + // 锚点字符仍然在可视区域顶部(相对顶边的位移保持不变) + var segment = view.GetVisualDescendants().OfType().First(s => s.Key.BlockKey == anchor.BlockKey && s.Key.Segment == anchor.Segment); + var top = scroll.TranslatePoint(default, view)!.Value.Y; + var charTop = segment.TranslatePoint(default, view)!.Value.Y + segment.GetCharacterBounds(anchor.Offset).Top; + Assert.InRange(top - charTop, anchor.Displacement - 1.5, anchor.Displacement + 1.5); + + var after = view.CaptureAnchor()!; + Assert.Equal((anchor.BlockKey, anchor.Segment), (after.BlockKey, after.Segment)); + window.Close(); + }); + } + + [Fact] + public async Task ReadingAnchor_IsNotRestoredOntoADifferentDocument() + { + await HeadlessApp.Run(async () => + { + var (window, scroll, view) = await Show(LongDocument()); + view.ScrollToSourceOffset(LongDocument().IndexOf("Paragraph 20 with", StringComparison.Ordinal)); + await HeadlessApp.Settle(); + var anchor = view.CaptureAnchor()!; + + view.Markdown = LongDocument().Replace("Paragraph", "Chapter"); + await HeadlessApp.Settle(); + view.ApplyLatest(); + await HeadlessApp.Settle(); + var offset = scroll.Offset.Y; + + Assert.NotEqual(anchor.DocumentId, view.DocumentId); + Assert.Equal(AnchorRestoreStatus.WrongDocument, view.RestoreAnchor(anchor)); + Assert.Equal(offset, scroll.Offset.Y); + window.Close(); + }); + } + + [Fact] + public async Task ReadingAnchor_FallsBackToNearestBlock_WhenTargetIsGone() + { + await HeadlessApp.Run(async () => + { + var document = new StreamingDocument(); + document.Load(LongDocument()); + var view = new MarkdownView { Document = document, Margin = new(12) }; + var scroll = new ScrollViewer { Content = view }; + var window = new Window { Width = 420, Height = 320, Content = scroll, Background = Brushes.Black }; + window.Show(); + await document.WhenIdle(); + await HeadlessApp.Settle(); + view.ApplyLatest(); + await HeadlessApp.Settle(); + + view.ScrollToSourceOffset(LongDocument().IndexOf("Paragraph 30 with", StringComparison.Ordinal)); + await HeadlessApp.Settle(); + var anchor = view.CaptureAnchor()!; + + // 外部重置文档(视图的 DocumentId 不变):原来的块已经不存在 + document.Load("# Short\n\nOnly a little text.\n"); + await document.WhenIdle(); + view.ApplyLatest(); + await HeadlessApp.Settle(); + + Assert.Equal(AnchorRestoreStatus.Approximate, view.RestoreAnchor(anchor)); + window.Close(); + }); + } + + [Fact] + public async Task ReadingAnchor_WithoutScrollViewer_OrBeforeLayout_ReportsWhy() + { + await HeadlessApp.Run(async () => + { + // 没有外层 ScrollViewer + var bare = new MarkdownView { Markdown = Sample }; + var bareWindow = new Window { Width = 400, Height = 300, Content = bare }; + bareWindow.Show(); + await HeadlessApp.Settle(); + bare.ApplyLatest(); + await HeadlessApp.Settle(); + Assert.Null(bare.CaptureAnchor()); + Assert.Equal(AnchorRestoreStatus.NoViewport, bare.RestoreAnchor(new ReadingAnchor(bare.DocumentId, 0, 0, 0, 0))); + bareWindow.Close(); + + // 还没有快照 + var empty = new MarkdownView { Markdown = Sample }; + Assert.Equal(AnchorRestoreStatus.NoDocument, empty.RestoreAnchor(new ReadingAnchor(empty.DocumentId, 0, 0, 0, 0))); + + // 刚改过宽度、版面还没有稳定 + var (window, _, view) = await Show(LongDocument()); + var anchor = view.CaptureAnchor()!; + view.Width = 280; + Assert.Equal(AnchorRestoreStatus.LayoutPending, view.RestoreAnchor(anchor)); + window.Close(); + }); + } +} diff --git a/tests/StreamMark.Core.Tests/Parsing/SourceTextTests.cs b/tests/StreamMark.Core.Tests/Parsing/SourceTextTests.cs new file mode 100644 index 0000000..054a186 --- /dev/null +++ b/tests/StreamMark.Core.Tests/Parsing/SourceTextTests.cs @@ -0,0 +1,71 @@ +using StreamMark.Core.Model; +using Xunit; + +namespace StreamMark.Core.Tests.Parsing; + +public sealed class SourceTextTests +{ + [Theory] + [InlineData("abc\ndef", 0, 0, 0)] + [InlineData("abc\ndef", 3, 0, 3)] + [InlineData("abc\ndef", 4, 1, 0)] + [InlineData("abc\ndef", 7, 1, 3)] + [InlineData("abc\r\ndef", 5, 1, 0)] + // \r\n 是一个换行符但占 2 个单位:偏移在 \r 与 \n 之间算作行尾 + [InlineData("abc\r\ndef", 4, 0, 3)] + [InlineData("a\rb\nc", 2, 1, 0)] + [InlineData("a\rb\nc", 4, 2, 0)] + // 中文每个字 1 个单位,代理对(emoji)2 个单位 + [InlineData("中文\n段落", 3, 1, 0)] + [InlineData("😀x\ny", 2, 0, 2)] + [InlineData("😀x\ny", 4, 1, 0)] + public void ToLocation_FollowsDocumentedConvention(string text, int offset, int line, int column) + { + Assert.Equal(new SourceLocation(line, column), SourceText.ToLocation(text, offset)); + } + + [Fact] + public void ToLocation_ClampsOutOfRangeOffsets() + { + Assert.Equal(new SourceLocation(0, 0), SourceText.ToLocation("abc", -5)); + Assert.Equal(new SourceLocation(0, 3), SourceText.ToLocation("abc", 99)); + Assert.Equal(new SourceLocation(1, 0), SourceText.ToLocation("abc\n", 99)); + } + + [Theory] + [InlineData("abc\ndef", 0, 0, 0)] + [InlineData("abc\ndef", 1, 2, 6)] + [InlineData("abc\r\ndef\r\nghi", 2, 1, 11)] + [InlineData("a\rb\nc", 1, 0, 2)] + [InlineData("中文\n段落", 1, 1, 4)] + [InlineData("😀x\ny", 0, 2, 2)] + public void ToOffset_FollowsDocumentedConvention(string text, int line, int column, int expected) + { + Assert.Equal(expected, SourceText.ToOffset(text, new SourceLocation(line, column))); + } + + [Fact] + public void ToOffset_ClampsColumnToLineEnd_AndLineToTextEnd() + { + // 列超过行长度时钳到行尾(换行符之前),不会跳到下一行 + Assert.Equal(3, SourceText.ToOffset("abc\r\ndef", new SourceLocation(0, 99))); + Assert.Equal(8, SourceText.ToOffset("abc\r\ndef", new SourceLocation(9, 0))); + Assert.Equal(0, SourceText.ToOffset("abc", new SourceLocation(-1, -1))); + } + + [Theory] + [InlineData("# T\r\n\r\n中文 😀 para\r\n\r\n- a\r\n- b")] + [InlineData("one\rtwo\nthree\r\nfour")] + public void RoundTrip_EveryOffsetOutsideLineBreaks(string text) + { + for (var offset = 0; offset <= text.Length; offset++) + { + // 偏移在 \r\n 中间时会被归到行尾,不满足往返 + if (offset > 0 && offset < text.Length && text[offset - 1] == '\r' && text[offset] == '\n') + { + continue; + } + Assert.Equal(offset, SourceText.ToOffset(text, SourceText.ToLocation(text, offset))); + } + } +} From a62dc22f2460ecc0f0cc124de9456c61dd7896f6 Mon Sep 17 00:00:00 2001 From: token <61819790+239573049@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:44:47 +0800 Subject: [PATCH 3/5] Task lists: opt-in checkbox interaction that asks the host to edit the source Task checkboxes stay read-only by default. With MarkdownView.IsTaskInteractionEnabled, a click on the box (or Space/Enter once it has focus) raises TaskToggleRequested instead of changing anything: StreamMark never edits the source or the snapshot, the host decides, edits its own text and assigns it back. A rejected request leaves the screen unchanged. - ListItemBlock.TaskMarker holds the source range of "[ ]" / "[x]", taken from Markdig's TaskList span. It enters ComputeFingerprint as a position relative to the list item, so a list that merely shifts does not produce an Update. - TaskToggleRequestedEventArgs carries the marker range, current and requested state, DocumentId and snapshot Version; ApplyTo(source) replaces only the character inside the brackets, so indentation and CRLF are preserved. MarkdownView.IsCurrent(request) lets the host reject a stale request. - Requests are only raised for a complete snapshot, not while streaming. - Clicking the task text still selects text. Task items expose a CheckBox automation peer with IToggleProvider. Closes #7 Co-Authored-By: Claude Sonnet 5.5 --- .../modules/avalonia-text-rendering.md | 5 +- .../codemap/modules/core-parsing-and-model.md | 1 + README.md | 4 + .../Automation/MarkdownAutomationPeers.cs | 29 ++ src/StreamMark.Avalonia/MarkdownView.cs | 37 ++- .../Rendering/ContainerViews.cs | 100 +++++- .../Rendering/RenderContext.cs | 8 + .../TaskToggleRequestedEventArgs.cs | 49 +++ src/StreamMark.Core/Model/Blocks.cs | 9 + src/StreamMark.Core/Parsing/MarkdigMapper.cs | 8 +- .../Rendering/TaskInteractionTests.cs | 309 ++++++++++++++++++ .../Parsing/TaskMarkerTests.cs | 126 +++++++ 12 files changed, 679 insertions(+), 6 deletions(-) create mode 100644 src/StreamMark.Avalonia/TaskToggleRequestedEventArgs.cs create mode 100644 tests/StreamMark.Avalonia.Tests/Rendering/TaskInteractionTests.cs create mode 100644 tests/StreamMark.Core.Tests/Parsing/TaskMarkerTests.cs diff --git a/.agents/codemap/modules/avalonia-text-rendering.md b/.agents/codemap/modules/avalonia-text-rendering.md index 553e3a4..e3bddbe 100644 --- a/.agents/codemap/modules/avalonia-text-rendering.md +++ b/.agents/codemap/modules/avalonia-text-rendering.md @@ -39,8 +39,8 @@ updated: 2026-10-16 | `MathBlockView` | `src/StreamMark.Avalonia/Rendering/MathBlockView.cs:15` | 块级公式,`ISelectableSegment` 自身即片段;排版失败时保留上一次成功结果,闭合后仍失败则退回源码 | | `DiagramBlockView` | `src/StreamMark.Avalonia/Rendering/DiagramBlockView.cs:17` | 图表(如 Mermaid)后台渲染 + 静态 LRU 缓存 + 图表 / 源码切换,渲染前与失败时显示源码;挂上可视树后才渲染,主题的半透明颜色叠到真实底色上再传给渲染器;图表按宽度适配、高度只受 6000px 安全上限约束 | | `HtmlBoxBlockView` + `HtmlPaint` | `src/StreamMark.Avalonia/Rendering/HtmlBoxBlockView.cs:11` / `HtmlPaint.cs:9` | HTML 盒子:外边距 / 边框 / 背景 / 宽度,文字样式经 `RenderContext.WithHtml` 下传;`HtmlPaint` 处理作者颜色的对比度 | -| `QuoteBlockView` / `AlertBlockView` / `ListBlockView` / `ListItemBlockView` / `FootnotesBlockView` / `DefinitionListBlockView` | `src/StreamMark.Avalonia/Rendering/ContainerViews.cs:12` / `:29` / `:100` / `:201` / `:286` / `:330` | 引用、GitHub 提示块、列表(项目符号按嵌套深度)、列表项(符号 / 复选框与首行基线对齐)、脚注(转成有序 `ListBlock`)、定义列表 | -| `RenderContext` | `src/StreamMark.Avalonia/Rendering/RenderContext.cs:12` | 一个文档共享的渲染上下文:`Style` / `Services` / `Selection` / `Entry` / `Streaming` / `State` / `ImageBaseUri` / `Inherited` | +| `QuoteBlockView` / `AlertBlockView` / `ListBlockView` / `ListItemBlockView` / `FootnotesBlockView` / `DefinitionListBlockView` | `src/StreamMark.Avalonia/Rendering/ContainerViews.cs:12` / `:29` / `:100` / `:201` / `:286` / `:330` | 引用、GitHub 提示块、列表(项目符号按嵌套深度)、列表项(符号 / 复选框与首行基线对齐;启用任务交互时复选框可点击、可聚焦,见 `RenderContext.TaskToggle`)、脚注(转成有序 `ListBlock`)、定义列表 | +| `RenderContext` | `src/StreamMark.Avalonia/Rendering/RenderContext.cs:12` | 一个文档共享的渲染上下文:`Style` / `Services` / `Selection` / `Entry` / `Streaming` / `State` / `ImageBaseUri` / `Inherited` / `TaskToggle`(internal:宿主启用任务交互时设置,为 null 则复选框只读) | | `StreamingState` | `src/StreamMark.Avalonia/Rendering/StreamingState.cs:7` | 渐显与光标状态:`RecordReveal`(:29)记录每批文本的显示时间,`GetFadingBatches` 返回仍在渐显的各批(距末尾字符数、长度、时间),`GetFadingTail` 返回字符数与首末不透明度;`_trimmed` 记录已移出队列批次的终点(队列第一批的起点) | | `InlineTextSource`(`TextSegmentView` 私有嵌套) | `src/StreamMark.Avalonia/Rendering/TextSegmentView.cs:687` | `ITextSource` 实现:按 span / 对象切分 run、解析字重字形与回退字体、生成 `DrawableTextRun` | | `DrawableTextRun` 家族 | `src/StreamMark.Avalonia/Rendering/InlineRuns.cs:18` / `:34` / `:69` / `:135` | 行内公式、独立排版的文字对象(公式源码 / 图片替代文字)、行内图片(骨架 / 标签 / 缩放)、任务复选框 | @@ -89,6 +89,7 @@ updated: 2026-10-16 - To add 一种新的行内对象渲染:在 `InlineTextSource.CreateObjectRun`(`TextSegmentView.cs:867`)加分支,新 run 继承 `DrawableTextRun`(参照 `InlineRuns.cs:18-167`),并让 `FirstDifference` 的 `Objects` 比较能识别它的偏移变化(:204)。 ## Invariants and traps +- **任务复选框只发「请求」,不改状态**:`ListItemBlockView` 命中复选框(绘制时记下的 `_taskBox`,外扩 3px)或聚焦后按空格 / 回车,调用 `RenderContext.TaskToggle(item)`,由 `MarkdownView.RequestTaskToggle` 触发 `TaskToggleRequested`;勾选状态永远只由快照决定,宿主不改原文就不会变。复选框一带要画透明填充才会命中(没有绘制内容的区域不参与命中)。只有 `IsTaskInteractionEnabled` 且快照 `IsComplete` 时才发请求;`BlockOverlay` 的副本上下文故意不带 `TaskToggle`(只读);`TranscriptPanel` 暂未接入 — see `src/StreamMark.Avalonia/Rendering/ContainerViews.cs`(`ListItemBlockView`)、`src/StreamMark.Avalonia/MarkdownView.cs`(`RequestTaskToggle`)、`tests/StreamMark.Avalonia.Tests/Rendering/TaskInteractionTests.cs`。 - `_runCache.InvalidateFrom(i)` 必须传入**差异点所在段落的起点**,不是差异点本身 — 缓存以换行符分隔的段落为单位、按段落起点存放整段的整形结果,从差异点失效会复用旧段落(连同段末换行),流式每次追加都变成一次硬换行;回归测试 `SmoothStreamWrapTests` — see `src/StreamMark.Avalonia/Rendering/TextSegmentView.cs:164`。 - `TextRunCache` 是 Avalonia 12 的 `[Unstable]` 预览 API — 只允许在 `TextSegmentView` 内使用,并用 `#pragma warning disable CS0618` 限定范围;换别的文件用要重新评估(同一快照里 `TextLayout` 也是预览重载)— see `src/StreamMark.Avalonia/Rendering/TextSegmentView.cs:30`。 - 中文粗体的回退字体必须按**本段字的字重 / 字形**自行解析 — Avalonia 为缺字找回退字体时优先复用“上一段”的回退字体,不检查字重,且 `Typeface(..., Bold)` 在只有中粗(600)的字族上会落回常规字重;所以 `CreateCharacters`(:762)先判主字体是否覆盖字符,缺字才 `TryMatchCharacter`,`RealFace` 只在 `FontFamily.FamilyTypefaces` 列出的**真实字面**里选(粗体取离请求最近的 ≥600 字重,其次最粗的 ≥500,再没有就用常规;回退字体一律用正体,不请求斜体)。**不要用 `Typeface(…, 字重).GlyphTypeface` 去试探不存在的字重 / 字形**:Avalonia 12 会合成粗体 / 斜体,合成时把整份字体文件复制三份常驻内存(Apple Color Emoji 约 600MB,回归测试 `InlineStyleProbeTests.FallbackRuns_UseRealFaces_NotSyntheticCopies`)— see `src/StreamMark.Avalonia/Rendering/TextSegmentView.cs:759`。 diff --git a/.agents/codemap/modules/core-parsing-and-model.md b/.agents/codemap/modules/core-parsing-and-model.md index bfdd233..a0f78e2 100644 --- a/.agents/codemap/modules/core-parsing-and-model.md +++ b/.agents/codemap/modules/core-parsing-and-model.md @@ -89,6 +89,7 @@ updated: 2026-10-16 - **流式半截标签被丢弃而非按源码显示** — `Feed` 遇到 `TryRead` 失败且本行再没有 `>` 时,把剩余文本整段丢掉等下一批(`HtmlTree.cs:144-150`);这是 `Streaming_PartialHtml_NeverShowsRawTags` 守护的性质(`tests/StreamMark.Core.Tests/Html/HtmlTests.cs:202`)。 - **一行只有自闭合/行内组件时,块解析器主动交回段落解析器** — 否则 CommonMark 会把它当第 7 类 HTML 块按原文显示(`HtmlComponentExtension.cs:94-99`)。 - **任务复选框从段落文本里"提升 + 摘除"** — 映射器把首个行内对象识别为 `TaskCheckbox` 后提升为 `ListItemBlock.Task`,并同步平移/裁剪文本、span 与后续对象偏移(`MarkdigMapper.cs:241-244`、`:StripLeadingObject`);改动这个逻辑必须同时处理占位字符后的那个空格。 +- **任务标记的源码区间 `ListItemBlock.TaskMarker`** — 来自 Markdig `TaskList` 行内节点的 `Span`,经 `OffsetMap` 回写成源码偏移(`MarkdigMapper.MapList`),共 3 个字符(`[ ]` / `[x]` / `[X]`),不是任务项时长度为 0。它进了 `ComputeFingerprint`,但记的是**相对列表项起点**的位置:列表整体平移指纹不变,标记在项内位置变了(项目符号后多空格)指纹才变;漏进指纹的话,完成后标记区间变化不会产生 Update,视图会拿着旧区间向宿主发请求 — `tests/StreamMark.Core.Tests/Parsing/TaskMarkerTests.cs`。 - **多个顶层 HTML 元素合并成一个块时用 `Tag="#fragment"`** — 顶层块的 `Key` 是源码起点、必须唯一,所以不能把第一个子元素的 tag 当身份(`MarkdigMapper.cs:139-142`、`Model/Blocks.cs:HtmlBoxBlock`)。 - **软换行两侧都是 CJK 时不插空格** — 两处独立实现,Markdown 侧 `InlineBuilder.SoftBreak`(`MarkdigMapper.cs:567-573`)、HTML 侧 `HtmlInlineBuilder.AppendCollapsed`(`HtmlMapper.cs:469-507`);字符区间是复制的,改一处不会同步另一处。 - **属性除了读值不要依赖哈希** — `ComponentAttributes.GetHashCode` 只返回 `Items.Length`(`InlineContent.cs:79`),`Equals` 才是逐项比较(`:77`);拿它当字典键会严重退化。 diff --git a/README.md b/README.md index ac79bcd..168fe9c 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,10 @@ Raw HTML in Markdown (README style) is converted into the block model, so it sup - `ScrollToSourceOffset(offset)` scrolls to the deepest block that contains the offset: a list item, a paragraph inside a quote, or a code block as a whole. It returns a `SourceNavigationResult`; when nothing moved, the status says why: `NoDocument` (no snapshot applied yet), `OutOfRange`, or `LayoutPending` (try again next frame). - `GetSourceRange(TextPoint)` and `GetSelectionSourceRange()` map a rendered point or the selection back to the source range of the blocks it covers. `TextPoint.Offset` is an offset inside the segment's plain text, not a source offset, so text with emphasis or links never lines up with the source. - `CaptureAnchor()` and `RestoreAnchor(anchor)` save and restore the reading position: the character at the top of the outer `ScrollViewer` plus its pixel displacement. A width, font or theme change keeps the place. An anchor is tied to `DocumentId`, which changes whenever `Markdown`, `Document` or `Stream` is assigned; an anchor from another document returns `WrongDocument`. If the anchored block is gone, the view scrolls to the next block (`Approximate`). +- **Task checkboxes** (`MarkdownView`, off by default): task list checkboxes are read-only. Set `IsTaskInteractionEnabled = true` and a click on the box (or Space / Enter once it has focus) raises `TaskToggleRequested` instead of changing anything. StreamMark never edits the source or the snapshot: the host decides, edits its own text (with its own undo grouping and saving), and assigns the new text. A rejected request changes nothing on screen. + - `TaskToggleRequestedEventArgs` carries `Marker` (the source range of `[ ]` / `[x]`), `IsChecked`, `RequestedChecked`, `DocumentId` and `Version`. `ApplyTo(source)` replaces only the character inside the brackets, so indentation and CRLF are kept; it returns `null` if the source no longer matches. + - Requests are raised only once the document is complete, not while a stream is still running. If the host handles a request asynchronously, `view.IsCurrent(request)` tells whether the document and snapshot are still the ones the request was made on. + - Clicking the task text still selects text. The list item exposes a `CheckBox` automation peer with `IToggleProvider`; `Toggle()` raises the same request. - **Search**: `view.Search` is a `SearchModel`. - `Find(query, matchCase)` highlights every match, including content scrolled off-screen. - `Next()`, `Previous()` and `MoveTo(i)` jump to a match. `Clear()` removes the highlights. diff --git a/src/StreamMark.Avalonia/Automation/MarkdownAutomationPeers.cs b/src/StreamMark.Avalonia/Automation/MarkdownAutomationPeers.cs index b8e84a9..0e474ce 100644 --- a/src/StreamMark.Avalonia/Automation/MarkdownAutomationPeers.cs +++ b/src/StreamMark.Avalonia/Automation/MarkdownAutomationPeers.cs @@ -67,3 +67,32 @@ public sealed class MarkdownDocumentAutomationPeer(Control owner) : ControlAutom protected override bool IsControlElementCore() => true; } + +/// +/// 任务列表项:控件类型为 CheckBox,开关状态取自模型; 与点击一样只是向宿主发出请求 +/// (宿主没有启用任务交互、或不是任务项时什么都不做)。 +/// +public sealed class TaskItemAutomationPeer(ListItemBlockView owner) : ControlAutomationPeer(owner), IToggleProvider +{ + private ListItemBlockView Item => (ListItemBlockView)Owner; + + protected override AutomationControlType GetAutomationControlTypeCore() => + Item.Task is null ? AutomationControlType.ListItem : AutomationControlType.CheckBox; + + protected override string? GetNameCore() => + AutomationProperties.GetName(Owner) is { Length: > 0 } name ? name + : Item.Segments().FirstOrDefault()?.Text; + + protected override bool IsContentElementCore() => true; + + protected override bool IsControlElementCore() => true; + + public ToggleState ToggleState => Item.Task switch + { + true => ToggleState.On, + false => ToggleState.Off, + _ => ToggleState.Indeterminate, + }; + + public void Toggle() => Item.RequestToggle(); +} diff --git a/src/StreamMark.Avalonia/MarkdownView.cs b/src/StreamMark.Avalonia/MarkdownView.cs index b9b8e6e..c1fd705 100644 --- a/src/StreamMark.Avalonia/MarkdownView.cs +++ b/src/StreamMark.Avalonia/MarkdownView.cs @@ -26,6 +26,9 @@ public class MarkdownView : Control, ISelectionHost public static readonly StyledProperty StreamProperty = AvaloniaProperty.Register(nameof(Stream)); + public static readonly StyledProperty IsTaskInteractionEnabledProperty = + AvaloniaProperty.Register(nameof(IsTaskInteractionEnabled)); + /// 链接点击(来自内部任一文本片段)。 public static readonly RoutedEvent LinkClickedEvent = TextSegmentView.LinkClickedEvent; @@ -43,6 +46,7 @@ static MarkdownView() MarkdownProperty.Changed.AddClassHandler((view, _) => view.OnSourceChanged()); DocumentProperty.Changed.AddClassHandler((view, _) => view.OnSourceChanged()); StreamProperty.Changed.AddClassHandler((view, _) => view.OnSourceChanged()); + IsTaskInteractionEnabledProperty.Changed.AddClassHandler((view, _) => view.InvalidateContext()); MarkdownServices.ServicesProperty.Changed.AddClassHandler((view, _) => view.InvalidateContext()); MarkdownServices.ImageBaseUriProperty.Changed.AddClassHandler((view, _) => view.InvalidateContext()); } @@ -87,6 +91,16 @@ public MarkdownStream? Stream set => SetValue(StreamProperty, value); } + /// + /// 任务列表的复选框是否可以点击(或聚焦后按空格 / 回车)。默认关闭,复选框只读; + /// 启用后用户操作只会触发 ,由宿主决定是否修改原文。 + /// + public bool IsTaskInteractionEnabled + { + get => GetValue(IsTaskInteractionEnabledProperty); + set => SetValue(IsTaskInteractionEnabledProperty, value); + } + public SelectionModel Selection { get; } = new(); public SelectionController SelectionController => _selectionController; @@ -100,6 +114,13 @@ public event EventHandler? LinkClicked remove => RemoveHandler(LinkClickedEvent, value); } + /// 用户请求切换某个任务复选框(需要 );宿主接受时自行修改原文。 + public event EventHandler? TaskToggleRequested; + + /// 请求是否仍然对应当前显示的文档: 与快照版本都没有变。宿主异步处理请求时用它拒绝过期的请求。 + public bool IsCurrent(TaskToggleRequestedEventArgs request) => + request.DocumentId == DocumentId && _presenter.Applied is { } snapshot && snapshot.Version == request.Version; + private StreamingDocument? EffectiveDocument => Stream?.Document ?? Document ?? _ownDocument; /// @@ -180,7 +201,21 @@ public void ApplyLatest() } private RenderContext CreateContext() => - new(MarkdownStyle.Resolve(this), MarkdownServices.GetServices(this), Selection, 0, Stream?.State, _state, MarkdownServices.GetImageBaseUri(this)); + new(MarkdownStyle.Resolve(this), MarkdownServices.GetServices(this), Selection, 0, Stream?.State, _state, MarkdownServices.GetImageBaseUri(this)) + { + TaskToggle = IsTaskInteractionEnabled ? RequestTaskToggle : null, + }; + + // 只在快照完整时发请求:流式中源码还会变,点击的标记位置可能马上失效 + private bool RequestTaskToggle(Core.Model.ListItemBlock item) + { + if (item.Task is not { } isChecked || item.TaskMarker.Length == 0 || _presenter.Applied is not { IsComplete: true } snapshot || TaskToggleRequested is not { } handler) + { + return false; + } + handler(this, new TaskToggleRequestedEventArgs(DocumentId, snapshot.Version, item.TaskMarker, isChecked)); + return true; + } private void InvalidateContext() { diff --git a/src/StreamMark.Avalonia/Rendering/ContainerViews.cs b/src/StreamMark.Avalonia/Rendering/ContainerViews.cs index e50f4d1..bdf7985 100644 --- a/src/StreamMark.Avalonia/Rendering/ContainerViews.cs +++ b/src/StreamMark.Avalonia/Rendering/ContainerViews.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; using System.Globalization; using Avalonia; +using Avalonia.Automation.Peers; +using Avalonia.Input; using Avalonia.Media; using Avalonia.Media.TextFormatting; using StreamMark.Avalonia.Selection; @@ -197,12 +199,27 @@ protected override Size ArrangeOverride(Size finalSize) public override IEnumerable Segments() => _items.SelectMany(i => i.Segments()); } -/// 列表项:左侧绘制项目符号 / 序号 / 复选框,右侧是子块。 +/// +/// 列表项:左侧绘制项目符号 / 序号 / 复选框,右侧是子块。 +/// 上下文带有 时任务项的复选框可交互(点击、聚焦后按空格或回车), +/// 只是向宿主发出"请求",视图自己的勾选状态仍然只由快照决定。 +/// public sealed class ListItemBlockView : StackBlockView { + private static readonly Cursor HandCursor = new(StandardCursorType.Hand); + private TextLayout? _marker; private string _markerText = ""; private bool? _task; + private Rect _taskBox; + private bool _pressedOnBox; + + public ListItemBlockView() + { + // 聚焦环随焦点出现 / 消失 + GotFocus += (_, _) => InvalidateVisual(); + LostFocus += (_, _) => InvalidateVisual(); + } public string Marker { @@ -233,6 +250,9 @@ public bool? Task public bool Tight { get; set; } + /// 复选框是否可交互:任务项,且宿主启用了任务交互。 + internal bool IsTaskInteractive => _task is not null && Context?.TaskToggle is not null; + private double Indent => Math.Round((Context?.Style.FontSize ?? 14) * (_markerText.Length > 2 ? 2.0 : 1.6)); protected override Thickness ContentPadding => new(Indent, 0, 0, 0); @@ -245,10 +265,74 @@ protected override void OnUpdate(RenderContext context, MarkdownBlock? previous, { _marker = null; } + // 只有可交互的任务项才是 Tab 停靠点 + Focusable = IsTaskInteractive; + if (!IsTaskInteractive) + { + Cursor = null; + _pressedOnBox = false; + } UpdateChildren(context, ((ListItemBlock)block).Children, topKey, ref segment); InvalidateVisual(); } + /// 向宿主请求切换勾选状态;没有启用交互、不是任务项或宿主没有接收时返回 false。 + internal bool RequestToggle() => IsTaskInteractive && Block is ListItemBlock item && Context!.TaskToggle!(item); + + // 命中区域比画出来的复选框大一圈,方便点击 + private bool HitsBox(Point point) => _taskBox.Width > 0 && _taskBox.Inflate(3).Contains(point); + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (IsTaskInteractive && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && HitsBox(e.GetPosition(this))) + { + _pressedOnBox = true; + e.Pointer.Capture(this); + e.Handled = true; + } + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + Cursor = IsTaskInteractive && HitsBox(e.GetPosition(this)) ? HandCursor : null; + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (!_pressedOnBox) + { + return; + } + _pressedOnBox = false; + e.Pointer.Capture(null); + e.Handled = true; + if (HitsBox(e.GetPosition(this))) + { + RequestToggle(); + } + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + _pressedOnBox = false; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (!e.Handled && e.KeyModifiers == KeyModifiers.None && e.Key is Key.Space or Key.Enter && IsTaskInteractive) + { + e.Handled = true; + RequestToggle(); + } + } + + protected override AutomationPeer OnCreateAutomationPeer() => new Automation.TaskItemAutomationPeer(this); + public override void Render(DrawingContext context) { if (Context is null) @@ -270,10 +354,22 @@ public override void Render(DrawingContext context) var box = Math.Round(style.FontSize * 0.95); var run = new CheckboxRun(isChecked, style.FontSize, style.SecondaryForeground, style.LinkForeground, new GenericTextRunProperties(new Typeface(style.FontFamily), style.FontSize)); - run.Draw(context, new Point(Indent - box - style.FontSize * 0.45, baseline - run.Baseline)); + var boxOrigin = new Point(Indent - box - style.FontSize * 0.45, baseline - run.Baseline); + run.Draw(context, boxOrigin); + _taskBox = new Rect(boxOrigin, new Size(box, box)); + if (IsTaskInteractive) + { + // 透明填充让复选框一带能接收指针(没有绘制内容的区域不参与命中) + context.FillRectangle(Brushes.Transparent, _taskBox.Inflate(3)); + if (IsFocused) + { + context.DrawRectangle(new Pen(style.LinkForeground, 1.5), _taskBox.Inflate(2), 3); + } + } return; } + _taskBox = default; _marker ??= new TextLayout(_markerText, new Typeface(style.FontFamily), _markerText.Length == 1 ? style.FontSize * 1.1 : style.FontSize, style.ListMarkerForeground, TextAlignment.Right); var markerBaseline = _marker.TextLines.Count > 0 ? _marker.TextLines[0].Baseline : _marker.Height; diff --git a/src/StreamMark.Avalonia/Rendering/RenderContext.cs b/src/StreamMark.Avalonia/Rendering/RenderContext.cs index 2c37c26..3f79e70 100644 --- a/src/StreamMark.Avalonia/Rendering/RenderContext.cs +++ b/src/StreamMark.Avalonia/Rendering/RenderContext.cs @@ -49,11 +49,18 @@ public RenderContext(MarkdownStyle style, MarkdownServices services, SelectionMo /// 外层 HTML 盒子的背景色(用于判断作者颜色是否可读),没有时为 null。 public CssColor? HtmlBackground { get; private init; } + /// + /// 任务复选框的点击 / 键盘请求,由宿主视图在启用任务交互时设置;为 null 时复选框只读。 + /// 参数是被点的列表项,返回是否已把请求交给宿主。 + /// + internal Func? TaskToggle { get; init; } + public RenderContext With(MarkdownStyle? style = null, MarkdownServices? services = null, int? entry = null) => new(style ?? Style, services ?? Services, Selection, entry ?? Entry, Streaming, State, ImageBaseUri) { Inherited = Inherited, HtmlBackground = HtmlBackground, + TaskToggle = TaskToggle, }; /// 进入一个 HTML 盒子:叠加其文字样式与背景。 @@ -62,5 +69,6 @@ public RenderContext WithHtml(HtmlTextStyle text, CssColor? background) => { Inherited = text.Over(Inherited ?? HtmlTextStyle.Default), HtmlBackground = background is { A: > 0 } ? background : HtmlBackground, + TaskToggle = TaskToggle, }; } diff --git a/src/StreamMark.Avalonia/TaskToggleRequestedEventArgs.cs b/src/StreamMark.Avalonia/TaskToggleRequestedEventArgs.cs new file mode 100644 index 0000000..0b5045c --- /dev/null +++ b/src/StreamMark.Avalonia/TaskToggleRequestedEventArgs.cs @@ -0,0 +1,49 @@ +using StreamMark.Core.Model; + +namespace StreamMark.Avalonia; + +/// +/// 宿主启用任务交互()后,用户点击或用键盘激活任务复选框时发出的请求。 +/// StreamMark 不会写文件,也不会改动快照:宿主自行决定接受或拒绝,接受就修改自己的原文 +/// (并记入撤销栈、保存),再把新文本交给视图;拒绝则什么都不用做,视图的勾选状态不会变。 +/// +/// +/// 只在快照已经完整(IsComplete)时发出;流式输出中源码还会变化,点击会被忽略。 +/// 宿主异步处理请求时,用 检查它是否已经过期。 +/// +public sealed class TaskToggleRequestedEventArgs(int documentId, long version, SourceRange marker, bool isChecked) : EventArgs +{ + /// 请求发出时 的值。 + public int DocumentId { get; } = documentId; + + /// 请求所基于的快照版本(DocumentSnapshot.Version)。 + public long Version { get; } = version; + + /// 任务标记 [ ] / [x] 在源码中的区间(3 个字符,UTF-16 偏移,与 的其他用法相同)。 + public SourceRange Marker { get; } = marker; + + /// 当前是否已勾选。 + public bool IsChecked { get; } = isChecked; + + /// 用户想要的状态(与 相反)。 + public bool RequestedChecked => !IsChecked; + + /// + /// 把 中的标记改成 :只替换方括号里的那一个字符, + /// 其余文本、缩进和换行(包括 CRLF)原样保留。 必须是发出请求时的那份源码; + /// 标记处的文字与 不符(源码已经变了)时返回 null。 + /// + public string? ApplyTo(string source) + { + if (Marker.Length != 3 || Marker.End > source.Length || source[Marker.Start] != '[' || source[Marker.End - 1] != ']') + { + return null; + } + var current = source[Marker.Start + 1]; + if (IsChecked ? current is not ('x' or 'X') : current != ' ') + { + return null; + } + return string.Concat(source.AsSpan(0, Marker.Start + 1), RequestedChecked ? "x" : " ", source.AsSpan(Marker.Start + 2)); + } +} diff --git a/src/StreamMark.Core/Model/Blocks.cs b/src/StreamMark.Core/Model/Blocks.cs index 16f72e6..e29154d 100644 --- a/src/StreamMark.Core/Model/Blocks.cs +++ b/src/StreamMark.Core/Model/Blocks.cs @@ -112,9 +112,18 @@ internal override void AddContent(ref Fingerprint fp) /// 列表项。 为 null 表示不是任务项。 public sealed record ListItemBlock(bool? Task, ImmutableArray Children) : MarkdownBlock { + /// + /// 任务标记 [ ] / [x] 在整篇源码中的区间(共 3 个字符,偏移单位与 相同); + /// 不是任务项时长度为 0。 + /// + public SourceRange TaskMarker { get; init; } + internal override void AddContent(ref Fingerprint fp) { fp.Add(Task is null ? -1 : Task.Value ? 1 : 0); + // 记相对列表项起点的位置:整体平移不改变指纹,标记在项内的位置变了(例如项目符号后多了空格)则视为内容变化 + fp.Add(TaskMarker.Length == 0 ? -1 : TaskMarker.Start - Range.Start); + fp.Add(TaskMarker.Length); AddChildren(ref fp, Children); } } diff --git a/src/StreamMark.Core/Parsing/MarkdigMapper.cs b/src/StreamMark.Core/Parsing/MarkdigMapper.cs index bda3487..85cb1cb 100644 --- a/src/StreamMark.Core/Parsing/MarkdigMapper.cs +++ b/src/StreamMark.Core/Parsing/MarkdigMapper.cs @@ -254,13 +254,19 @@ private static Model.ListBlock MapList(MdListBlock list, MapContext ctx) } var children = MapChildren(item, ctx); bool? task = null; + var marker = default(SourceRange); // 任务复选框是首个段落的第一个行内元素:提升为列表项属性,并从段落文本中去掉 if (children.Length > 0 && children[0] is Model.ParagraphBlock first && first.Content.Objects is [{ Kind: InlineObjectKind.TaskCheckbox, Offset: 0 } checkbox, ..]) { task = checkbox.Flag; children = children.SetItem(0, first with { Content = StripLeadingObject(first.Content) }); + if (item.Count > 0 && item[0] is MdParagraphBlock { Inline.FirstChild: TaskList { Span.IsEmpty: false } taskInline }) + { + var markerStart = ctx.Map.ToSource(taskInline.Span.Start); + marker = new SourceRange(markerStart, ctx.Map.ToSource(taskInline.Span.End + 1) - markerStart); + } } - items.Add(new Model.ListItemBlock(task, children) { Range = RangeOf(item, ctx) }); + items.Add(new Model.ListItemBlock(task, children) { Range = RangeOf(item, ctx), TaskMarker = marker }); } var start = 1; if (list.IsOrdered && list.OrderedStart is { } orderedStart && int.TryParse(orderedStart, out var parsed)) diff --git a/tests/StreamMark.Avalonia.Tests/Rendering/TaskInteractionTests.cs b/tests/StreamMark.Avalonia.Tests/Rendering/TaskInteractionTests.cs new file mode 100644 index 0000000..c7f6701 --- /dev/null +++ b/tests/StreamMark.Avalonia.Tests/Rendering/TaskInteractionTests.cs @@ -0,0 +1,309 @@ +using Avalonia; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.VisualTree; +using StreamMark.Avalonia.Rendering; +using StreamMark.Core.Model; +using StreamMark.Core.Streaming; +using Xunit; + +namespace StreamMark.Avalonia.Tests.Rendering; + +public sealed class TaskInteractionTests +{ + private const string Source = "- [ ] first\n- [x] second\n- plain\n"; + + private static async Task<(Window Window, MarkdownView View, List Requests)> Show(string markdown, bool interactive) + { + var view = new MarkdownView { Markdown = markdown, Margin = new(12), IsTaskInteractionEnabled = interactive }; + var requests = new List(); + view.TaskToggleRequested += (_, e) => requests.Add(e); + var window = new Window { Width = 520, Height = 420, Content = view, Background = Brushes.Black }; + window.Show(); + await HeadlessApp.Settle(); + view.ApplyLatest(); + await HeadlessApp.Settle(); + return (window, view, requests); + } + + private static List TaskItems(MarkdownView view) => + view.GetVisualDescendants().OfType().Where(i => i.Task is not null).ToList(); + + // 复选框中心(窗口坐标):列表项正文起点往左,扣掉复选框右侧的间距和半个复选框 + private static Point BoxCenter(Window window, MarkdownView view, ListItemBlockView item) + { + var style = MarkdownStyle.Resolve(view); + var segment = item.Segments().First(); + var origin = segment.View.TranslatePoint(default, item)!.Value; + var line = segment.GetCharacterBounds(0); + var box = Math.Round(style.FontSize * 0.95); + return item.TranslatePoint(new Point(origin.X - style.FontSize * 0.45 - box / 2, origin.Y + line.Top + line.Height / 2), window)!.Value; + } + + private static Point TextPoint(Window window, ListItemBlockView item) + { + var segment = item.Segments().First(); + var line = segment.GetCharacterBounds(2); + return segment.View.TranslatePoint(new Point(line.Left + 1, line.Top + line.Height / 2), window)!.Value; + } + + private static void Click(Window window, Point point) + { + window.MouseDown(point, MouseButton.Left); + window.MouseUp(point, MouseButton.Left); + } + + [Fact] + public async Task ReadOnlyByDefault_ClicksAndKeysRaiseNothing() + { + await HeadlessApp.Run(async () => + { + var (window, view, requests) = await Show(Source, interactive: false); + Assert.False(view.IsTaskInteractionEnabled); + var items = TaskItems(view); + Assert.Equal(2, items.Count); + + Click(window, BoxCenter(window, view, items[0])); + Assert.False(items[0].Focusable); + window.KeyPress(Key.Space, RawInputModifiers.None, PhysicalKey.Space, " "); + Assert.Empty(requests); + window.Close(); + }); + } + + [Fact] + public async Task Click_RaisesRequestWithMarkerStateAndVersion_AndLeavesRenderedStateAlone() + { + await HeadlessApp.Run(async () => + { + var (window, view, requests) = await Show(Source, interactive: true); + var items = TaskItems(view); + + Click(window, BoxCenter(window, view, items[0])); + var request = Assert.Single(requests); + Assert.False(request.IsChecked); + Assert.True(request.RequestedChecked); + Assert.Equal("[ ]", Source.Substring(request.Marker.Start, request.Marker.Length)); + Assert.Equal(Source.IndexOf("[ ] first", StringComparison.Ordinal), request.Marker.Start); + Assert.Equal(view.DocumentId, request.DocumentId); + Assert.Equal(view.Snapshot!.Version, request.Version); + Assert.True(view.IsCurrent(request)); + Assert.Equal("- [x] first\n- [x] second\n- plain\n", request.ApplyTo(Source)); + + // 宿主没有改原文(相当于拒绝):视图的勾选状态不变 + await HeadlessApp.Settle(); + Assert.Equal(new bool?[] { false, true }, TaskItems(view).Select(i => i.Task).ToArray()); + + // 已勾选的项请求取消 + Click(window, BoxCenter(window, view, items[1])); + Assert.Equal(2, requests.Count); + Assert.True(requests[1].IsChecked); + Assert.Equal("- [ ] first\n- [ ] second\n- plain\n", requests[1].ApplyTo(Source)); + window.Close(); + }); + } + + [Fact] + public async Task ClickingTaskText_SelectsTextInsteadOfRaisingRequest() + { + await HeadlessApp.Run(async () => + { + var (window, view, requests) = await Show(Source, interactive: true); + var items = TaskItems(view); + + Click(window, TextPoint(window, items[1])); + Assert.Empty(requests); + Assert.NotNull(view.Selection.Anchor); + window.Close(); + }); + } + + [Fact] + public async Task NestedOrderedAndDuplicateTasks_IdentifyTheClickedMarker() + { + await HeadlessApp.Run(async () => + { + const string text = "1. [ ] same\n2. [x] other\n\n- [ ] parent\n - [ ] same\n - [x] same\n\n- [ ] same\n"; + var (window, view, requests) = await Show(text, interactive: true); + var items = TaskItems(view); + Assert.Equal(6, items.Count); + + // 依次点每一项,请求里的标记位置必须就是那一项自己的 "[…]" + var expected = new List(); + for (var from = 0; (from = text.IndexOf("[", from, StringComparison.Ordinal)) >= 0; from++) + { + expected.Add(from); + } + Assert.Equal(6, expected.Count); + foreach (var item in items) + { + Click(window, BoxCenter(window, view, item)); + } + Assert.Equal(expected, requests.Select(r => r.Marker.Start).ToList()); + Assert.All(requests, r => Assert.StartsWith("[", text.Substring(r.Marker.Start, r.Marker.Length))); + window.Close(); + }); + } + + [Fact] + public async Task ApplyTo_ChangesOnlyTheMarkerCharacter_AndKeepsIndentAndCrlf() + { + await HeadlessApp.Run(async () => + { + const string text = "intro\r\n\r\n- [ ] top\r\n - [x] nested 中文 😀\r\n- [ ] last"; + var (window, view, requests) = await Show(text, interactive: true); + var items = TaskItems(view); + + Click(window, BoxCenter(window, view, items[1])); + var request = Assert.Single(requests); + var changed = request.ApplyTo(text)!; + Assert.Equal(text.Length, changed.Length); + var differences = Enumerable.Range(0, text.Length).Where(i => text[i] != changed[i]).ToList(); + Assert.Equal([request.Marker.Start + 1], differences); + Assert.Equal('x', text[differences[0]]); + Assert.Equal(' ', changed[differences[0]]); + Assert.Contains("\r\n - [ ] nested 中文 😀\r\n", changed); + + // 宿主把新文本交回视图:只有这一项的状态变了 + view.Markdown = changed; + await HeadlessApp.Settle(); + view.ApplyLatest(); + await HeadlessApp.Settle(); + Assert.Equal(new bool?[] { false, false, false }, TaskItems(view).Select(i => i.Task).ToArray()); + window.Close(); + }); + } + + [Fact] + public void ApplyTo_RejectsSourceThatNoLongerMatches() + { + var request = new TaskToggleRequestedEventArgs(1, 1, new SourceRange(2, 3), isChecked: false); + Assert.Equal("- [x] a", request.ApplyTo("- [ ] a")); + Assert.Null(request.ApplyTo("- [x] a")); + Assert.Null(request.ApplyTo("- (x) a")); + Assert.Null(request.ApplyTo("- [")); + Assert.Equal("- [ ] a", new TaskToggleRequestedEventArgs(1, 1, new SourceRange(2, 3), isChecked: true).ApplyTo("- [X] a")); + } + + [Fact] + public async Task Keyboard_SpaceAndEnter_ActivateTheFocusedTask() + { + await HeadlessApp.Run(async () => + { + var (window, view, requests) = await Show(Source, interactive: true); + var items = TaskItems(view); + Assert.All(items, i => Assert.True(i.Focusable)); + + Assert.True(items[1].Focus()); + window.KeyPress(Key.Space, RawInputModifiers.None, PhysicalKey.Space, " "); + window.KeyPress(Key.Enter, RawInputModifiers.None, PhysicalKey.Enter, null); + Assert.Equal(2, requests.Count); + Assert.All(requests, r => Assert.Equal(Source.IndexOf("[x]", StringComparison.Ordinal), r.Marker.Start)); + + // 其他键不触发 + window.KeyPress(Key.A, RawInputModifiers.None, PhysicalKey.A, "a"); + Assert.Equal(2, requests.Count); + window.Close(); + }); + } + + [Fact] + public async Task Automation_ExposesCheckBoxStateAndToggle() + { + await HeadlessApp.Run(async () => + { + var (window, view, requests) = await Show(Source, interactive: true); + var items = TaskItems(view); + + var peer = ControlAutomationPeer.CreatePeerForElement(items[0]); + Assert.Equal(AutomationControlType.CheckBox, peer.GetAutomationControlType()); + var toggle = Assert.IsAssignableFrom(peer); + Assert.Equal(ToggleState.Off, toggle.ToggleState); + Assert.Equal(ToggleState.On, ((IToggleProvider)ControlAutomationPeer.CreatePeerForElement(items[1])).ToggleState); + Assert.Equal("first", peer.GetName()); + + toggle.Toggle(); + var request = Assert.Single(requests); + Assert.Equal(Source.IndexOf("[ ]", StringComparison.Ordinal), request.Marker.Start); + window.Close(); + }); + } + + [Fact] + public async Task StaleRequests_AreDetectedByDocumentAndVersion() + { + await HeadlessApp.Run(async () => + { + var (window, view, requests) = await Show(Source, interactive: true); + Click(window, BoxCenter(window, view, TaskItems(view)[0])); + var request = Assert.Single(requests); + Assert.True(view.IsCurrent(request)); + + // 宿主还在处理时文档被换掉:请求过期 + view.Markdown = "- [ ] other\n"; + await HeadlessApp.Settle(); + view.ApplyLatest(); + await HeadlessApp.Settle(); + Assert.False(view.IsCurrent(request)); + window.Close(); + }); + } + + [Fact] + public async Task WhileStreaming_ClicksAreIgnored_UntilTheDocumentCompletes() + { + await HeadlessApp.Run(async () => + { + var document = new StreamingDocument(); + document.Append("- [ ] one\n- [x] two\n\nstill writing"); + var view = new MarkdownView { Document = document, Margin = new(12), IsTaskInteractionEnabled = true }; + var requests = new List(); + view.TaskToggleRequested += (_, e) => requests.Add(e); + var window = new Window { Width = 520, Height = 420, Content = view, Background = Brushes.Black }; + window.Show(); + await document.WhenIdle(); + await HeadlessApp.Settle(); + view.ApplyLatest(); + await HeadlessApp.Settle(); + + var items = TaskItems(view); + Assert.Equal(2, items.Count); + Click(window, BoxCenter(window, view, items[0])); + Assert.Empty(requests); + + document.Complete(); + await document.WhenIdle(); + view.ApplyLatest(); + await HeadlessApp.Settle(); + Click(window, BoxCenter(window, view, TaskItems(view)[0])); + Assert.Single(requests); + window.Close(); + }); + } + + [Fact] + public async Task EnablingInteractionLater_MakesExistingCheckboxesClickable() + { + await HeadlessApp.Run(async () => + { + var (window, view, requests) = await Show(Source, interactive: false); + Click(window, BoxCenter(window, view, TaskItems(view)[0])); + Assert.Empty(requests); + + view.IsTaskInteractionEnabled = true; + await HeadlessApp.Settle(); + Click(window, BoxCenter(window, view, TaskItems(view)[0])); + Assert.Single(requests); + + view.IsTaskInteractionEnabled = false; + await HeadlessApp.Settle(); + Click(window, BoxCenter(window, view, TaskItems(view)[0])); + Assert.Single(requests); + window.Close(); + }); + } +} diff --git a/tests/StreamMark.Core.Tests/Parsing/TaskMarkerTests.cs b/tests/StreamMark.Core.Tests/Parsing/TaskMarkerTests.cs new file mode 100644 index 0000000..2988b59 --- /dev/null +++ b/tests/StreamMark.Core.Tests/Parsing/TaskMarkerTests.cs @@ -0,0 +1,126 @@ +using StreamMark.Core.Model; +using StreamMark.Core.Streaming; +using Xunit; + +namespace StreamMark.Core.Tests.Parsing; + +public sealed class TaskMarkerTests +{ + private static async Task Parse(string text) + { + var document = new StreamingDocument(); + document.Load(text); + await document.WhenIdle(); + return document.Latest; + } + + // 所有列表项,含嵌套在列表项、引用里的 + private static IEnumerable Items(IEnumerable blocks) + { + foreach (var block in blocks) + { + switch (block) + { + case ListBlock list: + foreach (var item in list.Items) + { + yield return item; + foreach (var nested in Items(item.Children)) + { + yield return nested; + } + } + break; + case QuoteBlock quote: + foreach (var nested in Items(quote.Children)) + { + yield return nested; + } + break; + } + } + } + + private static string Slice(string text, SourceRange range) => text.Substring(range.Start, range.Length); + + [Theory] + [InlineData("\n")] + [InlineData("\r\n")] + public async Task TaskMarker_PointsAtTheBracketsInSource_ForEveryListShape(string newline) + { + var text = string.Join(newline, + "- [ ] unordered open", + "- [x] unordered done", + "- plain item", + "", + "1. [X] ordered upper-case", + "2. [ ] ordered open", + "", + "- [ ] parent 中文 😀", + " - [x] child same label", + " - [ ] child same label", + " - [ ] grandchild", + "", + "> - [ ] quoted task", + "> - [x] quoted done", + "", + "- [ ] same label", + "- [ ] same label", + ""); + var snapshot = await Parse(text); + var items = Items(snapshot.Blocks).ToList(); + + var tasks = items.Where(i => i.Task is not null).ToList(); + Assert.Equal(12, tasks.Count); + foreach (var item in tasks) + { + var marker = Slice(text, item.TaskMarker); + Assert.Equal(3, item.TaskMarker.Length); + Assert.Equal(item.Task is true, marker is "[x]" or "[X]"); + Assert.True(marker is "[ ]" or "[x]" or "[X]", $"unexpected marker text '{marker}'"); + // 标记在列表项里面,且在子块之前 + Assert.True(item.TaskMarker.Start >= item.Range.Start && item.TaskMarker.End <= item.Range.End); + } + foreach (var item in items.Where(i => i.Task is null)) + { + Assert.Equal(0, item.TaskMarker.Length); + } + + // 重复的标签靠源码位置区分:两个 "same label" 的标记位置不同 + var same = tasks.Where(i => i.Children is [ParagraphBlock p, ..] && p.Content.Text == "same label").ToList(); + Assert.Equal(2, same.Count); + Assert.NotEqual(same[0].TaskMarker.Start, same[1].TaskMarker.Start); + Assert.Equal(text.IndexOf("- [ ] same label", StringComparison.Ordinal) + 2, same[0].TaskMarker.Start); + } + + [Fact] + public async Task TaskMarker_ChangesFingerprint_WhenCheckedStateOrRelativePositionChanges() + { + static ulong Fingerprint(DocumentSnapshot snapshot) => snapshot[0].ComputeFingerprint(); + + var open = await Parse("- [ ] item\n"); + var done = await Parse("- [x] item\n"); + var spaced = await Parse("- [ ] item\n"); + + // [ ] → [x]:状态变了,必须产生 Update + Assert.NotEqual(Fingerprint(open), Fingerprint(done)); + Assert.NotEmpty(SnapshotDiff.Compute(open, done)); + + // 项目符号后多一个空格:标记在项内的相对位置变了,指纹也要变(否则视图会拿着旧区间) + var openItem = Assert.Single(Items(open.Blocks)); + var spacedItem = Assert.Single(Items(spaced.Blocks)); + Assert.Equal(openItem.TaskMarker.Start + 1, spacedItem.TaskMarker.Start); + Assert.NotEqual(Fingerprint(open), Fingerprint(spaced)); + } + + [Fact] + public async Task TaskMarker_FingerprintIsStableWhenWholeListShifts() + { + var before = await Parse("- [ ] a\n- [x] b\n"); + var after = await Parse("intro paragraph\n\n- [ ] a\n- [x] b\n"); + + // 前面多了一段,列表整体平移:内容指纹相同(只有 Range 不同) + Assert.Equal(before[0].ComputeFingerprint(), after[1].ComputeFingerprint()); + Assert.NotEqual(before[0].Range.Start, after[1].Range.Start); + } +} From be3039627b5963b5bcd7da92c87fe6fcc32a65f2 Mon Sep 17 00:00:00 2001 From: token <61819790+239573049@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:44:59 +0800 Subject: [PATCH 4/5] Editor preview: RefreshMarkdown keeps content, selection and state across a full-text replace MarkdownView.RefreshMarkdown(text) replaces the whole text without clearing the view. Assigning Markdown still means "a new document" and resets everything; StreamingDocument stays append-only. - The old content stays on screen until the new text is parsed. The view remembers StreamingDocument.Version at refresh time and does not apply a snapshot that has not caught up, so rapid A -> B -> C updates never show A or B once C is accepted. - SnapshotEdit aligns the old and new snapshots by content: common prefix and suffix, then a longest-common-subsequence match of what lies between (falls back to positional pairing above 1M block pairs). Blocks that only moved because text was added or removed before them become Update ops, so their views, layout and block state survive. No new DiffKind, so the three switches stay as they are. SnapshotDiff alone would treat every block after an insertion as removed and re-inserted. - BlockStateStore.Remap, SelectionRemap and the reading anchor follow the alignment. A selection endpoint inside a rewritten block is clamped to the paired new block; inside a deleted block it moves to the start of the next surviving block (or the document end), and a selection whose ends land on the same point is cleared. - StreamingDocument.Load is now atomic (clear, write and mark complete under one lock) so the worker can no longer pick up an incomplete intermediate snapshot between Reset and Complete. StreamingDocument.Version is exposed. - The Gallery NativeAOT smoke test checks the new alignment, task marker and line/column paths (0 IL warnings, all checks pass). - Notes the timing-sensitive LongCodeTests assertion as a known local flake. Closes #5 Co-Authored-By: Claude Sonnet 5.5 --- .agents/codemap/modules/avalonia-selection.md | 1 + .../modules/avalonia-text-rendering.md | 1 + .agents/codemap/modules/core-streaming.md | 4 + .agents/memory/MEMORY.md | 1 + .agents/memory/longcode-jump-timing-flaky.md | 12 + README.md | 5 + docs/architecture.md | 4 +- docs/decisions.md | 9 + samples/StreamMark.Gallery/SmokeTest.cs | 11 + src/StreamMark.Avalonia/MarkdownView.cs | 128 ++++++- .../Plugins/BlockStateStore.cs | 23 +- .../Rendering/DocumentPresenter.cs | 12 +- .../Selection/SelectionRemap.cs | 55 +++ src/StreamMark.Core/Streaming/SnapshotEdit.cs | 268 +++++++++++++++ .../Streaming/StreamingDocument.cs | 31 +- .../Rendering/RefreshMarkdownTests.cs | 313 ++++++++++++++++++ .../Streaming/SnapshotEditTests.cs | 220 ++++++++++++ 17 files changed, 1090 insertions(+), 8 deletions(-) create mode 100644 .agents/memory/longcode-jump-timing-flaky.md create mode 100644 src/StreamMark.Avalonia/Selection/SelectionRemap.cs create mode 100644 src/StreamMark.Core/Streaming/SnapshotEdit.cs create mode 100644 tests/StreamMark.Avalonia.Tests/Rendering/RefreshMarkdownTests.cs create mode 100644 tests/StreamMark.Core.Tests/Streaming/SnapshotEditTests.cs diff --git a/.agents/codemap/modules/avalonia-selection.md b/.agents/codemap/modules/avalonia-selection.md index 04e8a6a..d587305 100644 --- a/.agents/codemap/modules/avalonia-selection.md +++ b/.agents/codemap/modules/avalonia-selection.md @@ -74,6 +74,7 @@ updated: 2026-10-16 ## Invariants and traps - **`TextPoint.Offset` 不是源码偏移**:它是片段纯文本里的下标(粗体、链接的标记不在里面)。源码位置只有 `BlockKey`(块起点)与块的 `Range` 是真的,所以反查只给块级区间(`SourceRangeOf`),不要把片段偏移加到 `BlockKey` 上冒充源码偏移 — see `Selection/DocumentNavigation.cs:107`。 +- **文档被整篇替换后选区的去向(`SelectionRemap`)**:平移的块精确平移;被替换的块映射到配对的新块并把片段序号 / 偏移钳到新文本范围内;被删除的块退到后面第一个存活块的开头(没有则文档末尾);两端落到同一点则清空(原本就是插入点的保留)。选区坐标只存值,所以搬迁只是重写 `BlockKey` — see `Selection/SelectionRemap.cs`、`MarkdownView.RemapSelection`。 - **阅读锚点的身份是 `MarkdownView.DocumentId`,不是快照的 `Generation`**:`Generation` 在 `Complete()` 时也会递增(`DocumentComposer.Finish`),同一份流式源码完成前后 `Generation` 不同。`DocumentId` 只在 `Markdown` / `Document` / `Stream` 被赋值时递增;外部对已绑定的 `StreamingDocument` 直接 `Reset` 它察觉不到,此时锚点按块 `Key` 找不到会退到最近的块(`Approximate`)— see `MarkdownView.cs:110`、`:328`。 - 源码导航与锚点恢复在版面未稳定(`IsMeasureValid` / `IsArrangeValid` 为假)时直接返回 `LayoutPending` 而不滚动,不要改成"尽力而为"——位置会按旧版面算错 — see `MarkdownView.cs`(`IsLayoutReady`)。 - 选区坐标绝不持有视图引用 — 否则虚拟化回收后选区指向已丢弃的控件 — see `Selection/SelectionModel.cs:9`、`Selection/SelectionController.cs:189`。 diff --git a/.agents/codemap/modules/avalonia-text-rendering.md b/.agents/codemap/modules/avalonia-text-rendering.md index e3bddbe..5d27ba8 100644 --- a/.agents/codemap/modules/avalonia-text-rendering.md +++ b/.agents/codemap/modules/avalonia-text-rendering.md @@ -89,6 +89,7 @@ updated: 2026-10-16 - To add 一种新的行内对象渲染:在 `InlineTextSource.CreateObjectRun`(`TextSegmentView.cs:867`)加分支,新 run 继承 `DrawableTextRun`(参照 `InlineRuns.cs:18-167`),并让 `FirstDifference` 的 `Objects` 比较能识别它的偏移变化(:204)。 ## Invariants and traps +- **整篇替换(`MarkdownView.RefreshMarkdown`)的应用顺序不能换**:先 `SnapshotEdit.Compute` → `BlockStateStore.Remap`(视图 `Update` 时按**新** key 读状态)→ 选区搬迁(`SelectionRemap`)→ `DocumentPresenter.ApplyEdit` → 等 `LayoutUpdated` 后恢复阅读锚点。刷新期间 `ApplyLatest` 在 `Latest.Version < _refreshVersion` 时直接返回(保持旧画面),所以 A → B → C 之间的快照不会上屏;`Markdown` 赋值仍走清空路径并递增 `DocumentId` — see `src/StreamMark.Avalonia/MarkdownView.cs`(`RefreshMarkdown` / `ApplyEdited`)、`tests/StreamMark.Avalonia.Tests/Rendering/RefreshMarkdownTests.cs`。 - **任务复选框只发「请求」,不改状态**:`ListItemBlockView` 命中复选框(绘制时记下的 `_taskBox`,外扩 3px)或聚焦后按空格 / 回车,调用 `RenderContext.TaskToggle(item)`,由 `MarkdownView.RequestTaskToggle` 触发 `TaskToggleRequested`;勾选状态永远只由快照决定,宿主不改原文就不会变。复选框一带要画透明填充才会命中(没有绘制内容的区域不参与命中)。只有 `IsTaskInteractionEnabled` 且快照 `IsComplete` 时才发请求;`BlockOverlay` 的副本上下文故意不带 `TaskToggle`(只读);`TranscriptPanel` 暂未接入 — see `src/StreamMark.Avalonia/Rendering/ContainerViews.cs`(`ListItemBlockView`)、`src/StreamMark.Avalonia/MarkdownView.cs`(`RequestTaskToggle`)、`tests/StreamMark.Avalonia.Tests/Rendering/TaskInteractionTests.cs`。 - `_runCache.InvalidateFrom(i)` 必须传入**差异点所在段落的起点**,不是差异点本身 — 缓存以换行符分隔的段落为单位、按段落起点存放整段的整形结果,从差异点失效会复用旧段落(连同段末换行),流式每次追加都变成一次硬换行;回归测试 `SmoothStreamWrapTests` — see `src/StreamMark.Avalonia/Rendering/TextSegmentView.cs:164`。 - `TextRunCache` 是 Avalonia 12 的 `[Unstable]` 预览 API — 只允许在 `TextSegmentView` 内使用,并用 `#pragma warning disable CS0618` 限定范围;换别的文件用要重新评估(同一快照里 `TextLayout` 也是预览重载)— see `src/StreamMark.Avalonia/Rendering/TextSegmentView.cs:30`。 diff --git a/.agents/codemap/modules/core-streaming.md b/.agents/codemap/modules/core-streaming.md index a40f672..cd48a0c 100644 --- a/.agents/codemap/modules/core-streaming.md +++ b/.agents/codemap/modules/core-streaming.md @@ -15,9 +15,11 @@ updated: 2026-10-16 | `StreamingDocument` | `src/StreamMark.Core/Streaming/StreamingDocument.cs:16` | 唯一公开的门面:`Append` / `Complete` / `Reset` / `Load` / `LoadNow`,任意线程可调 | | `StreamingDocument.Append`/`Complete`/`Reset` | `src/StreamMark.Core/Streaming/StreamingDocument.cs:65`、`:84`、`:99` | 提交文本并唤醒后台工作者;`Complete` 之后 `Append` 抛 `InvalidOperationException`(`:75`) | | `StreamingDocument.Latest` / `SnapshotReady` | `src/StreamMark.Core/Streaming/StreamingDocument.cs:47`、`:50` | 取快照的两种方式:轮询 + 事件(事件在**后台线程**触发,注释要求处理函数尽快返回) | +| `StreamingDocument.Version` / `.Load` | `src/StreamMark.Core/Streaming/StreamingDocument.cs` | `Version`:最新提交的源码版本,`DocumentSnapshot.Version` 追上它才说明最新文本已解析完(视图用它丢弃刷新时的过期快照)。`Load` 是**原子**的(单次加锁清空 + 写入 + 标记完成),工作线程取不到「已清空但未完成」的中间状态 | | `StreamingDocument.WhenIdle` | `src/StreamMark.Core/Streaming/StreamingDocument.cs:186` | 测试与 `Load` 路径用的"已处理完"信号 | | `DocumentSnapshot` | `src/StreamMark.Core/Streaming/DocumentSnapshot.cs:11` | 冻结块 + 尾部块的只读视图;`Version` / `Generation` / `SourceLength` / `IsComplete` | | `SnapshotDiff.Compute` | `src/StreamMark.Core/Streaming/SnapshotDiff.cs:24` | 把快照对比算成顺序可应用的 `DiffOp` 列表(这是渲染层的唯一增量入口) | +| `SnapshotEdit.Compute` | `src/StreamMark.Core/Streaming/SnapshotEdit.cs` | 宿主整篇替换文本后的新旧快照对齐:公共前缀 / 后缀 + 中间按内容做最长公共子序列(块数乘积 > 100 万退回按位置)。输出仍是 Insert / Update / Remove(平移的块用 Update,所以**没有**新增 `DiffKind`),外加每个旧块的去向 `BlockFate`(`Unchanged` / `Shifted` / `Replaced` / `Deleted`)与 `MapKey`(旧源码偏移 → 新偏移) | | `StreamPacer` | `src/StreamMark.Core/Pacing/StreamPacer.cs:60` | 突发网络分片 → 平滑显示节奏的纯算法(无计时器,时间由调用方传入) | | `DocumentComposer`(internal) | `src/StreamMark.Core/Streaming/DocumentComposer.cs:15` | 后台工作线程独占的组装器:`Update` / `Finish` / `Reset` / `FrozenEnd` | | `StreamSegmenter`(internal) | `src/StreamMark.Core/Streaming/StreamSegmenter.cs:12` | 冻结边界扫描器 + 未闭合围栏代码块的快速路径探针 | @@ -77,6 +79,8 @@ updated: 2026-10-16 - To 加一种差异操作:`DiffKind`(`src/StreamMark.Core/Streaming/SnapshotDiff.cs:5`)是 `byte` 枚举,`DocumentPresenter.Apply`(`src/StreamMark.Avalonia/Rendering/DocumentPresenter.cs:35`)与 `TranscriptPanel`(`src/StreamMark.Avalonia/Transcript/TranscriptPanel.cs:276`)各有一份 `switch`,**必须同步改三处**;漏改 → 新操作被静默忽略,控件与快照脱节。 ## Invariants and traps +- **`SnapshotDiff` 对「宿主整篇替换」是错的工具**:它按源码起点(`Key`)归并,在前面增删文字会让后面所有块 `Key` 偏移,全部被当成删除再插入,视图和块状态都丢。整篇替换走 `SnapshotEdit`(按内容对齐);两者的 `DiffOp` 语义相同,`DocumentPresenter` 共用同一段应用逻辑 — see `src/StreamMark.Core/Streaming/SnapshotEdit.cs`、`tests/StreamMark.Core.Tests/Streaming/SnapshotEditTests.cs`(含随机编辑下「应用 ops 得到新文档」的属性测试)。 +- **`SnapshotEdit` 的配对不能只按位置**:一次刷新里有两处编辑(例如开头增加、末尾删除)时前缀与后缀都对不上,中间若按位置硬配对,会把状态和选区配到不相干的块上 — `TwoSeparateEdits_*` 守这条。`Shifted` 的位移量是**每个块自己的**(`New.Key - Old.Key`),不是全局一个 delta。 - **冻结前缀永不重解析**:`DocumentComposer.Update` 只解析 `[oldFrozen, newFrozen)` 一次,之后的快照直接复用这些对象 — see `src/StreamMark.Core/Streaming/DocumentComposer.cs:66`。因此冻结片段里**只保留与位置无关的 remend 转义修正**(singleTilde / comparisonOperators),否则已完整的文本会被"补全" — see `src/StreamMark.Core/Streaming/DocumentComposer.cs:17`、`docs/streaming-rules.md:45`。 - **永远不冻结最后一个块**:`CanCut` 需要"下一行已到达"才能确认前一块结束 — see `src/StreamMark.Core/Streaming/StreamSegmenter.cs:273`;测试 `LastBlock_IsNeverFrozen`(`tests/StreamMark.Core.Tests/Streaming/SegmenterTests.cs:27`)。 - **边界只前进**:`TryCommit` 有 `boundary <= FrozenEnd` 直接 return — see `src/StreamMark.Core/Streaming/StreamSegmenter.cs:262`;`AppendOnlyList` 的"旧分块永不移动"是这条不变量的底层保证 — see `src/StreamMark.Core/Streaming/AppendOnlyList.cs:3`。 diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md index 9c5848b..6d56cfa 100644 --- a/.agents/memory/MEMORY.md +++ b/.agents/memory/MEMORY.md @@ -20,6 +20,7 @@ - [回退字体曾依赖取 run 的起点与匹配历史](fallback-font-position-history.md) — Windows 上流式断行与整段排版不一致的原因(alpha.19 修复) - [差异视图正在加入(2026-10-16)](diff-view-in-flight.md) — 当前在飞的特性,以及它会收紧哪些不变量 - [Mermaid 配色默认复刻 mermaid.js,宿主半透明色必须叠底色](mermaid-theme-mermaidjs.md) — 白底白字的来历、`DataPalette` 的蓝色陷阱、高图不再封顶 900px +- [LongCodeTests 的 20ms 计时断言本机偶发失败](longcode-jump-timing-flaky.md) — 全量里只有它失败时先单独重跑,别当成回归 - [表格操作按钮在网格外的操作条里](table-actions-outside-grid.md) — 复制按钮曾盖在表头上,负坐标 / Popup 点不中 ## Reference diff --git a/.agents/memory/longcode-jump-timing-flaky.md b/.agents/memory/longcode-jump-timing-flaky.md new file mode 100644 index 0000000..0f8ba45 --- /dev/null +++ b/.agents/memory/longcode-jump-timing-flaky.md @@ -0,0 +1,12 @@ +--- +name: longcode-jump-timing-flaky +description: LongCodeTests 的"跳转排版中位数 < 20ms"是计时断言,本机跑全量时偶发失败,单独重跑通过;不是回归 +type: project +updated: 2026-10-21 +source: session +evidence: [tests/StreamMark.Avalonia.Tests/Transcript/LongCodeTests.cs:ThreeThousandLineCode_IsChunked_AndCopiesExactly] +--- +2026-10-21 在 `6516221` 上,`dotnet test StreamMark.slnx --no-build` 全量运行时 `ThreeThousandLineCode_IsChunked_AndCopiesExactly` 失败("chunk jump layout too slow (median)",实测 11–24ms,本机阈值 20ms,CI 为 100ms);单独重跑通过,之后三次全量运行都通过。 + +**Why:** 断言的是墙钟时间,本机(非 CI)阈值只有 20ms,刚编译完、机器忙或有别的 dotnet 进程时中位数会擦边。它不依赖被测代码的改动。 +**How to apply:** 全量测试里只有这一条失败、且消息是 `chunk jump layout too slow` 时,先单独重跑(`--filter "FullyQualifiedName~ThreeThousandLineCode"`)再判断是否回归;别因为它去改渲染代码。若它反复稳定失败,才说明分段排版真的退化了。 diff --git a/README.md b/README.md index 168fe9c..485064e 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,11 @@ Raw HTML in Markdown (README style) is converted into the block model, so it sup - `ScrollToSourceOffset(offset)` scrolls to the deepest block that contains the offset: a list item, a paragraph inside a quote, or a code block as a whole. It returns a `SourceNavigationResult`; when nothing moved, the status says why: `NoDocument` (no snapshot applied yet), `OutOfRange`, or `LayoutPending` (try again next frame). - `GetSourceRange(TextPoint)` and `GetSelectionSourceRange()` map a rendered point or the selection back to the source range of the blocks it covers. `TextPoint.Offset` is an offset inside the segment's plain text, not a source offset, so text with emphasis or links never lines up with the source. - `CaptureAnchor()` and `RestoreAnchor(anchor)` save and restore the reading position: the character at the top of the outer `ScrollViewer` plus its pixel displacement. A width, font or theme change keeps the place. An anchor is tied to `DocumentId`, which changes whenever `Markdown`, `Document` or `Stream` is assigned; an anchor from another document returns `WrongDocument`. If the anchored block is gone, the view scrolls to the next block (`Approximate`). +- **Editor previews** (`MarkdownView.RefreshMarkdown(text)`): replaces the whole text without clearing the view, for a preview next to a source editor where any part of the text can change. Assigning `Markdown` still means "a new document" and resets selection and block state. + - The old content stays on screen until the new text has been parsed in the background. Updates that arrive in quick succession (A, B, C) never show A or B once C is accepted. + - The new and old snapshots are aligned by content. Blocks that did not change, or only moved because text was added or removed before them, are updated in place: their views, layout and block state (`BlockStateStore`, via `Remap`) are kept. + - The selection and the reading position move with them. An endpoint inside a rewritten block stays on the paired new block and is clamped to the new text. An endpoint inside a deleted block moves to the start of the next surviving block (or the end of the document); if both ends land on the same point the selection is cleared. + - `DocumentId` does not change, so reading anchors captured earlier stay valid. `RefreshMarkdown` throws when the text comes from `Document` or `Stream`. - **Task checkboxes** (`MarkdownView`, off by default): task list checkboxes are read-only. Set `IsTaskInteractionEnabled = true` and a click on the box (or Space / Enter once it has focus) raises `TaskToggleRequested` instead of changing anything. StreamMark never edits the source or the snapshot: the host decides, edits its own text (with its own undo grouping and saving), and assigns the new text. A rejected request changes nothing on screen. - `TaskToggleRequestedEventArgs` carries `Marker` (the source range of `[ ]` / `[x]`), `IsChecked`, `RequestedChecked`, `DocumentId` and `Version`. `ApplyTo(source)` replaces only the character inside the brackets, so indentation and CRLF are kept; it returns `null` if the source no longer matches. - Requests are raised only once the document is complete, not while a stream is still running. If the host handles a request asynchronously, `view.IsCurrent(request)` tells whether the document and snapshot are still the ones the request was made on. diff --git a/docs/architecture.md b/docs/architecture.md index 45b7005..8bddba6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,6 +46,8 @@ StreamMark 是面向 AI 对话场景的高性能流式 Markdown 渲染库:Aval 3. **冻结片段独立解析的结果与整篇解析一致**(引用定义除外,见 streaming-rules.md)。测试 `StreamingEquivalenceTests` 在每一步都验证这一点。 4. **偏移永远指原始源码**:remend 会插入转义字符,`OffsetMap` 把修补后文本中的位置映射回原始位置。 5. **界面线程不做解析**:解析在后台单线程合并执行,界面层每帧最多应用一次差异。 +6. **宿主整篇替换文本(编辑器预览)不改变只追加的约定**:`MarkdownView.RefreshMarkdown` 仍是 `Load`(新一代),但视图不清空:用 `SnapshotEdit` 把新旧两份快照按内容对齐,前面增删文字让后面的块整体平移时,视图原地更新到新的源码位置,块状态、选区与阅读位置按对齐结果搬过去。`SnapshotDiff` 只按源码起点归并,对这种情况会把后面所有块当成删除再插入,所以刷新另走 `SnapshotEdit`。 +7. **阅读锚点与源码位置的身份是视图级的 `DocumentId`**,不是快照的 `Generation`(`Complete()` 时也会递增)。块级精度:`TextPoint.Offset` 是片段纯文本偏移,不是源码偏移,源码位置只到块的 `Range`。 ## 线程模型 @@ -67,7 +69,7 @@ StreamMark 是面向 AI 对话场景的高性能流式 Markdown 渲染库:Aval | `TextSegmentView` | 一个文本片段 = 一个 `SegmentLayout`(逐行的 `TextLine`,与 `TextLayout` 结果一致)。自定义 `ITextSource` 提供行内样式;公式、图片、任务复选框是 `DrawableTextRun`(基线对齐)。流式追加只从差异点所在行的前一行重新排版,每行一个 `SegmentLineVisual` 单独录制,所以只有变化的行重新录制、产生脏区。片段自身只画可命中区域、选区 / 查找高亮与流式光标;删除线随行自绘 | | `TailFade` | 文档末尾片段的逐字渐显,在渲染线程完成:界面线程在排版变化时把渐显中的字形与各批出现时间交给合成层的自定义视觉(`CompositionCustomVisualHandler`),所在行视觉用矩形裁掉渐显部分;之后每帧只由渲染线程重绘这一小块,界面线程不参与。渐显结束后恢复行的完整绘制 | | 块视图 | `TextBlockView`(段落 / 标题 / HTML)、`CodeBlockView`(逐行排版缓存 + 后台增量高亮 + 复制 / 换行)、`TableBlockView`(自定义列宽布局,不用 `Grid`)、`MathBlockView`(保留上一次成功排版)、`DiagramBlockView`(闭合后后台渲染、缓存、图表 / 源码切换)、列表 / 引用 / 提示块 / 容器 / 脚注 / 定义列表 | -| `DocumentPresenter` | 把快照差异应用到顶层块视图:同类型复用,类型变化才替换 | +| `DocumentPresenter` | 把快照差异应用到顶层块视图:同类型复用,类型变化才替换;`ApplyEdit` 应用 `SnapshotEdit`(宿主整篇替换文本) | | `MarkdownView` | 单文档、非虚拟化、高度随内容;`Markdown` / `Document` / `Stream` 三种输入 | | `TranscriptPanel` / `TranscriptView` | 会话:所有消息摊平成行(Markdown 顶层块各一行,宿主内容各一行),只实例化视口 ±600px 内的行;按块类型估算未测量行高;滚动锚定;贴底跟随;`MaxContentWidth` 居中列 | | `SelectionModel` / `SelectionController` | 选区坐标 `(消息, 顶层块 key, 片段, 偏移)`,与视图无关;单击 / 拖拽(自动滚动)/ 双击选词 / 三击选段 / Shift 扩展;⌘C 纯文本、⌘⇧C Markdown 源码、⌘A 全选;被虚拟化移出的块也能复制 | diff --git a/docs/decisions.md b/docs/decisions.md index 066f878..1196f67 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -77,3 +77,12 @@ - **无障碍**:Avalonia 12 没有公开的 `TextPattern`,读屏只能读到名称;标题通过 `AutomationProperties.HeadingLevel` 提供级别。 - **键盘选区**:Shift+↑/↓ 用字符框上下方的探测点按坐标命中,探测距离逐步加大,以跨过块之间的间距。 - **修正**:Markdig 中脚注组的区间从文档开头算起,曾使 `FootnotesBlock` 的 key 为 0,与第一个块重复;现在取其中脚注的区间。 +- **编辑器预览的集成(2026-10-21,issue #5 / #6 / #7)**: + - **整篇替换用 `RefreshMarkdown`,不让 `StreamingDocument` 可变**。被否决:让文档支持任意位置编辑(会破坏"源码只追加、冻结前缀永不重解析",流式与整篇解析的等价性无从保证);`Markdown` 赋值自动保留状态(会让宿主把旧文档的选区和状态带进一个真正的新文档,赋值仍然表示换文档)。 + - **对齐在 Core(`SnapshotEdit`),不新增 `DiffKind`**:平移的块用 `Update` 表示,`SnapshotDiff` / `DocumentPresenter.Apply` / `TranscriptPanel` 三处 `switch` 不用动。前缀 / 后缀剥离后,中间部分按内容做最长公共子序列(块数乘积超过 100 万则退回按位置替换)。按位置硬配对在一次刷新里有两处编辑时会把状态和选区配到不相干的块上,所以不取。 + - **不接受宿主传入的改动范围**:前缀 / 后缀 + 内容配对对编辑已经足够,宿主给的范围不会让结果更准,却多一个会写错的参数。 + - **`Load` 原子化**:原来是 `Reset` 再 `Complete`,工作线程可能在两者之间取到"已清空但未完成"的文本,产生一个只含尾部补全的中间快照;对刷新而言那会是一帧错误画面。 + - **过期结果靠版本丢弃**:视图记下刷新时的 `StreamingDocument.Version`,`Latest.Version` 没追上就不应用,A → B → C 之间的快照不会上屏。 + - **阅读锚点身份用视图级 `DocumentId`**:`DocumentSnapshot.Generation` 在 `Complete()` 时也递增,同一份流式源码完成前后不同。已知限制:外部直接对绑定的 `StreamingDocument.Reset` 视图察觉不到。 + - **源码位置只做块级**:`TextPoint.Offset` 是片段纯文本偏移,粗体 / 链接的标记不在里面,不能当源码偏移。精确到字符需要给行内内容记源码区间,成本与收益不成比例,先不做。 + - **任务勾选只发请求**:StreamMark 不写文件、不改快照,宿主改自己的原文并管撤销;只在快照完整时发;`ListItemBlock.TaskMarker` 进指纹时记相对列表项起点的位置,整体平移不产生 Update。 diff --git a/samples/StreamMark.Gallery/SmokeTest.cs b/samples/StreamMark.Gallery/SmokeTest.cs index 14bd5ac..35ce58c 100644 --- a/samples/StreamMark.Gallery/SmokeTest.cs +++ b/samples/StreamMark.Gallery/SmokeTest.cs @@ -61,6 +61,17 @@ void Check(string name, bool ok, string? detail = null) Check("stream.blocks", final.Count == 5 && final[1] is ParagraphBlock { Content.Objects.Length: 1 } && final[2] is CodeBlock { Language: "csharp" } && final[3] is TableBlock, kinds); Check("stream.diff", SnapshotDiff.Compute(null, final).Count == final.Count); + // 编辑器预览:整篇替换后的块对齐、任务标记的源码区间、源码偏移与行列换算 + var edited = new StreamingDocument(); + edited.Load("Intro.\n\n" + markdown); + edited.WhenIdle().GetAwaiter().GetResult(); + var edit = SnapshotEdit.Compute(final, edited.Latest); + Check("edit.align", edit.Moves.Count == final.Count && edit.Moves.All(m => m.Fate == BlockFate.Shifted) && edit.Ops.Count(o => o.Kind == DiffKind.Insert) == 1, + string.Join(",", edit.Moves.Select(m => m.Fate))); + var task = final[^1] is ListBlock { Items: [{ Task: true } first, ..] } ? first : null; + Check("task.marker", task is not null && markdown.Substring(task.TaskMarker.Start, task.TaskMarker.Length) == "[x]"); + Check("source.location", SourceText.ToLocation(markdown, markdown.IndexOf("bold", StringComparison.Ordinal)) == new SourceLocation(2, 7)); + // 界面:在无头平台上用 Skia 真实渲染会话视图(公式、高亮、图表、表格),并截图 try { diff --git a/src/StreamMark.Avalonia/MarkdownView.cs b/src/StreamMark.Avalonia/MarkdownView.cs index c1fd705..c8603fc 100644 --- a/src/StreamMark.Avalonia/MarkdownView.cs +++ b/src/StreamMark.Avalonia/MarkdownView.cs @@ -39,6 +39,10 @@ public class MarkdownView : Control, ISelectionHost private RenderContext? _context; private bool _frameRequested; private readonly Plugins.BlockStateStore _state = new(); + private bool _refreshing; + private long _refreshVersion; + private ReadingAnchor? _pendingAnchor; + private int _anchorAttempts; static MarkdownView() { @@ -130,8 +134,49 @@ public bool IsCurrent(TaskToggleRequestedEventArgs request) => /// public int DocumentId { get; private set; } + /// + /// 编辑器预览用的全文刷新:把文本换成 ,但和直接设置 不同,不会清空视图。 + /// 新文本在后台解析完成之前继续显示旧内容;解析完成后,没变的块与整体平移的块(前面增删了文字)原地更新, + /// 视图、排版和块状态都保留,选区与阅读位置按新的源码位置搬过去。 + /// 不变,所以此前捕获的 仍然有效(同一个文档的新版本)。 + /// 连续刷新时,被后来的文本取代的中间版本不会显示。 + /// + /// + /// 选区端点的去向:在整体平移的块里精确平移;在内容被改写的块里留在配对的新块上,片段序号与偏移钳到新文本范围内; + /// 在被删除的块里退到后面第一个存活块的开头(没有则文档末尾),两端落到同一点时选区清空。 + /// 直接给 赋值仍然表示换了一个新文档:清空选区与块状态, 递增。 + /// 只在文本来自 时可用; / 的内容归宿主自己管理。 + /// + /// 视图绑定了 或 。 + public void RefreshMarkdown(string? markdown) + { + if (Stream is not null || Document is not null) + { + throw new InvalidOperationException("RefreshMarkdown only applies when the text comes from the Markdown property, not from Document or Stream."); + } + _refreshing = true; + try + { + Markdown = markdown; + } + finally + { + _refreshing = false; + } + } + private void OnSourceChanged() { + if (_refreshing && _ownDocument is not null && _presenter.Applied is not null && Stream is null && Document is null) + { + // 全文刷新:旧画面、选区和块状态都留着,等新文本解析完再按对齐结果应用(见 ApplyLatest) + _ownDocument.Load(Markdown ?? ""); + _refreshVersion = _ownDocument.Version; + RequestFrame(); + return; + } + _refreshVersion = 0; + _pendingAnchor = null; DocumentId++; if (Stream is null && Document is null) { @@ -197,7 +242,88 @@ public void ApplyLatest() } _context ??= CreateContext(); Stream?.SetFrameSource(TopLevel.GetTopLevel(this)); - _presenter.Apply(document.Latest, _context); + var snapshot = document.Latest; + if (_refreshVersion != 0) + { + // 刷新的新文本还没解析完(或只解析到了已被取代的中间版本):继续显示旧内容 + if (snapshot.Version < _refreshVersion) + { + return; + } + _refreshVersion = 0; + ApplyEdited(snapshot, _context); + return; + } + _presenter.Apply(snapshot, _context); + } + + // 顺序要紧:视图更新时按新 key 读取块状态,所以先搬状态与选区,再应用差异,最后在版面稳定后恢复阅读位置 + private void ApplyEdited(DocumentSnapshot snapshot, RenderContext context) + { + var anchor = CaptureAnchor(); + var edit = SnapshotEdit.Compute(_presenter.Applied, snapshot); + _state.Remap(edit.MapKey); + RemapSelection(edit); + _presenter.ApplyEdit(edit, context); + if (anchor is not null && RemapAnchor(edit, anchor) is { } moved) + { + if (_pendingAnchor is null) + { + LayoutUpdated += OnLayoutUpdatedForAnchor; + } + _pendingAnchor = moved; + _anchorAttempts = 0; + } + } + + private void RemapSelection(SnapshotEdit edit) + { + if (Selection.Anchor is not { } anchor || Selection.Active is not { } active) + { + return; + } + var mappedAnchor = SelectionRemap.Map(edit, anchor); + var mappedActive = SelectionRemap.Map(edit, active); + if (anchor == active) + { + Selection.Set(mappedAnchor, mappedAnchor); + } + else if (mappedAnchor == mappedActive) + { + Selection.Clear(); + } + else + { + Selection.Set(mappedAnchor, mappedActive); + } + } + + private static ReadingAnchor? RemapAnchor(SnapshotEdit edit, ReadingAnchor anchor) + { + if (edit.FindMove(anchor.BlockKey) is not { } move) + { + return anchor; + } + if (move.Fate != BlockFate.Deleted) + { + return anchor with { BlockKey = move.New!.Key }; + } + var fallback = move.New ?? (edit.Next.Count > 0 ? edit.Next[^1] : null); + return fallback is null ? null : anchor with { BlockKey = fallback.Key, Segment = 0, Offset = 0 }; + } + + // 应用差异之后版面还没有重新计算:等布局完成再恢复阅读位置(偶尔还会被打断,最多重试几次) + private void OnLayoutUpdatedForAnchor(object? sender, EventArgs e) + { + if (_pendingAnchor is { } anchor) + { + if (RestoreAnchor(anchor) == AnchorRestoreStatus.LayoutPending && ++_anchorAttempts < 5) + { + return; + } + _pendingAnchor = null; + } + LayoutUpdated -= OnLayoutUpdatedForAnchor; } private RenderContext CreateContext() => diff --git a/src/StreamMark.Avalonia/Plugins/BlockStateStore.cs b/src/StreamMark.Avalonia/Plugins/BlockStateStore.cs index 13ac68a..d4ff993 100644 --- a/src/StreamMark.Avalonia/Plugins/BlockStateStore.cs +++ b/src/StreamMark.Avalonia/Plugins/BlockStateStore.cs @@ -3,7 +3,7 @@ namespace StreamMark.Avalonia.Plugins; /// /// 块级界面状态(展开 / 折叠、换行、图表与源码切换、滚动位置等),按 (顶层块 key, 状态名) 保存。 /// 每个文档一份,独立于视图:视图被虚拟化回收后再出现、或样式变化重建视图时,状态得以保留。 -/// 源码只追加,块 key(源码起点)稳定。只在界面线程访问。 +/// 源码只追加,块 key(源码起点)稳定;宿主整篇替换文本(MarkdownView.RefreshMarkdown)时由 搬到新 key。只在界面线程访问。 /// public sealed class BlockStateStore { @@ -15,4 +15,25 @@ public T Get(int blockKey, string name, T fallback) => public void Set(int blockKey, string name, T value) where T : notnull => _values[(blockKey, name)] = value; public void Clear() => _values.Clear(); + + /// + /// 文档被编辑、块的 key(源码偏移)变了时搬运状态: 返回旧 key 对应的新 key, + /// 返回 null 表示那个块已经不存在,它的状态被丢弃。 + /// + public void Remap(Func map) + { + var moved = new Dictionary<(int Key, string Name), object>(_values.Count); + foreach (var ((key, name), value) in _values) + { + if (map(key) is { } newKey) + { + moved[(newKey, name)] = value; + } + } + _values.Clear(); + foreach (var (entry, value) in moved) + { + _values[entry] = value; + } + } } diff --git a/src/StreamMark.Avalonia/Rendering/DocumentPresenter.cs b/src/StreamMark.Avalonia/Rendering/DocumentPresenter.cs index 628286e..617fd43 100644 --- a/src/StreamMark.Avalonia/Rendering/DocumentPresenter.cs +++ b/src/StreamMark.Avalonia/Rendering/DocumentPresenter.cs @@ -27,11 +27,19 @@ public DocumentPresenter(Action insert, Action r public RenderContext? Context { get; private set; } /// 应用新快照;返回是否有任何变化。 - public bool Apply(DocumentSnapshot snapshot, RenderContext context) + public bool Apply(DocumentSnapshot snapshot, RenderContext context) => + Apply(SnapshotDiff.Compute(Applied, snapshot), snapshot, context); + + /// + /// 应用编辑前后两份快照的对齐结果(宿主整篇替换文本):被平移的块原地更新到新的源码位置,而不是删掉重建, + /// 所以视图、排版缓存与界面状态都保留。 必须是以当前已应用的快照为起点算出的。 + /// + public bool ApplyEdit(SnapshotEdit edit, RenderContext context) => Apply(edit.Ops, edit.Next, context); + + private bool Apply(IReadOnlyList ops, DocumentSnapshot snapshot, RenderContext context) { var contextChanged = !ReferenceEquals(Context, context); Context = context; - var ops = SnapshotDiff.Compute(Applied, snapshot); foreach (var op in ops) { switch (op.Kind) diff --git a/src/StreamMark.Avalonia/Selection/SelectionRemap.cs b/src/StreamMark.Avalonia/Selection/SelectionRemap.cs new file mode 100644 index 0000000..279a4c3 --- /dev/null +++ b/src/StreamMark.Avalonia/Selection/SelectionRemap.cs @@ -0,0 +1,55 @@ +using StreamMark.Core.Streaming; + +namespace StreamMark.Avalonia.Selection; + +/// +/// 文档被编辑后把选区坐标搬到新快照。规则: +/// 平移的块(前面增删了文字)精确平移; +/// 被替换的块映射到配对的新块,片段序号与偏移钳到新文本的范围内; +/// 被删除的块退到后面第一个存活块的开头,没有时退到文档末尾。 +/// +internal static class SelectionRemap +{ + public static TextPoint Map(SnapshotEdit edit, TextPoint point) + { + if (edit.FindMove(point.BlockKey) is not { } move) + { + return point; + } + switch (move.Fate) + { + case BlockFate.Unchanged: + case BlockFate.Shifted: + return point with { BlockKey = move.New!.Key }; + case BlockFate.Replaced: + return Clamp(point with { BlockKey = move.New!.Key }, move.New); + default: + return move.New is { } next ? new TextPoint(point.Entry, next.Key, 0, 0) : EndOfDocument(edit, point.Entry); + } + } + + private static TextPoint Clamp(TextPoint point, Core.Model.MarkdownBlock block) + { + var segments = BlockText.Segments(block); + if (segments.Count == 0) + { + return point with { Segment = 0, Offset = 0 }; + } + if (point.Segment >= segments.Count) + { + return point with { Segment = segments.Count - 1, Offset = segments[^1].Length }; + } + return point.Segment < 0 ? point : point with { Offset = Math.Clamp(point.Offset, 0, segments[point.Segment].Length) }; + } + + private static TextPoint EndOfDocument(SnapshotEdit edit, int entry) + { + if (edit.Next.Count == 0) + { + return new TextPoint(entry, 0, 0, 0); + } + var last = edit.Next[^1]; + var segments = BlockText.Segments(last); + return segments.Count == 0 ? new TextPoint(entry, last.Key, 0, 0) : new TextPoint(entry, last.Key, segments.Count - 1, segments[^1].Length); + } +} diff --git a/src/StreamMark.Core/Streaming/SnapshotEdit.cs b/src/StreamMark.Core/Streaming/SnapshotEdit.cs new file mode 100644 index 0000000..9395cc3 --- /dev/null +++ b/src/StreamMark.Core/Streaming/SnapshotEdit.cs @@ -0,0 +1,268 @@ +using StreamMark.Core.Model; + +namespace StreamMark.Core.Streaming; + +/// 旧快照里的一个顶层块在新快照里的去向。 +public enum BlockFate : byte +{ + /// 内容与源码位置都没变。 + Unchanged, + + /// 内容没变,只是前面的文字增减让它整体平移(块内部的所有源码偏移按这个块自己的位移量平移)。 + Shifted, + + /// 内容变了:新块是在同一位置上与它配对的替换者。 + Replaced, + + /// 被删除(没有配对的新块)。 + Deleted, +} + +/// +/// 一个旧块的去向。:除 外是对应的新块; +/// 被删除时是紧跟在它后面的新块,文档末尾被删除时为 null。 +/// +public readonly record struct BlockMove(MarkdownBlock Old, MarkdownBlock? New, BlockFate Fate); + +/// +/// 编辑前后两份快照的对齐: 先取公共前缀(位置与内容都相同)与公共后缀(内容相同、整体平移同样的距离), +/// 中间部分再按内容做最长公共子序列配对(编辑不止一处时,两处编辑之间还有没变的块),配对之间的空隙按位置替换,其余视为删除 / 插入。快照之间不共享冻结前缀(例如宿主整篇替换了文本), +/// 此时只能按源码起点归并,在前面增删文字就会让后面所有块被当成删除再插入; +/// 对齐之后这些块是 ,视图可以原地更新,状态和选区也能跟着搬。 +/// +/// +/// 与 的语义相同(按顺序应用, 是正在变换的列表中的位置), +/// 平移的块以 表示,所以不需要新的操作类型。 +/// +public sealed class SnapshotEdit +{ + private readonly BlockMove[] _moves; + + private SnapshotEdit(DocumentSnapshot next, List ops, BlockMove[] moves) + { + Next = next; + Ops = ops; + _moves = moves; + } + + /// 编辑之后的快照。 + public DocumentSnapshot Next { get; } + + /// 把旧块列表变成新块列表的操作。 + public IReadOnlyList Ops { get; } + + /// 每个旧顶层块的去向,顺序与旧快照相同。 + public IReadOnlyList Moves => _moves; + + public static SnapshotEdit Compute(DocumentSnapshot? previous, DocumentSnapshot next) + { + previous ??= DocumentSnapshot.Empty; + var old = previous.Blocks.ToArray(); + var cur = next.Blocks.ToArray(); + int n = old.Length, m = cur.Length; + + var prefix = 0; + while (prefix < n && prefix < m && old[prefix].Range == cur[prefix].Range && SameContent(old[prefix], cur[prefix])) + { + prefix++; + } + + // 后缀:内容相同、长度相同,且源码起点平移的距离始终一样(编辑只发生在它们前面) + var suffix = 0; + var delta = 0; + while (suffix < n - prefix && suffix < m - prefix) + { + var o = old[n - 1 - suffix]; + var c = cur[m - 1 - suffix]; + var shift = c.Range.Start - o.Range.Start; + if (o.Range.Length != c.Range.Length || suffix > 0 && shift != delta || !SameContent(o, c)) + { + break; + } + delta = shift; + suffix++; + } + + int oldMid = n - prefix - suffix, newMid = m - prefix - suffix; + var ops = new List(); + var moves = new BlockMove[n]; + + for (var i = 0; i < prefix; i++) + { + moves[i] = new BlockMove(old[i], cur[i], BlockFate.Unchanged); + } + + // 中间:先按内容配对(编辑不止一处时,前缀 / 后缀之间还有没变的块),配对之间的空隙再按位置替换 + var position = prefix; + int oldNext = prefix, newNext = prefix; + foreach (var (matchOld, matchNew) in Match(old, cur, prefix, oldMid, newMid)) + { + Gap(old, cur, oldNext, matchOld, newNext, matchNew, ref position, ops, moves); + var o = old[matchOld]; + var c = cur[matchNew]; + var moved = o.Range != c.Range; + moves[matchOld] = new BlockMove(o, c, moved ? BlockFate.Shifted : BlockFate.Unchanged); + if (moved) + { + ops.Add(new DiffOp(DiffKind.Update, position, c)); + } + position++; + oldNext = matchOld + 1; + newNext = matchNew + 1; + } + Gap(old, cur, oldNext, prefix + oldMid, newNext, prefix + newMid, ref position, ops, moves); + + for (var t = 0; t < suffix; t++) + { + var o = old[n - suffix + t]; + var c = cur[m - suffix + t]; + moves[n - suffix + t] = new BlockMove(o, c, delta == 0 ? BlockFate.Unchanged : BlockFate.Shifted); + if (delta != 0) + { + // 视图原地更新到新的源码位置,而不是删掉重建 + ops.Add(new DiffOp(DiffKind.Update, position, c)); + } + position++; + } + return new SnapshotEdit(next, ops, moves); + } + + // 两处配对之间的空隙:旧块 [oldFrom, oldTo) 与新块 [newFrom, newTo) 按位置替换,多出的旧块删除、多出的新块插入 + private static void Gap(MarkdownBlock[] old, MarkdownBlock[] cur, int oldFrom, int oldTo, int newFrom, int newTo, + ref int position, List ops, BlockMove[] moves) + { + int oldCount = oldTo - oldFrom, newCount = newTo - newFrom; + var paired = Math.Min(oldCount, newCount); + for (var k = 0; k < paired; k++) + { + var o = old[oldFrom + k]; + var c = cur[newFrom + k]; + var same = o.Range == c.Range && SameContent(o, c); + moves[oldFrom + k] = new BlockMove(o, c, same ? BlockFate.Unchanged : BlockFate.Replaced); + if (!same) + { + ops.Add(new DiffOp(DiffKind.Update, position + k, c)); + } + } + // 被删除的块:记下新列表里紧跟在这段空隙后面的块,选区之类的位置可以退到那里 + var after = newTo < cur.Length ? cur[newTo] : null; + for (var k = paired; k < oldCount; k++) + { + moves[oldFrom + k] = new BlockMove(old[oldFrom + k], after, BlockFate.Deleted); + ops.Add(new DiffOp(DiffKind.Remove, position + paired, null)); + } + for (var k = paired; k < newCount; k++) + { + ops.Add(new DiffOp(DiffKind.Insert, position + k, cur[newFrom + k])); + } + position += newCount; + } + + // 配对的上限:中间区域的块数乘积超过它就不做内容配对(整篇都改了,位置替换已经够用),避免二次方的开销 + private const int MaxMatchCells = 1_000_000; + + // 最长公共子序列:内容(类型 + 指纹 + 源码长度)相同的块配对,返回升序的 (旧下标, 新下标) + private static List<(int Old, int New)> Match(MarkdownBlock[] old, MarkdownBlock[] cur, int start, int oldCount, int newCount) + { + var matches = new List<(int Old, int New)>(); + if (oldCount == 0 || newCount == 0 || (long)oldCount * newCount > MaxMatchCells) + { + return matches; + } + var oldPrint = new ulong[oldCount]; + var newPrint = new ulong[newCount]; + for (var i = 0; i < oldCount; i++) + { + oldPrint[i] = old[start + i].ComputeFingerprint(); + } + for (var j = 0; j < newCount; j++) + { + newPrint[j] = cur[start + j].ComputeFingerprint(); + } + bool Equal(int i, int j) => + oldPrint[i] == newPrint[j] && old[start + i].GetType() == cur[start + j].GetType() && old[start + i].Range.Length == cur[start + j].Range.Length; + + var width = newCount + 1; + var table = new int[(oldCount + 1) * width]; + for (var i = oldCount - 1; i >= 0; i--) + { + for (var j = newCount - 1; j >= 0; j--) + { + table[i * width + j] = Equal(i, j) ? table[(i + 1) * width + j + 1] + 1 : Math.Max(table[(i + 1) * width + j], table[i * width + j + 1]); + } + } + int a = 0, b = 0; + while (a < oldCount && b < newCount) + { + if (Equal(a, b)) + { + matches.Add((start + a, start + b)); + a++; + b++; + } + else if (table[(a + 1) * width + b] >= table[a * width + b + 1]) + { + a++; + } + else + { + b++; + } + } + return matches; + } + + /// 旧顶层块(按起点)的去向;没有这个块时返回 null。 + public BlockMove? FindMove(int oldBlockKey) + { + var index = IndexOfLastStartingAtOrBefore(oldBlockKey); + return index >= 0 && _moves[index].Old.Key == oldBlockKey ? _moves[index] : null; + } + + /// + /// 旧源码偏移 → 新源码偏移。块没变或整体平移时,块内任意偏移都能映射; + /// 内容变了的块只有它的起点能映射(内部的偏移已经不可信);被删除的块、块之间的空隙以及不认识的偏移返回 null。 + /// + public int? MapKey(int oldOffset) + { + var index = IndexOfLastStartingAtOrBefore(oldOffset); + if (index < 0) + { + return null; + } + var move = _moves[index]; + if (move.Fate == BlockFate.Deleted || move.New is null) + { + return null; + } + if (oldOffset == move.Old.Key) + { + return move.New.Key; + } + return move.Fate is BlockFate.Unchanged or BlockFate.Shifted && oldOffset < move.Old.Range.End + ? oldOffset + (move.New.Key - move.Old.Key) + : null; + } + + private int IndexOfLastStartingAtOrBefore(int offset) + { + int low = 0, high = _moves.Length - 1, found = -1; + while (low <= high) + { + var mid = (low + high) / 2; + if (_moves[mid].Old.Key <= offset) + { + found = mid; + low = mid + 1; + } + else + { + high = mid - 1; + } + } + return found; + } + + private static bool SameContent(MarkdownBlock a, MarkdownBlock b) => + ReferenceEquals(a, b) || a.GetType() == b.GetType() && a.ComputeFingerprint() == b.ComputeFingerprint(); +} diff --git a/src/StreamMark.Core/Streaming/StreamingDocument.cs b/src/StreamMark.Core/Streaming/StreamingDocument.cs index 5e3be70..0446d5b 100644 --- a/src/StreamMark.Core/Streaming/StreamingDocument.cs +++ b/src/StreamMark.Core/Streaming/StreamingDocument.cs @@ -60,6 +60,21 @@ public bool IsComplete } } + /// + /// 最新提交的源码版本(每次追加、、、 都会递增,跨重置也不回退)。 + /// 后台解析是异步的: 追上这个值,才说明最新提交的文本已经解析完。 + /// + public long Version + { + get + { + lock (_sourceLock) + { + return _version; + } + } + } + public void Append(string text) => Append(text.AsSpan()); public void Append(ReadOnlySpan text) @@ -112,11 +127,21 @@ public void Reset(string? text = null) Schedule(); } - /// 一次性加载完整文本(非流式消息)。 + /// + /// 一次性加载完整文本(非流式消息)。清空、写入和标记完成在同一次加锁里完成,工作线程不会取到 + /// "已清空但还没完成"的中间状态,所以不会为这段文本产生一个未完成的快照。 + /// public void Load(string markdown) { - Reset(markdown); - Complete(); + lock (_sourceLock) + { + _source.Clear(); + _source.Append(markdown); + _completeRequested = true; + _sourceGeneration++; + _version++; + } + Schedule(); } /// diff --git a/tests/StreamMark.Avalonia.Tests/Rendering/RefreshMarkdownTests.cs b/tests/StreamMark.Avalonia.Tests/Rendering/RefreshMarkdownTests.cs new file mode 100644 index 0000000..123d5cc --- /dev/null +++ b/tests/StreamMark.Avalonia.Tests/Rendering/RefreshMarkdownTests.cs @@ -0,0 +1,313 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.VisualTree; +using StreamMark.Avalonia.Plugins; +using StreamMark.Avalonia.Rendering; +using StreamMark.Avalonia.Selection; +using StreamMark.Core.Model; +using Xunit; + +namespace StreamMark.Avalonia.Tests.Rendering; + +public sealed class RefreshMarkdownTests +{ + private const string Source = "# Title\n\nFirst paragraph here.\n\nSecond one.\n\nLast paragraph.\n"; + + private static async Task<(Window Window, MarkdownView View)> Show(string markdown) + { + var view = new MarkdownView { Markdown = markdown, Margin = new(12) }; + var window = new Window { Width = 520, Height = 420, Content = view, Background = Brushes.Black }; + window.Show(); + await HeadlessApp.Settle(); + view.ApplyLatest(); + await HeadlessApp.Settle(); + return (window, view); + } + + // 刷新并等到新快照上屏 + private static async Task Refresh(MarkdownView view, string markdown) + { + var before = view.Snapshot!.Version; + view.RefreshMarkdown(markdown); + for (var i = 0; i < 100 && view.Snapshot!.Version == before; i++) + { + await HeadlessApp.Settle(2); + } + await HeadlessApp.Settle(); + Assert.NotEqual(before, view.Snapshot!.Version); + } + + private static List Blocks(MarkdownView view) => view.GetVisualChildren().OfType().ToList(); + + [Fact] + public async Task EditingTheLastParagraph_KeepsSelectionInFirstParagraphAndReusesViews() + { + await HeadlessApp.Run(async () => + { + var (window, view) = await Show(Source); + var snapshot = view.Snapshot!; + view.Selection.Set(new TextPoint(0, snapshot[1].Key, 0, 0), new TextPoint(0, snapshot[1].Key, 0, 5)); + Assert.Equal("First", view.SelectionController.GetSelectedText()); + var documentId = view.DocumentId; + var viewsBefore = Blocks(view); + + await Refresh(view, Source.Replace("Last paragraph.", "Changed last paragraph, a bit longer.")); + + Assert.Equal("First", view.SelectionController.GetSelectedText()); + Assert.Equal(documentId, view.DocumentId); + var viewsAfter = Blocks(view); + Assert.Equal(viewsBefore.Count, viewsAfter.Count); + // 没变的块与被改写的块都是同一个视图实例(原地更新) + Assert.All(viewsBefore.Zip(viewsAfter), pair => Assert.Same(pair.First, pair.Second)); + Assert.Contains("Changed last paragraph", ((ParagraphBlock)view.Snapshot![^1]).Content.Text); + window.Close(); + }); + } + + [Fact] + public async Task InsertingBeforeTheSelection_RemapsPositionsAndKeepsViews() + { + await HeadlessApp.Run(async () => + { + var (window, view) = await Show(Source); + var snapshot = view.Snapshot!; + view.Selection.Set(new TextPoint(0, snapshot[2].Key, 0, 0), new TextPoint(0, snapshot[3].Key, 0, 4)); + var selectedBefore = view.SelectionController.GetSelectedText(); + Assert.Equal("Second one.\n\nLast", selectedBefore); + var viewsBefore = Blocks(view); + var oldKeys = viewsBefore.Select(v => v.TopKey).ToList(); + + const string inserted = "Brand new opening paragraph.\n\n"; + await Refresh(view, Source.Insert(Source.IndexOf("First", StringComparison.Ordinal), inserted)); + + // 选区跟着搬,选中的文字不变 + Assert.Equal(selectedBefore, view.SelectionController.GetSelectedText()); + Assert.Equal(view.Snapshot![3].Key, view.Selection.Anchor!.Value.BlockKey); + Assert.Equal(oldKeys[2] + inserted.Length, view.Selection.Anchor!.Value.BlockKey); + // 旧块的视图全部保留,只是源码位置变了;插入的那一块是新视图 + var viewsAfter = Blocks(view); + Assert.Equal(viewsBefore.Count + 1, viewsAfter.Count); + Assert.All(viewsBefore.Skip(1), v => Assert.Contains(v, viewsAfter)); + Assert.Equal(oldKeys.Skip(1).Select(k => k + inserted.Length), viewsBefore.Skip(1).Select(v => v.TopKey)); + window.Close(); + }); + } + + [Fact] + public async Task DeletingBeforeTheSelection_ShiftsItBackToTheSameText() + { + await HeadlessApp.Run(async () => + { + var (window, view) = await Show(Source); + var snapshot = view.Snapshot!; + view.Selection.Set(new TextPoint(0, snapshot[3].Key, 0, 5), new TextPoint(0, snapshot[3].Key, 0, 14)); + Assert.Equal("paragraph", view.SelectionController.GetSelectedText()); + + await Refresh(view, Source.Replace("First paragraph here.\n\n", "")); + + Assert.Equal("paragraph", view.SelectionController.GetSelectedText()); + Assert.Equal(view.Snapshot![^1].Key, view.Selection.Anchor!.Value.BlockKey); + window.Close(); + }); + } + + [Fact] + public async Task DeletingTheSelectedBlock_FallsBackToTheNextBlock_AndClearsAnEmptiedSelection() + { + await HeadlessApp.Run(async () => + { + // 选区完全在被删除的块里:两端退到同一点,选区清空 + var (window, view) = await Show(Source); + var snapshot = view.Snapshot!; + view.Selection.Set(new TextPoint(0, snapshot[2].Key, 0, 0), new TextPoint(0, snapshot[2].Key, 0, 6)); + await Refresh(view, Source.Replace("Second one.\n\n", "")); + Assert.True(view.Selection.IsEmpty); + Assert.Null(view.Selection.Anchor); + + // 起点在存活的块里、终点在被删除的块里:终点退到后面第一个存活块的开头 + view.Selection.Set(new TextPoint(0, view.Snapshot![1].Key, 0, 2), new TextPoint(0, view.Snapshot![2].Key, 0, 4)); + Assert.Equal("rst paragraph here.\n\nLast", view.SelectionController.GetSelectedText()); + await Refresh(view, Source.Replace("Second one.\n\n", "").Replace("Last paragraph.\n", "")); + Assert.False(view.Selection.IsEmpty); + Assert.Equal("rst paragraph here.", view.SelectionController.GetSelectedText()); + window.Close(); + }); + } + + [Fact] + public async Task EditingInsideTheSelectedBlock_ClampsTheSelectionToTheNewText() + { + await HeadlessApp.Run(async () => + { + var (window, view) = await Show(Source); + var snapshot = view.Snapshot!; + view.Selection.Set(new TextPoint(0, snapshot[1].Key, 0, 6), new TextPoint(0, snapshot[1].Key, 0, 20)); + + await Refresh(view, Source.Replace("First paragraph here.", "First one")); + + // 终点超出新文本:钳到段落末尾 + Assert.Equal("one", view.SelectionController.GetSelectedText()); + window.Close(); + }); + } + + [Fact] + public async Task RapidRefreshes_NeverShowIntermediateVersions() + { + await HeadlessApp.Run(async () => + { + var (window, view) = await Show(Source); + const string a = "# Title A\n\nParagraph from A.\n"; + const string b = "# Title B\n\nParagraph from B.\n"; + const string c = "# Title C\n\nParagraph from C.\n"; + var original = ((HeadingBlock)view.Snapshot![0]).Content.Text; + + view.RefreshMarkdown(a); + view.RefreshMarkdown(b); + view.RefreshMarkdown(c); + var seen = new HashSet(); + for (var i = 0; i < 60; i++) + { + await HeadlessApp.Settle(1); + seen.Add(((HeadingBlock)view.Snapshot![0]).Content.Text); + } + + // 旧画面保持到 C 就绪;A、B 从来没有上屏 + Assert.Subset(new HashSet { original, "Title C" }, seen); + Assert.Contains("Title C", seen); + Assert.Equal("Title C", ((HeadingBlock)view.Snapshot![0]).Content.Text); + window.Close(); + }); + } + + [Fact] + public async Task OldContentStaysVisibleUntilTheReplacementIsApplied() + { + await HeadlessApp.Run(async () => + { + var (window, view) = await Show(Source); + var viewsBefore = Blocks(view); + + view.RefreshMarkdown(Source.Replace("Last paragraph.", "Other.")); + // 提交刷新的这一刻,画面没有被清空 + Assert.Equal(viewsBefore.Count, Blocks(view).Count); + Assert.NotNull(view.Snapshot); + window.Close(); + }); + } + + [Fact] + public async Task AssigningMarkdown_StillStartsANewDocument() + { + await HeadlessApp.Run(async () => + { + var (window, view) = await Show(Source); + var snapshot = view.Snapshot!; + view.Selection.Set(new TextPoint(0, snapshot[1].Key, 0, 0), new TextPoint(0, snapshot[1].Key, 0, 5)); + var documentId = view.DocumentId; + var viewsBefore = Blocks(view); + + view.Markdown = Source.Replace("Last paragraph.", "Other."); + await HeadlessApp.Settle(10); + view.ApplyLatest(); + await HeadlessApp.Settle(); + + Assert.NotEqual(documentId, view.DocumentId); + Assert.True(view.Selection.IsEmpty); + Assert.All(Blocks(view), v => Assert.DoesNotContain(v, viewsBefore)); + window.Close(); + }); + } + + [Fact] + public async Task BlockState_FollowsShiftedBlocks_AndIsDroppedForDeletedOnes() + { + await HeadlessApp.Run(async () => + { + const string text = "Intro.\n\n```js\nlet a = 1;\n```\n\nMiddle.\n\n```js\nlet b = 2;\n```\n"; + var (window, view) = await Show(text); + var codeViews = view.GetVisualDescendants().OfType().ToList(); + Assert.Equal(2, codeViews.Count); + var state = codeViews[0].CurrentContext!.State; + state.Set(codeViews[0].TopKey, "code.wrap", true); + state.Set(codeViews[1].TopKey, "code.wrap", true); + int firstKey = codeViews[0].TopKey, secondKey = codeViews[1].TopKey; + + // 第一个代码块之前增加文字,第二个代码块被删除 + const string prefix = "Brand new text before everything.\n\n"; + await Refresh(view, prefix + text[..text.IndexOf("Middle.", StringComparison.Ordinal)]); + + Assert.True(state.Get(firstKey + prefix.Length, "code.wrap", false)); + Assert.False(state.Get(firstKey, "code.wrap", false)); + Assert.False(state.Get(secondKey, "code.wrap", false)); + Assert.False(state.Get(secondKey + prefix.Length, "code.wrap", false)); + window.Close(); + }); + } + + [Fact] + public void BlockStateStore_Remap_MovesDropsAndDoesNotCollide() + { + var store = new BlockStateStore(); + store.Set(10, "a", 1); + store.Set(20, "a", 2); + store.Set(30, "b", 3); + store.Set(20, "c", 4); + + // 10 → 20 与 20 → 10 同时发生,不能互相覆盖;30 被删除 + store.Remap(key => key switch { 10 => 20, 20 => 10, _ => null }); + + Assert.Equal(1, store.Get(20, "a", 0)); + Assert.Equal(2, store.Get(10, "a", 0)); + Assert.Equal(4, store.Get(10, "c", 0)); + Assert.Equal(0, store.Get(30, "b", 0)); + } + + [Fact] + public async Task ReadingPosition_StaysPut_WhenContentIsInsertedAboveTheViewport() + { + await HeadlessApp.Run(async () => + { + var text = string.Concat(Enumerable.Range(0, 60).Select(i => $"Paragraph number {i} " + string.Concat(Enumerable.Repeat("lorem ipsum dolor sit amet ", 8)) + "\n\n")); + var view = new MarkdownView { Markdown = text, Margin = new(12) }; + var scroll = new ScrollViewer { Content = view }; + var window = new Window { Width = 420, Height = 320, Content = scroll, Background = Brushes.Black }; + window.Show(); + await HeadlessApp.Settle(); + view.ApplyLatest(); + await HeadlessApp.Settle(); + + view.ScrollToSourceOffset(text.IndexOf("Paragraph number 40 ", StringComparison.Ordinal)); + await HeadlessApp.Settle(); + double TopOf(string prefix) + { + var segment = view.GetVisualDescendants().OfType().First(s => s.Content.Text.StartsWith(prefix, StringComparison.Ordinal)); + return segment.TranslatePoint(default, scroll)!.Value.Y; + } + var before = TopOf("Paragraph number 40 "); + Assert.InRange(before, -300, 400); + var offsetBefore = scroll.Offset.Y; + + // 视口上方插入一大段:如果阅读位置不跟着走,画面会跳走 + var inserted = string.Concat(Enumerable.Repeat("An inserted paragraph that is long enough to take a few lines of the preview pane. ", 12)) + "\n\n"; + await Refresh(view, inserted + text); + await HeadlessApp.Settle(8); + + Assert.True(scroll.Offset.Y > offsetBefore); + Assert.InRange(TopOf("Paragraph number 40 "), before - 3, before + 3); + window.Close(); + }); + } + + [Fact] + public async Task RefreshMarkdown_ThrowsWhenTheContentComesFromADocument() + { + await HeadlessApp.Run(async () => + { + var view = new MarkdownView { Document = new Core.Streaming.StreamingDocument() }; + Assert.Throws(() => view.RefreshMarkdown("text")); + await Task.CompletedTask; + }); + } +} diff --git a/tests/StreamMark.Core.Tests/Streaming/SnapshotEditTests.cs b/tests/StreamMark.Core.Tests/Streaming/SnapshotEditTests.cs new file mode 100644 index 0000000..4ac059e --- /dev/null +++ b/tests/StreamMark.Core.Tests/Streaming/SnapshotEditTests.cs @@ -0,0 +1,220 @@ +using StreamMark.Core.Model; +using StreamMark.Core.Streaming; +using Xunit; + +namespace StreamMark.Core.Tests.Streaming; + +public sealed class SnapshotEditTests +{ + private static DocumentSnapshot Parse(string text) => new DocumentComposer().Finish(text, 1); + + private static List Apply(DocumentSnapshot previous, SnapshotEdit edit) + { + var list = previous.Blocks.ToList(); + foreach (var op in edit.Ops) + { + switch (op.Kind) + { + case DiffKind.Insert: + list.Insert(op.Index, op.Block!); + break; + case DiffKind.Update: + list[op.Index] = op.Block!; + break; + case DiffKind.Remove: + list.RemoveAt(op.Index); + break; + } + } + return list; + } + + private static void AssertSame(IReadOnlyList expected, IReadOnlyList actual, string context) + { + Assert.True(expected.Count == actual.Count, $"{context}: {expected.Count} blocks vs {actual.Count}"); + for (var i = 0; i < expected.Count; i++) + { + Assert.True(expected[i].GetType() == actual[i].GetType(), $"{context}: block {i} type"); + Assert.True(expected[i].Range == actual[i].Range, $"{context}: block {i} range {expected[i].Range} vs {actual[i].Range}"); + Assert.True(expected[i].ComputeFingerprint() == actual[i].ComputeFingerprint(), $"{context}: block {i} content"); + } + } + + private const string Base = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n\nThird paragraph.\n\nLast paragraph.\n"; + + [Fact] + public void IdenticalText_ProducesNoOps() + { + var edit = SnapshotEdit.Compute(Parse(Base), Parse(Base)); + Assert.Empty(edit.Ops); + Assert.All(edit.Moves, m => Assert.Equal(BlockFate.Unchanged, m.Fate)); + } + + [Fact] + public void EditingTheLastParagraph_TouchesOnlyThatBlock() + { + var before = Parse(Base); + var after = Parse(Base.Replace("Last paragraph.", "Changed last paragraph, now longer.")); + var edit = SnapshotEdit.Compute(before, after); + + var op = Assert.Single(edit.Ops); + Assert.Equal(new DiffOp(DiffKind.Update, 4, after[4]), op); + Assert.Equal([BlockFate.Unchanged, BlockFate.Unchanged, BlockFate.Unchanged, BlockFate.Unchanged, BlockFate.Replaced], edit.Moves.Select(m => m.Fate)); + // 没变的块没有任何操作,选区和状态留在原处 + Assert.Equal(before[1].Key, edit.MapKey(before[1].Key)); + Assert.Equal(after[4].Key, edit.MapKey(before[4].Key)); + // 被替换块内部的偏移不可信 + Assert.Null(edit.MapKey(before[4].Key + 3)); + } + + [Fact] + public void InsertingBeforeEverything_ShiftsTheRestInsteadOfRebuildingIt() + { + var before = Parse(Base); + var inserted = "Brand new opening paragraph.\n\n"; + var after = Parse(inserted + Base); + var edit = SnapshotEdit.Compute(before, after); + + // 一个插入 + 每个旧块一次原地更新;没有删除 + Assert.Equal(DiffKind.Insert, edit.Ops[0].Kind); + Assert.Equal(0, edit.Ops[0].Index); + Assert.Equal(before.Count, edit.Ops.Count(o => o.Kind == DiffKind.Update)); + Assert.DoesNotContain(edit.Ops, o => o.Kind == DiffKind.Remove); + Assert.All(edit.Moves, m => Assert.Equal(BlockFate.Shifted, m.Fate)); + AssertSame(after.Blocks.ToList(), Apply(before, edit), "insert at start"); + + // 块内任意偏移都按同一距离平移 + var second = before[2]; + Assert.Equal(second.Key + inserted.Length, edit.MapKey(second.Key)); + Assert.Equal(second.Key + 5 + inserted.Length, edit.MapKey(second.Key + 5)); + // 块与块之间的空隙不属于任何块 + Assert.Null(edit.MapKey(second.Range.End)); + } + + [Fact] + public void DeletingAMiddleParagraph_RemovesOnlyItAndShiftsTheRest() + { + var before = Parse(Base); + var after = Parse(Base.Replace("Second paragraph.\n\n", "")); + var edit = SnapshotEdit.Compute(before, after); + + Assert.Equal([BlockFate.Unchanged, BlockFate.Unchanged, BlockFate.Deleted, BlockFate.Shifted, BlockFate.Shifted], edit.Moves.Select(m => m.Fate)); + Assert.Equal(1, edit.Ops.Count(o => o.Kind == DiffKind.Remove)); + AssertSame(after.Blocks.ToList(), Apply(before, edit), "delete middle"); + + // 被删除的块没有映射;它的去向是后面第一个存活的块,选区可以退到那里 + Assert.Null(edit.MapKey(before[2].Key)); + Assert.Same(after[2], edit.Moves[2].New); + Assert.Equal(after[2].Key, edit.MapKey(before[3].Key)); + } + + [Fact] + public void DeletingTheLastParagraph_HasNoFallbackBlock() + { + var before = Parse(Base); + var after = Parse(Base.Replace("\nLast paragraph.\n", "")); + var edit = SnapshotEdit.Compute(before, after); + + Assert.Equal(BlockFate.Deleted, edit.Moves[^1].Fate); + Assert.Null(edit.Moves[^1].New); + AssertSame(after.Blocks.ToList(), Apply(before, edit), "delete last"); + } + + [Fact] + public void ReplacingOneParagraphWithTwo_PairsOneAndInsertsTheOther() + { + var before = Parse(Base); + var after = Parse(Base.Replace("Second paragraph.", "Second A.\n\nSecond B.")); + var edit = SnapshotEdit.Compute(before, after); + + Assert.Equal(BlockFate.Replaced, edit.Moves[2].Fate); + Assert.Contains(edit.Ops, o => o.Kind == DiffKind.Insert); + AssertSame(after.Blocks.ToList(), Apply(before, edit), "one to two"); + } + + [Fact] + public void StateInNestedBlocks_FollowsTheEnclosingShiftedBlock() + { + const string list = "- one\n- two\n\n```js\nlet a = 1;\n```\n"; + var before = Parse(list); + var after = Parse("Intro.\n\n" + list); + var edit = SnapshotEdit.Compute(before, after); + + var code = before.Blocks.OfType().Single(); + var item = ((ListBlock)before[0]).Items[1]; + Assert.Equal(code.Key + 8, edit.MapKey(code.Key)); + // 嵌套在列表里的块(它们的 Key 是各自的源码偏移)同样平移 + Assert.Equal(item.Key + 8, edit.MapKey(item.Key)); + } + + [Fact] + public void TwoSeparateEdits_KeepTheUntouchedBlocksBetweenThemPaired() + { + const string text = "Intro.\n\n```js\nlet a = 1;\n```\n\nMiddle.\n\n```js\nlet b = 2;\n```\n"; + var before = Parse(text); + // 开头增加一段,同时末尾的代码块被删除:前缀与后缀都对不上,中间的 Intro 与第一个代码块仍要配上 + var after = Parse("Brand new text.\n\n" + text[..text.IndexOf("Middle.", StringComparison.Ordinal)]); + var edit = SnapshotEdit.Compute(before, after); + + Assert.Equal([BlockFate.Shifted, BlockFate.Shifted, BlockFate.Deleted, BlockFate.Deleted], edit.Moves.Select(m => m.Fate)); + Assert.Same(after[1], edit.Moves[0].New); + Assert.Same(after[2], edit.Moves[1].New); + Assert.Null(edit.Moves[3].New); + AssertSame(after.Blocks.ToList(), Apply(before, edit), "two edits"); + } + + [Fact] + public void TwoSeparateEdits_InsertAtTopAndChangeAtBottom() + { + var before = Parse(Base); + var after = Parse("Opening line.\n\n" + Base.Replace("Last paragraph.", "A different ending.")); + var edit = SnapshotEdit.Compute(before, after); + + Assert.Equal([BlockFate.Shifted, BlockFate.Shifted, BlockFate.Shifted, BlockFate.Shifted, BlockFate.Replaced], edit.Moves.Select(m => m.Fate)); + AssertSame(after.Blocks.ToList(), Apply(before, edit), "top and bottom"); + } + + [Theory] + [InlineData("chat-mixed.md")] + [InlineData("lists-quotes.md")] + [InlineData("code-heavy.md")] + public void RandomEdits_AppliedOps_ReproduceTheNewDocument(string fixture) + { + var text = StreamingEquivalenceTests.Fixture(fixture); + var random = new Random(20261021); + string[] snippets = ["\n\nA new paragraph.\n\n", "x", "# Heading\n\n", "- item\n", "\n\n```\ncode\n```\n\n", "**bold** and [link](https://x.y)"]; + for (var round = 0; round < 80; round++) + { + var edited = text; + for (var edits = random.Next(1, 4); edits > 0; edits--) + { + var at = random.Next(0, edited.Length + 1); + edited = random.Next(3) == 0 && at < edited.Length + ? edited.Remove(at, Math.Min(random.Next(1, 240), edited.Length - at)) + : edited.Insert(at, snippets[random.Next(snippets.Length)]); + } + + var before = Parse(text); + var after = Parse(edited); + var edit = SnapshotEdit.Compute(before, after); + var context = $"{fixture} round {round}"; + + AssertSame(after.Blocks.ToList(), Apply(before, edit), context); + Assert.Equal(before.Count, edit.Moves.Count); + foreach (var move in edit.Moves) + { + if (move.Fate == BlockFate.Deleted) + { + Assert.Null(edit.MapKey(move.Old.Key)); + continue; + } + Assert.True(edit.MapKey(move.Old.Key) == move.New!.Key, $"{context}: key {move.Old.Key}"); + if (move.Fate is BlockFate.Unchanged or BlockFate.Shifted) + { + Assert.True(move.Old.ComputeFingerprint() == move.New.ComputeFingerprint(), $"{context}: content of {move.Old.Key}"); + Assert.Equal(move.Old.Range.Length, move.New.Range.Length); + } + } + } + } +} From 2e9d78c559b7a6b2678b808603f73d380b4d887e Mon Sep 17 00:00:00 2001 From: token <61819790+239573049@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:53:17 +0800 Subject: [PATCH 5/5] Document installing 0.1.0-alpha.24 Co-Authored-By: Claude Sonnet 5.5 --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 485064e..2d8ee5b 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,10 @@ Or in the project file: ```xml - - - - + + + + ```