1package daemon
2
3import (
4 "sort"
5
6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/domain/layout"
8 "github.com/bnema/vev/internal/usecase/picker"
9)
10
11type overflowKind uint8
12
13const (
14 overflowNone overflowKind = iota
15 overflowTabs
16 overflowSessions
17)
18
19type overflowStep struct {
20 kind overflowKind
21 delta int
22}
23
24// resolveOverflow is the pure escalation policy for directional navigation.
25// The caller supplies its position on the selected axis, so disabled axes and
26// either hard wall all resolve to the zero step.
27func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count int) overflowStep {
28 var step overflowStep
29 switch dir {
30 case layout.Left:
31 if cfg.OverflowTabs {
32 step = overflowStep{kind: overflowTabs, delta: -1}
33 }
34 case layout.Right:
35 if cfg.OverflowTabs {
36 step = overflowStep{kind: overflowTabs, delta: 1}
37 }
38 case layout.Up:
39 if cfg.OverflowSessions {
40 step = overflowStep{kind: overflowSessions, delta: -1}
41 }
42 case layout.Down:
43 if cfg.OverflowSessions {
44 step = overflowStep{kind: overflowSessions, delta: 1}
45 }
46 }
47 if step.kind == overflowNone || count < 2 || position < 0 || position >= count || position+step.delta < 0 || position+step.delta >= count {
48 return overflowStep{}
49 }
50 return step
51}
52
53// overflowSourceEligible validates that directional overflow still originates
54// from the active tiled tab. The session-to-tab lock order matches tab overflow
55// commit and leaves no lock held across a session handoff.
56func overflowSourceEligible(sess *session, ac *attachedClient, expectedSource *tab) bool {
57 if sess == nil || expectedSource == nil {
58 return false
59 }
60 sess.mu.Lock()
61 defer sess.mu.Unlock()
62 if ac == nil || !attachmentRegisteredLocked(sess, ac) || domain.TabStableID(expectedSource.stableID) != ac.viewSnapshot().tabID {
63 return false
64 }
65 expectedSource.mu.Lock()
66 defer expectedSource.mu.Unlock()
67 return expectedSource.floating.state != floatingVisible
68}
69
70type sessionOverflowSnapshot struct {
71 session *session
72 id domain.SessionID
73 name string
74}
75
76// prepareSessionOverflow snapshots only the live registry entries, then sorts
77// immutable names exactly like the picker. No daemon or session lock survives
78// the snapshot, sorting, or the later switchToTarget call.
79func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction, cfg domain.NavConfig) (picker.Target, bool) {
80 if current == nil || !cfg.OverflowSessions || (dir != layout.Up && dir != layout.Down) {
81 return picker.Target{}, false
82 }
83 current.mu.Lock()
84 currentID := current.id
85 current.mu.Unlock()
86
87 d.mu.Lock()
88 live := sessionsSnapshot(d.sessions)
89 d.mu.Unlock()
90
91 snapshots := make([]sessionOverflowSnapshot, 0, len(live))
92 for _, sess := range live {
93 sess.mu.Lock()
94 snapshot := sessionOverflowSnapshot{session: sess, id: sess.id, name: sess.name}
95 sess.mu.Unlock()
96 snapshots = append(snapshots, snapshot)
97 }
98 sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].name < snapshots[j].name })
99
100 position := -1
101 for i, snapshot := range snapshots {
102 if snapshot.session == current && snapshot.id == currentID {
103 position = i
104 break
105 }
106 }
107 step := resolveOverflow(dir, cfg, position, len(snapshots))
108 if step.kind != overflowSessions {
109 return picker.Target{}, false
110 }
111 target := snapshots[position+step.delta]
112 return picker.Target{Session: target.id, TabIndex: -1}, true
113}
114
115// commitSessionOverflow carries its source constraint into the handoff so tab
116// identity and floating visibility are revalidated while source ownership is
117// transferred under the daemon -> routing -> session -> tab lock order.
118func (d *Daemon) commitSessionOverflow(from *session, ac *attachedClient, expectedSource *tab, target picker.Target) error {
119 if from == nil || ac == nil || expectedSource == nil {
120 return errNoNeighbor
121 }
122 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{expectedSource: expectedSource})
123}
124
125type tabOverflowCandidate struct {
126 source *tab
127 target *tab
128 sourceIndex int
129 delta int
130 direction layout.Direction
131 span domain.Rect
132 entry layout.PaneID
133 entryPane *pane
134 targetOldFocus layout.PaneID
135}
136
137func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClient, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
138 if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {
139 return tabOverflowCandidate{}, false
140 }
141 sess.mu.Lock()
142 if ac != nil && (!attachmentRegisteredLocked(sess, ac) || domain.TabStableID(expectedSource.stableID) != ac.viewSnapshot().tabID) {
143 sess.mu.Unlock()
144 return tabOverflowCandidate{}, false
145 }
146 sourceIndex := -1
147 for i, tb := range sess.tabs {
148 if tb == expectedSource {
149 sourceIndex = i
150 break
151 }
152 }
153 if sourceIndex < 0 {
154 sess.mu.Unlock()
155 return tabOverflowCandidate{}, false
156 }
157 targetIndex := sourceIndex + delta
158 if targetIndex < 0 || targetIndex >= len(sess.tabs) {
159 sess.mu.Unlock()
160 return tabOverflowCandidate{}, false
161 }
162 source := sess.tabs[sourceIndex]
163 target := sess.tabs[targetIndex]
164 sess.mu.Unlock()
165
166 source.mu.Lock()
167 floatingVisible := source.floating.state == floatingVisible
168 source.mu.Unlock()
169 if floatingVisible {
170 return tabOverflowCandidate{}, false
171 }
172
173 target.mu.Lock()
174 if target.tree == nil {
175 target.mu.Unlock()
176 return tabOverflowCandidate{}, false
177 }
178 area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows}
179 entry, err := target.tree.EntryPane(dir, span, area)
180 entryPane := target.panes[entry]
181 oldFocus := target.tree.Focus
182 valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry)
183 target.mu.Unlock()
184 if !valid {
185 return tabOverflowCandidate{}, false
186 }
187 return tabOverflowCandidate{
188 source: source,
189 target: target,
190 sourceIndex: sourceIndex,
191 delta: delta,
192 direction: dir,
193 span: span,
194 entry: entry,
195 entryPane: entryPane,
196 targetOldFocus: oldFocus,
197 }, true
198}
199
200func (d *Daemon) commitTabOverflowForAttachment(sess *session, ac *attachedClient, candidate tabOverflowCandidate) bool {
201 if sess == nil || candidate.source == nil || candidate.target == nil {
202 return false
203 }
204 sess.mu.Lock()
205 targetIndex := candidate.sourceIndex + candidate.delta
206 if (ac != nil && (!attachmentRegisteredLocked(sess, ac) || domain.TabStableID(candidate.source.stableID) != ac.viewSnapshot().tabID)) || candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) ||
207 sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {
208 sess.mu.Unlock()
209 return false
210 }
211
212 candidate.source.mu.Lock()
213 if candidate.source.floating.state == floatingVisible {
214 candidate.source.mu.Unlock()
215 sess.mu.Unlock()
216 return false
217 }
218 candidate.target.mu.Lock()
219 if candidate.target.tree == nil {
220 candidate.target.mu.Unlock()
221 candidate.source.mu.Unlock()
222 sess.mu.Unlock()
223 return false
224 }
225 area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows}
226 entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area)
227 if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) {
228 candidate.target.mu.Unlock()
229 candidate.source.mu.Unlock()
230 sess.mu.Unlock()
231 return false
232 }
233 targetTree := candidate.target.tree.Clone()
234 if err := targetTree.FocusEnter(candidate.direction, candidate.span, area); err != nil {
235 candidate.target.mu.Unlock()
236 candidate.source.mu.Unlock()
237 sess.mu.Unlock()
238 return false
239 }
240 candidate.target.tree = targetTree
241 candidate.target.bumpLayoutGenerationLocked()
242 candidate.target.mu.Unlock()
243 candidate.source.mu.Unlock()
244 sess.mu.Unlock()
245 sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
246 sess.geometry.applyTabLayout(d, sess, candidate.target)
247 return true
248}