Skip to content

Commit 1dd9a11

Browse files
committed
Bump core: view events on macOS, Linux relayout after resize; add the Linux view GUI test
tools/gui/core_view_test_linux.py is the Linux twin of the macOS and Windows view tests (layout, clicks, focus event, re-flow after a resize). All three now pass on real desktops: this Mac, the Windows host and the Ubuntu 24.04 NUC. The macOS test also clicks Sign in after the resize, like the other two. specs/view.md records the platform status and the missing view-level resize event.
1 parent 323e4d9 commit 1dd9a11

4 files changed

Lines changed: 188 additions & 7 deletions

File tree

‎specs/view.md‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
> 状态:**已实施**(core 2026-09-25:`View` / `Label` / `Button` / `TextField` / `ImageView`,
44
> Absolute + Row / Column 布局,`Window::GetContentView()`;macOS 在真机上跑过
5-
> `tools/gui/core_view_test.py`,Windows 跑过 `core_view_test.ps1`,Linux 只做了语法检查,见 §9)
5+
> 三个桌面平台都跑过真机 GUI 测试,见 §9)
66
> 适用范围:`core/src/view.h`、`core/src/window.h` 的 `GetContentView`、
77
> `foundation/geometry.h` 的 `EdgeInsets`、`foundation/id_allocator.h` 的 tag 20–24、
88
> `foundation/handle_table.h` 的基类链、六个平台目录的 `view_*` 文件、codegen 的类继承支持
@@ -593,9 +593,9 @@ Windows 后续可像 `Menu` 一样加 `ViewBackend::WinUI3`,本文不设计它
593593

594594
| 平台 | 状态 |
595595
|---|---|
596-
| macOS | 编译、单测、`view_example` 真机运行;GUI 测试的布局断言 12 项全过。点击和缩放两段需要辅助功能权限,在有权限的终端里跑 |
597-
| Windows | MSVC 编译;`tools/gui/core_view_test.ps1` 在真机桌面上 25 项全过(布局、点击、焦点事件、缩放后重排) |
598-
| Linux | 已写(GTK 3),`clang++ -fsyntax-only` 通过,未链接未运行 |
596+
| macOS | `tools/gui/core_view_test.py` 在本机桌面全过(布局、点击、焦点事件、缩放后重排再点击);运行它的终端需要辅助功能权限 |
597+
| Windows | MSVC 编译;`tools/gui/core_view_test.ps1` 在 Windows 主机桌面全过 |
598+
| Linux | Ubuntu 24.04 / GNOME Wayland,应用走 Xwayland(`GDK_BACKEND=x11`);`tools/gui/core_view_test_linux.py` 全过。GTK 原生 Wayland 后端只能从内部断言,未测 |
599599
| Android / iOS / OHOS | 桩:`IsSupported()` 为 false |
600600

601601
未决:
@@ -604,7 +604,11 @@ Windows 后续可像 `Menu` 一样加 `ViewBackend::WinUI3`,本文不设计它
604604
但和 `WindowDragSession`、`DropTarget` 的坐标与命中语义要对齐。先不做。
605605
- **多行文本高度**:`Label` 的固有高度不随宽度换行而变(单趟布局)。需要时再加两趟。
606606
- **焦点事件的覆盖面**:macOS 只有 `TextField` 发 `ViewFocusedEvent` / `ViewBlurredEvent`
607-
(走 `NSTextFieldDelegate`),按钮不发;Windows / Linux 按各自控件的焦点通知发。
607+
(成为第一响应者 / 字段编辑器结束时),按钮默认不接受键盘焦点所以不发;Windows / Linux
608+
按各自控件的焦点通知发,按钮也发。
609+
- **没有视图级的尺寸变化事件**:Linux 上 `WindowResizedEvent` 早于 GTK 给内容分配尺寸,
610+
在它的监听器里读到的子视图 frame 还是旧的。需要「布局完成」的时机再加
611+
`ViewResizedEvent`。
608612
- **后续控件**,按需要加、每个一步:`Checkbox`(`SetChecked` / `CheckboxToggledEvent`)、
609613
`Dropdown`(`SetItems` / `SetSelectedIndex` / `DropdownSelectionChangedEvent`)、
610614
`Slider`(`SetMinimumValue` / `SetMaximumValue` / `SetValue` / `SliderValueChangedEvent`)、

