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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .agents/codemap/modules/avalonia-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -70,6 +73,10 @@ 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`。
- **文档被整篇替换后选区的去向(`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`。
- `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`),双击/三击不会进入拖拽状态。
Expand Down
6 changes: 4 additions & 2 deletions .agents/codemap/modules/avalonia-text-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 行内公式、独立排版的文字对象(公式源码 / 图片替代文字)、行内图片(骨架 / 标签 / 缩放)、任务复选框 |
Expand Down Expand Up @@ -89,6 +89,8 @@ 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`。
- 中文粗体的回退字体必须按**本段字的字重 / 字形**自行解析 — 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`。
Expand Down
2 changes: 2 additions & 0 deletions .agents/codemap/modules/core-parsing-and-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down Expand Up @@ -88,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`);拿它当字典键会严重退化。
Expand Down
4 changes: 4 additions & 0 deletions .agents/codemap/modules/core-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 冻结边界扫描器 + 未闭合围栏代码块的快速路径探针 |
Expand Down Expand Up @@ -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`。
Expand Down
Loading
Loading