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 Unverified
B5690EEEBB952194The 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`). | |||
| 22 | 22 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | |
| 23 | 23 | | `background` | `#111111` | Solid background color | | |
| 24 | 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 | 26 | | `border.active` / `border.inactive` | `#808080` / `#111111` | Separator colors around the focused / other windows | | |
| 26 | 27 | | `layout.gaps` | `0` | Space between windows | | |
| 27 | 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 | 90 | | `spawn-terminal` | Run `terminal` | | |
| 90 | 91 | | `close-window` | Close the focused window | | |
| 91 | 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 | 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 | 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 | 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 | 129 | | `cmd+r` | `cycle-column-width` | | |
| 127 | 130 | | `cmd+f` | `maximize-column` | | |
| 128 | 131 | | `cmd+shift+f` | `toggle-fullscreen` | | |
| 132 | + | | `cmd+v` | `toggle-window-floating` | | |
| 133 | + | | `cmd+shift+v` | `toggle-floating-visible` | | |
| 129 | 134 | | `cmd+q` | `close-window` | | |
| 130 | 135 | | `ctrl+alt+backspace` | `quit` | | |
| 131 | 136 | | `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 | |||
| 32 | 32 | border.active = #808080 | |
| 33 | 33 | 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 | 38 | layout.gaps = 0 | |
| 36 | 39 | # Columns sharing the screen equally before new ones scroll off to the right. | |
| 37 | 40 | # A lone column fills the screen without separators. | |
| @@ -115,6 +118,8 @@ bind.cmd+bracketright = consume-or-expel-window-right | |||
| 115 | 118 | bind.cmd+r = cycle-column-width | |
| 116 | 119 | bind.cmd+f = maximize-column | |
| 117 | 120 | bind.cmd+shift+f = toggle-fullscreen | |
| 121 | + | bind.cmd+v = toggle-window-floating | |
| 122 | + | bind.cmd+shift+v = toggle-floating-visible | |
| 118 | 123 | bind.cmd+q = close-window | |
| 119 | 124 | bind.ctrl+alt+backspace = quit | |
| 120 | 125 | bind.ctrl+cmd+space = spawn fuzzel | |
| @@ -32,6 +32,9 @@ border.width = 2 | |||||
| 32 | border.active = #808080 | 32 | border.active = #808080 | ||
| 33 | border.inactive = #111111 | 33 | 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 = 0 | 38 | 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-width | 118 | bind.cmd+r = cycle-column-width | ||
| 116 | bind.cmd+f = maximize-column | 119 | bind.cmd+f = maximize-column | ||
| 117 | bind.cmd+shift+f = toggle-fullscreen | 120 | 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-window | 123 | bind.cmd+q = close-window | ||
| 119 | bind.ctrl+alt+backspace = quit | 124 | bind.ctrl+alt+backspace = quit | ||
| 120 | bind.ctrl+cmd+space = spawn fuzzel | 125 | bind.ctrl+cmd+space = spawn fuzzel | ||
M internal/adapters/config/config.go +10 -1
| @@ -32,6 +32,8 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 32 | 32 | {"cmd+r", "cycle-column-width"}, | |
| 33 | 33 | {"cmd+f", "maximize-column"}, | |
| 34 | 34 | {"cmd+shift+f", "toggle-fullscreen"}, | |
| 35 | + | {"cmd+v", "toggle-window-floating"}, | |
| 36 | + | {"cmd+shift+v", "toggle-floating-visible"}, | |
| 35 | 37 | {"cmd+q", "close-window"}, | |
| 36 | 38 | {"ctrl+alt+backspace", "quit"}, | |
| 37 | 39 | {"ctrl+cmd+space", "spawn fuzzel"}, | |
| @@ -44,7 +46,7 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 44 | 46 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | |
| 45 | 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 | 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 | 51 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 50 | 52 | ||
| @@ -87,6 +89,7 @@ func Defaults() ports.Config { | |||
| 87 | 89 | c.Terminal.AutoOpen = "first" | |
| 88 | 90 | c.Xwayland = "xwayland-satellite" | |
| 89 | 91 | c.Background.Color = "#111111" | |
| 92 | + | c.Floating.Dim = 0.3 | |
| 90 | 93 | c.Border.Width = 2 | |
| 91 | 94 | c.Border.Active = "#808080" | |
| 92 | 95 | c.Border.Inactive = "#111111" | |
| @@ -450,6 +453,12 @@ func set(c *ports.Config, key, v string) error { | |||
| 450 | 453 | return fmt.Errorf("must be #rrggbb") | |
| 451 | 454 | } | |
| 452 | 455 | 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 | 462 | case "border.width": | |
| 454 | 463 | n, err := strconv.Atoi(v) | |
| 455 | 464 | 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 = 2 | 93 | 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 = v | 455 | 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) { | |||
| 22 | 22 | func TestDefaultsAndLoad(t *testing.T) { | |
| 23 | 23 | t.Setenv("TERMINAL", "") | |
| 24 | 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 | 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 | 29 | t.Fatal(d.Binds) | |
| 30 | 30 | } | |
| 31 | 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 | 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 | + | } | |
| @@ -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) { | |||
| 240 | 240 | ||
| 241 | 241 | func TestShownBySnapshotAllocations(t *testing.T) { | |
| 242 | 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 | 244 | seen := map[ports.WindowID]uint64{1: 1, 2: 2} | |
| 245 | 245 | previous := o.shownBy(s, seen) | |
| 246 | 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 | 412 | // A VRR refusal on a composed frame with an overlay retries with the | |
| 388 | 413 | // overlay: the composed image left its window out. | |
| 389 | 414 | 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 the | 412 | // 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 | |||
| 85 | 85 | if pick == nil { | |
| 86 | 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 | 93 | for _, l := range s.Layers { | |
| 89 | 94 | if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 && !(pick.Fullscreen && l.Layer == ports.LayerTop) { | |
| 90 | 95 | 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) { | |||
| 72 | 72 | if _, _, reason := overlayCandidate(s, c); reason != "layer_above" { | |
| 73 | 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 | 83 | // 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) { | |||
| 272 | 272 | } | |
| 273 | 273 | ||
| 274 | 274 | // 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 | 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 | - | 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}) | |
| 279 | 280 | if len(got) != 2 || got[1] != 3 || got[5] != 6 { | |
| 280 | 281 | t.Fatalf("shows %v", got) | |
| 281 | 282 | } | |
| @@ -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 of | 274 | // 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) { | |||
| 70 | 70 | contents := make(chan ports.SurfaceContent, 8) | |
| 71 | 71 | r, f := recordingRenderer(t, nil) | |
| 72 | 72 | 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} | |
| 76 | 76 | contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}} | |
| 77 | 77 | ctx, cancel := context.WithCancel(context.Background()) | |
| 78 | 78 | done := make(chan error, 1) | |
| @@ -111,7 +111,7 @@ func TestRun(t *testing.T) { | |||
| 111 | 111 | func TestRunSkipsContentNotShown(t *testing.T) { | |
| 112 | 112 | scenes := make(chan ports.Scene, 1) | |
| 113 | 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 | 115 | r, f := recordingRenderer(t, nil) | |
| 116 | 116 | ctx, cancel := context.WithCancel(context.Background()) | |
| 117 | 117 | 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 { | |||
| 299 | 299 | 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 | 310 | // contentDraw draws the visible rect of a w×h buffer mapped onto full | |
| 303 | 311 | // (target pixels) through the inverse of its buffer transform t. exact: the | |
| 304 | 312 | // buffer is drawn at its size. | |
| @@ -299,6 +299,14 @@ func (r *Renderer) fillDraw(rect image.Rectangle, c [3]uint8) draw { | |||||
| 299 | return dr | 299 | 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 full | 310 | // 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: the | 311 | // (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) { | |||
| 57 | 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 | 66 | // layers draws the layer surfaces below the windows, or above them. | |
| 61 | 67 | // A fullscreen window hides the bottom and top layers. | |
| 62 | 68 | func (w *sceneWalk) layers(afterWindows bool) { | |
| @@ -77,12 +83,15 @@ func (w *sceneWalk) layers(afterWindows bool) { | |||
| 77 | 83 | func (w *sceneWalk) windows() { | |
| 78 | 84 | tileLines := false | |
| 79 | 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 | 89 | if win.Floating && !tileLines { | |
| 81 | 90 | w.separators(0) | |
| 82 | 91 | 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 | + | } | |
| 86 | 95 | } | |
| 87 | 96 | // Content sits inside the border; core sized the client to match. | |
| 88 | 97 | 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 := false | 84 | 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 = true | 91 | 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 | - | continue | 94 | + | } |
| 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) { | |||
| 170 | 170 | if surf == nil || surf.destroyed { | |
| 171 | 171 | return ports.OutputFormats{}, false | |
| 172 | 172 | } | |
| 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 { | |
| 175 | 175 | return ports.OutputFormats{}, false | |
| 176 | 176 | } | |
| 177 | 177 | out, ok := g.scanout[root.xdg.window.last.Output] | |
| @@ -193,7 +193,7 @@ func (g *dmabufGlobal) setOutputFormats(f ports.OutputFormats) { | |||
| 193 | 193 | } | |
| 194 | 194 | for fb, surf := range g.feedbacks { | |
| 195 | 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 | 197 | g.send(fb, surf) | |
| 198 | 198 | } | |
| 199 | 199 | } | |
| @@ -207,7 +207,8 @@ func (g *dmabufGlobal) resendSurface(root *surface) { | |||
| 207 | 207 | return | |
| 208 | 208 | } | |
| 209 | 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 | 212 | g.send(fb, surf) | |
| 212 | 213 | } | |
| 213 | 214 | } | |
| @@ -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{}, false | 171 | 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{}, false | 175 | 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 | return | 207 | 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) { | |||
| 256 | 256 | t.Fatalf("tiled window: %+v", r) | |
| 257 | 257 | } | |
| 258 | 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 | 260 | r := round("fullscreen") | |
| 261 | 261 | for len(r) == 1 { // the formats may land after the configure | |
| 262 | 262 | r = round("fullscreen with formats") | |
| @@ -264,19 +264,28 @@ func TestDMABufScanoutTranche(t *testing.T) { | |||
| 264 | 264 | if len(r) != 2 || r[0].flags != scanout || len(r[0].indices) != 1 || r[0].indices[0] != 1 || r[1].flags != 0 { | |
| 265 | 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 | 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 | 278 | if r := round("other output"); len(r) != 1 { | |
| 270 | 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 | 282 | if r := round("tiled again"); len(r) != 1 || r[0].flags != 0 { | |
| 274 | 283 | t.Fatalf("left fullscreen: %+v", r) | |
| 275 | 284 | } | |
| 276 | 285 | // A destroyed feedback gets nothing more (a protocol error would | |
| 277 | 286 | // kill the connection) and leaves the server's list. | |
| 278 | 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 | 289 | formats <- ports.OutputFormats{Output: "HEADLESS-1", Device: 7} | |
| 281 | 290 | if err := c.Roundtrip(); err != nil { | |
| 282 | 291 | t.Fatal(err) | |
| @@ -287,3 +296,46 @@ func TestDMABufScanoutTranche(t *testing.T) { | |||
| 287 | 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 | + | } | |
| @@ -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 configure | 261 | 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 would | 285 | // 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() { | |||
| 428 | 428 | cb.Destroy() | |
| 429 | 429 | } | |
| 430 | 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 | 433 | for _, fb := range u.feedback { | |
| 434 | 434 | discard(fb) | |
| @@ -477,7 +477,7 @@ func (s *surface) setBarrier(now time.Time) { | |||
| 477 | 477 | // fifoOutput is the output pacing a surface, "" when none does. | |
| 478 | 478 | func (s *Server) fifoOutput(surf *surface) string { | |
| 479 | 479 | name := s.frameOutput(surf) | |
| 480 | - | if name != "" && s.outputByNameExact(name) == nil { | |
| 480 | + | if name != "" && name != suspendedFrameQueue && s.outputByNameExact(name) == nil { | |
| 481 | 481 | return "" | |
| 482 | 482 | } | |
| 483 | 483 | return name | |
| @@ -485,6 +485,9 @@ func (s *Server) fifoOutput(surf *surface) string { | |||
| 485 | 485 | ||
| 486 | 486 | // outputPeriod is one refresh of an output ("": 60 Hz). | |
| 487 | 487 | func (s *Server) outputPeriod(name string) time.Duration { | |
| 488 | + | if name == suspendedFrameQueue { | |
| 489 | + | return suspendedFramePeriod | |
| 490 | + | } | |
| 488 | 491 | if o := s.outputByNameExact(name); o != nil && name != "" { | |
| 489 | 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 | 510 | delete(s.lastFlip, name) | |
| 508 | 511 | } | |
| 509 | 512 | } | |
| 510 | - | wait := defaultFramePeriod | |
| 513 | + | wait := suspendedFramePeriod | |
| 511 | 514 | s.readinessGeneration++ | |
| 512 | 515 | for surf := range s.fifoSurfaces { | |
| 513 | 516 | name := s.fifoOutput(surf) | |
| @@ -558,6 +561,9 @@ func (s *Server) tickFifo(now time.Time, flipped map[string]bool) (time.Duration | |||
| 558 | 561 | if surf.barrier { | |
| 559 | 562 | wait = min(wait, deadline.Sub(now)) | |
| 560 | 563 | } | |
| 564 | + | if len(surf.queue) > 0 { | |
| 565 | + | wait = min(wait, p) | |
| 566 | + | } | |
| 561 | 567 | if !surf.barrier && len(surf.queue) == 0 { | |
| 562 | 568 | delete(s.fifoSurfaces, surf) | |
| 563 | 569 | } | |
| @@ -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 name | 483 | 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 := defaultFramePeriod | 513 | + | 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) { | |||
| 274 | 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 | + | } | |
| @@ -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 ( | |||
| 14 | 14 | // Frame callbacks (wl_surface.frame) tell a client when to draw its next | |
| 15 | 15 | // frame. They are paced per output: on its page flip when it flips, else | |
| 16 | 16 | // 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 | |
| 19 | 19 | // pacer sleeps while no callback waits. | |
| 20 | 20 | ||
| 21 | 21 | // defaultFramePeriod paces surfaces without an output (60 Hz). | |
| 22 | 22 | const defaultFramePeriod = time.Second / 60 | |
| 23 | + | const suspendedFramePeriod = time.Second | |
| 24 | + | const suspendedFrameQueue = "\x00suspended" // never a wl_output name | |
| 23 | 25 | ||
| 24 | 26 | // pace fires the due frame callbacks on page flips and deadlines. | |
| 25 | 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 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 { | |
| 112 | 113 | surf = surf.root() | |
| 113 | 114 | for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil { | |
| 114 | 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 | 129 | switch { | |
| 117 | 130 | case surf.xdg != nil && surf.xdg.window != nil: | |
| 131 | + | if s.invisible(surf) { | |
| 132 | + | return suspendedFrameQueue | |
| 133 | + | } | |
| 118 | 134 | return surf.xdg.window.last.Output | |
| 119 | 135 | case surf.layer != nil && surf.layer.output != nil: | |
| 120 | 136 | return surf.layer.output.name() | |
| @@ -124,12 +140,45 @@ func (s *Server) frameOutput(surf *surface) string { | |||
| 124 | 140 | ||
| 125 | 141 | // queueFrames adds a committed surface's callbacks to its output's queue | |
| 126 | 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 | 145 | 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 | + | } | |
| 132 | 180 | } | |
| 181 | + | s.wakePacer() | |
| 133 | 182 | } | |
| 134 | 183 | ||
| 135 | 184 | // 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 | 196 | // Callbacks of surfaces whose output is gone go with the outputless ones. | |
| 148 | 197 | for name, callbacks := range s.awaiting { | |
| 149 | - | if _, ok := periods[name]; !ok && name != "" { | |
| 198 | + | if _, ok := periods[name]; !ok && name != "" && name != suspendedFrameQueue { | |
| 150 | 199 | delete(s.awaiting, name) | |
| 151 | 200 | s.awaiting[""] = append(s.awaiting[""], callbacks...) | |
| 152 | 201 | } | |
| 153 | 202 | } | |
| 154 | 203 | periods[""] = defaultFramePeriod | |
| 204 | + | periods[suspendedFrameQueue] = suspendedFramePeriod | |
| 155 | 205 | for name := range s.frameDue { | |
| 156 | 206 | if _, ok := periods[name]; !ok { | |
| 157 | 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 | 233 | waiting := false | |
| 184 | - | wait = defaultFramePeriod | |
| 234 | + | wait = suspendedFramePeriod | |
| 185 | 235 | for name, callbacks := range s.awaiting { | |
| 186 | 236 | if len(callbacks) == 0 || slices.Contains(fire, name) { | |
| 187 | 237 | continue | |
| @@ -215,6 +265,7 @@ func (s *Server) sendFrames(name string, flipped bool) { | |||
| 215 | 265 | ms = uint32(max(f.When-monotonic(s.started), 0).Milliseconds()) | |
| 216 | 266 | } | |
| 217 | 267 | for _, cb := range callbacks { | |
| 268 | + | delete(s.frameOwners, cb) | |
| 218 | 269 | if !cb.Resource.Alive() { | |
| 219 | 270 | continue | |
| 220 | 271 | } | |
| @@ -14,12 +14,14 @@ import ( | |||||
| 14 | // Frame callbacks (wl_surface.frame) tell a client when to draw its next | 14 | // 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, else | 15 | // 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 a | 16 | // 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 | 17 | + | // second and a V-Sync game follows the screen. Cursors and unplaced |
| 18 | - | // (hidden windows, cursors, unplaced surfaces) are paced at 60 Hz. The | 18 | + | // 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 / 60 | 22 | 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 hidden | 111 | + | // 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.Output | 134 | 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 queue | 141 | // 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 past | 184 | // 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[""] = defaultFramePeriod | 203 | 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 := false | 233 | waiting := false | ||
| 184 | - | wait = defaultFramePeriod | 234 | + | 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 | continue | 237 | 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 | continue | 270 | continue | ||
| 220 | } | 271 | } | ||
M internal/adapters/wayland/frames_test.go +37 -0
| @@ -85,3 +85,40 @@ func TestDueFrames(t *testing.T) { | |||
| 85 | 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 | + | } | |
| @@ -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 | |||
| 94 | 94 | return | |
| 95 | 95 | } | |
| 96 | 96 | output := srv.frameOutput(s) | |
| 97 | - | if win == 0 || output == "" { | |
| 97 | + | if win == 0 || output == "" || output == suspendedFrameQueue { | |
| 98 | 98 | for _, fb := range pending { | |
| 99 | 99 | discard(fb) | |
| 100 | 100 | } | |
| @@ -130,6 +130,8 @@ func (s *Server) presentFlip(output string, f *ports.FlipInfo) { | |||
| 130 | 130 | case !w.fb.Resource.Alive(): | |
| 131 | 131 | case w.surf.destroyed: | |
| 132 | 132 | discard(w.fb) | |
| 133 | + | case s.invisible(w.surf): | |
| 134 | + | discard(w.fb) | |
| 133 | 135 | case s.frameOutput(w.surf) != output || !ok || shown < w.seq: | |
| 134 | 136 | kept = append(kept, w) | |
| 135 | 137 | case f.Merged > 0: | |
| @@ -188,7 +190,7 @@ func (s *Server) dropFeedbacks(now time.Time) { | |||
| 188 | 190 | if !w.fb.Resource.Alive() { | |
| 189 | 191 | 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 | 194 | discard(w.fb) | |
| 193 | 195 | continue | |
| 194 | 196 | } | |
| @@ -94,7 +94,7 @@ func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedb | |||||
| 94 | return | 94 | 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 | continue | 191 | 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 | continue | 195 | continue | ||
| 194 | } | 196 | } | ||
M internal/adapters/wayland/presentation_test.go +11 -1
| @@ -69,7 +69,7 @@ func TestPresentationFeedback(t *testing.T) { | |||
| 69 | 69 | c := protocolClient(t, s, dir) | |
| 70 | 70 | w, surf, xdg := surfaceMapper(t, c, events)() | |
| 71 | 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 | 73 | for deadline := time.Now().Add(2 * time.Second); ; { | |
| 74 | 74 | var out string | |
| 75 | 75 | s.display.Do(func() { out = s.frameOutput(s.windows[w.ID].xdg.surface) }) | |
| @@ -158,6 +158,16 @@ func TestPresentationFeedback(t *testing.T) { | |||
| 158 | 158 | flip := func(when time.Duration, seq uint64, shows uint64, zero ports.WindowID) { | |
| 159 | 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 | 171 | // Presented with the flip's time, counter and flags. | |
| 162 | 172 | a := feedback() | |
| 163 | 173 | 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 string | 74 | 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 { | |||
| 34 | 34 | // releaseBuffer releases a replaced buffer, or holds it until the output | |
| 35 | 35 | // showing its window no longer reads it. | |
| 36 | 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 | 39 | window, name := surf.root().windowID(), s.frameOutput(surf) | |
| 38 | 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 | 42 | s.release(b, sync) | |
| 41 | 43 | return | |
| 42 | 44 | } | |
| @@ -73,8 +75,8 @@ func (s *Server) releaseBufferSync(surf *surface, sync syncHold) { | |||
| 73 | 75 | if !sync.release.set() && sync.acquire == nil { | |
| 74 | 76 | return | |
| 75 | 77 | } | |
| 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 == "" { | |
| 78 | 80 | s.releaseSync(sync) | |
| 79 | 81 | return | |
| 80 | 82 | } | |
| @@ -34,9 +34,11 @@ type heldBuffer struct { | |||||
| 34 | // releaseBuffer releases a replaced buffer, or holds it until the output | 34 | // 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 | return | 43 | 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 | return | 76 | 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 | return | 81 | return | ||
| 80 | } | 82 | } | ||
M internal/adapters/wayland/release_test.go +63 -1
| @@ -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 | 149 | // A later queued use of the same wl_buffer still owns it after the output | |
| 88 | 150 | // acknowledges the intervening commit. | |
| 89 | 151 | func TestHeldBufferRequeuedBeforeOutputAck(t *testing.T) { | |
| 90 | 152 | s, events, commands, contents, dir := contentServer(t) | |
| 91 | 153 | c := protocolClient(t, s, dir) | |
| 92 | 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 | 156 | if err := c.Roundtrip(); err != nil { | |
| 95 | 157 | t.Fatal(err) | |
| 96 | 158 | } | |
| @@ -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 output | 149 | // 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 { | |||
| 68 | 68 | channels Channels | |
| 69 | 69 | // awaiting holds frame callbacks by output name, due at its next | |
| 70 | 70 | // 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{} | |
| 74 | 75 | // Pacer scratch: periods is used on the display goroutine, while | |
| 75 | 76 | // flipped and frameReports belong to pace and its synchronous Do call. | |
| 76 | 77 | framePeriods map[string]time.Duration | |
| @@ -691,8 +692,14 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 691 | 692 | return | |
| 692 | 693 | } | |
| 693 | 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.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 | |
| 695 | 697 | 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 | 703 | w.sendConfigure() | |
| 697 | 704 | s.toplevelChanged(w) | |
| 698 | 705 | if scanoutChanged && w.xdg.surface != nil { | |
| @@ -68,9 +68,10 @@ type Server struct { | |||||
| 68 | channels Channels | 68 | channels Channels | ||
| 69 | // awaiting holds frame callbacks by output name, due at its next | 69 | // 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.Callback | 71 | + | awaiting map[string][]*wayland.Callback |
| 72 | - | frameDue map[string]time.Time | 72 | + | 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, while | 75 | // 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.Duration | 77 | framePeriods map[string]time.Duration | ||
| @@ -691,8 +692,14 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 691 | return | 692 | 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.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 | |||
| 695 | w.last, w.hasLast = c, true | 697 | 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) { | |||
| 379 | 379 | s.inputAll, s.inputRects = u.inputAll, u.inputRects | |
| 380 | 380 | } | |
| 381 | 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 | 384 | if s.role != nil { | |
| 385 | 385 | 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.inputRects | 379 | 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 { | |||
| 121 | 121 | w, surf, xdg := surfaceMapper(t, h.c, events)() | |
| 122 | 122 | registerProtocol(t, h.c, xdg) | |
| 123 | 123 | 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 | 125 | mgr := bindProtocol(t, h.c, "wp_linux_drm_syncobj_manager_v1") | |
| 126 | 126 | h.syncSurf = h.c.AllocateID() | |
| 127 | 127 | 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, surf | 123 | 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 { | |||
| 166 | 166 | if c.Activated { | |
| 167 | 167 | add(xdgshell.ToplevelStateActivated) | |
| 168 | 168 | } | |
| 169 | + | if !c.Visible && version >= 6 { | |
| 170 | + | add(xdgshell.ToplevelStateSuspended) | |
| 171 | + | } | |
| 169 | 172 | if c.Fullscreen || c.Floating { | |
| 170 | 173 | return states | |
| 171 | 174 | } | |
| @@ -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 states | 173 | return states | ||
| 171 | } | 174 | } | ||
M internal/adapters/wayland/xdg_states_test.go +7 -4
| @@ -17,10 +17,13 @@ func TestToplevelStates(t *testing.T) { | |||
| 17 | 17 | version int32 | |
| 18 | 18 | want []xdgshell.ToplevelState | |
| 19 | 19 | }{ | |
| 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}, | |
| 24 | 27 | } | |
| 25 | 28 | for _, tt := range tests { | |
| 26 | 29 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -17,10 +17,13 @@ func TestToplevelStates(t *testing.T) { | |||||
| 17 | version int32 | 17 | version int32 | ||
| 18 | want []xdgshell.ToplevelState | 18 | 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 ( | |||
| 10 | 10 | type Action string | |
| 11 | 11 | ||
| 12 | 12 | 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" | |
| 25 | 27 | // Workspaces stack vertically; up/down stop at the ends. | |
| 26 | 28 | ActionFocusWorkspaceUp Action = "focus-workspace-up" | |
| 27 | 29 | ActionFocusWorkspaceDown Action = "focus-workspace-down" | |
| @@ -238,6 +240,12 @@ func (m *Monitor) Apply(a Action) Effect { | |||
| 238 | 240 | case ActionToggleFullscreen: | |
| 239 | 241 | m.ToggleFullscreen() | |
| 240 | 242 | return Effect{} | |
| 243 | + | case ActionToggleWindowFloating: | |
| 244 | + | m.Current().ToggleWindowFloating() | |
| 245 | + | return Effect{} | |
| 246 | + | case ActionToggleFloatingVisible: | |
| 247 | + | m.Current().ToggleFloatingVisible() | |
| 248 | + | return Effect{} | |
| 241 | 249 | case ActionMaximizeColumn: | |
| 242 | 250 | // A client that made itself fullscreen (Wine at monitor size) goes | |
| 243 | 251 | // back to its column width; the next press maximizes the column. | |
| @@ -296,6 +304,10 @@ func (w *Workspace) Apply(a Action) Effect { | |||
| 296 | 304 | // In place only: binds go through Monitor.Apply, which gives a | |
| 297 | 305 | // fixed-overflow fullscreen its own workspace. | |
| 298 | 306 | w.ToggleFullscreen() | |
| 307 | + | case ActionToggleWindowFloating: | |
| 308 | + | w.ToggleWindowFloating() | |
| 309 | + | case ActionToggleFloatingVisible: | |
| 310 | + | w.ToggleFloatingVisible() | |
| 299 | 311 | case ActionCloseWindow: | |
| 300 | 312 | id, _ := w.Focused() | |
| 301 | 313 | return Effect{Close: id} | |
| @@ -10,18 +10,20 @@ import ( | |||||
| 10 | type Action string | 10 | 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) goes | 250 | // 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 a | 304 | // 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 { | |||
| 189 | 189 | continue | |
| 190 | 190 | } | |
| 191 | 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 | 193 | default: | |
| 194 | 194 | return fmt.Errorf("invalid action %q", a) | |
| 195 | 195 | } | |
| @@ -317,6 +317,30 @@ func (c *Core) clientRect(p Placement) Rect { | |||
| 317 | 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 | 344 | // visible reports whether the window, layer surface or popup is on screen | |
| 321 | 345 | // on any output. | |
| 322 | 346 | func (c *Core) visible(id WindowID) bool { | |
| @@ -420,6 +444,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 420 | 444 | o := sc.mon.Output() | |
| 421 | 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 | 446 | layout := sc.mon.Layout() | |
| 447 | + | scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim) | |
| 423 | 448 | // Only the focused output lights the focused window's lines. | |
| 424 | 449 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen) | |
| 425 | 450 | for _, p := range layout { | |
| @@ -429,19 +454,28 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 429 | 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 | 455 | floating := p.Floating | |
| 431 | 456 | 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 | |
| 438 | 468 | } | |
| 469 | + | if err := c.command(ctx, v); err != nil { | |
| 470 | + | return err | |
| 471 | + | } | |
| 472 | + | c.sent[p.ID] = v | |
| 439 | 473 | continue | |
| 440 | 474 | } | |
| 441 | 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()} | |
| 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. | |
| 445 | 479 | v.Width, v.Height = 0, 0 | |
| 446 | 480 | } | |
| 447 | 481 | if old, ok := c.sent[p.ID]; !ok || old != v { | |
| @@ -189,7 +189,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 189 | continue | 189 | 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 screen | 344 | // 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.Floating | 455 | 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 err | 460 | + | old, ok := c.sent[p.ID] |
| 436 | - | } | 461 | + | v := old |
| 437 | - | c.sent[p.ID] = 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 | |||
| 438 | } | 468 | } | ||
| 469 | + | if err := c.command(ctx, v); err != nil { | |||
| 470 | + | return err | |||
| 471 | + | } | |||
| 472 | + | c.sent[p.ID] = v | |||
| 439 | continue | 473 | 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, 0 | 479 | 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) { | |||
| 73 | 73 | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 { | |
| 74 | 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 | 77 | t.Fatal(v) | |
| 78 | 78 | } | |
| 79 | 79 | command(t, commands) | |
| @@ -117,7 +117,7 @@ func TestOwner(t *testing.T) { | |||
| 117 | 117 | if s.Windows[0].Rect.W != 92 { | |
| 118 | 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 | 121 | t.Fatal(v) | |
| 122 | 122 | } | |
| 123 | 123 | cfg.Layout.MaxColumns = 0 | |
| @@ -299,7 +299,7 @@ func TestBorderInset(t *testing.T) { | |||
| 299 | 299 | // Alone, the window is borderless and gets the full rect. | |
| 300 | 300 | client <- ports.WindowMapped{ID: 1} | |
| 301 | 301 | 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 | 303 | t.Fatalf("%v for outer %v", v, r) | |
| 304 | 304 | } | |
| 305 | 305 | // With a second column, the left window owns the shared separator: its | |
| @@ -310,7 +310,7 @@ func TestBorderInset(t *testing.T) { | |||
| 310 | 310 | if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 { | |
| 311 | 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 | 314 | found := false | |
| 315 | 315 | for len(commands) > 0 { | |
| 316 | 316 | if v := <-commands; v == want { | |
| @@ -538,7 +538,7 @@ func TestOutputScale(t *testing.T) { | |||
| 538 | 538 | } | |
| 539 | 539 | client <- ports.WindowMapped{ID: 1} | |
| 540 | 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 | 542 | t.Fatal(v) | |
| 543 | 543 | } | |
| 544 | 544 | 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 = 0 | 123 | 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].Rect | 301 | 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: its | 305 | // 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 := false | 314 | 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 { | |||
| 136 | 136 | fullscreen WindowID | |
| 137 | 137 | // Floats are floating windows, bottom to top; floatFocus is set while | |
| 138 | 138 | // 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 | |
| 141 | 146 | // home is the monitor (key or connector) the workspace belongs to; "" | |
| 142 | 147 | // means the one it is on. On another monitor it is a guest; homePos is | |
| 143 | 148 | // its position there, where it returns. | |
| @@ -164,13 +169,17 @@ type origPlace struct { | |||
| 164 | 169 | stacked []WindowID | |
| 165 | 170 | width Width | |
| 166 | 171 | expanded bool | |
| 167 | - | float *Float | |
| 172 | + | // fullWidth is the column's maximize-column state. | |
| 173 | + | fullWidth bool | |
| 174 | + | float *Float | |
| 168 | 175 | } | |
| 169 | 176 | ||
| 170 | 177 | // Float is a floating window and its client size, logical. | |
| 171 | 178 | type Float struct { | |
| 172 | 179 | ID WindowID | |
| 173 | 180 | 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 | 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 | 205 | if id == 0 || w.has(id) { | |
| 197 | 206 | 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 | 212 | w.Floats = append(w.Floats, Float{ID: id, W: width, H: height}) | |
| 200 | 213 | w.floatFocus = true | |
| 201 | 214 | } | |
| @@ -203,7 +216,9 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) { | |||
| 203 | 216 | // ResizeFloating records the size a floating window draws. | |
| 204 | 217 | func (w *Workspace) ResizeFloating(id WindowID, width, height int) { | |
| 205 | 218 | 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 | + | } | |
| 207 | 222 | } | |
| 208 | 223 | } | |
| 209 | 224 | ||
| @@ -223,7 +238,7 @@ func (w *Workspace) has(id WindowID) bool { | |||
| 223 | 238 | } | |
| 224 | 239 | ||
| 225 | 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 | 242 | return w.Floats[len(w.Floats)-1].ID, true | |
| 228 | 243 | } | |
| 229 | 244 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | |
| @@ -317,8 +332,11 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||
| 317 | 332 | if w.fullscreen == id { | |
| 318 | 333 | w.fullscreen = 0 | |
| 319 | 334 | } | |
| 335 | + | if w.hiddenFullscreen == id { | |
| 336 | + | w.hiddenFullscreen = 0 | |
| 337 | + | } | |
| 320 | 338 | if len(w.Floats) == 0 { | |
| 321 | - | w.floatFocus = false | |
| 339 | + | w.floatFocus, w.floatsHidden, w.hiddenFocus = false, false, false | |
| 322 | 340 | } | |
| 323 | 341 | return | |
| 324 | 342 | } | |
| @@ -359,6 +377,9 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||
| 359 | 377 | // window is raised. | |
| 360 | 378 | func (w *Workspace) FocusID(id WindowID) bool { | |
| 361 | 379 | if i := w.floatIndex(id); i >= 0 { | |
| 380 | + | if w.floatsHidden { | |
| 381 | + | return false | |
| 382 | + | } | |
| 362 | 383 | f := w.Floats[i] | |
| 363 | 384 | w.Floats = append(slices.Delete(w.Floats, i, i+1), f) | |
| 364 | 385 | w.floatFocus = true | |
| @@ -601,6 +622,15 @@ func (w *Workspace) Activate(id WindowID) { | |||
| 601 | 622 | // requests are automatic events (ADR 011 golden rule). | |
| 602 | 623 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |
| 603 | 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 | 634 | if on { | |
| 605 | 635 | w.fullscreen = id | |
| 606 | 636 | } else if w.fullscreen == id { | |
| @@ -860,7 +890,12 @@ func (w *Workspace) Layout() []Placement { | |||
| 860 | 890 | setNeighbors(result, gap) | |
| 861 | 891 | // Floating windows go last: they are drawn and hit on top. | |
| 862 | 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 | 899 | // Floats stay above a fullscreen window: a dialog opened from a | |
| 865 | 900 | // fullscreen app must be seen. | |
| 866 | 901 | if w.fullscreen == f.ID { | |
| @@ -909,9 +944,93 @@ func setNeighbors(tiles []Placement, gap int) { | |||
| 909 | 944 | func (w *Workspace) floatRect(f Float) Rect { | |
| 910 | 945 | u := w.Usable | |
| 911 | 946 | 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 | 951 | fw, fh = u.W/2, u.H/2 | |
| 914 | 952 | } | |
| 915 | 953 | fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H) | |
| 916 | 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 | + | } | |
| @@ -136,8 +136,13 @@ type Workspace struct { | |||||
| 136 | fullscreen WindowID | 136 | fullscreen WindowID | ||
| 137 | // Floats are floating windows, bottom to top; floatFocus is set while | 137 | // 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 []Float | 139 | + | Floats []Float |
| 140 | - | floatFocus bool | 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 | |||
| 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 is | 147 | // 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 []WindowID | 169 | stacked []WindowID | ||
| 165 | width Width | 170 | width Width | ||
| 166 | expanded bool | 171 | expanded bool | ||
| 167 | - | float *Float | 172 | + | // 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 WindowID | 179 | ID WindowID | ||
| 173 | W, H int | 180 | 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 | return | 206 | 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 = true | 213 | 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, height | 219 | + | 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, true | 242 | 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 = 0 | 333 | 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 = false | 339 | + | w.floatFocus, w.floatsHidden, w.hiddenFocus = false, false, false |
| 322 | } | 340 | } | ||
| 323 | return | 341 | 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 = true | 385 | 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 = id | 635 | 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 a | 899 | // 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.Usable | 945 | u := w.Usable | ||
| 911 | fw, fh := f.W, f.H | 946 | 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/2 | 951 | 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) { | |||
| 284 | 284 | case w.origin != nil && id != w.back.id: | |
| 285 | 285 | // A dialog of a fullscreen workspace already floats above it; its | |
| 286 | 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 | 290 | case on && m.ownWorkspace(w, id): | |
| 288 | 291 | focused, _ := w.Focused() | |
| 289 | 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 | 339 | fs.back.float = &fl | |
| 337 | 340 | w.RemoveWindow(id) | |
| 338 | 341 | fs.AddFloating(id, fl.W, fl.H) | |
| 342 | + | fs.Floats[len(fs.Floats)-1] = fl | |
| 339 | 343 | } else { | |
| 340 | 344 | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | |
| 341 | 345 | c := w.Columns[col] | |
| @@ -380,9 +384,10 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||
| 380 | 384 | switch prev := origin.Focus; { | |
| 381 | 385 | case back.float != nil && focus: | |
| 382 | 386 | origin.AddFloating(id, back.float.W, back.float.H) | |
| 387 | + | origin.Floats[len(origin.Floats)-1] = *back.float | |
| 383 | 388 | case back.float != nil: | |
| 384 | 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 | 391 | case slices.ContainsFunc(origin.Columns, back.holdsStack): | |
| 387 | 392 | back.col = slices.IndexFunc(origin.Columns, back.holdsStack) | |
| 388 | 393 | c := &origin.Columns[back.col] | |
| @@ -502,6 +507,7 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||
| 502 | 507 | fl := cur.Floats[f] | |
| 503 | 508 | cur.RemoveWindow(id) | |
| 504 | 509 | to.AddFloating(id, fl.W, fl.H) | |
| 510 | + | to.Floats[len(to.Floats)-1] = fl | |
| 505 | 511 | to.FocusID(id) | |
| 506 | 512 | } else { | |
| 507 | 513 | col := Column{Windows: []WindowID{id}} | |
| @@ -655,6 +661,7 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||
| 655 | 661 | } | |
| 656 | 662 | for _, f := range w.Floats { | |
| 657 | 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 | 667 | 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; its | 285 | // 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 = &fl | 339 | 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 = hidden | 667 | m.hidden = hidden | ||
M internal/core/popup.go +2 -1
| @@ -144,7 +144,8 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||
| 144 | 144 | for _, sc := range c.screens { | |
| 145 | 145 | for _, pl := range sc.mon.Layout() { | |
| 146 | 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 | 149 | return nil, Rect{}, false | |
| 149 | 150 | } | |
| 150 | 151 | 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{}, false | 149 | return nil, Rect{}, false | ||
| 149 | } | 150 | } | ||
| 150 | return sc, c.clientRect(pl), true | 151 | return sc, c.clientRect(pl), true | ||
M internal/core/state.go +6 -1
| @@ -36,11 +36,16 @@ func (c *Core) state() ports.State { | |||
| 36 | 36 | o.Active = i + 1 | |
| 37 | 37 | } | |
| 38 | 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 | 44 | for _, w := range m.all() { | |
| 40 | 45 | n := indexOf(m.Workspaces, w) + 1 | |
| 41 | 46 | for _, id := range w.windows() { | |
| 42 | 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 | } | |
| @@ -36,11 +36,16 @@ func (c *Core) state() ports.State { | |||||
| 36 | o.Active = i + 1 | 36 | 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) + 1 | 45 | 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 { | |||
| 74 | 74 | // Startup are commands run once when the session starts, in order. | |
| 75 | 75 | Startup [][]string | |
| 76 | 76 | Background struct{ Color string } | |
| 77 | + | Floating struct{ Dim float64 } | |
| 77 | 78 | // Border is drawn inside the window edge; Width 0 disables it. | |
| 78 | 79 | Border struct { | |
| 79 | 80 | 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 [][]string | 75 | 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 int | 80 | Width int | ||
M internal/ports/messages.go +15 -4
| @@ -547,7 +547,13 @@ type ConfigureWindow struct { | |||
| 547 | 547 | Fullscreen, Activated bool | |
| 548 | 548 | // Floating windows are not tiled: no tiled states. | |
| 549 | 549 | 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 | |
| 551 | 557 | } | |
| 552 | 558 | ||
| 553 | 559 | func (ConfigureWindow) clientCommand() {} | |
| @@ -737,7 +743,11 @@ type Scene struct { | |||
| 737 | 743 | Off bool | |
| 738 | 744 | Background string | |
| 739 | 745 | 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 | |
| 741 | 751 | // Separators are the lines between windows, drawn in slice order with | |
| 742 | 752 | // the Border colors: tile lines over the tiles, under the floats; a | |
| 743 | 753 | // float's border right after the float. | |
| @@ -750,10 +760,11 @@ type Scene struct { | |||
| 750 | 760 | } | |
| 751 | 761 | ||
| 752 | 762 | // 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 | 765 | func (s Scene) Shows(id WindowID) bool { | |
| 755 | 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 | 768 | return true | |
| 758 | 769 | } | |
| 759 | 770 | } | |
| @@ -547,7 +547,13 @@ type ConfigureWindow struct { | |||||
| 547 | Fullscreen, Activated bool | 547 | Fullscreen, Activated bool | ||
| 548 | // Floating windows are not tiled: no tiled states. | 548 | // Floating windows are not tiled: no tiled states. | ||
| 549 | Floating bool | 549 | 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 | |||
| 551 | } | 557 | } | ||
| 552 | 558 | ||||
| 553 | func (ConfigureWindow) clientCommand() {} | 559 | func (ConfigureWindow) clientCommand() {} | ||
| @@ -737,7 +743,11 @@ type Scene struct { | |||||
| 737 | Off bool | 743 | Off bool | ||
| 738 | Background string | 744 | Background string | ||
| 739 | Border Border | 745 | 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 | |||
| 741 | // Separators are the lines between windows, drawn in slice order with | 751 | // Separators are the lines between windows, drawn in slice order with | ||
| 742 | // the Border colors: tile lines over the tiles, under the floats; a | 752 | // 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 its | 762 | // 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 true | 768 | return true | ||
| 758 | } | 769 | } | ||
| 759 | } | 770 | } | ||
M internal/ports/types.go +7 -0
| @@ -17,6 +17,13 @@ const ( | |||
| 17 | 17 | 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 | 27 | // Inset shrinks r by b on each side in s. | |
| 21 | 28 | func (r Rect) Inset(s Sides, b int) Rect { | |
| 22 | 29 | b = min(max(b, 0), r.W/2, r.H/2) | |
| @@ -17,6 +17,13 @@ const ( | |||||
| 17 | SideAll = SideLeft | SideRight | SideTop | SideBottom | 17 | 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) | ||