detached 188d6b5

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
4747 }
4848 }
4949
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+
5061 func TestParse(t *testing.T) {
5162 c, w := parseString(t, `
5263 # 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 # comment63 # comment
M internal/core/expel.go +2 -1
@@ -65,7 +65,8 @@ func (w *Workspace) stack(id, target WindowID) {
6565 w.RemoveWindow(id)
6666 i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) })
6767 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)
6970 }
7071
7172 // 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) {
4949 if got := ids(tc.w); !reflect.DeepEqual(got, tc.want) || stays != tc.stays || tc.w.Focus != tc.focusCol {
5050 t.Fatal(got, stays, tc.w.Focus)
5151 }
52-
5352 })
5453 }
5554 }
@@ -82,6 +81,15 @@ func TestConsumeOrExpelSkips(t *testing.T) {
8281 })
8382 }
8483
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+
8593 func TestConsumeOrExpelSlots(t *testing.T) {
8694 slots := func(w *Workspace) []int {
8795 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 []int95 var out []int
M internal/core/monitor.go +6 -2
@@ -496,10 +496,14 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) {
496496 to.FocusID(id)
497497 } else {
498498 col := Column{Windows: []WindowID{id}}
499+ ok := true
499500 if column {
500- col, _ = cur.takeColumn()
501+ col, ok = cur.takeColumn()
501502 } else {
502- cur.RemoveWindow(id)
503+ _, ok = cur.takeWindow()
504+ }
505+ if !ok {
506+ return
503507 }
504508 to.receive(col, -1)
505509 }
@@ -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) {
246246 t.Fatal(got)
247247 }
248248 })
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+ })
249268 t.Run("slot column loses its slot", func(t *testing.T) {
250269 m := stack()
251270 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 = 2270 m.Current().Columns[1].Slot = 2
M internal/core/screens_test.go +22 -0
@@ -3,6 +3,7 @@ package core_test
33 import (
44 "context"
55 "reflect"
6+ "slices"
67 "testing"
78
89 "github.com/bnema/nefertty/internal/adapters/config"
@@ -579,3 +580,24 @@ func TestExpelCrossesToFullNeighbor(t *testing.T) {
579580 t.Fatal(got, focused)
580581 }
581582 }
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) {
391391 r.press(t, "d")
392392 noSpawn(t, r.spawn)
393393 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close