fix(layout): keep the fullscreen workspace link only while it holds its window alone
- commit
- 3fea6c2217b99201f0953262b0ab0c7a3f020e49
- author
- brice · · 2026-09-25 20:10 UTC
- parent
- 4b313c6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +190 -41
M internal/core/action.go +2 -0
| @@ -222,6 +222,8 @@ func (w *Workspace) Apply(a Action) Effect { | |||
| 222 | 222 | case ActionCycleColumnWidth: | |
| 223 | 223 | w.CycleWidth() | |
| 224 | 224 | case ActionToggleFullscreen: | |
| 225 | + | // In place only: binds go through Monitor.Apply, which gives a | |
| 226 | + | // fixed-overflow fullscreen its own workspace. | |
| 225 | 227 | w.ToggleFullscreen() | |
| 226 | 228 | case ActionCloseWindow: | |
| 227 | 229 | id, _ := w.Focused() | |
| @@ -222,6 +222,8 @@ func (w *Workspace) Apply(a Action) Effect { | |||||
| 222 | case ActionCycleColumnWidth: | 222 | case ActionCycleColumnWidth: | ||
| 223 | w.CycleWidth() | 223 | w.CycleWidth() | ||
| 224 | case ActionToggleFullscreen: | 224 | case ActionToggleFullscreen: | ||
| 225 | + | // In place only: binds go through Monitor.Apply, which gives a | |||
| 226 | + | // fixed-overflow fullscreen its own workspace. | |||
| 225 | w.ToggleFullscreen() | 227 | w.ToggleFullscreen() | ||
| 226 | case ActionCloseWindow: | 228 | case ActionCloseWindow: | ||
| 227 | id, _ := w.Focused() | 229 | id, _ := w.Focused() | ||
M internal/core/layout.go +12 -3
| @@ -137,9 +137,18 @@ type Workspace struct { | |||
| 137 | 137 | // termAt is when core last spawned a terminal for this workspace. | |
| 138 | 138 | termAt time.Time | |
| 139 | 139 | // origin is set on the workspace a fixed-overflow fullscreen window | |
| 140 | - | // moved to; the window returns to column originCol there. | |
| 141 | - | origin *Workspace | |
| 142 | - | originCol int | |
| 140 | + | // moved to; back is where it returns there (Monitor.fullscreenHome). | |
| 141 | + | origin *Workspace | |
| 142 | + | back origPlace | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // origPlace remembers where a window was: its column, its row in a stacked | |
| 146 | + | // column, the column's width and slot, or its floating size. | |
| 147 | + | type origPlace struct { | |
| 148 | + | col, row, slot int | |
| 149 | + | stacked bool | |
| 150 | + | width Width | |
| 151 | + | float *Float | |
| 143 | 152 | } | |
| 144 | 153 | ||
| 145 | 154 | // Float is a floating window and its client size, logical. | |
| @@ -137,9 +137,18 @@ type Workspace struct { | |||||
| 137 | // termAt is when core last spawned a terminal for this workspace. | 137 | // termAt is when core last spawned a terminal for this workspace. | ||
| 138 | termAt time.Time | 138 | termAt time.Time | ||
| 139 | // origin is set on the workspace a fixed-overflow fullscreen window | 139 | // origin is set on the workspace a fixed-overflow fullscreen window | ||
| 140 | - | // moved to; the window returns to column originCol there. | 140 | + | // moved to; back is where it returns there (Monitor.fullscreenHome). |
| 141 | - | origin *Workspace | 141 | + | origin *Workspace |
| 142 | - | originCol int | 142 | + | back origPlace |
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // origPlace remembers where a window was: its column, its row in a stacked | |||
| 146 | + | // column, the column's width and slot, or its floating size. | |||
| 147 | + | type origPlace struct { | |||
| 148 | + | col, row, slot int | |||
| 149 | + | stacked bool | |||
| 150 | + | width Width | |||
| 151 | + | float *Float | |||
| 143 | } | 152 | } | ||
| 144 | 153 | ||||
| 145 | // Float is a floating window and its client size, logical. | 154 | // Float is a floating window and its client size, logical. | ||
M internal/core/monitor.go +94 -38
| @@ -117,7 +117,7 @@ func (m *Monitor) Current() *Workspace { | |||
| 117 | 117 | func (m *Monitor) newWorkspace() *Workspace { | |
| 118 | 118 | w := m.template | |
| 119 | 119 | w.presets = append([]Width(nil), m.template.presets...) | |
| 120 | - | w.Columns, w.Floats, w.floatFocus, w.home, w.origin = nil, nil, false, "", nil | |
| 120 | + | w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{} | |
| 121 | 121 | return &w | |
| 122 | 122 | } | |
| 123 | 123 | ||
| @@ -125,6 +125,7 @@ func (m *Monitor) newWorkspace() *Workspace { | |||
| 125 | 125 | // unnamed ones that are neither the first nor active. The active workspace | |
| 126 | 126 | // never changes. | |
| 127 | 127 | func (m *Monitor) normalize() { | |
| 128 | + | m.fullscreenHome() | |
| 128 | 129 | spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" } | |
| 129 | 130 | for i := len(m.Workspaces) - 2; i >= 1; i-- { | |
| 130 | 131 | if spare(i) && i != m.Active { | |
| @@ -245,26 +246,31 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) { | |||
| 245 | 246 | func (m *Monitor) RemoveWindow(id WindowID) { | |
| 246 | 247 | if w, _ := m.find(id); w != nil { | |
| 247 | 248 | w.RemoveWindow(id) | |
| 248 | - | if w.origin != nil && w.empty() { | |
| 249 | - | m.leaveFullscreen(w) | |
| 249 | + | if w.origin != nil && w.empty() && m.has(w.origin) { | |
| 250 | + | shown := m.Current() == w | |
| 251 | + | origin := w.origin | |
| 252 | + | m.take(w) | |
| 253 | + | if shown { | |
| 254 | + | m.show(origin) | |
| 255 | + | } | |
| 250 | 256 | } | |
| 251 | 257 | m.normalize() | |
| 252 | 258 | } | |
| 253 | 259 | } | |
| 254 | 260 | ||
| 255 | 261 | // SetFullscreen applies a client request. Under fixed overflow the window | |
| 256 | - | // gets its own workspace, as with the bind; the view follows only when the | |
| 257 | - | // window is the focused one on screen, so a request never takes the user | |
| 258 | - | // away from what they look at. | |
| 262 | + | // gets its own workspace, as with the bind. A client request never moves | |
| 263 | + | // the user (ADR 011): the view follows only a window that is focused and | |
| 264 | + | // on screen, and a window coming back does not take the focus. | |
| 259 | 265 | func (m *Monitor) SetFullscreen(id WindowID, on bool) { | |
| 260 | 266 | w, _ := m.find(id) | |
| 261 | 267 | switch { | |
| 262 | 268 | case w == nil: | |
| 263 | - | case on && w.origin == nil && w.fullscreen != id && m.ownWorkspace(w, id): | |
| 269 | + | case on && m.ownWorkspace(w, id): | |
| 264 | 270 | focused, _ := w.Focused() | |
| 265 | 271 | m.enterFullscreen(w, id, w == m.Current() && focused == id) | |
| 266 | 272 | case !on && w.origin != nil && w.fullscreen == id: | |
| 267 | - | m.leaveFullscreen(w) | |
| 273 | + | m.leaveFullscreen(w, false) | |
| 268 | 274 | default: | |
| 269 | 275 | w.SetFullscreen(id, on) | |
| 270 | 276 | } | |
| @@ -280,8 +286,8 @@ func (m *Monitor) ToggleFullscreen() { | |||
| 280 | 286 | switch { | |
| 281 | 287 | case !ok: | |
| 282 | 288 | case w.origin != nil && w.fullscreen == id: | |
| 283 | - | m.leaveFullscreen(w) | |
| 284 | - | case w.fullscreen != id && m.ownWorkspace(w, id): | |
| 289 | + | m.leaveFullscreen(w, true) | |
| 290 | + | case m.ownWorkspace(w, id): | |
| 285 | 291 | m.enterFullscreen(w, id, true) | |
| 286 | 292 | default: | |
| 287 | 293 | w.ToggleFullscreen() | |
| @@ -290,52 +296,86 @@ func (m *Monitor) ToggleFullscreen() { | |||
| 290 | 296 | } | |
| 291 | 297 | ||
| 292 | 298 | // ownWorkspace reports whether going fullscreen moves the window to its own | |
| 293 | - | // workspace: fixed overflow, a tiled window, and others to leave behind. | |
| 299 | + | // workspace: fixed overflow hides every other window behind a fullscreen | |
| 300 | + | // one and never scrolls, so it would leave the user stuck. Not needed when | |
| 301 | + | // the window is alone, or already has its own workspace. | |
| 294 | 302 | func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool { | |
| 295 | - | return w.Overflow == OverflowFixed && w.floatIndex(id) < 0 && len(w.windows()) > 1 && indexOf(m.Workspaces, w) >= 0 | |
| 303 | + | return w.Overflow == OverflowFixed && w.origin == nil && w.fullscreen != id && len(w.windows()) > 1 && m.has(w) | |
| 296 | 304 | } | |
| 297 | 305 | ||
| 298 | - | // enterFullscreen moves the tiled window id from w to a new workspace just | |
| 299 | - | // below it, fullscreen, and shows it when show is set. | |
| 306 | + | // enterFullscreen moves window id from w to a new workspace just below the | |
| 307 | + | // numbered one on screen, fullscreen, and shows it when show is set. It | |
| 308 | + | // remembers the window's place in w to return there. | |
| 300 | 309 | func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | |
| 301 | - | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | |
| 302 | - | width := w.Columns[col].Width | |
| 303 | - | w.RemoveWindow(id) | |
| 304 | 310 | fs := m.newWorkspace() | |
| 305 | 311 | fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns | |
| 306 | - | fs.origin, fs.originCol = w, col | |
| 307 | - | fs.AddWindow(id) | |
| 308 | - | fs.Columns[0].Width = width | |
| 312 | + | fs.origin = w | |
| 313 | + | if f := w.floatIndex(id); f >= 0 { | |
| 314 | + | fl := w.Floats[f] | |
| 315 | + | fs.back.float = &fl | |
| 316 | + | w.RemoveWindow(id) | |
| 317 | + | fs.AddFloating(id, fl.W, fl.H) | |
| 318 | + | } else { | |
| 319 | + | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | |
| 320 | + | c := w.Columns[col] | |
| 321 | + | fs.back = origPlace{col: col, row: slices.Index(c.Windows, id), slot: c.Slot, stacked: len(c.Windows) > 1, width: c.Width} | |
| 322 | + | w.RemoveWindow(id) | |
| 323 | + | fs.AddWindow(id) | |
| 324 | + | fs.Columns[0].Width = c.Width | |
| 325 | + | } | |
| 309 | 326 | fs.SetFullscreen(id, true) | |
| 310 | - | at := indexOf(m.Workspaces, w) + 1 | |
| 327 | + | at := m.Active + 1 | |
| 328 | + | if i := indexOf(m.Workspaces, w); i >= 0 { | |
| 329 | + | at = i + 1 | |
| 330 | + | } | |
| 311 | 331 | m.Workspaces = slices.Insert(m.Workspaces, at, fs) | |
| 312 | 332 | if at <= m.Active { | |
| 313 | 333 | m.Active++ | |
| 314 | 334 | } | |
| 315 | 335 | if show { | |
| 316 | - | m.Focus(at) | |
| 336 | + | m.show(fs) | |
| 317 | 337 | } | |
| 318 | 338 | } | |
| 319 | 339 | ||
| 320 | - | // leaveFullscreen sends the window of the fullscreen workspace fs back to | |
| 321 | - | // its origin column and drops fs. The view goes along when fs was on | |
| 322 | - | // screen. Without the origin, fs becomes a normal workspace. | |
| 323 | - | func (m *Monitor) leaveFullscreen(fs *Workspace) { | |
| 324 | - | origin := fs.origin | |
| 325 | - | fs.origin = nil | |
| 326 | - | fs.fullscreen = 0 | |
| 327 | - | if !m.has(origin) { | |
| 340 | + | // leaveFullscreen sends the window of fs back to its place in the origin | |
| 341 | + | // and drops fs; the view goes along when fs was on screen. focus is set | |
| 342 | + | // for the user's bind: the window gets the focus back there. | |
| 343 | + | func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |
| 344 | + | if m.fullscreenHome(); fs.origin == nil { | |
| 345 | + | fs.SetFullscreen(fs.fullscreen, false) | |
| 328 | 346 | return | |
| 329 | 347 | } | |
| 330 | - | if len(fs.Columns) > 0 { | |
| 331 | - | col := fs.Columns[0] | |
| 332 | - | col.Focus = 0 | |
| 333 | - | fs.Columns = fs.Columns[1:] | |
| 334 | - | origin.insertColumn(fs.originCol, col) | |
| 335 | - | } | |
| 336 | - | if !fs.empty() { | |
| 337 | - | return | |
| 348 | + | origin, back, id := fs.origin, fs.back, fs.fullscreen | |
| 349 | + | fs.origin, fs.back = nil, origPlace{} | |
| 350 | + | fs.RemoveWindow(id) | |
| 351 | + | switch prev := origin.Focus; { | |
| 352 | + | case back.float != nil && focus: | |
| 353 | + | origin.AddFloating(id, back.float.W, back.float.H) | |
| 354 | + | case back.float != nil: | |
| 355 | + | // At the bottom: the top float, maybe focused, stays on top. | |
| 356 | + | origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H}) | |
| 357 | + | case back.stacked && back.col < len(origin.Columns): | |
| 358 | + | c := &origin.Columns[back.col] | |
| 359 | + | row := min(back.row, len(c.Windows)) | |
| 360 | + | c.Windows = slices.Insert(c.Windows, row, id) | |
| 361 | + | if focus { | |
| 362 | + | origin.Focus, c.Focus, origin.floatFocus = back.col, row, false | |
| 363 | + | } else if row <= c.Focus && len(c.Windows) > 1 { | |
| 364 | + | c.Focus++ | |
| 365 | + | } | |
| 366 | + | default: | |
| 367 | + | at := min(back.col, len(origin.Columns)) | |
| 368 | + | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot}) | |
| 369 | + | if focus { | |
| 370 | + | origin.floatFocus = false | |
| 371 | + | } else if len(origin.Columns) > 1 { | |
| 372 | + | origin.Focus = prev | |
| 373 | + | if at <= prev { | |
| 374 | + | origin.Focus++ | |
| 375 | + | } | |
| 376 | + | } | |
| 338 | 377 | } | |
| 378 | + | origin.scroll() | |
| 339 | 379 | shown := m.Current() == fs | |
| 340 | 380 | m.take(fs) | |
| 341 | 381 | if shown { | |
| @@ -343,6 +383,22 @@ func (m *Monitor) leaveFullscreen(fs *Workspace) { | |||
| 343 | 383 | } | |
| 344 | 384 | } | |
| 345 | 385 | ||
| 386 | + | // fullscreenHome keeps each fullscreen workspace linked to its origin only | |
| 387 | + | // while it holds just its fullscreen window and the origin is still here. | |
| 388 | + | // Anything else (another window opened or moved in, the window activated | |
| 389 | + | // away from fullscreen, the origin gone to another monitor) makes it a | |
| 390 | + | // normal workspace, so a later exit never moves the wrong window. | |
| 391 | + | func (m *Monitor) fullscreenHome() { | |
| 392 | + | for _, w := range m.all() { | |
| 393 | + | if w.origin == nil { | |
| 394 | + | continue | |
| 395 | + | } | |
| 396 | + | if !m.has(w.origin) || w.fullscreen == 0 || len(w.windows()) != 1 || w.windows()[0] != w.fullscreen { | |
| 397 | + | w.origin, w.back = nil, origPlace{} | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 346 | 402 | // MoveToWorkspace moves the focused window to numbered workspace index i; | |
| 347 | 403 | // focus stays here. | |
| 348 | 404 | func (m *Monitor) MoveToWorkspace(i int) { | |
| @@ -117,7 +117,7 @@ func (m *Monitor) Current() *Workspace { | |||||
| 117 | func (m *Monitor) newWorkspace() *Workspace { | 117 | func (m *Monitor) newWorkspace() *Workspace { | ||
| 118 | w := m.template | 118 | w := m.template | ||
| 119 | w.presets = append([]Width(nil), m.template.presets...) | 119 | w.presets = append([]Width(nil), m.template.presets...) | ||
| 120 | - | w.Columns, w.Floats, w.floatFocus, w.home, w.origin = nil, nil, false, "", nil | 120 | + | w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{} |
| 121 | return &w | 121 | return &w | ||
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| @@ -125,6 +125,7 @@ func (m *Monitor) newWorkspace() *Workspace { | |||||
| 125 | // unnamed ones that are neither the first nor active. The active workspace | 125 | // unnamed ones that are neither the first nor active. The active workspace | ||
| 126 | // never changes. | 126 | // never changes. | ||
| 127 | func (m *Monitor) normalize() { | 127 | func (m *Monitor) normalize() { | ||
| 128 | + | m.fullscreenHome() | |||
| 128 | spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" } | 129 | spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" } | ||
| 129 | for i := len(m.Workspaces) - 2; i >= 1; i-- { | 130 | for i := len(m.Workspaces) - 2; i >= 1; i-- { | ||
| 130 | if spare(i) && i != m.Active { | 131 | if spare(i) && i != m.Active { | ||
| @@ -245,26 +246,31 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) { | |||||
| 245 | func (m *Monitor) RemoveWindow(id WindowID) { | 246 | func (m *Monitor) RemoveWindow(id WindowID) { | ||
| 246 | if w, _ := m.find(id); w != nil { | 247 | if w, _ := m.find(id); w != nil { | ||
| 247 | w.RemoveWindow(id) | 248 | w.RemoveWindow(id) | ||
| 248 | - | if w.origin != nil && w.empty() { | 249 | + | if w.origin != nil && w.empty() && m.has(w.origin) { |
| 249 | - | m.leaveFullscreen(w) | 250 | + | shown := m.Current() == w |
| 251 | + | origin := w.origin | |||
| 252 | + | m.take(w) | |||
| 253 | + | if shown { | |||
| 254 | + | m.show(origin) | |||
| 255 | + | } | |||
| 250 | } | 256 | } | ||
| 251 | m.normalize() | 257 | m.normalize() | ||
| 252 | } | 258 | } | ||
| 253 | } | 259 | } | ||
| 254 | 260 | ||||
| 255 | // SetFullscreen applies a client request. Under fixed overflow the window | 261 | // SetFullscreen applies a client request. Under fixed overflow the window | ||
| 256 | - | // gets its own workspace, as with the bind; the view follows only when the | 262 | + | // gets its own workspace, as with the bind. A client request never moves |
| 257 | - | // window is the focused one on screen, so a request never takes the user | 263 | + | // the user (ADR 011): the view follows only a window that is focused and |
| 258 | - | // away from what they look at. | 264 | + | // on screen, and a window coming back does not take the focus. |
| 259 | func (m *Monitor) SetFullscreen(id WindowID, on bool) { | 265 | func (m *Monitor) SetFullscreen(id WindowID, on bool) { | ||
| 260 | w, _ := m.find(id) | 266 | w, _ := m.find(id) | ||
| 261 | switch { | 267 | switch { | ||
| 262 | case w == nil: | 268 | case w == nil: | ||
| 263 | - | case on && w.origin == nil && w.fullscreen != id && m.ownWorkspace(w, id): | 269 | + | case on && m.ownWorkspace(w, id): |
| 264 | focused, _ := w.Focused() | 270 | focused, _ := w.Focused() | ||
| 265 | m.enterFullscreen(w, id, w == m.Current() && focused == id) | 271 | m.enterFullscreen(w, id, w == m.Current() && focused == id) | ||
| 266 | case !on && w.origin != nil && w.fullscreen == id: | 272 | case !on && w.origin != nil && w.fullscreen == id: | ||
| 267 | - | m.leaveFullscreen(w) | 273 | + | m.leaveFullscreen(w, false) |
| 268 | default: | 274 | default: | ||
| 269 | w.SetFullscreen(id, on) | 275 | w.SetFullscreen(id, on) | ||
| 270 | } | 276 | } | ||
| @@ -280,8 +286,8 @@ func (m *Monitor) ToggleFullscreen() { | |||||
| 280 | switch { | 286 | switch { | ||
| 281 | case !ok: | 287 | case !ok: | ||
| 282 | case w.origin != nil && w.fullscreen == id: | 288 | case w.origin != nil && w.fullscreen == id: | ||
| 283 | - | m.leaveFullscreen(w) | 289 | + | m.leaveFullscreen(w, true) |
| 284 | - | case w.fullscreen != id && m.ownWorkspace(w, id): | 290 | + | case m.ownWorkspace(w, id): |
| 285 | m.enterFullscreen(w, id, true) | 291 | m.enterFullscreen(w, id, true) | ||
| 286 | default: | 292 | default: | ||
| 287 | w.ToggleFullscreen() | 293 | w.ToggleFullscreen() | ||
| @@ -290,52 +296,86 @@ func (m *Monitor) ToggleFullscreen() { | |||||
| 290 | } | 296 | } | ||
| 291 | 297 | ||||
| 292 | // ownWorkspace reports whether going fullscreen moves the window to its own | 298 | // ownWorkspace reports whether going fullscreen moves the window to its own | ||
| 293 | - | // workspace: fixed overflow, a tiled window, and others to leave behind. | 299 | + | // workspace: fixed overflow hides every other window behind a fullscreen |
| 300 | + | // one and never scrolls, so it would leave the user stuck. Not needed when | |||
| 301 | + | // the window is alone, or already has its own workspace. | |||
| 294 | func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool { | 302 | func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool { | ||
| 295 | - | return w.Overflow == OverflowFixed && w.floatIndex(id) < 0 && len(w.windows()) > 1 && indexOf(m.Workspaces, w) >= 0 | 303 | + | return w.Overflow == OverflowFixed && w.origin == nil && w.fullscreen != id && len(w.windows()) > 1 && m.has(w) |
| 296 | } | 304 | } | ||
| 297 | 305 | ||||
| 298 | - | // enterFullscreen moves the tiled window id from w to a new workspace just | 306 | + | // enterFullscreen moves window id from w to a new workspace just below the |
| 299 | - | // below it, fullscreen, and shows it when show is set. | 307 | + | // numbered one on screen, fullscreen, and shows it when show is set. It |
| 308 | + | // remembers the window's place in w to return there. | |||
| 300 | func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | 309 | func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | ||
| 301 | - | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | |||
| 302 | - | width := w.Columns[col].Width | |||
| 303 | - | w.RemoveWindow(id) | |||
| 304 | fs := m.newWorkspace() | 310 | fs := m.newWorkspace() | ||
| 305 | fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns | 311 | fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns | ||
| 306 | - | fs.origin, fs.originCol = w, col | 312 | + | fs.origin = w |
| 307 | - | fs.AddWindow(id) | 313 | + | if f := w.floatIndex(id); f >= 0 { |
| 308 | - | fs.Columns[0].Width = width | 314 | + | fl := w.Floats[f] |
| 315 | + | fs.back.float = &fl | |||
| 316 | + | w.RemoveWindow(id) | |||
| 317 | + | fs.AddFloating(id, fl.W, fl.H) | |||
| 318 | + | } else { | |||
| 319 | + | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | |||
| 320 | + | c := w.Columns[col] | |||
| 321 | + | fs.back = origPlace{col: col, row: slices.Index(c.Windows, id), slot: c.Slot, stacked: len(c.Windows) > 1, width: c.Width} | |||
| 322 | + | w.RemoveWindow(id) | |||
| 323 | + | fs.AddWindow(id) | |||
| 324 | + | fs.Columns[0].Width = c.Width | |||
| 325 | + | } | |||
| 309 | fs.SetFullscreen(id, true) | 326 | fs.SetFullscreen(id, true) | ||
| 310 | - | at := indexOf(m.Workspaces, w) + 1 | 327 | + | at := m.Active + 1 |
| 328 | + | if i := indexOf(m.Workspaces, w); i >= 0 { | |||
| 329 | + | at = i + 1 | |||
| 330 | + | } | |||
| 311 | m.Workspaces = slices.Insert(m.Workspaces, at, fs) | 331 | m.Workspaces = slices.Insert(m.Workspaces, at, fs) | ||
| 312 | if at <= m.Active { | 332 | if at <= m.Active { | ||
| 313 | m.Active++ | 333 | m.Active++ | ||
| 314 | } | 334 | } | ||
| 315 | if show { | 335 | if show { | ||
| 316 | - | m.Focus(at) | 336 | + | m.show(fs) |
| 317 | } | 337 | } | ||
| 318 | } | 338 | } | ||
| 319 | 339 | ||||
| 320 | - | // leaveFullscreen sends the window of the fullscreen workspace fs back to | 340 | + | // leaveFullscreen sends the window of fs back to its place in the origin |
| 321 | - | // its origin column and drops fs. The view goes along when fs was on | 341 | + | // and drops fs; the view goes along when fs was on screen. focus is set |
| 322 | - | // screen. Without the origin, fs becomes a normal workspace. | 342 | + | // for the user's bind: the window gets the focus back there. |
| 323 | - | func (m *Monitor) leaveFullscreen(fs *Workspace) { | 343 | + | func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { |
| 324 | - | origin := fs.origin | 344 | + | if m.fullscreenHome(); fs.origin == nil { |
| 325 | - | fs.origin = nil | 345 | + | fs.SetFullscreen(fs.fullscreen, false) |
| 326 | - | fs.fullscreen = 0 | |||
| 327 | - | if !m.has(origin) { | |||
| 328 | return | 346 | return | ||
| 329 | } | 347 | } | ||
| 330 | - | if len(fs.Columns) > 0 { | 348 | + | origin, back, id := fs.origin, fs.back, fs.fullscreen |
| 331 | - | col := fs.Columns[0] | 349 | + | fs.origin, fs.back = nil, origPlace{} |
| 332 | - | col.Focus = 0 | 350 | + | fs.RemoveWindow(id) |
| 333 | - | fs.Columns = fs.Columns[1:] | 351 | + | switch prev := origin.Focus; { |
| 334 | - | origin.insertColumn(fs.originCol, col) | 352 | + | case back.float != nil && focus: |
| 335 | - | } | 353 | + | origin.AddFloating(id, back.float.W, back.float.H) |
| 336 | - | if !fs.empty() { | 354 | + | case back.float != nil: |
| 337 | - | return | 355 | + | // At the bottom: the top float, maybe focused, stays on top. |
| 356 | + | origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H}) | |||
| 357 | + | case back.stacked && back.col < len(origin.Columns): | |||
| 358 | + | c := &origin.Columns[back.col] | |||
| 359 | + | row := min(back.row, len(c.Windows)) | |||
| 360 | + | c.Windows = slices.Insert(c.Windows, row, id) | |||
| 361 | + | if focus { | |||
| 362 | + | origin.Focus, c.Focus, origin.floatFocus = back.col, row, false | |||
| 363 | + | } else if row <= c.Focus && len(c.Windows) > 1 { | |||
| 364 | + | c.Focus++ | |||
| 365 | + | } | |||
| 366 | + | default: | |||
| 367 | + | at := min(back.col, len(origin.Columns)) | |||
| 368 | + | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot}) | |||
| 369 | + | if focus { | |||
| 370 | + | origin.floatFocus = false | |||
| 371 | + | } else if len(origin.Columns) > 1 { | |||
| 372 | + | origin.Focus = prev | |||
| 373 | + | if at <= prev { | |||
| 374 | + | origin.Focus++ | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 338 | } | 377 | } | ||
| 378 | + | origin.scroll() | |||
| 339 | shown := m.Current() == fs | 379 | shown := m.Current() == fs | ||
| 340 | m.take(fs) | 380 | m.take(fs) | ||
| 341 | if shown { | 381 | if shown { | ||
| @@ -343,6 +383,22 @@ func (m *Monitor) leaveFullscreen(fs *Workspace) { | |||||
| 343 | } | 383 | } | ||
| 344 | } | 384 | } | ||
| 345 | 385 | ||||
| 386 | + | // fullscreenHome keeps each fullscreen workspace linked to its origin only | |||
| 387 | + | // while it holds just its fullscreen window and the origin is still here. | |||
| 388 | + | // Anything else (another window opened or moved in, the window activated | |||
| 389 | + | // away from fullscreen, the origin gone to another monitor) makes it a | |||
| 390 | + | // normal workspace, so a later exit never moves the wrong window. | |||
| 391 | + | func (m *Monitor) fullscreenHome() { | |||
| 392 | + | for _, w := range m.all() { | |||
| 393 | + | if w.origin == nil { | |||
| 394 | + | continue | |||
| 395 | + | } | |||
| 396 | + | if !m.has(w.origin) || w.fullscreen == 0 || len(w.windows()) != 1 || w.windows()[0] != w.fullscreen { | |||
| 397 | + | w.origin, w.back = nil, origPlace{} | |||
| 398 | + | } | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | ||||
| 346 | // MoveToWorkspace moves the focused window to numbered workspace index i; | 402 | // MoveToWorkspace moves the focused window to numbered workspace index i; | ||
| 347 | // focus stays here. | 403 | // focus stays here. | ||
| 348 | func (m *Monitor) MoveToWorkspace(i int) { | 404 | func (m *Monitor) MoveToWorkspace(i int) { | ||
M internal/core/monitor_test.go +82 -0
| @@ -475,3 +475,85 @@ func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) { | |||
| 475 | 475 | t.Fatal(got) | |
| 476 | 476 | } | |
| 477 | 477 | } | |
| 478 | + | ||
| 479 | + | // The fullscreen workspace link survives only while it holds just its | |
| 480 | + | // window; exits restore the exact place and never move the user's focus. | |
| 481 | + | func TestMonitorFixedFullscreenEdges(t *testing.T) { | |
| 482 | + | fixed := func() *Monitor { | |
| 483 | + | m := monitor() | |
| 484 | + | m.SetOverflow(OverflowFixed) | |
| 485 | + | for id := WindowID(1); id <= 3; id++ { | |
| 486 | + | m.AddWindow(id) | |
| 487 | + | } | |
| 488 | + | return m | |
| 489 | + | } | |
| 490 | + | t.Run("another window opened there unlinks it", func(t *testing.T) { | |
| 491 | + | m := fixed() | |
| 492 | + | m.Current().FocusID(2) | |
| 493 | + | m.ToggleFullscreen() | |
| 494 | + | m.AddWindow(4) | |
| 495 | + | m.SetFullscreen(2, false) | |
| 496 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {2, 4}, {}}) { | |
| 497 | + | t.Fatal(got) | |
| 498 | + | } | |
| 499 | + | }) | |
| 500 | + | t.Run("client exit keeps the user's focus", func(t *testing.T) { | |
| 501 | + | m := fixed() | |
| 502 | + | m.Current().FocusID(1) | |
| 503 | + | m.SetFullscreen(3, true) | |
| 504 | + | m.SetFullscreen(3, false) | |
| 505 | + | if id, _ := m.Focused(); id != 1 || m.Active != 0 { | |
| 506 | + | t.Fatal("focus moved to", id, m.Active) | |
| 507 | + | } | |
| 508 | + | m.Current().FocusID(3) | |
| 509 | + | m.SetFullscreen(1, true) | |
| 510 | + | m.SetFullscreen(1, false) | |
| 511 | + | if id, _ := m.Focused(); id != 3 { | |
| 512 | + | t.Fatal("focus moved to", id) | |
| 513 | + | } | |
| 514 | + | }) | |
| 515 | + | t.Run("stacked slot window returns to its row", func(t *testing.T) { | |
| 516 | + | m := fixed() | |
| 517 | + | w := m.Current() | |
| 518 | + | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Slot: 2}} | |
| 519 | + | w.FocusID(3) | |
| 520 | + | m.ToggleFullscreen() | |
| 521 | + | m.ToggleFullscreen() | |
| 522 | + | if !reflect.DeepEqual(w.Columns[1], Column{Windows: []WindowID{2, 3}, Focus: 1, Slot: 2}) || w.Focus != 1 { | |
| 523 | + | t.Fatal(w.Columns, w.Focus) | |
| 524 | + | } | |
| 525 | + | }) | |
| 526 | + | t.Run("moved out by hand unlinks it", func(t *testing.T) { | |
| 527 | + | m := fixed() | |
| 528 | + | m.Current().FocusID(2) | |
| 529 | + | m.ToggleFullscreen() | |
| 530 | + | m.Apply(ActionMoveToWorkspaceUp) | |
| 531 | + | m.AddWindow(4) | |
| 532 | + | m.RemoveWindow(4) | |
| 533 | + | if m.Active != 1 { | |
| 534 | + | t.Fatal("view jumped to", m.Active) | |
| 535 | + | } | |
| 536 | + | }) | |
| 537 | + | t.Run("floating window", func(t *testing.T) { | |
| 538 | + | m := fixed() | |
| 539 | + | m.AddFloating(4, 10, 10) | |
| 540 | + | m.ToggleFullscreen() | |
| 541 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}, {}}) || m.Active != 1 || m.Workspaces[1].floatIndex(4) != 0 { | |
| 542 | + | t.Fatal(got, m.Active) | |
| 543 | + | } | |
| 544 | + | m.ToggleFullscreen() | |
| 545 | + | if w := m.Current(); m.Active != 0 || w.floatIndex(4) != 0 || !w.floatFocus { | |
| 546 | + | t.Fatal(m.Active, w.Floats) | |
| 547 | + | } | |
| 548 | + | }) | |
| 549 | + | t.Run("origin gone unlinks it", func(t *testing.T) { | |
| 550 | + | m := fixed() | |
| 551 | + | m.Current().FocusID(2) | |
| 552 | + | m.ToggleFullscreen() | |
| 553 | + | m.take(m.Workspaces[0]) | |
| 554 | + | m.ToggleFullscreen() | |
| 555 | + | if id, _ := m.Focused(); id != 2 || m.Current().fullscreen != 0 { | |
| 556 | + | t.Fatal(id) | |
| 557 | + | } | |
| 558 | + | }) | |
| 559 | + | } | |
| @@ -475,3 +475,85 @@ func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) { | |||||
| 475 | t.Fatal(got) | 475 | t.Fatal(got) | ||
| 476 | } | 476 | } | ||
| 477 | } | 477 | } | ||
| 478 | + | ||||
| 479 | + | // The fullscreen workspace link survives only while it holds just its | |||
| 480 | + | // window; exits restore the exact place and never move the user's focus. | |||
| 481 | + | func TestMonitorFixedFullscreenEdges(t *testing.T) { | |||
| 482 | + | fixed := func() *Monitor { | |||
| 483 | + | m := monitor() | |||
| 484 | + | m.SetOverflow(OverflowFixed) | |||
| 485 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 486 | + | m.AddWindow(id) | |||
| 487 | + | } | |||
| 488 | + | return m | |||
| 489 | + | } | |||
| 490 | + | t.Run("another window opened there unlinks it", func(t *testing.T) { | |||
| 491 | + | m := fixed() | |||
| 492 | + | m.Current().FocusID(2) | |||
| 493 | + | m.ToggleFullscreen() | |||
| 494 | + | m.AddWindow(4) | |||
| 495 | + | m.SetFullscreen(2, false) | |||
| 496 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {2, 4}, {}}) { | |||
| 497 | + | t.Fatal(got) | |||
| 498 | + | } | |||
| 499 | + | }) | |||
| 500 | + | t.Run("client exit keeps the user's focus", func(t *testing.T) { | |||
| 501 | + | m := fixed() | |||
| 502 | + | m.Current().FocusID(1) | |||
| 503 | + | m.SetFullscreen(3, true) | |||
| 504 | + | m.SetFullscreen(3, false) | |||
| 505 | + | if id, _ := m.Focused(); id != 1 || m.Active != 0 { | |||
| 506 | + | t.Fatal("focus moved to", id, m.Active) | |||
| 507 | + | } | |||
| 508 | + | m.Current().FocusID(3) | |||
| 509 | + | m.SetFullscreen(1, true) | |||
| 510 | + | m.SetFullscreen(1, false) | |||
| 511 | + | if id, _ := m.Focused(); id != 3 { | |||
| 512 | + | t.Fatal("focus moved to", id) | |||
| 513 | + | } | |||
| 514 | + | }) | |||
| 515 | + | t.Run("stacked slot window returns to its row", func(t *testing.T) { | |||
| 516 | + | m := fixed() | |||
| 517 | + | w := m.Current() | |||
| 518 | + | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Slot: 2}} | |||
| 519 | + | w.FocusID(3) | |||
| 520 | + | m.ToggleFullscreen() | |||
| 521 | + | m.ToggleFullscreen() | |||
| 522 | + | if !reflect.DeepEqual(w.Columns[1], Column{Windows: []WindowID{2, 3}, Focus: 1, Slot: 2}) || w.Focus != 1 { | |||
| 523 | + | t.Fatal(w.Columns, w.Focus) | |||
| 524 | + | } | |||
| 525 | + | }) | |||
| 526 | + | t.Run("moved out by hand unlinks it", func(t *testing.T) { | |||
| 527 | + | m := fixed() | |||
| 528 | + | m.Current().FocusID(2) | |||
| 529 | + | m.ToggleFullscreen() | |||
| 530 | + | m.Apply(ActionMoveToWorkspaceUp) | |||
| 531 | + | m.AddWindow(4) | |||
| 532 | + | m.RemoveWindow(4) | |||
| 533 | + | if m.Active != 1 { | |||
| 534 | + | t.Fatal("view jumped to", m.Active) | |||
| 535 | + | } | |||
| 536 | + | }) | |||
| 537 | + | t.Run("floating window", func(t *testing.T) { | |||
| 538 | + | m := fixed() | |||
| 539 | + | m.AddFloating(4, 10, 10) | |||
| 540 | + | m.ToggleFullscreen() | |||
| 541 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}, {}}) || m.Active != 1 || m.Workspaces[1].floatIndex(4) != 0 { | |||
| 542 | + | t.Fatal(got, m.Active) | |||
| 543 | + | } | |||
| 544 | + | m.ToggleFullscreen() | |||
| 545 | + | if w := m.Current(); m.Active != 0 || w.floatIndex(4) != 0 || !w.floatFocus { | |||
| 546 | + | t.Fatal(m.Active, w.Floats) | |||
| 547 | + | } | |||
| 548 | + | }) | |||
| 549 | + | t.Run("origin gone unlinks it", func(t *testing.T) { | |||
| 550 | + | m := fixed() | |||
| 551 | + | m.Current().FocusID(2) | |||
| 552 | + | m.ToggleFullscreen() | |||
| 553 | + | m.take(m.Workspaces[0]) | |||
| 554 | + | m.ToggleFullscreen() | |||
| 555 | + | if id, _ := m.Focused(); id != 2 || m.Current().fullscreen != 0 { | |||
| 556 | + | t.Fatal(id) | |||
| 557 | + | } | |||
| 558 | + | }) | |||
| 559 | + | } | |||