okami.mom/ultraviolet

git clone https://git.nkp.pet/okami.mom/ultraviolet

jane michelle arcfix: hold the poll reader for the input loop's lifetime (#179)666ce5e

main
11.4 KiB408 linesraw
1package uv
2
3import (
4	"fmt"
5	"os"
6	"os/signal"
7	"time"
8
9	"github.com/charmbracelet/x/ansi"
10	"golang.org/x/sync/errgroup"
11)
12
13// DefaultBufferSize is the default size of the input buffer used for reading
14// terminal events.
15const DefaultBufferSize = 4096
16
17// DefaultEventTimeout is the default duration to wait for input events before
18// timing out.
19const DefaultEventTimeout = 100 * time.Millisecond
20
21// Options represents options for creating a new [Terminal].
22type Options struct {
23	// BufferSize is the size of the input buffer used for reading terminal
24	// events. If zero, [DefaultBufferSize] is used.
25	BufferSize int
26
27	// EventTimeout is the duration to wait for input events before timing out.
28	// If zero, a default of 100 milliseconds is used.
29	EventTimeout time.Duration
30
31	// LegacyKeyEncoding represents any legacy key encoding ambiguities. By
32	// default, the terminal will use its preferred key encoding settings.
33	LegacyKeyEncoding LegacyKeyEncoding
34
35	// LookupKeys whether to use a lookup table for common key sequences. If
36	// true, the terminal will use a lookup table to quickly identify common
37	// key sequences, reducing the need for more complex decoding logic. This
38	// can improve performance for common key sequences at the cost of
39	// increased memory usage.
40	//
41	// This is enabled by default.
42	LookupKeys bool
43
44	// UseTerminfoKeys whether to use terminfo databases key definitions to
45	// build up the keys lookup table. If true, the terminal will use terminfo
46	// databases key definitions to build up the keys lookup table, which can
47	// provide more accurate key mappings for legacy non-xterm like terminals.
48	//
49	// This won't take effect if [TerminalOptions.LookupKeys] is false, since
50	// the lookup table won't be used.
51	//
52	// This is disabled by default.
53	UseTerminfoKeys bool
54
55	// Logger is an optional logger for tracing terminal I/O operations.
56	// If nil, no logging is performed.
57	Logger Logger
58}
59
60// DefaultOptions returns the default [Terminal] options.
61func DefaultOptions() *Options {
62	return &Options{
63		BufferSize:   DefaultBufferSize,
64		EventTimeout: DefaultEventTimeout,
65		LookupKeys:   true,
66	}
67}
68
69// Terminal represents an interactive terminal application.
70type Terminal struct {
71	con   Console
72	opts  *Options
73	scr   *TerminalScreen
74	pr    pollReader
75	buf   []byte
76	evc   chan Event
77	errg  errgroup.Group
78	winch chan os.Signal
79	donec chan struct{}
80}
81
82// DefaultTerminal creates a new [Terminal] instance using the default standard
83// console and the given options. Options can be nil to use the default
84// options.
85//
86// This is a convenience function for creating a terminal that uses the
87// standard input and output file descriptors.
88func DefaultTerminal() *Terminal {
89	return NewTerminal(nil, nil)
90}
91
92// ControllingTerminal creates a new [Terminal] instance using the controlling
93// terminal's input and output file descriptors.
94// Options can be nil to use the default options.
95//
96// This is a convenience function for creating a terminal that uses the
97// controlling TTY of the current process.
98func ControllingTerminal() (*Terminal, error) {
99	con, err := ControllingConsole()
100	if err != nil {
101		return nil, err
102	}
103	return NewTerminal(con, nil), nil
104}
105
106// NewTerminal creates a new [Terminal] instance with the given console and
107// options.
108// Options can be nil to use the default options.
109func NewTerminal(con Console, opts *Options) *Terminal {
110	t := &Terminal{}
111	if con == nil {
112		con = DefaultConsole()
113	}
114	if opts == nil {
115		opts = DefaultOptions()
116	}
117	if opts.BufferSize <= 0 {
118		opts.BufferSize = DefaultBufferSize
119	}
120	if opts.EventTimeout <= 0 {
121		opts.EventTimeout = DefaultEventTimeout
122	}
123	t.con = con
124	t.opts = opts
125	t.scr = NewTerminalScreen(t.con.Writer(), t.con.Environ())
126	t.buf = make([]byte, opts.BufferSize)
127	// These channels never close during the terminal's lifetime.
128	t.evc = make(chan Event)
129	t.winch = make(chan os.Signal, 1) // buffered to avoid missing signals
130	if opts.Logger != nil {
131		t.scr.rend.SetLogger(opts.Logger)
132	}
133	return t
134}
135
136// GetSize returns the current size of the terminal in characters and pixels.
137func (t *Terminal) GetSize() (width, height int, err error) {
138	w, h, err := t.con.GetSize()
139	if err != nil {
140		return 0, 0, fmt.Errorf("getting terminal size: %w", err)
141	}
142	return int(w), int(h), nil
143}
144
145// GetWinsize returns the current size of the terminal as a [Winsize] struct.
146// This includes both character dimensions (columns and rows) and pixel
147// dimensions (xpixel and ypixel).
148//
149// Note that this only returns the pixel dimensions on Unix-like systems that
150// support the TIOCGWINSZ ioctl. On other platforms, the pixel dimensions may
151// be zero or not available.
152func (t *Terminal) GetWinsize() (*Winsize, error) {
153	ws, err := t.con.GetWinsize()
154	if err != nil {
155		return nil, fmt.Errorf("getting terminal winsize: %w", err)
156	}
157	return ws, nil
158}
159
160// Screen returns the terminal's screen.
161func (t *Terminal) Screen() *TerminalScreen {
162	return t.scr
163}
164
165// Events returns the terminal's event channel.
166func (t *Terminal) Events() <-chan Event {
167	return t.evc
168}
169
170// Start starts the terminal application event loop. This is a non-blocking
171// call. Use [Terminal.Wait] to wait for the terminal to exit.
172func (t *Terminal) Start() error {
173	_, err := t.con.MakeRaw()
174	if err != nil {
175		return fmt.Errorf("failed to set terminal to raw mode: %w", err)
176	}
177
178	evs := newEventScanner()
179	evs.lookup = t.opts.LookupKeys
180	if evs.lookup {
181		evs.table = buildKeysTable(t.opts.LegacyKeyEncoding, t.con.Getenv("TERM"), t.opts.UseTerminfoKeys)
182	}
183	if t.opts.Logger != nil {
184		evs.setLogger(t.opts.Logger)
185	}
186	bufc := make(chan []byte)
187	t.donec = make(chan struct{})
188	t.pr, err = newPollReader(t.con.Reader())
189	if err != nil {
190		return fmt.Errorf("failed to create poll reader: %w", err)
191	}
192
193	// input loop
194	//
195	// The loop holds its own reference to the reader for its lifetime. Stop
196	// sets t.pr to nil, and a loop that reads the field on every iteration
197	// can evaluate it after that has happened and dereference nil. Cancel
198	// and Close on the reader are what stop this loop; nilling the field is
199	// only bookkeeping for a later Start.
200	pr := t.pr
201	t.errg.Go(func() error {
202		for {
203			n, err := pr.Read(t.buf)
204			if err != nil {
205				return fmt.Errorf("reading terminal input: %w", err)
206			}
207			// Hand the event loop its own copy. t.buf is reused by the
208			// next Read, and the receiver only copies out of the slice
209			// after the send has completed, so sending t.buf[:n] lets
210			// one read's bytes be overwritten by the next before they
211			// have been consumed.
212			data := make([]byte, n)
213			copy(data, t.buf[:n])
214			select {
215			case bufc <- data:
216			case <-t.donec:
217				return nil
218			}
219		}
220	})
221
222	// event loop
223	sendEvents := func(buf []byte, expired bool) int {
224		n, events := evs.scanEvents(buf, expired)
225		for _, ev := range events {
226			t.handleEvent(ev)
227			t.SendEvent(ev)
228		}
229		return n
230	}
231	t.errg.Go(func() error {
232		var buf []byte
233		timer := time.NewTimer(t.opts.EventTimeout)
234		timeout := time.Now().Add(t.opts.EventTimeout)
235
236		for {
237			select {
238			case <-t.donec:
239				return nil
240			case <-timer.C:
241				expired := len(buf) > 0 && time.Now().After(timeout)
242				n := sendEvents(buf, expired)
243				if n > 0 {
244					buf = buf[min(n, len(buf)):]
245				}
246				if len(buf) > 0 {
247					timer.Reset(t.opts.EventTimeout)
248				}
249			case data := <-bufc:
250				buf = append(buf, data...)
251				n := sendEvents(buf, false)
252				timeout = time.Now().Add(t.opts.EventTimeout)
253				timer.Stop()
254				if n > 0 {
255					buf = buf[min(n, len(buf)):]
256				}
257				if len(buf) > 0 {
258					timer.Reset(t.opts.EventTimeout)
259				}
260			}
261		}
262	})
263
264	sendWinsize := func() error {
265		ws, err := t.con.GetWinsize()
266		if err != nil {
267			return fmt.Errorf("getting terminal size: %w", err)
268		}
269		if ws.Col > 0 && ws.Row > 0 {
270			t.SendEvent(WindowSizeEvent{
271				Width:  int(ws.Col),
272				Height: int(ws.Row),
273			})
274		}
275		if ws.Xpixel > 0 && ws.Ypixel > 0 {
276			t.SendEvent(PixelSizeEvent{
277				Width:  int(ws.Xpixel),
278				Height: int(ws.Ypixel),
279			})
280		}
281		return nil
282	}
283
284	// winch handler
285	NotifyWinch(t.winch)
286	t.errg.Go(func() error {
287		for {
288			select {
289			case <-t.donec:
290				return nil
291			case <-t.winch:
292				if err := sendWinsize(); err != nil {
293					return err
294				}
295			}
296		}
297	})
298
299	// init window size
300	t.errg.Go(func() error {
301		if err := sendWinsize(); err != nil {
302			return err
303		}
304		return nil
305	})
306
307	// Restore any previous screen state.
308	t.scr.Restore()
309	// Query whether the terminal supports Unicode core mode (DEC mode 2027) so
310	// we can negotiate grapheme-cluster width. The response is handled in the
311	// event loop.
312	t.scr.requestGraphemeWidth()
313	if err := t.scr.Flush(); err != nil {
314		return fmt.Errorf("failed to flush terminal screen: %w", err)
315	}
316
317	return nil
318}
319
320// handleEvent reacts to internal events before they are forwarded to the
321// application. It negotiates Unicode core mode (DEC mode 2027): when the
322// terminal reports the mode is in a settable or active state, it enables
323// grapheme-cluster width so wide-glyph measurement matches the terminal. The
324// event is still forwarded to the application.
325//
326// The mode is only enabled for settable/active states (mode set, reset but
327// settable, or permanently set). It is explicitly not enabled when the mode is
328// not recognized or permanently reset, since those terminals cannot honor it.
329// Enabling goes through the screen's locked write path so it cannot race with
330// the application's Render/Flush.
331func (t *Terminal) handleEvent(ev Event) {
332	if mr, ok := ev.(ModeReportEvent); ok {
333		if mr.Mode == ansi.ModeUnicodeCore &&
334			(mr.Value.IsSet() || mr.Value == ansi.ModeReset) {
335			t.scr.enableGraphemeWidth()
336		}
337	}
338}
339
340// Wait waits for the terminal event loop to exit and returns any error that
341// occurred.
342func (t *Terminal) Wait() error {
343	if err := t.errg.Wait(); err != nil {
344		return fmt.Errorf("terminal event loop error: %w", err)
345	}
346	return nil
347}
348
349// Stop stops the terminal event loop. It is safe to call Stop without a
350// prior [Terminal.Start], and safe to call Stop multiple times in a row.
351// After Stop returns, [Terminal.Start] may be called again to resume.
352func (t *Terminal) Stop() error {
353	if t.donec != nil {
354		select {
355		case <-t.donec:
356			// Already closed.
357		default:
358			close(t.donec)
359		}
360	}
361	if t.winch != nil {
362		signal.Stop(t.winch)
363	}
364	if t.pr != nil {
365		t.pr.Cancel()
366		_ = t.pr.Close()
367		t.pr = nil
368	}
369	t.scr.Reset()
370	if err := t.scr.Flush(); err != nil {
371		_ = t.con.Restore()
372		return fmt.Errorf("failed to flush terminal screen: %w", err)
373	}
374	if err := t.con.Restore(); err != nil {
375		return fmt.Errorf("failed to restore terminal state: %w", err)
376	}
377	return nil
378}
379
380// SendEvent sends an event to the terminal's event channel.
381//
382// This can be used to inject custom events into the terminal's event loop,
383// such as timer events, signals, or application-specific events.
384func (t *Terminal) SendEvent(e Event) {
385	select {
386	case t.evc <- e:
387	case <-t.donec:
388	}
389}
390
391// Write writes data directly to the terminal's console output.
392//
393// This is a low-level operation that bypasses the terminal screen buffering
394// and writes directly to the console output handler, usually [os.Stdout] or
395// the controlling TTY.
396func (t *Terminal) Write(p []byte) (n int, err error) {
397	return t.con.Write(p)
398}
399
400// Read reads data from the terminal's console input.
401//
402// This is a low-level operation that bypasses the terminal event processing
403// and reads directly from the console input handler, usually [os.Stdin] or the
404// controlling TTY. Use this method with caution, as it may interfere with the
405// terminal's event loop and screen management.
406func (t *Terminal) Read(p []byte) (n int, err error) {
407	return t.con.Read(p)
408}