package terminal import ( "sync" "time" "rickub.com/turbo-editors/turbo-core/ui" ) // refreshInterval is how often a busy terminal asks for a redraw. // // A command such as a build writes far faster than a screen can usefully be // repainted, and asking for a redraw per chunk would both waste the work and // flood the event queue — which drops what does not fit. Sixty times a second // is past what an eye can follow. const refreshInterval = 16 * time.Millisecond // wheelStep is how many lines one notch of the mouse wheel scrolls back. const wheelStep = 3 // View is a terminal window's contents: a shell in a pseudo-terminal, its // output drawn through the theme, and the keyboard wired back to it. // // The shell writes from a goroutine of its own while the editor draws from the // main one, so everything the two share is behind a lock. // // view, err := terminal.NewView(terminal.ViewOptions{ // Options: terminal.Options{Dir: "."}, // OnChange: wakeTheEventLoop, // }) // if err != nil { // return err // } // window := ui.NewWindow(view.Title(), view) type View struct { ui.FocusBox session *Session parser *Parser // mu guards the parser and its screen, which the reading goroutine writes // to and the drawing one reads from. mu sync.Mutex // scrollOffset is how many lines back into the history the view is // looking. Zero is the live screen. scrollOffset int dirty chan struct{} closed chan struct{} once sync.Once // name, onChange and onExit come from ViewOptions and are never written // again. // // They are not exported fields for a reason worth stating: NewView starts // the reading goroutine, and that goroutine reads all three. A caller // assigning them *after* NewView returned is a data race — one that hid for // a whole feature because a shell takes longer to produce output than an // assignment takes to run, and only surfaced when a command finished // immediately. Taking them as options makes the race impossible rather // than merely unlikely. name string onChange func() onExit func() } // ViewOptions are everything a view needs, including what the pty needs. // // The callbacks are given here rather than assigned afterwards because NewView // starts the goroutine that calls them. type ViewOptions struct { Options // Name is what the window is called when the program inside has not asked // for a title of its own. // // Without it a command run through a shell shows as "sh", which says // nothing about what is in the window. A caller running one command should // set this to that command. Name string // OnChange is called when there is something new to draw. It is called // from a goroutine of its own, so it must be safe to call from one. OnChange func() // OnExit is called once the shell has gone, from that same goroutine. OnExit func() } // NewView starts a shell and returns the view showing it. // // A size of zero in the options is filled in once the view is given its bounds, // which is what happens as soon as it goes into a window. // // Everything the view needs is given here rather than assigned afterwards, // because this starts the goroutine that reads it. func NewView(options ViewOptions) (*View, error) { if options.Width <= 0 { options.Width, options.Height = 80, 24 } session, err := Start(options.Options) if err != nil { return nil, err } screen := NewScreen(options.Width, options.Height) v := &View{ session: session, parser: NewParser(screen), dirty: make(chan struct{}, 1), closed: make(chan struct{}), name: options.Name, onChange: options.OnChange, onExit: options.OnExit, } v.SetFocused(true) // Everything the goroutines read is set by now, which is the whole reason // the callbacks are options rather than fields. go v.read() go v.refresh() return v, nil } // Title returns what the window holding this view should be called: the title // the program asked for, the Name its caller gave it, or the name of the // program running in it. // // A program that sets its own title wins, because it is saying something the // caller could not have known — vim naming the file it has open, ssh naming // the host. func (v *View) Title() string { v.mu.Lock() defer v.mu.Unlock() if title := v.parser.Title(); title != "" { return title } if v.name != "" { return v.name } return v.session.Command() } // read copies the shell's output into the emulator until the shell has gone. func (v *View) read() { buffer := make([]byte, 8192) for { n, err := v.session.Read(buffer) if n > 0 { v.consume(buffer[:n]) } if err != nil { v.finish() return } } } // consume feeds a chunk to the emulator and notes that there is something new // to draw. func (v *View) consume(chunk []byte) { v.mu.Lock() v.parser.Write(chunk) //nolint:errcheck // the parser never fails // Output arriving while the user is reading history pulls the view back to // the live screen, which is what every terminal does. v.scrollOffset = 0 v.mu.Unlock() select { case v.dirty <- struct{}{}: default: // a redraw is already pending, and one is enough } } // finish reports that the shell has gone. // // Why it went does not matter to the editor: a shell that exited cleanly and // one whose pseudo-terminal was closed underneath it both leave a window with // nothing behind it. func (v *View) finish() { v.once.Do(func() { close(v.closed) }) if v.onExit != nil { v.onExit() } } // refresh asks for a redraw at a steady rate for as long as there is something // new, rather than once per chunk of output. func (v *View) refresh() { ticker := time.NewTicker(refreshInterval) defer ticker.Stop() for { select { case <-v.closed: v.notify() return case <-ticker.C: select { case <-v.dirty: v.notify() default: } } } } // notify tells the editor there is something to draw. func (v *View) notify() { if v.onChange != nil { v.onChange() } } // Exited reports whether the program in the terminal has gone. // // A window whose command has finished still shows its output, which is the // point — but it should stop behaving like a terminal, or the editor's own // keys never reach it again. func (v *View) Exited() bool { select { case <-v.closed: return true default: return false } } // Close ends the session and stops the goroutines behind it. func (v *View) Close() error { v.once.Do(func() { close(v.closed) }) return v.session.Close() } // SetBounds places the view and tells the shell its terminal has changed size. func (v *View) SetBounds(r ui.Rect) { v.FocusBox.SetBounds(r) width, height := max(r.W, 1), max(r.H, 1) v.mu.Lock() current, currentHeight := v.parser.Screen().Size() if current == width && currentHeight == height { v.mu.Unlock() return } v.parser.Screen().Resize(width, height) v.mu.Unlock() // A shell that is not told the new size goes on drawing at the old one. _ = v.session.Resize(width, height) }