fix(core): show a window stacked into a fullscreen column and keep slots unique
stack activates the window so a scroll fullscreen in the target column no longer hides it. move-window-to-workspace releases the slot of a slot window leaving a stacked column. Adds core tests for expel onto a fixed fullscreen, slot expel without respawn, follow-move with floats, and config parsing.
- commit
- 188d6b56003f68929fc6b96f8eb161c02e687117
- author
- brice · · 2026-09-26 14:54 UTC
- parent
- ade7c5a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +88 -4
M internal/adapters/config/config_test.go +11 -0
| @@ -47,6 +47,17 @@ func TestDefaultsAndLoad(t *testing.T) { | |||
| 47 | 47 | } | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | + | func TestFollowMove(t *testing.T) { | |
| 51 | + | c, w := parseString(t, "focus.follow-move = on\n") | |
| 52 | + | if !c.Focus.FollowMove || len(w) != 0 { | |
| 53 | + | t.Fatal(c.Focus, w) | |
| 54 | + | } | |
| 55 | + | c, w = parseString(t, "focus.follow-move = yes\n") | |
| 56 | + | if c.Focus.FollowMove || len(w) != 1 { | |
| 57 | + | t.Fatal(c.Focus, w) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 50 | 61 | func TestParse(t *testing.T) { | |
| 51 | 62 | c, w := parseString(t, ` | |
| 52 | 63 | # comment | |
| @@ -47,6 +47,17 @@ func TestDefaultsAndLoad(t *testing.T) { | |||||
| 47 | } | 47 | } | ||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | + | func TestFollowMove(t *testing.T) { | |||
| 51 | + | c, w := parseString(t, "focus.follow-move = on\n") | |||
| 52 | + | if !c.Focus.FollowMove || len(w) != 0 { | |||
| 53 | + | t.Fatal(c.Focus, w) | |||
| 54 | + | } | |||
| 55 | + | c, w = parseString(t, "focus.follow-move = yes\n") | |||
| 56 | + | if c.Focus.FollowMove || len(w) != 1 { | |||
| 57 | + | t.Fatal(c.Focus, w) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 50 | func TestParse(t *testing.T) { | 61 | func TestParse(t *testing.T) { | ||
| 51 | c, w := parseString(t, ` | 62 | c, w := parseString(t, ` | ||
| 52 | # comment | 63 | # comment | ||
M internal/core/expel.go +2 -1
| @@ -65,7 +65,8 @@ func (w *Workspace) stack(id, target WindowID) { | |||
| 65 | 65 | w.RemoveWindow(id) | |
| 66 | 66 | i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) }) | |
| 67 | 67 | w.Columns[i].Windows = append(w.Columns[i].Windows, id) | |
| 68 | - | w.FocusID(id) | |
| 68 | + | // Activate leaves a fullscreen of the target column, which would hide it. | |
| 69 | + | w.Activate(id) | |
| 69 | 70 | } | |
| 70 | 71 | ||
| 71 | 72 | // dropSlotOf clears the slot of the column whose slot window is id. | |
| @@ -65,7 +65,8 @@ func (w *Workspace) stack(id, target WindowID) { | |||||
| 65 | w.RemoveWindow(id) | 65 | w.RemoveWindow(id) | ||
| 66 | i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) }) | 66 | i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) }) | ||
| 67 | w.Columns[i].Windows = append(w.Columns[i].Windows, id) | 67 | w.Columns[i].Windows = append(w.Columns[i].Windows, id) | ||
| 68 | - | w.FocusID(id) | 68 | + | // Activate leaves a fullscreen of the target column, which would hide it. |
| 69 | + | w.Activate(id) | |||
| 69 | } | 70 | } | ||
| 70 | 71 | ||||
| 71 | // dropSlotOf clears the slot of the column whose slot window is id. | 72 | // dropSlotOf clears the slot of the column whose slot window is id. | ||
M internal/core/expel_test.go +9 -1
| @@ -49,7 +49,6 @@ func TestConsumeOrExpel(t *testing.T) { | |||
| 49 | 49 | if got := ids(tc.w); !reflect.DeepEqual(got, tc.want) || stays != tc.stays || tc.w.Focus != tc.focusCol { | |
| 50 | 50 | t.Fatal(got, stays, tc.w.Focus) | |
| 51 | 51 | } | |
| 52 | - | ||
| 53 | 52 | }) | |
| 54 | 53 | } | |
| 55 | 54 | } | |
| @@ -82,6 +81,15 @@ func TestConsumeOrExpelSkips(t *testing.T) { | |||
| 82 | 81 | }) | |
| 83 | 82 | } | |
| 84 | 83 | ||
| 84 | + | func TestConsumeIntoFullscreenColumnShows(t *testing.T) { | |
| 85 | + | w := cols(OverflowScroll, 2, 0, []WindowID{1}, []WindowID{2}) | |
| 86 | + | w.fullscreen = 2 | |
| 87 | + | w.ConsumeOrExpel(1) | |
| 88 | + | if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{2, 1}}) || id != 1 || w.fullscreen != 0 { | |
| 89 | + | t.Fatal(ids(w), id, w.fullscreen) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 85 | 93 | func TestConsumeOrExpelSlots(t *testing.T) { | |
| 86 | 94 | slots := func(w *Workspace) []int { | |
| 87 | 95 | var out []int | |
| @@ -49,7 +49,6 @@ func TestConsumeOrExpel(t *testing.T) { | |||||
| 49 | if got := ids(tc.w); !reflect.DeepEqual(got, tc.want) || stays != tc.stays || tc.w.Focus != tc.focusCol { | 49 | if got := ids(tc.w); !reflect.DeepEqual(got, tc.want) || stays != tc.stays || tc.w.Focus != tc.focusCol { | ||
| 50 | t.Fatal(got, stays, tc.w.Focus) | 50 | t.Fatal(got, stays, tc.w.Focus) | ||
| 51 | } | 51 | } | ||
| 52 | - | ||||
| 53 | }) | 52 | }) | ||
| 54 | } | 53 | } | ||
| 55 | } | 54 | } | ||
| @@ -82,6 +81,15 @@ func TestConsumeOrExpelSkips(t *testing.T) { | |||||
| 82 | }) | 81 | }) | ||
| 83 | } | 82 | } | ||
| 84 | 83 | ||||
| 84 | + | func TestConsumeIntoFullscreenColumnShows(t *testing.T) { | |||
| 85 | + | w := cols(OverflowScroll, 2, 0, []WindowID{1}, []WindowID{2}) | |||
| 86 | + | w.fullscreen = 2 | |||
| 87 | + | w.ConsumeOrExpel(1) | |||
| 88 | + | if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{2, 1}}) || id != 1 || w.fullscreen != 0 { | |||
| 89 | + | t.Fatal(ids(w), id, w.fullscreen) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 85 | func TestConsumeOrExpelSlots(t *testing.T) { | 93 | func TestConsumeOrExpelSlots(t *testing.T) { | ||
| 86 | slots := func(w *Workspace) []int { | 94 | slots := func(w *Workspace) []int { | ||
| 87 | var out []int | 95 | var out []int | ||
M internal/core/monitor.go +6 -2
| @@ -496,10 +496,14 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||
| 496 | 496 | to.FocusID(id) | |
| 497 | 497 | } else { | |
| 498 | 498 | col := Column{Windows: []WindowID{id}} | |
| 499 | + | ok := true | |
| 499 | 500 | if column { | |
| 500 | - | col, _ = cur.takeColumn() | |
| 501 | + | col, ok = cur.takeColumn() | |
| 501 | 502 | } else { | |
| 502 | - | cur.RemoveWindow(id) | |
| 503 | + | _, ok = cur.takeWindow() | |
| 504 | + | } | |
| 505 | + | if !ok { | |
| 506 | + | return | |
| 503 | 507 | } | |
| 504 | 508 | to.receive(col, -1) | |
| 505 | 509 | } | |
| @@ -496,10 +496,14 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||||
| 496 | to.FocusID(id) | 496 | to.FocusID(id) | ||
| 497 | } else { | 497 | } else { | ||
| 498 | col := Column{Windows: []WindowID{id}} | 498 | col := Column{Windows: []WindowID{id}} | ||
| 499 | + | ok := true | |||
| 499 | if column { | 500 | if column { | ||
| 500 | - | col, _ = cur.takeColumn() | 501 | + | col, ok = cur.takeColumn() |
| 501 | } else { | 502 | } else { | ||
| 502 | - | cur.RemoveWindow(id) | 503 | + | _, ok = cur.takeWindow() |
| 504 | + | } | |||
| 505 | + | if !ok { | |||
| 506 | + | return | |||
| 503 | } | 507 | } | ||
| 504 | to.receive(col, -1) | 508 | to.receive(col, -1) | ||
| 505 | } | 509 | } | ||
M internal/core/monitor_test.go +19 -0
| @@ -246,6 +246,25 @@ func TestMoveColumnToWorkspace(t *testing.T) { | |||
| 246 | 246 | t.Fatal(got) | |
| 247 | 247 | } | |
| 248 | 248 | }) | |
| 249 | + | t.Run("the slot window alone releases its slot", func(t *testing.T) { | |
| 250 | + | m := stack() | |
| 251 | + | c := &m.Current().Columns[1] | |
| 252 | + | c.Slot, c.Focus = 2, 0 | |
| 253 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |
| 254 | + | if c := m.Current().Columns[1]; c.Slot != 0 || c.Windows[0] != 3 { | |
| 255 | + | t.Fatal(c) | |
| 256 | + | } | |
| 257 | + | }) | |
| 258 | + | t.Run("follow a floating window", func(t *testing.T) { | |
| 259 | + | m := stack() | |
| 260 | + | m.SetFollowMove(true) | |
| 261 | + | m.AddFloating(9, 10, 10) | |
| 262 | + | m.Current().FocusID(9) | |
| 263 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |
| 264 | + | if id, _ := m.Focused(); m.Active != 1 || id != 9 { | |
| 265 | + | t.Fatal(m.Active, id) | |
| 266 | + | } | |
| 267 | + | }) | |
| 249 | 268 | t.Run("slot column loses its slot", func(t *testing.T) { | |
| 250 | 269 | m := stack() | |
| 251 | 270 | m.Current().Columns[1].Slot = 2 | |
| @@ -246,6 +246,25 @@ func TestMoveColumnToWorkspace(t *testing.T) { | |||||
| 246 | t.Fatal(got) | 246 | t.Fatal(got) | ||
| 247 | } | 247 | } | ||
| 248 | }) | 248 | }) | ||
| 249 | + | t.Run("the slot window alone releases its slot", func(t *testing.T) { | |||
| 250 | + | m := stack() | |||
| 251 | + | c := &m.Current().Columns[1] | |||
| 252 | + | c.Slot, c.Focus = 2, 0 | |||
| 253 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |||
| 254 | + | if c := m.Current().Columns[1]; c.Slot != 0 || c.Windows[0] != 3 { | |||
| 255 | + | t.Fatal(c) | |||
| 256 | + | } | |||
| 257 | + | }) | |||
| 258 | + | t.Run("follow a floating window", func(t *testing.T) { | |||
| 259 | + | m := stack() | |||
| 260 | + | m.SetFollowMove(true) | |||
| 261 | + | m.AddFloating(9, 10, 10) | |||
| 262 | + | m.Current().FocusID(9) | |||
| 263 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |||
| 264 | + | if id, _ := m.Focused(); m.Active != 1 || id != 9 { | |||
| 265 | + | t.Fatal(m.Active, id) | |||
| 266 | + | } | |||
| 267 | + | }) | |||
| 249 | t.Run("slot column loses its slot", func(t *testing.T) { | 268 | t.Run("slot column loses its slot", func(t *testing.T) { | ||
| 250 | m := stack() | 269 | m := stack() | ||
| 251 | m.Current().Columns[1].Slot = 2 | 270 | m.Current().Columns[1].Slot = 2 | ||
M internal/core/screens_test.go +22 -0
| @@ -3,6 +3,7 @@ package core_test | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "reflect" | |
| 6 | + | "slices" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/nefertty/internal/adapters/config" | |
| @@ -579,3 +580,24 @@ func TestExpelCrossesToFullNeighbor(t *testing.T) { | |||
| 579 | 580 | t.Fatal(got, focused) | |
| 580 | 581 | } | |
| 581 | 582 | } | |
| 583 | + | ||
| 584 | + | func TestExpelOntoFixedFullscreenFloats(t *testing.T) { | |
| 585 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right) | |
| 586 | + | // DP-1: [1] [2 3], focus on 3. | |
| 587 | + | r.mapWindow(t, 1) | |
| 588 | + | r.mapWindow(t, 2) | |
| 589 | + | r.mapWindow(t, 3) | |
| 590 | + | r.key(t, "bracketleft", ports.ModAlt) | |
| 591 | + | // DP-2: [4] [5]; 5 goes fullscreen on its own workspace. | |
| 592 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 593 | + | r.mapWindow(t, 4) | |
| 594 | + | r.mapWindow(t, 5) | |
| 595 | + | r.key(t, "f", ports.ModAlt|ports.ModShift) | |
| 596 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |
| 597 | + | // 3 floats above the fullscreen window, visible and focused. | |
| 598 | + | set := r.key(t, "bracketright", ports.ModAlt) | |
| 599 | + | got, focused := windowsOf(set, "DP-2") | |
| 600 | + | if !slices.Contains(got, 3) || focused != 3 { | |
| 601 | + | t.Fatal(got, focused) | |
| 602 | + | } | |
| 603 | + | } | |
| @@ -3,6 +3,7 @@ package core_test | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "reflect" | 5 | "reflect" | ||
| 6 | + | "slices" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/nefertty/internal/adapters/config" | 9 | "github.com/bnema/nefertty/internal/adapters/config" | ||
| @@ -579,3 +580,24 @@ func TestExpelCrossesToFullNeighbor(t *testing.T) { | |||||
| 579 | t.Fatal(got, focused) | 580 | t.Fatal(got, focused) | ||
| 580 | } | 581 | } | ||
| 581 | } | 582 | } | ||
| 583 | + | ||||
| 584 | + | func TestExpelOntoFixedFullscreenFloats(t *testing.T) { | |||
| 585 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right) | |||
| 586 | + | // DP-1: [1] [2 3], focus on 3. | |||
| 587 | + | r.mapWindow(t, 1) | |||
| 588 | + | r.mapWindow(t, 2) | |||
| 589 | + | r.mapWindow(t, 3) | |||
| 590 | + | r.key(t, "bracketleft", ports.ModAlt) | |||
| 591 | + | // DP-2: [4] [5]; 5 goes fullscreen on its own workspace. | |||
| 592 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 593 | + | r.mapWindow(t, 4) | |||
| 594 | + | r.mapWindow(t, 5) | |||
| 595 | + | r.key(t, "f", ports.ModAlt|ports.ModShift) | |||
| 596 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |||
| 597 | + | // 3 floats above the fullscreen window, visible and focused. | |||
| 598 | + | set := r.key(t, "bracketright", ports.ModAlt) | |||
| 599 | + | got, focused := windowsOf(set, "DP-2") | |||
| 600 | + | if !slices.Contains(got, 3) || focused != 3 { | |||
| 601 | + | t.Fatal(got, focused) | |||
| 602 | + | } | |||
| 603 | + | } | |||
M internal/core/slots_test.go +19 -0
| @@ -391,3 +391,22 @@ func TestSlotKeptWhileFullscreenAway(t *testing.T) { | |||
| 391 | 391 | r.press(t, "d") | |
| 392 | 392 | noSpawn(t, r.spawn) | |
| 393 | 393 | } | |
| 394 | + | ||
| 395 | + | // Expelling a slot window from a stacked column keeps one column per slot: | |
| 396 | + | // the slot and its width follow the window, and nothing is respawned. | |
| 397 | + | func TestSlotFollowsExpelledWindow(t *testing.T) { | |
| 398 | + | r := startSlots(t, true) // dev is numbered: [1] [dev] [empty] | |
| 399 | + | r.fill(t) | |
| 400 | + | r.press(t, "d") // dev: [code] [foot], focus on code | |
| 401 | + | r.client <- ports.WindowMapped{ID: 9} | |
| 402 | + | sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 }) | |
| 403 | + | r.press(t, "bracketleft") // 9 joins code: [code 9] [foot] | |
| 404 | + | r.press(t, "Up") // focus code, the slot window | |
| 405 | + | r.press(t, "bracketright") // code leaves: [9] [code] [foot] | |
| 406 | + | r.press(t, "d") | |
| 407 | + | s := r.press(t, "d") // shown again: no slot is empty | |
| 408 | + | noSpawn(t, r.spawn) | |
| 409 | + | if got := visible(s); got[2].W != 70 { | |
| 410 | + | t.Fatalf("slots: %v", got) | |
| 411 | + | } | |
| 412 | + | } | |
| @@ -391,3 +391,22 @@ func TestSlotKeptWhileFullscreenAway(t *testing.T) { | |||||
| 391 | r.press(t, "d") | 391 | r.press(t, "d") | ||
| 392 | noSpawn(t, r.spawn) | 392 | noSpawn(t, r.spawn) | ||
| 393 | } | 393 | } | ||
| 394 | + | ||||
| 395 | + | // Expelling a slot window from a stacked column keeps one column per slot: | |||
| 396 | + | // the slot and its width follow the window, and nothing is respawned. | |||
| 397 | + | func TestSlotFollowsExpelledWindow(t *testing.T) { | |||
| 398 | + | r := startSlots(t, true) // dev is numbered: [1] [dev] [empty] | |||
| 399 | + | r.fill(t) | |||
| 400 | + | r.press(t, "d") // dev: [code] [foot], focus on code | |||
| 401 | + | r.client <- ports.WindowMapped{ID: 9} | |||
| 402 | + | sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 }) | |||
| 403 | + | r.press(t, "bracketleft") // 9 joins code: [code 9] [foot] | |||
| 404 | + | r.press(t, "Up") // focus code, the slot window | |||
| 405 | + | r.press(t, "bracketright") // code leaves: [9] [code] [foot] | |||
| 406 | + | r.press(t, "d") | |||
| 407 | + | s := r.press(t, "d") // shown again: no slot is empty | |||
| 408 | + | noSpawn(t, r.spawn) | |||
| 409 | + | if got := visible(s); got[2].W != 70 { | |||
| 410 | + | t.Fatalf("slots: %v", got) | |||
| 411 | + | } | |||
| 412 | + | } | |||