detached d71e4a4

test(app): cover X11 fullscreen, keyboard grab, output hint and class rules end to end

commit
d71e4a4374af89f3ac84e2350bf2cf3d7e7a4ca6
author
brice · · 2026-10-22 15:45 UTC
committer
brice · · 2026-10-22 17:54 UTC
parent
43a1ce7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +277 -8
M internal/app/testdata/x11probe/main.go +93 -7
@@ -8,6 +8,13 @@
88 // main one is mapped (prints "override mapped") and unmaps it after
99 // -override-for (prints "override unmapped"). With -override-dismiss-on-focusout
1010 // it unmaps it instead when the main window loses focus (prints "dismissed").
11+//
12+// With -fullscreen it asks the window manager for _NET_WM_STATE_FULLSCREEN
13+// -fullscreen-delay after the window is mapped (0: right at the map) and
14+// prints "fullscreen WxH" when the window is resized; -fullscreen-off asks to
15+// leave it that long after (prints "fullscreen off WxH"). -keyboard-grab
16+// calls XGrabKeyboard on the window once it is mapped (prints "grab").
17+// -at X,Y creates the window at that root position.
1118 package main
1219
1320 import (
@@ -50,7 +57,16 @@ func run() error {
5057 overrideType := flag.String("override-type", "menu", "override window type: menu or tooltip")
5158 overrideShape := flag.Bool("override-shape", false, "give the override window an empty input shape")
5259 dismiss := flag.Bool("override-dismiss-on-focusout", false, "unmap the override window when the main window loses focus")
60+ fullscreen := flag.Bool("fullscreen", false, "request _NET_WM_STATE_FULLSCREEN after the window is mapped")
61+ fullscreenDelay := flag.Duration("fullscreen-delay", 0, "delay between the map and the fullscreen request")
62+ fullscreenOff := flag.Duration("fullscreen-off", 0, "leave fullscreen this long after the request (0: never)")
63+ keyboardGrab := flag.Bool("keyboard-grab", false, "XGrabKeyboard on the window once it is mapped")
64+ at := flag.String("at", "0,0", "window position X,Y in root coordinates")
5365 flag.Parse()
66+ atX, atY, okAt := strings.Cut(*at, ",")
67+ if !okAt {
68+ return fmt.Errorf("bad -at %q", *at)
69+ }
5470 w, h, ok := parseSize(*size)
5571 if !ok {
5672 return fmt.Errorf("bad -size %q", *size)
@@ -62,13 +78,14 @@ func run() error {
6278 }
6379 defer c.Close()
6480
65- var atoms [7]uint32
81+ var atoms [9]uint32
6682 if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING",
67- "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU", "_NET_WM_WINDOW_TYPE_TOOLTIP"}, false, atoms[:]); err != nil {
83+ "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU", "_NET_WM_WINDOW_TYPE_TOOLTIP", "_NET_WM_STATE", "_NET_WM_STATE_FULLSCREEN"}, false, atoms[:]); err != nil {
6884 return err
6985 }
7086 protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3])
7187 typeAtom, typeMenu, typeTooltip := xproto.Atom(atoms[4]), atoms[5], atoms[6]
88+ netWMState, netWMFullscreen := atoms[7], atoms[8]
7289
7390 b := c.Buf()
7491 xwin, err := c.NewXID()
@@ -82,8 +99,8 @@ func run() error {
8299 win, gc := xproto.Window(xwin), xproto.Gcontext(xgc)
83100
84101 var v xproto.CreateWindowValues
85- v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange)
86- xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), 0, 0, uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v)
102+ v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange | xproto.EventMaskPropertyChange)
103+ xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), int16(atoi(atX)), int16(atoi(atY)), uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v)
87104
88105 wmClass := []byte("x11probe\x00" + *class + "\x00")
89106 xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmClass, xproto.AtomString, 8, uint32(len(wmClass)), wmClass)
@@ -100,6 +117,13 @@ func run() error {
100117 return err
101118 }
102119 time.Sleep(*sleep)
120+ if *at != "0,0" {
121+ // Like games, ask for the rect before the map (a ConfigureRequest the
122+ // window manager sees as the output hint).
123+ var cv xproto.ConfigureWindowValues
124+ cv.SetX(int32(atoi(atX))).SetY(int32(atoi(atY))).SetWidth(uint32(w)).SetHeight(uint32(h))
125+ xproto.ConfigureWindow(b, win, &cv)
126+ }
103127 xproto.MapWindow(b, win)
104128
105129 // The override-redirect window is created once the main window is mapped.
@@ -153,7 +177,21 @@ func run() error {
153177 }
154178 var ovErr error
155179
156- mapped, done := false, false
180+ // Fullscreen requests go to the root window as a client message, as ICCCM
181+ // and EWMH say for a mapped window.
182+ fsMsg := func(action uint32) {
183+ var ev [32]byte
184+ ev[0] = byte(xproto.KindClientMessage)
185+ ev[1] = 32
186+ binary.LittleEndian.PutUint32(ev[4:], uint32(win))
187+ binary.LittleEndian.PutUint32(ev[8:], netWMState)
188+ binary.LittleEndian.PutUint32(ev[12:], action)
189+ binary.LittleEndian.PutUint32(ev[16:], netWMFullscreen)
190+ xproto.SendEvent(b, false, xproto.Window(c.Root()), xproto.EventMaskSubstructureRedirect|xproto.EventMaskSubstructureNotify, &ev)
191+ }
192+ var fsAt, fsOffAt time.Time // zero: nothing pending
193+
194+ mapped, done, isFS := false, false, false
157195 cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify
158196 hd := &xturbo.Handler{
159197 OnEvent: func(ev *xturbo.Event) {
@@ -172,6 +210,20 @@ func run() error {
172210 if *override {
173211 ovErr = mapOverride()
174212 }
213+ if *fullscreen {
214+ fsAt = time.Now().Add(*fullscreenDelay)
215+ }
216+ if *keyboardGrab {
217+ xproto.GrabKeyboard(b, true, win, 0, xproto.GrabModeAsync, xproto.GrabModeAsync)
218+ fmt.Println("grab")
219+ }
220+ }
221+ case xproto.KindPropertyNotify:
222+ if e := xproto.PropertyNotifyOf(ev); e.Window() == win && xproto.Atom(e.Atom()) == xproto.Atom(netWMState) {
223+ seq := xproto.GetProperty(b, false, win, xproto.Atom(netWMState), xproto.AtomAtom, 0, 64)
224+ if err := c.Expect(seq, 16, 0, 0, 0, 0); err != nil {
225+ ovErr = err
226+ }
175227 }
176228 case xproto.KindFocusIn:
177229 fmt.Println("focus")
@@ -186,6 +238,24 @@ func run() error {
186238 }
187239 }
188240 },
241+ OnReply: func(r *xturbo.Reply) {
242+ rep, ok := xproto.ParseGetPropertyReply(r.Raw)
243+ if !ok {
244+ return
245+ }
246+ on := false
247+ for v := rep.Value(); len(v) >= 4; v = v[4:] {
248+ on = on || binary.LittleEndian.Uint32(v) == netWMFullscreen
249+ }
250+ // Report the state the window manager published, once per change.
251+ switch {
252+ case on && !isFS:
253+ fmt.Printf("fullscreen %dx%d\n", cw, ch)
254+ case !on && isFS:
255+ fmt.Printf("fullscreen off %dx%d\n", cw, ch)
256+ }
257+ isFS = on
258+ },
189259 OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) },
190260 }
191261 for !done {
@@ -196,8 +266,13 @@ func run() error {
196266 return err
197267 }
198268 timeout := -1
199- if !ovDeadline.IsZero() {
200- timeout = max(int(time.Until(ovDeadline).Milliseconds()), 0)
269+ for _, d := range []time.Time{ovDeadline, fsAt, fsOffAt} {
270+ if !d.IsZero() {
271+ ms := max(int(time.Until(d).Milliseconds())+1, 0)
272+ if timeout < 0 || ms < timeout {
273+ timeout = ms
274+ }
275+ }
201276 }
202277 fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}}
203278 if _, err := unix.Poll(fds, timeout); err != nil && err != unix.EINTR {
@@ -206,6 +281,17 @@ func run() error {
206281 if !ovDeadline.IsZero() && !time.Now().Before(ovDeadline) {
207282 unmapOverride("override unmapped")
208283 }
284+ if !fsAt.IsZero() && !time.Now().Before(fsAt) {
285+ fsAt = time.Time{}
286+ fsMsg(1)
287+ if *fullscreenOff > 0 {
288+ fsOffAt = time.Now().Add(*fullscreenOff)
289+ }
290+ }
291+ if !fsOffAt.IsZero() && !time.Now().Before(fsOffAt) {
292+ fsOffAt = time.Time{}
293+ fsMsg(0)
294+ }
209295 if err := c.Dispatch(hd); err != nil {
210296 return err
211297 }
@@ -8,6 +8,13 @@
8 // main one is mapped (prints "override mapped") and unmaps it after8 // main one is mapped (prints "override mapped") and unmaps it after
9 // -override-for (prints "override unmapped"). With -override-dismiss-on-focusout9 // -override-for (prints "override unmapped"). With -override-dismiss-on-focusout
10 // it unmaps it instead when the main window loses focus (prints "dismissed").10 // it unmaps it instead when the main window loses focus (prints "dismissed").
11+//
12+// With -fullscreen it asks the window manager for _NET_WM_STATE_FULLSCREEN
13+// -fullscreen-delay after the window is mapped (0: right at the map) and
14+// prints "fullscreen WxH" when the window is resized; -fullscreen-off asks to
15+// leave it that long after (prints "fullscreen off WxH"). -keyboard-grab
16+// calls XGrabKeyboard on the window once it is mapped (prints "grab").
17+// -at X,Y creates the window at that root position.
11 package main18 package main
12 19
13 import (20 import (
@@ -50,7 +57,16 @@ func run() error {
50 overrideType := flag.String("override-type", "menu", "override window type: menu or tooltip")57 overrideType := flag.String("override-type", "menu", "override window type: menu or tooltip")
51 overrideShape := flag.Bool("override-shape", false, "give the override window an empty input shape")58 overrideShape := flag.Bool("override-shape", false, "give the override window an empty input shape")
52 dismiss := flag.Bool("override-dismiss-on-focusout", false, "unmap the override window when the main window loses focus")59 dismiss := flag.Bool("override-dismiss-on-focusout", false, "unmap the override window when the main window loses focus")
60+ fullscreen := flag.Bool("fullscreen", false, "request _NET_WM_STATE_FULLSCREEN after the window is mapped")
61+ fullscreenDelay := flag.Duration("fullscreen-delay", 0, "delay between the map and the fullscreen request")
62+ fullscreenOff := flag.Duration("fullscreen-off", 0, "leave fullscreen this long after the request (0: never)")
63+ keyboardGrab := flag.Bool("keyboard-grab", false, "XGrabKeyboard on the window once it is mapped")
64+ at := flag.String("at", "0,0", "window position X,Y in root coordinates")
53 flag.Parse()65 flag.Parse()
66+ atX, atY, okAt := strings.Cut(*at, ",")
67+ if !okAt {
68+ return fmt.Errorf("bad -at %q", *at)
69+ }
54 w, h, ok := parseSize(*size)70 w, h, ok := parseSize(*size)
55 if !ok {71 if !ok {
56 return fmt.Errorf("bad -size %q", *size)72 return fmt.Errorf("bad -size %q", *size)
@@ -62,13 +78,14 @@ func run() error {
62 }78 }
63 defer c.Close()79 defer c.Close()
64 80
65- var atoms [7]uint3281+ var atoms [9]uint32
66 if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING",82 if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING",
67- "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU", "_NET_WM_WINDOW_TYPE_TOOLTIP"}, false, atoms[:]); err != nil {83+ "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU", "_NET_WM_WINDOW_TYPE_TOOLTIP", "_NET_WM_STATE", "_NET_WM_STATE_FULLSCREEN"}, false, atoms[:]); err != nil {
68 return err84 return err
69 }85 }
70 protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3])86 protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3])
71 typeAtom, typeMenu, typeTooltip := xproto.Atom(atoms[4]), atoms[5], atoms[6]87 typeAtom, typeMenu, typeTooltip := xproto.Atom(atoms[4]), atoms[5], atoms[6]
88+ netWMState, netWMFullscreen := atoms[7], atoms[8]
72 89
73 b := c.Buf()90 b := c.Buf()
74 xwin, err := c.NewXID()91 xwin, err := c.NewXID()
@@ -82,8 +99,8 @@ func run() error {
82 win, gc := xproto.Window(xwin), xproto.Gcontext(xgc)99 win, gc := xproto.Window(xwin), xproto.Gcontext(xgc)
83 100
84 var v xproto.CreateWindowValues101 var v xproto.CreateWindowValues
85- v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange)102+ v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange | xproto.EventMaskPropertyChange)
86- xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), 0, 0, uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v)103+ xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), int16(atoi(atX)), int16(atoi(atY)), uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v)
87 104
88 wmClass := []byte("x11probe\x00" + *class + "\x00")105 wmClass := []byte("x11probe\x00" + *class + "\x00")
89 xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmClass, xproto.AtomString, 8, uint32(len(wmClass)), wmClass)106 xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmClass, xproto.AtomString, 8, uint32(len(wmClass)), wmClass)
@@ -100,6 +117,13 @@ func run() error {
100 return err117 return err
101 }118 }
102 time.Sleep(*sleep)119 time.Sleep(*sleep)
120+ if *at != "0,0" {
121+ // Like games, ask for the rect before the map (a ConfigureRequest the
122+ // window manager sees as the output hint).
123+ var cv xproto.ConfigureWindowValues
124+ cv.SetX(int32(atoi(atX))).SetY(int32(atoi(atY))).SetWidth(uint32(w)).SetHeight(uint32(h))
125+ xproto.ConfigureWindow(b, win, &cv)
126+ }
103 xproto.MapWindow(b, win)127 xproto.MapWindow(b, win)
104 128
105 // The override-redirect window is created once the main window is mapped.129 // The override-redirect window is created once the main window is mapped.
@@ -153,7 +177,21 @@ func run() error {
153 }177 }
154 var ovErr error178 var ovErr error
155 179
156- mapped, done := false, false180+ // Fullscreen requests go to the root window as a client message, as ICCCM
181+ // and EWMH say for a mapped window.
182+ fsMsg := func(action uint32) {
183+ var ev [32]byte
184+ ev[0] = byte(xproto.KindClientMessage)
185+ ev[1] = 32
186+ binary.LittleEndian.PutUint32(ev[4:], uint32(win))
187+ binary.LittleEndian.PutUint32(ev[8:], netWMState)
188+ binary.LittleEndian.PutUint32(ev[12:], action)
189+ binary.LittleEndian.PutUint32(ev[16:], netWMFullscreen)
190+ xproto.SendEvent(b, false, xproto.Window(c.Root()), xproto.EventMaskSubstructureRedirect|xproto.EventMaskSubstructureNotify, &ev)
191+ }
192+ var fsAt, fsOffAt time.Time // zero: nothing pending
193+
194+ mapped, done, isFS := false, false, false
157 cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify195 cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify
158 hd := &xturbo.Handler{196 hd := &xturbo.Handler{
159 OnEvent: func(ev *xturbo.Event) {197 OnEvent: func(ev *xturbo.Event) {
@@ -172,6 +210,20 @@ func run() error {
172 if *override {210 if *override {
173 ovErr = mapOverride()211 ovErr = mapOverride()
174 }212 }
213+ if *fullscreen {
214+ fsAt = time.Now().Add(*fullscreenDelay)
215+ }
216+ if *keyboardGrab {
217+ xproto.GrabKeyboard(b, true, win, 0, xproto.GrabModeAsync, xproto.GrabModeAsync)
218+ fmt.Println("grab")
219+ }
220+ }
221+ case xproto.KindPropertyNotify:
222+ if e := xproto.PropertyNotifyOf(ev); e.Window() == win && xproto.Atom(e.Atom()) == xproto.Atom(netWMState) {
223+ seq := xproto.GetProperty(b, false, win, xproto.Atom(netWMState), xproto.AtomAtom, 0, 64)
224+ if err := c.Expect(seq, 16, 0, 0, 0, 0); err != nil {
225+ ovErr = err
226+ }
175 }227 }
176 case xproto.KindFocusIn:228 case xproto.KindFocusIn:
177 fmt.Println("focus")229 fmt.Println("focus")
@@ -186,6 +238,24 @@ func run() error {
186 }238 }
187 }239 }
188 },240 },
241+ OnReply: func(r *xturbo.Reply) {
242+ rep, ok := xproto.ParseGetPropertyReply(r.Raw)
243+ if !ok {
244+ return
245+ }
246+ on := false
247+ for v := rep.Value(); len(v) >= 4; v = v[4:] {
248+ on = on || binary.LittleEndian.Uint32(v) == netWMFullscreen
249+ }
250+ // Report the state the window manager published, once per change.
251+ switch {
252+ case on && !isFS:
253+ fmt.Printf("fullscreen %dx%d\n", cw, ch)
254+ case !on && isFS:
255+ fmt.Printf("fullscreen off %dx%d\n", cw, ch)
256+ }
257+ isFS = on
258+ },
189 OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) },259 OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) },
190 }260 }
191 for !done {261 for !done {
@@ -196,8 +266,13 @@ func run() error {
196 return err266 return err
197 }267 }
198 timeout := -1268 timeout := -1
199- if !ovDeadline.IsZero() {269+ for _, d := range []time.Time{ovDeadline, fsAt, fsOffAt} {
200- timeout = max(int(time.Until(ovDeadline).Milliseconds()), 0)270+ if !d.IsZero() {
271+ ms := max(int(time.Until(d).Milliseconds())+1, 0)
272+ if timeout < 0 || ms < timeout {
273+ timeout = ms
274+ }
275+ }
201 }276 }
202 fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}}277 fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}}
203 if _, err := unix.Poll(fds, timeout); err != nil && err != unix.EINTR {278 if _, err := unix.Poll(fds, timeout); err != nil && err != unix.EINTR {
@@ -206,6 +281,17 @@ func run() error {
206 if !ovDeadline.IsZero() && !time.Now().Before(ovDeadline) {281 if !ovDeadline.IsZero() && !time.Now().Before(ovDeadline) {
207 unmapOverride("override unmapped")282 unmapOverride("override unmapped")
208 }283 }
284+ if !fsAt.IsZero() && !time.Now().Before(fsAt) {
285+ fsAt = time.Time{}
286+ fsMsg(1)
287+ if *fullscreenOff > 0 {
288+ fsOffAt = time.Now().Add(*fullscreenOff)
289+ }
290+ }
291+ if !fsOffAt.IsZero() && !time.Now().Before(fsOffAt) {
292+ fsOffAt = time.Time{}
293+ fsMsg(0)
294+ }
209 if err := c.Dispatch(hd); err != nil {295 if err := c.Dispatch(hd); err != nil {
210 return err296 return err
211 }297 }
M internal/app/xwayland_test.go +184 -1
@@ -51,6 +51,13 @@ type xwaylandEnv struct {
5151 // X11 display. It skips the test without Xwayland and stops everything when
5252 // the test ends.
5353 func startXwayland(t *testing.T) *xwaylandEnv {
54+ t.Helper()
55+ return startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, nil)
56+}
57+
58+// startXwaylandWith is startXwayland with the given headless outputs, left to
59+// right, and an optional change to the default config.
60+func startXwaylandWith(t *testing.T, sizes [][2]int, mutate func(*ports.Config)) *xwaylandEnv {
5461 t.Helper()
5562 if _, err := exec.LookPath("Xwayland"); err != nil {
5663 t.Skip("Xwayland not on PATH")
@@ -73,11 +80,14 @@ func startXwayland(t *testing.T) *xwaylandEnv {
7380 ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs})))
7481 cfg := config.Defaults()
7582 cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}
83+ if mutate != nil {
84+ mutate(&cfg)
85+ }
7686 done := make(chan error, 1)
7787 go func() {
7888 done <- Run(ctx, Options{
7989 Backend: "headless", NoTerminal: true, Config: cfg, Script: script,
80- ScreenshotDir: shots, Sizes: [][2]int{{xwaylandW, xwaylandH}},
90+ ScreenshotDir: shots, Sizes: sizes,
8191 })
8292 }()
8393 t.Cleanup(func() {
@@ -392,3 +402,176 @@ func TestXwaylandOverrideShape(t *testing.T) {
392402 })
393403 until(t, "only the toplevel in state", func() bool { return len(readState(e.statePath).Windows) == 1 })
394404 }
405+
406+// fullOutput is the share of the output screenshot at path that is green.
407+func fullOutput(path string) float64 {
408+ return greenShare(path, image.Rect(0, 0, xwaylandW, xwaylandH))
409+}
410+
411+// windowOf returns the state window with the given pid.
412+func windowOf(st statefile.State, pid int) (statefile.Window, bool) {
413+ for _, w := range st.Windows {
414+ if w.PID == pid {
415+ return w, true
416+ }
417+ }
418+ return statefile.Window{}, false
419+}
420+
421+// TestXwaylandFullscreen: a fullscreen request after the 1 s grace fills the
422+// output, the toggle bind leaves it, and a request made right at the map is
423+// ignored (the grace, current policy).
424+func TestXwaylandFullscreen(t *testing.T) {
425+ // Gaps make a tile visibly smaller than the output.
426+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Layout.Gaps = 20 })
427+ shot := filepath.Join(e.shots, "latest.png")
428+ corner := image.Rect(0, 0, 4, 4)
429+ p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "2s")
430+ until(t, "tiled with a gap", func() bool {
431+ return strings.Contains(p.out.String(), "mapped ") && fullOutput(shot) > 0.3 && greenShare(shot, corner) == 0
432+ })
433+ until(t, "probe fullscreen at output size", func() bool {
434+ return strings.Contains(p.out.String(), "fullscreen 640x360")
435+ })
436+ until(t, "output all green", func() bool { return fullOutput(shot) >= 0.99 })
437+
438+ // The toggle bind (cmd+shift+f) leaves fullscreen.
439+ if _, err := io.WriteString(e.script, "key Super+Shift+f\n"); err != nil {
440+ t.Fatal(err)
441+ }
442+ until(t, "probe left fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
443+ until(t, "gap back", func() bool { return greenShare(shot, corner) == 0 && fullOutput(shot) < 0.99 })
444+}
445+
446+// TestXwaylandFullscreenOff: the client leaves fullscreen itself.
447+func TestXwaylandFullscreenOff(t *testing.T) {
448+ e := startXwayland(t)
449+ p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "1s")
450+ until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
451+ until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
452+ until(t, "tiled", func() bool {
453+ st := readState(e.statePath)
454+ return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }()
455+ })
456+}
457+
458+// TestXwaylandFullscreenGrace: a request made at once after the map stays
459+// tiled (fullscreenGrace in core).
460+func TestXwaylandFullscreenGrace(t *testing.T) {
461+ e := startXwayland(t)
462+ p := startProbe(t, e.bin, e.display, "-fullscreen")
463+ until(t, "mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") })
464+ until(t, "tiled window", func() bool {
465+ st := readState(e.statePath)
466+ return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }()
467+ })
468+ // Stay inside the grace, then check it is still not fullscreen.
469+ time.Sleep(500 * time.Millisecond)
470+ st := readState(e.statePath)
471+ if _, ok := tileRect(st, st.Windows[0].ID); !ok {
472+ t.Error("window went fullscreen inside the grace")
473+ }
474+ if strings.Contains(p.out.String(), "fullscreen ") {
475+ t.Errorf("probe saw a fullscreen resize inside the grace: %q", p.out.String())
476+ }
477+}
478+
479+// TestXwaylandKeyboardGrab: while a fullscreen, focused X11 client holds the
480+// keyboard grab the close bind does not fire; after it leaves fullscreen the
481+// binds work again.
482+func TestXwaylandKeyboardGrab(t *testing.T) {
483+ e := startXwayland(t)
484+ p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "2500ms", "-keyboard-grab")
485+ until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
486+ until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") })
487+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
488+ if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
489+ t.Fatal(err)
490+ }
491+ select {
492+ case err := <-p.waited:
493+ t.Fatalf("probe closed (%v) by Super+q despite the keyboard grab", err)
494+ case <-time.After(2 * time.Second):
495+ }
496+
497+ // The probe leaves fullscreen itself; its grab stays, but the binds work again.
498+ until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
499+ if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
500+ t.Fatal(err)
501+ }
502+ select {
503+ case err := <-p.waited:
504+ if err != nil {
505+ t.Errorf("probe exited with %v, want 0", err)
506+ }
507+ case <-time.After(2 * time.Second):
508+ t.Fatal("Super+q did not close the client after it left fullscreen")
509+ }
510+}
511+
512+// TestXwaylandKeyboardGrabWindowed: a grab on a windowed client does not lock
513+// the binds.
514+func TestXwaylandKeyboardGrabWindowed(t *testing.T) {
515+ e := startXwayland(t)
516+ p := startProbe(t, e.bin, e.display, "-keyboard-grab")
517+ until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") })
518+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
519+ if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
520+ t.Fatal(err)
521+ }
522+ select {
523+ case err := <-p.waited:
524+ if err != nil {
525+ t.Errorf("probe exited with %v, want 0", err)
526+ }
527+ case <-time.After(2 * time.Second):
528+ t.Fatal("Super+q did not close a windowed client that holds a keyboard grab")
529+ }
530+}
531+
532+// TestXwaylandOutputHint: with two outputs a window created at the origin of
533+// the second one opens on it, and a fullscreen request fills that output.
534+func TestXwaylandOutputHint(t *testing.T) {
535+ sizes := [][2]int{{xwaylandW, xwaylandH}, {xwaylandW, xwaylandH}}
536+ e := startXwaylandWith(t, sizes, nil)
537+ p := startProbe(t, e.bin, e.display, "-size", "640x360", "-at", "640,0", "-fullscreen", "-fullscreen-delay", "1500ms")
538+ var w statefile.Window
539+ until(t, "window on HEADLESS-2", func() bool {
540+ var ok bool
541+ w, ok = windowOf(readState(e.statePath), p.cmd.Process.Pid)
542+ if ok && w.Output != "HEADLESS-2" {
543+ t.Logf("window output = %s", w.Output)
544+ }
545+ return ok && w.Output == "HEADLESS-2"
546+ })
547+ until(t, "probe fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
548+ until(t, "HEADLESS-2 all green", func() bool {
549+ return fullOutput(filepath.Join(e.shots, "HEADLESS-2", "latest.png")) >= 0.99
550+ })
551+ if g := fullOutput(filepath.Join(e.shots, "HEADLESS-1", "latest.png")); g > 0.1 {
552+ t.Errorf("HEADLESS-1 green share = %.2f, want empty", g)
553+ }
554+}
555+
556+// TestXwaylandClassRule: [window-rule] matches the WM_CLASS of an X11 window
557+// the way it matches a Wayland app ID (steam_app_<id> from Proton).
558+func TestXwaylandClassRule(t *testing.T) {
559+ yes := true
560+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) {
561+ c.Rules = append(c.Rules, ports.WindowRule{Name: "games", AppID: regexp.MustCompile(`^steam_app_\d+$`), Floating: &yes})
562+ })
563+ startProbe(t, e.bin, e.display, "-class", "steam_app_413150")
564+ until(t, "floating steam_app window", func() bool {
565+ st := readState(e.statePath)
566+ return len(st.Windows) == 1 && st.Windows[0].AppID == "steam_app_413150" && st.Windows[0].Floating
567+ })
568+ startProbe(t, e.bin, e.display, "-class", "Other")
569+ until(t, "other class tiled", func() bool {
570+ for _, w := range readState(e.statePath).Windows {
571+ if w.AppID == "Other" {
572+ return !w.Floating
573+ }
574+ }
575+ return false
576+ })
577+}
@@ -51,6 +51,13 @@ type xwaylandEnv struct {
51 // X11 display. It skips the test without Xwayland and stops everything when51 // X11 display. It skips the test without Xwayland and stops everything when
52 // the test ends.52 // the test ends.
53 func startXwayland(t *testing.T) *xwaylandEnv {53 func startXwayland(t *testing.T) *xwaylandEnv {
54+ t.Helper()
55+ return startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, nil)
56+}
57+
58+// startXwaylandWith is startXwayland with the given headless outputs, left to
59+// right, and an optional change to the default config.
60+func startXwaylandWith(t *testing.T, sizes [][2]int, mutate func(*ports.Config)) *xwaylandEnv {
54 t.Helper()61 t.Helper()
55 if _, err := exec.LookPath("Xwayland"); err != nil {62 if _, err := exec.LookPath("Xwayland"); err != nil {
56 t.Skip("Xwayland not on PATH")63 t.Skip("Xwayland not on PATH")
@@ -73,11 +80,14 @@ func startXwayland(t *testing.T) *xwaylandEnv {
73 ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs})))80 ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs})))
74 cfg := config.Defaults()81 cfg := config.Defaults()
75 cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}82 cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}
83+ if mutate != nil {
84+ mutate(&cfg)
85+ }
76 done := make(chan error, 1)86 done := make(chan error, 1)
77 go func() {87 go func() {
78 done <- Run(ctx, Options{88 done <- Run(ctx, Options{
79 Backend: "headless", NoTerminal: true, Config: cfg, Script: script,89 Backend: "headless", NoTerminal: true, Config: cfg, Script: script,
80- ScreenshotDir: shots, Sizes: [][2]int{{xwaylandW, xwaylandH}},90+ ScreenshotDir: shots, Sizes: sizes,
81 })91 })
82 }()92 }()
83 t.Cleanup(func() {93 t.Cleanup(func() {
@@ -392,3 +402,176 @@ func TestXwaylandOverrideShape(t *testing.T) {
392 })402 })
393 until(t, "only the toplevel in state", func() bool { return len(readState(e.statePath).Windows) == 1 })403 until(t, "only the toplevel in state", func() bool { return len(readState(e.statePath).Windows) == 1 })
394 }404 }
405+
406+// fullOutput is the share of the output screenshot at path that is green.
407+func fullOutput(path string) float64 {
408+ return greenShare(path, image.Rect(0, 0, xwaylandW, xwaylandH))
409+}
410+
411+// windowOf returns the state window with the given pid.
412+func windowOf(st statefile.State, pid int) (statefile.Window, bool) {
413+ for _, w := range st.Windows {
414+ if w.PID == pid {
415+ return w, true
416+ }
417+ }
418+ return statefile.Window{}, false
419+}
420+
421+// TestXwaylandFullscreen: a fullscreen request after the 1 s grace fills the
422+// output, the toggle bind leaves it, and a request made right at the map is
423+// ignored (the grace, current policy).
424+func TestXwaylandFullscreen(t *testing.T) {
425+ // Gaps make a tile visibly smaller than the output.
426+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Layout.Gaps = 20 })
427+ shot := filepath.Join(e.shots, "latest.png")
428+ corner := image.Rect(0, 0, 4, 4)
429+ p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "2s")
430+ until(t, "tiled with a gap", func() bool {
431+ return strings.Contains(p.out.String(), "mapped ") && fullOutput(shot) > 0.3 && greenShare(shot, corner) == 0
432+ })
433+ until(t, "probe fullscreen at output size", func() bool {
434+ return strings.Contains(p.out.String(), "fullscreen 640x360")
435+ })
436+ until(t, "output all green", func() bool { return fullOutput(shot) >= 0.99 })
437+
438+ // The toggle bind (cmd+shift+f) leaves fullscreen.
439+ if _, err := io.WriteString(e.script, "key Super+Shift+f\n"); err != nil {
440+ t.Fatal(err)
441+ }
442+ until(t, "probe left fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
443+ until(t, "gap back", func() bool { return greenShare(shot, corner) == 0 && fullOutput(shot) < 0.99 })
444+}
445+
446+// TestXwaylandFullscreenOff: the client leaves fullscreen itself.
447+func TestXwaylandFullscreenOff(t *testing.T) {
448+ e := startXwayland(t)
449+ p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "1s")
450+ until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
451+ until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
452+ until(t, "tiled", func() bool {
453+ st := readState(e.statePath)
454+ return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }()
455+ })
456+}
457+
458+// TestXwaylandFullscreenGrace: a request made at once after the map stays
459+// tiled (fullscreenGrace in core).
460+func TestXwaylandFullscreenGrace(t *testing.T) {
461+ e := startXwayland(t)
462+ p := startProbe(t, e.bin, e.display, "-fullscreen")
463+ until(t, "mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") })
464+ until(t, "tiled window", func() bool {
465+ st := readState(e.statePath)
466+ return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }()
467+ })
468+ // Stay inside the grace, then check it is still not fullscreen.
469+ time.Sleep(500 * time.Millisecond)
470+ st := readState(e.statePath)
471+ if _, ok := tileRect(st, st.Windows[0].ID); !ok {
472+ t.Error("window went fullscreen inside the grace")
473+ }
474+ if strings.Contains(p.out.String(), "fullscreen ") {
475+ t.Errorf("probe saw a fullscreen resize inside the grace: %q", p.out.String())
476+ }
477+}
478+
479+// TestXwaylandKeyboardGrab: while a fullscreen, focused X11 client holds the
480+// keyboard grab the close bind does not fire; after it leaves fullscreen the
481+// binds work again.
482+func TestXwaylandKeyboardGrab(t *testing.T) {
483+ e := startXwayland(t)
484+ p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "2500ms", "-keyboard-grab")
485+ until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
486+ until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") })
487+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
488+ if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
489+ t.Fatal(err)
490+ }
491+ select {
492+ case err := <-p.waited:
493+ t.Fatalf("probe closed (%v) by Super+q despite the keyboard grab", err)
494+ case <-time.After(2 * time.Second):
495+ }
496+
497+ // The probe leaves fullscreen itself; its grab stays, but the binds work again.
498+ until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
499+ if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
500+ t.Fatal(err)
501+ }
502+ select {
503+ case err := <-p.waited:
504+ if err != nil {
505+ t.Errorf("probe exited with %v, want 0", err)
506+ }
507+ case <-time.After(2 * time.Second):
508+ t.Fatal("Super+q did not close the client after it left fullscreen")
509+ }
510+}
511+
512+// TestXwaylandKeyboardGrabWindowed: a grab on a windowed client does not lock
513+// the binds.
514+func TestXwaylandKeyboardGrabWindowed(t *testing.T) {
515+ e := startXwayland(t)
516+ p := startProbe(t, e.bin, e.display, "-keyboard-grab")
517+ until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") })
518+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
519+ if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
520+ t.Fatal(err)
521+ }
522+ select {
523+ case err := <-p.waited:
524+ if err != nil {
525+ t.Errorf("probe exited with %v, want 0", err)
526+ }
527+ case <-time.After(2 * time.Second):
528+ t.Fatal("Super+q did not close a windowed client that holds a keyboard grab")
529+ }
530+}
531+
532+// TestXwaylandOutputHint: with two outputs a window created at the origin of
533+// the second one opens on it, and a fullscreen request fills that output.
534+func TestXwaylandOutputHint(t *testing.T) {
535+ sizes := [][2]int{{xwaylandW, xwaylandH}, {xwaylandW, xwaylandH}}
536+ e := startXwaylandWith(t, sizes, nil)
537+ p := startProbe(t, e.bin, e.display, "-size", "640x360", "-at", "640,0", "-fullscreen", "-fullscreen-delay", "1500ms")
538+ var w statefile.Window
539+ until(t, "window on HEADLESS-2", func() bool {
540+ var ok bool
541+ w, ok = windowOf(readState(e.statePath), p.cmd.Process.Pid)
542+ if ok && w.Output != "HEADLESS-2" {
543+ t.Logf("window output = %s", w.Output)
544+ }
545+ return ok && w.Output == "HEADLESS-2"
546+ })
547+ until(t, "probe fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
548+ until(t, "HEADLESS-2 all green", func() bool {
549+ return fullOutput(filepath.Join(e.shots, "HEADLESS-2", "latest.png")) >= 0.99
550+ })
551+ if g := fullOutput(filepath.Join(e.shots, "HEADLESS-1", "latest.png")); g > 0.1 {
552+ t.Errorf("HEADLESS-1 green share = %.2f, want empty", g)
553+ }
554+}
555+
556+// TestXwaylandClassRule: [window-rule] matches the WM_CLASS of an X11 window
557+// the way it matches a Wayland app ID (steam_app_<id> from Proton).
558+func TestXwaylandClassRule(t *testing.T) {
559+ yes := true
560+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) {
561+ c.Rules = append(c.Rules, ports.WindowRule{Name: "games", AppID: regexp.MustCompile(`^steam_app_\d+$`), Floating: &yes})
562+ })
563+ startProbe(t, e.bin, e.display, "-class", "steam_app_413150")
564+ until(t, "floating steam_app window", func() bool {
565+ st := readState(e.statePath)
566+ return len(st.Windows) == 1 && st.Windows[0].AppID == "steam_app_413150" && st.Windows[0].Floating
567+ })
568+ startProbe(t, e.bin, e.display, "-class", "Other")
569+ until(t, "other class tiled", func() bool {
570+ for _, w := range readState(e.statePath).Windows {
571+ if w.AppID == "Other" {
572+ return !w.Floating
573+ }
574+ }
575+ return false
576+ })
577+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close