detached ae24f44

feat(wayland): serve xdg-foreign v2 and show a fullscreen window's dialogs over it

Portal dialogs import the requesting window through zxdg_importer_v2 and float over it. A dialog of the covering fullscreen window shows with the focus; binds act on it, and a focus move returns to the window without leaving fullscreen. Parent changes after map reach core (WindowParent).
commit
ae24f44f6809643fb2562c8793179d4dc0e9e33f
author
brice · · 2026-10-03 06:50 UTC
parent
adf81e6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +645 -16
M docs/config.md +2 -2
@@ -124,7 +124,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
124124 | `spawn <command>` | Run a program |
125125 | `spawn-terminal` | Run `terminal` |
126126 | `close-window` | Close the focused window |
127-| `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; a window opening meanwhile waits hidden too. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |
127+| `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows wait hidden until it leaves; a window opening meanwhile waits hidden too. The window's own dialogs are the exception: a dialog it opens (a file chooser, a screen-sharing or permission request from the desktop portal) shows over it with the focus, and a focus move goes back to the fullscreen window without leaving fullscreen. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |
128128 | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |
129129 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |
130130 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window |
@@ -154,7 +154,7 @@ Each workspace has a stash: a horizontal strip of windows set aside with `toggle
154154
155155 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible` to get back to the tiles. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).
156156
157-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.
157+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.
158158
159159 ### Free floating windows
160160
@@ -124,7 +124,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
124 | `spawn <command>` | Run a program |124 | `spawn <command>` | Run a program |
125 | `spawn-terminal` | Run `terminal` |125 | `spawn-terminal` | Run `terminal` |
126 | `close-window` | Close the focused window |126 | `close-window` | Close the focused window |
127-| `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; a window opening meanwhile waits hidden too. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |127+| `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows wait hidden until it leaves; a window opening meanwhile waits hidden too. The window's own dialogs are the exception: a dialog it opens (a file chooser, a screen-sharing or permission request from the desktop portal) shows over it with the focus, and a focus move goes back to the fullscreen window without leaving fullscreen. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |
128 | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |128 | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |
129 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |129 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |
130 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window |130 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window |
@@ -154,7 +154,7 @@ Each workspace has a stash: a horizontal strip of windows set aside with `toggle
154 154
155 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible` to get back to the tiles. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).155 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible` to get back to the tiles. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).
156 156
157-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.157+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.
158 158
159 ### Free floating windows159 ### Free floating windows
160 160
A internal/adapters/wayland/foreign.go +159 -0
@@ -0,0 +1,159 @@
1+package wayland
2+
3+import (
4+ "crypto/rand"
5+ "slices"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/go-wayland-bindings/server/xdgforeign"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// xdg-foreign-unstable-v2 lets a client make its toplevel the child of
13+// another client's toplevel. The desktop portal uses it: the application
14+// exports its window and passes the handle over D-Bus, the portal backend
15+// imports it and parents its dialog to it, so the dialog floats over the
16+// application that asked (window.parent) instead of opening as a new tile.
17+
18+// foreignExport is one zxdg_exported_v2: the handle of a toplevel.
19+type foreignExport struct {
20+ handle string
21+ window *window
22+ imports []*foreignImport
23+}
24+
25+// foreignImport is one zxdg_imported_v2 and the toplevels it parented.
26+type foreignImport struct {
27+ res *xdgforeign.ZxdgImportedV2
28+ export *foreignExport // nil once invalid
29+ children []*window
30+}
31+
32+func registerForeign(d *server.Display, s *Server) error {
33+ if err := xdgforeign.NewZxdgExporterV2Global(d, 1, func(c server.Client, v, id uint32) {
34+ _, _ = xdgforeign.NewZxdgExporterV2(c, int32(v), id, foreignExporter{s})
35+ }); err != nil {
36+ return err
37+ }
38+ return xdgforeign.NewZxdgImporterV2Global(d, 1, func(c server.Client, v, id uint32) {
39+ _, _ = xdgforeign.NewZxdgImporterV2(c, int32(v), id, foreignImporter{s})
40+ })
41+}
42+
43+// toplevelOf is the xdg_toplevel window of a wl_surface, or nil.
44+func (s *Server) toplevelOf(surf *wayland.Surface) *window {
45+ st := s.surfaceOf(surf)
46+ if st == nil || st.xdg == nil || st.xdg.window == nil || st.xdg.window.toplevel == nil {
47+ return nil
48+ }
49+ return st.xdg.window
50+}
51+
52+type foreignExporter struct{ s *Server }
53+
54+func (foreignExporter) Destroy(*xdgforeign.ZxdgExporterV2) {}
55+
56+func (e foreignExporter) ExportToplevel(r *xdgforeign.ZxdgExporterV2, id uint32, surf *wayland.Surface) {
57+ w := e.s.toplevelOf(surf)
58+ if w == nil {
59+ r.PostError(uint32(xdgforeign.ZxdgExporterV2ErrorInvalidSurface), "surface is not an xdg_toplevel")
60+ return
61+ }
62+ ex := &foreignExport{handle: rand.Text(), window: w}
63+ res, err := xdgforeign.NewZxdgExportedV2(r.Client(), r.Version(), id, exportedHandler{})
64+ if err != nil {
65+ return
66+ }
67+ e.s.foreignExports[ex.handle] = ex
68+ res.OnDestroy = func() { e.s.revokeExport(ex) }
69+ res.SendHandle(ex.handle)
70+}
71+
72+type exportedHandler struct{}
73+
74+func (exportedHandler) Destroy(*xdgforeign.ZxdgExportedV2) {}
75+
76+type foreignImporter struct{ s *Server }
77+
78+func (foreignImporter) Destroy(*xdgforeign.ZxdgImporterV2) {}
79+
80+// ImportToplevel imports a handle; an unknown one is destroyed at once, as
81+// the protocol asks.
82+func (i foreignImporter) ImportToplevel(r *xdgforeign.ZxdgImporterV2, id uint32, handle string) {
83+ imp := &foreignImport{}
84+ res, err := xdgforeign.NewZxdgImportedV2(r.Client(), r.Version(), id, importedHandler{s: i.s, imp: imp})
85+ if err != nil {
86+ return
87+ }
88+ imp.res = res
89+ res.OnDestroy = func() { imp.invalidate(false) }
90+ ex := i.s.foreignExports[handle]
91+ if ex == nil {
92+ res.SendDestroyed()
93+ return
94+ }
95+ imp.export = ex
96+ ex.imports = append(ex.imports, imp)
97+}
98+
99+type importedHandler struct {
100+ s *Server
101+ imp *foreignImport
102+}
103+
104+func (importedHandler) Destroy(*xdgforeign.ZxdgImportedV2) {}
105+
106+// SetParentOf makes the imported toplevel the parent of the client's own
107+// toplevel, as xdg_toplevel.set_parent.
108+func (h importedHandler) SetParentOf(r *xdgforeign.ZxdgImportedV2, surf *wayland.Surface) {
109+ child := h.s.toplevelOf(surf)
110+ if child == nil || child.toplevel.Client() != r.Client() {
111+ r.PostError(uint32(xdgforeign.ZxdgImportedV2ErrorInvalidSurface), "surface is not an xdg_toplevel of this client")
112+ return
113+ }
114+ ex := h.imp.export
115+ if ex == nil || ex.window == child {
116+ return
117+ }
118+ child.setParent(ex.window)
119+ if !slices.Contains(h.imp.children, child) {
120+ h.imp.children = append(h.imp.children, child)
121+ }
122+}
123+
124+// invalidate ends the relationships an import set up. send tells the client
125+// (destroyed) when the export, not the import, went away.
126+func (imp *foreignImport) invalidate(send bool) {
127+ for _, c := range imp.children {
128+ if imp.export != nil && c.parent == imp.export.window {
129+ c.setParent(nil)
130+ }
131+ }
132+ imp.children = nil
133+ if ex := imp.export; ex != nil {
134+ ex.imports = removeItem(ex.imports, imp)
135+ imp.export = nil
136+ }
137+ if send && imp.res.Alive() {
138+ imp.res.SendDestroyed()
139+ }
140+}
141+
142+// revokeExport drops a handle and every relationship made with it.
143+func (s *Server) revokeExport(ex *foreignExport) {
144+ if s.foreignExports[ex.handle] == ex {
145+ delete(s.foreignExports, ex.handle)
146+ }
147+ for len(ex.imports) > 0 {
148+ ex.imports[0].invalidate(true)
149+ }
150+}
151+
152+// foreignToplevelGone revokes the handles of a destroyed toplevel.
153+func (s *Server) foreignToplevelGone(w *window) {
154+ for _, ex := range s.foreignExports {
155+ if ex.window == w {
156+ s.revokeExport(ex)
157+ }
158+ }
159+}
@@ -0,0 +1,159 @@
1+package wayland
2+
3+import (
4+ "crypto/rand"
5+ "slices"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/go-wayland-bindings/server/xdgforeign"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// xdg-foreign-unstable-v2 lets a client make its toplevel the child of
13+// another client's toplevel. The desktop portal uses it: the application
14+// exports its window and passes the handle over D-Bus, the portal backend
15+// imports it and parents its dialog to it, so the dialog floats over the
16+// application that asked (window.parent) instead of opening as a new tile.
17+
18+// foreignExport is one zxdg_exported_v2: the handle of a toplevel.
19+type foreignExport struct {
20+ handle string
21+ window *window
22+ imports []*foreignImport
23+}
24+
25+// foreignImport is one zxdg_imported_v2 and the toplevels it parented.
26+type foreignImport struct {
27+ res *xdgforeign.ZxdgImportedV2
28+ export *foreignExport // nil once invalid
29+ children []*window
30+}
31+
32+func registerForeign(d *server.Display, s *Server) error {
33+ if err := xdgforeign.NewZxdgExporterV2Global(d, 1, func(c server.Client, v, id uint32) {
34+ _, _ = xdgforeign.NewZxdgExporterV2(c, int32(v), id, foreignExporter{s})
35+ }); err != nil {
36+ return err
37+ }
38+ return xdgforeign.NewZxdgImporterV2Global(d, 1, func(c server.Client, v, id uint32) {
39+ _, _ = xdgforeign.NewZxdgImporterV2(c, int32(v), id, foreignImporter{s})
40+ })
41+}
42+
43+// toplevelOf is the xdg_toplevel window of a wl_surface, or nil.
44+func (s *Server) toplevelOf(surf *wayland.Surface) *window {
45+ st := s.surfaceOf(surf)
46+ if st == nil || st.xdg == nil || st.xdg.window == nil || st.xdg.window.toplevel == nil {
47+ return nil
48+ }
49+ return st.xdg.window
50+}
51+
52+type foreignExporter struct{ s *Server }
53+
54+func (foreignExporter) Destroy(*xdgforeign.ZxdgExporterV2) {}
55+
56+func (e foreignExporter) ExportToplevel(r *xdgforeign.ZxdgExporterV2, id uint32, surf *wayland.Surface) {
57+ w := e.s.toplevelOf(surf)
58+ if w == nil {
59+ r.PostError(uint32(xdgforeign.ZxdgExporterV2ErrorInvalidSurface), "surface is not an xdg_toplevel")
60+ return
61+ }
62+ ex := &foreignExport{handle: rand.Text(), window: w}
63+ res, err := xdgforeign.NewZxdgExportedV2(r.Client(), r.Version(), id, exportedHandler{})
64+ if err != nil {
65+ return
66+ }
67+ e.s.foreignExports[ex.handle] = ex
68+ res.OnDestroy = func() { e.s.revokeExport(ex) }
69+ res.SendHandle(ex.handle)
70+}
71+
72+type exportedHandler struct{}
73+
74+func (exportedHandler) Destroy(*xdgforeign.ZxdgExportedV2) {}
75+
76+type foreignImporter struct{ s *Server }
77+
78+func (foreignImporter) Destroy(*xdgforeign.ZxdgImporterV2) {}
79+
80+// ImportToplevel imports a handle; an unknown one is destroyed at once, as
81+// the protocol asks.
82+func (i foreignImporter) ImportToplevel(r *xdgforeign.ZxdgImporterV2, id uint32, handle string) {
83+ imp := &foreignImport{}
84+ res, err := xdgforeign.NewZxdgImportedV2(r.Client(), r.Version(), id, importedHandler{s: i.s, imp: imp})
85+ if err != nil {
86+ return
87+ }
88+ imp.res = res
89+ res.OnDestroy = func() { imp.invalidate(false) }
90+ ex := i.s.foreignExports[handle]
91+ if ex == nil {
92+ res.SendDestroyed()
93+ return
94+ }
95+ imp.export = ex
96+ ex.imports = append(ex.imports, imp)
97+}
98+
99+type importedHandler struct {
100+ s *Server
101+ imp *foreignImport
102+}
103+
104+func (importedHandler) Destroy(*xdgforeign.ZxdgImportedV2) {}
105+
106+// SetParentOf makes the imported toplevel the parent of the client's own
107+// toplevel, as xdg_toplevel.set_parent.
108+func (h importedHandler) SetParentOf(r *xdgforeign.ZxdgImportedV2, surf *wayland.Surface) {
109+ child := h.s.toplevelOf(surf)
110+ if child == nil || child.toplevel.Client() != r.Client() {
111+ r.PostError(uint32(xdgforeign.ZxdgImportedV2ErrorInvalidSurface), "surface is not an xdg_toplevel of this client")
112+ return
113+ }
114+ ex := h.imp.export
115+ if ex == nil || ex.window == child {
116+ return
117+ }
118+ child.setParent(ex.window)
119+ if !slices.Contains(h.imp.children, child) {
120+ h.imp.children = append(h.imp.children, child)
121+ }
122+}
123+
124+// invalidate ends the relationships an import set up. send tells the client
125+// (destroyed) when the export, not the import, went away.
126+func (imp *foreignImport) invalidate(send bool) {
127+ for _, c := range imp.children {
128+ if imp.export != nil && c.parent == imp.export.window {
129+ c.setParent(nil)
130+ }
131+ }
132+ imp.children = nil
133+ if ex := imp.export; ex != nil {
134+ ex.imports = removeItem(ex.imports, imp)
135+ imp.export = nil
136+ }
137+ if send && imp.res.Alive() {
138+ imp.res.SendDestroyed()
139+ }
140+}
141+
142+// revokeExport drops a handle and every relationship made with it.
143+func (s *Server) revokeExport(ex *foreignExport) {
144+ if s.foreignExports[ex.handle] == ex {
145+ delete(s.foreignExports, ex.handle)
146+ }
147+ for len(ex.imports) > 0 {
148+ ex.imports[0].invalidate(true)
149+ }
150+}
151+
152+// foreignToplevelGone revokes the handles of a destroyed toplevel.
153+func (s *Server) foreignToplevelGone(w *window) {
154+ for _, ex := range s.foreignExports {
155+ if ex.window == w {
156+ s.revokeExport(ex)
157+ }
158+ }
159+}
A internal/adapters/wayland/foreign_test.go +205 -0
@@ -0,0 +1,205 @@
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/xdgforeign"
9+ xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/bnema/wlturbo"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// handleProxy receives zxdg_exported_v2.handle.
16+type handleProxy struct {
17+ wlturbo.BaseProxy
18+ handles chan string
19+}
20+
21+func (p *handleProxy) Dispatch(e *wlturbo.Event) { p.handles <- e.String() }
22+
23+// destroyedProxy receives zxdg_imported_v2.destroyed.
24+type destroyedProxy struct {
25+ wlturbo.BaseProxy
26+ destroyed chan struct{}
27+}
28+
29+func (p *destroyedProxy) Dispatch(*wlturbo.Event) { p.destroyed <- struct{}{} }
30+
31+// exportToplevel exports surf and returns the exported object and handle.
32+func exportToplevel(t *testing.T, c *wlturbo.Display, surf uint32) (uint32, string) {
33+ t.Helper()
34+ exporter := bindProtocol(t, c, "zxdg_exporter_v2")
35+ id := c.AllocateID()
36+ p := &handleProxy{handles: make(chan string, 1)}
37+ p.SetID(id)
38+ registerWireProxy(c, p)
39+ requestProtocol(t, c, exporter, xdgforeign.ZxdgExporterV2RequestExportToplevel, id, surf)
40+ if err := c.Roundtrip(); err != nil {
41+ t.Fatal(err)
42+ }
43+ select {
44+ case h := <-p.handles:
45+ return id, h
46+ default:
47+ t.Fatal("no handle")
48+ return 0, ""
49+ }
50+}
51+
52+// importToplevel imports handle; the proxy reports destroyed.
53+func importToplevel(t *testing.T, c *wlturbo.Display, handle string) (uint32, *destroyedProxy) {
54+ t.Helper()
55+ importer := bindProtocol(t, c, "zxdg_importer_v2")
56+ id := c.AllocateID()
57+ p := &destroyedProxy{destroyed: make(chan struct{}, 1)}
58+ p.SetID(id)
59+ registerWireProxy(c, p)
60+ requestProtocol(t, c, importer, xdgforeign.ZxdgImporterV2RequestImportToplevel, id, handle)
61+ if err := c.Roundtrip(); err != nil {
62+ t.Fatal(err)
63+ }
64+ return id, p
65+}
66+
67+// childToplevel maps a toplevel of c whose parent is set by parent, called
68+// before its first buffer (as a portal dialog does).
69+func childToplevel(t *testing.T, c *wlturbo.Display, parent func(surf uint32)) uint32 {
70+ t.Helper()
71+ comp := bindProtocol(t, c, "wl_compositor")
72+ wm := bindProtocol(t, c, "xdg_wm_base")
73+ shm := bindProtocol(t, c, "wl_shm")
74+ registerProtocol(t, c, shm)
75+ fd, err := unix.MemfdCreate("dialog-buffer", 0)
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ defer unix.Close(fd)
80+ if err := unix.Ftruncate(fd, 4); err != nil {
81+ t.Fatal(err)
82+ }
83+ pool, buffer := c.AllocateID(), c.AllocateID()
84+ registerProtocol(t, c, pool)
85+ registerProtocol(t, c, buffer)
86+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
87+ t.Fatal(err)
88+ }
89+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
90+ surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
91+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
92+ registerProtocol(t, c, surf)
93+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
94+ serials := make(chan uint32, 1)
95+ xp := &configureProxy{serial: serials}
96+ xp.SetID(xdg)
97+ registerWireProxy(c, xp)
98+ registerProtocol(t, c, top)
99+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
100+ parent(surf)
101+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
102+ if err := c.Roundtrip(); err != nil {
103+ t.Fatal(err)
104+ }
105+ select {
106+ case serial := <-serials:
107+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
108+ default:
109+ t.Fatal("missing configure")
110+ }
111+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
112+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
113+ if err := c.Roundtrip(); err != nil {
114+ t.Fatal(err)
115+ }
116+ return surf
117+}
118+
119+// A portal backend parents its dialog to another client's window through
120+// xdg-foreign: the dialog maps floating, with that window as its parent.
121+func TestForeignParentsDialog(t *testing.T) {
122+ s, events, _, dir := lifecycleServer(t)
123+ app := protocolClient(t, s, dir)
124+ appWin, appSurf, _ := surfaceMapper(t, app, events)()
125+ _, handle := exportToplevel(t, app, appSurf)
126+
127+ portal := protocolClient(t, s, dir)
128+ imported, _ := importToplevel(t, portal, handle)
129+ childToplevel(t, portal, func(surf uint32) {
130+ requestProtocol(t, portal, imported, xdgforeign.ZxdgImportedV2RequestSetParentOf, surf)
131+ })
132+ dialog := mapped(t, events, 2*time.Second)
133+ if !dialog.Floating || dialog.Parent != appWin.ID {
134+ t.Fatalf("dialog %+v, want floating over %d", dialog, appWin.ID)
135+ }
136+
137+ // Destroying the import ends the relationship: core hears it.
138+ requestProtocol(t, portal, imported, xdgforeign.ZxdgImportedV2RequestDestroy)
139+ if err := portal.Roundtrip(); err != nil {
140+ t.Fatal(err)
141+ }
142+ for deadline := time.After(2 * time.Second); ; {
143+ select {
144+ case ev := <-events:
145+ if p, ok := ev.(ports.WindowParent); ok {
146+ if p.ID != dialog.ID || p.Parent != 0 {
147+ t.Fatalf("parent change %+v", p)
148+ }
149+ return
150+ }
151+ case <-deadline:
152+ t.Fatal("no parent change")
153+ }
154+ }
155+}
156+
157+// An unknown handle is destroyed at once; revoking an export destroys its
158+// imports and drops the parent they set.
159+func TestForeignInvalidHandles(t *testing.T) {
160+ s, events, _, dir := lifecycleServer(t)
161+ portal := protocolClient(t, s, dir)
162+ _, unknown := importToplevel(t, portal, "not-a-handle")
163+ select {
164+ case <-unknown.destroyed:
165+ default:
166+ t.Fatal("unknown handle not destroyed")
167+ }
168+
169+ app := protocolClient(t, s, dir)
170+ _, appSurf, _ := surfaceMapper(t, app, events)()
171+ exported, handle := exportToplevel(t, app, appSurf)
172+ imported, p := importToplevel(t, portal, handle)
173+ requestProtocol(t, app, exported, xdgforeign.ZxdgExportedV2RequestDestroy)
174+ if err := app.Roundtrip(); err != nil {
175+ t.Fatal(err)
176+ }
177+ if err := portal.Roundtrip(); err != nil {
178+ t.Fatal(err)
179+ }
180+ select {
181+ case <-p.destroyed:
182+ default:
183+ t.Fatal("import not destroyed with its export")
184+ }
185+ childToplevel(t, portal, func(surf uint32) {
186+ requestProtocol(t, portal, imported, xdgforeign.ZxdgImportedV2RequestSetParentOf, surf)
187+ })
188+ if dialog := mapped(t, events, 2*time.Second); dialog.Floating || dialog.Parent != 0 {
189+ t.Fatalf("revoked parent still applied: %+v", dialog)
190+ }
191+}
192+
193+// Only toplevels can be exported.
194+func TestForeignExportNeedsToplevel(t *testing.T) {
195+ s, _, _, dir := lifecycleServer(t)
196+ c := protocolClient(t, s, dir)
197+ comp := bindProtocol(t, c, "wl_compositor")
198+ surf := c.AllocateID()
199+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
200+ exporter := bindProtocol(t, c, "zxdg_exporter_v2")
201+ id := c.AllocateID()
202+ registerProtocol(t, c, id)
203+ requestProtocol(t, c, exporter, xdgforeign.ZxdgExporterV2RequestExportToplevel, id, surf)
204+ expectProtocolError(t, c, exporter, uint32(xdgforeign.ZxdgExporterV2ErrorInvalidSurface))
205+}
@@ -0,0 +1,205 @@
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/xdgforeign"
9+ xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/bnema/wlturbo"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// handleProxy receives zxdg_exported_v2.handle.
16+type handleProxy struct {
17+ wlturbo.BaseProxy
18+ handles chan string
19+}
20+
21+func (p *handleProxy) Dispatch(e *wlturbo.Event) { p.handles <- e.String() }
22+
23+// destroyedProxy receives zxdg_imported_v2.destroyed.
24+type destroyedProxy struct {
25+ wlturbo.BaseProxy
26+ destroyed chan struct{}
27+}
28+
29+func (p *destroyedProxy) Dispatch(*wlturbo.Event) { p.destroyed <- struct{}{} }
30+
31+// exportToplevel exports surf and returns the exported object and handle.
32+func exportToplevel(t *testing.T, c *wlturbo.Display, surf uint32) (uint32, string) {
33+ t.Helper()
34+ exporter := bindProtocol(t, c, "zxdg_exporter_v2")
35+ id := c.AllocateID()
36+ p := &handleProxy{handles: make(chan string, 1)}
37+ p.SetID(id)
38+ registerWireProxy(c, p)
39+ requestProtocol(t, c, exporter, xdgforeign.ZxdgExporterV2RequestExportToplevel, id, surf)
40+ if err := c.Roundtrip(); err != nil {
41+ t.Fatal(err)
42+ }
43+ select {
44+ case h := <-p.handles:
45+ return id, h
46+ default:
47+ t.Fatal("no handle")
48+ return 0, ""
49+ }
50+}
51+
52+// importToplevel imports handle; the proxy reports destroyed.
53+func importToplevel(t *testing.T, c *wlturbo.Display, handle string) (uint32, *destroyedProxy) {
54+ t.Helper()
55+ importer := bindProtocol(t, c, "zxdg_importer_v2")
56+ id := c.AllocateID()
57+ p := &destroyedProxy{destroyed: make(chan struct{}, 1)}
58+ p.SetID(id)
59+ registerWireProxy(c, p)
60+ requestProtocol(t, c, importer, xdgforeign.ZxdgImporterV2RequestImportToplevel, id, handle)
61+ if err := c.Roundtrip(); err != nil {
62+ t.Fatal(err)
63+ }
64+ return id, p
65+}
66+
67+// childToplevel maps a toplevel of c whose parent is set by parent, called
68+// before its first buffer (as a portal dialog does).
69+func childToplevel(t *testing.T, c *wlturbo.Display, parent func(surf uint32)) uint32 {
70+ t.Helper()
71+ comp := bindProtocol(t, c, "wl_compositor")
72+ wm := bindProtocol(t, c, "xdg_wm_base")
73+ shm := bindProtocol(t, c, "wl_shm")
74+ registerProtocol(t, c, shm)
75+ fd, err := unix.MemfdCreate("dialog-buffer", 0)
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ defer unix.Close(fd)
80+ if err := unix.Ftruncate(fd, 4); err != nil {
81+ t.Fatal(err)
82+ }
83+ pool, buffer := c.AllocateID(), c.AllocateID()
84+ registerProtocol(t, c, pool)
85+ registerProtocol(t, c, buffer)
86+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
87+ t.Fatal(err)
88+ }
89+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
90+ surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
91+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
92+ registerProtocol(t, c, surf)
93+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
94+ serials := make(chan uint32, 1)
95+ xp := &configureProxy{serial: serials}
96+ xp.SetID(xdg)
97+ registerWireProxy(c, xp)
98+ registerProtocol(t, c, top)
99+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
100+ parent(surf)
101+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
102+ if err := c.Roundtrip(); err != nil {
103+ t.Fatal(err)
104+ }
105+ select {
106+ case serial := <-serials:
107+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
108+ default:
109+ t.Fatal("missing configure")
110+ }
111+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
112+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
113+ if err := c.Roundtrip(); err != nil {
114+ t.Fatal(err)
115+ }
116+ return surf
117+}
118+
119+// A portal backend parents its dialog to another client's window through
120+// xdg-foreign: the dialog maps floating, with that window as its parent.
121+func TestForeignParentsDialog(t *testing.T) {
122+ s, events, _, dir := lifecycleServer(t)
123+ app := protocolClient(t, s, dir)
124+ appWin, appSurf, _ := surfaceMapper(t, app, events)()
125+ _, handle := exportToplevel(t, app, appSurf)
126+
127+ portal := protocolClient(t, s, dir)
128+ imported, _ := importToplevel(t, portal, handle)
129+ childToplevel(t, portal, func(surf uint32) {
130+ requestProtocol(t, portal, imported, xdgforeign.ZxdgImportedV2RequestSetParentOf, surf)
131+ })
132+ dialog := mapped(t, events, 2*time.Second)
133+ if !dialog.Floating || dialog.Parent != appWin.ID {
134+ t.Fatalf("dialog %+v, want floating over %d", dialog, appWin.ID)
135+ }
136+
137+ // Destroying the import ends the relationship: core hears it.
138+ requestProtocol(t, portal, imported, xdgforeign.ZxdgImportedV2RequestDestroy)
139+ if err := portal.Roundtrip(); err != nil {
140+ t.Fatal(err)
141+ }
142+ for deadline := time.After(2 * time.Second); ; {
143+ select {
144+ case ev := <-events:
145+ if p, ok := ev.(ports.WindowParent); ok {
146+ if p.ID != dialog.ID || p.Parent != 0 {
147+ t.Fatalf("parent change %+v", p)
148+ }
149+ return
150+ }
151+ case <-deadline:
152+ t.Fatal("no parent change")
153+ }
154+ }
155+}
156+
157+// An unknown handle is destroyed at once; revoking an export destroys its
158+// imports and drops the parent they set.
159+func TestForeignInvalidHandles(t *testing.T) {
160+ s, events, _, dir := lifecycleServer(t)
161+ portal := protocolClient(t, s, dir)
162+ _, unknown := importToplevel(t, portal, "not-a-handle")
163+ select {
164+ case <-unknown.destroyed:
165+ default:
166+ t.Fatal("unknown handle not destroyed")
167+ }
168+
169+ app := protocolClient(t, s, dir)
170+ _, appSurf, _ := surfaceMapper(t, app, events)()
171+ exported, handle := exportToplevel(t, app, appSurf)
172+ imported, p := importToplevel(t, portal, handle)
173+ requestProtocol(t, app, exported, xdgforeign.ZxdgExportedV2RequestDestroy)
174+ if err := app.Roundtrip(); err != nil {
175+ t.Fatal(err)
176+ }
177+ if err := portal.Roundtrip(); err != nil {
178+ t.Fatal(err)
179+ }
180+ select {
181+ case <-p.destroyed:
182+ default:
183+ t.Fatal("import not destroyed with its export")
184+ }
185+ childToplevel(t, portal, func(surf uint32) {
186+ requestProtocol(t, portal, imported, xdgforeign.ZxdgImportedV2RequestSetParentOf, surf)
187+ })
188+ if dialog := mapped(t, events, 2*time.Second); dialog.Floating || dialog.Parent != 0 {
189+ t.Fatalf("revoked parent still applied: %+v", dialog)
190+ }
191+}
192+
193+// Only toplevels can be exported.
194+func TestForeignExportNeedsToplevel(t *testing.T) {
195+ s, _, _, dir := lifecycleServer(t)
196+ c := protocolClient(t, s, dir)
197+ comp := bindProtocol(t, c, "wl_compositor")
198+ surf := c.AllocateID()
199+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
200+ exporter := bindProtocol(t, c, "zxdg_exporter_v2")
201+ id := c.AllocateID()
202+ registerProtocol(t, c, id)
203+ requestProtocol(t, c, exporter, xdgforeign.ZxdgExporterV2RequestExportToplevel, id, surf)
204+ expectProtocolError(t, c, exporter, uint32(xdgforeign.ZxdgExporterV2ErrorInvalidSurface))
205+}
M internal/adapters/wayland/globals.go +1 -0
@@ -42,6 +42,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
4242 func() error { return registerExclusion(d, s) },
4343 func() error { return registerSecurityContext(d, s) },
4444 func() error { return registerActivation(d, s) },
45+ func() error { return registerForeign(d, s) },
4546 func() error { return registerVirtualKeyboard(d, s) },
4647 func() error { return registerTextInput(d, s) },
4748 func() error { return registerInputMethod(d, s) },
@@ -42,6 +42,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
42 func() error { return registerExclusion(d, s) },42 func() error { return registerExclusion(d, s) },
43 func() error { return registerSecurityContext(d, s) },43 func() error { return registerSecurityContext(d, s) },
44 func() error { return registerActivation(d, s) },44 func() error { return registerActivation(d, s) },
45+ func() error { return registerForeign(d, s) },
45 func() error { return registerVirtualKeyboard(d, s) },46 func() error { return registerVirtualKeyboard(d, s) },
46 func() error { return registerTextInput(d, s) },47 func() error { return registerTextInput(d, s) },
47 func() error { return registerInputMethod(d, s) },48 func() error { return registerInputMethod(d, s) },
M internal/adapters/wayland/server.go +4 -1
@@ -171,7 +171,9 @@ type Server struct {
171171 lastFlip map[string]time.Time
172172 // tokens are the issued xdg-activation tokens (activation.go).
173173 tokens map[string]activationToken
174- surfaces map[*server.Resource]*surface
174+ // foreignExports are the live xdg-foreign handles (foreign.go).
175+ foreignExports map[string]*foreignExport
176+ surfaces map[*server.Resource]*surface
175177 buffers map[*server.Resource]clientBuffer
176178 dmabuf *dmabufGlobal
177179 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
@@ -391,6 +393,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
391393 s.colorOutputs = map[*colorOutput]struct{}{}
392394 s.colorFeedbacks = map[*colorFeedback]struct{}{}
393395 s.tokens = map[string]activationToken{}
396+ s.foreignExports = map[string]*foreignExport{}
394397 if opts.Keymap != "" {
395398 fd, size, e := keymapFile(opts.Keymap)
396399 if e != nil {
@@ -171,7 +171,9 @@ type Server struct {
171 lastFlip map[string]time.Time171 lastFlip map[string]time.Time
172 // tokens are the issued xdg-activation tokens (activation.go).172 // tokens are the issued xdg-activation tokens (activation.go).
173 tokens map[string]activationToken173 tokens map[string]activationToken
174- surfaces map[*server.Resource]*surface174+ // foreignExports are the live xdg-foreign handles (foreign.go).
175+ foreignExports map[string]*foreignExport
176+ surfaces map[*server.Resource]*surface
175 buffers map[*server.Resource]clientBuffer177 buffers map[*server.Resource]clientBuffer
176 dmabuf *dmabufGlobal178 dmabuf *dmabufGlobal
177 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.179 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
@@ -391,6 +393,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
391 s.colorOutputs = map[*colorOutput]struct{}{}393 s.colorOutputs = map[*colorOutput]struct{}{}
392 s.colorFeedbacks = map[*colorFeedback]struct{}{}394 s.colorFeedbacks = map[*colorFeedback]struct{}{}
393 s.tokens = map[string]activationToken{}395 s.tokens = map[string]activationToken{}
396+ s.foreignExports = map[string]*foreignExport{}
394 if opts.Keymap != "" {397 if opts.Keymap != "" {
395 fd, size, e := keymapFile(opts.Keymap)398 fd, size, e := keymapFile(opts.Keymap)
396 if e != nil {399 if e != nil {
M internal/adapters/wayland/wire_client_test.go +5 -0
@@ -47,6 +47,7 @@ import (
4747 wire_wlroutputpower "github.com/bnema/go-wayland-bindings/server/wlroutputpowermanagement"
4848 wire_wlrscreencopy "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"
4949 wire_xdgactivation "github.com/bnema/go-wayland-bindings/server/xdgactivation"
50+ wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"
5051 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"
5152 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
5253 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
@@ -189,6 +190,10 @@ var wireSchemas = map[string]wireSchema{
189190 "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil},
190191 "xdg_activation_token_v1": {wire_xdgactivation.ActivationTokenV1Interface, &client_xdgactivation.XdgActivationToken{}, map[uint16]bool{4: true}, nil},
191192 "xdg_activation_v1": {wire_xdgactivation.ActivationV1Interface, &client_xdgactivation.XdgActivation{}, map[uint16]bool{0: true}, nil},
193+ "zxdg_exporter_v2": {wire_xdgforeign.ZxdgExporterV2Interface, nil, map[uint16]bool{0: true}, nil},
194+ "zxdg_importer_v2": {wire_xdgforeign.ZxdgImporterV2Interface, nil, map[uint16]bool{0: true}, nil},
195+ "zxdg_exported_v2": {wire_xdgforeign.ZxdgExportedV2Interface, nil, map[uint16]bool{0: true}, nil},
196+ "zxdg_imported_v2": {wire_xdgforeign.ZxdgImportedV2Interface, nil, map[uint16]bool{0: true}, nil},
192197 "xdg_popup": {wire_xdgshell.PopupInterface, &client_xdgshell.XdgPopup{}, map[uint16]bool{0: true}, nil},
193198 "xdg_positioner": {wire_xdgshell.PositionerInterface, &client_xdgshell.XdgPositioner{}, map[uint16]bool{0: true}, nil},
194199 "xdg_surface": {wire_xdgshell.SurfaceInterface, &client_xdgshell.XdgSurface{}, map[uint16]bool{0: true}, nil},
@@ -47,6 +47,7 @@ import (
47 wire_wlroutputpower "github.com/bnema/go-wayland-bindings/server/wlroutputpowermanagement"47 wire_wlroutputpower "github.com/bnema/go-wayland-bindings/server/wlroutputpowermanagement"
48 wire_wlrscreencopy "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"48 wire_wlrscreencopy "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"
49 wire_xdgactivation "github.com/bnema/go-wayland-bindings/server/xdgactivation"49 wire_xdgactivation "github.com/bnema/go-wayland-bindings/server/xdgactivation"
50+ wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"
50 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"51 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"
51 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"52 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
52 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"53 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
@@ -189,6 +190,10 @@ var wireSchemas = map[string]wireSchema{
189 "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil},190 "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil},
190 "xdg_activation_token_v1": {wire_xdgactivation.ActivationTokenV1Interface, &client_xdgactivation.XdgActivationToken{}, map[uint16]bool{4: true}, nil},191 "xdg_activation_token_v1": {wire_xdgactivation.ActivationTokenV1Interface, &client_xdgactivation.XdgActivationToken{}, map[uint16]bool{4: true}, nil},
191 "xdg_activation_v1": {wire_xdgactivation.ActivationV1Interface, &client_xdgactivation.XdgActivation{}, map[uint16]bool{0: true}, nil},192 "xdg_activation_v1": {wire_xdgactivation.ActivationV1Interface, &client_xdgactivation.XdgActivation{}, map[uint16]bool{0: true}, nil},
193+ "zxdg_exporter_v2": {wire_xdgforeign.ZxdgExporterV2Interface, nil, map[uint16]bool{0: true}, nil},
194+ "zxdg_importer_v2": {wire_xdgforeign.ZxdgImporterV2Interface, nil, map[uint16]bool{0: true}, nil},
195+ "zxdg_exported_v2": {wire_xdgforeign.ZxdgExportedV2Interface, nil, map[uint16]bool{0: true}, nil},
196+ "zxdg_imported_v2": {wire_xdgforeign.ZxdgImportedV2Interface, nil, map[uint16]bool{0: true}, nil},
192 "xdg_popup": {wire_xdgshell.PopupInterface, &client_xdgshell.XdgPopup{}, map[uint16]bool{0: true}, nil},197 "xdg_popup": {wire_xdgshell.PopupInterface, &client_xdgshell.XdgPopup{}, map[uint16]bool{0: true}, nil},
193 "xdg_positioner": {wire_xdgshell.PositionerInterface, &client_xdgshell.XdgPositioner{}, map[uint16]bool{0: true}, nil},198 "xdg_positioner": {wire_xdgshell.PositionerInterface, &client_xdgshell.XdgPositioner{}, map[uint16]bool{0: true}, nil},
194 "xdg_surface": {wire_xdgshell.SurfaceInterface, &client_xdgshell.XdgSurface{}, map[uint16]bool{0: true}, nil},199 "xdg_surface": {wire_xdgshell.SurfaceInterface, &client_xdgshell.XdgSurface{}, map[uint16]bool{0: true}, nil},
M internal/adapters/wayland/xdg.go +29 -7
@@ -235,7 +235,11 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
235235 slot := x.server.slotToken(r.Client())
236236 // Its size comes with the commit, in WindowResized (afterCommit).
237237 w.floating = w.floats()
238- 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})
238+ var parent ports.WindowID
239+ if w.parent != nil && w.parent.mapped {
240+ parent = w.parent.id
241+ }
242+ 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})
239243 x.server.syncInhibitors()
240244 x.server.toplevelChanged(w)
241245 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
@@ -245,6 +249,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
245249 }
246250 t.OnDestroy = func() {
247251 w.unmap()
252+ x.server.foreignToplevelGone(w)
248253 x.surface.dropQueue()
249254 x.surface.current, x.surface.next.buffer = nil, nil
250255 x.surface.next.attached = false
@@ -294,15 +299,32 @@ type top struct{ w *window }
294299
295300 func (top) Destroy(*xdgshell.Toplevel) {}
296301 func (t top) SetParent(_ *xdgshell.Toplevel, parent *xdgshell.Toplevel) {
297- t.w.parent = nil
298- if parent == nil {
302+ var p *window
303+ if parent != nil {
304+ for _, w := range t.w.xdg.server.windows {
305+ if w.toplevel != nil && w.toplevel.Resource == parent.Resource && w != t.w {
306+ p = w
307+ }
308+ }
309+ }
310+ t.w.setParent(p)
311+}
312+
313+// setParent changes the dialog parent; core follows it once mapped (the
314+// map carries the parent of the first frame).
315+func (w *window) setParent(p *window) {
316+ if w.parent == p {
299317 return
300318 }
301- for _, w := range t.w.xdg.server.windows {
302- if w.toplevel != nil && w.toplevel.Resource == parent.Resource && w != t.w {
303- t.w.parent = w
304- }
319+ w.parent = p
320+ if !w.mapped {
321+ return
322+ }
323+ var id ports.WindowID
324+ if p != nil && p.mapped {
325+ id = p.id
305326 }
327+ w.xdg.server.emit(ports.WindowParent{ID: w.id, Parent: id})
306328 }
307329 func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {
308330 if title == t.w.title {
@@ -235,7 +235,11 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
235 slot := x.server.slotToken(r.Client())235 slot := x.server.slotToken(r.Client())
236 // Its size comes with the commit, in WindowResized (afterCommit).236 // Its size comes with the commit, in WindowResized (afterCommit).
237 w.floating = w.floats()237 w.floating = w.floats()
238- 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})238+ var parent ports.WindowID
239+ if w.parent != nil && w.parent.mapped {
240+ parent = w.parent.id
241+ }
242+ 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})
239 x.server.syncInhibitors()243 x.server.syncInhibitors()
240 x.server.toplevelChanged(w)244 x.server.toplevelChanged(w)
241 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")245 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
@@ -245,6 +249,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
245 }249 }
246 t.OnDestroy = func() {250 t.OnDestroy = func() {
247 w.unmap()251 w.unmap()
252+ x.server.foreignToplevelGone(w)
248 x.surface.dropQueue()253 x.surface.dropQueue()
249 x.surface.current, x.surface.next.buffer = nil, nil254 x.surface.current, x.surface.next.buffer = nil, nil
250 x.surface.next.attached = false255 x.surface.next.attached = false
@@ -294,15 +299,32 @@ type top struct{ w *window }
294 299
295 func (top) Destroy(*xdgshell.Toplevel) {}300 func (top) Destroy(*xdgshell.Toplevel) {}
296 func (t top) SetParent(_ *xdgshell.Toplevel, parent *xdgshell.Toplevel) {301 func (t top) SetParent(_ *xdgshell.Toplevel, parent *xdgshell.Toplevel) {
297- t.w.parent = nil302+ var p *window
298- if parent == nil {303+ if parent != nil {
304+ for _, w := range t.w.xdg.server.windows {
305+ if w.toplevel != nil && w.toplevel.Resource == parent.Resource && w != t.w {
306+ p = w
307+ }
308+ }
309+ }
310+ t.w.setParent(p)
311+}
312+
313+// setParent changes the dialog parent; core follows it once mapped (the
314+// map carries the parent of the first frame).
315+func (w *window) setParent(p *window) {
316+ if w.parent == p {
299 return317 return
300 }318 }
301- for _, w := range t.w.xdg.server.windows {319+ w.parent = p
302- if w.toplevel != nil && w.toplevel.Resource == parent.Resource && w != t.w {320+ if !w.mapped {
303- t.w.parent = w321+ return
304- }322+ }
323+ var id ports.WindowID
324+ if p != nil && p.mapped {
325+ id = p.id
305 }326 }
327+ w.xdg.server.emit(ports.WindowParent{ID: w.id, Parent: id})
306 }328 }
307 func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {329 func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {
308 if title == t.w.title {330 if title == t.w.title {
M internal/core/core.go +4 -0
@@ -1041,6 +1041,10 @@ func (c *Core) Run(ctx context.Context) error {
10411041 continue
10421042 case ports.WindowAppID:
10431043 c.windows.setAppID(v.ID, v.AppID)
1044+ case ports.WindowParent:
1045+ if _, w := c.screenOf(v.ID); w != nil {
1046+ w.SetDialogParent(v.ID, v.Parent)
1047+ }
10441048 case ports.WindowUnmapped:
10451049 if c.popups[v.ID] != nil {
10461050 if err := c.dropPopup(ctx, v.ID); err != nil {
@@ -1041,6 +1041,10 @@ func (c *Core) Run(ctx context.Context) error {
1041 continue1041 continue
1042 case ports.WindowAppID:1042 case ports.WindowAppID:
1043 c.windows.setAppID(v.ID, v.AppID)1043 c.windows.setAppID(v.ID, v.AppID)
1044+ case ports.WindowParent:
1045+ if _, w := c.screenOf(v.ID); w != nil {
1046+ w.SetDialogParent(v.ID, v.Parent)
1047+ }
1044 case ports.WindowUnmapped:1048 case ports.WindowUnmapped:
1045 if c.popups[v.ID] != nil {1049 if c.popups[v.ID] != nil {
1046 if err := c.dropPopup(ctx, v.ID); err != nil {1050 if err := c.dropPopup(ctx, v.ID); err != nil {
M internal/core/core_test.go +43 -0
@@ -797,6 +797,49 @@ func TestFloatOverFullscreenHit(t *testing.T) {
797797 }
798798 }
799799
800+// A dialog of the fullscreen window (a portal dialog parented through
801+// xdg-foreign) shows over it and takes the pointer at once.
802+func TestDialogOverFullscreenHit(t *testing.T) {
803+ cfg := config.Defaults()
804+ cfg.Border.Width = 0
805+ client := make(chan ports.ClientEvent, 8)
806+ input := make(chan ports.InputEvent, 8)
807+ output := make(chan ports.OutputEvent, 8)
808+ commands := make(chan ports.ClientCommand, 64)
809+ scenes := make(chan []ports.Scene, 1)
810+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})
811+ if err != nil {
812+ t.Fatal(err)
813+ }
814+ ctx, cancel := context.WithCancel(context.Background())
815+ defer cancel()
816+ go c.Run(ctx)
817+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
818+ scene(t, scenes)
819+ client <- ports.WindowMapped{ID: 1}
820+ scene(t, scenes)
821+ client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
822+ scene(t, scenes)
823+ client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10, Parent: 1}
824+ s := scene(t, scenes)
825+ shown := false
826+ for _, w := range s.Windows {
827+ if w.ID == 2 {
828+ shown = !w.Hidden && w.Rect.W > 0
829+ }
830+ }
831+ if !shown {
832+ t.Fatalf("dialog not shown: %+v", s.Windows)
833+ }
834+ for len(commands) > 0 {
835+ <-commands
836+ }
837+ input <- ports.PointerMotion{X: 50, Y: 40}
838+ if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 {
839+ t.Fatalf("pointer went to %v, want the dialog", v)
840+ }
841+}
842+
800843 // A window that asks for fullscreen as it maps (Wine at a remembered
801844 // monitor size) stays in its column: the windows it opens next are seen.
802845 // A later request is honoured.
@@ -797,6 +797,49 @@ func TestFloatOverFullscreenHit(t *testing.T) {
797 }797 }
798 }798 }
799 799
800+// A dialog of the fullscreen window (a portal dialog parented through
801+// xdg-foreign) shows over it and takes the pointer at once.
802+func TestDialogOverFullscreenHit(t *testing.T) {
803+ cfg := config.Defaults()
804+ cfg.Border.Width = 0
805+ client := make(chan ports.ClientEvent, 8)
806+ input := make(chan ports.InputEvent, 8)
807+ output := make(chan ports.OutputEvent, 8)
808+ commands := make(chan ports.ClientCommand, 64)
809+ scenes := make(chan []ports.Scene, 1)
810+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})
811+ if err != nil {
812+ t.Fatal(err)
813+ }
814+ ctx, cancel := context.WithCancel(context.Background())
815+ defer cancel()
816+ go c.Run(ctx)
817+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
818+ scene(t, scenes)
819+ client <- ports.WindowMapped{ID: 1}
820+ scene(t, scenes)
821+ client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
822+ scene(t, scenes)
823+ client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10, Parent: 1}
824+ s := scene(t, scenes)
825+ shown := false
826+ for _, w := range s.Windows {
827+ if w.ID == 2 {
828+ shown = !w.Hidden && w.Rect.W > 0
829+ }
830+ }
831+ if !shown {
832+ t.Fatalf("dialog not shown: %+v", s.Windows)
833+ }
834+ for len(commands) > 0 {
835+ <-commands
836+ }
837+ input <- ports.PointerMotion{X: 50, Y: 40}
838+ if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 {
839+ t.Fatalf("pointer went to %v, want the dialog", v)
840+ }
841+}
842+
800 // A window that asks for fullscreen as it maps (Wine at a remembered843 // A window that asks for fullscreen as it maps (Wine at a remembered
801 // monitor size) stays in its column: the windows it opens next are seen.844 // monitor size) stays in its column: the windows it opens next are seen.
802 // A later request is honoured.845 // A later request is honoured.
A internal/core/dialog_test.go +42 -0
@@ -0,0 +1,42 @@
1+package core
2+
3+import "testing"
4+
5+// A dialog of the covering fullscreen window shows over it with the focus;
6+// other floats stay hidden. A focus move goes back to the fullscreen window
7+// without leaving fullscreen.
8+func TestDialogOverFullscreenParent(t *testing.T) {
9+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
10+ t.Run(string(overflow), func(t *testing.T) {
11+ w := workspace()
12+ w.Overflow = overflow
13+ w.AddWindow(1)
14+ w.AddWindow(2)
15+ w.FocusID(1)
16+ w.ToggleFullscreen()
17+ w.AddFloating(3, 20, 10) // a dialog of nobody
18+ w.AddDialog(4, 1, 20, 10) // the dialog of the fullscreen window
19+ w.AddDialog(5, 2, 20, 10) // the dialog of a hidden window
20+ wantOrder(t, w, 1, 4)
21+ if id, _ := w.Focused(); id != 4 {
22+ t.Fatalf("focus %d, want the dialog", id)
23+ }
24+ if !w.FocusColumn(1) {
25+ t.Fatal("focus move refused")
26+ }
27+ if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 {
28+ t.Fatalf("focus %d fullscreen %d", id, w.fullscreen)
29+ }
30+ wantOrder(t, w, 1, 4)
31+ w.FocusID(4)
32+ if id, _ := w.Focused(); id != 4 {
33+ t.Fatalf("focus %d, want the dialog back", id)
34+ }
35+ w.RemoveWindow(4)
36+ if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 {
37+ t.Fatalf("after close focus %d fullscreen %d", id, w.fullscreen)
38+ }
39+ wantOrder(t, w, 1)
40+ })
41+ }
42+}
@@ -0,0 +1,42 @@
1+package core
2+
3+import "testing"
4+
5+// A dialog of the covering fullscreen window shows over it with the focus;
6+// other floats stay hidden. A focus move goes back to the fullscreen window
7+// without leaving fullscreen.
8+func TestDialogOverFullscreenParent(t *testing.T) {
9+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
10+ t.Run(string(overflow), func(t *testing.T) {
11+ w := workspace()
12+ w.Overflow = overflow
13+ w.AddWindow(1)
14+ w.AddWindow(2)
15+ w.FocusID(1)
16+ w.ToggleFullscreen()
17+ w.AddFloating(3, 20, 10) // a dialog of nobody
18+ w.AddDialog(4, 1, 20, 10) // the dialog of the fullscreen window
19+ w.AddDialog(5, 2, 20, 10) // the dialog of a hidden window
20+ wantOrder(t, w, 1, 4)
21+ if id, _ := w.Focused(); id != 4 {
22+ t.Fatalf("focus %d, want the dialog", id)
23+ }
24+ if !w.FocusColumn(1) {
25+ t.Fatal("focus move refused")
26+ }
27+ if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 {
28+ t.Fatalf("focus %d fullscreen %d", id, w.fullscreen)
29+ }
30+ wantOrder(t, w, 1, 4)
31+ w.FocusID(4)
32+ if id, _ := w.Focused(); id != 4 {
33+ t.Fatalf("focus %d, want the dialog back", id)
34+ }
35+ w.RemoveWindow(4)
36+ if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 {
37+ t.Fatalf("after close focus %d fullscreen %d", id, w.fullscreen)
38+ }
39+ wantOrder(t, w, 1)
40+ })
41+ }
42+}
M internal/core/layout.go +71 -4
@@ -229,6 +229,9 @@ type Float struct {
229229 // usable area, so it keeps its place on any output.
230230 free bool
231231 cx, cy float64
232+ // over is the parent of a dialog: while the parent covers the output
233+ // fullscreen, the dialog shows over it and may take the focus.
234+ over WindowID
232235 }
233236
234237 func (w *Workspace) empty() bool {
@@ -315,6 +318,52 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) {
315318 w.floatFocus = true
316319 }
317320
321+// AddDialog is AddFloating for a dialog of parent: it shows, focused, over
322+// its parent's fullscreen. A dialog follows a user action in its parent
323+// (a portal request, a file chooser), so the user expects to see it.
324+func (w *Workspace) AddDialog(id, parent WindowID, width, height int) {
325+ if id == 0 || w.has(id) {
326+ return
327+ }
328+ w.Floats = append(w.Floats, Float{ID: id, W: width, H: height, over: parent})
329+ w.floatFocus = true
330+}
331+
332+// SetDialogParent follows a dialog's parent after it mapped: a cleared or
333+// changed parent ends its place over the old one's fullscreen.
334+func (w *Workspace) SetDialogParent(id, parent WindowID) {
335+ if i := w.floatIndex(id); i >= 0 {
336+ w.Floats[i].over = parent
337+ w.reconcileFloats()
338+ }
339+}
340+
341+// coverDialog reports whether f is a dialog of the covering fullscreen
342+// window cover: the one float drawn over it.
343+func (f Float) coverDialog(cover WindowID) bool {
344+ return cover != 0 && f.over == cover && f.ID != cover
345+}
346+
347+// coverDialog is the topmost dialog shown over the covering fullscreen
348+// window when the floats have the focus, else 0. The floats it hides cannot
349+// take the focus.
350+func (w *Workspace) coverDialog() WindowID {
351+ if !w.floatFocus {
352+ return 0
353+ }
354+ cover := w.cover()
355+ for i := len(w.Floats) - 1; i >= 0; i-- {
356+ if f := w.Floats[i]; !f.below && f.coverDialog(cover) {
357+ return f.ID
358+ }
359+ }
360+ return 0
361+}
362+
363+// coverDialogFocused reports whether a dialog over the covering fullscreen
364+// window has the focus.
365+func (w *Workspace) coverDialogFocused() bool { return w.coverDialog() != 0 }
366+
318367 // ResizeFloating records the size a floating window draws. Core sizes a
319368 // free float: its reports only fill a missing size.
320369 func (w *Workspace) ResizeFloating(id WindowID, width, height int) {
@@ -340,8 +389,12 @@ func (w *Workspace) has(id WindowID) bool {
340389 }
341390
342391 func (w *Workspace) Focused() (WindowID, bool) {
343- // Nothing else is drawn: the covering fullscreen window has the focus.
392+ // Nothing else is drawn but its dialogs: the covering fullscreen window
393+ // has the focus unless one of them took it.
344394 if full := w.cover(); full != 0 {
395+ if d := w.coverDialog(); d != 0 {
396+ return d, true
397+ }
345398 return full, true
346399 }
347400 if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below {
@@ -545,6 +598,11 @@ func (w *Workspace) FocusID(id WindowID) bool {
545598 // fullscreen window it leaves fullscreen for the window there (leaveCover);
546599 // false means it stays, with no window on that side.
547600 func (w *Workspace) FocusColumn(dir int) bool {
601+ if w.coverDialogFocused() {
602+ // The first move goes back to the fullscreen window under it.
603+ w.floatFocus = false
604+ return true
605+ }
548606 if w.pinned() {
549607 return w.leaveCover(func() { w.focusColumn(dir) })
550608 }
@@ -604,6 +662,10 @@ func (w *Workspace) onScreenFocus() bool {
604662 // float; false means no window is available in that direction. From a
605663 // covering fullscreen window it leaves fullscreen for that window.
606664 func (w *Workspace) FocusWindow(dir int) bool {
665+ if w.coverDialogFocused() {
666+ w.floatFocus = false
667+ return true
668+ }
607669 if w.pinned() {
608670 return w.leaveCover(func() { w.focusWindow(dir) })
609671 }
@@ -1147,6 +1209,11 @@ func (w *Workspace) focusCover() {
11471209 if c == 0 {
11481210 return
11491211 }
1212+ if d := w.coverDialog(); d != 0 {
1213+ // Its dialog is on screen with the focus: the action is on it.
1214+ w.FocusID(d)
1215+ return
1216+ }
11501217 if w.isFloat(c) {
11511218 w.FocusID(c)
11521219 return
@@ -1451,9 +1518,9 @@ func (w *Workspace) Layout() []Placement {
14511518 if order != group {
14521519 continue
14531520 }
1454- // A covering fullscreen window hides every float, its own dialogs
1455- // too: they show again when it leaves fullscreen.
1456- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover}
1521+ // A covering fullscreen window hides every float but its own
1522+ // dialogs: the others show again when it leaves fullscreen.
1523+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !f.coverDialog(cover)}
14571524 if p.Hidden {
14581525 p.Rect = Rect{}
14591526 result = append(result, p)
@@ -229,6 +229,9 @@ type Float struct {
229 // usable area, so it keeps its place on any output.229 // usable area, so it keeps its place on any output.
230 free bool230 free bool
231 cx, cy float64231 cx, cy float64
232+ // over is the parent of a dialog: while the parent covers the output
233+ // fullscreen, the dialog shows over it and may take the focus.
234+ over WindowID
232 }235 }
233 236
234 func (w *Workspace) empty() bool {237 func (w *Workspace) empty() bool {
@@ -315,6 +318,52 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) {
315 w.floatFocus = true318 w.floatFocus = true
316 }319 }
317 320
321+// AddDialog is AddFloating for a dialog of parent: it shows, focused, over
322+// its parent's fullscreen. A dialog follows a user action in its parent
323+// (a portal request, a file chooser), so the user expects to see it.
324+func (w *Workspace) AddDialog(id, parent WindowID, width, height int) {
325+ if id == 0 || w.has(id) {
326+ return
327+ }
328+ w.Floats = append(w.Floats, Float{ID: id, W: width, H: height, over: parent})
329+ w.floatFocus = true
330+}
331+
332+// SetDialogParent follows a dialog's parent after it mapped: a cleared or
333+// changed parent ends its place over the old one's fullscreen.
334+func (w *Workspace) SetDialogParent(id, parent WindowID) {
335+ if i := w.floatIndex(id); i >= 0 {
336+ w.Floats[i].over = parent
337+ w.reconcileFloats()
338+ }
339+}
340+
341+// coverDialog reports whether f is a dialog of the covering fullscreen
342+// window cover: the one float drawn over it.
343+func (f Float) coverDialog(cover WindowID) bool {
344+ return cover != 0 && f.over == cover && f.ID != cover
345+}
346+
347+// coverDialog is the topmost dialog shown over the covering fullscreen
348+// window when the floats have the focus, else 0. The floats it hides cannot
349+// take the focus.
350+func (w *Workspace) coverDialog() WindowID {
351+ if !w.floatFocus {
352+ return 0
353+ }
354+ cover := w.cover()
355+ for i := len(w.Floats) - 1; i >= 0; i-- {
356+ if f := w.Floats[i]; !f.below && f.coverDialog(cover) {
357+ return f.ID
358+ }
359+ }
360+ return 0
361+}
362+
363+// coverDialogFocused reports whether a dialog over the covering fullscreen
364+// window has the focus.
365+func (w *Workspace) coverDialogFocused() bool { return w.coverDialog() != 0 }
366+
318 // ResizeFloating records the size a floating window draws. Core sizes a367 // ResizeFloating records the size a floating window draws. Core sizes a
319 // free float: its reports only fill a missing size.368 // free float: its reports only fill a missing size.
320 func (w *Workspace) ResizeFloating(id WindowID, width, height int) {369 func (w *Workspace) ResizeFloating(id WindowID, width, height int) {
@@ -340,8 +389,12 @@ func (w *Workspace) has(id WindowID) bool {
340 }389 }
341 390
342 func (w *Workspace) Focused() (WindowID, bool) {391 func (w *Workspace) Focused() (WindowID, bool) {
343- // Nothing else is drawn: the covering fullscreen window has the focus.392+ // Nothing else is drawn but its dialogs: the covering fullscreen window
393+ // has the focus unless one of them took it.
344 if full := w.cover(); full != 0 {394 if full := w.cover(); full != 0 {
395+ if d := w.coverDialog(); d != 0 {
396+ return d, true
397+ }
345 return full, true398 return full, true
346 }399 }
347 if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below {400 if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below {
@@ -545,6 +598,11 @@ func (w *Workspace) FocusID(id WindowID) bool {
545 // fullscreen window it leaves fullscreen for the window there (leaveCover);598 // fullscreen window it leaves fullscreen for the window there (leaveCover);
546 // false means it stays, with no window on that side.599 // false means it stays, with no window on that side.
547 func (w *Workspace) FocusColumn(dir int) bool {600 func (w *Workspace) FocusColumn(dir int) bool {
601+ if w.coverDialogFocused() {
602+ // The first move goes back to the fullscreen window under it.
603+ w.floatFocus = false
604+ return true
605+ }
548 if w.pinned() {606 if w.pinned() {
549 return w.leaveCover(func() { w.focusColumn(dir) })607 return w.leaveCover(func() { w.focusColumn(dir) })
550 }608 }
@@ -604,6 +662,10 @@ func (w *Workspace) onScreenFocus() bool {
604 // float; false means no window is available in that direction. From a662 // float; false means no window is available in that direction. From a
605 // covering fullscreen window it leaves fullscreen for that window.663 // covering fullscreen window it leaves fullscreen for that window.
606 func (w *Workspace) FocusWindow(dir int) bool {664 func (w *Workspace) FocusWindow(dir int) bool {
665+ if w.coverDialogFocused() {
666+ w.floatFocus = false
667+ return true
668+ }
607 if w.pinned() {669 if w.pinned() {
608 return w.leaveCover(func() { w.focusWindow(dir) })670 return w.leaveCover(func() { w.focusWindow(dir) })
609 }671 }
@@ -1147,6 +1209,11 @@ func (w *Workspace) focusCover() {
1147 if c == 0 {1209 if c == 0 {
1148 return1210 return
1149 }1211 }
1212+ if d := w.coverDialog(); d != 0 {
1213+ // Its dialog is on screen with the focus: the action is on it.
1214+ w.FocusID(d)
1215+ return
1216+ }
1150 if w.isFloat(c) {1217 if w.isFloat(c) {
1151 w.FocusID(c)1218 w.FocusID(c)
1152 return1219 return
@@ -1451,9 +1518,9 @@ func (w *Workspace) Layout() []Placement {
1451 if order != group {1518 if order != group {
1452 continue1519 continue
1453 }1520 }
1454- // A covering fullscreen window hides every float, its own dialogs1521+ // A covering fullscreen window hides every float but its own
1455- // too: they show again when it leaves fullscreen.1522+ // dialogs: the others show again when it leaves fullscreen.
1456- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover}1523+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !f.coverDialog(cover)}
1457 if p.Hidden {1524 if p.Hidden {
1458 p.Rect = Rect{}1525 p.Rect = Rect{}
1459 result = append(result, p)1526 result = append(result, p)
M internal/core/screens_test.go +59 -0
@@ -1003,6 +1003,65 @@ func TestStashKeepsFocusAtEdges(t *testing.T) {
10031003 }
10041004 }
10051005
1006+// Binds act on the dialog shown over a fullscreen window, not on the window:
1007+// close closes the dialog, a focus move goes back to the window and keeps it
1008+// fullscreen. A dialog of a window on another output opens there.
1009+func TestBindsOnDialogOverFullscreen(t *testing.T) {
1010+ for _, overflow := range []string{"scroll", "fixed"} {
1011+ t.Run(overflow, func(t *testing.T) {
1012+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = overflow }, left, right)
1013+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1
1014+ r.mapWindow(t, 1)
1015+ r.mapWindow(t, 2)
1016+ r.key(t, "Left", ports.ModAlt)
1017+ r.key(t, "f", ports.ModAlt|ports.ModShift) // 1 fullscreen
1018+ r.client <- ports.WindowMapped{ID: 9, Floating: true, Width: 10, Height: 10, Parent: 1}
1019+ receive(t, r.scenes)
1020+ st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 9 })
1021+ if st.Window.ID != 9 {
1022+ t.Fatalf("focus %+v, want the dialog", st.Window)
1023+ }
1024+ for len(r.commands) > 0 {
1025+ <-r.commands
1026+ }
1027+ r.input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModAlt, Pressed: true}
1028+ r.input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModAlt}
1029+ for {
1030+ if v, ok := receive(t, r.commands).(ports.CloseWindow); ok {
1031+ if v.ID != 9 {
1032+ t.Fatalf("closed %d, want the dialog", v.ID)
1033+ }
1034+ break
1035+ }
1036+ }
1037+ scenes := r.key(t, "Right", ports.ModAlt)
1038+ st = stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID != 9 })
1039+ if st.Window.ID != 1 {
1040+ t.Fatalf("focus %+v, want the fullscreen window", st.Window)
1041+ }
1042+ for _, s := range scenes {
1043+ for _, w := range s.Windows {
1044+ if s.Output == "DP-1" && w.ID == 1 && !w.Fullscreen {
1045+ t.Fatalf("window 1 left fullscreen: %+v", w)
1046+ }
1047+ }
1048+ }
1049+ // A dialog of the window on DP-1 opens on DP-1 while DP-2 has the focus.
1050+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1051+ r.client <- ports.WindowMapped{ID: 10, Floating: true, Width: 10, Height: 10, Parent: 1}
1052+ for {
1053+ seen := shown(receive(t, r.scenes))
1054+ if slices.Contains(seen["DP-2"], 10) {
1055+ t.Fatalf("dialog on the focused output: %v", seen)
1056+ }
1057+ if slices.Contains(seen["DP-1"], 10) {
1058+ break
1059+ }
1060+ }
1061+ })
1062+ }
1063+}
1064+
10061065 // A focused native dialog: focus-column first leaves the dialog for the
10071066 // tiles below, it does not hop to the neighbor monitor.
10081067 func TestDialogFocusStaysOnMonitor(t *testing.T) {
@@ -1003,6 +1003,65 @@ func TestStashKeepsFocusAtEdges(t *testing.T) {
1003 }1003 }
1004 }1004 }
1005 1005
1006+// Binds act on the dialog shown over a fullscreen window, not on the window:
1007+// close closes the dialog, a focus move goes back to the window and keeps it
1008+// fullscreen. A dialog of a window on another output opens there.
1009+func TestBindsOnDialogOverFullscreen(t *testing.T) {
1010+ for _, overflow := range []string{"scroll", "fixed"} {
1011+ t.Run(overflow, func(t *testing.T) {
1012+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = overflow }, left, right)
1013+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1
1014+ r.mapWindow(t, 1)
1015+ r.mapWindow(t, 2)
1016+ r.key(t, "Left", ports.ModAlt)
1017+ r.key(t, "f", ports.ModAlt|ports.ModShift) // 1 fullscreen
1018+ r.client <- ports.WindowMapped{ID: 9, Floating: true, Width: 10, Height: 10, Parent: 1}
1019+ receive(t, r.scenes)
1020+ st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 9 })
1021+ if st.Window.ID != 9 {
1022+ t.Fatalf("focus %+v, want the dialog", st.Window)
1023+ }
1024+ for len(r.commands) > 0 {
1025+ <-r.commands
1026+ }
1027+ r.input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModAlt, Pressed: true}
1028+ r.input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModAlt}
1029+ for {
1030+ if v, ok := receive(t, r.commands).(ports.CloseWindow); ok {
1031+ if v.ID != 9 {
1032+ t.Fatalf("closed %d, want the dialog", v.ID)
1033+ }
1034+ break
1035+ }
1036+ }
1037+ scenes := r.key(t, "Right", ports.ModAlt)
1038+ st = stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID != 9 })
1039+ if st.Window.ID != 1 {
1040+ t.Fatalf("focus %+v, want the fullscreen window", st.Window)
1041+ }
1042+ for _, s := range scenes {
1043+ for _, w := range s.Windows {
1044+ if s.Output == "DP-1" && w.ID == 1 && !w.Fullscreen {
1045+ t.Fatalf("window 1 left fullscreen: %+v", w)
1046+ }
1047+ }
1048+ }
1049+ // A dialog of the window on DP-1 opens on DP-1 while DP-2 has the focus.
1050+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1051+ r.client <- ports.WindowMapped{ID: 10, Floating: true, Width: 10, Height: 10, Parent: 1}
1052+ for {
1053+ seen := shown(receive(t, r.scenes))
1054+ if slices.Contains(seen["DP-2"], 10) {
1055+ t.Fatalf("dialog on the focused output: %v", seen)
1056+ }
1057+ if slices.Contains(seen["DP-1"], 10) {
1058+ break
1059+ }
1060+ }
1061+ })
1062+ }
1063+}
1064+
1006 // A focused native dialog: focus-column first leaves the dialog for the1065 // A focused native dialog: focus-column first leaves the dialog for the
1007 // tiles below, it does not hop to the neighbor monitor.1066 // tiles below, it does not hop to the neighbor monitor.
1008 func TestDialogFocusStaysOnMonitor(t *testing.T) {1067 func TestDialogFocusStaysOnMonitor(t *testing.T) {
M internal/core/spawn_placement.go +9 -2
@@ -73,9 +73,16 @@ func (p *spawnPlacement) expireTerminals(now time.Time) {
7373 // Untagged windows return before any map lookup or allocation.
7474 func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
7575 if v.Floating {
76- if s, _ := c.screenOf(v.ID); s == nil {
77- c.cur().mon.AddFloating(v.ID, v.Width, v.Height)
76+ if s, _ := c.screenOf(v.ID); s != nil {
77+ return
7878 }
79+ // A dialog opens on its parent's workspace, over it, fullscreen too.
80+ if ps, pw := c.screenOf(v.Parent); v.Parent != 0 && pw != nil {
81+ pw.AddDialog(v.ID, v.Parent, v.Width, v.Height)
82+ ps.mon.normalize()
83+ return
84+ }
85+ c.cur().mon.AddFloating(v.ID, v.Width, v.Height)
7986 return
8087 }
8188 if v.Slot != "" {
@@ -73,9 +73,16 @@ func (p *spawnPlacement) expireTerminals(now time.Time) {
73 // Untagged windows return before any map lookup or allocation.73 // Untagged windows return before any map lookup or allocation.
74 func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {74 func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
75 if v.Floating {75 if v.Floating {
76- if s, _ := c.screenOf(v.ID); s == nil {76+ if s, _ := c.screenOf(v.ID); s != nil {
77- c.cur().mon.AddFloating(v.ID, v.Width, v.Height)77+ return
78 }78 }
79+ // A dialog opens on its parent's workspace, over it, fullscreen too.
80+ if ps, pw := c.screenOf(v.Parent); v.Parent != 0 && pw != nil {
81+ pw.AddDialog(v.ID, v.Parent, v.Width, v.Height)
82+ ps.mon.normalize()
83+ return
84+ }
85+ c.cur().mon.AddFloating(v.ID, v.Width, v.Height)
79 return86 return
80 }87 }
81 if v.Slot != "" {88 if v.Slot != "" {
M internal/ports/client_events.go +12 -0
@@ -16,6 +16,10 @@ type WindowMapped struct {
1616 // size, Width×Height logical (0 when unknown).
1717 Floating bool
1818 Width, Height int
19+ // Parent is the window a dialog belongs to (xdg_toplevel.set_parent or
20+ // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over
21+ // it.
22+ Parent WindowID
1923 }
2024
2125 // SlotEnv is the environment variable neferwl sets on processes it spawns
@@ -138,6 +142,14 @@ type WindowAppID struct {
138142
139143 func (WindowAppID) clientEvent() {}
140144
145+// WindowParent carries the new parent of a mapped dialog (0: none), after
146+// set_parent or an xdg-foreign import changed or ended the relationship.
147+type WindowParent struct {
148+ ID, Parent WindowID
149+}
150+
151+func (WindowParent) clientEvent() {}
152+
141153 // WindowResized carries wayland → core the new size of a floating window.
142154 type WindowResized struct {
143155 ID WindowID
@@ -16,6 +16,10 @@ type WindowMapped struct {
16 // size, Width×Height logical (0 when unknown).16 // size, Width×Height logical (0 when unknown).
17 Floating bool17 Floating bool
18 Width, Height int18 Width, Height int
19+ // Parent is the window a dialog belongs to (xdg_toplevel.set_parent or
20+ // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over
21+ // it.
22+ Parent WindowID
19 }23 }
20 24
21 // SlotEnv is the environment variable neferwl sets on processes it spawns25 // SlotEnv is the environment variable neferwl sets on processes it spawns
@@ -138,6 +142,14 @@ type WindowAppID struct {
138 142
139 func (WindowAppID) clientEvent() {}143 func (WindowAppID) clientEvent() {}
140 144
145+// WindowParent carries the new parent of a mapped dialog (0: none), after
146+// set_parent or an xdg-foreign import changed or ended the relationship.
147+type WindowParent struct {
148+ ID, Parent WindowID
149+}
150+
151+func (WindowParent) clientEvent() {}
152+
141 // WindowResized carries wayland → core the new size of a floating window.153 // WindowResized carries wayland → core the new size of a floating window.
142 type WindowResized struct {154 type WindowResized struct {
143 ID WindowID155 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