‎tools/gui/core_view_test.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
tools/gui/core_view_test.py [--build]
88
99
Built on the gui-test skill (.agents/skills). It takes over the mouse for ~10 s and
10-
sends no keyboard input.
10+
sends no keyboard input. The terminal it runs in needs Accessibility access.
1111
"""
1212

1313
import re
@@ -113,6 +113,13 @@ def main():
113113
near(checks, 'sign in followed the trailing edge', s2[0] + s2[2],
114114
CONTENT[0] + 120 - PADDING)
115115
near(checks, 'button width unchanged by the resize', s2[2], s[2])
116+
117+
# Look again: the button answers where the re-flow put it.
118+
app.click(to_screen(app.window(WINDOW), after, 'sign_in'))
119+
pause(1.0)
120+
st = statuses(app)
121+
checks.check('sign in still answers after the re-flow',
122+
len(st) >= 3 and st[-1] == 'A username is required.', st)
116123
except Abort as e:
117124
checks.check('ran to the end', False, e)
118125
finally:

‎tools/gui/core_view_test_linux.py‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
#!/usr/bin/env python3
2+
"""GUI test (Linux) of core's view_example (C++): a sign-in form of native controls
3+
(Label, TextField, Button) laid out by a Column and a Row. The Linux twin of
4+
core_view_test.py (macOS) and core_view_test.ps1 (Windows): the layout the example
5+
prints, clicks on the real buttons, the focus event, and the re-flow after a resize.
6+
7+
tools/gui/core_view_test_linux.py [--wayland] [--keep-open]
8+
9+
Runs on the host's desktop session through the remote-hosts skill:
10+
11+
.agents/skills/remote-hosts/scripts/remote.sh linux desktop \\
12+
tools/gui/core_view_test_linux.py 180
13+
14+
The example is expected in $REMOTE_SCRATCH/core-build (build_core_example_linux.sh) or,
15+
run locally, in core/build. It takes over the mouse for ~15 s and sends no keys.
16+
"""
17+
18+
import ctypes
19+
import os
20+
import re
21+
import sys
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, pause # noqa: E402
26+
from xinput import session_type # noqa: E402
27+
28+
NAME = 'view_example'
29+
WINDOW = 'Sign in'
30+
CONTENT = (360, 220)
31+
PADDING, SPACING = 16, 8
32+
33+
34+
def executable():
35+
scratch = os.environ.get('REMOTE_SCRATCH')
36+
if scratch and os.path.exists(os.path.join(scratch, 'core-build')):
37+
return os.path.join(scratch, 'core-build', 'examples', NAME, NAME)
38+
workspace = os.environ.get('REMOTE_WORKSPACE') or os.path.dirname(
39+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
40+
return os.path.join(workspace, 'core', 'build', 'examples', NAME, NAME)
41+
42+
43+
def layouts(app):
44+
"""{label: (x, y, w, h)} from the LAST "[view] layout" block the example printed."""
45+
out = {}
46+
for line in app.output().splitlines():
47+
m = re.match(r'\[view\] layout (\w+) (\S+) (\S+) (\S+) (\S+)', line)
48+
if m:
49+
if m.group(1) == 'root':
50+
out = {}
51+
out[m.group(1)] = tuple(float(v) for v in m.group(2, 3, 4, 5))
52+
return out
53+
54+
55+
def statuses(app):
56+
return [l.split(': ', 1)[1] for l in app.output().splitlines()
57+
if l.startswith('[view] status: ')]
58+
59+
60+
def near(checks, what, actual, expected, tolerance=2):
61+
checks.check(what, abs(actual - expected) <= tolerance, f'{actual} vs {expected}')
62+
63+
64+
def control_center(app, layout, name):
65+
"""Screen point of a control's centre: its root-relative logical frame, scaled by the
66+
client area against the root (GNOME's scale is per monitor)."""
67+
x, y, w, _ = next(c for t, c in app.contents() if t == WINDOW)
68+
scale = w / layout['root'][2]
69+
cx, cy, cw, ch = layout[name]
70+
return x + (cx + cw / 2) * scale, y + (cy + ch / 2) * scale
71+
72+
73+
def click_control(app, layout, name):
74+
xid = next(w.xid for w in app._windows() if w.title == WINDOW)
75+
app.x.activate(xid) # raise it over whatever else is on the desktop
76+
pause(0.4)
77+
point = control_center(app, layout, name)
78+
print(f'click {name} at {point[0]:.0f},{point[1]:.0f}', flush=True)
79+
app.click(point)
80+
81+
82+
def resize_client(app, width, height):
83+
"""XResizeWindow on the client window; the window manager keeps the frame around it."""
84+
xid = next(w.xid for w in app._windows() if w.title == WINDOW)
85+
x11 = app.x.x11
86+
x11.XResizeWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_uint, ctypes.c_uint]
87+
x11.XResizeWindow(app.x.dpy, xid, int(width), int(height))
88+
app.x.flush()
89+
90+
91+
def main():
92+
backend = 'wayland' if '--wayland' in sys.argv else 'x11'
93+
assert_idle()
94+
app = GuiApp(executable(), backend=backend)
95+
app.keep_open = '--keep-open' in sys.argv
96+
checks = Checks()
97+
print(f'session: {session_type()}, app backend: {backend}', flush=True)
98+
try:
99+
app.launch(min_windows=1, flutter=False)
100+
pause(0.5)
101+
layout = layouts(app)
102+
checks.check('layout printed for every control',
103+
{'root', 'heading', 'name', 'password', 'status', 'actions', 'clear',
104+
'sign_in'} <= set(layout), sorted(layout))
105+
near(checks, 'root is the content area (w)', layout['root'][2], CONTENT[0])
106+
near(checks, 'root is the content area (h)', layout['root'][3], CONTENT[1])
107+
content = next(c for t, c in app.contents() if t == WINDOW)
108+
near(checks, "client area has the root's aspect", content[3] / content[2],
109+
CONTENT[1] / CONTENT[0], 0.01)
110+
near(checks, 'heading starts at the padding', layout['heading'][1], PADDING)
111+
near(checks, 'heading spans the padded width', layout['heading'][2],
112+
CONTENT[0] - 2 * PADDING)
113+
h, n = layout['heading'], layout['name']
114+
near(checks, 'name follows the heading', n[1], h[1] + h[3] + SPACING)
115+
near(checks, 'password follows name', layout['password'][1], n[1] + n[3] + SPACING)
116+
checks.check('a text field has a height', n[3] > 10, n[3])
117+
near(checks, 'actions row is 32 high (preferred size)', layout['actions'][3], 32)
118+
s, c = layout['sign_in'], layout['clear']
119+
near(checks, 'sign in hugs the trailing edge', s[0] + s[2], CONTENT[0] - PADDING)
120+
near(checks, 'clear sits before sign in, spaced', c[0] + c[2] + SPACING, s[0])
121+
checks.check('buttons have an intrinsic width', 40 < s[2] < 140, s[2])
122+
123+
click_control(app, layout, 'sign_in')
124+
pause(1.0)
125+
checks.check('empty sign-in is refused',
126+
statuses(app)[-1:] == ['A username is required.'], statuses(app))
127+
checks.check('the refused sign-in focused the username field',
128+
'[view] focused: name' in app.output())
129+
130+
click_control(app, layouts(app), 'clear')
131+
pause(1.0)
132+
checks.check('clear resets the status',
133+
statuses(app)[-1:] == ['Enter your credentials.'], statuses(app))
134+
135+
scale = content[2] / CONTENT[0]
136+
resize_client(app, (CONTENT[0] + 120) * scale, (CONTENT[1] + 60) * scale)
137+
pause(1.5)
138+
# WindowResizedEvent arrives before GTK lays the content out, so the layout the
139+
# example printed for it is stale. Click where "Sign in" must be now — it hugs
140+
# the trailing edge, and the Column keeps its row where it was — and read the
141+
# layout printed with the status line.
142+
moved = dict(layout)
143+
moved['root'] = (0, 0, CONTENT[0] + 120, CONTENT[1] + 60)
144+
moved['sign_in'] = (s[0] + 120, s[1], s[2], s[3])
145+
click_control(app, moved, 'sign_in')
146+
pause(1.0)
147+
st = statuses(app)
148+
checks.check('sign in answers where the re-flow put it',
149+
len(st) >= 3 and st[-1] == 'A username is required.', st)
150+
after = layouts(app)
151+
near(checks, 'root grew with the window (w)', after['root'][2], CONTENT[0] + 120)
152+
near(checks, 'root grew with the window (h)', after['root'][3], CONTENT[1] + 60)
153+
near(checks, 'heading re-stretched', after['heading'][2], CONTENT[0] + 120 - 2 * PADDING)
154+
s2 = after['sign_in']
155+
near(checks, 'sign in followed the trailing edge', s2[0] + s2[2],
156+
CONTENT[0] + 120 - PADDING)
157+
near(checks, 'button width unchanged by the resize', s2[2], s[2])
158+
except Abort as e:
159+
checks.check('ran to the end', False, e)
160+
finally:
161+
app.quit()
162+
for line in app.output().splitlines():
163+
if not line.startswith('[view] layout'):
164+
print(f'app: {line}')
165+
print(f'{checks.failures} failure(s); app log: {app.log_path}')
166+
return 1 if checks.failures else 0
167+
168+
169+
if __name__ == '__main__':
170+
sys.exit(main())

0 commit comments

Comments
 (0)