fix(input): route owned browser shortcut messages

Limit the Ctrl+F adapter to this foreground window and UI thread. Keep physical keyboard/link assertions and retain exact focus, pointer and fixture screenshot evidence.

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:51:10 -05:00
1 parent 7d7c712c63
commit 2292603569
5 files changed
+221 -3

No files matched your search

+20
View File
@@ -185,3 +185,23 @@ browser-focus key test. Fragment lookup now requires the generated browser headi
ID and name; physical link click/reveal assertions remain. Also correct one ADR
heading's Markdown spacing. Neither correction is verified until hosted acceptance.
Full PRODUCT.md scope remains active; no local desktop input or release publication.
## Owned browser keyboard integration
7d7c712c6365fd6a036c572de3b51b49c6daa1ea CI 38109196556 is terminal FAILURE:
36/38 native/UI and published tests; installed channels not reached. Ctrl+F still
never opens find, despite a loaded root accelerator. Fragment collapse is now
correctly observed, but pointer hit is not the exact link/text child. Keep those
outcome assertions and retain owned-window screenshot, UIA, hit/focus diagnostics.
The follow-up installs WH_GETMESSAGE only on the current app UI thread, requires
the active owned window/child and Ctrl without Shift/Alt/Windows, and consumes
only that find command before browser dispatch. It queues the existing OpenFind,
forwards every other message, and removes the callback on window closure. There
is no global hook, browser bridge or search/document text logging. Host keyboard
acceptance remains pending. Exact logs: artifacts/window-find-ui-38109196556.log
and artifacts/window-find-installed-38109196556.log.
Separate local editor work is tracked by issue #11: literal ProcessStartInfo
ArgumentList, persisted executable selection and native controls. Its 195 core
tests/100% lines pass; that work is not included in this keyboard correction.
+11
View File
@@ -68,3 +68,14 @@ does not establish working release signing.
| --- | --- | --- |
| 2026-10-10 | Codex (GPT-6) | Added directory watching and guarded numeric viewport preservation; hosted reload acceptance pending |
| 2026-10-10 | Codex (GPT-6) | Replaced template-only interfaces with actual document and browser boundaries; recorded separate unit/native/browser evidence |
## 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).
@@ -0,0 +1,145 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace MarkdownViewr.Windows;
/// <summary>Routes one command from owned browser child windows before their native message dispatch.</summary>
internal sealed partial class DocumentKeyboard : IDisposable
{
private readonly nint _window;
private readonly Func<bool> _find;
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)
{
_window = window;
_find = find;
_callback = OnMessage;
_hook = SetWindowsHookExW(3, Marshal.GetFunctionPointerForDelegate(_callback), 0, GetCurrentThreadId());
if(_hook == 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not initialize the document keyboard shortcut.");
}
}
/// <summary>Consumes only an unmodified Ctrl+F 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())
{
message.Message = 0;
message.WParam = 0;
message.LParam = 0;
Marshal.StructureToPtr(message, pointer, fDeleteOld: false);
}
}
return CallNextHookEx(_hook, code, remove, pointer);
}
/// <summary>Checks only command modifiers; no keyboard input or document content is retained.</summary>
private static bool IsDown(int key) => (GetKeyState(key) & 0x8000) != 0;
/// <summary>Removes the native callback before the owning window and browser are destroyed.</summary>
public void Dispose()
{
if(_hook != 0)
{
_ = UnhookWindowsHookEx(_hook);
_hook = 0;
}
GC.KeepAlive(_callback);
}
/// <summary>The Windows message layout, including its reserved trailing field.</summary>
[StructLayout(LayoutKind.Sequential)]
private struct NativeMessage
{
public nint Window;
public uint Message;
public nuint WParam;
public nint LParam;
public uint Time;
public int X;
public int Y;
public uint Private;
}
/// <summary>The current-thread hook callback uses the Windows calling convention.</summary>
[UnmanagedFunctionPointer(CallingConvention.Winapi)]
private delegate nint HookCallback(int code, nuint remove, nint pointer);
/// <summary>Registers the callback in the named thread.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
private static partial nint SetWindowsHookExW(int hook, nint callback, nint module, uint thread);
/// <summary>Preserves all unhandled message processing in the hook chain.</summary>
[LibraryImport("user32.dll")]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
private static partial nint CallNextHookEx(nint hook, int code, nuint remove, nint pointer);
/// <summary>Releases the thread callback registration.</summary>
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
private static partial bool UnhookWindowsHookEx(nint hook);
/// <summary>Limits delivery to the current UI thread.</summary>
[LibraryImport("kernel32.dll")]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
private static partial uint GetCurrentThreadId();
/// <summary>Requires this window to own foreground input.</summary>
[LibraryImport("user32.dll")]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
private static partial nint GetForegroundWindow();
/// <summary>Confirms that a browser child belongs to the app window.</summary>
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
private static partial bool IsChild(nint parent, nint child);
/// <summary>Reads the current message thread's modifier state.</summary>
[LibraryImport("user32.dll")]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
private static partial short GetKeyState(int key);
}
+26 -1
View File
@@ -18,12 +18,33 @@ 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;
@@ -63,7 +84,11 @@ public sealed partial class MainWindow
return;
}
FindBar.Visibility = Visibility.Visible;
FindText.Focus(FocusState.Programmatic);
bool focused = FindText.Focus(FocusState.Programmatic);
if(string.Equals(Environment.GetEnvironmentVariable("MARKDOWNVIEWR_INPUT_TRACE"), "1", StringComparison.Ordinal))
{
App.Log.Info(FormattableString.Invariant($"Input trace: find toolbar opened; focus accepted={focused}."));
}
FindText.SelectAll();
QueueFind();
}
@@ -273,7 +273,18 @@ public class AppTests
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");
try
{
session.WaitForElement("FindText");
}
catch(TimeoutException)
{
AutomationElement focused = session.Automation.FocusedElement();
_output.WriteLine($"Find shortcut focus: Id={focused.Properties.AutomationId.ValueOrDefault}; Type={focused.Properties.ControlType.ValueOrDefault}; PID={focused.Properties.ProcessId.ValueOrDefault}.");
_output.WriteLine(session.ReadLogs());
_output.WriteLine(session.DescribeDocumentStatus());
throw;
}
FlaUI.Core.Input.Keyboard.Type("alpha");
WaitForFindCount(session, "1 of 3");
Assert.Equal("Alpha", ReadSourceSelection(source));
@@ -450,7 +461,13 @@ public class AppTests
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.");
bool exactLink = hit.Equals(link) || hit.Parent.Equals(link);
if(!exactLink)
{
_output.WriteLine($"Fragment point={point}; mouse={FlaUI.Core.Input.Mouse.Position}; link={link.Properties.BoundingRectangle.ValueOrDefault}; hit Id={hit.Properties.AutomationId.ValueOrDefault}; Type={hit.Properties.ControlType.ValueOrDefault}; PID={hit.Properties.ProcessId.ValueOrDefault}; bounds={hit.Properties.BoundingRectangle.ValueOrDefault}; parent Type={hit.Parent.Properties.ControlType.ValueOrDefault}; bounds={hit.Parent.Properties.BoundingRectangle.ValueOrDefault}.");
_output.WriteLine(session.DescribeDocumentStatus());
}
Assert.True(exactLink, "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);