package uv import ( "fmt" "os" "os/signal" "time" "github.com/charmbracelet/x/ansi" "golang.org/x/sync/errgroup" ) // DefaultBufferSize is the default size of the input buffer used for reading // terminal events. const DefaultBufferSize = 4096 // DefaultEventTimeout is the default duration to wait for input events before // timing out. const DefaultEventTimeout = 100 * time.Millisecond // Options represents options for creating a new [Terminal]. type Options struct { // BufferSize is the size of the input buffer used for reading terminal // events. If zero, [DefaultBufferSize] is used. BufferSize int // EventTimeout is the duration to wait for input events before timing out. // If zero, a default of 100 milliseconds is used. EventTimeout time.Duration // LegacyKeyEncoding represents any legacy key encoding ambiguities. By // default, the terminal will use its preferred key encoding settings. LegacyKeyEncoding LegacyKeyEncoding // LookupKeys whether to use a lookup table for common key sequences. If // true, the terminal will use a lookup table to quickly identify common // key sequences, reducing the need for more complex decoding logic. This // can improve performance for common key sequences at the cost of // increased memory usage. // // This is enabled by default. LookupKeys bool // UseTerminfoKeys whether to use terminfo databases key definitions to // build up the keys lookup table. If true, the terminal will use terminfo // databases key definitions to build up the keys lookup table, which can // provide more accurate key mappings for legacy non-xterm like terminals. // // This won't take effect if [TerminalOptions.LookupKeys] is false, since // the lookup table won't be used. // // This is disabled by default. UseTerminfoKeys bool // Logger is an optional logger for tracing terminal I/O operations. // If nil, no logging is performed. Logger Logger } // DefaultOptions returns the default [Terminal] options. func DefaultOptions() *Options { return &Options{ BufferSize: DefaultBufferSize, EventTimeout: DefaultEventTimeout, LookupKeys: true, } } // Terminal represents an interactive terminal application. type Terminal struct { con Console opts *Options scr *TerminalScreen pr pollReader buf []byte evc chan Event errg errgroup.Group winch chan os.Signal donec chan struct{} } // DefaultTerminal creates a new [Terminal] instance using the default standard // console and the given options. Options can be nil to use the default // options. // // This is a convenience function for creating a terminal that uses the // standard input and output file descriptors. func DefaultTerminal() *Terminal { return NewTerminal(nil, nil) } // ControllingTerminal creates a new [Terminal] instance using the controlling // terminal's input and output file descriptors. // Options can be nil to use the default options. // // This is a convenience function for creating a terminal that uses the // controlling TTY of the current process. func ControllingTerminal() (*Terminal, error) { con, err := ControllingConsole() if err != nil { return nil, err } return NewTerminal(con, nil), nil } // NewTerminal creates a new [Terminal] instance with the given console and // options. // Options can be nil to use the default options. func NewTerminal(con Console, opts *Options) *Terminal { t := &Terminal{} if con == nil { con = DefaultConsole() } if opts == nil { opts = DefaultOptions() } if opts.BufferSize <= 0 { opts.BufferSize = DefaultBufferSize } if opts.EventTimeout <= 0 { opts.EventTimeout = DefaultEventTimeout } t.con = con t.opts = opts t.scr = NewTerminalScreen(t.con.Writer(), t.con.Environ()) t.buf = make([]byte, opts.BufferSize) // These channels never close during the terminal's lifetime. t.evc = make(chan Event) t.winch = make(chan os.Signal, 1) // buffered to avoid missing signals if opts.Logger != nil { t.scr.rend.SetLogger(opts.Logger) } return t } // GetSize returns the current size of the terminal in characters and pixels. func (t *Terminal) GetSize() (width, height int, err error) { w, h, err := t.con.GetSize() if err != nil { return 0, 0, fmt.Errorf("getting terminal size: %w", err) } return int(w), int(h), nil } // GetWinsize returns the current size of the terminal as a [Winsize] struct. // This includes both character dimensions (columns and rows) and pixel // dimensions (xpixel and ypixel). // // Note that this only returns the pixel dimensions on Unix-like systems that // support the TIOCGWINSZ ioctl. On other platforms, the pixel dimensions may // be zero or not available. func (t *Terminal) GetWinsize() (*Winsize, error) { ws, err := t.con.GetWinsize() if err != nil { return nil, fmt.Errorf("getting terminal winsize: %w", err) } return ws, nil } // Screen returns the terminal's screen. func (t *Terminal) Screen() *TerminalScreen { return t.scr } // Events returns the terminal's event channel. func (t *Terminal) Events() <-chan Event { return t.evc } // Start starts the terminal application event loop. This is a non-blocking // call. Use [Terminal.Wait] to wait for the terminal to exit. func (t *Terminal) Start() error { _, err := t.con.MakeRaw() if err != nil { return fmt.Errorf("failed to set terminal to raw mode: %w", err) } evs := newEventScanner() evs.lookup = t.opts.LookupKeys if evs.lookup { evs.table = buildKeysTable(t.opts.LegacyKeyEncoding, t.con.Getenv("TERM"), t.opts.UseTerminfoKeys) } if t.opts.Logger != nil { evs.setLogger(t.opts.Logger) } bufc := make(chan []byte) t.donec = make(chan struct{}) t.pr, err = newPollReader(t.con.Reader()) if err != nil { return fmt.Errorf("failed to create poll reader: %w", err) } // input loop // // The loop holds its own reference to the reader for its lifetime. Stop // sets t.pr to nil, and a loop that reads the field on every iteration // can evaluate it after that has happened and dereference nil. Cancel // and Close on the reader are what stop this loop; nilling the field is // only bookkeeping for a later Start. pr := t.pr t.errg.Go(func() error { for { n, err := pr.Read(t.buf) if err != nil { return fmt.Errorf("reading terminal input: %w", err) } // Hand the event loop its own copy. t.buf is reused by the // next Read, and the receiver only copies out of the slice // after the send has completed, so sending t.buf[:n] lets // one read's bytes be overwritten by the next before they // have been consumed. data := make([]byte, n) copy(data, t.buf[:n]) select { case bufc <- data: case <-t.donec: return nil } } }) // event loop sendEvents := func(buf []byte, expired bool) int { n, events := evs.scanEvents(buf, expired) for _, ev := range events { t.handleEvent(ev) t.SendEvent(ev) } return n } t.errg.Go(func() error { var buf []byte timer := time.NewTimer(t.opts.EventTimeout) timeout := time.Now().Add(t.opts.EventTimeout) for { select { case <-t.donec: return nil case <-timer.C: expired := len(buf) > 0 && time.Now().After(timeout) n := sendEvents(buf, expired) if n > 0 { buf = buf[min(n, len(buf)):] } if len(buf) > 0 { timer.Reset(t.opts.EventTimeout) } case data := <-bufc: buf = append(buf, data...) n := sendEvents(buf, false) timeout = time.Now().Add(t.opts.EventTimeout) timer.Stop() if n > 0 { buf = buf[min(n, len(buf)):] } if len(buf) > 0 { timer.Reset(t.opts.EventTimeout) } } } }) sendWinsize := func() error { ws, err := t.con.GetWinsize() if err != nil { return fmt.Errorf("getting terminal size: %w", err) } if ws.Col > 0 && ws.Row > 0 { t.SendEvent(WindowSizeEvent{ Width: int(ws.Col), Height: int(ws.Row), }) } if ws.Xpixel > 0 && ws.Ypixel > 0 { t.SendEvent(PixelSizeEvent{ Width: int(ws.Xpixel), Height: int(ws.Ypixel), }) } return nil } // winch handler NotifyWinch(t.winch) t.errg.Go(func() error { for { select { case <-t.donec: return nil case <-t.winch: if err := sendWinsize(); err != nil { return err } } } }) // init window size t.errg.Go(func() error { if err := sendWinsize(); err != nil { return err } return nil }) // Restore any previous screen state. t.scr.Restore() // Query whether the terminal supports Unicode core mode (DEC mode 2027) so // we can negotiate grapheme-cluster width. The response is handled in the // event loop. t.scr.requestGraphemeWidth() if err := t.scr.Flush(); err != nil { return fmt.Errorf("failed to flush terminal screen: %w", err) } return nil } // handleEvent reacts to internal events before they are forwarded to the // application. It negotiates Unicode core mode (DEC mode 2027): when the // terminal reports the mode is in a settable or active state, it enables // grapheme-cluster width so wide-glyph measurement matches the terminal. The // event is still forwarded to the application. // // The mode is only enabled for settable/active states (mode set, reset but // settable, or permanently set). It is explicitly not enabled when the mode is // not recognized or permanently reset, since those terminals cannot honor it. // Enabling goes through the screen's locked write path so it cannot race with // the application's Render/Flush. func (t *Terminal) handleEvent(ev Event) { if mr, ok := ev.(ModeReportEvent); ok { if mr.Mode == ansi.ModeUnicodeCore && (mr.Value.IsSet() || mr.Value == ansi.ModeReset) { t.scr.enableGraphemeWidth() } } } // Wait waits for the terminal event loop to exit and returns any error that // occurred. func (t *Terminal) Wait() error { if err := t.errg.Wait(); err != nil { return fmt.Errorf("terminal event loop error: %w", err) } return nil } // Stop stops the terminal event loop. It is safe to call Stop without a // prior [Terminal.Start], and safe to call Stop multiple times in a row. // After Stop returns, [Terminal.Start] may be called again to resume. func (t *Terminal) Stop() error { if t.donec != nil { select { case <-t.donec: // Already closed. default: close(t.donec) } } if t.winch != nil { signal.Stop(t.winch) } if t.pr != nil { t.pr.Cancel() _ = t.pr.Close() t.pr = nil } t.scr.Reset() if err := t.scr.Flush(); err != nil { _ = t.con.Restore() return fmt.Errorf("failed to flush terminal screen: %w", err) } if err := t.con.Restore(); err != nil { return fmt.Errorf("failed to restore terminal state: %w", err) } return nil } // SendEvent sends an event to the terminal's event channel. // // This can be used to inject custom events into the terminal's event loop, // such as timer events, signals, or application-specific events. func (t *Terminal) SendEvent(e Event) { select { case t.evc <- e: case <-t.donec: } } // Write writes data directly to the terminal's console output. // // This is a low-level operation that bypasses the terminal screen buffering // and writes directly to the console output handler, usually [os.Stdout] or // the controlling TTY. func (t *Terminal) Write(p []byte) (n int, err error) { return t.con.Write(p) } // Read reads data from the terminal's console input. // // This is a low-level operation that bypasses the terminal event processing // and reads directly from the console input handler, usually [os.Stdin] or the // controlling TTY. Use this method with caution, as it may interfere with the // terminal's event loop and screen management. func (t *Terminal) Read(p []byte) (n int, err error) { return t.con.Read(p) }