detached fa4ed84

feat(config): swap stash binds (#64)

* fix(core): open a window under a fullscreen stashed window without tiles

* feat(config): bind cmd+s to toggle-stash-visible and cmd+shift+s to toggle-window-stash
commit
fa4ed84d6af848e14613459ff0b29589bdadaca9
author
bnema · · 2026-09-29 16:13 UTC
committer
GitHub · · 2026-09-29 16:13 UTC
parent
1d16b83
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +34 -18
M docs/config.md +2 -2
@@ -173,8 +173,8 @@ A click on a preview picks it. Other binds still work. Native floating windows (
173173 | `cmd+r` | `cycle-column-width` |
174174 | `cmd+f` | `maximize-column` |
175175 | `cmd+shift+f` | `toggle-fullscreen` |
176-| `cmd+s` | `toggle-window-stash` |
177-| `cmd+shift+s` | `toggle-stash-visible` |
176+| `cmd+s` | `toggle-stash-visible` |
177+| `cmd+shift+s` | `toggle-window-stash` |
178178 | `cmd+o` | `toggle-overview` |
179179 | `cmd+q` | `close-window` |
180180 | `ctrl+alt+backspace` | `quit` |
@@ -173,8 +173,8 @@ A click on a preview picks it. Other binds still work. Native floating windows (
173 | `cmd+r` | `cycle-column-width` |173 | `cmd+r` | `cycle-column-width` |
174 | `cmd+f` | `maximize-column` |174 | `cmd+f` | `maximize-column` |
175 | `cmd+shift+f` | `toggle-fullscreen` |175 | `cmd+shift+f` | `toggle-fullscreen` |
176-| `cmd+s` | `toggle-window-stash` |176+| `cmd+s` | `toggle-stash-visible` |
177-| `cmd+shift+s` | `toggle-stash-visible` |177+| `cmd+shift+s` | `toggle-window-stash` |
178 | `cmd+o` | `toggle-overview` |178 | `cmd+o` | `toggle-overview` |
179 | `cmd+q` | `close-window` |179 | `cmd+q` | `close-window` |
180 | `ctrl+alt+backspace` | `quit` |180 | `ctrl+alt+backspace` | `quit` |
M examples/config +2 -2
@@ -128,8 +128,8 @@ bind.cmd+bracketright = consume-or-expel-window-right
128128 bind.cmd+r = cycle-column-width
129129 bind.cmd+f = maximize-column
130130 bind.cmd+shift+f = toggle-fullscreen
131-bind.cmd+s = toggle-window-stash
132-bind.cmd+shift+s = toggle-stash-visible
131+bind.cmd+s = toggle-stash-visible
132+bind.cmd+shift+s = toggle-window-stash
133133 bind.cmd+o = toggle-overview
134134 bind.cmd+q = close-window
135135 bind.ctrl+alt+backspace = quit
@@ -128,8 +128,8 @@ bind.cmd+bracketright = consume-or-expel-window-right
128 bind.cmd+r = cycle-column-width128 bind.cmd+r = cycle-column-width
129 bind.cmd+f = maximize-column129 bind.cmd+f = maximize-column
130 bind.cmd+shift+f = toggle-fullscreen130 bind.cmd+shift+f = toggle-fullscreen
131-bind.cmd+s = toggle-window-stash131+bind.cmd+s = toggle-stash-visible
132-bind.cmd+shift+s = toggle-stash-visible132+bind.cmd+shift+s = toggle-window-stash
133 bind.cmd+o = toggle-overview133 bind.cmd+o = toggle-overview
134 bind.cmd+q = close-window134 bind.cmd+q = close-window
135 bind.ctrl+alt+backspace = quit135 bind.ctrl+alt+backspace = quit
M internal/adapters/config/config.go +2 -2
@@ -37,8 +37,8 @@ var defaultBinds = []struct{ combo, action string }{
3737 {"cmd+r", "cycle-column-width"},
3838 {"cmd+f", "maximize-column"},
3939 {"cmd+shift+f", "toggle-fullscreen"},
40- {"cmd+s", "toggle-window-stash"},
41- {"cmd+shift+s", "toggle-stash-visible"},
40+ {"cmd+s", "toggle-stash-visible"},
41+ {"cmd+shift+s", "toggle-window-stash"},
4242 {"cmd+o", "toggle-overview"},
4343 {"cmd+q", "close-window"},
4444 {"ctrl+alt+backspace", "quit"},
@@ -37,8 +37,8 @@ var defaultBinds = []struct{ combo, action string }{
37 {"cmd+r", "cycle-column-width"},37 {"cmd+r", "cycle-column-width"},
38 {"cmd+f", "maximize-column"},38 {"cmd+f", "maximize-column"},
39 {"cmd+shift+f", "toggle-fullscreen"},39 {"cmd+shift+f", "toggle-fullscreen"},
40- {"cmd+s", "toggle-window-stash"},40+ {"cmd+s", "toggle-stash-visible"},
41- {"cmd+shift+s", "toggle-stash-visible"},41+ {"cmd+shift+s", "toggle-window-stash"},
42 {"cmd+o", "toggle-overview"},42 {"cmd+o", "toggle-overview"},
43 {"cmd+q", "close-window"},43 {"cmd+q", "close-window"},
44 {"ctrl+alt+backspace", "quit"},44 {"ctrl+alt+backspace", "quit"},
M internal/adapters/config/config_test.go +1 -1
@@ -26,7 +26,7 @@ func TestDefaultsAndLoad(t *testing.T) {
2626 if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 56 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {
2727 t.Fatalf("defaults: %+v", d)
2828 }
29- if d.Binds["Cmd+s"] != "toggle-window-stash" || d.Binds["Cmd+Shift+s"] != "toggle-stash-visible" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
29+ if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
3030 t.Fatal(d.Binds)
3131 }
3232 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
@@ -26,7 +26,7 @@ func TestDefaultsAndLoad(t *testing.T) {
26 if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 56 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {26 if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 56 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {
27 t.Fatalf("defaults: %+v", d)27 t.Fatalf("defaults: %+v", d)
28 }28 }
29- if d.Binds["Cmd+s"] != "toggle-window-stash" || d.Binds["Cmd+Shift+s"] != "toggle-stash-visible" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {29+ if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
30 t.Fatal(d.Binds)30 t.Fatal(d.Binds)
31 }31 }
32 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {32 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
M internal/core/layout.go +2 -1
@@ -287,7 +287,8 @@ func (w *Workspace) AddWindow(id WindowID) {
287287 // window (ADR 011): it waits, hidden, after the focused column.
288288 at := len(w.Columns)
289289 if w.Overflow != OverflowFixed {
290- at = w.Focus + 1
290+ // No tiles when the covering window is a stashed one.
291+ at = min(w.Focus+1, len(w.Columns))
291292 }
292293 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}})
293294 return
@@ -287,7 +287,8 @@ func (w *Workspace) AddWindow(id WindowID) {
287 // window (ADR 011): it waits, hidden, after the focused column.287 // window (ADR 011): it waits, hidden, after the focused column.
288 at := len(w.Columns)288 at := len(w.Columns)
289 if w.Overflow != OverflowFixed {289 if w.Overflow != OverflowFixed {
290- at = w.Focus + 1290+ // No tiles when the covering window is a stashed one.
291+ at = min(w.Focus+1, len(w.Columns))
291 }292 }
292 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}})293 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}})
293 return294 return
M internal/core/screens_test.go +10 -10
@@ -888,14 +888,14 @@ func TestStashEndToEnd(t *testing.T) {
888888 for id := ports.WindowID(1); id <= 3; id++ {
889889 r.mapWindow(t, id)
890890 }
891- r.key(t, "s", ports.ModAlt) // 3 stashed
892- r.key(t, "Left", ports.ModAlt) // stays on 3: its stash's only window
893- sc := r.key(t, "s", ports.ModAlt|ports.ModShift)
891+ r.key(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed
892+ r.key(t, "Left", ports.ModAlt) // stays on 3: its stash's only window
893+ sc := r.key(t, "s", ports.ModAlt)
894894 if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) {
895895 t.Fatalf("hidden stash: %v", got)
896896 }
897897 r.key(t, "Left", ports.ModAlt) // tiles: from 2 to 1
898- sc = r.key(t, "s", ports.ModAlt)
898+ sc = r.key(t, "s", ports.ModAlt|ports.ModShift)
899899 // Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed.
900900 var peek, sel ports.SceneWindow
901901 for _, w := range sc[0].Windows {
@@ -937,7 +937,7 @@ func TestStashEndToEnd(t *testing.T) {
937937 t.Fatalf("tile %+v", w)
938938 }
939939 r.input <- ports.PointerButton{Button: 0x110}
940- r.key(t, "s", ports.ModAlt|ports.ModShift)
940+ r.key(t, "s", ports.ModAlt)
941941 st = receive(t, r.state)
942942 for !st.Windows[0].Hidden {
943943 st = receive(t, r.state)
@@ -974,7 +974,7 @@ func TestStashKeepsFocusAtEdges(t *testing.T) {
974974 r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1
975975 r.mapWindow(t, 1)
976976 r.mapWindow(t, 2)
977- r.key(t, "s", ports.ModAlt)
977+ r.key(t, "s", ports.ModAlt|ports.ModShift)
978978 for _, k := range []string{"Right", "Down"} {
979979 r.key(t, k, ports.ModAlt)
980980 }
@@ -1008,7 +1008,7 @@ func TestStashHidesOnBackgroundClick(t *testing.T) {
10081008 r := startMulti(t, func(c *ports.Config) { c.Stash.Gap = 10 }, left)
10091009 r.mapWindow(t, 1)
10101010 r.mapWindow(t, 2)
1011- r.key(t, "s", ports.ModAlt) // 2 stashed and focused, over tile 1
1011+ r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 1
10121012 r.input <- ports.PointerMotion{X: 5, Y: 50}
10131013 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
10141014 st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 1 })
@@ -1025,10 +1025,10 @@ func TestStashThinPeekClick(t *testing.T) {
10251025 r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Border.Width = 9, 4 }, left)
10261026 r.mapWindow(t, 1)
10271027 r.mapWindow(t, 2)
1028- r.key(t, "s", ports.ModAlt) // 2 stashed
1029- r.key(t, "s", ports.ModAlt|ports.ModShift)
1028+ r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed
1029+ r.key(t, "s", ports.ModAlt)
10301030 r.key(t, "Left", ports.ModAlt)
1031- r.key(t, "s", ports.ModAlt) // stash 2 1, 2 peeks 2px from the left
1031+ r.key(t, "s", ports.ModAlt|ports.ModShift) // stash 2 1, 2 peeks 2px from the left
10321032 r.input <- ports.PointerMotion{X: 1, Y: 50}
10331033 for {
10341034 if v, ok := (<-r.commands).(ports.PointerFocus); ok {
@@ -888,14 +888,14 @@ func TestStashEndToEnd(t *testing.T) {
888 for id := ports.WindowID(1); id <= 3; id++ {888 for id := ports.WindowID(1); id <= 3; id++ {
889 r.mapWindow(t, id)889 r.mapWindow(t, id)
890 }890 }
891- r.key(t, "s", ports.ModAlt) // 3 stashed891+ r.key(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed
892- r.key(t, "Left", ports.ModAlt) // stays on 3: its stash's only window892+ r.key(t, "Left", ports.ModAlt) // stays on 3: its stash's only window
893- sc := r.key(t, "s", ports.ModAlt|ports.ModShift)893+ sc := r.key(t, "s", ports.ModAlt)
894 if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) {894 if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) {
895 t.Fatalf("hidden stash: %v", got)895 t.Fatalf("hidden stash: %v", got)
896 }896 }
897 r.key(t, "Left", ports.ModAlt) // tiles: from 2 to 1897 r.key(t, "Left", ports.ModAlt) // tiles: from 2 to 1
898- sc = r.key(t, "s", ports.ModAlt)898+ sc = r.key(t, "s", ports.ModAlt|ports.ModShift)
899 // Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed.899 // Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed.
900 var peek, sel ports.SceneWindow900 var peek, sel ports.SceneWindow
901 for _, w := range sc[0].Windows {901 for _, w := range sc[0].Windows {
@@ -937,7 +937,7 @@ func TestStashEndToEnd(t *testing.T) {
937 t.Fatalf("tile %+v", w)937 t.Fatalf("tile %+v", w)
938 }938 }
939 r.input <- ports.PointerButton{Button: 0x110}939 r.input <- ports.PointerButton{Button: 0x110}
940- r.key(t, "s", ports.ModAlt|ports.ModShift)940+ r.key(t, "s", ports.ModAlt)
941 st = receive(t, r.state)941 st = receive(t, r.state)
942 for !st.Windows[0].Hidden {942 for !st.Windows[0].Hidden {
943 st = receive(t, r.state)943 st = receive(t, r.state)
@@ -974,7 +974,7 @@ func TestStashKeepsFocusAtEdges(t *testing.T) {
974 r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1974 r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1
975 r.mapWindow(t, 1)975 r.mapWindow(t, 1)
976 r.mapWindow(t, 2)976 r.mapWindow(t, 2)
977- r.key(t, "s", ports.ModAlt)977+ r.key(t, "s", ports.ModAlt|ports.ModShift)
978 for _, k := range []string{"Right", "Down"} {978 for _, k := range []string{"Right", "Down"} {
979 r.key(t, k, ports.ModAlt)979 r.key(t, k, ports.ModAlt)
980 }980 }
@@ -1008,7 +1008,7 @@ func TestStashHidesOnBackgroundClick(t *testing.T) {
1008 r := startMulti(t, func(c *ports.Config) { c.Stash.Gap = 10 }, left)1008 r := startMulti(t, func(c *ports.Config) { c.Stash.Gap = 10 }, left)
1009 r.mapWindow(t, 1)1009 r.mapWindow(t, 1)
1010 r.mapWindow(t, 2)1010 r.mapWindow(t, 2)
1011- r.key(t, "s", ports.ModAlt) // 2 stashed and focused, over tile 11011+ r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 1
1012 r.input <- ports.PointerMotion{X: 5, Y: 50}1012 r.input <- ports.PointerMotion{X: 5, Y: 50}
1013 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}1013 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
1014 st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 1 })1014 st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 1 })
@@ -1025,10 +1025,10 @@ func TestStashThinPeekClick(t *testing.T) {
1025 r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Border.Width = 9, 4 }, left)1025 r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Border.Width = 9, 4 }, left)
1026 r.mapWindow(t, 1)1026 r.mapWindow(t, 1)
1027 r.mapWindow(t, 2)1027 r.mapWindow(t, 2)
1028- r.key(t, "s", ports.ModAlt) // 2 stashed1028+ r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed
1029- r.key(t, "s", ports.ModAlt|ports.ModShift)1029+ r.key(t, "s", ports.ModAlt)
1030 r.key(t, "Left", ports.ModAlt)1030 r.key(t, "Left", ports.ModAlt)
1031- r.key(t, "s", ports.ModAlt) // stash 2 1, 2 peeks 2px from the left1031+ r.key(t, "s", ports.ModAlt|ports.ModShift) // stash 2 1, 2 peeks 2px from the left
1032 r.input <- ports.PointerMotion{X: 1, Y: 50}1032 r.input <- ports.PointerMotion{X: 1, Y: 50}
1033 for {1033 for {
1034 if v, ok := (<-r.commands).(ports.PointerFocus); ok {1034 if v, ok := (<-r.commands).(ports.PointerFocus); ok {
M internal/core/stash_test.go +15 -0
@@ -140,6 +140,21 @@ func TestStashAloneHasFocus(t *testing.T) {
140140 }
141141 }
142142
143+// A window opening under a fullscreen stashed window, with no tiles left,
144+// waits as the only column.
145+func TestStashFullscreenWithoutTilesAddWindow(t *testing.T) {
146+ m := newMonitor("", "")
147+ m.SetOutput(100, 80)
148+ m.AddWindow(1)
149+ w := m.Current()
150+ w.ToggleWindowStash()
151+ w.ToggleFullscreen()
152+ w.AddWindow(2)
153+ if len(w.Columns) != 1 || w.Columns[0].Windows[0] != 2 || w.cover() != 1 {
154+ t.Fatalf("columns %+v cover %d", w.Columns, w.cover())
155+ }
156+}
157+
143158 // A fullscreen request made while the stash was hidden applies on show,
144159 // even over an off-screen fullscreen tile.
145160 func TestStashPendingFullscreenOverTile(t *testing.T) {
@@ -140,6 +140,21 @@ func TestStashAloneHasFocus(t *testing.T) {
140 }140 }
141 }141 }
142 142
143+// A window opening under a fullscreen stashed window, with no tiles left,
144+// waits as the only column.
145+func TestStashFullscreenWithoutTilesAddWindow(t *testing.T) {
146+ m := newMonitor("", "")
147+ m.SetOutput(100, 80)
148+ m.AddWindow(1)
149+ w := m.Current()
150+ w.ToggleWindowStash()
151+ w.ToggleFullscreen()
152+ w.AddWindow(2)
153+ if len(w.Columns) != 1 || w.Columns[0].Windows[0] != 2 || w.cover() != 1 {
154+ t.Fatalf("columns %+v cover %d", w.Columns, w.cover())
155+ }
156+}
157+
143 // A fullscreen request made while the stash was hidden applies on show,158 // A fullscreen request made while the stash was hidden applies on show,
144 // even over an off-screen fullscreen tile.159 // even over an off-screen fullscreen tile.
145 func TestStashPendingFullscreenOverTile(t *testing.T) {160 func TestStashPendingFullscreenOverTile(t *testing.T) {

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