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:
5 files changed
+221
-3
No files matched your search
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user