detached ff30418

feat: floating toggles, dim veil and throttling of invisible windows (#43)

* feat(ports): add Scene.Dim for the floating veil

* feat(vulkan): draw the floating dim veil

* feat(core): add floating window and visibility toggles

* fix(drm): compose the scene while the floating veil is shown

* fix(core): keep focus, fullscreen and maximize state across float toggles

* feat(core): publish window visibility in ConfigureWindow

* test(drm): drop VRR after the hold when a fullscreen game is hidden

* feat(wayland): suspend and throttle invisible windows

* fix(core): mark windows mapped hidden and popups of off-screen windows invisible

* fix(core): keep hidden floats hidden on fullscreen requests and align dim and state with drawn placements

* fix(wayland): hold buffers of hidden windows until scanout lets go and resend popup feedback with their toplevel

* test(wayland): release held buffers of hidden windows after the hold

* fix(core): keep the expanded column across float toggles
commit
ff3041890e694fc23ee9a7925c470257135d00f4
author
bnema · · 2026-09-28 12:04 UTC
committer
GitHub · · 2026-09-28 12:04 UTC
parent
e2957a8
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
39 files · +1115 -95
M docs/config.md +5 -0
@@ -22,6 +22,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
2222 | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
2323 | `background` | `#111111` | Solid background color |
2424 | `border.width` | `2` | Separator lines between tiles, in logical pixels; `0` hides them |
25+| `floating.dim` | `0.3` | Black veil over tiles and lower layers when floats are visible; opacity 0 to 1 |
2526 | `border.active` / `border.inactive` | `#808080` / `#111111` | Separator colors around the focused / other windows |
2627 | `layout.gaps` | `0` | Space between windows |
2728 | `layout.max-columns` | `2` | Columns that share the screen before scrolling |
@@ -89,6 +90,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
8990 | `spawn-terminal` | Run `terminal` |
9091 | `close-window` | Close the focused window |
9192 | `toggle-fullscreen` | Fullscreen the focused window |
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 |
94+| `toggle-floating-visible` | Hide or show all floats on this workspace; a new float shows them again |
9295 | `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 |
9396 | `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 |
9497 | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen |
@@ -126,6 +129,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
126129 | `cmd+r` | `cycle-column-width` |
127130 | `cmd+f` | `maximize-column` |
128131 | `cmd+shift+f` | `toggle-fullscreen` |
132+| `cmd+v` | `toggle-window-floating` |
133+| `cmd+shift+v` | `toggle-floating-visible` |
129134 | `cmd+q` | `close-window` |
130135 | `ctrl+alt+backspace` | `quit` |
131136 | `ctrl+cmd+space` | `spawn fuzzel` |
@@ -22,6 +22,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
22 | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |22 | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
23 | `background` | `#111111` | Solid background color |23 | `background` | `#111111` | Solid background color |
24 | `border.width` | `2` | Separator lines between tiles, in logical pixels; `0` hides them |24 | `border.width` | `2` | Separator lines between tiles, in logical pixels; `0` hides them |
25+| `floating.dim` | `0.3` | Black veil over tiles and lower layers when floats are visible; opacity 0 to 1 |
25 | `border.active` / `border.inactive` | `#808080` / `#111111` | Separator colors around the focused / other windows |26 | `border.active` / `border.inactive` | `#808080` / `#111111` | Separator colors around the focused / other windows |
26 | `layout.gaps` | `0` | Space between windows |27 | `layout.gaps` | `0` | Space between windows |
27 | `layout.max-columns` | `2` | Columns that share the screen before scrolling |28 | `layout.max-columns` | `2` | Columns that share the screen before scrolling |
@@ -89,6 +90,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
89 | `spawn-terminal` | Run `terminal` |90 | `spawn-terminal` | Run `terminal` |
90 | `close-window` | Close the focused window |91 | `close-window` | Close the focused window |
91 | `toggle-fullscreen` | Fullscreen the focused window |92 | `toggle-fullscreen` | Fullscreen the focused window |
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 |
94+| `toggle-floating-visible` | Hide or show all floats on this workspace; a new float shows them again |
92 | `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 |
93 | `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 |
94 | `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 |
@@ -126,6 +129,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
126 | `cmd+r` | `cycle-column-width` |129 | `cmd+r` | `cycle-column-width` |
127 | `cmd+f` | `maximize-column` |130 | `cmd+f` | `maximize-column` |
128 | `cmd+shift+f` | `toggle-fullscreen` |131 | `cmd+shift+f` | `toggle-fullscreen` |
132+| `cmd+v` | `toggle-window-floating` |
133+| `cmd+shift+v` | `toggle-floating-visible` |
129 | `cmd+q` | `close-window` |134 | `cmd+q` | `close-window` |
130 | `ctrl+alt+backspace` | `quit` |135 | `ctrl+alt+backspace` | `quit` |
131 | `ctrl+cmd+space` | `spawn fuzzel` |136 | `ctrl+cmd+space` | `spawn fuzzel` |
M examples/config +5 -0
@@ -32,6 +32,9 @@ border.width = 2
3232 border.active = #808080
3333 border.inactive = #111111
3434
35+# Black veil over tiles and lower layers when a float is visible (0 to 1).
36+floating.dim = 0.3
37+
3538 layout.gaps = 0
3639 # Columns sharing the screen equally before new ones scroll off to the right.
3740 # A lone column fills the screen without separators.
@@ -115,6 +118,8 @@ bind.cmd+bracketright = consume-or-expel-window-right
115118 bind.cmd+r = cycle-column-width
116119 bind.cmd+f = maximize-column
117120 bind.cmd+shift+f = toggle-fullscreen
121+bind.cmd+v = toggle-window-floating
122+bind.cmd+shift+v = toggle-floating-visible
118123 bind.cmd+q = close-window
119124 bind.ctrl+alt+backspace = quit
120125 bind.ctrl+cmd+space = spawn fuzzel
@@ -32,6 +32,9 @@ border.width = 2
32 border.active = #80808032 border.active = #808080
33 border.inactive = #11111133 border.inactive = #111111
34 34
35+# Black veil over tiles and lower layers when a float is visible (0 to 1).
36+floating.dim = 0.3
37+
35 layout.gaps = 038 layout.gaps = 0
36 # Columns sharing the screen equally before new ones scroll off to the right.39 # Columns sharing the screen equally before new ones scroll off to the right.
37 # A lone column fills the screen without separators.40 # A lone column fills the screen without separators.
@@ -115,6 +118,8 @@ bind.cmd+bracketright = consume-or-expel-window-right
115 bind.cmd+r = cycle-column-width118 bind.cmd+r = cycle-column-width
116 bind.cmd+f = maximize-column119 bind.cmd+f = maximize-column
117 bind.cmd+shift+f = toggle-fullscreen120 bind.cmd+shift+f = toggle-fullscreen
121+bind.cmd+v = toggle-window-floating
122+bind.cmd+shift+v = toggle-floating-visible
118 bind.cmd+q = close-window123 bind.cmd+q = close-window
119 bind.ctrl+alt+backspace = quit124 bind.ctrl+alt+backspace = quit
120 bind.ctrl+cmd+space = spawn fuzzel125 bind.ctrl+cmd+space = spawn fuzzel
M internal/adapters/config/config.go +10 -1
@@ -32,6 +32,8 @@ var defaultBinds = []struct{ combo, action string }{
3232 {"cmd+r", "cycle-column-width"},
3333 {"cmd+f", "maximize-column"},
3434 {"cmd+shift+f", "toggle-fullscreen"},
35+ {"cmd+v", "toggle-window-floating"},
36+ {"cmd+shift+v", "toggle-floating-visible"},
3537 {"cmd+q", "close-window"},
3638 {"ctrl+alt+backspace", "quit"},
3739 {"ctrl+cmd+space", "spawn fuzzel"},
@@ -44,7 +46,7 @@ var defaultBinds = []struct{ combo, action string }{
4446 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
4547 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
4648 }
47-var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
49+var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "toggle-window-floating": true, "toggle-floating-visible": true, "close-window": true, "quit": true}
4850 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
4951 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
5052
@@ -87,6 +89,7 @@ func Defaults() ports.Config {
8789 c.Terminal.AutoOpen = "first"
8890 c.Xwayland = "xwayland-satellite"
8991 c.Background.Color = "#111111"
92+ c.Floating.Dim = 0.3
9093 c.Border.Width = 2
9194 c.Border.Active = "#808080"
9295 c.Border.Inactive = "#111111"
@@ -450,6 +453,12 @@ func set(c *ports.Config, key, v string) error {
450453 return fmt.Errorf("must be #rrggbb")
451454 }
452455 c.Background.Color = v
456+ case "floating.dim":
457+ dim, err := strconv.ParseFloat(v, 64)
458+ if err != nil || !(dim >= 0 && dim <= 1) {
459+ return fmt.Errorf("must be between 0 and 1")
460+ }
461+ c.Floating.Dim = dim
453462 case "border.width":
454463 n, err := strconv.Atoi(v)
455464 if err != nil || n < 0 || n > 32 {
@@ -32,6 +32,8 @@ var defaultBinds = []struct{ combo, action string }{
32 {"cmd+r", "cycle-column-width"},32 {"cmd+r", "cycle-column-width"},
33 {"cmd+f", "maximize-column"},33 {"cmd+f", "maximize-column"},
34 {"cmd+shift+f", "toggle-fullscreen"},34 {"cmd+shift+f", "toggle-fullscreen"},
35+ {"cmd+v", "toggle-window-floating"},
36+ {"cmd+shift+v", "toggle-floating-visible"},
35 {"cmd+q", "close-window"},37 {"cmd+q", "close-window"},
36 {"ctrl+alt+backspace", "quit"},38 {"ctrl+alt+backspace", "quit"},
37 {"ctrl+cmd+space", "spawn fuzzel"},39 {"ctrl+cmd+space", "spawn fuzzel"},
@@ -44,7 +46,7 @@ var defaultBinds = []struct{ combo, action string }{
44 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},46 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
45 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},47 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
46 }48 }
47-var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "close-window": true, "quit": true}49+var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "toggle-window-floating": true, "toggle-floating-visible": true, "close-window": true, "quit": true}
48 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}50 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
49 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)51 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
50 52
@@ -87,6 +89,7 @@ func Defaults() ports.Config {
87 c.Terminal.AutoOpen = "first"89 c.Terminal.AutoOpen = "first"
88 c.Xwayland = "xwayland-satellite"90 c.Xwayland = "xwayland-satellite"
89 c.Background.Color = "#111111"91 c.Background.Color = "#111111"
92+ c.Floating.Dim = 0.3
90 c.Border.Width = 293 c.Border.Width = 2
91 c.Border.Active = "#808080"94 c.Border.Active = "#808080"
92 c.Border.Inactive = "#111111"95 c.Border.Inactive = "#111111"
@@ -450,6 +453,12 @@ func set(c *ports.Config, key, v string) error {
450 return fmt.Errorf("must be #rrggbb")453 return fmt.Errorf("must be #rrggbb")
451 }454 }
452 c.Background.Color = v455 c.Background.Color = v
456+ case "floating.dim":
457+ dim, err := strconv.ParseFloat(v, 64)
458+ if err != nil || !(dim >= 0 && dim <= 1) {
459+ return fmt.Errorf("must be between 0 and 1")
460+ }
461+ c.Floating.Dim = dim
453 case "border.width":462 case "border.width":
454 n, err := strconv.Atoi(v)463 n, err := strconv.Atoi(v)
455 if err != nil || n < 0 || n > 32 {464 if err != nil || n < 0 || n > 32 {
M internal/adapters/config/config_test.go +20 -2
@@ -22,10 +22,10 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) {
2222 func TestDefaultsAndLoad(t *testing.T) {
2323 t.Setenv("TERMINAL", "")
2424 d := Defaults()
25- if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 53 || d.Layout.MaxColumns != 2 {
25+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 55 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {
2626 t.Fatalf("defaults: %+v", d)
2727 }
28- if d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
28+ if d.Binds["Cmd+v"] != "toggle-window-floating" || d.Binds["Cmd+Shift+v"] != "toggle-floating-visible" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
2929 t.Fatal(d.Binds)
3030 }
3131 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
@@ -401,3 +401,21 @@ func TestTerminalResolutionAndAutoOpen(t *testing.T) {
401401 t.Fatal(got)
402402 }
403403 }
404+
405+func TestFloatingDim(t *testing.T) {
406+ for _, tc := range []struct {
407+ value string
408+ want float64
409+ warning bool
410+ }{
411+ {"0", 0, false}, {"0.75", 0.75, false}, {"1", 1, false},
412+ {"-0.1", 0.3, true}, {"1.1", 0.3, true}, {"NaN", 0.3, true}, {"invalid", 0.3, true},
413+ } {
414+ t.Run(tc.value, func(t *testing.T) {
415+ c, warnings := parseString(t, "floating.dim = "+tc.value)
416+ if c.Floating.Dim != tc.want || (len(warnings) != 0) != tc.warning {
417+ t.Fatalf("dim %v, warnings %v", c.Floating.Dim, warnings)
418+ }
419+ })
420+ }
421+}
@@ -22,10 +22,10 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) {
22 func TestDefaultsAndLoad(t *testing.T) {22 func TestDefaultsAndLoad(t *testing.T) {
23 t.Setenv("TERMINAL", "")23 t.Setenv("TERMINAL", "")
24 d := Defaults()24 d := Defaults()
25- if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 53 || d.Layout.MaxColumns != 2 {25+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 55 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {
26 t.Fatalf("defaults: %+v", d)26 t.Fatalf("defaults: %+v", d)
27 }27 }
28- if d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {28+ if d.Binds["Cmd+v"] != "toggle-window-floating" || d.Binds["Cmd+Shift+v"] != "toggle-floating-visible" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
29 t.Fatal(d.Binds)29 t.Fatal(d.Binds)
30 }30 }
31 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {31 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
@@ -401,3 +401,21 @@ func TestTerminalResolutionAndAutoOpen(t *testing.T) {
401 t.Fatal(got)401 t.Fatal(got)
402 }402 }
403 }403 }
404+
405+func TestFloatingDim(t *testing.T) {
406+ for _, tc := range []struct {
407+ value string
408+ want float64
409+ warning bool
410+ }{
411+ {"0", 0, false}, {"0.75", 0.75, false}, {"1", 1, false},
412+ {"-0.1", 0.3, true}, {"1.1", 0.3, true}, {"NaN", 0.3, true}, {"invalid", 0.3, true},
413+ } {
414+ t.Run(tc.value, func(t *testing.T) {
415+ c, warnings := parseString(t, "floating.dim = "+tc.value)
416+ if c.Floating.Dim != tc.want || (len(warnings) != 0) != tc.warning {
417+ t.Fatalf("dim %v, warnings %v", c.Floating.Dim, warnings)
418+ }
419+ })
420+ }
421+}
M internal/adapters/drm/output_test.go +26 -1
@@ -240,7 +240,7 @@ func TestReportSeenAllocations(t *testing.T) {
240240
241241 func TestShownBySnapshotAllocations(t *testing.T) {
242242 o := &Output{}
243- s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}}}
243+ s := ports.Scene{OutputWidth: 10, OutputHeight: 10, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}}
244244 seen := map[ports.WindowID]uint64{1: 1, 2: 2}
245245 previous := o.shownBy(s, seen)
246246 if allocs := testing.AllocsPerRun(100, func() { o.shownBy(s, seen) }); allocs != 0 {
@@ -384,6 +384,31 @@ func TestVRRKeptWhileFullscreenComposed(t *testing.T) {
384384 }
385385 }
386386
387+// A fullscreen game on a workspace that is left keeps VRR only for the
388+// hold: its hidden window no longer counts as a game frame, and its
389+// commits do not draw frames.
390+func TestVRRHiddenGameOnlyHolds(t *testing.T) {
391+ o, _, _ := testOutput(t)
392+ game := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true}
393+ shown := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{game}}
394+ o.vrrOn = true
395+ if !o.wantVRR(fullscreenShown(&shown)) {
396+ t.Fatal("VRR off while the game is shown")
397+ }
398+ game.Hidden, game.Rect = true, ports.Rect{}
399+ left := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{game, {ID: 2, Rect: ports.Rect{W: 100, H: 50}}}}
400+ if fullscreenShown(&left) || left.Shows(1) {
401+ t.Fatal("hidden game still drives frames")
402+ }
403+ if !o.wantVRR(fullscreenShown(&left)) {
404+ t.Fatal("VRR dropped before the hold")
405+ }
406+ o.composedSince = time.Now().Add(-vrrHold - time.Millisecond)
407+ if o.stateVRR() {
408+ t.Fatal("VRR kept after the hold for a hidden game")
409+ }
410+}
411+
387412 // A VRR refusal on a composed frame with an overlay retries with the
388413 // overlay: the composed image left its window out.
389414 func TestVRRRefusedKeepsOverlay(t *testing.T) {
@@ -240,7 +240,7 @@ func TestReportSeenAllocations(t *testing.T) {
240 240
241 func TestShownBySnapshotAllocations(t *testing.T) {241 func TestShownBySnapshotAllocations(t *testing.T) {
242 o := &Output{}242 o := &Output{}
243- s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}}}243+ s := ports.Scene{OutputWidth: 10, OutputHeight: 10, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}}
244 seen := map[ports.WindowID]uint64{1: 1, 2: 2}244 seen := map[ports.WindowID]uint64{1: 1, 2: 2}
245 previous := o.shownBy(s, seen)245 previous := o.shownBy(s, seen)
246 if allocs := testing.AllocsPerRun(100, func() { o.shownBy(s, seen) }); allocs != 0 {246 if allocs := testing.AllocsPerRun(100, func() { o.shownBy(s, seen) }); allocs != 0 {
@@ -384,6 +384,31 @@ func TestVRRKeptWhileFullscreenComposed(t *testing.T) {
384 }384 }
385 }385 }
386 386
387+// A fullscreen game on a workspace that is left keeps VRR only for the
388+// hold: its hidden window no longer counts as a game frame, and its
389+// commits do not draw frames.
390+func TestVRRHiddenGameOnlyHolds(t *testing.T) {
391+ o, _, _ := testOutput(t)
392+ game := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true}
393+ shown := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{game}}
394+ o.vrrOn = true
395+ if !o.wantVRR(fullscreenShown(&shown)) {
396+ t.Fatal("VRR off while the game is shown")
397+ }
398+ game.Hidden, game.Rect = true, ports.Rect{}
399+ left := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{game, {ID: 2, Rect: ports.Rect{W: 100, H: 50}}}}
400+ if fullscreenShown(&left) || left.Shows(1) {
401+ t.Fatal("hidden game still drives frames")
402+ }
403+ if !o.wantVRR(fullscreenShown(&left)) {
404+ t.Fatal("VRR dropped before the hold")
405+ }
406+ o.composedSince = time.Now().Add(-vrrHold - time.Millisecond)
407+ if o.stateVRR() {
408+ t.Fatal("VRR kept after the hold for a hidden game")
409+ }
410+}
411+
387 // A VRR refusal on a composed frame with an overlay retries with the412 // A VRR refusal on a composed frame with an overlay retries with the
388 // overlay: the composed image left its window out.413 // overlay: the composed image left its window out.
389 func TestVRRRefusedKeepsOverlay(t *testing.T) {414 func TestVRRRefusedKeepsOverlay(t *testing.T) {
M internal/adapters/drm/overlay.go +5 -0
@@ -85,6 +85,11 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
8585 if pick == nil {
8686 return ports.SceneWindow{}, ports.SurfaceContent{}, "no_candidate"
8787 }
88+ if s.Dim > 0 {
89+ // The renderer draws the veil under the first float it composes: a
90+ // float on the plane would take the veil with it.
91+ return ports.SceneWindow{}, ports.SurfaceContent{}, "dim"
92+ }
8893 for _, l := range s.Layers {
8994 if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 && !(pick.Fullscreen && l.Layer == ports.LayerTop) {
9095 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"
@@ -85,6 +85,11 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
85 if pick == nil {85 if pick == nil {
86 return ports.SceneWindow{}, ports.SurfaceContent{}, "no_candidate"86 return ports.SceneWindow{}, ports.SurfaceContent{}, "no_candidate"
87 }87 }
88+ if s.Dim > 0 {
89+ // The renderer draws the veil under the first float it composes: a
90+ // float on the plane would take the veil with it.
91+ return ports.SceneWindow{}, ports.SurfaceContent{}, "dim"
92+ }
88 for _, l := range s.Layers {93 for _, l := range s.Layers {
89 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 && !(pick.Fullscreen && l.Layer == ports.LayerTop) {
90 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"95 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"
M internal/adapters/drm/overlay_test.go +6 -0
@@ -72,6 +72,12 @@ func TestOverlayCandidate(t *testing.T) {
7272 if _, _, reason := overlayCandidate(s, c); reason != "layer_above" {
7373 t.Fatalf("reason %q", reason)
7474 }
75+ // The dim veil is composed under the float: nothing leaves for the plane.
76+ s, _ = overlayScene()
77+ s.Dim = 0.3
78+ if _, _, reason := overlayCandidate(s, c); reason != "dim" {
79+ t.Fatalf("dim reason %q", reason)
80+ }
7581 }
7682
7783 // The overlay is chosen and the composed scene leaves its window out;
@@ -72,6 +72,12 @@ func TestOverlayCandidate(t *testing.T) {
72 if _, _, reason := overlayCandidate(s, c); reason != "layer_above" {72 if _, _, reason := overlayCandidate(s, c); reason != "layer_above" {
73 t.Fatalf("reason %q", reason)73 t.Fatalf("reason %q", reason)
74 }74 }
75+ // The dim veil is composed under the float: nothing leaves for the plane.
76+ s, _ = overlayScene()
77+ s.Dim = 0.3
78+ if _, _, reason := overlayCandidate(s, c); reason != "dim" {
79+ t.Fatalf("dim reason %q", reason)
80+ }
75 }81 }
76 82
77 // The overlay is chosen and the composed scene leaves its window out;83 // The overlay is chosen and the composed scene leaves its window out;
M internal/adapters/drm/scanout_test.go +4 -3
@@ -272,10 +272,11 @@ func TestPlaneWithoutInFormatsIsLinear(t *testing.T) {
272272 }
273273
274274 // A flip shows only the windows its scene draws: presentation feedback of
275-// a window on another workspace or hidden is discarded, not presented.
275+// a window on another workspace, hidden or scrolled off the output is
276+// discarded, not presented.
276277 func TestShownByScene(t *testing.T) {
277- s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}, {ID: 2, Hidden: true}}, Layers: []ports.SceneLayer{{ID: 5}}}
278- got := (&Output{}).shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 5: 6, 9: 7})
278+ s := ports.Scene{OutputWidth: 10, OutputHeight: 10, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 5, W: 10, H: 10}}, {ID: 2, Hidden: true}, {ID: 3, Rect: ports.Rect{X: 10, W: 10, H: 10}}}, Layers: []ports.SceneLayer{{ID: 5}}}
279+ got := (&Output{}).shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 3: 8, 5: 6, 9: 7})
279280 if len(got) != 2 || got[1] != 3 || got[5] != 6 {
280281 t.Fatalf("shows %v", got)
281282 }
@@ -272,10 +272,11 @@ func TestPlaneWithoutInFormatsIsLinear(t *testing.T) {
272 }272 }
273 273
274 // A flip shows only the windows its scene draws: presentation feedback of274 // A flip shows only the windows its scene draws: presentation feedback of
275-// a window on another workspace or hidden is discarded, not presented.275+// a window on another workspace, hidden or scrolled off the output is
276+// discarded, not presented.
276 func TestShownByScene(t *testing.T) {277 func TestShownByScene(t *testing.T) {
277- s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}, {ID: 2, Hidden: true}}, Layers: []ports.SceneLayer{{ID: 5}}}278+ s := ports.Scene{OutputWidth: 10, OutputHeight: 10, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 5, W: 10, H: 10}}, {ID: 2, Hidden: true}, {ID: 3, Rect: ports.Rect{X: 10, W: 10, H: 10}}}, Layers: []ports.SceneLayer{{ID: 5}}}
278- got := (&Output{}).shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 5: 6, 9: 7})279+ got := (&Output{}).shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 3: 8, 5: 6, 9: 7})
279 if len(got) != 2 || got[1] != 3 || got[5] != 6 {280 if len(got) != 2 || got[1] != 3 || got[5] != 6 {
280 t.Fatalf("shows %v", got)281 t.Fatalf("shows %v", got)
281 }282 }
M internal/adapters/headless/output_test.go +4 -4
@@ -70,9 +70,9 @@ func TestRun(t *testing.T) {
7070 contents := make(chan ports.SurfaceContent, 8)
7171 r, f := recordingRenderer(t, nil)
7272 contents <- ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}
73- shown := []ports.SceneWindow{{ID: 1}, {ID: 2}}
74- scenes <- ports.Scene{Seq: 1, Windows: shown}
75- scenes <- ports.Scene{Seq: 2, Windows: shown}
73+ shown := []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 2, H: 2}}}
74+ scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: shown}
75+ scenes <- ports.Scene{Seq: 2, OutputWidth: 2, OutputHeight: 2, Windows: shown}
7676 contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}
7777 ctx, cancel := context.WithCancel(context.Background())
7878 done := make(chan error, 1)
@@ -111,7 +111,7 @@ func TestRun(t *testing.T) {
111111 func TestRunSkipsContentNotShown(t *testing.T) {
112112 scenes := make(chan ports.Scene, 1)
113113 contents := make(chan ports.SurfaceContent, 2)
114- scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}, {ID: 3, Hidden: true}}}
114+ scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 3, Hidden: true}}}
115115 r, f := recordingRenderer(t, nil)
116116 ctx, cancel := context.WithCancel(context.Background())
117117 done := make(chan error, 1)
@@ -70,9 +70,9 @@ func TestRun(t *testing.T) {
70 contents := make(chan ports.SurfaceContent, 8)70 contents := make(chan ports.SurfaceContent, 8)
71 r, f := recordingRenderer(t, nil)71 r, f := recordingRenderer(t, nil)
72 contents <- ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}72 contents <- ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}
73- shown := []ports.SceneWindow{{ID: 1}, {ID: 2}}73+ shown := []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 2, H: 2}}}
74- scenes <- ports.Scene{Seq: 1, Windows: shown}74+ scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: shown}
75- scenes <- ports.Scene{Seq: 2, Windows: shown}75+ scenes <- ports.Scene{Seq: 2, OutputWidth: 2, OutputHeight: 2, Windows: shown}
76 contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}76 contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}
77 ctx, cancel := context.WithCancel(context.Background())77 ctx, cancel := context.WithCancel(context.Background())
78 done := make(chan error, 1)78 done := make(chan error, 1)
@@ -111,7 +111,7 @@ func TestRun(t *testing.T) {
111 func TestRunSkipsContentNotShown(t *testing.T) {111 func TestRunSkipsContentNotShown(t *testing.T) {
112 scenes := make(chan ports.Scene, 1)112 scenes := make(chan ports.Scene, 1)
113 contents := make(chan ports.SurfaceContent, 2)113 contents := make(chan ports.SurfaceContent, 2)
114- scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}, {ID: 3, Hidden: true}}}114+ scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 3, Hidden: true}}}
115 r, f := recordingRenderer(t, nil)115 r, f := recordingRenderer(t, nil)
116 ctx, cancel := context.WithCancel(context.Background())116 ctx, cancel := context.WithCancel(context.Background())
117 done := make(chan error, 1)117 done := make(chan error, 1)
M internal/adapters/vulkan/compose.go +8 -0
@@ -299,6 +299,14 @@ func (r *Renderer) fillDraw(rect image.Rectangle, c [3]uint8) draw {
299299 return dr
300300 }
301301
302+// dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA
303+// leaves the destination at (1-alpha) of its previous value.
304+func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw {
305+ dr := r.fillDraw(rect, [3]uint8{})
306+ dr.pc.color[3] = float32(max(0, min(1, alpha)))
307+ return dr
308+}
309+
302310 // contentDraw draws the visible rect of a w×h buffer mapped onto full
303311 // (target pixels) through the inverse of its buffer transform t. exact: the
304312 // buffer is drawn at its size.
@@ -299,6 +299,14 @@ func (r *Renderer) fillDraw(rect image.Rectangle, c [3]uint8) draw {
299 return dr299 return dr
300 }300 }
301 301
302+// dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA
303+// leaves the destination at (1-alpha) of its previous value.
304+func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw {
305+ dr := r.fillDraw(rect, [3]uint8{})
306+ dr.pc.color[3] = float32(max(0, min(1, alpha)))
307+ return dr
308+}
309+
302 // contentDraw draws the visible rect of a w×h buffer mapped onto full310 // contentDraw draws the visible rect of a w×h buffer mapped onto full
303 // (target pixels) through the inverse of its buffer transform t. exact: the311 // (target pixels) through the inverse of its buffer transform t. exact: the
304 // buffer is drawn at its size.312 // buffer is drawn at its size.
M internal/adapters/vulkan/scene.go +12 -3
@@ -57,6 +57,12 @@ func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) {
5757 w.draws = append(w.draws, w.r.fillDraw(rect, c))
5858 }
5959
60+// dim paints a translucent black quad over the whole output. Damage clipping
61+// later limits it to the region being repainted, just like other fills.
62+func (w *sceneWalk) dim() {
63+ w.draws = append(w.draws, w.r.dimDraw(w.bounds, w.s.Dim))
64+}
65+
6066 // layers draws the layer surfaces below the windows, or above them.
6167 // A fullscreen window hides the bottom and top layers.
6268 func (w *sceneWalk) layers(afterWindows bool) {
@@ -77,12 +83,15 @@ func (w *sceneWalk) layers(afterWindows bool) {
7783 func (w *sceneWalk) windows() {
7884 tileLines := false
7985 for _, win := range w.s.Windows {
86+ if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 {
87+ continue
88+ }
8089 if win.Floating && !tileLines {
8190 w.separators(0)
8291 tileLines = true
83- }
84- if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 {
85- continue
92+ if w.s.Dim > 0 {
93+ w.dim()
94+ }
8695 }
8796 // Content sits inside the border; core sized the client to match.
8897 b, inset := 0, ports.Sides(0)
@@ -57,6 +57,12 @@ func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) {
57 w.draws = append(w.draws, w.r.fillDraw(rect, c))57 w.draws = append(w.draws, w.r.fillDraw(rect, c))
58 }58 }
59 59
60+// dim paints a translucent black quad over the whole output. Damage clipping
61+// later limits it to the region being repainted, just like other fills.
62+func (w *sceneWalk) dim() {
63+ w.draws = append(w.draws, w.r.dimDraw(w.bounds, w.s.Dim))
64+}
65+
60 // layers draws the layer surfaces below the windows, or above them.66 // layers draws the layer surfaces below the windows, or above them.
61 // A fullscreen window hides the bottom and top layers.67 // A fullscreen window hides the bottom and top layers.
62 func (w *sceneWalk) layers(afterWindows bool) {68 func (w *sceneWalk) layers(afterWindows bool) {
@@ -77,12 +83,15 @@ func (w *sceneWalk) layers(afterWindows bool) {
77 func (w *sceneWalk) windows() {83 func (w *sceneWalk) windows() {
78 tileLines := false84 tileLines := false
79 for _, win := range w.s.Windows {85 for _, win := range w.s.Windows {
86+ if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 {
87+ continue
88+ }
80 if win.Floating && !tileLines {89 if win.Floating && !tileLines {
81 w.separators(0)90 w.separators(0)
82 tileLines = true91 tileLines = true
83- }92+ if w.s.Dim > 0 {
84- if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 {93+ w.dim()
85- continue94+ }
86 }95 }
87 // Content sits inside the border; core sized the client to match.96 // Content sits inside the border; core sized the client to match.
88 b, inset := 0, ports.Sides(0)97 b, inset := 0, ports.Sides(0)
A internal/adapters/vulkan/scene_dim_test.go +150 -0
@@ -0,0 +1,150 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "image/color"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// These walk tests need no Vulkan device: placeholder windows and solid
12+// separators produce draw commands without importing client buffers.
13+func TestSceneDimOrder(t *testing.T) {
14+ r := &Renderer{width: 80, height: 60}
15+ s := ports.Scene{
16+ Dim: 0.4, Border: ports.Border{Active: "#ffffff"},
17+ Windows: []ports.SceneWindow{
18+ {ID: 1, Rect: ports.Rect{W: 20, H: 20}},
19+ {ID: 2, Floating: true, Hidden: true, Rect: ports.Rect{W: 20, H: 20}},
20+ {ID: 3, Floating: true, Popup: true, Rect: ports.Rect{W: 20, H: 20}},
21+ {ID: 4, Floating: true, Rect: ports.Rect{W: 0, H: 20}},
22+ {ID: 5, Floating: true, Rect: ports.Rect{X: 30, W: 20, H: 20}},
23+ {ID: 6, Floating: true, Rect: ports.Rect{X: 50, W: 20, H: 20}},
24+ },
25+ Separators: []ports.Separator{{Rect: ports.Rect{W: 80, H: 1}, Active: true}},
26+ }
27+ walk := func(s ports.Scene) []draw {
28+ return r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
29+ }
30+ got := walk(s)
31+ if len(got) != 5 { // tile, tile lines, veil, two floats
32+ t.Fatalf("draws = %d, want 5", len(got))
33+ }
34+ veil := got[2]
35+ if veil.pc.rect != [4]int32{0, 0, 80, 60} || veil.pc.color != [4]float32{0, 0, 0, float32(s.Dim)} || veil.pc.misc[0] != modeSolid || veil.pc.misc[1] != 0 {
36+ t.Fatalf("veil = %+v", veil.pc)
37+ }
38+ if got[1].pc.color != [4]float32{1, 1, 1, 1} || got[3].pc.rect[0] != 30 || got[4].pc.rect[0] != 50 {
39+ t.Fatalf("tile line / float order: %+v", got)
40+ }
41+ for _, tc := range []struct {
42+ name string
43+ dim float64
44+ windows []ports.SceneWindow
45+ wantCount int
46+ wantAlpha float32
47+ }{
48+ {"zero", 0, s.Windows, 4, 0},
49+ {"negative", -1, s.Windows, 4, 0},
50+ {"clamped", 2, s.Windows, 5, 1},
51+ {"no float", 0.4, s.Windows[:4], 2, 0},
52+ } {
53+ t.Run(tc.name, func(t *testing.T) {
54+ s.Dim, s.Windows = tc.dim, tc.windows
55+ ds := walk(s)
56+ if len(ds) != tc.wantCount {
57+ t.Fatalf("draws = %d, want %d", len(ds), tc.wantCount)
58+ }
59+ if tc.wantAlpha != 0 && ds[2].pc.color[3] != tc.wantAlpha {
60+ t.Fatalf("alpha = %v, want %v", ds[2].pc.color[3], tc.wantAlpha)
61+ }
62+ })
63+ }
64+}
65+
66+func TestSceneDimPartialDamage(t *testing.T) {
67+ r := &Renderer{width: 80, height: 60}
68+ s := ports.Scene{Seq: 8, Dim: 0.5, Windows: []ports.SceneWindow{
69+ {ID: 1, Rect: ports.Rect{W: 40, H: 40}},
70+ {ID: 2, Floating: true, Rect: ports.Rect{X: 50, W: 20, H: 20}},
71+ }}
72+ tg := &target{valid: true, sceneSeq: 8, windows: map[ports.WindowID]heldWindow{1: {seq: 1, rect: image.Rect(0, 0, 40, 40)}, 2: {seq: 1, rect: image.Rect(50, 0, 70, 20)}}}
73+ contents := map[ports.WindowID]ports.SurfaceContent{1: {Seq: 2}, 2: {Seq: 1}}
74+ dmg := newDamage(tg, s, image.Rect(0, 0, 80, 60))
75+ ds := r.draws(s, contents, dmg)
76+ dmg.finish()
77+ if dmg.all() || dmg.area != image.Rect(0, 0, 40, 40) {
78+ t.Fatalf("damage = %v (full=%v)", dmg.area, dmg.all())
79+ }
80+ ds = dmg.clip(ds)
81+ if len(ds) != 2 || ds[1].pc.color != [4]float32{0, 0, 0, 0.5} || ds[1].pc.rect != [4]int32{0, 0, 40, 40} {
82+ t.Fatalf("clipped draws = %+v", ds)
83+ }
84+}
85+
86+// The actual blend must darken already painted pixels without darkening
87+// floats, window popups or top layers. Also exercise a retained target:
88+// content damage beneath the veil must replay it over that region.
89+func TestRendererDimBlendAndDamage(t *testing.T) {
90+ r, err := New(64, 48)
91+ if err != nil {
92+ t.Skipf("Vulkan unavailable: %v", err)
93+ }
94+ defer r.Close()
95+ s := ports.Scene{Seq: 10, Background: "#ffffff", Dim: 0.5,
96+ Windows: []ports.SceneWindow{
97+ {ID: 1, Rect: ports.Rect{W: 20, H: 20}},
98+ {ID: 2, Floating: true, Rect: ports.Rect{X: 30, W: 10, H: 10}},
99+ {ID: 3, Popup: true, Rect: ports.Rect{X: 42, W: 8, H: 8}},
100+ },
101+ Layers: []ports.SceneLayer{{ID: 4, Layer: ports.LayerTop, Rect: ports.Rect{X: 52, W: 8, H: 8}}},
102+ }
103+ white := solidContent(t, 20, 20, color.RGBA{255, 255, 255, 255})
104+ white.Seq = 1
105+ popup := solidContent(t, 8, 8, color.RGBA{255, 255, 255, 255})
106+ top := solidContent(t, 8, 8, color.RGBA{255, 255, 255, 255})
107+ contents := map[ports.WindowID]ports.SurfaceContent{1: white, 3: popup, 4: top}
108+ check := func() {
109+ t.Helper()
110+ if err := render(r, s, contents); err != nil {
111+ t.Fatal(err)
112+ }
113+ for _, tc := range []struct {
114+ x, y int
115+ want color.RGBA
116+ }{
117+ {60, 40, color.RGBA{128, 128, 128, 255}}, // background
118+ {5, 5, color.RGBA{128, 128, 128, 255}}, // tile
119+ {43, 5, color.RGBA{255, 255, 255, 255}}, // window popup
120+ {53, 5, color.RGBA{255, 255, 255, 255}}, // top layer
121+ } {
122+ if got := r.Pixels().RGBAAt(tc.x, tc.y); !near(got, tc.want, 2) {
123+ t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)
124+ }
125+ }
126+ }
127+ check()
128+ // Reuse the target with unchanged scene Seq, but changed tile content.
129+ // Red becomes 128 after the black veil, not 255 (missing veil).
130+ for y := 2; y < 5; y++ {
131+ for x := 2; x < 5; x++ {
132+ if _, err := white.SHM.File.WriteAt([]byte{0, 0, 255, 255}, int64((y*20+x)*4)); err != nil {
133+ t.Fatal(err)
134+ }
135+ }
136+ }
137+ white.Seq, white.Version = 2, 2
138+ white.DamageHistory = []ports.SeqDamage{{Seq: 1, Full: true}, {Seq: 2, Rects: []ports.Rect{{X: 2, Y: 2, W: 3, H: 3}}}}
139+ contents[1] = white
140+ before := r.redrawn
141+ if err := render(r, s, contents); err != nil {
142+ t.Fatal(err)
143+ }
144+ if r.redrawn-before >= 64*48 {
145+ t.Fatal("content change unexpectedly triggered full redraw")
146+ }
147+ if got := r.Pixels().RGBAAt(3, 3); !near(got, color.RGBA{128, 0, 0, 255}, 2) {
148+ t.Fatalf("damaged tile beneath veil = %v, want dark red", got)
149+ }
150+}
@@ -0,0 +1,150 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "image/color"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// These walk tests need no Vulkan device: placeholder windows and solid
12+// separators produce draw commands without importing client buffers.
13+func TestSceneDimOrder(t *testing.T) {
14+ r := &Renderer{width: 80, height: 60}
15+ s := ports.Scene{
16+ Dim: 0.4, Border: ports.Border{Active: "#ffffff"},
17+ Windows: []ports.SceneWindow{
18+ {ID: 1, Rect: ports.Rect{W: 20, H: 20}},
19+ {ID: 2, Floating: true, Hidden: true, Rect: ports.Rect{W: 20, H: 20}},
20+ {ID: 3, Floating: true, Popup: true, Rect: ports.Rect{W: 20, H: 20}},
21+ {ID: 4, Floating: true, Rect: ports.Rect{W: 0, H: 20}},
22+ {ID: 5, Floating: true, Rect: ports.Rect{X: 30, W: 20, H: 20}},
23+ {ID: 6, Floating: true, Rect: ports.Rect{X: 50, W: 20, H: 20}},
24+ },
25+ Separators: []ports.Separator{{Rect: ports.Rect{W: 80, H: 1}, Active: true}},
26+ }
27+ walk := func(s ports.Scene) []draw {
28+ return r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
29+ }
30+ got := walk(s)
31+ if len(got) != 5 { // tile, tile lines, veil, two floats
32+ t.Fatalf("draws = %d, want 5", len(got))
33+ }
34+ veil := got[2]
35+ if veil.pc.rect != [4]int32{0, 0, 80, 60} || veil.pc.color != [4]float32{0, 0, 0, float32(s.Dim)} || veil.pc.misc[0] != modeSolid || veil.pc.misc[1] != 0 {
36+ t.Fatalf("veil = %+v", veil.pc)
37+ }
38+ if got[1].pc.color != [4]float32{1, 1, 1, 1} || got[3].pc.rect[0] != 30 || got[4].pc.rect[0] != 50 {
39+ t.Fatalf("tile line / float order: %+v", got)
40+ }
41+ for _, tc := range []struct {
42+ name string
43+ dim float64
44+ windows []ports.SceneWindow
45+ wantCount int
46+ wantAlpha float32
47+ }{
48+ {"zero", 0, s.Windows, 4, 0},
49+ {"negative", -1, s.Windows, 4, 0},
50+ {"clamped", 2, s.Windows, 5, 1},
51+ {"no float", 0.4, s.Windows[:4], 2, 0},
52+ } {
53+ t.Run(tc.name, func(t *testing.T) {
54+ s.Dim, s.Windows = tc.dim, tc.windows
55+ ds := walk(s)
56+ if len(ds) != tc.wantCount {
57+ t.Fatalf("draws = %d, want %d", len(ds), tc.wantCount)
58+ }
59+ if tc.wantAlpha != 0 && ds[2].pc.color[3] != tc.wantAlpha {
60+ t.Fatalf("alpha = %v, want %v", ds[2].pc.color[3], tc.wantAlpha)
61+ }
62+ })
63+ }
64+}
65+
66+func TestSceneDimPartialDamage(t *testing.T) {
67+ r := &Renderer{width: 80, height: 60}
68+ s := ports.Scene{Seq: 8, Dim: 0.5, Windows: []ports.SceneWindow{
69+ {ID: 1, Rect: ports.Rect{W: 40, H: 40}},
70+ {ID: 2, Floating: true, Rect: ports.Rect{X: 50, W: 20, H: 20}},
71+ }}
72+ tg := &target{valid: true, sceneSeq: 8, windows: map[ports.WindowID]heldWindow{1: {seq: 1, rect: image.Rect(0, 0, 40, 40)}, 2: {seq: 1, rect: image.Rect(50, 0, 70, 20)}}}
73+ contents := map[ports.WindowID]ports.SurfaceContent{1: {Seq: 2}, 2: {Seq: 1}}
74+ dmg := newDamage(tg, s, image.Rect(0, 0, 80, 60))
75+ ds := r.draws(s, contents, dmg)
76+ dmg.finish()
77+ if dmg.all() || dmg.area != image.Rect(0, 0, 40, 40) {
78+ t.Fatalf("damage = %v (full=%v)", dmg.area, dmg.all())
79+ }
80+ ds = dmg.clip(ds)
81+ if len(ds) != 2 || ds[1].pc.color != [4]float32{0, 0, 0, 0.5} || ds[1].pc.rect != [4]int32{0, 0, 40, 40} {
82+ t.Fatalf("clipped draws = %+v", ds)
83+ }
84+}
85+
86+// The actual blend must darken already painted pixels without darkening
87+// floats, window popups or top layers. Also exercise a retained target:
88+// content damage beneath the veil must replay it over that region.
89+func TestRendererDimBlendAndDamage(t *testing.T) {
90+ r, err := New(64, 48)
91+ if err != nil {
92+ t.Skipf("Vulkan unavailable: %v", err)
93+ }
94+ defer r.Close()
95+ s := ports.Scene{Seq: 10, Background: "#ffffff", Dim: 0.5,
96+ Windows: []ports.SceneWindow{
97+ {ID: 1, Rect: ports.Rect{W: 20, H: 20}},
98+ {ID: 2, Floating: true, Rect: ports.Rect{X: 30, W: 10, H: 10}},
99+ {ID: 3, Popup: true, Rect: ports.Rect{X: 42, W: 8, H: 8}},
100+ },
101+ Layers: []ports.SceneLayer{{ID: 4, Layer: ports.LayerTop, Rect: ports.Rect{X: 52, W: 8, H: 8}}},
102+ }
103+ white := solidContent(t, 20, 20, color.RGBA{255, 255, 255, 255})
104+ white.Seq = 1
105+ popup := solidContent(t, 8, 8, color.RGBA{255, 255, 255, 255})
106+ top := solidContent(t, 8, 8, color.RGBA{255, 255, 255, 255})
107+ contents := map[ports.WindowID]ports.SurfaceContent{1: white, 3: popup, 4: top}
108+ check := func() {
109+ t.Helper()
110+ if err := render(r, s, contents); err != nil {
111+ t.Fatal(err)
112+ }
113+ for _, tc := range []struct {
114+ x, y int
115+ want color.RGBA
116+ }{
117+ {60, 40, color.RGBA{128, 128, 128, 255}}, // background
118+ {5, 5, color.RGBA{128, 128, 128, 255}}, // tile
119+ {43, 5, color.RGBA{255, 255, 255, 255}}, // window popup
120+ {53, 5, color.RGBA{255, 255, 255, 255}}, // top layer
121+ } {
122+ if got := r.Pixels().RGBAAt(tc.x, tc.y); !near(got, tc.want, 2) {
123+ t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)
124+ }
125+ }
126+ }
127+ check()
128+ // Reuse the target with unchanged scene Seq, but changed tile content.
129+ // Red becomes 128 after the black veil, not 255 (missing veil).
130+ for y := 2; y < 5; y++ {
131+ for x := 2; x < 5; x++ {
132+ if _, err := white.SHM.File.WriteAt([]byte{0, 0, 255, 255}, int64((y*20+x)*4)); err != nil {
133+ t.Fatal(err)
134+ }
135+ }
136+ }
137+ white.Seq, white.Version = 2, 2
138+ white.DamageHistory = []ports.SeqDamage{{Seq: 1, Full: true}, {Seq: 2, Rects: []ports.Rect{{X: 2, Y: 2, W: 3, H: 3}}}}
139+ contents[1] = white
140+ before := r.redrawn
141+ if err := render(r, s, contents); err != nil {
142+ t.Fatal(err)
143+ }
144+ if r.redrawn-before >= 64*48 {
145+ t.Fatal("content change unexpectedly triggered full redraw")
146+ }
147+ if got := r.Pixels().RGBAAt(3, 3); !near(got, color.RGBA{128, 0, 0, 255}, 2) {
148+ t.Fatalf("damaged tile beneath veil = %v, want dark red", got)
149+ }
150+}
M internal/adapters/wayland/dmabuf.go +5 -4
@@ -170,8 +170,8 @@ func (g *dmabufGlobal) scanoutFor(surf *surface) (ports.OutputFormats, bool) {
170170 if surf == nil || surf.destroyed {
171171 return ports.OutputFormats{}, false
172172 }
173- root := surf.root()
174- if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast || !root.xdg.window.last.Fullscreen {
173+ root := toplevelRoot(surf)
174+ if g.server.invisible(surf) || root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast || !root.xdg.window.last.Fullscreen {
175175 return ports.OutputFormats{}, false
176176 }
177177 out, ok := g.scanout[root.xdg.window.last.Output]
@@ -193,7 +193,7 @@ func (g *dmabufGlobal) setOutputFormats(f ports.OutputFormats) {
193193 }
194194 for fb, surf := range g.feedbacks {
195195 if surf != nil && !surf.destroyed {
196- if root := surf.root(); root.xdg != nil && root.xdg.window != nil && root.xdg.window.last.Fullscreen && root.xdg.window.last.Output == f.Output {
196+ if root := toplevelRoot(surf); root.xdg != nil && root.xdg.window != nil && root.xdg.window.last.Fullscreen && root.xdg.window.last.Output == f.Output {
197197 g.send(fb, surf)
198198 }
199199 }
@@ -207,7 +207,8 @@ func (g *dmabufGlobal) resendSurface(root *surface) {
207207 return
208208 }
209209 for fb, surf := range g.feedbacks {
210- if surf != nil && surf.root() == root {
210+ // Popups follow their toplevel's offer (scanoutFor).
211+ if surf != nil && toplevelRoot(surf) == root {
211212 g.send(fb, surf)
212213 }
213214 }
@@ -170,8 +170,8 @@ func (g *dmabufGlobal) scanoutFor(surf *surface) (ports.OutputFormats, bool) {
170 if surf == nil || surf.destroyed {170 if surf == nil || surf.destroyed {
171 return ports.OutputFormats{}, false171 return ports.OutputFormats{}, false
172 }172 }
173- root := surf.root()173+ root := toplevelRoot(surf)
174- if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast || !root.xdg.window.last.Fullscreen {174+ if g.server.invisible(surf) || root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast || !root.xdg.window.last.Fullscreen {
175 return ports.OutputFormats{}, false175 return ports.OutputFormats{}, false
176 }176 }
177 out, ok := g.scanout[root.xdg.window.last.Output]177 out, ok := g.scanout[root.xdg.window.last.Output]
@@ -193,7 +193,7 @@ func (g *dmabufGlobal) setOutputFormats(f ports.OutputFormats) {
193 }193 }
194 for fb, surf := range g.feedbacks {194 for fb, surf := range g.feedbacks {
195 if surf != nil && !surf.destroyed {195 if surf != nil && !surf.destroyed {
196- if root := surf.root(); root.xdg != nil && root.xdg.window != nil && root.xdg.window.last.Fullscreen && root.xdg.window.last.Output == f.Output {196+ if root := toplevelRoot(surf); root.xdg != nil && root.xdg.window != nil && root.xdg.window.last.Fullscreen && root.xdg.window.last.Output == f.Output {
197 g.send(fb, surf)197 g.send(fb, surf)
198 }198 }
199 }199 }
@@ -207,7 +207,8 @@ func (g *dmabufGlobal) resendSurface(root *surface) {
207 return207 return
208 }208 }
209 for fb, surf := range g.feedbacks {209 for fb, surf := range g.feedbacks {
210- if surf != nil && surf.root() == root {210+ // Popups follow their toplevel's offer (scanoutFor).
211+ if surf != nil && toplevelRoot(surf) == root {
211 g.send(fb, surf)212 g.send(fb, surf)
212 }213 }
213 }214 }
M internal/adapters/wayland/dmabuf_test.go +56 -4
@@ -256,7 +256,7 @@ func TestDMABufScanoutTranche(t *testing.T) {
256256 t.Fatalf("tiled window: %+v", r)
257257 }
258258 formats <- ports.OutputFormats{Output: "HEADLESS-1", Device: 7, Formats: []ports.DMABufFormat{tiled}}
259- commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "HEADLESS-1"}
259+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "HEADLESS-1", Visible: true}
260260 r := round("fullscreen")
261261 for len(r) == 1 { // the formats may land after the configure
262262 r = round("fullscreen with formats")
@@ -264,19 +264,28 @@ func TestDMABufScanoutTranche(t *testing.T) {
264264 if len(r) != 2 || r[0].flags != scanout || len(r[0].indices) != 1 || r[0].indices[0] != 1 || r[1].flags != 0 {
265265 t.Fatalf("fullscreen: %+v", r)
266266 }
267+ // Hidden, the window loses its scanout tranche; shown, it gets it back.
268+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "HEADLESS-1"}
269+ if r := round("hidden"); len(r) != 1 {
270+ t.Fatalf("hidden fullscreen: %+v", r)
271+ }
272+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "HEADLESS-1", Visible: true}
273+ if r := round("shown again"); len(r) != 2 || r[0].flags != scanout {
274+ t.Fatalf("shown fullscreen: %+v", r)
275+ }
267276 // Fullscreen on another output: its formats (none) apply at once.
268- commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "OTHER-1"}
277+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "OTHER-1", Visible: true}
269278 if r := round("other output"); len(r) != 1 {
270279 t.Fatalf("moved output: %+v", r)
271280 }
272- commands <- ports.ConfigureWindow{ID: w.ID, Width: 800, Height: 600, Output: "HEADLESS-1"}
281+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 800, Height: 600, Output: "HEADLESS-1", Visible: true}
273282 if r := round("tiled again"); len(r) != 1 || r[0].flags != 0 {
274283 t.Fatalf("left fullscreen: %+v", r)
275284 }
276285 // A destroyed feedback gets nothing more (a protocol error would
277286 // kill the connection) and leaves the server's list.
278287 requestProtocol(t, c, fb, linuxdmabuf.ZwpLinuxDmabufFeedbackV1RequestDestroy)
279- commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "HEADLESS-1"}
288+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "HEADLESS-1", Visible: true}
280289 formats <- ports.OutputFormats{Output: "HEADLESS-1", Device: 7}
281290 if err := c.Roundtrip(); err != nil {
282291 t.Fatal(err)
@@ -287,3 +296,46 @@ func TestDMABufScanoutTranche(t *testing.T) {
287296 t.Fatalf("%d feedbacks kept", n)
288297 }
289298 }
299+
300+func TestInvisibleScanoutOffer(t *testing.T) {
301+ w := &window{hasLast: true, last: ports.ConfigureWindow{Fullscreen: true, Output: "DP-2", Visible: true}}
302+ surf := &surface{xdg: &xdgSurface{window: w}}
303+ s := &Server{}
304+ g := &dmabufGlobal{server: s, scanout: map[string]ports.OutputFormats{"DP-2": {Output: "DP-2", Formats: []ports.DMABufFormat{linearARGB}}}}
305+ if _, ok := g.scanoutFor(surf); !ok {
306+ t.Fatal("no visible scanout")
307+ }
308+ w.last.Visible = false
309+ if _, ok := g.scanoutFor(surf); ok {
310+ t.Fatal("invisible scanout offered")
311+ }
312+ w.last.Visible = true
313+ if _, ok := g.scanoutFor(surf); !ok {
314+ t.Fatal("scanout not restored")
315+ }
316+}
317+
318+// A popup's feedback follows its toplevel: resent with it, and on its
319+// output's format changes.
320+func TestPopupFeedbackFollowsToplevel(t *testing.T) {
321+ topWin := &window{hasLast: true, last: ports.ConfigureWindow{Fullscreen: true, Output: "DP-2", Visible: true}}
322+ top := &surface{xdg: &xdgSurface{window: topWin}}
323+ topWin.xdg = top.xdg
324+ top.xdg.surface = top
325+ popWin := &window{}
326+ popWin.popup = &popup{w: popWin, parent: topWin}
327+ pop := &surface{xdg: &xdgSurface{window: popWin}}
328+ popWin.xdg = pop.xdg
329+ pop.xdg.surface = pop
330+ if toplevelRoot(pop) != top {
331+ t.Fatal("popup does not resolve to its toplevel")
332+ }
333+ g := &dmabufGlobal{server: &Server{}, scanout: map[string]ports.OutputFormats{"DP-2": {Output: "DP-2", Formats: []ports.DMABufFormat{linearARGB}}}}
334+ if _, ok := g.scanoutFor(pop); !ok {
335+ t.Fatal("popup of a visible fullscreen window has no offer")
336+ }
337+ topWin.last.Visible = false
338+ if _, ok := g.scanoutFor(pop); ok {
339+ t.Fatal("popup of an invisible window keeps its offer")
340+ }
341+}
@@ -256,7 +256,7 @@ func TestDMABufScanoutTranche(t *testing.T) {
256 t.Fatalf("tiled window: %+v", r)256 t.Fatalf("tiled window: %+v", r)
257 }257 }
258 formats <- ports.OutputFormats{Output: "HEADLESS-1", Device: 7, Formats: []ports.DMABufFormat{tiled}}258 formats <- ports.OutputFormats{Output: "HEADLESS-1", Device: 7, Formats: []ports.DMABufFormat{tiled}}
259- commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "HEADLESS-1"}259+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "HEADLESS-1", Visible: true}
260 r := round("fullscreen")260 r := round("fullscreen")
261 for len(r) == 1 { // the formats may land after the configure261 for len(r) == 1 { // the formats may land after the configure
262 r = round("fullscreen with formats")262 r = round("fullscreen with formats")
@@ -264,19 +264,28 @@ func TestDMABufScanoutTranche(t *testing.T) {
264 if len(r) != 2 || r[0].flags != scanout || len(r[0].indices) != 1 || r[0].indices[0] != 1 || r[1].flags != 0 {264 if len(r) != 2 || r[0].flags != scanout || len(r[0].indices) != 1 || r[0].indices[0] != 1 || r[1].flags != 0 {
265 t.Fatalf("fullscreen: %+v", r)265 t.Fatalf("fullscreen: %+v", r)
266 }266 }
267+ // Hidden, the window loses its scanout tranche; shown, it gets it back.
268+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "HEADLESS-1"}
269+ if r := round("hidden"); len(r) != 1 {
270+ t.Fatalf("hidden fullscreen: %+v", r)
271+ }
272+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "HEADLESS-1", Visible: true}
273+ if r := round("shown again"); len(r) != 2 || r[0].flags != scanout {
274+ t.Fatalf("shown fullscreen: %+v", r)
275+ }
267 // Fullscreen on another output: its formats (none) apply at once.276 // Fullscreen on another output: its formats (none) apply at once.
268- commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "OTHER-1"}277+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "OTHER-1", Visible: true}
269 if r := round("other output"); len(r) != 1 {278 if r := round("other output"); len(r) != 1 {
270 t.Fatalf("moved output: %+v", r)279 t.Fatalf("moved output: %+v", r)
271 }280 }
272- commands <- ports.ConfigureWindow{ID: w.ID, Width: 800, Height: 600, Output: "HEADLESS-1"}281+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 800, Height: 600, Output: "HEADLESS-1", Visible: true}
273 if r := round("tiled again"); len(r) != 1 || r[0].flags != 0 {282 if r := round("tiled again"); len(r) != 1 || r[0].flags != 0 {
274 t.Fatalf("left fullscreen: %+v", r)283 t.Fatalf("left fullscreen: %+v", r)
275 }284 }
276 // A destroyed feedback gets nothing more (a protocol error would285 // A destroyed feedback gets nothing more (a protocol error would
277 // kill the connection) and leaves the server's list.286 // kill the connection) and leaves the server's list.
278 requestProtocol(t, c, fb, linuxdmabuf.ZwpLinuxDmabufFeedbackV1RequestDestroy)287 requestProtocol(t, c, fb, linuxdmabuf.ZwpLinuxDmabufFeedbackV1RequestDestroy)
279- commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "HEADLESS-1"}288+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Output: "HEADLESS-1", Visible: true}
280 formats <- ports.OutputFormats{Output: "HEADLESS-1", Device: 7}289 formats <- ports.OutputFormats{Output: "HEADLESS-1", Device: 7}
281 if err := c.Roundtrip(); err != nil {290 if err := c.Roundtrip(); err != nil {
282 t.Fatal(err)291 t.Fatal(err)
@@ -287,3 +296,46 @@ func TestDMABufScanoutTranche(t *testing.T) {
287 t.Fatalf("%d feedbacks kept", n)296 t.Fatalf("%d feedbacks kept", n)
288 }297 }
289 }298 }
299+
300+func TestInvisibleScanoutOffer(t *testing.T) {
301+ w := &window{hasLast: true, last: ports.ConfigureWindow{Fullscreen: true, Output: "DP-2", Visible: true}}
302+ surf := &surface{xdg: &xdgSurface{window: w}}
303+ s := &Server{}
304+ g := &dmabufGlobal{server: s, scanout: map[string]ports.OutputFormats{"DP-2": {Output: "DP-2", Formats: []ports.DMABufFormat{linearARGB}}}}
305+ if _, ok := g.scanoutFor(surf); !ok {
306+ t.Fatal("no visible scanout")
307+ }
308+ w.last.Visible = false
309+ if _, ok := g.scanoutFor(surf); ok {
310+ t.Fatal("invisible scanout offered")
311+ }
312+ w.last.Visible = true
313+ if _, ok := g.scanoutFor(surf); !ok {
314+ t.Fatal("scanout not restored")
315+ }
316+}
317+
318+// A popup's feedback follows its toplevel: resent with it, and on its
319+// output's format changes.
320+func TestPopupFeedbackFollowsToplevel(t *testing.T) {
321+ topWin := &window{hasLast: true, last: ports.ConfigureWindow{Fullscreen: true, Output: "DP-2", Visible: true}}
322+ top := &surface{xdg: &xdgSurface{window: topWin}}
323+ topWin.xdg = top.xdg
324+ top.xdg.surface = top
325+ popWin := &window{}
326+ popWin.popup = &popup{w: popWin, parent: topWin}
327+ pop := &surface{xdg: &xdgSurface{window: popWin}}
328+ popWin.xdg = pop.xdg
329+ pop.xdg.surface = pop
330+ if toplevelRoot(pop) != top {
331+ t.Fatal("popup does not resolve to its toplevel")
332+ }
333+ g := &dmabufGlobal{server: &Server{}, scanout: map[string]ports.OutputFormats{"DP-2": {Output: "DP-2", Formats: []ports.DMABufFormat{linearARGB}}}}
334+ if _, ok := g.scanoutFor(pop); !ok {
335+ t.Fatal("popup of a visible fullscreen window has no offer")
336+ }
337+ topWin.last.Visible = false
338+ if _, ok := g.scanoutFor(pop); ok {
339+ t.Fatal("popup of an invisible window keeps its offer")
340+ }
341+}
M internal/adapters/wayland/fifo.go +9 -3
@@ -428,7 +428,7 @@ func (s *surface) dropQueue() {
428428 cb.Destroy()
429429 }
430430 } else if len(u.callbacks) > 0 {
431- s.server.queueFrames(s.server.frameOutput(s), u.callbacks)
431+ s.server.queueFrames(s, u.callbacks)
432432 }
433433 for _, fb := range u.feedback {
434434 discard(fb)
@@ -477,7 +477,7 @@ func (s *surface) setBarrier(now time.Time) {
477477 // fifoOutput is the output pacing a surface, "" when none does.
478478 func (s *Server) fifoOutput(surf *surface) string {
479479 name := s.frameOutput(surf)
480- if name != "" && s.outputByNameExact(name) == nil {
480+ if name != "" && name != suspendedFrameQueue && s.outputByNameExact(name) == nil {
481481 return ""
482482 }
483483 return name
@@ -485,6 +485,9 @@ func (s *Server) fifoOutput(surf *surface) string {
485485
486486 // outputPeriod is one refresh of an output ("": 60 Hz).
487487 func (s *Server) outputPeriod(name string) time.Duration {
488+ if name == suspendedFrameQueue {
489+ return suspendedFramePeriod
490+ }
488491 if o := s.outputByNameExact(name); o != nil && name != "" {
489492 return framePeriod(o.place.Info.RefreshMilli)
490493 }
@@ -507,7 +510,7 @@ func (s *Server) tickFifo(now time.Time, flipped map[string]bool) (time.Duration
507510 delete(s.lastFlip, name)
508511 }
509512 }
510- wait := defaultFramePeriod
513+ wait := suspendedFramePeriod
511514 s.readinessGeneration++
512515 for surf := range s.fifoSurfaces {
513516 name := s.fifoOutput(surf)
@@ -558,6 +561,9 @@ func (s *Server) tickFifo(now time.Time, flipped map[string]bool) (time.Duration
558561 if surf.barrier {
559562 wait = min(wait, deadline.Sub(now))
560563 }
564+ if len(surf.queue) > 0 {
565+ wait = min(wait, p)
566+ }
561567 if !surf.barrier && len(surf.queue) == 0 {
562568 delete(s.fifoSurfaces, surf)
563569 }
@@ -428,7 +428,7 @@ func (s *surface) dropQueue() {
428 cb.Destroy()428 cb.Destroy()
429 }429 }
430 } else if len(u.callbacks) > 0 {430 } else if len(u.callbacks) > 0 {
431- s.server.queueFrames(s.server.frameOutput(s), u.callbacks)431+ s.server.queueFrames(s, u.callbacks)
432 }432 }
433 for _, fb := range u.feedback {433 for _, fb := range u.feedback {
434 discard(fb)434 discard(fb)
@@ -477,7 +477,7 @@ func (s *surface) setBarrier(now time.Time) {
477 // fifoOutput is the output pacing a surface, "" when none does.477 // fifoOutput is the output pacing a surface, "" when none does.
478 func (s *Server) fifoOutput(surf *surface) string {478 func (s *Server) fifoOutput(surf *surface) string {
479 name := s.frameOutput(surf)479 name := s.frameOutput(surf)
480- if name != "" && s.outputByNameExact(name) == nil {480+ if name != "" && name != suspendedFrameQueue && s.outputByNameExact(name) == nil {
481 return ""481 return ""
482 }482 }
483 return name483 return name
@@ -485,6 +485,9 @@ func (s *Server) fifoOutput(surf *surface) string {
485 485
486 // outputPeriod is one refresh of an output ("": 60 Hz).486 // outputPeriod is one refresh of an output ("": 60 Hz).
487 func (s *Server) outputPeriod(name string) time.Duration {487 func (s *Server) outputPeriod(name string) time.Duration {
488+ if name == suspendedFrameQueue {
489+ return suspendedFramePeriod
490+ }
488 if o := s.outputByNameExact(name); o != nil && name != "" {491 if o := s.outputByNameExact(name); o != nil && name != "" {
489 return framePeriod(o.place.Info.RefreshMilli)492 return framePeriod(o.place.Info.RefreshMilli)
490 }493 }
@@ -507,7 +510,7 @@ func (s *Server) tickFifo(now time.Time, flipped map[string]bool) (time.Duration
507 delete(s.lastFlip, name)510 delete(s.lastFlip, name)
508 }511 }
509 }512 }
510- wait := defaultFramePeriod513+ wait := suspendedFramePeriod
511 s.readinessGeneration++514 s.readinessGeneration++
512 for surf := range s.fifoSurfaces {515 for surf := range s.fifoSurfaces {
513 name := s.fifoOutput(surf)516 name := s.fifoOutput(surf)
@@ -558,6 +561,9 @@ func (s *Server) tickFifo(now time.Time, flipped map[string]bool) (time.Duration
558 if surf.barrier {561 if surf.barrier {
559 wait = min(wait, deadline.Sub(now))562 wait = min(wait, deadline.Sub(now))
560 }563 }
564+ if len(surf.queue) > 0 {
565+ wait = min(wait, p)
566+ }
561 if !surf.barrier && len(surf.queue) == 0 {567 if !surf.barrier && len(surf.queue) == 0 {
562 delete(s.fifoSurfaces, surf)568 delete(s.fifoSurfaces, surf)
563 }569 }
M internal/adapters/wayland/fifo_test.go +16 -0
@@ -274,3 +274,19 @@ func TestGraphReadyMemoizesPredecessors(t *testing.T) {
274274 t.Fatal("predecessors not memoized")
275275 }
276276 }
277+
278+func TestInvisibleFifoPeriod(t *testing.T) {
279+ out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
280+ s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}, frameReady: make(chan struct{}, 1)}
281+ w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2"}}
282+ surf := &surface{server: s, xdg: &xdgSurface{window: w}}
283+ now := time.Unix(100, 0)
284+ surf.setBarrier(now)
285+ if _, _ = s.tickFifo(now.Add(100*time.Millisecond), map[string]bool{"DP-2": true}); !surf.barrier || s.outputPeriod(s.fifoOutput(surf)) != time.Second {
286+ t.Fatalf("hidden barrier cleared or period wrong: %v %v", surf.barrier, s.outputPeriod(s.fifoOutput(surf)))
287+ }
288+ w.last.Visible = true
289+ if _, waiting := s.tickFifo(now.Add(101*time.Millisecond), nil); surf.barrier || waiting {
290+ t.Fatalf("visible barrier not resumed: %v waiting %v", surf.barrier, waiting)
291+ }
292+}
@@ -274,3 +274,19 @@ func TestGraphReadyMemoizesPredecessors(t *testing.T) {
274 t.Fatal("predecessors not memoized")274 t.Fatal("predecessors not memoized")
275 }275 }
276 }276 }
277+
278+func TestInvisibleFifoPeriod(t *testing.T) {
279+ out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
280+ s := &Server{outputs: []*output{out}, fifoSurfaces: map[*surface]struct{}{}, lastFlip: map[string]time.Time{}, frameReady: make(chan struct{}, 1)}
281+ w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2"}}
282+ surf := &surface{server: s, xdg: &xdgSurface{window: w}}
283+ now := time.Unix(100, 0)
284+ surf.setBarrier(now)
285+ if _, _ = s.tickFifo(now.Add(100*time.Millisecond), map[string]bool{"DP-2": true}); !surf.barrier || s.outputPeriod(s.fifoOutput(surf)) != time.Second {
286+ t.Fatalf("hidden barrier cleared or period wrong: %v %v", surf.barrier, s.outputPeriod(s.fifoOutput(surf)))
287+ }
288+ w.last.Visible = true
289+ if _, waiting := s.tickFifo(now.Add(101*time.Millisecond), nil); surf.barrier || waiting {
290+ t.Fatalf("visible barrier not resumed: %v waiting %v", surf.barrier, waiting)
291+ }
292+}
M internal/adapters/wayland/frames.go +62 -11
@@ -14,12 +14,14 @@ import (
1414 // Frame callbacks (wl_surface.frame) tell a client when to draw its next
1515 // frame. They are paced per output: on its page flip when it flips, else
1616 // at its refresh rate, so a 165 Hz output lets clients draw 165 frames a
17-// second and a V-Sync game follows the screen. Surfaces with no output
18-// (hidden windows, cursors, unplaced surfaces) are paced at 60 Hz. The
17+// second and a V-Sync game follows the screen. Cursors and unplaced
18+// surfaces are paced at 60 Hz; invisible toplevel trees at 1 Hz. The
1919 // pacer sleeps while no callback waits.
2020
2121 // defaultFramePeriod paces surfaces without an output (60 Hz).
2222 const defaultFramePeriod = time.Second / 60
23+const suspendedFramePeriod = time.Second
24+const suspendedFrameQueue = "\x00suspended" // never a wl_output name
2325
2426 // pace fires the due frame callbacks on page flips and deadlines.
2527 func (s *Server) pace(ctx context.Context) {
@@ -106,15 +108,29 @@ func (s *Server) pace(ctx context.Context) {
106108 }
107109 }
108110
109-// frameOutput is the output showing a surface, "" when none does (a hidden
110-// window, a cursor): unlike outputOfSurface, no fallback to the focused one.
111-func (s *Server) frameOutput(surf *surface) string {
111+// toplevelRoot resolves subsurfaces and popups to their parent toplevel.
112+func toplevelRoot(surf *surface) *surface {
112113 surf = surf.root()
113114 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
114115 surf = surf.xdg.window.popup.parent.xdg.surface.root()
115116 }
117+ return surf
118+}
119+
120+func (s *Server) invisible(surf *surface) bool {
121+ root := toplevelRoot(surf)
122+ return root.xdg != nil && root.xdg.window != nil && root.xdg.window.hasLast && !root.xdg.window.last.Visible
123+}
124+
125+// frameOutput is the pacing class for a surface; "" is the 60 Hz
126+// outputless class, not the invisible toplevel class.
127+func (s *Server) frameOutput(surf *surface) string {
128+ surf = toplevelRoot(surf)
116129 switch {
117130 case surf.xdg != nil && surf.xdg.window != nil:
131+ if s.invisible(surf) {
132+ return suspendedFrameQueue
133+ }
118134 return surf.xdg.window.last.Output
119135 case surf.layer != nil && surf.layer.output != nil:
120136 return surf.layer.output.name()
@@ -124,12 +140,45 @@ func (s *Server) frameOutput(surf *surface) string {
124140
125141 // queueFrames adds a committed surface's callbacks to its output's queue
126142 // ("" when it has none) and wakes the pacer.
127-func (s *Server) queueFrames(name string, callbacks []*wayland.Callback) {
143+func (s *Server) queueFrames(surf *surface, callbacks []*wayland.Callback) {
144+ name := s.frameOutput(surf)
128145 s.awaiting[name] = append(s.awaiting[name], callbacks...)
129- select {
130- case s.frameReady <- struct{}{}:
131- default:
146+ if s.frameOwners == nil {
147+ s.frameOwners = make(map[*wayland.Callback]*surface)
148+ }
149+ for _, cb := range callbacks {
150+ s.frameOwners[cb] = surf
151+ }
152+ s.wakePacer()
153+}
154+
155+// relocateCallbacks reclassifies callbacks committed before a visibility change.
156+func (s *Server) relocateCallbacks(root *surface) {
157+ moved := make(map[string][]*wayland.Callback)
158+ for name, callbacks := range s.awaiting {
159+ kept := callbacks[:0]
160+ for _, cb := range callbacks {
161+ owner := s.frameOwners[cb]
162+ if owner == nil || toplevelRoot(owner) != root || s.frameOutput(owner) == name {
163+ kept = append(kept, cb)
164+ continue
165+ }
166+ dest := s.frameOutput(owner)
167+ moved[dest] = append(moved[dest], cb)
168+ }
169+ if len(kept) == 0 {
170+ delete(s.awaiting, name)
171+ } else {
172+ s.awaiting[name] = kept
173+ }
174+ }
175+ for name, callbacks := range moved {
176+ s.awaiting[name] = append(s.awaiting[name], callbacks...)
177+ if name != suspendedFrameQueue {
178+ delete(s.frameDue, name) // resume without waiting for the old phase
179+ }
132180 }
181+ s.wakePacer()
133182 }
134183
135184 // dueFrames returns the queues to fire now: flipped outputs and those past
@@ -146,12 +195,13 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
146195 }
147196 // Callbacks of surfaces whose output is gone go with the outputless ones.
148197 for name, callbacks := range s.awaiting {
149- if _, ok := periods[name]; !ok && name != "" {
198+ if _, ok := periods[name]; !ok && name != "" && name != suspendedFrameQueue {
150199 delete(s.awaiting, name)
151200 s.awaiting[""] = append(s.awaiting[""], callbacks...)
152201 }
153202 }
154203 periods[""] = defaultFramePeriod
204+ periods[suspendedFrameQueue] = suspendedFramePeriod
155205 for name := range s.frameDue {
156206 if _, ok := periods[name]; !ok {
157207 delete(s.frameDue, name)
@@ -181,7 +231,7 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
181231 }
182232 }
183233 waiting := false
184- wait = defaultFramePeriod
234+ wait = suspendedFramePeriod
185235 for name, callbacks := range s.awaiting {
186236 if len(callbacks) == 0 || slices.Contains(fire, name) {
187237 continue
@@ -215,6 +265,7 @@ func (s *Server) sendFrames(name string, flipped bool) {
215265 ms = uint32(max(f.When-monotonic(s.started), 0).Milliseconds())
216266 }
217267 for _, cb := range callbacks {
268+ delete(s.frameOwners, cb)
218269 if !cb.Resource.Alive() {
219270 continue
220271 }
@@ -14,12 +14,14 @@ import (
14 // Frame callbacks (wl_surface.frame) tell a client when to draw its next14 // Frame callbacks (wl_surface.frame) tell a client when to draw its next
15 // frame. They are paced per output: on its page flip when it flips, else15 // frame. They are paced per output: on its page flip when it flips, else
16 // at its refresh rate, so a 165 Hz output lets clients draw 165 frames a16 // at its refresh rate, so a 165 Hz output lets clients draw 165 frames a
17-// second and a V-Sync game follows the screen. Surfaces with no output17+// second and a V-Sync game follows the screen. Cursors and unplaced
18-// (hidden windows, cursors, unplaced surfaces) are paced at 60 Hz. The18+// surfaces are paced at 60 Hz; invisible toplevel trees at 1 Hz. The
19 // pacer sleeps while no callback waits.19 // pacer sleeps while no callback waits.
20 20
21 // defaultFramePeriod paces surfaces without an output (60 Hz).21 // defaultFramePeriod paces surfaces without an output (60 Hz).
22 const defaultFramePeriod = time.Second / 6022 const defaultFramePeriod = time.Second / 60
23+const suspendedFramePeriod = time.Second
24+const suspendedFrameQueue = "\x00suspended" // never a wl_output name
23 25
24 // pace fires the due frame callbacks on page flips and deadlines.26 // pace fires the due frame callbacks on page flips and deadlines.
25 func (s *Server) pace(ctx context.Context) {27 func (s *Server) pace(ctx context.Context) {
@@ -106,15 +108,29 @@ func (s *Server) pace(ctx context.Context) {
106 }108 }
107 }109 }
108 110
109-// frameOutput is the output showing a surface, "" when none does (a hidden111+// toplevelRoot resolves subsurfaces and popups to their parent toplevel.
110-// window, a cursor): unlike outputOfSurface, no fallback to the focused one.112+func toplevelRoot(surf *surface) *surface {
111-func (s *Server) frameOutput(surf *surface) string {
112 surf = surf.root()113 surf = surf.root()
113 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {114 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
114 surf = surf.xdg.window.popup.parent.xdg.surface.root()115 surf = surf.xdg.window.popup.parent.xdg.surface.root()
115 }116 }
117+ return surf
118+}
119+
120+func (s *Server) invisible(surf *surface) bool {
121+ root := toplevelRoot(surf)
122+ return root.xdg != nil && root.xdg.window != nil && root.xdg.window.hasLast && !root.xdg.window.last.Visible
123+}
124+
125+// frameOutput is the pacing class for a surface; "" is the 60 Hz
126+// outputless class, not the invisible toplevel class.
127+func (s *Server) frameOutput(surf *surface) string {
128+ surf = toplevelRoot(surf)
116 switch {129 switch {
117 case surf.xdg != nil && surf.xdg.window != nil:130 case surf.xdg != nil && surf.xdg.window != nil:
131+ if s.invisible(surf) {
132+ return suspendedFrameQueue
133+ }
118 return surf.xdg.window.last.Output134 return surf.xdg.window.last.Output
119 case surf.layer != nil && surf.layer.output != nil:135 case surf.layer != nil && surf.layer.output != nil:
120 return surf.layer.output.name()136 return surf.layer.output.name()
@@ -124,12 +140,45 @@ func (s *Server) frameOutput(surf *surface) string {
124 140
125 // queueFrames adds a committed surface's callbacks to its output's queue141 // queueFrames adds a committed surface's callbacks to its output's queue
126 // ("" when it has none) and wakes the pacer.142 // ("" when it has none) and wakes the pacer.
127-func (s *Server) queueFrames(name string, callbacks []*wayland.Callback) {143+func (s *Server) queueFrames(surf *surface, callbacks []*wayland.Callback) {
144+ name := s.frameOutput(surf)
128 s.awaiting[name] = append(s.awaiting[name], callbacks...)145 s.awaiting[name] = append(s.awaiting[name], callbacks...)
129- select {146+ if s.frameOwners == nil {
130- case s.frameReady <- struct{}{}:147+ s.frameOwners = make(map[*wayland.Callback]*surface)
131- default:148+ }
149+ for _, cb := range callbacks {
150+ s.frameOwners[cb] = surf
151+ }
152+ s.wakePacer()
153+}
154+
155+// relocateCallbacks reclassifies callbacks committed before a visibility change.
156+func (s *Server) relocateCallbacks(root *surface) {
157+ moved := make(map[string][]*wayland.Callback)
158+ for name, callbacks := range s.awaiting {
159+ kept := callbacks[:0]
160+ for _, cb := range callbacks {
161+ owner := s.frameOwners[cb]
162+ if owner == nil || toplevelRoot(owner) != root || s.frameOutput(owner) == name {
163+ kept = append(kept, cb)
164+ continue
165+ }
166+ dest := s.frameOutput(owner)
167+ moved[dest] = append(moved[dest], cb)
168+ }
169+ if len(kept) == 0 {
170+ delete(s.awaiting, name)
171+ } else {
172+ s.awaiting[name] = kept
173+ }
174+ }
175+ for name, callbacks := range moved {
176+ s.awaiting[name] = append(s.awaiting[name], callbacks...)
177+ if name != suspendedFrameQueue {
178+ delete(s.frameDue, name) // resume without waiting for the old phase
179+ }
132 }180 }
181+ s.wakePacer()
133 }182 }
134 183
135 // dueFrames returns the queues to fire now: flipped outputs and those past184 // dueFrames returns the queues to fire now: flipped outputs and those past
@@ -146,12 +195,13 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
146 }195 }
147 // Callbacks of surfaces whose output is gone go with the outputless ones.196 // Callbacks of surfaces whose output is gone go with the outputless ones.
148 for name, callbacks := range s.awaiting {197 for name, callbacks := range s.awaiting {
149- if _, ok := periods[name]; !ok && name != "" {198+ if _, ok := periods[name]; !ok && name != "" && name != suspendedFrameQueue {
150 delete(s.awaiting, name)199 delete(s.awaiting, name)
151 s.awaiting[""] = append(s.awaiting[""], callbacks...)200 s.awaiting[""] = append(s.awaiting[""], callbacks...)
152 }201 }
153 }202 }
154 periods[""] = defaultFramePeriod203 periods[""] = defaultFramePeriod
204+ periods[suspendedFrameQueue] = suspendedFramePeriod
155 for name := range s.frameDue {205 for name := range s.frameDue {
156 if _, ok := periods[name]; !ok {206 if _, ok := periods[name]; !ok {
157 delete(s.frameDue, name)207 delete(s.frameDue, name)
@@ -181,7 +231,7 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
181 }231 }
182 }232 }
183 waiting := false233 waiting := false
184- wait = defaultFramePeriod234+ wait = suspendedFramePeriod
185 for name, callbacks := range s.awaiting {235 for name, callbacks := range s.awaiting {
186 if len(callbacks) == 0 || slices.Contains(fire, name) {236 if len(callbacks) == 0 || slices.Contains(fire, name) {
187 continue237 continue
@@ -215,6 +265,7 @@ func (s *Server) sendFrames(name string, flipped bool) {
215 ms = uint32(max(f.When-monotonic(s.started), 0).Milliseconds())265 ms = uint32(max(f.When-monotonic(s.started), 0).Milliseconds())
216 }266 }
217 for _, cb := range callbacks {267 for _, cb := range callbacks {
268+ delete(s.frameOwners, cb)
218 if !cb.Resource.Alive() {269 if !cb.Resource.Alive() {
219 continue270 continue
220 }271 }
M internal/adapters/wayland/frames_test.go +37 -0
@@ -85,3 +85,40 @@ func TestDueFrames(t *testing.T) {
8585 t.Fatalf("unplugged: fire %v due %v", fire, s.frameDue)
8686 }
8787 }
88+
89+func TestInvisibleFramePacingAndMigration(t *testing.T) {
90+ out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
91+ s := &Server{outputs: []*output{out}, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1)}
92+ w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
93+ root := &surface{xdg: &xdgSurface{window: w}}
94+ child := &surface{sub: subState{parent: root}}
95+ cursor := &surface{}
96+ // Use distinct live callback pointers as identities; dueFrames never dereferences them.
97+ a, b := new(wayland.Callback), new(wayland.Callback)
98+ s.queueFrames(child, []*wayland.Callback{a})
99+ s.queueFrames(cursor, []*wayland.Callback{b})
100+ w.last.Visible = false
101+ s.relocateCallbacks(root)
102+ if len(s.awaiting[suspendedFrameQueue]) != 1 || len(s.awaiting["DP-2"]) != 0 || len(s.awaiting[""]) != 1 || s.frameOutput(child) != suspendedFrameQueue {
103+ t.Fatalf("migration: %+v", s.awaiting)
104+ }
105+ now := time.Unix(100, 0)
106+ fire, _, _ := s.dueFrames(now, nil)
107+ if !slices.Contains(fire, suspendedFrameQueue) || !slices.Contains(fire, "") {
108+ t.Fatalf("initial fire: %v", fire)
109+ }
110+ delete(s.awaiting, suspendedFrameQueue)
111+ delete(s.awaiting, "")
112+ s.queueFrames(child, []*wayland.Callback{a})
113+ s.queueFrames(cursor, []*wayland.Callback{b})
114+ fire, wait, _ := s.dueFrames(now.Add(20*time.Millisecond), nil)
115+ if slices.Contains(fire, suspendedFrameQueue) || !slices.Contains(fire, "") || wait != 980*time.Millisecond {
116+ t.Fatalf("20ms: fire %v wait %v", fire, wait)
117+ }
118+ w.last.Visible = true
119+ s.relocateCallbacks(root)
120+ fire, _, _ = s.dueFrames(now.Add(21*time.Millisecond), nil)
121+ if !slices.Contains(fire, "DP-2") || len(s.awaiting[suspendedFrameQueue]) != 0 {
122+ t.Fatalf("resume: fire %v queues %+v", fire, s.awaiting)
123+ }
124+}
@@ -85,3 +85,40 @@ func TestDueFrames(t *testing.T) {
85 t.Fatalf("unplugged: fire %v due %v", fire, s.frameDue)85 t.Fatalf("unplugged: fire %v due %v", fire, s.frameDue)
86 }86 }
87 }87 }
88+
89+func TestInvisibleFramePacingAndMigration(t *testing.T) {
90+ out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
91+ s := &Server{outputs: []*output{out}, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1)}
92+ w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
93+ root := &surface{xdg: &xdgSurface{window: w}}
94+ child := &surface{sub: subState{parent: root}}
95+ cursor := &surface{}
96+ // Use distinct live callback pointers as identities; dueFrames never dereferences them.
97+ a, b := new(wayland.Callback), new(wayland.Callback)
98+ s.queueFrames(child, []*wayland.Callback{a})
99+ s.queueFrames(cursor, []*wayland.Callback{b})
100+ w.last.Visible = false
101+ s.relocateCallbacks(root)
102+ if len(s.awaiting[suspendedFrameQueue]) != 1 || len(s.awaiting["DP-2"]) != 0 || len(s.awaiting[""]) != 1 || s.frameOutput(child) != suspendedFrameQueue {
103+ t.Fatalf("migration: %+v", s.awaiting)
104+ }
105+ now := time.Unix(100, 0)
106+ fire, _, _ := s.dueFrames(now, nil)
107+ if !slices.Contains(fire, suspendedFrameQueue) || !slices.Contains(fire, "") {
108+ t.Fatalf("initial fire: %v", fire)
109+ }
110+ delete(s.awaiting, suspendedFrameQueue)
111+ delete(s.awaiting, "")
112+ s.queueFrames(child, []*wayland.Callback{a})
113+ s.queueFrames(cursor, []*wayland.Callback{b})
114+ fire, wait, _ := s.dueFrames(now.Add(20*time.Millisecond), nil)
115+ if slices.Contains(fire, suspendedFrameQueue) || !slices.Contains(fire, "") || wait != 980*time.Millisecond {
116+ t.Fatalf("20ms: fire %v wait %v", fire, wait)
117+ }
118+ w.last.Visible = true
119+ s.relocateCallbacks(root)
120+ fire, _, _ = s.dueFrames(now.Add(21*time.Millisecond), nil)
121+ if !slices.Contains(fire, "DP-2") || len(s.awaiting[suspendedFrameQueue]) != 0 {
122+ t.Fatalf("resume: fire %v queues %+v", fire, s.awaiting)
123+ }
124+}
M internal/adapters/wayland/presentation.go +4 -2
@@ -94,7 +94,7 @@ func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedb
9494 return
9595 }
9696 output := srv.frameOutput(s)
97- if win == 0 || output == "" {
97+ if win == 0 || output == "" || output == suspendedFrameQueue {
9898 for _, fb := range pending {
9999 discard(fb)
100100 }
@@ -130,6 +130,8 @@ func (s *Server) presentFlip(output string, f *ports.FlipInfo) {
130130 case !w.fb.Resource.Alive():
131131 case w.surf.destroyed:
132132 discard(w.fb)
133+ case s.invisible(w.surf):
134+ discard(w.fb)
133135 case s.frameOutput(w.surf) != output || !ok || shown < w.seq:
134136 kept = append(kept, w)
135137 case f.Merged > 0:
@@ -188,7 +190,7 @@ func (s *Server) dropFeedbacks(now time.Time) {
188190 if !w.fb.Resource.Alive() {
189191 continue
190192 }
191- if w.surf.destroyed || s.frameOutput(w.surf) == "" || now.Sub(w.at) > feedbackTimeout {
193+ if w.surf.destroyed || s.invisible(w.surf) || s.frameOutput(w.surf) == "" || now.Sub(w.at) > feedbackTimeout {
192194 discard(w.fb)
193195 continue
194196 }
@@ -94,7 +94,7 @@ func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedb
94 return94 return
95 }95 }
96 output := srv.frameOutput(s)96 output := srv.frameOutput(s)
97- if win == 0 || output == "" {97+ if win == 0 || output == "" || output == suspendedFrameQueue {
98 for _, fb := range pending {98 for _, fb := range pending {
99 discard(fb)99 discard(fb)
100 }100 }
@@ -130,6 +130,8 @@ func (s *Server) presentFlip(output string, f *ports.FlipInfo) {
130 case !w.fb.Resource.Alive():130 case !w.fb.Resource.Alive():
131 case w.surf.destroyed:131 case w.surf.destroyed:
132 discard(w.fb)132 discard(w.fb)
133+ case s.invisible(w.surf):
134+ discard(w.fb)
133 case s.frameOutput(w.surf) != output || !ok || shown < w.seq:135 case s.frameOutput(w.surf) != output || !ok || shown < w.seq:
134 kept = append(kept, w)136 kept = append(kept, w)
135 case f.Merged > 0:137 case f.Merged > 0:
@@ -188,7 +190,7 @@ func (s *Server) dropFeedbacks(now time.Time) {
188 if !w.fb.Resource.Alive() {190 if !w.fb.Resource.Alive() {
189 continue191 continue
190 }192 }
191- if w.surf.destroyed || s.frameOutput(w.surf) == "" || now.Sub(w.at) > feedbackTimeout {193+ if w.surf.destroyed || s.invisible(w.surf) || s.frameOutput(w.surf) == "" || now.Sub(w.at) > feedbackTimeout {
192 discard(w.fb)194 discard(w.fb)
193 continue195 continue
194 }196 }
M internal/adapters/wayland/presentation_test.go +11 -1
@@ -69,7 +69,7 @@ func TestPresentationFeedback(t *testing.T) {
6969 c := protocolClient(t, s, dir)
7070 w, surf, xdg := surfaceMapper(t, c, events)()
7171 registerProtocol(t, c, xdg)
72- commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1"}
72+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1", Visible: true}
7373 for deadline := time.Now().Add(2 * time.Second); ; {
7474 var out string
7575 s.display.Do(func() { out = s.frameOutput(s.windows[w.ID].xdg.surface) })
@@ -158,6 +158,16 @@ func TestPresentationFeedback(t *testing.T) {
158158 flip := func(when time.Duration, seq uint64, shows uint64, zero ports.WindowID) {
159159 presented <- ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: when, Seq: seq, Refresh: time.Second / 60, ZeroCopy: zero, HardwareClock: true, Shows: map[ports.WindowID]uint64{w.ID: shows}}}
160160 }
161+ // With no flip, hiding must discard a waiting feedback immediately.
162+ hidden := feedback()
163+ commit()
164+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1", Visible: false}
165+ if ev := answer(hidden, "hidden without flip"); !ev.discarded {
166+ t.Fatalf("hidden feedback: %+v", ev)
167+ }
168+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1", Visible: true}
169+ // Synchronize the resume before committing the next feedback.
170+ s.display.Do(func() {})
161171 // Presented with the flip's time, counter and flags.
162172 a := feedback()
163173 commit()
@@ -69,7 +69,7 @@ func TestPresentationFeedback(t *testing.T) {
69 c := protocolClient(t, s, dir)69 c := protocolClient(t, s, dir)
70 w, surf, xdg := surfaceMapper(t, c, events)()70 w, surf, xdg := surfaceMapper(t, c, events)()
71 registerProtocol(t, c, xdg)71 registerProtocol(t, c, xdg)
72- commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1"}72+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1", Visible: true}
73 for deadline := time.Now().Add(2 * time.Second); ; {73 for deadline := time.Now().Add(2 * time.Second); ; {
74 var out string74 var out string
75 s.display.Do(func() { out = s.frameOutput(s.windows[w.ID].xdg.surface) })75 s.display.Do(func() { out = s.frameOutput(s.windows[w.ID].xdg.surface) })
@@ -158,6 +158,16 @@ func TestPresentationFeedback(t *testing.T) {
158 flip := func(when time.Duration, seq uint64, shows uint64, zero ports.WindowID) {158 flip := func(when time.Duration, seq uint64, shows uint64, zero ports.WindowID) {
159 presented <- ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: when, Seq: seq, Refresh: time.Second / 60, ZeroCopy: zero, HardwareClock: true, Shows: map[ports.WindowID]uint64{w.ID: shows}}}159 presented <- ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: when, Seq: seq, Refresh: time.Second / 60, ZeroCopy: zero, HardwareClock: true, Shows: map[ports.WindowID]uint64{w.ID: shows}}}
160 }160 }
161+ // With no flip, hiding must discard a waiting feedback immediately.
162+ hidden := feedback()
163+ commit()
164+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1", Visible: false}
165+ if ev := answer(hidden, "hidden without flip"); !ev.discarded {
166+ t.Fatalf("hidden feedback: %+v", ev)
167+ }
168+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1", Visible: true}
169+ // Synchronize the resume before committing the next feedback.
170+ s.display.Do(func() {})
161 // Presented with the flip's time, counter and flags.171 // Presented with the flip's time, counter and flags.
162 a := feedback()172 a := feedback()
163 commit()173 commit()
M internal/adapters/wayland/release.go +5 -3
@@ -34,9 +34,11 @@ type heldBuffer struct {
3434 // releaseBuffer releases a replaced buffer, or holds it until the output
3535 // showing its window no longer reads it.
3636 func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer, sync syncHold) {
37+ // An invisible window is held like a shown one: a scanout plane may
38+ // still read its buffer until the flip that hides it.
3739 window, name := surf.root().windowID(), s.frameOutput(surf)
3840 if !b.Resource.Alive() || window == 0 || name == "" {
39- // Not drawn (unmapped, hidden, a cursor): nothing reads it.
41+ // Not drawn (unmapped, a cursor): nothing reads it.
4042 s.release(b, sync)
4143 return
4244 }
@@ -73,8 +75,8 @@ func (s *Server) releaseBufferSync(surf *surface, sync syncHold) {
7375 if !sync.release.set() && sync.acquire == nil {
7476 return
7577 }
76- window := surf.root().windowID()
77- if window == 0 || s.frameOutput(surf) == "" {
78+ window, name := surf.root().windowID(), s.frameOutput(surf)
79+ if window == 0 || name == "" {
7880 s.releaseSync(sync)
7981 return
8082 }
@@ -34,9 +34,11 @@ type heldBuffer struct {
34 // releaseBuffer releases a replaced buffer, or holds it until the output34 // releaseBuffer releases a replaced buffer, or holds it until the output
35 // showing its window no longer reads it.35 // showing its window no longer reads it.
36 func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer, sync syncHold) {36 func (s *Server) releaseBuffer(surf *surface, b *wayland.Buffer, sync syncHold) {
37+ // An invisible window is held like a shown one: a scanout plane may
38+ // still read its buffer until the flip that hides it.
37 window, name := surf.root().windowID(), s.frameOutput(surf)39 window, name := surf.root().windowID(), s.frameOutput(surf)
38 if !b.Resource.Alive() || window == 0 || name == "" {40 if !b.Resource.Alive() || window == 0 || name == "" {
39- // Not drawn (unmapped, hidden, a cursor): nothing reads it.41+ // Not drawn (unmapped, a cursor): nothing reads it.
40 s.release(b, sync)42 s.release(b, sync)
41 return43 return
42 }44 }
@@ -73,8 +75,8 @@ func (s *Server) releaseBufferSync(surf *surface, sync syncHold) {
73 if !sync.release.set() && sync.acquire == nil {75 if !sync.release.set() && sync.acquire == nil {
74 return76 return
75 }77 }
76- window := surf.root().windowID()78+ window, name := surf.root().windowID(), s.frameOutput(surf)
77- if window == 0 || s.frameOutput(surf) == "" {79+ if window == 0 || name == "" {
78 s.releaseSync(sync)80 s.releaseSync(sync)
79 return81 return
80 }82 }
M internal/adapters/wayland/release_test.go +63 -1
@@ -84,13 +84,75 @@ func TestKeepHeld(t *testing.T) {
8484 }
8585 }
8686
87+// A buffer replaced just after its window becomes invisible is still held:
88+// a scanout plane may read it until the flip that hides the window.
89+func TestInvisibleWindowBufferHeldUntilReport(t *testing.T) {
90+ s, events, commands, contents, dir := contentServer(t)
91+ go func() {
92+ for range contents {
93+ }
94+ }()
95+ c := protocolClient(t, s, dir)
96+ w, root, xdg := surfaceMapper(t, c, events)()
97+ // Our configures must not block the mapper's one-slot proxy.
98+ registerProtocol(t, c, xdg)
99+ // Commands apply asynchronously: wait for each configure.
100+ configure := func(v ports.ConfigureWindow) {
101+ t.Helper()
102+ commands <- v
103+ deadline := time.Now().Add(2 * time.Second)
104+ for {
105+ applied := false
106+ s.display.Do(func() { win := s.windows[w.ID]; applied = win.hasLast && win.last == v })
107+ if applied {
108+ return
109+ }
110+ if time.Now().After(deadline) {
111+ t.Fatalf("configure not applied: %+v", v)
112+ }
113+ time.Sleep(time.Millisecond)
114+ }
115+ }
116+ configure(ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1", Visible: true})
117+ b, other := shmBuffer(t, c), shmBuffer(t, c)
118+ attach := func(buf uint32) {
119+ requestProtocol(t, c, root, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
120+ requestProtocol(t, c, root, wayland.SurfaceRequestCommit)
121+ }
122+ attach(b)
123+ if err := c.Roundtrip(); err != nil {
124+ t.Fatal(err)
125+ }
126+ configure(ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1"})
127+ attach(other)
128+ if err := c.Roundtrip(); err != nil {
129+ t.Fatal(err)
130+ }
131+ s.display.Do(func() {
132+ held := false
133+ for _, h := range s.held {
134+ held = held || h.res != nil && h.res.ID() == b
135+ }
136+ if !held {
137+ t.Errorf("replaced buffer of an invisible window released at once: held=%d", len(s.held))
138+ }
139+ // Without a report showing it, the hold still ends.
140+ s.releaseHeld(time.Now().Add(heldTimeout), nil)
141+ for _, h := range s.held {
142+ if h.res != nil && h.res.ID() == b {
143+ t.Error("held buffer of an invisible window never released")
144+ }
145+ }
146+ })
147+}
148+
87149 // A later queued use of the same wl_buffer still owns it after the output
88150 // acknowledges the intervening commit.
89151 func TestHeldBufferRequeuedBeforeOutputAck(t *testing.T) {
90152 s, events, commands, contents, dir := contentServer(t)
91153 c := protocolClient(t, s, dir)
92154 w, root, _ := surfaceMapper(t, c, events)()
93- commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1"}
155+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1", Visible: true}
94156 if err := c.Roundtrip(); err != nil {
95157 t.Fatal(err)
96158 }
@@ -84,13 +84,75 @@ func TestKeepHeld(t *testing.T) {
84 }84 }
85 }85 }
86 86
87+// A buffer replaced just after its window becomes invisible is still held:
88+// a scanout plane may read it until the flip that hides the window.
89+func TestInvisibleWindowBufferHeldUntilReport(t *testing.T) {
90+ s, events, commands, contents, dir := contentServer(t)
91+ go func() {
92+ for range contents {
93+ }
94+ }()
95+ c := protocolClient(t, s, dir)
96+ w, root, xdg := surfaceMapper(t, c, events)()
97+ // Our configures must not block the mapper's one-slot proxy.
98+ registerProtocol(t, c, xdg)
99+ // Commands apply asynchronously: wait for each configure.
100+ configure := func(v ports.ConfigureWindow) {
101+ t.Helper()
102+ commands <- v
103+ deadline := time.Now().Add(2 * time.Second)
104+ for {
105+ applied := false
106+ s.display.Do(func() { win := s.windows[w.ID]; applied = win.hasLast && win.last == v })
107+ if applied {
108+ return
109+ }
110+ if time.Now().After(deadline) {
111+ t.Fatalf("configure not applied: %+v", v)
112+ }
113+ time.Sleep(time.Millisecond)
114+ }
115+ }
116+ configure(ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1", Visible: true})
117+ b, other := shmBuffer(t, c), shmBuffer(t, c)
118+ attach := func(buf uint32) {
119+ requestProtocol(t, c, root, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
120+ requestProtocol(t, c, root, wayland.SurfaceRequestCommit)
121+ }
122+ attach(b)
123+ if err := c.Roundtrip(); err != nil {
124+ t.Fatal(err)
125+ }
126+ configure(ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1"})
127+ attach(other)
128+ if err := c.Roundtrip(); err != nil {
129+ t.Fatal(err)
130+ }
131+ s.display.Do(func() {
132+ held := false
133+ for _, h := range s.held {
134+ held = held || h.res != nil && h.res.ID() == b
135+ }
136+ if !held {
137+ t.Errorf("replaced buffer of an invisible window released at once: held=%d", len(s.held))
138+ }
139+ // Without a report showing it, the hold still ends.
140+ s.releaseHeld(time.Now().Add(heldTimeout), nil)
141+ for _, h := range s.held {
142+ if h.res != nil && h.res.ID() == b {
143+ t.Error("held buffer of an invisible window never released")
144+ }
145+ }
146+ })
147+}
148+
87 // A later queued use of the same wl_buffer still owns it after the output149 // A later queued use of the same wl_buffer still owns it after the output
88 // acknowledges the intervening commit.150 // acknowledges the intervening commit.
89 func TestHeldBufferRequeuedBeforeOutputAck(t *testing.T) {151 func TestHeldBufferRequeuedBeforeOutputAck(t *testing.T) {
90 s, events, commands, contents, dir := contentServer(t)152 s, events, commands, contents, dir := contentServer(t)
91 c := protocolClient(t, s, dir)153 c := protocolClient(t, s, dir)
92 w, root, _ := surfaceMapper(t, c, events)()154 w, root, _ := surfaceMapper(t, c, events)()
93- commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1"}155+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1", Visible: true}
94 if err := c.Roundtrip(); err != nil {156 if err := c.Roundtrip(); err != nil {
95 t.Fatal(err)157 t.Fatal(err)
96 }158 }
M internal/adapters/wayland/server.go +11 -4
@@ -68,9 +68,10 @@ type Server struct {
6868 channels Channels
6969 // awaiting holds frame callbacks by output name, due at its next
7070 // frame (frameDue, or its page flip).
71- awaiting map[string][]*wayland.Callback
72- frameDue map[string]time.Time
73- frameReady chan struct{}
71+ awaiting map[string][]*wayland.Callback
72+ frameOwners map[*wayland.Callback]*surface
73+ frameDue map[string]time.Time
74+ frameReady chan struct{}
7475 // Pacer scratch: periods is used on the display goroutine, while
7576 // flipped and frameReports belong to pace and its synchronous Do call.
7677 framePeriods map[string]time.Duration
@@ -691,8 +692,14 @@ func (s *Server) apply(cmd ports.ClientCommand) {
691692 return
692693 }
693694 s.log.Info().Uint64("id", uint64(c.ID)).Int("w", c.Width).Int("h", c.Height).Bool("fullscreen", c.Fullscreen).Bool("activated", c.Activated).Msg("configure")
694- scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output
695+ visibilityChanged := w.hasLast && w.last.Visible != c.Visible
696+ scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible
695697 w.last, w.hasLast = c, true
698+ if visibilityChanged {
699+ s.relocateCallbacks(w.xdg.surface)
700+ s.dropFeedbacks(time.Now())
701+ s.wakePacer() // reconsider FIFO barriers at the new period
702+ }
696703 w.sendConfigure()
697704 s.toplevelChanged(w)
698705 if scanoutChanged && w.xdg.surface != nil {
@@ -68,9 +68,10 @@ type Server struct {
68 channels Channels68 channels Channels
69 // awaiting holds frame callbacks by output name, due at its next69 // awaiting holds frame callbacks by output name, due at its next
70 // frame (frameDue, or its page flip).70 // frame (frameDue, or its page flip).
71- awaiting map[string][]*wayland.Callback71+ awaiting map[string][]*wayland.Callback
72- frameDue map[string]time.Time72+ frameOwners map[*wayland.Callback]*surface
73- frameReady chan struct{}73+ frameDue map[string]time.Time
74+ frameReady chan struct{}
74 // Pacer scratch: periods is used on the display goroutine, while75 // Pacer scratch: periods is used on the display goroutine, while
75 // flipped and frameReports belong to pace and its synchronous Do call.76 // flipped and frameReports belong to pace and its synchronous Do call.
76 framePeriods map[string]time.Duration77 framePeriods map[string]time.Duration
@@ -691,8 +692,14 @@ func (s *Server) apply(cmd ports.ClientCommand) {
691 return692 return
692 }693 }
693 s.log.Info().Uint64("id", uint64(c.ID)).Int("w", c.Width).Int("h", c.Height).Bool("fullscreen", c.Fullscreen).Bool("activated", c.Activated).Msg("configure")694 s.log.Info().Uint64("id", uint64(c.ID)).Int("w", c.Width).Int("h", c.Height).Bool("fullscreen", c.Fullscreen).Bool("activated", c.Activated).Msg("configure")
694- scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output695+ visibilityChanged := w.hasLast && w.last.Visible != c.Visible
696+ scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible
695 w.last, w.hasLast = c, true697 w.last, w.hasLast = c, true
698+ if visibilityChanged {
699+ s.relocateCallbacks(w.xdg.surface)
700+ s.dropFeedbacks(time.Now())
701+ s.wakePacer() // reconsider FIFO barriers at the new period
702+ }
696 w.sendConfigure()703 w.sendConfigure()
697 s.toplevelChanged(w)704 s.toplevelChanged(w)
698 if scanoutChanged && w.xdg.surface != nil {705 if scanoutChanged && w.xdg.surface != nil {
M internal/adapters/wayland/surface.go +1 -1
@@ -379,7 +379,7 @@ func (s *surface) applyCommit(u *update) {
379379 s.inputAll, s.inputRects = u.inputAll, u.inputRects
380380 }
381381 if len(u.callbacks) > 0 {
382- s.server.queueFrames(s.server.frameOutput(s), u.callbacks)
382+ s.server.queueFrames(s, u.callbacks)
383383 }
384384 if s.role != nil {
385385 if u.layer != nil && s.layer == u.layer {
@@ -379,7 +379,7 @@ func (s *surface) applyCommit(u *update) {
379 s.inputAll, s.inputRects = u.inputAll, u.inputRects379 s.inputAll, s.inputRects = u.inputAll, u.inputRects
380 }380 }
381 if len(u.callbacks) > 0 {381 if len(u.callbacks) > 0 {
382- s.server.queueFrames(s.server.frameOutput(s), u.callbacks)382+ s.server.queueFrames(s, u.callbacks)
383 }383 }
384 if s.role != nil {384 if s.role != nil {
385 if u.layer != nil && s.layer == u.layer {385 if u.layer != nil && s.layer == u.layer {
M internal/adapters/wayland/syncobj_test.go +1 -1
@@ -121,7 +121,7 @@ func newSyncHarness(t *testing.T) *syncHarness {
121121 w, surf, xdg := surfaceMapper(t, h.c, events)()
122122 registerProtocol(t, h.c, xdg)
123123 h.win, h.surf = w.ID, surf
124- commands <- ports.ConfigureWindow{ID: w.ID, Width: 64, Height: 16, Output: "HEADLESS-1"}
124+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 64, Height: 16, Output: "HEADLESS-1", Visible: true}
125125 mgr := bindProtocol(t, h.c, "wp_linux_drm_syncobj_manager_v1")
126126 h.syncSurf = h.c.AllocateID()
127127 requestProtocol(t, h.c, mgr, linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1RequestGetSurface, h.syncSurf, surf)
@@ -121,7 +121,7 @@ func newSyncHarness(t *testing.T) *syncHarness {
121 w, surf, xdg := surfaceMapper(t, h.c, events)()121 w, surf, xdg := surfaceMapper(t, h.c, events)()
122 registerProtocol(t, h.c, xdg)122 registerProtocol(t, h.c, xdg)
123 h.win, h.surf = w.ID, surf123 h.win, h.surf = w.ID, surf
124- commands <- ports.ConfigureWindow{ID: w.ID, Width: 64, Height: 16, Output: "HEADLESS-1"}124+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 64, Height: 16, Output: "HEADLESS-1", Visible: true}
125 mgr := bindProtocol(t, h.c, "wp_linux_drm_syncobj_manager_v1")125 mgr := bindProtocol(t, h.c, "wp_linux_drm_syncobj_manager_v1")
126 h.syncSurf = h.c.AllocateID()126 h.syncSurf = h.c.AllocateID()
127 requestProtocol(t, h.c, mgr, linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1RequestGetSurface, h.syncSurf, surf)127 requestProtocol(t, h.c, mgr, linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1RequestGetSurface, h.syncSurf, surf)
M internal/adapters/wayland/xdg.go +3 -0
@@ -166,6 +166,9 @@ func toplevelStates(c ports.ConfigureWindow, version int32) []byte {
166166 if c.Activated {
167167 add(xdgshell.ToplevelStateActivated)
168168 }
169+ if !c.Visible && version >= 6 {
170+ add(xdgshell.ToplevelStateSuspended)
171+ }
169172 if c.Fullscreen || c.Floating {
170173 return states
171174 }
@@ -166,6 +166,9 @@ func toplevelStates(c ports.ConfigureWindow, version int32) []byte {
166 if c.Activated {166 if c.Activated {
167 add(xdgshell.ToplevelStateActivated)167 add(xdgshell.ToplevelStateActivated)
168 }168 }
169+ if !c.Visible && version >= 6 {
170+ add(xdgshell.ToplevelStateSuspended)
171+ }
169 if c.Fullscreen || c.Floating {172 if c.Fullscreen || c.Floating {
170 return states173 return states
171 }174 }
M internal/adapters/wayland/xdg_states_test.go +7 -4
@@ -17,10 +17,13 @@ func TestToplevelStates(t *testing.T) {
1717 version int32
1818 want []xdgshell.ToplevelState
1919 }{
20- {"tiled", ports.ConfigureWindow{Activated: true}, 2, append([]xdgshell.ToplevelState{xdgshell.ToplevelStateActivated, xdgshell.ToplevelStateMaximized}, tiled...)},
21- {"tiled v1", ports.ConfigureWindow{}, 1, []xdgshell.ToplevelState{xdgshell.ToplevelStateMaximized}},
22- {"floating", ports.ConfigureWindow{Floating: true, Activated: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateActivated}},
23- {"fullscreen", ports.ConfigureWindow{Fullscreen: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateFullscreen}},
20+ {"tiled", ports.ConfigureWindow{Activated: true, Visible: true}, 2, append([]xdgshell.ToplevelState{xdgshell.ToplevelStateActivated, xdgshell.ToplevelStateMaximized}, tiled...)},
21+ {"tiled v1", ports.ConfigureWindow{Visible: true}, 1, []xdgshell.ToplevelState{xdgshell.ToplevelStateMaximized}},
22+ {"floating", ports.ConfigureWindow{Floating: true, Activated: true, Visible: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateActivated}},
23+ {"fullscreen", ports.ConfigureWindow{Fullscreen: true, Visible: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateFullscreen}},
24+ {"hidden tiled v6", ports.ConfigureWindow{}, 6, append([]xdgshell.ToplevelState{xdgshell.ToplevelStateSuspended, xdgshell.ToplevelStateMaximized}, tiled...)},
25+ {"hidden fullscreen v6", ports.ConfigureWindow{Fullscreen: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateFullscreen, xdgshell.ToplevelStateSuspended}},
26+ {"hidden floating v5", ports.ConfigureWindow{Floating: true}, 5, nil},
2427 }
2528 for _, tt := range tests {
2629 t.Run(tt.name, func(t *testing.T) {
@@ -17,10 +17,13 @@ func TestToplevelStates(t *testing.T) {
17 version int3217 version int32
18 want []xdgshell.ToplevelState18 want []xdgshell.ToplevelState
19 }{19 }{
20- {"tiled", ports.ConfigureWindow{Activated: true}, 2, append([]xdgshell.ToplevelState{xdgshell.ToplevelStateActivated, xdgshell.ToplevelStateMaximized}, tiled...)},20+ {"tiled", ports.ConfigureWindow{Activated: true, Visible: true}, 2, append([]xdgshell.ToplevelState{xdgshell.ToplevelStateActivated, xdgshell.ToplevelStateMaximized}, tiled...)},
21- {"tiled v1", ports.ConfigureWindow{}, 1, []xdgshell.ToplevelState{xdgshell.ToplevelStateMaximized}},21+ {"tiled v1", ports.ConfigureWindow{Visible: true}, 1, []xdgshell.ToplevelState{xdgshell.ToplevelStateMaximized}},
22- {"floating", ports.ConfigureWindow{Floating: true, Activated: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateActivated}},22+ {"floating", ports.ConfigureWindow{Floating: true, Activated: true, Visible: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateActivated}},
23- {"fullscreen", ports.ConfigureWindow{Fullscreen: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateFullscreen}},23+ {"fullscreen", ports.ConfigureWindow{Fullscreen: true, Visible: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateFullscreen}},
24+ {"hidden tiled v6", ports.ConfigureWindow{}, 6, append([]xdgshell.ToplevelState{xdgshell.ToplevelStateSuspended, xdgshell.ToplevelStateMaximized}, tiled...)},
25+ {"hidden fullscreen v6", ports.ConfigureWindow{Fullscreen: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateFullscreen, xdgshell.ToplevelStateSuspended}},
26+ {"hidden floating v5", ports.ConfigureWindow{Floating: true}, 5, nil},
24 }27 }
25 for _, tt := range tests {28 for _, tt := range tests {
26 t.Run(tt.name, func(t *testing.T) {29 t.Run(tt.name, func(t *testing.T) {
M internal/core/action.go +24 -12
@@ -10,18 +10,20 @@ import (
1010 type Action string
1111
1212 const (
13- ActionSpawnTerminal Action = "spawn-terminal"
14- ActionFocusColumnLeft Action = "focus-column-left"
15- ActionFocusColumnRight Action = "focus-column-right"
16- ActionFocusWindowUp Action = "focus-window-up"
17- ActionFocusWindowDown Action = "focus-window-down"
18- ActionMoveColumnLeft Action = "move-column-left"
19- ActionMoveColumnRight Action = "move-column-right"
20- ActionCycleColumnWidth Action = "cycle-column-width"
21- ActionMaximizeColumn Action = "maximize-column"
22- ActionToggleFullscreen Action = "toggle-fullscreen"
23- ActionCloseWindow Action = "close-window"
24- ActionQuit Action = "quit"
13+ ActionSpawnTerminal Action = "spawn-terminal"
14+ ActionFocusColumnLeft Action = "focus-column-left"
15+ ActionFocusColumnRight Action = "focus-column-right"
16+ ActionFocusWindowUp Action = "focus-window-up"
17+ ActionFocusWindowDown Action = "focus-window-down"
18+ ActionMoveColumnLeft Action = "move-column-left"
19+ ActionMoveColumnRight Action = "move-column-right"
20+ ActionCycleColumnWidth Action = "cycle-column-width"
21+ ActionMaximizeColumn Action = "maximize-column"
22+ ActionToggleFullscreen Action = "toggle-fullscreen"
23+ ActionToggleWindowFloating Action = "toggle-window-floating"
24+ ActionToggleFloatingVisible Action = "toggle-floating-visible"
25+ ActionCloseWindow Action = "close-window"
26+ ActionQuit Action = "quit"
2527 // Workspaces stack vertically; up/down stop at the ends.
2628 ActionFocusWorkspaceUp Action = "focus-workspace-up"
2729 ActionFocusWorkspaceDown Action = "focus-workspace-down"
@@ -238,6 +240,12 @@ func (m *Monitor) Apply(a Action) Effect {
238240 case ActionToggleFullscreen:
239241 m.ToggleFullscreen()
240242 return Effect{}
243+ case ActionToggleWindowFloating:
244+ m.Current().ToggleWindowFloating()
245+ return Effect{}
246+ case ActionToggleFloatingVisible:
247+ m.Current().ToggleFloatingVisible()
248+ return Effect{}
241249 case ActionMaximizeColumn:
242250 // A client that made itself fullscreen (Wine at monitor size) goes
243251 // back to its column width; the next press maximizes the column.
@@ -296,6 +304,10 @@ func (w *Workspace) Apply(a Action) Effect {
296304 // In place only: binds go through Monitor.Apply, which gives a
297305 // fixed-overflow fullscreen its own workspace.
298306 w.ToggleFullscreen()
307+ case ActionToggleWindowFloating:
308+ w.ToggleWindowFloating()
309+ case ActionToggleFloatingVisible:
310+ w.ToggleFloatingVisible()
299311 case ActionCloseWindow:
300312 id, _ := w.Focused()
301313 return Effect{Close: id}
@@ -10,18 +10,20 @@ import (
10 type Action string10 type Action string
11 11
12 const (12 const (
13- ActionSpawnTerminal Action = "spawn-terminal"13+ ActionSpawnTerminal Action = "spawn-terminal"
14- ActionFocusColumnLeft Action = "focus-column-left"14+ ActionFocusColumnLeft Action = "focus-column-left"
15- ActionFocusColumnRight Action = "focus-column-right"15+ ActionFocusColumnRight Action = "focus-column-right"
16- ActionFocusWindowUp Action = "focus-window-up"16+ ActionFocusWindowUp Action = "focus-window-up"
17- ActionFocusWindowDown Action = "focus-window-down"17+ ActionFocusWindowDown Action = "focus-window-down"
18- ActionMoveColumnLeft Action = "move-column-left"18+ ActionMoveColumnLeft Action = "move-column-left"
19- ActionMoveColumnRight Action = "move-column-right"19+ ActionMoveColumnRight Action = "move-column-right"
20- ActionCycleColumnWidth Action = "cycle-column-width"20+ ActionCycleColumnWidth Action = "cycle-column-width"
21- ActionMaximizeColumn Action = "maximize-column"21+ ActionMaximizeColumn Action = "maximize-column"
22- ActionToggleFullscreen Action = "toggle-fullscreen"22+ ActionToggleFullscreen Action = "toggle-fullscreen"
23- ActionCloseWindow Action = "close-window"23+ ActionToggleWindowFloating Action = "toggle-window-floating"
24- ActionQuit Action = "quit"24+ ActionToggleFloatingVisible Action = "toggle-floating-visible"
25+ ActionCloseWindow Action = "close-window"
26+ ActionQuit Action = "quit"
25 // Workspaces stack vertically; up/down stop at the ends.27 // Workspaces stack vertically; up/down stop at the ends.
26 ActionFocusWorkspaceUp Action = "focus-workspace-up"28 ActionFocusWorkspaceUp Action = "focus-workspace-up"
27 ActionFocusWorkspaceDown Action = "focus-workspace-down"29 ActionFocusWorkspaceDown Action = "focus-workspace-down"
@@ -238,6 +240,12 @@ func (m *Monitor) Apply(a Action) Effect {
238 case ActionToggleFullscreen:240 case ActionToggleFullscreen:
239 m.ToggleFullscreen()241 m.ToggleFullscreen()
240 return Effect{}242 return Effect{}
243+ case ActionToggleWindowFloating:
244+ m.Current().ToggleWindowFloating()
245+ return Effect{}
246+ case ActionToggleFloatingVisible:
247+ m.Current().ToggleFloatingVisible()
248+ return Effect{}
241 case ActionMaximizeColumn:249 case ActionMaximizeColumn:
242 // A client that made itself fullscreen (Wine at monitor size) goes250 // A client that made itself fullscreen (Wine at monitor size) goes
243 // back to its column width; the next press maximizes the column.251 // back to its column width; the next press maximizes the column.
@@ -296,6 +304,10 @@ func (w *Workspace) Apply(a Action) Effect {
296 // In place only: binds go through Monitor.Apply, which gives a304 // In place only: binds go through Monitor.Apply, which gives a
297 // fixed-overflow fullscreen its own workspace.305 // fixed-overflow fullscreen its own workspace.
298 w.ToggleFullscreen()306 w.ToggleFullscreen()
307+ case ActionToggleWindowFloating:
308+ w.ToggleWindowFloating()
309+ case ActionToggleFloatingVisible:
310+ w.ToggleFloatingVisible()
299 case ActionCloseWindow:311 case ActionCloseWindow:
300 id, _ := w.Focused()312 id, _ := w.Focused()
301 return Effect{Close: id}313 return Effect{Close: id}
M internal/core/core.go +44 -10
@@ -189,7 +189,7 @@ func (c *Core) apply(cfg ports.Config) error {
189189 continue
190190 }
191191 switch Action(a) {
192- case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
192+ case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowFloating, ActionToggleFloatingVisible, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
193193 default:
194194 return fmt.Errorf("invalid action %q", a)
195195 }
@@ -317,6 +317,30 @@ func (c *Core) clientRect(p Placement) Rect {
317317 return p.Rect.Inset(p.Inset, c.cfg.Border.Width)
318318 }
319319
320+// onScreen reports whether a placement is drawn on its output o: not
321+// hidden and not scrolled off.
322+func onScreen(p Placement, o Rect) bool {
323+ return !p.Hidden && p.Rect.Overlaps(Rect{W: o.W, H: o.H})
324+}
325+
326+// floatDim is the veil opacity of a layout: dim while a float is drawn
327+// over the tiles, none under a fullscreen float (nothing is seen below).
328+func floatDim(layout []Placement, o Rect, dim float64) float64 {
329+ shown := false
330+ for _, p := range layout {
331+ if p.Floating && onScreen(p, o) {
332+ if p.Fullscreen {
333+ return 0
334+ }
335+ shown = true
336+ }
337+ }
338+ if !shown {
339+ return 0
340+ }
341+ return dim
342+}
343+
320344 // visible reports whether the window, layer surface or popup is on screen
321345 // on any output.
322346 func (c *Core) visible(id WindowID) bool {
@@ -420,6 +444,7 @@ func (c *Core) publish(ctx context.Context) error {
420444 o := sc.mon.Output()
421445 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...)}
422446 layout := sc.mon.Layout()
447+ scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim)
423448 // Only the focused output lights the focused window's lines.
424449 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)
425450 for _, p := range layout {
@@ -429,19 +454,28 @@ func (c *Core) publish(ctx context.Context) error {
429454 scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset})
430455 floating := p.Floating
431456 if p.Hidden {
432- if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") {
433- old.Activated, old.Output = false, ""
434- if err := c.command(ctx, old); err != nil {
435- return err
436- }
437- c.sent[p.ID] = old
457+ // A hidden window keeps its size, state and output; it is
458+ // only deactivated and marked invisible. One mapped hidden
459+ // is told so at once, sized by the client.
460+ old, ok := c.sent[p.ID]
461+ v := old
462+ if !ok {
463+ v = ports.ConfigureWindow{ID: p.ID, Floating: floating}
464+ }
465+ v.Activated, v.Visible, v.Output = false, false, sc.name()
466+ if ok && v == old {
467+ continue
438468 }
469+ if err := c.command(ctx, v); err != nil {
470+ return err
471+ }
472+ c.sent[p.ID] = v
439473 continue
440474 }
441475 r := c.clientRect(p)
442- v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: focused, Floating: floating && !p.Fullscreen, Output: sc.name()}
443- if v.Floating {
444- // A floating window picks its own size.
476+ v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: focused, Floating: floating && !p.Fullscreen, Output: sc.name(), Visible: onScreen(p, o)}
477+ if v.Floating && !sc.mon.Current().imposedFloat(p.ID) {
478+ // Native floating windows pick their own size.
445479 v.Width, v.Height = 0, 0
446480 }
447481 if old, ok := c.sent[p.ID]; !ok || old != v {
@@ -189,7 +189,7 @@ func (c *Core) apply(cfg ports.Config) error {
189 continue189 continue
190 }190 }
191 switch Action(a) {191 switch Action(a) {
192- case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:192+ case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowFloating, ActionToggleFloatingVisible, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
193 default:193 default:
194 return fmt.Errorf("invalid action %q", a)194 return fmt.Errorf("invalid action %q", a)
195 }195 }
@@ -317,6 +317,30 @@ func (c *Core) clientRect(p Placement) Rect {
317 return p.Rect.Inset(p.Inset, c.cfg.Border.Width)317 return p.Rect.Inset(p.Inset, c.cfg.Border.Width)
318 }318 }
319 319
320+// onScreen reports whether a placement is drawn on its output o: not
321+// hidden and not scrolled off.
322+func onScreen(p Placement, o Rect) bool {
323+ return !p.Hidden && p.Rect.Overlaps(Rect{W: o.W, H: o.H})
324+}
325+
326+// floatDim is the veil opacity of a layout: dim while a float is drawn
327+// over the tiles, none under a fullscreen float (nothing is seen below).
328+func floatDim(layout []Placement, o Rect, dim float64) float64 {
329+ shown := false
330+ for _, p := range layout {
331+ if p.Floating && onScreen(p, o) {
332+ if p.Fullscreen {
333+ return 0
334+ }
335+ shown = true
336+ }
337+ }
338+ if !shown {
339+ return 0
340+ }
341+ return dim
342+}
343+
320 // visible reports whether the window, layer surface or popup is on screen344 // visible reports whether the window, layer surface or popup is on screen
321 // on any output.345 // on any output.
322 func (c *Core) visible(id WindowID) bool {346 func (c *Core) visible(id WindowID) bool {
@@ -420,6 +444,7 @@ func (c *Core) publish(ctx context.Context) error {
420 o := sc.mon.Output()444 o := sc.mon.Output()
421 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...)}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...)}
422 layout := sc.mon.Layout()446 layout := sc.mon.Layout()
447+ scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim)
423 // Only the focused output lights the focused window's lines.448 // Only the focused output lights the focused window's lines.
424 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)449 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)
425 for _, p := range layout {450 for _, p := range layout {
@@ -429,19 +454,28 @@ func (c *Core) publish(ctx context.Context) error {
429 scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset})454 scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset})
430 floating := p.Floating455 floating := p.Floating
431 if p.Hidden {456 if p.Hidden {
432- if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") {457+ // A hidden window keeps its size, state and output; it is
433- old.Activated, old.Output = false, ""458+ // only deactivated and marked invisible. One mapped hidden
434- if err := c.command(ctx, old); err != nil {459+ // is told so at once, sized by the client.
435- return err460+ old, ok := c.sent[p.ID]
436- }461+ v := old
437- c.sent[p.ID] = old462+ if !ok {
463+ v = ports.ConfigureWindow{ID: p.ID, Floating: floating}
464+ }
465+ v.Activated, v.Visible, v.Output = false, false, sc.name()
466+ if ok && v == old {
467+ continue
438 }468 }
469+ if err := c.command(ctx, v); err != nil {
470+ return err
471+ }
472+ c.sent[p.ID] = v
439 continue473 continue
440 }474 }
441 r := c.clientRect(p)475 r := c.clientRect(p)
442- v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: focused, Floating: floating && !p.Fullscreen, Output: sc.name()}476+ v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: focused, Floating: floating && !p.Fullscreen, Output: sc.name(), Visible: onScreen(p, o)}
443- if v.Floating {477+ if v.Floating && !sc.mon.Current().imposedFloat(p.ID) {
444- // A floating window picks its own size.478+ // Native floating windows pick their own size.
445 v.Width, v.Height = 0, 0479 v.Width, v.Height = 0, 0
446 }480 }
447 if old, ok := c.sent[p.ID]; !ok || old != v {481 if old, ok := c.sent[p.ID]; !ok || old != v {
M internal/core/core_test.go +5 -5
@@ -73,7 +73,7 @@ func TestOwner(t *testing.T) {
7373 if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {
7474 t.Fatal(s)
7575 }
76- if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1"}) {
76+ if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1", Visible: true}) {
7777 t.Fatal(v)
7878 }
7979 command(t, commands)
@@ -117,7 +117,7 @@ func TestOwner(t *testing.T) {
117117 if s.Windows[0].Rect.W != 92 {
118118 t.Fatal(s)
119119 }
120- if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true, Output: "OUT-1"}) {
120+ if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true, Output: "OUT-1", Visible: true}) {
121121 t.Fatal(v)
122122 }
123123 cfg.Layout.MaxColumns = 0
@@ -299,7 +299,7 @@ func TestBorderInset(t *testing.T) {
299299 // Alone, the window is borderless and gets the full rect.
300300 client <- ports.WindowMapped{ID: 1}
301301 r := scene(t, scenes).Windows[0].Rect
302- if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) {
302+ if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1", Visible: true}) {
303303 t.Fatalf("%v for outer %v", v, r)
304304 }
305305 // With a second column, the left window owns the shared separator: its
@@ -310,7 +310,7 @@ func TestBorderInset(t *testing.T) {
310310 if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 {
311311 t.Fatal(s)
312312 }
313- want := ports.ConfigureWindow{ID: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1"}
313+ want := ports.ConfigureWindow{ID: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1", Visible: true}
314314 found := false
315315 for len(commands) > 0 {
316316 if v := <-commands; v == want {
@@ -538,7 +538,7 @@ func TestOutputScale(t *testing.T) {
538538 }
539539 client <- ports.WindowMapped{ID: 1}
540540 scene(t, scenes)
541- if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2"}) {
541+ if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2", Visible: true}) {
542542 t.Fatal(v)
543543 }
544544 for len(commands) > 0 {
@@ -73,7 +73,7 @@ func TestOwner(t *testing.T) {
73 if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {73 if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {
74 t.Fatal(s)74 t.Fatal(s)
75 }75 }
76- if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1"}) {76+ if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1", Visible: true}) {
77 t.Fatal(v)77 t.Fatal(v)
78 }78 }
79 command(t, commands)79 command(t, commands)
@@ -117,7 +117,7 @@ func TestOwner(t *testing.T) {
117 if s.Windows[0].Rect.W != 92 {117 if s.Windows[0].Rect.W != 92 {
118 t.Fatal(s)118 t.Fatal(s)
119 }119 }
120- if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true, Output: "OUT-1"}) {120+ if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true, Output: "OUT-1", Visible: true}) {
121 t.Fatal(v)121 t.Fatal(v)
122 }122 }
123 cfg.Layout.MaxColumns = 0123 cfg.Layout.MaxColumns = 0
@@ -299,7 +299,7 @@ func TestBorderInset(t *testing.T) {
299 // Alone, the window is borderless and gets the full rect.299 // Alone, the window is borderless and gets the full rect.
300 client <- ports.WindowMapped{ID: 1}300 client <- ports.WindowMapped{ID: 1}
301 r := scene(t, scenes).Windows[0].Rect301 r := scene(t, scenes).Windows[0].Rect
302- if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) {302+ if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1", Visible: true}) {
303 t.Fatalf("%v for outer %v", v, r)303 t.Fatalf("%v for outer %v", v, r)
304 }304 }
305 // With a second column, the left window owns the shared separator: its305 // With a second column, the left window owns the shared separator: its
@@ -310,7 +310,7 @@ func TestBorderInset(t *testing.T) {
310 if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 {310 if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 {
311 t.Fatal(s)311 t.Fatal(s)
312 }312 }
313- want := ports.ConfigureWindow{ID: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1"}313+ want := ports.ConfigureWindow{ID: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1", Visible: true}
314 found := false314 found := false
315 for len(commands) > 0 {315 for len(commands) > 0 {
316 if v := <-commands; v == want {316 if v := <-commands; v == want {
@@ -538,7 +538,7 @@ func TestOutputScale(t *testing.T) {
538 }538 }
539 client <- ports.WindowMapped{ID: 1}539 client <- ports.WindowMapped{ID: 1}
540 scene(t, scenes)540 scene(t, scenes)
541- if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2"}) {541+ if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2", Visible: true}) {
542 t.Fatal(v)542 t.Fatal(v)
543 }543 }
544 for len(commands) > 0 {544 for len(commands) > 0 {
A internal/core/floating_test.go +233 -0
@@ -0,0 +1,233 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestToggleWindowFloating(t *testing.T) {
12+ for _, stacked := range []bool{false, true} {
13+ t.Run(map[bool]string{false: "column", true: "stack"}[stacked], func(t *testing.T) {
14+ w := workspace()
15+ w.border = 2
16+ w.AddWindow(1)
17+ w.AddWindow(2)
18+ w.Columns[1].Width = Width{Pixels: 35}
19+ if stacked {
20+ w.Columns[1].Windows = []WindowID{3, 2}
21+ w.Columns[1].Focus = 1
22+ }
23+ w.Apply(ActionToggleWindowFloating)
24+ if len(w.Floats) != 1 || !w.Floats[0].back.holdsStack(Column{Windows: []WindowID{3}}) && stacked {
25+ t.Fatalf("float %+v", w.Floats)
26+ }
27+ p := w.Layout()[len(w.Layout())-1]
28+ if !p.Focused || p.Rect != (Rect{X: 10, Y: 8, W: 80, H: 64}) {
29+ t.Fatalf("placement %+v", p)
30+ }
31+ w.ResizeFloating(2, 12, 14)
32+ if w.Layout()[len(w.Layout())-1].Rect != p.Rect {
33+ t.Fatal("client resize moved imposed float")
34+ }
35+ w.Apply(ActionToggleWindowFloating)
36+ id, _ := w.Focused()
37+ if id != 2 || len(w.Floats) != 0 || w.Focus != 1 || w.Columns[1].Width != (Width{Pixels: 35}) || !slices.Contains(w.Columns[1].Windows, 2) {
38+ t.Fatalf("round trip: %+v", w)
39+ }
40+ })
41+ }
42+ w := workspace()
43+ w.AddFloating(4, 20, 10)
44+ w.Apply(ActionToggleWindowFloating)
45+ if len(w.Floats) != 0 || len(w.Columns) != 1 || w.Columns[0].Windows[0] != 4 {
46+ t.Fatalf("native float: %+v", w)
47+ }
48+}
49+
50+func TestToggleFloatingVisible(t *testing.T) {
51+ w := workspace()
52+ w.Apply(ActionToggleFloatingVisible)
53+ w.AddWindow(1)
54+ w.AddFloating(2, 20, 10)
55+ w.AddFloating(3, 20, 10)
56+ w.Apply(ActionToggleFloatingVisible)
57+ if id, _ := w.Focused(); id != 1 {
58+ t.Fatalf("focused %d", id)
59+ }
60+ for _, p := range w.Layout()[1:] {
61+ if !p.Hidden || p.Focused {
62+ t.Fatalf("hidden %+v", p)
63+ }
64+ }
65+ if w.FocusID(2) {
66+ t.Fatal("focused hidden float")
67+ }
68+ w.Apply(ActionToggleFloatingVisible)
69+ if id, _ := w.Focused(); id != 3 {
70+ t.Fatalf("restored focus %d", id)
71+ }
72+ w.Apply(ActionToggleFloatingVisible)
73+ w.AddFloating(4, 12, 12)
74+ if w.floatsHidden || w.Layout()[3].Hidden {
75+ t.Fatal("new float did not reveal floats")
76+ }
77+}
78+
79+// A lone maximized column comes back maximized.
80+func TestToggleWindowFloatingKeepsFullWidth(t *testing.T) {
81+ w := workspace()
82+ w.Overflow = OverflowFixed
83+ w.AddWindow(1)
84+ w.AddWindow(2)
85+ w.Apply(ActionMaximizeColumn)
86+ w.Apply(ActionToggleWindowFloating)
87+ w.Apply(ActionToggleWindowFloating)
88+ if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].FullWidth {
89+ t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])
90+ }
91+}
92+
93+// A lone expanded column (fixed overflow) comes back expanded.
94+func TestToggleWindowFloatingKeepsExpanded(t *testing.T) {
95+ w := workspace()
96+ w.Overflow = OverflowFixed
97+ w.SetMaxColumns(3)
98+ w.AddWindow(1)
99+ w.AddWindow(2)
100+ w.CycleWidth()
101+ if !w.Columns[w.Focus].Expanded {
102+ t.Fatal("column not expanded")
103+ }
104+ w.Apply(ActionToggleWindowFloating)
105+ w.Apply(ActionToggleWindowFloating)
106+ if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].Expanded {
107+ t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])
108+ }
109+}
110+
111+// A client fullscreen request of a hidden float leaves it hidden on its
112+// workspace, even in fixed overflow (which moves fullscreen windows out).
113+func TestHiddenFloatFullscreenRequest(t *testing.T) {
114+ m := newMonitor("", "")
115+ m.SetOutput(100, 80)
116+ m.SetOverflow(OverflowFixed)
117+ m.AddWindow(1)
118+ m.AddFloating(2, 20, 10)
119+ w := m.Current()
120+ w.ToggleFloatingVisible()
121+ m.SetFullscreen(2, true)
122+ if m.Current() != w || w.floatIndex(2) < 0 || !w.floatsHidden || w.fullscreen != 0 {
123+ t.Fatalf("hidden float left or showed: %+v", w)
124+ }
125+ w.ToggleFloatingVisible()
126+ if w.fullscreen != 2 {
127+ t.Fatalf("fullscreen lost on show: %d", w.fullscreen)
128+ }
129+}
130+
131+// Hiding floats keeps the columns' focus, and a hidden fullscreen float
132+// no longer hides the columns.
133+func TestToggleFloatingVisibleFocusAndFullscreen(t *testing.T) {
134+ w := workspace()
135+ w.Overflow = OverflowFixed
136+ w.AddWindow(1)
137+ w.AddFloating(2, 20, 10)
138+ w.FocusID(1)
139+ w.Apply(ActionToggleFloatingVisible)
140+ w.Apply(ActionToggleFloatingVisible)
141+ if id, _ := w.Focused(); id != 1 {
142+ t.Fatalf("column focus lost: %d", id)
143+ }
144+ w.FocusID(2)
145+ w.SetFullscreen(2, true)
146+ w.Apply(ActionToggleFloatingVisible)
147+ for _, p := range w.Layout() {
148+ if p.ID == 1 && (p.Hidden || p.Rect.W == 0) {
149+ t.Fatalf("column hidden under hidden fullscreen float: %+v", p)
150+ }
151+ }
152+ w.Apply(ActionToggleFloatingVisible)
153+ if id, _ := w.Focused(); id != 2 || w.fullscreen != 2 {
154+ t.Fatalf("restored focus %d fullscreen %d", id, w.fullscreen)
155+ }
156+}
157+
158+func TestFloatingSceneDimAndConfigure(t *testing.T) {
159+ cfg := ports.Config{}
160+ cfg.Keyboard.CmdKey = "super"
161+ cfg.Layout.MaxColumns = 2
162+ cfg.Floating.Dim = 0.3
163+ cfg.Border.Width = 2
164+ commands := make(chan ports.ClientCommand, 128)
165+ scenes := make(chan []ports.Scene, 1)
166+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ w := c.cur().mon.Current()
171+ c.cur().mon.SetOutput(100, 80)
172+ w.AddWindow(1)
173+ ctx := context.Background()
174+ check := func(want float64) {
175+ t.Helper()
176+ if err := c.publish(ctx); err != nil {
177+ t.Fatal(err)
178+ }
179+ if s := (<-scenes)[0]; s.Dim != want {
180+ t.Fatalf("dim %v want %v", s.Dim, want)
181+ }
182+ }
183+ check(0)
184+ w.ToggleWindowFloating()
185+ check(0.3)
186+ found := false
187+ for len(commands) > 0 {
188+ if v, ok := (<-commands).(ports.ConfigureWindow); ok && v.ID == 1 {
189+ if v.Floating {
190+ found = true
191+ if v.Width != 76 || v.Height != 60 {
192+ t.Fatalf("imposed configure %+v", v)
193+ }
194+ }
195+ }
196+ }
197+ if !found {
198+ t.Fatal("missing imposed configure")
199+ }
200+ w.AddFloating(2, 20, 10)
201+ check(0.3)
202+ found = false
203+ for len(commands) > 0 {
204+ if v, ok := (<-commands).(ports.ConfigureWindow); ok && v.ID == 2 {
205+ found = true
206+ if v.Width != 0 || v.Height != 0 {
207+ t.Fatalf("native configure %+v", v)
208+ }
209+ }
210+ }
211+ if !found {
212+ t.Fatal("missing native configure")
213+ }
214+ w.ToggleFloatingVisible()
215+ check(0)
216+ w.ToggleFloatingVisible()
217+ check(0.3)
218+ w.SetFullscreen(1, true)
219+ check(0)
220+ w.SetFullscreen(1, false)
221+ w.ToggleWindowFloating()
222+ w.SetFullscreen(1, true)
223+ check(0)
224+ // A dialog shown over a fullscreen tile dims it.
225+ w.SetFullscreen(1, false)
226+ w.RemoveWindow(1)
227+ w.RemoveWindow(2)
228+ w.AddWindow(3)
229+ w.SetFullscreen(3, true)
230+ check(0)
231+ w.AddFloating(4, 20, 10)
232+ check(0.3)
233+}
@@ -0,0 +1,233 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestToggleWindowFloating(t *testing.T) {
12+ for _, stacked := range []bool{false, true} {
13+ t.Run(map[bool]string{false: "column", true: "stack"}[stacked], func(t *testing.T) {
14+ w := workspace()
15+ w.border = 2
16+ w.AddWindow(1)
17+ w.AddWindow(2)
18+ w.Columns[1].Width = Width{Pixels: 35}
19+ if stacked {
20+ w.Columns[1].Windows = []WindowID{3, 2}
21+ w.Columns[1].Focus = 1
22+ }
23+ w.Apply(ActionToggleWindowFloating)
24+ if len(w.Floats) != 1 || !w.Floats[0].back.holdsStack(Column{Windows: []WindowID{3}}) && stacked {
25+ t.Fatalf("float %+v", w.Floats)
26+ }
27+ p := w.Layout()[len(w.Layout())-1]
28+ if !p.Focused || p.Rect != (Rect{X: 10, Y: 8, W: 80, H: 64}) {
29+ t.Fatalf("placement %+v", p)
30+ }
31+ w.ResizeFloating(2, 12, 14)
32+ if w.Layout()[len(w.Layout())-1].Rect != p.Rect {
33+ t.Fatal("client resize moved imposed float")
34+ }
35+ w.Apply(ActionToggleWindowFloating)
36+ id, _ := w.Focused()
37+ if id != 2 || len(w.Floats) != 0 || w.Focus != 1 || w.Columns[1].Width != (Width{Pixels: 35}) || !slices.Contains(w.Columns[1].Windows, 2) {
38+ t.Fatalf("round trip: %+v", w)
39+ }
40+ })
41+ }
42+ w := workspace()
43+ w.AddFloating(4, 20, 10)
44+ w.Apply(ActionToggleWindowFloating)
45+ if len(w.Floats) != 0 || len(w.Columns) != 1 || w.Columns[0].Windows[0] != 4 {
46+ t.Fatalf("native float: %+v", w)
47+ }
48+}
49+
50+func TestToggleFloatingVisible(t *testing.T) {
51+ w := workspace()
52+ w.Apply(ActionToggleFloatingVisible)
53+ w.AddWindow(1)
54+ w.AddFloating(2, 20, 10)
55+ w.AddFloating(3, 20, 10)
56+ w.Apply(ActionToggleFloatingVisible)
57+ if id, _ := w.Focused(); id != 1 {
58+ t.Fatalf("focused %d", id)
59+ }
60+ for _, p := range w.Layout()[1:] {
61+ if !p.Hidden || p.Focused {
62+ t.Fatalf("hidden %+v", p)
63+ }
64+ }
65+ if w.FocusID(2) {
66+ t.Fatal("focused hidden float")
67+ }
68+ w.Apply(ActionToggleFloatingVisible)
69+ if id, _ := w.Focused(); id != 3 {
70+ t.Fatalf("restored focus %d", id)
71+ }
72+ w.Apply(ActionToggleFloatingVisible)
73+ w.AddFloating(4, 12, 12)
74+ if w.floatsHidden || w.Layout()[3].Hidden {
75+ t.Fatal("new float did not reveal floats")
76+ }
77+}
78+
79+// A lone maximized column comes back maximized.
80+func TestToggleWindowFloatingKeepsFullWidth(t *testing.T) {
81+ w := workspace()
82+ w.Overflow = OverflowFixed
83+ w.AddWindow(1)
84+ w.AddWindow(2)
85+ w.Apply(ActionMaximizeColumn)
86+ w.Apply(ActionToggleWindowFloating)
87+ w.Apply(ActionToggleWindowFloating)
88+ if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].FullWidth {
89+ t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])
90+ }
91+}
92+
93+// A lone expanded column (fixed overflow) comes back expanded.
94+func TestToggleWindowFloatingKeepsExpanded(t *testing.T) {
95+ w := workspace()
96+ w.Overflow = OverflowFixed
97+ w.SetMaxColumns(3)
98+ w.AddWindow(1)
99+ w.AddWindow(2)
100+ w.CycleWidth()
101+ if !w.Columns[w.Focus].Expanded {
102+ t.Fatal("column not expanded")
103+ }
104+ w.Apply(ActionToggleWindowFloating)
105+ w.Apply(ActionToggleWindowFloating)
106+ if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].Expanded {
107+ t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])
108+ }
109+}
110+
111+// A client fullscreen request of a hidden float leaves it hidden on its
112+// workspace, even in fixed overflow (which moves fullscreen windows out).
113+func TestHiddenFloatFullscreenRequest(t *testing.T) {
114+ m := newMonitor("", "")
115+ m.SetOutput(100, 80)
116+ m.SetOverflow(OverflowFixed)
117+ m.AddWindow(1)
118+ m.AddFloating(2, 20, 10)
119+ w := m.Current()
120+ w.ToggleFloatingVisible()
121+ m.SetFullscreen(2, true)
122+ if m.Current() != w || w.floatIndex(2) < 0 || !w.floatsHidden || w.fullscreen != 0 {
123+ t.Fatalf("hidden float left or showed: %+v", w)
124+ }
125+ w.ToggleFloatingVisible()
126+ if w.fullscreen != 2 {
127+ t.Fatalf("fullscreen lost on show: %d", w.fullscreen)
128+ }
129+}
130+
131+// Hiding floats keeps the columns' focus, and a hidden fullscreen float
132+// no longer hides the columns.
133+func TestToggleFloatingVisibleFocusAndFullscreen(t *testing.T) {
134+ w := workspace()
135+ w.Overflow = OverflowFixed
136+ w.AddWindow(1)
137+ w.AddFloating(2, 20, 10)
138+ w.FocusID(1)
139+ w.Apply(ActionToggleFloatingVisible)
140+ w.Apply(ActionToggleFloatingVisible)
141+ if id, _ := w.Focused(); id != 1 {
142+ t.Fatalf("column focus lost: %d", id)
143+ }
144+ w.FocusID(2)
145+ w.SetFullscreen(2, true)
146+ w.Apply(ActionToggleFloatingVisible)
147+ for _, p := range w.Layout() {
148+ if p.ID == 1 && (p.Hidden || p.Rect.W == 0) {
149+ t.Fatalf("column hidden under hidden fullscreen float: %+v", p)
150+ }
151+ }
152+ w.Apply(ActionToggleFloatingVisible)
153+ if id, _ := w.Focused(); id != 2 || w.fullscreen != 2 {
154+ t.Fatalf("restored focus %d fullscreen %d", id, w.fullscreen)
155+ }
156+}
157+
158+func TestFloatingSceneDimAndConfigure(t *testing.T) {
159+ cfg := ports.Config{}
160+ cfg.Keyboard.CmdKey = "super"
161+ cfg.Layout.MaxColumns = 2
162+ cfg.Floating.Dim = 0.3
163+ cfg.Border.Width = 2
164+ commands := make(chan ports.ClientCommand, 128)
165+ scenes := make(chan []ports.Scene, 1)
166+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ w := c.cur().mon.Current()
171+ c.cur().mon.SetOutput(100, 80)
172+ w.AddWindow(1)
173+ ctx := context.Background()
174+ check := func(want float64) {
175+ t.Helper()
176+ if err := c.publish(ctx); err != nil {
177+ t.Fatal(err)
178+ }
179+ if s := (<-scenes)[0]; s.Dim != want {
180+ t.Fatalf("dim %v want %v", s.Dim, want)
181+ }
182+ }
183+ check(0)
184+ w.ToggleWindowFloating()
185+ check(0.3)
186+ found := false
187+ for len(commands) > 0 {
188+ if v, ok := (<-commands).(ports.ConfigureWindow); ok && v.ID == 1 {
189+ if v.Floating {
190+ found = true
191+ if v.Width != 76 || v.Height != 60 {
192+ t.Fatalf("imposed configure %+v", v)
193+ }
194+ }
195+ }
196+ }
197+ if !found {
198+ t.Fatal("missing imposed configure")
199+ }
200+ w.AddFloating(2, 20, 10)
201+ check(0.3)
202+ found = false
203+ for len(commands) > 0 {
204+ if v, ok := (<-commands).(ports.ConfigureWindow); ok && v.ID == 2 {
205+ found = true
206+ if v.Width != 0 || v.Height != 0 {
207+ t.Fatalf("native configure %+v", v)
208+ }
209+ }
210+ }
211+ if !found {
212+ t.Fatal("missing native configure")
213+ }
214+ w.ToggleFloatingVisible()
215+ check(0)
216+ w.ToggleFloatingVisible()
217+ check(0.3)
218+ w.SetFullscreen(1, true)
219+ check(0)
220+ w.SetFullscreen(1, false)
221+ w.ToggleWindowFloating()
222+ w.SetFullscreen(1, true)
223+ check(0)
224+ // A dialog shown over a fullscreen tile dims it.
225+ w.SetFullscreen(1, false)
226+ w.RemoveWindow(1)
227+ w.RemoveWindow(2)
228+ w.AddWindow(3)
229+ w.SetFullscreen(3, true)
230+ check(0)
231+ w.AddFloating(4, 20, 10)
232+ check(0.3)
233+}
M internal/core/layout.go +127 -8
@@ -136,8 +136,13 @@ type Workspace struct {
136136 fullscreen WindowID
137137 // Floats are floating windows, bottom to top; floatFocus is set while
138138 // the top one has the focus instead of the columns.
139- Floats []Float
140- floatFocus bool
139+ Floats []Float
140+ floatFocus bool
141+ floatsHidden bool
142+ // hiddenFocus and hiddenFullscreen hold the float focus and a float's
143+ // fullscreen while the floats are hidden, restored when shown.
144+ hiddenFocus bool
145+ hiddenFullscreen WindowID
141146 // home is the monitor (key or connector) the workspace belongs to; ""
142147 // means the one it is on. On another monitor it is a guest; homePos is
143148 // its position there, where it returns.
@@ -164,13 +169,17 @@ type origPlace struct {
164169 stacked []WindowID
165170 width Width
166171 expanded bool
167- float *Float
172+ // fullWidth is the column's maximize-column state.
173+ fullWidth bool
174+ float *Float
168175 }
169176
170177 // Float is a floating window and its client size, logical.
171178 type Float struct {
172179 ID WindowID
173180 W, H int
181+ // back records the former column of a tiled window; nil for native floats.
182+ back *origPlace
174183 }
175184
176185 func (w *Workspace) empty() bool { return len(w.Columns) == 0 && len(w.Floats) == 0 }
@@ -196,6 +205,10 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) {
196205 if id == 0 || w.has(id) {
197206 return
198207 }
208+ if w.floatsHidden {
209+ // A new float must be seen: show the hidden ones with it.
210+ w.ToggleFloatingVisible()
211+ }
199212 w.Floats = append(w.Floats, Float{ID: id, W: width, H: height})
200213 w.floatFocus = true
201214 }
@@ -203,7 +216,9 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) {
203216 // ResizeFloating records the size a floating window draws.
204217 func (w *Workspace) ResizeFloating(id WindowID, width, height int) {
205218 if i := w.floatIndex(id); i >= 0 {
206- w.Floats[i].W, w.Floats[i].H = width, height
219+ if w.Floats[i].back == nil {
220+ w.Floats[i].W, w.Floats[i].H = width, height
221+ }
207222 }
208223 }
209224
@@ -223,7 +238,7 @@ func (w *Workspace) has(id WindowID) bool {
223238 }
224239
225240 func (w *Workspace) Focused() (WindowID, bool) {
226- if w.floatFocus && len(w.Floats) > 0 {
241+ if w.floatFocus && !w.floatsHidden && len(w.Floats) > 0 {
227242 return w.Floats[len(w.Floats)-1].ID, true
228243 }
229244 if w.Focus < 0 || w.Focus >= len(w.Columns) {
@@ -317,8 +332,11 @@ func (w *Workspace) RemoveWindow(id WindowID) {
317332 if w.fullscreen == id {
318333 w.fullscreen = 0
319334 }
335+ if w.hiddenFullscreen == id {
336+ w.hiddenFullscreen = 0
337+ }
320338 if len(w.Floats) == 0 {
321- w.floatFocus = false
339+ w.floatFocus, w.floatsHidden, w.hiddenFocus = false, false, false
322340 }
323341 return
324342 }
@@ -359,6 +377,9 @@ func (w *Workspace) RemoveWindow(id WindowID) {
359377 // window is raised.
360378 func (w *Workspace) FocusID(id WindowID) bool {
361379 if i := w.floatIndex(id); i >= 0 {
380+ if w.floatsHidden {
381+ return false
382+ }
362383 f := w.Floats[i]
363384 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)
364385 w.floatFocus = true
@@ -601,6 +622,15 @@ func (w *Workspace) Activate(id WindowID) {
601622 // requests are automatic events (ADR 011 golden rule).
602623 func (w *Workspace) SetFullscreen(id WindowID, on bool) {
603624 if w.floatIndex(id) >= 0 {
625+ if w.floatsHidden {
626+ // Applied when the floats are shown again.
627+ if on {
628+ w.hiddenFullscreen = id
629+ } else if w.hiddenFullscreen == id {
630+ w.hiddenFullscreen = 0
631+ }
632+ return
633+ }
604634 if on {
605635 w.fullscreen = id
606636 } else if w.fullscreen == id {
@@ -860,7 +890,12 @@ func (w *Workspace) Layout() []Placement {
860890 setNeighbors(result, gap)
861891 // Floating windows go last: they are drawn and hit on top.
862892 for _, f := range w.Floats {
863- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll}
893+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll, Hidden: w.floatsHidden}
894+ if p.Hidden {
895+ p.Rect = Rect{}
896+ result = append(result, p)
897+ continue
898+ }
864899 // Floats stay above a fullscreen window: a dialog opened from a
865900 // fullscreen app must be seen.
866901 if w.fullscreen == f.ID {
@@ -909,9 +944,93 @@ func setNeighbors(tiles []Placement, gap int) {
909944 func (w *Workspace) floatRect(f Float) Rect {
910945 u := w.Usable
911946 fw, fh := f.W, f.H
912- if fw <= 0 || fh <= 0 {
947+ if f.back != nil {
948+ fw, fh = max(u.W*80/100-2*w.border, 0), max(u.H*80/100-2*w.border, 0)
949+ }
950+ if f.back == nil && (fw <= 0 || fh <= 0) {
913951 fw, fh = u.W/2, u.H/2
914952 }
915953 fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H)
916954 return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}
917955 }
956+
957+// imposedFloat reports whether core, rather than the client, owns the size.
958+func (w *Workspace) imposedFloat(id WindowID) bool {
959+ i := w.floatIndex(id)
960+ return i >= 0 && w.Floats[i].back != nil
961+}
962+
963+// ToggleWindowFloating converts a tile to an 80% float, remembering its
964+// column and row. Native floats instead become new columns.
965+func (w *Workspace) ToggleWindowFloating() {
966+ id, ok := w.Focused()
967+ if !ok {
968+ return
969+ }
970+ if i := w.floatIndex(id); i >= 0 {
971+ f := w.Floats[i]
972+ w.RemoveWindow(id)
973+ if f.back == nil {
974+ w.addColumn(Column{Windows: []WindowID{id}})
975+ } else {
976+ back := f.back
977+ at := min(back.col, len(w.Columns))
978+ if i := slices.IndexFunc(w.Columns, back.holdsStack); i >= 0 {
979+ at = i
980+ c := &w.Columns[at]
981+ row := 0
982+ for _, v := range back.stacked[:back.row] {
983+ if slices.Contains(c.Windows, v) {
984+ row++
985+ }
986+ }
987+ c.Windows = slices.Insert(c.Windows, row, id)
988+ c.Focus = row
989+ w.Focus = at
990+ w.scroll()
991+ } else {
992+ // Its column is gone: it comes back expanded, unless another
993+ // column was expanded meanwhile (as leaveFullscreen).
994+ expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded })
995+ w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, FullWidth: back.fullWidth, Expanded: expanded})
996+ }
997+ }
998+ w.floatFocus = false
999+ return
1000+ }
1001+ col := w.Focus
1002+ c := w.Columns[col]
1003+ row := c.Focus
1004+ lone := len(c.Windows) == 1
1005+ back := &origPlace{col: col, row: row, slot: c.Slot, width: c.Width, fullWidth: c.FullWidth && lone, expanded: c.Expanded && lone}
1006+ for _, v := range c.Windows {
1007+ if v != id {
1008+ back.stacked = append(back.stacked, v)
1009+ }
1010+ }
1011+ w.RemoveWindow(id)
1012+ w.AddFloating(id, 0, 0)
1013+ w.Floats[len(w.Floats)-1].back = back
1014+}
1015+
1016+// ToggleFloatingVisible hides every float without changing its placement.
1017+func (w *Workspace) ToggleFloatingVisible() {
1018+ if len(w.Floats) == 0 {
1019+ return
1020+ }
1021+ w.floatsHidden = !w.floatsHidden
1022+ if w.floatsHidden {
1023+ // A hidden fullscreen float would still hide the columns.
1024+ w.hiddenFocus, w.floatFocus = w.floatFocus, false
1025+ w.hiddenFullscreen = 0
1026+ if w.floatIndex(w.fullscreen) >= 0 {
1027+ w.hiddenFullscreen, w.fullscreen = w.fullscreen, 0
1028+ }
1029+ return
1030+ }
1031+ w.floatFocus = w.hiddenFocus
1032+ if w.fullscreen == 0 && w.floatIndex(w.hiddenFullscreen) >= 0 {
1033+ w.fullscreen = w.hiddenFullscreen
1034+ }
1035+ w.hiddenFocus, w.hiddenFullscreen = false, 0
1036+}
@@ -136,8 +136,13 @@ type Workspace struct {
136 fullscreen WindowID136 fullscreen WindowID
137 // Floats are floating windows, bottom to top; floatFocus is set while137 // Floats are floating windows, bottom to top; floatFocus is set while
138 // the top one has the focus instead of the columns.138 // the top one has the focus instead of the columns.
139- Floats []Float139+ Floats []Float
140- floatFocus bool140+ floatFocus bool
141+ floatsHidden bool
142+ // hiddenFocus and hiddenFullscreen hold the float focus and a float's
143+ // fullscreen while the floats are hidden, restored when shown.
144+ hiddenFocus bool
145+ hiddenFullscreen WindowID
141 // home is the monitor (key or connector) the workspace belongs to; ""146 // home is the monitor (key or connector) the workspace belongs to; ""
142 // means the one it is on. On another monitor it is a guest; homePos is147 // means the one it is on. On another monitor it is a guest; homePos is
143 // its position there, where it returns.148 // its position there, where it returns.
@@ -164,13 +169,17 @@ type origPlace struct {
164 stacked []WindowID169 stacked []WindowID
165 width Width170 width Width
166 expanded bool171 expanded bool
167- float *Float172+ // fullWidth is the column's maximize-column state.
173+ fullWidth bool
174+ float *Float
168 }175 }
169 176
170 // Float is a floating window and its client size, logical.177 // Float is a floating window and its client size, logical.
171 type Float struct {178 type Float struct {
172 ID WindowID179 ID WindowID
173 W, H int180 W, H int
181+ // back records the former column of a tiled window; nil for native floats.
182+ back *origPlace
174 }183 }
175 184
176 func (w *Workspace) empty() bool { return len(w.Columns) == 0 && len(w.Floats) == 0 }185 func (w *Workspace) empty() bool { return len(w.Columns) == 0 && len(w.Floats) == 0 }
@@ -196,6 +205,10 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) {
196 if id == 0 || w.has(id) {205 if id == 0 || w.has(id) {
197 return206 return
198 }207 }
208+ if w.floatsHidden {
209+ // A new float must be seen: show the hidden ones with it.
210+ w.ToggleFloatingVisible()
211+ }
199 w.Floats = append(w.Floats, Float{ID: id, W: width, H: height})212 w.Floats = append(w.Floats, Float{ID: id, W: width, H: height})
200 w.floatFocus = true213 w.floatFocus = true
201 }214 }
@@ -203,7 +216,9 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) {
203 // ResizeFloating records the size a floating window draws.216 // ResizeFloating records the size a floating window draws.
204 func (w *Workspace) ResizeFloating(id WindowID, width, height int) {217 func (w *Workspace) ResizeFloating(id WindowID, width, height int) {
205 if i := w.floatIndex(id); i >= 0 {218 if i := w.floatIndex(id); i >= 0 {
206- w.Floats[i].W, w.Floats[i].H = width, height219+ if w.Floats[i].back == nil {
220+ w.Floats[i].W, w.Floats[i].H = width, height
221+ }
207 }222 }
208 }223 }
209 224
@@ -223,7 +238,7 @@ func (w *Workspace) has(id WindowID) bool {
223 }238 }
224 239
225 func (w *Workspace) Focused() (WindowID, bool) {240 func (w *Workspace) Focused() (WindowID, bool) {
226- if w.floatFocus && len(w.Floats) > 0 {241+ if w.floatFocus && !w.floatsHidden && len(w.Floats) > 0 {
227 return w.Floats[len(w.Floats)-1].ID, true242 return w.Floats[len(w.Floats)-1].ID, true
228 }243 }
229 if w.Focus < 0 || w.Focus >= len(w.Columns) {244 if w.Focus < 0 || w.Focus >= len(w.Columns) {
@@ -317,8 +332,11 @@ func (w *Workspace) RemoveWindow(id WindowID) {
317 if w.fullscreen == id {332 if w.fullscreen == id {
318 w.fullscreen = 0333 w.fullscreen = 0
319 }334 }
335+ if w.hiddenFullscreen == id {
336+ w.hiddenFullscreen = 0
337+ }
320 if len(w.Floats) == 0 {338 if len(w.Floats) == 0 {
321- w.floatFocus = false339+ w.floatFocus, w.floatsHidden, w.hiddenFocus = false, false, false
322 }340 }
323 return341 return
324 }342 }
@@ -359,6 +377,9 @@ func (w *Workspace) RemoveWindow(id WindowID) {
359 // window is raised.377 // window is raised.
360 func (w *Workspace) FocusID(id WindowID) bool {378 func (w *Workspace) FocusID(id WindowID) bool {
361 if i := w.floatIndex(id); i >= 0 {379 if i := w.floatIndex(id); i >= 0 {
380+ if w.floatsHidden {
381+ return false
382+ }
362 f := w.Floats[i]383 f := w.Floats[i]
363 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)384 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)
364 w.floatFocus = true385 w.floatFocus = true
@@ -601,6 +622,15 @@ func (w *Workspace) Activate(id WindowID) {
601 // requests are automatic events (ADR 011 golden rule).622 // requests are automatic events (ADR 011 golden rule).
602 func (w *Workspace) SetFullscreen(id WindowID, on bool) {623 func (w *Workspace) SetFullscreen(id WindowID, on bool) {
603 if w.floatIndex(id) >= 0 {624 if w.floatIndex(id) >= 0 {
625+ if w.floatsHidden {
626+ // Applied when the floats are shown again.
627+ if on {
628+ w.hiddenFullscreen = id
629+ } else if w.hiddenFullscreen == id {
630+ w.hiddenFullscreen = 0
631+ }
632+ return
633+ }
604 if on {634 if on {
605 w.fullscreen = id635 w.fullscreen = id
606 } else if w.fullscreen == id {636 } else if w.fullscreen == id {
@@ -860,7 +890,12 @@ func (w *Workspace) Layout() []Placement {
860 setNeighbors(result, gap)890 setNeighbors(result, gap)
861 // Floating windows go last: they are drawn and hit on top.891 // Floating windows go last: they are drawn and hit on top.
862 for _, f := range w.Floats {892 for _, f := range w.Floats {
863- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll}893+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll, Hidden: w.floatsHidden}
894+ if p.Hidden {
895+ p.Rect = Rect{}
896+ result = append(result, p)
897+ continue
898+ }
864 // Floats stay above a fullscreen window: a dialog opened from a899 // Floats stay above a fullscreen window: a dialog opened from a
865 // fullscreen app must be seen.900 // fullscreen app must be seen.
866 if w.fullscreen == f.ID {901 if w.fullscreen == f.ID {
@@ -909,9 +944,93 @@ func setNeighbors(tiles []Placement, gap int) {
909 func (w *Workspace) floatRect(f Float) Rect {944 func (w *Workspace) floatRect(f Float) Rect {
910 u := w.Usable945 u := w.Usable
911 fw, fh := f.W, f.H946 fw, fh := f.W, f.H
912- if fw <= 0 || fh <= 0 {947+ if f.back != nil {
948+ fw, fh = max(u.W*80/100-2*w.border, 0), max(u.H*80/100-2*w.border, 0)
949+ }
950+ if f.back == nil && (fw <= 0 || fh <= 0) {
913 fw, fh = u.W/2, u.H/2951 fw, fh = u.W/2, u.H/2
914 }952 }
915 fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H)953 fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H)
916 return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}954 return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}
917 }955 }
956+
957+// imposedFloat reports whether core, rather than the client, owns the size.
958+func (w *Workspace) imposedFloat(id WindowID) bool {
959+ i := w.floatIndex(id)
960+ return i >= 0 && w.Floats[i].back != nil
961+}
962+
963+// ToggleWindowFloating converts a tile to an 80% float, remembering its
964+// column and row. Native floats instead become new columns.
965+func (w *Workspace) ToggleWindowFloating() {
966+ id, ok := w.Focused()
967+ if !ok {
968+ return
969+ }
970+ if i := w.floatIndex(id); i >= 0 {
971+ f := w.Floats[i]
972+ w.RemoveWindow(id)
973+ if f.back == nil {
974+ w.addColumn(Column{Windows: []WindowID{id}})
975+ } else {
976+ back := f.back
977+ at := min(back.col, len(w.Columns))
978+ if i := slices.IndexFunc(w.Columns, back.holdsStack); i >= 0 {
979+ at = i
980+ c := &w.Columns[at]
981+ row := 0
982+ for _, v := range back.stacked[:back.row] {
983+ if slices.Contains(c.Windows, v) {
984+ row++
985+ }
986+ }
987+ c.Windows = slices.Insert(c.Windows, row, id)
988+ c.Focus = row
989+ w.Focus = at
990+ w.scroll()
991+ } else {
992+ // Its column is gone: it comes back expanded, unless another
993+ // column was expanded meanwhile (as leaveFullscreen).
994+ expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded })
995+ w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, FullWidth: back.fullWidth, Expanded: expanded})
996+ }
997+ }
998+ w.floatFocus = false
999+ return
1000+ }
1001+ col := w.Focus
1002+ c := w.Columns[col]
1003+ row := c.Focus
1004+ lone := len(c.Windows) == 1
1005+ back := &origPlace{col: col, row: row, slot: c.Slot, width: c.Width, fullWidth: c.FullWidth && lone, expanded: c.Expanded && lone}
1006+ for _, v := range c.Windows {
1007+ if v != id {
1008+ back.stacked = append(back.stacked, v)
1009+ }
1010+ }
1011+ w.RemoveWindow(id)
1012+ w.AddFloating(id, 0, 0)
1013+ w.Floats[len(w.Floats)-1].back = back
1014+}
1015+
1016+// ToggleFloatingVisible hides every float without changing its placement.
1017+func (w *Workspace) ToggleFloatingVisible() {
1018+ if len(w.Floats) == 0 {
1019+ return
1020+ }
1021+ w.floatsHidden = !w.floatsHidden
1022+ if w.floatsHidden {
1023+ // A hidden fullscreen float would still hide the columns.
1024+ w.hiddenFocus, w.floatFocus = w.floatFocus, false
1025+ w.hiddenFullscreen = 0
1026+ if w.floatIndex(w.fullscreen) >= 0 {
1027+ w.hiddenFullscreen, w.fullscreen = w.fullscreen, 0
1028+ }
1029+ return
1030+ }
1031+ w.floatFocus = w.hiddenFocus
1032+ if w.fullscreen == 0 && w.floatIndex(w.hiddenFullscreen) >= 0 {
1033+ w.fullscreen = w.hiddenFullscreen
1034+ }
1035+ w.hiddenFocus, w.hiddenFullscreen = false, 0
1036+}
M internal/core/monitor.go +8 -1
@@ -284,6 +284,9 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) {
284284 case w.origin != nil && id != w.back.id:
285285 // A dialog of a fullscreen workspace already floats above it; its
286286 // own fullscreen would take the workspace from the window it holds.
287+ case w.floatsHidden && w.floatIndex(id) >= 0:
288+ // A hidden float stays hidden: its fullscreen waits for the show.
289+ w.SetFullscreen(id, on)
287290 case on && m.ownWorkspace(w, id):
288291 focused, _ := w.Focused()
289292 m.enterFullscreen(w, id, w == m.Current() && focused == id)
@@ -336,6 +339,7 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
336339 fs.back.float = &fl
337340 w.RemoveWindow(id)
338341 fs.AddFloating(id, fl.W, fl.H)
342+ fs.Floats[len(fs.Floats)-1] = fl
339343 } else {
340344 col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
341345 c := w.Columns[col]
@@ -380,9 +384,10 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
380384 switch prev := origin.Focus; {
381385 case back.float != nil && focus:
382386 origin.AddFloating(id, back.float.W, back.float.H)
387+ origin.Floats[len(origin.Floats)-1] = *back.float
383388 case back.float != nil:
384389 // At the bottom: the top float, maybe focused, stays on top.
385- origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H})
390+ origin.Floats = slices.Insert(origin.Floats, 0, *back.float)
386391 case slices.ContainsFunc(origin.Columns, back.holdsStack):
387392 back.col = slices.IndexFunc(origin.Columns, back.holdsStack)
388393 c := &origin.Columns[back.col]
@@ -502,6 +507,7 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) {
502507 fl := cur.Floats[f]
503508 cur.RemoveWindow(id)
504509 to.AddFloating(id, fl.W, fl.H)
510+ to.Floats[len(to.Floats)-1] = fl
505511 to.FocusID(id)
506512 } else {
507513 col := Column{Windows: []WindowID{id}}
@@ -655,6 +661,7 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
655661 }
656662 for _, f := range w.Floats {
657663 m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)
664+ m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f
658665 }
659666 }
660667 m.hidden = hidden
@@ -284,6 +284,9 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) {
284 case w.origin != nil && id != w.back.id:284 case w.origin != nil && id != w.back.id:
285 // A dialog of a fullscreen workspace already floats above it; its285 // A dialog of a fullscreen workspace already floats above it; its
286 // own fullscreen would take the workspace from the window it holds.286 // own fullscreen would take the workspace from the window it holds.
287+ case w.floatsHidden && w.floatIndex(id) >= 0:
288+ // A hidden float stays hidden: its fullscreen waits for the show.
289+ w.SetFullscreen(id, on)
287 case on && m.ownWorkspace(w, id):290 case on && m.ownWorkspace(w, id):
288 focused, _ := w.Focused()291 focused, _ := w.Focused()
289 m.enterFullscreen(w, id, w == m.Current() && focused == id)292 m.enterFullscreen(w, id, w == m.Current() && focused == id)
@@ -336,6 +339,7 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
336 fs.back.float = &fl339 fs.back.float = &fl
337 w.RemoveWindow(id)340 w.RemoveWindow(id)
338 fs.AddFloating(id, fl.W, fl.H)341 fs.AddFloating(id, fl.W, fl.H)
342+ fs.Floats[len(fs.Floats)-1] = fl
339 } else {343 } else {
340 col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })344 col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
341 c := w.Columns[col]345 c := w.Columns[col]
@@ -380,9 +384,10 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
380 switch prev := origin.Focus; {384 switch prev := origin.Focus; {
381 case back.float != nil && focus:385 case back.float != nil && focus:
382 origin.AddFloating(id, back.float.W, back.float.H)386 origin.AddFloating(id, back.float.W, back.float.H)
387+ origin.Floats[len(origin.Floats)-1] = *back.float
383 case back.float != nil:388 case back.float != nil:
384 // At the bottom: the top float, maybe focused, stays on top.389 // At the bottom: the top float, maybe focused, stays on top.
385- origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H})390+ origin.Floats = slices.Insert(origin.Floats, 0, *back.float)
386 case slices.ContainsFunc(origin.Columns, back.holdsStack):391 case slices.ContainsFunc(origin.Columns, back.holdsStack):
387 back.col = slices.IndexFunc(origin.Columns, back.holdsStack)392 back.col = slices.IndexFunc(origin.Columns, back.holdsStack)
388 c := &origin.Columns[back.col]393 c := &origin.Columns[back.col]
@@ -502,6 +507,7 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) {
502 fl := cur.Floats[f]507 fl := cur.Floats[f]
503 cur.RemoveWindow(id)508 cur.RemoveWindow(id)
504 to.AddFloating(id, fl.W, fl.H)509 to.AddFloating(id, fl.W, fl.H)
510+ to.Floats[len(to.Floats)-1] = fl
505 to.FocusID(id)511 to.FocusID(id)
506 } else {512 } else {
507 col := Column{Windows: []WindowID{id}}513 col := Column{Windows: []WindowID{id}}
@@ -655,6 +661,7 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
655 }661 }
656 for _, f := range w.Floats {662 for _, f := range w.Floats {
657 m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)663 m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)
664+ m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f
658 }665 }
659 }666 }
660 m.hidden = hidden667 m.hidden = hidden
M internal/core/popup.go +2 -1
@@ -144,7 +144,8 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
144144 for _, sc := range c.screens {
145145 for _, pl := range sc.mon.Layout() {
146146 if pl.ID == id {
147- if pl.Hidden {
147+ // Hidden or scrolled off: not on screen, like its popups.
148+ if !onScreen(pl, sc.mon.Output()) {
148149 return nil, Rect{}, false
149150 }
150151 return sc, c.clientRect(pl), true
@@ -144,7 +144,8 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
144 for _, sc := range c.screens {144 for _, sc := range c.screens {
145 for _, pl := range sc.mon.Layout() {145 for _, pl := range sc.mon.Layout() {
146 if pl.ID == id {146 if pl.ID == id {
147- if pl.Hidden {147+ // Hidden or scrolled off: not on screen, like its popups.
148+ if !onScreen(pl, sc.mon.Output()) {
148 return nil, Rect{}, false149 return nil, Rect{}, false
149 }150 }
150 return sc, c.clientRect(pl), true151 return sc, c.clientRect(pl), true
M internal/core/state.go +6 -1
@@ -36,11 +36,16 @@ func (c *Core) state() ports.State {
3636 o.Active = i + 1
3737 }
3838 st.Outputs = append(st.Outputs, o)
39+ // Visible follows the configures: drawn on the output.
40+ shown := map[WindowID]bool{}
41+ for _, p := range m.Layout() {
42+ shown[p.ID] = onScreen(p, m.Output())
43+ }
3944 for _, w := range m.all() {
4045 n := indexOf(m.Workspaces, w) + 1
4146 for _, id := range w.windows() {
4247 info := c.clients[id]
43- st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur, IdleInhibit: c.idle[id]})
48+ st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: shown[id], IdleInhibit: c.idle[id]})
4449 }
4550 }
4651 }
@@ -36,11 +36,16 @@ func (c *Core) state() ports.State {
36 o.Active = i + 136 o.Active = i + 1
37 }37 }
38 st.Outputs = append(st.Outputs, o)38 st.Outputs = append(st.Outputs, o)
39+ // Visible follows the configures: drawn on the output.
40+ shown := map[WindowID]bool{}
41+ for _, p := range m.Layout() {
42+ shown[p.ID] = onScreen(p, m.Output())
43+ }
39 for _, w := range m.all() {44 for _, w := range m.all() {
40 n := indexOf(m.Workspaces, w) + 145 n := indexOf(m.Workspaces, w) + 1
41 for _, id := range w.windows() {46 for _, id := range w.windows() {
42 info := c.clients[id]47 info := c.clients[id]
43- st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur, IdleInhibit: c.idle[id]})48+ st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: shown[id], IdleInhibit: c.idle[id]})
44 }49 }
45 }50 }
46 }51 }
A internal/core/visibility_test.go +97 -0
@@ -0,0 +1,97 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Every window's configure says whether it is on its output: hidden
11+// workspaces, scrolled-off columns, maximized neighbors and hidden floats
12+// are not visible, and keep their size and output.
13+func TestConfigureVisible(t *testing.T) {
14+ cfg := ports.Config{}
15+ cfg.Keyboard.CmdKey = "super"
16+ cfg.Layout.MaxColumns = 1
17+ commands := make(chan ports.ClientCommand, 256)
18+ scenes := make(chan []ports.Scene, 1)
19+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80})
24+ m := c.cur().mon
25+ w := m.Current()
26+ ctx := context.Background()
27+ sent := map[WindowID]ports.ConfigureWindow{}
28+ publish := func() {
29+ t.Helper()
30+ if err := c.publish(ctx); err != nil {
31+ t.Fatal(err)
32+ }
33+ <-scenes
34+ for len(commands) > 0 {
35+ if v, ok := (<-commands).(ports.ConfigureWindow); ok {
36+ sent[v.ID] = v
37+ }
38+ }
39+ }
40+ visible := func(want map[WindowID]bool) {
41+ t.Helper()
42+ publish()
43+ state := map[WindowID]bool{}
44+ for _, s := range c.state().Windows {
45+ state[s.ID] = s.Visible
46+ }
47+ for id, vis := range want {
48+ v := sent[id]
49+ if v.Visible != vis || v.Output == "" || v.Width <= 0 {
50+ t.Fatalf("window %d: %+v, want visible %v", id, v, vis)
51+ }
52+ if state[id] != vis {
53+ t.Fatalf("window %d: state visible %v, want %v", id, state[id], vis)
54+ }
55+ }
56+ }
57+ // One column per screen: the second scrolls the first off.
58+ m.AddWindow(1)
59+ visible(map[WindowID]bool{1: true})
60+ m.AddWindow(2)
61+ visible(map[WindowID]bool{1: false, 2: true})
62+ w.FocusColumn(-1)
63+ visible(map[WindowID]bool{1: true, 2: false})
64+ // A floated window is visible until the floats are hidden.
65+ w.FocusID(2)
66+ w.ToggleWindowFloating()
67+ visible(map[WindowID]bool{1: true, 2: true})
68+ w.ToggleFloatingVisible()
69+ visible(map[WindowID]bool{1: true, 2: false})
70+ w.ToggleFloatingVisible()
71+ // Another workspace hides them all; coming back shows them.
72+ if len(m.Workspaces) < 2 {
73+ t.Fatal("no spare workspace")
74+ }
75+ m.Focus(1)
76+ visible(map[WindowID]bool{1: false, 2: false})
77+ m.Focus(0)
78+ visible(map[WindowID]bool{1: true, 2: true})
79+ // A window mapped on a hidden workspace is told it is invisible at
80+ // once, sized by the client.
81+ m.Workspaces[1].AddWindow(3)
82+ publish()
83+ if v, ok := sent[3]; !ok || v.Visible || v.Width != 0 || v.Output != "OUT-1" {
84+ t.Fatalf("hidden map: %+v %v", v, ok)
85+ }
86+ // A column scrolled off takes its popups off screen with it.
87+ c.popups[10] = &popupState{id: 10, parent: 1, mapped: true, rect: Rect{W: 5, H: 5}}
88+ c.popupOrder = append(c.popupOrder, 10)
89+ w.FocusID(1)
90+ if !c.visible(10) {
91+ t.Fatal("popup of a visible window not on screen")
92+ }
93+ m.AddWindow(4)
94+ if c.visible(1) || c.visible(10) {
95+ t.Fatal("popup of a scrolled-off window still on screen")
96+ }
97+}
@@ -0,0 +1,97 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Every window's configure says whether it is on its output: hidden
11+// workspaces, scrolled-off columns, maximized neighbors and hidden floats
12+// are not visible, and keep their size and output.
13+func TestConfigureVisible(t *testing.T) {
14+ cfg := ports.Config{}
15+ cfg.Keyboard.CmdKey = "super"
16+ cfg.Layout.MaxColumns = 1
17+ commands := make(chan ports.ClientCommand, 256)
18+ scenes := make(chan []ports.Scene, 1)
19+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80})
24+ m := c.cur().mon
25+ w := m.Current()
26+ ctx := context.Background()
27+ sent := map[WindowID]ports.ConfigureWindow{}
28+ publish := func() {
29+ t.Helper()
30+ if err := c.publish(ctx); err != nil {
31+ t.Fatal(err)
32+ }
33+ <-scenes
34+ for len(commands) > 0 {
35+ if v, ok := (<-commands).(ports.ConfigureWindow); ok {
36+ sent[v.ID] = v
37+ }
38+ }
39+ }
40+ visible := func(want map[WindowID]bool) {
41+ t.Helper()
42+ publish()
43+ state := map[WindowID]bool{}
44+ for _, s := range c.state().Windows {
45+ state[s.ID] = s.Visible
46+ }
47+ for id, vis := range want {
48+ v := sent[id]
49+ if v.Visible != vis || v.Output == "" || v.Width <= 0 {
50+ t.Fatalf("window %d: %+v, want visible %v", id, v, vis)
51+ }
52+ if state[id] != vis {
53+ t.Fatalf("window %d: state visible %v, want %v", id, state[id], vis)
54+ }
55+ }
56+ }
57+ // One column per screen: the second scrolls the first off.
58+ m.AddWindow(1)
59+ visible(map[WindowID]bool{1: true})
60+ m.AddWindow(2)
61+ visible(map[WindowID]bool{1: false, 2: true})
62+ w.FocusColumn(-1)
63+ visible(map[WindowID]bool{1: true, 2: false})
64+ // A floated window is visible until the floats are hidden.
65+ w.FocusID(2)
66+ w.ToggleWindowFloating()
67+ visible(map[WindowID]bool{1: true, 2: true})
68+ w.ToggleFloatingVisible()
69+ visible(map[WindowID]bool{1: true, 2: false})
70+ w.ToggleFloatingVisible()
71+ // Another workspace hides them all; coming back shows them.
72+ if len(m.Workspaces) < 2 {
73+ t.Fatal("no spare workspace")
74+ }
75+ m.Focus(1)
76+ visible(map[WindowID]bool{1: false, 2: false})
77+ m.Focus(0)
78+ visible(map[WindowID]bool{1: true, 2: true})
79+ // A window mapped on a hidden workspace is told it is invisible at
80+ // once, sized by the client.
81+ m.Workspaces[1].AddWindow(3)
82+ publish()
83+ if v, ok := sent[3]; !ok || v.Visible || v.Width != 0 || v.Output != "OUT-1" {
84+ t.Fatalf("hidden map: %+v %v", v, ok)
85+ }
86+ // A column scrolled off takes its popups off screen with it.
87+ c.popups[10] = &popupState{id: 10, parent: 1, mapped: true, rect: Rect{W: 5, H: 5}}
88+ c.popupOrder = append(c.popupOrder, 10)
89+ w.FocusID(1)
90+ if !c.visible(10) {
91+ t.Fatal("popup of a visible window not on screen")
92+ }
93+ m.AddWindow(4)
94+ if c.visible(1) || c.visible(10) {
95+ t.Fatal("popup of a scrolled-off window still on screen")
96+ }
97+}
M internal/ports/config.go +1 -0
@@ -74,6 +74,7 @@ type Config struct {
7474 // Startup are commands run once when the session starts, in order.
7575 Startup [][]string
7676 Background struct{ Color string }
77+ Floating struct{ Dim float64 }
7778 // Border is drawn inside the window edge; Width 0 disables it.
7879 Border struct {
7980 Width int
@@ -74,6 +74,7 @@ type Config struct {
74 // Startup are commands run once when the session starts, in order.74 // Startup are commands run once when the session starts, in order.
75 Startup [][]string75 Startup [][]string
76 Background struct{ Color string }76 Background struct{ Color string }
77+ Floating struct{ Dim float64 }
77 // Border is drawn inside the window edge; Width 0 disables it.78 // Border is drawn inside the window edge; Width 0 disables it.
78 Border struct {79 Border struct {
79 Width int80 Width int
M internal/ports/messages.go +15 -4
@@ -547,7 +547,13 @@ type ConfigureWindow struct {
547547 Fullscreen, Activated bool
548548 // Floating windows are not tiled: no tiled states.
549549 Floating bool
550- Output string
550+ // Output is the output the window belongs to, kept while it is not
551+ // visible (scale, foreign toplevel).
552+ Output string
553+ // Visible is set while part of the window is on its output: not on a
554+ // hidden workspace, scrolled off, behind a maximized column or a
555+ // hidden float. An invisible window is suspended and throttled.
556+ Visible bool
551557 }
552558
553559 func (ConfigureWindow) clientCommand() {}
@@ -737,7 +743,11 @@ type Scene struct {
737743 Off bool
738744 Background string
739745 Border Border
740- Windows []SceneWindow
746+ // Dim darkens the background, bottom layers and tiles (tile lines
747+ // included) with black at this opacity, 0 to 1, under the first
748+ // visible float. 0 draws nothing.
749+ Dim float64
750+ Windows []SceneWindow
741751 // Separators are the lines between windows, drawn in slice order with
742752 // the Border colors: tile lines over the tiles, under the floats; a
743753 // float's border right after the float.
@@ -750,10 +760,11 @@ type Scene struct {
750760 }
751761
752762 // Shows reports whether the scene draws the surface of id: only its
753-// content changes need a new frame.
763+// content changes need a new frame. A window scrolled off the output is
764+// not drawn.
754765 func (s Scene) Shows(id WindowID) bool {
755766 for _, w := range s.Windows {
756- if w.ID == id && !w.Hidden {
767+ if w.ID == id && !w.Hidden && w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) {
757768 return true
758769 }
759770 }
@@ -547,7 +547,13 @@ type ConfigureWindow struct {
547 Fullscreen, Activated bool547 Fullscreen, Activated bool
548 // Floating windows are not tiled: no tiled states.548 // Floating windows are not tiled: no tiled states.
549 Floating bool549 Floating bool
550- Output string550+ // Output is the output the window belongs to, kept while it is not
551+ // visible (scale, foreign toplevel).
552+ Output string
553+ // Visible is set while part of the window is on its output: not on a
554+ // hidden workspace, scrolled off, behind a maximized column or a
555+ // hidden float. An invisible window is suspended and throttled.
556+ Visible bool
551 }557 }
552 558
553 func (ConfigureWindow) clientCommand() {}559 func (ConfigureWindow) clientCommand() {}
@@ -737,7 +743,11 @@ type Scene struct {
737 Off bool743 Off bool
738 Background string744 Background string
739 Border Border745 Border Border
740- Windows []SceneWindow746+ // Dim darkens the background, bottom layers and tiles (tile lines
747+ // included) with black at this opacity, 0 to 1, under the first
748+ // visible float. 0 draws nothing.
749+ Dim float64
750+ Windows []SceneWindow
741 // Separators are the lines between windows, drawn in slice order with751 // Separators are the lines between windows, drawn in slice order with
742 // the Border colors: tile lines over the tiles, under the floats; a752 // the Border colors: tile lines over the tiles, under the floats; a
743 // float's border right after the float.753 // float's border right after the float.
@@ -750,10 +760,11 @@ type Scene struct {
750 }760 }
751 761
752 // Shows reports whether the scene draws the surface of id: only its762 // Shows reports whether the scene draws the surface of id: only its
753-// content changes need a new frame.763+// content changes need a new frame. A window scrolled off the output is
764+// not drawn.
754 func (s Scene) Shows(id WindowID) bool {765 func (s Scene) Shows(id WindowID) bool {
755 for _, w := range s.Windows {766 for _, w := range s.Windows {
756- if w.ID == id && !w.Hidden {767+ if w.ID == id && !w.Hidden && w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) {
757 return true768 return true
758 }769 }
759 }770 }
M internal/ports/types.go +7 -0
@@ -17,6 +17,13 @@ const (
1717 SideAll = SideLeft | SideRight | SideTop | SideBottom
1818 )
1919
20+// Overlaps reports whether r and o share a positive area: touching edges
21+// do not overlap.
22+func (r Rect) Overlaps(o Rect) bool {
23+ return r.W > 0 && r.H > 0 && o.W > 0 && o.H > 0 &&
24+ r.X < o.X+o.W && o.X < r.X+r.W && r.Y < o.Y+o.H && o.Y < r.Y+r.H
25+}
26+
2027 // Inset shrinks r by b on each side in s.
2128 func (r Rect) Inset(s Sides, b int) Rect {
2229 b = min(max(b, 0), r.W/2, r.H/2)
@@ -17,6 +17,13 @@ const (
17 SideAll = SideLeft | SideRight | SideTop | SideBottom17 SideAll = SideLeft | SideRight | SideTop | SideBottom
18 )18 )
19 19
20+// Overlaps reports whether r and o share a positive area: touching edges
21+// do not overlap.
22+func (r Rect) Overlaps(o Rect) bool {
23+ return r.W > 0 && r.H > 0 && o.W > 0 && o.H > 0 &&
24+ r.X < o.X+o.W && o.X < r.X+r.W && r.Y < o.Y+o.H && o.Y < r.Y+r.H
25+}
26+
20 // Inset shrinks r by b on each side in s.27 // Inset shrinks r by b on each side in s.
21 func (r Rect) Inset(s Sides, b int) Rect {28 func (r Rect) Inset(s Sides, b int) Rect {
22 b = min(max(b, 0), r.W/2, r.H/2)29 b = min(max(b, 0), r.W/2, r.H/2)

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