Skip to content

feat(code-viewer): native WinUI 3 code viewer replacing WebView2/VS Code embed#78

Merged
zhuowcui merged 21 commits into
mainfrom
agents/native-code-viewer-integration
May 8, 2026
Merged

feat(code-viewer): native WinUI 3 code viewer replacing WebView2/VS Code embed#78
zhuowcui merged 21 commits into
mainfrom
agents/native-code-viewer-integration

Conversation

@Xueyang-Song

@Xueyang-Song Xueyang-Song commented May 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Replaces the heavy WebView2/VS Code embed on the repo Code tab with a fully native WinUI 3 code viewer. Everything is rendered natively — no web views, no Electron.

Features

File Tree

  • Lazy-loaded hierarchical file tree (top-level only; directories expand on click to load their contents)
  • Debounced, off-thread search (150ms debounce + Task.Run flatten) — typing never blocks the UI
  • Horizontal scroll when file names are long
  • Resizable panel (GridSplitter between tree and code viewer)

Code Viewer (MicaEditor / WinUIEdit + Lexilla)

  • Syntax highlighting via Lexilla's native CreateLexer for 60+ languages
  • Language detected from file extension and filename (language-map.json with 350+ mappings)
  • Copy button and Open-on-GitHub button with visual feedback
  • Breadcrumb showing current file path
  • File content cached for a few minutes to avoid re-fetching on navigation

File Preview

  • Markdown — rich preview via CommunityToolkit MarkdownTextBlock with full app-color theming via MarkdownConfig.Themes (mutable brush instances — theme changes update in-place without re-rendering). Centered at 860px max-width. Images constrained to container width with correct resize-up/down behavior.
  • Images — BitmapImage for raster formats; SkiaSharp.Views.WinUI for SVG
  • Plain text / code — falls through to the MicaEditor
  • Unsupported formats — friendly message with link to open on GitHub

Design

  • Follows app color tokens (AppInkBrush, AppSurfaceBrush, AppAccentBrush, etc.)
  • No rounded corners on tree/editor panels
  • Dark and light mode fully supported throughout
  • Opens README automatically on first load if one exists

Quality & Reliability

Unit Tests (90 tests, all passing)

New JitHub.WinUI.Tests xUnit project with comprehensive coverage of the code viewer's pure logic layer:

Test Class Count What's Covered
FilePreviewResolverTests 43 Size guards, all image/markdown/csv/json/xml/yaml/svg routing, binary detection, hex vs unsupported, case insensitivity, language ID passthrough
LanguageIdResolverTests 27 Extension/filename/shebang resolution, case insensitivity, compound extensions, IsKnown(), fallback to plaintext
RepoFileCacheServiceTests 20 TryGet/GetAsync/PutAsync flows, memory LRU eviction (by count and bytes), disk cap enforcement, TTL expiry, purge, binary entries

Tests run in CI via .github/workflows/code-viewer-unit-tests.yml on every push/PR.

AOT Compatibility

  • Removed YamlDotNet (reflection-based deserializer, incompatible with PublishAot=True) — YAML now renders in the syntax-highlighted code editor
  • All JSON serialization uses [JsonSerializable] source-generated contexts
  • CI vscode asset sync steps removed from both CI workflows

Key Commits

Commit Description
95bbd90 Initial native code viewer — file tree, syntax highlighting, image/SVG/markdown renderers
af797ae Fix TreeViewNode DataContext cast (ContentControl wrapper)
325c4d5 Broad language support via direct Lexilla CreateLexer (60+ languages)
8ba666e UI polish — search, GridSplitter, README auto-open, copy feedback
b9384d9 GridSplitter polish, image constraint, Markdown theme colors
5cf88e8 Correct Markdown theming via MarkdownConfig.Themes API (not resource keys)
fcc0453 Mutable brushes for zero-cost theme switching; image walker restored
cf5c05c File tree search: UpdateSourceTrigger=PropertyChanged
e93ee65 Debounced off-thread file tree search
b85b1a2 Fix image MaxWidth growing back correctly after window widened
0c9a41d CI: remove VS Code editor asset sync from CI workflows
2f6c2e2 Fix: remove YamlDotNet to ensure AOT compatibility
1db19b9 Test: add comprehensive unit test suite (90 tests) + CI workflow

Xueyang-Song and others added 21 commits May 7, 2026 09:20
Replaces WebView2/VS Code embed in RepoCodePage with a fully native WinUI 3 code viewer.

