Skip to content

Commit 90180e4

Browse files
committed
Sync with core e791090: one window ID on both GTK objects (#65)
tools/gui/flutter_window_title_bar_close_test_linux.py clicks GNOME's header bar close button in flutter_window_title_bar_example: with Keep open the requests are cancelled, with Allow the window closes and the app exits 0. It found the core bug: without e791090 the Flutter window received no events of its own on Linux and closed regardless.
1 parent e0ca21a commit 90180e4

3 files changed

Lines changed: 161 additions & 1 deletion

File tree

‎tools/gui/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ runner) lives in [`.agents/skills/`](../../.agents/skills); read
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`. |
2323
| `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`. |
24+
| `flutter_window_title_bar_close_test_linux.py` | `window_title_bar_example` (#65) | Linux (GNOME) | The Linux twin of the close test: with Keep open, two real clicks on the header bar's close button (found by its accessible name through AT-SPI) are cancelled and counted; with Allow the click closes the window and the app exits with code 0. The Flutter view is located through AT-SPI too, since the runner's header bar sits inside the X11 client area. The system menu is gnome-shell's and is not driven. Set `TITLE_BAR_EXAMPLE_EXE` for a build outside `$REMOTE_WORKSPACE`. |
2425
| `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 |
2526
| `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 |
2627
| `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: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
#!/usr/bin/env python3
2+
"""GUI test (Linux, GNOME) of cancellable window close (#65) in
3+
flutter_window_title_bar_example: with Closing set to Keep open the example
4+
cancels every WindowCloseRequestedEvent, so a real click on the header bar's
5+
close button leaves the window open and counts one cancelled request; with
6+
Allow the same click closes the window and the app exits with code 0. The
7+
Linux twin of flutter_window_title_bar_close_test.ps1 (Windows).
8+
9+
.agents/skills/remote-hosts/scripts/remote.sh linux desktop \
10+
tools/gui/flutter_window_title_bar_close_test_linux.py 200
11+
12+
The close button is GTK's own (the Flutter runner uses a header bar under
13+
GNOME), found by its accessible name through AT-SPI. The system menu is
14+
gnome-shell's, outside the app's windows, so it is not driven here. The
15+
example is expected built in debug in the host's checkout ($REMOTE_WORKSPACE),
16+
or set TITLE_BAR_EXAMPLE_EXE. The app runs under GDK_BACKEND=x11.
17+
"""
18+
19+
import os
20+
import sys
21+
import time
22+
23+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # the flat kit on a remote host
24+
25+
from guiapp import Abort, Checks, GuiApp, assert_idle, flutter_executable, pause # noqa: E402
26+
27+
import gi # noqa: E402
28+
29+
gi.require_version('Atspi', '2.0')
30+
from gi.repository import Atspi # noqa: E402
31+
32+
NAME = 'window_title_bar_example'
33+
34+
35+
def executable():
36+
if os.environ.get('TITLE_BAR_EXAMPLE_EXE'):
37+
return os.environ['TITLE_BAR_EXAMPLE_EXE']
38+
workspace = os.environ.get('REMOTE_WORKSPACE') or os.path.dirname(
39+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
40+
return flutter_executable(
41+
os.path.join(workspace, 'examples', 'flutter_window_title_bar_example'), NAME)
42+
43+
44+
def close_button(pid, timeout=10):
45+
"""Screen center of the header bar's close button, through AT-SPI."""
46+
deadline = time.time() + timeout
47+
while time.time() < deadline:
48+
desktop = Atspi.get_desktop(0)
49+
for i in range(desktop.get_child_count()):
50+
app = desktop.get_child_at_index(i)
51+
if app is None or app.get_process_id() != pid:
52+
continue
53+
stack = [app]
54+
while stack:
55+
node = stack.pop()
56+
if (node.get_role() == Atspi.Role.PUSH_BUTTON and
57+
(node.get_name() or '').lower() == 'close'):
58+
r = node.get_extents(Atspi.CoordType.SCREEN)
59+
if r.width > 0 and r.height > 0:
60+
return r.x + r.width / 2, r.y + r.height / 2
61+
stack.extend(node.get_child_at_index(j) for j in range(node.get_child_count()))
62+
time.sleep(0.5)
63+
raise Abort('no accessible close button: is the AT-SPI bus running?')
64+
65+
66+
def accessibles(pid):
67+
"""Every accessible of the app, depth first."""
68+
desktop = Atspi.get_desktop(0)
69+
for i in range(desktop.get_child_count()):
70+
app = desktop.get_child_at_index(i)
71+
if app is None or app.get_process_id() != pid:
72+
continue
73+
stack = [app]
74+
while stack:
75+
node = stack.pop()
76+
yield node
77+
stack.extend(node.get_child_at_index(j) for j in range(node.get_child_count()))
78+
79+
80+
def view_origin(app, timeout=10):
81+
"""Screen position of the Flutter view.
82+
83+
Under GNOME the runner puts a GTK header bar above the view, inside the X11
84+
client area, so the view does not start where the harness's to_screen()
85+
assumes. Its accessible, the one exactly the view's size, tells.
86+
"""
87+
width, height = app.views()[0].size
88+
deadline = time.time() + timeout
89+
while time.time() < deadline:
90+
for node in accessibles(app.proc.pid):
91+
r = node.get_extents(Atspi.CoordType.SCREEN)
92+
if abs(r.width - width) <= 1 and abs(r.height - height) <= 1:
93+
return r.x, r.y
94+
time.sleep(0.5)
95+
raise Abort(f'no accessible of the view size {width}x{height}')
96+
97+
98+
def text_center(app, text):
99+
x, y = app.views()[0].center(text)
100+
ox, oy = view_origin(app)
101+
return ox + x, oy + y
102+
103+
104+
def has_text(app, text):
105+
return any(t == text for t, _ in app.views()[0].texts)
106+
107+
108+
def cancelled(app):
109+
view = app.views()[0]
110+
for text, _ in view.texts:
111+
if text.startswith('Cancelled closes: '):
112+
return int(text.split(':')[1])
113+
raise Abort('the cancelled-closes counter is missing')
114+
115+
116+
def main():
117+
assert_idle()
118+
checks = Checks()
119+
app = GuiApp(executable())
120+
app.launch()
121+
try:
122+
app.click(text_center(app, 'Keep open'))
123+
pause(0.8)
124+
checks.check('Keep open is chosen',
125+
has_text(app, 'Close requests are cancelled; try the × or the menu'))
126+
checks.check('the counter starts at 0', cancelled(app) == 0)
127+
128+
app.click(close_button(app.proc.pid))
129+
pause(1.5)
130+
checks.check('with Keep open, the close button leaves the window open',
131+
app.proc.poll() is None and len(app.windows()) == 1)
132+
checks.check('and the example cancelled one request', cancelled(app) == 1)
133+
134+
app.click(close_button(app.proc.pid))
135+
pause(1.5)
136+
checks.check('a second click is cancelled too', app.proc.poll() is None and
137+
cancelled(app) == 2)
138+
139+
app.click(text_center(app, 'Allow'))
140+
pause(0.8)
141+
checks.check('Allow is chosen', has_text(app, 'Closing the window closes it'))
142+
app.click(close_button(app.proc.pid))
143+
try:
144+
code = app.proc.wait(10)
145+
except Exception:
146+
code = None
147+
checks.check('with Allow, the close button closes the window and the app exits',
148+
code is not None, f'exit code {code}')
149+
checks.check('with exit code 0', code == 0, f'exit code {code}')
150+
except Abort as error:
151+
checks.check('the scenario ran to the end', False, str(error))
152+
finally:
153+
app.quit()
154+
print('FAILED' if checks.failures else 'OK', flush=True)
155+
return 1 if checks.failures else 0
156+
157+
158+
if __name__ == '__main__':
159+
sys.exit(main())

0 commit comments

Comments
 (0)