1package selection
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "sync"
8 "sync/atomic"
9
10 "github.com/bnema/nefercap/internal/adapters/uierrors"
11 "github.com/bnema/nefercap/internal/core"
12 "github.com/bnema/nefercap/internal/ports"
13)
14
15var errOverlayClosed = errors.New("selection: overlay closed by the compositor")
16
17// session is the only state the per-output overlays share. Each overlay owns
18// its model and window on its own goroutine; they talk to each other only
19// through this first-decision-wins record and the shared cancel, never through
20// window or picker state.
21type session struct {
22 cancel context.CancelFunc
23
24 // toggles counts Tab presses on any overlay; wakes redraw every overlay
25 // after one. wakes is set before the overlays start and never changes.
26 toggles atomic.Uint32
27 wakes []chan struct{}
28 // freezes counts F presses on any overlay: each flips the frozen screen
29 // on or off. canFreeze is set before the overlays start.
30 freezes atomic.Uint32
31 canFreeze bool
32
33 mu sync.Mutex
34 decided bool
35 accepted bool
36 result core.PickResult
37 mode ports.Mode // the mode the confirming overlay showed
38 frozen bool // whether the confirming overlay showed the frozen screen
39 errs []error
40}
41
42func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} }
43
44// toggle flips the capture mode for every overlay and asks each to redraw.
45// After the first decision it does nothing, so a late Tab on another overlay
46// cannot change what was confirmed.
47func (s *session) toggle() { s.flip(&s.toggles) }
48
49// toggleFreeze flips the frozen screen for every overlay, like toggle.
50func (s *session) toggleFreeze() {
51 if s.canFreeze {
52 s.flip(&s.freezes)
53 }
54}
55
56// flip counts one press before the first decision and wakes every overlay.
57func (s *session) flip(n *atomic.Uint32) {
58 s.mu.Lock()
59 decided := s.decided
60 if !decided {
61 n.Add(1)
62 }
63 s.mu.Unlock()
64 if decided {
65 return
66 }
67 for _, w := range s.wakes {
68 select {
69 case w <- struct{}{}:
70 default:
71 }
72 }
73}
74
75// accept records the first decision, with the mode and frozen state its
76// overlay showed, and ends every overlay.
77func (s *session) accept(r core.PickResult, mode ports.Mode, frozen bool) {
78 s.mu.Lock()
79 if !s.decided {
80 s.decided, s.accepted, s.result, s.mode, s.frozen = true, true, r, mode, frozen
81 }
82 s.mu.Unlock()
83 s.cancel()
84}
85
86// abort records the first decision as "no selection" and ends every overlay.
87func (s *session) abort() {
88 s.mu.Lock()
89 s.decided = true
90 s.mu.Unlock()
91 s.cancel()
92}
93
94// fail records a real error, which rejects the selection, and ends every overlay.
95func (s *session) fail(err error) {
96 s.mu.Lock()
97 s.decided = true
98 s.errs = append(s.errs, err)
99 s.mu.Unlock()
100 s.cancel()
101}
102
103func (s *session) isDecided() bool {
104 s.mu.Lock()
105 defer s.mu.Unlock()
106 return s.decided
107}
108
109// runEnded classifies one overlay's Run result with uierrors.Ended. The state
110// it depends on (decided, parent error) is read once, so a context that ends
111// meanwhile cannot flip the answer. An overlay that ended with no decision and
112// a live parent was removed by the compositor, which is a failure, and any real
113// error reported with it is kept. Failures name the output.
114func (s *session) runEnded(parent context.Context, output string, runErr error) {
115 perr := parent.Err()
116 explained := perr != nil || s.isDecided()
117 if err := uierrors.Ended(runErr, explained, perr, errOverlayClosed); err != nil {
118 s.fail(fmt.Errorf("selection: output %s: %w", output, err))
119 }
120}
121
122// outcome is read after every overlay has returned. The caller's context wins;
123// then real errors; then the decision.
124func (s *session) outcome(parent context.Context) (core.PickResult, bool, error) {
125 s.mu.Lock()
126 defer s.mu.Unlock()
127 if perr := parent.Err(); perr != nil {
128 return core.PickResult{}, false, errors.Join(append([]error{perr}, s.errs...)...)
129 }
130 if len(s.errs) > 0 {
131 return core.PickResult{}, false, errors.Join(s.errs...)
132 }
133 return s.result, s.accepted, nil
134}
135
136// acceptedMode is the mode recorded by accept.
137func (s *session) acceptedMode() ports.Mode {
138 s.mu.Lock()
139 defer s.mu.Unlock()
140 return s.mode
141}
142
143// acceptedFrozen is the frozen state recorded by accept.
144func (s *session) acceptedFrozen() bool {
145 s.mu.Lock()
146 defer s.mu.Unlock()
147 return s.frozen
148}