feat(find): pair native document and source search

Add one Ctrl+F toolbar with literal source positions, native browser highlights, shared counts and wrapping navigation. Serialize query/reload updates, restore section choices and keep scripts/messages/host objects disabled. Add real keyboard, selection, viewport, hostile-query/reload and fragment-link acceptance.

Refs: #1, #9, #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-10 22:30:13 -05:00
1 parent 4bf0b81b85
commit 4f4280921c
15 files changed
+883 -23

No files matched your search

+3 -2
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 and outline controls are implemented with their new browser acceptance pending; source/find 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. 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.
A Windows Markdown viewer with live reload, themes and document navigation.
@@ -34,7 +34,8 @@ 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.
- **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 synchronization and paired find remain in development.
- **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.
- **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 and vim keys remain in development; paired-find desktop 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
@@ -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 and resizable outline/depth/jump/section controls are implemented, with hosted acceptance 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 native browser/source find is implemented; its new keyboard/selection/viewport/reload acceptance is pending. The full feature list below remains the target; the first reader is not a completed port.
## Reference and feature scope
+26
View File
@@ -142,3 +142,29 @@ real 70-pixel source-divider drag; normalize only native line-ending presentatio
Source/outline resize and frontmatter remain unverified until exact hosted results.
All 9ff820f producers are now terminal; the follow-up may be pushed without cancelling them.
Native paired-find research retained in artifacts/native-find-notes.md; full scope active.
## Verified native input/source and paired find increment
4bf0b81b85e99a9f583c5e60b7276e49b423b114 CI 38106991467 completed SUCCESS.
Exact TRX: 34/34 loose native/UI, 34/34 published, stable/dev 33/33 each; no skips.
Real frontmatter switching, outline resize and literal-source/read-only/reload/physical
resize all pass. CodeQL 38106991641/scripts 38106991033/security 38106991494 passed.
Four payload gates verified 536 files; stable 0.1→0.4 and dev beta.1→beta.2 probes
called AppKit apply/restarted within their channels. Retained artifacts:
artifacts/native-move-installed-38106991467, plus native/installed verified job logs.
These synthetic unsigned fixtures remain distinct from production signing proof.
Next increment implements paired find: pure bounded literal source positions/counts,
native WebView2.Find with suppressed default dialog, Ctrl+F toolbar, wrapping controls,
Enter/Shift+Enter/Escape, actual source selection and query/page lifecycle guards.
Search temporarily reveals sections; closing restores prior/explicit choices. Browser
feature accelerators are disabled so the host owns its commands; scripts/messages/host
objects stay disabled. No new dependencies. 179 core tests pass with 100% lines; build
zero warnings and format pass. New hosted tests exercise physical Ctrl+F typing from
browser focus, source provider ranges, native viewport wrap, collapse restoration,
literal hostile-looking terms and reload/empty counts. Physical local-fragment reveal
also added. No local UI input. Observe exact new hosted handles after signed push.
Source heading/scroll/collapse synchronization and vim keys remain issue #10 scope.
Full PRODUCT.md, editor/print/multiwindow/activation/reference/standards/signing work
remain active. Do not claim new find acceptance or completed port before its evidence.
+6 -4
View File
@@ -18,13 +18,15 @@
- [x] Complete hosted theme editor/validation/restoration acceptance (issue #7): CI 38102867556 passed; size 17 edit also passes in 29/30 later UI tests.
- [x] Implement persistent resizable TOC/depth, safe heading/local-fragment jumps and independent nested section collapse (issue #9).
- [x] Verify hosted heading jumps, independent nested collapse and theme/reload state on 5c3988e (31/33 UI pass).
- [ ] Verify physical sidebar resize and frontmatter input follow-up; keep exact assertions.
- [x] Verify physical sidebar/source resize and frontmatter modes: CI 38106991467, 34 native/published and 33 per installed channel, no skips.
- [x] Implement literal read-only source pane, saved visibility/width and shared committed reload snapshots (issue #10).
- [ ] Verify hosted source visibility/read-only/literal/reload acceptance and physical source resize.
- [ ] Add synchronized headings/scroll/collapse, paired find and vim keys (issue #10).
- [x] Verify source visibility/read-only/literal/reload and physical resize in native, published and both installed builds on 4bf0b81.
- [x] Implement paired native browser/source find with bounded literal terms, shared counts, wraparound and reload guards (issue #10).
- [ ] Verify actual Ctrl+F typing, source ranges, native match viewport, collapse restoration and literal/reload queries.
- [ ] Add synchronized headings/scroll/collapse and vim keys (issue #10); audit find/source collapse parity against the pinned reference.
- [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.
- [ ] Verify actual hosted frontmatter mode switching.
- [x] Verify actual hosted frontmatter mode switching on 4bf0b81 (including both installed channels).
- [ ] Add safe configured editor launch and printing options.
- [ ] Add multiple document windows, recent files and Windows activation/file associations.
- [x] Verify the all-rule two-pass PSScriptAnalyzer mitigation on scripts 38104988748 and 38105483489; closes #5 only on reviewed merge.
+19 -2
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. User-theme editing, syntax colors and 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 synchronized source navigation 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
@@ -54,6 +54,22 @@ fixed restore expression. No strings from documents become executable script.
Any later document-created JavaScript or native message bridge requires a revised CSP,
origin/nonce/schema checks and tests before enabling it. Custom CSS must not introduce network or file access.
## Native source and find
The optional source surface is a native read-only TextBox holding the same bounded,
literal snapshot as the reader. It does not parse HTML. Core find uses case-insensitive,
nonoverlapping UTF-16 source positions and pairs counts with the rendered surface.
Use the pinned runtime's [native finder](https://learn.microsoft.com/en-us/dotnet/api/microsoft.web.webview2.core.corewebview2find.startasync), suppress its default dialog, and provide one native Ctrl+F toolbar.
Query strings enter the native API directly; no search term becomes executable script.
Disable browser feature accelerators so the host owns find/print/reload commands;
[standard movement, selection and copy shortcuts remain available](https://learn.microsoft.com/en-us/dotnet/api/microsoft.web.webview2.core.corewebview2settings.arebrowseracceleratorkeysenabled?view=webview2-dotnet-1.0.4191.47).
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, mirrored collapse
and vim keys remain separate work. Browser scripts, messages and host objects remain disabled.
## Verification and follow-up
Core regression tests cover GFM, hostile HTML and resource paths at 100% core line
@@ -62,7 +78,8 @@ a real junction pointing outside the document folder. Browser/UI acceptance is a
separate gate; these unit tests do not prove browser behavior or release signing.
- [ ] Review this decision and complete document UI acceptance.
- [ ] Complete hosted save/replacement/deletion/scroll acceptance; add navigation, themes, source/find and print from PRODUCT.md.
- [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.
- [ ] Verify WebView2 runtime deployment and exact signed installed release artifacts.
## References
+3 -2
View File
@@ -26,6 +26,7 @@ physical directory. It does not authorize native commands or remote resource acc
| 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 |
| 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 |
| Local images | Per-document URI prefix; one decode; filename/extension policy; final open-handle containment; 16 MiB limit |
@@ -51,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 outline/collapse acceptance, 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. New find acceptance, synchronized source/collapse, external editor commands,
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 frontmatter/outline acceptance remains required.
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.
Historical EX-0002 does not authorize unsigned production publication. Azure setup
and real final-artifact signature validation are deferred; an active profile alone
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Documents;
/// <summary>Pairs literal source occurrences with rendered matches without counting the two views twice.</summary>
public sealed class DocumentFind
{
/// <summary>Bounds a native search term independently of document size.</summary>
public const int MaximumQueryCharacters = 256;
private int _renderedCount;
/// <summary>Finds nonoverlapping case-insensitive literal text; user input is never a regular expression.</summary>
public DocumentFind(string source, string query)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(query);
if(source.Length > MarkdownRenderer.MaximumCharacters || query.Length > MaximumQueryCharacters)
{
throw new ArgumentException("The document or find term exceeds its supported size.", source.Length > MarkdownRenderer.MaximumCharacters ? nameof(source) : nameof(query));
}
Query = query;
var matches = new List<int>();
if(query.Length > 0)
{
int offset = 0;
while(offset <= source.Length - query.Length)
{
int position = source.IndexOf(query, offset, StringComparison.OrdinalIgnoreCase);
if(position < 0)
{
break;
}
matches.Add(position);
offset = position + query.Length;
}
}
SourceMatches = matches.AsReadOnly();
Current = matches.Count > 0 ? 1 : 0;
}
/// <summary>The literal query shared by both surfaces.</summary>
public string Query { get; }
/// <summary>UTF-16 offsets into the literal source, in document order.</summary>
public IReadOnlyList<int> SourceMatches { get; }
/// <summary>Uses the reference's paired occurrence count, including text visible in only one surface.</summary>
public int Total => Math.Max(SourceMatches.Count, _renderedCount);
/// <summary>One-based current occurrence; zero means no matches.</summary>
public int Current { get; private set; }
/// <summary>The current source range, or no range when the rendered document has more occurrences.</summary>
public (int Start, int Length)? SourceSelection => Current > 0 && Current <= SourceMatches.Count ? (SourceMatches[Current - 1], Query.Length) : null;
/// <summary>Browser count changes retain a valid occurrence and cannot create a negative total.</summary>
public void SetRenderedCount(int count)
{
_renderedCount = Query.Length > 0 ? Math.Max(0, count) : 0;
Current = Total > 0 ? Math.Clamp(Current, 1, Total) : 0;
}
/// <summary>Advances one paired occurrence and wraps at the end.</summary>
public void Next() => Current = Total > 0 ? (Current >= Total ? 1 : Current + 1) : 0;
/// <summary>Moves back one paired occurrence and wraps at the beginning.</summary>
public void Previous() => Current = Total > 0 ? (Current <= 1 ? Total : Current - 1) : 0;
}
@@ -0,0 +1,176 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Runtime.InteropServices;
using System.Text.Json;
using Microsoft.Web.WebView2.Core;
namespace MarkdownViewr.Windows;
/// <summary>Native browser search accepts a literal term without executing it or exposing a host bridge.</summary>
internal sealed partial class DocumentBrowser
{
private int[]? _findCollapsed;
private bool _findSubscribed;
private string _findQuery = "";
/// <summary>Signals that restoration finished on the current intercepted page.</summary>
public event EventHandler? DocumentReady;
/// <summary>Reports native browser counts after an active find session changes.</summary>
public event EventHandler? FindCountChanged;
/// <summary>Search must wait for the active page, never a pending old document.</summary>
public bool IsReady => !_closed && _pageReady;
/// <summary>The rendered count is independent of literal source occurrences.</summary>
public int FindCount => !_closed && _findSubscribed && _pageReady && IsCurrentDocument() ? _view.CoreWebView2.Find.MatchCount : 0;
/// <summary>Expands sections during search and uses the runtime's safe native finder and highlight support.</summary>
public async Task<int> FindAsync(string query)
{
if(_closed || !_pageReady || !IsCurrentDocument() || _environment is null)
{
return 0;
}
Uri document = _policy.DocumentUri;
ulong navigation = _navigationId;
try
{
CoreWebView2Find finder = _view.CoreWebView2.Find;
if(!_findSubscribed)
{
finder.MatchCountChanged += (_, _) =>
{
if(!_closed && _pageReady && IsCurrentDocument())
{
FindCountChanged?.Invoke(this, EventArgs.Empty);
}
};
_findSubscribed = true;
}
if(_findCollapsed is null)
{
await CaptureViewportAsync();
if(!IsMatchingFindDocument(document, navigation))
{
return 0;
}
_findCollapsed = _collapsedSections.ToArray();
}
await ExecuteDocumentScriptAsync("for(const e of document.querySelectorAll('[data-mv-body]')) e.hidden=false;");
if(!IsMatchingFindDocument(document, navigation))
{
return 0;
}
finder.Stop();
_findQuery = query;
CoreWebView2FindOptions options = _environment.CreateFindOptions();
options.FindTerm = query;
options.IsCaseSensitive = false;
options.ShouldMatchWord = false;
options.ShouldHighlightAllMatches = true;
options.SuppressDefaultFindDialog = true;
await finder.StartAsync(options);
return IsMatchingFindDocument(document, navigation) ? finder.MatchCount : 0;
}
catch(Exception exception) when(exception is COMException or NotImplementedException)
{
if(!_closed)
{
_status("Find could not search this document. Check that the Microsoft Edge WebView2 Runtime is up to date.");
}
return 0;
}
}
/// <summary>Moves the real native browser highlight; the paired source count is controlled separately.</summary>
public void MoveFind(bool forward)
{
if(_closed || !_pageReady || _findQuery.Length == 0 || !IsCurrentDocument())
{
return;
}
try
{
if(forward)
{
_view.CoreWebView2.Find.FindNext();
}
else
{
_view.CoreWebView2.Find.FindPrevious();
}
}
catch(COMException)
{
if(!_closed)
{
_status("Find could not move to this occurrence.");
}
}
}
/// <summary>Removes native highlights and restores the section choices from before this search.</summary>
public async Task EndFindAsync()
{
if(_closed)
{
return;
}
Uri document = _policy.DocumentUri;
if(_findSubscribed)
{
_view.CoreWebView2.Find.Stop();
}
_findQuery = "";
if(_findCollapsed is not { } collapsed)
{
return;
}
_findCollapsed = null;
_collapsedSections = collapsed;
string indices = JsonSerializer.Serialize(collapsed);
await ExecuteDocumentScriptAsync("if(location.href.split('#')[0] === '" + document.AbsoluteUri + "') for(const e of document.querySelectorAll('[data-mv-body]')) e.hidden=" + indices + ".includes(Number(e.dataset.mvBody));");
}
/// <summary>Before navigation, preserve the pre-search collapse choices only for the same heading sequence.</summary>
private void ResetFindForNavigation()
{
if(_findSubscribed)
{
_view.CoreWebView2.Find.Stop();
}
if(_findCollapsed is { } collapsed)
{
_collapsedSections = collapsed;
}
_findCollapsed = null;
_findQuery = "";
}
/// <summary>Rejects stale continuations after reload, theme navigation, close or another document open.</summary>
private bool IsMatchingFindDocument(Uri document, ulong navigation)
=> !_closed && _pageReady && document == _policy.DocumentUri && navigation == _navigationId && IsCurrentDocument();
}
+25 -10
View File
@@ -14,7 +14,7 @@ using Microsoft.Web.WebView2.Core;
namespace MarkdownViewr.Windows;
/// <summary>Hosts passive Markdown without network access, authored scripts or native capabilities.</summary>
internal sealed class DocumentBrowser
internal sealed partial class DocumentBrowser
{
private const string SecurityHeaders = "Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nCache-Control: no-store";
private readonly WebView2 _view;
@@ -66,6 +66,7 @@ internal sealed class DocumentBrowser
core.Settings.AreDefaultScriptDialogsEnabled = false;
core.Settings.AreDevToolsEnabled = false;
core.Settings.AreDefaultContextMenusEnabled = false;
core.Settings.AreBrowserAcceleratorKeysEnabled = false;
core.PermissionRequested += (_, args) => args.State = CoreWebView2PermissionState.Deny;
core.DownloadStarting += (_, args) => args.Cancel = true;
core.NewWindowRequested += (_, args) => args.Handled = true;
@@ -82,6 +83,7 @@ internal sealed class DocumentBrowser
/// <summary>Replaces the document capability and navigates to an intercepted HTTPS response, with no HTTP server.</summary>
public void Show(DocumentFiles? files, string sanitizedHtml, string title, Core.Documents.DocumentViewport viewport = default, IReadOnlyList<Core.Documents.DocumentHeading>? headings = null)
{
ResetFindForNavigation();
headings ??= [];
if(!_headings.SequenceEqual(headings))
{
@@ -126,33 +128,44 @@ internal sealed class DocumentBrowser
/// <summary>Only a host-generated section body can be collapsed, leaving its own heading visible.</summary>
public Task ToggleSectionAsync(int index)
public async Task ToggleSectionAsync(int index)
{
if(index < 0 || index >= _headings.Count)
{
return Task.CompletedTask;
return;
}
Uri document = _policy.DocumentUri;
string? result = await ExecuteDocumentScriptAsync(FormattableString.Invariant($"(()=>{{const e=document.querySelector('[data-mv-body=\"{index}\"]'); if(e) return e.hidden=!e.hidden;}})()"));
if(document == _policy.DocumentUri && _findCollapsed is { } collapsed && result is "true" or "false")
{
_findCollapsed = string.Equals(result, "true", StringComparison.Ordinal) ? collapsed.Append(index).Distinct().ToArray() : collapsed.Where(value => value != index).ToArray();
}
return ExecuteDocumentScriptAsync(FormattableString.Invariant($"(()=>{{const e=document.querySelector('[data-mv-body=\"{index}\"]'); if(e) e.hidden=!e.hidden;}})()"));
}
/// <summary>Applies an explicit native command to every generated section, including nested descendants.</summary>
public Task SetSectionsAsync(bool collapsed)
=> ExecuteDocumentScriptAsync("for(const e of document.querySelectorAll('[data-mv-body]')) e.hidden=" + (collapsed ? "true" : "false") + ";");
public async Task SetSectionsAsync(bool collapsed)
{
Uri document = _policy.DocumentUri;
string? result = await ExecuteDocumentScriptAsync("for(const e of document.querySelectorAll('[data-mv-body]')) e.hidden=" + (collapsed ? "true" : "false") + ";");
if(result is not null && document == _policy.DocumentUri && _findCollapsed is not null)
{
_findCollapsed = collapsed ? Enumerable.Range(0, _headings.Count).ToArray() : [];
}
}
/// <summary>Fixed host operations run only on the ready intercepted document; content script execution remains disabled.</summary>
private async Task ExecuteDocumentScriptAsync(string script)
private async Task<string?> ExecuteDocumentScriptAsync(string script)
{
if(_closed || !_pageReady || !IsCurrentDocument())
{
return;
return null;
}
try
{
await _view.CoreWebView2.ExecuteScriptAsync(script);
return await _view.CoreWebView2.ExecuteScriptAsync(script);
}
catch(COMException)
{
@@ -160,6 +173,7 @@ internal sealed class DocumentBrowser
{
_status("The document navigation could not be completed.");
}
return null;
}
}
@@ -222,6 +236,7 @@ internal sealed class DocumentBrowser
if(!_closed && args.NavigationId == _navigationId && IsCurrentDocument())
{
_pageReady = true;
DocumentReady?.Invoke(this, EventArgs.Empty);
}
}
catch(COMException)
@@ -0,0 +1,220 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
namespace MarkdownViewr.Windows;
/// <summary>Coordinates native find controls with literal source selection and the browser's own finder.</summary>
public sealed partial class MainWindow
{
private Microsoft.UI.Dispatching.DispatcherQueueTimer? _findDelay;
private Core.Documents.DocumentFind? _find;
private int _findGeneration;
private bool _finding;
private bool _findQueued;
/// <summary>Debounces typing and refreshes the query only after the current page has finished restoration.</summary>
private void InitializeFind()
{
_findDelay = DispatcherQueue.CreateTimer();
_findDelay.Interval = TimeSpan.FromMilliseconds(200);
_findDelay.IsRepeating = false;
_findDelay.Tick += async (_, _) => await RunFindAsync();
_browser.DocumentReady += (_, _) => QueueFind();
_browser.FindCountChanged += (_, _) =>
{
if(!_finding && !_closed && _find is { } find && string.Equals(find.Query, FindText.Text, StringComparison.Ordinal) && FindBar.Visibility == Visibility.Visible)
{
find.SetRenderedCount(_browser.FindCount);
RefreshFindResult();
}
};
}
/// <summary>Ctrl+F opens one native find toolbar and keeps keyboard focus in its text field.</summary>
private void Find_Click(object sender, RoutedEventArgs e)
{
FindBar.Visibility = Visibility.Visible;
FindText.Focus(FocusState.Programmatic);
FindText.SelectAll();
QueueFind();
}
/// <summary>Typing is literal; only the latest query and committed snapshot can update the result.</summary>
private void FindText_Changed(object sender, TextChangedEventArgs e) => QueueFind();
/// <summary>Supersedes pending results without launching parallel browser searches.</summary>
private void QueueFind()
{
_findGeneration++;
if(_closed || _findDelay is null || FindBar.Visibility != Visibility.Visible)
{
return;
}
_find = null;
FindCountText.Text = "Searching…";
FindNextButton.IsEnabled = false;
FindPreviousButton.IsEnabled = false;
_findDelay.Stop();
_findDelay.Start();
}
/// <summary>Serializes native sessions and discards old query/document completions.</summary>
private async Task RunFindAsync()
{
if(_closed || FindBar.Visibility != Visibility.Visible || !_browser.IsReady)
{
return;
}
if(_finding)
{
_findQueued = true;
return;
}
_finding = true;
try
{
do
{
_findQueued = false;
int generation = _findGeneration;
string snapshot = _sourceMarkdown;
var find = new Core.Documents.DocumentFind(_settings.ShowSource ? SourceText.Text : snapshot, FindText.Text);
if(find.Query.Length == 0)
{
await _browser.EndFindAsync();
}
else
{
find.SetRenderedCount(await _browser.FindAsync(find.Query));
}
if(!_closed && generation == _findGeneration && string.Equals(snapshot, _sourceMarkdown, StringComparison.Ordinal) && FindBar.Visibility == Visibility.Visible && _browser.IsReady)
{
_find = find;
}
else if(!_closed && FindBar.Visibility == Visibility.Visible)
{
_findQueued = true;
}
else
{
await _browser.EndFindAsync();
}
} while(_findQueued && !_closed && FindBar.Visibility == Visibility.Visible && _browser.IsReady);
}
finally
{
_finding = false;
if(!_closed)
{
RefreshFindResult();
}
}
}
/// <summary>Displays one paired count and selects only a valid literal source range.</summary>
private void RefreshFindResult()
{
int current = _find?.Current ?? 0;
int total = _find?.Total ?? 0;
FindCountText.Text = FormattableString.Invariant($"{current} of {total}");
FindNextButton.IsEnabled = total > 0 && !_finding;
FindPreviousButton.IsEnabled = total > 0 && !_finding;
if(_settings.ShowSource && _find?.SourceSelection is { } selection)
{
SourceText.Select(selection.Start, selection.Length);
}
else
{
SourceText.Select(0, 0);
}
}
/// <summary>Moves one paired occurrence; browser-only and source-only tails retain their own valid selections.</summary>
private void MoveFind(bool forward)
{
if(_finding || _find is not { Total: > 0 } find)
{
return;
}
if(forward)
{
find.Next();
}
else
{
find.Previous();
}
if(find.Current <= _browser.FindCount)
{
_browser.MoveFind(forward);
}
RefreshFindResult();
}
/// <summary>The native next button wraps through the same paired occurrence list.</summary>
private void FindNext_Click(object sender, RoutedEventArgs e) => MoveFind(forward: true);
/// <summary>The native previous button wraps from the first paired occurrence to the last.</summary>
private void FindPrevious_Click(object sender, RoutedEventArgs e) => MoveFind(forward: false);
/// <summary>Escape closes find; Enter and Shift+Enter move through occurrences without entering a newline.</summary>
private async void FindText_KeyDown(object sender, KeyRoutedEventArgs e)
{
if(e.Key == global::Windows.System.VirtualKey.Escape)
{
e.Handled = true;
await CloseFindAsync();
}
else if(e.Key == global::Windows.System.VirtualKey.Enter)
{
e.Handled = true;
bool shift = (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(global::Windows.System.VirtualKey.Shift) & global::Windows.UI.Core.CoreVirtualKeyStates.Down) != global::Windows.UI.Core.CoreVirtualKeyStates.None;
MoveFind(forward: !shift);
}
}
/// <summary>The close button clears highlights and restores pre-search section choices.</summary>
private async void FindClose_Click(object sender, RoutedEventArgs e) => await CloseFindAsync();
/// <summary>Invalidates pending queries before clearing both surfaces.</summary>
private async Task CloseFindAsync()
{
_findGeneration++;
_findDelay?.Stop();
FindBar.Visibility = Visibility.Collapsed;
_find = null;
SourceText.Select(0, 0);
await _browser.EndFindAsync();
}
}
@@ -63,6 +63,7 @@ public sealed partial class MainWindow
_settings.ShowSource = SourceToggle.IsChecked;
Save();
RefreshSource();
QueueFind();
}
+24 -2
View File
@@ -11,6 +11,7 @@
<Grid x:Name="RootGrid" SizeChanged="ReadingArea_SizeChanged">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
@@ -27,6 +28,13 @@
<MenuFlyoutSeparator />
<MenuFlyoutItem x:Name="ExitMenuItem" Text="Exit" Click="Exit_Click" />
</MenuBarItem>
<MenuBarItem Title="Edit">
<MenuFlyoutItem x:Name="FindMenuItem" Text="Find..." Click="Find_Click">
<MenuFlyoutItem.KeyboardAccelerators>
<KeyboardAccelerator Key="F" Modifiers="Control" />
</MenuFlyoutItem.KeyboardAccelerators>
</MenuFlyoutItem>
</MenuBarItem>
<MenuBarItem Title="View">
<ToggleMenuFlyoutItem x:Name="SourceToggle" Text="Markdown source" Click="Source_Click" />
<ToggleMenuFlyoutItem x:Name="OutlineToggle" Text="Table of contents" Click="Outline_Click" />
@@ -56,8 +64,22 @@
</MenuBarItem>
</MenuBar>
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="16,8" Text="Starting the document surface..." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" AutomationProperties.AutomationId="StatusText" AutomationProperties.Name="{Binding Text, ElementName=StatusText}" AutomationProperties.AccessibilityView="Content" AutomationProperties.LiveSetting="Polite" />
<Grid Grid.Row="1">
<Grid x:Name="FindBar" Grid.Row="1" Visibility="Collapsed" Margin="16,8" ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="FindText" PlaceholderText="Find in document and source" MaxLength="256" TextChanged="FindText_Changed" KeyDown="FindText_KeyDown" AutomationProperties.Name="Find in document and source" />
<TextBlock x:Name="FindCountText" Grid.Column="1" Text="0 of 0" VerticalAlignment="Center" AutomationProperties.Name="{Binding Text, ElementName=FindCountText}" AutomationProperties.LiveSetting="Polite" />
<Button x:Name="FindPreviousButton" Grid.Column="2" Content="Previous" Click="FindPrevious_Click" IsEnabled="False" />
<Button x:Name="FindNextButton" Grid.Column="3" Content="Next" Click="FindNext_Click" IsEnabled="False" />
<Button x:Name="FindCloseButton" Grid.Column="4" Content="Close find" Click="FindClose_Click" />
</Grid>
<TextBlock x:Name="StatusText" Grid.Row="3" Margin="16,8" Text="Starting the document surface..." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" AutomationProperties.AutomationId="StatusText" AutomationProperties.Name="{Binding Text, ElementName=StatusText}" AutomationProperties.AccessibilityView="Content" AutomationProperties.LiveSetting="Polite" />
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition x:Name="OutlineColumn" Width="0" />
<ColumnDefinition x:Name="OutlineDividerColumn" Width="0" />
@@ -41,6 +41,7 @@ public sealed partial class MainWindow : Window, IDisposable
CreateDocumentThemeMenu();
InitializeOutline();
InitializeSource();
InitializeFind();
InitializeInputDiagnostics();
(_settings.FrontmatterKind switch { Core.Documents.FrontmatterMode.Metadata => FrontmatterMetadata, Core.Documents.FrontmatterMode.Markdown => FrontmatterMarkdown, _ => FrontmatterHide }).IsChecked = true;
Title = AppIdentity.Info.DisplayName;
@@ -282,6 +283,8 @@ public sealed partial class MainWindow : Window, IDisposable
private void ShowDocument(DocumentFiles files, Core.Documents.DocumentOutline outline, string path, Core.Documents.DocumentViewport viewport, bool reload)
{
_browser.Show(files, outline.Html, Path.GetFileName(path), viewport, outline.Headings);
_find = null;
_findGeneration++;
_sourceMarkdown = files.Markdown;
RefreshSource();
_outline = outline;
@@ -304,6 +307,8 @@ public sealed partial class MainWindow : Window, IDisposable
if(!_closed)
{
_closed = true;
_findGeneration++;
_findDelay?.Stop();
_watcher?.Dispose();
_watcher = null;
_browser.Close();
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Documents;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Proves paired counts, literal positions and navigation for the real two-surface find controls.</summary>
public sealed class DocumentFindTests
{
/// <summary>Formatting and case differences retain offsets in the original source rather than rendered text.</summary>
[Fact]
public void Create_MixedCaseSource_ReturnsLiteralPositions()
{
var find = new DocumentFind("**Alpha**\nALPHA alpha", "alpha");
Assert.Equal("alpha", find.Query);
Assert.Equal(new[] { 2, 10, 16 }, find.SourceMatches);
Assert.Equal(3, find.Total);
Assert.Equal(1, find.Current);
Assert.Equal((2, 5), find.SourceSelection);
find.SetRenderedCount(3);
Assert.Equal(3, find.Total);
}
/// <summary>Markup/script/regex punctuation is ordinary search text and cannot become executable input.</summary>
[Theory]
[InlineData("before a.*[x] after", "a.*[x]", 7)]
[InlineData("before <script>alert('x')</script> after", "<script>alert('x')</script>", 7)]
[InlineData("🙂 CAFÉ then café", "café", 3)]
[InlineData("banana", "ana", 1)]
public void Create_LiteralQuery_FindsNonoverlappingRanges(string source, string query, int offset)
{
var find = new DocumentFind(source, query);
Assert.Equal(offset, find.SourceMatches[0]);
Assert.Equal((offset, query.Length), find.SourceSelection);
if(string.Equals(query, "ana", StringComparison.Ordinal))
{
Assert.Single(find.SourceMatches);
}
}
/// <summary>Empty/absent/longer-than-source queries leave navigation inert.</summary>
[Theory]
[InlineData("text", "")]
[InlineData("text", "missing")]
[InlineData("", "query")]
[InlineData("longer source", "absent")]
public void Create_NoMatch_LeavesZeroCount(string source, string query)
{
var find = new DocumentFind(source, query);
find.Next();
find.Previous();
find.SetRenderedCount(-1);
Assert.Empty(find.SourceMatches);
Assert.Equal(0, find.Total);
Assert.Equal(0, find.Current);
Assert.Null(find.SourceSelection);
}
/// <summary>The larger surface count wins, with wraparound and no false range for a rendered-only occurrence.</summary>
[Fact]
public void Navigate_DifferentSurfaceCounts_WrapsPairedOccurrences()
{
var find = new DocumentFind("one", "one");
find.SetRenderedCount(3);
Assert.Equal(3, find.Total);
find.Previous();
Assert.Equal(3, find.Current);
Assert.Null(find.SourceSelection);
find.Next();
Assert.Equal(1, find.Current);
Assert.Equal((0, 3), find.SourceSelection);
find.Next();
Assert.Equal(2, find.Current);
find.Previous();
Assert.Equal(1, find.Current);
find.Previous();
find.SetRenderedCount(0);
Assert.Equal(1, find.Current);
Assert.Equal(1, find.Total);
}
/// <summary>A rendered-only query acquires its first occurrence when the native browser reports matches.</summary>
[Fact]
public void SetCount_RenderedOnlyMatch_InitializesAndClearsCurrent()
{
var find = new DocumentFind("![caption](image.png)", "visible");
find.SetRenderedCount(2);
Assert.Equal(1, find.Current);
Assert.Null(find.SourceSelection);
find.Next();
Assert.Equal(2, find.Current);
find.SetRenderedCount(-1);
Assert.Equal(0, find.Current);
}
/// <summary>Empty terms ignore late counts; native integer limits cannot overflow wraparound arithmetic.</summary>
[Fact]
public void Navigate_EmptyAndMaximumCounts_StaysInRange()
{
var empty = new DocumentFind("source", "");
empty.SetRenderedCount(9);
Assert.Equal(0, empty.Total);
var maximum = new DocumentFind("source", "missing");
maximum.SetRenderedCount(int.MaxValue);
maximum.Previous();
Assert.Equal(int.MaxValue, maximum.Current);
maximum.Next();
Assert.Equal(1, maximum.Current);
}
/// <summary>Programmatic callers cannot bypass the same document/query limits used by native controls.</summary>
[Fact]
public void Create_InvalidInput_RejectsBeforeSearch()
{
Assert.Throws<ArgumentNullException>(() => new DocumentFind(null!, "query"));
Assert.Throws<ArgumentNullException>(() => new DocumentFind("source", null!));
Assert.Throws<ArgumentException>(() => new DocumentFind(new string('x', MarkdownRenderer.MaximumCharacters + 1), "query"));
Assert.Throws<ArgumentException>(() => new DocumentFind("source", new string('x', DocumentFind.MaximumQueryCharacters + 1)));
}
}
@@ -262,6 +262,126 @@ public class AppTests
}
/// <summary>Ctrl+F accepts physical typing and pairs the actual source selection with wrapping browser occurrences.</summary>
[Fact]
public void Find_PairedMatches_UpdatesCounterAndSourceSelection()
{
using var session = new AppSession(document: "# Find acceptance\n\n**Alpha** first alpha\n\nLast ALPHA");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Find acceptance")), "the document heading", TimeSpan.FromSeconds(30));
session.InvokeMenuItem("View", "SourceToggle");
AutomationElement source = session.WaitForElement("SourceText");
session.MainWindow.SetForeground();
session.WaitForElement("DocumentView").Focus();
FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_F);
session.WaitForElement("FindText");
FlaUI.Core.Input.Keyboard.Type("alpha");
WaitForFindCount(session, "1 of 3");
Assert.Equal("Alpha", ReadSourceSelection(source));
session.ClickElement("FindNextButton");
WaitForFindCount(session, "2 of 3");
Assert.Equal("alpha", ReadSourceSelection(source));
session.ClickElement("FindPreviousButton");
WaitForFindCount(session, "1 of 3");
session.ClickElement("FindPreviousButton");
WaitForFindCount(session, "3 of 3");
Assert.Equal("ALPHA", ReadSourceSelection(source));
session.ClickElement("FindNextButton");
WaitForFindCount(session, "1 of 3");
_output.WriteLine(session.DescribeDocumentStatus());
session.ClickElement("FindCloseButton");
session.WaitForElementGone("FindText");
Assert.Equal("", ReadSourceSelection(source));
_output.WriteLine(session.DescribeDocumentStatus());
}
/// <summary>Find reveals collapsed text, moves the real browser viewport and restores prior section choices on close.</summary>
[Fact]
public void Find_CollapsedLongDocument_MovesViewportAndRestoresSections()
{
string paragraphs = string.Join("\n\n", Enumerable.Range(0, 100).Select(index => "Find filler paragraph " + index));
using var session = new AppSession(document: "# Find root\n\nFirst needle occurrence\n\n" + paragraphs + "\n\n## Find tail\n\nLast needle occurrence");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Find root")), "the root heading", TimeSpan.FromSeconds(30));
session.InvokeMenuItem("View", "OutlineToggle");
session.ClickElement("OutlineHeading:1");
session.ClickElement("ToggleSection");
AppSession.WaitUntilGone(() => session.FindInApp(cf => cf.ByName("Last needle occurrence")), "the collapsed tail content");
session.ClickElement("OutlineHeading:0");
session.InvokeMenuItem("Edit", "FindMenuItem");
session.EnterText("FindText", "needle");
WaitForFindCount(session, "1 of 2");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Last needle occurrence")), "the revealed tail match");
session.ClickElement("FindNextButton");
WaitForFindCount(session, "2 of 2");
AutomationElement scroller = FindDocumentScroller(session);
session.WaitFor(() => scroller.Patterns.Scroll.Pattern.VerticalScrollPercent.Value > 70 ? scroller : null, "the browser viewport at the last find match");
session.ClickElement("FindNextButton");
WaitForFindCount(session, "1 of 2");
session.WaitFor(() => scroller.Patterns.Scroll.Pattern.VerticalScrollPercent.Value < 10 ? scroller : null, "the browser viewport after wrapping to the first match");
session.ClickElement("FindCloseButton");
AppSession.WaitUntilGone(() => session.FindInApp(cf => cf.ByName("Last needle occurrence")), "the restored collapsed tail content");
Assert.NotNull(session.FindInApp(cf => cf.ByName("Find root")));
session.InvokeMenuItem("Edit", "FindMenuItem");
WaitForFindCount(session, "1 of 2");
session.ClickElement("ExpandSections");
session.ClickElement("FindCloseButton");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Last needle occurrence")), "the explicit expansion retained after closing find");
}
/// <summary>Hostile-looking terms stay literal and the current query is recomputed for the newly committed reload snapshot.</summary>
[Fact]
public void Find_LiteralQueryAndReload_KeepsDocumentSafeAndRefreshesCount()
{
const string Query = "');document.body.textContent='unsafe';//";
using var session = new AppSession(document: "# Literal find\n\n`a.*[x]`\n\n```text\n" + Query + "\n```");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Literal find")), "the document heading", TimeSpan.FromSeconds(30));
session.InvokeMenuItem("View", "SourceToggle");
AutomationElement source = session.WaitForElement("SourceText");
session.InvokeMenuItem("Edit", "FindMenuItem");
session.EnterText("FindText", "a.*[x]");
WaitForFindCount(session, "1 of 1");
Assert.Equal("a.*[x]", ReadSourceSelection(source));
session.EnterText("FindText", Query);
WaitForFindCount(session, "1 of 1");
session.WaitFor(() => string.Equals(ReadSourceSelection(source), Query, StringComparison.Ordinal) ? source : null, "the literal hostile-looking query selection");
Assert.NotNull(session.FindInApp(cf => cf.ByName("Literal find")));
File.WriteAllText(Path.Combine(session.DataDirectory, "document.md"), "# Reloaded find\n\n```text\n" + Query + "\n" + Query + "\n```");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Reloaded find")), "the reloaded document heading");
WaitForFindCount(session, "1 of 2");
Assert.Equal(Query, ReadSourceSelection(source));
session.EnterText("FindText", "absent literal query");
WaitForFindCount(session, "0 of 0");
Assert.False(session.WaitForElement("FindNextButton").IsEnabled);
Assert.False(session.WaitForElement("FindPreviousButton").IsEnabled);
Assert.Equal("", ReadSourceSelection(source));
Assert.NotNull(session.FindInApp(cf => cf.ByName("Reloaded find")));
}
/// <summary>Waits for real native results, retaining fixture-only surface/log evidence on timeout.</summary>
private static void WaitForFindCount(AppSession session, string count)
{
try
{
session.WaitFor(() => session.FindInApp(cf => cf.ByAutomationId("FindCountText").And(cf.ByName(count))), "the paired find count " + count);
}
catch(TimeoutException exception)
{
throw new TimeoutException($"{exception.Message} Logs: {session.ReadLogs()} Native tree: {session.DescribeDocumentStatus()}", exception);
}
}
/// <summary>Reads the native text provider's actual selected range, rather than a mirrored application property.</summary>
private static string ReadSourceSelection(AutomationElement source)
=> string.Concat(source.Patterns.Text.Pattern.GetSelection().Select(range => range.GetText(-1)));
/// <summary>The native depth choice filters exact headings and a real divider drag saves its new width.</summary>
[Fact]
public void Outline_DepthAndResize_PersistsNativeChoices()
@@ -313,6 +433,34 @@ public class AppTests
/// <summary>A physical local-fragment click reveals a hidden generated heading without loading another page.</summary>
[Fact]
public void Outline_LocalFragmentClick_RevealsCollapsedTarget()
{
using var session = new AppSession(document: "[Jump to target](#target-heading)\n\n# Fragment root\n\nRoot body\n\n## Target heading\n\nTarget body");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Fragment root")), "the root heading", TimeSpan.FromSeconds(30));
session.InvokeMenuItem("View", "OutlineToggle");
session.ClickElement("OutlineHeading:0");
session.ClickElement("ToggleSection");
AppSession.WaitUntilGone(() => session.FindInApp(cf => cf.ByName("Target heading")), "the heading inside its collapsed ancestor");
AutomationElement link = session.WaitFor(() => session.MainWindow.FindFirstDescendant(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Hyperlink).And(cf.ByName("Jump to target"))), "the local fragment link");
session.MainWindow.SetForeground();
Assert.True(link.TryGetClickablePoint(out System.Drawing.Point point));
Assert.True(session.MainWindow.BoundingRectangle.Contains(point));
NativeMouse.MoveTo(point);
FlaUI.Core.Input.Wait.UntilInputIsProcessed();
AutomationElement hit = session.Automation.FromPoint(point);
Assert.True(hit.Equals(link) || hit.Parent.Equals(link), "The pointer must hit the exact owned document link or its text child.");
FlaUI.Core.Input.Mouse.Down(FlaUI.Core.Input.MouseButton.Left);
FlaUI.Core.Input.Wait.UntilInputIsProcessed();
FlaUI.Core.Input.Mouse.Up(FlaUI.Core.Input.MouseButton.Left);
FlaUI.Core.Input.Wait.UntilInputIsProcessed();
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Target heading")), "the fragment's revealed target heading");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Target body")), "the revealed target body");
Assert.NotNull(session.FindInApp(cf => cf.ByName("Fragment root")));
}
/// <summary>A heading selection moves the actual browser viewport and all-section operations change displayed content.</summary>
[Fact]
public void Outline_HeadingJumpAndCollapse_UpdatesActualDocument()