refactor(core): make fullscreen exclusive so nothing but a locker draws above it
- commit
- 8abededfd1f170cfd197ff733004da8734829886
- author
- brice · · 2026-09-28 14:10 UTC
- committer
- brice · · 2026-09-28 14:44 UTC
- parent
- 865e07c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +276 -72
M README.md +2 -2
| @@ -22,7 +22,7 @@ The design is deliberately small, and it will stay small: no animations, no them | |||
| 22 | 22 | ||
| 23 | 23 | ## Features | |
| 24 | 24 | ||
| 25 | - | - **Games.** A fullscreen window is scanned out directly, with tearing, VRR and explicit sync when the client asks for them. Wine runs natively on Wayland; Steam and other X11 clients run through xwayland-satellite. See [the performance path](#the-performance-path). | |
| 25 | + | - **Games.** Fullscreen is exclusive: nothing is drawn above a fullscreen window but a locker, so it is scanned out directly, with tearing, VRR and explicit sync when the client asks for them. Wine runs natively on Wayland; Steam and other X11 clients run through xwayland-satellite. See [the performance path](#the-performance-path). | |
| 26 | 26 | - **HDR.** HDR10 output on capable displays. HDR clients (games, browsers, video players) are shown at full range; SDR content is shown at a configured brightness. | |
| 27 | 27 | - **Column tiling.** Up to `max-columns` windows share the screen, as in Sway; past that, columns scroll to the right, as in Niri. Limits are set per output or per workspace. | |
| 28 | 28 | - **Workspaces.** Numbered workspaces are created and removed as needed. Named workspaces declare their columns (width and command); NeferWL starts the commands and places each window. | |
| @@ -57,7 +57,7 @@ These protocols and kernel features cut copies, waits and latency between the ap | |||
| 57 | 57 | ||
| 58 | 58 | - **Zero copy.** GPU clients (`zwp_linux_dmabuf_v1` v4) are sampled in place. Vulkan composes straight into exported scanout images, and the frame fence goes to KMS as `IN_FENCE_FD`: no CPU copy, no CPU wait before a flip. | |
| 59 | 59 | - **Direct scanout.** A fullscreen window's buffer goes straight to the display plane; dmabuf feedback sends the game a scanout-ready format first. A lone opaque window can use an overlay plane. | |
| 60 | - | - **Tearing and VRR.** `wp_tearing_control_v1` flips without waiting for vblank when the game requests it; variable refresh runs while a fullscreen window covers the output, scanned out or composed (a notification or overlay above it keeps VRR on). | |
| 60 | + | - **Tearing and VRR.** `wp_tearing_control_v1` flips without waiting for vblank when the game requests it; variable refresh runs while a fullscreen window covers the output, scanned out or composed. | |
| 61 | 61 | - **Explicit sync.** `wp_linux_drm_syncobj_v1` passes GPU fences from the app to KMS. NVIDIA drivers need it. | |
| 62 | 62 | - **Frame pacing.** Apps get real flip times through `wp_presentation`, and can queue frames with `wp_fifo_v1` and `wp_commit_timing_v1`. | |
| 63 | 63 | - **Only what changed.** NeferWL redraws only damaged regions, skips surfaces hidden behind opaque ones, and leaves idle outputs alone. Frame, cursor, overlay and VRR go to the kernel in one atomic commit. | |
| @@ -22,7 +22,7 @@ The design is deliberately small, and it will stay small: no animations, no them | |||||
| 22 | 22 | ||||
| 23 | ## Features | 23 | ## Features | ||
| 24 | 24 | ||||
| 25 | - | - **Games.** A fullscreen window is scanned out directly, with tearing, VRR and explicit sync when the client asks for them. Wine runs natively on Wayland; Steam and other X11 clients run through xwayland-satellite. See [the performance path](#the-performance-path). | 25 | + | - **Games.** Fullscreen is exclusive: nothing is drawn above a fullscreen window but a locker, so it is scanned out directly, with tearing, VRR and explicit sync when the client asks for them. Wine runs natively on Wayland; Steam and other X11 clients run through xwayland-satellite. See [the performance path](#the-performance-path). |
| 26 | - **HDR.** HDR10 output on capable displays. HDR clients (games, browsers, video players) are shown at full range; SDR content is shown at a configured brightness. | 26 | - **HDR.** HDR10 output on capable displays. HDR clients (games, browsers, video players) are shown at full range; SDR content is shown at a configured brightness. | ||
| 27 | - **Column tiling.** Up to `max-columns` windows share the screen, as in Sway; past that, columns scroll to the right, as in Niri. Limits are set per output or per workspace. | 27 | - **Column tiling.** Up to `max-columns` windows share the screen, as in Sway; past that, columns scroll to the right, as in Niri. Limits are set per output or per workspace. | ||
| 28 | - **Workspaces.** Numbered workspaces are created and removed as needed. Named workspaces declare their columns (width and command); NeferWL starts the commands and places each window. | 28 | - **Workspaces.** Numbered workspaces are created and removed as needed. Named workspaces declare their columns (width and command); NeferWL starts the commands and places each window. | ||
| @@ -57,7 +57,7 @@ These protocols and kernel features cut copies, waits and latency between the ap | |||||
| 57 | 57 | ||||
| 58 | - **Zero copy.** GPU clients (`zwp_linux_dmabuf_v1` v4) are sampled in place. Vulkan composes straight into exported scanout images, and the frame fence goes to KMS as `IN_FENCE_FD`: no CPU copy, no CPU wait before a flip. | 58 | - **Zero copy.** GPU clients (`zwp_linux_dmabuf_v1` v4) are sampled in place. Vulkan composes straight into exported scanout images, and the frame fence goes to KMS as `IN_FENCE_FD`: no CPU copy, no CPU wait before a flip. | ||
| 59 | - **Direct scanout.** A fullscreen window's buffer goes straight to the display plane; dmabuf feedback sends the game a scanout-ready format first. A lone opaque window can use an overlay plane. | 59 | - **Direct scanout.** A fullscreen window's buffer goes straight to the display plane; dmabuf feedback sends the game a scanout-ready format first. A lone opaque window can use an overlay plane. | ||
| 60 | - | - **Tearing and VRR.** `wp_tearing_control_v1` flips without waiting for vblank when the game requests it; variable refresh runs while a fullscreen window covers the output, scanned out or composed (a notification or overlay above it keeps VRR on). | 60 | + | - **Tearing and VRR.** `wp_tearing_control_v1` flips without waiting for vblank when the game requests it; variable refresh runs while a fullscreen window covers the output, scanned out or composed. |
| 61 | - **Explicit sync.** `wp_linux_drm_syncobj_v1` passes GPU fences from the app to KMS. NVIDIA drivers need it. | 61 | - **Explicit sync.** `wp_linux_drm_syncobj_v1` passes GPU fences from the app to KMS. NVIDIA drivers need it. | ||
| 62 | - **Frame pacing.** Apps get real flip times through `wp_presentation`, and can queue frames with `wp_fifo_v1` and `wp_commit_timing_v1`. | 62 | - **Frame pacing.** Apps get real flip times through `wp_presentation`, and can queue frames with `wp_fifo_v1` and `wp_commit_timing_v1`. | ||
| 63 | - **Only what changed.** NeferWL redraws only damaged regions, skips surfaces hidden behind opaque ones, and leaves idle outputs alone. Frame, cursor, overlay and VRR go to the kernel in one atomic commit. | 63 | - **Only what changed.** NeferWL redraws only damaged regions, skips surfaces hidden behind opaque ones, and leaves idle outputs alone. Frame, cursor, overlay and VRR go to the kernel in one atomic commit. | ||
M docs/config.md +2 -2
| @@ -89,9 +89,9 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 89 | 89 | | `spawn <command>` | Run a program | | |
| 90 | 90 | | `spawn-terminal` | Run `terminal` | | |
| 91 | 91 | | `close-window` | Close the focused window | | |
| 92 | - | | `toggle-fullscreen` | Fullscreen the focused window | | |
| 92 | + | | `toggle-fullscreen` | Fullscreen the focused window. 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; activating one of them leaves fullscreen | | |
| 93 | 93 | | `toggle-window-floating` | Float a tiled window at 80% of usable area; toggle 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 | | |
| 94 | - | | `toggle-floating-visible` | Hide all floats on this workspace when a float has the focus and none is fullscreen, or show hidden ones again; a new float shows them too | | |
| 94 | + | | `toggle-floating-visible` | Hide all floats on this workspace when a float has the focus, or show hidden ones again; a new float shows them too. Does nothing under a fullscreen window | | |
| 95 | 95 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | |
| 96 | 96 | | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time | | |
| 97 | 97 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | |
| @@ -89,9 +89,9 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 89 | | `spawn <command>` | Run a program | | 89 | | `spawn <command>` | Run a program | | ||
| 90 | | `spawn-terminal` | Run `terminal` | | 90 | | `spawn-terminal` | Run `terminal` | | ||
| 91 | | `close-window` | Close the focused window | | 91 | | `close-window` | Close the focused window | | ||
| 92 | - | | `toggle-fullscreen` | Fullscreen the focused window | | 92 | + | | `toggle-fullscreen` | Fullscreen the focused window. 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; activating one of them leaves fullscreen | |
| 93 | | `toggle-window-floating` | Float a tiled window at 80% of usable area; toggle 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 | | 93 | | `toggle-window-floating` | Float a tiled window at 80% of usable area; toggle 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 | | ||
| 94 | - | | `toggle-floating-visible` | Hide all floats on this workspace when a float has the focus and none is fullscreen, or show hidden ones again; a new float shows them too | | 94 | + | | `toggle-floating-visible` | Hide all floats on this workspace when a float has the focus, or show hidden ones again; a new float shows them too. Does nothing under a fullscreen window | |
| 95 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | 95 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | ||
| 96 | | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time | | 96 | | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time | | ||
| 97 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | 97 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | ||
M internal/adapters/drm/overlay.go +1 -1
| @@ -91,7 +91,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 91 | 91 | return ports.SceneWindow{}, ports.SurfaceContent{}, "dim" | |
| 92 | 92 | } | |
| 93 | 93 | for _, l := range s.Layers { | |
| 94 | - | if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 && !(pick.Fullscreen && l.Layer == ports.LayerTop) { | |
| 94 | + | if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 { | |
| 95 | 95 | return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above" | |
| 96 | 96 | } | |
| 97 | 97 | } | |
| @@ -91,7 +91,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 91 | return ports.SceneWindow{}, ports.SurfaceContent{}, "dim" | 91 | return ports.SceneWindow{}, ports.SurfaceContent{}, "dim" | ||
| 92 | } | 92 | } | ||
| 93 | for _, l := range s.Layers { | 93 | for _, l := range s.Layers { | ||
| 94 | - | if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 && !(pick.Fullscreen && l.Layer == ports.LayerTop) { | 94 | + | if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 { |
| 95 | return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above" | 95 | return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above" | ||
| 96 | } | 96 | } | ||
| 97 | } | 97 | } | ||
M internal/adapters/drm/scanout.go +3 -1
| @@ -78,8 +78,10 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 78 | 78 | if full == nil { | |
| 79 | 79 | return ports.SurfaceContent{}, "no_fullscreen" | |
| 80 | 80 | } | |
| 81 | + | // Core leaves the layers a fullscreen window hides out of the scene: | |
| 82 | + | // one above the windows here is drawn. | |
| 81 | 83 | for _, l := range s.Layers { | |
| 82 | - | if l.Layer == ports.LayerOverlay && l.Rect.W > 0 && l.Rect.H > 0 { | |
| 84 | + | if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 { | |
| 83 | 85 | return ports.SurfaceContent{}, "overlay_surface" | |
| 84 | 86 | } | |
| 85 | 87 | } | |
| @@ -78,8 +78,10 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 78 | if full == nil { | 78 | if full == nil { | ||
| 79 | return ports.SurfaceContent{}, "no_fullscreen" | 79 | return ports.SurfaceContent{}, "no_fullscreen" | ||
| 80 | } | 80 | } | ||
| 81 | + | // Core leaves the layers a fullscreen window hides out of the scene: | |||
| 82 | + | // one above the windows here is drawn. | |||
| 81 | for _, l := range s.Layers { | 83 | for _, l := range s.Layers { | ||
| 82 | - | if l.Layer == ports.LayerOverlay && l.Rect.W > 0 && l.Rect.H > 0 { | 84 | + | if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 { |
| 83 | return ports.SurfaceContent{}, "overlay_surface" | 85 | return ports.SurfaceContent{}, "overlay_surface" | ||
| 84 | } | 86 | } | ||
| 85 | } | 87 | } | ||
M internal/adapters/drm/scanout_test.go +4 -1
| @@ -33,8 +33,11 @@ func TestScanoutCandidate(t *testing.T) { | |||
| 33 | 33 | {"overlay layer", func(s *ports.Scene) { | |
| 34 | 34 | s.Layers = []ports.SceneLayer{{ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 10, H: 10}}} | |
| 35 | 35 | }, nil, "overlay_surface"}, | |
| 36 | - | {"top layer covered", func(s *ports.Scene) { | |
| 36 | + | {"top layer", func(s *ports.Scene) { | |
| 37 | 37 | s.Layers = []ports.SceneLayer{{ID: 3, Layer: ports.LayerTop, Rect: ports.Rect{W: 10, H: 10}}} | |
| 38 | + | }, nil, "overlay_surface"}, | |
| 39 | + | {"background layer", func(s *ports.Scene) { | |
| 40 | + | s.Layers = []ports.SceneLayer{{ID: 3, Layer: ports.LayerBackground, Rect: ports.Rect{W: 10, H: 10}}} | |
| 38 | 41 | }, nil, ""}, | |
| 39 | 42 | {"shm", nil, func(c *ports.SurfaceContent) { c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} }, "not_dmabuf"}, | |
| 40 | 43 | {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"}, | |
| @@ -33,8 +33,11 @@ func TestScanoutCandidate(t *testing.T) { | |||||
| 33 | {"overlay layer", func(s *ports.Scene) { | 33 | {"overlay layer", func(s *ports.Scene) { | ||
| 34 | s.Layers = []ports.SceneLayer{{ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 10, H: 10}}} | 34 | s.Layers = []ports.SceneLayer{{ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 10, H: 10}}} | ||
| 35 | }, nil, "overlay_surface"}, | 35 | }, nil, "overlay_surface"}, | ||
| 36 | - | {"top layer covered", func(s *ports.Scene) { | 36 | + | {"top layer", func(s *ports.Scene) { |
| 37 | s.Layers = []ports.SceneLayer{{ID: 3, Layer: ports.LayerTop, Rect: ports.Rect{W: 10, H: 10}}} | 37 | s.Layers = []ports.SceneLayer{{ID: 3, Layer: ports.LayerTop, Rect: ports.Rect{W: 10, H: 10}}} | ||
| 38 | + | }, nil, "overlay_surface"}, | |||
| 39 | + | {"background layer", func(s *ports.Scene) { | |||
| 40 | + | s.Layers = []ports.SceneLayer{{ID: 3, Layer: ports.LayerBackground, Rect: ports.Rect{W: 10, H: 10}}} | |||
| 38 | }, nil, ""}, | 41 | }, nil, ""}, | ||
| 39 | {"shm", nil, func(c *ports.SurfaceContent) { c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} }, "not_dmabuf"}, | 42 | {"shm", nil, func(c *ports.SurfaceContent) { c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} }, "not_dmabuf"}, | ||
| 40 | {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"}, | 43 | {"subsurface", nil, func(c *ports.SurfaceContent) { c.Children = []ports.Subsurface{{}} }, "subsurfaces"}, | ||
M internal/adapters/vulkan/renderer_test.go +2 -2
| @@ -203,9 +203,9 @@ func TestRendererLayers(t *testing.T) { | |||
| 203 | 203 | check(color.RGBA{255, 0, 0, 255}) | |
| 204 | 204 | scene.Layers[0].Layer = ports.LayerBottom | |
| 205 | 205 | check(window) | |
| 206 | + | // Core leaves hidden layers out of the scene; the renderer draws what | |
| 207 | + | // it gets, over a fullscreen window too. | |
| 206 | 208 | scene.Windows[0].Fullscreen = true | |
| 207 | - | scene.Layers[0].Layer = ports.LayerTop | |
| 208 | - | check(window) | |
| 209 | 209 | scene.Layers[0].Layer = ports.LayerOverlay | |
| 210 | 210 | check(color.RGBA{255, 0, 0, 255}) | |
| 211 | 211 | scene.Windows[0].Hidden = true | |
| @@ -203,9 +203,9 @@ func TestRendererLayers(t *testing.T) { | |||||
| 203 | check(color.RGBA{255, 0, 0, 255}) | 203 | check(color.RGBA{255, 0, 0, 255}) | ||
| 204 | scene.Layers[0].Layer = ports.LayerBottom | 204 | scene.Layers[0].Layer = ports.LayerBottom | ||
| 205 | check(window) | 205 | check(window) | ||
| 206 | + | // Core leaves hidden layers out of the scene; the renderer draws what | |||
| 207 | + | // it gets, over a fullscreen window too. | |||
| 206 | scene.Windows[0].Fullscreen = true | 208 | scene.Windows[0].Fullscreen = true | ||
| 207 | - | scene.Layers[0].Layer = ports.LayerTop | |||
| 208 | - | check(window) | |||
| 209 | scene.Layers[0].Layer = ports.LayerOverlay | 209 | scene.Layers[0].Layer = ports.LayerOverlay | ||
| 210 | check(color.RGBA{255, 0, 0, 255}) | 210 | check(color.RGBA{255, 0, 0, 255}) | ||
| 211 | scene.Windows[0].Hidden = true | 211 | scene.Windows[0].Hidden = true | ||
M internal/adapters/vulkan/scene.go +6 -11
| @@ -16,10 +16,9 @@ type sceneWalk struct { | |||
| 16 | 16 | contents map[ports.WindowID]ports.SurfaceContent | |
| 17 | 17 | dmg *damageRegion | |
| 18 | 18 | // scale maps the scene's logical pixels to the target's physical ones. | |
| 19 | - | scale float64 | |
| 20 | - | bounds image.Rectangle | |
| 21 | - | fullscreen bool | |
| 22 | - | draws []draw | |
| 19 | + | scale float64 | |
| 20 | + | bounds image.Rectangle | |
| 21 | + | draws []draw | |
| 23 | 22 | } | |
| 24 | 23 | ||
| 25 | 24 | // draws walks the scene into quads in paint order. | |
| @@ -30,9 +29,6 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 30 | 29 | if w.scale <= 0 { | |
| 31 | 30 | w.scale = 1 | |
| 32 | 31 | } | |
| 33 | - | for _, win := range s.Windows { | |
| 34 | - | w.fullscreen = w.fullscreen || (!win.Hidden && win.Fullscreen) | |
| 35 | - | } | |
| 36 | 32 | w.layers(false) | |
| 37 | 33 | w.windows() | |
| 38 | 34 | w.popups(false) | |
| @@ -64,12 +60,11 @@ func (w *sceneWalk) dim() { | |||
| 64 | 60 | } | |
| 65 | 61 | ||
| 66 | 62 | // layers draws the layer surfaces below the windows, or above them. | |
| 67 | - | // A fullscreen window hides the bottom and top layers. | |
| 63 | + | // The scene holds only the shown ones: core hides them under a fullscreen | |
| 64 | + | // window. | |
| 68 | 65 | func (w *sceneWalk) layers(afterWindows bool) { | |
| 69 | 66 | for _, layer := range w.s.Layers { | |
| 70 | - | if (layer.Layer >= ports.LayerTop) != afterWindows || | |
| 71 | - | (w.fullscreen && (layer.Layer == ports.LayerBottom || layer.Layer == ports.LayerTop)) || | |
| 72 | - | layer.Rect.W <= 0 || layer.Rect.H <= 0 { | |
| 67 | + | if (layer.Layer >= ports.LayerTop) != afterWindows || layer.Rect.W <= 0 || layer.Rect.H <= 0 { | |
| 73 | 68 | continue | |
| 74 | 69 | } | |
| 75 | 70 | content := w.contents[layer.ID] | |
| @@ -16,10 +16,9 @@ type sceneWalk struct { | |||||
| 16 | contents map[ports.WindowID]ports.SurfaceContent | 16 | contents map[ports.WindowID]ports.SurfaceContent | ||
| 17 | dmg *damageRegion | 17 | dmg *damageRegion | ||
| 18 | // scale maps the scene's logical pixels to the target's physical ones. | 18 | // scale maps the scene's logical pixels to the target's physical ones. | ||
| 19 | - | scale float64 | 19 | + | scale float64 |
| 20 | - | bounds image.Rectangle | 20 | + | bounds image.Rectangle |
| 21 | - | fullscreen bool | 21 | + | draws []draw |
| 22 | - | draws []draw | |||
| 23 | } | 22 | } | ||
| 24 | 23 | ||||
| 25 | // draws walks the scene into quads in paint order. | 24 | // draws walks the scene into quads in paint order. | ||
| @@ -30,9 +29,6 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 30 | if w.scale <= 0 { | 29 | if w.scale <= 0 { | ||
| 31 | w.scale = 1 | 30 | w.scale = 1 | ||
| 32 | } | 31 | } | ||
| 33 | - | for _, win := range s.Windows { | |||
| 34 | - | w.fullscreen = w.fullscreen || (!win.Hidden && win.Fullscreen) | |||
| 35 | - | } | |||
| 36 | w.layers(false) | 32 | w.layers(false) | ||
| 37 | w.windows() | 33 | w.windows() | ||
| 38 | w.popups(false) | 34 | w.popups(false) | ||
| @@ -64,12 +60,11 @@ func (w *sceneWalk) dim() { | |||||
| 64 | } | 60 | } | ||
| 65 | 61 | ||||
| 66 | // layers draws the layer surfaces below the windows, or above them. | 62 | // layers draws the layer surfaces below the windows, or above them. | ||
| 67 | - | // A fullscreen window hides the bottom and top layers. | 63 | + | // The scene holds only the shown ones: core hides them under a fullscreen |
| 64 | + | // window. | |||
| 68 | func (w *sceneWalk) layers(afterWindows bool) { | 65 | func (w *sceneWalk) layers(afterWindows bool) { | ||
| 69 | for _, layer := range w.s.Layers { | 66 | for _, layer := range w.s.Layers { | ||
| 70 | - | if (layer.Layer >= ports.LayerTop) != afterWindows || | 67 | + | if (layer.Layer >= ports.LayerTop) != afterWindows || layer.Rect.W <= 0 || layer.Rect.H <= 0 { |
| 71 | - | (w.fullscreen && (layer.Layer == ports.LayerBottom || layer.Layer == ports.LayerTop)) || | |||
| 72 | - | layer.Rect.W <= 0 || layer.Rect.H <= 0 { | |||
| 73 | continue | 68 | continue | ||
| 74 | } | 69 | } | ||
| 75 | content := w.contents[layer.ID] | 70 | content := w.contents[layer.ID] | ||
M internal/core/action.go +2 -2
| @@ -177,7 +177,7 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 177 | 177 | return Effect{} | |
| 178 | 178 | } | |
| 179 | 179 | c.focusScreen = i | |
| 180 | - | c.cur().mon.Current().expelTo(id, dir) | |
| 180 | + | c.cur().mon.landing().expelTo(id, dir) | |
| 181 | 181 | from.mon.normalize() | |
| 182 | 182 | c.cur().mon.normalize() | |
| 183 | 183 | return Effect{} | |
| @@ -197,7 +197,7 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 197 | 197 | } | |
| 198 | 198 | from := c.cur() | |
| 199 | 199 | c.focusScreen = i | |
| 200 | - | to := c.cur().mon.Current() | |
| 200 | + | to := c.cur().mon.landing() | |
| 201 | 201 | at := 0 | |
| 202 | 202 | if dir < 0 { | |
| 203 | 203 | at = len(to.Columns) | |
| @@ -177,7 +177,7 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 177 | return Effect{} | 177 | return Effect{} | ||
| 178 | } | 178 | } | ||
| 179 | c.focusScreen = i | 179 | c.focusScreen = i | ||
| 180 | - | c.cur().mon.Current().expelTo(id, dir) | 180 | + | c.cur().mon.landing().expelTo(id, dir) |
| 181 | from.mon.normalize() | 181 | from.mon.normalize() | ||
| 182 | c.cur().mon.normalize() | 182 | c.cur().mon.normalize() | ||
| 183 | return Effect{} | 183 | return Effect{} | ||
| @@ -197,7 +197,7 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 197 | } | 197 | } | ||
| 198 | from := c.cur() | 198 | from := c.cur() | ||
| 199 | c.focusScreen = i | 199 | c.focusScreen = i | ||
| 200 | - | to := c.cur().mon.Current() | 200 | + | to := c.cur().mon.landing() |
| 201 | at := 0 | 201 | at := 0 | ||
| 202 | if dir < 0 { | 202 | if dir < 0 { | ||
| 203 | at = len(to.Columns) | 203 | at = len(to.Columns) | ||
M internal/core/core.go +8 -2
| @@ -377,7 +377,8 @@ func (c *Core) keyboardFocus() WindowID { | |||
| 377 | 377 | if g := c.grabFocus(); g != 0 { | |
| 378 | 378 | return g | |
| 379 | 379 | } | |
| 380 | - | if c.layerFocus != 0 && (!c.onDemand(c.layerFocus) || !maps.Equal(c.layerOver, c.windowFocus())) { | |
| 380 | + | // A layer hidden by a fullscreen window loses the keyboard. | |
| 381 | + | if c.layerFocus != 0 && (!c.onDemand(c.layerFocus) || !c.visible(c.layerFocus) || !maps.Equal(c.layerOver, c.windowFocus())) { | |
| 381 | 382 | c.layerFocus, c.layerOver = 0, nil | |
| 382 | 383 | } | |
| 383 | 384 | if c.layerFocus != 0 { | |
| @@ -442,7 +443,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 442 | 443 | for i, sc := range c.screens { | |
| 443 | 444 | c.seq++ | |
| 444 | 445 | o := sc.mon.Output() | |
| 445 | - | scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), sc.placed...)} | |
| 446 | + | scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)} | |
| 446 | 447 | layout := sc.mon.Layout() | |
| 447 | 448 | scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim) | |
| 448 | 449 | // Only the focused output lights the focused window's lines. | |
| @@ -1092,6 +1093,11 @@ func (c *Core) activate(ctx context.Context, id WindowID) error { | |||
| 1092 | 1093 | return nil | |
| 1093 | 1094 | } | |
| 1094 | 1095 | before := c.cur().mon.Current() | |
| 1096 | + | if full := w.cover(); full != 0 && full != id && w.origin != nil { | |
| 1097 | + | // The window hides under a fullscreen workspace: it goes home first. | |
| 1098 | + | s.mon.leaveFullscreen(w, false) | |
| 1099 | + | _, w = c.screenOf(id) | |
| 1100 | + | } | |
| 1095 | 1101 | if w != s.mon.Current() { | |
| 1096 | 1102 | s.mon.show(w) | |
| 1097 | 1103 | } | |
| @@ -377,7 +377,8 @@ func (c *Core) keyboardFocus() WindowID { | |||||
| 377 | if g := c.grabFocus(); g != 0 { | 377 | if g := c.grabFocus(); g != 0 { | ||
| 378 | return g | 378 | return g | ||
| 379 | } | 379 | } | ||
| 380 | - | if c.layerFocus != 0 && (!c.onDemand(c.layerFocus) || !maps.Equal(c.layerOver, c.windowFocus())) { | 380 | + | // A layer hidden by a fullscreen window loses the keyboard. |
| 381 | + | if c.layerFocus != 0 && (!c.onDemand(c.layerFocus) || !c.visible(c.layerFocus) || !maps.Equal(c.layerOver, c.windowFocus())) { | |||
| 381 | c.layerFocus, c.layerOver = 0, nil | 382 | c.layerFocus, c.layerOver = 0, nil | ||
| 382 | } | 383 | } | ||
| 383 | if c.layerFocus != 0 { | 384 | if c.layerFocus != 0 { | ||
| @@ -442,7 +443,7 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 442 | for i, sc := range c.screens { | 443 | for i, sc := range c.screens { | ||
| 443 | c.seq++ | 444 | c.seq++ | ||
| 444 | o := sc.mon.Output() | 445 | o := sc.mon.Output() | ||
| 445 | - | scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), sc.placed...)} | 446 | + | scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)} |
| 446 | layout := sc.mon.Layout() | 447 | layout := sc.mon.Layout() | ||
| 447 | scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim) | 448 | scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim) | ||
| 448 | // Only the focused output lights the focused window's lines. | 449 | // Only the focused output lights the focused window's lines. | ||
| @@ -1092,6 +1093,11 @@ func (c *Core) activate(ctx context.Context, id WindowID) error { | |||||
| 1092 | return nil | 1093 | return nil | ||
| 1093 | } | 1094 | } | ||
| 1094 | before := c.cur().mon.Current() | 1095 | before := c.cur().mon.Current() | ||
| 1096 | + | if full := w.cover(); full != 0 && full != id && w.origin != nil { | |||
| 1097 | + | // The window hides under a fullscreen workspace: it goes home first. | |||
| 1098 | + | s.mon.leaveFullscreen(w, false) | |||
| 1099 | + | _, w = c.screenOf(id) | |||
| 1100 | + | } | |||
| 1095 | if w != s.mon.Current() { | 1101 | if w != s.mon.Current() { | ||
| 1096 | s.mon.show(w) | 1102 | s.mon.show(w) | ||
| 1097 | } | 1103 | } | ||
M internal/core/core_test.go +11 -1
| @@ -698,7 +698,8 @@ func TestPopupChainDismissOrder(t *testing.T) { | |||
| 698 | 698 | } | |
| 699 | 699 | } | |
| 700 | 700 | ||
| 701 | - | // A dialog over a fullscreen window gets the pointer. | |
| 701 | + | // A dialog opened under a fullscreen window stays hidden and out of reach | |
| 702 | + | // of the pointer until the window leaves fullscreen. | |
| 702 | 703 | func TestFloatOverFullscreenHit(t *testing.T) { | |
| 703 | 704 | cfg := config.Defaults() | |
| 704 | 705 | cfg.Border.Width = 0 | |
| @@ -726,6 +727,15 @@ func TestFloatOverFullscreenHit(t *testing.T) { | |||
| 726 | 727 | <-commands | |
| 727 | 728 | } | |
| 728 | 729 | input <- ports.PointerMotion{X: 50, Y: 40} | |
| 730 | + | if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 1 { | |
| 731 | + | t.Fatalf("pointer went to %v, want the fullscreen window", v) | |
| 732 | + | } | |
| 733 | + | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: false} | |
| 734 | + | scene(t, scenes) | |
| 735 | + | for len(commands) > 0 { | |
| 736 | + | <-commands | |
| 737 | + | } | |
| 738 | + | input <- ports.PointerMotion{X: 50, Y: 41} | |
| 729 | 739 | if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 { | |
| 730 | 740 | t.Fatalf("pointer went to %v, want the dialog", v) | |
| 731 | 741 | } | |
| @@ -698,7 +698,8 @@ func TestPopupChainDismissOrder(t *testing.T) { | |||||
| 698 | } | 698 | } | ||
| 699 | } | 699 | } | ||
| 700 | 700 | ||||
| 701 | - | // A dialog over a fullscreen window gets the pointer. | 701 | + | // A dialog opened under a fullscreen window stays hidden and out of reach |
| 702 | + | // of the pointer until the window leaves fullscreen. | |||
| 702 | func TestFloatOverFullscreenHit(t *testing.T) { | 703 | func TestFloatOverFullscreenHit(t *testing.T) { | ||
| 703 | cfg := config.Defaults() | 704 | cfg := config.Defaults() | ||
| 704 | cfg.Border.Width = 0 | 705 | cfg.Border.Width = 0 | ||
| @@ -726,6 +727,15 @@ func TestFloatOverFullscreenHit(t *testing.T) { | |||||
| 726 | <-commands | 727 | <-commands | ||
| 727 | } | 728 | } | ||
| 728 | input <- ports.PointerMotion{X: 50, Y: 40} | 729 | input <- ports.PointerMotion{X: 50, Y: 40} | ||
| 730 | + | if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 1 { | |||
| 731 | + | t.Fatalf("pointer went to %v, want the fullscreen window", v) | |||
| 732 | + | } | |||
| 733 | + | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: false} | |||
| 734 | + | scene(t, scenes) | |||
| 735 | + | for len(commands) > 0 { | |||
| 736 | + | <-commands | |||
| 737 | + | } | |||
| 738 | + | input <- ports.PointerMotion{X: 50, Y: 41} | |||
| 729 | if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 { | 739 | if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 { | ||
| 730 | t.Fatalf("pointer went to %v, want the dialog", v) | 740 | t.Fatalf("pointer went to %v, want the dialog", v) | ||
| 731 | } | 741 | } | ||
M internal/core/floating_test.go +29 -1
| @@ -76,6 +76,32 @@ func TestToggleFloatingVisible(t *testing.T) { | |||
| 76 | 76 | } | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 79 | + | // A fullscreen window is exclusive: a new window waits hidden without | |
| 80 | + | // moving the view, in scroll and fixed overflow, and shows once it leaves. | |
| 81 | + | func TestExclusiveFullscreenNewWindow(t *testing.T) { | |
| 82 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} { | |
| 83 | + | w := workspace() | |
| 84 | + | w.Overflow = overflow | |
| 85 | + | w.AddWindow(1) | |
| 86 | + | w.SetFullscreen(1, true) | |
| 87 | + | w.AddWindow(2) | |
| 88 | + | for _, p := range w.Layout() { | |
| 89 | + | if p.ID == 1 && (!p.Fullscreen || !p.Focused || p.Hidden) || p.ID == 2 && !p.Hidden { | |
| 90 | + | t.Fatalf("overflow %v: %+v", overflow, p) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | if w.ToggleFloatingVisible(); w.floatsHidden { | |
| 94 | + | t.Fatal("floats toggled under fullscreen") | |
| 95 | + | } | |
| 96 | + | w.SetFullscreen(1, false) | |
| 97 | + | for _, p := range w.Layout() { | |
| 98 | + | if p.ID == 2 && p.Hidden { | |
| 99 | + | t.Fatalf("overflow %v: new window still hidden", overflow) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 79 | 105 | // A fullscreen window does not float until it leaves fullscreen. | |
| 80 | 106 | func TestToggleWindowFloatingIgnoresFullscreen(t *testing.T) { | |
| 81 | 107 | w := workspace() | |
| @@ -244,7 +270,7 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) { | |||
| 244 | 270 | w.ToggleWindowFloating() | |
| 245 | 271 | w.SetFullscreen(1, true) | |
| 246 | 272 | check(0) | |
| 247 | - | // A dialog shown over a fullscreen tile dims it. | |
| 273 | + | // A dialog of a fullscreen tile waits hidden: no veil over the game. | |
| 248 | 274 | w.SetFullscreen(1, false) | |
| 249 | 275 | w.RemoveWindow(1) | |
| 250 | 276 | w.RemoveWindow(2) | |
| @@ -252,5 +278,7 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) { | |||
| 252 | 278 | w.SetFullscreen(3, true) | |
| 253 | 279 | check(0) | |
| 254 | 280 | w.AddFloating(4, 20, 10) | |
| 281 | + | check(0) | |
| 282 | + | w.SetFullscreen(3, false) | |
| 255 | 283 | check(0.3) | |
| 256 | 284 | } | |
| @@ -76,6 +76,32 @@ func TestToggleFloatingVisible(t *testing.T) { | |||||
| 76 | } | 76 | } | ||
| 77 | } | 77 | } | ||
| 78 | 78 | ||||
| 79 | + | // A fullscreen window is exclusive: a new window waits hidden without | |||
| 80 | + | // moving the view, in scroll and fixed overflow, and shows once it leaves. | |||
| 81 | + | func TestExclusiveFullscreenNewWindow(t *testing.T) { | |||
| 82 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} { | |||
| 83 | + | w := workspace() | |||
| 84 | + | w.Overflow = overflow | |||
| 85 | + | w.AddWindow(1) | |||
| 86 | + | w.SetFullscreen(1, true) | |||
| 87 | + | w.AddWindow(2) | |||
| 88 | + | for _, p := range w.Layout() { | |||
| 89 | + | if p.ID == 1 && (!p.Fullscreen || !p.Focused || p.Hidden) || p.ID == 2 && !p.Hidden { | |||
| 90 | + | t.Fatalf("overflow %v: %+v", overflow, p) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | if w.ToggleFloatingVisible(); w.floatsHidden { | |||
| 94 | + | t.Fatal("floats toggled under fullscreen") | |||
| 95 | + | } | |||
| 96 | + | w.SetFullscreen(1, false) | |||
| 97 | + | for _, p := range w.Layout() { | |||
| 98 | + | if p.ID == 2 && p.Hidden { | |||
| 99 | + | t.Fatalf("overflow %v: new window still hidden", overflow) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 79 | // A fullscreen window does not float until it leaves fullscreen. | 105 | // A fullscreen window does not float until it leaves fullscreen. | ||
| 80 | func TestToggleWindowFloatingIgnoresFullscreen(t *testing.T) { | 106 | func TestToggleWindowFloatingIgnoresFullscreen(t *testing.T) { | ||
| 81 | w := workspace() | 107 | w := workspace() | ||
| @@ -244,7 +270,7 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) { | |||||
| 244 | w.ToggleWindowFloating() | 270 | w.ToggleWindowFloating() | ||
| 245 | w.SetFullscreen(1, true) | 271 | w.SetFullscreen(1, true) | ||
| 246 | check(0) | 272 | check(0) | ||
| 247 | - | // A dialog shown over a fullscreen tile dims it. | 273 | + | // A dialog of a fullscreen tile waits hidden: no veil over the game. |
| 248 | w.SetFullscreen(1, false) | 274 | w.SetFullscreen(1, false) | ||
| 249 | w.RemoveWindow(1) | 275 | w.RemoveWindow(1) | ||
| 250 | w.RemoveWindow(2) | 276 | w.RemoveWindow(2) | ||
| @@ -252,5 +278,7 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) { | |||||
| 252 | w.SetFullscreen(3, true) | 278 | w.SetFullscreen(3, true) | ||
| 253 | check(0) | 279 | check(0) | ||
| 254 | w.AddFloating(4, 20, 10) | 280 | w.AddFloating(4, 20, 10) | ||
| 281 | + | check(0) | |||
| 282 | + | w.SetFullscreen(3, false) | |||
| 255 | check(0.3) | 283 | check(0.3) | ||
| 256 | } | 284 | } | ||
M internal/core/layer_input_test.go +2 -1
| @@ -215,10 +215,11 @@ func TestLayerHitOrder(t *testing.T) { | |||
| 215 | 215 | hit(50, 40, 1) | |
| 216 | 216 | hit(50, 5, 6) | |
| 217 | 217 | hit(50, 75, 7) | |
| 218 | + | // A fullscreen window is exclusive: bars and notifications are hidden. | |
| 218 | 219 | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true} | |
| 219 | 220 | scene(t, scenes) | |
| 220 | 221 | hit(50, 5, 1) | |
| 221 | - | hit(50, 75, 7) | |
| 222 | + | hit(50, 75, 1) | |
| 222 | 223 | } | |
| 223 | 224 | ||
| 224 | 225 | // The pointer moving to another output does not take the keyboard from a | |
| @@ -215,10 +215,11 @@ func TestLayerHitOrder(t *testing.T) { | |||||
| 215 | hit(50, 40, 1) | 215 | hit(50, 40, 1) | ||
| 216 | hit(50, 5, 6) | 216 | hit(50, 5, 6) | ||
| 217 | hit(50, 75, 7) | 217 | hit(50, 75, 7) | ||
| 218 | + | // A fullscreen window is exclusive: bars and notifications are hidden. | |||
| 218 | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true} | 219 | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true} | ||
| 219 | scene(t, scenes) | 220 | scene(t, scenes) | ||
| 220 | hit(50, 5, 1) | 221 | hit(50, 5, 1) | ||
| 221 | - | hit(50, 75, 7) | 222 | + | hit(50, 75, 1) |
| 222 | } | 223 | } | ||
| 223 | 224 | ||||
| 224 | // The pointer moving to another output does not take the keyboard from a | 225 | // The pointer moving to another output does not take the keyboard from a | ||
M internal/core/layers.go +30 -10
| @@ -86,30 +86,50 @@ func (c *Core) layerOf(id WindowID) (*screen, ports.SceneLayer, bool) { | |||
| 86 | 86 | return nil, ports.SceneLayer{}, false | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | - | // hasFullscreen reports whether a visible fullscreen window covers the screen. | |
| 89 | + | // hasFullscreen reports whether a fullscreen window covers the screen. In | |
| 90 | + | // scroll overflow, one scrolled off by the user no longer does. | |
| 90 | 91 | func hasFullscreen(sc *screen) bool { | |
| 91 | - | for _, p := range sc.mon.Layout() { | |
| 92 | - | if p.Fullscreen && !p.Hidden { | |
| 93 | - | return true | |
| 92 | + | return sc.mon.Current().cover() != 0 | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // shown reports whether a layer surface is drawn. A fullscreen window is | |
| 96 | + | // exclusive: it hides every layer above the background (bars, | |
| 97 | + | // notifications), so the output can scan it out. A surface taking the | |
| 98 | + | // keyboard exclusively (a locker, a launcher) still shows: it has the | |
| 99 | + | // keyboard and must be seen. | |
| 100 | + | func shown(sc *screen, l ports.SceneLayer) bool { | |
| 101 | + | return l.Layer == ports.LayerBackground || !hasFullscreen(sc) || sc.exclusiveKeyboard(l.ID) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // exclusiveKeyboard reports whether layer surface id takes the keyboard | |
| 105 | + | // exclusively. | |
| 106 | + | func (sc *screen) exclusiveKeyboard(id WindowID) bool { | |
| 107 | + | for _, l := range sc.layers { | |
| 108 | + | if l.ID == id { | |
| 109 | + | return l.Keyboard == 1 | |
| 94 | 110 | } | |
| 95 | 111 | } | |
| 96 | 112 | return false | |
| 97 | 113 | } | |
| 98 | 114 | ||
| 99 | - | // shown reports whether a layer is drawn: a fullscreen window hides the | |
| 100 | - | // bottom and top layers, as the renderer does. | |
| 101 | - | func shown(sc *screen, layer ports.Layer) bool { | |
| 102 | - | return !(hasFullscreen(sc) && (layer == ports.LayerBottom || layer == ports.LayerTop)) | |
| 115 | + | // shownLayers are the placed layer surfaces drawn on the screen. | |
| 116 | + | func shownLayers(sc *screen) []ports.SceneLayer { | |
| 117 | + | out := make([]ports.SceneLayer, 0, len(sc.placed)) | |
| 118 | + | for _, l := range sc.placed { | |
| 119 | + | if shown(sc, l) { | |
| 120 | + | out = append(out, l) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | return out | |
| 103 | 124 | } | |
| 104 | 125 | ||
| 105 | 126 | // layerAt returns the topmost shown layer surface under the output-local | |
| 106 | 127 | // point among those above (or below) the windows. | |
| 107 | 128 | func (c *Core) layerAt(sc *screen, lx, ly float64, above bool) (WindowID, float64, float64) { | |
| 108 | - | full := hasFullscreen(sc) | |
| 109 | 129 | for i := len(sc.placed) - 1; i >= 0; i-- { | |
| 110 | 130 | l := sc.placed[i] | |
| 111 | 131 | r := l.Rect | |
| 112 | - | if (l.Layer >= ports.LayerTop) != above || (full && (l.Layer == ports.LayerBottom || l.Layer == ports.LayerTop)) || r.W <= 0 || r.H <= 0 { | |
| 132 | + | if (l.Layer >= ports.LayerTop) != above || !shown(sc, l) || r.W <= 0 || r.H <= 0 { | |
| 113 | 133 | continue | |
| 114 | 134 | } | |
| 115 | 135 | if lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) && c.acceptsInput(l.ID, lx-float64(r.X), ly-float64(r.Y)) { | |
| @@ -86,30 +86,50 @@ func (c *Core) layerOf(id WindowID) (*screen, ports.SceneLayer, bool) { | |||||
| 86 | return nil, ports.SceneLayer{}, false | 86 | return nil, ports.SceneLayer{}, false | ||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | - | // hasFullscreen reports whether a visible fullscreen window covers the screen. | 89 | + | // hasFullscreen reports whether a fullscreen window covers the screen. In |
| 90 | + | // scroll overflow, one scrolled off by the user no longer does. | |||
| 90 | func hasFullscreen(sc *screen) bool { | 91 | func hasFullscreen(sc *screen) bool { | ||
| 91 | - | for _, p := range sc.mon.Layout() { | 92 | + | return sc.mon.Current().cover() != 0 |
| 92 | - | if p.Fullscreen && !p.Hidden { | 93 | + | } |
| 93 | - | return true | 94 | + | |
| 95 | + | // shown reports whether a layer surface is drawn. A fullscreen window is | |||
| 96 | + | // exclusive: it hides every layer above the background (bars, | |||
| 97 | + | // notifications), so the output can scan it out. A surface taking the | |||
| 98 | + | // keyboard exclusively (a locker, a launcher) still shows: it has the | |||
| 99 | + | // keyboard and must be seen. | |||
| 100 | + | func shown(sc *screen, l ports.SceneLayer) bool { | |||
| 101 | + | return l.Layer == ports.LayerBackground || !hasFullscreen(sc) || sc.exclusiveKeyboard(l.ID) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // exclusiveKeyboard reports whether layer surface id takes the keyboard | |||
| 105 | + | // exclusively. | |||
| 106 | + | func (sc *screen) exclusiveKeyboard(id WindowID) bool { | |||
| 107 | + | for _, l := range sc.layers { | |||
| 108 | + | if l.ID == id { | |||
| 109 | + | return l.Keyboard == 1 | |||
| 94 | } | 110 | } | ||
| 95 | } | 111 | } | ||
| 96 | return false | 112 | return false | ||
| 97 | } | 113 | } | ||
| 98 | 114 | ||||
| 99 | - | // shown reports whether a layer is drawn: a fullscreen window hides the | 115 | + | // shownLayers are the placed layer surfaces drawn on the screen. |
| 100 | - | // bottom and top layers, as the renderer does. | 116 | + | func shownLayers(sc *screen) []ports.SceneLayer { |
| 101 | - | func shown(sc *screen, layer ports.Layer) bool { | 117 | + | out := make([]ports.SceneLayer, 0, len(sc.placed)) |
| 102 | - | return !(hasFullscreen(sc) && (layer == ports.LayerBottom || layer == ports.LayerTop)) | 118 | + | for _, l := range sc.placed { |
| 119 | + | if shown(sc, l) { | |||
| 120 | + | out = append(out, l) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | return out | |||
| 103 | } | 124 | } | ||
| 104 | 125 | ||||
| 105 | // layerAt returns the topmost shown layer surface under the output-local | 126 | // layerAt returns the topmost shown layer surface under the output-local | ||
| 106 | // point among those above (or below) the windows. | 127 | // point among those above (or below) the windows. | ||
| 107 | func (c *Core) layerAt(sc *screen, lx, ly float64, above bool) (WindowID, float64, float64) { | 128 | func (c *Core) layerAt(sc *screen, lx, ly float64, above bool) (WindowID, float64, float64) { | ||
| 108 | - | full := hasFullscreen(sc) | |||
| 109 | for i := len(sc.placed) - 1; i >= 0; i-- { | 129 | for i := len(sc.placed) - 1; i >= 0; i-- { | ||
| 110 | l := sc.placed[i] | 130 | l := sc.placed[i] | ||
| 111 | r := l.Rect | 131 | r := l.Rect | ||
| 112 | - | if (l.Layer >= ports.LayerTop) != above || (full && (l.Layer == ports.LayerBottom || l.Layer == ports.LayerTop)) || r.W <= 0 || r.H <= 0 { | 132 | + | if (l.Layer >= ports.LayerTop) != above || !shown(sc, l) || r.W <= 0 || r.H <= 0 { |
| 113 | continue | 133 | continue | ||
| 114 | } | 134 | } | ||
| 115 | if lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) && c.acceptsInput(l.ID, lx-float64(r.X), ly-float64(r.Y)) { | 135 | if lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) && c.acceptsInput(l.ID, lx-float64(r.X), ly-float64(r.Y)) { | ||
M internal/core/layers_test.go +86 -0
| @@ -108,3 +108,89 @@ func TestLayerChangedChannels(t *testing.T) { | |||
| 108 | 108 | cancel() | |
| 109 | 109 | <-done | |
| 110 | 110 | } | |
| 111 | + | ||
| 112 | + | // In scroll overflow, a fullscreen column the user scrolled off no longer | |
| 113 | + | // covers the output: the layers show again. | |
| 114 | + | func TestScrolledOffFullscreenShowsLayers(t *testing.T) { | |
| 115 | + | m := newMonitor("", "") | |
| 116 | + | m.SetOutput(100, 80) | |
| 117 | + | m.AddWindow(1) | |
| 118 | + | m.SetFullscreen(1, true) | |
| 119 | + | m.AddWindow(2) | |
| 120 | + | sc := &screen{mon: m} | |
| 121 | + | bar := ports.SceneLayer{ID: 6, Layer: ports.LayerTop} | |
| 122 | + | if shown(sc, bar) { | |
| 123 | + | t.Fatal("bar shown over fullscreen") | |
| 124 | + | } | |
| 125 | + | m.Current().FocusID(2) | |
| 126 | + | if !shown(sc, bar) { | |
| 127 | + | t.Fatal("bar hidden with the fullscreen column scrolled off") | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // A fullscreen window is exclusive: the scene carries only the background | |
| 132 | + | // and a layer taking the keyboard exclusively (a locker), so the output | |
| 133 | + | // can scan the window out. | |
| 134 | + | func TestFullscreenSceneLayers(t *testing.T) { | |
| 135 | + | client := make(chan ports.ClientEvent) | |
| 136 | + | output := make(chan ports.OutputEvent) | |
| 137 | + | commands := make(chan ports.ClientCommand, 64) | |
| 138 | + | scenes := make(chan []ports.Scene, 1) | |
| 139 | + | cfg := ports.Config{} | |
| 140 | + | cfg.Keyboard.CmdKey = "super" | |
| 141 | + | cfg.Layout.MaxColumns = 2 | |
| 142 | + | c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}) | |
| 143 | + | if err != nil { | |
| 144 | + | t.Fatal(err) | |
| 145 | + | } | |
| 146 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 147 | + | defer cancel() | |
| 148 | + | done := make(chan struct{}) | |
| 149 | + | go func() { defer close(done); _ = c.Run(ctx) }() | |
| 150 | + | go func() { | |
| 151 | + | for range commands { | |
| 152 | + | } | |
| 153 | + | }() | |
| 154 | + | recv := func() ports.Scene { | |
| 155 | + | t.Helper() | |
| 156 | + | select { | |
| 157 | + | case s := <-scenes: | |
| 158 | + | return s[0] | |
| 159 | + | case <-time.After(time.Second): | |
| 160 | + | t.Fatal("scene timeout") | |
| 161 | + | return ports.Scene{} | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | layers := func(s ports.Scene) []ports.WindowID { | |
| 165 | + | var ids []ports.WindowID | |
| 166 | + | for _, l := range s.Layers { | |
| 167 | + | ids = append(ids, l.ID) | |
| 168 | + | } | |
| 169 | + | return ids | |
| 170 | + | } | |
| 171 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 172 | + | recv() | |
| 173 | + | client <- ports.WindowMapped{ID: 1} | |
| 174 | + | recv() | |
| 175 | + | all := []ports.LayerSurface{ | |
| 176 | + | {ID: 5, Layer: ports.LayerBackground, Anchor: 15}, | |
| 177 | + | {ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10}, | |
| 178 | + | {ID: 7, Layer: ports.LayerOverlay, Width: 20, Height: 10}, | |
| 179 | + | } | |
| 180 | + | client <- ports.LayerChanged{Layers: all} | |
| 181 | + | if got := layers(recv()); len(got) != 3 { | |
| 182 | + | t.Fatalf("layers %v", got) | |
| 183 | + | } | |
| 184 | + | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true} | |
| 185 | + | if got := layers(recv()); len(got) != 1 || got[0] != 5 { | |
| 186 | + | t.Fatalf("fullscreen layers %v, want the background", got) | |
| 187 | + | } | |
| 188 | + | locker := ports.LayerSurface{ID: 8, Layer: ports.LayerOverlay, Anchor: 15, Keyboard: 1} | |
| 189 | + | client <- ports.LayerChanged{Layers: append(all, locker)} | |
| 190 | + | if got := layers(recv()); len(got) != 2 || got[1] != 8 { | |
| 191 | + | t.Fatalf("fullscreen layers %v, want the background and the locker", got) | |
| 192 | + | } | |
| 193 | + | cancel() | |
| 194 | + | <-done | |
| 195 | + | close(commands) | |
| 196 | + | } | |
| @@ -108,3 +108,89 @@ func TestLayerChangedChannels(t *testing.T) { | |||||
| 108 | cancel() | 108 | cancel() | ||
| 109 | <-done | 109 | <-done | ||
| 110 | } | 110 | } | ||
| 111 | + | ||||
| 112 | + | // In scroll overflow, a fullscreen column the user scrolled off no longer | |||
| 113 | + | // covers the output: the layers show again. | |||
| 114 | + | func TestScrolledOffFullscreenShowsLayers(t *testing.T) { | |||
| 115 | + | m := newMonitor("", "") | |||
| 116 | + | m.SetOutput(100, 80) | |||
| 117 | + | m.AddWindow(1) | |||
| 118 | + | m.SetFullscreen(1, true) | |||
| 119 | + | m.AddWindow(2) | |||
| 120 | + | sc := &screen{mon: m} | |||
| 121 | + | bar := ports.SceneLayer{ID: 6, Layer: ports.LayerTop} | |||
| 122 | + | if shown(sc, bar) { | |||
| 123 | + | t.Fatal("bar shown over fullscreen") | |||
| 124 | + | } | |||
| 125 | + | m.Current().FocusID(2) | |||
| 126 | + | if !shown(sc, bar) { | |||
| 127 | + | t.Fatal("bar hidden with the fullscreen column scrolled off") | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // A fullscreen window is exclusive: the scene carries only the background | |||
| 132 | + | // and a layer taking the keyboard exclusively (a locker), so the output | |||
| 133 | + | // can scan the window out. | |||
| 134 | + | func TestFullscreenSceneLayers(t *testing.T) { | |||
| 135 | + | client := make(chan ports.ClientEvent) | |||
| 136 | + | output := make(chan ports.OutputEvent) | |||
| 137 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 138 | + | scenes := make(chan []ports.Scene, 1) | |||
| 139 | + | cfg := ports.Config{} | |||
| 140 | + | cfg.Keyboard.CmdKey = "super" | |||
| 141 | + | cfg.Layout.MaxColumns = 2 | |||
| 142 | + | c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}) | |||
| 143 | + | if err != nil { | |||
| 144 | + | t.Fatal(err) | |||
| 145 | + | } | |||
| 146 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 147 | + | defer cancel() | |||
| 148 | + | done := make(chan struct{}) | |||
| 149 | + | go func() { defer close(done); _ = c.Run(ctx) }() | |||
| 150 | + | go func() { | |||
| 151 | + | for range commands { | |||
| 152 | + | } | |||
| 153 | + | }() | |||
| 154 | + | recv := func() ports.Scene { | |||
| 155 | + | t.Helper() | |||
| 156 | + | select { | |||
| 157 | + | case s := <-scenes: | |||
| 158 | + | return s[0] | |||
| 159 | + | case <-time.After(time.Second): | |||
| 160 | + | t.Fatal("scene timeout") | |||
| 161 | + | return ports.Scene{} | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | layers := func(s ports.Scene) []ports.WindowID { | |||
| 165 | + | var ids []ports.WindowID | |||
| 166 | + | for _, l := range s.Layers { | |||
| 167 | + | ids = append(ids, l.ID) | |||
| 168 | + | } | |||
| 169 | + | return ids | |||
| 170 | + | } | |||
| 171 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 172 | + | recv() | |||
| 173 | + | client <- ports.WindowMapped{ID: 1} | |||
| 174 | + | recv() | |||
| 175 | + | all := []ports.LayerSurface{ | |||
| 176 | + | {ID: 5, Layer: ports.LayerBackground, Anchor: 15}, | |||
| 177 | + | {ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10}, | |||
| 178 | + | {ID: 7, Layer: ports.LayerOverlay, Width: 20, Height: 10}, | |||
| 179 | + | } | |||
| 180 | + | client <- ports.LayerChanged{Layers: all} | |||
| 181 | + | if got := layers(recv()); len(got) != 3 { | |||
| 182 | + | t.Fatalf("layers %v", got) | |||
| 183 | + | } | |||
| 184 | + | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true} | |||
| 185 | + | if got := layers(recv()); len(got) != 1 || got[0] != 5 { | |||
| 186 | + | t.Fatalf("fullscreen layers %v, want the background", got) | |||
| 187 | + | } | |||
| 188 | + | locker := ports.LayerSurface{ID: 8, Layer: ports.LayerOverlay, Anchor: 15, Keyboard: 1} | |||
| 189 | + | client <- ports.LayerChanged{Layers: append(all, locker)} | |||
| 190 | + | if got := layers(recv()); len(got) != 2 || got[1] != 8 { | |||
| 191 | + | t.Fatalf("fullscreen layers %v, want the background and the locker", got) | |||
| 192 | + | } | |||
| 193 | + | cancel() | |||
| 194 | + | <-done | |||
| 195 | + | close(commands) | |||
| 196 | + | } | |||
M internal/core/layout.go +54 -16
| @@ -206,7 +206,7 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) { | |||
| 206 | 206 | } | |
| 207 | 207 | if w.floatsHidden { | |
| 208 | 208 | // A new float must be seen: show the hidden ones with it. | |
| 209 | - | w.ToggleFloatingVisible() | |
| 209 | + | w.showFloats() | |
| 210 | 210 | } | |
| 211 | 211 | w.Floats = append(w.Floats, Float{ID: id, W: width, H: height}) | |
| 212 | 212 | w.floatFocus = true | |
| @@ -237,6 +237,10 @@ func (w *Workspace) has(id WindowID) bool { | |||
| 237 | 237 | } | |
| 238 | 238 | ||
| 239 | 239 | func (w *Workspace) Focused() (WindowID, bool) { | |
| 240 | + | // Nothing else is drawn: the covering fullscreen window has the focus. | |
| 241 | + | if full := w.cover(); full != 0 { | |
| 242 | + | return full, true | |
| 243 | + | } | |
| 240 | 244 | if w.floatFocus && !w.floatsHidden && len(w.Floats) > 0 { | |
| 241 | 245 | return w.Floats[len(w.Floats)-1].ID, true | |
| 242 | 246 | } | |
| @@ -253,6 +257,16 @@ func (w *Workspace) AddWindow(id WindowID) { | |||
| 253 | 257 | if id == 0 || w.has(id) { | |
| 254 | 258 | return | |
| 255 | 259 | } | |
| 260 | + | if w.cover() != 0 { | |
| 261 | + | // A new window never moves the view off a covering fullscreen | |
| 262 | + | // window (ADR 011): it waits, hidden, after the focused column. | |
| 263 | + | at := len(w.Columns) | |
| 264 | + | if w.Overflow != OverflowFixed { | |
| 265 | + | at = w.Focus + 1 | |
| 266 | + | } | |
| 267 | + | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}}) | |
| 268 | + | return | |
| 269 | + | } | |
| 256 | 270 | w.addColumn(Column{Windows: []WindowID{id}}) | |
| 257 | 271 | } | |
| 258 | 272 | ||
| @@ -608,10 +622,10 @@ func (w *Workspace) ToggleFullscreen() { | |||
| 608 | 622 | w.scroll() | |
| 609 | 623 | } | |
| 610 | 624 | ||
| 611 | - | // Activate focuses a window and makes it visible: a tiled window leaves | |
| 612 | - | // another window's fullscreen, which would hide it. | |
| 625 | + | // Activate focuses a window and makes it visible: it leaves another | |
| 626 | + | // window's fullscreen, which hides everything else. | |
| 613 | 627 | func (w *Workspace) Activate(id WindowID) { | |
| 614 | - | if w.fullscreen != 0 && w.fullscreen != id && w.floatIndex(id) < 0 { | |
| 628 | + | if w.fullscreen != 0 && w.fullscreen != id { | |
| 615 | 629 | w.fullscreen = 0 | |
| 616 | 630 | } | |
| 617 | 631 | w.FocusID(id) | |
| @@ -689,6 +703,25 @@ func (w *Workspace) SetMaxColumns(n int) { | |||
| 689 | 703 | w.scroll() | |
| 690 | 704 | } | |
| 691 | 705 | func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | |
| 706 | + | // cover returns the fullscreen window that covers the output, or 0. The | |
| 707 | + | // fullscreen is exclusive: while it covers, no other window of the | |
| 708 | + | // workspace is drawn, so the output can scan it out. | |
| 709 | + | func (w *Workspace) cover() WindowID { | |
| 710 | + | if w.fullscreen == 0 { | |
| 711 | + | return 0 | |
| 712 | + | } | |
| 713 | + | if w.floatIndex(w.fullscreen) >= 0 || w.Overflow == OverflowFixed { | |
| 714 | + | return w.fullscreen | |
| 715 | + | } | |
| 716 | + | // Scroll overflow: only while its column is aligned on the output. | |
| 717 | + | for i, c := range w.Columns { | |
| 718 | + | if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.ViewX == 0 { | |
| 719 | + | return w.fullscreen | |
| 720 | + | } | |
| 721 | + | } | |
| 722 | + | return 0 | |
| 723 | + | } | |
| 724 | + | ||
| 692 | 725 | func (w *Workspace) fullscreenColumn(i int) bool { | |
| 693 | 726 | for _, id := range w.Columns[i].Windows { | |
| 694 | 727 | if id == w.fullscreen && id != 0 { | |
| @@ -843,7 +876,7 @@ func (w *Workspace) Layout() []Placement { | |||
| 843 | 876 | gap := w.gap() | |
| 844 | 877 | cols := w.columnRects() | |
| 845 | 878 | focusedID, _ := w.Focused() | |
| 846 | - | floatFocused := w.floatIndex(focusedID) >= 0 | |
| 879 | + | cover := w.cover() | |
| 847 | 880 | for i, c := range w.Columns { | |
| 848 | 881 | col := cols[i] | |
| 849 | 882 | n := len(c.Windows) | |
| @@ -870,7 +903,7 @@ func (w *Workspace) Layout() []Placement { | |||
| 870 | 903 | // Fixed overflow cannot scroll to other columns while one fills | |
| 871 | 904 | // the view. Keep them in place, but out of the scene. | |
| 872 | 905 | maximized := w.Overflow == OverflowFixed && w.Columns[w.Focus].FullWidth && i != w.Focus | |
| 873 | - | hidden := ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized | |
| 906 | + | hidden := (cover != 0 && id != cover) || ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized | |
| 874 | 907 | if full { | |
| 875 | 908 | // Scroll mode aligns the view on the column; fixed never scrolls. | |
| 876 | 909 | r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H} | |
| @@ -881,22 +914,21 @@ func (w *Workspace) Layout() []Placement { | |||
| 881 | 914 | if hidden { | |
| 882 | 915 | r = Rect{} | |
| 883 | 916 | } | |
| 884 | - | focused := !floatFocused && i == w.Focus && j == c.Focus | |
| 885 | - | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden}) | |
| 917 | + | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden}) | |
| 886 | 918 | y += h + gap | |
| 887 | 919 | } | |
| 888 | 920 | } | |
| 889 | 921 | setNeighbors(result, gap) | |
| 890 | 922 | // Floating windows go last: they are drawn and hit on top. | |
| 891 | 923 | for _, f := range w.Floats { | |
| 892 | - | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll, Hidden: w.floatsHidden} | |
| 924 | + | // A covering fullscreen window hides every float, its own dialogs | |
| 925 | + | // too: they show again when it leaves fullscreen. | |
| 926 | + | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: w.floatsHidden || cover != 0 && f.ID != cover} | |
| 893 | 927 | if p.Hidden { | |
| 894 | 928 | p.Rect = Rect{} | |
| 895 | 929 | result = append(result, p) | |
| 896 | 930 | continue | |
| 897 | 931 | } | |
| 898 | - | // Floats stay above a fullscreen window: a dialog opened from a | |
| 899 | - | // fullscreen app must be seen. | |
| 900 | 932 | if w.fullscreen == f.ID { | |
| 901 | 933 | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | |
| 902 | 934 | } | |
| @@ -1015,19 +1047,25 @@ func (w *Workspace) ToggleWindowFloating() { | |||
| 1015 | 1047 | } | |
| 1016 | 1048 | ||
| 1017 | 1049 | // ToggleFloatingVisible hides every float without changing its placement | |
| 1018 | - | // when a float has the focus, and shows them again from any window. A | |
| 1019 | - | // fullscreen float (a game in scanout) is never hidden: hiding it drops | |
| 1020 | - | // scanout and VRR. | |
| 1050 | + | // when a float has the focus, and shows them again from any window. It | |
| 1051 | + | // does nothing under a covering fullscreen window (a game in scanout): | |
| 1052 | + | // hiding it would drop scanout and VRR. | |
| 1021 | 1053 | func (w *Workspace) ToggleFloatingVisible() { | |
| 1022 | - | if len(w.Floats) == 0 { | |
| 1054 | + | if len(w.Floats) == 0 || w.cover() != 0 { | |
| 1023 | 1055 | return | |
| 1024 | 1056 | } | |
| 1025 | 1057 | if !w.floatsHidden { | |
| 1026 | - | if w.floatFocus && w.floatIndex(w.fullscreen) < 0 { | |
| 1058 | + | if w.floatFocus { | |
| 1027 | 1059 | w.floatsHidden, w.floatFocus, w.hiddenFullscreen = true, false, 0 | |
| 1028 | 1060 | } | |
| 1029 | 1061 | return | |
| 1030 | 1062 | } | |
| 1063 | + | w.showFloats() | |
| 1064 | + | } | |
| 1065 | + | ||
| 1066 | + | // showFloats shows the hidden floats; the top one takes the focus and a | |
| 1067 | + | // hidden fullscreen float is fullscreen again. | |
| 1068 | + | func (w *Workspace) showFloats() { | |
| 1031 | 1069 | w.floatsHidden = false | |
| 1032 | 1070 | w.floatFocus = true | |
| 1033 | 1071 | if w.fullscreen == 0 && w.floatIndex(w.hiddenFullscreen) >= 0 { | |
| @@ -206,7 +206,7 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) { | |||||
| 206 | } | 206 | } | ||
| 207 | if w.floatsHidden { | 207 | if w.floatsHidden { | ||
| 208 | // A new float must be seen: show the hidden ones with it. | 208 | // A new float must be seen: show the hidden ones with it. | ||
| 209 | - | w.ToggleFloatingVisible() | 209 | + | w.showFloats() |
| 210 | } | 210 | } | ||
| 211 | w.Floats = append(w.Floats, Float{ID: id, W: width, H: height}) | 211 | w.Floats = append(w.Floats, Float{ID: id, W: width, H: height}) | ||
| 212 | w.floatFocus = true | 212 | w.floatFocus = true | ||
| @@ -237,6 +237,10 @@ func (w *Workspace) has(id WindowID) bool { | |||||
| 237 | } | 237 | } | ||
| 238 | 238 | ||||
| 239 | func (w *Workspace) Focused() (WindowID, bool) { | 239 | func (w *Workspace) Focused() (WindowID, bool) { | ||
| 240 | + | // Nothing else is drawn: the covering fullscreen window has the focus. | |||
| 241 | + | if full := w.cover(); full != 0 { | |||
| 242 | + | return full, true | |||
| 243 | + | } | |||
| 240 | if w.floatFocus && !w.floatsHidden && len(w.Floats) > 0 { | 244 | if w.floatFocus && !w.floatsHidden && len(w.Floats) > 0 { | ||
| 241 | return w.Floats[len(w.Floats)-1].ID, true | 245 | return w.Floats[len(w.Floats)-1].ID, true | ||
| 242 | } | 246 | } | ||
| @@ -253,6 +257,16 @@ func (w *Workspace) AddWindow(id WindowID) { | |||||
| 253 | if id == 0 || w.has(id) { | 257 | if id == 0 || w.has(id) { | ||
| 254 | return | 258 | return | ||
| 255 | } | 259 | } | ||
| 260 | + | if w.cover() != 0 { | |||
| 261 | + | // A new window never moves the view off a covering fullscreen | |||
| 262 | + | // window (ADR 011): it waits, hidden, after the focused column. | |||
| 263 | + | at := len(w.Columns) | |||
| 264 | + | if w.Overflow != OverflowFixed { | |||
| 265 | + | at = w.Focus + 1 | |||
| 266 | + | } | |||
| 267 | + | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}}) | |||
| 268 | + | return | |||
| 269 | + | } | |||
| 256 | w.addColumn(Column{Windows: []WindowID{id}}) | 270 | w.addColumn(Column{Windows: []WindowID{id}}) | ||
| 257 | } | 271 | } | ||
| 258 | 272 | ||||
| @@ -608,10 +622,10 @@ func (w *Workspace) ToggleFullscreen() { | |||||
| 608 | w.scroll() | 622 | w.scroll() | ||
| 609 | } | 623 | } | ||
| 610 | 624 | ||||
| 611 | - | // Activate focuses a window and makes it visible: a tiled window leaves | 625 | + | // Activate focuses a window and makes it visible: it leaves another |
| 612 | - | // another window's fullscreen, which would hide it. | 626 | + | // window's fullscreen, which hides everything else. |
| 613 | func (w *Workspace) Activate(id WindowID) { | 627 | func (w *Workspace) Activate(id WindowID) { | ||
| 614 | - | if w.fullscreen != 0 && w.fullscreen != id && w.floatIndex(id) < 0 { | 628 | + | if w.fullscreen != 0 && w.fullscreen != id { |
| 615 | w.fullscreen = 0 | 629 | w.fullscreen = 0 | ||
| 616 | } | 630 | } | ||
| 617 | w.FocusID(id) | 631 | w.FocusID(id) | ||
| @@ -689,6 +703,25 @@ func (w *Workspace) SetMaxColumns(n int) { | |||||
| 689 | w.scroll() | 703 | w.scroll() | ||
| 690 | } | 704 | } | ||
| 691 | func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | 705 | func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | ||
| 706 | + | // cover returns the fullscreen window that covers the output, or 0. The | |||
| 707 | + | // fullscreen is exclusive: while it covers, no other window of the | |||
| 708 | + | // workspace is drawn, so the output can scan it out. | |||
| 709 | + | func (w *Workspace) cover() WindowID { | |||
| 710 | + | if w.fullscreen == 0 { | |||
| 711 | + | return 0 | |||
| 712 | + | } | |||
| 713 | + | if w.floatIndex(w.fullscreen) >= 0 || w.Overflow == OverflowFixed { | |||
| 714 | + | return w.fullscreen | |||
| 715 | + | } | |||
| 716 | + | // Scroll overflow: only while its column is aligned on the output. | |||
| 717 | + | for i, c := range w.Columns { | |||
| 718 | + | if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.ViewX == 0 { | |||
| 719 | + | return w.fullscreen | |||
| 720 | + | } | |||
| 721 | + | } | |||
| 722 | + | return 0 | |||
| 723 | + | } | |||
| 724 | + | ||||
| 692 | func (w *Workspace) fullscreenColumn(i int) bool { | 725 | func (w *Workspace) fullscreenColumn(i int) bool { | ||
| 693 | for _, id := range w.Columns[i].Windows { | 726 | for _, id := range w.Columns[i].Windows { | ||
| 694 | if id == w.fullscreen && id != 0 { | 727 | if id == w.fullscreen && id != 0 { | ||
| @@ -843,7 +876,7 @@ func (w *Workspace) Layout() []Placement { | |||||
| 843 | gap := w.gap() | 876 | gap := w.gap() | ||
| 844 | cols := w.columnRects() | 877 | cols := w.columnRects() | ||
| 845 | focusedID, _ := w.Focused() | 878 | focusedID, _ := w.Focused() | ||
| 846 | - | floatFocused := w.floatIndex(focusedID) >= 0 | 879 | + | cover := w.cover() |
| 847 | for i, c := range w.Columns { | 880 | for i, c := range w.Columns { | ||
| 848 | col := cols[i] | 881 | col := cols[i] | ||
| 849 | n := len(c.Windows) | 882 | n := len(c.Windows) | ||
| @@ -870,7 +903,7 @@ func (w *Workspace) Layout() []Placement { | |||||
| 870 | // Fixed overflow cannot scroll to other columns while one fills | 903 | // Fixed overflow cannot scroll to other columns while one fills | ||
| 871 | // the view. Keep them in place, but out of the scene. | 904 | // the view. Keep them in place, but out of the scene. | ||
| 872 | maximized := w.Overflow == OverflowFixed && w.Columns[w.Focus].FullWidth && i != w.Focus | 905 | maximized := w.Overflow == OverflowFixed && w.Columns[w.Focus].FullWidth && i != w.Focus | ||
| 873 | - | hidden := ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized | 906 | + | hidden := (cover != 0 && id != cover) || ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized |
| 874 | if full { | 907 | if full { | ||
| 875 | // Scroll mode aligns the view on the column; fixed never scrolls. | 908 | // Scroll mode aligns the view on the column; fixed never scrolls. | ||
| 876 | r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H} | 909 | r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H} | ||
| @@ -881,22 +914,21 @@ func (w *Workspace) Layout() []Placement { | |||||
| 881 | if hidden { | 914 | if hidden { | ||
| 882 | r = Rect{} | 915 | r = Rect{} | ||
| 883 | } | 916 | } | ||
| 884 | - | focused := !floatFocused && i == w.Focus && j == c.Focus | 917 | + | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden}) |
| 885 | - | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden}) | |||
| 886 | y += h + gap | 918 | y += h + gap | ||
| 887 | } | 919 | } | ||
| 888 | } | 920 | } | ||
| 889 | setNeighbors(result, gap) | 921 | setNeighbors(result, gap) | ||
| 890 | // Floating windows go last: they are drawn and hit on top. | 922 | // Floating windows go last: they are drawn and hit on top. | ||
| 891 | for _, f := range w.Floats { | 923 | for _, f := range w.Floats { | ||
| 892 | - | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll, Hidden: w.floatsHidden} | 924 | + | // A covering fullscreen window hides every float, its own dialogs |
| 925 | + | // too: they show again when it leaves fullscreen. | |||
| 926 | + | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: w.floatsHidden || cover != 0 && f.ID != cover} | |||
| 893 | if p.Hidden { | 927 | if p.Hidden { | ||
| 894 | p.Rect = Rect{} | 928 | p.Rect = Rect{} | ||
| 895 | result = append(result, p) | 929 | result = append(result, p) | ||
| 896 | continue | 930 | continue | ||
| 897 | } | 931 | } | ||
| 898 | - | // Floats stay above a fullscreen window: a dialog opened from a | |||
| 899 | - | // fullscreen app must be seen. | |||
| 900 | if w.fullscreen == f.ID { | 932 | if w.fullscreen == f.ID { | ||
| 901 | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | 933 | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | ||
| 902 | } | 934 | } | ||
| @@ -1015,19 +1047,25 @@ func (w *Workspace) ToggleWindowFloating() { | |||||
| 1015 | } | 1047 | } | ||
| 1016 | 1048 | ||||
| 1017 | // ToggleFloatingVisible hides every float without changing its placement | 1049 | // ToggleFloatingVisible hides every float without changing its placement | ||
| 1018 | - | // when a float has the focus, and shows them again from any window. A | 1050 | + | // when a float has the focus, and shows them again from any window. It |
| 1019 | - | // fullscreen float (a game in scanout) is never hidden: hiding it drops | 1051 | + | // does nothing under a covering fullscreen window (a game in scanout): |
| 1020 | - | // scanout and VRR. | 1052 | + | // hiding it would drop scanout and VRR. |
| 1021 | func (w *Workspace) ToggleFloatingVisible() { | 1053 | func (w *Workspace) ToggleFloatingVisible() { | ||
| 1022 | - | if len(w.Floats) == 0 { | 1054 | + | if len(w.Floats) == 0 || w.cover() != 0 { |
| 1023 | return | 1055 | return | ||
| 1024 | } | 1056 | } | ||
| 1025 | if !w.floatsHidden { | 1057 | if !w.floatsHidden { | ||
| 1026 | - | if w.floatFocus && w.floatIndex(w.fullscreen) < 0 { | 1058 | + | if w.floatFocus { |
| 1027 | w.floatsHidden, w.floatFocus, w.hiddenFullscreen = true, false, 0 | 1059 | w.floatsHidden, w.floatFocus, w.hiddenFullscreen = true, false, 0 | ||
| 1028 | } | 1060 | } | ||
| 1029 | return | 1061 | return | ||
| 1030 | } | 1062 | } | ||
| 1063 | + | w.showFloats() | |||
| 1064 | + | } | |||
| 1065 | + | ||||
| 1066 | + | // showFloats shows the hidden floats; the top one takes the focus and a | |||
| 1067 | + | // hidden fullscreen float is fullscreen again. | |||
| 1068 | + | func (w *Workspace) showFloats() { | |||
| 1031 | w.floatsHidden = false | 1069 | w.floatsHidden = false | ||
| 1032 | w.floatFocus = true | 1070 | w.floatFocus = true | ||
| 1033 | if w.fullscreen == 0 && w.floatIndex(w.hiddenFullscreen) >= 0 { | 1071 | if w.fullscreen == 0 && w.floatIndex(w.hiddenFullscreen) >= 0 { | ||
M internal/core/layout_test.go +8 -7
| @@ -393,15 +393,16 @@ func TestFullscreenPersistsAndScrolls(t *testing.T) { | |||
| 393 | 393 | func TestSetFullscreenStackDeactivation(t *testing.T) { | |
| 394 | 394 | w := workspace() | |
| 395 | 395 | w.Columns = []Column{{Windows: []WindowID{1, 2}, Focus: 0}} | |
| 396 | - | // A client request does not take focus; window 1 keeps it but is covered. | |
| 396 | + | // The covering fullscreen window is the only one drawn, so it has the | |
| 397 | + | // focus; window 1 gets it back when 2 leaves fullscreen. | |
| 397 | 398 | w.SetFullscreen(2, true) | |
| 398 | 399 | p := w.Layout() | |
| 399 | - | if !p[0].Hidden || !p[1].Fullscreen || p[1].Focused || !p[0].Focused { | |
| 400 | + | if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused || p[0].Focused { | |
| 400 | 401 | t.Fatal(p) | |
| 401 | 402 | } | |
| 402 | 403 | w.SetFullscreen(2, false) | |
| 403 | - | if w.Layout()[0].Hidden { | |
| 404 | - | t.Fatal(w.Layout()) | |
| 404 | + | if p := w.Layout(); p[0].Hidden || !p[0].Focused { | |
| 405 | + | t.Fatal(p) | |
| 405 | 406 | } | |
| 406 | 407 | } | |
| 407 | 408 | ||
| @@ -675,8 +676,8 @@ func TestFixedOverflowEdgeCases(t *testing.T) { | |||
| 675 | 676 | }) | |
| 676 | 677 | } | |
| 677 | 678 | ||
| 678 | - | // Activating a window hidden by another's fullscreen shows it; a floating | |
| 679 | - | // target stays above the fullscreen window, which keeps it. | |
| 679 | + | // Activating a window hidden by another's fullscreen shows it: the | |
| 680 | + | // fullscreen is exclusive, so it is left, even for a float. | |
| 680 | 681 | func TestActivateLeavesFullscreen(t *testing.T) { | |
| 681 | 682 | for _, tc := range []struct { | |
| 682 | 683 | name string | |
| @@ -685,7 +686,7 @@ func TestActivateLeavesFullscreen(t *testing.T) { | |||
| 685 | 686 | }{ | |
| 686 | 687 | {"fixed", func(w *Workspace) { w.Overflow = OverflowFixed; w.MaxColumns = 2; w.AddWindow(3) }, false}, | |
| 687 | 688 | {"same column", func(w *Workspace) { w.Columns[0].Windows = append(w.Columns[0].Windows, 3) }, false}, | |
| 688 | - | {"floating", func(w *Workspace) { w.AddFloating(3, 10, 10) }, true}, | |
| 689 | + | {"floating", func(w *Workspace) { w.AddFloating(3, 10, 10) }, false}, | |
| 689 | 690 | } { | |
| 690 | 691 | t.Run(tc.name, func(t *testing.T) { | |
| 691 | 692 | w := &Workspace{} | |
| @@ -393,15 +393,16 @@ func TestFullscreenPersistsAndScrolls(t *testing.T) { | |||||
| 393 | func TestSetFullscreenStackDeactivation(t *testing.T) { | 393 | func TestSetFullscreenStackDeactivation(t *testing.T) { | ||
| 394 | w := workspace() | 394 | w := workspace() | ||
| 395 | w.Columns = []Column{{Windows: []WindowID{1, 2}, Focus: 0}} | 395 | w.Columns = []Column{{Windows: []WindowID{1, 2}, Focus: 0}} | ||
| 396 | - | // A client request does not take focus; window 1 keeps it but is covered. | 396 | + | // The covering fullscreen window is the only one drawn, so it has the |
| 397 | + | // focus; window 1 gets it back when 2 leaves fullscreen. | |||
| 397 | w.SetFullscreen(2, true) | 398 | w.SetFullscreen(2, true) | ||
| 398 | p := w.Layout() | 399 | p := w.Layout() | ||
| 399 | - | if !p[0].Hidden || !p[1].Fullscreen || p[1].Focused || !p[0].Focused { | 400 | + | if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused || p[0].Focused { |
| 400 | t.Fatal(p) | 401 | t.Fatal(p) | ||
| 401 | } | 402 | } | ||
| 402 | w.SetFullscreen(2, false) | 403 | w.SetFullscreen(2, false) | ||
| 403 | - | if w.Layout()[0].Hidden { | 404 | + | if p := w.Layout(); p[0].Hidden || !p[0].Focused { |
| 404 | - | t.Fatal(w.Layout()) | 405 | + | t.Fatal(p) |
| 405 | } | 406 | } | ||
| 406 | } | 407 | } | ||
| 407 | 408 | ||||
| @@ -675,8 +676,8 @@ func TestFixedOverflowEdgeCases(t *testing.T) { | |||||
| 675 | }) | 676 | }) | ||
| 676 | } | 677 | } | ||
| 677 | 678 | ||||
| 678 | - | // Activating a window hidden by another's fullscreen shows it; a floating | 679 | + | // Activating a window hidden by another's fullscreen shows it: the |
| 679 | - | // target stays above the fullscreen window, which keeps it. | 680 | + | // fullscreen is exclusive, so it is left, even for a float. |
| 680 | func TestActivateLeavesFullscreen(t *testing.T) { | 681 | func TestActivateLeavesFullscreen(t *testing.T) { | ||
| 681 | for _, tc := range []struct { | 682 | for _, tc := range []struct { | ||
| 682 | name string | 683 | name string | ||
| @@ -685,7 +686,7 @@ func TestActivateLeavesFullscreen(t *testing.T) { | |||||
| 685 | }{ | 686 | }{ | ||
| 686 | {"fixed", func(w *Workspace) { w.Overflow = OverflowFixed; w.MaxColumns = 2; w.AddWindow(3) }, false}, | 687 | {"fixed", func(w *Workspace) { w.Overflow = OverflowFixed; w.MaxColumns = 2; w.AddWindow(3) }, false}, | ||
| 687 | {"same column", func(w *Workspace) { w.Columns[0].Windows = append(w.Columns[0].Windows, 3) }, false}, | 688 | {"same column", func(w *Workspace) { w.Columns[0].Windows = append(w.Columns[0].Windows, 3) }, false}, | ||
| 688 | - | {"floating", func(w *Workspace) { w.AddFloating(3, 10, 10) }, true}, | 689 | + | {"floating", func(w *Workspace) { w.AddFloating(3, 10, 10) }, false}, |
| 689 | } { | 690 | } { | ||
| 690 | t.Run(tc.name, func(t *testing.T) { | 691 | t.Run(tc.name, func(t *testing.T) { | ||
| 691 | w := &Workspace{} | 692 | w := &Workspace{} | ||
M internal/core/monitor.go +18 -8
| @@ -232,8 +232,8 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) { | |||
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | 234 | // AddWindow places a new window on the workspace on screen. On a | |
| 235 | - | // fullscreen workspace it floats, above the fullscreen window: a tiled one | |
| 236 | - | // would be hidden behind it (fixed overflow) while it has the focus. | |
| 235 | + | // fullscreen workspace it floats, hidden until the fullscreen window | |
| 236 | + | // leaves, then tiles after it at home. | |
| 237 | 237 | func (m *Monitor) AddWindow(id WindowID) { | |
| 238 | 238 | if w, _ := m.find(id); w != nil { | |
| 239 | 239 | return | |
| @@ -530,9 +530,9 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||
| 530 | 530 | } | |
| 531 | 531 | ||
| 532 | 532 | // receive adds a column moved in from another workspace and focuses it: at | |
| 533 | - | // index at, or where a new window goes when at < 0. It stays visible: on a | |
| 534 | - | // fixed-overflow fullscreen workspace its windows float above, and an | |
| 535 | - | // in-place fullscreen is left. | |
| 533 | + | // index at, or where a new window goes when at < 0. An in-place fullscreen | |
| 534 | + | // is left. On a fixed-overflow fullscreen workspace its windows float, | |
| 535 | + | // hidden until the fullscreen window leaves: it stays exclusive. | |
| 536 | 536 | func (w *Workspace) receive(col Column, at int) { | |
| 537 | 537 | id := col.Windows[col.Focus] | |
| 538 | 538 | switch { | |
| @@ -540,6 +540,7 @@ func (w *Workspace) receive(col Column, at int) { | |||
| 540 | 540 | for _, v := range col.Windows { | |
| 541 | 541 | w.joinFullscreen(v) | |
| 542 | 542 | } | |
| 543 | + | return | |
| 543 | 544 | case at < 0: | |
| 544 | 545 | w.addColumn(col) | |
| 545 | 546 | default: | |
| @@ -548,14 +549,23 @@ func (w *Workspace) receive(col Column, at int) { | |||
| 548 | 549 | w.Activate(id) | |
| 549 | 550 | } | |
| 550 | 551 | ||
| 551 | - | // joinFullscreen adds a tiled window to a fullscreen workspace fs: it floats | |
| 552 | - | // above the fullscreen window, which would hide it (fixed overflow), and | |
| 553 | - | // tiles again once home. | |
| 552 | + | // joinFullscreen adds a tiled window to a fullscreen workspace fs: it | |
| 553 | + | // floats, hidden while the window is fullscreen, and tiles again once home. | |
| 554 | 554 | func (fs *Workspace) joinFullscreen(id WindowID) { | |
| 555 | 555 | fs.AddFloating(id, 0, 0) | |
| 556 | 556 | fs.back.tiled = append(fs.back.tiled, id) | |
| 557 | 557 | } | |
| 558 | 558 | ||
| 559 | + | // landing is the workspace on screen for a window the user moves here. A | |
| 560 | + | // fullscreen workspace hides everything but its window, so it goes home | |
| 561 | + | // first: the moved window must be seen. | |
| 562 | + | func (m *Monitor) landing() *Workspace { | |
| 563 | + | if fs := m.Current(); fs.origin != nil { | |
| 564 | + | m.leaveFullscreen(fs, false) | |
| 565 | + | } | |
| 566 | + | return m.Current() | |
| 567 | + | } | |
| 568 | + | ||
| 559 | 569 | func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() } | |
| 560 | 570 | ||
| 561 | 571 | // Layout places every window: the workspace on screen laid out, others hidden. | |
| @@ -232,8 +232,8 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) { | |||||
| 232 | } | 232 | } | ||
| 233 | 233 | ||||
| 234 | // AddWindow places a new window on the workspace on screen. On a | 234 | // AddWindow places a new window on the workspace on screen. On a | ||
| 235 | - | // fullscreen workspace it floats, above the fullscreen window: a tiled one | 235 | + | // fullscreen workspace it floats, hidden until the fullscreen window |
| 236 | - | // would be hidden behind it (fixed overflow) while it has the focus. | 236 | + | // leaves, then tiles after it at home. |
| 237 | func (m *Monitor) AddWindow(id WindowID) { | 237 | func (m *Monitor) AddWindow(id WindowID) { | ||
| 238 | if w, _ := m.find(id); w != nil { | 238 | if w, _ := m.find(id); w != nil { | ||
| 239 | return | 239 | return | ||
| @@ -530,9 +530,9 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||||
| 530 | } | 530 | } | ||
| 531 | 531 | ||||
| 532 | // receive adds a column moved in from another workspace and focuses it: at | 532 | // receive adds a column moved in from another workspace and focuses it: at | ||
| 533 | - | // index at, or where a new window goes when at < 0. It stays visible: on a | 533 | + | // index at, or where a new window goes when at < 0. An in-place fullscreen |
| 534 | - | // fixed-overflow fullscreen workspace its windows float above, and an | 534 | + | // is left. On a fixed-overflow fullscreen workspace its windows float, |
| 535 | - | // in-place fullscreen is left. | 535 | + | // hidden until the fullscreen window leaves: it stays exclusive. |
| 536 | func (w *Workspace) receive(col Column, at int) { | 536 | func (w *Workspace) receive(col Column, at int) { | ||
| 537 | id := col.Windows[col.Focus] | 537 | id := col.Windows[col.Focus] | ||
| 538 | switch { | 538 | switch { | ||
| @@ -540,6 +540,7 @@ func (w *Workspace) receive(col Column, at int) { | |||||
| 540 | for _, v := range col.Windows { | 540 | for _, v := range col.Windows { | ||
| 541 | w.joinFullscreen(v) | 541 | w.joinFullscreen(v) | ||
| 542 | } | 542 | } | ||
| 543 | + | return | |||
| 543 | case at < 0: | 544 | case at < 0: | ||
| 544 | w.addColumn(col) | 545 | w.addColumn(col) | ||
| 545 | default: | 546 | default: | ||
| @@ -548,14 +549,23 @@ func (w *Workspace) receive(col Column, at int) { | |||||
| 548 | w.Activate(id) | 549 | w.Activate(id) | ||
| 549 | } | 550 | } | ||
| 550 | 551 | ||||
| 551 | - | // joinFullscreen adds a tiled window to a fullscreen workspace fs: it floats | 552 | + | // joinFullscreen adds a tiled window to a fullscreen workspace fs: it |
| 552 | - | // above the fullscreen window, which would hide it (fixed overflow), and | 553 | + | // floats, hidden while the window is fullscreen, and tiles again once home. |
| 553 | - | // tiles again once home. | |||
| 554 | func (fs *Workspace) joinFullscreen(id WindowID) { | 554 | func (fs *Workspace) joinFullscreen(id WindowID) { | ||
| 555 | fs.AddFloating(id, 0, 0) | 555 | fs.AddFloating(id, 0, 0) | ||
| 556 | fs.back.tiled = append(fs.back.tiled, id) | 556 | fs.back.tiled = append(fs.back.tiled, id) | ||
| 557 | } | 557 | } | ||
| 558 | 558 | ||||
| 559 | + | // landing is the workspace on screen for a window the user moves here. A | |||
| 560 | + | // fullscreen workspace hides everything but its window, so it goes home | |||
| 561 | + | // first: the moved window must be seen. | |||
| 562 | + | func (m *Monitor) landing() *Workspace { | |||
| 563 | + | if fs := m.Current(); fs.origin != nil { | |||
| 564 | + | m.leaveFullscreen(fs, false) | |||
| 565 | + | } | |||
| 566 | + | return m.Current() | |||
| 567 | + | } | |||
| 568 | + | ||||
| 559 | func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() } | 569 | func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() } | ||
| 560 | 570 | ||||
| 561 | // Layout places every window: the workspace on screen laid out, others hidden. | 571 | // Layout places every window: the workspace on screen laid out, others hidden. | ||
M internal/core/popup.go +1 -1
| @@ -136,7 +136,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||
| 136 | 136 | return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true | |
| 137 | 137 | } | |
| 138 | 138 | if sc, l, ok := c.layerOf(id); ok { | |
| 139 | - | if !shown(sc, l.Layer) { | |
| 139 | + | if !shown(sc, l) { | |
| 140 | 140 | return nil, Rect{}, false | |
| 141 | 141 | } | |
| 142 | 142 | return sc, l.Rect, true | |
| @@ -136,7 +136,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||||
| 136 | return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true | 136 | return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true | ||
| 137 | } | 137 | } | ||
| 138 | if sc, l, ok := c.layerOf(id); ok { | 138 | if sc, l, ok := c.layerOf(id); ok { | ||
| 139 | - | if !shown(sc, l.Layer) { | 139 | + | if !shown(sc, l) { |
| 140 | return nil, Rect{}, false | 140 | return nil, Rect{}, false | ||
| 141 | } | 141 | } | ||
| 142 | return sc, l.Rect, true | 142 | return sc, l.Rect, true | ||
M internal/core/popup_test.go +7 -3
| @@ -55,14 +55,18 @@ func TestFloatingWindows(t *testing.T) { | |||
| 55 | 55 | if r := w.Layout()[1].Rect; r.W != u.W || r.H != u.H { | |
| 56 | 56 | t.Fatalf("clamped %+v", r) | |
| 57 | 57 | } | |
| 58 | - | // A dialog from a fullscreen window shows above it and takes the focus. | |
| 58 | + | // A dialog from a fullscreen window waits hidden: the fullscreen is | |
| 59 | + | // exclusive and keeps the focus. It shows, focused, once it leaves. | |
| 59 | 60 | w.SetFullscreen(1, true) | |
| 60 | 61 | w.RemoveWindow(2) | |
| 61 | 62 | w.AddFloating(2, 20, 10) | |
| 62 | - | if l := w.Layout(); l[1].Hidden || !l[1].Focused || l[0].Focused { | |
| 63 | - | t.Fatalf("float under fullscreen %+v", l) | |
| 63 | + | if l := w.Layout(); !l[1].Hidden || l[1].Focused || !l[0].Focused { | |
| 64 | + | t.Fatalf("float over fullscreen %+v", l) | |
| 64 | 65 | } | |
| 65 | 66 | w.SetFullscreen(1, false) | |
| 67 | + | if l := w.Layout(); l[1].Hidden || !l[1].Focused { | |
| 68 | + | t.Fatalf("float after fullscreen %+v", l) | |
| 69 | + | } | |
| 66 | 70 | w.RemoveWindow(2) | |
| 67 | 71 | if id, _ := w.Focused(); id != 1 || len(w.Floats) != 0 { | |
| 68 | 72 | t.Fatalf("focus after close %d", id) | |
| @@ -55,14 +55,18 @@ func TestFloatingWindows(t *testing.T) { | |||||
| 55 | if r := w.Layout()[1].Rect; r.W != u.W || r.H != u.H { | 55 | if r := w.Layout()[1].Rect; r.W != u.W || r.H != u.H { | ||
| 56 | t.Fatalf("clamped %+v", r) | 56 | t.Fatalf("clamped %+v", r) | ||
| 57 | } | 57 | } | ||
| 58 | - | // A dialog from a fullscreen window shows above it and takes the focus. | 58 | + | // A dialog from a fullscreen window waits hidden: the fullscreen is |
| 59 | + | // exclusive and keeps the focus. It shows, focused, once it leaves. | |||
| 59 | w.SetFullscreen(1, true) | 60 | w.SetFullscreen(1, true) | ||
| 60 | w.RemoveWindow(2) | 61 | w.RemoveWindow(2) | ||
| 61 | w.AddFloating(2, 20, 10) | 62 | w.AddFloating(2, 20, 10) | ||
| 62 | - | if l := w.Layout(); l[1].Hidden || !l[1].Focused || l[0].Focused { | 63 | + | if l := w.Layout(); !l[1].Hidden || l[1].Focused || !l[0].Focused { |
| 63 | - | t.Fatalf("float under fullscreen %+v", l) | 64 | + | t.Fatalf("float over fullscreen %+v", l) |
| 64 | } | 65 | } | ||
| 65 | w.SetFullscreen(1, false) | 66 | w.SetFullscreen(1, false) | ||
| 67 | + | if l := w.Layout(); l[1].Hidden || !l[1].Focused { | |||
| 68 | + | t.Fatalf("float after fullscreen %+v", l) | |||
| 69 | + | } | |||
| 66 | w.RemoveWindow(2) | 70 | w.RemoveWindow(2) | ||
| 67 | if id, _ := w.Focused(); id != 1 || len(w.Floats) != 0 { | 71 | if id, _ := w.Focused(); id != 1 || len(w.Floats) != 0 { | ||
| 68 | t.Fatalf("focus after close %d", id) | 72 | t.Fatalf("focus after close %d", id) | ||