package uv import ( "bytes" "errors" "hash/maphash" "io" "strings" "unicode/utf8" "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/x/ansi" ) // ErrInvalidDimensions is returned when the dimensions of a window are invalid // for the operation. var ErrInvalidDimensions = errors.New("invalid dimensions") // capabilities represents a mask of supported ANSI escape sequences. type capabilities uint const ( // Vertical Position Absolute [ansi.VPA]. capVPA capabilities = 1 << iota // Horizontal Position Absolute [ansi.HPA]. capHPA // Cursor Horizontal Absolute [ansi.CHA]. capCHA // Cursor Horizontal Tab [ansi.CHT]. capCHT // Cursor Backward Tab [ansi.CBT]. capCBT // Repeat Previous Character [ansi.REP]. capREP // Erase Character [ansi.ECH]. capECH // Insert Character [ansi.ICH]. capICH // Scroll Down [ansi.SD]. capSD // Scroll Up [ansi.SU]. capSU // These capabilities depend on the tty termios settings and are not // enabled by default. // Tabulation [ansi.HT]. capHT // Backspace [ansi.BS]. capBS noCaps capabilities = 0 allCaps = capVPA | capHPA | capCHA | capCHT | capCBT | capREP | capECH | capICH | capSD | capSU ) // Set sets the given capabilities. func (v *capabilities) Set(c capabilities) { *v |= c } // Reset resets the given capabilities. func (v *capabilities) Reset(c capabilities) { *v &^= c } // Contains returns whether the capabilities contains the given capability. func (v capabilities) Contains(c capabilities) bool { return v&c == c } // cursor represents a terminal cursor. type cursor struct { Cell Position } // LineData represents the metadata for a line. type LineData struct { // First and last changed cell indices. FirstCell, LastCell int // Old index used for scrolling oldIndex int //nolint:unused } // tFlag is a bitmask of terminal flags. type tFlag uint // Terminal writer flags. const ( tRelativeCursor tFlag = 1 << iota tFullscreen tMapNewline tScrollOptim // tGraphemeWidth indicates the terminal measures cell width using Unicode // grapheme clustering (DEC mode 2027 / Unicode core). tGraphemeWidth ) // Set sets the given flags. func (v *tFlag) Set(c tFlag) { *v |= c } // Reset resets the given flags. func (v *tFlag) Reset(c tFlag) { *v &^= c } // Contains returns whether the terminal flags contains the given flags. func (v tFlag) Contains(c tFlag) bool { return v&c == c } // TerminalRenderer is a terminal screen render and lazy writer that buffers // the output until it is flushed. It handles rendering a screen from a // [Buffer] to the terminal with the minimal necessary escape sequences to // transition the terminal to the new buffer state. It uses various escape // sequence optimizations to reduce the number of bytes sent to the terminal. // It's designed to be lazy and only flush the output when necessary by calling // the [TerminalRenderer.Flush] method. // // The renderer handles the terminal's alternate screen and cursor visibility // via the [TerminalRenderer.EnterAltScreen], [TerminalRenderer.ExitAltScreen], // [TerminalRenderer.ShowCursor] and [TerminalRenderer.HideCursor] methods. // Using these methods will queue the appropriate escape sequences to enter or // exit the alternate screen and show or hide the cursor respectively to be // flushed to the terminal. // // Use the [io.Writer] and [io.StringWriter] interfaces to queue custom // commands the renderer. // // The renderer is not thread-safe, the caller must protect the renderer when // using it from multiple goroutines. type TerminalRenderer struct { w io.Writer buf *bytes.Buffer // buffer for writing to the screen curbuf *RenderBuffer // the current buffer tabs *TabStops hasher maphash.Hash oldhash, newhash []uint64 // the old and new hash values for each line hashtab []hashmap // the hashmap table oldnum []int // old indices from previous hash cur, saved cursor // the current and saved cursors flags tFlag // terminal writer flags. method ansi.Method // the width method used to measure cell width term string // the terminal type clear bool // whether to force clear the screen caps capabilities // terminal control sequence capabilities atPhantom bool // whether the cursor is out of bounds and at a phantom cell noWrapLine bool // whether autowrap is off for the line currently being repainted driftRows []bool // rows holding a cell the terminal may measure differently termW, termH int // the size [TerminalRenderer.Resize] was last told termResized bool // whether the terminal changed size since the last render damaged []bool // rows this render disturbed itself, see [TerminalRenderer.damage] logger Logger // The logger used for debugging. // profile is the color profile to use when downsampling colors. This is // used to determine the appropriate color the terminal can display. profile colorprofile.Profile } // NewTerminalRenderer returns a new [TerminalRenderer] that uses the given // writer, terminal type, and initializes the width of the terminal. The // terminal type is used to determine the capabilities of the terminal and // should be set to the value of the TERM environment variable. // // The renderer will try to detect the color profile from the output and the // given environment variables. Use [TerminalRenderer.SetColorProfile] method // to set a specific color profile for downsampling. // // See [TerminalRenderer] for more information on how to use the renderer. func NewTerminalRenderer(w io.Writer, env []string) (s *TerminalRenderer) { s = new(TerminalRenderer) s.w = w s.profile = colorprofile.Detect(w, env) s.buf = new(bytes.Buffer) s.term = Environ(env).Getenv("TERM") s.caps = xtermCaps(s.term) s.cur = cursor{Cell: EmptyCell, Position: Pos(-1, -1)} // start at -1 to force a move s.saved = s.cur s.oldhash, s.newhash = nil, nil return } // SetLogger sets the logger to use for debugging. If nil, no logging will be // performed. func (s *TerminalRenderer) SetLogger(logger Logger) { s.logger = logger } // SetColorProfile sets the color profile to use for downsampling colors. This // is used to determine the appropriate color the terminal can display. func (s *TerminalRenderer) SetColorProfile(profile colorprofile.Profile) { s.profile = profile } // SetScrollOptim sets whether to use hard scroll optimizations. func (s *TerminalRenderer) SetScrollOptim(v bool) { if v { s.flags.Set(tScrollOptim) } else { s.flags.Reset(tScrollOptim) } } // SetMapNewline sets whether the terminal is currently mapping newlines to // CRLF or carriage return and line feed. This is used to correctly determine // how to move the cursor when writing to the screen. func (s *TerminalRenderer) SetMapNewline(v bool) { if v { s.flags.Set(tMapNewline) } else { s.flags.Reset(tMapNewline) } } // SetWidthMethod sets the width method the renderer uses to measure the // display width of strings. This should match the width method configured on // the screen so that the renderer doesn't disagree with the cell widths it is // asked to draw. func (s *TerminalRenderer) SetWidthMethod(method ansi.Method) { s.method = method } // SetGraphemeWidth sets whether the terminal measures cell width using Unicode // grapheme clustering (DEC mode 2027 / Unicode core). When enabled, the // renderer measures string width using [ansi.GraphemeWidth]; otherwise it // falls back to [ansi.WcWidth]. func (s *TerminalRenderer) SetGraphemeWidth(v bool) { if v { s.flags.Set(tGraphemeWidth) s.method = ansi.GraphemeWidth } else { s.flags.Reset(tGraphemeWidth) s.method = ansi.WcWidth } } // SetBackspace sets whether to use backspace as a movement optimization. func (s *TerminalRenderer) SetBackspace(v bool) { if v { s.caps.Set(capBS) } else { s.caps.Reset(capBS) } } // SetTabStops sets the tab stops for the terminal and enables hard tabs // movement optimizations. Use -1 to disable hard tabs. This option is ignored // when the terminal type is "linux" as it does not support hard tabs. func (s *TerminalRenderer) SetTabStops(width int) { if width < 0 || strings.HasPrefix(s.term, "linux") { // Linux terminal does not support hard tabs. s.caps.Reset(capHT) } else { s.caps.Set(capHT) s.tabs = DefaultTabStops(width) } } // SetFullscreen sets whether whole screen is being used. This is usually // paired with the alternate screen mode, and used to control cursor movements // and optimizations when the terminal is occupying the whole screen. func (s *TerminalRenderer) SetFullscreen(v bool) { if v { s.flags.Set(tFullscreen) } else { s.flags.Reset(tFullscreen) } } // Fullscreen returns whether whole screen is being used. This is usually // paired with the alternate screen mode, and used to control cursor movements // and optimizations when the terminal is occupying the whole screen. func (s *TerminalRenderer) Fullscreen() bool { return s.flags.Contains(tFullscreen) } // SetRelativeCursor sets whether to use relative cursor movements. func (s *TerminalRenderer) SetRelativeCursor(v bool) { if v { s.flags.Set(tRelativeCursor) } else { s.flags.Reset(tRelativeCursor) } } // SaveCursor saves the current cursor position and styles. This can be used // when a program enters the alternate screen buffer mode using DECSET 1049 // [ansi.ModeAltScreenSaveCursor] and wants to restore the cursor position and // styles later. func (s *TerminalRenderer) SaveCursor() { s.saved = s.cur } // RestoreCursor restores the saved cursor position and styles. This can be // used when a program exits the alternate screen buffer mode using DECRST 1049 // [ansi.ModeAltScreenSaveCursor] and wants to restore the cursor position and // styles saved earlier. func (s *TerminalRenderer) RestoreCursor() { s.cur = s.saved } // EnterAltScreen is a helper that queues the [ansi.ModeAltScreenSaveCursor] // escape sequence to enter the alternate screen buffer and save cursor mode. // // It saves the current cursor properties, enables // [TerminalRenderer.SetFullscreen] flag, and disables // [TerminalRenderer.SetRelativeCursor] flag. On the next call to // [TerminalRenderer.Render], it will move the cursor home and clear the screen // to prepare for rendering the new buffer. // // Note: you might want to reapply the cursor visibility state after calling // this method, as some terminals reset the cursor visibility when switching to // the alternate screen. func (s *TerminalRenderer) EnterAltScreen() { s.SaveCursor() s.buf.WriteString(ansi.SetModeAltScreenSaveCursor) s.SetFullscreen(true) s.SetRelativeCursor(false) s.Erase() } // ExitAltScreen is a helper that queues the [ansi.ModeAltScreenSaveCursor] // escape sequence to exit the alternate screen buffer and restore cursor mode. // // It restores the saved cursor properties, disables // [TerminalRenderer.SetFullscreen] flag, and enables // [TerminalRenderer.SetRelativeCursor] flag. On the next call to // [TerminalRenderer.Render], it will move the cursor to the first line and // clear everything below the cursor to prepare for rendering the new buffer. // // Note: you might want to reapply the cursor visibility state after calling // this method, as some terminals reset the cursor visibility when switching to // the alternate screen. func (s *TerminalRenderer) ExitAltScreen() { s.Erase() s.SetRelativeCursor(true) s.SetFullscreen(false) s.buf.WriteString(ansi.ResetModeAltScreenSaveCursor) s.RestoreCursor() } // PrependString adds the lines of the given string to the top of the terminal // screen. The lines prepended are not managed by the renderer and will not be // cleared or updated by the renderer. // // Using this when the terminal is using the alternate screen or when occupying // the whole screen may not produce any visible effects. This is because // once the terminal writes the prepended lines, they will get overwritten // by the next frame. func (s *TerminalRenderer) PrependString(newbuf *RenderBuffer, str string) { if len(str) == 0 { return } // TODO: Use scrolling region if available. // TODO: Use [Screen.Write] [io.Writer] interface. // We need to scroll the screen up by the number of lines in the queue. // We can't use [ansi.SU] because we want the cursor to move down until // it reaches the bottom of the screen. w, h := newbuf.Width(), newbuf.Height() s.move(newbuf, 0, h-1) lines := strings.Split(str, "\n") offset := 0 for _, line := range lines { lineWidth := s.method.StringWidth(line) if w > 0 && lineWidth > w { offset += (lineWidth / w) } if lineWidth == 0 || lineWidth%w != 0 { offset++ } } s.buf.WriteString(strings.Repeat("\n", offset)) s.cur.Y += offset // XXX: Now go to the top of the screen, insert new lines, and write // the queued strings. It is important to use [Screen.moveCursor] // instead of [Screen.move] because we don't want to perform any checks // on the cursor position. s.moveCursor(newbuf, 0, 0, false) s.buf.WriteString(ansi.InsertLine(offset)) for _, line := range lines { s.buf.WriteString(line) s.buf.WriteString("\r\n") } } // moveCursor moves the cursor to the specified position. // // It is safe to call this function with a nil [Buffer], in that case, it won't // be using any optimizations that depend on the buffer. func (s *TerminalRenderer) moveCursor(newbuf *RenderBuffer, x, y int, overwrite bool) { if !s.flags.Contains(tFullscreen) && s.flags.Contains(tRelativeCursor) && s.cur.X == -1 && s.cur.Y == -1 { // First cursor movement in inline mode, move the cursor to the first // column before moving to the target position. _ = s.buf.WriteByte('\r') s.cur.X, s.cur.Y = 0, 0 } seq := moveCursor(s, newbuf, x, y, overwrite) _, _ = s.buf.WriteString(seq) s.cur.X, s.cur.Y = x, y } // move moves the cursor to the specified position in the buffer. // // It is safe to call this function with a nil [Buffer], in that case, it won't // be using any optimizations that depend on the buffer. func (s *TerminalRenderer) move(newbuf *RenderBuffer, x, y int) { // XXX: Make sure we use the max height and width of the buffer in case // we're in the middle of a resize operation. var width, height int if s.curbuf != nil { width, height = s.curbuf.Width(), s.curbuf.Height() } if newbuf != nil { width = max(newbuf.Width(), width) height = max(newbuf.Height(), height) } if width > 0 && x >= width { // Handle autowrap y += (x / width) x %= width } // XXX: Disable styles if there's any // Some move operations such as [ansi.LF] can apply styles to the new // cursor position, thus, we need to reset the styles before moving the // cursor. blank := s.clearBlank() resetPen := y != s.cur.Y && !blank.Equal(&EmptyCell) if resetPen { s.updatePen(nil) } // Reset wrap around (phantom cursor) state if s.atPhantom { s.cur.X = 0 _ = s.buf.WriteByte('\r') s.atPhantom = false // reset phantom cell state } // TODO: Investigate if we need to handle this case and/or if we need the // following code. // // if width > 0 && s.cur.X >= width { // l := (s.cur.X + 1) / width // // s.cur.Y += l // if height > 0 && s.cur.Y >= height { // l -= s.cur.Y - height - 1 // } // // if l > 0 { // s.cur.X = 0 // s.buf.WriteString("\r" + strings.Repeat("\n", l)) //nolint:errcheck // } // } if height > 0 { // Only clamp the remembered cursor row in fullscreen mode, where the // buffer covers the whole screen. In relative mode the terminal // screen extends beyond the frame buffer: after a shrink the // real cursor can legitimately sit below the new frame's last row, // and clamping the model here would skip the cursor-up move and leave // the old frame's top lines on screen. if !s.flags.Contains(tRelativeCursor) && s.cur.Y > height-1 { s.cur.Y = height - 1 } if y > height-1 { y = height - 1 } } if x == s.cur.X && y == s.cur.Y { // We give up later because we need to run checks for the phantom cell // and others before we can determine if we can give up. return } // We set the new cursor in tscreen.moveCursor]. s.moveCursor(newbuf, x, y, true) // Overwrite cells if possible } // cellEqual returns whether the two cells are equal. A nil cell is considered // a [EmptyCell]. func cellEqual(a, b *Cell) bool { if a == b { return true } // TODO: This is needed to handle empty lines correctly when scroll // optimizations are enabled. Instead, a nil check should be equivalent to // checking for an [EmptyCell], should it? // Investigate why when we assign the pointers to &[EmptyCell], this causes // scroll optimization related artifacts where excess lines are left behind // in empty lines after scrolling. if a == nil || b == nil { return false } return a.Equal(b) } // putCell draws a cell at the current cursor position. // // The lower right corner is written with autowrap off, since a pending wrap // there scrolls the screen as soon as anything else prints. A multi-codepoint // cluster is the exception: with autowrap off the terminal never advances past // the margin and reads the combining codepoints as belonging to the cell to the // left, so the cluster keeps autowrap on and the wrap is cancelled after. func (s *TerminalRenderer) putCell(newbuf *RenderBuffer, cell *Cell) { width, height := newbuf.Width(), newbuf.Height() atMargin := s.cur.X == width-1 && !s.noWrapLine lowerRight := atMargin && s.flags.Contains(tFullscreen) && s.cur.Y == height-1 // Only the corner cell can care, so the cluster is measured only there. if lowerRight && cell != nil && utf8.RuneCountInString(cell.Content) > 1 { s.putAttrCell(newbuf, cell) // The cluster kept autowrap on so it landed whole, which leaves the // cursor pending wrap. On the last row there is no next row to wrap // onto, so the next print scrolls the screen instead. Cancel it here // rather than leave the frame ending in a state the model denies. if s.atPhantom { _ = s.buf.WriteByte('\r') s.cur.X = 0 s.atPhantom = false } return } if lowerRight { s.putCellLR(newbuf, cell) } else { s.putAttrCell(newbuf, cell) } } // wrapCursor wraps the cursor to the next line. func (s *TerminalRenderer) wrapCursor() { const autoRightMargin = true if autoRightMargin { // Assume we have auto wrap mode enabled. s.cur.X = 0 s.cur.Y++ } else { s.cur.X-- } } func (s *TerminalRenderer) putAttrCell(newbuf *RenderBuffer, cell *Cell) { if cell != nil && cell.Width == 0 { // XXX: Zero width cells are special and should not be written to the // screen no matter what other attributes they have. // Zero width cells are used for wide characters that are split into // multiple cells. return } // We're at pending wrap state (phantom cell), incoming cell should // wrap. if s.atPhantom { s.wrapCursor() s.atPhantom = false } s.updatePen(cell) cellWidth := 1 if cell == nil { _ = s.buf.WriteByte(' ') } else { _, _ = s.buf.WriteString(cell.Content) cellWidth = cell.Width } s.cur.X += cellWidth if s.cur.X >= newbuf.Width() { s.atPhantom = true } } // putCellLR draws a cell at the lower right corner of the screen. func (s *TerminalRenderer) putCellLR(newbuf *RenderBuffer, cell *Cell) { // Optimize for the lower right corner cell. curX := s.cur.X if !cell.isWidePlaceholder() { _, _ = s.buf.WriteString(ansi.ResetModeAutoWrap) s.putAttrCell(newbuf, cell) // Writing to lower-right corner cell should not wrap. s.atPhantom = false s.cur.X = curX _, _ = s.buf.WriteString(ansi.SetModeAutoWrap) } } // updatePen updates the cursor pen styles. func (s *TerminalRenderer) updatePen(cell *Cell) { if cell == nil { cell = &EmptyCell } // Downsample pen when we don't have a [colorprofile.TrueColor], // otherwise, use the original style. newStyle := ConvertStyle(cell.Style, s.profile) newLink := ConvertLink(cell.Link, s.profile) oldStyle := ConvertStyle(s.cur.Style, s.profile) oldLink := ConvertLink(s.cur.Link, s.profile) if !newStyle.Equal(&oldStyle) { seq := newStyle.Diff(&oldStyle) if newStyle.IsZero() && len(seq) > len(ansi.ResetStyle) { seq = ansi.ResetStyle } _, _ = s.buf.WriteString(seq) s.cur.Style = cell.Style // Copy the original style } if !newLink.Equal(&oldLink) { _, _ = s.buf.WriteString(ansi.SetHyperlink(newLink.URL, newLink.Params)) s.cur.Link = cell.Link // Copy the original link } } // canClearWith checks whether the given cell can be used by clearing commands // like [ansi.EL] to clear the screen. It tests if a cell is empty i.e. space // or blank and doesn't include any bad style attributes such as [AttrReverse]. func canClearWith(c *Cell) bool { if c == nil { return true } if c.Width != 1 || len(c.Content) != 1 || c.Content != " " { return false } // NOTE: This assumes that the terminal supports bce terminfo capability // which all xterm-compatible terminals and terminals that use xterm* // terminal types do. return c.Style.Underline == UnderlineNone && c.Style.Attrs&^(AttrBold|AttrFaint|AttrItalic|AttrBlink|AttrRapidBlink) == 0 && c.Link.IsZero() } // emitRange emits a range of cells to the buffer. It it equivalent to calling // tscreen.putCell] for each cell in the range. This is optimized to use // [ansi.ECH] and [ansi.REP]. // Returns whether the cursor is at the end of interval or somewhere in the // middle. func (s *TerminalRenderer) emitRange(newbuf *RenderBuffer, line Line, n int) (eoi bool) { hasECH := s.caps.Contains(capECH) hasREP := s.caps.Contains(capREP) if hasECH || hasREP { //nolint:nestif for n > 0 { var count int for n > 1 && !cellEqual(line.At(0), line.At(1)) { s.putCell(newbuf, line.At(0)) line = line[1:] n-- } cell0 := line[0] if n == 1 { s.putCell(newbuf, &cell0) return false } count = 2 for count < n && cellEqual(line.At(count), &cell0) { count++ } ech := ansi.EraseCharacter(count) cup := ansi.CursorPosition(s.cur.X+count, s.cur.Y) rep := ansi.RepeatPreviousCharacter(count) if hasECH && count > len(ech)+len(cup) && canClearWith(&cell0) { s.updatePen(&cell0) _, _ = s.buf.WriteString(ech) // If this is the last cell, we don't need to move the cursor. if count < n { s.move(newbuf, s.cur.X+count, s.cur.Y) } else { return true // cursor in the middle } } else if hasREP && count > len(rep) && (len(cell0.Content) == 1 && cell0.Content[0] >= ansi.US && cell0.Content[0] < ansi.DEL) { // We only support ASCII characters. Most terminals will handle // non-ASCII characters correctly, but some might not, ahem xterm. // // NOTE: [ansi.REP] only repeats the last rune and won't work // if the last cell contains multiple runes. wrapPossible := s.cur.X+count >= newbuf.Width() repCount := count if wrapPossible { repCount-- } s.updatePen(&cell0) s.putCell(newbuf, &cell0) repCount-- // cell0 is a single width cell ASCII character _, _ = s.buf.WriteString(ansi.RepeatPreviousCharacter(repCount)) s.cur.X += repCount if wrapPossible { s.putCell(newbuf, &cell0) } } else { for i := 0; i < count; i++ { s.putCell(newbuf, line.At(i)) } } line = line[clamp(count, 0, len(line)):] n -= count } return false } for i := 0; i < n; i++ { s.putCell(newbuf, line.At(i)) } return false } // putRange puts a range of cells from the old line to the new line. // Returns whether the cursor is at the end of interval or somewhere in the // middle. func (s *TerminalRenderer) putRange(newbuf *RenderBuffer, oldLine, newLine Line, y, start, end int) (eoi bool) { inline := min(len(ansi.CursorPosition(start+1, y+1)), min(len(ansi.HorizontalPositionAbsolute(start+1)), len(ansi.CursorForward(start+1)))) if (end - start + 1) > inline { //nolint:nestif var j, same int for j, same = start, 0; j <= end; j++ { oldCell, newCell := oldLine.At(j), newLine.At(j) if same == 0 && oldCell.isWidePlaceholder() && newCell.isWidePlaceholder() { continue } if cellEqual(oldCell, newCell) { same++ } else { if same > end-start { s.emitRange(newbuf, newLine[start:], j-same-start) s.move(newbuf, j, y) start = j } same = 0 } } i := s.emitRange(newbuf, newLine[start:], j-same-start) // Always return 1 for the next [tScreen.move] after a // [tScreen.putRange] if we found identical characters at end of // interval. if same == 0 { return i } return true } return s.emitRange(newbuf, newLine[start:], end-start+1) } // clearToEnd clears the screen from the current cursor position to the end of // line. func (s *TerminalRenderer) clearToEnd(newbuf *RenderBuffer, blank *Cell, force bool) { if s.cur.Y >= 0 { curline := s.curbuf.Line(s.cur.Y) // We use the newbuf width because the current buffer might be smaller // than the new buffer during a resize operation and we want to detect // that. for j := s.cur.X; j < newbuf.Width(); j++ { if j >= 0 { c := curline.At(j) if !cellEqual(c, blank) { curline.Set(j, blank) force = true } } } } if force { s.updatePen(blank) count := newbuf.Width() - s.cur.X if s.el0Cost() <= count { _, _ = s.buf.WriteString(ansi.EraseLineRight) } else { for i := 0; i < count; i++ { s.putCell(newbuf, blank) } } } } // clearBlank returns a blank cell based on the current cursor background color. func (s *TerminalRenderer) clearBlank() *Cell { return &s.cur.Cell } // insertCells inserts the count cells pointed by the given line at the current // cursor position. func (s *TerminalRenderer) insertCells(newbuf *RenderBuffer, line Line, count int) { supportsICH := s.caps.Contains(capICH) if supportsICH { // Use [ansi.ICH] as an optimization. _, _ = s.buf.WriteString(ansi.InsertCharacter(count)) } else { // Otherwise, use [ansi.IRM] mode. _, _ = s.buf.WriteString(ansi.SetModeInsertReplace) } for i := 0; count > 0; i++ { s.putAttrCell(newbuf, line.At(i)) count-- } if !supportsICH { _, _ = s.buf.WriteString(ansi.ResetModeInsertReplace) } } // el0Cost returns the cost of using [ansi.EL] 0 i.e. [ansi.EraseLineRight]. If // this terminal supports background color erase, it can be cheaper to use // [ansi.EL] 0 i.e. [ansi.EraseLineRight] to clear // trailing spaces. func (s *TerminalRenderer) el0Cost() int { if s.caps != noCaps { return 0 } return len(ansi.EraseLineRight) } // lineHasDrift reports whether the line contains a cell that a cell-level // diff cannot safely reposition across: a wide cell (width > 1), or a cell // whose width the terminal may measure differently than the model. Wide cells // occupy several columns, so a diff that lands on a continuation column splits // the character; and a width disagreement means the model cannot know which // column each glyph landed on. func lineHasDrift(m ansi.Method, line Line) bool { for i := 0; i < len(line); i++ { c := line.At(i) if c == nil || c.Width == 0 || len(c.Content) == 0 { continue } if c.Width > 1 { return true } // One printable ASCII byte is one column under every width model, so // it can never be a cell the models disagree about. Worth saying out // loud because working the width out means segmenting the content, // and this runs for every cell of every line the renderer diffs. if len(c.Content) == 1 && c.Content[0] >= 0x20 && c.Content[0] < 0x7f { continue } if m.StringWidth(c.Content) != ansi.StringWidth(c.Content) { return true } } return false } // paintLine brings row y of the screen in line with newbuf. A drift-prone row, // or any row when force is set, is repainted; every other row is diffed. func (s *TerminalRenderer) paintLine(newbuf *RenderBuffer, y int, force bool) { drift := lineHasDrift(s.method, s.curbuf.Line(y)) || lineHasDrift(s.method, newbuf.Line(y)) s.markDrift(y, newbuf.Height(), drift) switch { case drift && !s.flags.Contains(tGraphemeWidth): // Clip a cluster the terminal measures wider than the model instead of // letting it spill onto the next row, then put the cursor back. s.noWrapLine = true _, _ = s.buf.WriteString(ansi.ResetModeAutoWrap) s.repaintLine(newbuf, y) s.noWrapLine = false _, _ = s.buf.WriteString(ansi.SetModeAutoWrap) s.reanchorWideLine(newbuf) case drift || force: s.repaintLine(newbuf, y) default: s.transformLine(newbuf, y) } } // repaintLine erases row y from column 0 and writes newbuf's cells, so the // result does not depend on what the model believes is on screen. func (s *TerminalRenderer) repaintLine(newbuf *RenderBuffer, y int) { oldLine := s.curbuf.Line(y) newLine := newbuf.Line(y) s.move(newbuf, 0, y) blank := s.clearBlank() s.updatePen(blank) _, _ = s.buf.WriteString(ansi.EraseLineRight) s.cur.X = 0 // A cell whose width runs past the right margin cannot be shown, so stop // there. The buffer can hold one after a resize narrowed the line around // it, and painting it would put a clipped half-glyph on the screen. width := newbuf.Width() clipped := width for x := 0; x < width; x++ { if c := newLine.At(x); c != nil && !c.isWidePlaceholder() && x+c.Width > width { clipped = x break } } // Write the content cells. The line was just erased, so trailing blanks // need no write, and writing them would risk wrapping past the right // margin if the terminal painted a wide cell wider than the model // measured. last := -1 for x := 0; x < clipped; x++ { if c := newLine.At(x); c != nil && !c.isWidePlaceholder() && !cellEqual(c, blank) { last = x } } for x := 0; x <= last; x++ { s.putCell(newbuf, newLine.At(x)) } // Adopt the new line as the model of what is on screen. The clipped tail // was never painted, so the model records it as blank: claiming otherwise // would let a later frame skip the cell, and a resize that widens the line // would leave the half-glyph on screen forever. if len(oldLine) == len(newLine) { copy(oldLine, newLine) for x := clipped; x < width && x < len(oldLine); x++ { oldLine[x] = Cell{} } } } // markDrift records whether a row holds a cell the terminal may measure // differently than the model, so a later width change knows which rows to // repaint. Recorded here rather than rescanned later, because the diff already // had to work it out. func (s *TerminalRenderer) markDrift(y, height int, drift bool) { if y < 0 { return } if n := max(height, y+1); len(s.driftRows) < n { s.driftRows = append(s.driftRows, make([]bool, n-len(s.driftRows))...) } s.driftRows[y] = drift } // transformLine diffs row y against the model and writes only what changed, // using [ansi.ICH] and [ansi.DCH] to shift cells. It must not see a drift-prone // row; [TerminalRenderer.paintLine] routes those to a repaint. func (s *TerminalRenderer) transformLine(newbuf *RenderBuffer, y int) { var firstCell, oLastCell, nLastCell int // first, old last, new last index oldLine := s.curbuf.Line(y) newLine := newbuf.Line(y) // Find the first changed cell in the line blank := newLine.At(0) // It might be cheaper to clear leading spaces with [ansi.EL] 1 i.e. // [ansi.EraseLineLeft]. if canClearWith(blank) { //nolint:nestif var oFirstCell, nFirstCell int for oFirstCell = 0; oFirstCell < s.curbuf.Width(); oFirstCell++ { if !cellEqual(oldLine.At(oFirstCell), blank) { break } } for nFirstCell = 0; nFirstCell < newbuf.Width(); nFirstCell++ { if !cellEqual(newLine.At(nFirstCell), blank) { break } } if nFirstCell == oFirstCell { firstCell = nFirstCell // Find the first differing cell for firstCell < newbuf.Width() && cellEqual(oldLine.At(firstCell), newLine.At(firstCell)) { firstCell++ } } else if oFirstCell > nFirstCell { firstCell = nFirstCell } else if oFirstCell < nFirstCell { firstCell = oFirstCell el1Cost := len(ansi.EraseLineLeft) if el1Cost < nFirstCell-oFirstCell { if nFirstCell >= newbuf.Width() { s.move(newbuf, 0, y) s.updatePen(blank) _, _ = s.buf.WriteString(ansi.EraseLineRight) } else { s.move(newbuf, nFirstCell-1, y) s.updatePen(blank) _, _ = s.buf.WriteString(ansi.EraseLineLeft) } for firstCell < nFirstCell { oldLine.Set(firstCell, blank) firstCell++ } } } } else { // Find the first differing cell for firstCell < newbuf.Width() && cellEqual(newLine.At(firstCell), oldLine.At(firstCell)) { firstCell++ } } // If we didn't find one, we're done if firstCell >= newbuf.Width() { return } blank = newLine.At(newbuf.Width() - 1) if blank != nil && !canClearWith(blank) { // Find the last differing cell nLastCell = newbuf.Width() - 1 for nLastCell > firstCell && cellEqual(newLine.At(nLastCell), oldLine.At(nLastCell)) { nLastCell-- } if nLastCell >= firstCell { s.move(newbuf, firstCell, y) s.putRange(newbuf, oldLine, newLine, y, firstCell, nLastCell) if firstCell < len(oldLine) && firstCell < len(newLine) { copy(oldLine[firstCell:], newLine[firstCell:]) } else { copy(oldLine, newLine) } } return } // Find last non-blank cell in the old line. // We always use the newbuf width to detect new cell changes. oLastCell = newbuf.Width() - 1 for oLastCell > firstCell && cellEqual(oldLine.At(oLastCell), blank) { oLastCell-- } // Find last non-blank cell in the new line. // We always use the newbuf width to detect new cell changes. nLastCell = newbuf.Width() - 1 for nLastCell > firstCell && cellEqual(newLine.At(nLastCell), blank) { nLastCell-- } if nLastCell == firstCell && s.el0Cost() < oLastCell-nLastCell { //nolint:nestif s.move(newbuf, firstCell, y) if !cellEqual(newLine.At(firstCell), blank) { s.putCell(newbuf, newLine.At(firstCell)) } s.clearToEnd(newbuf, blank, false) } else if nLastCell != oLastCell && !cellEqual(newLine.At(nLastCell), oldLine.At(oLastCell)) { s.move(newbuf, firstCell, y) if oLastCell-nLastCell > s.el0Cost() { if s.putRange(newbuf, oldLine, newLine, y, firstCell, nLastCell) { s.move(newbuf, nLastCell+1, y) } s.clearToEnd(newbuf, blank, false) } else { n := max(nLastCell, oLastCell) s.putRange(newbuf, oldLine, newLine, y, firstCell, n) } } else { nLastNonBlank := nLastCell oLastNonBlank := oLastCell // Find the last cells that really differ. // Can be -1 if no cells differ. for cellEqual(newLine.At(nLastCell), oldLine.At(oLastCell)) { if !cellEqual(newLine.At(nLastCell-1), oldLine.At(oLastCell-1)) { break } nLastCell-- oLastCell-- if nLastCell == -1 || oLastCell == -1 { break } } n := min(oLastCell, nLastCell) if n >= firstCell { s.move(newbuf, firstCell, y) s.putRange(newbuf, oldLine, newLine, y, firstCell, n) } if oLastCell < nLastCell { m := max(nLastNonBlank, oLastNonBlank) if n != 0 { for n > 0 { wide := newLine.At(n + 1) if !wide.isWidePlaceholder() { break } n-- oLastCell-- } } else if n >= firstCell && newLine.At(n) != nil && newLine.At(n).Width > 1 { next := newLine.At(n + 1) for next.isWidePlaceholder() { n++ oLastCell++ next = newLine.At(n + 1) } } // TODO: This can sometimes send unnecessary cursor movements with // negative or zero ranges. This could happen on a screen resize // where oLastCell < nLastCell and oLastCell is -1 or less. // Investigate and fix. s.move(newbuf, n+1, y) ichCost := 3 + nLastCell - oLastCell if s.caps.Contains(capICH) && (nLastCell < nLastNonBlank || ichCost > (m-n)) { s.putRange(newbuf, oldLine, newLine, y, n+1, m) } else { s.insertCells(newbuf, newLine[n+1:], nLastCell-oLastCell) } } else if oLastCell > nLastCell { s.move(newbuf, n+1, y) dchCost := 3 + oLastCell - nLastCell if dchCost > len(ansi.EraseLineRight)+nLastNonBlank-(n+1) { if s.putRange(newbuf, oldLine, newLine, y, n+1, nLastNonBlank) { s.move(newbuf, nLastNonBlank+1, y) } s.clearToEnd(newbuf, blank, false) } else { s.updatePen(blank) s.deleteCells(oLastCell - nLastCell) } } } // Update the old line with the new line if firstCell < len(oldLine) && firstCell < len(newLine) { copy(oldLine[firstCell:], newLine[firstCell:]) } else { copy(oldLine, newLine) } } // reanchorWideLine re-anchors the cursor after a line that may have left it // adrift. This is a best-effort fallback that bounds cursor desync to one line // on terminals whose width model disagrees with ours. When the terminal // negotiated Unicode grapheme width (mode 2027) the models agree, so no // re-anchor is needed. // // The row is re-anchored along with the column. A terminal that measures a // cluster wider than the model does runs past the right margin and wraps, so a // line the model believes fits can leave the real cursor a row further down. // Recovering the column alone would then leave every following line painted one // row off, which is the drift this re-anchor exists to contain. func (s *TerminalRenderer) reanchorWideLine(newbuf *RenderBuffer) { // In relative cursor mode there is no absolute row to move to, so the // column is all that can be recovered. if s.flags.Contains(tRelativeCursor) { if s.atPhantom || s.cur.X < 0 || s.cur.X >= newbuf.Width() { return } _, _ = s.buf.WriteString(ansi.CursorHorizontalAbsolute(s.cur.X + 1)) return } if s.cur.X < 0 || s.cur.Y < 0 { return } // A pending wrap is as untrustworthy as the column: the terminal may have // already taken it. An absolute move cancels it and lands on a column the // model can name, so clamp into the line and record that. x := min(s.cur.X, newbuf.Width()-1) _, _ = s.buf.WriteString(ansi.CursorPosition(x+1, s.cur.Y+1)) s.cur.X = x s.atPhantom = false } // deleteCells deletes the count cells at the current cursor position and moves // the rest of the line to the left. This is equivalent to [ansi.DCH]. func (s *TerminalRenderer) deleteCells(count int) { // [ansi.DCH] will shift in cells from the right margin so we need to // ensure that they are the right style. _, _ = s.buf.WriteString(ansi.DeleteCharacter(count)) } // clearToBottom clears the screen from the current cursor position to the end // of the screen. func (s *TerminalRenderer) clearToBottom(blank *Cell) { row, col := s.cur.Y, s.cur.X if row < 0 { row = 0 } s.updatePen(blank) _, _ = s.buf.WriteString(ansi.EraseScreenBelow) // Clear the rest of the current line s.curbuf.ClearArea(Rect(col, row, s.curbuf.Width()-col, 1)) // Clear everything below the current line s.curbuf.ClearArea(Rect(0, row+1, s.curbuf.Width(), s.curbuf.Height()-row-1)) } // clearBottom tests if clearing the end of the screen would satisfy part of // the screen update. Scan backwards through lines in the screen checking if // each is blank and one or more are changed. // It returns the top line. func (s *TerminalRenderer) clearBottom(newbuf *RenderBuffer, total int) (top int) { if total <= 0 { return 0 } top = total last := min(s.curbuf.Width(), newbuf.Width()) blank := s.clearBlank() canClearWithBlank := canClearWith(blank) if canClearWithBlank { //nolint:nestif var row int for row = total - 1; row >= 0; row-- { oldLine := s.curbuf.Line(row) newLine := newbuf.Line(row) var col int ok := true for col = 0; ok && col < last; col++ { ok = cellEqual(newLine.At(col), blank) } if !ok { break } for col = 0; ok && col < last; col++ { ok = cellEqual(oldLine.At(col), blank) } if !ok { top = row } } if top < total { // top is the first row of the trailing blank region, so the // erase starts there. Starting a row earlier would wipe a row // this frame did not touch, and the loop that repaints only // touched rows would never put it back. s.move(newbuf, 0, top) s.clearToBottom(blank) if s.oldhash != nil && s.newhash != nil && row < len(s.oldhash) && row < len(s.newhash) { for row := top; row < newbuf.Height(); row++ { s.oldhash[row] = s.newhash[row] } } } } return top } // clearScreen clears the screen and put cursor at home. func (s *TerminalRenderer) clearScreen(blank *Cell) { s.updatePen(blank) _, _ = s.buf.WriteString(ansi.CursorHomePosition) _, _ = s.buf.WriteString(ansi.EraseEntireScreen) s.cur.X, s.cur.Y = 0, 0 s.curbuf.Fill(blank) } // clearBelow clears everything below and including the row. func (s *TerminalRenderer) clearBelow(newbuf *RenderBuffer, blank *Cell, row int) { s.move(newbuf, 0, row) s.clearToBottom(blank) } // clearUpdate forces a screen redraw. func (s *TerminalRenderer) clearUpdate(newbuf *RenderBuffer) { blank := s.clearBlank() var nonEmpty int if s.flags.Contains(tFullscreen) { // XXX: We're using the maximum height of the two buffers to ensure we // write newly added lines to the screen in // [terminalWriter.transformLine]. nonEmpty = max(s.curbuf.Height(), newbuf.Height()) s.clearScreen(blank) } else { nonEmpty = newbuf.Height() // FIXME: Investigate the double [ansi.ClearScreenBelow] call. // Commenting the line below out seems to work but it might cause other // bugs. s.clearBelow(newbuf, blank, 0) } nonEmpty = s.clearBottom(newbuf, nonEmpty) for i := 0; i < nonEmpty && i < newbuf.Height(); i++ { s.paintLine(newbuf, i, false) } } func (s *TerminalRenderer) logf(format string, args ...any) { if s.logger == nil { return } s.logger.Printf(format, args...) } // Buffered returns the number of bytes buffered for the next flush. func (s *TerminalRenderer) Buffered() int { return s.buf.Len() } // Flush flushes the buffer to the screen. func (s *TerminalRenderer) Flush() (err error) { // Write the buffer if n := s.buf.Len(); n > 0 { bts := s.buf.Bytes() if s.logger != nil { s.logf("output: %q", bts) } _, err = s.w.Write(bts) s.buf.Reset() } return } // sizeChange is what a render owes to the screen or the frame having changed // shape since the last one. type sizeChange struct { // frameResized reports that the frame is a different shape than the model, // so the model has to be resized before anything is diffed against it. frameResized bool // repaintAll requires every row to be painted whatever the model says. repaintAll bool } // reconcileSize decides what a change of size costs, and is the only place that // decides it. // // A terminal that changed size rewrapped what was on it, and a frame that // changed shape leaves no row holding its old meaning. Either way the model // cannot be trusted. What differs is how much of the screen is the renderer's to // put right: fullscreen owns every cell and clears it, while an inline frame // shares the screen with whatever came before and so repaints its own rows and // leaves the rest alone. func (s *TerminalRenderer) reconcileSize(newbuf *RenderBuffer) sizeChange { newWidth, newHeight := newbuf.Width(), newbuf.Height() curWidth, curHeight := s.curbuf.Width(), s.curbuf.Height() fullscreen := s.flags.Contains(tFullscreen) change := sizeChange{ frameResized: curWidth != newWidth || curHeight != newHeight, } if s.termResized { if fullscreen { s.clear = true } else { change.repaintAll = true } s.termResized = false } if change.frameResized { // No row keeps its meaning, so the hashes that describe them are worth // nothing. Inline uses the narrower partial clear in Render instead. s.oldhash, s.newhash = nil, nil if fullscreen { s.clear = true } // Nor does the application's touch list, which never saw rows that // vanished and came back between two frames. s.damage(0, newHeight) } // The terminal clips a row it measures wider than the model does, and the // model believes it painted the whole row. Changing the width changes what // fits, so a row holding a cell the two might measure differently has to be // painted again. Rows of plain cells are measured the same by everyone, so an // ASCII screen pays nothing. if !fullscreen && curWidth != newWidth { for y, drifts := range s.driftRows { if drifts { s.damage(y, 1) } } } return change } // damage marks n rows from y as rows this render disturbed on its own: rows it // scrolled, rows it erased, rows the terminal is about to measure differently // than the model does. The diff loop paints a damaged row whether or not the // application drew into it, because the application has no reason to redraw a // row it did not change and no way to know the renderer moved it. func (s *TerminalRenderer) damage(y, n int) { for i := max(y, 0); i < min(y+n, len(s.damaged)); i++ { s.damaged[i] = true } } // beginDamage starts a fresh account, sized to the screen this render is about // to paint. A row outside it cannot be painted, so it cannot be damaged, and // clamping here is what lets every reader index the record without checking. // Reset on the way in rather than the way out, so no future exit path has to // remember to do it. The slice is kept when the size holds, so a steady stream // of frames allocates nothing. func (s *TerminalRenderer) beginDamage(height int) { if len(s.damaged) != height { s.damaged = make([]bool, height) return } clear(s.damaged) } // Redraw forces a full redraw of the screen. It's equivalent to calling // [TerminalRenderer.Erase] and [TerminalRenderer.Render]. func (s *TerminalRenderer) Redraw(newbuf *RenderBuffer) { s.clear = true s.Render(newbuf) } // Render renders changes of the screen to the internal buffer. Call // [terminalWriter.Flush] to flush pending changes to the screen. func (s *TerminalRenderer) Render(newbuf *RenderBuffer) { // Do we need to render anything? A latched resize counts: the terminal moved // content the application cannot know about, so a frame it considers // unchanged still has to be put back on screen. touchedLines := newbuf.TouchedLines() if !s.clear && !s.termResized && touchedLines == 0 { return } // Every reader below indexes the touch list by screen row, so the list has to // cover the screen before any of them run. Growing here is what lets them do // it without a length check each. newbuf.growTouched() if s.curbuf == nil || s.curbuf.Bounds().Empty() { // Initialize the current buffer s.curbuf = NewRenderBuffer(newbuf.Width(), newbuf.Height()) } newWidth, newHeight := newbuf.Width(), newbuf.Height() curHeight := s.curbuf.Height() s.beginDamage(newHeight) size := s.reconcileSize(newbuf) // TODO: Investigate whether this is necessary. Theoretically, terminals // can add/remove tab stops and we should be able to handle that. We could // use [ansi.DECTABSR] to read the tab stops, but that's not implemented in // most terminals :/ // // Are we using hard tabs? If so, ensure tabs are using the // // default interval using [ansi.DECST8C]. // if s.opts.HardTabs && !s.initTabs { // s.buf.WriteString(ansi.SetTabEvery8Columns) // s.initTabs = true // } var nonEmpty int // An inline frame that gives up rows has to erase what it no longer covers, // or the tail of the taller frame stays on screen below the shorter one. // Fullscreen has no such rows: it repaints the whole screen instead. // // The erase runs from the new last row to the bottom of the screen, so it // reaches that residue wherever the terminal moved it. A width change at // the same time is therefore a reason to erase rather than a reason to skip // it, since a terminal that rewrapped those rows has spread them further // than the model can account for. partialClear := !s.flags.Contains(tFullscreen) && s.cur.X != -1 && s.cur.Y != -1 && curHeight > 0 && curHeight > newHeight if !s.clear && partialClear { // From the first row the frame gave up, so the frame's own rows are left // alone: nothing it still owns needs putting back, and a frame that // collapsed to nothing starts at its own first row rather than the one // above, which belongs to whatever shared the screen first. s.clearBelow(newbuf, nil, newHeight) } // Resize the model before diffing so the loop below walks every row // of the new screen, including rows added by a grow. if size.frameResized { s.curbuf.Resize(newWidth, newHeight) } if s.clear { //nolint:nestif s.clearUpdate(newbuf) s.clear = false } else if touchedLines > 0 || size.repaintAll { // On Windows, there's a bug with Windows Terminal where [ansi.DECSTBM] // misbehaves and moves the cursor outside of the scrolling region. For // now, we disable the optimizations completely on Windows. // See https://github.com/microsoft/terminal/issues/19016 if s.flags.Contains(tScrollOptim) && s.flags.Contains(tFullscreen) { // Optimize scrolling for the alternate screen buffer. // TODO: Should we optimize for inline mode as well? If so, we need // to know the actual cursor position to use [ansi.DECSTBM]. s.scrollOptimize(newbuf) } var changedLines int var i int nonEmpty = newHeight nonEmpty = s.clearBottom(newbuf, nonEmpty) for i = 0; i < nonEmpty; i++ { // A repaintAll row has to be put back whatever the model says. if size.repaintAll || s.damaged[i] || (newbuf.Touched[i] != nil && (newbuf.Touched[i].FirstCell != -1 || newbuf.Touched[i].LastCell != -1)) { s.paintLine(newbuf, i, size.repaintAll) changedLines++ } } } if !s.flags.Contains(tFullscreen) && size.frameResized { s.move(newbuf, 0, max(newHeight-1, 0)) } // Sync windows and screen newbuf.growTouched() resetTouched(newbuf.Touched) s.updatePen(nil) } // Erase marks the screen to be fully erased on the next render. func (s *TerminalRenderer) Erase() { s.clear = true } // Resize updates the terminal screen tab stops. This is used to calculate // terminal tab stops for hard tab optimizations. // // Resize also invalidates the cursor model when the renderer can recover // from it: a terminal may move the cursor on any resize, so the remembered // position no longer matches reality, and the next move will be absolute. // // In relative cursor mode there is no absolute move to fall back on, and // -1 there means "first move, assume the origin" rather than "unknown", so // invalidating would assert a position instead of forgetting one. Keep the // old model in that mode and let the next render diff against it. // // A resize also forces the next render to repaint, since the screen moves // content around to fit and the model cannot see where it went. Whether that // means the whole screen or only the frame's own rows is decided in // [TerminalRenderer.Render]. func (s *TerminalRenderer) Resize(width, height int) { if s.tabs != nil { s.tabs.Resize(width) } // Latched against the size last reported, not against the model, because // Render sees only the size the application draws at: a screen that shrinks // and grows back between two frames looks unchanged by the time it gets // there. What the resize costs is decided in Render, which knows the mode. if width > 0 && height > 0 { s.termResized = s.termResized || (s.termW > 0 && (width != s.termW || height != s.termH)) s.termW, s.termH = width, height } if !s.flags.Contains(tRelativeCursor) { s.cur.X, s.cur.Y = -1, -1 } } // Position returns the cursor position in the screen buffer after applying any // pending transformations from the underlying buffer. func (s *TerminalRenderer) Position() (x, y int) { return s.cur.X, s.cur.Y } // SetPosition changes the logical cursor position. This can be used when we // change the cursor position outside of the screen and need to update the // screen cursor position. // This changes the cursor position for both normal and alternate screen // buffers. func (s *TerminalRenderer) SetPosition(x, y int) { s.cur.X, s.cur.Y = x, y } // WriteString writes the given string to the underlying buffer. func (s *TerminalRenderer) WriteString(str string) (int, error) { return s.buf.WriteString(str) //nolint:wrapcheck } // Write writes the given bytes to the underlying buffer. func (s *TerminalRenderer) Write(b []byte) (int, error) { return s.buf.Write(b) //nolint:wrapcheck } // MoveTo calculates and writes the shortest sequence to move the cursor to the // given position. It uses the current cursor position and the new position to // calculate the shortest amount of sequences to move the cursor. func (s *TerminalRenderer) MoveTo(x, y int) { s.move(nil, x, y) } // notLocal returns whether the coordinates are not considered local movement // using the defined thresholds. // This takes the number of columns, and the coordinates of the current and // target positions. func notLocal(cols, fx, fy, tx, ty int) bool { // The typical distance for a [ansi.CUP] sequence. Anything less than this // is considered local movement. const longDist = 8 - 1 return (tx > longDist) && (tx < cols-1-longDist) && (abs(ty-fy)+abs(tx-fx) > longDist) } // relativeCursorMove returns the relative cursor movement sequence using one or two // of the following sequences [ansi.CUU], [ansi.CUD], [ansi.CUF], [ansi.CUB], // [ansi.VPA], [ansi.HPA]. // When overwrite is true, this will try to optimize the sequence by using the // screen cells values to move the cursor instead of using escape sequences. // // It is safe to call this function with a nil [Buffer]. In that case, it won't // use any optimizations that require the new buffer such as overwrite. func relativeCursorMove(s *TerminalRenderer, newbuf *RenderBuffer, fx, fy, tx, ty int, overwrite, useTabs, useBackspace bool) string { var seq strings.Builder if newbuf == nil { overwrite = false // We can't overwrite the current buffer. } if ty != fy { //nolint:nestif var yseq string if s.caps.Contains(capVPA) && !s.flags.Contains(tRelativeCursor) { yseq = ansi.VerticalPositionAbsolute(ty + 1) } if ty > fy { n := ty - fy if cud := ansi.CursorDown(n); yseq == "" || len(cud) < len(yseq) { yseq = cud } if !s.flags.Contains(tFullscreen) || n < len(yseq) { // n is the cost of using newline characters yseq = strings.Repeat("\n", n) if s.flags.Contains(tMapNewline) { fx = 0 } } } else if ty < fy { n := fy - ty if cuu := ansi.CursorUp(n); yseq == "" || len(cuu) < len(yseq) { yseq = cuu } if n == 1 && fy-1 > 0 { // TODO: Ensure we're not unintentionally scrolling the screen up. yseq = ansi.ReverseIndex } } seq.WriteString(yseq) } if tx != fx { //nolint:nestif var xseq string if !s.flags.Contains(tRelativeCursor) { if s.caps.Contains(capHPA) { xseq = ansi.HorizontalPositionAbsolute(tx + 1) } else if s.caps.Contains(capCHA) { xseq = ansi.CursorHorizontalAbsolute(tx + 1) } } if tx > fx { n := tx - fx if useTabs && s.tabs != nil { var tabs int var col int for col = fx; s.tabs.Next(col) <= tx; col = s.tabs.Next(col) { tabs++ if col == s.tabs.Next(col) || col >= s.tabs.Width()-1 { break } } if tabs > 0 { cht := ansi.CursorHorizontalForwardTab(tabs) tab := strings.Repeat("\t", tabs) if false && s.caps.Contains(capCHT) && len(cht) < len(tab) { // TODO: The linux console and some terminals such as // Alacritty don't support [ansi.CHT]. Enable this when // we have a way to detect this, or after 5 years when // we're sure everyone has updated their terminals :P seq.WriteString(cht) } else { seq.WriteString(tab) } n = tx - col fx = col } } if cuf := ansi.CursorForward(n); xseq == "" || len(cuf) < len(xseq) { xseq = cuf } // If we have no attribute and style changes, overwrite is cheaper. var ovw string if overwrite && ty >= 0 { for i := 0; i < n; i++ { cell := newbuf.CellAt(fx+i, ty) if cell != nil && cell.Width > 0 { i += cell.Width - 1 if !cell.Style.Equal(&s.cur.Style) || !cell.Link.Equal(&s.cur.Link) { overwrite = false break } } } } if overwrite && ty >= 0 { for i := 0; i < n; i++ { cell := newbuf.CellAt(fx+i, ty) if cell != nil && cell.Width > 0 { ovw += cell.String() i += cell.Width - 1 } } } if overwrite && len(ovw) < len(xseq) { xseq = ovw } } else if tx < fx { n := fx - tx if useTabs && s.tabs != nil && s.caps.Contains(capCBT) { // VT100 does not support backward tabs [ansi.CBT]. col := fx var cbt int // cursor backward tabs count for s.tabs.Prev(col) >= tx { col = s.tabs.Prev(col) cbt++ if col == s.tabs.Prev(col) || col <= 0 { break } } if cbt > 0 { seq.WriteString(ansi.CursorBackwardTab(cbt)) n = col - tx } } if cub := ansi.CursorBackward(n); xseq == "" || len(cub) < len(xseq) { xseq = cub } if useBackspace && n < len(xseq) { xseq = strings.Repeat("\b", n) } } seq.WriteString(xseq) } return seq.String() } // moveCursor moves and returns the cursor movement sequence to move the cursor // to the specified position. // When overwrite is true, this will try to optimize the sequence by using the // screen cells values to move the cursor instead of using escape sequences. // // It is safe to call this function with a nil [Buffer]. In that case, it won't // use any optimizations that require the new buffer such as overwrite. func moveCursor(s *TerminalRenderer, newbuf *RenderBuffer, x, y int, overwrite bool) (seq string) { fx, fy := s.cur.X, s.cur.Y if !s.flags.Contains(tRelativeCursor) { width := -1 // Use -1 to indicate that we don't know the width of the screen. if s.tabs != nil { width = s.tabs.Width() } if newbuf != nil && width == -1 { // Even though this might not be accurate, we can use the new // buffer width as a fallback. Technically, if the new buffer // didn't have the width of the terminal, this would give us a // wrong result from [notLocal]. width = newbuf.Width() } // Method #0: Use [ansi.CUP] if the distance is long. seq = ansi.CursorPosition(x+1, y+1) if fx == -1 || fy == -1 || width == -1 || notLocal(width, fx, fy, x, y) { return seq } } // Optimize based on options. trials := 0 if s.caps.Contains(capHT) { trials |= 2 // 0b10 in binary } if s.caps.Contains(capBS) { trials |= 1 // 0b01 in binary } // Try all possible combinations of hard tabs and backspace optimizations. for i := 0; i <= trials; i++ { // Skip combinations that are not enabled. if i & ^trials != 0 { continue } useHardTabs := i&2 != 0 useBackspace := i&1 != 0 // Method #1: Use local movement sequences. nseq1 := relativeCursorMove(s, newbuf, fx, fy, x, y, overwrite, useHardTabs, useBackspace) if (i == 0 && len(seq) == 0) || len(nseq1) < len(seq) { seq = nseq1 } // Method #2: Use [ansi.CR] and local movement sequences. nseq2 := relativeCursorMove(s, newbuf, 0, fy, x, y, overwrite, useHardTabs, useBackspace) nseq2 = "\r" + nseq2 if len(nseq2) < len(seq) { seq = nseq2 } if !s.flags.Contains(tRelativeCursor) { // Method #3: Use [ansi.CursorHomePosition] and local movement sequences. nseq3 := relativeCursorMove(s, newbuf, 0, 0, x, y, overwrite, useHardTabs, useBackspace) nseq3 = ansi.CursorHomePosition + nseq3 if len(nseq3) < len(seq) { seq = nseq3 } } } return seq } // xtermCaps returns whether the terminal is xterm-like. This means that the // terminal supports ECMA-48 and ANSI X3.64 escape sequences. // xtermCaps returns a list of control sequence capabilities for the given // terminal type. This only supports a subset of sequences that can // be different among terminals. // NOTE: A hybrid approach would be to support Terminfo databases for a full // set of capabilities. func xtermCaps(termtype string) (v capabilities) { parts := strings.Split(termtype, "-") if len(parts) == 0 { return v } switch parts[0] { case "contour", "foot", "ghostty", "kitty", "rio", "st", "tmux", "wezterm": v = allCaps case "xterm": switch { case len(parts) > 1 && parts[1] == "ghostty": fallthrough case len(parts) > 1 && parts[1] == "kitty": fallthrough case len(parts) > 1 && parts[1] == "rio": // These terminals can be defined as xterm- variants for // compatibility with applications that check for xterm. v = allCaps default: // NOTE: We exclude capHPA from allCaps because terminals like // Konsole don't support it. Xterm terminfo defines HPA as CHA // which means we can use CHA instead of HPA. v = allCaps v &^= capHPA v &^= capCHT v &^= capREP } case "alacritty": v = allCaps v &^= capCHT // NOTE: alacritty added support for [ansi.CHT] in 2024-12-28 #62d5b13. case "screen": // See https://www.gnu.org/software/screen/manual/screen.html#Control-Sequences-1 v = allCaps v &^= capREP case "linux": // See https://man7.org/linux/man-pages/man4/console_codes.4.html v = capVPA | capCHA | capHPA | capECH | capICH } return v } // resetTouched marks every line as untouched, reusing the records already there // rather than allocating a new one per line on every render. func resetTouched(touched []*LineData) { for i, ld := range touched { if ld == nil { touched[i] = &LineData{FirstCell: -1, LastCell: -1} continue } ld.FirstCell, ld.LastCell = -1, -1 } }