fix(layout): return only the fullscreen window, restack by surviving neighbours
- commit
- 461a201d525ace2192bbd64aa0a283b6aca71d2a
- author
- brice · · 2026-09-25 20:17 UTC
- parent
- b49bcb3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +53 -19
M internal/core/layout.go +3 -3
| @@ -146,9 +146,9 @@ type Workspace struct { | |||
| 146 | 146 | // column, the column's width and slot, or its floating size. | |
| 147 | 147 | type origPlace struct { | |
| 148 | 148 | col, row, slot int | |
| 149 | - | // stacked holds a window left in the column: the window returns to its | |
| 150 | - | // row only while that window still marks the column. | |
| 151 | - | stacked WindowID | |
| 149 | + | // stacked holds the windows left in the column: the window returns to | |
| 150 | + | // its row in the column that still holds one of them. | |
| 151 | + | stacked []WindowID | |
| 152 | 152 | width Width | |
| 153 | 153 | float *Float | |
| 154 | 154 | } | |
| @@ -146,9 +146,9 @@ type Workspace struct { | |||||
| 146 | // column, the column's width and slot, or its floating size. | 146 | // column, the column's width and slot, or its floating size. | ||
| 147 | type origPlace struct { | 147 | type origPlace struct { | ||
| 148 | col, row, slot int | 148 | col, row, slot int | ||
| 149 | - | // stacked holds a window left in the column: the window returns to its | 149 | + | // stacked holds the windows left in the column: the window returns to |
| 150 | - | // row only while that window still marks the column. | 150 | + | // its row in the column that still holds one of them. |
| 151 | - | stacked WindowID | 151 | + | stacked []WindowID |
| 152 | width Width | 152 | width Width | ||
| 153 | float *Float | 153 | float *Float | ||
| 154 | } | 154 | } | ||
M internal/core/monitor.go +28 -13
| @@ -320,8 +320,10 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | |||
| 320 | 320 | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | |
| 321 | 321 | c := w.Columns[col] | |
| 322 | 322 | fs.back = origPlace{col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width} | |
| 323 | - | if i := slices.IndexFunc(c.Windows, func(v WindowID) bool { return v != id }); i >= 0 { | |
| 324 | - | fs.back.stacked = c.Windows[i] | |
| 323 | + | for _, v := range c.Windows { | |
| 324 | + | if v != id { | |
| 325 | + | fs.back.stacked = append(fs.back.stacked, v) | |
| 326 | + | } | |
| 325 | 327 | } | |
| 326 | 328 | w.RemoveWindow(id) | |
| 327 | 329 | fs.AddWindow(id) | |
| @@ -358,10 +360,17 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||
| 358 | 360 | case back.float != nil: | |
| 359 | 361 | // At the bottom: the top float, maybe focused, stays on top. | |
| 360 | 362 | origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H}) | |
| 361 | - | case back.stacked != 0 && slices.ContainsFunc(origin.Columns, func(c Column) bool { return slices.Contains(c.Windows, back.stacked) }): | |
| 362 | - | back.col = slices.IndexFunc(origin.Columns, func(c Column) bool { return slices.Contains(c.Windows, back.stacked) }) | |
| 363 | + | case slices.ContainsFunc(origin.Columns, back.holdsStack): | |
| 364 | + | back.col = slices.IndexFunc(origin.Columns, back.holdsStack) | |
| 363 | 365 | c := &origin.Columns[back.col] | |
| 364 | - | row := min(back.row, len(c.Windows)) | |
| 366 | + | // Below the windows that were above it and are still there. | |
| 367 | + | row := 0 | |
| 368 | + | for _, v := range back.stacked[:back.row] { | |
| 369 | + | if slices.Contains(c.Windows, v) { | |
| 370 | + | row++ | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | row = min(row, len(c.Windows)) | |
| 365 | 374 | c.Windows = slices.Insert(c.Windows, row, id) | |
| 366 | 375 | if focus { | |
| 367 | 376 | origin.Focus, c.Focus, origin.floatFocus = back.col, row, false | |
| @@ -381,11 +390,12 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||
| 381 | 390 | } | |
| 382 | 391 | } | |
| 383 | 392 | origin.scroll() | |
| 384 | - | shown := m.Current() == fs | |
| 385 | - | m.take(fs) | |
| 386 | - | if shown { | |
| 393 | + | if m.Current() == fs && (focus || fs.empty()) { | |
| 387 | 394 | m.show(origin) | |
| 388 | 395 | } | |
| 396 | + | if fs.empty() { | |
| 397 | + | m.take(fs) | |
| 398 | + | } | |
| 389 | 399 | } | |
| 390 | 400 | ||
| 391 | 401 | // awayInSlot reports whether id is away from slot n of w, fullscreen in a | |
| @@ -399,17 +409,22 @@ func (m *Monitor) awayInSlot(w *Workspace, id WindowID, n int) bool { | |||
| 399 | 409 | return false | |
| 400 | 410 | } | |
| 401 | 411 | ||
| 412 | + | // holdsStack reports whether c holds a window of the stack left behind. | |
| 413 | + | func (p origPlace) holdsStack(c Column) bool { | |
| 414 | + | return slices.ContainsFunc(c.Windows, func(v WindowID) bool { return slices.Contains(p.stacked, v) }) | |
| 415 | + | } | |
| 416 | + | ||
| 402 | 417 | // fullscreenHome keeps each fullscreen workspace linked to its origin only | |
| 403 | - | // while it holds just its fullscreen window and the origin is still here. | |
| 404 | - | // Anything else (another window opened or moved in, the window activated | |
| 405 | - | // away from fullscreen, the origin gone to another monitor) makes it a | |
| 406 | - | // normal workspace, so a later exit never moves the wrong window. | |
| 418 | + | // while its window is still there and fullscreen, and the origin is still | |
| 419 | + | // on this monitor. Otherwise (the window moved out or activated away from | |
| 420 | + | // fullscreen, the origin gone to another monitor) it is a normal workspace. | |
| 421 | + | // Other windows opened there (a dialog) stay when the window returns. | |
| 407 | 422 | func (m *Monitor) fullscreenHome() { | |
| 408 | 423 | for _, w := range m.all() { | |
| 409 | 424 | if w.origin == nil { | |
| 410 | 425 | continue | |
| 411 | 426 | } | |
| 412 | - | if !m.has(w.origin) || w.fullscreen == 0 || len(w.windows()) != 1 || w.windows()[0] != w.fullscreen { | |
| 427 | + | if !m.has(w.origin) || w.fullscreen == 0 || !w.has(w.fullscreen) { | |
| 413 | 428 | w.origin, w.back = nil, origPlace{} | |
| 414 | 429 | } | |
| 415 | 430 | } | |
| @@ -320,8 +320,10 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | |||||
| 320 | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | 320 | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | ||
| 321 | c := w.Columns[col] | 321 | c := w.Columns[col] | ||
| 322 | fs.back = origPlace{col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width} | 322 | fs.back = origPlace{col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width} | ||
| 323 | - | if i := slices.IndexFunc(c.Windows, func(v WindowID) bool { return v != id }); i >= 0 { | 323 | + | for _, v := range c.Windows { |
| 324 | - | fs.back.stacked = c.Windows[i] | 324 | + | if v != id { |
| 325 | + | fs.back.stacked = append(fs.back.stacked, v) | |||
| 326 | + | } | |||
| 325 | } | 327 | } | ||
| 326 | w.RemoveWindow(id) | 328 | w.RemoveWindow(id) | ||
| 327 | fs.AddWindow(id) | 329 | fs.AddWindow(id) | ||
| @@ -358,10 +360,17 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||||
| 358 | case back.float != nil: | 360 | case back.float != nil: | ||
| 359 | // At the bottom: the top float, maybe focused, stays on top. | 361 | // At the bottom: the top float, maybe focused, stays on top. | ||
| 360 | origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H}) | 362 | origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H}) | ||
| 361 | - | case back.stacked != 0 && slices.ContainsFunc(origin.Columns, func(c Column) bool { return slices.Contains(c.Windows, back.stacked) }): | 363 | + | case slices.ContainsFunc(origin.Columns, back.holdsStack): |
| 362 | - | back.col = slices.IndexFunc(origin.Columns, func(c Column) bool { return slices.Contains(c.Windows, back.stacked) }) | 364 | + | back.col = slices.IndexFunc(origin.Columns, back.holdsStack) |
| 363 | c := &origin.Columns[back.col] | 365 | c := &origin.Columns[back.col] | ||
| 364 | - | row := min(back.row, len(c.Windows)) | 366 | + | // Below the windows that were above it and are still there. |
| 367 | + | row := 0 | |||
| 368 | + | for _, v := range back.stacked[:back.row] { | |||
| 369 | + | if slices.Contains(c.Windows, v) { | |||
| 370 | + | row++ | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | row = min(row, len(c.Windows)) | |||
| 365 | c.Windows = slices.Insert(c.Windows, row, id) | 374 | c.Windows = slices.Insert(c.Windows, row, id) | ||
| 366 | if focus { | 375 | if focus { | ||
| 367 | origin.Focus, c.Focus, origin.floatFocus = back.col, row, false | 376 | origin.Focus, c.Focus, origin.floatFocus = back.col, row, false | ||
| @@ -381,11 +390,12 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||||
| 381 | } | 390 | } | ||
| 382 | } | 391 | } | ||
| 383 | origin.scroll() | 392 | origin.scroll() | ||
| 384 | - | shown := m.Current() == fs | 393 | + | if m.Current() == fs && (focus || fs.empty()) { |
| 385 | - | m.take(fs) | |||
| 386 | - | if shown { | |||
| 387 | m.show(origin) | 394 | m.show(origin) | ||
| 388 | } | 395 | } | ||
| 396 | + | if fs.empty() { | |||
| 397 | + | m.take(fs) | |||
| 398 | + | } | |||
| 389 | } | 399 | } | ||
| 390 | 400 | ||||
| 391 | // awayInSlot reports whether id is away from slot n of w, fullscreen in a | 401 | // awayInSlot reports whether id is away from slot n of w, fullscreen in a | ||
| @@ -399,17 +409,22 @@ func (m *Monitor) awayInSlot(w *Workspace, id WindowID, n int) bool { | |||||
| 399 | return false | 409 | return false | ||
| 400 | } | 410 | } | ||
| 401 | 411 | ||||
| 412 | + | // holdsStack reports whether c holds a window of the stack left behind. | |||
| 413 | + | func (p origPlace) holdsStack(c Column) bool { | |||
| 414 | + | return slices.ContainsFunc(c.Windows, func(v WindowID) bool { return slices.Contains(p.stacked, v) }) | |||
| 415 | + | } | |||
| 416 | + | ||||
| 402 | // fullscreenHome keeps each fullscreen workspace linked to its origin only | 417 | // fullscreenHome keeps each fullscreen workspace linked to its origin only | ||
| 403 | - | // while it holds just its fullscreen window and the origin is still here. | 418 | + | // while its window is still there and fullscreen, and the origin is still |
| 404 | - | // Anything else (another window opened or moved in, the window activated | 419 | + | // on this monitor. Otherwise (the window moved out or activated away from |
| 405 | - | // away from fullscreen, the origin gone to another monitor) makes it a | 420 | + | // fullscreen, the origin gone to another monitor) it is a normal workspace. |
| 406 | - | // normal workspace, so a later exit never moves the wrong window. | 421 | + | // Other windows opened there (a dialog) stay when the window returns. |
| 407 | func (m *Monitor) fullscreenHome() { | 422 | func (m *Monitor) fullscreenHome() { | ||
| 408 | for _, w := range m.all() { | 423 | for _, w := range m.all() { | ||
| 409 | if w.origin == nil { | 424 | if w.origin == nil { | ||
| 410 | continue | 425 | continue | ||
| 411 | } | 426 | } | ||
| 412 | - | if !m.has(w.origin) || w.fullscreen == 0 || len(w.windows()) != 1 || w.windows()[0] != w.fullscreen { | 427 | + | if !m.has(w.origin) || w.fullscreen == 0 || !w.has(w.fullscreen) { |
| 413 | w.origin, w.back = nil, origPlace{} | 428 | w.origin, w.back = nil, origPlace{} | ||
| 414 | } | 429 | } | ||
| 415 | } | 430 | } | ||
M internal/core/monitor_test.go +16 -3
| @@ -487,14 +487,27 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) { | |||
| 487 | 487 | } | |
| 488 | 488 | return m | |
| 489 | 489 | } | |
| 490 | - | t.Run("another window opened there unlinks it", func(t *testing.T) { | |
| 490 | + | t.Run("a window opened there stays when it returns", func(t *testing.T) { | |
| 491 | 491 | m := fixed() | |
| 492 | 492 | m.Current().FocusID(2) | |
| 493 | 493 | m.ToggleFullscreen() | |
| 494 | 494 | m.AddWindow(4) | |
| 495 | + | m.Current().MoveColumn(-1) | |
| 495 | 496 | m.SetFullscreen(2, false) | |
| 496 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {2, 4}, {}}) { | |
| 497 | - | t.Fatal(got) | |
| 497 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {4}, {}}) || m.Active != 0 { | |
| 498 | + | t.Fatal(got, m.Active) | |
| 499 | + | } | |
| 500 | + | }) | |
| 501 | + | t.Run("stack of three keeps its column", func(t *testing.T) { | |
| 502 | + | m := fixed() | |
| 503 | + | w := m.Current() | |
| 504 | + | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3, 5}}} | |
| 505 | + | w.FocusID(3) | |
| 506 | + | m.ToggleFullscreen() | |
| 507 | + | m.RemoveWindow(2) | |
| 508 | + | m.ToggleFullscreen() | |
| 509 | + | if len(w.Columns) != 2 || !slices.Equal(w.Columns[1].Windows, []WindowID{3, 5}) { | |
| 510 | + | t.Fatal(w.Columns) | |
| 498 | 511 | } | |
| 499 | 512 | }) | |
| 500 | 513 | t.Run("client exit keeps the user's focus", func(t *testing.T) { | |
| @@ -487,14 +487,27 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) { | |||||
| 487 | } | 487 | } | ||
| 488 | return m | 488 | return m | ||
| 489 | } | 489 | } | ||
| 490 | - | t.Run("another window opened there unlinks it", func(t *testing.T) { | 490 | + | t.Run("a window opened there stays when it returns", func(t *testing.T) { |
| 491 | m := fixed() | 491 | m := fixed() | ||
| 492 | m.Current().FocusID(2) | 492 | m.Current().FocusID(2) | ||
| 493 | m.ToggleFullscreen() | 493 | m.ToggleFullscreen() | ||
| 494 | m.AddWindow(4) | 494 | m.AddWindow(4) | ||
| 495 | + | m.Current().MoveColumn(-1) | |||
| 495 | m.SetFullscreen(2, false) | 496 | m.SetFullscreen(2, false) | ||
| 496 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {2, 4}, {}}) { | 497 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {4}, {}}) || m.Active != 0 { |
| 497 | - | t.Fatal(got) | 498 | + | t.Fatal(got, m.Active) |
| 499 | + | } | |||
| 500 | + | }) | |||
| 501 | + | t.Run("stack of three keeps its column", func(t *testing.T) { | |||
| 502 | + | m := fixed() | |||
| 503 | + | w := m.Current() | |||
| 504 | + | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3, 5}}} | |||
| 505 | + | w.FocusID(3) | |||
| 506 | + | m.ToggleFullscreen() | |||
| 507 | + | m.RemoveWindow(2) | |||
| 508 | + | m.ToggleFullscreen() | |||
| 509 | + | if len(w.Columns) != 2 || !slices.Equal(w.Columns[1].Windows, []WindowID{3, 5}) { | |||
| 510 | + | t.Fatal(w.Columns) | |||
| 498 | } | 511 | } | ||
| 499 | }) | 512 | }) | ||
| 500 | t.Run("client exit keeps the user's focus", func(t *testing.T) { | 513 | t.Run("client exit keeps the user's focus", func(t *testing.T) { | ||
M internal/core/slots_test.go +6 -0
| @@ -384,4 +384,10 @@ func TestSlotKeptWhileFullscreenAway(t *testing.T) { | |||
| 384 | 384 | r.press(t, "d") // leave and show dev while code is away | |
| 385 | 385 | r.press(t, "d") | |
| 386 | 386 | noSpawn(t, r.spawn) | |
| 387 | + | // code opens a second window while fullscreen: the slot stays. | |
| 388 | + | r.client <- ports.WindowMapped{ID: 9} | |
| 389 | + | scene(t, r.scenes) | |
| 390 | + | r.press(t, "d") | |
| 391 | + | r.press(t, "d") | |
| 392 | + | noSpawn(t, r.spawn) | |
| 387 | 393 | } | |
| @@ -384,4 +384,10 @@ func TestSlotKeptWhileFullscreenAway(t *testing.T) { | |||||
| 384 | r.press(t, "d") // leave and show dev while code is away | 384 | r.press(t, "d") // leave and show dev while code is away | ||
| 385 | r.press(t, "d") | 385 | r.press(t, "d") | ||
| 386 | noSpawn(t, r.spawn) | 386 | noSpawn(t, r.spawn) | ||
| 387 | + | // code opens a second window while fullscreen: the slot stays. | |||
| 388 | + | r.client <- ports.WindowMapped{ID: 9} | |||
| 389 | + | scene(t, r.scenes) | |||
| 390 | + | r.press(t, "d") | |||
| 391 | + | r.press(t, "d") | |||
| 392 | + | noSpawn(t, r.spawn) | |||
| 387 | } | 393 | } | ||