// Package terminal is a terminal emulator: a screen a program can write to // with escape sequences, a parser that turns a byte stream into changes to // that screen, and a pseudo-terminal to run a shell in. // // The screen and the parser know nothing about pseudo-terminals, processes or // tcell events. They are a pure function from bytes to a grid of cells, which // is what makes an emulator — the part that is otherwise hardest to be sure of // — testable by writing bytes in and reading cells out. package terminal import "github.com/gdamore/tcell/v2" // Cell is one character position on the screen. type Cell struct { Rune rune Style tcell.Style } // blank is what an erased cell holds. Erasing keeps the current background — // that is what makes a program painting a coloured panel and then clearing a // line inside it leave the panel's colour behind rather than a hole. func blank(style tcell.Style) Cell { return Cell{Rune: ' ', Style: style} } // Line is one row of the grid. type Line []Cell // Cursor is where the next character goes, in zero-based rows and columns. type Cursor struct { Row int Col int } // DefaultScrollback is how many lines that have scrolled off the top are kept // so they can be scrolled back to. const DefaultScrollback = 2000 // Screen is the grid a terminal program writes onto: its cells, the cursor, // the region that scrolls, and the lines that have scrolled off the top. // // Every method is a terminal operation rather than a drawing one, so the // parser can express a whole escape sequence as one call. // // s := terminal.NewScreen(80, 24) // s.WriteRune('h') // s.WriteRune('i') // fmt.Println(s.LineText(0)) // "hi" type Screen struct { width int height int lines []Line scrollback []Line maxScrollback int cursor Cursor style tcell.Style savedCursor Cursor savedStyle tcell.Style // The scrolling region, as inclusive row numbers. A program narrows it to // keep a status line still while the rest scrolls. top int bottom int cursorVisible bool autoWrap bool // applicationCursor is DECCKM. When a program sets it, the arrow keys are // expected to arrive as ESC O A rather than ESC [ A — which is what makes // them work inside vim and at a readline prompt. applicationCursor bool // wrapPending is set once a character has been written into the last // column. The cursor stays there, and only the *next* character moves it // to the following line — which is why a character written in the last // column does not scroll the screen by itself. wrapPending bool // The primary grid, kept aside while the alternate screen is showing. A // full-screen program such as vim asks for the alternate screen so that // what was on the terminal before comes back when it exits. alternate bool primaryLines []Line primaryCursor Cursor primaryStyle tcell.Style primaryHistory []Line } // NewScreen returns a blank screen of the given size, with the cursor at the // top left. A size below one in either direction is raised to one, so there is // always somewhere for the cursor to be. func NewScreen(width, height int) *Screen { s := &Screen{ width: max(width, 1), height: max(height, 1), maxScrollback: DefaultScrollback, style: tcell.StyleDefault, cursorVisible: true, autoWrap: true, } s.lines = s.blankLines(s.height) s.resetScrollRegion() return s } // blankLines returns n empty rows in the screen's current style. func (s *Screen) blankLines(n int) []Line { lines := make([]Line, n) for i := range lines { lines[i] = s.blankLine() } return lines } // blankLine returns one empty row. func (s *Screen) blankLine() Line { line := make(Line, s.width) for i := range line { line[i] = blank(s.style) } return line } // resetScrollRegion opens the scrolling region back up to the whole screen. func (s *Screen) resetScrollRegion() { s.top, s.bottom = 0, s.height-1 } // Size returns the screen's width and height in cells. func (s *Screen) Size() (width, height int) { return s.width, s.height } // Cursor returns where the next character will go. func (s *Screen) Cursor() Cursor { return s.cursor } // CursorVisible reports whether the program has asked for the cursor to be // shown. A full-screen program hides it while it repaints. func (s *Screen) CursorVisible() bool { return s.cursorVisible } // SetCursorVisible shows or hides the cursor. func (s *Screen) SetCursorVisible(visible bool) { s.cursorVisible = visible } // Style returns the style the next character will be written in. func (s *Screen) Style() tcell.Style { return s.style } // SetStyle sets the style the next characters will be written in. func (s *Screen) SetStyle(style tcell.Style) { s.style = style } // AutoWrap reports whether a character written past the right edge moves to // the next line. func (s *Screen) AutoWrap() bool { return s.autoWrap } // ApplicationCursor reports whether the program has asked for application // cursor keys. Whoever encodes key presses must ask, or the arrow keys will be // wrong in every full-screen program. func (s *Screen) ApplicationCursor() bool { return s.applicationCursor } // SetApplicationCursor turns application cursor keys on or off. func (s *Screen) SetApplicationCursor(on bool) { s.applicationCursor = on } // SetAutoWrap turns wrapping at the right edge on or off. func (s *Screen) SetAutoWrap(wrap bool) { s.autoWrap = wrap s.wrapPending = false } // Alternate reports whether the alternate screen is showing. func (s *Screen) Alternate() bool { return s.alternate } // CellAt returns the cell at a position, or a blank one when the position is // off the screen. Callers may therefore index freely while drawing. func (s *Screen) CellAt(row, col int) Cell { if row < 0 || row >= s.height || col < 0 || col >= s.width { return blank(tcell.StyleDefault) } return s.lines[row][col] } // Line returns a copy of one row, or nil when the row is off the screen. func (s *Screen) Line(row int) Line { if row < 0 || row >= s.height { return nil } line := make(Line, s.width) copy(line, s.lines[row]) return line } // LineText returns a row as a string with its trailing blanks removed, which // is what a test wants to assert on. func (s *Screen) LineText(row int) string { if row < 0 || row >= s.height { return "" } runes := make([]rune, s.width) for i, cell := range s.lines[row] { runes[i] = cell.Rune } end := len(runes) for end > 0 && runes[end-1] == ' ' { end-- } return string(runes[:end]) } // ScrollbackLen returns how many lines have scrolled off the top and are still // remembered. func (s *Screen) ScrollbackLen() int { return len(s.scrollback) } // ScrollbackLine returns a line that has scrolled off the top, counting from // the oldest, or nil when there is no such line. func (s *Screen) ScrollbackLine(i int) Line { if i < 0 || i >= len(s.scrollback) { return nil } line := make(Line, len(s.scrollback[i])) copy(line, s.scrollback[i]) return line } // SetMaxScrollback caps how many scrolled-off lines are kept. Lowering it // drops the oldest straight away. func (s *Screen) SetMaxScrollback(n int) { s.maxScrollback = max(n, 0) s.trimScrollback() } // trimScrollback drops the oldest lines once there are too many. func (s *Screen) trimScrollback() { if len(s.scrollback) > s.maxScrollback { s.scrollback = s.scrollback[len(s.scrollback)-s.maxScrollback:] } } // remember puts a line into the scrollback. // // Only lines leaving the top of a *full-height* region on the primary screen // are kept: a program scrolling a narrow region is redrawing part of its own // display, and an alternate screen is a scratch surface whose contents are not // history. func (s *Screen) remember(line Line) { if s.alternate || s.top != 0 || s.maxScrollback == 0 { return } s.scrollback = append(s.scrollback, line) s.trimScrollback() }