1// Package term adapts vev's ports to terminal I/O implementations.
2package term
3
4import (
5 "fmt"
6 "io"
7 "os"
8 "os/signal"
9 "sync"
10 "syscall"
11
12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/pkg/rawterm"
15)
16
17// Compile-time check that Terminal satisfies ports.Terminal.
18var _ ports.Terminal = (*Terminal)(nil)
19
20// bufSize is the batched writer's buffer capacity.
21const bufSize = 64 * 1024
22
23// Terminal implements ports.Terminal for the client-side controlling
24// terminal: raw mode, alt-screen, SIGWINCH-driven resize events, and a
25// batched stdout writer.
26//
27// Zero value is not usable; use New or NewWithFiles.
28type Terminal struct {
29 in *os.File
30 out *os.File
31 fd int
32 bw *batchWriter
33 observation ports.UIObservationSink
34
35 mu sync.Mutex
36 geometryMu sync.Mutex // serializes winsize reads with observation publication
37 orig *rawterm.State // saved terminal state; nil when raw mode wasn't entered (or was restored)
38 entered bool // true between a successful EnterRaw and its restore
39 rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped
40 restoreFn func() error // the single idempotent restore closure for the current session
41 kittyKeys bool // kitty keyboard flags were pushed and must be popped on restore
42
43 // Resize watcher state, also guarded by mu so watcher start
44 // (ResizeEvents) and watcher stop (restore) are strictly ordered:
45 // a restore that runs before the first ResizeEvents call must
46 // prevent any watcher (and its signal.Notify) from ever starting.
47 resizeCh chan domain.Geometry
48 resizeQuit chan struct{}
49 sigCh chan os.Signal
50 resizeDone bool // set by stopResizeLocked; no watcher may start afterwards
51 resizeWG sync.WaitGroup
52}
53
54// New creates a Terminal backed by os.Stdin and os.Stdout.
55func New() *Terminal {
56 return NewWithFiles(os.Stdin, os.Stdout)
57}
58
59// NewWithFiles creates a Terminal backed by the given tty files. It
60// exists so tests can inject a PTY pair (or a plain pipe, for the
61// non-tty paths) in place of the process's real stdin/stdout.
62func NewWithFiles(in, out *os.File) *Terminal {
63 return NewWithFilesAndObservation(in, out, nil)
64}
65
66// NewWithFilesAndObservation creates a terminal that mirrors successfully
67// emitted byte prefixes and flush boundaries to observation.
68func NewWithFilesAndObservation(in, out *os.File, observation ports.UIObservationSink) *Terminal {
69 return &Terminal{
70 in: in,
71 out: out,
72 fd: int(in.Fd()),
73 bw: newBatchWriterWithObservation(out, bufSize, observation),
74 observation: observation,
75 }
76}
77
78// EnterRaw puts the terminal into raw mode (when the input fd is
79// actually a tty; otherwise the raw-mode syscalls are skipped and
80// recorded, not treated as failure), enters the alt screen, and hides
81// the cursor. The returned restore func idempotently reverses all of
82// this; it is safe to call multiple times.
83func (t *Terminal) EnterRaw() (func() error, error) {
84 t.mu.Lock()
85 defer t.mu.Unlock()
86
87 if t.entered {
88 return t.restoreFn, nil
89 }
90
91 var old *rawterm.State
92 if rawterm.IsTerminal(t.fd) {
93 s, err := rawterm.MakeRaw(t.fd)
94 if err != nil {
95 return nil, fmt.Errorf("term: make raw: %w", err)
96 }
97 old = s
98 t.rawSkipped = false
99 } else {
100 t.rawSkipped = true
101 }
102 t.orig = old
103
104 if _, err := t.bw.Write(VisualEnterSequence()); err != nil {
105 t.resetVisualModesDirect()
106 _ = t.restoreRawLocked()
107 return nil, fmt.Errorf("term: enter alt screen: %w", err)
108 }
109 if err := t.bw.Flush(); err != nil {
110 t.resetVisualModesDirect()
111 _ = t.restoreRawLocked()
112 return nil, fmt.Errorf("term: enter alt screen: %w", err)
113 }
114
115 t.entered = true
116 t.restoreFn = t.makeRestoreLocked()
117 return t.restoreFn, nil
118}
119
120// EnableKittyKeyboard pushes kitty keyboard protocol flags on the alternate
121// screen entered by EnterRaw. The session's restore pops them.
122func (t *Terminal) EnableKittyKeyboard(flags int) error {
123 t.mu.Lock()
124 defer t.mu.Unlock()
125 if !t.entered || t.kittyKeys {
126 return nil
127 }
128 // Mark first: if any prefix reached the terminal, restore must pop.
129 t.kittyKeys = true
130 if _, err := t.bw.Write(KittyKeyboardPushSequence(flags)); err != nil {
131 return fmt.Errorf("term: enable kitty keyboard: %w", err)
132 }
133 if err := t.bw.Flush(); err != nil {
134 return fmt.Errorf("term: enable kitty keyboard: %w", err)
135 }
136 return nil
137}
138
139// restoreSequenceLocked returns the visual cleanup for the current session.
140// Must be called with t.mu held.
141func (t *Terminal) restoreSequenceLocked() []byte {
142 if t.kittyKeys {
143 return VisualRestoreSequenceWithKittyKeyboard()
144 }
145 return VisualRestoreSequence()
146}
147
148// resetVisualModesDirect writes visualRestore straight to the descriptor
149// instead of through the batched writer. It is the fallback for a writer that
150// has already failed: any prefix of the enter sequence may have reached the
151// terminal, and a hidden cursor, live mouse reporting or the alt screen would
152// outlive the process. Replaying the resets when nothing was emitted is
153// harmless. Best effort by construction — the descriptor is usually broken
154// too. Must be called with t.mu held.
155func (t *Terminal) resetVisualModesDirect() {
156 if t.observation != nil {
157 t.observation.InvalidateTerminalObservation()
158 }
159 _, _ = t.out.Write(t.restoreSequenceLocked())
160}
161
162// makeRestoreLocked returns an idempotent restore closure for the
163// current EnterRaw "session". Must be called with t.mu held.
164func (t *Terminal) makeRestoreLocked() func() error {
165 var once sync.Once
166 var err error
167 return func() error {
168 once.Do(func() {
169 err = t.restore()
170 })
171 return err
172 }
173}
174
175// restore reverses EnterRaw: it shows the cursor, exits the alt screen,
176// restores the original terminal mode (if raw mode was actually
177// entered), and stops the resize watcher, closing its channel.
178func (t *Terminal) restore() error {
179 t.mu.Lock()
180 defer t.mu.Unlock()
181
182 _, werr := t.bw.Write(t.restoreSequenceLocked())
183 ferr := t.bw.Flush()
184 if werr != nil || ferr != nil {
185 t.resetVisualModesDirect()
186 }
187
188 rerr := t.restoreRawLocked()
189
190 t.entered = false
191 t.kittyKeys = false
192 t.restoreFn = nil
193
194 t.stopResizeLocked()
195
196 switch {
197 case werr != nil:
198 return fmt.Errorf("term: restore: write: %w", werr)
199 case ferr != nil:
200 return fmt.Errorf("term: restore: flush: %w", ferr)
201 case rerr != nil:
202 return fmt.Errorf("term: restore: %w", rerr)
203 default:
204 return nil
205 }
206}
207
208// restoreRawLocked restores the original terminal mode if it was saved.
209// Must be called with t.mu held.
210func (t *Terminal) restoreRawLocked() error {
211 if t.orig == nil {
212 return nil
213 }
214 err := rawterm.Restore(t.fd, t.orig)
215 t.orig = nil
216 return err
217}
218
219// Geometry returns the current terminal cell dimensions and optional pixel
220// dimensions reported by the controlling terminal.
221func (t *Terminal) Geometry() (domain.Geometry, error) {
222 t.geometryMu.Lock()
223 defer t.geometryMu.Unlock()
224 ws, err := rawterm.GetWinsize(t.fd)
225 if err != nil {
226 return domain.Geometry{}, fmt.Errorf("term: get size: %w", err)
227 }
228 geometry := (domain.Geometry{
229 Size: domain.Size{Cols: int(ws.Col), Rows: int(ws.Row)},
230 PixelWidth: int(ws.Xpixel),
231 PixelHeight: int(ws.Ypixel),
232 }).NormalizePixels()
233 if t.observation != nil {
234 t.observation.ObserveTerminalResize(geometry)
235 }
236 return geometry, nil
237}
238
239// ResizeEvents returns a channel of coalesced terminal sizes, one per
240// SIGWINCH burst. The SIGWINCH watcher starts lazily on the first call;
241// subsequent calls return the same channel. The channel is closed once
242// restore (returned by EnterRaw) runs. If restore has already run when
243// ResizeEvents is first called, no watcher is started and the returned
244// channel is already closed.
245func (t *Terminal) ResizeEvents() <-chan domain.Geometry {
246 t.mu.Lock()
247 defer t.mu.Unlock()
248
249 if t.resizeCh != nil {
250 return t.resizeCh
251 }
252 t.resizeCh = make(chan domain.Geometry)
253
254 if t.resizeDone {
255 // restore ran before the first ResizeEvents call: never start a
256 // watcher (or a signal handler) after stop; hand back a closed
257 // channel so a consumer selecting on it doesn't hang.
258 close(t.resizeCh)
259 return t.resizeCh
260 }
261
262 t.resizeQuit = make(chan struct{})
263 t.sigCh = make(chan os.Signal, 1)
264 signal.Notify(t.sigCh, syscall.SIGWINCH)
265
266 t.resizeWG.Go(func() {
267 defer signal.Stop(t.sigCh)
268 resizeLoop(t.sigCh, t.resizeCh, t.resizeQuit, t.Geometry)
269 })
270
271 return t.resizeCh
272}
273
274// stopResizeLocked stops the SIGWINCH watcher goroutine (if one was
275// started) and waits for it to exit, so its channel is guaranteed
276// closed before stopResizeLocked returns. It marks the watcher as done
277// so no watcher can start afterwards. Must be called with t.mu held;
278// the watcher goroutine never takes t.mu, so waiting under the lock
279// cannot deadlock.
280func (t *Terminal) stopResizeLocked() {
281 if !t.resizeDone {
282 t.resizeDone = true
283 if t.resizeQuit != nil {
284 close(t.resizeQuit)
285 }
286 }
287 t.resizeWG.Wait()
288}
289
290// In returns the reader for the controlling terminal's input.
291func (t *Terminal) In() io.Reader {
292 return t.in
293}
294
295// Out returns the batched writer for the controlling terminal's output.
296// Call Flush to push buffered bytes to the underlying tty.
297func (t *Terminal) Out() io.Writer {
298 return t.bw
299}
300
301// Flush writes any buffered output to the terminal device.
302func (t *Terminal) Flush() error {
303 err := t.bw.Flush()
304 if err != nil && t.observation != nil {
305 t.observation.InvalidateTerminalObservation()
306 }
307 return err
308}