Skip to content

Commit 67bf57d

Browse files
committed
gui-test, remote-hosts: drive and measure Hyprland
The Omarchy laptop (Hyprland 0.56, Lua config, quickshell bar) has no Mutter RemoteDesktop, no ydotool and a root-only /dev/uinput. wlpointer, a small client of the wlroots virtual pointer protocol built on the host from the kit, presses the buttons; hyprctl moves the cursor (dispatchers in their Lua form) and measures windows and layer surfaces, grim captures. The Linux notes record what cost time there: Lua dispatchers and window rules, the tray drawer and pinning, light themes, killing helpers safely, and that Hyprland reports every toplevel as tiled and maximized.
1 parent c2ab4b3 commit 67bf57d

5 files changed

Lines changed: 366 additions & 0 deletions

File tree

‎.agents/skills/gui-test/SKILL.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,18 @@ Screenshots are for *you* to understand a failure, not for assertions.
163163
- Frames include the window manager's decorations; `app.title_bar_height()` is what to
164164
skip to press on the content (37 px on Ubuntu 24.04, not a constant to hard-code).
165165

166+
**Linux (Hyprland / wlroots, e.g. the Omarchy laptop)** — no Mutter here, and no X11
167+
measuring needed: `hyprctl -j clients` gives every window's class, position, size,
168+
floating/pinned/focus state in logical pixels, `hyprctl cursorpos` the pointer. Move the
169+
pointer with `hyprctl dispatch 'hl.dsp.cursor.move({ x = .., y = .. })'` (0.56+ takes
170+
dispatchers in Lua form) and press with `scripts/linux/wlpointer` (built from
171+
`wlpointer.c` + the protocol XML in the kit, through `zwlr_virtual_pointer_manager_v1`;
172+
the button lands where the real cursor is). `grim -g "x,y wxh"` takes *logical*
173+
geometry and writes physical pixels. The bar is a layer surface (`hyprctl -j layers`);
174+
a tray icon has no geometry anywhere, diff two `grim` captures of the bar before and
175+
after the app registers its item to find it. Example:
176+
`tools/gui/flutter_tray_popup_test_hyprland.py`.
177+
166178
**All three**
167179

168180
- A cold debug start can take 10+ s; poll, do not sleep blindly.

