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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 275 | 275 | ||
| 276 | 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 | 280 | ### Free floating windows | |
| 281 | 281 | ||
| @@ -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 windows | 280 | ### 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 { | |||
| 28 | 28 | func() error { return registerForeignToplevel(d, s) }, | |
| 29 | 29 | func() error { return registerExtForeignToplevel(d, s) }, | |
| 30 | 30 | func() error { return registerDecoration(d, s) }, | |
| 31 | + | func() error { return registerDialog(d, s) }, | |
| 31 | 32 | func() error { return registerScale(d, s) }, | |
| 32 | 33 | func() error { return registerCursorShape(d, s) }, | |
| 33 | 34 | 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 ( | |||
| 51 | 51 | wire_wlroutputpower "github.com/bnema/go-wayland-bindings/server/wlroutputpowermanagement" | |
| 52 | 52 | wire_wlrscreencopy "github.com/bnema/go-wayland-bindings/server/wlrscreencopy" | |
| 53 | 53 | wire_xdgactivation "github.com/bnema/go-wayland-bindings/server/xdgactivation" | |
| 54 | + | wire_xdgdialog "github.com/bnema/go-wayland-bindings/server/xdgdialog" | |
| 54 | 55 | wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign" | |
| 55 | 56 | wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput" | |
| 56 | 57 | wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell" | |
| @@ -197,6 +198,8 @@ var wireSchemas = map[string]wireSchema{ | |||
| 197 | 198 | "wp_viewport": {wire_viewporter.WpViewportInterface, &client_viewporter.WpViewport{}, map[uint16]bool{0: true}, nil}, | |
| 198 | 199 | "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil}, | |
| 199 | 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 | 203 | "xdg_activation_v1": {wire_xdgactivation.ActivationV1Interface, &client_xdgactivation.XdgActivation{}, map[uint16]bool{0: true}, nil}, | |
| 201 | 204 | "zxdg_exporter_v2": {wire_xdgforeign.ZxdgExporterV2Interface, nil, map[uint16]bool{0: true}, nil}, | |
| 202 | 205 | "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 { | |||
| 138 | 138 | // ext-foreign-toplevel-list (identifier) and to its capture sources. | |
| 139 | 139 | gen uint32 | |
| 140 | 140 | // 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 | |
| 142 | 145 | minW, minH int32 | |
| 143 | 146 | maxW, maxH int32 | |
| 144 | 147 | floating bool | |
| @@ -243,7 +246,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||
| 243 | 246 | if w.parent != nil && w.parent.mapped { | |
| 244 | 247 | 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 | 250 | x.server.syncInhibitors() | |
| 248 | 251 | x.server.toplevelChanged(w) | |
| 249 | 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") | |
| @@ -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 uint32 | 139 | 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 *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 | |||
| 142 | minW, minH int32 | 145 | minW, minH int32 | ||
| 143 | maxW, maxH int32 | 146 | maxW, maxH int32 | ||
| 144 | floating bool | 147 | 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.id | 247 | 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, | |||
| 66 | 66 | if _, w := c.screenOf(v.ID); w != nil { | |
| 67 | 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 | 73 | case ports.WindowUnmapped: | |
| 70 | 74 | if c.unmapClient(ctx, v) != nil { | |
| 71 | 75 | 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, true | 75 | return false, true | ||
M internal/core/layout.go +62 -0
| @@ -268,6 +268,9 @@ type Float struct { | |||
| 268 | 268 | // over is the parent of a dialog: while the parent covers the output | |
| 269 | 269 | // fullscreen, the dialog shows over it and may take the focus. | |
| 270 | 270 | 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 | 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 | 475 | // coverDialog reports whether f is a dialog of the covering fullscreen | |
| 419 | 476 | // window cover: the one float drawn over it. | |
| 420 | 477 | func (f Float) coverDialog(cover WindowID) bool { | |
| @@ -690,6 +747,8 @@ func (w *Workspace) FocusID(id WindowID) bool { | |||
| 690 | 747 | // fullscreen window it leaves fullscreen for the window there (leaveCover); | |
| 691 | 748 | // false means it stays, with no window on that side. | |
| 692 | 749 | func (w *Workspace) FocusColumn(dir int) bool { | |
| 750 | + | from, _ := w.Focused() | |
| 751 | + | defer w.focusModal(from) | |
| 693 | 752 | if w.coverDialogFocused() { | |
| 694 | 753 | // The first move goes back to the fullscreen window under it. | |
| 695 | 754 | w.floatFocus = false | |
| @@ -754,6 +813,8 @@ func (w *Workspace) onScreenFocus() bool { | |||
| 754 | 813 | // float; false means no window is available in that direction. From a | |
| 755 | 814 | // covering fullscreen window it leaves fullscreen for that window. | |
| 756 | 815 | func (w *Workspace) FocusWindow(dir int) bool { | |
| 816 | + | from, _ := w.Focused() | |
| 817 | + | defer w.focusModal(from) | |
| 757 | 818 | if w.coverDialogFocused() { | |
| 758 | 819 | w.floatFocus = false | |
| 759 | 820 | return true | |
| @@ -1155,6 +1216,7 @@ func (w *Workspace) Activate(id WindowID) { | |||
| 1155 | 1216 | w.leaveFullscreen() | |
| 1156 | 1217 | } | |
| 1157 | 1218 | w.FocusID(id) | |
| 1219 | + | w.focusModal(0) | |
| 1158 | 1220 | } | |
| 1159 | 1221 | ||
| 1160 | 1222 | // 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 output | 268 | // 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 WindowID | 270 | 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 fullscreen | 475 | // 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 = false | 754 | w.floatFocus = false | ||
| @@ -754,6 +813,8 @@ func (w *Workspace) onScreenFocus() bool { | |||||
| 754 | // float; false means no window is available in that direction. From a | 813 | // 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 = false | 819 | w.floatFocus = false | ||
| 759 | return true | 820 | 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: client | 1222 | // 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) { | |||
| 227 | 227 | ||
| 228 | 228 | // apply commits the selected front card without changing other stack items. | |
| 229 | 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 | 233 | switch item.kind { | |
| 231 | 234 | case stackFloat: | |
| 232 | 235 | 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) { | |||
| 81 | 81 | // A dialog opens on its parent's workspace, over it, fullscreen too. | |
| 82 | 82 | if ps, pw := c.screenOf(v.Parent); v.Parent != 0 && pw != nil { | |
| 83 | 83 | pw.AddDialog(v.ID, v.Parent, v.Width, v.Height) | |
| 84 | + | pw.SetModal(v.ID, v.Modal) | |
| 84 | 85 | ps.mon.normalize() | |
| 85 | 86 | return | |
| 86 | 87 | } | |
| @@ -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 | return | 86 | return | ||
| 86 | } | 87 | } | ||
M internal/core/stash.go +1 -0
| @@ -216,6 +216,7 @@ func (w *Workspace) Click(id WindowID) { | |||
| 216 | 216 | w.stashHidden = true | |
| 217 | 217 | } | |
| 218 | 218 | w.FocusID(id) | |
| 219 | + | w.focusModal(0) | |
| 219 | 220 | } | |
| 220 | 221 | ||
| 221 | 222 | // stashRect is where the selected stashed window is, border included. | |
| @@ -216,6 +216,7 @@ func (w *Workspace) Click(id WindowID) { | |||||
| 216 | w.stashHidden = true | 216 | 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 { | |||
| 23 | 23 | // Remap is set when the toplevel mapped before and was unmapped (a tray | |
| 24 | 24 | // app restored): window rules only place its first map. | |
| 25 | 25 | 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 | 31 | // SlotEnv is the environment variable neferwl sets on processes it spawns | |
| @@ -129,6 +132,14 @@ type WindowParent struct { | |||
| 129 | 132 | ||
| 130 | 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 | 143 | // WindowResized carries wayland → core the new size of a floating window. | |
| 133 | 144 | type WindowResized struct { | |
| 134 | 145 | ID WindowID | |
| @@ -23,6 +23,9 @@ type WindowMapped struct { | |||||
| 23 | // Remap is set when the toplevel mapped before and was unmapped (a tray | 23 | // 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 bool | 25 | 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 spawns | 31 | // 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 WindowID | 145 | ID WindowID | ||