feat(code-viewer): native WinUI 3 code viewer replacing WebView2/VS Code embed#78
Merged
Merged
Conversation
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
approved these changes
May 8, 2026
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. ) 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…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Task.Runflatten) — typing never blocks the UICode Viewer (MicaEditor / WinUIEdit + Lexilla)
CreateLexerfor 60+ languageslanguage-map.jsonwith 350+ mappings)File Preview
MarkdownTextBlockwith full app-color theming viaMarkdownConfig.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.Design
AppInkBrush,AppSurfaceBrush,AppAccentBrush, etc.)Quality & Reliability
Unit Tests (90 tests, all passing)
New
JitHub.WinUI.TestsxUnit project with comprehensive coverage of the code viewer's pure logic layer:FilePreviewResolverTestsLanguageIdResolverTestsIsKnown(), fallback to plaintextRepoFileCacheServiceTestsTests run in CI via
.github/workflows/code-viewer-unit-tests.ymlon every push/PR.AOT Compatibility
YamlDotNet(reflection-based deserializer, incompatible withPublishAot=True) — YAML now renders in the syntax-highlighted code editor[JsonSerializable]source-generated contextsKey Commits
95bbd90af797ae325c4d5CreateLexer(60+ languages)8ba666eb9384d95cf88e8MarkdownConfig.ThemesAPI (not resource keys)fcc0453cf5c05cUpdateSourceTrigger=PropertyChangede93ee65b85b1a20c9a41d2f6c2e21db19b9