feat(source): mirror folds and synchronize reading positions

Preserve canonical source/find snapshots while projecting literal nested folds and pairing native/browser heading geometry. Add bounded numeric frame validation and actual hosted viewport/fold/find/reload acceptance.

Refs #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
This commit is contained in:
James MaesandCodex committed 2026-10-11 00:20:40 -05:00
1 parent b0f3b8638f
commit ddb98ac286
18 files changed
+1030 -25

No files matched your search

+4 -4
View File
@@ -1,6 +1,6 @@
# MarkdownViewr
> In development: a Windows counterpart to [Darin's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), built with AppKit. The first reader implementation opens Markdown, renders GFM and supports manual and automatic reload. The 20 original document themes are implemented; user-theme editing has passed hosted acceptance. Frontmatter, outline navigation and the literal source pane passed hosted and installed acceptance. Paired find is implemented with its new acceptance pending; synchronized source/vim navigation and printing remain in development. Hosted reader/reload and both installed update channels pass. See [the feature plan](docs/PRODUCT.md) and [development tasks](docs/TODO.md). No Windows release is available yet.
> In development: a Windows counterpart to [Darin's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), built with AppKit. Opening, live reload, the 20 built-in themes, theme editing, frontmatter, outline navigation, literal source, paired find, configured editor launch and vim reading commands have passed hosted acceptance, including both installed update channels. Source folding and bidirectional scrolling are implemented and awaiting hosted acceptance. Printing, multiple windows and the remaining reference features are tracked in [the feature plan](docs/PRODUCT.md) and [development tasks](docs/TODO.md). No Windows release is available yet.
A Windows Markdown viewer with live reload, themes and document navigation.
@@ -34,10 +34,10 @@ Prerelease tags (`v1.2.3-beta.1`) publish to the `dev` channel
- **View → Document theme:** choose one of Darin's 20 built-in palettes. The saved document palette is independent of the Windows shell theme; changing it retains the reading position.
- **View → Edit document theme:** edit colors, code font/size and custom CSS. Keep its name to override a built-in, or enter a new name to create a custom theme. Invalid input keeps the editor open. **Restore built-in themes** removes built-in edits after confirmation and keeps separately named custom themes.
- **View → Table of contents:** show the heading sidebar, choose levels 1–6, and drag its divider to resize. Select a heading to jump there; collapse a selected section or all sections, then expand when ready. Matching section state survives reloads and document theme changes.
- **File → Configure editor / Open in editor:** save the full path of an installed `.exe`, then open the current document with one literal file argument. A missing editor or document reports a status message; the viewer remains open. Reference editor-list/folder-opening parity and hosted acceptance remain pending.
- **File → Configure editor / Open in editor:** save the full path of an installed `.exe`, then open the current document with one literal file argument. A missing editor or document reports a status message; the viewer remains open. Configured launch has passed hosted acceptance; reference editor-list/folder-opening parity remains pending.
- **Edit → Find / Ctrl+F:** search the rendered document and literal source, with one occurrence count and wrapping **Next/Previous** controls. Enter moves forward, Shift+Enter moves back, and Escape closes find. Search temporarily reveals collapsed sections; closing it restores prior choices and retains explicit section commands made during search.
- **Reading keys:** `j/k` move a line; `Ctrl+D/U` move half a viewport; `gg/G` go to the beginning/end; `]]/[[` visit visible headings; `/` opens native find and `n/N` advance/reverse its matches. Escape closes find. Prefixes expire after 500 ms; typing in find/theme/editor controls retains normal behavior. Windows `Ctrl+F` opens find; `Ctrl+B` moves back a viewport. New physical-key acceptance is pending.
- **View → Markdown source:** show the literal, read-only snapshot beside the reader, including frontmatter and fenced code. Visibility and preferred divider width persist; saves refresh both surfaces. Heading/scroll/collapse synchronization remains in development; paired-find and new vim desktop acceptance are being verified.
- **Reading keys:** `j/k` move a line; `Ctrl+D/U` move half a viewport; `gg/G` go to the beginning/end; `]]/[[` visit visible headings; `/` opens native find and `n/N` advance/reverse its matches. Escape closes find. Prefixes expire after 500 ms; typing in find/theme/editor controls retains normal behavior. Windows `Ctrl+F` opens find; `Ctrl+B` moves back a viewport. Physical-key acceptance has passed in hosted native, published and installed builds.
- **View → Markdown source:** show the literal, read-only snapshot beside the reader, including frontmatter and fenced code. Visibility and preferred divider width persist; saves refresh both surfaces. Scrolling follows the corresponding heading positions in either pane. Section collapse retains its literal heading and replaces the hidden source body with an ellipsis; nested choices remain independent. Find temporarily expands the source, then restores prior folds on close. New synchronization acceptance is pending.
- **View → Frontmatter:** hide leading YAML by default, show an escaped metadata table, or render the block as Markdown. The mode is saved and applied to reloads.
- **View:** choose the light, dark, or system shell theme; turn the startup splash and the
startup update check on or off. Choices are saved immediately.
+1 -1
View File
@@ -110,7 +110,7 @@ SOFTWARE.
## markdownViewr resources, frontmatter and navigation behavior
Frontmatter delimiter/metadata display behavior is adapted from MarkdownDocument.swift at the same pinned commit. Vim sequence/navigation semantics are adapted from Resources/template.html there, with native Windows Ctrl+F and input focus controls. The 20 original JSON themes are copied unchanged from Darin Kelkhoff's markdownViewr at commit 6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021.
Frontmatter delimiter/metadata display behavior is adapted from MarkdownDocument.swift at the same pinned commit. Vim sequence/navigation, nested source folding and heading-anchor scroll interpolation semantics are adapted from Resources/template.html there, with native Windows Ctrl+F, literal source controls and input focus guards. The 20 original JSON themes are copied unchanged from Darin Kelkhoff's markdownViewr at commit 6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021.
Source: <https://github.com/darinkelkhoff/markdownViewr/tree/6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021/markdownViewr/Resources/themes>
+1 -1
View File
@@ -2,7 +2,7 @@
Build a Windows counterpart to [Darin Kelkhoff's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), using the KofTwentyTwo AppKit foundations and standards. The agreed display name is MarkdownViewr, repository MarkdownViewr.Windows, and permanent application ID markdownviewr-windows.
The first reader implementation opens Markdown with a Windows picker or literal launch argument, renders sanitized GFM and supports bounded local images, manual reload and automatic saves with relative scroll preservation. The original 20 document themes and persistent palette selection are implemented. Hosted reader/live-reload/theme selection and both installed update channels passed on 77dd04d. Editable custom themes, color/code-font/CSS controls and restoration are implemented; their dialog/browser acceptance passed in CI 38102867556. Frontmatter, resizable outline/depth/jump/section controls and literal source visibility/reload/physical resize passed CI 38106991467: 34 native/UI and published tests, 33 per installed channel, no skips. Paired browser/source find, repeated local-fragment reveal and configured editor launch passed CI 38110726381: 41 native/published tests and 40 per installed channel, no skips. Vim navigation passed CI 38112298176: 44 native/published tests and 43 per installed channel, zero skips. Parser-derived source positions are implemented with 255 core tests/100% lines; source heading/scroll/collapse UI synchronization remains pending. The full feature list below remains the target; the first reader is not a completed port.
The first reader implementation opens Markdown with a Windows picker or literal launch argument, renders sanitized GFM and supports bounded local images, manual reload and automatic saves with relative scroll preservation. The original 20 document themes and persistent palette selection are implemented. Hosted reader/live-reload/theme selection and both installed update channels passed on 77dd04d. Editable custom themes, color/code-font/CSS controls and restoration are implemented; their dialog/browser acceptance passed in CI 38102867556. Frontmatter, resizable outline/depth/jump/section controls and literal source visibility/reload/physical resize passed CI 38106991467: 34 native/UI and published tests, 33 per installed channel, no skips. Paired browser/source find, repeated local-fragment reveal and configured editor launch passed CI 38110726381: 41 native/published tests and 40 per installed channel, no skips. Vim navigation passed CI 38112298176: 44 native/published tests and 43 per installed channel, zero skips. Parser-derived source positions, literal source folding and bidirectional heading-based scroll synchronization are implemented with 300 core tests/100% measured lines. Hosted acceptance of the new synchronization tests is pending; the previous source-provenance head b0f3b86 passed CI 38113042618, including published 44/44 and installed stable/dev 43/43 tests without skips. The full feature list below remains the target; the first reader is not a completed port.
## Reference and feature scope
+28 -1
View File
@@ -1,6 +1,6 @@
# Session State
Updated: 2026-10-10
Updated: 2026-10-11
## Active work and authority
@@ -11,6 +11,33 @@ Owner authorized signed/DCO commits, pushes, repository configuration and ticket
No merges, tags/releases or new Azure attempts. Human review acknowledgement remains
required. Local desktop reservation is unanswered; hosted runner UI tests are authorized.
## Current source synchronization increment — 2026-10-11
Previous pushed b0f3b8638f16d75e4dce5dc6f2cb585fb5214927 is fully verified:
CI 38113042618, CodeQL 38113042570, scripts 38113041887, security 38113042545 and
PR gates 38113042454 SUCCESS. Native job 114392386224 and installed job
114392386003 succeeded; retained TRX proves published 44/44 and stable/dev 43/43,
zero failures/skips, at artifacts/source-anchors-verified-installed-38113042618.
Fixtures remain synthetic/unsigned; all prior producers are terminal.
The current increment implements literal nested source folding and bidirectional
heading-anchor scroll mapping. Canonical snapshots remain immutable; native CRLF
and folded offsets retain exact find selections. Browser geometry is bounded,
validated and current-page/query guarded; feedback holds preserve explicit find
viewports. Microsoft TextBoxView content coordinates do not receive a duplicate
scroll offset. No new dependency or authored scripts/messages/host objects.
300 core tests/100% measured lines pass at artifacts/source-sync-core. Two new
hosted tests assert actual native/browser heading bounds, physical source scrolling
and nested fold/find/reload restoration: 46 native/published and 45 per installed
channel expected, not yet verified. No local desktop input occurred.
Next: commit/push this reviewed increment, observe its exact producer runs, retain
actual TRX/logs and address failures. Keep #10 open until acceptance and raw-heading
reference audit pass. Full PRODUCT scope remains: broader editor/theme/rendering/
link parity, printing, windows/recents/activation, standards inventory/current-head
human review and exact signed stable/dev artifacts. Published AppKit 0.1.0 remains;
no Azure attempt, merge/tag/release or fabricated human acknowledgement.
## Verified reader and editable themes
77dd04da96b75938a1c1d38c8bebe4851c11a011 CI 38102249889 SUCCESS: build/test,
+3 -1
View File
@@ -26,7 +26,9 @@
- [x] Implement all required vim reading/find/heading commands, timed prefixes and native focus/modifier guards (issue #10).
- [x] Verify physical vim viewport, collapsed-heading and query-typing acceptance on 6bf125d: CI 38112298176, 44 native/published and 43 per installed channel, zero skips.
- [x] Map sanitized surviving headings to original parser-derived source offsets (Setext/fences/raw HTML/frontmatter/duplicates); source UI synchronization remains pending.
- [ ] Add synchronized source headings/scroll/collapse (issue #10); audit find/source collapse parity against the pinned reference.
- [x] Implement literal nested source folding and bidirectional heading-based scroll synchronization (issue #10), preserving canonical find/reload snapshots.
- [ ] Verify actual native/browser heading positions, physical source scrolling and fold/find/reload restoration in hosted and installed builds.
- [ ] Audit find/source collapse parity against the pinned reference, including multiline and same-line raw HTML headings (issue #10).
- [x] Implement and core-test hidden/metadata/Markdown frontmatter modes, with persisted native controls and attribution.
- [x] Verify actual code-font size 17 editing in hosted UI runs.
- [x] Verify actual hosted frontmatter mode switching on 4bf0b81 (including both installed channels).
+27 -6
View File
@@ -45,7 +45,7 @@ Bound documents to 16 Mi characters and individual images to 16 MiB.
## Consequences
The reader supports a picker, literal launch arguments, manual reload and debounced save watching.
The 20 original themes are embedded unchanged, with their MIT notice shipped in THIRD-PARTY-NOTICES.md. System/SF Mono defaults map to Windows font fallbacks. Document theme selection persists separately from the AppKit shell theme and retains the viewport. Editable themes and native heading/section controls are implemented and verified; full syntax-color parity and synchronized source navigation remain pending. Automatic save
The 20 original themes are embedded unchanged, with their MIT notice shipped in THIRD-PARTY-NOTICES.md. System/SF Mono defaults map to Windows font fallbacks. Document theme selection persists separately from the AppKit shell theme and retains the viewport. Editable themes and native heading/section controls are implemented and verified; full syntax-color parity and new synchronized-source acceptance remain pending. Automatic save
reload uses fixed native ExecuteScriptAsync queries on the checked current document;
this API works while authored scripts remain disabled. Browser results must be exactly
two finite numeric fractions, clamped to 0–1; only invariant numeric arguments enter the
@@ -68,8 +68,8 @@ Disable browser feature accelerators so the host owns find/print/reload commands
Typing is debounced and native searches serialized. Check the current page/navigation
before applying results; repeat the current term after reload/theme navigation. Search
reveals sections temporarily and restores their saved choices on close, incorporating
explicit native section commands during search. Source synchronization and mirrored collapse
remain separate work. Authored scripts, messages and host objects remain disabled.
explicit native section commands during search. The literal source projection mirrors browser folds while preserving the canonical
snapshot and original query positions. Authored scripts, messages and host objects remain disabled.
Reading keys use the current app UI thread's existing message hook, bounded native
command enums and exact browser/read-only-source focus. Find/dialog controls retain
@@ -93,8 +93,28 @@ A fresh per-render private attribute carries this provenance through sanitation;
its generated name is allowed only for the internal outline render, then removed
from every element before presentation. The public renderer never admits it, and
authored IDs/data attributes remain stripped. No source marker grants a browser
capability. Full source scroll/heading/collapse UI synchronization remains pending;
these parser tests do not prove browser behavior. ADR status remains proposed.
capability. These parser tests do not prove browser behavior. ADR status remains proposed.
## Literal source folding and scroll synchronization
A pure source projection normalizes native CRLF display offsets, retains heading
lines and replaces folded bodies with plain ellipses. Nested choices remain
independent; search expands the projection without changing the original snapshot.
Native selections map original UTF-16 query ranges into the visible literal text.
A serial 150 ms owned-window timer captures only finite browser scroll coordinates,
generated heading indices and collapsed bodies. Frames are bounded and validated,
with current document/navigation/query/snapshot guards. Native TextBox character
rectangles pair with actual generated browser heading tops for piecewise linear
scroll mapping. Feedback holds protect native find selections and prevent reverse
scroll oscillation. Hidden panes and closed windows do not synchronize.
The native coordinate conversion uses the owned TextBoxView's content space; its
scroll offset must not be added a second time. See the
[Microsoft implementation](https://github.com/microsoft/microsoft-ui-xaml/blob/main/dxaml/xcp/core/native/text/Controls/TextBoxBase.cpp).
Hosted tests must measure actual heading glyph/viewport positions, physical source
scrolling and fold/find/reload restoration before this behavior is called verified.
No authored script, message bridge, rich-text interpretation or dependency is added.
## Verification and follow-up
@@ -105,7 +125,8 @@ separate gate; these unit tests do not prove browser behavior or release signing
- [ ] Review this decision and complete document UI acceptance.
- [x] Complete hosted save/replacement/deletion/scroll, editable-theme, outline/frontmatter and literal-source acceptance.
- [ ] Complete paired find, synchronized source/vim and print acceptance from PRODUCT.md.
- [x] Complete paired find and physical vim acceptance in native, published and installed builds.
- [ ] Complete synchronized source and print acceptance from PRODUCT.md.
- [ ] Verify WebView2 runtime deployment and exact signed installed release artifacts.
## References
+19 -3
View File
@@ -25,7 +25,7 @@ physical directory. It does not authorize native commands or remote resource acc
| File change notifications | Directory subscription scoped to selected path; 350 ms quiet-period debounce; replacement/deletion recovery; bounded transient retries; subscriptions released on window close |
| Theme JSON / CSS | Native editor and saved user overrides use the same bounded validation; corrupt theme settings are discarded before startup; restoration keeps separately named custom themes; embedded original palettes; bounded schema-version-one parser; validated hex colors and finite sizes; code-point escaped font names; less-than signs escaped before style embedding; no network capability |
| Browser viewport | Fixed origin-checked native query; validate two finite fractions; invariant numeric restore only; authored scripts/messages stay disabled |
| Source pane | Same bounded committed snapshot in a native read-only TextBox; literal HTML/CSS/script text never enters a renderer or command interpreter; hidden pane removed from accessibility surface |
| Source pane | Bounded immutable snapshot plus literal native fold projection; original UTF-16 find positions map to visible CRLF text; HTML/CSS/script text never enters a renderer or command interpreter; hidden pane removed from accessibility surface |
| Find | Bounded literal query; pure source positions and native WebView2 finder, with duplicate browser UI suppressed; no query enters executable code or activity logs. Serialized queries and page/navigation checks reject stale completions; existing numeric section operations reveal/restore content |
| Fixture input diagnostics | Explicit opt-in environment switch; observes handled native pointer/drag events without changing routing; logs owned control names, coordinates and button state only, never document text or keystrokes |
| Browser surface | Scripts, messages, host objects, permissions, downloads, frames and popups disabled; restrictive CSP |
@@ -52,11 +52,11 @@ physical directory. It does not authorize native commands or remote resource acc
## Remaining risks and work
This is a first reader, not a completed port. Native frontmatter/outline/literal-source acceptance passed CI 38106991467 in loose, published and both installed builds. New find acceptance, synchronized source/collapse, external editor commands,
This is a first reader, not a completed port. Native frontmatter/outline/literal-source acceptance passed CI 38106991467 in loose, published and both installed builds. Paired find and configured editor launch passed hosted and installed acceptance; new synchronized source/collapse, broader editor parity,
print and any future browser bridge need their own controls and tests. Save watching is
implemented; hosted real browser replacement/deletion/scroll and remote-image denial pass on 77dd04d.
Local links to other documents and mailto links are currently blocked. WebView2 uses
the installed evergreen runtime; published and both installed channels passed reader acceptance on 77dd04d and editable themes on 30ca094. New paired-find keyboard/source/viewport/hostile-query acceptance remains required.
the installed evergreen runtime; published and both installed channels passed reader acceptance on 77dd04d and editable themes on 30ca094. Paired-find keyboard/source/viewport/hostile-query acceptance passed on 5a9af63. New source synchronization acceptance remains required.
Historical EX-0002 does not authorize unsigned production publication. Azure setup
and real final-artifact signature validation are deferred; an active profile alone
@@ -108,3 +108,19 @@ remain unchanged. Positions pair only with sanitized surviving headings and do
not grant scripts, messages, native access or resources. The original snapshot is
immutable. Core regressions cover nested raw markup, Setext, fences, Unicode,
frontmatter and forged attributes; source scroll/folding UI acceptance is pending.
## Source synchronization boundary
Only fixed host operations capture numeric scroll positions, generated heading IDs
and hidden generated bodies on the matching ready document. Exact JSON shape,
bounded array lengths, unique in-range integer IDs and finite coordinates are
required. No source text, heading label or query enters executable code. The native
adapter serializes polling, rejects stale snapshot/query/page completions and
suppresses feedback around find selections and its own viewport movements. Closing
stops the timer; hidden panes do not synchronize. Literal ellipses only affect the
presentation; canonical document bytes and independent nested fold choices remain.
Opt-in fixture geometry logs contain numeric positions/counts only.
300 core tests cover folding, original/native offsets and malformed numeric frames
at 100% measured line coverage. Hosted actual glyph/viewport and fold/find/reload
acceptance is pending; raw multiline/same-line heading parity still needs audit.
@@ -0,0 +1,136 @@
/*
* Copyright (c) 2026 Darin Kelkhoff
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
namespace MarkdownViewr.Windows.Core.Documents;
/// <summary>Validated numeric geometry from a fixed host query, used for bidirectional heading-anchored synchronization.</summary>
public sealed class DocumentScrollState
{
/// <summary>Stores only current generated identities, finite geometry and bounded collapse choices.</summary>
private DocumentScrollState(double position, double maximum, Dictionary<int, double> headings, List<int> collapsed)
{
Position = Math.Clamp(position, 0, maximum);
Maximum = maximum;
Headings = headings;
Collapsed = collapsed.AsReadOnly();
}
/// <summary>The actual vertical viewport offset, bounded by the available scroll range.</summary>
public double Position { get; }
/// <summary>The available vertical scroll range, independent of content height.</summary>
public double Maximum { get; }
/// <summary>Visible generated heading identities and document-coordinate tops.</summary>
public IReadOnlyDictionary<int, double> Headings { get; }
/// <summary>Actual hidden generated section bodies, including independent nested choices.</summary>
public IReadOnlyList<int> Collapsed { get; }
/// <summary>Rejects malformed, oversized, duplicate or out-of-range geometry rather than allowing arbitrary browser data into commands.</summary>
public static DocumentScrollState? FromBrowserResult(string? json, int headingCount)
{
ArgumentOutOfRangeException.ThrowIfNegative(headingCount);
if(json is null || json.Length > headingCount * 128L + 512)
{
return null;
}
try
{
using var document = JsonDocument.Parse(json);
JsonElement root = document.RootElement;
if(root.ValueKind != JsonValueKind.Array || root.GetArrayLength() != 4 || !Number(root[0], out double position) || !Number(root[1], out double maximum) || maximum < 0 ||
root[2].ValueKind != JsonValueKind.Array || root[2].GetArrayLength() > headingCount || root[3].ValueKind != JsonValueKind.Array || root[3].GetArrayLength() > headingCount)
{
return null;
}
var headings = new Dictionary<int, double>();
foreach(JsonElement entry in root[2].EnumerateArray())
{
if(entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() != 2 || !Identity(entry[0], headingCount, out int index) || !Number(entry[1], out double top) || !headings.TryAdd(index, top))
{
return null;
}
}
var collapsed = new List<int>();
var seen = new HashSet<int>();
foreach(JsonElement entry in root[3].EnumerateArray())
{
if(!Identity(entry, headingCount, out int index) || !seen.Add(index))
{
return null;
}
collapsed.Add(index);
}
return new DocumentScrollState(position, maximum, headings, collapsed);
}
catch(JsonException)
{
return null;
}
}
/// <summary>Interpolates paired heading coordinates with explicit top/bottom anchors; no-heading documents use relative scrolling.</summary>
public static double MapPosition(double position, double fromMaximum, double toMaximum, IEnumerable<(double From, double To)> anchors)
{
ArgumentNullException.ThrowIfNull(anchors);
if(!double.IsFinite(position) || !double.IsFinite(fromMaximum) || !double.IsFinite(toMaximum) || fromMaximum <= 0 || toMaximum <= 0)
{
return 0;
}
double current = Math.Clamp(position, 0, fromMaximum);
double previousFrom = 0;
double previousTo = 0;
foreach((double from, double to) in anchors.Where(pair => double.IsFinite(pair.From) && double.IsFinite(pair.To) && pair.From > 0 && pair.From < fromMaximum).OrderBy(pair => pair.From))
{
if(from <= previousFrom)
{
continue;
}
double target = Math.Clamp(to, 0, toMaximum);
if(current <= from)
{
return Math.Clamp(previousTo + (current - previousFrom) / (from - previousFrom) * (target - previousTo), 0, toMaximum);
}
previousFrom = from;
previousTo = target;
}
return Math.Clamp(previousTo + (current - previousFrom) / (fromMaximum - previousFrom) * (toMaximum - previousTo), 0, toMaximum);
}
/// <summary>Only finite JSON numbers participate in pixel interpolation.</summary>
private static bool Number(JsonElement element, out double value)
{
value = 0;
return element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out value) && double.IsFinite(value);
}
/// <summary>Only the exact current generated heading range can be addressed.</summary>
private static bool Identity(JsonElement element, int count, out int value)
{
value = -1;
return element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out value) && value >= 0 && value < count;
}
}
@@ -28,11 +28,16 @@ internal sealed class DocumentSourceMap
{
ArgumentNullException.ThrowIfNull(source);
var ends = new List<int>();
int start = 0;
while(source.IndexOf('\n', start) is int end && end >= 0)
for(int index = 0; index < source.Length; index++)
{
ends.Add(end);
start = end + 1;
if(source[index] is '\r' or '\n')
{
ends.Add(index);
if(source[index] == '\r' && index + 1 < source.Length && source[index + 1] == '\n')
{
index++;
}
}
}
ends.Add(source.Length);
_lineEnds = ends.ToArray();
@@ -0,0 +1,197 @@
/*
* Copyright (c) 2026 Darin Kelkhoff
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text;
namespace MarkdownViewr.Windows.Core.Documents;
/// <summary>Mirrors section folds in a literal native display while preserving the original snapshot and source identities.</summary>
public sealed class DocumentSourceText
{
private const string Ellipsis = "⋯\r\n";
private readonly string _source;
private readonly string _native;
private readonly int[] _singleBreaks;
private readonly int[] _lineStarts;
private readonly DocumentSourceHeading[] _originalHeadings;
private readonly (int Index, int Start, int End)[] _sections;
private HashSet<int>? _collapsed;
private (int Start, int End)[] _ranges = [];
private int[] _rangeStarts = [];
private int[] _removed = [0];
/// <summary>Indexes original/native line coordinates once and derives section boundaries from parser identities and heading levels.</summary>
public DocumentSourceText(string source, DocumentOutline outline)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(outline);
if(source.Length > MarkdownRenderer.MaximumCharacters)
{
throw new ArgumentException("The source exceeds the document size limit.", nameof(source));
}
if(outline.SourceHeadings.Any(heading => heading.Start < 0 || heading.End < heading.Start || heading.End > source.Length))
{
throw new ArgumentException("Source positions must belong to the committed snapshot.", nameof(outline));
}
_source = source;
_native = source.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Replace("\n", "\r\n", StringComparison.Ordinal);
var singleBreaks = new List<int>();
var lineStarts = new List<int> { 0 };
for(int index = 0; index < source.Length; index++)
{
if(source[index] is '\r' or '\n')
{
if(source[index] == '\r' && index + 1 < source.Length && source[index + 1] == '\n')
{
index++;
}
else
{
singleBreaks.Add(index);
}
lineStarts.Add(index + 1);
}
}
_singleBreaks = singleBreaks.ToArray();
_lineStarts = lineStarts.ToArray();
_originalHeadings = outline.SourceHeadings.OrderBy(heading => heading.Start).ToArray();
_sections = new (int, int, int)[_originalHeadings.Length];
var stack = new Stack<int>();
for(int index = 0; index < _originalHeadings.Length; index++)
{
DocumentSourceHeading heading = _originalHeadings[index];
int level = outline.Headings[heading.Index].Level;
while(stack.TryPeek(out int previous) && outline.Headings[_originalHeadings[previous].Index].Level >= level)
{
stack.Pop();
(int Index, int Start, int End) section = _sections[previous];
_sections[previous] = (section.Index, section.Start, LineStart(heading.Start));
}
_sections[index] = (heading.Index, AfterLineBreak(heading.End), source.Length);
stack.Push(index);
}
SetCollapsed([]);
}
/// <summary>Visible literal text with Windows newlines and a plain ellipsis for omitted bodies, never HTML or rich text.</summary>
public string Text { get; private set; } = "";
/// <summary>Only visible headings, with offsets into the actual displayed native string.</summary>
public IReadOnlyList<DocumentSourceHeading> Headings { get; private set; } = [];
/// <summary>Nested folds remain independent; overlapping parent/child bodies merge without losing saved choices.</summary>
public bool SetCollapsed(IEnumerable<int> collapsed)
{
ArgumentNullException.ThrowIfNull(collapsed);
var choices = collapsed.ToHashSet();
if(_collapsed is not null && _collapsed.SetEquals(choices))
{
return false;
}
_collapsed = choices;
var ranges = new List<(int Start, int End)>();
foreach((int index, int start, int end) in _sections.Where(section => choices.Contains(section.Index) && section.Start < section.End).OrderBy(section => section.Start))
{
if(ranges.Count > 0 && start <= ranges[^1].End)
{
ranges[^1] = (ranges[^1].Start, Math.Max(end, ranges[^1].End));
}
else
{
ranges.Add((start, end));
}
}
_ranges = ranges.Select(range => (ToNative(range.Start), ToNative(range.End))).ToArray();
_rangeStarts = _ranges.Select(range => range.Start).ToArray();
_removed = new int[_ranges.Length + 1];
var text = new StringBuilder();
int cursor = 0;
for(int index = 0; index < _ranges.Length; index++)
{
(int start, int end) = _ranges[index];
text.Append(_native, cursor, start - cursor).Append(Ellipsis);
_removed[index + 1] = _removed[index] + end - start - Ellipsis.Length;
cursor = end;
}
text.Append(_native, cursor, _native.Length - cursor);
Text = text.ToString();
var headings = new List<DocumentSourceHeading>();
foreach(DocumentSourceHeading heading in _originalHeadings)
{
if(GetDisplayRange(heading.Start, heading.End - heading.Start) is { } range)
{
headings.Add(new DocumentSourceHeading(heading.Index, range.Start, range.Start + range.Length));
}
}
Headings = headings.AsReadOnly();
return true;
}
/// <summary>Maps original query spans into the visible native display; hidden or partly folded occurrences have no selection.</summary>
public (int Start, int Length)? GetDisplayRange(int start, int length)
{
ArgumentOutOfRangeException.ThrowIfNegative(start);
ArgumentOutOfRangeException.ThrowIfNegative(length);
if(start > _source.Length || length > _source.Length - start)
{
throw new ArgumentOutOfRangeException(nameof(length));
}
int nativeStart = ToNative(start);
int nativeEnd = ToNative(start + length);
int index = Array.BinarySearch(_rangeStarts, nativeStart);
index = index >= 0 ? index : ~index - 1;
if(index >= 0 && nativeStart < _ranges[index].End || index + 1 < _ranges.Length && nativeEnd > _ranges[index + 1].Start)
{
return null;
}
return (nativeStart - _removed[index + 1], nativeEnd - nativeStart);
}
/// <summary>Each original lone CR/LF expands by one native character; existing CRLF and Unicode text keep their lengths.</summary>
private int ToNative(int position)
{
int index = Array.BinarySearch(_singleBreaks, position);
return position + (index >= 0 ? index : ~index);
}
/// <summary>Preserves the complete next heading line, including quote/list prefixes.</summary>
private int LineStart(int position)
{
int index = Array.BinarySearch(_lineStarts, position);
return _lineStarts[index >= 0 ? index : ~index - 1];
}
/// <summary>A fold starts after the full ATX/Setext heading line and its original line break.</summary>
private int AfterLineBreak(int position)
{
if(position < _source.Length && _source[position] == '\r')
{
position++;
}
if(position < _source.Length && _source[position] == '\n')
{
position++;
}
return position;
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows;
/// <summary>Provides fixed numeric reading geometry without authored event handlers, scripts or messages.</summary>
internal sealed partial class DocumentBrowser
{
/// <summary>Captures actual visible heading tops and hidden generated bodies only for the matching ready document.</summary>
public async Task<Core.Documents.DocumentScrollState?> CaptureScrollStateAsync()
{
Uri document = _policy.DocumentUri;
ulong navigation = _navigationId;
string? json = await ExecuteDocumentScriptAsync(FormattableString.Invariant($$"""
(()=>{
const s=document.scrollingElement;if(!s)return null;
const y=s.scrollTop;const headings=[];const collapsed=[];
for(let i=0;i<{{_headings.Count}};i++){
const h=document.getElementById('mv-heading-'+i);
if(h&&h.offsetParent!==null)headings.push([i,h.getBoundingClientRect().top+y]);
const b=document.querySelector('[data-mv-body="'+i+'"]');
if(b&&b.hidden)collapsed.push(i);
}
return [y,Math.max(0,s.scrollHeight-s.clientHeight),headings,collapsed];
})()
"""));
return IsMatchingFindDocument(document, navigation) ? Core.Documents.DocumentScrollState.FromBrowserResult(json, _headings.Count) : null;
}
/// <summary>Scrolls only with a finite nonnegative host-computed pixel value; source text is never executable input.</summary>
public Task ScrollToSourcePositionAsync(double top)
{
if(!double.IsFinite(top) || top < 0)
{
return Task.CompletedTask;
}
return ExecuteDocumentScriptAsync(FormattableString.Invariant($"window.scrollTo({{top:{top:R},behavior:'instant'}});"));
}
}
+7 -2
View File
@@ -117,7 +117,11 @@ public sealed partial class MainWindow
_findQueued = false;
int generation = _findGeneration;
string snapshot = _sourceMarkdown;
var find = new Core.Documents.DocumentFind(_settings.ShowSource ? SourceText.Text : snapshot, FindText.Text);
if(FindText.Text.Length > 0)
{
ExpandSourceForFind();
}
var find = new Core.Documents.DocumentFind(snapshot, FindText.Text);
if(find.Query.Length == 0)
{
await _browser.EndFindAsync();
@@ -160,9 +164,10 @@ public sealed partial class MainWindow
FindCountText.Text = FormattableString.Invariant($"{current} of {total}");
FindNextButton.IsEnabled = total > 0 && !_finding;
FindPreviousButton.IsEnabled = total > 0 && !_finding;
if(_settings.ShowSource && _find?.SourceSelection is { } selection)
if(_settings.ShowSource && _find?.SourceSelection is { } original && _sourceDocument?.GetDisplayRange(original.Start, original.Length) is { } selection)
{
SourceText.Select(selection.Start, selection.Length);
HoldSourceSync();
}
else
{
@@ -0,0 +1,209 @@
/*
* Copyright (c) 2026 Darin Kelkhoff
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
namespace MarkdownViewr.Windows;
/// <summary>Synchronizes only owned native/browser viewports through validated numeric heading geometry.</summary>
public sealed partial class MainWindow
{
private Core.Documents.DocumentSourceText? _sourceDocument;
private ScrollViewer? _sourceScroller;
private Microsoft.UI.Dispatching.DispatcherQueueTimer? _sourceSyncTimer;
private bool _sourceSyncRunning;
private bool _sourceScrolled;
private bool _sourceCaptureAfterHold;
private long _sourceSyncHoldUntil;
private double _sourceLastY = double.NaN;
private double _sourceReaderLastY = double.NaN;
/// <summary>Polls fixed ready-page geometry without enabling authored scripts or messages; closing releases the timer.</summary>
private void InitializeSourceSync()
{
_sourceSyncTimer = DispatcherQueue.CreateTimer();
_sourceSyncTimer.Interval = TimeSpan.FromMilliseconds(150);
_sourceSyncTimer.Tick += async (_, _) => await SynchronizeSourceAsync();
SourceText.Loaded += (_, _) => AttachSourceScroller();
_browser.DocumentReady += (_, _) => _sourceReaderLastY = double.NaN;
Closed += (_, _) => _sourceSyncTimer.Stop();
_sourceSyncTimer.Start();
}
/// <summary>Owns the actual native text viewport; source gestures can originate from mouse, keyboard or accessibility.</summary>
private void AttachSourceScroller()
{
if(_sourceScroller is not null)
{
return;
}
SourceText.ApplyTemplate();
_sourceScroller = FindSourceScroller(SourceText);
if(_sourceScroller is not null)
{
_sourceScroller.ViewChanged += (_, _) =>
{
if(Environment.TickCount64 >= _sourceSyncHoldUntil)
{
_sourceScrolled = true;
}
};
}
}
/// <summary>Walks only this owned control's native visual tree, never other applications or desktop windows.</summary>
private static ScrollViewer? FindSourceScroller(DependencyObject element)
{
if(element is ScrollViewer scroller)
{
return scroller;
}
for(int index = 0; index < VisualTreeHelper.GetChildrenCount(element); index++)
{
if(FindSourceScroller(VisualTreeHelper.GetChild(element, index)) is { } found)
{
return found;
}
}
return null;
}
/// <summary>Search temporarily expands literal source; the canonical query/count remains independent of folding.</summary>
private void ExpandSourceForFind()
{
if(_sourceDocument is not null && _sourceDocument.SetCollapsed([]))
{
RefreshSource();
}
HoldSourceSync();
}
/// <summary>Find's explicit paired selection/viewport operations cannot be overwritten by scroll feedback.</summary>
private void HoldSourceSync(bool preserveSelection = true)
{
_sourceSyncHoldUntil = Environment.TickCount64 + 250;
_sourceScrolled = false;
_sourceCaptureAfterHold = preserveSelection;
_sourceLastY = _sourceScroller?.VerticalOffset ?? double.NaN;
_sourceReaderLastY = double.NaN;
}
/// <summary>Applies only current snapshot/query/page geometry and prevents reverse feedback after its own native movements.</summary>
private async Task SynchronizeSourceAsync()
{
if(_closed || _sourceSyncRunning || !_settings.ShowSource || _finding || !_browser.IsReady || _sourceDocument is not { } source)
{
return;
}
AttachSourceScroller();
if(_sourceScroller is not { } scroller)
{
return;
}
_sourceSyncRunning = true;
int findGeneration = _findGeneration;
try
{
Core.Documents.DocumentScrollState? frame = await _browser.CaptureScrollStateAsync();
if(frame is null || _closed || !ReferenceEquals(source, _sourceDocument) || findGeneration != _findGeneration || !_settings.ShowSource || !_browser.IsReady || _finding)
{
return;
}
if(source.SetCollapsed(frame.Collapsed))
{
RefreshSource();
if(FindBar.Visibility == Visibility.Visible && _find is not null)
{
RefreshFindResult();
}
return;
}
if(Environment.TickCount64 < _sourceSyncHoldUntil)
{
return;
}
if(_sourceCaptureAfterHold)
{
_sourceCaptureAfterHold = false;
_sourceScrolled = false;
_sourceLastY = scroller.VerticalOffset;
_sourceReaderLastY = frame.Position;
return;
}
await SynchronizeViewportsAsync(source, scroller, frame);
}
catch(Exception exception) when(exception is System.Runtime.InteropServices.COMException or InvalidOperationException or ArgumentException)
{
if(!_closed)
{
App.Log.Warning("The source reading position could not be synchronized.");
}
}
finally
{
_sourceSyncRunning = false;
}
}
/// <summary>Pairs visible native character rectangles with generated heading tops and moves only the surface that follows.</summary>
private async Task SynchronizeViewportsAsync(Core.Documents.DocumentSourceText source, ScrollViewer scroller, Core.Documents.DocumentScrollState frame)
{
var anchors = new List<(double Source, double Reader)>();
foreach(Core.Documents.DocumentSourceHeading heading in source.Headings)
{
if(heading.Start < SourceText.Text.Length && frame.Headings.TryGetValue(heading.Index, out double readerTop))
{
global::Windows.Foundation.Rect rectangle = SourceText.GetRectFromCharacterIndex(heading.Start, trailingEdge: false);
double sourceTop = rectangle.Y;
if(rectangle.Height > 0 && double.IsFinite(sourceTop))
{
anchors.Add((sourceTop, readerTop));
}
}
}
double sourceY = scroller.VerticalOffset;
if(string.Equals(Environment.GetEnvironmentVariable("MARKDOWNVIEWR_INPUT_TRACE"), "1", StringComparison.Ordinal) &&
(_sourceScrolled || !double.IsFinite(_sourceReaderLastY) || Math.Abs(frame.Position - _sourceReaderLastY) > 0.5))
{
App.Log.Info(FormattableString.Invariant($"Input trace: source geometry source={sourceY:R}/{scroller.ScrollableHeight:R}, reader={frame.Position:R}/{frame.Maximum:R}, paired={anchors.Count}."));
}
bool sourceMoved = _sourceScrolled || double.IsFinite(_sourceLastY) && Math.Abs(sourceY - _sourceLastY) > 0.5;
_sourceScrolled = false;
if(sourceMoved && double.IsFinite(_sourceReaderLastY))
{
double target = Core.Documents.DocumentScrollState.MapPosition(sourceY, scroller.ScrollableHeight, frame.Maximum, anchors.Select(pair => (pair.Source, pair.Reader)));
_sourceSyncHoldUntil = Environment.TickCount64 + 200;
await _browser.ScrollToSourcePositionAsync(target);
_sourceLastY = sourceY;
_sourceReaderLastY = target;
}
else if(!double.IsFinite(_sourceReaderLastY) || Math.Abs(frame.Position - _sourceReaderLastY) > 0.5)
{
double target = Core.Documents.DocumentScrollState.MapPosition(frame.Position, frame.Maximum, scroller.ScrollableHeight, anchors.Select(pair => (pair.Reader, pair.Source)));
_sourceSyncHoldUntil = Environment.TickCount64 + 200;
scroller.ChangeView(null, target, null, disableAnimation: true);
_sourceLastY = target;
_sourceReaderLastY = frame.Position;
}
}
}
@@ -21,6 +21,7 @@ public sealed partial class MainWindow
{
SourceToggle.IsChecked = _settings.ShowSource;
RefreshSource();
InitializeSourceSync();
}
@@ -30,10 +31,11 @@ public sealed partial class MainWindow
{
ApplyReadingWidths();
SourceText.Visibility = _settings.ShowSource ? Visibility.Visible : Visibility.Collapsed;
string text = _settings.ShowSource ? _sourceMarkdown : "";
string text = _settings.ShowSource ? _sourceDocument?.Text ?? _sourceMarkdown : "";
if(!string.Equals(SourceText.Text, text, StringComparison.Ordinal))
{
SourceText.Text = text;
HoldSourceSync(preserveSelection: false);
}
}
+2 -1
View File
@@ -289,8 +289,9 @@ public sealed partial class MainWindow : Window, IDisposable
_findGeneration++;
_documentKeyboard?.Reset();
_sourceMarkdown = files.Markdown;
RefreshSource();
_outline = outline;
_sourceDocument = new Core.Documents.DocumentSourceText(_sourceMarkdown, outline);
RefreshSource();
RefreshOutline();
_documentPath = Path.GetFullPath(path);
RefreshEditorCommands();
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Documents;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Proves bounded fixed-query geometry and paired heading interpolation without executing arbitrary data.</summary>
public sealed class DocumentScrollStateTests
{
/// <summary>Finite generated geometry is preserved and viewport overshoot is bounded.</summary>
[Fact]
public void FromBrowserResult_ValidFrame_PreservesGeneratedGeometry()
{
DocumentScrollState state = DocumentScrollState.FromBrowserResult("[120,100,[[0,12],[2,90]],[1,2]]", 3)!;
Assert.Equal(100, state.Position);
Assert.Equal(100, state.Maximum);
Assert.Equal(12, state.Headings[0]);
Assert.Equal(90, state.Headings[2]);
Assert.Equal(new[] { 1, 2 }, state.Collapsed);
Assert.Equal(0, DocumentScrollState.FromBrowserResult("[-5,100,[],[]]", 0)!.Position);
Assert.Equal(0, DocumentScrollState.FromBrowserResult("[0,0,[],[]]", 0)!.Maximum);
}
/// <summary>Untrusted shapes, identities, duplicate entries and nonfinite geometry cannot become scroll commands.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("null")]
[InlineData("{}")]
[InlineData("[0,100,[]]")]
[InlineData("[\"0\",100,[],[]]")]
[InlineData("[0,-1,[],[]]")]
[InlineData("[0,1e999,[],[]]")]
[InlineData("[0,100,{},[]]")]
[InlineData("[0,100,[],{}]")]
[InlineData("[0,100,[[0,1],[1,2],[2,3]],[]]")]
[InlineData("[0,100,[],[0,1,1]]")]
[InlineData("[0,100,[0],[]]")]
[InlineData("[0,100,[[0]],[]]")]
[InlineData("[0,100,[[\"0\",1]],[]]")]
[InlineData("[0,100,[[-1,1]],[]]")]
[InlineData("[0,100,[[2,1]],[]]")]
[InlineData("[0,100,[[0.5,1]],[]]")]
[InlineData("[0,100,[[0,\"one\"]],[]]")]
[InlineData("[0,100,[[0,1e999]],[]]")]
[InlineData("[0,100,[[0,1],[0,2]],[]]")]
[InlineData("[0,100,[],[0,0]]")]
[InlineData("[0,100,[],[\"0\"]]")]
[InlineData("[0,100,[],[2]]")]
public void FromBrowserResult_InvalidFrame_ReturnsNoGeometry(string? json)
{
Assert.Null(DocumentScrollState.FromBrowserResult(json, 2));
}
/// <summary>Query payload size scales only with the current generated heading count.</summary>
[Fact]
public void FromBrowserResult_OversizedOrInvalidCount_RejectsFrame()
{
Assert.Null(DocumentScrollState.FromBrowserResult(new string(' ', 513), 0));
Assert.Throws<ArgumentOutOfRangeException>(() => DocumentScrollState.FromBrowserResult("[]", -1));
}
/// <summary>Paired anchors retain viewport fractions between headings and reach exact document ends.</summary>
[Theory]
[InlineData(-1, 0)]
[InlineData(0, 0)]
[InlineData(12.5, 50)]
[InlineData(25, 100)]
[InlineData(50, 120)]
[InlineData(75, 140)]
[InlineData(87.5, 170)]
[InlineData(100, 200)]
[InlineData(110, 200)]
public void MapPosition_PairedHeadings_InterpolatesAndClamps(double position, double expected)
{
Assert.Equal(expected, DocumentScrollState.MapPosition(position, 100, 200, [(75, 140), (25, 100)]));
}
/// <summary>No headings use relative scroll; duplicate/nonfinite/off-range anchors cannot corrupt interpolation.</summary>
[Fact]
public void MapPosition_MissingOrUnusableAnchors_UsesBoundedGeometry()
{
Assert.Equal(200, DocumentScrollState.MapPosition(50, 100, 400, []));
Assert.Equal(120, DocumentScrollState.MapPosition(50, 100, 200, [(25, 100), (25, 110), (75, 140), (double.NaN, 1), (1, double.PositiveInfinity), (-1, 1), (100, 1)]));
Assert.Equal(100, DocumentScrollState.MapPosition(25, 100, 100, [(25, 200)]));
Assert.Equal(0, DocumentScrollState.MapPosition(25, 100, 100, [(25, -1)]));
Assert.Equal(0, DocumentScrollState.MapPosition(double.NaN, 100, 100, []));
Assert.Equal(0, DocumentScrollState.MapPosition(0, double.PositiveInfinity, 100, []));
Assert.Equal(0, DocumentScrollState.MapPosition(0, 100, double.NaN, []));
Assert.Equal(0, DocumentScrollState.MapPosition(0, 0, 100, []));
Assert.Equal(0, DocumentScrollState.MapPosition(0, 100, -1, []));
Assert.Throws<ArgumentNullException>(() => DocumentScrollState.MapPosition(0, 1, 1, null!));
}
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Documents;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Proves literal nested folding and original-to-native selection offsets without mutating document data.</summary>
public sealed class DocumentSourceTextTests
{
/// <summary>Setext and fenced pseudo-headings retain correct independent body boundaries across native line endings.</summary>
[Theory]
[InlineData("\n")]
[InlineData("\r\n")]
[InlineData("\r")]
public void SetCollapsed_NestedSections_PreservesLiteralSyntaxAndChoices(string newline)
{
string original = string.Join(newline, "😀 Intro", "", "Parent", "======", "Parent body", "", "## Child", "Child body", "```md", "# Fake", "```", "", "> ## Peer", "> Peer body", "", "# Last", "Tail");
var source = new DocumentSourceText(original, new DocumentOutline(original));
Assert.Equal(original.ReplaceLineEndings("\r\n"), source.Text);
Assert.Equal(4, source.Headings.Count);
Assert.True(source.SetCollapsed([1]));
Assert.Contains("Parent body", source.Text, StringComparison.Ordinal);
Assert.Contains("## Child\r\n⋯\r\n> ## Peer", source.Text, StringComparison.Ordinal);
Assert.DoesNotContain("# Fake", source.Text, StringComparison.Ordinal);
Assert.Null(source.GetDisplayRange(original.IndexOf("Child body", StringComparison.Ordinal), 5));
Assert.True(source.SetCollapsed([0, 1]));
Assert.Contains("Parent\r\n======\r\n⋯\r\n# Last", source.Text, StringComparison.Ordinal);
Assert.DoesNotContain("Child", source.Text, StringComparison.Ordinal);
Assert.Equal(new[] { 0, 3 }, source.Headings.Select(heading => heading.Index));
Assert.True(source.SetCollapsed([1]));
Assert.Contains("> ## Peer", source.Text, StringComparison.Ordinal);
Assert.DoesNotContain("Child body", source.Text, StringComparison.Ordinal);
Assert.False(source.SetCollapsed([1, 1]));
Assert.True(source.SetCollapsed([]));
Assert.Equal(original.ReplaceLineEndings("\r\n"), source.Text);
}
/// <summary>Selections and heading anchors remain exact after omitted bodies and UTF-16/native newline conversion.</summary>
[Fact]
public void GetDisplayRange_MixedNewlinesAndFold_MapsLiteralOccurrence()
{
const string Original = "😀 Intro\r\n# One\nHidden\n# Two\rVisible\r\n# Three";
var source = new DocumentSourceText(Original, new DocumentOutline(Original));
source.SetCollapsed([0]);
int start = Original.IndexOf("Visible", StringComparison.Ordinal);
(int Start, int Length) range = source.GetDisplayRange(start, "Visible".Length)!.Value;
Assert.Equal("Visible", source.Text.Substring(range.Start, range.Length));
Assert.Equal(source.Text.IndexOf("# Two", StringComparison.Ordinal), source.Headings.Single(heading => heading.Index == 1).Start);
Assert.Equal("# Two", source.Text[source.Headings[1].Start..source.Headings[1].End]);
Assert.Null(source.GetDisplayRange(Original.IndexOf("Hidden", StringComparison.Ordinal) - 1, 8));
Assert.Equal((source.Text.Length, 0), source.GetDisplayRange(Original.Length, 0));
Assert.Equal((0, 2), source.GetDisplayRange(0, 2));
Assert.Throws<ArgumentOutOfRangeException>(() => source.GetDisplayRange(-1, 0));
Assert.Throws<ArgumentOutOfRangeException>(() => source.GetDisplayRange(0, -1));
Assert.Throws<ArgumentOutOfRangeException>(() => source.GetDisplayRange(Original.Length + 1, 0));
Assert.Throws<ArgumentOutOfRangeException>(() => source.GetDisplayRange(0, Original.Length + 1));
}
/// <summary>Empty bodies, ordinary content and invalid collapse indices cannot hide another section or manufacture headings.</summary>
[Theory]
[InlineData("")]
[InlineData("ordinary literal <script> text")]
[InlineData("# Empty\n# Next")]
public void SetCollapsed_NoBodyOrInvalidIndices_RetainsText(string original)
{
var source = new DocumentSourceText(original, new DocumentOutline(original));
source.SetCollapsed([-1, 0, 99]);
Assert.Equal(original.ReplaceLineEndings("\r\n"), source.Text);
Assert.Throws<ArgumentNullException>(() => source.SetCollapsed(null!));
}
/// <summary>Raw tags on one line are retained literally; same-line peer headings cannot produce reversed body ranges.</summary>
[Fact]
public void SetCollapsed_RawSameLinePeers_RetainsHeadingLine()
{
const string Original = "<h2>One</h2><h2>Two</h2>\nBody\n\n# Last\nTail";
var source = new DocumentSourceText(Original, new DocumentOutline(Original));
source.SetCollapsed([0, 1]);
Assert.Contains("<h2>One</h2><h2>Two</h2>", source.Text, StringComparison.Ordinal);
Assert.DoesNotContain("Body", source.Text, StringComparison.Ordinal);
Assert.Contains("# Last\r\nTail", source.Text, StringComparison.Ordinal);
}
/// <summary>Folding accepts only bounded canonical snapshots with matching source anchors.</summary>
[Fact]
public void Create_InvalidSnapshot_RejectsInput()
{
var outline = new DocumentOutline("# Heading");
Assert.Throws<ArgumentNullException>(() => new DocumentSourceText(null!, outline));
Assert.Throws<ArgumentNullException>(() => new DocumentSourceText("", null!));
Assert.Throws<ArgumentException>(() => new DocumentSourceText(new string('x', MarkdownRenderer.MaximumCharacters + 1), outline));
Assert.Throws<ArgumentException>(() => new DocumentSourceText("", outline));
}
}
@@ -343,6 +343,135 @@ public class AppTests
}
/// <summary>Actual reader jumps, native source text-range scrolling and physical source wheel input synchronize both viewports.</summary>
[Fact]
public void Source_BidirectionalScroll_AlignsActualHeadingViewports()
{
string body = string.Join("\n\n", Enumerable.Range(0, 80).Select(index => "Source sync paragraph " + index));
using var session = new AppSession(document: "# Source sync root\n\n" + body + "\n\n## Source sync middle\n\n" + body + "\n\n## Source sync last\n\n" + body);
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Source sync root")), "the source sync document", TimeSpan.FromSeconds(30));
session.InvokeMenuItem("View", "SourceToggle");
AutomationElement source = session.WaitForElement("SourceText");
session.InvokeMenuItem("View", "OutlineToggle");
session.ClickElement("OutlineHeading:2");
WaitForSourceHeadingAlignment(session, source, "## Source sync last");
FlaUI.Core.ITextRange middle = source.Patterns.Text.Pattern.DocumentRange.FindText("## Source sync middle", backward: false, ignoreCase: false);
Assert.NotNull(middle);
middle.ScrollIntoView(alignToTop: true);
Wait.UntilInputIsProcessed();
WaitForBrowserHeadingAlignment(session, "mv-heading-1");
session.ClickElement("OutlineHeading:2");
WaitForSourceHeadingAlignment(session, source, "## Source sync last");
AutomationElement browserScroller = FindDocumentScroller(session);
double previous = browserScroller.Patterns.Scroll.Pattern.VerticalScrollPercent.Value;
session.MouseClickElement("SourceText");
Mouse.Scroll(-3);
Wait.UntilInputIsProcessed();
WaitForReaderScroll(session, browserScroller, value => value > previous + 0.2, "physical source-wheel scrolling mirrored in the reader");
Assert.Contains("## Source sync middle", source.Patterns.Value.Pattern.Value.Value, StringComparison.Ordinal);
}
/// <summary>Actual nested collapse mirrors source, search reveals original content and reload/expansion retains independent choices.</summary>
[Fact]
public void Source_CollapseFindReload_PreservesCanonicalSnapshot()
{
const string Markdown = "# Source fold root\n\nRoot body\n\n## Source fold child\n\nChild body\n\n### Source fold grandchild\n\nGrand body\n\n```md\n# Fence pseudo heading\n```\n\nSource fold peer\n----------------\nPeer body\n\n# Source fold last\nTail";
using var session = new AppSession(document: Markdown);
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Source fold root")), "the source folding document", TimeSpan.FromSeconds(30));
session.InvokeMenuItem("View", "SourceToggle");
AutomationElement source = session.WaitForElement("SourceText");
session.InvokeMenuItem("View", "OutlineToggle");
session.WaitForElement("OutlineDepth").AsComboBox().Select(2);
session.ClickElement("OutlineHeading:2");
session.ClickElement("ToggleSection");
WaitForSourceValue(session, source, value => !value.Contains("Grand body", StringComparison.Ordinal) && !value.Contains("# Fence pseudo", StringComparison.Ordinal) && value.Contains("### Source fold grandchild", StringComparison.Ordinal), "the folded native grandchild body and fence");
session.ClickElement("OutlineHeading:1");
session.ClickElement("ToggleSection");
WaitForSourceValue(session, source, value => !value.Contains("### Source fold grandchild", StringComparison.Ordinal) && value.Contains("Source fold peer\r\n----------------", StringComparison.Ordinal), "the folded parent and retained Setext peer");
session.InvokeMenuItem("Edit", "FindMenuItem");
session.EnterText("FindText", "Grand body");
WaitForFindCount(session, "1 of 1");
Assert.Equal("Grand body", ReadSourceSelection(source));
WaitForSourceValue(session, source, value => value.Contains("# Fence pseudo heading", StringComparison.Ordinal), "literal fenced source revealed for find");
session.ClickElement("FindCloseButton");
WaitForSourceValue(session, source, value => !value.Contains("Grand body", StringComparison.Ordinal) && !value.Contains("### Source fold grandchild", StringComparison.Ordinal), "pre-search source folds restored");
session.ClickElement("OutlineHeading:1");
session.ClickElement("ToggleSection");
WaitForSourceValue(session, source, value => value.Contains("Child body", StringComparison.Ordinal) && value.Contains("### Source fold grandchild", StringComparison.Ordinal) && !value.Contains("Grand body", StringComparison.Ordinal), "expanded parent with independently folded child");
string replacement = Markdown.Replace("Grand body", "Grand body reloaded", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(session.DataDirectory, "document.md"), replacement);
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Reloaded document.md")), "the committed source folding reload");
session.ClickElement("OutlineHeading:2");
session.ClickElement("ToggleSection");
WaitForSourceValue(session, source, value => value.Contains("Grand body reloaded", StringComparison.Ordinal), "the reloaded canonical body after expansion");
Assert.Equal(replacement, source.Patterns.Value.Pattern.Value.Value.ReplaceLineEndings("\n"));
}
/// <summary>Checks actual source-provider glyph rectangles against the owned viewport, retaining exact geometry on failure.</summary>
private void WaitForSourceHeadingAlignment(AppSession session, AutomationElement source, string text)
{
try
{
session.WaitFor(() =>
{
FlaUI.Core.ITextRange range = source.Patterns.Text.Pattern.DocumentRange.FindText(text, backward: false, ignoreCase: false);
System.Drawing.Rectangle[] rectangles = range.GetBoundingRectangles();
return rectangles.Length > 0 && rectangles[0].Top - source.BoundingRectangle.Top is >= -2 and <= 36 ? source : null;
}, "the actual native source heading aligned with its viewport");
}
catch(TimeoutException)
{
_output.WriteLine(session.ReadLogs());
_output.WriteLine(session.DescribeDocumentStatus());
throw;
}
}
/// <summary>Checks the generated browser heading's actual viewport location, never a mirrored application property.</summary>
private void WaitForBrowserHeadingAlignment(AppSession session, string identity)
{
try
{
session.WaitFor(() =>
{
AutomationElement? heading = session.FindInApp(cf => cf.ByAutomationId(identity));
double top = session.WaitForElement("DocumentView").BoundingRectangle.Top;
return heading is not null && heading.BoundingRectangle.Top - top is >= -2 and <= 36 ? heading : null;
}, "the actual reader heading aligned after native source scrolling");
}
catch(TimeoutException)
{
_output.WriteLine(session.ReadLogs());
_output.WriteLine(session.DescribeDocumentStatus());
throw;
}
}
/// <summary>Reads the native source value until the expected real presentation arrives, retaining owned fixture evidence.</summary>
private void WaitForSourceValue(AppSession session, AutomationElement source, Func<string, bool> matches, string description)
{
try
{
session.WaitFor(() => matches(source.Patterns.Value.Pattern.Value.Value) ? source : null, description);
}
catch(TimeoutException)
{
_output.WriteLine(session.ReadLogs());
_output.WriteLine(session.DescribeDocumentStatus());
throw;
}
}
/// <summary>Ctrl+F accepts physical typing and pairs the actual source selection with wrapping browser occurrences.</summary>
[Fact]
public void Find_PairedMatches_UpdatesCounterAndSourceSelection()