Skip to content

Commit a43ffb0

Browse files
committed
Open the system menu on release on Windows (#66)
DragToMoveArea opened it on the secondary press. On Windows the menu's modal loop then took the release, Flutter never saw it and ignored the next secondary click, so the menu opened once (found with a real Flutter app). It now opens on the release there, as a native title bar does; Linux keeps the press, which a Wayland compositor requires. tools/gui/flutter_window_title_bar_system_menu_test.ps1 checks it end to end in flutter_window_title_bar_example: the menu at the pointer, item states for a restored and a maximized window, Maximize and Restore chosen from it. Core e21c922 documents the timing on ShowSystemMenu.
1 parent 52b96df commit a43ffb0

6 files changed

Lines changed: 146 additions & 8 deletions

File tree

‎bindings/dart/nativeapi_flutter/CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
## 0.4.1
22

3+
* `DragToMoveArea` opens the system window menu on the secondary button's
4+
release on Windows, as a native title bar does. Opened on the press, the
5+
menu's modal loop took the release and Flutter ignored the next secondary
6+
click, so the menu opened only once. Linux keeps the press, which a Wayland
7+
compositor requires.
8+
39
* `MaximizeButtonArea`: wrap a custom title bar's maximize button in it and
410
Windows 11 shows the snap layouts when the pointer rests on it. The button
511
still gets hover, press and tap; the area follows it and goes away with it.

‎bindings/dart/nativeapi_flutter/lib/src/widgets/drag_to_move_area.dart‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:flutter/foundation.dart';
12
import 'package:flutter/widgets.dart';
23
import 'package:nativeapi/nativeapi.dart'
34
hide
@@ -13,8 +14,12 @@ import 'package:nativeapi/nativeapi.dart'
1314
View;
1415

1516
/// A custom title-bar region that drags the window and performs the native
16-
/// title-bar double-click action, including the macOS user preference. A secondary-button press opens the native system window menu
17-
/// where supported (Windows, or a supporting Linux window manager).
17+
/// title-bar double-click action, including the macOS user preference. A
18+
/// secondary click opens the native system window menu where supported
19+
/// (Windows, or a supporting Linux window manager): on release on Windows, as
20+
/// a native title bar does (the menu's modal loop would otherwise swallow the
21+
/// release, and Flutter would ignore the next secondary click), and on press
22+
/// elsewhere (a Wayland compositor accepts the request only with a press).
1823
class DragToMoveArea extends StatelessWidget {
1924
const DragToMoveArea({super.key, required this.child, this.window});
2025

@@ -25,13 +30,21 @@ class DragToMoveArea extends StatelessWidget {
2530

2631
@override
2732
Widget build(BuildContext context) {
33+
void showSystemMenu(Offset position) {
34+
(window ?? WindowManager.instance.getCurrent())?.showSystemMenu(
35+
Point(x: position.dx, y: position.dy),
36+
);
37+
}
38+
39+
final onRelease = defaultTargetPlatform == TargetPlatform.windows;
2840
return GestureDetector(
2941
behavior: HitTestBehavior.translucent,
30-
onSecondaryTapDown: (details) {
31-
(window ?? WindowManager.instance.getCurrent())?.showSystemMenu(
32-
Point(x: details.globalPosition.dx, y: details.globalPosition.dy),
33-
);
34-
},
42+
onSecondaryTapDown: onRelease
43+
? null
44+
: (details) => showSystemMenu(details.globalPosition),
45+
onSecondaryTapUp: onRelease
46+
? (details) => showSystemMenu(details.globalPosition)
47+
: null,
3548
onPanStart: (_) {
3649
(window ?? WindowManager.instance.getCurrent())?.startDragging();
3750
},

‎bindings/dart/nativeapi_flutter/test/widgets/window_drag_areas_test.dart‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,34 @@ void main() {
8383
expect(window.systemMenuPositions, hasLength(1));
8484
},
8585
);
86+
testWidgets('move area opens system menu on secondary release on Windows', (
87+
tester,
88+
) async {
89+
final window = _Window();
90+
await tester.pumpWidget(
91+
_host(
92+
DragToMoveArea(
93+
window: window,
94+
child: const SizedBox(width: 200, height: 100),
95+
),
96+
),
97+
);
98+
final point =
99+
tester.getCenter(find.byType(DragToMoveArea)) + const Offset(12, 8);
100+
final gesture = await tester.startGesture(
101+
point,
102+
kind: PointerDeviceKind.mouse,
103+
buttons: kSecondaryMouseButton,
104+
);
105+
await tester.pump(const Duration(milliseconds: 150));
106+
expect(window.systemMenuPositions, isEmpty);
107+
await gesture.up();
108+
await tester.pumpAndSettle();
109+
expect(window.systemMenuPositions, hasLength(1));
110+
expect(window.systemMenuPositions.single.x, point.dx);
111+
expect(window.systemMenuPositions.single.y, point.dy);
112+
expect(window.dragCount, 0);
113+
}, variant: TargetPlatformVariant.only(TargetPlatform.windows));
86114
testWidgets(
87115
'move area drags and delegates each double tap without a fallback',
88116
(tester) async {

‎core‎

Submodule core updated 1 file

‎tools/gui/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ runner) lives in [`.agents/skills/`](../../.agents/skills); read
1919
| `core_window_system_menu_test.ps1` | core system menu regression | Windows | Native popup states and guarded selections of custom, maximize, restore, minimize and close commands; content coordinates scale once. Override the executable with `CORE_SYSTEM_MENU_TEST_EXE`. |
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`. |
22+
| `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`. |
2223
| `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 |
2324
| `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 |
2425
| `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: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# End-to-end check of ShowSystemMenu (#66) from Flutter on Windows: a
2+
# secondary click on flutter_window_title_bar_example's DragToMoveArea strip
3+
# opens the native system menu at the pointer, with the item states of the
4+
# window's current state, and a chosen item acts on the window. Owner-guarded
5+
# clicks on the example's own window and menu only, no keyboard input.
6+
$ErrorActionPreference = 'Stop'
7+
. "$PSScriptRoot\env.ps1"
8+
. "$PSScriptRoot\winput.ps1"
9+
. "$PSScriptRoot\guiapp.ps1"
10+
Add-Type @"
11+
using System;
12+
using System.Runtime.InteropServices;
13+
public static class MenuWin {
14+
[DllImport("user32.dll")] public static extern bool IsZoomed(IntPtr h);
15+
}
16+
"@
17+
18+
Start-Result "$RemoteScratch\flutter_window_title_bar_system_menu_test.result.txt"
19+
$exe = $env:TITLE_BAR_EXAMPLE_EXE
20+
if (-not $exe) {
21+
$exe = Get-FlutterExe "$RemoteWorkspace\examples\flutter_window_title_bar_example" 'window_title_bar_example'
22+
}
23+
Assert-Idle
24+
$app = Start-GuiApp $exe
25+
# The probe fails (a traceback on stderr) until the VM service is up, and
26+
# PowerShell 5.1 turns native stderr into a terminating error under Stop.
27+
function Get-ViewsQuietly {
28+
$saved = $ErrorActionPreference
29+
$ErrorActionPreference = 'Continue'
30+
try { @(Get-Views $app 2>$null) } catch { @() } finally { $ErrorActionPreference = $saved }
31+
}
32+
function Get-State {
33+
$script:win = Get-Win $app 'nativeapi*'
34+
if (-not $script:win) { throw 'The example window is missing' }
35+
$script:view = Get-ViewsQuietly | Select-Object -First 1
36+
if (-not $script:view) { throw 'The example has no Flutter view to probe' }
37+
}
38+
# The system menu's items in their fixed order, whatever the display
39+
# language: Restore, Move, Size, Minimize, Maximize, Close.
40+
function Open-SystemMenu([string]$When) {
41+
Get-State
42+
$point = ConvertTo-Screen $win (Get-TextCenter $view 'Drag this strip to move the window')
43+
Invoke-RightClick $app $point
44+
if (-not (Wait-Menu $app)) { throw "No system menu opened $When" }
45+
$menu = @(Get-OpenMenus $app)[0]
46+
Say " menu $When at $($menu.Rect[0]),$($menu.Rect[1]) for a click at $($point[0]),$($point[1]): $(($menu.Items | % { "$($_.Title)=$(if ($_.Enabled) { 'on' } else { 'off' })" }) -join ', ')"
47+
Check "the system menu opens at the pointer $When" `
48+
([Math]::Abs($menu.Rect[0] - $point[0]) -le 4 -and [Math]::Abs($menu.Rect[1] - $point[1]) -le 4) `
49+
"menu $($menu.Rect[0]),$($menu.Rect[1]) vs click $($point[0]),$($point[1])"
50+
if ($menu.Items.Count -ne 6) { throw "Expected the 6 system menu items, got $($menu.Items.Count)" }
51+
return $menu
52+
}
53+
function Select-SystemMenuItem($Menu, [int]$Index) {
54+
$pt = Get-RectCenter $Menu.Items[$Index].Rect
55+
Move-Cursor $pt 400
56+
Pause 0.2
57+
Assert-Owner $app.Proc.Id $pt[0] $pt[1]
58+
[WInput]::Click($pt[0], $pt[1])
59+
Pause 1.5
60+
}
61+
62+
try {
63+
$deadline = (Get-Date).AddSeconds(40)
64+
while (@(Get-ViewsQuietly).Count -eq 0 -and (Get-Date) -lt $deadline) { Pause 0.5 }
65+
Get-State
66+
Invoke-Activate $app $win
67+
$width = $win.Right - $win.Left
68+
69+
$menu = Open-SystemMenu 'on a restored window'
70+
$on = @($menu.Items | % { $_.Enabled })
71+
Check 'restored: Restore is off; Move, Size, Minimize, Maximize, Close are on' `
72+
(-not $on[0] -and $on[1] -and $on[2] -and $on[3] -and $on[4] -and $on[5])
73+
Select-SystemMenuItem $menu 4
74+
Get-State
75+
Check 'choosing Maximize maximizes the window' ([MenuWin]::IsZoomed([IntPtr]$win.Hwnd))
76+
77+
$menu = Open-SystemMenu 'on a maximized window'
78+
$on = @($menu.Items | % { $_.Enabled })
79+
Check 'maximized: Restore, Minimize, Close are on; Move, Size, Maximize are off' `
80+
($on[0] -and -not $on[1] -and -not $on[2] -and $on[3] -and -not $on[4] -and $on[5])
81+
Select-SystemMenuItem $menu 0
82+
Get-State
83+
Check 'choosing Restore restores the window' (-not [MenuWin]::IsZoomed([IntPtr]$win.Hwnd))
84+
Check 'at its earlier width' ([Math]::Abs(($win.Right - $win.Left) - $width) -le 2) "$($win.Right - $win.Left) vs $width"
85+
} catch {
86+
Check 'the scenario ran to the end' $false "$($_.Exception.Message) at line $($_.InvocationInfo.ScriptLineNumber)"
87+
} finally {
88+
Stop-GuiApp $app | Out-Null
89+
}
90+
Say "$(if ($script:Failures) { 'FAILED' } else { 'OK' })"

0 commit comments

Comments
 (0)