Skip to content

Commit e0ca21a

Browse files
committed
Check cancellable window close from Flutter on Windows (#65)
flutter_window_title_bar_example gets a Closing option: Keep open cancels every WindowCloseRequestedEvent and counts it. tools/gui/flutter_window_title_bar_close_test.ps1 clicks the window's own close button and the system menu's Close for real: both stay cancelled, and with Allow the close button closes the window and the app exits 0.
1 parent a43ffb0 commit e0ca21a

3 files changed

Lines changed: 143 additions & 0 deletions

File tree

‎examples/flutter_window_title_bar_example/lib/main.dart‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ class _TitleBarPageState extends State<TitleBarPage> {
3535
String _note = 'Try the three states and watch the strip at the top';
3636
final List<String> _log = [];
3737
int? _listenerId;
38+
int? _closeListenerId;
39+
bool _keepOpen = false;
40+
int _cancelledCloses = 0;
3841

3942
@override
4043
void initState() {
@@ -48,6 +51,19 @@ class _TitleBarPageState extends State<TitleBarPage> {
4851
window.center();
4952
// The strip's Maximize / Restore follows the window however it changes:
5053
// its own button, the snap layouts, a double-click, a shortcut.
54+
// Every way of closing the window asks first: its close button, the
55+
// system menu, Alt+F4, the taskbar. Keep open cancels the request.
56+
_closeListenerId = window.addListener((event) {
57+
if (event is! WindowCloseRequestedEvent || !_keepOpen) return;
58+
if (!event.request.isCancelable) return; // The system insists.
59+
event.request.cancel();
60+
if (mounted) {
61+
setState(() {
62+
_cancelledCloses++;
63+
_note = 'Close cancelled — choose Allow to let it close';
64+
});
65+
}
66+
});
5167
_listenerId = WindowManager.instance.addListener((event) {
5268
if (event.windowId != window.id) return;
5369
if (event is WindowMaximizedEvent || event is WindowRestoredEvent) {
@@ -66,6 +82,8 @@ class _TitleBarPageState extends State<TitleBarPage> {
6682
void dispose() {
6783
final id = _listenerId;
6884
if (id != null) WindowManager.instance.removeListener(id);
85+
final closeId = _closeListenerId;
86+
if (closeId != null) _window?.removeListener(closeId);
6987
super.dispose();
7088
}
7189

@@ -169,6 +187,28 @@ class _TitleBarPageState extends State<TitleBarPage> {
169187
const Hint('A style resets these; set them after it'),
170188
],
171189
),
190+
OptionRow(
191+
label: 'Closing',
192+
children: [
193+
OptionChip(
194+
label: 'Allow',
195+
selected: !_keepOpen,
196+
onTap: () => setState(() {
197+
_keepOpen = false;
198+
_note = 'Closing the window closes it';
199+
}),
200+
),
201+
OptionChip(
202+
label: 'Keep open',
203+
selected: _keepOpen,
204+
onTap: () => setState(() {
205+
_keepOpen = true;
206+
_note = 'Close requests are cancelled; try the × or the menu';
207+
}),
208+
),
209+
Hint('Cancelled closes: $_cancelledCloses'),
210+
],
211+
),
172212
Expanded(
173213
child: SingleChildScrollView(
174214
child: Column(

‎tools/gui/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ runner) lives in [`.agents/skills/`](../../.agents/skills); read
2020
| `core_window_snap_layout_test.ps1` | core `window_maximize_button_test --serve` (#70) | Windows 11 | Real hover, no clicks: a hidden-title-bar window marks its own maximize button with `SetMaximizeButtonBounds`; resting the cursor there opens the snap layouts (another process's window appears below the button), the content window still receives the moves inside the button, and leaving closes the flyout and reaches the content as `WM_MOUSELEAVE`. Saves a screenshot. Override the executable with `CORE_SNAP_LAYOUT_TEST_EXE`. |
2121
| `flutter_window_title_bar_snap_test.ps1` | `window_title_bar_example` (#70) | Windows 11 | End to end in a real Flutter app: with the title bar Hidden the strip draws a Maximize / Restore chip wrapped in `MaximizeButtonArea`; resting the cursor on it opens the snap layouts and leaving closes them, a click maximizes (the chip reads Restore) and another restores to the earlier width. Owner-guarded clicks, no keyboard. Saves a screenshot. Set `TITLE_BAR_EXAMPLE_EXE` for a build outside `$RemoteWorkspace`. |
2222
| `flutter_window_title_bar_system_menu_test.ps1` | `window_title_bar_example` (#66) | Windows | End to end in a real Flutter app: a secondary click on the `DragToMoveArea` strip opens the native system menu at the pointer; its item states match the window (restored: Restore off; maximized: Move, Size, Maximize off); choosing Maximize maximizes and, from a second menu, Restore restores to the earlier width. Items are taken by their fixed order, so any display language works. Owner-guarded clicks, no keyboard. Set `TITLE_BAR_EXAMPLE_EXE` for a build outside `$RemoteWorkspace`. |
23+
| `flutter_window_title_bar_close_test.ps1` | `window_title_bar_example` (#65) | Windows | End to end in a real Flutter app: with Closing set to Keep open, the example cancels `WindowCloseRequestedEvent`; a real click on the window's own close button (found through DWM's caption button bounds) and the system menu's Close each leave the window open and count one cancelled request; with Allow, the close button closes the window and the app exits with code 0. Owner-guarded clicks, no keyboard. Set `TITLE_BAR_EXAMPLE_EXE` for a build outside `$RemoteWorkspace`. |
2324
| `core_window_shadow_layout_test_linux.cpp` | core shadow | Linux Wayland / X11 | No-input regression: stable shadow geometry, contour pixels inside transparent content, input pass-through, disable and decoration restoration |
2425
| `flutter_window_shape_smoke_macos.py` / `flutter_window_shape_test_windows.ps1` | `shaped_window_example` | macOS / Windows | macOS: no-input shape and pixel-alpha smoke test; Windows: native regions, real button clicks/drag, resize, clear/reapply, click-through to the underlying app window |
2526
| `flutter_detachable_window_test.py` / `.ps1` | `detachable_window_example` | macOS / Windows | tear a panel off, exact content size, header stays under the cursor, dock into the other window, `State` preserved |
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# End-to-end check of cancellable close requests (#65) from Flutter on
2+
# Windows: flutter_window_title_bar_example's "Keep open" cancels every close
3+
# request through WindowCloseRequestedEvent. The window's own close button and
4+
# the system menu's Close are clicked for real: cancelled, the window stays;
5+
# allowed, it closes and the app exits normally. Owner-guarded clicks on the
6+
# example's own window and menu only, no keyboard input.
7+
$ErrorActionPreference = 'Stop'
8+
. "$PSScriptRoot\env.ps1"
9+
. "$PSScriptRoot\winput.ps1"
10+
. "$PSScriptRoot\guiapp.ps1"
11+
Add-Type @"
12+
using System;
13+
using System.Runtime.InteropServices;
14+
public static class CloseWin {
15+
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
16+
[DllImport("dwmapi.dll")] static extern int DwmGetWindowAttribute(IntPtr h, int a, out RECT r, int size);
17+
[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
18+
// DWMWA_CAPTION_BUTTON_BOUNDS, relative to the window: minimize, maximize, close.
19+
public static int[] CaptionButtons(IntPtr h) {
20+
RECT r; if (DwmGetWindowAttribute(h, 5, out r, Marshal.SizeOf(typeof(RECT))) != 0) return null;
21+
return new[] { r.L, r.T, r.R, r.B };
22+
}
23+
}
24+
"@
25+
26+
Start-Result "$RemoteScratch\flutter_window_title_bar_close_test.result.txt"
27+
$exe = $env:TITLE_BAR_EXAMPLE_EXE
28+
if (-not $exe) {
29+
$exe = Get-FlutterExe "$RemoteWorkspace\examples\flutter_window_title_bar_example" 'window_title_bar_example'
30+
}
31+
Assert-Idle
32+
$app = Start-GuiApp $exe
33+
# Opened now, or ExitCode stays empty for a Start-Process -PassThru process.
34+
$null = $app.Proc.Handle
35+
# The probe fails (a traceback on stderr) until the VM service is up, and
36+
# PowerShell 5.1 turns native stderr into a terminating error under Stop.
37+
function Get-ViewsQuietly {
38+
$saved = $ErrorActionPreference
39+
$ErrorActionPreference = 'Continue'
40+
try { @(Get-Views $app 2>$null) } catch { @() } finally { $ErrorActionPreference = $saved }
41+
}
42+
function Get-State {
43+
$script:win = Get-Win $app 'nativeapi*'
44+
if (-not $script:win) { throw 'The example window is missing' }
45+
$script:view = Get-ViewsQuietly | Select-Object -First 1
46+
if (-not $script:view) { throw 'The example has no Flutter view to probe' }
47+
}
48+
function Get-Cancelled {
49+
Get-State
50+
$t = @($view.texts | % { $_[0] } | ? { $_ -like 'Cancelled closes: *' }) | Select-Object -First 1
51+
if (-not $t) { throw 'The cancelled-closes counter is missing' }
52+
[int]$t.Split(':')[1].Trim()
53+
}
54+
function Click-CloseButton {
55+
Get-State
56+
$b = [CloseWin]::CaptionButtons([IntPtr]$win.Hwnd)
57+
if (-not $b) { throw 'No caption button bounds' }
58+
$third = [int](($b[2] - $b[0]) / 3)
59+
$pt = @(($win.Left + $b[2] - [int]($third / 2)), ($win.Top + [int](($b[1] + $b[3]) / 2)))
60+
Invoke-Click $app $pt 400
61+
Pause 1.5
62+
}
63+
64+
try {
65+
$deadline = (Get-Date).AddSeconds(40)
66+
while (@(Get-ViewsQuietly).Count -eq 0 -and (Get-Date) -lt $deadline) { Pause 0.5 }
67+
Get-State
68+
Invoke-Activate $app $win
69+
Invoke-Click $app (ConvertTo-Screen $win (Get-TextCenter $view 'Keep open')) 400
70+
Pause 0.8
71+
Check 'the counter starts at 0' ((Get-Cancelled) -eq 0)
72+
73+
Click-CloseButton
74+
Check 'with Keep open, the close button leaves the window open' ([CloseWin]::IsWindow([IntPtr]$win.Hwnd) -and -not $app.Proc.HasExited)
75+
Check 'and the example cancelled one request' ((Get-Cancelled) -eq 1)
76+
77+
# The system menu's Close (the last of its fixed items), from the strip.
78+
Invoke-RightClick $app (ConvertTo-Screen $win (Get-TextCenter $view 'Drag this strip to move the window'))
79+
if (-not (Wait-Menu $app)) { throw 'No system menu opened' }
80+
$menu = @(Get-OpenMenus $app)[0]
81+
if ($menu.Items.Count -ne 6) { throw "Expected the 6 system menu items, got $($menu.Items.Count)" }
82+
$pt = Get-RectCenter $menu.Items[5].Rect
83+
Move-Cursor $pt 400
84+
Pause 0.2
85+
Assert-Owner $app.Proc.Id $pt[0] $pt[1]
86+
[WInput]::Click($pt[0], $pt[1])
87+
Pause 1.5
88+
Check 'with Keep open, the system menu Close leaves the window open' ([CloseWin]::IsWindow([IntPtr]$win.Hwnd) -and -not $app.Proc.HasExited)
89+
Check 'and the example cancelled a second request' ((Get-Cancelled) -eq 2)
90+
91+
Invoke-Click $app (ConvertTo-Screen $win (Get-TextCenter $view 'Allow')) 400
92+
Pause 0.8
93+
Click-CloseButton
94+
$exited = $app.Proc.WaitForExit(10000)
95+
Check 'with Allow, the close button closes the window and the app exits' $exited
96+
if ($exited) { Check 'with exit code 0' ($app.Proc.ExitCode -eq 0) "exit code $($app.Proc.ExitCode)" }
97+
} catch {
98+
Check 'the scenario ran to the end' $false "$($_.Exception.Message) at line $($_.InvocationInfo.ScriptLineNumber)"
99+
} finally {
100+
if (-not $app.Proc.HasExited) { Stop-GuiApp $app | Out-Null }
101+
}
102+
Say "$(if ($script:Failures) { 'FAILED' } else { 'OK' })"

0 commit comments

Comments
 (0)