Key changes:
- Add ScintillaLexerDatabase.cs: maps 50+ language IDs to WinUIEdit HighlightingLanguage or Lexilla-direct lexers via SCI_SETLEXERLANGUAGE. Covers Python, Bash, Ruby, SQL, CSS, PowerShell, Lua, R, VB.NET, Diff, Makefile, Dockerfile, TOML, INI, Batch, Markdown, Perl, and C-like languages (Java, Go, Rust, Swift, Kotlin, Scala, TypeScript, PHP, Dart, F#, Elixir, Haskell, CMake) with correct keyword sets and VS Code Dark+/Light+ token colors.
- Fix CodeEditorControl: ApplyLanguageId() uses database; ApplyFontSize() iterates all 128 styles without StyleClearAll() so token colors survive font changes; separate ApplyThemeColors() applies bg/fg/linenumber overrides independently.
- Fix LanguageIdResolver: fallback changed from 'text' to 'plaintext' (valid WinUIEdit ID).
- Fix tree expansion: switch RepoFileTreeView from ItemsSource-binding mode to TreeViewNode mode. Nodes are populated lazily in OnExpanding; HasUnrealizedChildren shows chevrons; no more E_NOINTERFACE crash.
- Remove EditorAssetService, sync-vscode-assets.ps1, download-vsocde.ps1, eng/Sync-JitHubVsCodeAssets.ps1.
- Add full renderer suite: CodePreview, MarkdownPreview, CsvPreview, JsonPreview, XmlPreview, YamlPreview, ImagePreview, SvgPreview, HexPreview, UnsupportedPreview.
- Add LRU+disk cache (RepoFileCacheService), file preview resolver, repo tree service.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… DataContext cast

In TreeViewNode mode the outer DataTemplate's DataContext is TreeViewNode,
not the VM. Wrapping with a ContentControl and binding Content={Binding Content}
routes the typed inner DataTemplate to RepoTreeNodeViewModel, fixing the
WinRT.IInspectable cast exception at runtime.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace broken SCI_SETLEXERLANGUAGE (4006) path with P/Invoke into WinUIEditor.dll's exported Lexilla CreateLexer + Editor.SetILexer (4033). The deprecated message id was a no-op, which is why PowerShell, Bash, Batch, SQL, etc. never highlighted.

Vastly expand ScintillaLexerDatabase: add asm, tcl, latex, vim, pascal/delphi, erlang, lisp/clojure/scheme, julia, nim, crystal, solidity, graphql, protobuf, hcl/terraform, ada, fortran, ocaml, matlab/octave, smalltalk, verilog/systemverilog, vhdl, nginx, actionscript, groovy/gradle, razor/cshtml, jsx/tsx with React-aware keywords, plus a richer JS keyword set.

Expand language-map.json: filenames NuGet.Config / nuget.config / App.config / Web.config / packages.config / Directory.Build.props / global.json / appsettings*.json / Jenkinsfile / WORKSPACE / BUILD.bazel / project.clj / mix.exs etc. New extensions .slnx, .axaml, .runsettings, .resw, .razor, .cshtml, .pas, .f90, .ada, .sol, .tfvars, .bicep, .pug, .liquid, .astro, .coffee, .feature and many more.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…pen, copy feedback

- Wire file tree search: FilteredRootNodes now drives the TreeView when
  FilterText is non-empty (flat results); full tree shown when cleared
- Add resizable panels via CommunityToolkit GridSplitter between tree
  and code pane; add Sizers 8.2.250402 package reference
- Enable horizontal scrollbar on TreeView (ScrollViewer attached props)
- Remove rounded corners from CodeEditorControl and RepoFileTreeView
- Copy-path / copy-raw-URL buttons show checkmark for 1.5s then revert
- Markdown: disable horizontal scroll, HorizontalAlignment=Stretch on
  MarkdownTextBlock to cap image width to viewport
- Markdown: local theme resource overrides for inline-code and link colors
- Auto-open README (md/rst/txt/adoc) when entering the code view

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… theme colors

- GridSplitter: transparent background, Width=4, PreviousAndNext resize behavior; visual divider is the tree panel's right border
- Markdown: correct CT MarkdownTextBlock resource keys (InlineCodeBackground/Foreground, CodeBlockBackground/Foreground, HyperlinkButtonForeground) scoped per theme
- Markdown: set Foreground=AppInkBrush directly on MarkdownTextBlock so headers inherit the correct ink color in both themes
- Markdown images: add RichPanel SizeChanged handler that sets MaxWidth on MarkdownTextBlock and walks the visual tree to set Stretch.Uniform + MaxWidth on all Image elements

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tterboxing

- MarkdownTextBlock: MaxWidth=860, HorizontalAlignment=Center — content reads like a web page
- Removed forced Stretch.Uniform on images; only MaxWidth is set so images scale down in width
  while their height follows naturally (no top/bottom empty space letterboxing)
- SizeChanged handler caps image MaxWidth at Min(viewport-32, 860)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…terboxing

Renderer sets a hard-coded Height on Image elements based on natural dimensions.
Setting Height=NaN (Auto) + Stretch.Uniform lets the height reflow from the
constrained MaxWidth, eliminating the empty space above/below images.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Root cause: images are rendered async by MarkdownTextBlock — SizeChanged fires before
they exist in the visual tree, so the walk was a no-op.

Fix:
- Subscribe to RichMarkdown.LayoutUpdated to re-walk after every layout pass
- Also clear Height on FrameworkElement wrappers that directly contain images
- Unified into ApplyImageConstraints() helper using RichPanel.ActualWidth

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…wn colors

- Add Default theme dictionary mirroring Dark — WinUI falls back to Default in some
  dark-mode scenarios when neither Light nor Dark key is matched by the lookup
- Slightly increase InlineCode/CodeBlock background contrast in dark mode
  (#303830 instead of #2A312B) so code spans are distinguishable from the canvas

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ection for Markdown

ThemeDictionaries are unreliable for CT MarkdownTextBlock — the control resolves
resource keys from its own scope in ways that make Default/Light/Dark keys fight.

New approach: set resource values directly in UserControl.Resources from code
on Loaded and ActualThemeChanged, so the correct colors are always injected for
the current actual theme with no ambiguity.

Colors set: InlineCodeBackground/Foreground, CodeBlockBackground/Foreground,
HyperlinkButtonForeground — for both light and dark themes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… correctly

ThemeResource lookups ignore the plain Resources bag — they only resolve from ThemeDictionaries. Fixed by calling InjectThemeDictionaries() at construction, which writes brushes directly into Resources.ThemeDictionaries Light, Dark, and Default entries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ault theme

Assigning the same ResourceDictionary to both Dark and Default slots throws
COMException 0x800F1000 (Element is already the child of another element).
Each ThemeDictionaries entry must be a distinct instance.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… colors

Replace ThemeDictionaries resource injection (which MarkdownTextBlock ignores)
with MarkdownConfig / MarkdownThemes — the actual API the control uses.

MarkdownThemes has direct Brush properties (H1-H6Foreground, InlineCode*,
CodeBlock*, LinkForeground, ImageMaxWidth, etc.) that are read at render
time, not via ThemeResource lookups.  Building a fresh MarkdownConfig in
BuildConfig() for the current ActualTheme and setting it before assigning
Text guarantees the very first render uses the right colors.  On
ActualThemeChanged we rebuild the config and null/restore Text to force a
full re-render.

Also removes the visual-tree-walker image constraint hack — MarkdownThemes
exposes ImageMaxWidth / ImageStretch natively, so setting ImageMaxWidth=860
and ImageStretch=Uniform is sufficient.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…nge re-render

Two improvements to MarkdownPreview:

1. Images being cut off — MarkdownThemes.ImageMaxWidth is a fixed cap at
   config-build time; it cannot respond to a container narrower than 860px.
   Restore the LayoutUpdated visual-tree-walker to clamp img.MaxWidth to
   the live panel width and clear any fixed Height the renderer sets
   (fixed Height + Stretch.Uniform causes letterboxing).

2. Theme-change re-render eliminated — instead of rebuilding MarkdownConfig
   and re-assigning Text on every ActualThemeChanged, create one set of
   SolidColorBrush fields at startup and reference them in MarkdownThemes.
   On theme change, only their .Color property is mutated; WinUI propagates
   the color update to all rendered elements without a re-render.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The FilterText TextBox binding was missing UpdateSourceTrigger=PropertyChanged,
so the TwoWay x:Bind only wrote back on focus-lost. Adding it causes FilterText
to update on every keystroke, which immediately triggers OnFilterTextChanged
in the VM and RebuildTreeView in the code-behind.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace synchronous per-keystroke RebuildFilter() with an async, debounced
pipeline that never blocks user input:

- 150ms debounce via Task.Delay — typing faster than 150ms/char coalesces
  into a single filter run; previous in-flight searches are cancelled via
  CancellationTokenSource (Interlocked.Exchange pattern).
- FlattenLeaves runs on a ThreadPool thread via Task.Run so the recursive
  tree walk never touches the UI thread.
- Results are applied back on the UI thread automatically because 'await'
  captures the WinUI SynchronizationContext when started from the UI thread.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…idened

The previous logic kept img.MaxWidth at the shrunken container value because
'img.MaxWidth < maxW' was always true after a shrink — it never allowed the
image to grow back.

Fix: on the first LayoutUpdated visit to each Image, save the renderer's
natural MaxWidth into img.Tag. Every subsequent visit computes
min(naturalMax, containerWidth) from that saved value, so:
- shrinking: clamps to the narrower container
- growing: restores up to the natural image size (or 860px cap)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The jithub-vs-code repo, Sync-JitHubVsCodeAssets.ps1, and associated scripts
are no longer part of the build now that the native code viewer replaces the
WebView2/VS Code embed.

- winapp-cli-smoke.yml: remove jithub-vs-code checkout, Node.js setup, yarn
  install, and Build editor assets step. Remove editor_assets_ref input and
  simplify verify_debug_build description.
- jithub-store-release.yml: remove jithub-vs-code checkout, Node.js setup,
  yarn install, Build editor assets step, and editor_assets_ref input.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
YamlDotNet's DeserializerBuilder/SerializerBuilder uses reflection-based
type discovery which is incompatible with PublishAot=True (Release builds).

Replace the YamlPreview rich/plain toggle with a simple syntax-highlighted
code view (using the existing CodeEditorControl with LanguageId=yaml).
YAML is already human-readable so normalization adds little value.

Remove the YamlDotNet package reference from the csproj entirely.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…services

- Add JitHub.WinUI.Tests xUnit project with 90 tests covering FilePreviewResolver,
  LanguageIdResolver, and RepoFileCacheService via source file linking
- FilePreviewResolverTests: 43 cases - size guards, all image/markdown/csv/json/xml/
  yaml/svg routing, binary detection, hex vs unsupported, case insensitivity, language id
- LanguageIdResolverTests: 27 cases - extension/filename/shebang resolution,
  case insensitivity, compound extensions, IsKnown(), fallback to plaintext
- RepoFileCacheServiceTests: 20 cases - TryGet/GetAsync/PutAsync flows, memory LRU
  eviction (by count and bytes), disk cap enforcement, TTL expiry, purge, binary entries
- Add internal testable constructors to LanguageIdResolver and RepoFileCacheService
  to bypass ApplicationData.Current and JSON file loading in test context
- Add CI workflow (.github/workflows/code-viewer-unit-tests.yml) that runs on push/PR
  and collects trx results + code coverage via coverlet

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Map ARM/Any CPU solution platforms to the project's actual configurations
(x86/x64/ARM64) to prevent the 'configuration does not exist' warning.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@zhuowcui zhuowcui merged commit 6793a31 into main May 8, 2026
2 checks passed
zhuowcui pushed a commit that referenced this pull request May 13, 2026
* Add native WinUI 3 MarkdownRenderer control (#78)

Introduces a fully-native markdown rendering control stack built on Markdig + Win2D (DirectWrite), replacing the CommunityToolkit MarkdownTextBlock.

Projects added to JitHub.slnx:
- MarkdownRenderer: core library (parser, flow layout engine, Win2D painter, theming, selection, accessibility)
- MarkdownRenderer.Gfm: optional GFM extension package (tables, task lists, alerts, footnotes)
- MarkdownRenderer.Sample: WinUI 3 sample/demo app
- MarkdownRenderer.Tests: 95 xUnit tests (layout, parsing, GFM integration, perf benchmarks)

Key design decisions:
- Off-thread parse+layout with atomic LayoutSnapshot publish; UI thread only schedules repaints
- ThemeSnapshot captures all resolved ElementStyle values on UI thread before Task.Run to avoid cross-thread DP access
- AOT-safe renderer dispatch via IMarkdownNodeRendererErased (no reflection/GetMethod)
- volatile _snapshot field for safe cross-thread observation
- Win11 typography defaults with live light/dark theme switching (repaint-only, no re-parse)
- DOM-style text selection with source-accurate markdown clipboard copy via MarkdownSourceMap
- IMarkdownEmbedFactory hook for hosting arbitrary WinUI controls inline or as blocks
- MarkdownAutomationPeer wired up with AutomationControlType.Document

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix CanvasDevice-not-associated crash on first render

CanvasVirtualControl only has a device after entering the visual tree and
CreateResources fires. Passing it as ICanvasResourceCreator to MarkdownLayoutContext
caused CanvasTextLayout creation to fail on the Task.Run background thread before
the first draw event.

Fix: use CanvasDevice.GetSharedDevice() as the resource creator for layout.
The shared device is always available and is the same underlying device that
CanvasVirtualControl uses, so DrawTextLayout on the draw session is compatible.

Also fix stale XML doc cref in LayoutSnapshot.cs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix list item layout bug + expand sample app

Bug fix: ListItemBox — bullet and text were on separate lines
The old BuildDefaultListItem placed the marker in a vertical StackBox on top of
the content, causing bullet and text to stack. Introduced ListItemBox, a new
two-column layout box that measures and arranges a fixed-width marker gutter
(28px) side by side with a variable-width content StackBox.

Updated TaskListItemRenderer to use ListItemBox with the same pattern so
checkbox items render inline with their text.

Sample app improvements:
- Toolbar with 7 sample tabs: Typography, Lists, Tables, Code, GFM Alerts,
  Selection, Full Demo
- Light/Dark theme toggle button
- ScrollViewer wrapping the renderer panel
- Markdown editor on the left with auto-scroll
- Comprehensive sample content covering every feature: headings, inline
  formatting, blockquotes, ordered/unordered/task lists with nesting, tables,
  fenced code blocks, GFM alerts (all 5 types), footnotes, thematic breaks,
  links, and selection/copy instructions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: visual bugs — headings, strikethrough, theme switching, list layout, footnotes, selection

- ThemeResolver: ResolveBrush now looks up App.ThemeDictionaries[Dark|Light] based on
  host.ActualTheme so light/dark switching re-styles without reloading the control
- ThemeResolver.GetDefault: explicit Body key with bottom-only margin (0,0,0,8) so list
  item bullets vertically align with their text; Win11 heading sizes updated (32/26/22/18/15/14);
  all styles carry Segoe UI Variable/Emoji/Symbol font family; dark-aware fallback colors
- InlineRun.ElementKey default changed from 'Body' to string.Empty (inherit container)
  so plain TextRun inside a heading inherits heading font size/weight
- ElementStyle: default FontFamily includes emoji/symbol fallback fonts
- InlineContainerBox.ApplyRunStyles: delta-only — skip when ElementKey empty, never
  override FontSize; prevents Body style from clobbering heading typography
- InlineContainerBox.Measure: switch to CanvasLineSpacingMode.Default (font-native metrics)
  fixes inflated line boxes that pushed underline/strikethrough out of position and caused
  oversized inline-code backgrounds
- InlineContainerBox.DrawDecorations: fixed y-positions (82% for underline, 45% for
  strikethrough); use container style when run.ElementKey is empty
- LayoutBuilder.BuildEmphasis: check DelimiterChar == '~' for strikethrough so ~~text~~
  correctly creates StrikethroughRun instead of StrongRun
- LayoutBuilder.BuildLink: added IsImage branch rendering alt text for images
- LayoutBuilder.BuildInline: added FootnoteLink case producing superscript reference numbers
- ListItemBox: expose Marker and Content as public properties for SelectionController
- LayoutBuilder: reduce list marker gutter from 28 to 22 px
- SelectionController.EnumerateBlockRects: handle ListItemBox by traversing Marker+Content
- MarkdownRendererControl.FindLinkInBlock: handle ListItemBox case
- FootnoteRenderer: use ListItemBox (marker | content) for proper horizontal alignment
- GfmChildBuilder: remove explicit Body ElementKey from TextRuns, fix strikethrough check

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: theme switching, emoji, doubled code bg, table text selection

- ThemeResolver: use hardcoded isDark colors (no XAML resource lookup) so
  ActualThemeChanged correctly drives light/dark re-render; remove step-3
  global app resource fallback in ResolveBrush
- Font families changed to single DirectWrite-compatible names ('Segoe UI
  Variable', 'Consolas') — comma-separated stacks are not supported by
  IDWriteTextFormat; system font fallback handles emoji ranges automatically
- InlineContainerBox.DrawDecorations: only draw per-run background when run
  ElementKey differs from container key — prevents doubled background on
  code blocks (container Paint() already draws the block-level background)
- LayoutBuilder.BuildCodeBlock: remove explicit ElementKey from TextRun so
  it inherits the container CodeBlock style cleanly
- TableBox: full rewrite using InlineContainerBox[][] per cell — enables
  hit-testing, selection rects, and source-accurate copy for table content
- TableRenderer: create InlineContainerBox per cell via GfmChildBuilder
- SelectionController.EnumerateBlockRects: add TableBox case
- MarkdownRendererControl.FindLinkInBlock: add TableBox case

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: WinUI control hosting, real images, real task-list checkboxes, link hover

Implements the six remaining feature/bug-fix items:

1. WinUI control hosting (block + inline) — IMarkdownEmbedFactory now has a
   thread-safe MeasureHeight (background) + UI-thread CreateBlock contract.
   New EmbedBox reserves block space; new InlineEmbedRun reserves inline space
   via U+FFFC + SetCharacterSpacing. After layout, MarkdownRendererControl
   walks the box tree on the UI thread and realizes FrameworkElements onto
   the overlay Canvas.
2. Images — new ImageBox loads via CanvasBitmap.LoadAsync with a per-URL
   bitmap cache. Image-only paragraphs are detected in LayoutBuilder and
   promoted to ImageBox. Re-layout fires when bitmaps decode.
3. Color emoji — CanvasTextLayout.Options = EnableColorFont so DirectWrite
   uses the COLR/CPAL color path for Segoe UI Emoji.
4. GFM task lists — TaskListItemRenderer now produces a real
   Microsoft.UI.Xaml.Controls.CheckBox via InlineEmbedRun instead of ☑/☐.
5. Strikethrough centering — uses CanvasLineMetrics.Baseline to position the
   strike at baseline - xHeight/2 instead of a fixed line fraction.
6. Link hover — OnPointerMoved hit-tests inline runs and sets HoveredRun on
   the owning InlineContainerBox; ApplyHoverColor brightens the run's
   foreground; ProtectedCursor switches to Hand over LinkRun.

Sample app gains Images and Embeds tabs; Embeds tab demonstrates a
SampleEmbedFactory that intercepts \\\�utton:Label fences and renders real
WinUI Buttons with click handlers.

All three projects build clean with 0 errors / 0 warnings on x64.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: image selection, real task-list checkboxes, link unhover, selection vibration

- Image/embed blocks now return whole-bounds rects from a new BlockBox.GetSelectionRects virtual, so dragging across images highlights and Ctrl+C copies the original markdown image syntax (source span registered).
- Detect GFM TaskList correctly: it is a LeafInline injected as the first inline of the first ParagraphBlock — not block-level data — so the renderer now walks into the paragraph and skips the marker inline when emitting body content. CheckBox embed now actually appears.
- ApplyHoverColor resets every LinkRun back to its theme foreground before brightening the hovered run, so the link color reverts when the pointer leaves.
- ProtectedCursor is only re-assigned when the hovered run actually changes; this stops the per-frame cursor reset that was causing selected text to vibrate vertically during a drag.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: prevent OperationCanceledException crash on rapid rebuild

Disposing the CancellationTokenSource immediately after Cancel() was racy: the background Task.Run lambda could still be accessing the token after disposal, causing ObjectDisposedException (not OCE) to escape the catch blocks and surface as an unhandled exception dialog.

Changes:
- RequestRebuild no longer calls Dispose() on the old CTS inline; instead the CTS is disposed by a ContinueWith callback after the task it owned finishes.
- RebuildAsync is split into a public shell that swallows OCE/unexpected exceptions and a private RebuildInternalAsync that propagates them cleanly via ThrowIfCancellationRequested at each checkpoint — no more duplicate try/catch pairs.
- OnUnloaded cancels and disposes the current CTS as part of teardown.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: eliminate selection vibration by guarding SetColor behind dirty flag

CanvasTextLayout.SetColor (IDWriteTextLayout::SetDrawingEffect) was called
on every paint frame when processing link hover, even when no hover was
active.  This forced DirectWrite to re-validate cached glyph-run metrics
on every frame, causing GetCharacterRegions to return slightly different
Y values each frame — visible as vertical text vibration during drag-select.

Fix: introduce _hoverColorsDirty flag on InlineContainerBox.
- ApplyHoverColor only runs when the flag is set, then clears it.
- Flag is set to true on construction, after Measure creates a new layout,
  and in the HoveredRun setter only when the value actually changes.
- ClearHover via the HoveredRun = null property path correctly triggers
  the flag so the reset (un-highlight) still runs once.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* build: lock trim/AOT warnings as errors in renderer libraries

Both MarkdownRenderer and MarkdownRenderer.Gfm already had
IsAotCompatible=true (which enables the IL2xxx/IL3xxx analyzers).
Make the contract permanent by promoting all trim, single-file, and
AOT-analyzer warnings to build errors via WarningsAsErrors.

Verified: clean Release build (0 warnings, 0 errors) on x64.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: assorted logic & threading bugs surfaced by multi-agent review

Triggered by parallel reviews from Opus-4.7, GPT-5.5 and GPT-5.3-Codex.
Each item below was flagged by at least one reviewer.

Hit-test correctness
- InlineContainerBox.HitTest: change boundary predicate from <= to <  so
  HitTest and RunAt agree on which run owns the boundary character.
- StackBox.HitTest: return false on padding-area hits instead of
  fabricating a position with the StackBox's own BlockIndex (no
  source-map entry exists for the StackBox itself).
- LayoutSnapshot.HitTest: use 'continue' instead of 'break' when a
  block's bounds top is below the hit Y so out-of-vertical-order
  blocks (custom renderers, footnote groups) still hit-test correctly.

Image lifecycle
- ImageBox: do not honour cached failure entries; let next instance
  retry.  Stop writing nulls into the static cache on failure.
- MarkdownRendererControl.OnImageLoadCompleted: always marshal to the
  UI thread via DispatcherQueue.TryEnqueue.  The async load completes
  on the thread pool; the previous else-branch could re-enter
  RequestRebuild off-thread and mutate UI-only state.

Pointer / hover
- Wire PointerExited, PointerCanceled and PointerCaptureLost to clear
  hover state and reset the cursor when the pointer leaves the canvas.

Layout / source-map
- LayoutBuilder.BuildList: parse Markdig's ListBlock.OrderedStart so
  '5. item' renders with marker '5.' rather than always '1.'.
- LayoutBuilder.BuildBlock: register a source-map entry for EmbedBox
  so Ctrl+C across an embed copies its original markdown.
- AlertRenderer: compute alert body source span by skipping past
  '[!TAG]' and trailing whitespace in the original source text
  instead of inheriting the full paragraph span (which included the
  tag and produced incorrect partial-selection copy).

Build verified: 0 errors, 0 warnings (Debug, x64).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: dispose layout snapshots, text layouts and image placeholders

Multi-agent review (Opus + GPT-5.3-Codex) flagged that
CanvasTextLayout, CanvasBitmap placeholder layouts and the entire
LayoutSnapshot tree were never disposed.  On heavy edits the renderer
leaked native handles for every superseded snapshot.

Changes
- BlockBox: add virtual Dispose() so concrete boxes can release
  native resources when a snapshot is replaced.
- InlineContainerBox: dispose cached CanvasTextLayout.
- ImageBox: dispose placeholder CanvasTextLayout. Cached CanvasBitmap
  remains owned by the static cache (shared across boxes) and is
  intentionally not disposed here.
- StackBox / ListItemBox / TableBox: recurse into children.
- LayoutSnapshot: implement IDisposable, recursively disposing every
  block.
- MarkdownRendererControl.RebuildInternalAsync: capture old snapshot,
  publish the new one, then dispose the old one.
- MarkdownRendererControl.OnUnloaded: clear the embed overlay and
  dispose the current snapshot so re-attaching the control to a new
  parent doesn't leak DirectWrite layouts or stale FrameworkElements.

Build verified: 0 errors, 0 warnings (Debug, x64).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(theming): proper override semantics, ElementStyleOverride, Theme.Changed wiring

Reviewers (GPT-5.5, GPT-5.3-Codex) flagged that ElementStyle overrides
could not selectively unset properties:
* Margin / Padding always replaced defaults wholesale; an override
  with a single non-default field clobbered the resolver's spacing.
* Underline / Strikethrough were OR-ed against defaults, so an
  override could only enable, never disable (e.g. could not strip
  the default underline from links).
* MarkdownTheme exposed a 'Changed' event the control never
  subscribed to, so external mutations silently failed to repaint.
* EmbedFactory dependency-property changes did not trigger a rebuild.

Changes
- New ElementStyleOverride type with all-nullable fields. Consumers
  now express partial overrides naturally; unset fields fall through
  to defaults.  ElementStyle (resolved style consumed by the layout
  engine) keeps its non-nullable shape.
- MarkdownTheme.Overrides retyped to
  IDictionary<string, ElementStyleOverride>.
- ThemeResolver.GetEffectiveStyle merges with '??' across every
  field, including Margin/Padding/Underline/Strikethrough.
- MarkdownRendererControl: Theme DP changed callback now
  subscribes/unsubscribes to MarkdownTheme.Changed and rebuilds on
  external mutations.  Subscription is cleared in OnUnloaded so the
  control is not pinned by a long-lived theme instance.
- EmbedFactory DP changed callback now calls RequestRebuild so
  swapping factories at runtime takes effect immediately.

Build verified: 0 errors, 0 warnings (Debug, x64).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(control): re-subscribe to Theme.Changed in OnLoadedInternal

Re-review caught a lifecycle bug introduced by 0cb263d: OnUnloaded
unhooks the Theme.Changed handler, but if the control is later
re-attached without the Theme DP being reassigned (TabView/ListView
recycling, navigation-hide-then-show), the subscription was never
restored and external mutations like theme.Invalidate() silently
stopped repainting.

OnLoadedInternal now re-binds the handler with an unsubscribe-first
pattern for idempotency in case Loaded fires without a prior Unload.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(sourcemap): slice original markdown verbatim across selection

Previous implementation walked entries one at a time, synthesizing
'\n\n' between distinct BlockIndex values.  Each table cell has its
own BlockIndex, so selections that crossed cells in a single row
copied as a column with double-newline separators instead of the
original '| cell1 | cell2 |' markdown.

New implementation finds the first and last entries overlapping the
selection, projects each end-offset back to a source-text byte
position, and returns the verbatim slice between them.  This
preserves every delimiter that already exists in the original source
(table pipes, list newlines, blockquote markers, footnote refs)
without us having to reconstruct them from rendered structure.

Trade-off: selections that cross hidden source regions (extension-
stripped tags) will now include those bytes too — which is what
'copy as markdown' should do anyway.

Build verified: 0 errors, 0 warnings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: suppress hover updates during selection drag to stop link-induced shake

User report: selection vibration was still visible — but only on text
that contained inline links.  Root cause:

OnPointerMoved updates two things on every move event:
1. selection range (when _selectionAnchor is set), and
2. hover state (HoveredRun on the InlineContainerBox under cursor).

While dragging across body text that includes a link, the pointer
crosses the link↔body run boundary on essentially every move event.
HoveredRun therefore toggles each move, dirtying _hoverColorsDirty,
which forces ApplyHoverColor to call SetColor on the cached
CanvasTextLayout each frame.  SetColor invalidates DirectWrite's
internal glyph metrics cache, which re-rounds vertical baselines
sub-pixel — visible as the text 'vibrating' beneath the selection
highlight.

Fix: short-circuit OnPointerMoved after the selection-extend branch
when _selectionAnchor is non-null.  No hover/cursor changes run
mid-drag.  When the user releases (_selectionAnchor cleared in
OnPointerReleased), the next move resumes normal hover behaviour, so
links still light up correctly outside of an active drag.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: silence first-chance OCE on rapid parse cancel

MarkdigParser.ParseAsync was throwing OperationCanceledException from inside the Task.Run lambda whenever a rebuild was superseded mid-parse. Although RebuildAsync catches the OCE, the throw still appeared in the debugger as a first-chance exception originating at MarkdigParser.cs:22, which the user was hitting on the image example page.

Replace ThrowIfCancellationRequested with cooperative IsCancellationRequested checks: bail with Task.FromCanceled before scheduling, and return null inside the lambda when cancellation has already been observed. The post-await ct.ThrowIfCancellationRequested() in RebuildInternalAsync still drives the cancellation flow, but the throw now happens at the call site (caught by RebuildAsync) rather than on a thread-pool stack.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* diag: add async ShakeLogger to investigate selection vibration

Adds MarkdownRenderer.Diagnostics.ShakeLogger — a lock-free producer / single-background-task drain that flushes via Debug.WriteLine. Hooked at the four sites that matter for the shake bug:

- OnRegionsInvalidated: per-frame counter + per-region rect + frame-end summary (regions, hovered run type, dragging flag).
- InlineContainerBox.Paint: x/y and CanvasTextLayout.LayoutBounds for every inline run paint.
- SelectionController.PaintHighlight: first selection rect coordinates per frame.
- OnPointerMoved (drag branch): pointer position + resolved DocumentPosition.

Enabled from MarkdownRenderer.Sample.App.OnLaunched. The drain task batches up to 256 entries every 50ms and never blocks producers, so the instrumentation does not perturb the selection-drag timing we are trying to characterise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: eliminate selection-drag text vibration via grayscale AA

Diagnostics added in dcddf40 captured per-frame paint coordinates during a selection drag and proved that text positions are pixel-perfect identical across every frame (e.g. block 2 always renders at y=144.4258, block 0 at y=16.0000). The text was never actually moving.

The perceived 'vibration' was a ClearType artefact. ClearType is colour-aware: the same glyph rendered onto a white background and onto an alpha-blended selection-tinted background produces subtly different sub-pixel RGB values. As a selection drag sweeps the highlight edge through a glyph, the background tint under that glyph alternates per frame and ClearType re-tunes the glyph, which reads to the eye as a sub-pixel shake.

Force CanvasTextAntialiasing.Grayscale on the drawing session before painting the snapshot. Glyph edges are now background-independent, so the alpha-blended selection highlight no longer perturbs glyph rendering. Paint coordinates were already stable, so this is a one-line fix.

Also: turn the ShakeLogger off by default in the sample app — keep the infrastructure available behind a single Enabled flag for future regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: stop hover-only text shake by gating invalidate/cursor on actual change

OnPointerMoved was calling Canvas.Invalidate() and reassigning ProtectedCursor on every transition between any two inline runs (including TextRun -> TextRun within the same paragraph as the pointer crossed character or line boundaries). Each ProtectedCursor reassignment plus full canvas invalidate per pointer-move event nudged the visual host by a sub-pixel, producing visible text 'shake' on plain hover with no selection drag active.

Now we track the hovered LINK identity (not arbitrary run identity) and only invalidate when that changes, and only set ProtectedCursor when its shape would actually flip Hand<->IBeam. Hover transitions between two non-link runs are a no-op.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: snap inline text origin to integer pixels to stop selection shake

Inline text was painted at fractional sub-pixel y values (e.g. y=144.4258). CanvasVirtualControl invalidates dirty regions on tile-aligned bounds, so as a selection-drag extends a highlight rect across the text, the dirty rect's shape changes per frame and DirectWrite re-rasterises glyphs straddling those tile boundaries at slightly different sub-pixel offsets — producing the residual selection-drag 'shake' the user reported even after hover-shake was eliminated.

Introduce InlineContainerBox.GetSnappedOrigin which rounds the layout origin to integer pixels, and route Paint, HitTest, RunAt, GetRangeRects and EnumerateEmbedRects through it so all five stay in sync. Glyph rasterisation is now position-stable across frames regardless of which dirty region is being repainted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: route pointer events to embedded WinUI elements (Model B)

Embedded inline WinUI elements (buttons, textboxes, checkboxes, etc.) now own pointer interactions when the pointer is directly over them, while still being included as atomic units in selection drags that pass through them. This matches how browsers treat <input>/<textarea> inside contenteditable text.

Specifically:

* On PointerPressed *over* an embed: do not start a selection and do not capture the pointer. XAML pointer routing delivers the click to the embedded element so its own click/focus handlers run. Any prior selection is cleared.

* On PointerMoved *hovering* an embed (no drag): do nothing. We do not override ProtectedCursor, so the embed's own cursor (Hand for Button, IBeam for TextBox, Arrow for Slider, …) takes effect via XAML routing. Stale link-hover state on our text is cleared.

* On PointerMoved *during a drag* through an embed: snap the pointer's DocumentPosition to either the start or end of the InlineEmbedRun based on which half of the rect the pointer is in. The embed is included in the selection as a single, indivisible unit — the user can never have a selection that ends halfway through an embedded button or textbox.

Implementation: cache (InlineContainerBox, InlineEmbedRun, Rect) tuples during PlaceEmbeds; new IsPointOverEmbed and TryHitTestEmbed helpers consult that cache from the pointer handlers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: reset ProtectedCursor to null when pointer leaves text, fixes IBeam over embeds

Root cause: PointerExited on _canvas fires whenever the pointer moves from the canvas to any sibling element, including the _overlay that hosts embedded WinUI elements (Button, CheckBox, etc.). The old code set ProtectedCursor = IBeam on PointerExited. WinUI cursor resolution walks up from the hit-tested element; if the embed has no ProtectedCursor of its own, it inherits IBeam from the UserControl parent — so IBeam appeared over all embedded buttons.

Fix: replace the two-state _cursorIsHand bool with a nullable InputSystemCursorShape? tracking three states: null (system default), IBeam (over text), Hand (over link). Setting ProtectedCursor = null removes the UserControl override, allowing XAML to walk up to the system default (Arrow), which lets each embed element use its own cursor without conflict.

SetCursorShape(null) is now called from: OnPointerExited (always, not just when a link was hovered), the IsPointOverEmbed early-return branch in OnPointerMoved, and from the new SetCursorShape helper which no-ops if the shape has not changed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: snap selection-highlight rects to integer pixels with square corners

Sub-pixel rect edges combined with rounded corners produced per-frame anti-aliasing variation around selection corners during a drag. Neighbouring glyph dirty rects then re-rasterised at slightly different sub-pixel offsets, manifesting as text shake on the embed page (and elsewhere with mixed inline content).

Floor/Ceiling the rect to integer DIPs and use FillRectangle with square corners (browser-style).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: move selection highlight to XAML overlay, eliminate canvas tile repaint during drag

Root cause: _canvas.Invalidate() was called on every PointerMoved during a selection drag. CanvasVirtualControl re-rasterised all visible tiles each frame; DirectWrite glyph positions depend on the sub-pixel offset of each tile's origin, which can vary slightly as the dirty-rect shape changes frame-to-frame. This produced the persistent 'selection shake' despite earlier integer-pixel snapping fixes.

Fix: render the selection highlight as XAML Rectangle elements on the existing _overlay Canvas instead of painting it onto the DirectWrite surface. Consequences:
- No _canvas.Invalidate() needed during drag — DirectWrite tiles are never touched.
- _selection.Changed → UpdateSelectionOverlay() replaces the rectangles; XAML compositor handles the visual update with zero glyph rasterisation.
- Ctrl+A and PointerPressed selection-start no longer invalidate the canvas for selection (hover-clear still does, as before).
- On new layout (RequestRebuild), the overlay is cleared and selection is reset to keep state consistent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: eliminate button-hover visual shake via integer pixel snapping and targeted canvas invalidation

Two root causes addressed:

1. Block embed (Button) positions were placed at fractional DIPs (e.g. Y=144.3) because StackBox accumulates float heights. A 1-px border at a fractional coordinate is anti-aliased differently when its colour changes between hover/normal states, producing a perceived 0.5-1px size change that made surrounding text appear to shift. Fix: Math.Round all Canvas.SetLeft/SetTop and Width/Height in PlaceEmbeds for both EmbedBox (block) and InlineContainerBox (inline) embeds.

2. _canvas.Invalidate() (full repaint) was called in OnPointerExited and in the IsPointOverEmbed branch of OnPointerMoved whenever a link had been hovered. PointerExited on the DirectWrite canvas fires whenever the pointer crosses into an embed (button/checkbox) sibling — at fractional boundaries this can oscillate rapidly, triggering repeated full-canvas repaints → tile-offset glyph shake. Fix: track _lastHoveredBox alongside _lastHoveredRun, and replace full Invalidate() with targeted Invalidate(box.Bounds) — only the paragraph containing the link is repainted. Also extend IsPointOverEmbed to cover block-embed rects (tracked in new _blockEmbedRects list) so the embed-hover early-return correctly suppresses IBeam cursor and link-hover work over Button elements.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: add image captions, SVG support, RTL, embed virtualization, accessibility peer

Implements four major features for the native markdown renderer:

- ImageBox now renders alt text as a centered caption beneath the image (new ImageCaption theme key, FontSize 12, italic, secondary fg) and respects FlowDirection for caption text direction.

- ImageBox supports SVG via Win2D CanvasSvgDocument. Handles both http(s) and data: URIs; intrinsic size extracted from width/height/viewBox attributes; SVG bytes cached, parsed lazily against the drawing-session device.

- StackBox (used by blockquotes/alerts) flips the accent bar to the right edge in RTL; ListItemBox flips marker/content columns in RTL; TableBox reverses column order in RTL. Flow direction plumbed through MarkdownLayoutContext to the boxes.

- Embed virtualization: MarkdownRendererControl now collects EmbedPlans during layout and realises only those whose rect intersects viewport ± 400 px. Embeds beyond ± 1200 px are derealised. IMarkdownEmbedFactory gains an optional RecycleBlock hook. Realisation is re-evaluated on every ViewChanged tick.

- MarkdownAutomationPeer now aggregates document text into GetNameCore, exposes per-block MarkdownBlockPeer children with appropriate Header control type and AutomationHeadingLevel for h1-h6, falling back to Text for paragraphs/list items.

- Sample app: RTL toggle in toolbar, new RTL sample (Arabic+English mixed bidi, blockquote, list, table), new Virtualization sample with 300 hosted Button embeds, richer Images sample exercising PNG with caption, data: SVG, remote SVG, and graceful broken-image fallback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: add unit + UI automation coverage, fix SVG viewBox parse bug

Add 18 unit tests across SvgIntrinsics and EmbedVisibility, exercising
the pure-logic helpers extracted from ImageBox + the embed virtualization
realize/derealize hysteresis bands.

Add MarkdownRenderer.Sample.Automation: a FlaUI-driven harness that
launches the sample app, exercises the automation tree, RTL toggle,
sample switching, embed virtualization (verifies bounded realization on
a 300-button document), and image sample loading. All 5 probes pass
end-to-end against the running app.

Fix: SvgIntrinsics.ParseStringAttribute treated whitespace as a value
terminator, breaking viewBox parsing (values contain spaces). Rewritten
to be quote-aware; tests caught the regression.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: round-1 review fixes (RTL rebuild, embed recycle, AT peers, virtualization probe)

Addresses findings from round-1 multi-agent code review.

- FlowDirection runtime callback (gated on IsLoaded) now triggers
  RequestRebuild so RTL toggles re-layout without a full reload.
- OnUnloaded derealises all hosted embeds and clears plan/rect caches
  before tearing down the overlay, preventing leaked FrameworkElements.
- InlineEmbedRun gains a Recycle Action; InlineEmbedPlan.Derealize
  invokes it so factories can pool/reset their hosted elements.
- MarkdownAutomationPeer caches per-block peers via ConditionalWeakTable
  so AT clients see stable peer identity across enumerations.
- MarkdownBlockPeer.GetBoundingRectangleCore reports per-block bounds in
  screen coords instead of inheriting the renderer's full rect.
- TableBox.Arrange anchors header and body cells to the right edge in
  RTL flow direction.
- ImageBox caches SVG intrinsic size on the loaded path so reuse keeps
  metadata; SvgIntrinsics rejects percent-only dimensions and tolerates
  comma-separated viewBox values, with regression tests.
- MarkdownRendererControl publishes RealizedEmbedCount via
  AutomationProperties.HelpText ("realized:N") so the UI-automation
  virtualisation probe can validate counts directly instead of walking
  the UIA tree (which is sensitive to peer caching). Probe updated to
  parse HelpText.

Verified: 120 unit tests pass; all 5 UI-automation probes pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: round-2 review fixes (no UIA pollution, SVG async paint, links peer, RTL hit-test)

Addresses findings from round-2 multi-agent code review (GPT-5.5 + Opus-4.7).

HIGH:
- MarkdownRendererControl no longer writes "realized:N" to its own
  AutomationProperties.HelpText. HelpText is read aloud by Narrator and
  raises UIA property-change events on every scroll tick; that's hostile
  to assistive tech and RPA tools. Replaced with a public
  EmbedsRealizationChanged event. The sample app subscribes to it and
  mirrors the count into a hidden, off-screen TextBlock with AutomationId
  "RealizedEmbedCount". The UI automation probe reads the TextBlock's
  Name, keeping the test signal external to the control's UIA surface.

MEDIUM:
- MarkdownBlockPeer.GetBoundingRectangleCore now composes the base
  FrameworkElementAutomationPeer's screen rect with the block's
  renderer-local offset (scaled by XamlRoot.RasterizationScale), so
  Narrator's per-element scan navigation lands on the correct screen
  position even when the host window isn't at desktop (0,0).
- New MarkdownLinkPeer exposes inline LinkRuns as UIA Hyperlinks with
  the link text as Name and the URL as HelpText. MarkdownBlockPeer
  surfaces them via GetChildrenCore so screen readers can discover and
  navigate links inside a paragraph or heading.
- MarkdownAutomationPeer.AppendText now includes ImageBox.Alt so
  documents whose only block is an image still expose accessible text
  instead of falling back to the "Markdown document" placeholder.
- ImageBox no longer parses SVG synchronously inside Paint. When bytes
  are present but the document hasn't pre-parsed yet (e.g. shared-device
  reparse needed), we kick a one-shot async re-parse on a background
  task and skip drawing this frame; LoadCompleted triggers a repaint
  when ready. The paint thread is never blocked on
  CanvasSvgDocument.LoadAsync.
- TryHitTestEmbed (inline embed selection snap) now flips its half-rect
  mapping in RTL so the visual right edge maps to logical offset 0,
  matching surrounding bidi text.

LOW:
- LayoutBuilder block-image caption uses FlattenContainer so alt text
  with inline formatting (e.g. ![**bold** caption](url)) is preserved.
- SvgIntrinsics ParseStringAttribute also accepts '\r' as an
  attribute-name separator so SVGs with CRLF line endings parse
  correctly. New regression test added.
- ImageBox removes the redundant per-call 30s CancellationTokenSource;
  HttpClient.Timeout already bounds the download.
- Sample app exposes the renderer's current FlowDirection through a
  hidden "FlowDirectionStatus" TextBlock so the rtl-toggle UI probe
  asserts the renderer actually flipped, not just the toggle button.

Verified: 121 unit tests pass; all 5 UI-automation probes pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: round-3 review fixes (SVG reparse loop, link peer bounds/invoke, debounce embed event)

Round-3 multi-agent review (GPT-5.5 + Opus-4.7, independent fresh context)
surfaced eight defects against the prior round-2 fixes. This commit addresses
all HIGH and MEDIUM findings plus the LOW dead-code / device-lost issues:

HIGH
* SVG reparse rebuild loop (ImageBox / MarkdownRendererControl)
  Pre-parsed SVG completion fired LoadCompleted -> RequestRebuild, which
  disposed the freshly parsed `_svg` and built a new ImageBox that
  re-triggered reparse, indefinitely. Split into a new
  LoadCompletedEventArgs.LayoutInvalidated flag: layout-affecting completions
  still call RequestRebuild; paint-only (device-specific) reparses only
  invalidate the canvas, so the parsed CanvasSvgDocument survives.
* MarkdownLinkPeer reported the renderer's full bounding rect for every link
  Override GetBoundingRectangleCore to delegate to the parent
  MarkdownBlockPeer's rect (a paragraph/heading-tight bound). Per-link
  sub-rects would require run-level screen tracking and are deferred.

MEDIUM
* MarkdownLinkPeer was not invocable via UIA
  Implement IInvokeProvider; route Invoke() through a new internal
  RaiseLinkClickFromAutomation so screen-reader / keyboard activation
  shares the same pipeline as pointer clicks.
* Link peers were re-allocated on every GetChildrenCore call
  Cache MarkdownLinkPeer per LinkRun via ConditionalWeakTable on the
  control. UIA traversals now see stable identity; focus tracking is
  preserved across repeated descendant walks.
* EmbedsRealizationChanged fired on every scroll tick
  Track the previously-fired realised count; only raise the event when
  the count actually changes. Also emit a transition-to-zero event when
  a rebuild produces a document with no embeds.
* MarkdownBlockPeer.GetBoundingRectangleCore ignored FlowDirection
  In RTL, layout coordinates remain LTR but the visual is mirrored.
  Reflect x within the owner's screen rect when FlowDirection==RightToLeft
  so Narrator scan navigation on tables/lists is visually correct.
* ImageBox SVG async reparse mutated state on a worker thread
  Marshal field publication and LoadCompleted invocation through
  MarkdownLayoutContext.Dispatcher (new optional parameter, wired from
  the control's DispatcherQueue) so writes happen-before subsequent
  Paint reads. Reset `_svgReparseStarted` on failure so transient
  device errors can recover instead of permanently disabling the image.
* SVG cache pinned device-bound documents across device-lost
  On DrawSvg exception, drop the parsed doc and unlatch the reparse
  guard so the next paint re-parses against the recovered device.

LOW
* Removed dead `window` local in Sample.Automation/Program.cs.

Tests
* New LoadCompletedEventArgsTests (3 cases).
* 124 unit tests pass (+3 from prior 121).
* 5/5 UI automation probes pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* MarkdownRenderer: round-4 review fixes (SVG retry storm, RTL revert, UIA thread, embed-count reset)

Address findings from round-4 multi-agent review (GPT-5.5 + Opus-4.7):

HIGH — SVG reparse failure storm (both reviewers): the round-3 fix that
reset _svgReparseStarted on failure caused unbounded retries at paint
refresh rate. Bound to MaxSvgReparseAttempts (2); after the final attempt
latch _loadFailed and fall back to the alt-text placeholder. Same logic
for the DrawSvg-exception branch — counts toward the same budget.

MEDIUM — _lastFiredRealizedCount not reset across rebuild: identity of
embeds changes per rebuild even when count matches; reset to -1 in
CommitSnapshot so the first post-rebuild realisation always fires.

MEDIUM — RaiseLinkClickFromAutomation thread affinity: UIA Invoke can
arrive on the RPC thread. Marshal back via DispatcherQueue before raising
LinkClick so consumers always run on the UI thread.

MEDIUM — Link peer cache race: replace TryGetValue+Add with the atomic
ConditionalWeakTable.GetValue(key, factory).

MEDIUM — ImageBox disposal race: in-flight SVG reparses could resurrect a
disposed box and leak the native CanvasSvgDocument. Track _disposed and,
in the Publish marshal, dispose the freshly-parsed doc and bail without
mutating state or raising LoadCompleted.

MEDIUM — RTL bounding-rect double-mirror: reverting the round-3 mirror in
MarkdownBlockPeer.GetBoundingRectangleCore. Sub-boxes (ListItemBox marker,
TableBox cells) already place themselves at RTL coordinates during layout,
so a blanket reflect-about-right-edge double-mirrors them. Without the
reflection, paragraphs/headings (full-width) still report correctly and
RTL-positioned sub-boxes report their actual on-screen location.

124 unit tests + 5 UI automation probes pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* MarkdownRenderer: round-5 review fixes (SVG fatal state survives rebuild, dispose-race in initial loads)

Address findings from round-5 GPT-5.5 review (Opus-4.7 returned CLEAN).

HIGH — SVG fatal failure latch lost on layout rebuild: when the bounded
reparse exhausted its attempts and fired LoadCompleted(layoutInvalidated:
true), the control rebuilt layout. The new ImageBox pulled the same
bytes from the static _svgBytesCache, resetting _svgReparseFailures to 0
and re-entering the failure cycle indefinitely.

Fix: add a static _failedUrls set. When latching fatal in either the
async reparse Publish path or the DrawSvg-exception path, register the
URL. New ImageBox instances for a registered URL initialise in
_loadFailed=true (skip both caches, skip StartLoad) so the placeholder
rendering is preserved across rebuilds.

MEDIUM — initial-load disposal race: LoadSvgAsync / LoadBitmapAsync had
no _disposed guard. If a snapshot was replaced while a load was in
flight, the awaited continuation could assign _svg/_bitmap and raise
LoadCompleted on an obsolete box — leaking the freshly-loaded native
resource (CanvasSvgDocument / CanvasBitmap) and driving an unnecessary
rebuild against a torn-down listener.

Fix: refactor both async loaders to compute results into local variables
first, then check _disposed before publishing state. If disposed, dispose
the freshly-allocated native resource and return without raising
LoadCompleted. This mirrors the disposal guard already in place for the
paint-thread reparse Publish().

Also: failed initial loads now register the URL in _failedUrls so the
post-rebuild ImageBox starts in the failed state.

124 unit tests + 5 UI automation probes pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* MarkdownRenderer: round-6 review fixes (UI-thread marshal for initial image loads, failure latch reset hook)

Address findings from round-6 multi-agent review (GPT-5.5 + Opus-4.7).
Both reviewers independently flagged the same thread-affinity bug in
LoadSvgAsync and concerns about the static _failedUrls latch.

MEDIUM (both) — LoadSvgAsync post-ConfigureAwait(false) race + UI-thread
contract violation: the HTTP branch uses `.ConfigureAwait(false)` so the
continuation runs on the threadpool. The previous _disposed check and
state publish happened off-UI-thread, leaving a TOCTOU window where
Dispose() could complete between the check and the `_svg = doc`
assignment (leaking the freshly-parsed CanvasSvgDocument). The
`LoadCompleted?.Invoke` call also violated its documented "always raised
on the UI thread" contract on this path.

Fix: introduce `PublishOnUiThread(Action)` helper mirroring the paint-
reparse Publish() dispatcher pattern. Both LoadBitmapAsync and
LoadSvgAsync compute results into locals, then call PublishOnUiThread,
which TryEnqueues to the dispatcher when off-thread. The _disposed
re-check, field assignments, and LoadCompleted invocation all run on
the UI thread under happens-before with Dispose(). Mark _disposed
`volatile` to remove the visibility hole independent of the marshal.

LOW (Opus) / MEDIUM (GPT) — _failedUrls is permanent + test pollution:
expose internal `ResetFailureLatchForTests()` so tests can clear the
static set between cases. The latch is still necessary on initial-load
failures to prevent rebuild→relayout→refetch storms on persistent
network/codec errors — documented in the LoadBitmapAsync comment.

124 unit tests + 5 UI automation probes pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* MarkdownRenderer: round-7 review fix (dispose dropped resources on dispatcher shutdown)

Address GPT-5.5 round-7 finding (Opus-4.7 returned CLEAN, considered and
ruled out this same edge case as non-regression but acknowledged the
shape).

LOW — PublishOnUiThread ignored DispatcherQueue.TryEnqueue return value:
during dispatcher shutdown TryEnqueue returns false and the publish
closure never runs, silently dropping any freshly-loaded CanvasBitmap /
CanvasSvgDocument captured by the closure (native resource leak).

Fix: PublishOnUiThread accepts an optional `onDropped` Action; both
LoadBitmapAsync and LoadSvgAsync pass disposers for `bmp` / `doc`. The
paint-thread reparse Publish() path gets the same TryEnqueue check + doc
disposal in the else branch for parity.

124 unit tests + 5 UI automation probes pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* MarkdownRenderer: fix RTL blockquote bar, theme switch, selection shake

- StackBox.Measure(): use ContentPadding.Right as child start offset when
  FlowDirection is RightToLeft so RTL blockquote content indents toward the
  accent bar (drawn on the right) rather than hugging the left margin.

- MarkdownRendererControl.OnRegionsInvalidated(): add ds.Clear(Transparent)
  before Paint() so CanvasVirtualControl tiles are fully cleared on each
  region repaint. Without this, old tile content (e.g. light-mode text)
  persisted through theme switches because CanvasVirtualControl does not
  auto-clear like CanvasControl does.

- MarkdownRendererControl.UpdateSelectionOverlay(): rewrite to pool
  Rectangle elements instead of calling Children.Remove/Insert per
  pointer-move event. Pool items are mutated in-place (Width/Height/
  Canvas.SetLeft/SetTop/Visibility) which does not trigger Canvas layout
  passes. Canvas.SetZIndex(-1) keeps selection rects behind embedded
  controls without requiring insertion at index 0. _selectionBrush is
  cached and only recreated when the accent color changes. Pool is cleared
  in CommitSnapshot and OnUnloaded so re-attach cycles start clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* MarkdownRenderer: fix RTL alert bar and theme background

- AlertRenderer.BuildBlock(): set FlowDirection = context.FlowDirection on the
  StackBox. The bar position (Paint) was already RTL-aware, but the FlowDirection
  was never forwarded from the layout context so it always defaulted to LTR and
  the accent bar always appeared on the left even in RTL documents.

- MarkdownRendererControl: replace ds.Clear(Colors.Transparent) with
  ds.Clear(_canvasBackground) where _canvasBackground is an opaque theme-aware
  color captured from ActualTheme at rebuild time. CanvasVirtualControl may not
  alpha-composite with the XAML compositor (depends on DirectX swap-chain
  configuration); transparent pixels render as black on those platforms, making
  the renderer background permanently black regardless of theme. Using an explicit
  Win11-matched background color (0x202020 dark / white light) fixes both the
  'always black background' issue and light/dark theme switching.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* MarkdownRenderer: features 4/5/6/10 — lazy images, scroll anchor, footnote backlinks, keyboard nav

Implements four new features end-to-end:

Feature 4 — Lazy image loading
- Images only fetch when within 800 px of the viewport (overscan band)
- EnsureLoading() is idempotent; cache hits bypass the gate

Feature 5 — Scroll anchoring
- Captures first visible block before re-layout (triggered by lazy image load)
- Restores scroll offset after snapshot commit with disableAnimation:true

Feature 6 — Footnote backlinks
- Forward link ¹²³ scrolls to footnote definition
- Back-link ↩ after each definition scrolls back to the inline citation
- Both are keyboard-activatable (Tab + Enter)

Feature 10 — Keyboard navigation
- Tab / Shift+Tab cycle through links and inline embeds (focus ring via XAML Border)
- Enter / Space activates focused link (fires LinkClick or handles internal anchor)
- Escape clears focus ring or text selection
- Focus ring uses accent color from Link style; ZIndex=1 above selection layer

Also:
- 20 new unit tests (144 total): FootnoteBacklinkTests, KeyboardNavTests,
  ScrollAnchoringTests, LazyImageLoadingTests
- 4 new sample pages: Lazy Images, Scroll Anchor, Footnotes, Keyboard Nav
- 4 new UI-automation probes in MarkdownRenderer.Sample.Automation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* MarkdownRenderer: fix 4 bugs found in round-1 review

Fix focus ring orphaned after rebuild:
- Add _focusRing = null after _overlay.Children.Clear() in CommitSnapshot
  and OnUnloaded, matching the existing _selectionOverlayRects.Clear() pattern.
  The lazy-init guard in UpdateFocusRing re-creates and re-adds the Border on
  the next Tab keypress.

Fix footnote forward-link navigation always silently doing nothing:
- ScrollToBlock only searched the flat top-level _snapshot.Blocks list.
  Footnote definition markers are nested inside ListItemBox → StackBox and
  therefore were never found. Added FindBlockY() recursive helper that walks
  ListItemBox.Marker/Content, StackBox.Children, and TableBox.GetCellBoxes().

Fix keyboard trap — Tab never able to leave the control:
- MoveFocus previously always wrapped at boundaries (mod arithmetic). Now
  when forward Tab is at the last item or Shift+Tab is at the first item,
  focus is cleared and the method returns false, allowing the default Tab
  routing to move focus to the next control in the window.

Fix GetFocusableItemRectFromBlock using wrong IsEmpty sentinel:
- Windows.Foundation.Rect.IsEmpty checks Width < 0, so default(Rect) with
  Width = 0 returns IsEmpty = false. Any non-matching block seen before the
  actual target caused an early return with (0,0,0,0), making the focus ring
  invisible for all nested links (list items, tables, blockquotes).
  Refactored both GetFocusableItemRect and GetFocusableItemRectFromBlock to
  return Rect? (null = not found). Updated all call sites accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* MarkdownRenderer: fix round-2 review issues (footnote Order vs Index, test coverage)

Fix footnote forward-link using fl.Index instead of fl.Footnote.Order:
- LayoutBuilder.cs line 293: fl.Index is a per-citation counter that
  increments globally across all footnote citations. fl.Footnote.Order is
  the stable 1-based sequence number. Using fl.Index caused the second
  citation of any repeated footnote to produce '#footnote-def-2' even if
  the footnote was the first footnote in the document.
- Changed to: fl.Footnote?.Order is > 0 ? fl.Footnote.Order : fl.Index

Improve FootnoteBacklinkTests to actually catch the bug:
- Replaced the vacuous 'local constant asserts itself' test with a test
  that verifies the exact URL format (#footnote-def-N, #footnote-ref-N).
- Added RepeatedCitation test: parses markdown with '[^a]...[^a]', asserts
  both citations have the same Footnote.Order while fl.Index differs, and
  demonstrates that using fl.Footnote.Order produces matching URLs for both
  citations while fl.Index would produce mismatched URLs (145 tests total).

Improve ProbeKeyboardNav automation probe:
- Previous probe only verified crash-survival (renderer.Name not empty).
- New probe presses Tab 15 times through all links, then re-focuses,
  Shift+Tabs, and Escapes, verifying the control is still responsive
  throughout, testing boundary-exit and Escape behaviors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: double/triple-click selection, context menu, focus dismissal, footnote backlink inline

- Mouse click dismisses keyboard focus ring
- Double-click selects word using new TextBoundaryHelper (pure-logic, testable)
- Triple-click selects current line/block
- Right-click shows context menu with Copy and Select All items
- Footnote back-link (↩) now renders inline with last paragraph, not on a new line
- Superscript footnote markers no longer get an underline decoration
- Fixed double-ScrollViewer issue in virtualization sample
- Added TextBoundaryHelper static class with FindWordBoundaries algorithm
- Added 14 new unit tests for TextBoundaryHelper (159 total, all passing)
- Added 4 new UI automation probes: click-dismisses-focus-ring,
  double-click-selects-word, triple-click-selects-line, context-menu-appears

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: review round-1 issues — system double-click time, run-boundary disambiguation, click count cap

- Read actual system double-click time via GetDoubleClickTime() P/Invoke instead
  of hardcoding 500ms; accessibility users who configure slow double-click now work
- Fix GetPositionFromBufferOffset: use strict < instead of <= so bufOffset exactly
  at a run boundary prefers start of next run, avoiding off-by-one in word selection
  when a word spans an exact run boundary
- Cap _consecutiveClickCount at 3 so 4th+ rapid click still triggers line-select
  instead of collapsing back to single-click and locking out word/line selection

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: review round-2 — suppress hover during double/triple-click drag, fix stale anchor

Always set _selectionAnchor = pos before word/block expand methods so:
- OnPointerMoved hover processing is suppressed during captured drag after
  double/triple click (prevents glyph shake when mousing during held click)
- _selectionAnchor is never stale on early-exit paths in ExpandSelectionToWord/Block
  (prevents unintended drag selection from an old anchor)
Remove the _selectionAnchor = null assignments from ExpandSelectionToWord and
ExpandSelectionToBlock — anchor management is now owned solely by OnPointerPressed

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: review round-3 — left-button-only guards for OnPointerPressed and OnPointerReleased

- OnPointerPressed: return early if not left button (IsLeftButtonPressed check)
  prevents right-click from incrementing multi-click counter or arming selection
- OnPointerReleased: track _leftPointerCaptured flag set in OnPointerPressed; skip
  link-click path when released button is not left (prevents LinkClick firing on
  right-click over a link, which also opened the context menu)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: review round-4 — pointer cancel/capture-lost clears drag state, buffer dirty tracking

- OnPointerExited (also handles PointerCanceled and PointerCaptureLost) now clears
  _leftPointerCaptured and _selectionAnchor so an interrupted gesture doesn't leave
  ghost drag-selection or cause a phantom LinkClick on the next right-click
- InlineContainerBox: add _bufferDirty flag set by Add() and cleared by BuildBuffer();
  EnsureBuffer() now rebuilds the buffer when _bufferDirty is true, not just when
  _buffer is empty — prevents stale word-boundary results when runs are added post-Measure

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: review round-5 — snap-right loop bound and Measure dirty-buffer guard

- TextBoundaryHelper: change snap-right loop from idx < buffer.Length-1 to
  idx < buffer.Length so a cursor on the last character (when it is whitespace)
  advances consistently past the end rather than being left stranded on a space
- InlineContainerBox.Measure: add _bufferDirty to the layout-rebuild condition so
  that if runs are added after an earlier Measure (same width), _layout is rebuilt
  rather than diverging from the updated _buffer
- TextBoundaryHelperTests: add 3 new tests covering trailing-space snap behaviour
  (162 tests total, all passing)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: review round-6 — footnote backlink traverses ListItemBox, fix assertion messages

- FootnoteRenderer.TryAppendToLastInlineBox: add ListItemBox arm so the ↩
  backlink is appended to the last ICB inside the list's content, not to an
  earlier sibling paragraph; fixes footnotes whose last block is a list
- ProbeVirtualization: fix two assertion error messages that said '≪300' while
  the actual guard was < 100; messages now match the code

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: review round-7 — word-drag snapping, cursor leak, EnsureBuffer zero-run

- MarkdownRendererControl: add ClickMode enum (Single/Word/Block); set in
  OnPointerPressed so OnPointerMoved uses it during drag — double-click drag
  now extends to word boundaries and triple-click drag to block boundaries,
  matching browser/text editor behaviour; single-click drag unchanged
- MarkdownRendererControl: add GetWordEndAt/GetBlockEndAt helpers that return
  just the end DocumentPosition without resetting the selection anchor
- MarkdownRendererControl: cache InputSystemCursor (Hand, IBeam) as fields;
  SetCursorShape reuses them instead of calling Create() on every transition;
  OnUnloaded disposes + nulls both to prevent WinRT IClosable leak
- InlineContainerBox.EnsureBuffer: when _runs.Count == 0 clear _bufferDirty
  immediately so Measure does not loop on a permanently-dirty empty ICB

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: review round-8 — direction-aware word/block drag, _dragAnchorStart/End

When the user double-click-drags or triple-click-drags, the selection now
correctly expands backward as well as forward:
- ExpandSelectionToWord/Block now return (start, end) so OnPointerPressed
  can save the initial word/block range in _dragAnchorStart/_dragAnchorEnd
- OnPointerMoved Word/Block modes: compare current pos to _dragAnchorStart
  to detect drag direction; forward drag anchors at _dragAnchorStart and
  extends to the current word/block end; backward drag anchors at
  _dragAnchorEnd and extends to the current word/block start
- GetWordEndAt and GetBlockEndAt helpers removed (no longer needed; logic
  is inlined in the direction-aware drag handler)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: apply round-9 review fixes

- Reset _clickMode to Single in OnPointerExited so stale Word/Block mode
  cannot survive PointerCaptureLost or PointerCanceled
- Reset _consecutiveClickCount to 0 in HitTest-miss branch so non-text
  rapid clicks don't elevate the next text click to word/block-select
- Remove unused 'context' parameter from TryAppendToLastInlineBox (and all
  recursive call-sites) - no functional change, eliminates dead parameter
  that masked a potential source-map mismatch
- Dispose Process handles in KillExistingApplicationInstances with 'using'
  to prevent handle leak in automation test runner

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: apply round-10 review fixes

- Move _leftPointerCaptured=true inside HitTest success branch so a
  press on empty space cannot gate a spurious link-click on release
- ExpandSelectionToWord/Block: clear selection + invalidate when no ICB
  found (code blocks, embeds) so stale selection visuals are erased
- OnUnloaded: reset ProtectedCursor=null before disposing cursor objects
  to avoid dangling native handle during same render frame
- OnPointerReleased: remove redundant IsEmpty+Start.Equals guard; keep
  only !IsEmpty check with explanatory comment
- EnsureBuffer: remove _buffer.Length==0 guard, rely solely on
  _bufferDirty to prevent repeated BuildBuffer for zero-length ICBs
- TextBoundaryHelper: return (charIndex,charIndex) for all-whitespace
  buffer instead of snapping to buffer.Length and returning wrong pos
- FootnoteRenderer: use per-iteration fallback counter (not hardcoded 1)
  for footnotes with Order==0 so each gets a unique order
- ProbeContextMenu: dismiss flyout before querying renderer.Name so
  open menu cannot block/stale the UIA property call

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: apply round-11 review fixes

- OnPointerReleased: snapshot _leftPointerCaptured before ReleasePointerCapture
  so a synchronous PointerCaptureLost dispatch can't zero the flag before we read it
- OnPointerPressed HitTest-miss: reset _lastPressTickMs/_lastPressPoint + clear
  _selectionAnchor so a miss never corrupts double-click timing or leaves stale anchor
- OnPointerPressed: move _lastPressPoint/TickMs update inside HitTest-success branch
- OnPointerMoved Word/Block drag: fall back to ExtendTo(pos) when FindInlineContainerAt
  returns null (code blocks, embeds) so drag still extends selection as char-level
- InlineContainerBox.Measure: guard BuildBuffer() with _bufferDirty so width-change
  reflows don't needlessly rebuild the run-content string on every resize
- TextBoundaryHelper: return (idx, idx) not (charIndex, idx) for all-whitespace
  so the returned range is always within clamped buffer bounds
- FootnoteRenderer: compute fallback orders via skip-over-assigned-set to prevent
  collision between fallback and Markdig-assigned orders in mixed documents
- ProbeContextMenu: stop UIA tree walk at ControlType.Window boundary rather than
  desktop root to avoid searching the entire desktop for MenuItems

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: apply round-12 review fixes

- InlineContainerBox.Measure: null _layout immediately after Dispose so a
  layout-creation exception leaves _layout=null rather than a dangling disposed ref
- ExpandSelectionToWord/Block null-ICB path: use SetAnchor(pos) instead of Clear()
  so subsequent drag ExtendTo calls always have a valid anchor rather than operating
  on a stale anchor from a prior gesture
- OnPointerPressed: check CapturePointer return value and clear _leftPointerCaptured
  if capture fails, preventing spurious link-click logic in OnPointerReleased when
  the pointer was never actually captured
- ReadRealizedEmbedCount: stop UIA tree walk at ControlType.Window boundary (same
  guard as ProbeContextMenu) so desktop-wide search cannot return false positives
  from an unrelated application with the same AutomationId

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: apply round-13 review fixes

- OnPointerPressed: remove IsSelectionEnabled from the early-return guard;
  when selection is disabled, still capture pointer for link navigation and
  return early before any selection-mutation code, so links remain clickable
- OnPointerReleased: guard link-click on _clickMode==Single so double/triple-
  click gestures never accidentally fire LinkClick on an empty selection
- InlineContainerBox.RunAt: add last-run fallback for charIndex >= buffer length
  so trailing-edge hover shows Hand cursor and hover color matching HitTest
- InlineContainerBox.GetPositionFromBufferOffset: use _runs[i].InlineIndex
  instead of loop counter i for defensive correctness

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: apply round-14 review fixes

- OnPointerPressed: reset _consecutiveClickCount/_lastPressTickMs/_lastPressPoint
  at the embed early-exit so a text→embed→text sequence never misclassifies the
  final text press as a double-click (which would suppress link-click in Released)
- OnPointerPressed: reset _clickMode=Single in the !IsSelectionEnabled fast path
  so a stale ClickMode.Word/Block from a prior selection gesture never blocks
  LinkClick when the user later re-presses with selection disabled
- KillExistingApplicationInstances: filter by MainModule.FileName equality so
  only the sample app exe is killed and not unrelated processes with the same name
- Program.Main: remove redundant app.Close() from finally block; the using-var
  disposes the FlaUI wrapper cleanly; double-Close can throw ObjectDisposedException

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: apply round-15 review fixes

- FootnoteRenderer.BuildBlock: assign stack.BlockIndex so the outer StackBox
  contain…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants