fix(core): keep fullscreen windows from floating or hiding
- commit
- 865e07c76636df5cb84c2187e69fd41452ce75af
- author
- brice · · 2026-09-28 13:16 UTC
- committer
- brice · · 2026-09-28 14:44 UTC
- parent
- 99fdfdf
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +37 -20
M docs/config.md +2 -2
| @@ -90,8 +90,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 90 | 90 | | `spawn-terminal` | Run `terminal` | | |
| 91 | 91 | | `close-window` | Close the focused window | | |
| 92 | 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 all floats on this workspace when a float has the focus, or show hidden ones again; a new float shows them too | | |
| 93 | + | | `toggle-window-floating` | Float a tiled window at 80% of usable area; toggle back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | |
| 94 | + | | `toggle-floating-visible` | Hide all floats on this workspace when a float has the focus and none is fullscreen, or show hidden ones again; a new float shows them too | | |
| 95 | 95 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | |
| 96 | 96 | | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time | | |
| 97 | 97 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | |
| @@ -90,8 +90,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 90 | | `spawn-terminal` | Run `terminal` | | 90 | | `spawn-terminal` | Run `terminal` | | ||
| 91 | | `close-window` | Close the focused window | | 91 | | `close-window` | Close the focused window | | ||
| 92 | | `toggle-fullscreen` | Fullscreen the focused window | | 92 | | `toggle-fullscreen` | Fullscreen the focused window | | ||
| 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 | | 93 | + | | `toggle-window-floating` | Float a tiled window at 80% of usable area; toggle back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | |
| 94 | - | | `toggle-floating-visible` | Hide all floats on this workspace when a float has the focus, or show hidden ones again; a new float shows them too | | 94 | + | | `toggle-floating-visible` | Hide all floats on this workspace when a float has the focus and none is fullscreen, or show hidden ones again; a new float shows them too | |
| 95 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | 95 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | ||
| 96 | | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time | | 96 | | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time | | ||
| 97 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | 97 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | ||
M internal/core/floating_test.go +24 -8
| @@ -76,6 +76,22 @@ func TestToggleFloatingVisible(t *testing.T) { | |||
| 76 | 76 | } | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 79 | + | // A fullscreen window does not float until it leaves fullscreen. | |
| 80 | + | func TestToggleWindowFloatingIgnoresFullscreen(t *testing.T) { | |
| 81 | + | w := workspace() | |
| 82 | + | w.AddWindow(1) | |
| 83 | + | w.SetFullscreen(1, true) | |
| 84 | + | w.Apply(ActionToggleWindowFloating) | |
| 85 | + | if len(w.Floats) != 0 || w.fullscreen != 1 { | |
| 86 | + | t.Fatalf("fullscreen window floated: %+v", w) | |
| 87 | + | } | |
| 88 | + | w.SetFullscreen(1, false) | |
| 89 | + | w.Apply(ActionToggleWindowFloating) | |
| 90 | + | if len(w.Floats) != 1 { | |
| 91 | + | t.Fatal("window did not float after leaving fullscreen") | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 79 | 95 | // A lone maximized column comes back maximized. | |
| 80 | 96 | func TestToggleWindowFloatingKeepsFullWidth(t *testing.T) { | |
| 81 | 97 | w := workspace() | |
| @@ -128,8 +144,7 @@ func TestHiddenFloatFullscreenRequest(t *testing.T) { | |||
| 128 | 144 | } | |
| 129 | 145 | } | |
| 130 | 146 | ||
| 131 | - | // Only a focused float hides the floats; a hidden fullscreen float no | |
| 132 | - | // longer hides the columns. | |
| 147 | + | // Only a focused float hides the floats, and never over a fullscreen float. | |
| 133 | 148 | func TestToggleFloatingVisibleFocusAndFullscreen(t *testing.T) { | |
| 134 | 149 | w := workspace() | |
| 135 | 150 | w.Overflow = OverflowFixed | |
| @@ -148,17 +163,18 @@ func TestToggleFloatingVisibleFocusAndFullscreen(t *testing.T) { | |||
| 148 | 163 | if id, _ := w.Focused(); id != 2 || w.floatsHidden { | |
| 149 | 164 | t.Fatalf("show from column: focused %d hidden %v", id, w.floatsHidden) | |
| 150 | 165 | } | |
| 166 | + | // A fullscreen float (a game in scanout) stays shown: hiding it would | |
| 167 | + | // drop scanout and VRR. | |
| 151 | 168 | w.FocusID(2) | |
| 152 | 169 | w.SetFullscreen(2, true) | |
| 153 | 170 | w.Apply(ActionToggleFloatingVisible) | |
| 154 | - | for _, p := range w.Layout() { | |
| 155 | - | if p.ID == 1 && (p.Hidden || p.Rect.W == 0) { | |
| 156 | - | t.Fatalf("column hidden under hidden fullscreen float: %+v", p) | |
| 157 | - | } | |
| 171 | + | if id, _ := w.Focused(); id != 2 || w.fullscreen != 2 || w.floatsHidden { | |
| 172 | + | t.Fatalf("fullscreen float hidden: focused %d fullscreen %d", id, w.fullscreen) | |
| 158 | 173 | } | |
| 174 | + | w.FocusID(1) | |
| 159 | 175 | w.Apply(ActionToggleFloatingVisible) | |
| 160 | - | if id, _ := w.Focused(); id != 2 || w.fullscreen != 2 { | |
| 161 | - | t.Fatalf("restored focus %d fullscreen %d", id, w.fullscreen) | |
| 176 | + | if w.fullscreen != 2 || w.floatsHidden { | |
| 177 | + | t.Fatalf("fullscreen float hidden from column: fullscreen %d", w.fullscreen) | |
| 162 | 178 | } | |
| 163 | 179 | } | |
| 164 | 180 | ||
| @@ -76,6 +76,22 @@ func TestToggleFloatingVisible(t *testing.T) { | |||||
| 76 | } | 76 | } | ||
| 77 | } | 77 | } | ||
| 78 | 78 | ||||
| 79 | + | // A fullscreen window does not float until it leaves fullscreen. | |||
| 80 | + | func TestToggleWindowFloatingIgnoresFullscreen(t *testing.T) { | |||
| 81 | + | w := workspace() | |||
| 82 | + | w.AddWindow(1) | |||
| 83 | + | w.SetFullscreen(1, true) | |||
| 84 | + | w.Apply(ActionToggleWindowFloating) | |||
| 85 | + | if len(w.Floats) != 0 || w.fullscreen != 1 { | |||
| 86 | + | t.Fatalf("fullscreen window floated: %+v", w) | |||
| 87 | + | } | |||
| 88 | + | w.SetFullscreen(1, false) | |||
| 89 | + | w.Apply(ActionToggleWindowFloating) | |||
| 90 | + | if len(w.Floats) != 1 { | |||
| 91 | + | t.Fatal("window did not float after leaving fullscreen") | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 79 | // A lone maximized column comes back maximized. | 95 | // A lone maximized column comes back maximized. | ||
| 80 | func TestToggleWindowFloatingKeepsFullWidth(t *testing.T) { | 96 | func TestToggleWindowFloatingKeepsFullWidth(t *testing.T) { | ||
| 81 | w := workspace() | 97 | w := workspace() | ||
| @@ -128,8 +144,7 @@ func TestHiddenFloatFullscreenRequest(t *testing.T) { | |||||
| 128 | } | 144 | } | ||
| 129 | } | 145 | } | ||
| 130 | 146 | ||||
| 131 | - | // Only a focused float hides the floats; a hidden fullscreen float no | 147 | + | // Only a focused float hides the floats, and never over a fullscreen float. |
| 132 | - | // longer hides the columns. | |||
| 133 | func TestToggleFloatingVisibleFocusAndFullscreen(t *testing.T) { | 148 | func TestToggleFloatingVisibleFocusAndFullscreen(t *testing.T) { | ||
| 134 | w := workspace() | 149 | w := workspace() | ||
| 135 | w.Overflow = OverflowFixed | 150 | w.Overflow = OverflowFixed | ||
| @@ -148,17 +163,18 @@ func TestToggleFloatingVisibleFocusAndFullscreen(t *testing.T) { | |||||
| 148 | if id, _ := w.Focused(); id != 2 || w.floatsHidden { | 163 | if id, _ := w.Focused(); id != 2 || w.floatsHidden { | ||
| 149 | t.Fatalf("show from column: focused %d hidden %v", id, w.floatsHidden) | 164 | t.Fatalf("show from column: focused %d hidden %v", id, w.floatsHidden) | ||
| 150 | } | 165 | } | ||
| 166 | + | // A fullscreen float (a game in scanout) stays shown: hiding it would | |||
| 167 | + | // drop scanout and VRR. | |||
| 151 | w.FocusID(2) | 168 | w.FocusID(2) | ||
| 152 | w.SetFullscreen(2, true) | 169 | w.SetFullscreen(2, true) | ||
| 153 | w.Apply(ActionToggleFloatingVisible) | 170 | w.Apply(ActionToggleFloatingVisible) | ||
| 154 | - | for _, p := range w.Layout() { | 171 | + | if id, _ := w.Focused(); id != 2 || w.fullscreen != 2 || w.floatsHidden { |
| 155 | - | if p.ID == 1 && (p.Hidden || p.Rect.W == 0) { | 172 | + | t.Fatalf("fullscreen float hidden: focused %d fullscreen %d", id, w.fullscreen) |
| 156 | - | t.Fatalf("column hidden under hidden fullscreen float: %+v", p) | |||
| 157 | - | } | |||
| 158 | } | 173 | } | ||
| 174 | + | w.FocusID(1) | |||
| 159 | w.Apply(ActionToggleFloatingVisible) | 175 | w.Apply(ActionToggleFloatingVisible) | ||
| 160 | - | if id, _ := w.Focused(); id != 2 || w.fullscreen != 2 { | 176 | + | if w.fullscreen != 2 || w.floatsHidden { |
| 161 | - | t.Fatalf("restored focus %d fullscreen %d", id, w.fullscreen) | 177 | + | t.Fatalf("fullscreen float hidden from column: fullscreen %d", w.fullscreen) |
| 162 | } | 178 | } | ||
| 163 | } | 179 | } | ||
| 164 | 180 | ||||
M internal/core/layout.go +11 -10
| @@ -963,7 +963,9 @@ func (w *Workspace) imposedFloat(id WindowID) bool { | |||
| 963 | 963 | // column and row. Native floats instead become new columns. | |
| 964 | 964 | func (w *Workspace) ToggleWindowFloating() { | |
| 965 | 965 | id, ok := w.Focused() | |
| 966 | - | if !ok { | |
| 966 | + | // A fullscreen window (a Wine game in scanout) keeps its place: it | |
| 967 | + | // leaves fullscreen first, then may float. | |
| 968 | + | if !ok || id == w.fullscreen { | |
| 967 | 969 | return | |
| 968 | 970 | } | |
| 969 | 971 | if i := w.floatIndex(id); i >= 0 { | |
| @@ -1013,21 +1015,20 @@ func (w *Workspace) ToggleWindowFloating() { | |||
| 1013 | 1015 | } | |
| 1014 | 1016 | ||
| 1015 | 1017 | // ToggleFloatingVisible hides every float without changing its placement | |
| 1016 | - | // when a float has the focus, and shows them again from any window. | |
| 1018 | + | // when a float has the focus, and shows them again from any window. A | |
| 1019 | + | // fullscreen float (a game in scanout) is never hidden: hiding it drops | |
| 1020 | + | // scanout and VRR. | |
| 1017 | 1021 | func (w *Workspace) ToggleFloatingVisible() { | |
| 1018 | - | if len(w.Floats) == 0 || !w.floatsHidden && !w.floatFocus { | |
| 1022 | + | if len(w.Floats) == 0 { | |
| 1019 | 1023 | return | |
| 1020 | 1024 | } | |
| 1021 | - | w.floatsHidden = !w.floatsHidden | |
| 1022 | - | if w.floatsHidden { | |
| 1023 | - | // A hidden fullscreen float would still hide the columns. | |
| 1024 | - | w.floatFocus = false | |
| 1025 | - | w.hiddenFullscreen = 0 | |
| 1026 | - | if w.floatIndex(w.fullscreen) >= 0 { | |
| 1027 | - | w.hiddenFullscreen, w.fullscreen = w.fullscreen, 0 | |
| 1025 | + | if !w.floatsHidden { | |
| 1026 | + | if w.floatFocus && w.floatIndex(w.fullscreen) < 0 { | |
| 1027 | + | w.floatsHidden, w.floatFocus, w.hiddenFullscreen = true, false, 0 | |
| 1028 | 1028 | } | |
| 1029 | 1029 | return | |
| 1030 | 1030 | } | |
| 1031 | + | w.floatsHidden = false | |
| 1031 | 1032 | w.floatFocus = true | |
| 1032 | 1033 | if w.fullscreen == 0 && w.floatIndex(w.hiddenFullscreen) >= 0 { | |
| 1033 | 1034 | w.fullscreen = w.hiddenFullscreen | |
| @@ -963,7 +963,9 @@ func (w *Workspace) imposedFloat(id WindowID) bool { | |||||
| 963 | // column and row. Native floats instead become new columns. | 963 | // column and row. Native floats instead become new columns. | ||
| 964 | func (w *Workspace) ToggleWindowFloating() { | 964 | func (w *Workspace) ToggleWindowFloating() { | ||
| 965 | id, ok := w.Focused() | 965 | id, ok := w.Focused() | ||
| 966 | - | if !ok { | 966 | + | // A fullscreen window (a Wine game in scanout) keeps its place: it |
| 967 | + | // leaves fullscreen first, then may float. | |||
| 968 | + | if !ok || id == w.fullscreen { | |||
| 967 | return | 969 | return | ||
| 968 | } | 970 | } | ||
| 969 | if i := w.floatIndex(id); i >= 0 { | 971 | if i := w.floatIndex(id); i >= 0 { | ||
| @@ -1013,21 +1015,20 @@ func (w *Workspace) ToggleWindowFloating() { | |||||
| 1013 | } | 1015 | } | ||
| 1014 | 1016 | ||||
| 1015 | // ToggleFloatingVisible hides every float without changing its placement | 1017 | // ToggleFloatingVisible hides every float without changing its placement | ||
| 1016 | - | // when a float has the focus, and shows them again from any window. | 1018 | + | // when a float has the focus, and shows them again from any window. A |
| 1019 | + | // fullscreen float (a game in scanout) is never hidden: hiding it drops | |||
| 1020 | + | // scanout and VRR. | |||
| 1017 | func (w *Workspace) ToggleFloatingVisible() { | 1021 | func (w *Workspace) ToggleFloatingVisible() { | ||
| 1018 | - | if len(w.Floats) == 0 || !w.floatsHidden && !w.floatFocus { | 1022 | + | if len(w.Floats) == 0 { |
| 1019 | return | 1023 | return | ||
| 1020 | } | 1024 | } | ||
| 1021 | - | w.floatsHidden = !w.floatsHidden | 1025 | + | if !w.floatsHidden { |
| 1022 | - | if w.floatsHidden { | 1026 | + | if w.floatFocus && w.floatIndex(w.fullscreen) < 0 { |
| 1023 | - | // A hidden fullscreen float would still hide the columns. | 1027 | + | w.floatsHidden, w.floatFocus, w.hiddenFullscreen = true, false, 0 |
| 1024 | - | w.floatFocus = false | |||
| 1025 | - | w.hiddenFullscreen = 0 | |||
| 1026 | - | if w.floatIndex(w.fullscreen) >= 0 { | |||
| 1027 | - | w.hiddenFullscreen, w.fullscreen = w.fullscreen, 0 | |||
| 1028 | } | 1028 | } | ||
| 1029 | return | 1029 | return | ||
| 1030 | } | 1030 | } | ||
| 1031 | + | w.floatsHidden = false | |||
| 1031 | w.floatFocus = true | 1032 | w.floatFocus = true | ||
| 1032 | if w.fullscreen == 0 && w.floatIndex(w.hiddenFullscreen) >= 0 { | 1033 | if w.fullscreen == 0 && w.floatIndex(w.hiddenFullscreen) >= 0 { | ||
| 1033 | w.fullscreen = w.hiddenFullscreen | 1034 | w.fullscreen = w.hiddenFullscreen | ||