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
29 mu sync.Mutex
30 decided bool
31 accepted bool
32 result core.PickResult
33 mode ports.Mode // the mode the confirming overlay showed
34 errs []error
35}
36
37func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} }
38
39// toggle flips the capture mode for every overlay and asks each to redraw.
40// After the first decision it does nothing, so a late Tab on another overlay
41// cannot change what was confirmed.
42func (s *session) toggle() {
43 s.mu.Lock()
44 decided := s.decided
45 if !decided {
46 s.toggles.Add(1)
47 }
48 s.mu.Unlock()
49 if decided {
50 return
51 }
52 for _, w := range s.wakes {
53 select {
54 case w <- struct{}{}:
55 default:
56 }
57 }
58}
59
60// accept records the first decision, with the mode its overlay showed, and
61// ends every overlay.
62func (s *session) accept(r core.PickResult, mode ports.Mode) {
63 s.mu.Lock()
64 if !s.decided {
65 s.decided, s.accepted, s.result, s.mode = true, true, r, mode
66 }
67 s.mu.Unlock()
68 s.cancel()
69}
70
71// abort records the first decision as "no selection" and ends every overlay.
72func (s *session) abort() {
73 s.mu.Lock()
74 s.decided = true
75 s.mu.Unlock()
76 s.cancel()
77}
78
79// fail records a real error, which rejects the selection, and ends every overlay.
80func (s *session) fail(err error) {
81 s.mu.Lock()
82 s.decided = true
83 s.errs = append(s.errs, err)
84 s.mu.Unlock()
85 s.cancel()
86}
87
88func (s *session) isDecided() bool {
89 s.mu.Lock()
90 defer s.mu.Unlock()
91 return s.decided
92}
93
94// runEnded classifies one overlay's Run result with uierrors.Ended. The state
95// it depends on (decided, parent error) is read once, so a context that ends
96// meanwhile cannot flip the answer. An overlay that ended with no decision and
97// a live parent was removed by the compositor, which is a failure, and any real
98// error reported with it is kept. Failures name the output.
99func (s *session) runEnded(parent context.Context, output string, runErr error) {
100 perr := parent.Err()
101 explained := perr != nil || s.isDecided()
102 if err := uierrors.Ended(runErr, explained, perr, errOverlayClosed); err != nil {
103 s.fail(fmt.Errorf("selection: output %s: %w", output, err))
104 }
105}
106
107// outcome is read after every overlay has returned. The caller's context wins;
108// then real errors; then the decision.
109func (s *session) outcome(parent context.Context) (core.PickResult, bool, error) {
110 s.mu.Lock()
111 defer s.mu.Unlock()
112 if perr := parent.Err(); perr != nil {
113 return core.PickResult{}, false, errors.Join(append([]error{perr}, s.errs...)...)
114 }
115 if len(s.errs) > 0 {
116 return core.PickResult{}, false, errors.Join(s.errs...)
117 }
118 return s.result, s.accepted, nil
119}
120
121// acceptedMode is the mode recorded by accept.
122func (s *session) acceptedMode() ports.Mode {
123 s.mu.Lock()
124 defer s.mu.Unlock()
125 return s.mode
126}