detached 9f4c5da

fix(workspaces): one spawn per slot, only the pending token fills a slot

Random slot tokens; a token is accepted only for its pending spawn, so
children of a slot app open normal windows. Refill only when the user
switches to the workspace; a spawn still pending on the second show is
respawned. Moving or closing a slot window releases the slot. Wayland reads
/proc only while a slot is pending (SlotsPending command). Slots are updated
before workspaces are renamed, and ordered after lower slot columns. Config
warns that slot widths are ignored with overflow = fixed. Tests no longer
rely on timeouts to prove no spawn happened.
commit
9f4c5da5e4fb54e3658e2b08a3e2ba339b402aa8
author
brice · · 2026-09-24 18:13 UTC
parent
bcfef74
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +313 -107
M examples/config +6 -5
@@ -35,17 +35,18 @@ layout.presets = 1/3, 1/2, 2/3, 1
3535 # when empty. hidden = on keeps one out of the numbers and of up/down: only its
3636 # bind shows it, and pressing the bind again returns to the previous workspace.
3737 # max-columns and overflow override the layout.* values.
38-# workspace.dev.hidden = on
39-# workspace.dev.max-columns = 3
40-# workspace.dev.overflow = fixed
41-# bind.cmd+d = workspace dev
38+# workspace.web.max-columns = 3
39+# workspace.web.overflow = fixed
4240 #
4341 # Slots: column.N = <width>, <command>. nefertty starts each command in the
4442 # background and puts its first window in column N, without taking focus.
4543 # A closed slot stays empty until the workspace is shown again. Apps that
46-# reuse a running instance need a new-instance flag.
44+# reuse a running instance need a new-instance flag. Widths need
45+# overflow = scroll.
46+# workspace.dev.hidden = on
4747 # workspace.dev.column.1 = 67%, code --new-window
4848 # workspace.dev.column.2 = 33%, foot
49+# bind.cmd+d = workspace dev
4950
5051 # Displays (drm backend): WxH, WxH@Hz, preferred or off.
5152 # The first listed display that is connected and not off is used.
@@ -35,17 +35,18 @@ layout.presets = 1/3, 1/2, 2/3, 1
35 # when empty. hidden = on keeps one out of the numbers and of up/down: only its35 # when empty. hidden = on keeps one out of the numbers and of up/down: only its
36 # bind shows it, and pressing the bind again returns to the previous workspace.36 # bind shows it, and pressing the bind again returns to the previous workspace.
37 # max-columns and overflow override the layout.* values.37 # max-columns and overflow override the layout.* values.
38-# workspace.dev.hidden = on38+# workspace.web.max-columns = 3
39-# workspace.dev.max-columns = 339+# workspace.web.overflow = fixed
40-# workspace.dev.overflow = fixed
41-# bind.cmd+d = workspace dev
42 #40 #
43 # Slots: column.N = <width>, <command>. nefertty starts each command in the41 # Slots: column.N = <width>, <command>. nefertty starts each command in the
44 # background and puts its first window in column N, without taking focus.42 # background and puts its first window in column N, without taking focus.
45 # A closed slot stays empty until the workspace is shown again. Apps that43 # A closed slot stays empty until the workspace is shown again. Apps that
46-# reuse a running instance need a new-instance flag.44+# reuse a running instance need a new-instance flag. Widths need
45+# overflow = scroll.
46+# workspace.dev.hidden = on
47 # workspace.dev.column.1 = 67%, code --new-window47 # workspace.dev.column.1 = 67%, code --new-window
48 # workspace.dev.column.2 = 33%, foot48 # workspace.dev.column.2 = 33%, foot
49+# bind.cmd+d = workspace dev
49 50
50 # Displays (drm backend): WxH, WxH@Hz, preferred or off.51 # Displays (drm backend): WxH, WxH@Hz, preferred or off.
51 # The first listed display that is connected and not off is used.52 # The first listed display that is connected and not off is used.
M internal/adapters/config/config.go +8 -0
@@ -456,6 +456,14 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
456456 }
457457 }
458458 for _, w := range c.Workspaces {
459+ overflow := w.Overflow
460+ if overflow == "" {
461+ overflow = c.Layout.Overflow
462+ }
463+ if overflow == "fixed" && len(w.Slots) > 0 {
464+ line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
465+ warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)})
466+ }
459467 if w.Hidden && !bound[w.Name] {
460468 warnings = append(warnings, Warning{Line: seen["workspace."+w.Name+".hidden"], Msg: fmt.Sprintf("workspace.%s.hidden: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})
461469 }
@@ -456,6 +456,14 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
456 }456 }
457 }457 }
458 for _, w := range c.Workspaces {458 for _, w := range c.Workspaces {
459+ overflow := w.Overflow
460+ if overflow == "" {
461+ overflow = c.Layout.Overflow
462+ }
463+ if overflow == "fixed" && len(w.Slots) > 0 {
464+ line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
465+ warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)})
466+ }
459 if w.Hidden && !bound[w.Name] {467 if w.Hidden && !bound[w.Name] {
460 warnings = append(warnings, Warning{Line: seen["workspace."+w.Name+".hidden"], Msg: fmt.Sprintf("workspace.%s.hidden: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})468 warnings = append(warnings, Warning{Line: seen["workspace."+w.Name+".hidden"], Msg: fmt.Sprintf("workspace.%s.hidden: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})
461 }469 }
M internal/adapters/config/config_test.go +9 -0
@@ -293,3 +293,12 @@ bind.cmd+d = workspace dev
293293 t.Fatal(w)
294294 }
295295 }
296+
297+func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) {
298+ _, w := parseString(t, `workspace.dev.overflow = fixed
299+workspace.dev.column.1 = 67%, foot
300+`)
301+ if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "ignored") {
302+ t.Fatal(w)
303+ }
304+}
@@ -293,3 +293,12 @@ bind.cmd+d = workspace dev
293 t.Fatal(w)293 t.Fatal(w)
294 }294 }
295 }295 }
296+
297+func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) {
298+ _, w := parseString(t, `workspace.dev.overflow = fixed
299+workspace.dev.column.1 = 67%, foot
300+`)
301+ if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "ignored") {
302+ t.Fatal(w)
303+ }
304+}
M internal/adapters/wayland/server.go +3 -0
@@ -55,6 +55,7 @@ type Channels struct {
5555 type Server struct {
5656 display *server.Display
5757 env procEnv
58+ slotsPending bool // core waits for a slot window
5859 name string
5960 cleanup func()
6061 log zerowrap.Logger
@@ -421,6 +422,8 @@ func (s *Server) apply(cmd ports.ClientCommand) {
421422 w.sendConfigure()
422423 case ports.SetOutputScale:
423424 s.setOutputScale(c)
425+ case ports.SlotsPending:
426+ s.slotsPending = c.Pending
424427 case ports.CloseWindow:
425428 w := s.windows[c.ID]
426429 if w == nil || !w.toplevel.Resource.Alive() {
@@ -55,6 +55,7 @@ type Channels struct {
55 type Server struct {55 type Server struct {
56 display *server.Display56 display *server.Display
57 env procEnv57 env procEnv
58+ slotsPending bool // core waits for a slot window
58 name string59 name string
59 cleanup func()60 cleanup func()
60 log zerowrap.Logger61 log zerowrap.Logger
@@ -421,6 +422,8 @@ func (s *Server) apply(cmd ports.ClientCommand) {
421 w.sendConfigure()422 w.sendConfigure()
422 case ports.SetOutputScale:423 case ports.SetOutputScale:
423 s.setOutputScale(c)424 s.setOutputScale(c)
425+ case ports.SlotsPending:
426+ s.slotsPending = c.Pending
424 case ports.CloseWindow:427 case ports.CloseWindow:
425 w := s.windows[c.ID]428 w := s.windows[c.ID]
426 if w == nil || !w.toplevel.Resource.Alive() {429 if w == nil || !w.toplevel.Resource.Alive() {
M internal/adapters/wayland/slot.go +9 -3
@@ -6,6 +6,7 @@ import (
66 "strconv"
77
88 "github.com/bnema/nefertty/internal/ports"
9+ "github.com/bnema/purego-libwayland/server"
910 )
1011
1112 // procEnv reads one environment variable of a running process.
@@ -35,8 +36,13 @@ func (linuxProcEnv) Lookup(pid int, key string) (string, bool) {
3536 return "", false
3637 }
3738
38-// slotToken returns the SlotEnv value of the client process, if any.
39-func (s *Server) slotToken(pid int) string {
40- v, _ := s.env.Lookup(pid, ports.SlotEnv)
39+// slotToken returns the SlotEnv value of the client process, if any. It
40+// reads /proc only while core waits for a slot window. There is no per-client
41+// cache: libwayland may reuse a client address after a disconnect.
42+func (s *Server) slotToken(c server.Client) string {
43+ if !s.slotsPending {
44+ return ""
45+ }
46+ v, _ := s.env.Lookup(c.PID(), ports.SlotEnv)
4147 return v
4248 }
@@ -6,6 +6,7 @@ import (
6 "strconv"6 "strconv"
7 7
8 "github.com/bnema/nefertty/internal/ports"8 "github.com/bnema/nefertty/internal/ports"
9+ "github.com/bnema/purego-libwayland/server"
9 )10 )
10 11
11 // procEnv reads one environment variable of a running process.12 // procEnv reads one environment variable of a running process.
@@ -35,8 +36,13 @@ func (linuxProcEnv) Lookup(pid int, key string) (string, bool) {
35 return "", false36 return "", false
36 }37 }
37 38
38-// slotToken returns the SlotEnv value of the client process, if any.39+// slotToken returns the SlotEnv value of the client process, if any. It
39-func (s *Server) slotToken(pid int) string {40+// reads /proc only while core waits for a slot window. There is no per-client
40- v, _ := s.env.Lookup(pid, ports.SlotEnv)41+// cache: libwayland may reuse a client address after a disconnect.
42+func (s *Server) slotToken(c server.Client) string {
43+ if !s.slotsPending {
44+ return ""
45+ }
46+ v, _ := s.env.Lookup(c.PID(), ports.SlotEnv)
41 return v47 return v
42 }48 }
M internal/adapters/wayland/slot_test.go +5 -4
@@ -12,13 +12,14 @@ func TestWindowMappedCarriesSlot(t *testing.T) {
1212 env := newMockprocEnv(t)
1313 // The test client runs in this process: its PID is ours.
1414 env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once()
15- env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("", false).Once()
16- s, events, _, dir := keyboardServer(t, func(s *Server) { s.env = env })
15+ s, events, commands, dir := keyboardServer(t, func(s *Server) { s.env = env })
1716 mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)
18- if w := mapWindow(); w.Slot != "7" {
17+ // No slot pending: /proc is not read (the mock would fail on a call).
18+ if w := mapWindow(); w.Slot != "" {
1919 t.Fatalf("slot %q", w.Slot)
2020 }
21- if w := mapWindow(); w.Slot != "" {
21+ commands <- ports.SlotsPending{Pending: true}
22+ if w := mapWindow(); w.Slot != "7" {
2223 t.Fatalf("slot %q", w.Slot)
2324 }
2425 }
@@ -12,13 +12,14 @@ func TestWindowMappedCarriesSlot(t *testing.T) {
12 env := newMockprocEnv(t)12 env := newMockprocEnv(t)
13 // The test client runs in this process: its PID is ours.13 // The test client runs in this process: its PID is ours.
14 env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once()14 env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once()
15- env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("", false).Once()15+ s, events, commands, dir := keyboardServer(t, func(s *Server) { s.env = env })
16- s, events, _, dir := keyboardServer(t, func(s *Server) { s.env = env })
17 mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)16 mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)
18- if w := mapWindow(); w.Slot != "7" {17+ // No slot pending: /proc is not read (the mock would fail on a call).
18+ if w := mapWindow(); w.Slot != "" {
19 t.Fatalf("slot %q", w.Slot)19 t.Fatalf("slot %q", w.Slot)
20 }20 }
21- if w := mapWindow(); w.Slot != "" {21+ commands <- ports.SlotsPending{Pending: true}
22+ if w := mapWindow(); w.Slot != "7" {
22 t.Fatalf("slot %q", w.Slot)23 t.Fatalf("slot %q", w.Slot)
23 }24 }
24 }25 }
M internal/adapters/wayland/xdg.go +1 -1
@@ -150,7 +150,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
150150 x.serials = append(x.serials, x.server.serial)
151151 } else if buffer && !w.mapped && x.acked {
152152 w.mapped = true
153- slot := x.server.slotToken(r.Client().PID())
153+ slot := x.server.slotToken(r.Client())
154154 x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot})
155155 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped")
156156 } else if !buffer && w.mapped {
@@ -150,7 +150,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
150 x.serials = append(x.serials, x.server.serial)150 x.serials = append(x.serials, x.server.serial)
151 } else if buffer && !w.mapped && x.acked {151 } else if buffer && !w.mapped && x.acked {
152 w.mapped = true152 w.mapped = true
153- slot := x.server.slotToken(r.Client().PID())153+ slot := x.server.slotToken(r.Client())
154 x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot})154 x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot})
155 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped")155 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped")
156 } else if !buffer && w.mapped {156 } else if !buffer && w.mapped {
M internal/core/core.go +45 -14
@@ -50,9 +50,10 @@ type Core struct {
5050 physW, physH int
5151 scale, cfgScale float64
5252 sentScale ports.SetOutputScale
53- slots slots
53+ slots map[slotKey]*slotState
5454 // toSpawn holds slots to start; Run sends them (apply has no context).
55- toSpawn []slotKey
55+ toSpawn []slotKey
56+ sentPending bool
5657 }
5758
5859 func keyName(s string) string {
@@ -179,28 +180,53 @@ func (c *Core) apply(cfg ports.Config) error {
179180 c.cfg = cfg
180181 c.binds = binds
181182 c.applyConfigScale()
183+ c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
182184 c.ws.SetNamed(named)
183185 c.ws.SetOverflow(defOverflow)
184186 c.ws.SetMaxColumns(cfg.Layout.MaxColumns)
185187 c.ws.SetPresets(presets)
186188 c.ws.SetGaps(cfg.Layout.Gaps)
187- c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
188189 return nil
189190 }
190191
191-// spawnSlots starts pending slot commands, then the empty slots of the
192-// workspace on screen.
193-func (c *Core) spawnSlots(ctx context.Context) error {
194- keys := append(c.toSpawn, c.refill()...)
192+// publishPending tells wayland at once when slots start waiting, before
193+// their windows can map.
194+func (c *Core) publishPending(ctx context.Context) error {
195+ if p := c.anyPending(); p != c.sentPending {
196+ if err := c.command(ctx, ports.SlotsPending{Pending: p}); err != nil {
197+ return err
198+ }
199+ c.sentPending = p
200+ }
201+ return nil
202+}
203+
204+// spawnSlots starts the slots queued by config. With shown set (the user
205+// just switched to another workspace), it also refills the empty slots of
206+// the workspace now on screen.
207+func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
208+ keys := c.toSpawn
195209 c.toSpawn = nil
210+ if shown {
211+ keys = append(keys, c.refill()...)
212+ }
213+ started := map[slotKey]bool{}
196214 for _, key := range keys {
197- if _, ok := c.slots.byKey[key]; !ok {
215+ st, ok := c.slots[key]
216+ // A key can be queued twice (new slot on the workspace on screen).
217+ if !ok || started[key] || st.window != 0 {
198218 continue
199219 }
220+ started[key] = true
221+ req := c.spawnSlot(key)
222+ // Wayland must read slot tokens before this window can map.
223+ if err := c.publishPending(ctx); err != nil {
224+ return err
225+ }
200226 select {
201227 case <-ctx.Done():
202228 return ctx.Err()
203- case c.ch.Spawn <- c.spawnSlot(key):
229+ case c.ch.Spawn <- req:
204230 }
205231 }
206232 return nil
@@ -209,7 +235,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
209235 if cap(ch.Scenes) != 1 {
210236 return nil, fmt.Errorf("scenes must have capacity 1")
211237 }
212- c := &Core{scale: 1, ws: NewMonitor(), slots: newSlots(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
238+ c := &Core{scale: 1, ws: NewMonitor(), slots: map[slotKey]*slotState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
213239 if err := c.apply(cfg); err != nil {
214240 return nil, err
215241 }
@@ -297,6 +323,9 @@ func (c *Core) publish(ctx context.Context) error {
297323 }
298324 c.sentScale = v
299325 }
326+ if err := c.publishPending(ctx); err != nil {
327+ return err
328+ }
300329 c.seq++
301330 scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output().W, OutputHeight: c.ws.Output().H, Scale: c.scale, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
302331 alive := map[WindowID]bool{}
@@ -360,7 +389,7 @@ func (c *Core) publish(ctx context.Context) error {
360389 return nil
361390 }
362391 func (c *Core) Run(ctx context.Context) error {
363- if c.spawnSlots(ctx) != nil {
392+ if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {
364393 return nil
365394 }
366395 for {
@@ -385,7 +414,7 @@ func (c *Core) Run(ctx context.Context) error {
385414 }
386415 case ports.WindowUnmapped:
387416 c.ws.RemoveWindow(v.ID)
388- c.slotWindowGone(v.ID)
417+ c.releaseSlots()
389418 if c.pointer == v.ID {
390419 c.pointer = 0
391420 if err := c.command(ctx, ports.PointerFocus{}); err != nil {
@@ -432,7 +461,7 @@ func (c *Core) Run(ctx context.Context) error {
432461 }
433462 continue
434463 }
435- if c.spawnSlots(ctx) != nil {
464+ if c.spawnSlots(ctx, false) != nil {
436465 return nil
437466 }
438467 if err := c.publish(ctx); err != nil {
@@ -543,6 +572,7 @@ func (c *Core) Run(ctx context.Context) error {
543572 }
544573 continue
545574 }
575+ before := c.ws.Current()
546576 effect := c.ws.Apply(action)
547577 if effect.Quit {
548578 return ErrQuit
@@ -559,7 +589,8 @@ func (c *Core) Run(ctx context.Context) error {
559589 }
560590 }
561591 // Showing a declared workspace refills its empty slots.
562- if c.spawnSlots(ctx) != nil {
592+ c.releaseSlots()
593+ if c.spawnSlots(ctx, c.ws.Current() != before) != nil {
563594 return nil
564595 }
565596 if effect.Close != 0 {
@@ -50,9 +50,10 @@ type Core struct {
50 physW, physH int50 physW, physH int
51 scale, cfgScale float6451 scale, cfgScale float64
52 sentScale ports.SetOutputScale52 sentScale ports.SetOutputScale
53- slots slots53+ slots map[slotKey]*slotState
54 // toSpawn holds slots to start; Run sends them (apply has no context).54 // toSpawn holds slots to start; Run sends them (apply has no context).
55- toSpawn []slotKey55+ toSpawn []slotKey
56+ sentPending bool
56 }57 }
57 58
58 func keyName(s string) string {59 func keyName(s string) string {
@@ -179,28 +180,53 @@ func (c *Core) apply(cfg ports.Config) error {
179 c.cfg = cfg180 c.cfg = cfg
180 c.binds = binds181 c.binds = binds
181 c.applyConfigScale()182 c.applyConfigScale()
183+ c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
182 c.ws.SetNamed(named)184 c.ws.SetNamed(named)
183 c.ws.SetOverflow(defOverflow)185 c.ws.SetOverflow(defOverflow)
184 c.ws.SetMaxColumns(cfg.Layout.MaxColumns)186 c.ws.SetMaxColumns(cfg.Layout.MaxColumns)
185 c.ws.SetPresets(presets)187 c.ws.SetPresets(presets)
186 c.ws.SetGaps(cfg.Layout.Gaps)188 c.ws.SetGaps(cfg.Layout.Gaps)
187- c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
188 return nil189 return nil
189 }190 }
190 191
191-// spawnSlots starts pending slot commands, then the empty slots of the192+// publishPending tells wayland at once when slots start waiting, before
192-// workspace on screen.193+// their windows can map.
193-func (c *Core) spawnSlots(ctx context.Context) error {194+func (c *Core) publishPending(ctx context.Context) error {
194- keys := append(c.toSpawn, c.refill()...)195+ if p := c.anyPending(); p != c.sentPending {
196+ if err := c.command(ctx, ports.SlotsPending{Pending: p}); err != nil {
197+ return err
198+ }
199+ c.sentPending = p
200+ }
201+ return nil
202+}
203+
204+// spawnSlots starts the slots queued by config. With shown set (the user
205+// just switched to another workspace), it also refills the empty slots of
206+// the workspace now on screen.
207+func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
208+ keys := c.toSpawn
195 c.toSpawn = nil209 c.toSpawn = nil
210+ if shown {
211+ keys = append(keys, c.refill()...)
212+ }
213+ started := map[slotKey]bool{}
196 for _, key := range keys {214 for _, key := range keys {
197- if _, ok := c.slots.byKey[key]; !ok {215+ st, ok := c.slots[key]
216+ // A key can be queued twice (new slot on the workspace on screen).
217+ if !ok || started[key] || st.window != 0 {
198 continue218 continue
199 }219 }
220+ started[key] = true
221+ req := c.spawnSlot(key)
222+ // Wayland must read slot tokens before this window can map.
223+ if err := c.publishPending(ctx); err != nil {
224+ return err
225+ }
200 select {226 select {
201 case <-ctx.Done():227 case <-ctx.Done():
202 return ctx.Err()228 return ctx.Err()
203- case c.ch.Spawn <- c.spawnSlot(key):229+ case c.ch.Spawn <- req:
204 }230 }
205 }231 }
206 return nil232 return nil
@@ -209,7 +235,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
209 if cap(ch.Scenes) != 1 {235 if cap(ch.Scenes) != 1 {
210 return nil, fmt.Errorf("scenes must have capacity 1")236 return nil, fmt.Errorf("scenes must have capacity 1")
211 }237 }
212- c := &Core{scale: 1, ws: NewMonitor(), slots: newSlots(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}238+ c := &Core{scale: 1, ws: NewMonitor(), slots: map[slotKey]*slotState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
213 if err := c.apply(cfg); err != nil {239 if err := c.apply(cfg); err != nil {
214 return nil, err240 return nil, err
215 }241 }
@@ -297,6 +323,9 @@ func (c *Core) publish(ctx context.Context) error {
297 }323 }
298 c.sentScale = v324 c.sentScale = v
299 }325 }
326+ if err := c.publishPending(ctx); err != nil {
327+ return err
328+ }
300 c.seq++329 c.seq++
301 scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output().W, OutputHeight: c.ws.Output().H, Scale: c.scale, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}330 scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output().W, OutputHeight: c.ws.Output().H, Scale: c.scale, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
302 alive := map[WindowID]bool{}331 alive := map[WindowID]bool{}
@@ -360,7 +389,7 @@ func (c *Core) publish(ctx context.Context) error {
360 return nil389 return nil
361 }390 }
362 func (c *Core) Run(ctx context.Context) error {391 func (c *Core) Run(ctx context.Context) error {
363- if c.spawnSlots(ctx) != nil {392+ if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {
364 return nil393 return nil
365 }394 }
366 for {395 for {
@@ -385,7 +414,7 @@ func (c *Core) Run(ctx context.Context) error {
385 }414 }
386 case ports.WindowUnmapped:415 case ports.WindowUnmapped:
387 c.ws.RemoveWindow(v.ID)416 c.ws.RemoveWindow(v.ID)
388- c.slotWindowGone(v.ID)417+ c.releaseSlots()
389 if c.pointer == v.ID {418 if c.pointer == v.ID {
390 c.pointer = 0419 c.pointer = 0
391 if err := c.command(ctx, ports.PointerFocus{}); err != nil {420 if err := c.command(ctx, ports.PointerFocus{}); err != nil {
@@ -432,7 +461,7 @@ func (c *Core) Run(ctx context.Context) error {
432 }461 }
433 continue462 continue
434 }463 }
435- if c.spawnSlots(ctx) != nil {464+ if c.spawnSlots(ctx, false) != nil {
436 return nil465 return nil
437 }466 }
438 if err := c.publish(ctx); err != nil {467 if err := c.publish(ctx); err != nil {
@@ -543,6 +572,7 @@ func (c *Core) Run(ctx context.Context) error {
543 }572 }
544 continue573 continue
545 }574 }
575+ before := c.ws.Current()
546 effect := c.ws.Apply(action)576 effect := c.ws.Apply(action)
547 if effect.Quit {577 if effect.Quit {
548 return ErrQuit578 return ErrQuit
@@ -559,7 +589,8 @@ func (c *Core) Run(ctx context.Context) error {
559 }589 }
560 }590 }
561 // Showing a declared workspace refills its empty slots.591 // Showing a declared workspace refills its empty slots.
562- if c.spawnSlots(ctx) != nil {592+ c.releaseSlots()
593+ if c.spawnSlots(ctx, c.ws.Current() != before) != nil {
563 return nil594 return nil
564 }595 }
565 if effect.Close != 0 {596 if effect.Close != 0 {
M internal/core/layout.go +15 -2
@@ -171,9 +171,12 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {
171171 if id == 0 || w.has(id) {
172172 return
173173 }
174+ // Right after the last slot column with a lower number, else first.
174175 at := 0
175- for at < len(w.Columns) && w.Columns[at].Slot > 0 && w.Columns[at].Slot < slot {
176- at++
176+ for i, c := range w.Columns {
177+ if c.Slot > 0 && c.Slot < slot {
178+ at = i + 1
179+ }
177180 }
178181 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot})
179182 if len(w.Columns) > 1 && at <= w.Focus {
@@ -192,6 +195,16 @@ func (w *Workspace) setSlotWidth(n int, width Width) {
192195 w.scroll()
193196 }
194197
198+// inSlot reports whether window id is in slot column n.
199+func (w *Workspace) inSlot(id WindowID, n int) bool {
200+ for _, c := range w.Columns {
201+ if c.Slot == n && slices.Contains(c.Windows, id) {
202+ return true
203+ }
204+ }
205+ return false
206+}
207+
195208 // unslot turns slot column n into a normal column.
196209 func (w *Workspace) unslot(n int) {
197210 for i := range w.Columns {
@@ -171,9 +171,12 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {
171 if id == 0 || w.has(id) {171 if id == 0 || w.has(id) {
172 return172 return
173 }173 }
174+ // Right after the last slot column with a lower number, else first.
174 at := 0175 at := 0
175- for at < len(w.Columns) && w.Columns[at].Slot > 0 && w.Columns[at].Slot < slot {176+ for i, c := range w.Columns {
176- at++177+ if c.Slot > 0 && c.Slot < slot {
178+ at = i + 1
179+ }
177 }180 }
178 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot})181 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot})
179 if len(w.Columns) > 1 && at <= w.Focus {182 if len(w.Columns) > 1 && at <= w.Focus {
@@ -192,6 +195,16 @@ func (w *Workspace) setSlotWidth(n int, width Width) {
192 w.scroll()195 w.scroll()
193 }196 }
194 197
198+// inSlot reports whether window id is in slot column n.
199+func (w *Workspace) inSlot(id WindowID, n int) bool {
200+ for _, c := range w.Columns {
201+ if c.Slot == n && slices.Contains(c.Windows, id) {
202+ return true
203+ }
204+ }
205+ return false
206+}
207+
195 // unslot turns slot column n into a normal column.208 // unslot turns slot column n into a normal column.
196 func (w *Workspace) unslot(n int) {209 func (w *Workspace) unslot(n int) {
197 for i := range w.Columns {210 for i := range w.Columns {
M internal/core/slots.go +77 -71
@@ -1,20 +1,24 @@
11 package core
22
33 import (
4+ "crypto/rand"
45 "fmt"
56 "slices"
6- "strconv"
77
88 "github.com/bnema/nefertty/internal/ports"
99 )
1010
1111 // Slots of declared workspaces (ADR 011): workspace.<name>.column.N reserves
1212 // column N for the window of one command. The slot, not the window, has the
13-// identity: nefertty spawns the command with SlotEnv set to a token, and the
14-// first window of a client carrying that token fills the slot.
13+// identity: nefertty spawns the command with SlotEnv set to a random token,
14+// and the first window of a client carrying that token fills the slot.
1515 //
16-// A slot is spawned at startup and when its workspace is shown while it is
17-// empty and no spawn is pending. Nothing is relaunched on its own.
16+// Only the token of the pending spawn is accepted: once the slot is filled,
17+// or the command changes, other windows with that token (children of the slot
18+// app inherit its environment) open as normal windows.
19+//
20+// A slot is spawned at startup, and again when the user shows its workspace
21+// while it is empty. Nothing is relaunched on its own.
1822
1923 type slotKey struct {
2024 workspace string
@@ -22,24 +26,17 @@ type slotKey struct {
2226 }
2327
2428 type slotState struct {
25- argv []string
26- width Width
27- token string // SlotEnv value of the last spawn; "" before the first
28- window WindowID // 0 while empty
29- pending bool // spawned, window not mapped yet
29+ argv []string
30+ width Width
31+ token string // SlotEnv value of the pending spawn; "" when none
32+ window WindowID // 0 while empty
33+ // stale is set when the workspace is shown while the spawn is pending.
34+ // Shown again still pending, the command is deemed failed and respawned;
35+ // a slow app therefore gets one show to map its window.
36+ stale bool
3037 }
3138
32-type slots struct {
33- byKey map[slotKey]*slotState
34- // byToken maps spawn tokens (current and past) to their slot, so a
35- // late window of an old spawn still lands in its slot.
36- byToken map[string]slotKey
37- next uint64
38-}
39-
40-func newSlots() slots {
41- return slots{byKey: map[slotKey]*slotState{}, byToken: map[string]slotKey{}}
42-}
39+func (s *slotState) pending() bool { return s.token != "" }
4340
4441 // slotSpec is one parsed slot of the config.
4542 type slotSpec struct {
@@ -65,29 +62,29 @@ func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {
6562 return specs, nil
6663 }
6764
68-// updateSlots applies the config. It returns the new slots, which the caller
69-// spawns; changed commands are respawned only when their slot is empty.
70-// A slot dropped from config keeps its window as a normal column.
65+// updateSlots applies the config and returns the slots to spawn: new ones,
66+// and empty ones whose command changed. A slot dropped from config keeps its
67+// window as a normal column. Call before Monitor.SetNamed, so a renamed or
68+// dropped workspace can still be found.
7169 func (c *Core) updateSlots(specs []slotSpec) []slotKey {
7270 want := map[slotKey]slotSpec{}
7371 for _, s := range specs {
7472 want[s.key] = s
7573 }
76- for key := range c.slots.byKey {
74+ for key := range c.slots {
7775 if _, ok := want[key]; ok {
7876 continue
7977 }
8078 if w := c.ws.byName(key.workspace); w != nil {
8179 w.unslot(key.index)
8280 }
83- c.forgetTokens(key)
84- delete(c.slots.byKey, key)
81+ delete(c.slots, key)
8582 }
8683 var spawn []slotKey
8784 for _, s := range specs {
88- st, ok := c.slots.byKey[s.key]
85+ st, ok := c.slots[s.key]
8986 if !ok {
90- c.slots.byKey[s.key] = &slotState{argv: s.argv, width: s.width}
87+ c.slots[s.key] = &slotState{argv: s.argv, width: s.width}
9188 spawn = append(spawn, s.key)
9289 continue
9390 }
@@ -100,10 +97,9 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
10097 if !slices.Equal(st.argv, s.argv) {
10198 st.argv = s.argv
10299 if st.window == 0 {
103- // A pending spawn ran the old command: its window is no
104- // longer the slot's; the new command replaces it.
105- c.forgetTokens(s.key)
106- st.pending = false
100+ // A pending spawn ran the old command: its window opens as
101+ // a normal one; the new command replaces it.
102+ st.token = ""
107103 spawn = append(spawn, s.key)
108104 }
109105 }
@@ -111,66 +107,76 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
111107 return spawn
112108 }
113109
114-// forgetTokens drops the spawn tokens of a slot: their windows open as normal ones.
115-func (c *Core) forgetTokens(key slotKey) {
116- for tok, k := range c.slots.byToken {
117- if k == key {
118- delete(c.slots.byToken, tok)
119- }
120- }
121-}
122-
123-// spawnSlot starts the slot's command with a fresh token.
110+// spawnSlot starts the slot's command with a fresh random token.
124111 func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest {
125- st := c.slots.byKey[key]
126- c.slots.next++
127- st.token = strconv.FormatUint(c.slots.next, 10)
128- st.pending = true
129- c.slots.byToken[st.token] = key
112+ st := c.slots[key]
113+ st.token, st.stale = rand.Text(), false
130114 return ports.SpawnRequest{Argv: slices.Clone(st.argv), Env: []string{ports.SlotEnv + "=" + st.token}}
131115 }
132116
133-// placeSlotWindow puts a mapped window with a slot token into its slot. It
134-// returns false when the window is not a slot window (unknown token, slot
135-// already filled, or its workspace is gone): it then follows normal rules.
117+// placeSlotWindow puts a mapped window carrying the pending token of a slot
118+// into that slot, without focus. It returns false for any other window, which
119+// then follows the normal rules.
136120 func (c *Core) placeSlotWindow(id WindowID, token string) bool {
137- key, ok := c.slots.byToken[token]
138- if !ok {
139- return false
140- }
141- st := c.slots.byKey[key]
142- w := c.ws.byName(key.workspace)
143- if st == nil || st.window != 0 || w == nil {
144- return false
121+ for key, st := range c.slots {
122+ if !st.pending() || st.token != token || st.window != 0 {
123+ continue
124+ }
125+ w := c.ws.byName(key.workspace)
126+ if w == nil {
127+ return false
128+ }
129+ st.window, st.token = id, ""
130+ w.AddSlotWindow(id, key.index, st.width)
131+ c.ws.normalize()
132+ return true
145133 }
146- st.window, st.pending = id, false
147- w.AddSlotWindow(id, key.index, st.width)
148- c.ws.normalize()
149- return true
134+ return false
150135 }
151136
152-// slotWindowGone empties the slot of a closed window. The slot stays
153-// reserved; it is refilled when the user next shows its workspace.
154-func (c *Core) slotWindowGone(id WindowID) {
155- for _, st := range c.slots.byKey {
156- if st.window == id {
137+// releaseSlots empties slots whose window is gone or no longer in its slot
138+// column (closed, or moved to another workspace by the user).
139+func (c *Core) releaseSlots() {
140+ for key, st := range c.slots {
141+ if st.window == 0 {
142+ continue
143+ }
144+ w := c.ws.byName(key.workspace)
145+ if w == nil || !w.inSlot(st.window, key.index) {
157146 st.window = 0
158147 }
159148 }
160149 }
161150
162-// refill returns the empty, idle slots of the workspace on screen.
151+// refill returns the empty slots of the workspace on screen to spawn. A slot
152+// still pending for the second show in a row is respawned: its command
153+// failed, exited or handed off to another instance without a window.
163154 func (c *Core) refill() []slotKey {
164155 name := c.ws.Current().Name
165156 if name == "" {
166157 return nil
167158 }
168159 var keys []slotKey
169- for key, st := range c.slots.byKey {
170- if key.workspace == name && st.window == 0 && !st.pending {
160+ for key, st := range c.slots {
161+ switch {
162+ case key.workspace != name || st.window != 0:
163+ case st.pending() && !st.stale:
164+ st.stale = true
165+ default:
166+ st.token = ""
171167 keys = append(keys, key)
172168 }
173169 }
174170 slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index })
175171 return keys
176172 }
173+
174+// anyPending reports whether a slot waits for its window.
175+func (c *Core) anyPending() bool {
176+ for _, st := range c.slots {
177+ if st.pending() {
178+ return true
179+ }
180+ }
181+ return false
182+}
@@ -1,20 +1,24 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "crypto/rand"
4 "fmt"5 "fmt"
5 "slices"6 "slices"
6- "strconv"
7 7
8 "github.com/bnema/nefertty/internal/ports"8 "github.com/bnema/nefertty/internal/ports"
9 )9 )
10 10
11 // Slots of declared workspaces (ADR 011): workspace.<name>.column.N reserves11 // Slots of declared workspaces (ADR 011): workspace.<name>.column.N reserves
12 // column N for the window of one command. The slot, not the window, has the12 // column N for the window of one command. The slot, not the window, has the
13-// identity: nefertty spawns the command with SlotEnv set to a token, and the13+// identity: nefertty spawns the command with SlotEnv set to a random token,
14-// first window of a client carrying that token fills the slot.14+// and the first window of a client carrying that token fills the slot.
15 //15 //
16-// A slot is spawned at startup and when its workspace is shown while it is16+// Only the token of the pending spawn is accepted: once the slot is filled,
17-// empty and no spawn is pending. Nothing is relaunched on its own.17+// or the command changes, other windows with that token (children of the slot
18+// app inherit its environment) open as normal windows.
19+//
20+// A slot is spawned at startup, and again when the user shows its workspace
21+// while it is empty. Nothing is relaunched on its own.
18 22
19 type slotKey struct {23 type slotKey struct {
20 workspace string24 workspace string
@@ -22,24 +26,17 @@ type slotKey struct {
22 }26 }
23 27
24 type slotState struct {28 type slotState struct {
25- argv []string29+ argv []string
26- width Width30+ width Width
27- token string // SlotEnv value of the last spawn; "" before the first31+ token string // SlotEnv value of the pending spawn; "" when none
28- window WindowID // 0 while empty32+ window WindowID // 0 while empty
29- pending bool // spawned, window not mapped yet33+ // stale is set when the workspace is shown while the spawn is pending.
34+ // Shown again still pending, the command is deemed failed and respawned;
35+ // a slow app therefore gets one show to map its window.
36+ stale bool
30 }37 }
31 38
32-type slots struct {39+func (s *slotState) pending() bool { return s.token != "" }
33- byKey map[slotKey]*slotState
34- // byToken maps spawn tokens (current and past) to their slot, so a
35- // late window of an old spawn still lands in its slot.
36- byToken map[string]slotKey
37- next uint64
38-}
39-
40-func newSlots() slots {
41- return slots{byKey: map[slotKey]*slotState{}, byToken: map[string]slotKey{}}
42-}
43 40
44 // slotSpec is one parsed slot of the config.41 // slotSpec is one parsed slot of the config.
45 type slotSpec struct {42 type slotSpec struct {
@@ -65,29 +62,29 @@ func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {
65 return specs, nil62 return specs, nil
66 }63 }
67 64
68-// updateSlots applies the config. It returns the new slots, which the caller65+// updateSlots applies the config and returns the slots to spawn: new ones,
69-// spawns; changed commands are respawned only when their slot is empty.66+// and empty ones whose command changed. A slot dropped from config keeps its
70-// A slot dropped from config keeps its window as a normal column.67+// window as a normal column. Call before Monitor.SetNamed, so a renamed or
68+// dropped workspace can still be found.
71 func (c *Core) updateSlots(specs []slotSpec) []slotKey {69 func (c *Core) updateSlots(specs []slotSpec) []slotKey {
72 want := map[slotKey]slotSpec{}70 want := map[slotKey]slotSpec{}
73 for _, s := range specs {71 for _, s := range specs {
74 want[s.key] = s72 want[s.key] = s
75 }73 }
76- for key := range c.slots.byKey {74+ for key := range c.slots {
77 if _, ok := want[key]; ok {75 if _, ok := want[key]; ok {
78 continue76 continue
79 }77 }
80 if w := c.ws.byName(key.workspace); w != nil {78 if w := c.ws.byName(key.workspace); w != nil {
81 w.unslot(key.index)79 w.unslot(key.index)
82 }80 }
83- c.forgetTokens(key)81+ delete(c.slots, key)
84- delete(c.slots.byKey, key)
85 }82 }
86 var spawn []slotKey83 var spawn []slotKey
87 for _, s := range specs {84 for _, s := range specs {
88- st, ok := c.slots.byKey[s.key]85+ st, ok := c.slots[s.key]
89 if !ok {86 if !ok {
90- c.slots.byKey[s.key] = &slotState{argv: s.argv, width: s.width}87+ c.slots[s.key] = &slotState{argv: s.argv, width: s.width}
91 spawn = append(spawn, s.key)88 spawn = append(spawn, s.key)
92 continue89 continue
93 }90 }
@@ -100,10 +97,9 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
100 if !slices.Equal(st.argv, s.argv) {97 if !slices.Equal(st.argv, s.argv) {
101 st.argv = s.argv98 st.argv = s.argv
102 if st.window == 0 {99 if st.window == 0 {
103- // A pending spawn ran the old command: its window is no100+ // A pending spawn ran the old command: its window opens as
104- // longer the slot's; the new command replaces it.101+ // a normal one; the new command replaces it.
105- c.forgetTokens(s.key)102+ st.token = ""
106- st.pending = false
107 spawn = append(spawn, s.key)103 spawn = append(spawn, s.key)
108 }104 }
109 }105 }
@@ -111,66 +107,76 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
111 return spawn107 return spawn
112 }108 }
113 109
114-// forgetTokens drops the spawn tokens of a slot: their windows open as normal ones.110+// spawnSlot starts the slot's command with a fresh random token.
115-func (c *Core) forgetTokens(key slotKey) {
116- for tok, k := range c.slots.byToken {
117- if k == key {
118- delete(c.slots.byToken, tok)
119- }
120- }
121-}
122-
123-// spawnSlot starts the slot's command with a fresh token.
124 func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest {111 func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest {
125- st := c.slots.byKey[key]112+ st := c.slots[key]
126- c.slots.next++113+ st.token, st.stale = rand.Text(), false
127- st.token = strconv.FormatUint(c.slots.next, 10)
128- st.pending = true
129- c.slots.byToken[st.token] = key
130 return ports.SpawnRequest{Argv: slices.Clone(st.argv), Env: []string{ports.SlotEnv + "=" + st.token}}114 return ports.SpawnRequest{Argv: slices.Clone(st.argv), Env: []string{ports.SlotEnv + "=" + st.token}}
131 }115 }
132 116
133-// placeSlotWindow puts a mapped window with a slot token into its slot. It117+// placeSlotWindow puts a mapped window carrying the pending token of a slot
134-// returns false when the window is not a slot window (unknown token, slot118+// into that slot, without focus. It returns false for any other window, which
135-// already filled, or its workspace is gone): it then follows normal rules.119+// then follows the normal rules.
136 func (c *Core) placeSlotWindow(id WindowID, token string) bool {120 func (c *Core) placeSlotWindow(id WindowID, token string) bool {
137- key, ok := c.slots.byToken[token]121+ for key, st := range c.slots {
138- if !ok {122+ if !st.pending() || st.token != token || st.window != 0 {
139- return false123+ continue
140- }124+ }
141- st := c.slots.byKey[key]125+ w := c.ws.byName(key.workspace)
142- w := c.ws.byName(key.workspace)126+ if w == nil {
143- if st == nil || st.window != 0 || w == nil {127+ return false
144- return false128+ }
129+ st.window, st.token = id, ""
130+ w.AddSlotWindow(id, key.index, st.width)
131+ c.ws.normalize()
132+ return true
145 }133 }
146- st.window, st.pending = id, false134+ return false
147- w.AddSlotWindow(id, key.index, st.width)
148- c.ws.normalize()
149- return true
150 }135 }
151 136
152-// slotWindowGone empties the slot of a closed window. The slot stays137+// releaseSlots empties slots whose window is gone or no longer in its slot
153-// reserved; it is refilled when the user next shows its workspace.138+// column (closed, or moved to another workspace by the user).
154-func (c *Core) slotWindowGone(id WindowID) {139+func (c *Core) releaseSlots() {
155- for _, st := range c.slots.byKey {140+ for key, st := range c.slots {
156- if st.window == id {141+ if st.window == 0 {
142+ continue
143+ }
144+ w := c.ws.byName(key.workspace)
145+ if w == nil || !w.inSlot(st.window, key.index) {
157 st.window = 0146 st.window = 0
158 }147 }
159 }148 }
160 }149 }
161 150
162-// refill returns the empty, idle slots of the workspace on screen.151+// refill returns the empty slots of the workspace on screen to spawn. A slot
152+// still pending for the second show in a row is respawned: its command
153+// failed, exited or handed off to another instance without a window.
163 func (c *Core) refill() []slotKey {154 func (c *Core) refill() []slotKey {
164 name := c.ws.Current().Name155 name := c.ws.Current().Name
165 if name == "" {156 if name == "" {
166 return nil157 return nil
167 }158 }
168 var keys []slotKey159 var keys []slotKey
169- for key, st := range c.slots.byKey {160+ for key, st := range c.slots {
170- if key.workspace == name && st.window == 0 && !st.pending {161+ switch {
162+ case key.workspace != name || st.window != 0:
163+ case st.pending() && !st.stale:
164+ st.stale = true
165+ default:
166+ st.token = ""
171 keys = append(keys, key)167 keys = append(keys, key)
172 }168 }
173 }169 }
174 slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index })170 slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index })
175 return keys171 return keys
176 }172 }
173+
174+// anyPending reports whether a slot waits for its window.
175+func (c *Core) anyPending() bool {
176+ for _, st := range c.slots {
177+ if st.pending() {
178+ return true
179+ }
180+ }
181+ return false
182+}
M internal/core/slots_test.go +129 -7
@@ -24,12 +24,12 @@ type slotRig struct {
2424
2525 // startSlots runs core with a hidden "dev" workspace of two slots, toggled
2626 // by Alt+d, on a 100x80 output.
27-func startSlots(t *testing.T) *slotRig {
27+func startSlots(t *testing.T, numbered ...bool) *slotRig {
2828 t.Helper()
2929 cfg := config.Defaults()
3030 cfg.Keyboard.CmdKey = "alt"
3131 cfg.Border.Width = 0
32- cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Hidden: true, Slots: []ports.SlotConfig{
32+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Hidden: len(numbered) == 0, Slots: []ports.SlotConfig{
3333 {Index: 1, Width: "70%", Argv: []string{"code"}},
3434 {Index: 2, Width: "30%", Argv: []string{"foot"}},
3535 }}}
@@ -70,15 +70,42 @@ func token(t *testing.T, req ports.SpawnRequest) string {
7070 return ""
7171 }
7272
73+// noSpawn checks that nothing was spawned. Core sends spawns before it
74+// publishes the scene of the same event, so callers wait for that scene first.
7375 func noSpawn(t *testing.T, ch <-chan ports.SpawnRequest) {
7476 t.Helper()
7577 select {
7678 case v := <-ch:
7779 t.Fatalf("unexpected spawn %v", v.Argv)
78- case <-time.After(50 * time.Millisecond):
80+ default:
7981 }
8082 }
8183
84+// press sends Alt+key and waits for the scene it publishes.
85+func (r *slotRig) press(t *testing.T, key string) ports.Scene {
86+ t.Helper()
87+ r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt, Pressed: true}
88+ r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt}
89+ return receive(t, r.scenes)
90+}
91+
92+// sync waits for core to handle everything sent so far.
93+func (r *slotRig) sync(t *testing.T) ports.Scene {
94+ t.Helper()
95+ return r.press(t, "Left") // focus-column-left publishes a scene
96+}
97+
98+// fill maps the slot windows 2 (code) and 3 (foot) and waits until core
99+// has placed them: client and input events are separate channels.
100+func (r *slotRig) fill(t *testing.T) (code, foot ports.SpawnRequest) {
101+ t.Helper()
102+ code, foot = receive(t, r.spawn), receive(t, r.spawn)
103+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
104+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
105+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
106+ return code, foot
107+}
108+
82109 // scene waits for a scene matching ok.
83110 func scene(t *testing.T, ch <-chan ports.Scene, ok func(ports.Scene) bool) ports.Scene {
84111 t.Helper()
@@ -122,8 +149,7 @@ func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
122149 t.Fatalf("slot window on screen or focus moved: %+v", s.Windows)
123150 }
124151 }
125- r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
126- s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })
152+ s = r.press(t, "d")
127153 got := visible(s)
128154 if got[2].X != 0 || got[2].W != 70 || got[3].X != 70 || got[3].W != 30 {
129155 t.Fatal(got)
@@ -140,6 +166,7 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) {
140166 scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
141167 noSpawn(t, r.spawn) // nothing relaunches on its own
142168 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
169+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
143170 again := receive(t, r.spawn)
144171 if !slices.Equal(again.Argv, []string{"foot"}) || token(t, again) == token(t, foot) {
145172 t.Fatal(again)
@@ -150,8 +177,8 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) {
150177 t.Fatal(got)
151178 }
152179 // Leaving and showing dev again with full slots spawns nothing.
153- r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
154- r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
180+ r.press(t, "d")
181+ r.press(t, "d")
155182 noSpawn(t, r.spawn)
156183 }
157184
@@ -191,6 +218,7 @@ func TestSlotReload(t *testing.T) {
191218 if !slices.Equal(argv, []string{"btop", "kitty"}) {
192219 t.Fatal(argv)
193220 }
221+ receive(t, r.scenes)
194222 noSpawn(t, r.spawn)
195223 // The old foot spawn no longer fills slot 2 (its command changed): it
196224 // opens as a normal window on workspace 1, on screen.
@@ -200,14 +228,17 @@ func TestSlotReload(t *testing.T) {
200228 t.Fatal(got)
201229 }
202230 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
231+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
203232 s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
204233 if got := visible(s); got[2].W != 50 {
205234 t.Fatal(got)
206235 }
236+ noSpawn(t, r.spawn) // kitty and btop are still starting
207237 // Dropping the workspace's slots spawns nothing and keeps the window.
208238 ws.Slots = nil
209239 cfg.Workspaces = []ports.WorkspaceConfig{ws}
210240 r.reload <- ports.ConfigChanged{Config: cfg}
241+ receive(t, r.scenes)
211242 noSpawn(t, r.spawn)
212243 }
213244
@@ -223,5 +254,96 @@ func TestInvalidSlotWidthRejected(t *testing.T) {
223254 if err := receive(t, r.errs); err == nil {
224255 t.Fatal("no error")
225256 }
257+ r.sync(t)
226258 noSpawn(t, r.spawn)
227259 }
260+
261+// Reloading while the slot workspace is on screen spawns a new slot once.
262+func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) {
263+ r := startSlots(t)
264+ r.fill(t)
265+ r.press(t, "d")
266+ cfg := r.cfg
267+ ws := cfg.Workspaces[0]
268+ ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Width: "20%", Argv: []string{"btop"}})
269+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
270+ r.reload <- ports.ConfigChanged{Config: cfg}
271+ if v := receive(t, r.spawn); v.Argv[0] != "btop" {
272+ t.Fatal(v)
273+ }
274+ receive(t, r.scenes)
275+ noSpawn(t, r.spawn)
276+}
277+
278+// Binds on the workspace on screen do not refill its empty slots; only
279+// showing it again does.
280+func TestSlotNotRefilledByOtherBinds(t *testing.T) {
281+ r := startSlots(t)
282+ r.fill(t)
283+ r.press(t, "d")
284+ r.client <- ports.WindowUnmapped{ID: 3}
285+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
286+ r.sync(t)
287+ noSpawn(t, r.spawn)
288+ r.press(t, "d")
289+ r.press(t, "d")
290+ if v := receive(t, r.spawn); v.Argv[0] != "foot" {
291+ t.Fatal(v)
292+ }
293+}
294+
295+// After a slot window closes, another window with the old token (a child
296+// of the slot app) opens as a normal window, not in the empty slot.
297+func TestSlotOldTokenIsNormal(t *testing.T) {
298+ r := startSlots(t)
299+ _, foot := r.fill(t)
300+ r.client <- ports.WindowUnmapped{ID: 3}
301+ r.client <- ports.WindowMapped{ID: 9, Slot: token(t, foot)}
302+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
303+ if got := visible(s); got[9] == (ports.Rect{}) {
304+ t.Fatalf("old-token window hidden in the slot: %v", got)
305+ }
306+}
307+
308+// A slot whose command never maps a window gets one show to do it; on the
309+// next show it is respawned and its old token no longer fills it.
310+func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
311+ r := startSlots(t)
312+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
313+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
314+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
315+ r.press(t, "d") // foot still starting: not respawned
316+ noSpawn(t, r.spawn)
317+ r.press(t, "d")
318+ r.press(t, "d")
319+ again := receive(t, r.spawn)
320+ if again.Argv[0] != "foot" || token(t, again) == token(t, foot) {
321+ t.Fatal(again)
322+ }
323+ r.client <- ports.WindowMapped{ID: 7, Slot: token(t, foot)}
324+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
325+ if got := visible(s); got[7] == (ports.Rect{}) {
326+ t.Fatalf("stale-token window not placed as a normal one: %v", got)
327+ }
328+ r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)}
329+ s = scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
330+ if got := visible(s); got[8].W != 30 {
331+ t.Fatalf("respawned window not in its slot: %v", got)
332+ }
333+}
334+
335+// Moving a slot window to another workspace releases the slot; showing the
336+// workspace again refills it.
337+func TestSlotReleasedWhenWindowMoved(t *testing.T) {
338+ r := startSlots(t, true) // dev is numbered: [1] [dev] [empty]
339+ r.fill(t)
340+ r.press(t, "d") // on dev, focus on code (slot 1)
341+ r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
342+ receive(t, r.scenes) // move-to-workspace-up: code goes to workspace 1
343+ noSpawn(t, r.spawn)
344+ r.press(t, "d") // back to workspace 1
345+ r.press(t, "d") // dev again: slot 1 is empty
346+ if v := receive(t, r.spawn); v.Argv[0] != "code" {
347+ t.Fatal(v)
348+ }
349+}
@@ -24,12 +24,12 @@ type slotRig struct {
24 24
25 // startSlots runs core with a hidden "dev" workspace of two slots, toggled25 // startSlots runs core with a hidden "dev" workspace of two slots, toggled
26 // by Alt+d, on a 100x80 output.26 // by Alt+d, on a 100x80 output.
27-func startSlots(t *testing.T) *slotRig {27+func startSlots(t *testing.T, numbered ...bool) *slotRig {
28 t.Helper()28 t.Helper()
29 cfg := config.Defaults()29 cfg := config.Defaults()
30 cfg.Keyboard.CmdKey = "alt"30 cfg.Keyboard.CmdKey = "alt"
31 cfg.Border.Width = 031 cfg.Border.Width = 0
32- cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Hidden: true, Slots: []ports.SlotConfig{32+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Hidden: len(numbered) == 0, Slots: []ports.SlotConfig{
33 {Index: 1, Width: "70%", Argv: []string{"code"}},33 {Index: 1, Width: "70%", Argv: []string{"code"}},
34 {Index: 2, Width: "30%", Argv: []string{"foot"}},34 {Index: 2, Width: "30%", Argv: []string{"foot"}},
35 }}}35 }}}
@@ -70,15 +70,42 @@ func token(t *testing.T, req ports.SpawnRequest) string {
70 return ""70 return ""
71 }71 }
72 72
73+// noSpawn checks that nothing was spawned. Core sends spawns before it
74+// publishes the scene of the same event, so callers wait for that scene first.
73 func noSpawn(t *testing.T, ch <-chan ports.SpawnRequest) {75 func noSpawn(t *testing.T, ch <-chan ports.SpawnRequest) {
74 t.Helper()76 t.Helper()
75 select {77 select {
76 case v := <-ch:78 case v := <-ch:
77 t.Fatalf("unexpected spawn %v", v.Argv)79 t.Fatalf("unexpected spawn %v", v.Argv)
78- case <-time.After(50 * time.Millisecond):80+ default:
79 }81 }
80 }82 }
81 83
84+// press sends Alt+key and waits for the scene it publishes.
85+func (r *slotRig) press(t *testing.T, key string) ports.Scene {
86+ t.Helper()
87+ r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt, Pressed: true}
88+ r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt}
89+ return receive(t, r.scenes)
90+}
91+
92+// sync waits for core to handle everything sent so far.
93+func (r *slotRig) sync(t *testing.T) ports.Scene {
94+ t.Helper()
95+ return r.press(t, "Left") // focus-column-left publishes a scene
96+}
97+
98+// fill maps the slot windows 2 (code) and 3 (foot) and waits until core
99+// has placed them: client and input events are separate channels.
100+func (r *slotRig) fill(t *testing.T) (code, foot ports.SpawnRequest) {
101+ t.Helper()
102+ code, foot = receive(t, r.spawn), receive(t, r.spawn)
103+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
104+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
105+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
106+ return code, foot
107+}
108+
82 // scene waits for a scene matching ok.109 // scene waits for a scene matching ok.
83 func scene(t *testing.T, ch <-chan ports.Scene, ok func(ports.Scene) bool) ports.Scene {110 func scene(t *testing.T, ch <-chan ports.Scene, ok func(ports.Scene) bool) ports.Scene {
84 t.Helper()111 t.Helper()
@@ -122,8 +149,7 @@ func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
122 t.Fatalf("slot window on screen or focus moved: %+v", s.Windows)149 t.Fatalf("slot window on screen or focus moved: %+v", s.Windows)
123 }150 }
124 }151 }
125- r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}152+ s = r.press(t, "d")
126- s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })
127 got := visible(s)153 got := visible(s)
128 if got[2].X != 0 || got[2].W != 70 || got[3].X != 70 || got[3].W != 30 {154 if got[2].X != 0 || got[2].W != 70 || got[3].X != 70 || got[3].W != 30 {
129 t.Fatal(got)155 t.Fatal(got)
@@ -140,6 +166,7 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) {
140 scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })166 scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
141 noSpawn(t, r.spawn) // nothing relaunches on its own167 noSpawn(t, r.spawn) // nothing relaunches on its own
142 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}168 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
169+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
143 again := receive(t, r.spawn)170 again := receive(t, r.spawn)
144 if !slices.Equal(again.Argv, []string{"foot"}) || token(t, again) == token(t, foot) {171 if !slices.Equal(again.Argv, []string{"foot"}) || token(t, again) == token(t, foot) {
145 t.Fatal(again)172 t.Fatal(again)
@@ -150,8 +177,8 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) {
150 t.Fatal(got)177 t.Fatal(got)
151 }178 }
152 // Leaving and showing dev again with full slots spawns nothing.179 // Leaving and showing dev again with full slots spawns nothing.
153- r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}180+ r.press(t, "d")
154- r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}181+ r.press(t, "d")
155 noSpawn(t, r.spawn)182 noSpawn(t, r.spawn)
156 }183 }
157 184
@@ -191,6 +218,7 @@ func TestSlotReload(t *testing.T) {
191 if !slices.Equal(argv, []string{"btop", "kitty"}) {218 if !slices.Equal(argv, []string{"btop", "kitty"}) {
192 t.Fatal(argv)219 t.Fatal(argv)
193 }220 }
221+ receive(t, r.scenes)
194 noSpawn(t, r.spawn)222 noSpawn(t, r.spawn)
195 // The old foot spawn no longer fills slot 2 (its command changed): it223 // The old foot spawn no longer fills slot 2 (its command changed): it
196 // opens as a normal window on workspace 1, on screen.224 // opens as a normal window on workspace 1, on screen.
@@ -200,14 +228,17 @@ func TestSlotReload(t *testing.T) {
200 t.Fatal(got)228 t.Fatal(got)
201 }229 }
202 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}230 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
231+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
203 s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })232 s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
204 if got := visible(s); got[2].W != 50 {233 if got := visible(s); got[2].W != 50 {
205 t.Fatal(got)234 t.Fatal(got)
206 }235 }
236+ noSpawn(t, r.spawn) // kitty and btop are still starting
207 // Dropping the workspace's slots spawns nothing and keeps the window.237 // Dropping the workspace's slots spawns nothing and keeps the window.
208 ws.Slots = nil238 ws.Slots = nil
209 cfg.Workspaces = []ports.WorkspaceConfig{ws}239 cfg.Workspaces = []ports.WorkspaceConfig{ws}
210 r.reload <- ports.ConfigChanged{Config: cfg}240 r.reload <- ports.ConfigChanged{Config: cfg}
241+ receive(t, r.scenes)
211 noSpawn(t, r.spawn)242 noSpawn(t, r.spawn)
212 }243 }
213 244
@@ -223,5 +254,96 @@ func TestInvalidSlotWidthRejected(t *testing.T) {
223 if err := receive(t, r.errs); err == nil {254 if err := receive(t, r.errs); err == nil {
224 t.Fatal("no error")255 t.Fatal("no error")
225 }256 }
257+ r.sync(t)
226 noSpawn(t, r.spawn)258 noSpawn(t, r.spawn)
227 }259 }
260+
261+// Reloading while the slot workspace is on screen spawns a new slot once.
262+func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) {
263+ r := startSlots(t)
264+ r.fill(t)
265+ r.press(t, "d")
266+ cfg := r.cfg
267+ ws := cfg.Workspaces[0]
268+ ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Width: "20%", Argv: []string{"btop"}})
269+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
270+ r.reload <- ports.ConfigChanged{Config: cfg}
271+ if v := receive(t, r.spawn); v.Argv[0] != "btop" {
272+ t.Fatal(v)
273+ }
274+ receive(t, r.scenes)
275+ noSpawn(t, r.spawn)
276+}
277+
278+// Binds on the workspace on screen do not refill its empty slots; only
279+// showing it again does.
280+func TestSlotNotRefilledByOtherBinds(t *testing.T) {
281+ r := startSlots(t)
282+ r.fill(t)
283+ r.press(t, "d")
284+ r.client <- ports.WindowUnmapped{ID: 3}
285+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
286+ r.sync(t)
287+ noSpawn(t, r.spawn)
288+ r.press(t, "d")
289+ r.press(t, "d")
290+ if v := receive(t, r.spawn); v.Argv[0] != "foot" {
291+ t.Fatal(v)
292+ }
293+}
294+
295+// After a slot window closes, another window with the old token (a child
296+// of the slot app) opens as a normal window, not in the empty slot.
297+func TestSlotOldTokenIsNormal(t *testing.T) {
298+ r := startSlots(t)
299+ _, foot := r.fill(t)
300+ r.client <- ports.WindowUnmapped{ID: 3}
301+ r.client <- ports.WindowMapped{ID: 9, Slot: token(t, foot)}
302+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
303+ if got := visible(s); got[9] == (ports.Rect{}) {
304+ t.Fatalf("old-token window hidden in the slot: %v", got)
305+ }
306+}
307+
308+// A slot whose command never maps a window gets one show to do it; on the
309+// next show it is respawned and its old token no longer fills it.
310+func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
311+ r := startSlots(t)
312+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
313+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
314+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
315+ r.press(t, "d") // foot still starting: not respawned
316+ noSpawn(t, r.spawn)
317+ r.press(t, "d")
318+ r.press(t, "d")
319+ again := receive(t, r.spawn)
320+ if again.Argv[0] != "foot" || token(t, again) == token(t, foot) {
321+ t.Fatal(again)
322+ }
323+ r.client <- ports.WindowMapped{ID: 7, Slot: token(t, foot)}
324+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
325+ if got := visible(s); got[7] == (ports.Rect{}) {
326+ t.Fatalf("stale-token window not placed as a normal one: %v", got)
327+ }
328+ r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)}
329+ s = scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
330+ if got := visible(s); got[8].W != 30 {
331+ t.Fatalf("respawned window not in its slot: %v", got)
332+ }
333+}
334+
335+// Moving a slot window to another workspace releases the slot; showing the
336+// workspace again refills it.
337+func TestSlotReleasedWhenWindowMoved(t *testing.T) {
338+ r := startSlots(t, true) // dev is numbered: [1] [dev] [empty]
339+ r.fill(t)
340+ r.press(t, "d") // on dev, focus on code (slot 1)
341+ r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
342+ receive(t, r.scenes) // move-to-workspace-up: code goes to workspace 1
343+ noSpawn(t, r.spawn)
344+ r.press(t, "d") // back to workspace 1
345+ r.press(t, "d") // dev again: slot 1 is empty
346+ if v := receive(t, r.spawn); v.Argv[0] != "code" {
347+ t.Fatal(v)
348+ }
349+}
M internal/ports/messages.go +6 -0
@@ -160,6 +160,12 @@ type SetOutputScale struct {
160160
161161 func (SetOutputScale) clientCommand() {}
162162
163+// SlotsPending tells wayland whether a slot waits for its window. Only then
164+// does it read the SlotEnv of mapping clients.
165+type SlotsPending struct{ Pending bool }
166+
167+func (SlotsPending) clientCommand() {}
168+
163169 // CloseWindow carries core → wayland close requests.
164170 type CloseWindow struct{ ID WindowID }
165171
@@ -160,6 +160,12 @@ type SetOutputScale struct {
160 160
161 func (SetOutputScale) clientCommand() {}161 func (SetOutputScale) clientCommand() {}
162 162
163+// SlotsPending tells wayland whether a slot waits for its window. Only then
164+// does it read the SlotEnv of mapping clients.
165+type SlotsPending struct{ Pending bool }
166+
167+func (SlotsPending) clientCommand() {}
168+
163 // CloseWindow carries core → wayland close requests.169 // CloseWindow carries core → wayland close requests.
164 type CloseWindow struct{ ID WindowID }170 type CloseWindow struct{ ID WindowID }
165 171

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