detached 8abeded

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
2222
2323 ## Features
2424
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).
2626 - **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.
2727 - **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.
2828 - **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
5757
5858 - **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.
5959 - **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.
6161 - **Explicit sync.** `wp_linux_drm_syncobj_v1` passes GPU fences from the app to KMS. NVIDIA drivers need it.
6262 - **Frame pacing.** Apps get real flip times through `wp_presentation`, and can queue frames with `wp_fifo_v1` and `wp_commit_timing_v1`.
6363 - **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 ## Features23 ## 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
8989 | `spawn <command>` | Run a program |
9090 | `spawn-terminal` | Run `terminal` |
9191 | `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 |
9393 | `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 |
9595 | `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 |
9696 | `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 |
9797 | `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
9191 return ports.SceneWindow{}, ports.SurfaceContent{}, "dim"
9292 }
9393 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 {
9595 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"
9696 }
9797 }
@@ -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
7878 if full == nil {
7979 return ports.SurfaceContent{}, "no_fullscreen"
8080 }
81+ // Core leaves the layers a fullscreen window hides out of the scene:
82+ // one above the windows here is drawn.
8183 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 {
8385 return ports.SurfaceContent{}, "overlay_surface"
8486 }
8587 }
@@ -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) {
3333 {"overlay layer", func(s *ports.Scene) {
3434 s.Layers = []ports.SceneLayer{{ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 10, H: 10}}}
3535 }, nil, "overlay_surface"},
36- {"top layer covered", func(s *ports.Scene) {
36+ {"top layer", func(s *ports.Scene) {
3737 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}}}
3841 }, nil, ""},
3942 {"shm", nil, func(c *ports.SurfaceContent) { c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} }, "not_dmabuf"},
4043 {"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) {
203203 check(color.RGBA{255, 0, 0, 255})
204204 scene.Layers[0].Layer = ports.LayerBottom
205205 check(window)
206+ // Core leaves hidden layers out of the scene; the renderer draws what
207+ // it gets, over a fullscreen window too.
206208 scene.Windows[0].Fullscreen = true
207- scene.Layers[0].Layer = ports.LayerTop
208- check(window)
209209 scene.Layers[0].Layer = ports.LayerOverlay
210210 check(color.RGBA{255, 0, 0, 255})
211211 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.LayerBottom204 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 = true208 scene.Windows[0].Fullscreen = true
207- scene.Layers[0].Layer = ports.LayerTop
208- check(window)
209 scene.Layers[0].Layer = ports.LayerOverlay209 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 = true211 scene.Windows[0].Hidden = true
M internal/adapters/vulkan/scene.go +6 -11
@@ -16,10 +16,9 @@ type sceneWalk struct {
1616 contents map[ports.WindowID]ports.SurfaceContent
1717 dmg *damageRegion
1818 // 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
2322 }
2423
2524 // 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
3029 if w.scale <= 0 {
3130 w.scale = 1
3231 }
33- for _, win := range s.Windows {
34- w.fullscreen = w.fullscreen || (!win.Hidden && win.Fullscreen)
35- }
3632 w.layers(false)
3733 w.windows()
3834 w.popups(false)
@@ -64,12 +60,11 @@ func (w *sceneWalk) dim() {
6460 }
6561
6662 // 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.
6865 func (w *sceneWalk) layers(afterWindows bool) {
6966 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 {
7368 continue
7469 }
7570 content := w.contents[layer.ID]
@@ -16,10 +16,9 @@ type sceneWalk struct {
16 contents map[ports.WindowID]ports.SurfaceContent16 contents map[ports.WindowID]ports.SurfaceContent
17 dmg *damageRegion17 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 float6419+ scale float64
20- bounds image.Rectangle20+ bounds image.Rectangle
21- fullscreen bool21+ 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 = 130 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 continue68 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 {
177177 return Effect{}
178178 }
179179 c.focusScreen = i
180- c.cur().mon.Current().expelTo(id, dir)
180+ c.cur().mon.landing().expelTo(id, dir)
181181 from.mon.normalize()
182182 c.cur().mon.normalize()
183183 return Effect{}
@@ -197,7 +197,7 @@ func (c *Core) applyAction(a Action) Effect {
197197 }
198198 from := c.cur()
199199 c.focusScreen = i
200- to := c.cur().mon.Current()
200+ to := c.cur().mon.landing()
201201 at := 0
202202 if dir < 0 {
203203 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 = i179 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 = i199 c.focusScreen = i
200- to := c.cur().mon.Current()200+ to := c.cur().mon.landing()
201 at := 0201 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 {
377377 if g := c.grabFocus(); g != 0 {
378378 return g
379379 }
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())) {
381382 c.layerFocus, c.layerOver = 0, nil
382383 }
383384 if c.layerFocus != 0 {
@@ -442,7 +443,7 @@ func (c *Core) publish(ctx context.Context) error {
442443 for i, sc := range c.screens {
443444 c.seq++
444445 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)}
446447 layout := sc.mon.Layout()
447448 scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim)
448449 // Only the focused output lights the focused window's lines.
@@ -1092,6 +1093,11 @@ func (c *Core) activate(ctx context.Context, id WindowID) error {
10921093 return nil
10931094 }
10941095 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+ }
10951101 if w != s.mon.Current() {
10961102 s.mon.show(w)
10971103 }
@@ -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 g378 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, nil382 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 nil1093 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) {
698698 }
699699 }
700700
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.
702703 func TestFloatOverFullscreenHit(t *testing.T) {
703704 cfg := config.Defaults()
704705 cfg.Border.Width = 0
@@ -726,6 +727,15 @@ func TestFloatOverFullscreenHit(t *testing.T) {
726727 <-commands
727728 }
728729 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}
729739 if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 {
730740 t.Fatalf("pointer went to %v, want the dialog", v)
731741 }
@@ -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 = 0705 cfg.Border.Width = 0
@@ -726,6 +727,15 @@ func TestFloatOverFullscreenHit(t *testing.T) {
726 <-commands727 <-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) {
7676 }
7777 }
7878
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+
79105 // A fullscreen window does not float until it leaves fullscreen.
80106 func TestToggleWindowFloatingIgnoresFullscreen(t *testing.T) {
81107 w := workspace()
@@ -244,7 +270,7 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) {
244270 w.ToggleWindowFloating()
245271 w.SetFullscreen(1, true)
246272 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.
248274 w.SetFullscreen(1, false)
249275 w.RemoveWindow(1)
250276 w.RemoveWindow(2)
@@ -252,5 +278,7 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) {
252278 w.SetFullscreen(3, true)
253279 check(0)
254280 w.AddFloating(4, 20, 10)
281+ check(0)
282+ w.SetFullscreen(3, false)
255283 check(0.3)
256284 }
@@ -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) {
215215 hit(50, 40, 1)
216216 hit(50, 5, 6)
217217 hit(50, 75, 7)
218+ // A fullscreen window is exclusive: bars and notifications are hidden.
218219 client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
219220 scene(t, scenes)
220221 hit(50, 5, 1)
221- hit(50, 75, 7)
222+ hit(50, 75, 1)
222223 }
223224
224225 // 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 a225 // 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) {
8686 return nil, ports.SceneLayer{}, false
8787 }
8888
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.
9091 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
94110 }
95111 }
96112 return false
97113 }
98114
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
103124 }
104125
105126 // layerAt returns the topmost shown layer surface under the output-local
106127 // point among those above (or below) the windows.
107128 func (c *Core) layerAt(sc *screen, lx, ly float64, above bool) (WindowID, float64, float64) {
108- full := hasFullscreen(sc)
109129 for i := len(sc.placed) - 1; i >= 0; i-- {
110130 l := sc.placed[i]
111131 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 {
113133 continue
114134 }
115135 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{}, false86 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 true94+
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 false112 return false
97 }113 }
98 114
99-// shown reports whether a layer is drawn: a fullscreen window hides the115+// 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-local126 // 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.Rect131 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 continue133 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) {
108108 cancel()
109109 <-done
110110 }
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 <-done109 <-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) {
206206 }
207207 if w.floatsHidden {
208208 // A new float must be seen: show the hidden ones with it.
209- w.ToggleFloatingVisible()
209+ w.showFloats()
210210 }
211211 w.Floats = append(w.Floats, Float{ID: id, W: width, H: height})
212212 w.floatFocus = true
@@ -237,6 +237,10 @@ func (w *Workspace) has(id WindowID) bool {
237237 }
238238
239239 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+ }
240244 if w.floatFocus && !w.floatsHidden && len(w.Floats) > 0 {
241245 return w.Floats[len(w.Floats)-1].ID, true
242246 }
@@ -253,6 +257,16 @@ func (w *Workspace) AddWindow(id WindowID) {
253257 if id == 0 || w.has(id) {
254258 return
255259 }
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+ }
256270 w.addColumn(Column{Windows: []WindowID{id}})
257271 }
258272
@@ -608,10 +622,10 @@ func (w *Workspace) ToggleFullscreen() {
608622 w.scroll()
609623 }
610624
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.
613627 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 {
615629 w.fullscreen = 0
616630 }
617631 w.FocusID(id)
@@ -689,6 +703,25 @@ func (w *Workspace) SetMaxColumns(n int) {
689703 w.scroll()
690704 }
691705 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+
692725 func (w *Workspace) fullscreenColumn(i int) bool {
693726 for _, id := range w.Columns[i].Windows {
694727 if id == w.fullscreen && id != 0 {
@@ -843,7 +876,7 @@ func (w *Workspace) Layout() []Placement {
843876 gap := w.gap()
844877 cols := w.columnRects()
845878 focusedID, _ := w.Focused()
846- floatFocused := w.floatIndex(focusedID) >= 0
879+ cover := w.cover()
847880 for i, c := range w.Columns {
848881 col := cols[i]
849882 n := len(c.Windows)
@@ -870,7 +903,7 @@ func (w *Workspace) Layout() []Placement {
870903 // Fixed overflow cannot scroll to other columns while one fills
871904 // the view. Keep them in place, but out of the scene.
872905 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
874907 if full {
875908 // Scroll mode aligns the view on the column; fixed never scrolls.
876909 r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H}
@@ -881,22 +914,21 @@ func (w *Workspace) Layout() []Placement {
881914 if hidden {
882915 r = Rect{}
883916 }
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})
886918 y += h + gap
887919 }
888920 }
889921 setNeighbors(result, gap)
890922 // Floating windows go last: they are drawn and hit on top.
891923 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}
893927 if p.Hidden {
894928 p.Rect = Rect{}
895929 result = append(result, p)
896930 continue
897931 }
898- // Floats stay above a fullscreen window: a dialog opened from a
899- // fullscreen app must be seen.
900932 if w.fullscreen == f.ID {
901933 p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true
902934 }
@@ -1015,19 +1047,25 @@ func (w *Workspace) ToggleWindowFloating() {
10151047 }
10161048
10171049 // 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.
10211053 func (w *Workspace) ToggleFloatingVisible() {
1022- if len(w.Floats) == 0 {
1054+ if len(w.Floats) == 0 || w.cover() != 0 {
10231055 return
10241056 }
10251057 if !w.floatsHidden {
1026- if w.floatFocus && w.floatIndex(w.fullscreen) < 0 {
1058+ if w.floatFocus {
10271059 w.floatsHidden, w.floatFocus, w.hiddenFullscreen = true, false, 0
10281060 }
10291061 return
10301062 }
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() {
10311069 w.floatsHidden = false
10321070 w.floatFocus = true
10331071 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 = true212 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, true245 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 return258 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 leaves625+// 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 = 0629 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) >= 0879+ 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 fills903 // 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.Focus905 maximized := w.Overflow == OverflowFixed && w.Columns[w.Focus].FullWidth && i != w.Focus
873- hidden := ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized906+ 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.Focus917+ 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 + gap918 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 continue930 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}, true933 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 placement1049 // ToggleFloatingVisible hides every float without changing its placement
1018-// when a float has the focus, and shows them again from any window. A1050+// 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 drops1051+// 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 return1055 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, 01059 w.floatsHidden, w.floatFocus, w.hiddenFullscreen = true, false, 0
1028 }1060 }
1029 return1061 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 = false1069 w.floatsHidden = false
1032 w.floatFocus = true1070 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) {
393393 func TestSetFullscreenStackDeactivation(t *testing.T) {
394394 w := workspace()
395395 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.
397398 w.SetFullscreen(2, true)
398399 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 {
400401 t.Fatal(p)
401402 }
402403 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)
405406 }
406407 }
407408
@@ -675,8 +676,8 @@ func TestFixedOverflowEdgeCases(t *testing.T) {
675676 })
676677 }
677678
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.
680681 func TestActivateLeavesFullscreen(t *testing.T) {
681682 for _, tc := range []struct {
682683 name string
@@ -685,7 +686,7 @@ func TestActivateLeavesFullscreen(t *testing.T) {
685686 }{
686687 {"fixed", func(w *Workspace) { w.Overflow = OverflowFixed; w.MaxColumns = 2; w.AddWindow(3) }, false},
687688 {"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},
689690 } {
690691 t.Run(tc.name, func(t *testing.T) {
691692 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 floating679+// 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 string683 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) {
232232 }
233233
234234 // 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.
237237 func (m *Monitor) AddWindow(id WindowID) {
238238 if w, _ := m.find(id); w != nil {
239239 return
@@ -530,9 +530,9 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) {
530530 }
531531
532532 // 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.
536536 func (w *Workspace) receive(col Column, at int) {
537537 id := col.Windows[col.Focus]
538538 switch {
@@ -540,6 +540,7 @@ func (w *Workspace) receive(col Column, at int) {
540540 for _, v := range col.Windows {
541541 w.joinFullscreen(v)
542542 }
543+ return
543544 case at < 0:
544545 w.addColumn(col)
545546 default:
@@ -548,14 +549,23 @@ func (w *Workspace) receive(col Column, at int) {
548549 w.Activate(id)
549550 }
550551
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.
554554 func (fs *Workspace) joinFullscreen(id WindowID) {
555555 fs.AddFloating(id, 0, 0)
556556 fs.back.tiled = append(fs.back.tiled, id)
557557 }
558558
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+
559569 func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }
560570
561571 // 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 a234 // AddWindow places a new window on the workspace on screen. On a
235-// fullscreen workspace it floats, above the fullscreen window: a tiled one235+// 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 return239 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: at532 // 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 a533+// 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 an534+// 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 floats552+// joinFullscreen adds a tiled window to a fullscreen workspace fs: it
552-// above the fullscreen window, which would hide it (fixed overflow), and553+// 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) {
136136 return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true
137137 }
138138 if sc, l, ok := c.layerOf(id); ok {
139- if !shown(sc, l.Layer) {
139+ if !shown(sc, l) {
140140 return nil, Rect{}, false
141141 }
142142 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}, true136 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{}, false140 return nil, Rect{}, false
141 }141 }
142 return sc, l.Rect, true142 return sc, l.Rect, true
M internal/core/popup_test.go +7 -3
@@ -55,14 +55,18 @@ func TestFloatingWindows(t *testing.T) {
5555 if r := w.Layout()[1].Rect; r.W != u.W || r.H != u.H {
5656 t.Fatalf("clamped %+v", r)
5757 }
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.
5960 w.SetFullscreen(1, true)
6061 w.RemoveWindow(2)
6162 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)
6465 }
6566 w.SetFullscreen(1, false)
67+ if l := w.Layout(); l[1].Hidden || !l[1].Focused {
68+ t.Fatalf("float after fullscreen %+v", l)
69+ }
6670 w.RemoveWindow(2)
6771 if id, _ := w.Focused(); id != 1 || len(w.Floats) != 0 {
6872 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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close