detached 8aab3d0

feat(wayland): serve xdg_dialog_v1 and focus modal dialogs over their parent

Serve xdg_wm_dialog_v1. A toplevel marked modal carries Modal on
WindowMapped; later set_modal/unset_modal and the object's destruction
reach core as WindowModal.

Core sends a user focus on a blocked parent (click, activation, focus
move, overview pick) to its topmost modal dialog, following nested modal
dialogs. A move away from the dialog reaches the parent once; a move that
goes nowhere leaves the focus where it is. Toggling modal is a client
request and moves no focus. A dialog hidden under another window's
fullscreen leaves that fullscreen first, as an activation does.

Refs: GitHub #99
commit
8aab3d05c8f2fa72f12cd1ad5bd9af0c8fcc8b56
author
brice · · 2026-10-08 09:21 UTC
committer
brice · · 2026-10-08 12:56 UTC
parent
a094d2b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +354 -3
M docs/config.md +1 -1
@@ -275,7 +275,7 @@ Each workspace has a stash: a horizontal strip of windows set aside with `toggle
275275
276276 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it, sliding the view, and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible`, or with a four-finger swipe down or up, to get back to the tiles; a swipe down shows it again. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).
277277
278-Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive, except for the fullscreen window's own dialogs. A dialog belongs to a window through `xdg_toplevel.set_parent`, or through `zxdg_importer_v2` when another program opens it, as the desktop portal does.
278+Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive, except for the fullscreen window's own dialogs. A dialog belongs to a window through `xdg_toplevel.set_parent`, or through `zxdg_importer_v2` when another program opens it, as the desktop portal does. A dialog the app marks modal through `xdg_dialog_v1` (GTK4 does for "Save as…") blocks its window: clicking it, activating it, picking it in the overview or moving the focus onto it within its workspace focuses the dialog instead, and so do nested modal dialogs. A focus move away from the dialog reaches its window once, so the next move can go past it.
279279
280280 ### Free floating windows
281281
@@ -275,7 +275,7 @@ Each workspace has a stash: a horizontal strip of windows set aside with `toggle
275 275
276 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it, sliding the view, and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible`, or with a four-finger swipe down or up, to get back to the tiles; a swipe down shows it again. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).276 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it, sliding the view, and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible`, or with a four-finger swipe down or up, to get back to the tiles; a swipe down shows it again. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).
277 277
278-Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive, except for the fullscreen window's own dialogs. A dialog belongs to a window through `xdg_toplevel.set_parent`, or through `zxdg_importer_v2` when another program opens it, as the desktop portal does.278+Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive, except for the fullscreen window's own dialogs. A dialog belongs to a window through `xdg_toplevel.set_parent`, or through `zxdg_importer_v2` when another program opens it, as the desktop portal does. A dialog the app marks modal through `xdg_dialog_v1` (GTK4 does for "Save as…") blocks its window: clicking it, activating it, picking it in the overview or moving the focus onto it within its workspace focuses the dialog instead, and so do nested modal dialogs. A focus move away from the dialog reaches its window once, so the next move can go past it.
279 279
280 ### Free floating windows280 ### Free floating windows
281 281
A internal/adapters/wayland/dialog.go +82 -0
@@ -0,0 +1,82 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/xdgdialog"
5+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-libwayland/server"
8+)
9+
10+// xdg-dialog: a client marks a toplevel as modal for its parent (a "Save
11+// as…" dialog). The parent itself still comes from xdg_toplevel.set_parent
12+// or xdg-foreign; core sends the focus the parent gets to its modal dialog.
13+
14+func registerDialog(d *server.Display, s *Server) error {
15+ return xdgdialog.NewWmDialogV1Global(d, 1, func(c server.Client, v, id uint32) {
16+ _, _ = xdgdialog.NewWmDialogV1(c, int32(v), id, wmDialog{s})
17+ })
18+}
19+
20+type wmDialog struct{ s *Server }
21+
22+func (wmDialog) Destroy(*xdgdialog.WmDialogV1) {}
23+
24+// GetXdgDialog makes the dialog object of a toplevel; a toplevel has at
25+// most one.
26+func (m wmDialog) GetXdgDialog(r *xdgdialog.WmDialogV1, id uint32, t *xdgshell.Toplevel) {
27+ w := m.s.toplevelWindow(t)
28+ if w != nil && w.dialog {
29+ r.PostError(uint32(xdgdialog.WmDialogV1ErrorAlreadyUsed), "toplevel already has a dialog object")
30+ return
31+ }
32+ res, err := xdgdialog.NewDialogV1(r.Client(), r.Version(), id, dialog{w})
33+ if err != nil || w == nil {
34+ return
35+ }
36+ w.dialog = true
37+ // Destroying the object ends the modal state. Once the toplevel is
38+ // gone the object is inert: its window stays unmapped, nothing is sent.
39+ res.OnDestroy = func() {
40+ w.dialog = false
41+ w.setModal(false)
42+ }
43+}
44+
45+type dialog struct{ w *window }
46+
47+func (dialog) Destroy(*xdgdialog.DialogV1) {}
48+func (d dialog) SetModal(*xdgdialog.DialogV1) {
49+ if d.w != nil {
50+ d.w.setModal(true)
51+ }
52+}
53+func (d dialog) UnsetModal(*xdgdialog.DialogV1) {
54+ if d.w != nil {
55+ d.w.setModal(false)
56+ }
57+}
58+
59+// toplevelWindow is the window of toplevel t, nil when unknown.
60+func (s *Server) toplevelWindow(t *xdgshell.Toplevel) *window {
61+ if t == nil {
62+ return nil
63+ }
64+ for _, w := range s.windows {
65+ if w.toplevel != nil && w.toplevel.Resource == t.Resource {
66+ return w
67+ }
68+ }
69+ return nil
70+}
71+
72+// setModal changes the modal state; core follows it once mapped (the map
73+// carries the state of the first frame).
74+func (w *window) setModal(modal bool) {
75+ if w.modal == modal {
76+ return
77+ }
78+ w.modal = modal
79+ if w.mapped {
80+ w.xdg.server.emit(ports.WindowModal{ID: w.id, Modal: modal})
81+ }
82+}
@@ -0,0 +1,82 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/xdgdialog"
5+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-libwayland/server"
8+)
9+
10+// xdg-dialog: a client marks a toplevel as modal for its parent (a "Save
11+// as…" dialog). The parent itself still comes from xdg_toplevel.set_parent
12+// or xdg-foreign; core sends the focus the parent gets to its modal dialog.
13+
14+func registerDialog(d *server.Display, s *Server) error {
15+ return xdgdialog.NewWmDialogV1Global(d, 1, func(c server.Client, v, id uint32) {
16+ _, _ = xdgdialog.NewWmDialogV1(c, int32(v), id, wmDialog{s})
17+ })
18+}
19+
20+type wmDialog struct{ s *Server }
21+
22+func (wmDialog) Destroy(*xdgdialog.WmDialogV1) {}
23+
24+// GetXdgDialog makes the dialog object of a toplevel; a toplevel has at
25+// most one.
26+func (m wmDialog) GetXdgDialog(r *xdgdialog.WmDialogV1, id uint32, t *xdgshell.Toplevel) {
27+ w := m.s.toplevelWindow(t)
28+ if w != nil && w.dialog {
29+ r.PostError(uint32(xdgdialog.WmDialogV1ErrorAlreadyUsed), "toplevel already has a dialog object")
30+ return
31+ }
32+ res, err := xdgdialog.NewDialogV1(r.Client(), r.Version(), id, dialog{w})
33+ if err != nil || w == nil {
34+ return
35+ }
36+ w.dialog = true
37+ // Destroying the object ends the modal state. Once the toplevel is
38+ // gone the object is inert: its window stays unmapped, nothing is sent.
39+ res.OnDestroy = func() {
40+ w.dialog = false
41+ w.setModal(false)
42+ }
43+}
44+
45+type dialog struct{ w *window }
46+
47+func (dialog) Destroy(*xdgdialog.DialogV1) {}
48+func (d dialog) SetModal(*xdgdialog.DialogV1) {
49+ if d.w != nil {
50+ d.w.setModal(true)
51+ }
52+}
53+func (d dialog) UnsetModal(*xdgdialog.DialogV1) {
54+ if d.w != nil {
55+ d.w.setModal(false)
56+ }
57+}
58+
59+// toplevelWindow is the window of toplevel t, nil when unknown.
60+func (s *Server) toplevelWindow(t *xdgshell.Toplevel) *window {
61+ if t == nil {
62+ return nil
63+ }
64+ for _, w := range s.windows {
65+ if w.toplevel != nil && w.toplevel.Resource == t.Resource {
66+ return w
67+ }
68+ }
69+ return nil
70+}
71+
72+// setModal changes the modal state; core follows it once mapped (the map
73+// carries the state of the first frame).
74+func (w *window) setModal(modal bool) {
75+ if w.modal == modal {
76+ return
77+ }
78+ w.modal = modal
79+ if w.mapped {
80+ w.xdg.server.emit(ports.WindowModal{ID: w.id, Modal: modal})
81+ }
82+}
A internal/adapters/wayland/dialog_test.go +113 -0
@@ -0,0 +1,113 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/go-wayland-bindings/server/xdgdialog"
9+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "golang.org/x/sys/unix"
12+)
13+
14+// A modal dialog maps with its state, and later changes reach core. A
15+// second dialog object for the same toplevel is a protocol error.
16+func TestDialogModal(t *testing.T) {
17+ s, events, _, dir := lifecycleServer(t)
18+ c := protocolClient(t, s, dir)
19+ parent, _, parentXDG := surfaceMapper(t, c, events)()
20+ // surfaceMapper allocates the toplevel right after its xdg_surface.
21+ parentTop := parentXDG + 1
22+
23+ comp := bindProtocol(t, c, "wl_compositor")
24+ wm := bindProtocol(t, c, "xdg_wm_base")
25+ shm := bindProtocol(t, c, "wl_shm")
26+ registerProtocol(t, c, shm)
27+ dialogs := bindProtocol(t, c, "xdg_wm_dialog_v1")
28+ fd, err := unix.MemfdCreate("dialog-buffer", 0)
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ defer unix.Close(fd)
33+ if err := unix.Ftruncate(fd, 4); err != nil {
34+ t.Fatal(err)
35+ }
36+ pool, buffer := c.AllocateID(), c.AllocateID()
37+ registerProtocol(t, c, pool)
38+ registerProtocol(t, c, buffer)
39+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
40+ t.Fatal(err)
41+ }
42+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
43+
44+ // The dialog toplevel: modal before its first frame.
45+ surf, xdg, top, dlg := c.AllocateID(), c.AllocateID(), c.AllocateID(), c.AllocateID()
46+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
47+ registerProtocol(t, c, surf)
48+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
49+ serials := make(chan uint32, 1)
50+ xp := &configureProxy{serial: serials}
51+ xp.SetID(xdg)
52+ registerWireProxy(c, xp)
53+ registerProtocol(t, c, top)
54+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
55+ registerProtocol(t, c, dlg)
56+ requestProtocol(t, c, dialogs, xdgdialog.WmDialogV1RequestGetXdgDialog, dlg, top)
57+ requestProtocol(t, c, dlg, xdgdialog.DialogV1RequestSetModal)
58+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetParent, parentTop)
59+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
60+ if err := c.Roundtrip(); err != nil {
61+ t.Fatal(err)
62+ }
63+ select {
64+ case serial := <-serials:
65+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
66+ default:
67+ t.Fatal("missing configure")
68+ }
69+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
70+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
71+ if err := c.Roundtrip(); err != nil {
72+ t.Fatal(err)
73+ }
74+ d := mapped(t, events, 2*time.Second)
75+ if !d.Modal || d.Parent != parent.ID || !d.Floating {
76+ t.Fatalf("dialog %+v, want modal over %d", d, parent.ID)
77+ }
78+
79+ modal := func(want bool) {
80+ t.Helper()
81+ if err := c.Roundtrip(); err != nil {
82+ t.Fatal(err)
83+ }
84+ for deadline := time.After(2 * time.Second); ; {
85+ select {
86+ case ev := <-events:
87+ if m, ok := ev.(ports.WindowModal); ok {
88+ if m.ID != d.ID || m.Modal != want {
89+ t.Fatalf("modal change %+v, want %v", m, want)
90+ }
91+ return
92+ }
93+ case <-deadline:
94+ t.Fatal("no modal change")
95+ }
96+ }
97+ }
98+ requestProtocol(t, c, dlg, xdgdialog.DialogV1RequestUnsetModal)
99+ modal(false)
100+ requestProtocol(t, c, dlg, xdgdialog.DialogV1RequestSetModal)
101+ modal(true)
102+ // Destroying the object ends the modal state.
103+ requestProtocol(t, c, dlg, xdgdialog.DialogV1RequestDestroy)
104+ modal(false)
105+
106+ // A toplevel gets one dialog object at a time.
107+ first, second := c.AllocateID(), c.AllocateID()
108+ registerProtocol(t, c, first)
109+ registerProtocol(t, c, second)
110+ requestProtocol(t, c, dialogs, xdgdialog.WmDialogV1RequestGetXdgDialog, first, top)
111+ requestProtocol(t, c, dialogs, xdgdialog.WmDialogV1RequestGetXdgDialog, second, top)
112+ expectProtocolError(t, c, dialogs, uint32(xdgdialog.WmDialogV1ErrorAlreadyUsed))
113+}
@@ -0,0 +1,113 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/go-wayland-bindings/server/xdgdialog"
9+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "golang.org/x/sys/unix"
12+)
13+
14+// A modal dialog maps with its state, and later changes reach core. A
15+// second dialog object for the same toplevel is a protocol error.
16+func TestDialogModal(t *testing.T) {
17+ s, events, _, dir := lifecycleServer(t)
18+ c := protocolClient(t, s, dir)
19+ parent, _, parentXDG := surfaceMapper(t, c, events)()
20+ // surfaceMapper allocates the toplevel right after its xdg_surface.
21+ parentTop := parentXDG + 1
22+
23+ comp := bindProtocol(t, c, "wl_compositor")
24+ wm := bindProtocol(t, c, "xdg_wm_base")
25+ shm := bindProtocol(t, c, "wl_shm")
26+ registerProtocol(t, c, shm)
27+ dialogs := bindProtocol(t, c, "xdg_wm_dialog_v1")
28+ fd, err := unix.MemfdCreate("dialog-buffer", 0)
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ defer unix.Close(fd)
33+ if err := unix.Ftruncate(fd, 4); err != nil {
34+ t.Fatal(err)
35+ }
36+ pool, buffer := c.AllocateID(), c.AllocateID()
37+ registerProtocol(t, c, pool)
38+ registerProtocol(t, c, buffer)
39+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
40+ t.Fatal(err)
41+ }
42+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
43+
44+ // The dialog toplevel: modal before its first frame.
45+ surf, xdg, top, dlg := c.AllocateID(), c.AllocateID(), c.AllocateID(), c.AllocateID()
46+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
47+ registerProtocol(t, c, surf)
48+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
49+ serials := make(chan uint32, 1)
50+ xp := &configureProxy{serial: serials}
51+ xp.SetID(xdg)
52+ registerWireProxy(c, xp)
53+ registerProtocol(t, c, top)
54+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
55+ registerProtocol(t, c, dlg)
56+ requestProtocol(t, c, dialogs, xdgdialog.WmDialogV1RequestGetXdgDialog, dlg, top)
57+ requestProtocol(t, c, dlg, xdgdialog.DialogV1RequestSetModal)
58+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetParent, parentTop)
59+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
60+ if err := c.Roundtrip(); err != nil {
61+ t.Fatal(err)
62+ }
63+ select {
64+ case serial := <-serials:
65+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
66+ default:
67+ t.Fatal("missing configure")
68+ }
69+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
70+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
71+ if err := c.Roundtrip(); err != nil {
72+ t.Fatal(err)
73+ }
74+ d := mapped(t, events, 2*time.Second)
75+ if !d.Modal || d.Parent != parent.ID || !d.Floating {
76+ t.Fatalf("dialog %+v, want modal over %d", d, parent.ID)
77+ }
78+
79+ modal := func(want bool) {
80+ t.Helper()
81+ if err := c.Roundtrip(); err != nil {
82+ t.Fatal(err)
83+ }
84+ for deadline := time.After(2 * time.Second); ; {
85+ select {
86+ case ev := <-events:
87+ if m, ok := ev.(ports.WindowModal); ok {
88+ if m.ID != d.ID || m.Modal != want {
89+ t.Fatalf("modal change %+v, want %v", m, want)
90+ }
91+ return
92+ }
93+ case <-deadline:
94+ t.Fatal("no modal change")
95+ }
96+ }
97+ }
98+ requestProtocol(t, c, dlg, xdgdialog.DialogV1RequestUnsetModal)
99+ modal(false)
100+ requestProtocol(t, c, dlg, xdgdialog.DialogV1RequestSetModal)
101+ modal(true)
102+ // Destroying the object ends the modal state.
103+ requestProtocol(t, c, dlg, xdgdialog.DialogV1RequestDestroy)
104+ modal(false)
105+
106+ // A toplevel gets one dialog object at a time.
107+ first, second := c.AllocateID(), c.AllocateID()
108+ registerProtocol(t, c, first)
109+ registerProtocol(t, c, second)
110+ requestProtocol(t, c, dialogs, xdgdialog.WmDialogV1RequestGetXdgDialog, first, top)
111+ requestProtocol(t, c, dialogs, xdgdialog.WmDialogV1RequestGetXdgDialog, second, top)
112+ expectProtocolError(t, c, dialogs, uint32(xdgdialog.WmDialogV1ErrorAlreadyUsed))
113+}
M internal/adapters/wayland/globals.go +1 -0
@@ -28,6 +28,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
2828 func() error { return registerForeignToplevel(d, s) },
2929 func() error { return registerExtForeignToplevel(d, s) },
3030 func() error { return registerDecoration(d, s) },
31+ func() error { return registerDialog(d, s) },
3132 func() error { return registerScale(d, s) },
3233 func() error { return registerCursorShape(d, s) },
3334 func() error { return registerDMABuf(d, s, o.DMABuf) },
@@ -28,6 +28,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
28 func() error { return registerForeignToplevel(d, s) },28 func() error { return registerForeignToplevel(d, s) },
29 func() error { return registerExtForeignToplevel(d, s) },29 func() error { return registerExtForeignToplevel(d, s) },
30 func() error { return registerDecoration(d, s) },30 func() error { return registerDecoration(d, s) },
31+ func() error { return registerDialog(d, s) },
31 func() error { return registerScale(d, s) },32 func() error { return registerScale(d, s) },
32 func() error { return registerCursorShape(d, s) },33 func() error { return registerCursorShape(d, s) },
33 func() error { return registerDMABuf(d, s, o.DMABuf) },34 func() error { return registerDMABuf(d, s, o.DMABuf) },
M internal/adapters/wayland/wire_client_test.go +3 -0
@@ -51,6 +51,7 @@ import (
5151 wire_wlroutputpower "github.com/bnema/go-wayland-bindings/server/wlroutputpowermanagement"
5252 wire_wlrscreencopy "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"
5353 wire_xdgactivation "github.com/bnema/go-wayland-bindings/server/xdgactivation"
54+ wire_xdgdialog "github.com/bnema/go-wayland-bindings/server/xdgdialog"
5455 wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"
5556 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"
5657 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
@@ -197,6 +198,8 @@ var wireSchemas = map[string]wireSchema{
197198 "wp_viewport": {wire_viewporter.WpViewportInterface, &client_viewporter.WpViewport{}, map[uint16]bool{0: true}, nil},
198199 "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil},
199200 "xdg_activation_token_v1": {wire_xdgactivation.ActivationTokenV1Interface, &client_xdgactivation.XdgActivationToken{}, map[uint16]bool{4: true}, nil},
201+ "xdg_dialog_v1": {wire_xdgdialog.DialogV1Interface, nil, map[uint16]bool{0: true}, nil},
202+ "xdg_wm_dialog_v1": {wire_xdgdialog.WmDialogV1Interface, nil, map[uint16]bool{0: true}, nil},
200203 "xdg_activation_v1": {wire_xdgactivation.ActivationV1Interface, &client_xdgactivation.XdgActivation{}, map[uint16]bool{0: true}, nil},
201204 "zxdg_exporter_v2": {wire_xdgforeign.ZxdgExporterV2Interface, nil, map[uint16]bool{0: true}, nil},
202205 "zxdg_importer_v2": {wire_xdgforeign.ZxdgImporterV2Interface, nil, map[uint16]bool{0: true}, nil},
@@ -51,6 +51,7 @@ import (
51 wire_wlroutputpower "github.com/bnema/go-wayland-bindings/server/wlroutputpowermanagement"51 wire_wlroutputpower "github.com/bnema/go-wayland-bindings/server/wlroutputpowermanagement"
52 wire_wlrscreencopy "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"52 wire_wlrscreencopy "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"
53 wire_xdgactivation "github.com/bnema/go-wayland-bindings/server/xdgactivation"53 wire_xdgactivation "github.com/bnema/go-wayland-bindings/server/xdgactivation"
54+ wire_xdgdialog "github.com/bnema/go-wayland-bindings/server/xdgdialog"
54 wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"55 wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"
55 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"56 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"
56 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"57 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
@@ -197,6 +198,8 @@ var wireSchemas = map[string]wireSchema{
197 "wp_viewport": {wire_viewporter.WpViewportInterface, &client_viewporter.WpViewport{}, map[uint16]bool{0: true}, nil},198 "wp_viewport": {wire_viewporter.WpViewportInterface, &client_viewporter.WpViewport{}, map[uint16]bool{0: true}, nil},
198 "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil},199 "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil},
199 "xdg_activation_token_v1": {wire_xdgactivation.ActivationTokenV1Interface, &client_xdgactivation.XdgActivationToken{}, map[uint16]bool{4: true}, nil},200 "xdg_activation_token_v1": {wire_xdgactivation.ActivationTokenV1Interface, &client_xdgactivation.XdgActivationToken{}, map[uint16]bool{4: true}, nil},
201+ "xdg_dialog_v1": {wire_xdgdialog.DialogV1Interface, nil, map[uint16]bool{0: true}, nil},
202+ "xdg_wm_dialog_v1": {wire_xdgdialog.WmDialogV1Interface, nil, map[uint16]bool{0: true}, nil},
200 "xdg_activation_v1": {wire_xdgactivation.ActivationV1Interface, &client_xdgactivation.XdgActivation{}, map[uint16]bool{0: true}, nil},203 "xdg_activation_v1": {wire_xdgactivation.ActivationV1Interface, &client_xdgactivation.XdgActivation{}, map[uint16]bool{0: true}, nil},
201 "zxdg_exporter_v2": {wire_xdgforeign.ZxdgExporterV2Interface, nil, map[uint16]bool{0: true}, nil},204 "zxdg_exporter_v2": {wire_xdgforeign.ZxdgExporterV2Interface, nil, map[uint16]bool{0: true}, nil},
202 "zxdg_importer_v2": {wire_xdgforeign.ZxdgImporterV2Interface, nil, map[uint16]bool{0: true}, nil},205 "zxdg_importer_v2": {wire_xdgforeign.ZxdgImporterV2Interface, nil, map[uint16]bool{0: true}, nil},
M internal/adapters/wayland/xdg.go +5 -2
@@ -138,7 +138,10 @@ type window struct {
138138 // ext-foreign-toplevel-list (identifier) and to its capture sources.
139139 gen uint32
140140 // parent is set_parent (dialogs); min and max are the size hints.
141- parent *window
141+ parent *window
142+ // dialog is set while an xdg_dialog_v1 object exists for the
143+ // toplevel; modal is its state.
144+ dialog, modal bool
142145 minW, minH int32
143146 maxW, maxH int32
144147 floating bool
@@ -243,7 +246,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
243246 if w.parent != nil && w.parent.mapped {
244247 parent = w.parent.id
245248 }
246- x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Remap: w.gen > 1})
249+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Modal: w.modal, Remap: w.gen > 1})
247250 x.server.syncInhibitors()
248251 x.server.toplevelChanged(w)
249252 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
@@ -138,7 +138,10 @@ type window struct {
138 // ext-foreign-toplevel-list (identifier) and to its capture sources.138 // ext-foreign-toplevel-list (identifier) and to its capture sources.
139 gen uint32139 gen uint32
140 // parent is set_parent (dialogs); min and max are the size hints.140 // parent is set_parent (dialogs); min and max are the size hints.
141- parent *window141+ parent *window
142+ // dialog is set while an xdg_dialog_v1 object exists for the
143+ // toplevel; modal is its state.
144+ dialog, modal bool
142 minW, minH int32145 minW, minH int32
143 maxW, maxH int32146 maxW, maxH int32
144 floating bool147 floating bool
@@ -243,7 +246,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
243 if w.parent != nil && w.parent.mapped {246 if w.parent != nil && w.parent.mapped {
244 parent = w.parent.id247 parent = w.parent.id
245 }248 }
246- x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Remap: w.gen > 1})249+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Modal: w.modal, Remap: w.gen > 1})
247 x.server.syncInhibitors()250 x.server.syncInhibitors()
248 x.server.toplevelChanged(w)251 x.server.toplevelChanged(w)
249 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")252 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
M internal/core/client.go +4 -0
@@ -66,6 +66,10 @@ func (c *Core) clientEvent(ctx context.Context, ev ports.ClientEvent) (publish,
6666 if _, w := c.screenOf(v.ID); w != nil {
6767 w.SetDialogParent(v.ID, v.Parent)
6868 }
69+ case ports.WindowModal:
70+ if _, w := c.screenOf(v.ID); w != nil {
71+ w.SetModal(v.ID, v.Modal)
72+ }
6973 case ports.WindowUnmapped:
7074 if c.unmapClient(ctx, v) != nil {
7175 return false, true
@@ -66,6 +66,10 @@ func (c *Core) clientEvent(ctx context.Context, ev ports.ClientEvent) (publish,
66 if _, w := c.screenOf(v.ID); w != nil {66 if _, w := c.screenOf(v.ID); w != nil {
67 w.SetDialogParent(v.ID, v.Parent)67 w.SetDialogParent(v.ID, v.Parent)
68 }68 }
69+ case ports.WindowModal:
70+ if _, w := c.screenOf(v.ID); w != nil {
71+ w.SetModal(v.ID, v.Modal)
72+ }
69 case ports.WindowUnmapped:73 case ports.WindowUnmapped:
70 if c.unmapClient(ctx, v) != nil {74 if c.unmapClient(ctx, v) != nil {
71 return false, true75 return false, true
M internal/core/layout.go +62 -0
@@ -268,6 +268,9 @@ type Float struct {
268268 // over is the parent of a dialog: while the parent covers the output
269269 // fullscreen, the dialog shows over it and may take the focus.
270270 over WindowID
271+ // modal marks a dialog that blocks its parent: the user focusing the
272+ // parent focuses it (modalOf).
273+ modal bool
271274 }
272275
273276 func (w *Workspace) empty() bool {
@@ -415,6 +418,60 @@ func (w *Workspace) SetDialogParent(id, parent WindowID) {
415418 }
416419 }
417420
421+// SetModal marks a dialog as blocking its parent or not. It moves no focus
422+// (a client request, ADR 011): the next user focus on the parent goes to
423+// the dialog. A dialog maps focused anyway (AddDialog).
424+func (w *Workspace) SetModal(id WindowID, modal bool) {
425+ if i := w.floatIndex(id); i >= 0 {
426+ w.Floats[i].modal = modal
427+ }
428+}
429+
430+// modalOf is the modal dialog that blocks window id, 0 when none: the
431+// topmost modal dialog of id, or of that dialog in turn (a confirmation
432+// over a "Save as…"). Only dialogs on id's workspace count.
433+func (w *Workspace) modalOf(id WindowID) WindowID {
434+ blocker := WindowID(0)
435+ // Each hop goes to another float: at most len(w.Floats) hops.
436+ for range len(w.Floats) {
437+ next := WindowID(0)
438+ for i := len(w.Floats) - 1; i >= 0; i-- {
439+ if f := w.Floats[i]; f.modal && f.over == id && id != 0 && f.ID != id && f.ID != blocker {
440+ next = f.ID
441+ break
442+ }
443+ }
444+ if next == 0 {
445+ break
446+ }
447+ blocker, id = next, next
448+ }
449+ return blocker
450+}
451+
452+// focusModal sends the focus the user just moved onto the parent of a
453+// modal dialog to that dialog, unless the move came from the dialog: a move
454+// away from a dialog reaches its parent, and the next one goes on. Nothing
455+// happens when the focus did not move.
456+func (w *Workspace) focusModal(from WindowID) {
457+ to, ok := w.Focused()
458+ if !ok || to == from {
459+ return
460+ }
461+ d := w.modalOf(to)
462+ if d == 0 || d == from {
463+ return
464+ }
465+ if c := w.cover(); c != 0 && c != d {
466+ if i := w.floatIndex(d); i >= 0 && !w.Floats[i].coverDialog(c) {
467+ // Hidden under another window's fullscreen: leave it, as
468+ // Activate does, so the dialog shows.
469+ w.leaveFullscreen()
470+ }
471+ }
472+ w.FocusID(d)
473+}
474+
418475 // coverDialog reports whether f is a dialog of the covering fullscreen
419476 // window cover: the one float drawn over it.
420477 func (f Float) coverDialog(cover WindowID) bool {
@@ -690,6 +747,8 @@ func (w *Workspace) FocusID(id WindowID) bool {
690747 // fullscreen window it leaves fullscreen for the window there (leaveCover);
691748 // false means it stays, with no window on that side.
692749 func (w *Workspace) FocusColumn(dir int) bool {
750+ from, _ := w.Focused()
751+ defer w.focusModal(from)
693752 if w.coverDialogFocused() {
694753 // The first move goes back to the fullscreen window under it.
695754 w.floatFocus = false
@@ -754,6 +813,8 @@ func (w *Workspace) onScreenFocus() bool {
754813 // float; false means no window is available in that direction. From a
755814 // covering fullscreen window it leaves fullscreen for that window.
756815 func (w *Workspace) FocusWindow(dir int) bool {
816+ from, _ := w.Focused()
817+ defer w.focusModal(from)
757818 if w.coverDialogFocused() {
758819 w.floatFocus = false
759820 return true
@@ -1155,6 +1216,7 @@ func (w *Workspace) Activate(id WindowID) {
11551216 w.leaveFullscreen()
11561217 }
11571218 w.FocusID(id)
1219+ w.focusModal(0)
11581220 }
11591221
11601222 // SetFullscreen applies a client request. It never moves focus: client
@@ -268,6 +268,9 @@ type Float struct {
268 // over is the parent of a dialog: while the parent covers the output268 // over is the parent of a dialog: while the parent covers the output
269 // fullscreen, the dialog shows over it and may take the focus.269 // fullscreen, the dialog shows over it and may take the focus.
270 over WindowID270 over WindowID
271+ // modal marks a dialog that blocks its parent: the user focusing the
272+ // parent focuses it (modalOf).
273+ modal bool
271 }274 }
272 275
273 func (w *Workspace) empty() bool {276 func (w *Workspace) empty() bool {
@@ -415,6 +418,60 @@ func (w *Workspace) SetDialogParent(id, parent WindowID) {
415 }418 }
416 }419 }
417 420
421+// SetModal marks a dialog as blocking its parent or not. It moves no focus
422+// (a client request, ADR 011): the next user focus on the parent goes to
423+// the dialog. A dialog maps focused anyway (AddDialog).
424+func (w *Workspace) SetModal(id WindowID, modal bool) {
425+ if i := w.floatIndex(id); i >= 0 {
426+ w.Floats[i].modal = modal
427+ }
428+}
429+
430+// modalOf is the modal dialog that blocks window id, 0 when none: the
431+// topmost modal dialog of id, or of that dialog in turn (a confirmation
432+// over a "Save as…"). Only dialogs on id's workspace count.
433+func (w *Workspace) modalOf(id WindowID) WindowID {
434+ blocker := WindowID(0)
435+ // Each hop goes to another float: at most len(w.Floats) hops.
436+ for range len(w.Floats) {
437+ next := WindowID(0)
438+ for i := len(w.Floats) - 1; i >= 0; i-- {
439+ if f := w.Floats[i]; f.modal && f.over == id && id != 0 && f.ID != id && f.ID != blocker {
440+ next = f.ID
441+ break
442+ }
443+ }
444+ if next == 0 {
445+ break
446+ }
447+ blocker, id = next, next
448+ }
449+ return blocker
450+}
451+
452+// focusModal sends the focus the user just moved onto the parent of a
453+// modal dialog to that dialog, unless the move came from the dialog: a move
454+// away from a dialog reaches its parent, and the next one goes on. Nothing
455+// happens when the focus did not move.
456+func (w *Workspace) focusModal(from WindowID) {
457+ to, ok := w.Focused()
458+ if !ok || to == from {
459+ return
460+ }
461+ d := w.modalOf(to)
462+ if d == 0 || d == from {
463+ return
464+ }
465+ if c := w.cover(); c != 0 && c != d {
466+ if i := w.floatIndex(d); i >= 0 && !w.Floats[i].coverDialog(c) {
467+ // Hidden under another window's fullscreen: leave it, as
468+ // Activate does, so the dialog shows.
469+ w.leaveFullscreen()
470+ }
471+ }
472+ w.FocusID(d)
473+}
474+
418 // coverDialog reports whether f is a dialog of the covering fullscreen475 // coverDialog reports whether f is a dialog of the covering fullscreen
419 // window cover: the one float drawn over it.476 // window cover: the one float drawn over it.
420 func (f Float) coverDialog(cover WindowID) bool {477 func (f Float) coverDialog(cover WindowID) bool {
@@ -690,6 +747,8 @@ func (w *Workspace) FocusID(id WindowID) bool {
690 // fullscreen window it leaves fullscreen for the window there (leaveCover);747 // fullscreen window it leaves fullscreen for the window there (leaveCover);
691 // false means it stays, with no window on that side.748 // false means it stays, with no window on that side.
692 func (w *Workspace) FocusColumn(dir int) bool {749 func (w *Workspace) FocusColumn(dir int) bool {
750+ from, _ := w.Focused()
751+ defer w.focusModal(from)
693 if w.coverDialogFocused() {752 if w.coverDialogFocused() {
694 // The first move goes back to the fullscreen window under it.753 // The first move goes back to the fullscreen window under it.
695 w.floatFocus = false754 w.floatFocus = false
@@ -754,6 +813,8 @@ func (w *Workspace) onScreenFocus() bool {
754 // float; false means no window is available in that direction. From a813 // float; false means no window is available in that direction. From a
755 // covering fullscreen window it leaves fullscreen for that window.814 // covering fullscreen window it leaves fullscreen for that window.
756 func (w *Workspace) FocusWindow(dir int) bool {815 func (w *Workspace) FocusWindow(dir int) bool {
816+ from, _ := w.Focused()
817+ defer w.focusModal(from)
757 if w.coverDialogFocused() {818 if w.coverDialogFocused() {
758 w.floatFocus = false819 w.floatFocus = false
759 return true820 return true
@@ -1155,6 +1216,7 @@ func (w *Workspace) Activate(id WindowID) {
1155 w.leaveFullscreen()1216 w.leaveFullscreen()
1156 }1217 }
1157 w.FocusID(id)1218 w.FocusID(id)
1219+ w.focusModal(0)
1158 }1220 }
1159 1221
1160 // SetFullscreen applies a client request. It never moves focus: client1222 // SetFullscreen applies a client request. It never moves focus: client
A internal/core/modal_test.go +67 -0
@@ -0,0 +1,67 @@
1+package core
2+
3+import "testing"
4+
5+// A modal dialog takes the focus its parent gets from the user: a click,
6+// an activation or a focus move onto the parent. A move away from the
7+// dialog reaches the parent once, and the next one goes on.
8+func TestModalDialogTakesParentFocus(t *testing.T) {
9+ w := workspace()
10+ w.AddWindow(1)
11+ w.AddWindow(2)
12+ w.AddDialog(3, 1, 20, 10)
13+ w.SetModal(3, true)
14+ focus := func(want WindowID) {
15+ t.Helper()
16+ if id, _ := w.Focused(); id != want {
17+ t.Fatalf("focus %d, want %d", id, want)
18+ }
19+ }
20+ focus(3)
21+ w.Click(2)
22+ focus(2)
23+ w.Click(1)
24+ focus(3)
25+ w.Click(2)
26+ w.Activate(1)
27+ focus(3)
28+ // Away from the dialog: the parent, then on.
29+ w.FocusColumn(-1)
30+ focus(1)
31+ // A move that goes nowhere (the left edge) leaves it there.
32+ w.FocusColumn(-1)
33+ focus(1)
34+ w.FocusColumn(1)
35+ focus(2)
36+ // Back onto the parent from elsewhere: the dialog.
37+ w.FocusColumn(-1)
38+ focus(3)
39+
40+ // Not modal: the parent keeps the focus it gets.
41+ w.SetModal(3, false)
42+ w.Click(1)
43+ focus(1)
44+ // Turning modal is a client request: it moves no focus, the next
45+ // user focus on the parent does.
46+ w.SetModal(3, true)
47+ focus(1)
48+ w.Click(2)
49+ w.Click(1)
50+ focus(3)
51+ // A modal dialog of the dialog blocks both.
52+ w.AddDialog(4, 3, 10, 5)
53+ w.SetModal(4, true)
54+ w.Click(2)
55+ w.Click(1)
56+ focus(4)
57+ w.RemoveWindow(4)
58+ // Picking the parent's card in the overview is a user focus too.
59+ w.Click(2)
60+ w.apply(stackItem{kind: stackColumns}, 1)
61+ focus(3)
62+ // Closed: the parent is reachable again.
63+ w.RemoveWindow(3)
64+ w.Click(2)
65+ w.Click(1)
66+ focus(1)
67+}
@@ -0,0 +1,67 @@
1+package core
2+
3+import "testing"
4+
5+// A modal dialog takes the focus its parent gets from the user: a click,
6+// an activation or a focus move onto the parent. A move away from the
7+// dialog reaches the parent once, and the next one goes on.
8+func TestModalDialogTakesParentFocus(t *testing.T) {
9+ w := workspace()
10+ w.AddWindow(1)
11+ w.AddWindow(2)
12+ w.AddDialog(3, 1, 20, 10)
13+ w.SetModal(3, true)
14+ focus := func(want WindowID) {
15+ t.Helper()
16+ if id, _ := w.Focused(); id != want {
17+ t.Fatalf("focus %d, want %d", id, want)
18+ }
19+ }
20+ focus(3)
21+ w.Click(2)
22+ focus(2)
23+ w.Click(1)
24+ focus(3)
25+ w.Click(2)
26+ w.Activate(1)
27+ focus(3)
28+ // Away from the dialog: the parent, then on.
29+ w.FocusColumn(-1)
30+ focus(1)
31+ // A move that goes nowhere (the left edge) leaves it there.
32+ w.FocusColumn(-1)
33+ focus(1)
34+ w.FocusColumn(1)
35+ focus(2)
36+ // Back onto the parent from elsewhere: the dialog.
37+ w.FocusColumn(-1)
38+ focus(3)
39+
40+ // Not modal: the parent keeps the focus it gets.
41+ w.SetModal(3, false)
42+ w.Click(1)
43+ focus(1)
44+ // Turning modal is a client request: it moves no focus, the next
45+ // user focus on the parent does.
46+ w.SetModal(3, true)
47+ focus(1)
48+ w.Click(2)
49+ w.Click(1)
50+ focus(3)
51+ // A modal dialog of the dialog blocks both.
52+ w.AddDialog(4, 3, 10, 5)
53+ w.SetModal(4, true)
54+ w.Click(2)
55+ w.Click(1)
56+ focus(4)
57+ w.RemoveWindow(4)
58+ // Picking the parent's card in the overview is a user focus too.
59+ w.Click(2)
60+ w.apply(stackItem{kind: stackColumns}, 1)
61+ focus(3)
62+ // Closed: the parent is reachable again.
63+ w.RemoveWindow(3)
64+ w.Click(2)
65+ w.Click(1)
66+ focus(1)
67+}
M internal/core/overview_stack.go +3 -0
@@ -227,6 +227,9 @@ func (w *Workspace) selectOverviewColumn(i int) {
227227
228228 // apply commits the selected front card without changing other stack items.
229229 func (w *Workspace) apply(item stackItem, selected WindowID) {
230+ // Picking a card is a user focus: a blocked parent gives it to its
231+ // modal dialog.
232+ defer w.focusModal(0)
230233 switch item.kind {
231234 case stackFloat:
232235 w.FocusID(item.id)
@@ -227,6 +227,9 @@ func (w *Workspace) selectOverviewColumn(i int) {
227 227
228 // apply commits the selected front card without changing other stack items.228 // apply commits the selected front card without changing other stack items.
229 func (w *Workspace) apply(item stackItem, selected WindowID) {229 func (w *Workspace) apply(item stackItem, selected WindowID) {
230+ // Picking a card is a user focus: a blocked parent gives it to its
231+ // modal dialog.
232+ defer w.focusModal(0)
230 switch item.kind {233 switch item.kind {
231 case stackFloat:234 case stackFloat:
232 w.FocusID(item.id)235 w.FocusID(item.id)
M internal/core/spawn_placement.go +1 -0
@@ -81,6 +81,7 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
8181 // A dialog opens on its parent's workspace, over it, fullscreen too.
8282 if ps, pw := c.screenOf(v.Parent); v.Parent != 0 && pw != nil {
8383 pw.AddDialog(v.ID, v.Parent, v.Width, v.Height)
84+ pw.SetModal(v.ID, v.Modal)
8485 ps.mon.normalize()
8586 return
8687 }
@@ -81,6 +81,7 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
81 // A dialog opens on its parent's workspace, over it, fullscreen too.81 // A dialog opens on its parent's workspace, over it, fullscreen too.
82 if ps, pw := c.screenOf(v.Parent); v.Parent != 0 && pw != nil {82 if ps, pw := c.screenOf(v.Parent); v.Parent != 0 && pw != nil {
83 pw.AddDialog(v.ID, v.Parent, v.Width, v.Height)83 pw.AddDialog(v.ID, v.Parent, v.Width, v.Height)
84+ pw.SetModal(v.ID, v.Modal)
84 ps.mon.normalize()85 ps.mon.normalize()
85 return86 return
86 }87 }
M internal/core/stash.go +1 -0
@@ -216,6 +216,7 @@ func (w *Workspace) Click(id WindowID) {
216216 w.stashHidden = true
217217 }
218218 w.FocusID(id)
219+ w.focusModal(0)
219220 }
220221
221222 // stashRect is where the selected stashed window is, border included.
@@ -216,6 +216,7 @@ func (w *Workspace) Click(id WindowID) {
216 w.stashHidden = true216 w.stashHidden = true
217 }217 }
218 w.FocusID(id)218 w.FocusID(id)
219+ w.focusModal(0)
219 }220 }
220 221
221 // stashRect is where the selected stashed window is, border included.222 // stashRect is where the selected stashed window is, border included.
M internal/ports/client_events.go +11 -0
@@ -23,6 +23,9 @@ type WindowMapped struct {
2323 // Remap is set when the toplevel mapped before and was unmapped (a tray
2424 // app restored): window rules only place its first map.
2525 Remap bool
26+ // Modal is set for a dialog that blocks its parent (xdg_dialog_v1):
27+ // focusing the parent focuses the dialog.
28+ Modal bool
2629 }
2730
2831 // SlotEnv is the environment variable neferwl sets on processes it spawns
@@ -129,6 +132,14 @@ type WindowParent struct {
129132
130133 func (WindowParent) clientEvent() {}
131134
135+// WindowModal carries a mapped dialog's new modal state (xdg_dialog_v1).
136+type WindowModal struct {
137+ ID WindowID
138+ Modal bool
139+}
140+
141+func (WindowModal) clientEvent() {}
142+
132143 // WindowResized carries wayland → core the new size of a floating window.
133144 type WindowResized struct {
134145 ID WindowID
@@ -23,6 +23,9 @@ type WindowMapped struct {
23 // Remap is set when the toplevel mapped before and was unmapped (a tray23 // Remap is set when the toplevel mapped before and was unmapped (a tray
24 // app restored): window rules only place its first map.24 // app restored): window rules only place its first map.
25 Remap bool25 Remap bool
26+ // Modal is set for a dialog that blocks its parent (xdg_dialog_v1):
27+ // focusing the parent focuses the dialog.
28+ Modal bool
26 }29 }
27 30
28 // SlotEnv is the environment variable neferwl sets on processes it spawns31 // SlotEnv is the environment variable neferwl sets on processes it spawns
@@ -129,6 +132,14 @@ type WindowParent struct {
129 132
130 func (WindowParent) clientEvent() {}133 func (WindowParent) clientEvent() {}
131 134
135+// WindowModal carries a mapped dialog's new modal state (xdg_dialog_v1).
136+type WindowModal struct {
137+ ID WindowID
138+ Modal bool
139+}
140+
141+func (WindowModal) clientEvent() {}
142+
132 // WindowResized carries wayland → core the new size of a floating window.143 // WindowResized carries wayland → core the new size of a floating window.
133 type WindowResized struct {144 type WindowResized struct {
134 ID WindowID145 ID WindowID

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