From 23c4d48c3d87d87c9419c4a1ad889a5c3501eeb9 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 6 Jan 2025 15:11:55 +0300 Subject: [PATCH] refactor(cellbuf): define Screen and Window types --- cellbuf/buffer.go | 5 - cellbuf/cell.go | 9 - cellbuf/screen.go | 1361 +++++++++++++++++++++++++++++- cellbuf/screen_write.go | 252 ------ cellbuf/window.go | 1700 ++++++++------------------------------ examples/cellbuf/main.go | 24 +- examples/layout/main.go | 7 +- 7 files changed, 1685 insertions(+), 1673 deletions(-) delete mode 100644 cellbuf/screen_write.go diff --git a/cellbuf/buffer.go b/cellbuf/buffer.go index 34bd2c45..908de108 100644 --- a/cellbuf/buffer.go +++ b/cellbuf/buffer.go @@ -224,11 +224,6 @@ func (b *Buffer) Cell(x int, y int) *Cell { return b.Lines[y].At(x) } -// Draw implements Screen. -func (b *Buffer) Draw(x int, y int, c *Cell) bool { - return b.SetCell(x, y, c) -} - // maxCellWidth is the maximum width a terminal cell can get. const maxCellWidth = 4 diff --git a/cellbuf/cell.go b/cellbuf/cell.go index 99aceb96..111a5bbb 100644 --- a/cellbuf/cell.go +++ b/cellbuf/cell.go @@ -93,15 +93,6 @@ func (c *Cell) Blank() *Cell { return c } -// Segment returns a segment of the cell. -func (c *Cell) Segment() Segment { - return Segment{ - Content: c.String(), - Style: c.Style, - Link: c.Link, - } -} - // Link represents a hyperlink in the terminal screen. type Link struct { URL string diff --git a/cellbuf/screen.go b/cellbuf/screen.go index e21fe5d0..9654e903 100644 --- a/cellbuf/screen.go +++ b/cellbuf/screen.go @@ -1,22 +1,1353 @@ package cellbuf -// Segment represents a continuous segment of cells with the same style -// attributes and hyperlink. -type Segment struct { - Style Style - Link Link - Content string - Width int +import ( + "bytes" + "errors" + "io" + "os" + "strings" + "sync" + + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/term" +) + +// ErrInvalidDimensions is returned when the dimensions of a window are invalid +// for the operation. +var ErrInvalidDimensions = errors.New("invalid dimensions") + +// 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. +func relativeCursorMove(s *Screen, fx, fy, tx, ty int, overwrite bool) string { + var seq strings.Builder + + width, height := s.newbuf.Width(), s.newbuf.Height() + if ty != fy { + var yseq string + if s.xtermLike && !s.opts.RelativeCursor { + yseq = ansi.VerticalPositionAbsolute(ty + 1) + } + + // OPTIM: Use [ansi.LF] and [ansi.ReverseIndex] as optimizations. + + if ty > fy { + n := ty - fy + if cud := ansi.CursorDown(n); yseq == "" || len(cud) < len(yseq) { + yseq = cud + } + shouldScroll := !s.opts.AltScreen + if lf := strings.Repeat("\n", n); shouldScroll || (fy+n < height && len(lf) < len(yseq)) { + // TODO: Ensure we're not unintentionally scrolling the screen down. + yseq = lf + } + } 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 { + var xseq string + if s.xtermLike && !s.opts.RelativeCursor { + xseq = ansi.HorizontalPositionAbsolute(tx + 1) + } + + if tx > fx { + n := tx - fx + if s.opts.HardTabs { + 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 >= width-1 { + break + } + } + + if tabs > 0 { + cht := ansi.CursorHorizontalForwardTab(tabs) + tab := strings.Repeat("\t", tabs) + if false && s.xtermLike && 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 := s.newbuf.Cell(fx+i, ty) + if cell != nil { + 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 := s.newbuf.Cell(fx+i, ty) + if cell != nil { + ovw += cell.String() + i += cell.Width - 1 + } else { + ovw += " " + } + } + } + + if overwrite && len(ovw) < len(xseq) { + xseq = ovw + } + } else if tx < fx { + n := fx - tx + if s.opts.HardTabs && s.xtermLike { + // 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 bs := strings.Repeat("\b", n); xseq == "" || len(bs) < len(xseq) { + xseq = bs + } + + if cub := ansi.CursorBackward(n); len(cub) < len(xseq) { + xseq = cub + } + } + + 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. +func moveCursor(s *Screen, x, y int, overwrite bool) (seq string) { + fx, fy := s.cur.X, s.cur.Y + + if !s.opts.RelativeCursor { + // Method #0: Use [ansi.CUP] if the distance is long. + seq = ansi.CursorPosition(x+1, y+1) + if fx == -1 || fy == -1 || notLocal(s.newbuf.Width(), fx, fy, x, y) { + return + } + } + + // Method #1: Use local movement sequences. + nseq := relativeCursorMove(s, fx, fy, x, y, overwrite) + if len(seq) == 0 || len(nseq) < len(seq) { + seq = nseq + } + + // Method #2: Use [ansi.CR] and local movement sequences. + nseq = "\r" + relativeCursorMove(s, 0, fy, x, y, overwrite) + if len(nseq) < len(seq) { + seq = nseq + } + + if !s.opts.RelativeCursor { + // Method #3: Use [ansi.CursorHomePosition] and local movement sequences. + nseq = ansi.CursorHomePosition + relativeCursorMove(s, 0, 0, x, y, overwrite) + if len(nseq) < len(seq) { + seq = nseq + } + } + + return +} + +// moveCursor moves the cursor to the specified position. +func (s *Screen) moveCursor(x, y int, overwrite bool) { + s.buf.WriteString(moveCursor(s, x, y, overwrite)) //nolint:errcheck + s.cur.X, s.cur.Y = x, y +} + +func (s *Screen) move(x, y int) { + width, height := s.newbuf.Width(), s.newbuf.Height() + if x >= width { + // Handle autowrap + y += (x / width) + x %= width + } + + // Disable styles if there's any + // TODO: Do we need this? It seems like it's only needed when used with + // alternate character sets which we don't support. + // var pen Style + // if !s.cur.Style.Empty() { + // pen = s.cur.Style + // s.buf.WriteString(ansi.ResetStyle) //nolint:errcheck + // } + + if s.cur.X >= width { + l := (s.cur.X + 1) / width + + s.cur.Y += l + if 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 s.cur.Y > height-1 { + s.cur.Y = height - 1 + } + if y > height-1 { + y = height - 1 + } + + // We set the new cursor in [Screen.moveCursor]. + s.moveCursor(x, y, true) // Overwrite cells if possible + + // TODO: Do we need this? It seems like it's only needed when used with + // alternate character sets which we don't support. + // if !pen.Empty() { + // s.buf.WriteString(pen.Sequence()) //nolint:errcheck + // } +} + +// Cursor represents a terminal Cursor. +type Cursor struct { + Style Style + Link Link + Position +} + +// ScreenOptions are options for the screen. +type ScreenOptions struct { + // Term is the terminal type to use when writing to the screen. When empty, + // `$TERM` is used from [os.Getenv]. + Term string + // Width is the desired width of the screen. When 0, the width is + // automatically determined using the terminal size. + Width int + // Height is the desired height of the screen. When 0, the height is + // automatically determined using the terminal size. + Height int + // Profile is the color profile to use when writing to the screen. + Profile colorprofile.Profile + // RelativeCursor is whether to use relative cursor movements. This is + // useful when alt-screen is not used or when using inline mode. + RelativeCursor bool + // AltScreen is whether to use the alternate screen buffer. + AltScreen bool + // ShowCursor is whether to show the cursor. + ShowCursor bool + // HardTabs is whether to use hard tabs to optimize cursor movements. + HardTabs bool +} + +// 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 +} + +// Screen represents the terminal screen. +type Screen struct { + w io.Writer + buf *bytes.Buffer // buffer for writing to the screen + curbuf *Buffer // the current buffer + newbuf *Buffer // the new buffer + tabs *TabStops + touch map[int]lineData + queueAbove []string // the queue of strings to write above the screen + 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 + opts ScreenOptions + pos Position // the position of the cursor after the last render + mu sync.Mutex + altScreenMode bool // whether alternate screen mode is enabled + cursorHidden bool // whether text cursor mode is enabled + clear bool // whether to force clear the screen + xtermLike bool // whether to use xterm-like optimizations, otherwise, it uses vt100 only +} + +// UseHardTabs sets whether to use hard tabs to optimize cursor movements. +func (s *Screen) UseHardTabs(v bool) { + s.opts.HardTabs = v +} + +// SetColorProfile sets the color profile to use when writing to the screen. +func (s *Screen) SetColorProfile(p colorprofile.Profile) { + s.opts.Profile = p +} + +// SetRelativeCursor sets whether to use relative cursor movements. +func (s *Screen) SetRelativeCursor(v bool) { + s.opts.RelativeCursor = v +} + +// EnterAltScreen enters the alternate screen buffer. +func (s *Screen) EnterAltScreen() { + s.opts.AltScreen = true + s.clear = true + s.saved = s.cur +} + +// ExitAltScreen exits the alternate screen buffer. +func (s *Screen) ExitAltScreen() { + s.opts.AltScreen = false + s.clear = true + s.cur = s.saved +} + +// ShowCursor shows the cursor. +func (s *Screen) ShowCursor() { + s.opts.ShowCursor = true +} + +// HideCursor hides the cursor. +func (s *Screen) HideCursor() { + s.opts.ShowCursor = false +} + +// Bounds implements Window. +func (s *Screen) Bounds() Rectangle { + // Always return the new buffer bounds. + return s.newbuf.Bounds() +} + +// Cell implements Window. +func (s *Screen) Cell(x int, y int) *Cell { + return s.newbuf.Cell(x, y) +} + +// Clear implements Window. +func (s *Screen) Clear() bool { + s.clear = true + return s.ClearRect(s.newbuf.Bounds()) +} + +// ClearRect implements Window. +func (s *Screen) ClearRect(r Rectangle) bool { + return s.FillRect(nil, r) +} + +// SetCell implements Window. +func (s *Screen) SetCell(x int, y int, cell *Cell) (v bool) { + s.mu.Lock() + defer s.mu.Unlock() + cellWidth := 1 + if cell != nil { + cellWidth = cell.Width + } + if prev := s.curbuf.Cell(x, y); !cellEqual(prev, cell) { + chg, ok := s.touch[y] + if !ok { + chg = lineData{firstCell: x, lastCell: x + cellWidth} + } else { + chg.firstCell = min(chg.firstCell, x) + chg.lastCell = max(chg.lastCell, x+cellWidth) + } + s.touch[y] = chg + } + + return s.newbuf.SetCell(x, y, cell) +} + +// Fill implements Window. +func (s *Screen) Fill(cell *Cell) bool { + return s.FillRect(cell, s.newbuf.Bounds()) +} + +// FillRect implements Window. +func (s *Screen) FillRect(cell *Cell, r Rectangle) bool { + s.mu.Lock() + defer s.mu.Unlock() + s.newbuf.FillRect(cell, r) + for i := r.Min.Y; i < r.Max.Y; i++ { + s.touch[i] = lineData{firstCell: r.Min.X, lastCell: r.Max.X} + } + return true +} + +// isXtermLike returns whether the terminal is xterm-like. This means that the +// terminal supports ECMA-48 and ANSI X3.64 escape sequences. +// TODO: Should this be a lookup table into each $TERM terminfo database? Like +// we could keep a map of ANSI escape sequence to terminfo capability name and +// check if the database supports the escape sequence. Instead of keeping a +// list of terminal names here. +func isXtermLike(termtype string) (v bool) { + parts := strings.Split(termtype, "-") + if len(parts) == 0 { + return + } + + switch parts[0] { + case + "alacritty", + "contour", + "foot", + "ghostty", + "kitty", + "linux", + "rio", + "screen", + "st", + "tmux", + "wezterm", + "xterm": + v = true + } + + return +} + +// NewScreen creates a new Screen. +func NewScreen(w io.Writer, opts *ScreenOptions) (s *Screen) { + s = new(Screen) + s.w = w + if opts != nil { + s.opts = *opts + } + + if s.opts.Term == "" { + s.opts.Term = os.Getenv("TERM") + } + + width, height := s.opts.Width, s.opts.Height + if width <= 0 || height <= 0 { + if f, ok := w.(term.File); ok { + width, height, _ = term.GetSize(f.Fd()) + } + } + + s.buf = new(bytes.Buffer) + s.xtermLike = isXtermLike(s.opts.Term) + s.curbuf = NewBuffer(width, height) + s.newbuf = NewBuffer(width, height) + s.reset() + + return +} + +// Width returns the width of the screen. +func (s *Screen) Width() int { + return s.opts.Width +} + +// Height returns the height of the screen. +func (s *Screen) Height() int { + return s.opts.Height +} + +// cellEqual returns whether the two cells are equal. A nil cell is considered +// a [BlankCell]. +func cellEqual(a, b *Cell) bool { + if a == nil { + a = &BlankCell + } + if b == nil { + b = &BlankCell + } + return a.Equal(b) +} + +// putCell draws a cell at the current cursor position. +func (s *Screen) putCell(cell *Cell) { + width, height := s.newbuf.Width(), s.newbuf.Height() + if s.opts.AltScreen && s.cur.X == width-1 && s.cur.Y == height-1 { + s.putCellLR(cell) + } else { + s.putAttrCell(cell) + } + + if s.cur.X >= width { + s.wrapCursor() + } +} + +// wrapCursor wraps the cursor to the next line. +func (s *Screen) wrapCursor() { + s.cur.X = 0 + s.cur.Y++ +} + +func (s *Screen) putAttrCell(cell *Cell) { + if cell != nil && cell.Width <= 0 { + return + } + + if cell == nil { + cell = s.clearBlank() + } + + // if s.cur.X >= s.newbuf.Width() { + // // TODO: Properly handle autowrap. + // s.wrapCursor() + // } + + s.updatePen(cell) + s.buf.WriteString(cell.String()) //nolint:errcheck + s.cur.X += cell.Width + + if s.cur.X >= s.newbuf.Width() { + // TODO: Properly handle autowrap. This is a hack. + s.cur.X = s.newbuf.Width() - 1 + } +} + +// putCellLR draws a cell at the lower right corner of the screen. +func (s *Screen) putCellLR(cell *Cell) { + // Optimize for the lower right corner cell. + curX := s.cur.X + s.buf.WriteString(ansi.ResetAutoWrapMode) //nolint:errcheck + s.putAttrCell(cell) + s.cur.X = curX + s.buf.WriteString(ansi.SetAutoWrapMode) //nolint:errcheck +} + +// updatePen updates the cursor pen styles. +func (s *Screen) updatePen(cell *Cell) { + if cell == nil { + cell = &BlankCell + } + + style := cell.Style + link := cell.Link + if s.opts.Profile != 0 { + // Downsample colors to the given color profile. + style = ConvertStyle(style, s.opts.Profile) + link = ConvertLink(link, s.opts.Profile) + } + + if !style.Equal(s.cur.Style) { + seq := style.DiffSequence(s.cur.Style) + if style.Empty() && len(seq) > len(ansi.ResetStyle) { + seq = ansi.ResetStyle + } + s.buf.WriteString(seq) //nolint:errcheck + s.cur.Style = style + } + if !link.Equal(s.cur.Link) { + s.buf.WriteString(ansi.SetHyperlink(link.URL, link.URLID)) //nolint:errcheck + s.cur.Link = link + } +} + +// emitRange emits a range of cells to the buffer. It it equivalent to calling +// [Screen.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 *Screen) emitRange(line Line, n int) (eoi bool) { + for n > 0 { + var count int + for n > 1 && !cellEqual(line.At(0), line.At(1)) { + s.putCell(line.At(0)) + line = line[1:] + n-- + } + + cell0 := line[0] + if n == 1 { + s.putCell(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 s.xtermLike && count > len(ech)+len(cup) && cell0 != nil && cell0.Clear() { + s.updatePen(cell0) + s.buf.WriteString(ech) //nolint:errcheck + + // If this is the last cell, we don't need to move the cursor. + if count < n { + s.move(s.cur.X+count, s.cur.Y) + } else { + return true // cursor in the middle + } + } else if s.xtermLike && count > len(rep) && + (cell0 == nil || (len(cell0.Comb) == 0 && cell0.Rune < 256)) { + // 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 >= s.newbuf.Width() + repCount := count + if wrapPossible { + repCount-- + } + + s.updatePen(cell0) + s.putCell(cell0) + repCount-- // cell0 is a single width cell ASCII character + + s.buf.WriteString(ansi.RepeatPreviousCharacter(repCount)) //nolint:errcheck + s.cur.X += repCount + if wrapPossible { + s.putCell(cell0) + } + } else { + for i := 0; i < count; i++ { + s.putCell(line.At(i)) + } + } + + line = line[clamp(count, 0, len(line)):] + n -= count + } + + return +} + +// 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 *Screen) putRange(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 { + var j, same int + for j, same = start, 0; j <= end; j++ { + oldCell, newCell := oldLine.At(j), newLine.At(j) + if same == 0 && oldCell != nil && oldCell.Empty() { + continue + } + if cellEqual(oldCell, newCell) { + same++ + } else { + if same > end-start { + s.emitRange(newLine[start:], j-same-start) + s.move(y, j) + start = j + } + same = 0 + } + } + + i := s.emitRange(newLine[start:], j-same-start) + + // Always return 1 for the next [Screen.move] after a [Screen.putRange] if + // we found identical characters at end of interval. + if same == 0 { + return i + } + return true + } + + return s.emitRange(newLine[start:], end-start+1) +} + +// clearToEnd clears the screen from the current cursor position to the end of +// line. +func (s *Screen) clearToEnd(blank *Cell, force bool) { + if s.cur.Y >= 0 { + curline := s.curbuf.Line(s.cur.Y) + for j := s.cur.X; j < s.curbuf.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 := s.newbuf.Width() - s.cur.X + if s.el0Cost() <= count { + s.buf.WriteString(ansi.EraseLineRight) //nolint:errcheck) + } else { + for i := 0; i < count; i++ { + s.putCell(blank) + } + } + } +} + +// clearBlank returns a blank cell based on the current cursor background color. +func (s *Screen) clearBlank() *Cell { + c := BlankCell + if !s.cur.Style.Empty() || !s.cur.Link.Empty() { + c.Style = s.cur.Style + c.Link = s.cur.Link + } + return &c +} + +// insertCells inserts the count cells pointed by the given line at the current +// cursor position. +func (s *Screen) insertCells(line Line, count int) { + if s.xtermLike { + // Use [ansi.ICH] as an optimization. + s.buf.WriteString(ansi.InsertCharacter(count)) //nolint:errcheck + } else { + // Otherwise, use [ansi.IRM] mode. + s.buf.WriteString(ansi.SetInsertReplaceMode) //nolint:errcheck + } + + for i := 0; count > 0; i++ { + s.putAttrCell(line[i]) + count-- + } + + if !s.xtermLike { + s.buf.WriteString(ansi.ResetInsertReplaceMode) //nolint:errcheck + } +} + +// 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 *Screen) el0Cost() int { + if s.xtermLike { + return 0 + } + return len(ansi.EraseLineRight) +} + +// transformLine transforms the given line in the current window to the +// corresponding line in the new window. It uses [ansi.ICH] and [ansi.DCH] to +// insert or delete characters. +func (s *Screen) transformLine(y int) { + var firstCell, oLastCell, nLastCell int // first, old last, new last index + oldLine := s.curbuf.Line(y) + newLine := s.newbuf.Line(y) + + // Find the first changed cell in the line + var lineChanged bool + for i := 0; i < s.newbuf.Width(); i++ { + if !cellEqual(newLine.At(i), oldLine.At(i)) { + lineChanged = true + break + } + } + + const ceolStandoutGlitch = false + if ceolStandoutGlitch && lineChanged { + s.move(0, y) + s.clearToEnd(nil, false) + s.putRange(oldLine, newLine, y, 0, s.newbuf.Width()-1) + } else { + blank := newLine.At(0) + + // It might be cheaper to clear leading spaces with [ansi.EL] 1 i.e. + // [ansi.EraseLineLeft]. + if blank == nil || blank.Clear() { + var oFirstCell, nFirstCell int + for oFirstCell = 0; oFirstCell < s.curbuf.Width(); oFirstCell++ { + if !cellEqual(oldLine.At(oFirstCell), blank) { + break + } + } + for nFirstCell = 0; nFirstCell < s.newbuf.Width(); nFirstCell++ { + if !cellEqual(newLine.At(nFirstCell), blank) { + break + } + } + + if nFirstCell == oFirstCell { + firstCell = nFirstCell + + // Find the first differing cell + for firstCell < s.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 >= s.newbuf.Width() { + s.move(0, y) + s.updatePen(blank) + s.buf.WriteString(ansi.EraseLineRight) //nolint:errcheck + } else { + s.move(nFirstCell-1, y) + s.updatePen(blank) + s.buf.WriteString(ansi.EraseLineLeft) //nolint:errcheck + } + + for firstCell < nFirstCell { + oldLine.Set(firstCell, blank) + firstCell++ + } + } + } + } else { + // Find the first differing cell + for firstCell < s.newbuf.Width() && cellEqual(newLine.At(firstCell), oldLine.At(firstCell)) { + firstCell++ + } + } + + // If we didn't find one, we're done + if firstCell >= s.newbuf.Width() { + return + } + + blank = newLine.At(s.newbuf.Width() - 1) + if blank != nil && !blank.Clear() { + // Find the last differing cell + nLastCell = s.newbuf.Width() - 1 + for nLastCell > firstCell && cellEqual(newLine.At(nLastCell), oldLine.At(nLastCell)) { + nLastCell-- + } + + if nLastCell >= firstCell { + s.move(firstCell, y) + s.putRange(oldLine, newLine, y, firstCell, nLastCell) + copy(oldLine[firstCell:], newLine[firstCell:]) + } + + return + } + + // Find last non-blank cell in the old line. + oLastCell = s.curbuf.Width() - 1 + for oLastCell > firstCell && cellEqual(oldLine.At(oLastCell), blank) { + oLastCell-- + } + + // Find last non-blank cell in the new line. + nLastCell = s.newbuf.Width() - 1 + for nLastCell > firstCell && cellEqual(newLine.At(nLastCell), blank) { + nLastCell-- + } + + if nLastCell == firstCell && s.el0Cost() < oLastCell-nLastCell { + s.move(firstCell, y) + if !cellEqual(newLine.At(firstCell), blank) { + s.putCell(newLine.At(firstCell)) + } + s.clearToEnd(blank, false) + } else if nLastCell != oLastCell && + !cellEqual(newLine.At(nLastCell), oldLine.At(oLastCell)) { + s.move(firstCell, y) + if oLastCell-nLastCell > s.el0Cost() { + if s.putRange(oldLine, newLine, y, firstCell, nLastCell) { + s.move(nLastCell+1, y) + } + s.clearToEnd(blank, false) + } else { + n := max(nLastCell, oLastCell) + s.putRange(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(firstCell, y) + s.putRange(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 == nil || !wide.Empty() { + break + } + n-- + oLastCell-- + } + } else if n >= firstCell && newLine.At(n) != nil && newLine.At(n).Width > 1 { + next := newLine.At(n + 1) + for next != nil && next.Empty() { + n++ + oLastCell++ + } + } + + s.move(n+1, y) + ichCost := 3 + nLastCell - oLastCell + if s.xtermLike && (nLastCell < nLastNonBlank || ichCost > (m-n)) { + s.putRange(oldLine, newLine, y, n+1, m) + } else { + s.insertCells(newLine[n+1:], nLastCell-oLastCell) + } + } else if oLastCell > nLastCell { + s.move(n+1, y) + dchCost := 3 + oLastCell - nLastCell + if dchCost > len(ansi.EraseLineRight)+nLastNonBlank-(n+1) { + if s.putRange(oldLine, newLine, y, n+1, nLastNonBlank) { + s.move(nLastNonBlank+1, y) + } + s.clearToEnd(blank, false) + } else { + s.updatePen(blank) + s.deleteCells(oLastCell - nLastCell) + } + } + } + } + + // Update the old line with the new line + if s.newbuf.Width() >= firstCell && len(oldLine) != 0 { + copy(oldLine[firstCell:], newLine[firstCell:]) + } +} + +// 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 *Screen) 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)) //nolint:errcheck +} + +// clearToBottom clears the screen from the current cursor position to the end +// of the screen. +func (s *Screen) clearToBottom(blank *Cell) { + row, col := s.cur.Y, s.cur.X + if row < 0 { + row = 0 + } + if col < 0 { + col = 0 + } + + s.updatePen(blank) + s.buf.WriteString(ansi.EraseScreenBelow) //nolint:errcheck + s.curbuf.ClearRect(Rect(col, row, s.curbuf.Width(), row+1)) + s.curbuf.ClearRect(Rect(0, row+1, s.curbuf.Width(), s.curbuf.Height())) +} + +// 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 *Screen) clearBottom(total int, force bool) (top int) { + top = total + if total <= 0 { + return + } + + last := min(s.curbuf.Width(), s.newbuf.Width()) + blank := s.clearBlank() + canClearWithBlank := blank == nil || blank.Clear() + + if canClearWithBlank || force { + var row int + for row = total - 1; row >= 0; row-- { + var col int + var ok bool + for col, ok = 0, true; ok && col < last; col++ { + ok = cellEqual(s.newbuf.Cell(col, row), blank) + } + if !ok { + break + } + + for col = 0; ok && col < last; col++ { + ok = cellEqual(s.curbuf.Cell(col, row), blank) + } + if !ok { + top = row + } + } + + if force || top < total { + s.moveCursor(0, top, false) + s.clearToBottom(blank) + if !s.opts.AltScreen { + // Move to the last line of the screen + s.moveCursor(0, s.newbuf.Height()-1, false) + } + // TODO: Line hashing + } + } + + return +} + +// clearScreen clears the screen and put cursor at home. +func (s *Screen) clearScreen(blank *Cell) { + s.updatePen(blank) + s.buf.WriteString(ansi.CursorHomePosition) //nolint:errcheck + s.buf.WriteString(ansi.EraseEntireScreen) //nolint:errcheck + s.cur.X, s.cur.Y = 0, 0 + s.curbuf.Fill(blank) +} + +// clearBelow clears everything below the screen. +func (s *Screen) clearBelow(blank *Cell, row int) { + s.updatePen(blank) + s.moveCursor(0, row, false) + s.clearToBottom(blank) + s.cur.X, s.cur.Y = 0, row + s.curbuf.FillRect(blank, Rect(0, row, s.curbuf.Width(), s.curbuf.Height())) +} + +// clearUpdate forces a screen redraw. +func (s *Screen) clearUpdate(partial bool) { + blank := s.clearBlank() + var nonEmpty int + if s.opts.AltScreen { + nonEmpty = min(s.curbuf.Height(), s.newbuf.Height()) + s.clearScreen(blank) + } else { + nonEmpty = s.newbuf.Height() + s.clearBelow(blank, 0) + } + nonEmpty = s.clearBottom(nonEmpty, partial) + for i := 0; i < nonEmpty; i++ { + s.transformLine(i) + } +} + +// Render implements Window. +func (s *Screen) Render() { + s.mu.Lock() + s.render() + // Write the buffer + if s.buf.Len() > 0 { + s.w.Write(s.buf.Bytes()) //nolint:errcheck + } + s.buf.Reset() + s.mu.Unlock() +} + +func (s *Screen) render() { + // Do we need to render anything? + if s.opts.AltScreen == s.altScreenMode && + !s.opts.ShowCursor == s.cursorHidden && + !s.clear && + len(s.touch) == 0 && + len(s.queueAbove) == 0 && + s.pos == undefinedPos { + return + } + + // 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 + // } + + // Do we need alt-screen mode? + if s.opts.AltScreen != s.altScreenMode { + if s.opts.AltScreen { + s.buf.WriteString(ansi.SetAltScreenSaveCursorMode) + } else { + s.buf.WriteString(ansi.ResetAltScreenSaveCursorMode) + } + s.altScreenMode = s.opts.AltScreen + } + + // Do we need text cursor mode? + if !s.opts.ShowCursor != s.cursorHidden { + s.cursorHidden = !s.opts.ShowCursor + if s.cursorHidden { + s.buf.WriteString(ansi.HideCursor) + } + } + + // Do we have queued strings to write above the screen? + if len(s.queueAbove) > 0 { + // 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. + s.moveCursor(0, s.newbuf.Height()-1, false) + s.buf.WriteString(strings.Repeat("\n", len(s.queueAbove))) + s.cur.Y += len(s.queueAbove) + // Now go to the top of the screen, insert new lines, and write the + // queued strings. + s.moveCursor(0, 0, false) + s.buf.WriteString(ansi.InsertLine(len(s.queueAbove))) + for _, line := range s.queueAbove { + s.buf.WriteString(line + "\r\n") + } + + // Clear the queue + s.queueAbove = s.queueAbove[:0] + } + + var nonEmpty int + + // Force clear? + // We only do partial clear if the screen is not in alternate screen mode + partialClear := s.curbuf.Width() == s.newbuf.Width() && + s.curbuf.Height() > s.newbuf.Height() + + if s.clear { + s.clearUpdate(partialClear) + s.clear = false + } else if len(s.touch) > 0 { + if s.opts.AltScreen { + // 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() + } + + var changedLines int + var i int + + if s.opts.AltScreen { + nonEmpty = min(s.curbuf.Height(), s.newbuf.Height()) + } else { + nonEmpty = s.newbuf.Height() + } + + nonEmpty = s.clearBottom(nonEmpty, partialClear) + for i = 0; i < nonEmpty; i++ { + _, ok := s.touch[i] + if ok { + s.transformLine(i) + changedLines++ + } + } + } + + // Sync windows and screen + s.touch = make(map[int]lineData, s.newbuf.Height()) + + if s.curbuf.Width() != s.newbuf.Width() || s.curbuf.Height() != s.newbuf.Height() { + // Resize the old buffer to match the new buffer. + _, oldh := s.curbuf.Width(), s.curbuf.Height() + s.curbuf.Resize(s.newbuf.Width(), s.newbuf.Height()) + // Sync new lines to old lines + for i := oldh - 1; i < s.newbuf.Height(); i++ { + copy(s.curbuf.Line(i), s.newbuf.Line(i)) + } + } + + s.updatePen(nil) // nil indicates a blank cell with no styles + + // Move the cursor to the specified position. + if s.pos != undefinedPos { + s.move(s.pos.X, s.pos.Y) + s.pos = undefinedPos + } + + if s.buf.Len() > 0 { + // Is the cursor visible? If so, disable it while rendering. + if s.opts.ShowCursor && !s.cursorHidden { + nb := new(bytes.Buffer) + nb.WriteString(ansi.HideCursor) + nb.Write(s.buf.Bytes()) + nb.WriteString(ansi.ShowCursor) + *s.buf = *nb + } + } +} + +// undefinedPos is the position used when the cursor position is undefined and +// in its initial state. +var undefinedPos = Pos(-1, -1) + +// Close writes the final screen update and resets the screen. +func (s *Screen) Close() (err error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.render() + s.updatePen(nil) + s.move(0, s.newbuf.Height()-1) + s.clearToEnd(nil, true) + + if s.altScreenMode { + s.buf.WriteString(ansi.ResetAltScreenSaveCursorMode) + s.altScreenMode = false + } + + if s.cursorHidden { + s.buf.WriteString(ansi.ShowCursor) + s.cursorHidden = false + } + + // Write the buffer + _, err = s.w.Write(s.buf.Bytes()) + s.buf.Reset() + if err != nil { + return + } + + s.reset() + return +} + +// reset resets the screen to its initial state. +func (s *Screen) reset() { + s.cursorHidden = false + s.altScreenMode = false + if s.opts.RelativeCursor { + s.cur = Cursor{} + } else { + s.cur = Cursor{Position: undefinedPos} + } + s.saved = s.cur + s.touch = make(map[int]lineData, s.newbuf.Height()) + if s.curbuf != nil { + s.curbuf.Clear() + } + if s.newbuf != nil { + s.newbuf.Clear() + } + s.buf.Reset() + s.tabs = DefaultTabStops(s.newbuf.Width()) + s.oldhash, s.newhash = nil, nil + + // We always disable HardTabs when termtype is "linux". + if strings.HasPrefix(s.opts.Term, "linux") { + s.opts.HardTabs = false + } +} + +// Resize resizes the screen. +func (s *Screen) Resize(width, height int) bool { + oldw := s.newbuf.Width() + oldh := s.newbuf.Height() + + if s.opts.AltScreen || width != oldw { + // We only clear the whole screen if the width changes. Adding/removing + // rows is handled by the [Screen.render] and [Screen.transformLine] + // methods. + s.clear = true + } + + // Clear new columns and lines + if width > oldh { + s.ClearRect(Rect(max(oldw-2, 0), 0, width-oldw, height)) + } else if width < oldw { + s.ClearRect(Rect(max(width-1, 0), 0, oldw-width, height)) + } + + if height > oldh { + s.ClearRect(Rect(0, max(oldh-1, 0), width, height-oldh)) + } else if height < oldh { + s.ClearRect(Rect(0, max(height-1, 0), width, oldh-height)) + } + + s.mu.Lock() + s.newbuf.Resize(width, height) + s.opts.Width, s.opts.Height = width, height + s.tabs.Resize(width) + s.oldhash, s.newhash = nil, nil + s.mu.Unlock() + + return true } -// Paint writes the given data to the canvas. If rect is not nil, it only -// writes to the rectangle. -func Paint(d Window, content string) []int { - return PaintRect(d, content, d.Bounds()) +// MoveTo moves the cursor to the specified position. +func (s *Screen) MoveTo(x, y int) bool { + pos := Pos(x, y) + if !pos.In(s.Bounds()) { + return false + } + s.mu.Lock() + s.pos = pos + s.mu.Unlock() + return true } -// PaintRect writes the given data to the canvas starting from the given -// rectangle. -func PaintRect(d Window, content string, rect Rectangle) []int { - return setContent(d, content, WcWidth, rect) +// InsertAbove inserts string above the screen. The inserted string is not +// managed by the screen. This does nothing when alternate screen mode is +// enabled. +func (s *Screen) InsertAbove(str string) { + if s.opts.AltScreen { + return + } + s.mu.Lock() + s.queueAbove = append(s.queueAbove, strings.Split(str, "\n")...) + s.mu.Unlock() } diff --git a/cellbuf/screen_write.go b/cellbuf/screen_write.go deleted file mode 100644 index a957f592..00000000 --- a/cellbuf/screen_write.go +++ /dev/null @@ -1,252 +0,0 @@ -package cellbuf - -import ( - "bytes" - "strings" - "unicode/utf8" - - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/wcwidth" -) - -// setContent writes the given data to the buffer starting from the first cell. -// It accepts both string and []byte data types. -func setContent( - d Window, - data string, - method Method, - rect Rectangle, -) []int { - var cell Cell - var pen Style - var link Link - x, y := rect.Min.X, rect.Min.Y - - p := ansi.GetParser() - defer ansi.PutParser(p) - data = strings.ReplaceAll(data, "\r\n", "\n") - - // linew is a slice of line widths. We use this to keep track of the - // written widths of each line. We use this information later to optimize - // rendering of the buffer. - linew := make([]int, rect.Dy()) - - var pendingWidth int - - var state byte - for len(data) > 0 { - seq, width, n, newState := ansi.DecodeSequence(data, state, p) - - var r rune - var comb []rune - switch width { - case 1, 2, 3, 4: // wide cells can go up to 4 cells wide - switch method { - case WcWidth: - for i, c := range seq { - if i == 0 { - r = c - width = wcwidth.RuneWidth(r) - continue - } - if wcwidth.RuneWidth(c) > 0 { - break - } - comb = append(comb, c) - } - - // We're breaking the grapheme to respect wcwidth's behavior - // while keeping combining characters together. - n = utf8.RuneLen(r) - for _, c := range comb { - n += utf8.RuneLen(c) - } - newState = 0 - - case GraphemeWidth: - // [ansi.DecodeSequence] already handles grapheme clusters - for i, c := range seq { - if i == 0 { - r = c - } else { - comb = append(comb, c) - } - } - } - - if x+width > rect.Min.X+rect.Dx() || y > rect.Min.Y+rect.Dy() { - break - } - - cell.Rune = r - cell.Comb = comb - cell.Width = width - cell.Style = pen - cell.Link = link - - d.Draw(x, y, &cell) //nolint:errcheck - - // Advance the cursor and line width - x += cell.Width - if cell.Equal(&BlankCell) { - pendingWidth += cell.Width - } else if y := y - rect.Min.Y; y < len(linew) { - linew[y] += cell.Width + pendingWidth - pendingWidth = 0 - } - - cell.Reset() - default: - // Valid sequences always have a non-zero Cmd. - switch { - case ansi.HasCsiPrefix(seq) && p.Cmd() != 0: - switch p.Cmd() { - case 'm': // SGR - Select Graphic Rendition - handleSgr(p, &pen) - } - case ansi.HasOscPrefix(seq) && p.Cmd() != 0: - switch p.Cmd() { - case 8: // Hyperlinks - handleHyperlinks(p, &link) - } - case ansi.Equal(seq, "\n"): - // Reset the rest of the line - d.ClearRect(Rect(x, y, rect.Dx()+rect.Min.X-x, 1)) - - y++ - // XXX: We gotta reset the x position here because we're moving - // to the next line. We shouldn't have any "\r\n" sequences, - // those are replaced above. - x = rect.Min.X - } - } - - // Advance the state and data - state = newState - data = data[n:] - } - - // Don't forget to clear the last line - d.ClearRect(Rect(x, y, rect.Dx()+rect.Min.X-x, 1)) - - y++ - if y < rect.Dy() { - // Clear the rest of the lines - d.ClearRect(Rect(rect.Min.X, y, rect.Min.X+rect.Dx(), rect.Min.Y+rect.Dy())) - } - - return linew -} - -// handleSgr handles Select Graphic Rendition (SGR) escape sequences. -func handleSgr(p *ansi.Parser, pen *Style) { - params := p.Params() - if len(params) == 0 { - pen.Reset() - return - } - - for i := 0; i < len(params); i++ { - r := params[i] - param, hasMore := r.Param(0), r.HasMore() // Are there more subparameters i.e. separated by ":"? - switch param { - case 0: // Reset - pen.Reset() - case 1: // Bold - pen.Bold(true) - case 2: // Dim/Faint - pen.Faint(true) - case 3: // Italic - pen.Italic(true) - case 4: // Underline - if hasMore { // Only accept subparameters i.e. separated by ":" - nextParam := params[i+1].Param(0) - switch nextParam { - case 0, 1, 2, 3, 4, 5: - i++ - switch nextParam { - case 0: // No Underline - pen.UnderlineStyle(NoUnderline) - case 1: // Single Underline - pen.UnderlineStyle(SingleUnderline) - case 2: // Double Underline - pen.UnderlineStyle(DoubleUnderline) - case 3: // Curly Underline - pen.UnderlineStyle(CurlyUnderline) - case 4: // Dotted Underline - pen.UnderlineStyle(DottedUnderline) - case 5: // Dashed Underline - pen.UnderlineStyle(DashedUnderline) - } - } - } else { - // Single Underline - pen.Underline(true) - } - case 5: // Slow Blink - pen.SlowBlink(true) - case 6: // Rapid Blink - pen.RapidBlink(true) - case 7: // Reverse - pen.Reverse(true) - case 8: // Conceal - pen.Conceal(true) - case 9: // Crossed-out/Strikethrough - pen.Strikethrough(true) - case 22: // Normal Intensity (not bold or faint) - pen.Bold(false).Faint(false) - case 23: // Not italic, not Fraktur - pen.Italic(false) - case 24: // Not underlined - pen.Underline(false) - case 25: // Blink off - pen.SlowBlink(false).RapidBlink(false) - case 27: // Positive (not reverse) - pen.Reverse(false) - case 28: // Reveal - pen.Conceal(false) - case 29: // Not crossed out - pen.Strikethrough(false) - case 30, 31, 32, 33, 34, 35, 36, 37: // Set foreground - pen.Foreground(ansi.Black + ansi.BasicColor(param-30)) //nolint:gosec - case 38: // Set foreground 256 or truecolor - if c := readColor(&i, params); c != nil { - pen.Foreground(c) - } - case 39: // Default foreground - pen.Foreground(nil) - case 40, 41, 42, 43, 44, 45, 46, 47: // Set background - pen.Background(ansi.Black + ansi.BasicColor(param-40)) //nolint:gosec - case 48: // Set background 256 or truecolor - if c := readColor(&i, params); c != nil { - pen.Background(c) - } - case 49: // Default Background - pen.Background(nil) - case 58: // Set underline color - if c := readColor(&i, params); c != nil { - pen.UnderlineColor(c) - } - case 59: // Default underline color - pen.UnderlineColor(nil) - case 90, 91, 92, 93, 94, 95, 96, 97: // Set bright foreground - pen.Foreground(ansi.BrightBlack + ansi.BasicColor(param-90)) //nolint:gosec - case 100, 101, 102, 103, 104, 105, 106, 107: // Set bright background - pen.Background(ansi.BrightBlack + ansi.BasicColor(param-100)) //nolint:gosec - } - } -} - -// handleHyperlinks handles hyperlink escape sequences. -func handleHyperlinks(p *ansi.Parser, link *Link) { - params := bytes.Split(p.Data(), []byte{';'}) - if len(params) != 3 { - return - } - for _, param := range bytes.Split(params[1], []byte{':'}) { - if bytes.HasPrefix(param, []byte("id=")) { - link.URLID = string(param) - } - } - link.URL = string(params[2]) -} diff --git a/cellbuf/window.go b/cellbuf/window.go index d2900ff8..c47de34b 100644 --- a/cellbuf/window.go +++ b/cellbuf/window.go @@ -2,1495 +2,443 @@ package cellbuf import ( "bytes" - "errors" - "io" - "os" + "fmt" + "image/color" "strings" - "sync" + "unicode/utf8" - "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/term" ) -// ErrInvalidDimensions is returned when the dimensions of a window are invalid -// for the operation. -var ErrInvalidDimensions = errors.New("invalid dimensions") - -// 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) +// Window represents a [Screen] 2D window. +type Window struct { + s *Screen // the screen this window belongs to + cur Cursor // the current cursor pos, style, and link + method Method // the method to use for calculating the width of the cells + x, y int // the starting position of the window + w, h int // the width and height of the window } -// 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. -func relativeCursorMove(s *Screen, fx, fy, tx, ty int, overwrite bool) string { - var seq strings.Builder - - width, height := s.newbuf.Width(), s.newbuf.Height() - if ty != fy { - var yseq string - if s.xtermLike && !s.opts.RelativeCursor { - yseq = ansi.VerticalPositionAbsolute(ty + 1) - } - - // OPTIM: Use [ansi.LF] and [ansi.ReverseIndex] as optimizations. - - if ty > fy { - n := ty - fy - if cud := ansi.CursorDown(n); yseq == "" || len(cud) < len(yseq) { - yseq = cud - } - shouldScroll := !s.opts.AltScreen - if lf := strings.Repeat("\n", n); shouldScroll || (fy+n < height && len(lf) < len(yseq)) { - // TODO: Ensure we're not unintentionally scrolling the screen down. - yseq = lf - } - } 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 { - var xseq string - if s.xtermLike && !s.opts.RelativeCursor { - xseq = ansi.HorizontalPositionAbsolute(tx + 1) - } - - if tx > fx { - n := tx - fx - if s.opts.HardTabs { - 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 >= width-1 { - break - } - } - - if tabs > 0 { - cht := ansi.CursorHorizontalForwardTab(tabs) - tab := strings.Repeat("\t", tabs) - if false && s.xtermLike && 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 := s.newbuf.Cell(fx+i, ty) - if cell != nil { - 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 := s.newbuf.Cell(fx+i, ty) - if cell != nil { - ovw += cell.String() - i += cell.Width - 1 - } else { - ovw += " " - } - } - } - - if overwrite && len(ovw) < len(xseq) { - xseq = ovw - } - } else if tx < fx { - n := fx - tx - if s.opts.HardTabs && s.xtermLike { - // 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 bs := strings.Repeat("\b", n); xseq == "" || len(bs) < len(xseq) { - xseq = bs - } - - if cub := ansi.CursorBackward(n); len(cub) < len(xseq) { - xseq = cub - } - } - - seq.WriteString(xseq) - } - - return seq.String() +// NewWindow creates a new window. Note that the window is not +// bound to the screen until it is used to draw something. +func (s *Screen) NewWindow(x, y, w, h int) *Window { + c := new(Window) + c.s = s + c.x, c.y = x, y + c.w, c.h = w, h + return c } -// 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. -func moveCursor(s *Screen, x, y int, overwrite bool) (seq string) { - fx, fy := s.cur.X, s.cur.Y - - if !s.opts.RelativeCursor { - // Method #0: Use [ansi.CUP] if the distance is long. - seq = ansi.CursorPosition(x+1, y+1) - if fx == -1 || fy == -1 || notLocal(s.newbuf.Width(), fx, fy, x, y) { - return - } - } - - // Method #1: Use local movement sequences. - nseq := relativeCursorMove(s, fx, fy, x, y, overwrite) - if len(seq) == 0 || len(nseq) < len(seq) { - seq = nseq - } - - // Method #2: Use [ansi.CR] and local movement sequences. - nseq = "\r" + relativeCursorMove(s, 0, fy, x, y, overwrite) - if len(nseq) < len(seq) { - seq = nseq - } - - if !s.opts.RelativeCursor { - // Method #3: Use [ansi.CursorHomePosition] and local movement sequences. - nseq = ansi.CursorHomePosition + relativeCursorMove(s, 0, 0, x, y, overwrite) - if len(nseq) < len(seq) { - seq = nseq - } - } - - return +// DefaultWindow creates a new window that covers the whole screen. +func (s *Screen) DefaultWindow() *Window { + return s.NewWindow(0, 0, s.Width(), s.Height()) } -// moveCursor moves the cursor to the specified position. -func (s *Screen) moveCursor(x, y int, overwrite bool) { - s.buf.WriteString(moveCursor(s, x, y, overwrite)) //nolint:errcheck - s.cur.X, s.cur.Y = x, y +// NewWindow creates a new sub-window. Note that the window is not bound to +// the screen until it is used to draw something. +func (c *Window) NewWindow(x, y, w, h int) *Window { + return c.s.NewWindow(c.x+x, c.y+y, w, h) } -func (s *Screen) move(x, y int) { - width, height := s.newbuf.Width(), s.newbuf.Height() - if x >= width { - // Handle autowrap - y += (x / width) - x %= width - } - - // Disable styles if there's any - // TODO: Do we need this? It seems like it's only needed when used with - // alternate character sets which we don't support. - // var pen Style - // if !s.cur.Style.Empty() { - // pen = s.cur.Style - // s.buf.WriteString(ansi.ResetStyle) //nolint:errcheck - // } - - if s.cur.X >= width { - l := (s.cur.X + 1) / width - - s.cur.Y += l - if 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 s.cur.Y > height-1 { - s.cur.Y = height - 1 - } - if y > height-1 { - y = height - 1 - } - - // We set the new cursor in [Screen.moveCursor]. - s.moveCursor(x, y, true) // Overwrite cells if possible - - // TODO: Do we need this? It seems like it's only needed when used with - // alternate character sets which we don't support. - // if !pen.Empty() { - // s.buf.WriteString(pen.Sequence()) //nolint:errcheck - // } +// Bounds returns the bounds of the window. +func (c *Window) Bounds() Rectangle { + return Rect(c.x, c.y, c.w, c.h) } -// Cursor represents a terminal Cursor. -type Cursor struct { - Style Style - Link Link - Position +// Width returns the width of the window. +func (c *Window) Width() int { + return c.w } -// ScreenOptions are options for the screen. -type ScreenOptions struct { - // Term is the terminal type to use when writing to the screen. When empty, - // `$TERM` is used from [os.Getenv]. - Term string - // Width is the desired width of the screen. When 0, the width is - // automatically determined using the terminal size. - Width int - // Height is the desired height of the screen. When 0, the height is - // automatically determined using the terminal size. - Height int - // Profile is the color profile to use when writing to the screen. - Profile colorprofile.Profile - // RelativeCursor is whether to use relative cursor movements. This is - // useful when alt-screen is not used or when using inline mode. - RelativeCursor bool - // AltScreen is whether to use the alternate screen buffer. - AltScreen bool - // ShowCursor is whether to show the cursor. - ShowCursor bool - // HardTabs is whether to use hard tabs to optimize cursor movements. - HardTabs bool +// Height returns the height of the window. +func (c *Window) Height() int { + return c.h } -// 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 +// X returns the x position of the window. +func (c *Window) X() int { + return c.x } -// Screen represents the terminal screen. -type Screen struct { - w io.Writer - buf *bytes.Buffer // buffer for writing to the screen - curbuf *Buffer // the current buffer - newbuf *Buffer // the new buffer - tabs *TabStops - touch map[int]lineData - queueAbove []string // the queue of strings to write above the screen - 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 - opts ScreenOptions - pos Position // the position of the cursor after the last render - mu sync.Mutex - altScreenMode bool // whether alternate screen mode is enabled - cursorHidden bool // whether text cursor mode is enabled - clear bool // whether to force clear the screen - xtermLike bool // whether to use xterm-like optimizations, otherwise, it uses vt100 only +// Y returns the y position of the window. +func (c *Window) Y() int { + return c.y } -var _ Window = &Screen{} - -// UseHardTabs sets whether to use hard tabs to optimize cursor movements. -func (s *Screen) UseHardTabs(v bool) { - s.opts.HardTabs = v +// CellAt returns the cell at the given position. If the position is out of +// bounds, it will return nil. +func (c *Window) CellAt(x, y int) *Cell { + if !Pos(x, y).In(c.Bounds()) { + return nil + } + return c.s.Cell(c.x+x, c.y+y) } -// SetColorProfile sets the color profile to use when writing to the screen. -func (s *Screen) SetColorProfile(p colorprofile.Profile) { - s.opts.Profile = p +// SetMethod sets the method to use for calculating the width of the cells. +// The default method is [WcWidth]. +func (c *Window) SetMethod(method Method) { + c.method = method } -// SetRelativeCursor sets whether to use relative cursor movements. -func (s *Screen) SetRelativeCursor(v bool) { - s.opts.RelativeCursor = v +// SetForegroundColor sets the foreground color of the window. Use `nil` to +// use the default color. +func (c *Window) SetForegroundColor(color color.Color) { + c.cur.Style.Fg = color } -// EnterAltScreen enters the alternate screen buffer. -func (s *Screen) EnterAltScreen() { - s.opts.AltScreen = true - s.clear = true - s.saved = s.cur +// SetBackgroundColor sets the background color of the window. Use `nil` to +// use the default color. +func (c *Window) SetBackgroundColor(color color.Color) { + c.cur.Style.Bg = color } -// ExitAltScreen exits the alternate screen buffer. -func (s *Screen) ExitAltScreen() { - s.opts.AltScreen = false - s.clear = true - s.cur = s.saved +// SetAttributes sets the text attributes of the window. +func (c *Window) SetAttributes(attrs AttrMask) { + c.cur.Style.Attrs = attrs } -// ShowCursor shows the cursor. -func (s *Screen) ShowCursor() { - s.opts.ShowCursor = true +// EnableAttributes enables the given text attributes of the window. Use zero +// to disable all attributes. +func (c *Window) EnableAttributes(attrs AttrMask) { + c.cur.Style.Attrs |= attrs } -// HideCursor hides the cursor. -func (s *Screen) HideCursor() { - s.opts.ShowCursor = false +// DisableAttributes disables the given text attributes of the window. +func (c *Window) DisableAttributes(attrs AttrMask) { + c.cur.Style.Attrs &^= attrs } -// Bounds implements Window. -func (s *Screen) Bounds() Rectangle { - // Always return the new buffer bounds. - return s.newbuf.Bounds() +// SetUnderlineStyle sets the underline attribute of the window. Use +// [NoUnderline] or zero to remove the underline attribute. +func (c *Window) SetUnderlineStyle(u UnderlineStyle) { + c.cur.Style.UlStyle = u } -// Cell implements Window. -// TODO: Rename this to [CellAt] -func (s *Screen) Cell(x int, y int) *Cell { - return s.newbuf.Cell(x, y) +// SetUnderlineColor sets the underline color of the window. Use `nil` to use +// the default color. +func (c *Window) SetUnderlineColor(color color.Color) { + c.cur.Style.Ul = color } -// Clear implements Window. -func (s *Screen) Clear() bool { - s.clear = true - return s.ClearRect(s.newbuf.Bounds()) +// SetHyperlink sets the hyperlink of the window. Use an empty string to +// remove the hyperlink. Use opts to set the hyperlink options such as `id=123` +// etc. +func (c *Window) SetHyperlink(link string, opts ...string) { + c.cur.Link = Link{ + URL: link, + URLID: strings.Join(opts, ":"), + } } -// ClearRect implements Window. -func (s *Screen) ClearRect(r Rectangle) bool { - return s.FillRect(nil, r) +// ResetHyperlink resets the hyperlink of the window. +func (c *Window) ResetHyperlink() { + c.cur.Link = Link{} } -// Draw implements Window. -// TODO: Rename this to [SetCell] -func (s *Screen) Draw(x int, y int, cell *Cell) (v bool) { - s.mu.Lock() - defer s.mu.Unlock() - cellWidth := 1 - if cell != nil { - cellWidth = cell.Width - } - if prev := s.curbuf.Cell(x, y); !cellEqual(prev, cell) { - chg, ok := s.touch[y] - if !ok { - chg = lineData{firstCell: x, lastCell: x + cellWidth - 1} - } else { - chg.firstCell = min(chg.firstCell, x) - chg.lastCell = max(chg.lastCell, x+cellWidth-1) - } - s.touch[y] = chg - } - - return s.newbuf.Draw(x, y, cell) +// Reset resets the cursor position, styles and attributes. +func (c *Window) Reset() { + c.cur = Cursor{} } -// Fill implements Window. -func (s *Screen) Fill(cell *Cell) bool { - return s.FillRect(cell, s.newbuf.Bounds()) +// Resize resizes the window to the given width and height. If the new size is +// out of bounds, it will do nothing. +func (c *Window) Resize(w, h int) { + c.w, c.h = w, h } -// FillRect implements Window. -func (s *Screen) FillRect(cell *Cell, r Rectangle) bool { - s.mu.Lock() - defer s.mu.Unlock() - s.newbuf.FillRect(cell, r) - for i := r.Min.Y; i < r.Max.Y; i++ { - s.touch[i] = lineData{firstCell: r.Min.X, lastCell: r.Max.X - 1} - } - return true +// SetContent clears the window with blank cells, and draws the given string. +func (c *Window) SetContent(s string) { + // Replace all "\n" with "\r\n" to ensure the cursor is reset to the start + // of the line. Make sure we don't replace "\r\n" with "\r\r\n". + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\n", "\r\n") + c.Clear() + c.MoveTo(0, 0) + c.Print(s) } -// isXtermLike returns whether the terminal is xterm-like. This means that the -// terminal supports ECMA-48 and ANSI X3.64 escape sequences. -// TODO: Should this be a lookup table into each $TERM terminfo database? Like -// we could keep a map of ANSI escape sequence to terminfo capability name and -// check if the database supports the escape sequence. Instead of keeping a -// list of terminal names here. -func isXtermLike(termtype string) (v bool) { - parts := strings.Split(termtype, "-") - if len(parts) == 0 { - return - } - - switch parts[0] { - case - "alacritty", - "contour", - "foot", - "ghostty", - "kitty", - "linux", - "rio", - "screen", - "st", - "tmux", - "wezterm", - "xterm": - v = true - } - - return +// Fill fills the window with the given cell and resets the cursor position, +// styles and attributes. +func (c *Window) Fill(cell *Cell) bool { + return c.s.FillRect(cell, c.Bounds()) } -// NewScreen creates a new Screen. -func NewScreen(w io.Writer, opts *ScreenOptions) (s *Screen) { - s = new(Screen) - s.w = w - if opts != nil { - s.opts = *opts - } - - if s.opts.Term == "" { - s.opts.Term = os.Getenv("TERM") +// FillString fills the window with the given string and resets the cursor +// position, styles and attributes. +func (c *Window) FillString(s string) (v bool) { + switch c.method { + case WcWidth: + v = c.Fill(NewCellString(s)) + case GraphemeWidth: + v = c.Fill(NewGraphemeCell(s)) } - - width, height := s.opts.Width, s.opts.Height - if width <= 0 || height <= 0 { - if f, ok := w.(term.File); ok { - width, height, _ = term.GetSize(f.Fd()) - } - } - - s.buf = new(bytes.Buffer) - s.xtermLike = isXtermLike(s.opts.Term) - s.curbuf = NewBuffer(width, height) - s.newbuf = NewBuffer(width, height) - s.reset() - return } -// Width returns the width of the screen. -func (s *Screen) Width() int { - return s.opts.Width -} - -// Height returns the height of the screen. -func (s *Screen) Height() int { - return s.opts.Height +// Clear clears the window with blank cells and resets the cursor position, +// styles and attributes. +func (c *Window) Clear() bool { + return c.s.ClearRect(c.Bounds()) } -// cellEqual returns whether the two cells are equal. A nil cell is considered -// a [BlankCell]. -func cellEqual(a, b *Cell) bool { - if a == nil { - a = &BlankCell - } - if b == nil { - b = &BlankCell +// MoveTo moves the cursor to the given position. If the position is out of +// bounds, it will do nothing. +func (c *Window) MoveTo(x, y int) (v bool) { + if !Pos(c.x+x, c.y+y).In(c.Bounds()) { + return } - return a.Equal(b) + c.cur.X, c.cur.Y = x, y + return c.s.MoveTo(c.x+x, c.y+y) } -// putCell draws a cell at the current cursor position. -func (s *Screen) putCell(cell *Cell) { - width, height := s.newbuf.Width(), s.newbuf.Height() - if s.opts.AltScreen && s.cur.X == width-1 && s.cur.Y == height-1 { - s.putCellLR(cell) - } else { - s.putAttrCell(cell) - } - - if s.cur.X >= width { - s.wrapCursor() +// Print prints the given string at the current cursor position. If the cursor +// is out of bounds, it will do nothing. +func (c *Window) Print(format string, v ...interface{}) { + if len(v) > 0 { + format = fmt.Sprintf(format, v...) } + c.drawString(format, c.cur.X, c.cur.Y, defaultDrawOpts) } -// wrapCursor wraps the cursor to the next line. -func (s *Screen) wrapCursor() { - s.cur.X = 0 - s.cur.Y++ +// PrintTruncate draws a string starting at the given position and +// truncates the string with the given tail if necessary. +func (c *Window) PrintTruncate(s string, tail string) { + c.drawString(s, c.cur.X, c.cur.Y, &drawOpts{tail: tail, truncate: true}) } -func (s *Screen) putAttrCell(cell *Cell) { - if cell != nil && cell.Width <= 0 { +// SetCell sets a cell at the given position. If the position is out of bounds, +// it will do nothing. +func (c *Window) SetCell(x, y int, cell *Cell) (v bool) { + pos := Pos(c.x+x, c.y+y) + if !pos.In(c.Bounds()) { return } - - if cell == nil { - cell = s.clearBlank() - } - - // if s.cur.X >= s.newbuf.Width() { - // // TODO: Properly handle autowrap. - // s.wrapCursor() - // } - - s.updatePen(cell) - s.buf.WriteString(cell.String()) //nolint:errcheck - s.cur.X += cell.Width - - if s.cur.X >= s.newbuf.Width() { - // TODO: Properly handle autowrap. This is a hack. - s.cur.X = s.newbuf.Width() - 1 - } + return c.s.SetCell(pos.X, pos.Y, cell) } -// putCellLR draws a cell at the lower right corner of the screen. -func (s *Screen) putCellLR(cell *Cell) { - // Optimize for the lower right corner cell. - curX := s.cur.X - s.buf.WriteString(ansi.ResetAutoWrapMode) //nolint:errcheck - s.putAttrCell(cell) - s.cur.X = curX - s.buf.WriteString(ansi.SetAutoWrapMode) //nolint:errcheck +// drawOpts represents the options for drawing a string. +type drawOpts struct { + tail string // the tail to append if the string is truncated, empty by default to crop + truncate bool // truncate the string if it's too long } -// updatePen updates the cursor pen styles. -func (s *Screen) updatePen(cell *Cell) { - if cell == nil { - cell = &BlankCell - } +var defaultDrawOpts = &drawOpts{} - style := cell.Style - link := cell.Link - if s.opts.Profile != 0 { - // Downsample colors to the given color profile. - style = ConvertStyle(style, s.opts.Profile) - link = ConvertLink(link, s.opts.Profile) +// drawString draws a string starting at the given position. +func (c *Window) drawString(s string, x, y int, opts *drawOpts) { + if opts == nil { + opts = defaultDrawOpts } - if !style.Equal(s.cur.Style) { - seq := style.DiffSequence(s.cur.Style) - if style.Empty() && len(seq) > len(ansi.ResetStyle) { - seq = ansi.ResetStyle - } - s.buf.WriteString(seq) //nolint:errcheck - s.cur.Style = style - } - if !link.Equal(s.cur.Link) { - s.buf.WriteString(ansi.SetHyperlink(link.URL, link.URLID)) //nolint:errcheck - s.cur.Link = link + wrapCursor := func() { + // Wrap the string to the width of the window + x = 0 + y++ } -} - -// emitRange emits a range of cells to the buffer. It it equivalent to calling -// [Screen.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 *Screen) emitRange(line Line, n int) (eoi bool) { - for n > 0 { - var count int - for n > 1 && !cellEqual(line.At(0), line.At(1)) { - s.putCell(line.At(0)) - line = line[1:] - n-- - } - cell0 := line[0] - if n == 1 { - s.putCell(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 s.xtermLike && count > len(ech)+len(cup) && cell0 != nil && cell0.Clear() { - s.updatePen(cell0) - s.buf.WriteString(ech) //nolint:errcheck - - // If this is the last cell, we don't need to move the cursor. - if count < n { - s.move(s.cur.X+count, s.cur.Y) - } else { - return true // cursor in the middle - } - } else if s.xtermLike && count > len(rep) && - (cell0 == nil || (len(cell0.Comb) == 0 && cell0.Rune < 256)) { - // 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 >= s.newbuf.Width() - repCount := count - if wrapPossible { - repCount-- - } + p := ansi.GetParser() + defer ansi.PutParser(p) - s.updatePen(cell0) - s.putCell(cell0) - repCount-- // cell0 is a single width cell ASCII character - - s.buf.WriteString(ansi.RepeatPreviousCharacter(repCount)) //nolint:errcheck - s.cur.X += repCount - if wrapPossible { - s.putCell(cell0) - } + var tail Cell + if opts.truncate && len(opts.tail) > 0 { + if c.method == WcWidth { + tail = *NewCellString(opts.tail) } else { - for i := 0; i < count; i++ { - s.putCell(line.At(i)) - } + tail = *NewGraphemeCell(opts.tail) } - - line = line[clamp(count, 0, len(line)):] - n -= count } - return -} + var state byte + for len(s) > 0 { + seq, width, n, newState := ansi.DecodeSequence(s, state, p) -// 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 *Screen) putRange(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 { - var j, same int - for j, same = start, 0; j <= end; j++ { - oldCell, newCell := oldLine.At(j), newLine.At(j) - if same == 0 && oldCell != nil && oldCell.Empty() { - continue - } - if cellEqual(oldCell, newCell) { - same++ - } else { - if same > end-start { - s.emitRange(newLine[start:], j-same-start) - s.move(y, j) - start = j - } - same = 0 - } - } - - i := s.emitRange(newLine[start:], j-same-start) + var cell *Cell + switch width { + case 1, 2, 3, 4: // wide cells can go up to 4 cells wide + switch c.method { + case WcWidth: + cell = NewCellString(seq) - // Always return 1 for the next [Screen.move] after a [Screen.putRange] if - // we found identical characters at end of interval. - if same == 0 { - return i - } - return true - } - - return s.emitRange(newLine[start:], end-start+1) -} - -// clearToEnd clears the screen from the current cursor position to the end of -// line. -func (s *Screen) clearToEnd(blank *Cell, force bool) { - if s.cur.Y >= 0 { - curline := s.curbuf.Line(s.cur.Y) - for j := s.cur.X; j < s.curbuf.Width(); j++ { - if j >= 0 { - c := curline.At(j) - if !cellEqual(c, blank) { - curline.Set(j, blank) - force = true + // We're breaking the grapheme to respect wcwidth's behavior + // while keeping combining characters together. + n = utf8.RuneLen(cell.Rune) + for _, c := range cell.Comb { + n += utf8.RuneLen(c) } - } - } - } + newState = 0 - if force { - s.updatePen(blank) - count := s.newbuf.Width() - s.cur.X - if s.el0Cost() <= count { - s.buf.WriteString(ansi.EraseLineRight) //nolint:errcheck) - } else { - for i := 0; i < count; i++ { - s.putCell(blank) + case GraphemeWidth: + // [ansi.DecodeSequence] already handles grapheme clusters + cell = newGraphemeCell(seq, width) } - } - } -} - -// clearBlank returns a blank cell based on the current cursor background color. -func (s *Screen) clearBlank() *Cell { - c := BlankCell - if !s.cur.Style.Empty() || !s.cur.Link.Empty() { - c.Style = s.cur.Style - c.Link = s.cur.Link - } - return &c -} - -// insertCells inserts the count cells pointed by the given line at the current -// cursor position. -func (s *Screen) insertCells(line Line, count int) { - if s.xtermLike { - // Use [ansi.ICH] as an optimization. - s.buf.WriteString(ansi.InsertCharacter(count)) //nolint:errcheck - } else { - // Otherwise, use [ansi.IRM] mode. - s.buf.WriteString(ansi.SetInsertReplaceMode) //nolint:errcheck - } - - for i := 0; count > 0; i++ { - s.putAttrCell(line[i]) - count-- - } - - if !s.xtermLike { - s.buf.WriteString(ansi.ResetInsertReplaceMode) //nolint:errcheck - } -} - -// 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 *Screen) el0Cost() int { - if s.xtermLike { - return 0 - } - return len(ansi.EraseLineRight) -} -// transformLine transforms the given line in the current window to the -// corresponding line in the new window. It uses [ansi.ICH] and [ansi.DCH] to -// insert or delete characters. -func (s *Screen) transformLine(y int) { - var firstCell, oLastCell, nLastCell int // first, old last, new last index - oldLine := s.curbuf.Line(y) - newLine := s.newbuf.Line(y) - - // Find the first changed cell in the line - var lineChanged bool - for i := 0; i < s.newbuf.Width(); i++ { - if !cellEqual(newLine.At(i), oldLine.At(i)) { - lineChanged = true - break - } - } - - const ceolStandoutGlitch = false - if ceolStandoutGlitch && lineChanged { - s.move(0, y) - s.clearToEnd(nil, false) - s.putRange(oldLine, newLine, y, 0, s.newbuf.Width()-1) - } else { - blank := newLine.At(0) - - // It might be cheaper to clear leading spaces with [ansi.EL] 1 i.e. - // [ansi.EraseLineLeft]. - if blank == nil || blank.Clear() { - var oFirstCell, nFirstCell int - for oFirstCell = 0; oFirstCell < s.curbuf.Width(); oFirstCell++ { - if !cellEqual(oldLine.At(oFirstCell), blank) { + if !opts.truncate && x >= c.w { + // Auto wrap the cursor. + wrapCursor() + if y >= c.h { break } - } - for nFirstCell = 0; nFirstCell < s.newbuf.Width(); nFirstCell++ { - if !cellEqual(newLine.At(nFirstCell), blank) { + } else if opts.truncate && x+width > c.w-tail.Width { + if !Pos(x, y).In(c.Bounds()) { break } - } - - if nFirstCell == oFirstCell { - firstCell = nFirstCell - - // Find the first differing cell - for firstCell < s.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 >= s.newbuf.Width() { - s.move(0, y) - s.updatePen(blank) - s.buf.WriteString(ansi.EraseLineRight) //nolint:errcheck - } else { - s.move(nFirstCell-1, y) - s.updatePen(blank) - s.buf.WriteString(ansi.EraseLineLeft) //nolint:errcheck - } - - for firstCell < nFirstCell { - oldLine.Set(firstCell, blank) - firstCell++ - } - } - } - } else { - // Find the first differing cell - for firstCell < s.newbuf.Width() && cellEqual(newLine.At(firstCell), oldLine.At(firstCell)) { - firstCell++ - } - } - - // If we didn't find one, we're done - if firstCell >= s.newbuf.Width() { - return - } - - blank = newLine.At(s.newbuf.Width() - 1) - if blank != nil && !blank.Clear() { - // Find the last differing cell - nLastCell = s.newbuf.Width() - 1 - for nLastCell > firstCell && cellEqual(newLine.At(nLastCell), oldLine.At(nLastCell)) { - nLastCell-- - } - if nLastCell >= firstCell { - s.move(firstCell, y) - s.putRange(oldLine, newLine, y, firstCell, nLastCell) - copy(oldLine[firstCell:], newLine[firstCell:]) + // Truncate the string and append the tail if any. + cell := tail + cell.Style = c.cur.Style + cell.Link = c.cur.Link + c.SetCell(x, y, &cell) + break } - return - } - - // Find last non-blank cell in the old line. - oLastCell = s.curbuf.Width() - 1 - for oLastCell > firstCell && cellEqual(oldLine.At(oLastCell), blank) { - oLastCell-- - } + cell.Style = c.cur.Style + cell.Link = c.cur.Link - // Find last non-blank cell in the new line. - nLastCell = s.newbuf.Width() - 1 - for nLastCell > firstCell && cellEqual(newLine.At(nLastCell), blank) { - nLastCell-- - } + // NOTE: [Window.SetCell] will handle out of bounds positions. + c.SetCell(x, y, cell) //nolint:errcheck - if nLastCell == firstCell && s.el0Cost() < oLastCell-nLastCell { - s.move(firstCell, y) - if !cellEqual(newLine.At(firstCell), blank) { - s.putCell(newLine.At(firstCell)) - } - s.clearToEnd(blank, false) - } else if nLastCell != oLastCell && - !cellEqual(newLine.At(nLastCell), oldLine.At(oLastCell)) { - s.move(firstCell, y) - if oLastCell-nLastCell > s.el0Cost() { - if s.putRange(oldLine, newLine, y, firstCell, nLastCell) { - s.move(nLastCell+1, y) + // Advance the cursor and line width + x += cell.Width + default: + // Valid sequences always have a non-zero Cmd. + // TODO: Handle cursor movement and other sequences + switch { + case ansi.HasCsiPrefix(seq) && p.Cmd() != 0: + switch p.Cmd() { + case 'm': // SGR - Select Graphic Rendition + handleSgr(p, &c.cur.Style) } - s.clearToEnd(blank, false) - } else { - n := max(nLastCell, oLastCell) - s.putRange(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 + case ansi.HasOscPrefix(seq) && p.Cmd() != 0: + switch p.Cmd() { + case 8: // Hyperlinks + handleHyperlinks(p, &c.cur.Link) } - nLastCell-- - oLastCell-- - if nLastCell == -1 || oLastCell == -1 { - break - } - } - - n := min(oLastCell, nLastCell) - if n >= firstCell { - s.move(firstCell, y) - s.putRange(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 == nil || !wide.Empty() { - break - } - n-- - oLastCell-- - } - } else if n >= firstCell && newLine.At(n) != nil && newLine.At(n).Width > 1 { - next := newLine.At(n + 1) - for next != nil && next.Empty() { - n++ - oLastCell++ - } - } - - s.move(n+1, y) - ichCost := 3 + nLastCell - oLastCell - if s.xtermLike && (nLastCell < nLastNonBlank || ichCost > (m-n)) { - s.putRange(oldLine, newLine, y, n+1, m) - } else { - s.insertCells(newLine[n+1:], nLastCell-oLastCell) - } - } else if oLastCell > nLastCell { - s.move(n+1, y) - dchCost := 3 + oLastCell - nLastCell - if dchCost > len(ansi.EraseLineRight)+nLastNonBlank-(n+1) { - if s.putRange(oldLine, newLine, y, n+1, nLastNonBlank) { - s.move(nLastNonBlank+1, y) - } - s.clearToEnd(blank, false) - } else { - s.updatePen(blank) - s.deleteCells(oLastCell - nLastCell) + case ansi.Equal(seq, "\n"): + if y+1 < c.y+c.h { + y++ } + case ansi.Equal(seq, "\r"): + x = 0 } } - } - - // Update the old line with the new line - if s.newbuf.Width() >= firstCell && len(oldLine) != 0 { - copy(oldLine[firstCell:], newLine[firstCell:]) - } -} - -// 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 *Screen) 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)) //nolint:errcheck -} -// clearToBottom clears the screen from the current cursor position to the end -// of the screen. -func (s *Screen) clearToBottom(blank *Cell) { - row, col := s.cur.Y, s.cur.X - if row < 0 { - row = 0 - } - if col < 0 { - col = 0 + // Advance the state and data + state = newState + s = s[n:] } - s.updatePen(blank) - s.buf.WriteString(ansi.EraseScreenBelow) //nolint:errcheck - s.curbuf.ClearRect(Rect(col, row, s.curbuf.Width(), row+1)) - s.curbuf.ClearRect(Rect(0, row+1, s.curbuf.Width(), s.curbuf.Height())) + c.cur.X, c.cur.Y = x, y } -// 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 *Screen) clearBottom(total int, force bool) (top int) { - top = total - if total <= 0 { +// handleSgr handles Select Graphic Rendition (SGR) escape sequences. +func handleSgr(p *ansi.Parser, pen *Style) { + params := p.Params() + if len(params) == 0 { + pen.Reset() return } - last := min(s.curbuf.Width(), s.newbuf.Width()) - blank := s.clearBlank() - canClearWithBlank := blank == nil || blank.Clear() - - if canClearWithBlank || force { - var row int - for row = total - 1; row >= 0; row-- { - var col int - var ok bool - for col, ok = 0, true; ok && col < last; col++ { - ok = cellEqual(s.newbuf.Cell(col, row), blank) - } - if !ok { - break - } - - for col = 0; ok && col < last; col++ { - ok = cellEqual(s.curbuf.Cell(col, row), blank) - } - if !ok { - top = row - } - } - - if force || top < total { - s.moveCursor(0, top, false) - s.clearToBottom(blank) - if !s.opts.AltScreen { - // Move to the last line of the screen - s.moveCursor(0, s.newbuf.Height()-1, false) - } - // TODO: Line hashing - } - } - - return -} - -// clearScreen clears the screen and put cursor at home. -func (s *Screen) clearScreen(blank *Cell) { - s.updatePen(blank) - s.buf.WriteString(ansi.CursorHomePosition) //nolint:errcheck - s.buf.WriteString(ansi.EraseEntireScreen) //nolint:errcheck - s.cur.X, s.cur.Y = 0, 0 - s.curbuf.Fill(blank) -} - -// clearBelow clears everything below the screen. -func (s *Screen) clearBelow(blank *Cell, row int) { - s.updatePen(blank) - s.moveCursor(0, row, false) - s.clearToBottom(blank) - s.cur.X, s.cur.Y = 0, row - s.curbuf.FillRect(blank, Rect(0, row, s.curbuf.Width(), s.curbuf.Height())) -} - -// clearUpdate forces a screen redraw. -func (s *Screen) clearUpdate(partial bool) { - blank := s.clearBlank() - var nonEmpty int - if s.opts.AltScreen { - nonEmpty = min(s.curbuf.Height(), s.newbuf.Height()) - s.clearScreen(blank) - } else { - nonEmpty = s.newbuf.Height() - s.clearBelow(blank, 0) - } - nonEmpty = s.clearBottom(nonEmpty, partial) - for i := 0; i < nonEmpty; i++ { - s.transformLine(i) - } -} - -// Render implements Window. -func (s *Screen) Render() { - s.mu.Lock() - s.render() - // Write the buffer - if s.buf.Len() > 0 { - s.w.Write(s.buf.Bytes()) //nolint:errcheck - } - s.buf.Reset() - s.mu.Unlock() -} - -func (s *Screen) render() { - // Do we need to render anything? - if s.opts.AltScreen == s.altScreenMode && - !s.opts.ShowCursor == s.cursorHidden && - !s.clear && - len(s.touch) == 0 && - len(s.queueAbove) == 0 && - s.pos == undefinedPos { + for i := 0; i < len(params); i++ { + r := params[i] + param, hasMore := r.Param(0), r.HasMore() // Are there more subparameters i.e. separated by ":"? + switch param { + case 0: // Reset + pen.Reset() + case 1: // Bold + pen.Bold(true) + case 2: // Dim/Faint + pen.Faint(true) + case 3: // Italic + pen.Italic(true) + case 4: // Underline + if hasMore { // Only accept subparameters i.e. separated by ":" + nextParam := params[i+1].Param(0) + switch nextParam { + case 0, 1, 2, 3, 4, 5: + i++ + switch nextParam { + case 0: // No Underline + pen.UnderlineStyle(NoUnderline) + case 1: // Single Underline + pen.UnderlineStyle(SingleUnderline) + case 2: // Double Underline + pen.UnderlineStyle(DoubleUnderline) + case 3: // Curly Underline + pen.UnderlineStyle(CurlyUnderline) + case 4: // Dotted Underline + pen.UnderlineStyle(DottedUnderline) + case 5: // Dashed Underline + pen.UnderlineStyle(DashedUnderline) + } + } + } else { + // Single Underline + pen.Underline(true) + } + case 5: // Slow Blink + pen.SlowBlink(true) + case 6: // Rapid Blink + pen.RapidBlink(true) + case 7: // Reverse + pen.Reverse(true) + case 8: // Conceal + pen.Conceal(true) + case 9: // Crossed-out/Strikethrough + pen.Strikethrough(true) + case 22: // Normal Intensity (not bold or faint) + pen.Bold(false).Faint(false) + case 23: // Not italic, not Fraktur + pen.Italic(false) + case 24: // Not underlined + pen.Underline(false) + case 25: // Blink off + pen.SlowBlink(false).RapidBlink(false) + case 27: // Positive (not reverse) + pen.Reverse(false) + case 28: // Reveal + pen.Conceal(false) + case 29: // Not crossed out + pen.Strikethrough(false) + case 30, 31, 32, 33, 34, 35, 36, 37: // Set foreground + pen.Foreground(ansi.Black + ansi.BasicColor(param-30)) //nolint:gosec + case 38: // Set foreground 256 or truecolor + if c := readColor(&i, params); c != nil { + pen.Foreground(c) + } + case 39: // Default foreground + pen.Foreground(nil) + case 40, 41, 42, 43, 44, 45, 46, 47: // Set background + pen.Background(ansi.Black + ansi.BasicColor(param-40)) //nolint:gosec + case 48: // Set background 256 or truecolor + if c := readColor(&i, params); c != nil { + pen.Background(c) + } + case 49: // Default Background + pen.Background(nil) + case 58: // Set underline color + if c := readColor(&i, params); c != nil { + pen.UnderlineColor(c) + } + case 59: // Default underline color + pen.UnderlineColor(nil) + case 90, 91, 92, 93, 94, 95, 96, 97: // Set bright foreground + pen.Foreground(ansi.BrightBlack + ansi.BasicColor(param-90)) //nolint:gosec + case 100, 101, 102, 103, 104, 105, 106, 107: // Set bright background + pen.Background(ansi.BrightBlack + ansi.BasicColor(param-100)) //nolint:gosec + } + } +} + +// handleHyperlinks handles hyperlink escape sequences. +func handleHyperlinks(p *ansi.Parser, link *Link) { + params := bytes.Split(p.Data(), []byte{';'}) + if len(params) != 3 { return } - - // 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 - // } - - // Do we need alt-screen mode? - if s.opts.AltScreen != s.altScreenMode { - if s.opts.AltScreen { - s.buf.WriteString(ansi.SetAltScreenSaveCursorMode) - } else { - s.buf.WriteString(ansi.ResetAltScreenSaveCursorMode) - } - s.altScreenMode = s.opts.AltScreen - } - - // Do we need text cursor mode? - if !s.opts.ShowCursor != s.cursorHidden { - s.cursorHidden = !s.opts.ShowCursor - if s.cursorHidden { - s.buf.WriteString(ansi.HideCursor) - } - } - - // Do we have queued strings to write above the screen? - if len(s.queueAbove) > 0 { - // 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. - s.moveCursor(0, s.newbuf.Height()-1, false) - s.buf.WriteString(strings.Repeat("\n", len(s.queueAbove))) - s.cur.Y += len(s.queueAbove) - // Now go to the top of the screen, insert new lines, and write the - // queued strings. - s.moveCursor(0, 0, false) - s.buf.WriteString(ansi.InsertLine(len(s.queueAbove))) - for _, line := range s.queueAbove { - s.buf.WriteString(line + "\r\n") - } - - // Clear the queue - s.queueAbove = s.queueAbove[:0] - } - - var nonEmpty int - - // Force clear? - // We only do partial clear if the screen is not in alternate screen mode - partialClear := s.curbuf.Width() == s.newbuf.Width() && - s.curbuf.Height() > s.newbuf.Height() - - if s.clear { - s.clearUpdate(partialClear) - s.clear = false - } else if len(s.touch) > 0 { - if s.opts.AltScreen { - // 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() - } - - var changedLines int - var i int - - if s.opts.AltScreen { - nonEmpty = min(s.curbuf.Height(), s.newbuf.Height()) - } else { - nonEmpty = s.newbuf.Height() - } - - nonEmpty = s.clearBottom(nonEmpty, partialClear) - for i = 0; i < nonEmpty; i++ { - _, ok := s.touch[i] - if ok { - s.transformLine(i) - changedLines++ - } - } - } - - // Sync windows and screen - s.touch = make(map[int]lineData, s.newbuf.Height()) - - if s.curbuf.Width() != s.newbuf.Width() || s.curbuf.Height() != s.newbuf.Height() { - // Resize the old buffer to match the new buffer. - _, oldh := s.curbuf.Width(), s.curbuf.Height() - s.curbuf.Resize(s.newbuf.Width(), s.newbuf.Height()) - // Sync new lines to old lines - for i := oldh - 1; i < s.newbuf.Height(); i++ { - copy(s.curbuf.Line(i), s.newbuf.Line(i)) - } - } - - s.updatePen(nil) // nil indicates a blank cell with no styles - - // Move the cursor to the specified position. - if s.pos != undefinedPos { - s.move(s.pos.X, s.pos.Y) - s.pos = undefinedPos - } - - if s.buf.Len() > 0 { - // Is the cursor visible? If so, disable it while rendering. - if s.opts.ShowCursor && !s.cursorHidden { - nb := new(bytes.Buffer) - nb.WriteString(ansi.HideCursor) - nb.Write(s.buf.Bytes()) - nb.WriteString(ansi.ShowCursor) - *s.buf = *nb + for _, param := range bytes.Split(params[1], []byte{':'}) { + if bytes.HasPrefix(param, []byte("id=")) { + link.URLID = string(param) } } -} - -// undefinedPos is the position used when the cursor position is undefined and -// in its initial state. -var undefinedPos = Pos(-1, -1) - -// Close writes the final screen update and resets the screen. -func (s *Screen) Close() (err error) { - s.mu.Lock() - defer s.mu.Unlock() - - s.render() - s.updatePen(nil) - s.move(0, s.newbuf.Height()-1) - s.clearToEnd(nil, true) - - if s.altScreenMode { - s.buf.WriteString(ansi.ResetAltScreenSaveCursorMode) - s.altScreenMode = false - } - - if s.cursorHidden { - s.buf.WriteString(ansi.ShowCursor) - s.cursorHidden = false - } - - // Write the buffer - _, err = s.w.Write(s.buf.Bytes()) - s.buf.Reset() - if err != nil { - return - } - - s.reset() - return -} - -// reset resets the screen to its initial state. -func (s *Screen) reset() { - s.cursorHidden = false - s.altScreenMode = false - if s.opts.RelativeCursor { - s.cur = Cursor{} - } else { - s.cur = Cursor{Position: undefinedPos} - } - s.saved = s.cur - s.touch = make(map[int]lineData, s.newbuf.Height()) - if s.curbuf != nil { - s.curbuf.Clear() - } - if s.newbuf != nil { - s.newbuf.Clear() - } - s.buf.Reset() - s.tabs = DefaultTabStops(s.newbuf.Width()) - s.oldhash, s.newhash = nil, nil - - // We always disable HardTabs when termtype is "linux". - if strings.HasPrefix(s.opts.Term, "linux") { - s.opts.HardTabs = false - } -} - -// Resize resizes the screen. -func (s *Screen) Resize(width, height int) bool { - oldw := s.newbuf.Width() - oldh := s.newbuf.Height() - - if s.opts.AltScreen || width != oldw { - // We only clear the whole screen if the width changes. Adding/removing - // rows is handled by the [Screen.render] and [Screen.transformLine] - // methods. - s.clear = true - } - - // Clear new columns and lines - if width > oldh { - s.ClearRect(Rect(max(oldw-2, 0), 0, width-oldw, height)) - } else if width < oldw { - s.ClearRect(Rect(max(width-1, 0), 0, oldw-width, height)) - } - - if height > oldh { - s.ClearRect(Rect(0, max(oldh-1, 0), width, height-oldh)) - } else if height < oldh { - s.ClearRect(Rect(0, max(height-1, 0), width, oldh-height)) - } - - s.mu.Lock() - s.newbuf.Resize(width, height) - s.opts.Width, s.opts.Height = width, height - s.tabs.Resize(width) - s.oldhash, s.newhash = nil, nil - s.mu.Unlock() - - return true -} - -// MoveTo moves the cursor to the specified position. -func (s *Screen) MoveTo(x, y int) bool { - pos := Pos(x, y) - if !pos.In(s.Bounds()) { - return false - } - s.mu.Lock() - s.pos = pos - s.mu.Unlock() - return true -} - -// InsertAbove inserts string above the screen. The inserted string is not -// managed by the screen. This does nothing when alternate screen mode is -// enabled. -func (s *Screen) InsertAbove(str string) { - if s.opts.AltScreen { - return - } - s.mu.Lock() - s.queueAbove = append(s.queueAbove, strings.Split(str, "\n")...) - s.mu.Unlock() -} - -// newWindow creates a new window. -func (s *Screen) newWindow(x, y, width, height int) (w *SubWindow, err error) { - w = new(SubWindow) - w.scr = s - w.bounds = Rect(x, y, width, height) - if x < 0 || y < 0 || width <= 0 || height <= 0 { - return nil, ErrInvalidDimensions - } - - scrw, scrh := s.Bounds().Dx(), s.Bounds().Dy() - if x+width > scrw || y+height > scrh { - return nil, ErrInvalidDimensions - } - - return -} - -// Window represents parts of the terminal screen. -type Window interface { - Cell(x int, y int) *Cell - Fill(cell *Cell) bool - FillRect(cell *Cell, r Rectangle) bool - Clear() bool - ClearRect(r Rectangle) bool - Draw(x int, y int, cell *Cell) (v bool) - Bounds() Rectangle - Resize(width, height int) bool - MoveTo(x, y int) bool -} - -// SubWindow represents a terminal SubWindow. -type SubWindow struct { - scr *Screen // the screen where the window is attached - par *SubWindow // the parent screen (nil if the window is the primary window) - bounds Rectangle // the window's bounds -} - -var _ Window = &SubWindow{} - -// NewWindow creates a new sub-window. -func (s *Screen) NewWindow(x, y, width, height int) (*SubWindow, error) { - return s.newWindow(x, y, width, height) -} - -// NewWindow creates a new sub-window. -func (w *SubWindow) NewWindow(x, y, width, height int) (s *SubWindow, err error) { - s, err = w.scr.newWindow(x, y, width, height) - w.par = w - return -} - -// MoveTo moves the cursor to the specified position. -func (w *SubWindow) MoveTo(x, y int) bool { - pos := Pos(x, y) - if !pos.In(w.Bounds()) { - return false - } - - x, y = w.bounds.Min.X+x, w.bounds.Min.Y+y - return w.scr.MoveTo(x, y) -} - -// Cell implements Window. -func (w *SubWindow) Cell(x int, y int) *Cell { - pos := Pos(x, y) - if !pos.In(w.Bounds()) { - return nil - } - bx, by := w.Bounds().Min.X, w.Bounds().Min.Y - return w.scr.Cell(bx+x, by+y) -} - -// Fill implements Window. -func (w *SubWindow) Fill(cell *Cell) bool { - return w.FillRect(cell, w.Bounds()) -} - -// FillRect fills the cells in the specified rectangle with the specified -// cell. -func (w *SubWindow) FillRect(cell *Cell, r Rectangle) bool { - if !r.In(w.Bounds()) { - return false - } - - w.scr.FillRect(cell, r) - return true -} - -// Clear implements Window. -func (w *SubWindow) Clear() bool { - return w.ClearRect(w.Bounds()) -} - -// ClearRect clears the cells in the specified rectangle based on the current -// cursor background color. Use [SetPen] to set the background color. -func (w *SubWindow) ClearRect(r Rectangle) bool { - if !r.In(w.Bounds()) { - return false - } - - w.scr.ClearRect(r) - return true -} - -// Draw implements Window. -func (w *SubWindow) Draw(x int, y int, cell *Cell) (v bool) { - if !Pos(x, y).In(w.Bounds()) { - return - } - - bx, by := w.Bounds().Min.X, w.Bounds().Min.Y - return w.scr.newbuf.Draw(bx+x, by+y, cell) -} - -// Bounds returns the window's bounds. -func (w *SubWindow) Bounds() Rectangle { - return w.bounds -} - -// Resize implements Window. -func (w *SubWindow) Resize(width int, height int) bool { - if width <= 0 || height <= 0 { - return false - } - - if w.Bounds().Dx() == width && w.Bounds().Dy() == height { - return true - } - - x, y := w.bounds.Min.X, w.bounds.Min.Y - scrw, scrh := w.scr.Bounds().Dx(), w.scr.Bounds().Dy() - if x+width > scrw || y+height > scrh { - return false - } - - w.bounds = Rect(x, y, width, height) - return true + link.URL = string(params[2]) } diff --git a/examples/cellbuf/main.go b/examples/cellbuf/main.go index 772ce1f9..3262bb41 100644 --- a/examples/cellbuf/main.go +++ b/examples/cellbuf/main.go @@ -53,22 +53,17 @@ func main() { os.Stdout.WriteString(ansi.SetMode(modes...)) //nolint:errcheck defer os.Stdout.WriteString(ansi.ResetMode(modes...)) //nolint:errcheck - x, y := (w/2)-8, h/2 + x, y := 0, 0 + ctx := scr.NewWindow(10, 5, 40, 10) render := func() { - scr.Fill(cellbuf.NewCell('你')) + scr.Clear() + ctx.Fill(cellbuf.NewCell('你')) text := " !Hello, world! " - rect := cellbuf.Rect(x, y, ansi.StringWidth(text), 1) - - // This will produce the following escape sequence: - // "\x1b[7m\x1b]8;;https://charm.sh\x07 ! Hello, world! \x1b]8;;\x07\x1b[m" - content := ansi.Style{}.Reverse().String() + - ansi.SetHyperlink("https://charm.sh") + - text + - ansi.ResetHyperlink() + - ansi.ResetStyle - - cellbuf.PaintRect(scr, content, rect) + ctx.SetHyperlink("https://charm.sh") + ctx.EnableAttributes(cellbuf.ReverseAttr) + ctx.MoveTo(x, y) + ctx.PrintTruncate(text, "") scr.Render() } @@ -78,6 +73,7 @@ func main() { w = nw } scr.Resize(nw, nh) + ctx.Resize(nw, nh) render() } @@ -103,7 +99,7 @@ func main() { case input.WindowSizeEvent: resize(ev.Width, ev.Height) case input.MouseClickEvent: - x, y = ev.X, ev.Y + x, y = ev.X-10, ev.Y-5 case input.KeyPressEvent: switch ev.String() { case "ctrl+c", "q": diff --git a/examples/layout/main.go b/examples/layout/main.go index c9fecafa..60c9d242 100644 --- a/examples/layout/main.go +++ b/examples/layout/main.go @@ -409,9 +409,12 @@ func main() { dialogWidth := lipgloss.Width(dialogUI) + dialogBoxStyle.GetHorizontalFrameSize() dialogHeight := lipgloss.Height(dialogUI) + dialogBoxStyle.GetVerticalFrameSize() dialogX, dialogY := physicalWidth/2-dialogWidth/2-docStyle.GetVerticalFrameSize()-1, 12 + ctx := scr.DefaultWindow() render := func() { - cellbuf.Paint(scr, docStyle.Render(doc.String())) - cellbuf.PaintRect(scr, dialogBoxStyle.Render(dialogUI), cellbuf.Rect(dialogX, dialogY, dialogWidth, dialogHeight)) + ctx.Clear() + ctx.SetContent(docStyle.Render(doc.String())) + box := scr.NewWindow(dialogX, dialogY, dialogWidth, dialogHeight) + box.SetContent(dialogBoxStyle.Render(dialogUI)) scr.Render() }