feat(navigation): add native vim reading commands
Route bounded reading commands through the owned UI thread with text-focus, modifier, layout and timed-prefix guards. Add physical viewport, heading and query-typing acceptance and preserve reference attribution. Refs #10 Co-Authored-By: Codex (GPT-6) <noreply@openai.com> Signed-off-by: James Maes <jmaes@dmdbrands.com>
This commit is contained in:
17 files changed
+710
-47
No files matched your search
@@ -36,7 +36,8 @@ Prerelease tags (`v1.2.3-beta.1`) publish to the `dev` channel
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -108,9 +108,9 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## markdownViewr resources and frontmatter behavior
|
||||
## markdownViewr resources, frontmatter and navigation behavior
|
||||
|
||||
Frontmatter delimiter/metadata display behavior is adapted from MarkdownDocument.swift at the same pinned commit. 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 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.
|
||||
|
||||
Source: <https://github.com/darinkelkhoff/markdownViewr/tree/6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021/markdownViewr/Resources/themes>
|
||||
|
||||
|
||||
+1
-1
@@ -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 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.
|
||||
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 is implemented with 239 core tests/100% lines; its new physical-key acceptance remains pending. The full feature list below remains the target; the first reader is not a completed port.
|
||||
|
||||
## Reference and feature scope
|
||||
|
||||
|
||||
@@ -262,3 +262,32 @@ The physical test repeats collapse/link/reveal and retains owned point/bounds ch
|
||||
and actual heading/body outcomes. Hosted acceptance remains required. Retained log:
|
||||
artifacts/editor-find-ui-38110140930.log. Existing installed producer is still
|
||||
being observed; no new push supersedes a live producer. Full product scope active.
|
||||
|
||||
## Vim increment prepared after fragment correction
|
||||
|
||||
Pushed fragment-event head 5a9af6328f490eda4c67d1ab6aa58a161bab46a5:
|
||||
CI 38110726381 native/UI job 114385547259 SUCCESS, 41 tests. This includes
|
||||
same-fragment reveal after recollapse and all configured-editor/paired-find cases.
|
||||
Build/format, CodeQL 38110726336, scripts 38110725744, security 38110726260
|
||||
and PR gates 38110726369 passed. Installed producer 114385547073 now SUCCESS;
|
||||
retained TRX proves published 41/41 and stable/dev 40/40 each, zero skips. Four
|
||||
payload gates verify 536 files per payload. Evidence is retained in
|
||||
artifacts/fragment-installed-verified-38110726381 and verified job logs; fixtures
|
||||
remain synthetic/unsigned and do not prove production signing. Prior 9edd982 is terminal
|
||||
40/41 native/published; installed channels were not reached.
|
||||
|
||||
Local next increment implements j/k, Ctrl+d/u, gg/G, ]]/[[, slash, n/N and Escape;
|
||||
Windows Ctrl+F owns native find and Ctrl+B reverse-page remains. Core now passes
|
||||
239 tests/100% lines. Prefixes use injected monotonic milliseconds, expire after
|
||||
500 ms and reject held-key imitation; lifecycle resets and focus/Alt/Windows/Shift
|
||||
controls preserve native typing. The existing owned-thread adapter translates only
|
||||
reading keys using the actual layout without mutating dead-key state, sends bounded
|
||||
enums and rechecks focus on queued dispatch. No authored script/message bridge.
|
||||
Three physical-key tests measure real browser viewport/heading/find behavior and
|
||||
query typing; browser scroll provider lookup is now scoped inside DocumentView so
|
||||
source scrolling cannot satisfy browser assertions. Hosted acceptance is pending.
|
||||
|
||||
No local GUI input, merge/tag/release or Azure attempt. Full PRODUCT.md remains;
|
||||
source heading/scroll/collapse sync, broader editor/reference/theme/rendering/link
|
||||
parity, print, windows/activation/recents, standards inventory/review and production
|
||||
signed stable/dev final bytes remain. Preserve Darin's MIT navigation attribution.
|
||||
+6
-3
@@ -22,13 +22,16 @@
|
||||
- [x] Implement literal read-only source pane, saved visibility/width and shared committed reload snapshots (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] Verify paired find and repeated local-fragment reveal on 5a9af63: 41 native/published and 40 per installed channel, zero skips.
|
||||
- [x] Implement all required vim reading/find/heading commands, timed prefixes and native focus/modifier guards (issue #10).
|
||||
- [ ] Verify physical vim browser viewport, collapsed-heading and query-typing acceptance in native/published/installed builds.
|
||||
- [ ] Add synchronized source headings/scroll/collapse (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.
|
||||
- [x] Verify actual hosted frontmatter mode switching on 4bf0b81 (including both installed channels).
|
||||
- [x] Implement bounded persisted editor selection and deliberate native launch using one literal process argument (issue #11).
|
||||
- [ ] Verify real hosted editor fixture launch, validation/cancel and missing-executable acceptance; audit editor list/folder parity.
|
||||
- [x] Verify actual editor fixture launch, validation/cancel and missing-executable acceptance on 5a9af63 in native/published/installed builds.
|
||||
- [ ] Audit editor list selection/reordering/removal and folder-opening parity (issue #11).
|
||||
- [ ] Add native printing/theme/layout options and screen-state restoration.
|
||||
- [ ] 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.
|
||||
|
||||
@@ -68,8 +68,18 @@ 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, mirrored collapse
|
||||
and vim keys remain separate work. Browser scripts, messages and host objects remain disabled.
|
||||
explicit native section commands during search. Source synchronization and mirrored collapse
|
||||
remain separate work. 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
|
||||
normal input. Translate only reading input with the active keyboard layout and
|
||||
ToUnicode flag 4, preserving dead-key state; Alt/Windows/extra Ctrl modifiers are
|
||||
ignored. Timed prefixes reset on focus/page/window transitions; held keys cannot
|
||||
imitate gg/heading sequences. Dispatch rechecks focus. Browser scrolling and visible
|
||||
heading operations use fixed host scripts and generated numeric identities only.
|
||||
No content script, native bridge or dependency is added. Physical-key browser and
|
||||
text-entry acceptance remains distinct from pure key-model tests.
|
||||
|
||||
## Verification and follow-up
|
||||
|
||||
|
||||
@@ -71,14 +71,18 @@ does not establish working release signing.
|
||||
|
||||
## Owned-window keyboard boundary
|
||||
|
||||
Browser-focus shortcuts cannot rely on XAML menu/root acceleration alone. The
|
||||
Windows adapter uses a current-UI-thread `WH_GETMESSAGE` hook, requires this
|
||||
foreground window or its children, recognizes only Ctrl+F without additional
|
||||
modifiers, and queues the existing native find command. Other messages pass
|
||||
through; closing the window removes the callback. No global keyboard registration,
|
||||
other-process input capture, arbitrary command dispatch, browser bridge or text
|
||||
logging is introduced. Actual hosted browser-focus acceptance is still required.
|
||||
See Microsoft's [thread-hook contract](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowshookexw).
|
||||
Browser-focus shortcuts use a current-UI-thread `WH_GETMESSAGE` hook requiring the
|
||||
active owned window/child. Ctrl+F remains a native window command. Vim keys require
|
||||
the actual passive-browser/read-only-source focus; native text controls retain input.
|
||||
Alt/Windows and extra Ctrl modifiers do not become reading commands. Only bounded
|
||||
enums leave the key model; prefixes expire/reset and held prefix keys do not complete
|
||||
sequences. Layout translation uses ToUnicode flag 4 to preserve dead-key state;
|
||||
non-reading input is not translated. Dispatch rechecks focus. Other messages pass
|
||||
through; closing removes the callback. No global hook, other-process capture,
|
||||
arbitrary command, browser bridge or text logging is introduced. Fixture-only focus
|
||||
traces contain native control names/types, never query or source text. Actual hosted
|
||||
physical keyboard acceptance is required.
|
||||
See Microsoft's [thread-hook contract](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowshookexw) and [layout conversion contract](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-tounicode).
|
||||
|
||||
## Configured editor boundary
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Darin Kelkhoff
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Navigation;
|
||||
|
||||
/// <summary>Bounded native reading commands adapted from the pinned reference's vim navigation.</summary>
|
||||
public enum ReaderCommand
|
||||
{
|
||||
None,
|
||||
Prefix,
|
||||
LineDown,
|
||||
LineUp,
|
||||
HalfPageDown,
|
||||
HalfPageUp,
|
||||
PageUp,
|
||||
Top,
|
||||
Bottom,
|
||||
NextHeading,
|
||||
PreviousHeading,
|
||||
FocusFind,
|
||||
NextMatch,
|
||||
PreviousMatch,
|
||||
CloseFind,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Navigation;
|
||||
|
||||
/// <summary>A transient reading key and modifiers; uppercase and layout punctuation come from native translation.</summary>
|
||||
public readonly record struct ReaderKey(char Character, bool Control = false, bool Shift = false, bool Alt = false, bool Windows = false, bool Repeat = false);
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Darin Kelkhoff
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Navigation;
|
||||
|
||||
/// <summary>Resolves the reference's vim sequences without interfering with text entry or modified native shortcuts.</summary>
|
||||
public sealed class ReaderKeyboard
|
||||
{
|
||||
private char _prefix;
|
||||
private long _prefixAt;
|
||||
|
||||
|
||||
|
||||
/// <summary>Uses injected monotonic milliseconds; a held prefix cannot imitate a deliberate two-key sequence.</summary>
|
||||
public ReaderCommand Resolve(ReaderKey key, bool readingFocused, long milliseconds)
|
||||
{
|
||||
if(key.Alt || key.Windows)
|
||||
{
|
||||
Reset();
|
||||
return ReaderCommand.None;
|
||||
}
|
||||
if(key.Control)
|
||||
{
|
||||
Reset();
|
||||
return ResolveControl(key, readingFocused);
|
||||
}
|
||||
if(!readingFocused)
|
||||
{
|
||||
Reset();
|
||||
return ReaderCommand.None;
|
||||
}
|
||||
long elapsed = milliseconds - _prefixAt;
|
||||
if(_prefix != '\0' && key.Character == _prefix && !key.Repeat && elapsed is >= 0 and < 500)
|
||||
{
|
||||
char prefix = _prefix;
|
||||
Reset();
|
||||
return prefix switch { 'g' => ReaderCommand.Top, ']' => ReaderCommand.NextHeading, _ => ReaderCommand.PreviousHeading };
|
||||
}
|
||||
Reset();
|
||||
if(key.Character is 'g' or ']' or '[')
|
||||
{
|
||||
_prefix = key.Character;
|
||||
_prefixAt = milliseconds;
|
||||
return ReaderCommand.Prefix;
|
||||
}
|
||||
return key.Character switch
|
||||
{
|
||||
'j' => ReaderCommand.LineDown,
|
||||
'k' => ReaderCommand.LineUp,
|
||||
'G' => ReaderCommand.Bottom,
|
||||
'n' => ReaderCommand.NextMatch,
|
||||
'N' => ReaderCommand.PreviousMatch,
|
||||
'/' => ReaderCommand.FocusFind,
|
||||
'\u001B' => ReaderCommand.CloseFind,
|
||||
_ => ReaderCommand.None,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Windows find ignores Caps Lock; Shift-modified commands remain available to native controls.</summary>
|
||||
private static ReaderCommand ResolveControl(ReaderKey key, bool readingFocused)
|
||||
{
|
||||
if(key.Shift)
|
||||
{
|
||||
return ReaderCommand.None;
|
||||
}
|
||||
if(char.ToUpperInvariant(key.Character) == 'F')
|
||||
{
|
||||
return ReaderCommand.FocusFind;
|
||||
}
|
||||
return readingFocused ? char.ToLowerInvariant(key.Character) switch
|
||||
{
|
||||
'd' => ReaderCommand.HalfPageDown,
|
||||
'u' => ReaderCommand.HalfPageUp,
|
||||
'b' => ReaderCommand.PageUp,
|
||||
_ => ReaderCommand.None,
|
||||
} : ReaderCommand.None;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Clears only the bounded prefix on focus, page or window changes; no arbitrary key history is stored.</summary>
|
||||
public void Reset() => _prefix = '\0';
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Darin Kelkhoff
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
/// <summary>Executes fixed native reading operations without giving document content an input/message bridge.</summary>
|
||||
internal sealed partial class DocumentBrowser
|
||||
{
|
||||
/// <summary>Moves by computed line height, viewport fractions, document ends or visible generated headings.</summary>
|
||||
public Task NavigateReaderAsync(Core.Navigation.ReaderCommand command)
|
||||
{
|
||||
if(command is Core.Navigation.ReaderCommand.NextHeading or Core.Navigation.ReaderCommand.PreviousHeading)
|
||||
{
|
||||
return MoveVisibleHeadingAsync(command == Core.Navigation.ReaderCommand.NextHeading);
|
||||
}
|
||||
string? amount = command switch
|
||||
{
|
||||
Core.Navigation.ReaderCommand.LineDown => "line",
|
||||
Core.Navigation.ReaderCommand.LineUp => "-line",
|
||||
Core.Navigation.ReaderCommand.HalfPageDown => "s.clientHeight/2",
|
||||
Core.Navigation.ReaderCommand.HalfPageUp => "-s.clientHeight/2",
|
||||
Core.Navigation.ReaderCommand.PageUp => "-s.clientHeight",
|
||||
Core.Navigation.ReaderCommand.Top => "0",
|
||||
Core.Navigation.ReaderCommand.Bottom => "s.scrollHeight",
|
||||
_ => null,
|
||||
};
|
||||
if(amount is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
string operation = command is Core.Navigation.ReaderCommand.Top or Core.Navigation.ReaderCommand.Bottom ? "scrollTo" : "scrollBy";
|
||||
return ExecuteDocumentScriptAsync("(()=>{const s=document.scrollingElement;if(!s)return;const c=getComputedStyle(document.body);let line=parseFloat(c.lineHeight);if(!Number.isFinite(line))line=parseFloat(c.fontSize)*1.5;if(!Number.isFinite(line)||line<=0)line=24;s." + operation + "({top:" + amount + ",behavior:'instant'});})()");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Heading sequences skip collapsed descendants and use document coordinates even inside nested sections.</summary>
|
||||
private Task<string?> MoveVisibleHeadingAsync(bool forward)
|
||||
{
|
||||
string direction = forward ? "top>y+2" : "top<y-2";
|
||||
string update = forward ? "target=h;break;" : "target=h;";
|
||||
return ExecuteDocumentScriptAsync(FormattableString.Invariant($$"""
|
||||
(()=>{
|
||||
const s=document.scrollingElement;if(!s)return;
|
||||
const y=s.scrollTop;let target=null;
|
||||
for(let i=0;i<{{_headings.Count}};i++){
|
||||
const h=document.getElementById('mv-heading-'+i);
|
||||
if(h&&h.offsetParent!==null){
|
||||
const top=h.getBoundingClientRect().top+y;
|
||||
if({{direction}}){ {{update}} }
|
||||
}
|
||||
}
|
||||
if(target)target.scrollIntoView({block:'start',behavior:'instant'});
|
||||
})()
|
||||
"""));
|
||||
}
|
||||
}
|
||||
@@ -9,21 +9,24 @@ using System.Runtime.InteropServices;
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
/// <summary>Routes one command from owned browser child windows before their native message dispatch.</summary>
|
||||
/// <summary>Routes bounded reading commands from owned child windows before native browser dispatch.</summary>
|
||||
internal sealed partial class DocumentKeyboard : IDisposable
|
||||
{
|
||||
private readonly nint _window;
|
||||
private readonly Func<bool> _find;
|
||||
private readonly Func<bool> _readingFocused;
|
||||
private readonly Func<Core.Navigation.ReaderCommand, bool> _execute;
|
||||
private readonly Core.Navigation.ReaderKeyboard _keys = new();
|
||||
private readonly HookCallback _callback;
|
||||
private nint _hook;
|
||||
|
||||
|
||||
|
||||
/// <summary>Installs a current-UI-thread message hook; no other process, thread or desktop is observed.</summary>
|
||||
public DocumentKeyboard(nint window, Func<bool> find)
|
||||
public DocumentKeyboard(nint window, Func<bool> readingFocused, Func<Core.Navigation.ReaderCommand, bool> execute)
|
||||
{
|
||||
_window = window;
|
||||
_find = find;
|
||||
_readingFocused = readingFocused;
|
||||
_execute = execute;
|
||||
_callback = OnMessage;
|
||||
_hook = SetWindowsHookExW(3, Marshal.GetFunctionPointerForDelegate(_callback), 0, GetCurrentThreadId());
|
||||
if(_hook == 0)
|
||||
@@ -34,14 +37,13 @@ internal sealed partial class DocumentKeyboard : IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>Consumes only an unmodified Ctrl+F removed from this active window's own message queue.</summary>
|
||||
/// <summary>Consumes only allowed reading commands removed from this active window's own message queue.</summary>
|
||||
private nint OnMessage(int code, nuint remove, nint pointer)
|
||||
{
|
||||
if(code >= 0 && remove == 1 && GetForegroundWindow() == _window)
|
||||
{
|
||||
NativeMessage message = Marshal.PtrToStructure<NativeMessage>(pointer);
|
||||
if(message.Message == 0x0100 && message.WParam == 0x46 && (message.Window == _window || IsChild(_window, message.Window))
|
||||
&& IsDown(0x11) && !IsDown(0x10) && !IsDown(0x12) && !IsDown(0x5B) && !IsDown(0x5C) && _find())
|
||||
if(message.Message == 0x0100 && (message.Window == _window || IsChild(_window, message.Window)) && HandleKey(message))
|
||||
{
|
||||
message.Message = 0;
|
||||
message.WParam = 0;
|
||||
@@ -54,6 +56,54 @@ internal sealed partial class DocumentKeyboard : IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>Does not translate text-control input; Ctrl+F remains a native window command.</summary>
|
||||
private bool HandleKey(NativeMessage message)
|
||||
{
|
||||
if(message.WParam is 0x10 or 0x11 or 0x12 or 0x5B or 0x5C or >= 0xA0 and <= 0xA5)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
bool control = IsDown(0x11);
|
||||
bool shift = IsDown(0x10);
|
||||
bool alt = IsDown(0x12);
|
||||
bool windows = IsDown(0x5B) || IsDown(0x5C);
|
||||
bool focused = _readingFocused();
|
||||
bool nativeFind = message.WParam == 0x46 && control && !shift && !alt && !windows;
|
||||
if((!focused && !nativeFind) || alt || windows)
|
||||
{
|
||||
_keys.Reset();
|
||||
return false;
|
||||
}
|
||||
char character = nativeFind ? 'f' : message.WParam == 0x1B ? '\u001B' : control && !shift && message.WParam is 0x44 or 0x55 or 0x42
|
||||
? char.ToLowerInvariant((char)message.WParam)
|
||||
: TranslateKey((uint)message.WParam, (uint)(((nuint)message.LParam >> 16) & 0xFF));
|
||||
var key = new Core.Navigation.ReaderKey(character, control, shift, alt, windows, ((nuint)message.LParam & 0x40000000) != 0);
|
||||
Core.Navigation.ReaderCommand command = _keys.Resolve(key, focused, Environment.TickCount64);
|
||||
return command == Core.Navigation.ReaderCommand.Prefix || command != Core.Navigation.ReaderCommand.None && _execute(command);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Uses the active layout with no dead-key-state mutation; only one ASCII reading character is considered.</summary>
|
||||
private static unsafe char TranslateKey(uint key, uint scan)
|
||||
{
|
||||
byte* keyboard = stackalloc byte[256];
|
||||
char* text = stackalloc char[8];
|
||||
if(!GetKeyboardState(keyboard))
|
||||
{
|
||||
return '\0';
|
||||
}
|
||||
keyboard[0x11] = keyboard[0xA2] = keyboard[0xA3] = 0;
|
||||
int count = ToUnicode(key, scan, keyboard, text, 8, 4);
|
||||
return count == 1 && text[0] <= 0x7F ? text[0] : '\0';
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Focus/page/window changes cannot carry a prefix into another document or text control.</summary>
|
||||
public void Reset() => _keys.Reset();
|
||||
|
||||
|
||||
/// <summary>Checks only command modifiers; no keyboard input or document content is retained.</summary>
|
||||
private static bool IsDown(int key) => (GetKeyState(key) & 0x8000) != 0;
|
||||
|
||||
@@ -142,4 +192,19 @@ internal sealed partial class DocumentKeyboard : IDisposable
|
||||
[LibraryImport("user32.dll")]
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
private static partial short GetKeyState(int key);
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads only the owning UI thread's current modifier/layout state.</summary>
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
private static unsafe partial bool GetKeyboardState(byte* keyboard);
|
||||
|
||||
|
||||
|
||||
/// <summary>Translates using flag 4 so dead keys and normal native text input keep their original state.</summary>
|
||||
[LibraryImport("user32.dll")]
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
private static unsafe partial int ToUnicode(uint key, uint scan, byte* keyboard, char* text, int count, uint flags);
|
||||
}
|
||||
@@ -18,33 +18,12 @@ public sealed partial class MainWindow
|
||||
private int _findGeneration;
|
||||
private bool _finding;
|
||||
private bool _findQueued;
|
||||
private DocumentKeyboard? _documentKeyboard;
|
||||
|
||||
|
||||
|
||||
/// <summary>Debounces typing and refreshes the query only after the current page has finished restoration.</summary>
|
||||
private void InitializeFind()
|
||||
{
|
||||
try
|
||||
{
|
||||
_documentKeyboard = new DocumentKeyboard(WinRT.Interop.WindowNative.GetWindowHandle(this), () =>
|
||||
{
|
||||
if(_closed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if(string.Equals(Environment.GetEnvironmentVariable("MARKDOWNVIEWR_INPUT_TRACE"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
App.Log.Info("Input trace: owned document find command queued.");
|
||||
}
|
||||
return DispatcherQueue.TryEnqueue(OpenFind);
|
||||
});
|
||||
}
|
||||
catch(System.ComponentModel.Win32Exception)
|
||||
{
|
||||
App.Log.Warning("The document keyboard shortcut could not be initialized; Edit → Find remains available.");
|
||||
}
|
||||
Closed += (_, _) => _documentKeyboard?.Dispose();
|
||||
_findDelay = DispatcherQueue.CreateTimer();
|
||||
_findDelay.Interval = TimeSpan.FromMilliseconds(200);
|
||||
_findDelay.IsRepeating = false;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
/// <summary>Routes native reading shortcuts through explicit focus checks and bounded browser commands.</summary>
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
private DocumentKeyboard? _documentKeyboard;
|
||||
private string? _readingFocusTrace;
|
||||
|
||||
|
||||
|
||||
/// <summary>Registers only this window's thread and releases prefix/callback state on lifecycle changes.</summary>
|
||||
private void InitializeKeyboard()
|
||||
{
|
||||
try
|
||||
{
|
||||
_documentKeyboard = new DocumentKeyboard(WinRT.Interop.WindowNative.GetWindowHandle(this), IsReadingFocused, QueueReaderCommand);
|
||||
}
|
||||
catch(System.ComponentModel.Win32Exception)
|
||||
{
|
||||
App.Log.Warning("Document keyboard shortcuts could not be initialized; native menus remain available.");
|
||||
}
|
||||
Closed += (_, _) => _documentKeyboard?.Dispose();
|
||||
Activated += (_, args) =>
|
||||
{
|
||||
if(args.WindowActivationState == WindowActivationState.Deactivated)
|
||||
{
|
||||
_documentKeyboard?.Reset();
|
||||
}
|
||||
};
|
||||
_browser.DocumentReady += (_, _) => _documentKeyboard?.Reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Read-only source and the passive browser accept reading keys; find/dialog/other controls retain normal input.</summary>
|
||||
private bool IsReadingFocused()
|
||||
{
|
||||
if(_closed || !_browser.IsReady || Content.XamlRoot is not { } root)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
object focused = FocusManager.GetFocusedElement(root);
|
||||
if(string.Equals(Environment.GetEnvironmentVariable("MARKDOWNVIEWR_INPUT_TRACE"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
string observation = (focused as FrameworkElement)?.Name + ":" + focused?.GetType().Name;
|
||||
if(!string.Equals(observation, _readingFocusTrace, StringComparison.Ordinal))
|
||||
{
|
||||
_readingFocusTrace = observation;
|
||||
App.Log.Info("Input trace: reading focus " + observation + ".");
|
||||
}
|
||||
}
|
||||
return ReferenceEquals(focused, DocumentView) || _settings.ShowSource && ReferenceEquals(focused, SourceText);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Checks focus again on dispatch so queued reading commands cannot act after a text control takes focus.</summary>
|
||||
private bool QueueReaderCommand(Core.Navigation.ReaderCommand command)
|
||||
{
|
||||
if(_closed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return DispatcherQueue.TryEnqueue(() => _ = ExecuteReaderCommandAsync(command));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Observes expected native lifetime failures while preserving the current document and normal input.</summary>
|
||||
private async Task ExecuteReaderCommandAsync(Core.Navigation.ReaderCommand command)
|
||||
{
|
||||
if(_closed || command != Core.Navigation.ReaderCommand.FocusFind && !IsReadingFocused())
|
||||
{
|
||||
_documentKeyboard?.Reset();
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
switch(command)
|
||||
{
|
||||
case Core.Navigation.ReaderCommand.FocusFind:
|
||||
OpenFind();
|
||||
break;
|
||||
case Core.Navigation.ReaderCommand.NextMatch:
|
||||
MoveFind(forward: true);
|
||||
break;
|
||||
case Core.Navigation.ReaderCommand.PreviousMatch:
|
||||
MoveFind(forward: false);
|
||||
break;
|
||||
case Core.Navigation.ReaderCommand.CloseFind:
|
||||
await CloseFindAsync();
|
||||
break;
|
||||
default:
|
||||
await _browser.NavigateReaderAsync(command);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch(Exception exception) when(exception is System.Runtime.InteropServices.COMException or ObjectDisposedException or InvalidOperationException)
|
||||
{
|
||||
if(!_closed)
|
||||
{
|
||||
StatusText.Text = "The reading command could not be completed.";
|
||||
App.Log.Warning("A native reading command could not be completed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ public sealed partial class MainWindow : Window, IDisposable
|
||||
InitializeOutline();
|
||||
InitializeSource();
|
||||
InitializeFind();
|
||||
InitializeKeyboard();
|
||||
RefreshEditorCommands();
|
||||
InitializeInputDiagnostics();
|
||||
(_settings.FrontmatterKind switch { Core.Documents.FrontmatterMode.Metadata => FrontmatterMetadata, Core.Documents.FrontmatterMode.Markdown => FrontmatterMarkdown, _ => FrontmatterHide }).IsChecked = true;
|
||||
@@ -286,6 +287,7 @@ public sealed partial class MainWindow : Window, IDisposable
|
||||
_browser.Show(files, outline.Html, Path.GetFileName(path), viewport, outline.Headings);
|
||||
_find = null;
|
||||
_findGeneration++;
|
||||
_documentKeyboard?.Reset();
|
||||
_sourceMarkdown = files.Markdown;
|
||||
RefreshSource();
|
||||
_outline = outline;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Navigation;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>Verifies all reading commands, focus/modifier guards and deterministic sequence timing.</summary>
|
||||
public sealed class ReaderKeyboardTests
|
||||
{
|
||||
/// <summary>Single keys retain the pinned reading, find and dismissal semantics.</summary>
|
||||
[Theory]
|
||||
[InlineData('j', ReaderCommand.LineDown)]
|
||||
[InlineData('k', ReaderCommand.LineUp)]
|
||||
[InlineData('G', ReaderCommand.Bottom)]
|
||||
[InlineData('n', ReaderCommand.NextMatch)]
|
||||
[InlineData('N', ReaderCommand.PreviousMatch)]
|
||||
[InlineData('/', ReaderCommand.FocusFind)]
|
||||
[InlineData('\u001B', ReaderCommand.CloseFind)]
|
||||
[InlineData('J', ReaderCommand.None)]
|
||||
[InlineData('x', ReaderCommand.None)]
|
||||
public void Resolve_SingleReadingKey_ReturnsCommand(char key, ReaderCommand expected)
|
||||
=> Assert.Equal(expected, new ReaderKeyboard().Resolve(new ReaderKey(key), readingFocused: true, 100));
|
||||
|
||||
|
||||
|
||||
/// <summary>Windows Ctrl+F owns native find, while half-page and reverse-page commands apply only to reading.</summary>
|
||||
[Theory]
|
||||
[InlineData('d', ReaderCommand.HalfPageDown)]
|
||||
[InlineData('u', ReaderCommand.HalfPageUp)]
|
||||
[InlineData('b', ReaderCommand.PageUp)]
|
||||
[InlineData('f', ReaderCommand.FocusFind)]
|
||||
[InlineData('F', ReaderCommand.FocusFind)]
|
||||
[InlineData('x', ReaderCommand.None)]
|
||||
[InlineData('D', ReaderCommand.HalfPageDown)]
|
||||
[InlineData('U', ReaderCommand.HalfPageUp)]
|
||||
[InlineData('B', ReaderCommand.PageUp)]
|
||||
public void Resolve_ControlKey_PreservesNativeCommand(char key, ReaderCommand expected)
|
||||
=> Assert.Equal(expected, new ReaderKeyboard().Resolve(new ReaderKey(key, Control: true), readingFocused: true, 0));
|
||||
|
||||
|
||||
|
||||
/// <summary>Two separate matching key presses complete each prefix before the 500 ms deadline.</summary>
|
||||
[Theory]
|
||||
[InlineData('g', ReaderCommand.Top)]
|
||||
[InlineData(']', ReaderCommand.NextHeading)]
|
||||
[InlineData('[', ReaderCommand.PreviousHeading)]
|
||||
public void Resolve_TwoKeys_CompletesBoundedSequence(char key, ReaderCommand expected)
|
||||
{
|
||||
var keyboard = new ReaderKeyboard();
|
||||
Assert.Equal(ReaderCommand.Prefix, keyboard.Resolve(new ReaderKey(key), true, 100));
|
||||
Assert.Equal(expected, keyboard.Resolve(new ReaderKey(key), true, 599));
|
||||
Assert.Equal(ReaderCommand.Prefix, keyboard.Resolve(new ReaderKey(key), true, 600));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Late keys, a backward clock and repeated keydown events start a new prefix without jumping.</summary>
|
||||
[Theory]
|
||||
[InlineData(600L, false)]
|
||||
[InlineData(99L, false)]
|
||||
[InlineData(200L, true)]
|
||||
public void Resolve_ExpiredOrRepeatedPrefix_DoesNotJump(long next, bool repeated)
|
||||
{
|
||||
var keyboard = new ReaderKeyboard();
|
||||
Assert.Equal(ReaderCommand.Prefix, keyboard.Resolve(new ReaderKey('g'), true, 100));
|
||||
Assert.Equal(ReaderCommand.Prefix, keyboard.Resolve(new ReaderKey('g', Repeat: repeated), true, next));
|
||||
Assert.Equal(ReaderCommand.Top, keyboard.Resolve(new ReaderKey('g'), true, next + 1));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Different prefixes and ordinary keys replace or clear the pending sequence.</summary>
|
||||
[Fact]
|
||||
public void Resolve_DifferentKeyOrExplicitReset_ClearsPreviousPrefix()
|
||||
{
|
||||
var keyboard = new ReaderKeyboard();
|
||||
Assert.Equal(ReaderCommand.Prefix, keyboard.Resolve(new ReaderKey('g'), true, 1));
|
||||
Assert.Equal(ReaderCommand.Prefix, keyboard.Resolve(new ReaderKey(']'), true, 2));
|
||||
Assert.Equal(ReaderCommand.NextHeading, keyboard.Resolve(new ReaderKey(']'), true, 3));
|
||||
Assert.Equal(ReaderCommand.Prefix, keyboard.Resolve(new ReaderKey('g'), true, 4));
|
||||
Assert.Equal(ReaderCommand.LineDown, keyboard.Resolve(new ReaderKey('j'), true, 5));
|
||||
Assert.Equal(ReaderCommand.Prefix, keyboard.Resolve(new ReaderKey('g'), true, 6));
|
||||
keyboard.Reset();
|
||||
Assert.Equal(ReaderCommand.Prefix, keyboard.Resolve(new ReaderKey('g'), true, 7));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Text-entry focus, Alt/Windows and unrelated modified commands never become vim actions.</summary>
|
||||
[Theory]
|
||||
[InlineData(false, false, false)]
|
||||
[InlineData(true, true, false)]
|
||||
[InlineData(true, false, true)]
|
||||
public void Resolve_NonReadingOrSystemModifier_LeavesInputAlone(bool focused, bool alt, bool windows)
|
||||
{
|
||||
var keyboard = new ReaderKeyboard();
|
||||
Assert.Equal(ReaderCommand.Prefix, keyboard.Resolve(new ReaderKey('g'), true, 0));
|
||||
Assert.Equal(ReaderCommand.None, keyboard.Resolve(new ReaderKey('g', Alt: alt, Windows: windows), focused, 1));
|
||||
Assert.Equal(ReaderCommand.Prefix, keyboard.Resolve(new ReaderKey('g'), true, 2));
|
||||
Assert.Equal(ReaderCommand.None, keyboard.Resolve(new ReaderKey('d', Control: true, Alt: alt, Windows: windows), false, 3));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Shift-modified Ctrl commands are rejected even if a keyboard layout reports a lowercase character.</summary>
|
||||
[Theory]
|
||||
[InlineData('d')]
|
||||
[InlineData('u')]
|
||||
[InlineData('b')]
|
||||
[InlineData('f')]
|
||||
public void Resolve_ShiftControlKey_LeavesInputAlone(char key)
|
||||
=> Assert.Equal(ReaderCommand.None, new ReaderKeyboard().Resolve(new ReaderKey(key, Control: true, Shift: true), true, 0));
|
||||
|
||||
|
||||
/// <summary>Ctrl+F still works from native text controls; Ctrl+Shift+F remains available to those controls.</summary>
|
||||
[Fact]
|
||||
public void Resolve_TextControlFind_RequiresExactNativeModifiers()
|
||||
{
|
||||
var keyboard = new ReaderKeyboard();
|
||||
Assert.Equal(ReaderCommand.FocusFind, keyboard.Resolve(new ReaderKey('f', Control: true), false, 0));
|
||||
Assert.Equal(ReaderCommand.None, keyboard.Resolve(new ReaderKey('f', Control: true, Shift: true), false, 1));
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using FlaUI.Core.AutomationElements;
|
||||
using FlaUI.Core.Input;
|
||||
using FlaUI.Core.Patterns;
|
||||
using FlaUI.Core.WindowsAPI;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
|
||||
@@ -523,6 +525,147 @@ public class AppTests
|
||||
|
||||
|
||||
|
||||
/// <summary>Physical vim keys move the actual browser by lines, half-pages and document ends.</summary>
|
||||
[Fact]
|
||||
public void ReadingKeys_ScrollCommands_MoveActualBrowserViewport()
|
||||
{
|
||||
string body = string.Join("\n\n", Enumerable.Range(0, 180).Select(index => "Vim scrolling paragraph " + index));
|
||||
using var session = new AppSession(document: "# Vim scroll fixture\n\n" + body);
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Vim scroll fixture")), "the vim heading", TimeSpan.FromSeconds(30));
|
||||
AutomationElement scroller = FindDocumentScroller(session);
|
||||
FocusBrowser(session);
|
||||
SendReadingKey(VirtualKeyShort.KEY_J);
|
||||
WaitForReaderScroll(session, scroller, value => value > 0.001, "j scrolling one line");
|
||||
SendReadingKey(VirtualKeyShort.KEY_K);
|
||||
WaitForReaderScroll(session, scroller, value => value < 0.01, "k returning one line");
|
||||
SendReadingKey(VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_D);
|
||||
WaitForReaderScroll(session, scroller, value => value > 0.1, "Ctrl+D scrolling half a viewport");
|
||||
SendReadingKey(VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_U);
|
||||
WaitForReaderScroll(session, scroller, value => value < 0.01, "Ctrl+U returning half a viewport");
|
||||
SendReadingKey(VirtualKeyShort.SHIFT, VirtualKeyShort.KEY_G);
|
||||
WaitForReaderScroll(session, scroller, value => value > 99, "G reaching the document end");
|
||||
SendReadingKey(VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_B);
|
||||
WaitForReaderScroll(session, scroller, value => value < 99, "Ctrl+B returning a viewport");
|
||||
Keyboard.Type(VirtualKeyShort.KEY_G, VirtualKeyShort.KEY_G);
|
||||
Wait.UntilInputIsProcessed();
|
||||
WaitForReaderScroll(session, scroller, value => value < 0.01, "gg returning to the document start");
|
||||
Assert.NotNull(session.FindInApp(cf => cf.ByName("Vim scroll fixture")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Slash opens native find; n/N wrap matches while real typing remains in the query control.</summary>
|
||||
[Fact]
|
||||
public void ReadingKeys_FindAndTextFocus_PreserveLiteralInput()
|
||||
{
|
||||
string body = string.Join("\n\n", Enumerable.Range(0, 100).Select(index => "Vim find filler " + index));
|
||||
using var session = new AppSession(document: "# Vim find fixture\n\nFirst needle\n\n" + body + "\n\nLast needle");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Vim find fixture")), "the vim find heading", TimeSpan.FromSeconds(30));
|
||||
session.InvokeMenuItem("View", "SourceToggle");
|
||||
AutomationElement scroller = FindDocumentScroller(session);
|
||||
FocusBrowser(session);
|
||||
SendReadingKey(VirtualKeyShort.OEM_2);
|
||||
session.WaitForElement("FindText");
|
||||
session.EnterText("FindText", "needle");
|
||||
WaitForFindCount(session, "1 of 2");
|
||||
FocusBrowser(session);
|
||||
SendReadingKey(VirtualKeyShort.KEY_N);
|
||||
WaitForFindCount(session, "2 of 2");
|
||||
WaitForReaderScroll(session, scroller, value => value > 80, "n moving to the final native match");
|
||||
SendReadingKey(VirtualKeyShort.SHIFT, VirtualKeyShort.KEY_N);
|
||||
WaitForFindCount(session, "1 of 2");
|
||||
WaitForReaderScroll(session, scroller, value => value < 10, "N moving to the first native match");
|
||||
SendReadingKey(VirtualKeyShort.OEM_2);
|
||||
AutomationElement query = session.WaitForElement("FindText");
|
||||
query.Focus();
|
||||
SendReadingKey(VirtualKeyShort.KEY_J);
|
||||
SendReadingKey(VirtualKeyShort.KEY_K);
|
||||
SendReadingKey(VirtualKeyShort.KEY_G);
|
||||
Assert.Equal("jkg", query.AsTextBox().Text);
|
||||
WaitForFindCount(session, "0 of 0");
|
||||
Assert.True(scroller.Patterns.Scroll.Pattern.VerticalScrollPercent.Value < 10);
|
||||
FocusBrowser(session);
|
||||
SendReadingKey(VirtualKeyShort.ESCAPE);
|
||||
session.WaitForElementGone("FindText");
|
||||
Assert.NotNull(session.FindInApp(cf => cf.ByName("Vim find fixture")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Heading sequences move between actual visible headings and skip a collapsed nested descendant.</summary>
|
||||
[Fact]
|
||||
public void ReadingKeys_HeadingSequences_SkipCollapsedDescendant()
|
||||
{
|
||||
string body = string.Join("\n\n", Enumerable.Range(0, 60).Select(index => "Vim heading filler " + index));
|
||||
using var session = new AppSession(document: "# Vim root\n\n" + body + "\n\n## Vim middle\n\nMiddle body\n\n### Hidden vim child\n\n" + body + "\n\n## Vim end\n\n" + body);
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Vim root")), "the vim root heading", TimeSpan.FromSeconds(30));
|
||||
session.InvokeMenuItem("View", "OutlineToggle");
|
||||
session.ClickElement("OutlineHeading:1");
|
||||
session.ClickElement("ToggleSection");
|
||||
AppSession.WaitUntilGone(() => session.FindInApp(cf => cf.ByAutomationId("mv-heading-2")), "the collapsed nested vim heading");
|
||||
AutomationElement scroller = FindDocumentScroller(session);
|
||||
FocusBrowser(session);
|
||||
Keyboard.Type(VirtualKeyShort.KEY_G, VirtualKeyShort.KEY_G);
|
||||
Wait.UntilInputIsProcessed();
|
||||
WaitForReaderScroll(session, scroller, value => value < 0.01, "gg starting visible-heading navigation");
|
||||
Keyboard.Type(VirtualKeyShort.OEM_6, VirtualKeyShort.OEM_6);
|
||||
Wait.UntilInputIsProcessed();
|
||||
WaitForReaderScroll(session, scroller, value => value > 0.001, "the first visible heading");
|
||||
Keyboard.Type(VirtualKeyShort.OEM_6, VirtualKeyShort.OEM_6);
|
||||
Wait.UntilInputIsProcessed();
|
||||
WaitForReaderScroll(session, scroller, value => value > 20, "the middle visible heading");
|
||||
double middle = scroller.Patterns.Scroll.Pattern.VerticalScrollPercent.Value;
|
||||
Keyboard.Type(VirtualKeyShort.OEM_6, VirtualKeyShort.OEM_6);
|
||||
Wait.UntilInputIsProcessed();
|
||||
WaitForReaderScroll(session, scroller, value => value > middle + 0.01, "the final heading after skipping its hidden predecessor");
|
||||
Assert.Null(session.FindInApp(cf => cf.ByAutomationId("mv-heading-2")));
|
||||
AutomationElement finalHeading = session.WaitForElement("mv-heading-3");
|
||||
double viewportTop = session.WaitForElement("DocumentView").BoundingRectangle.Top;
|
||||
Assert.InRange(finalHeading.BoundingRectangle.Top - viewportTop, -2, 10);
|
||||
Keyboard.Type(VirtualKeyShort.OEM_4, VirtualKeyShort.OEM_4);
|
||||
Wait.UntilInputIsProcessed();
|
||||
WaitForReaderScroll(session, scroller, value => Math.Abs(value - middle) < 0.1, "the previous visible middle heading");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Focuses only the owned reader before injecting real virtual-key events.</summary>
|
||||
private static void FocusBrowser(AppSession session)
|
||||
{
|
||||
session.MainWindow.SetForeground();
|
||||
session.WaitForElement("DocumentView").Focus();
|
||||
Wait.UntilInputIsProcessed();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Uses actual Windows keydown/up messages, including simultaneous modifiers.</summary>
|
||||
private static void SendReadingKey(params VirtualKeyShort[] keys)
|
||||
{
|
||||
Keyboard.TypeSimultaneously(keys);
|
||||
Wait.UntilInputIsProcessed();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Retains the owned fixture's focus/provider/viewport evidence when a physical reading key fails.</summary>
|
||||
private void WaitForReaderScroll(AppSession session, AutomationElement scroller, Func<double, bool> matches, string description)
|
||||
{
|
||||
try
|
||||
{
|
||||
session.WaitFor(() => matches(scroller.Patterns.Scroll.Pattern.VerticalScrollPercent.Value) ? scroller : null, description);
|
||||
}
|
||||
catch(TimeoutException)
|
||||
{
|
||||
AutomationElement focused = session.Automation.FocusedElement();
|
||||
_output.WriteLine($"Reading focus Id={focused.Properties.AutomationId.ValueOrDefault}; Type={focused.Properties.ControlType.ValueOrDefault}; PID={focused.Properties.ProcessId.ValueOrDefault}; viewport={scroller.Patterns.Scroll.Pattern.VerticalScrollPercent.Value}.");
|
||||
_output.WriteLine(session.ReadLogs());
|
||||
_output.WriteLine(session.DescribeDocumentStatus());
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>A physical local-fragment click reveals a hidden generated heading without loading another page.</summary>
|
||||
[Fact]
|
||||
public void Outline_LocalFragmentClick_RevealsCollapsedTarget()
|
||||
@@ -795,10 +938,11 @@ public class AppTests
|
||||
/// <summary>Finds the browser's actual scroll provider, which is separate from the native window.</summary>
|
||||
private static AutomationElement FindDocumentScroller(AppSession session)
|
||||
{
|
||||
AutomationElement browser = session.WaitForElement("DocumentView");
|
||||
return session.WaitFor(
|
||||
() => session.MainWindow.FindAllDescendants().FirstOrDefault(element =>
|
||||
() => browser.FindAllDescendants().Prepend(browser).FirstOrDefault(element =>
|
||||
element.Patterns.Scroll.TryGetPattern(out IScrollPattern? scroll) && scroll.VerticallyScrollable.Value),
|
||||
"the browser scroll provider");
|
||||
"the owned browser scroll provider");
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user