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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 124 | 124 | | `spawn <command>` | Run a program | | |
| 125 | 125 | | `spawn-terminal` | Run `terminal` | | |
| 126 | 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 | 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 | 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 | 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 | 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 | 159 | ### Free floating windows | |
| 160 | 160 | ||
| @@ -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 windows | 159 | ### 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 { | |||
| 42 | 42 | func() error { return registerExclusion(d, s) }, | |
| 43 | 43 | func() error { return registerSecurityContext(d, s) }, | |
| 44 | 44 | func() error { return registerActivation(d, s) }, | |
| 45 | + | func() error { return registerForeign(d, s) }, | |
| 45 | 46 | func() error { return registerVirtualKeyboard(d, s) }, | |
| 46 | 47 | func() error { return registerTextInput(d, s) }, | |
| 47 | 48 | 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 { | |||
| 171 | 171 | lastFlip map[string]time.Time | |
| 172 | 172 | // tokens are the issued xdg-activation tokens (activation.go). | |
| 173 | 173 | 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 | |
| 175 | 177 | buffers map[*server.Resource]clientBuffer | |
| 176 | 178 | dmabuf *dmabufGlobal | |
| 177 | 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 | 393 | s.colorOutputs = map[*colorOutput]struct{}{} | |
| 392 | 394 | s.colorFeedbacks = map[*colorFeedback]struct{}{} | |
| 393 | 395 | s.tokens = map[string]activationToken{} | |
| 396 | + | s.foreignExports = map[string]*foreignExport{} | |
| 394 | 397 | if opts.Keymap != "" { | |
| 395 | 398 | fd, size, e := keymapFile(opts.Keymap) | |
| 396 | 399 | if e != nil { | |
| @@ -171,7 +171,9 @@ type Server struct { | |||||
| 171 | lastFlip map[string]time.Time | 171 | 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]activationToken | 173 | 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 | |||
| 175 | buffers map[*server.Resource]clientBuffer | 177 | buffers map[*server.Resource]clientBuffer | ||
| 176 | dmabuf *dmabufGlobal | 178 | 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 ( | |||
| 47 | 47 | wire_wlroutputpower "github.com/bnema/go-wayland-bindings/server/wlroutputpowermanagement" | |
| 48 | 48 | wire_wlrscreencopy "github.com/bnema/go-wayland-bindings/server/wlrscreencopy" | |
| 49 | 49 | wire_xdgactivation "github.com/bnema/go-wayland-bindings/server/xdgactivation" | |
| 50 | + | wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign" | |
| 50 | 51 | wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput" | |
| 51 | 52 | wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell" | |
| 52 | 53 | wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture" | |
| @@ -189,6 +190,10 @@ var wireSchemas = map[string]wireSchema{ | |||
| 189 | 190 | "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil}, | |
| 190 | 191 | "xdg_activation_token_v1": {wire_xdgactivation.ActivationTokenV1Interface, &client_xdgactivation.XdgActivationToken{}, map[uint16]bool{4: true}, nil}, | |
| 191 | 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 | 197 | "xdg_popup": {wire_xdgshell.PopupInterface, &client_xdgshell.XdgPopup{}, map[uint16]bool{0: true}, nil}, | |
| 193 | 198 | "xdg_positioner": {wire_xdgshell.PositionerInterface, &client_xdgshell.XdgPositioner{}, map[uint16]bool{0: true}, nil}, | |
| 194 | 199 | "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) { | |||
| 235 | 235 | slot := x.server.slotToken(r.Client()) | |
| 236 | 236 | // Its size comes with the commit, in WindowResized (afterCommit). | |
| 237 | 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 | 243 | x.server.syncInhibitors() | |
| 240 | 244 | x.server.toplevelChanged(w) | |
| 241 | 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 | 250 | t.OnDestroy = func() { | |
| 247 | 251 | w.unmap() | |
| 252 | + | x.server.foreignToplevelGone(w) | |
| 248 | 253 | x.surface.dropQueue() | |
| 249 | 254 | x.surface.current, x.surface.next.buffer = nil, nil | |
| 250 | 255 | x.surface.next.attached = false | |
| @@ -294,15 +299,32 @@ type top struct{ w *window } | |||
| 294 | 299 | ||
| 295 | 300 | func (top) Destroy(*xdgshell.Toplevel) {} | |
| 296 | 301 | 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 { | |
| 299 | 317 | return | |
| 300 | 318 | } | |
| 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 | |
| 305 | 326 | } | |
| 327 | + | w.xdg.server.emit(ports.WindowParent{ID: w.id, Parent: id}) | |
| 306 | 328 | } | |
| 307 | 329 | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { | |
| 308 | 330 | 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, nil | 254 | x.surface.current, x.surface.next.buffer = nil, nil | ||
| 250 | x.surface.next.attached = false | 255 | 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 = nil | 302 | + | 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 | return | 317 | 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 = w | 321 | + | 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 { | |||
| 1041 | 1041 | continue | |
| 1042 | 1042 | case ports.WindowAppID: | |
| 1043 | 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 | 1048 | case ports.WindowUnmapped: | |
| 1045 | 1049 | if c.popups[v.ID] != nil { | |
| 1046 | 1050 | if err := c.dropPopup(ctx, v.ID); err != nil { | |
| @@ -1041,6 +1041,10 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1041 | continue | 1041 | 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) { | |||
| 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 | 843 | // A window that asks for fullscreen as it maps (Wine at a remembered | |
| 801 | 844 | // monitor size) stays in its column: the windows it opens next are seen. | |
| 802 | 845 | // 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 remembered | 843 | // 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 { | |||
| 229 | 229 | // usable area, so it keeps its place on any output. | |
| 230 | 230 | free bool | |
| 231 | 231 | 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 | 237 | func (w *Workspace) empty() bool { | |
| @@ -315,6 +318,52 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) { | |||
| 315 | 318 | 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 | 367 | // ResizeFloating records the size a floating window draws. Core sizes a | |
| 319 | 368 | // free float: its reports only fill a missing size. | |
| 320 | 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 | 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 | 394 | if full := w.cover(); full != 0 { | |
| 395 | + | if d := w.coverDialog(); d != 0 { | |
| 396 | + | return d, true | |
| 397 | + | } | |
| 345 | 398 | return full, true | |
| 346 | 399 | } | |
| 347 | 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 | 598 | // fullscreen window it leaves fullscreen for the window there (leaveCover); | |
| 546 | 599 | // false means it stays, with no window on that side. | |
| 547 | 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 | 606 | if w.pinned() { | |
| 549 | 607 | return w.leaveCover(func() { w.focusColumn(dir) }) | |
| 550 | 608 | } | |
| @@ -604,6 +662,10 @@ func (w *Workspace) onScreenFocus() bool { | |||
| 604 | 662 | // float; false means no window is available in that direction. From a | |
| 605 | 663 | // covering fullscreen window it leaves fullscreen for that window. | |
| 606 | 664 | func (w *Workspace) FocusWindow(dir int) bool { | |
| 665 | + | if w.coverDialogFocused() { | |
| 666 | + | w.floatFocus = false | |
| 667 | + | return true | |
| 668 | + | } | |
| 607 | 669 | if w.pinned() { | |
| 608 | 670 | return w.leaveCover(func() { w.focusWindow(dir) }) | |
| 609 | 671 | } | |
| @@ -1147,6 +1209,11 @@ func (w *Workspace) focusCover() { | |||
| 1147 | 1209 | if c == 0 { | |
| 1148 | 1210 | 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 | 1217 | if w.isFloat(c) { | |
| 1151 | 1218 | w.FocusID(c) | |
| 1152 | 1219 | return | |
| @@ -1451,9 +1518,9 @@ func (w *Workspace) Layout() []Placement { | |||
| 1451 | 1518 | if order != group { | |
| 1452 | 1519 | continue | |
| 1453 | 1520 | } | |
| 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)} | |
| 1457 | 1524 | if p.Hidden { | |
| 1458 | 1525 | p.Rect = Rect{} | |
| 1459 | 1526 | 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 bool | 230 | free bool | ||
| 231 | cx, cy float64 | 231 | 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 = true | 318 | 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 a | 367 | // 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, true | 398 | 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 a | 662 | // 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 | return | 1210 | 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 | return | 1219 | return | ||
| @@ -1451,9 +1518,9 @@ func (w *Workspace) Layout() []Placement { | |||||
| 1451 | if order != group { | 1518 | if order != group { | ||
| 1452 | continue | 1519 | continue | ||
| 1453 | } | 1520 | } | ||
| 1454 | - | // A covering fullscreen window hides every float, its own dialogs | 1521 | + | // 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) { | |||
| 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 | 1065 | // A focused native dialog: focus-column first leaves the dialog for the | |
| 1007 | 1066 | // tiles below, it does not hop to the neighbor monitor. | |
| 1008 | 1067 | 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 the | 1065 | // 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) { | |||
| 73 | 73 | // Untagged windows return before any map lookup or allocation. | |
| 74 | 74 | func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |
| 75 | 75 | 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 | |
| 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 | 86 | return | |
| 80 | 87 | } | |
| 81 | 88 | 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 | return | 86 | 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 { | |||
| 16 | 16 | // size, Width×Height logical (0 when unknown). | |
| 17 | 17 | Floating bool | |
| 18 | 18 | 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 | 25 | // SlotEnv is the environment variable neferwl sets on processes it spawns | |
| @@ -138,6 +142,14 @@ type WindowAppID struct { | |||
| 138 | 142 | ||
| 139 | 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 | 153 | // WindowResized carries wayland → core the new size of a floating window. | |
| 142 | 154 | type WindowResized struct { | |
| 143 | 155 | 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 bool | 17 | Floating bool | ||
| 18 | Width, Height int | 18 | 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 spawns | 25 | // 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 WindowID | 155 | ID WindowID | ||