From da6bcd5969779bfe0396bcf89a8025d4375d611e Mon Sep 17 00:00:00 2001 From: JJ Liebig Date: Fri, 18 Sep 2026 02:42:37 +0200 Subject: [PATCH] fix(windows): preserve mouse capture during refresh (#4319) * fix(windows): preserve mouse capture during refresh refs #4284 * test(windows): fix default input gauntlet invocation * fix(windows): preserve pixel mouse transitions refs #4284 --- scripts/test_windows_input.ps1 | 4 +++- scripts/windows_input/README.md | 10 ++++++++++ src/client/terminal_setup.rs | 25 ++++++++++++++++--------- src/terminal_modes.rs | 21 ++++++++++++--------- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/scripts/test_windows_input.ps1 b/scripts/test_windows_input.ps1 index dfbd41b4..28839a07 100644 --- a/scripts/test_windows_input.ps1 +++ b/scripts/test_windows_input.ps1 @@ -29,8 +29,10 @@ $root = [IO.Directory]::CreateDirectory($OutputDirectory).FullName $null = Invoke-GauntletProcess $python @($reportScript, 'matrix', '--output', (Join-Path $root 'matrix.json')) $matrix = Read-GauntletJson (Join-Path $root 'matrix.json') if ($MatrixOnly) { Write-Host "Matrix: $root/matrix.json"; exit 0 } +$caseSelectionProvided = $PSBoundParameters.ContainsKey('Cases') +$Cases = @($Cases | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $knownCases = @($matrix.cases | ForEach-Object id) -if (@($Cases).Count -and @($Cases | Where-Object { $_ -notin $knownCases }).Count) { throw 'Unknown case selection' } +if (($caseSelectionProvided -and -not @($Cases).Count) -or @($Cases | Where-Object { $_ -notin $knownCases }).Count) { throw 'Unknown case selection' } $selectedCases = if (@($Cases).Count) { @($matrix.cases | Where-Object id -in $Cases) } else { @($matrix.cases) } if (-not $IsWindows) { throw 'Real-host qualification requires Windows and an interactive desktop; no tests passed' } if (-not $AllowInputInjection) { throw 'Read scripts/windows_input/README.md, then explicitly pass -AllowInputInjection on an isolated desktop' } diff --git a/scripts/windows_input/README.md b/scripts/windows_input/README.md index 340ecb0e..36caf3f6 100644 --- a/scripts/windows_input/README.md +++ b/scripts/windows_input/README.md @@ -51,6 +51,16 @@ The full catalogue normally exits `2` because operator-assisted and explicitly unimplemented qualification cases remain. That is incomplete coverage, not an automated test failure; inspect the printed matrix and retained `report.json`. +### Expected outcome + +A healthy run builds and identifies the current checkout, reaches the result +matrix, and reports no `FAIL` rows or cleanup errors. The default Herdr column +should identify itself as `Win32 (Herdr)*` from captured runtime evidence. A full +run may still exit `2` and show `MANUAL`, `NOT TESTED`, or a documented host +capability limitation when a terminal channel, physical gesture, or oracle is +unavailable. A focused campaign exits `0` when every selected observation passes; +exit `1` means an assertion, harness, or cleanup failure and needs investigation. + The recipe itself is the explicit opt-in to foreground input injection. It builds the current checkout in release mode, stages that exact binary with the pinned ConPTY runtime, and prints its path and hash. Use `-ExePath` only to compare a diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index e888305d..3bb5c281 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -348,7 +348,9 @@ fn set_windows_native_mouse_capture( sgr_pixels: bool, set_console_capture: impl FnOnce(bool) -> io::Result<()>, ) -> io::Result<()> { - crate::terminal_modes::clear_host_mouse_reporting(writer)?; + if !enabled { + crate::terminal_modes::clear_host_mouse_reporting(writer)?; + } set_console_capture(enabled)?; if enabled { crate::terminal_modes::set_windows_mouse_reporting(writer, true, sgr_pixels)?; @@ -365,7 +367,9 @@ fn windows_uses_vt_mouse_reporting() -> bool { pub(super) fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<()> { #[cfg(windows)] if windows_uses_vt_mouse_reporting() { - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + if !enabled { + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + } return crate::terminal_modes::set_windows_mouse_reporting( &mut io::stdout(), enabled, @@ -642,7 +646,7 @@ mod tests { } #[test] - fn windows_native_mouse_capture_never_resets_encoding_after_native_enable() { + fn windows_native_mouse_capture_reasserts_final_encoding_after_native_enable() { let mut output = SharedOutput::default(); for sgr_pixels in [false, false, true, false] { let start = output.0.borrow().len(); @@ -663,8 +667,8 @@ mod tests { let bytes = output.0.borrow(); let before = std::str::from_utf8(&bytes[start..native_boundary.get()]).unwrap(); let after = std::str::from_utf8(&bytes[native_boundary.get()..]).unwrap(); - assert!(before.contains("\x1b[?1016l")); - for reset in ["\x1b[?1005l", "\x1b[?1006l", "\x1b[?1016l"] { + assert!(!before.contains("\x1b[?1016l")); + for reset in ["\x1b[?1005l", "\x1b[?1006l"] { assert!( !after.contains(reset), "mouse format reset after native capture (sgr_pixels={sgr_pixels}): {after:?}" @@ -672,22 +676,25 @@ mod tests { } assert!(after.contains("\x1b[?1003h\x1b[?1006h")); assert_eq!(after.contains("\x1b[?1016h"), sgr_pixels); + if !sgr_pixels { + assert!(after.starts_with("\x1b[?1016l")); + } } } #[test] - fn windows_native_mouse_capture_restores_reporting_after_reset() { + fn windows_native_mouse_capture_disable_clears_reporting() { let mut output = Vec::new(); - set_windows_native_mouse_capture(&mut output, true, false, |enabled| { - assert!(enabled); + set_windows_native_mouse_capture(&mut output, false, false, |enabled| { + assert!(!enabled); Ok(()) }) .unwrap(); assert_eq!( output, - b"\x1b[?1006l\x1b[?1016l\x1b[?1015l\x1b[?1005l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?9l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h" + b"\x1b[?1006l\x1b[?1016l\x1b[?1015l\x1b[?1005l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?9l" ); } } diff --git a/src/terminal_modes.rs b/src/terminal_modes.rs index 11bf56fc..b3973209 100644 --- a/src/terminal_modes.rs +++ b/src/terminal_modes.rs @@ -21,15 +21,18 @@ pub(crate) fn set_windows_mouse_reporting( enabled: bool, sgr_pixels: bool, ) -> io::Result<()> { - writer.write_all(if enabled { - WINDOWS_MOUSE_REPORTING_ENABLE_SEQUENCE + if enabled { + // Keep a true-to-false pixel transition in the same buffered reassertion, + // before the final SGR enable, so refresh never exposes a disabled gap. + if !sgr_pixels { + writer.write_all(b"\x1b[?1016l")?; + } + writer.write_all(WINDOWS_MOUSE_REPORTING_ENABLE_SEQUENCE)?; + if sgr_pixels { + writer.write_all(b"\x1b[?1016h")?; + } } else { - WINDOWS_MOUSE_REPORTING_DISABLE_SEQUENCE - })?; - // The caller clears modes before native capture. Resetting 1016 here can - // undo the SGR encoding that ConPTY has already enabled on the host. - if enabled && sgr_pixels { - writer.write_all(b"\x1b[?1016h")?; + writer.write_all(WINDOWS_MOUSE_REPORTING_DISABLE_SEQUENCE)?; } writer.flush() } @@ -195,7 +198,7 @@ mod tests { assert_eq!( output, - b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1016h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1016l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l" + b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1016h\x1b[?1016l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1016l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l" ); } }