detached 47739da

test(app): cover override-redirect X11 windows end to end

commit
47739da5abcf5530fa87af322525abbdda544a20
author
brice · · 2026-10-22 15:22 UTC
committer
brice · · 2026-10-22 17:54 UTC
parent
dc722bb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +211 -8
M internal/app/testdata/x11probe/main.go +95 -4
@@ -3,6 +3,11 @@
33 // It opens one window filled with solid green, prints "mapped WxH" after the
44 // first ConfigureNotify and "focus" on each FocusIn, and exits 0 when the
55 // window manager sends WM_DELETE_WINDOW.
6+//
7+// With -override it also maps a magenta override-redirect window after the
8+// main one is mapped (prints "override mapped") and unmaps it after
9+// -override-for (prints "override unmapped"). With -override-dismiss-on-focusout
10+// it unmaps it instead when the main window loses focus (prints "dismissed").
611 package main
712
813 import (
@@ -17,10 +22,14 @@ import (
1722 "golang.org/x/sys/unix"
1823
1924 "bnema.dev/xturbo"
25+ "bnema.dev/xturbo/shape"
2026 "bnema.dev/xturbo/xproto"
2127 )
2228
23-const green = 0x00ff00
29+const (
30+ green = 0x00ff00
31+ magenta = 0xff00ff
32+)
2433
2534 func main() {
2635 if err := run(); err != nil {
@@ -34,6 +43,13 @@ func run() error {
3443 title := flag.String("title", "probe", "_NET_WM_NAME")
3544 size := flag.String("size", "300x200", "window size WxH")
3645 sleep := flag.Duration("sleep", 0, "wait before mapping the window")
46+ override := flag.Bool("override", false, "map a magenta override-redirect window after the main one")
47+ overrideAt := flag.String("override-at", "100,50", "override window position X,Y in root coordinates")
48+ overrideSize := flag.String("override-size", "120x80", "override window size WxH")
49+ overrideFor := flag.Duration("override-for", 0, "unmap the override window after this delay (0: never)")
50+ 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")
52+ dismiss := flag.Bool("override-dismiss-on-focusout", false, "unmap the override window when the main window loses focus")
3753 flag.Parse()
3854 w, h, ok := parseSize(*size)
3955 if !ok {
@@ -46,11 +62,13 @@ func run() error {
4662 }
4763 defer c.Close()
4864
49- var atoms [4]uint32
50- if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING"}, false, atoms[:]); err != nil {
65+ var atoms [7]uint32
66+ 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 {
5168 return err
5269 }
5370 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]
5472
5573 b := c.Buf()
5674 xwin, err := c.NewXID()
@@ -84,6 +102,57 @@ func run() error {
84102 time.Sleep(*sleep)
85103 xproto.MapWindow(b, win)
86104
105+ // The override-redirect window is created once the main window is mapped.
106+ var ovWin xproto.Window
107+ var ovAt, ovSz [2]int
108+ if *override {
109+ var ok1, ok2 bool
110+ x, y, found := strings.Cut(*overrideAt, ",")
111+ ovAt[0], ovAt[1] = atoi(x), atoi(y)
112+ ok1 = found
113+ ovSz[0], ovSz[1], ok2 = parseSize(*overrideSize)
114+ if !ok1 || !ok2 || (*overrideType != "menu" && *overrideType != "tooltip") {
115+ return fmt.Errorf("bad -override-at/-override-size/-override-type")
116+ }
117+ }
118+ var ovDeadline time.Time // zero: no timed unmap pending
119+ unmapOverride := func(msg string) {
120+ if ovWin == 0 {
121+ return
122+ }
123+ xproto.UnmapWindow(b, ovWin)
124+ ovWin = 0
125+ ovDeadline = time.Time{}
126+ fmt.Println(msg)
127+ }
128+ mapOverride := func() error {
129+ id, err := c.NewXID()
130+ if err != nil {
131+ return err
132+ }
133+ ovWin = xproto.Window(id)
134+ var ov xproto.CreateWindowValues
135+ ov.SetBackgroundPixel(magenta).SetOverrideRedirect(true)
136+ xproto.CreateWindow(b, 0, ovWin, xproto.Window(c.Root()), int16(ovAt[0]), int16(ovAt[1]), uint16(ovSz[0]), uint16(ovSz[1]), 0, xproto.WindowClassInputOutput, 0, &ov)
137+ typ := typeMenu
138+ if *overrideType == "tooltip" {
139+ typ = typeTooltip
140+ }
141+ var tb [4]byte
142+ binary.LittleEndian.PutUint32(tb[:], typ)
143+ xproto.ChangeProperty(b, xproto.PropModeReplace, ovWin, typeAtom, xproto.AtomAtom, 32, 1, tb[:])
144+ if *overrideShape {
145+ shape.Rectangles(b, shape.SoSet, shape.SkInput, 0, ovWin, 0, 0, nil)
146+ }
147+ xproto.MapWindow(b, ovWin)
148+ fmt.Println("override mapped")
149+ if *overrideFor > 0 {
150+ ovDeadline = time.Now().Add(*overrideFor)
151+ }
152+ return nil
153+ }
154+ var ovErr error
155+
87156 mapped, done := false, false
88157 cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify
89158 hd := &xturbo.Handler{
@@ -100,9 +169,16 @@ func run() error {
100169 if !mapped {
101170 mapped = true
102171 fmt.Printf("mapped %dx%d\n", cw, ch)
172+ if *override {
173+ ovErr = mapOverride()
174+ }
103175 }
104176 case xproto.KindFocusIn:
105177 fmt.Println("focus")
178+ case xproto.KindFocusOut:
179+ if *dismiss && xproto.FocusOutOf(ev).Event() == win {
180+ unmapOverride("dismissed")
181+ }
106182 case xproto.KindClientMessage:
107183 e := xproto.ClientMessageOf(ev)
108184 if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) {
@@ -119,13 +195,23 @@ func run() error {
119195 } else if err != nil {
120196 return err
121197 }
198+ timeout := -1
199+ if !ovDeadline.IsZero() {
200+ timeout = max(int(time.Until(ovDeadline).Milliseconds()), 0)
201+ }
122202 fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}}
123- if _, err := unix.Poll(fds, -1); err != nil && err != unix.EINTR {
203+ if _, err := unix.Poll(fds, timeout); err != nil && err != unix.EINTR {
124204 return err
125205 }
206+ if !ovDeadline.IsZero() && !time.Now().Before(ovDeadline) {
207+ unmapOverride("override unmapped")
208+ }
126209 if err := c.Dispatch(hd); err != nil {
127210 return err
128211 }
212+ if ovErr != nil {
213+ return ovErr
214+ }
129215 }
130216 return nil
131217 }
@@ -139,3 +225,8 @@ func parseSize(s string) (w, h int, ok bool) {
139225 h, err2 := strconv.Atoi(hs)
140226 return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff
141227 }
228+
229+func atoi(s string) int {
230+ n, _ := strconv.Atoi(s)
231+ return n
232+}
@@ -3,6 +3,11 @@
3 // It opens one window filled with solid green, prints "mapped WxH" after the3 // It opens one window filled with solid green, prints "mapped WxH" after the
4 // first ConfigureNotify and "focus" on each FocusIn, and exits 0 when the4 // first ConfigureNotify and "focus" on each FocusIn, and exits 0 when the
5 // window manager sends WM_DELETE_WINDOW.5 // window manager sends WM_DELETE_WINDOW.
6+//
7+// With -override it also maps a magenta override-redirect window after the
8+// main one is mapped (prints "override mapped") and unmaps it after
9+// -override-for (prints "override unmapped"). With -override-dismiss-on-focusout
10+// it unmaps it instead when the main window loses focus (prints "dismissed").
6 package main11 package main
7 12
8 import (13 import (
@@ -17,10 +22,14 @@ import (
17 "golang.org/x/sys/unix"22 "golang.org/x/sys/unix"
18 23
19 "bnema.dev/xturbo"24 "bnema.dev/xturbo"
25+ "bnema.dev/xturbo/shape"
20 "bnema.dev/xturbo/xproto"26 "bnema.dev/xturbo/xproto"
21 )27 )
22 28
23-const green = 0x00ff0029+const (
30+ green = 0x00ff00
31+ magenta = 0xff00ff
32+)
24 33
25 func main() {34 func main() {
26 if err := run(); err != nil {35 if err := run(); err != nil {
@@ -34,6 +43,13 @@ func run() error {
34 title := flag.String("title", "probe", "_NET_WM_NAME")43 title := flag.String("title", "probe", "_NET_WM_NAME")
35 size := flag.String("size", "300x200", "window size WxH")44 size := flag.String("size", "300x200", "window size WxH")
36 sleep := flag.Duration("sleep", 0, "wait before mapping the window")45 sleep := flag.Duration("sleep", 0, "wait before mapping the window")
46+ override := flag.Bool("override", false, "map a magenta override-redirect window after the main one")
47+ overrideAt := flag.String("override-at", "100,50", "override window position X,Y in root coordinates")
48+ overrideSize := flag.String("override-size", "120x80", "override window size WxH")
49+ overrideFor := flag.Duration("override-for", 0, "unmap the override window after this delay (0: never)")
50+ 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")
52+ dismiss := flag.Bool("override-dismiss-on-focusout", false, "unmap the override window when the main window loses focus")
37 flag.Parse()53 flag.Parse()
38 w, h, ok := parseSize(*size)54 w, h, ok := parseSize(*size)
39 if !ok {55 if !ok {
@@ -46,11 +62,13 @@ func run() error {
46 }62 }
47 defer c.Close()63 defer c.Close()
48 64
49- var atoms [4]uint3265+ var atoms [7]uint32
50- if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING"}, false, atoms[:]); err != nil {66+ 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 {
51 return err68 return err
52 }69 }
53 protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3])70 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]
54 72
55 b := c.Buf()73 b := c.Buf()
56 xwin, err := c.NewXID()74 xwin, err := c.NewXID()
@@ -84,6 +102,57 @@ func run() error {
84 time.Sleep(*sleep)102 time.Sleep(*sleep)
85 xproto.MapWindow(b, win)103 xproto.MapWindow(b, win)
86 104
105+ // The override-redirect window is created once the main window is mapped.
106+ var ovWin xproto.Window
107+ var ovAt, ovSz [2]int
108+ if *override {
109+ var ok1, ok2 bool
110+ x, y, found := strings.Cut(*overrideAt, ",")
111+ ovAt[0], ovAt[1] = atoi(x), atoi(y)
112+ ok1 = found
113+ ovSz[0], ovSz[1], ok2 = parseSize(*overrideSize)
114+ if !ok1 || !ok2 || (*overrideType != "menu" && *overrideType != "tooltip") {
115+ return fmt.Errorf("bad -override-at/-override-size/-override-type")
116+ }
117+ }
118+ var ovDeadline time.Time // zero: no timed unmap pending
119+ unmapOverride := func(msg string) {
120+ if ovWin == 0 {
121+ return
122+ }
123+ xproto.UnmapWindow(b, ovWin)
124+ ovWin = 0
125+ ovDeadline = time.Time{}
126+ fmt.Println(msg)
127+ }
128+ mapOverride := func() error {
129+ id, err := c.NewXID()
130+ if err != nil {
131+ return err
132+ }
133+ ovWin = xproto.Window(id)
134+ var ov xproto.CreateWindowValues
135+ ov.SetBackgroundPixel(magenta).SetOverrideRedirect(true)
136+ xproto.CreateWindow(b, 0, ovWin, xproto.Window(c.Root()), int16(ovAt[0]), int16(ovAt[1]), uint16(ovSz[0]), uint16(ovSz[1]), 0, xproto.WindowClassInputOutput, 0, &ov)
137+ typ := typeMenu
138+ if *overrideType == "tooltip" {
139+ typ = typeTooltip
140+ }
141+ var tb [4]byte
142+ binary.LittleEndian.PutUint32(tb[:], typ)
143+ xproto.ChangeProperty(b, xproto.PropModeReplace, ovWin, typeAtom, xproto.AtomAtom, 32, 1, tb[:])
144+ if *overrideShape {
145+ shape.Rectangles(b, shape.SoSet, shape.SkInput, 0, ovWin, 0, 0, nil)
146+ }
147+ xproto.MapWindow(b, ovWin)
148+ fmt.Println("override mapped")
149+ if *overrideFor > 0 {
150+ ovDeadline = time.Now().Add(*overrideFor)
151+ }
152+ return nil
153+ }
154+ var ovErr error
155+
87 mapped, done := false, false156 mapped, done := false, false
88 cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify157 cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify
89 hd := &xturbo.Handler{158 hd := &xturbo.Handler{
@@ -100,9 +169,16 @@ func run() error {
100 if !mapped {169 if !mapped {
101 mapped = true170 mapped = true
102 fmt.Printf("mapped %dx%d\n", cw, ch)171 fmt.Printf("mapped %dx%d\n", cw, ch)
172+ if *override {
173+ ovErr = mapOverride()
174+ }
103 }175 }
104 case xproto.KindFocusIn:176 case xproto.KindFocusIn:
105 fmt.Println("focus")177 fmt.Println("focus")
178+ case xproto.KindFocusOut:
179+ if *dismiss && xproto.FocusOutOf(ev).Event() == win {
180+ unmapOverride("dismissed")
181+ }
106 case xproto.KindClientMessage:182 case xproto.KindClientMessage:
107 e := xproto.ClientMessageOf(ev)183 e := xproto.ClientMessageOf(ev)
108 if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) {184 if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) {
@@ -119,13 +195,23 @@ func run() error {
119 } else if err != nil {195 } else if err != nil {
120 return err196 return err
121 }197 }
198+ timeout := -1
199+ if !ovDeadline.IsZero() {
200+ timeout = max(int(time.Until(ovDeadline).Milliseconds()), 0)
201+ }
122 fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}}202 fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}}
123- if _, err := unix.Poll(fds, -1); err != nil && err != unix.EINTR {203+ if _, err := unix.Poll(fds, timeout); err != nil && err != unix.EINTR {
124 return err204 return err
125 }205 }
206+ if !ovDeadline.IsZero() && !time.Now().Before(ovDeadline) {
207+ unmapOverride("override unmapped")
208+ }
126 if err := c.Dispatch(hd); err != nil {209 if err := c.Dispatch(hd); err != nil {
127 return err210 return err
128 }211 }
212+ if ovErr != nil {
213+ return ovErr
214+ }
129 }215 }
130 return nil216 return nil
131 }217 }
@@ -139,3 +225,8 @@ func parseSize(s string) (w, h int, ok bool) {
139 h, err2 := strconv.Atoi(hs)225 h, err2 := strconv.Atoi(hs)
140 return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff226 return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff
141 }227 }
228+
229+func atoi(s string) int {
230+ n, _ := strconv.Atoi(s)
231+ return n
232+}
M internal/app/xwayland_test.go +116 -4
@@ -41,10 +41,17 @@ type probe struct {
4141 waited chan error
4242 }
4343
44-// TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows
45-// up in the state with its class and pid, is drawn, tiles next to a second
46-// one, receives focus and is closed by the close-window bind.
47-func TestXwaylandProbe(t *testing.T) {
44+// xwaylandEnv is a running headless compositor with the built-in XWM.
45+type xwaylandEnv struct {
46+ bin, display, statePath, shots string
47+ script *io.PipeWriter
48+}
49+
50+// startXwayland runs the compositor on one headless output and waits for its
51+// X11 display. It skips the test without Xwayland and stops everything when
52+// the test ends.
53+func startXwayland(t *testing.T) *xwaylandEnv {
54+ t.Helper()
4855 if _, err := exec.LookPath("Xwayland"); err != nil {
4956 t.Skip("Xwayland not on PATH")
5057 }
@@ -101,7 +108,15 @@ func TestXwaylandProbe(t *testing.T) {
101108 if err != nil {
102109 t.Fatal(err)
103110 }
111+ return &xwaylandEnv{bin: bin, display: display, statePath: statePath, shots: shots, script: scriptW}
112+}
104113
114+// TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows
115+// up in the state with its class and pid, is drawn, tiles next to a second
116+// one, receives focus and is closed by the close-window bind.
117+func TestXwaylandProbe(t *testing.T) {
118+ e := startXwayland(t)
119+ bin, display, statePath, shots, scriptW := e.bin, e.display, e.statePath, e.shots, e.script
105120 first := startProbe(t, bin, display, "-title", "one")
106121 until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") })
107122
@@ -280,3 +295,100 @@ func greenShare(path string, r image.Rectangle) float64 {
280295 }
281296 return float64(n) / float64(r.Dx()*r.Dy())
282297 }
298+
299+// magentaShare is the fraction of pixels of r in the PNG at path that are
300+// clearly magenta; 0 when the image cannot be read yet.
301+func magentaShare(path string, r image.Rectangle) float64 {
302+ f, err := os.Open(path)
303+ if err != nil {
304+ return 0
305+ }
306+ defer f.Close()
307+ img, err := png.Decode(f)
308+ if err != nil {
309+ return 0
310+ }
311+ r = r.Intersect(img.Bounds())
312+ if r.Empty() {
313+ return 0
314+ }
315+ n := 0
316+ for y := r.Min.Y; y < r.Max.Y; y++ {
317+ for x := r.Min.X; x < r.Max.X; x++ {
318+ cr, cg, cb, _ := img.At(x, y).RGBA()
319+ if cr>>8 > 200 && cg>>8 < 60 && cb>>8 > 200 {
320+ n++
321+ }
322+ }
323+ }
324+ return float64(n) / float64(r.Dx()*r.Dy())
325+}
326+
327+// TestXwaylandOverride: an override-redirect window (a menu) of an X11 client
328+// is drawn above the toplevel at its X position, is not a window in the
329+// state, leaves keyboard focus on the toplevel and vanishes when unmapped.
330+func TestXwaylandOverride(t *testing.T) {
331+ e := startXwayland(t)
332+ // Probe unmaps the menu 3 s after mapping it; the first checks run well
333+ // inside that window.
334+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-at", "100,50",
335+ "-override-size", "120x80", "-override-for", "3s")
336+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
337+ shot := filepath.Join(e.shots, "latest.png")
338+ box := image.Rect(100, 50, 220, 130) // output-local: one output at the X origin
339+ until(t, "magenta menu in latest.png", func() bool { return magentaShare(shot, box) >= 0.9 })
340+
341+ var st statefile.State
342+ until(t, "focused X11Probe toplevel", func() bool {
343+ st = readState(e.statePath)
344+ return len(st.Windows) >= 1 && st.Window != nil
345+ })
346+ if len(st.Windows) != 1 || st.Windows[0].AppID != "X11Probe" {
347+ t.Fatalf("state windows = %+v, want only the X11Probe toplevel", st.Windows)
348+ }
349+ id := st.Windows[0].ID
350+ if st.Window.ID != id {
351+ t.Errorf("focused window = %+v, want the toplevel %d", st.Window, id)
352+ }
353+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
354+
355+ // The menu is above the toplevel: its box is magenta, not green.
356+ if g := greenShare(shot, box); g > 0.1 {
357+ t.Errorf("green share in menu box = %.2f, menu is not above the toplevel", g)
358+ }
359+ until(t, "override unmapped", func() bool { return strings.Contains(p.out.String(), "override unmapped") })
360+ until(t, "menu gone from latest.png", func() bool {
361+ return magentaShare(shot, box) == 0 && greenShare(shot, box) >= 0.9
362+ })
363+ if st := readState(e.statePath); st.Window == nil || st.Window.ID != id {
364+ t.Errorf("focus after the menu closed = %+v, want %d", st.Window, id)
365+ }
366+}
367+
368+// TestXwaylandOverrideDismiss: the menu owner sees FocusOut on the toplevel
369+// when core focuses another window, which is how toolkits close their menus.
370+func TestXwaylandOverrideDismiss(t *testing.T) {
371+ e := startXwayland(t)
372+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-dismiss-on-focusout")
373+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
374+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
375+ // A second X11 window takes the focus away from the menu owner.
376+ startProbe(t, e.bin, e.display, "-title", "other")
377+ until(t, "menu dismissed on FocusOut", func() bool { return strings.Contains(p.out.String(), "dismissed") })
378+ box := image.Rect(100, 50, 220, 130)
379+ until(t, "menu gone from latest.png", func() bool {
380+ return magentaShare(filepath.Join(e.shots, "latest.png"), box) == 0
381+ })
382+}
383+
384+// TestXwaylandOverrideShape: a menu with an empty input shape is still drawn
385+// and still not a window.
386+func TestXwaylandOverrideShape(t *testing.T) {
387+ e := startXwayland(t)
388+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-shape", "-override-type", "tooltip")
389+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
390+ until(t, "magenta tooltip in latest.png", func() bool {
391+ return magentaShare(filepath.Join(e.shots, "latest.png"), image.Rect(100, 50, 220, 130)) >= 0.9
392+ })
393+ until(t, "only the toplevel in state", func() bool { return len(readState(e.statePath).Windows) == 1 })
394+}
@@ -41,10 +41,17 @@ type probe struct {
41 waited chan error41 waited chan error
42 }42 }
43 43
44-// TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows44+// xwaylandEnv is a running headless compositor with the built-in XWM.
45-// up in the state with its class and pid, is drawn, tiles next to a second45+type xwaylandEnv struct {
46-// one, receives focus and is closed by the close-window bind.46+ bin, display, statePath, shots string
47-func TestXwaylandProbe(t *testing.T) {47+ script *io.PipeWriter
48+}
49+
50+// startXwayland runs the compositor on one headless output and waits for its
51+// X11 display. It skips the test without Xwayland and stops everything when
52+// the test ends.
53+func startXwayland(t *testing.T) *xwaylandEnv {
54+ t.Helper()
48 if _, err := exec.LookPath("Xwayland"); err != nil {55 if _, err := exec.LookPath("Xwayland"); err != nil {
49 t.Skip("Xwayland not on PATH")56 t.Skip("Xwayland not on PATH")
50 }57 }
@@ -101,7 +108,15 @@ func TestXwaylandProbe(t *testing.T) {
101 if err != nil {108 if err != nil {
102 t.Fatal(err)109 t.Fatal(err)
103 }110 }
111+ return &xwaylandEnv{bin: bin, display: display, statePath: statePath, shots: shots, script: scriptW}
112+}
104 113
114+// TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows
115+// up in the state with its class and pid, is drawn, tiles next to a second
116+// one, receives focus and is closed by the close-window bind.
117+func TestXwaylandProbe(t *testing.T) {
118+ e := startXwayland(t)
119+ bin, display, statePath, shots, scriptW := e.bin, e.display, e.statePath, e.shots, e.script
105 first := startProbe(t, bin, display, "-title", "one")120 first := startProbe(t, bin, display, "-title", "one")
106 until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") })121 until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") })
107 122
@@ -280,3 +295,100 @@ func greenShare(path string, r image.Rectangle) float64 {
280 }295 }
281 return float64(n) / float64(r.Dx()*r.Dy())296 return float64(n) / float64(r.Dx()*r.Dy())
282 }297 }
298+
299+// magentaShare is the fraction of pixels of r in the PNG at path that are
300+// clearly magenta; 0 when the image cannot be read yet.
301+func magentaShare(path string, r image.Rectangle) float64 {
302+ f, err := os.Open(path)
303+ if err != nil {
304+ return 0
305+ }
306+ defer f.Close()
307+ img, err := png.Decode(f)
308+ if err != nil {
309+ return 0
310+ }
311+ r = r.Intersect(img.Bounds())
312+ if r.Empty() {
313+ return 0
314+ }
315+ n := 0
316+ for y := r.Min.Y; y < r.Max.Y; y++ {
317+ for x := r.Min.X; x < r.Max.X; x++ {
318+ cr, cg, cb, _ := img.At(x, y).RGBA()
319+ if cr>>8 > 200 && cg>>8 < 60 && cb>>8 > 200 {
320+ n++
321+ }
322+ }
323+ }
324+ return float64(n) / float64(r.Dx()*r.Dy())
325+}
326+
327+// TestXwaylandOverride: an override-redirect window (a menu) of an X11 client
328+// is drawn above the toplevel at its X position, is not a window in the
329+// state, leaves keyboard focus on the toplevel and vanishes when unmapped.
330+func TestXwaylandOverride(t *testing.T) {
331+ e := startXwayland(t)
332+ // Probe unmaps the menu 3 s after mapping it; the first checks run well
333+ // inside that window.
334+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-at", "100,50",
335+ "-override-size", "120x80", "-override-for", "3s")
336+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
337+ shot := filepath.Join(e.shots, "latest.png")
338+ box := image.Rect(100, 50, 220, 130) // output-local: one output at the X origin
339+ until(t, "magenta menu in latest.png", func() bool { return magentaShare(shot, box) >= 0.9 })
340+
341+ var st statefile.State
342+ until(t, "focused X11Probe toplevel", func() bool {
343+ st = readState(e.statePath)
344+ return len(st.Windows) >= 1 && st.Window != nil
345+ })
346+ if len(st.Windows) != 1 || st.Windows[0].AppID != "X11Probe" {
347+ t.Fatalf("state windows = %+v, want only the X11Probe toplevel", st.Windows)
348+ }
349+ id := st.Windows[0].ID
350+ if st.Window.ID != id {
351+ t.Errorf("focused window = %+v, want the toplevel %d", st.Window, id)
352+ }
353+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
354+
355+ // The menu is above the toplevel: its box is magenta, not green.
356+ if g := greenShare(shot, box); g > 0.1 {
357+ t.Errorf("green share in menu box = %.2f, menu is not above the toplevel", g)
358+ }
359+ until(t, "override unmapped", func() bool { return strings.Contains(p.out.String(), "override unmapped") })
360+ until(t, "menu gone from latest.png", func() bool {
361+ return magentaShare(shot, box) == 0 && greenShare(shot, box) >= 0.9
362+ })
363+ if st := readState(e.statePath); st.Window == nil || st.Window.ID != id {
364+ t.Errorf("focus after the menu closed = %+v, want %d", st.Window, id)
365+ }
366+}
367+
368+// TestXwaylandOverrideDismiss: the menu owner sees FocusOut on the toplevel
369+// when core focuses another window, which is how toolkits close their menus.
370+func TestXwaylandOverrideDismiss(t *testing.T) {
371+ e := startXwayland(t)
372+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-dismiss-on-focusout")
373+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
374+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
375+ // A second X11 window takes the focus away from the menu owner.
376+ startProbe(t, e.bin, e.display, "-title", "other")
377+ until(t, "menu dismissed on FocusOut", func() bool { return strings.Contains(p.out.String(), "dismissed") })
378+ box := image.Rect(100, 50, 220, 130)
379+ until(t, "menu gone from latest.png", func() bool {
380+ return magentaShare(filepath.Join(e.shots, "latest.png"), box) == 0
381+ })
382+}
383+
384+// TestXwaylandOverrideShape: a menu with an empty input shape is still drawn
385+// and still not a window.
386+func TestXwaylandOverrideShape(t *testing.T) {
387+ e := startXwayland(t)
388+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-shape", "-override-type", "tooltip")
389+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
390+ until(t, "magenta tooltip in latest.png", func() bool {
391+ return magentaShare(filepath.Join(e.shots, "latest.png"), image.Rect(100, 50, 220, 130)) >= 0.9
392+ })
393+ until(t, "only the toplevel in state", func() bool { return len(readState(e.statePath).Windows) == 1 })
394+}

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