detached 34856b1

feat(stash): capture windows opened while the stash is shown

commit
34856b1f41b2988fb255b0f9ca82a8bd7544d5e7
author
brice · · 2026-10-02 15:06 UTC
parent
b87e882
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +96 -3
M docs/config.md +1 -0
@@ -26,6 +26,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
2626 | `stash.width` | `80` | Width of the selected stashed window, over the tiles, in percent of the usable width; 10 to 90 (`80%` works too). The rest is split between both sides, where the neighbors show. Its height is 80% of the usable height |
2727 | `stash.gap` | `2` | Space between the selected stashed window and its neighbors, in percent of the usable width; 0 to 10. The neighbors show from there to the screen edge: with the defaults, 8% of each. A gap as wide as the side margin hides them |
2828 | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 |
29+| `stash.capture` | `on` | `on`: a window opened while the stash is shown joins its end, selected; `toggle-window-stash` tiles it as a new column. `off`: new windows always tile |
2930 | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up |
3031 | `layout.gaps` | `0` | Space between windows |
3132 | `layout.max-columns` | `2` | Columns that share the screen before scrolling |
@@ -26,6 +26,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
26 | `stash.width` | `80` | Width of the selected stashed window, over the tiles, in percent of the usable width; 10 to 90 (`80%` works too). The rest is split between both sides, where the neighbors show. Its height is 80% of the usable height |26 | `stash.width` | `80` | Width of the selected stashed window, over the tiles, in percent of the usable width; 10 to 90 (`80%` works too). The rest is split between both sides, where the neighbors show. Its height is 80% of the usable height |
27 | `stash.gap` | `2` | Space between the selected stashed window and its neighbors, in percent of the usable width; 0 to 10. The neighbors show from there to the screen edge: with the defaults, 8% of each. A gap as wide as the side margin hides them |27 | `stash.gap` | `2` | Space between the selected stashed window and its neighbors, in percent of the usable width; 0 to 10. The neighbors show from there to the screen edge: with the defaults, 8% of each. A gap as wide as the side margin hides them |
28 | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 |28 | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 |
29+| `stash.capture` | `on` | `on`: a window opened while the stash is shown joins its end, selected; `toggle-window-stash` tiles it as a new column. `off`: new windows always tile |
29 | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up |30 | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up |
30 | `layout.gaps` | `0` | Space between windows |31 | `layout.gaps` | `0` | Space between windows |
31 | `layout.max-columns` | `2` | Columns that share the screen before scrolling |32 | `layout.max-columns` | `2` | Columns that share the screen before scrolling |
M internal/adapters/config/config.go +7 -0
@@ -112,6 +112,7 @@ func Defaults() ports.Config {
112112 c.Stash.Width = 80
113113 c.Stash.Gap = 2
114114 c.Stash.Dim = 0.5
115+ c.Stash.Capture = true
115116 c.Border.Width = 2
116117 c.Border.Active = "#808080"
117118 c.Border.Inactive = "#111111"
@@ -504,6 +505,12 @@ func set(c *ports.Config, key, v string) error {
504505 return fmt.Errorf("must be between 0 and 1")
505506 }
506507 c.Stash.Dim = dim
508+ case "stash.capture":
509+ b, err := onOff(v)
510+ if err != nil {
511+ return err
512+ }
513+ c.Stash.Capture = b
507514 case "border.width":
508515 n, err := strconv.Atoi(v)
509516 if err != nil || n < 0 || n > 32 {
@@ -112,6 +112,7 @@ func Defaults() ports.Config {
112 c.Stash.Width = 80112 c.Stash.Width = 80
113 c.Stash.Gap = 2113 c.Stash.Gap = 2
114 c.Stash.Dim = 0.5114 c.Stash.Dim = 0.5
115+ c.Stash.Capture = true
115 c.Border.Width = 2116 c.Border.Width = 2
116 c.Border.Active = "#808080"117 c.Border.Active = "#808080"
117 c.Border.Inactive = "#111111"118 c.Border.Inactive = "#111111"
@@ -504,6 +505,12 @@ func set(c *ports.Config, key, v string) error {
504 return fmt.Errorf("must be between 0 and 1")505 return fmt.Errorf("must be between 0 and 1")
505 }506 }
506 c.Stash.Dim = dim507 c.Stash.Dim = dim
508+ case "stash.capture":
509+ b, err := onOff(v)
510+ if err != nil {
511+ return err
512+ }
513+ c.Stash.Capture = b
507 case "border.width":514 case "border.width":
508 n, err := strconv.Atoi(v)515 n, err := strconv.Atoi(v)
509 if err != nil || n < 0 || n > 32 {516 if err != nil || n < 0 || n > 32 {
M internal/adapters/config/config_test.go +12 -0
@@ -487,6 +487,18 @@ func TestStash(t *testing.T) {
487487 }
488488 }
489489
490+func TestStashCapture(t *testing.T) {
491+ if c, _ := parseString(t, ""); !c.Stash.Capture {
492+ t.Fatal("stash.capture is on by default")
493+ }
494+ if c, w := parseString(t, "stash.capture = off"); c.Stash.Capture || len(w) != 0 {
495+ t.Fatalf("off: %v %v", c.Stash.Capture, w)
496+ }
497+ if c, w := parseString(t, "stash.capture = yes"); !c.Stash.Capture || len(w) == 0 {
498+ t.Fatalf("invalid: %v %v", c.Stash.Capture, w)
499+ }
500+}
501+
490502 func TestVRRFlipGap(t *testing.T) {
491503 for _, tc := range []struct {
492504 value string
@@ -487,6 +487,18 @@ func TestStash(t *testing.T) {
487 }487 }
488 }488 }
489 489
490+func TestStashCapture(t *testing.T) {
491+ if c, _ := parseString(t, ""); !c.Stash.Capture {
492+ t.Fatal("stash.capture is on by default")
493+ }
494+ if c, w := parseString(t, "stash.capture = off"); c.Stash.Capture || len(w) != 0 {
495+ t.Fatalf("off: %v %v", c.Stash.Capture, w)
496+ }
497+ if c, w := parseString(t, "stash.capture = yes"); !c.Stash.Capture || len(w) == 0 {
498+ t.Fatalf("invalid: %v %v", c.Stash.Capture, w)
499+ }
500+}
501+
490 func TestVRRFlipGap(t *testing.T) {502 func TestVRRFlipGap(t *testing.T) {
491 for _, tc := range []struct {503 for _, tc := range []struct {
492 value string504 value string
M internal/core/monitor.go +11 -2
@@ -28,6 +28,8 @@ type Monitor struct {
2828 named []NamedWorkspace
2929 // followMove shows the target workspace after a move to it.
3030 followMove bool
31+ // stashCapture puts new windows in the shown stash (stash.capture).
32+ stashCapture bool
3133 // switchOff slides the view between numbered workspaces: the view is
3234 // at Active+switchOff while a swipe follows the fingers or its spring
3335 // (switchMotion) lands.
@@ -294,12 +296,15 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) {
294296 return nil, -1
295297 }
296298
297-// AddWindow places a new window on the workspace on screen (see arrive).
299+// AddWindow places a new window on the workspace on screen; with
300+// stash.capture, in its stash when shown.
298301 func (m *Monitor) AddWindow(id WindowID) {
299302 if w, _ := m.find(id); w != nil {
300303 return
301304 }
302- m.Current().AddWindow(id)
305+ if w := m.Current(); !m.stashCapture || !w.captureStash(id) {
306+ w.AddWindow(id)
307+ }
303308 m.normalize()
304309 }
305310
@@ -468,6 +473,10 @@ func (m *Monitor) SetStash(width, gap int) {
468473 }
469474 m.each(func(w *Workspace) { w.stashWidth, w.stashGap = width, min(max(gap, 0), 10) })
470475 }
476+
477+// SetStashCapture makes new windows join the shown stash (stash.capture).
478+func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }
479+
471480 func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
472481
473482 // SetFollowMove makes moves to another workspace show it (focus.follow-move).
@@ -28,6 +28,8 @@ type Monitor struct {
28 named []NamedWorkspace28 named []NamedWorkspace
29 // followMove shows the target workspace after a move to it.29 // followMove shows the target workspace after a move to it.
30 followMove bool30 followMove bool
31+ // stashCapture puts new windows in the shown stash (stash.capture).
32+ stashCapture bool
31 // switchOff slides the view between numbered workspaces: the view is33 // switchOff slides the view between numbered workspaces: the view is
32 // at Active+switchOff while a swipe follows the fingers or its spring34 // at Active+switchOff while a swipe follows the fingers or its spring
33 // (switchMotion) lands.35 // (switchMotion) lands.
@@ -294,12 +296,15 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) {
294 return nil, -1296 return nil, -1
295 }297 }
296 298
297-// AddWindow places a new window on the workspace on screen (see arrive).299+// AddWindow places a new window on the workspace on screen; with
300+// stash.capture, in its stash when shown.
298 func (m *Monitor) AddWindow(id WindowID) {301 func (m *Monitor) AddWindow(id WindowID) {
299 if w, _ := m.find(id); w != nil {302 if w, _ := m.find(id); w != nil {
300 return303 return
301 }304 }
302- m.Current().AddWindow(id)305+ if w := m.Current(); !m.stashCapture || !w.captureStash(id) {
306+ w.AddWindow(id)
307+ }
303 m.normalize()308 m.normalize()
304 }309 }
305 310
@@ -468,6 +473,10 @@ func (m *Monitor) SetStash(width, gap int) {
468 }473 }
469 m.each(func(w *Workspace) { w.stashWidth, w.stashGap = width, min(max(gap, 0), 10) })474 m.each(func(w *Workspace) { w.stashWidth, w.stashGap = width, min(max(gap, 0), 10) })
470 }475 }
476+
477+// SetStashCapture makes new windows join the shown stash (stash.capture).
478+func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }
479+
471 func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }480 func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
472 481
473 // SetFollowMove makes moves to another workspace show it (focus.follow-move).482 // SetFollowMove makes moves to another workspace show it (focus.follow-move).
M internal/core/screens.go +1 -0
@@ -395,6 +395,7 @@ func (c *Core) settings(m *Monitor) {
395395 m.SetGaps(c.cfg.Layout.Gaps)
396396 m.SetBorder(c.cfg.Border.Width)
397397 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)
398+ m.SetStashCapture(c.cfg.Stash.Capture)
398399 m.SetFollowMove(c.cfg.Focus.FollowMove)
399400 }
400401
@@ -395,6 +395,7 @@ func (c *Core) settings(m *Monitor) {
395 m.SetGaps(c.cfg.Layout.Gaps)395 m.SetGaps(c.cfg.Layout.Gaps)
396 m.SetBorder(c.cfg.Border.Width)396 m.SetBorder(c.cfg.Border.Width)
397 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)397 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)
398+ m.SetStashCapture(c.cfg.Stash.Capture)
398 m.SetFollowMove(c.cfg.Focus.FollowMove)399 m.SetFollowMove(c.cfg.Focus.FollowMove)
399 }400 }
400 401
M internal/core/stash.go +14 -0
@@ -67,6 +67,20 @@ func (w *Workspace) addStash(f Float) {
6767 w.showStash()
6868 }
6969
70+// captureStash puts a new window id in the shown stash, selected, and
71+// reports whether it did. Like a new tile, it leaves a focused native
72+// float focused. Under a covering fullscreen window the stash is not on
73+// screen: the window tiles as usual.
74+func (w *Workspace) captureStash(id WindowID) bool {
75+ if id == 0 || w.stashHidden || len(w.Stash) == 0 || w.cover() != 0 {
76+ return false
77+ }
78+ // No former column: toggle-window-stash tiles it as a new column.
79+ w.Stash = append(w.Stash, Float{ID: id})
80+ w.stashAt, w.stashFocus = len(w.Stash)-1, true
81+ return true
82+}
83+
7084 // showStash shows the stash and gives it the focus; a member whose
7185 // fullscreen waited while hidden is fullscreen again, selected, as if it
7286 // asked now.
@@ -67,6 +67,20 @@ func (w *Workspace) addStash(f Float) {
67 w.showStash()67 w.showStash()
68 }68 }
69 69
70+// captureStash puts a new window id in the shown stash, selected, and
71+// reports whether it did. Like a new tile, it leaves a focused native
72+// float focused. Under a covering fullscreen window the stash is not on
73+// screen: the window tiles as usual.
74+func (w *Workspace) captureStash(id WindowID) bool {
75+ if id == 0 || w.stashHidden || len(w.Stash) == 0 || w.cover() != 0 {
76+ return false
77+ }
78+ // No former column: toggle-window-stash tiles it as a new column.
79+ w.Stash = append(w.Stash, Float{ID: id})
80+ w.stashAt, w.stashFocus = len(w.Stash)-1, true
81+ return true
82+}
83+
70 // showStash shows the stash and gives it the focus; a member whose84 // showStash shows the stash and gives it the focus; a member whose
71 // fullscreen waited while hidden is fullscreen again, selected, as if it85 // fullscreen waited while hidden is fullscreen again, selected, as if it
72 // asked now.86 // asked now.
M internal/core/stash_test.go +47 -0
@@ -196,6 +196,53 @@ func TestStashFromRemovedNamedWorkspace(t *testing.T) {
196196 }
197197 }
198198
199+// With stash.capture, a window opened while the stash is shown joins its
200+// end, selected, and takes the focus unless a native dialog has it. A
201+// hidden stash, a covering fullscreen window or capture off let it tile.
202+func TestStashCapture(t *testing.T) {
203+ for _, tc := range []struct {
204+ name string
205+ overflow Overflow
206+ setup func(*Monitor)
207+ captured bool
208+ focused WindowID
209+ }{
210+ {"shown", OverflowScroll, func(*Monitor) {}, true, 4},
211+ {"shown fixed", OverflowFixed, func(*Monitor) {}, true, 4},
212+ {"dialog keeps focus", OverflowScroll, func(m *Monitor) { m.AddFloating(9, 10, 10) }, true, 9},
213+ {"hidden", OverflowScroll, func(m *Monitor) { m.Current().ToggleStashVisible() }, false, 4},
214+ {"under fullscreen", OverflowScroll, func(m *Monitor) { m.Current().SetFullscreen(3, true) }, false, 3},
215+ {"off", OverflowScroll, func(m *Monitor) { m.SetStashCapture(false) }, false, 3},
216+ } {
217+ t.Run(tc.name, func(t *testing.T) {
218+ m := stashMonitor(tc.overflow)
219+ m.SetStashCapture(true)
220+ tc.setup(m)
221+ w := m.Current()
222+ m.AddWindow(4)
223+ if captured := w.stashIndex(4) == 2 && w.stashAt == 2; captured != tc.captured {
224+ t.Fatalf("captured %v, stash %+v", captured, w.Stash)
225+ }
226+ if id, _ := w.Focused(); id != tc.focused {
227+ t.Fatalf("focused %d", id)
228+ }
229+ })
230+ }
231+}
232+
233+// A captured window has no former column: toggle-window-stash tiles it as
234+// a new column after the focused one.
235+func TestStashCaptureUnstash(t *testing.T) {
236+ m := stashMonitor(OverflowScroll)
237+ m.SetStashCapture(true)
238+ w := m.Current()
239+ m.AddWindow(4)
240+ w.ToggleWindowStash()
241+ if len(w.Columns) != 2 || w.Columns[1].Windows[0] != 4 || w.Focus != 1 || w.stashIndex(4) >= 0 {
242+ t.Fatalf("columns %+v focus %d stash %+v", w.Columns, w.Focus, w.Stash)
243+ }
244+}
245+
199246 // stash.width sets the selected window's width; the neighbors sit
200247 // stash.gap beside it, in what the margins show.
201248 func TestStashWidth(t *testing.T) {
@@ -196,6 +196,53 @@ func TestStashFromRemovedNamedWorkspace(t *testing.T) {
196 }196 }
197 }197 }
198 198
199+// With stash.capture, a window opened while the stash is shown joins its
200+// end, selected, and takes the focus unless a native dialog has it. A
201+// hidden stash, a covering fullscreen window or capture off let it tile.
202+func TestStashCapture(t *testing.T) {
203+ for _, tc := range []struct {
204+ name string
205+ overflow Overflow
206+ setup func(*Monitor)
207+ captured bool
208+ focused WindowID
209+ }{
210+ {"shown", OverflowScroll, func(*Monitor) {}, true, 4},
211+ {"shown fixed", OverflowFixed, func(*Monitor) {}, true, 4},
212+ {"dialog keeps focus", OverflowScroll, func(m *Monitor) { m.AddFloating(9, 10, 10) }, true, 9},
213+ {"hidden", OverflowScroll, func(m *Monitor) { m.Current().ToggleStashVisible() }, false, 4},
214+ {"under fullscreen", OverflowScroll, func(m *Monitor) { m.Current().SetFullscreen(3, true) }, false, 3},
215+ {"off", OverflowScroll, func(m *Monitor) { m.SetStashCapture(false) }, false, 3},
216+ } {
217+ t.Run(tc.name, func(t *testing.T) {
218+ m := stashMonitor(tc.overflow)
219+ m.SetStashCapture(true)
220+ tc.setup(m)
221+ w := m.Current()
222+ m.AddWindow(4)
223+ if captured := w.stashIndex(4) == 2 && w.stashAt == 2; captured != tc.captured {
224+ t.Fatalf("captured %v, stash %+v", captured, w.Stash)
225+ }
226+ if id, _ := w.Focused(); id != tc.focused {
227+ t.Fatalf("focused %d", id)
228+ }
229+ })
230+ }
231+}
232+
233+// A captured window has no former column: toggle-window-stash tiles it as
234+// a new column after the focused one.
235+func TestStashCaptureUnstash(t *testing.T) {
236+ m := stashMonitor(OverflowScroll)
237+ m.SetStashCapture(true)
238+ w := m.Current()
239+ m.AddWindow(4)
240+ w.ToggleWindowStash()
241+ if len(w.Columns) != 2 || w.Columns[1].Windows[0] != 4 || w.Focus != 1 || w.stashIndex(4) >= 0 {
242+ t.Fatalf("columns %+v focus %d stash %+v", w.Columns, w.Focus, w.Stash)
243+ }
244+}
245+
199 // stash.width sets the selected window's width; the neighbors sit246 // stash.width sets the selected window's width; the neighbors sit
200 // stash.gap beside it, in what the margins show.247 // stash.gap beside it, in what the margins show.
201 func TestStashWidth(t *testing.T) {248 func TestStashWidth(t *testing.T) {
M internal/ports/config.go +3 -1
@@ -81,10 +81,12 @@ type Config struct {
8181 // Stash is the strip of windows set aside by toggle-window-stash:
8282 // Width is the width of its selected window (10 to 90) and Gap the
8383 // space between it and its neighbors (0 to 10), in percent of the
84- // usable width; Dim darkens the neighbors, 0 to 1.
84+ // usable width; Dim darkens the neighbors, 0 to 1. Capture puts new
85+ // windows in the stash while it is shown.
8586 Stash struct {
8687 Width, Gap int
8788 Dim float64
89+ Capture bool
8890 }
8991 // Border is drawn inside the window edge; Width 0 disables it.
9092 Border struct {
@@ -81,10 +81,12 @@ type Config struct {
81 // Stash is the strip of windows set aside by toggle-window-stash:81 // Stash is the strip of windows set aside by toggle-window-stash:
82 // Width is the width of its selected window (10 to 90) and Gap the82 // Width is the width of its selected window (10 to 90) and Gap the
83 // space between it and its neighbors (0 to 10), in percent of the83 // space between it and its neighbors (0 to 10), in percent of the
84- // usable width; Dim darkens the neighbors, 0 to 1.84+ // usable width; Dim darkens the neighbors, 0 to 1. Capture puts new
85+ // windows in the stash while it is shown.
85 Stash struct {86 Stash struct {
86 Width, Gap int87 Width, Gap int
87 Dim float6488 Dim float64
89+ Capture bool
88 }90 }
89 // Border is drawn inside the window edge; Width 0 disables it.91 // Border is drawn inside the window edge; Width 0 disables it.
90 Border struct {92 Border struct {

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