1package core
2
3import (
4 "crypto/rand"
5 "fmt"
6 "slices"
7
8 "github.com/bnema/neferwl/internal/ports"
9)
10
11// 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 the
13// identity: neferwl spawns the command with SlotEnv set to a random token,
14// and the first window of a client carrying that token fills the slot.
15//
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.
22
23type slotKey struct {
24 workspace string
25 index int
26}
27
28type slotState struct {
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
37}
38
39func (s *slotState) pending() bool { return s.token != "" }
40
41// slotSpec is one parsed slot of the config.
42type slotSpec struct {
43 key slotKey
44 argv []string
45 width Width
46}
47
48func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {
49 var specs []slotSpec
50 for _, w := range ws {
51 for _, s := range w.Slots {
52 width, err := ParseWidth(s.Width)
53 if err != nil {
54 return nil, fmt.Errorf("workspace %s column %d: %w", w.Name, s.Index, err)
55 }
56 if len(s.Argv) == 0 || s.Index < 1 {
57 return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index)
58 }
59 specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), width: width})
60 }
61 }
62 return specs, nil
63}
64
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 named workspaces are applied, so a
68// renamed or dropped workspace can still be found.
69func (c *Core) updateSlots(specs []slotSpec) []slotKey {
70 want := map[slotKey]slotSpec{}
71 for _, s := range specs {
72 want[s.key] = s
73 }
74 for key := range c.slots {
75 if _, ok := want[key]; ok {
76 continue
77 }
78 if _, w := c.byName(key.workspace); w != nil {
79 w.unslot(key.index)
80 }
81 delete(c.slots, key)
82 }
83 var spawn []slotKey
84 for _, s := range specs {
85 st, ok := c.slots[s.key]
86 if !ok {
87 c.slots[s.key] = &slotState{argv: s.argv, width: s.width}
88 spawn = append(spawn, s.key)
89 continue
90 }
91 if st.width != s.width {
92 st.width = s.width
93 if _, w := c.byName(s.key.workspace); w != nil {
94 w.setSlotWidth(s.key.index, s.width)
95 }
96 }
97 if !slices.Equal(st.argv, s.argv) {
98 st.argv = s.argv
99 if st.window == 0 {
100 // A pending spawn ran the old command: its window opens as
101 // a normal one; the new command replaces it.
102 st.token = ""
103 spawn = append(spawn, s.key)
104 }
105 }
106 }
107 return spawn
108}
109
110// spawnSlot starts the slot's command with a fresh random token.
111func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest {
112 st := c.slots[key]
113 st.token, st.stale = rand.Text(), false
114 return ports.SpawnRequest{Argv: slices.Clone(st.argv), Env: []string{ports.SlotEnv + "=" + st.token}}
115}
116
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.
120func (c *Core) placeSlotWindow(id WindowID, token string) bool {
121 for key, st := range c.slots {
122 if !st.pending() || st.token != token || st.window != 0 {
123 continue
124 }
125 sc, w := c.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 sc.mon.normalize()
132 return true
133 }
134 return false
135}
136
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).
139func (c *Core) releaseSlots() {
140 for key, st := range c.slots {
141 if st.window == 0 {
142 continue
143 }
144 sc, w := c.byName(key.workspace)
145 // A slot window away in its own fullscreen workspace keeps its slot.
146 if w == nil || !w.inSlot(st.window, key.index) && !sc.mon.awayInSlot(w, st.window, key.index) {
147 st.window = 0
148 }
149 }
150}
151
152// refill returns the empty slots of the workspace on screen to spawn. A slot
153// still pending for the second show in a row is respawned: its command
154// failed, exited or handed off to another instance without a window.
155func (c *Core) refill() []slotKey {
156 name := c.cur().mon.Current().Name
157 if name == "" {
158 return nil
159 }
160 var keys []slotKey
161 for key, st := range c.slots {
162 switch {
163 case key.workspace != name || st.window != 0:
164 case st.pending() && !st.stale:
165 st.stale = true
166 default:
167 st.token = ""
168 keys = append(keys, key)
169 }
170 }
171 slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index })
172 return keys
173}
174
175// anyPending reports whether a slot waits for its window.
176func (c *Core) anyPending() bool {
177 if len(c.terms) > 0 {
178 return true
179 }
180 for _, st := range c.slots {
181 if st.pending() {
182 return true
183 }
184 }
185 return false
186}