‎.agents/skills/gui-test/references/input-drivers.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,30 @@ idle_ms() # ms since the last real user input (GNOME id
141141
Wayland-native on top".
142142
- There is no equivalent of `Click-Desktop`: to blur, click a window of your own
143143
(`GuiApp.blur()` parks one in a corner for that).
144+
145+
## Linux (Hyprland and other wlroots compositors)
146+
147+
Mutter's RemoteDesktop does not exist here; what does is the wlroots virtual pointer
148+
protocol, which Hyprland, sway and the rest implement (GNOME does not).
149+
`scripts/linux/wlpointer.c` is a small client for it:
150+
151+
```sh
152+
wayland-scanner client-header wlr-virtual-pointer-unstable-v1.xml wlr-virtual-pointer-client.h
153+
wayland-scanner private-code wlr-virtual-pointer-unstable-v1.xml wlr-virtual-pointer.c
154+
cc -O1 -o wlpointer wlpointer.c wlr-virtual-pointer.c -lwayland-client
155+
156+
wlpointer click [left|right|middle] # press + release, 60 ms apart
157+
wlpointer press|release [button]
158+
wlpointer move X Y EXTENT_W EXTENT_H # absolute motion, X/Y in 0..EXTENT
159+
```
160+
161+
The button goes to whatever is under the real cursor, so position the cursor first —
162+
on Hyprland `hyprctl dispatch 'hl.dsp.cursor.move({ x = X, y = Y })'` (logical pixels;
163+
0.56+ with a Lua config only accepts dispatchers in this form, the old `movecursor X Y`
164+
is a syntax error) and read it back with `hyprctl cursorpos`. Owner checks: there is no
165+
"window at point" query, but `hyprctl -j clients` has every window's `at`/`size`
166+
(logical) and `hyprctl -j layers` the bar and other layer surfaces, which is enough to
167+
decide whether a point is on the app, on the bar, or on somebody else's window before
168+
pressing. `/dev/uinput` is root-only and `ydotool` is absent on Omarchy, so this is the
169+
only input path short of sudo. Verified 2026-10-01 on Hyprland 0.56.2 (Omarchy): a
170+
`click` in the quickshell bar's tray reached the StatusNotifierItem as `Activate`.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// wlpointer — press, release or click a mouse button, or move the pointer, through the
2+
// compositor's zwlr_virtual_pointer_manager_v1 (Hyprland, sway and other wlroots-based
3+
// compositors; GNOME has no such global, use the Mutter RemoteDesktop driver there).
4+
// The event lands wherever the real cursor is: position it first (Hyprland:
5+
// `hyprctl dispatch movecursor X Y`) or with `move`.
6+
//
7+
// wlpointer click [left|right|middle] press + release, 60 ms apart
8+
// wlpointer press|release [button]
9+
// wlpointer move X Y EXTENT_W EXTENT_H absolute motion, X/Y in 0..EXTENT
10+
//
11+
// Build (next to wlr-virtual-pointer-unstable-v1.xml):
12+
// wayland-scanner client-header wlr-virtual-pointer-unstable-v1.xml wlr-virtual-pointer-client.h
13+
// wayland-scanner private-code wlr-virtual-pointer-unstable-v1.xml wlr-virtual-pointer.c
14+
// cc -O1 -o wlpointer wlpointer.c wlr-virtual-pointer.c -lwayland-client
15+
#include <linux/input-event-codes.h>
16+
#include <stdio.h>
17+
#include <stdlib.h>
18+
#include <string.h>
19+
#include <sys/time.h>
20+
#include <unistd.h>
21+
#include <wayland-client.h>
22+
23+
#include "wlr-virtual-pointer-client.h"
24+
25+
static struct wl_seat* seat;
26+
static struct zwlr_virtual_pointer_manager_v1* manager;
27+
28+
static void on_global(void* data, struct wl_registry* registry, uint32_t name,
29+
const char* interface, uint32_t version) {
30+
(void)data;
31+
(void)version;
32+
if (!seat && strcmp(interface, wl_seat_interface.name) == 0) {
33+
seat = wl_registry_bind(registry, name, &wl_seat_interface, 1);
34+
} else if (strcmp(interface, zwlr_virtual_pointer_manager_v1_interface.name) == 0) {
35+
manager = wl_registry_bind(registry, name, &zwlr_virtual_pointer_manager_v1_interface, 1);
36+
}
37+
}
38+
39+
static void on_global_remove(void* data, struct wl_registry* registry, uint32_t name) {
40+
(void)data;
41+
(void)registry;
42+
(void)name;
43+
}
44+
45+
static uint32_t now_ms(void) {
46+
struct timeval tv;
47+
gettimeofday(&tv, NULL);
48+
return (uint32_t)(tv.tv_sec * 1000 + tv.tv_usec / 1000);
49+
}
50+
51+
static int usage(void) {
52+
fprintf(stderr,
53+
"usage: wlpointer click|press|release [left|right|middle]\n"
54+
" wlpointer move X Y EXTENT_W EXTENT_H\n");
55+
return 64;
56+
}
57+
58+
int main(int argc, char** argv) {
59+
const char* cmd = argc > 1 ? argv[1] : "";
60+
int is_click = strcmp(cmd, "click") == 0;
61+
int is_press = strcmp(cmd, "press") == 0;
62+
int is_release = strcmp(cmd, "release") == 0;
63+
int is_move = strcmp(cmd, "move") == 0;
64+
if (!(is_click || is_press || is_release || (is_move && argc >= 6))) return usage();
65+
66+
struct wl_display* display = wl_display_connect(NULL);
67+
if (!display) {
68+
fprintf(stderr, "wlpointer: cannot connect to the Wayland display\n");
69+
return 2;
70+
}
71+
struct wl_registry* registry = wl_display_get_registry(display);
72+
static const struct wl_registry_listener listener = {on_global, on_global_remove};
73+
wl_registry_add_listener(registry, &listener, NULL);
74+
wl_display_roundtrip(display);
75+
if (!manager) {
76+
fprintf(stderr, "wlpointer: the compositor has no zwlr_virtual_pointer_manager_v1\n");
77+
return 3;
78+
}
79+
struct zwlr_virtual_pointer_v1* pointer =
80+
zwlr_virtual_pointer_manager_v1_create_virtual_pointer(manager, seat);
81+
82+
if (is_move) {
83+
zwlr_virtual_pointer_v1_motion_absolute(pointer, now_ms(), (uint32_t)atoi(argv[2]),
84+
(uint32_t)atoi(argv[3]), (uint32_t)atoi(argv[4]),
85+
(uint32_t)atoi(argv[5]));
86+
zwlr_virtual_pointer_v1_frame(pointer);
87+
} else {
88+
uint32_t button = BTN_LEFT;
89+
if (argc > 2) {
90+
if (strcmp(argv[2], "right") == 0) button = BTN_RIGHT;
91+
else if (strcmp(argv[2], "middle") == 0) button = BTN_MIDDLE;
92+
else if (strcmp(argv[2], "left") != 0) return usage();
93+
}
94+
if (is_click || is_press) {
95+
zwlr_virtual_pointer_v1_button(pointer, now_ms(), button, WL_POINTER_BUTTON_STATE_PRESSED);
96+
zwlr_virtual_pointer_v1_frame(pointer);
97+
wl_display_flush(display);
98+
if (is_click) usleep(60 * 1000);
99+
}
100+
if (is_click || is_release) {
101+
zwlr_virtual_pointer_v1_button(pointer, now_ms(), button, WL_POINTER_BUTTON_STATE_RELEASED);
102+
zwlr_virtual_pointer_v1_frame(pointer);
103+
}
104+
}
105+
wl_display_roundtrip(display);
106+
zwlr_virtual_pointer_v1_destroy(pointer);
107+
wl_display_disconnect(display);
108+
return 0;
109+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<protocol name="wlr_virtual_pointer_unstable_v1">
3+
<copyright>
4+
Copyright © 2019 Josef Gajdusek
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a
7+
copy of this software and associated documentation files (the "Software"),
8+
to deal in the Software without restriction, including without limitation
9+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
10+
and/or sell copies of the Software, and to permit persons to whom the
11+
Software is furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice (including the next
14+
paragraph) shall be included in all copies or substantial portions of the
15+
Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
20+
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
22+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
23+
DEALINGS IN THE SOFTWARE.
24+
</copyright>
25+
26+
<interface name="zwlr_virtual_pointer_v1" version="2">
27+
<description summary="virtual pointer">
28+
This protocol allows clients to emulate a physical pointer device. The
29+
requests are mostly mirror opposites of those specified in wl_pointer.
30+
</description>
31+
32+
<enum name="error">
33+
<entry name="invalid_axis" value="0"
34+
summary="client sent invalid axis enumeration value" />
35+
<entry name="invalid_axis_source" value="1"
36+
summary="client sent invalid axis source enumeration value" />
37+
</enum>
38+
39+
<request name="motion">
40+
<description summary="pointer relative motion event">
41+
The pointer has moved by a relative amount to the previous request.
42+
43+
Values are in the global compositor space.
44+
</description>
45+
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
46+
<arg name="dx" type="fixed" summary="displacement on the x-axis"/>
47+
<arg name="dy" type="fixed" summary="displacement on the y-axis"/>
48+
</request>
49+
50+
<request name="motion_absolute">
51+
<description summary="pointer absolute motion event">
52+
The pointer has moved in an absolute coordinate frame.
53+
54+
Value of x can range from 0 to x_extent, value of y can range from 0
55+
to y_extent.
56+
</description>
57+
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
58+
<arg name="x" type="uint" summary="position on the x-axis"/>
59+
<arg name="y" type="uint" summary="position on the y-axis"/>
60+
<arg name="x_extent" type="uint" summary="extent of the x-axis"/>
61+
<arg name="y_extent" type="uint" summary="extent of the y-axis"/>
62+
</request>
63+
64+
<request name="button">
65+
<description summary="button event">
66+
A button was pressed or released.
67+
</description>
68+
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
69+
<arg name="button" type="uint" summary="button that produced the event"/>
70+
<arg name="state" type="uint" enum="wl_pointer.button_state" summary="physical state of the button"/>
71+
</request>
72+
73+
<request name="axis">
74+
<description summary="axis event">
75+
Scroll and other axis requests.
76+
</description>
77+
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
78+
<arg name="axis" type="uint" enum="wl_pointer.axis" summary="axis type"/>
79+
<arg name="value" type="fixed" summary="length of vector in touchpad coordinates"/>
80+
</request>
81+
82+
<request name="frame">
83+
<description summary="end of a pointer event sequence">
84+
Indicates the set of events that logically belong together.
85+
</description>
86+
</request>
87+
88+
<request name="axis_source">
89+
<description summary="axis source event">
90+
Source information for scroll and other axis.
91+
</description>
92+
<arg name="axis_source" type="uint" enum="wl_pointer.axis_source" summary="source of the axis event"/>
93+
</request>
94+
95+
<request name="axis_stop">
96+
<description summary="axis stop event">
97+
Stop notification for scroll and other axes.
98+
</description>
99+
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
100+
<arg name="axis" type="uint" enum="wl_pointer.axis" summary="the axis stopped with this event"/>
101+
</request>
102+
103+
<request name="axis_discrete">
104+
<description summary="axis click event">
105+
Discrete step information for scroll and other axes.
106+
107+
This event allows the client to extend data normally sent using the axis
108+
event with discrete value.
109+
</description>
110+
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
111+
<arg name="axis" type="uint" enum="wl_pointer.axis" summary="axis type"/>
112+
<arg name="value" type="fixed" summary="length of vector in touchpad coordinates"/>
113+
<arg name="discrete" type="int" summary="number of steps"/>
114+
</request>
115+
116+
<request name="destroy" type="destructor" since="1">
117+
<description summary="destroy the virtual pointer object"/>
118+
</request>
119+
</interface>
120+
121+
<interface name="zwlr_virtual_pointer_manager_v1" version="2">
122+
<description summary="virtual pointer manager">
123+
This object allows clients to create individual virtual pointer objects.
124+
</description>
125+
126+
<request name="create_virtual_pointer">
127+
<description summary="Create a new virtual pointer">
128+
Creates a new virtual pointer. The optional seat is a suggestion to the
129+
compositor.
130+
</description>
131+
<arg name="seat" type="object" interface="wl_seat" allow-null="true"/>
132+
<arg name="id" type="new_id" interface="zwlr_virtual_pointer_v1"/>
133+
</request>
134+
135+
<request name="destroy" type="destructor" since="1">
136+
<description summary="destroy the virtual pointer manager"/>
137+
</request>
138+
139+
<!-- Version 2 additions -->
140+
<request name="create_virtual_pointer_with_output" since="2">
141+
<description summary="Create a new virtual pointer">
142+
Creates a new virtual pointer. The seat and the output arguments are
143+
optional. If the seat argument is set, the compositor should assign the
144+
input device to the requested seat. If the output argument is set, the
145+
compositor should map the input device to the requested output.
146+
</description>
147+
<arg name="seat" type="object" interface="wl_seat" allow-null="true"/>
148+
<arg name="output" type="object" interface="wl_output" allow-null="true"/>
149+
<arg name="id" type="new_id" interface="zwlr_virtual_pointer_v1"/>
150+
</request>
151+
</interface>
152+
</protocol>

‎.agents/skills/remote-hosts/references/linux.md‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,69 @@ gdbus call --session -d org.gnome.Mutter.DisplayConfig \
138138
`~/.Xauthority` usually does not exist. `xdotool` would be the input driver there.
139139
- GTK needs `libgtk-3-dev` etc. for building core; Flutter needs `clang`, `ninja`,
140140
`pkg-config`.
141+
142+
## Hyprland (the Omarchy laptop, `hosts/omarchy.env`; verified 2026-10-01)
143+
144+
Hyprland 0.56.2 with the **Lua** config (`~/.config/hypr/hyprland.lua`, Omarchy 4), a
145+
quickshell bar (`/usr/share/omarchy/shell`) with its own StatusNotifierWatcher/Host, a
146+
1600×1000 logical desktop at scale 1.6. Differences from the GNOME host that cost time:
147+
148+
- `desktop` works unchanged: the systemd user environment carries `WAYLAND_DISPLAY`,
149+
`DISPLAY` and `HYPRLAND_INSTANCE_SIGNATURE`; `desktop.sh` exports the first two, so a
150+
script sets `HYPRLAND_INSTANCE_SIGNATURE` itself (newest dir under
151+
`$XDG_RUNTIME_DIR/hypr`) before calling `hyprctl`. In a bare `exec`, export
152+
`XDG_RUNTIME_DIR=/run/user/1000 WAYLAND_DISPLAY=wayland-1` too, or `grim` says
153+
"failed to create display".
154+
- **Dispatchers are Lua calls**: `hyprctl dispatch 'hl.dsp.cursor.move({ x = 700, y = 120 })'`,
155+
`hyprctl dispatch 'hl.dsp.focus({ window = "address:0x..." })'`,
156+
`hl.dsp.window.close({ window = ... })`. The classic `movecursor 700 120` /
157+
`focuswindow address:...` strings fail with a Lua syntax error (exit 7). `hyprctl repl
158+
'<lua>'` prints a return value, `hyprctl eval` does not; listing the keys of `hl.dsp`
159+
and `hl.dsp.window` with `pairs` is the quickest way to see what this version has (the
160+
wiki documents a newer one — `hl.dsp.window.focus` is not there yet, `hl.dsp.focus` is).
161+
- **Rules**: edit the config and `hyprctl reload`, then `hyprctl configerrors` (empty when
162+
fine). A test appends one marked `require("hypr.<name>")` line to `hyprland.lua` with
163+
the rule in `~/.config/hypr/<name>.lua`, and restores the backed-up file at the end;
164+
`hl.window_rule({ match = { class = "..." }, ... })` is the global that always exists.
165+
`move`/`size` expressions know `cursor_x`/`cursor_y`, `monitor_w/h`, `window_w/h`.
166+
- **Input**: no Mutter RemoteDesktop, `ydotool` absent, `/dev/uinput` root-only. Use the
167+
gui-test kit's `wlpointer` (zwlr_virtual_pointer; `wayland-scanner`, `cc` and
168+
`wayland-client` are on the host) for buttons, `hl.dsp.cursor.move` for position.
169+
- **Measuring**: `hyprctl -j clients` (class/initialClass, `at`, `size`, `floating`,
170+
`pinned`, `mapped`, `focusHistoryID`), `hyprctl -j activewindow`, `hyprctl cursorpos`,
171+
`hyprctl -j layers` (the bar: namespace `omarchy-bar`, 26 px high, reserved at the top).
172+
All logical pixels. A hidden GTK window is unmapped, i.e. gone from `clients`.
173+
- **Screenshots**: `grim -g "x,y wxh"` with *logical* geometry; the PNG/PPM is physical
174+
(`-g "0,0 1600x26"` → 2560×42). `-t ppm` is easy to parse without PIL/numpy, neither of
175+
which is installed.
176+
- **Tray**: Omarchy's tray puts new StatusNotifierItems in a collapsed drawer (revealed on
177+
hover of the chevron); the example's item id `nativeapi-tray` is pinned in
178+
`~/.config/omarchy/shell.json` (`omarchy.tray` → `pinned`), so it is always visible at
179+
the left end of the bar's right cluster (around x 1440 logical). Clicking a layer
180+
surface does not take the keyboard focus from the window, so a click in the bar is not
181+
a blur.
182+
- `pkill -f <pattern>` run through `ssh host '...'` matches the remote shell's own
183+
command line when the pattern appears in it, and kills the session (exit 255). Put the
184+
kill in a script file, or write the pattern so it does not match itself
185+
(`bundle/tray_icon_exampl[e]`).
186+
- Omarchy's light themes (e.g. `lupine`, bar background #fafafa) swallow a white tray
187+
icon: locate icons by diffing captures only once the app draws one in the theme's
188+
foreground, and wait for a killed instance's item to leave the StatusNotifierWatcher
189+
before the reference capture.
190+
- `foot` is the terminal to use as a helper window (`alacritty`/`kitty` absent); it
191+
ignores SIGTERM from a non-tty parent, kill it with SIGKILL by pid — never `pkill foot`,
192+
the user's terminals are foot too.
193+
- **Hyprland sends the four xdg `tiled` states to every toplevel**, floating ones
194+
included (`src/protocols/XDGShell.cpp`, unconditional in 0.56.2). Consequences seen on
195+
2026-10-01: a decorated GTK3 window maps with its CSD shadow margins
196+
(`set_window_geometry(20, 20, w, h)`) and drops them one configure later, so Flutter
197+
lays out twice and a popup visibly "grows in" (~150 ms after a ~150 ms click-to-map
198+
latency); core's hidden-title-bar shadow (`window_shadow_linux.h`) takes the states
199+
as "tiled", goes fitted, and the window stays inflated by the reserved margin
200+
(400×640 content became a 664×904 window; fixed the same day: the gutter is decided
201+
only once the compositor has answered, and the no-shadow CSS rule clears the theme's
202+
decoration margin too - `core/tests/window_shadow_remap_linux_test.cpp`, run it
203+
floating). Measure such things with `grim -t ppm`
204+
bursts plus `WAYLAND_DEBUG=1` on the app (filter `set_window_geometry`,
205+
`xdg_toplevel.configure`, `set_buffer_scale`, `attach`); `hyprctl keyword` does not
206+
work with the Lua config ("Use eval"), so toggle nothing that way.

0 commit comments

Comments
 (0)