okami.mom/ultraviolet
git clone https://git.nkp.pet/okami.mom/ultraviolet
666ce5e
main
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. 25BufferSize 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. 29EventTimeout time.Duration 30 31// LegacyKeyEncoding represents any legacy key encoding ambiguities. By 32// default, the terminal will use its preferred key encoding settings. 33LegacyKeyEncoding 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. 42LookupKeys 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. 53UseTerminfoKeys bool 54 55// Logger is an optional logger for tracing terminal I/O operations. 56// If nil, no logging is performed. 57Logger Logger 58} 59 60// DefaultOptions returns the default [Terminal] options. 61func DefaultOptions ()* Options { 62return & Options { 63BufferSize :DefaultBufferSize , 64EventTimeout :DefaultEventTimeout , 65LookupKeys :true , 66 } 67} 68 69// Terminal represents an interactive terminal application. 70type Terminal struct { 71con Console 72opts * Options 73scr * TerminalScreen 74pr pollReader 75buf []byte 76evc chan Event 77errg errgroup.Group 78winch chan os.Signal 79donec 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 { 89return 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 ) { 99con ,err := ControllingConsole () 100if err != nil { 101return nil ,err 102 } 103return 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 { 110t := & Terminal {} 111if con == nil { 112con = DefaultConsole () 113 } 114if opts == nil { 115opts = DefaultOptions () 116 } 117if opts .BufferSize <= 0 { 118opts .BufferSize = DefaultBufferSize 119 } 120if opts .EventTimeout <= 0 { 121opts .EventTimeout = DefaultEventTimeout 122 } 123t .con = con 124t .opts = opts 125t .scr = NewTerminalScreen (t .con .Writer (),t .con .Environ ()) 126t .buf = make ([]byte ,opts .BufferSize ) 127// These channels never close during the terminal's lifetime. 128t .evc = make (chan Event ) 129t .winch = make (chan os.Signal ,1 )// buffered to avoid missing signals 130if opts .Logger != nil { 131t .scr .rend .SetLogger (opts .Logger ) 132 } 133return 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 ) { 138w ,h ,err := t .con .GetSize () 139if err != nil { 140return 0 ,0 ,fmt .Errorf ("getting terminal size: %w" ,err ) 141 } 142return 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 ) { 153ws ,err := t .con .GetWinsize () 154if err != nil { 155return nil ,fmt .Errorf ("getting terminal winsize: %w" ,err ) 156 } 157return ws ,nil 158} 159 160// Screen returns the terminal's screen. 161func (t * Terminal )Screen ()* TerminalScreen { 162return t .scr 163} 164 165// Events returns the terminal's event channel. 166func (t * Terminal )Events ()<- chan Event { 167return 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 () 174if err != nil { 175return fmt .Errorf ("failed to set terminal to raw mode: %w" ,err ) 176 } 177 178evs := newEventScanner () 179evs .lookup = t .opts .LookupKeys 180if evs .lookup { 181evs .table = buildKeysTable (t .opts .LegacyKeyEncoding ,t .con .Getenv ("TERM" ),t .opts .UseTerminfoKeys ) 182 } 183if t .opts .Logger != nil { 184evs .setLogger (t .opts .Logger ) 185 } 186bufc := make (chan []byte ) 187t .donec = make (chan struct {}) 188t .pr ,err = newPollReader (t .con .Reader ()) 189if err != nil { 190return 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. 200pr := t .pr 201t .errg .Go (func ()error { 202for { 203n ,err := pr .Read (t .buf ) 204if err != nil { 205return 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. 212data := make ([]byte ,n ) 213copy (data ,t .buf [:n ]) 214select { 215case bufc <- data : 216case <- t .donec : 217return nil 218 } 219 } 220 }) 221 222// event loop 223sendEvents := func (buf []byte ,expired bool )int { 224n ,events := evs .scanEvents (buf ,expired ) 225for _ ,ev := range events { 226t .handleEvent (ev ) 227t .SendEvent (ev ) 228 } 229return n 230 } 231t .errg .Go (func ()error { 232var buf []byte 233timer := time .NewTimer (t .opts .EventTimeout ) 234timeout := time .Now ().Add (t .opts .EventTimeout ) 235 236for { 237select { 238case <- t .donec : 239return nil 240case <- timer .C : 241expired := len (buf )> 0 && time .Now ().After (timeout ) 242n := sendEvents (buf ,expired ) 243if n > 0 { 244buf = buf [min (n ,len (buf )):] 245 } 246if len (buf )> 0 { 247timer .Reset (t .opts .EventTimeout ) 248 } 249case data := <- bufc : 250buf = append (buf ,data ... ) 251n := sendEvents (buf ,false ) 252timeout = time .Now ().Add (t .opts .EventTimeout ) 253timer .Stop () 254if n > 0 { 255buf = buf [min (n ,len (buf )):] 256 } 257if len (buf )> 0 { 258timer .Reset (t .opts .EventTimeout ) 259 } 260 } 261 } 262 }) 263 264sendWinsize := func ()error { 265ws ,err := t .con .GetWinsize () 266if err != nil { 267return fmt .Errorf ("getting terminal size: %w" ,err ) 268 } 269if ws .Col > 0 && ws .Row > 0 { 270t .SendEvent (WindowSizeEvent { 271Width :int (ws .Col ), 272Height :int (ws .Row ), 273 }) 274 } 275if ws .Xpixel > 0 && ws .Ypixel > 0 { 276t .SendEvent (PixelSizeEvent { 277Width :int (ws .Xpixel ), 278Height :int (ws .Ypixel ), 279 }) 280 } 281return nil 282 } 283 284// winch handler 285NotifyWinch (t .winch ) 286t .errg .Go (func ()error { 287for { 288select { 289case <- t .donec : 290return nil 291case <- t .winch : 292if err := sendWinsize ();err != nil { 293return err 294 } 295 } 296 } 297 }) 298 299// init window size 300t .errg .Go (func ()error { 301if err := sendWinsize ();err != nil { 302return err 303 } 304return nil 305 }) 306 307// Restore any previous screen state. 308t .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. 312t .scr .requestGraphemeWidth () 313if err := t .scr .Flush ();err != nil { 314return fmt .Errorf ("failed to flush terminal screen: %w" ,err ) 315 } 316 317return 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 ) { 332if mr ,ok := ev .(ModeReportEvent );ok { 333if mr .Mode == ansi .ModeUnicodeCore && 334 (mr .Value .IsSet ()|| mr .Value == ansi .ModeReset ) { 335t .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 { 343if err := t .errg .Wait ();err != nil { 344return fmt .Errorf ("terminal event loop error: %w" ,err ) 345 } 346return 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 { 353if t .donec != nil { 354select { 355case <- t .donec : 356// Already closed. 357default : 358close (t .donec ) 359 } 360 } 361if t .winch != nil { 362signal .Stop (t .winch ) 363 } 364if t .pr != nil { 365t .pr .Cancel () 366_ = t .pr .Close () 367t .pr = nil 368 } 369t .scr .Reset () 370if err := t .scr .Flush ();err != nil { 371_ = t .con .Restore () 372return fmt .Errorf ("failed to flush terminal screen: %w" ,err ) 373 } 374if err := t .con .Restore ();err != nil { 375return fmt .Errorf ("failed to restore terminal state: %w" ,err ) 376 } 377return 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 ) { 385select { 386case t .evc <- e : 387case <- 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 ) { 397return 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 ) { 407return t .con .Read (p ) 408}