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// Escape sequences for alt-screen and cursor visibility control. All
21// emissions go through the batched writer and are explicitly flushed.
22const (
23 altScreenEnter = "\x1b[?1049h"
24 altScreenExit = "\x1b[?1049l"
25 cursorHide = "\x1b[?25l"
26 cursorShow = "\x1b[?25h"
27 cursorStyleDefault = "\x1b[0 q"
28 mouseEnable = "\x1b[?1002h\x1b[?1006h"
29 mouseDisable = "\x1b[?1002l\x1b[?1006l"
30 bracketedPasteEnable = "\x1b[?2004h"
31 bracketedPasteDisable = "\x1b[?2004l"
32 colorSchemeEnable = "\x1b[?2031h"
33 colorSchemeDisable = "\x1b[?2031l"
34 // DECAWM. vev addresses every row explicitly and never relies on the
35 // terminal wrapping its output, so autowrap stays off while it owns the
36 // alt screen: a frame the daemon composed for a wider terminal is then
37 // clipped at the right margin instead of bleeding a styled run onto the
38 // next row. Restored alongside the other modes, since DECAWM is global
39 // and is not saved by the alt-screen switch.
40 autowrapDisable = "\x1b[?7l"
41 autowrapEnable = "\x1b[?7h"
42)
43
44// visualRestore turns off every mode EnterRaw turns on, in reverse order, and
45// hands the normal screen back. Shared by the ordinary restore and the direct
46// fallback so the two can never drift: a partial emission may have left any
47// prefix of the enter sequence in effect, so the reset has to cover all of it.
48// Every mode reset here is idempotent.
49const visualRestore = cursorShow + cursorStyleDefault + mouseDisable +
50 bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
51
52// bufSize is the batched writer's buffer capacity.
53const bufSize = 64 * 1024
54
55// Terminal implements ports.Terminal for the client-side controlling
56// terminal: raw mode, alt-screen, SIGWINCH-driven resize events, and a
57// batched stdout writer.
58//
59// Zero value is not usable; use New or NewWithFiles.
60type Terminal struct {
61 in *os.File
62 out *os.File
63 fd int
64 bw *batchWriter
65
66 mu sync.Mutex
67 orig *rawterm.State // saved terminal state; nil when raw mode wasn't entered (or was restored)
68 entered bool // true between a successful EnterRaw and its restore
69 rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped
70 restoreFn func() error // the single idempotent restore closure for the current session
71
72 // Resize watcher state, also guarded by mu so watcher start
73 // (ResizeEvents) and watcher stop (restore) are strictly ordered:
74 // a restore that runs before the first ResizeEvents call must
75 // prevent any watcher (and its signal.Notify) from ever starting.
76 resizeCh chan domain.Size
77 resizeQuit chan struct{}
78 sigCh chan os.Signal
79 resizeDone bool // set by stopResizeLocked; no watcher may start afterwards
80 resizeWG sync.WaitGroup
81}
82
83// New creates a Terminal backed by os.Stdin and os.Stdout.
84func New() *Terminal {
85 return NewWithFiles(os.Stdin, os.Stdout)
86}
87
88// NewWithFiles creates a Terminal backed by the given tty files. It
89// exists so tests can inject a PTY pair (or a plain pipe, for the
90// non-tty paths) in place of the process's real stdin/stdout.
91func NewWithFiles(in, out *os.File) *Terminal {
92 return &Terminal{
93 in: in,
94 out: out,
95 fd: int(in.Fd()),
96 bw: newBatchWriter(out, bufSize),
97 }
98}
99
100// EnterRaw puts the terminal into raw mode (when the input fd is
101// actually a tty; otherwise the raw-mode syscalls are skipped and
102// recorded, not treated as failure), enters the alt screen, and hides
103// the cursor. The returned restore func idempotently reverses all of
104// this; it is safe to call multiple times.
105func (t *Terminal) EnterRaw() (func() error, error) {
106 t.mu.Lock()
107 defer t.mu.Unlock()
108
109 if t.entered {
110 return t.restoreFn, nil
111 }
112
113 var old *rawterm.State
114 if rawterm.IsTerminal(t.fd) {
115 s, err := rawterm.MakeRaw(t.fd)
116 if err != nil {
117 return nil, fmt.Errorf("term: make raw: %w", err)
118 }
119 old = s
120 t.rawSkipped = false
121 } else {
122 t.rawSkipped = true
123 }
124 t.orig = old
125
126 if _, err := t.bw.WriteString(altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil {
127 t.resetVisualModesDirect()
128 _ = t.restoreRawLocked()
129 return nil, fmt.Errorf("term: enter alt screen: %w", err)
130 }
131 if err := t.bw.Flush(); err != nil {
132 t.resetVisualModesDirect()
133 _ = t.restoreRawLocked()
134 return nil, fmt.Errorf("term: enter alt screen: %w", err)
135 }
136
137 t.entered = true
138 t.restoreFn = t.makeRestoreLocked()
139 return t.restoreFn, nil
140}
141
142// resetVisualModesDirect writes visualRestore straight to the descriptor
143// instead of through the batched writer. It is the fallback for a writer that
144// has already failed: any prefix of the enter sequence may have reached the
145// terminal, and a hidden cursor, live mouse reporting or the alt screen would
146// outlive the process. Replaying the resets when nothing was emitted is
147// harmless. Best effort by construction — the descriptor is usually broken
148// too. Must be called with t.mu held.
149func (t *Terminal) resetVisualModesDirect() {
150 _, _ = t.out.WriteString(visualRestore)
151}
152
153// makeRestoreLocked returns an idempotent restore closure for the
154// current EnterRaw "session". Must be called with t.mu held.
155func (t *Terminal) makeRestoreLocked() func() error {
156 var once sync.Once
157 var err error
158 return func() error {
159 once.Do(func() {
160 err = t.restore()
161 })
162 return err
163 }
164}
165
166// restore reverses EnterRaw: it shows the cursor, exits the alt screen,
167// restores the original terminal mode (if raw mode was actually
168// entered), and stops the resize watcher, closing its channel.
169func (t *Terminal) restore() error {
170 t.mu.Lock()
171 defer t.mu.Unlock()
172
173 _, werr := t.bw.WriteString(visualRestore)
174 ferr := t.bw.Flush()
175 if werr != nil || ferr != nil {
176 t.resetVisualModesDirect()
177 }
178
179 rerr := t.restoreRawLocked()
180
181 t.entered = false
182 t.restoreFn = nil
183
184 t.stopResizeLocked()
185
186 switch {
187 case werr != nil:
188 return fmt.Errorf("term: restore: write: %w", werr)
189 case ferr != nil:
190 return fmt.Errorf("term: restore: flush: %w", ferr)
191 case rerr != nil:
192 return fmt.Errorf("term: restore: %w", rerr)
193 default:
194 return nil
195 }
196}
197
198// restoreRawLocked restores the original terminal mode if it was saved.
199// Must be called with t.mu held.
200func (t *Terminal) restoreRawLocked() error {
201 if t.orig == nil {
202 return nil
203 }
204 err := rawterm.Restore(t.fd, t.orig)
205 t.orig = nil
206 return err
207}
208
209// Size returns the current terminal dimensions.
210func (t *Terminal) Size() (domain.Size, error) {
211 cols, rows, err := rawterm.GetSize(t.fd)
212 if err != nil {
213 return domain.Size{}, fmt.Errorf("term: get size: %w", err)
214 }
215 return domain.Size{Cols: cols, Rows: rows}, nil
216}
217
218// ResizeEvents returns a channel of coalesced terminal sizes, one per
219// SIGWINCH burst. The SIGWINCH watcher starts lazily on the first call;
220// subsequent calls return the same channel. The channel is closed once
221// restore (returned by EnterRaw) runs. If restore has already run when
222// ResizeEvents is first called, no watcher is started and the returned
223// channel is already closed.
224func (t *Terminal) ResizeEvents() <-chan domain.Size {
225 t.mu.Lock()
226 defer t.mu.Unlock()
227
228 if t.resizeCh != nil {
229 return t.resizeCh
230 }
231 t.resizeCh = make(chan domain.Size)
232
233 if t.resizeDone {
234 // restore ran before the first ResizeEvents call: never start a
235 // watcher (or a signal handler) after stop; hand back a closed
236 // channel so a consumer selecting on it doesn't hang.
237 close(t.resizeCh)
238 return t.resizeCh
239 }
240
241 t.resizeQuit = make(chan struct{})
242 t.sigCh = make(chan os.Signal, 1)
243 signal.Notify(t.sigCh, syscall.SIGWINCH)
244
245 t.resizeWG.Go(func() {
246 defer signal.Stop(t.sigCh)
247 resizeLoop(t.sigCh, t.resizeCh, t.resizeQuit, t.Size)
248 })
249
250 return t.resizeCh
251}
252
253// stopResizeLocked stops the SIGWINCH watcher goroutine (if one was
254// started) and waits for it to exit, so its channel is guaranteed
255// closed before stopResizeLocked returns. It marks the watcher as done
256// so no watcher can start afterwards. Must be called with t.mu held;
257// the watcher goroutine never takes t.mu, so waiting under the lock
258// cannot deadlock.
259func (t *Terminal) stopResizeLocked() {
260 if !t.resizeDone {
261 t.resizeDone = true
262 if t.resizeQuit != nil {
263 close(t.resizeQuit)
264 }
265 }
266 t.resizeWG.Wait()
267}
268
269// In returns the reader for the controlling terminal's input.
270func (t *Terminal) In() io.Reader {
271 return t.in
272}
273
274// Out returns the batched writer for the controlling terminal's output.
275// Call Flush to push buffered bytes to the underlying tty.
276func (t *Terminal) Out() io.Writer {
277 return t.bw
278}
279
280// Flush writes any buffered output to the terminal device.
281func (t *Terminal) Flush() error {
282 return t.bw.Flush()
283}