diff --git a/cellbuf/buffer.go b/cellbuf/buffer.go index 1271737c..94690c98 100644 --- a/cellbuf/buffer.go +++ b/cellbuf/buffer.go @@ -1,115 +1,153 @@ package cellbuf -// NewBuffer returns a new buffer with the given width and height. -func NewBuffer(width, height int) *buffer { - var buf buffer - buf.Resize(width, height) - return &buf +import ( + "strings" + + "github.com/charmbracelet/x/wcwidth" + "github.com/rivo/uniseg" +) + +// NewCell returns a new cell. This is a convenience function that initializes a +// new cell with the given content. The cell's width is determined by the +// content using [wcwidth.RuneWidth]. +func NewCell(r rune, comb ...rune) *Cell { + width := wcwidth.StringWidth(string(append([]rune{r}, comb...))) + return &Cell{ + Rune: r, + Comb: comb, + Width: width, + } } -// buffer is a 2D grid of cells representing a screen or terminal. -type buffer struct { - cells []Cell - width int +// NewGraphemeCell returns a new cell. This is a convenience function that +// initializes a new cell with the given content. The cell's width is determined +// by the content using [uniseg.FirstGraphemeClusterInString]. +// This is used when the content is a grapheme cluster i.e. a sequence of runes +// that form a single visual unit. +// This will only return the first grapheme cluster in the string. If the +// string is empty, it will return an empty cell with a width of 0. +func NewGraphemeCell(s string) (c *Cell) { + c = new(Cell) + g, _, w, _ := uniseg.FirstGraphemeClusterInString(s, -1) + c.Width = w + for i, r := range g { + if i == 0 { + c.Rune = r + } else { + c.Comb = append(c.Comb, r) + } + } + return } -// Width returns the width of the buffer. -func (b *buffer) Width() int { - return b.width +// Line represents a line in the terminal. +// A nil cell represents an blank cell, a cell with a space character and a +// width of 1. +// If a cell has no content and a width of 0, it is a placeholder for a wide +// cell. +type Line []*Cell + +// Width returns the width of the line. +func (l Line) Width() int { + return len(l) } -// Height returns the height of the buffer. -func (b *buffer) Height() int { - if b.width == 0 { - return 0 - } - return len(b.cells) / b.width +// Len returns the length of the line. +func (l Line) Len() int { + return len(l) } -// Cell returns the cell at the given x, y position. -func (b *buffer) Cell(x, y int) *Cell { - if b.width == 0 { - return nil +// String returns the string representation of the line. Any trailing spaces +// are removed. +func (l Line) String() (s string) { + for _, c := range l { + if c == nil { + s += " " + } else if c.Empty() { + continue + } else { + s += c.String() + } } - height := len(b.cells) / b.width - if x < 0 || x >= b.width || y < 0 || y >= height { + s = strings.TrimRight(s, " ") + return +} + +// At returns the cell at the given x position. +// If the cell does not exist, it returns nil. +func (l Line) At(x int) *Cell { + if x < 0 || x >= len(l) { return nil } - idx := y*b.width + x - if idx < 0 || idx >= len(b.cells) { - return nil + + c := l[x] + if c == nil { + newCell := BlankCell + return &newCell } - return &b.cells[idx] + + return c } -// Draw sets the cell at the given x, y position. -func (b *buffer) Draw(x, y int, c Cell) (v bool) { - return b.SetCell(x, y, &c) +// Set sets the cell at the given x position. If a wide cell is given, it will +// set the cell and the following cells to [EmptyCell]. It returns true if the +// cell was set. +func (l Line) Set(x int, c *Cell) bool { + return l.set(x, c, true) } -// SetCell sets the cell at the given x, y position. -func (b *buffer) SetCell(x, y int, c *Cell) (v bool) { - if b.width == 0 { - return +func (l Line) set(x int, c *Cell, clone bool) bool { + width := l.Width() + if x < 0 || x >= width { + return false } - height := len(b.cells) / b.width - if x > b.width-1 || y > height-1 { - return - } - idx := y*b.width + x - if idx < 0 || idx >= len(b.cells) { - return + + // Don't allocate a new cell if the cell is a clear blank. + if c != nil && c.Equal(&BlankCell) { + c = nil } // When a wide cell is partially overwritten, we need // to fill the rest of the cell with space cells to // avoid rendering issues. - prev := b.cells[idx] - if prev.Width > 1 { + prev := l.At(x) + if prev != nil && prev.Width > 1 { // Writing to the first wide cell - for j := 0; j < prev.Width && idx+j < len(b.cells); j++ { - newCell := prev - newCell.Content = " " - newCell.Width = 1 - b.cells[idx+j] = newCell + for j := 0; j < prev.Width && x+j < l.Width(); j++ { + l[x+j] = prev.Clone().Blank() } - } else if prev.Width == 0 { + } else if prev != nil && prev.Width == 0 { // Writing to wide cell placeholders - for j := 1; j < 4 && idx-j >= 0; j++ { - wide := b.cells[idx-j] - if wide.Width > 1 { + for j := 1; j < maxCellWidth && x-j >= 0; j++ { + wide := l.At(x - j) + if wide != nil && wide.Width > 1 && j < wide.Width { for k := 0; k < wide.Width; k++ { - newCell := wide - newCell.Content = " " - newCell.Width = 1 - b.cells[idx-j+k] = newCell + l[x-j+k] = wide.Clone().Blank() } break } } } - if c == nil { - newCell := spaceCell - c = &newCell + if clone && c != nil { + // Clone the cell if not nil. + c = c.Clone() } - if c != nil && x+c.Width > b.width { + if c != nil && x+c.Width > width { // If the cell is too wide, we write blanks with the same style. - newCell := *c - newCell.Content = " " - newCell.Width = 1 - for i := 0; i < c.Width && idx+i < len(b.cells); i++ { - b.cells[idx+i] = newCell + for i := 0; i < c.Width && x+i < width; i++ { + l[x+i] = c.Clone().Blank() } } else { - b.cells[idx] = *c + l[x] = c - // Mark wide cells with emptyCell zero width + // Mark wide cells with an empty cell zero width // We set the wide cell down below - if c.Width > 1 { - for j := 1; j < c.Width && idx+j < len(b.cells); j++ { - b.cells[idx+j] = emptyCell + if c != nil && c.Width > 1 { + for j := 1; j < c.Width && x+j < l.Width(); j++ { + var wide Cell + l[x+j] = &wide } } } @@ -117,65 +155,295 @@ func (b *buffer) SetCell(x, y int, c *Cell) (v bool) { return true } -// Clone returns a deep copy of the buffer. -func (b *buffer) Clone() *buffer { - var clone buffer - clone.width = b.width - clone.cells = make([]Cell, len(b.cells)) - copy(clone.cells, b.cells) - return &clone +// Buffer is a 2D grid of cells representing a screen or terminal. +type Buffer struct { + // Lines holds the lines of the buffer. + Lines []Line +} + +// NewBuffer creates a new buffer with the given width and height. +// This is a convenience function that initializes a new buffer and resizes it. +func NewBuffer(width int, height int) *Buffer { + b := new(Buffer) + b.Resize(width, height) + return b } -// Resize resizes the buffer to the given width and height. It grows the buffer -// if necessary and fills the new cells with space cells. Otherwise, it -// truncates the buffer. -func (b *buffer) Resize(width, height int) { - b.width = width - if area := width * height; len(b.cells) < area { - ln := len(b.cells) - b.cells = append(b.cells, make([]Cell, area-ln)...) - // Fill the buffer with space cells - for i := ln; i < area; i++ { - b.cells[i] = spaceCell +// String returns the string representation of the buffer. +func (b *Buffer) String() (s string) { + for i, l := range b.Lines { + s += l.String() + if i < len(b.Lines)-1 { + s += "\r\n" } - } else if len(b.cells) > area { - // Truncate the buffer if necessary - b.cells = b.cells[:area] } + return +} + +// Line returns a pointer to the line at the given y position. +// If the line does not exist, it returns nil. +func (b *Buffer) Line(y int) Line { + if y < 0 || y >= len(b.Lines) { + return nil + } + return b.Lines[y] +} + +// Cell implements Screen. +func (b *Buffer) Cell(x int, y int) *Cell { + if y < 0 || y >= len(b.Lines) { + return nil + } + 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 + +// SetCell sets the cell at the given x, y position. +func (b *Buffer) SetCell(x, y int, c *Cell) bool { + return b.setCell(x, y, c, true) +} + +// setCell sets the cell at the given x, y position. This will always clone and +// allocates a new cell if c is not nil. +func (b *Buffer) setCell(x, y int, c *Cell, clone bool) bool { + if y < 0 || y >= len(b.Lines) { + return false + } + return b.Lines[y].set(x, c, clone) +} + +// Height implements Screen. +func (b *Buffer) Height() int { + return len(b.Lines) +} + +// Width implements Screen. +func (b *Buffer) Width() int { + if len(b.Lines) == 0 { + return 0 + } + return b.Lines[0].Width() } // Bounds returns the bounds of the buffer. -func (b *buffer) Bounds() Rectangle { +func (b *Buffer) Bounds() Rectangle { return Rect(0, 0, b.Width(), b.Height()) } -// Fill fills the buffer with the given cell. If rect is not nil, it fills the -// rectangle with the cell. Otherwise, it fills the whole buffer. -func (b *buffer) Fill(c *Cell, rects ...Rectangle) { - Fill(b, c, rects...) +// Resize resizes the buffer to the given width and height. +func (b *Buffer) Resize(width int, height int) { + if width == 0 || height == 0 { + b.Lines = nil + return + } + + if width > b.Width() { + line := make(Line, width-b.Width()) + for i := range b.Lines { + b.Lines[i] = append(b.Lines[i], line...) + } + } else if width < b.Width() { + for i := range b.Lines { + b.Lines[i] = b.Lines[i][:width] + } + } + + if height > len(b.Lines) { + for i := len(b.Lines); i < height; i++ { + b.Lines = append(b.Lines, make(Line, width)) + } + } else if height < len(b.Lines) { + b.Lines = b.Lines[:height] + } } -// Clear clears the buffer with space cells. If rect is not nil, it clears the -// rectangle. Otherwise, it clears the whole buffer. -func (b *buffer) Clear(rects ...Rectangle) { - Clear(b, rects...) +// FillInRect fills the buffer with the given cell and rectangle. +func (b *Buffer) FillInRect(c *Cell, rect Rectangle) { + cellWidth := 1 + if c != nil && c.Width > 1 { + cellWidth = c.Width + } + for y := rect.Min.Y; y < rect.Max.Y; y++ { + for x := rect.Min.X; x < rect.Max.X; x += cellWidth { + b.setCell(x, y, c, false) //nolint:errcheck + } + } +} + +// Fill fills the buffer with the given cell and rectangle. +func (b *Buffer) Fill(c *Cell) { + b.FillInRect(c, b.Bounds()) +} + +// Clear clears the buffer with space cells and rectangle. +func (b *Buffer) Clear() { + b.ClearInRect(b.Bounds()) +} + +// ClearInRect clears the buffer with space cells within the specified +// rectangles. Only cells within the rectangle's bounds are affected. +func (b *Buffer) ClearInRect(rect Rectangle) { + b.FillInRect(nil, rect) +} + +// InsertLine inserts n lines at the given line position, with the given +// optional cell, within the specified rectangles. If no rectangles are +// specified, it inserts lines in the entire buffer. Only cells within the +// rectangle's horizontal bounds are affected. Lines are pushed out of the +// rectangle bounds and lost. This follows terminal [ansi.IL] behavior. +// It returns the pushed out lines. +func (b *Buffer) InsertLine(y, n int, c *Cell) { + b.InsertLineInRect(y, n, c, b.Bounds()) +} + +// InsertLineInRect inserts new lines at the given line position, with the +// given optional cell, within the rectangle bounds. Only cells within the +// rectangle's horizontal bounds are affected. Lines are pushed out of the +// rectangle bounds and lost. This follows terminal [ansi.IL] behavior. +func (b *Buffer) InsertLineInRect(y, n int, c *Cell, rect Rectangle) { + if n <= 0 || y < rect.Min.Y || y >= rect.Max.Y || y >= b.Height() { + return + } + + // Limit number of lines to insert to available space + if y+n > rect.Max.Y { + n = rect.Max.Y - y + } + + // Move existing lines down within the bounds + for i := rect.Max.Y - 1; i >= y+n; i-- { + for x := rect.Min.X; x < rect.Max.X; x++ { + // We don't need to clone c here because we're just moving lines down. + b.setCell(x, i, b.Lines[i-n][x], false) + } + } + + // Clear the newly inserted lines within bounds + for i := y; i < y+n; i++ { + for x := rect.Min.X; x < rect.Max.X; x++ { + b.setCell(x, i, c, true) + } + } +} + +// DeleteLineInRect deletes lines at the given line position, with the given +// optional cell, within the rectangle bounds. Only cells within the +// rectangle's bounds are affected. Lines are shifted up within the bounds and +// new blank lines are created at the bottom. This follows terminal [ansi.DL] +// behavior. +func (b *Buffer) DeleteLineInRect(y, n int, c *Cell, rect Rectangle) { + if n <= 0 || y < rect.Min.Y || y >= rect.Max.Y || y >= b.Height() { + return + } + + // Limit deletion count to available space in scroll region + if n > rect.Max.Y-y { + n = rect.Max.Y - y + } + + // Shift cells up within the bounds + for dst := y; dst < rect.Max.Y-n; dst++ { + src := dst + n + for x := rect.Min.X; x < rect.Max.X; x++ { + // We don't need to clone c here because we're just moving cells up. + // b.lines[dst][x] = b.lines[src][x] + b.setCell(x, dst, b.Lines[src][x], false) + } + } + + // Fill the bottom n lines with blank cells + for i := rect.Max.Y - n; i < rect.Max.Y; i++ { + for x := rect.Min.X; x < rect.Max.X; x++ { + b.setCell(x, i, c, true) + } + } } -// Paint writes the given data to the buffer. If rect is not nil, it writes the -// data within the rectangle. Otherwise, it writes the data to the whole -// buffer. -func (b *buffer) Paint(m Method, data string, rect *Rectangle) []int { - return Paint(b, m, data, rect) +// DeleteLine deletes n lines at the given line position, with the given +// optional cell, within the specified rectangles. If no rectangles are +// specified, it deletes lines in the entire buffer. +func (b *Buffer) DeleteLine(y, n int, c *Cell) { + b.DeleteLineInRect(y, n, c, b.Bounds()) +} + +// InsertCell inserts new cells at the given position, with the given optional +// cell, within the specified rectangles. If no rectangles are specified, it +// inserts cells in the entire buffer. This follows terminal [ansi.ICH] +// behavior. +func (b *Buffer) InsertCell(x, y, n int, c *Cell) { + b.InsertCellInRect(x, y, n, c, b.Bounds()) +} + +// InsertCellInRect inserts new cells at the given position, with the given +// optional cell, within the rectangle bounds. Only cells within the +// rectangle's bounds are affected, following terminal [ansi.ICH] behavior. +func (b *Buffer) InsertCellInRect(x, y, n int, c *Cell, rect Rectangle) { + if n <= 0 || y < rect.Min.Y || y >= rect.Max.Y || y >= b.Height() || + x < rect.Min.X || x >= rect.Max.X || x >= b.Width() { + return + } + + // Limit number of cells to insert to available space + if x+n > rect.Max.X { + n = rect.Max.X - x + } + + // Move existing cells within rectangle bounds to the right + for i := rect.Max.X - 1; i >= x+n && i-n >= rect.Min.X; i-- { + // We don't need to clone c here because we're just moving cells to the + // right. + // b.lines[y][i] = b.lines[y][i-n] + b.setCell(i, y, b.Lines[y][i-n], false) + } + + // Clear the newly inserted cells within rectangle bounds + for i := x; i < x+n && i < rect.Max.X; i++ { + b.setCell(i, y, c, true) + } } -// Render returns a string representation of the buffer with ANSI escape -// sequences. -func (b *buffer) Render(opts ...RenderOption) string { - return Render(b, opts...) +// DeleteCell deletes cells at the given position, with the given optional +// cell, within the specified rectangles. If no rectangles are specified, it +// deletes cells in the entire buffer. This follows terminal [ansi.DCH] +// behavior. +func (b *Buffer) DeleteCell(x, y, n int, c *Cell) { + b.DeleteCellInRect(x, y, n, c, b.Bounds()) } -// RenderLine returns a string representation of the yth line of the buffer along -// with the width of the line. -func (b *buffer) RenderLine(n int, opts ...RenderOption) (w int, line string) { - return RenderLine(b, n, opts...) +// DeleteCellInRect deletes cells at the given position, with the given +// optional cell, within the rectangle bounds. Only cells within the +// rectangle's bounds are affected, following terminal [ansi.DCH] behavior. +func (b *Buffer) DeleteCellInRect(x, y, n int, c *Cell, rect Rectangle) { + if n <= 0 || y < rect.Min.Y || y >= rect.Max.Y || y >= b.Height() || + x < rect.Min.X || x >= rect.Max.X || x >= b.Width() { + return + } + + // Calculate how many positions we can actually delete + remainingCells := rect.Max.X - x + if n > remainingCells { + n = remainingCells + } + + // Shift the remaining cells to the left + for i := x; i < rect.Max.X-n; i++ { + if i+n < rect.Max.X { + // We don't need to clone c here because we're just moving cells to + // the left. + // b.lines[y][i] = b.lines[y][i+n] + b.setCell(i, y, b.Lines[y][i+n], false) + } + } + + // Fill the vacated positions with the given cell + for i := rect.Max.X - n; i < rect.Max.X; i++ { + b.setCell(i, y, c, true) + } } diff --git a/cellbuf/cell.go b/cellbuf/cell.go index fc927d20..fde7d2c0 100644 --- a/cellbuf/cell.go +++ b/cellbuf/cell.go @@ -1,17 +1,517 @@ package cellbuf -import "github.com/charmbracelet/x/vt" +import ( + "github.com/charmbracelet/x/ansi" +) var ( - // spaceCell is 1-cell wide, has no style, and a space rune. - spaceCell = Cell{ - Content: " ", - Width: 1, - } + // BlankCell is a cell with a single space, width of 1, and no style or link. + BlankCell = Cell{Rune: ' ', Width: 1} - // emptyCell is an empty cell. - emptyCell = Cell{} + // EmptyCell is just an empty cell used for comparisons and as a placeholder + // for wide cells. + EmptyCell = Cell{} ) // Cell represents a single cell in the terminal screen. -type Cell = vt.Cell +type Cell struct { + // The style of the cell. Nil style means no style. Zero value prints a + // reset sequence. + Style Style + + // Link is the hyperlink of the cell. + Link Link + + // Comb is the combining runes of the cell. This is nil if the cell is a + // single rune or if it's a zero width cell that is part of a wider cell. + Comb []rune + + // Width is the mono-space width of the grapheme cluster. + Width int + + // Rune is the main rune of the cell. This is zero if the cell is part of a + // wider cell. + Rune rune +} + +// String returns the string content of the cell excluding any styles, links, +// and escape sequences. +func (c Cell) String() string { + if len(c.Comb) == 0 { + return string(c.Rune) + } + return string(append([]rune{c.Rune}, c.Comb...)) +} + +// Equal returns whether the cell is equal to the other cell. +func (c *Cell) Equal(o *Cell) bool { + return o != nil && + c.Width == o.Width && + c.Rune == o.Rune && + runesEqual(c.Comb, o.Comb) && + c.Style.Equal(o.Style) && + c.Link.Equal(o.Link) +} + +// Empty returns whether the cell is empty. +func (c Cell) Empty() bool { + return c.Rune == 0 && + len(c.Comb) == 0 && + c.Width == 0 && + c.Style.Empty() && + c.Link.Empty() +} + +// Reset resets the cell to the default state zero value. +func (c *Cell) Reset() { + c.Rune = 0 + c.Comb = nil + c.Width = 0 + c.Style.Reset() + c.Link.Reset() +} + +// Clear returns whether the cell consists of only attributes that don't +// affect appearance of a space character. +func (c *Cell) Clear() bool { + return c.Rune == ' ' && len(c.Comb) == 0 && c.Width == 1 && c.Style.Clear() && c.Link.Empty() +} + +// Clone returns a copy of the cell. +func (c *Cell) Clone() (n *Cell) { + n = new(Cell) + *n = *c + return +} + +// Blank makes the cell a blank cell by setting the rune to a space, comb to +// nil, and the width to 1. +func (c *Cell) Blank() *Cell { + c.Rune = ' ' + c.Comb = nil + c.Width = 1 + 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 + URLID string +} + +// String returns a string representation of the hyperlink. +func (h Link) String() string { + return h.URL +} + +// Reset resets the hyperlink to the default state zero value. +func (h *Link) Reset() { + h.URL = "" + h.URLID = "" +} + +// Equal returns whether the hyperlink is equal to the other hyperlink. +func (h Link) Equal(o Link) bool { + return h == o +} + +// Empty returns whether the hyperlink is empty. +func (h Link) Empty() bool { + return h.URL == "" && h.URLID == "" +} + +// AttrMask is a bitmask for text attributes that can change the look of text. +// These attributes can be combined to create different styles. +type AttrMask uint8 + +// These are the available text attributes that can be combined to create +// different styles. +const ( + BoldAttr AttrMask = 1 << iota + FaintAttr + ItalicAttr + SlowBlinkAttr + RapidBlinkAttr + ReverseAttr + ConcealAttr + StrikethroughAttr + + ResetAttr AttrMask = 0 +) + +var attrMaskNames = map[AttrMask]string{ + BoldAttr: "BoldAttr", + FaintAttr: "FaintAttr", + ItalicAttr: "ItalicAttr", + SlowBlinkAttr: "SlowBlinkAttr", + RapidBlinkAttr: "RapidBlinkAttr", + ReverseAttr: "ReverseAttr", + ConcealAttr: "ConcealAttr", + StrikethroughAttr: "StrikethroughAttr", + ResetAttr: "ResetAttr", +} + +// UnderlineStyle is the style of underline to use for text. +type UnderlineStyle uint8 + +// These are the available underline styles. +const ( + NoUnderline UnderlineStyle = iota + SingleUnderline + DoubleUnderline + CurlyUnderline + DottedUnderline + DashedUnderline +) + +var underlineStyleNames = map[UnderlineStyle]string{ + NoUnderline: "NoUnderline", + SingleUnderline: "SingleUnderline", + DoubleUnderline: "DoubleUnderline", + CurlyUnderline: "CurlyUnderline", + DottedUnderline: "DottedUnderline", + DashedUnderline: "DashedUnderline", +} + +// String returns a string representation of the underline style. +func (u UnderlineStyle) String() string { + return underlineStyleNames[u] +} + +// Style represents the Style of a cell. +type Style struct { + Fg ansi.Color + Bg ansi.Color + Ul ansi.Color + Attrs AttrMask + UlStyle UnderlineStyle +} + +// Sequence returns the ANSI sequence that sets the style. +func (s Style) Sequence() string { + if s.Empty() { + return ansi.ResetStyle + } + + var b ansi.Style + + if s.Attrs != 0 { + if s.Attrs&BoldAttr != 0 { + b = b.Bold() + } + if s.Attrs&FaintAttr != 0 { + b = b.Faint() + } + if s.Attrs&ItalicAttr != 0 { + b = b.Italic() + } + if s.Attrs&SlowBlinkAttr != 0 { + b = b.SlowBlink() + } + if s.Attrs&RapidBlinkAttr != 0 { + b = b.RapidBlink() + } + if s.Attrs&ReverseAttr != 0 { + b = b.Reverse() + } + if s.Attrs&ConcealAttr != 0 { + b = b.Conceal() + } + if s.Attrs&StrikethroughAttr != 0 { + b = b.Strikethrough() + } + } + if s.UlStyle != NoUnderline { + switch s.UlStyle { + case SingleUnderline: + b = b.Underline() + case DoubleUnderline: + b = b.DoubleUnderline() + case CurlyUnderline: + b = b.CurlyUnderline() + case DottedUnderline: + b = b.DottedUnderline() + case DashedUnderline: + b = b.DashedUnderline() + } + } + if s.Fg != nil { + b = b.ForegroundColor(s.Fg) + } + if s.Bg != nil { + b = b.BackgroundColor(s.Bg) + } + if s.Ul != nil { + b = b.UnderlineColor(s.Ul) + } + + return b.String() +} + +// DiffSequence returns the ANSI sequence that sets the style as a diff from +// another style. +func (s Style) DiffSequence(o Style) string { + if o.Empty() { + return s.Sequence() + } + + var b ansi.Style + + if !colorEqual(s.Fg, o.Fg) { + b = b.ForegroundColor(s.Fg) + } + + if !colorEqual(s.Bg, o.Bg) { + b = b.BackgroundColor(s.Bg) + } + + if !colorEqual(s.Ul, o.Ul) { + b = b.UnderlineColor(s.Ul) + } + + var ( + noBlink bool + isNormal bool + ) + + if s.Attrs != o.Attrs { + if s.Attrs&BoldAttr != o.Attrs&BoldAttr { + if s.Attrs&BoldAttr != 0 { + b = b.Bold() + } else if !isNormal { + isNormal = true + b = b.NormalIntensity() + } + } + if s.Attrs&FaintAttr != o.Attrs&FaintAttr { + if s.Attrs&FaintAttr != 0 { + b = b.Faint() + } else if !isNormal { + isNormal = true + b = b.NormalIntensity() + } + } + if s.Attrs&ItalicAttr != o.Attrs&ItalicAttr { + if s.Attrs&ItalicAttr != 0 { + b = b.Italic() + } else { + b = b.NoItalic() + } + } + if s.Attrs&SlowBlinkAttr != o.Attrs&SlowBlinkAttr { + if s.Attrs&SlowBlinkAttr != 0 { + b = b.SlowBlink() + } else if !noBlink { + b = b.NoBlink() + } + } + if s.Attrs&RapidBlinkAttr != o.Attrs&RapidBlinkAttr { + if s.Attrs&RapidBlinkAttr != 0 { + b = b.RapidBlink() + } else if !noBlink { + b = b.NoBlink() + } + } + if s.Attrs&ReverseAttr != o.Attrs&ReverseAttr { + if s.Attrs&ReverseAttr != 0 { + b = b.Reverse() + } else { + b = b.NoReverse() + } + } + if s.Attrs&ConcealAttr != o.Attrs&ConcealAttr { + if s.Attrs&ConcealAttr != 0 { + b = b.Conceal() + } else { + b = b.NoConceal() + } + } + if s.Attrs&StrikethroughAttr != o.Attrs&StrikethroughAttr { + if s.Attrs&StrikethroughAttr != 0 { + b = b.Strikethrough() + } else { + b = b.NoStrikethrough() + } + } + } + + return b.String() +} + +// Equal returns true if the style is equal to the other style. +func (s Style) Equal(o Style) bool { + return colorEqual(s.Fg, o.Fg) && + colorEqual(s.Bg, o.Bg) && + colorEqual(s.Ul, o.Ul) && + s.Attrs == o.Attrs && + s.UlStyle == o.UlStyle +} + +func colorEqual(c, o ansi.Color) bool { + if c == nil && o == nil { + return true + } + if c == nil || o == nil { + return false + } + cr, cg, cb, ca := c.RGBA() + or, og, ob, oa := o.RGBA() + return cr == or && cg == og && cb == ob && ca == oa +} + +// Bold sets the bold attribute. +func (s *Style) Bold(v bool) *Style { + if v { + s.Attrs |= BoldAttr + } else { + s.Attrs &^= BoldAttr + } + return s +} + +// Faint sets the faint attribute. +func (s *Style) Faint(v bool) *Style { + if v { + s.Attrs |= FaintAttr + } else { + s.Attrs &^= FaintAttr + } + return s +} + +// Italic sets the italic attribute. +func (s *Style) Italic(v bool) *Style { + if v { + s.Attrs |= ItalicAttr + } else { + s.Attrs &^= ItalicAttr + } + return s +} + +// SlowBlink sets the slow blink attribute. +func (s *Style) SlowBlink(v bool) *Style { + if v { + s.Attrs |= SlowBlinkAttr + } else { + s.Attrs &^= SlowBlinkAttr + } + return s +} + +// RapidBlink sets the rapid blink attribute. +func (s *Style) RapidBlink(v bool) *Style { + if v { + s.Attrs |= RapidBlinkAttr + } else { + s.Attrs &^= RapidBlinkAttr + } + return s +} + +// Reverse sets the reverse attribute. +func (s *Style) Reverse(v bool) *Style { + if v { + s.Attrs |= ReverseAttr + } else { + s.Attrs &^= ReverseAttr + } + return s +} + +// Conceal sets the conceal attribute. +func (s *Style) Conceal(v bool) *Style { + if v { + s.Attrs |= ConcealAttr + } else { + s.Attrs &^= ConcealAttr + } + return s +} + +// Strikethrough sets the strikethrough attribute. +func (s *Style) Strikethrough(v bool) *Style { + if v { + s.Attrs |= StrikethroughAttr + } else { + s.Attrs &^= StrikethroughAttr + } + return s +} + +// UnderlineStyle sets the underline style. +func (s *Style) UnderlineStyle(style UnderlineStyle) *Style { + s.UlStyle = style + return s +} + +// Underline sets the underline attribute. +// This is a syntactic sugar for [UnderlineStyle]. +func (s *Style) Underline(v bool) *Style { + if v { + return s.UnderlineStyle(SingleUnderline) + } + return s.UnderlineStyle(NoUnderline) +} + +// Foreground sets the foreground color. +func (s *Style) Foreground(c ansi.Color) *Style { + s.Fg = c + return s +} + +// Background sets the background color. +func (s *Style) Background(c ansi.Color) *Style { + s.Bg = c + return s +} + +// UnderlineColor sets the underline color. +func (s *Style) UnderlineColor(c ansi.Color) *Style { + s.Ul = c + return s +} + +// Reset resets the style to default. +func (s *Style) Reset() *Style { + s.Fg = nil + s.Bg = nil + s.Ul = nil + s.Attrs = ResetAttr + s.UlStyle = NoUnderline + return s +} + +// Empty returns true if the style is empty. +func (s *Style) Empty() bool { + return s.Fg == nil && s.Bg == nil && s.Ul == nil && s.Attrs == ResetAttr && s.UlStyle == NoUnderline +} + +// Clear returns whether the style consists of only attributes that don't +// affect appearance of a space character. +func (s *Style) Clear() bool { + return s.Fg == nil && s.Bg == nil && s.Ul == nil && + s.UlStyle == NoUnderline && + s.Attrs&^(BoldAttr|FaintAttr|ItalicAttr|StrikethroughAttr) == 0 +} + +func runesEqual(a, b []rune) bool { + if len(a) != len(b) { + return false + } + for i, r := range a { + if r != b[i] { + return false + } + } + return true +} diff --git a/cellbuf/geom.go b/cellbuf/geom.go index c444b8fa..bd734733 100644 --- a/cellbuf/geom.go +++ b/cellbuf/geom.go @@ -1,21 +1,50 @@ package cellbuf import ( - "github.com/charmbracelet/x/vt" + "image" ) // Position represents an x, y position. -type Position = vt.Position +type Position = image.Point // Pos is a shorthand for Position{X: x, Y: y}. func Pos(x, y int) Position { - return vt.Pos(x, y) + return image.Pt(x, y) } // Rectange represents a rectangle. -type Rectangle = vt.Rectangle +type Rectangle struct { + image.Rectangle +} + +// Contains reports whether the rectangle contains the given point. +func (r Rectangle) Contains(p Position) bool { + return p.In(r.Bounds()) +} + +// Width returns the width of the rectangle. +func (r Rectangle) Width() int { + return r.Rectangle.Dx() +} + +// Height returns the height of the rectangle. +func (r Rectangle) Height() int { + return r.Rectangle.Dy() +} + +// X returns the starting x position of the rectangle. +// This is equivalent to Min.X. +func (r Rectangle) X() int { + return r.Min.X +} + +// Y returns the starting y position of the rectangle. +// This is equivalent to Min.Y. +func (r Rectangle) Y() int { + return r.Min.Y +} // Rect is a shorthand for Rectangle. func Rect(x, y, w, h int) Rectangle { - return vt.Rect(x, y, w, h) + return Rectangle{image.Rect(x, y, x+w, y+h)} } diff --git a/cellbuf/go.mod b/cellbuf/go.mod index e646b294..bc06df08 100644 --- a/cellbuf/go.mod +++ b/cellbuf/go.mod @@ -5,14 +5,13 @@ go 1.18 require ( github.com/charmbracelet/colorprofile v0.1.9 github.com/charmbracelet/x/ansi v0.5.2 - github.com/charmbracelet/x/vt v0.0.0-20241113152101-0af7d04e9f32 + github.com/charmbracelet/x/term v0.2.1 github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 + github.com/rivo/uniseg v0.4.7 ) require ( - github.com/charmbracelet/x/term v0.2.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.20.0 // indirect diff --git a/cellbuf/go.sum b/cellbuf/go.sum index 9362b2c0..e74b0f5a 100644 --- a/cellbuf/go.sum +++ b/cellbuf/go.sum @@ -4,8 +4,6 @@ github.com/charmbracelet/x/ansi v0.5.2 h1:dEa1x2qdOZXD/6439s+wF7xjV+kZLu/iN00GuX github.com/charmbracelet/x/ansi v0.5.2/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/charmbracelet/x/vt v0.0.0-20241113152101-0af7d04e9f32 h1:F6G/LwhlSj/oQgnNKkELI934e/oao0MM67rst7MExDY= -github.com/charmbracelet/x/vt v0.0.0-20241113152101-0af7d04e9f32/go.mod h1:+CYC0tzYqYMtIryA0lcGQgCUaAiRLaS7Rxi9R+PFii8= github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 h1:D5OO0lVavz7A+Swdhp62F9gbkibxmz9B2hZ/jVdMPf0= github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91/go.mod h1:Ey8PFmYwH+/td9bpiEx07Fdx9ZVkxfIjWXxBluxF4Nw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= diff --git a/cellbuf/link.go b/cellbuf/link.go index 71bad227..112f8e8a 100644 --- a/cellbuf/link.go +++ b/cellbuf/link.go @@ -2,12 +2,8 @@ package cellbuf import ( "github.com/charmbracelet/colorprofile" - "github.com/charmbracelet/x/vt" ) -// Link represents a hyperlink in the terminal screen. -type Link = vt.Link - // Convert converts a hyperlink to respect the given color profile. func ConvertLink(h Link, p colorprofile.Profile) Link { if p == colorprofile.NoTTY { diff --git a/cellbuf/options.go b/cellbuf/options.go new file mode 100644 index 00000000..3b3e56c2 --- /dev/null +++ b/cellbuf/options.go @@ -0,0 +1,238 @@ +package cellbuf + +import ( + "bytes" + + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/wcwidth" +) + +// Options are options for manipulating the buffer. +type Options struct { + // Parser is the parser to use when writing to the buffer. + Parser *ansi.Parser + // Area is the area to write to. + Area Rectangle + // Profile is the profile to use when writing to the buffer. + Profile colorprofile.Profile + // Method is the width calculation method to use when writing to the buffer. + Method Method + // AutoWrap is whether to automatically wrap text when it reaches the end + // of the line. + AutoWrap bool + // NewLine whether to automatically insert a carriage returns [ansi.CR] + // when a linefeed [ansi.LF] is encountered. + NewLine bool +} + +// SetString sets the string at the given x, y position. It returns the new x +// and y position after writing the string. If the string is wider than the +// buffer and auto-wrap is enabled, it will wrap to the next line. Otherwise, +// it will be truncated. +func (opt Options) SetString(b *Buffer, x, y int, s string) (int, int) { + if opt.Area.Empty() { + opt.Area = b.Bounds() + } + + p := opt.Parser + if p == nil { + p = ansi.GetParser() + defer ansi.PutParser(p) + } + + var pen Style + var link Link + var state byte + + handleCell := func(content string, width int) { + var r rune + var comb []rune + for i, c := range content { + if i == 0 { + r = c + } else { + comb = append(comb, c) + } + } + + c := &Cell{ + Rune: r, + Comb: comb, + Width: width, + Style: pen, + Link: link, + } + + b.SetCell(x, y, c) + if x+width >= opt.Area.Max.X && opt.AutoWrap { + x = opt.Area.Min.X + y++ + } else { + x += width + } + } + + blankCell := func() *Cell { + if pen.Bg != nil { + return &Cell{ + Rune: ' ', + Width: 1, + Style: pen, + } + } + return nil + } + + for len(s) > 0 { + seq, width, n, newState := ansi.DecodeSequence(s, state, p) + switch width { + case 1, 2, 3, 4: // wide cells can go up to 4 cells wide + switch opt.Method { + case WcWidth: + for _, r := range seq { + width = wcwidth.RuneWidth(r) + handleCell(string(r), width) + } + case GraphemeWidth: + handleCell(seq, width) + } + case 0: + switch { + case ansi.HasCsiPrefix(seq): + switch p.Cmd() { + case 'm': // Select Graphic Rendition [ansi.SGR] + handleSgr(p, &pen) + case 'L': // Insert Line [ansi.IL] + count := 1 + if n, ok := p.Param(0, 1); ok && n > 0 { + count = n + } + + b.InsertLine(y, count, blankCell()) + case 'M': // Delete Line [ansi.DL] + count := 1 + if n, ok := p.Param(0, 1); ok && n > 0 { + count = n + } + + b.DeleteLine(y, count, blankCell()) + } + case ansi.HasOscPrefix(seq): + switch p.Cmd() { + case 8: // Hyperlinks + handleHyperlinks(p, &link) + } + case ansi.HasEscPrefix(seq): + switch p.Cmd() { + case 'M': // Reverse Index [ansi.RI] + // Move the cursor up one line in the same column. If the + // cursor is at the top margin, the screen performs a scroll-up. + if y > opt.Area.Min.Y { + y-- + } + } + case seq == "\n", seq == "\v", seq == "\f": + if opt.NewLine { + x = opt.Area.Min.X + } + y++ + case seq == "\r": + x = opt.Area.Min.X + } + default: + // Should never happen + panic("invalid cell width") + } + + s = s[n:] + state = newState + } + + return x, y +} + +// Render renders the buffer to a string. +func (opt Options) Render(b *Buffer) string { + var buf bytes.Buffer + height := b.Height() + for y := 0; y < height; y++ { + _, line := renderLine(b, y, opt) + buf.WriteString(line) + if y < height-1 { + buf.WriteString("\r\n") + } + } + return buf.String() +} + +// RenderLine renders a single line of the buffer to a string. +// It returns the width of the line and the rendered string. +func (opt Options) RenderLine(b *Buffer, y int) (int, string) { + return renderLine(b, y, opt) +} + +// Span represents a span of cells with the same style and link. +type Span struct { + // Segment is the content of the span. + Segment + // Position is the starting position of the span. + Position +} + +// Diff computes the diff between two buffers as a slice of affected cells. It +// only returns affected cells withing the given rectangle. +func (opt Options) Diff(b, prev *Buffer) (diff []Span) { + if prev == nil { + return nil + } + + area := opt.Area + if area.Empty() { + area = b.Bounds() + } + + for y := area.Min.Y; y < area.Max.Y; y++ { + var span *Span + for x := area.Min.X; x < area.Max.X; x++ { + cellA := b.Cell(x, y) + cellB := prev.Cell(x, y) + + if cellA.Equal(cellB) { + continue + } + + if cellB == nil { + cellB = &BlankCell + } + + if span == nil { + span = &Span{ + Position: Pos(x, y), + Segment: cellB.Segment(), + } + continue + } + + if span.X+span.Width == x && + span.Style.Equal(cellB.Style) && + span.Link == cellB.Link { + span.Content += cellB.String() + span.Width += cellB.Width + continue + } + + diff = append(diff, *span) + span = &Span{ + Position: Pos(x, y), + Segment: cellB.Segment(), + } + } + + if span != nil { + diff = append(diff, *span) + } + } + + return +} diff --git a/cellbuf/screen.go b/cellbuf/screen.go index a8550e6d..97dd16af 100644 --- a/cellbuf/screen.go +++ b/cellbuf/screen.go @@ -4,93 +4,31 @@ import ( "bytes" "strings" - "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/x/ansi" ) // Segment represents a continuous segment of cells with the same style // attributes and hyperlink. -type Segment = Cell - -// Buffer represents a screen grid of cells. -type Buffer interface { - // Width returns the width of the grid. - Width() int - - // Height returns the height of the grid. - Height() int - - // Cell returns the cell at the given position. If the cell is out of - // bounds, it returns nil. - Cell(x, y int) *Cell - - // SetCell writes a cell to the grid at the given position. It returns true - // if the cell was written successfully. If the cell is nil, a blank cell - // is written. - SetCell(x, y int, c *Cell) bool -} - -// Resizable is an interface for buffers that can be resized. -type Resizable interface { - // Resize resizes the buffer to the given width and height. - Resize(width, height int) +type Segment struct { + Style Style + Link Link + Content string + Width int } // Paint writes the given data to the canvas. If rect is not nil, it only -// writes to the rectangle. Otherwise, it writes to the whole canvas. -func Paint(d Buffer, m Method, content string, rect *Rectangle) []int { - if rect == nil { - r := Rect(0, 0, d.Width(), d.Height()) - rect = &r - } - return setContent(d, content, m, *rect) -} - -// RenderOptions represents options for rendering a canvas. -type RenderOptions struct { - // Profile is the color profile to use when rendering the canvas. - Profile colorprofile.Profile -} - -// RenderOption is a function that configures a RenderOptions. -type RenderOption func(*RenderOptions) - -// WithRenderProfile sets the color profile to use when rendering the canvas. -func WithRenderProfile(p colorprofile.Profile) RenderOption { - return func(o *RenderOptions) { - o.Profile = p - } -} - -// Render returns a string representation of the grid with ANSI escape sequences. -func Render(d Buffer, opts ...RenderOption) string { - var opt RenderOptions - for _, o := range opts { - o(&opt) - } - var buf bytes.Buffer - height := d.Height() - for y := 0; y < height; y++ { - _, line := renderLine(d, y, opt) - buf.WriteString(line) - if y < height-1 { - buf.WriteString("\r\n") - } - } - return buf.String() +// writes to the rectangle. +func Paint(d Window, content string) []int { + return PaintRect(d, content, d.Bounds()) } -// RenderLine returns a string representation of the yth line of the grid along -// with the width of the line. -func RenderLine(d Buffer, n int, opts ...RenderOption) (w int, line string) { - var opt RenderOptions - for _, o := range opts { - o(&opt) - } - return renderLine(d, n, opt) +// 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) } -func renderLine(d Buffer, n int, opt RenderOptions) (w int, line string) { +func renderLine(d *Buffer, n int, opt Options) (w int, line string) { var pen Style var link Link var buf bytes.Buffer @@ -139,12 +77,12 @@ func renderLine(d Buffer, n int, opt RenderOptions) (w int, line string) { // We only write the cell content if it's not empty. If it is, we // append it to the pending line and width to be evaluated later. - if cell.Equal(&spaceCell) { - pendingLine += cell.Content + if cell.Equal(&BlankCell) { + pendingLine += cell.String() pendingWidth += cell.Width } else { writePending() - buf.WriteString(cell.Content) + buf.WriteString(cell.String()) w += cell.Width } } @@ -157,56 +95,3 @@ func renderLine(d Buffer, n int, opt RenderOptions) (w int, line string) { } return w, strings.TrimRight(buf.String(), " ") // Trim trailing spaces } - -// Fill fills the canvas with the given cell. If rect is not nil, it only fills -// the rectangle. Otherwise, it fills the whole canvas. -func Fill(d Buffer, c *Cell, rects ...Rectangle) { - if len(rects) == 0 { - fill(d, c, Rect(0, 0, d.Width(), d.Height())) - return - } - for _, rect := range rects { - fill(d, c, rect) - } -} - -func fill(d Buffer, c *Cell, rect Rectangle) { - cellWidth := 1 - if c != nil { - cellWidth = c.Width - } - for y := rect.Min.Y; y < rect.Max.Y; y++ { - for x := rect.Min.X; x < rect.Max.X; x += cellWidth { - d.SetCell(x, y, c) //nolint:errcheck - } - } -} - -// Clear clears the canvas with space cells. If rect is not nil, it only clears -// the rectangle. Otherwise, it clears the whole canvas. -func Clear(d Buffer, rects ...Rectangle) { - if len(rects) == 0 { - fill(d, nil, Rect(0, 0, d.Width(), d.Height())) - return - } - for _, rect := range rects { - fill(d, nil, rect) - } -} - -// Equal returns whether two grids are equal. -func Equal(a, b Buffer) bool { - if a.Width() != b.Width() || a.Height() != b.Height() { - return false - } - for y := 0; y < a.Height(); y++ { - for x := 0; x < a.Width(); x++ { - ca := a.Cell(x, y) - cb := b.Cell(x, y) - if ca != nil && cb != nil && !ca.Equal(cb) { - return false - } - } - } - return true -} diff --git a/cellbuf/screen_write.go b/cellbuf/screen_write.go index e1977903..ae25286a 100644 --- a/cellbuf/screen_write.go +++ b/cellbuf/screen_write.go @@ -6,14 +6,13 @@ import ( "unicode/utf8" "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/vt" "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 Buffer, + d Window, data string, method Method, rect Rectangle, @@ -38,36 +37,58 @@ func setContent( for len(data) > 0 { seq, width, n, newState := ansi.DecodeSequence(data, state, p) + var r rune + var comb []rune switch width { - case 2, 3, 4: // wide cells can go up to 4 cells wide - + case 1, 2, 3, 4: // wide cells can go up to 4 cells wide switch method { case WcWidth: - if r, rw := utf8.DecodeRuneInString(data); r != utf8.RuneError { - n = rw - width = wcwidth.RuneWidth(r) - seq = string(r) - newState = 0 + 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) + } + } } - fallthrough - case 1: - if x+width >= rect.X()+rect.Width() || y >= rect.Y()+rect.Height() { + + if x+width > rect.X()+rect.Width() || y > rect.Y()+rect.Height() { break } - cell.Content = seq + cell.Rune = r + cell.Comb = comb cell.Width = width cell.Style = pen cell.Link = link - d.SetCell(x, y, &cell) //nolint:errcheck + d.Draw(x, y, &cell) //nolint:errcheck // Advance the cursor and line width x += cell.Width - if cell.Equal(&spaceCell) { + if cell.Equal(&BlankCell) { pendingWidth += cell.Width } else if y := y - rect.Y(); y < len(linew) { linew[y] += cell.Width + pendingWidth @@ -90,16 +111,13 @@ func setContent( } case ansi.Equal(seq, "\n"): // Reset the rest of the line - for x < rect.X()+rect.Width() { - d.SetCell(x, y, nil) //nolint:errcheck - x++ - } + d.ClearInRect(Rect(x, y, rect.Width()+rect.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 = 0 + x = rect.X() } } @@ -108,9 +126,13 @@ func setContent( data = data[n:] } - for x < rect.X()+rect.Width() { - d.SetCell(x, y, nil) //nolint:errcheck - x++ + // Don't forget to clear the last line + d.ClearInRect(Rect(x, y, rect.Width()+rect.X()-x, 1)) + + y++ + if y < rect.Height() { + // Clear the rest of the lines + d.ClearInRect(Rect(rect.X(), y, rect.X()+rect.Width(), rect.Y()+rect.Height())) } return linew @@ -144,17 +166,17 @@ func handleSgr(p *ansi.Parser, pen *Style) { i++ switch nextParam { case 0: // No Underline - pen.UnderlineStyle(vt.NoUnderline) + pen.UnderlineStyle(NoUnderline) case 1: // Single Underline - pen.UnderlineStyle(vt.SingleUnderline) + pen.UnderlineStyle(SingleUnderline) case 2: // Double Underline - pen.UnderlineStyle(vt.DoubleUnderline) + pen.UnderlineStyle(DoubleUnderline) case 3: // Curly Underline - pen.UnderlineStyle(vt.CurlyUnderline) + pen.UnderlineStyle(CurlyUnderline) case 4: // Dotted Underline - pen.UnderlineStyle(vt.DottedUnderline) + pen.UnderlineStyle(DottedUnderline) case 5: // Dashed Underline - pen.UnderlineStyle(vt.DashedUnderline) + pen.UnderlineStyle(DashedUnderline) } } } else { diff --git a/cellbuf/style.go b/cellbuf/style.go index 0e32833f..82c4afb7 100644 --- a/cellbuf/style.go +++ b/cellbuf/style.go @@ -2,12 +2,8 @@ package cellbuf import ( "github.com/charmbracelet/colorprofile" - "github.com/charmbracelet/x/vt" ) -// Style represents the Style of a cell. -type Style = vt.Style - // Convert converts a style to respect the given color profile. func ConvertStyle(s Style, p colorprofile.Profile) Style { switch p { diff --git a/cellbuf/utils.go b/cellbuf/utils.go index 39afcc7e..b94d0302 100644 --- a/cellbuf/utils.go +++ b/cellbuf/utils.go @@ -40,3 +40,31 @@ func readColor(idxp *int, params []ansi.Parameter) (c ansi.Color) { } return } + +func min(a, b int) int { //nolint:predeclared + if a > b { + return b + } + return a +} + +func max(a, b int) int { //nolint:predeclared + if a > b { + return a + } + return b +} + +func clamp(v, low, high int) int { + if high < low { + low, high = high, low + } + return min(high, max(low, v)) +} + +func abs(a int) int { + if a < 0 { + return -a + } + return a +} diff --git a/cellbuf/window.go b/cellbuf/window.go new file mode 100644 index 00000000..deff2a7b --- /dev/null +++ b/cellbuf/window.go @@ -0,0 +1,1341 @@ +package cellbuf + +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) (seq string) { + 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 && fy+n >= s.newbuf.Height() + if lf := strings.Repeat("\n", n); yseq == "" || shouldScroll || fy+n < s.newbuf.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 yseq == "" || n == 1 && fy-1 > 0 { + // TODO: Ensure we're not unintentionally scrolling the screen up. + yseq = ansi.ReverseIndex + } + } + + seq += 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 cuf := ansi.CursorForward(n); xseq == "" || len(cuf) < len(xseq) { + xseq = cuf + } + + // OPTIM: Use [ansi.HT] and hard tabs as an optimization. + + // 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 && (xseq == "" || len(ovw) < len(xseq)) { + xseq = ovw + } + } else if tx < fx { + n := fx - tx + if cub := ansi.CursorBackward(n); xseq == "" || len(cub) < len(xseq) { + xseq = cub + } + + // OPTIM: Use back tabs as an optimization. + } + + seq += xseq + } + + return +} + +// 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 seq == "" || 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 seq == "" || 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 seq == "" || len(nseq) < len(seq) { + seq = nseq + } + } + + return +} + +// moveCursor moves the cursor to the specified position. +func (s *Screen) moveCursor(w io.Writer, x, y int, overwrite bool) { + io.WriteString(w, moveCursor(s, x, y, overwrite)) //nolint:errcheck + s.cur.X, s.cur.Y = x, y +} + +func (s *Screen) move(w io.Writer, x, y int) { + width, height := s.newbuf.Width(), s.newbuf.Height() + if s.cur.X == x && s.cur.Y == y || width <= 0 || height <= 0 { + return + } + + if x >= width { + // Handle autowrap + y += (x / width) + x %= width + } + + // Disable styles if there's any + var pen Style + if !s.cur.Style.Empty() { + pen = s.cur.Style + io.WriteString(w, 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 + io.WriteString(w, "\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(w, x, y, true) // Overwrite cells if possible + + if !pen.Empty() { + io.WriteString(w, 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 +} + +// Screen represents the terminal screen. +type Screen struct { + w io.Writer + curbuf *Buffer // the current buffer + newbuf *Buffer // the new buffer + queueAbove []string // the queue of strings to write above the screen + touch map[int][2]int + cur, saved Cursor // the current and saved cursors + pos Position // the position of the cursor after the last render + opts ScreenOptions + mu sync.Mutex + lastChar rune // the last character written to the screen + 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 +} + +var _ Window = &Screen{} + +// 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.ClearInRect(s.newbuf.Bounds()) +} + +// ClearInRect implements Window. +func (s *Screen) ClearInRect(r Rectangle) bool { + s.newbuf.ClearInRect(r) + s.mu.Lock() + for i := r.Min.Y; i < r.Max.Y; i++ { + s.touch[i] = [2]int{r.Min.X, r.Width() - 1} + } + s.mu.Unlock() + return true +} + +// Draw implements Window. +func (s *Screen) Draw(x int, y int, cell *Cell) (v bool) { + cellWidth := 1 + if cell != nil { + cellWidth = cell.Width + } + + s.mu.Lock() + chg := s.touch[y] + chg[0] = min(chg[0], x) + chg[1] = max(chg[1], x+cellWidth) + s.touch[y] = chg + s.mu.Unlock() + + return s.newbuf.Draw(x, y, cell) +} + +// Fill implements Window. +func (s *Screen) Fill(cell *Cell) bool { + return s.FillInRect(cell, s.newbuf.Bounds()) +} + +// FillInRect implements Window. +func (s *Screen) FillInRect(cell *Cell, r Rectangle) bool { + s.newbuf.FillInRect(cell, r) + s.mu.Lock() + for i := r.Min.Y; i < r.Max.Y; i++ { + s.touch[i] = [2]int{r.Min.X, r.Width() - 1} + } + s.mu.Unlock() + return true +} + +// isXtermLike returns whether the terminal is xterm-like. This means that the +// terminal supports ECMA-48 and ANSI X3.64 escape sequences. +func isXtermLike(termtype string) (v bool) { + parts := strings.Split(termtype, "-") + if len(parts) == 0 { + return + } + + switch parts[0] { + case + "alacritty", + "foot", + "ghostty", + "kitty", + "linux", + "screen", + "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.xtermLike = isXtermLike(s.opts.Term) + s.curbuf = NewBuffer(width, height) + s.newbuf = NewBuffer(width, height) + s.reset() + + return +} + +// 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) +} + +// cellRunes returns the runes of the cell content. A nil cell is considered a +// [BlankCell]. +func cellRunes(c *Cell) []rune { + if c == nil { + return []rune{BlankCell.Rune} + } + return append([]rune{c.Rune}, c.Comb...) +} + +// putCell draws a cell at the current cursor position. +func (s *Screen) putCell(w io.Writer, cell *Cell) { + if cell != nil && cell.Empty() { + return + } + + blank := s.clearBlank() + if cell == nil { + cell = blank + } + + s.updatePen(w, cell) + io.WriteString(w, cell.String()) //nolint:errcheck + s.cur.X += cell.Width + s.lastChar = cell.Rune + + if s.cur.X >= s.newbuf.Width() { + s.cur.X = s.newbuf.Width() - 1 + } +} + +// updatePen updates the cursor pen styles. +func (s *Screen) updatePen(w io.Writer, 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 + } + io.WriteString(w, seq) //nolint:errcheck + s.cur.Style = style + } + if !link.Equal(s.cur.Link) { + io.WriteString(w, 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(w io.Writer, line Line, n int) (eoi bool) { + for n > 0 { + var count int + for n > 1 && !cellEqual(line.At(0), line.At(1)) { + s.putCell(w, line.At(0)) + line = line[1:] + n-- + } + + cell0 := line[0] + if n == 1 { + s.putCell(w, cell0) + return + } + + 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(w, cell0) + io.WriteString(w, ech) //nolint:errcheck + + // If this is the last cell, we don't need to move the cursor. + if count < n { + s.move(w, s.cur.X+count, s.cur.Y) + } else { + return true // cursor in the middle + } + } else if runes := cellRunes(cell0); s.xtermLike && count > len(rep) && + len(runes) == 1 && runes[0] < 256 { + // We only support ASCII characters. Most terminals will handle + // non-ASCII characters correctly, but some might not. + // + // 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-- + } + + if runes[0] != s.lastChar { + cellWidth := 1 + if cell0 != nil { + cellWidth = cell0.Width + } + s.putCell(w, cell0) + repCount -= cellWidth + } + + s.updatePen(w, cell0) + io.WriteString(w, ansi.RepeatPreviousCharacter(repCount)) //nolint:errcheck + s.cur.X += repCount + if wrapPossible { + s.putCell(w, cell0) + } + } else { + for i := 0; i < count; i++ { + s.putCell(w, 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(w io.Writer, 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(w, newLine[start:], j-same-start) + s.move(w, y, j) + start = j + } + same = 0 + } + } + + i := s.emitRange(w, 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(w, newLine[start:], end-start+1) +} + +// clearToEnd clears the screen from the current cursor position to the end of +// line. +func (s *Screen) clearToEnd(w io.Writer, 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(w, blank) + count := s.newbuf.Width() - s.cur.X + eraseRight := ansi.EraseLineRight + if len(eraseRight) <= count { + io.WriteString(w, eraseRight) //nolint:errcheck + } else { + for i := 0; i < count; i++ { + s.putCell(w, 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(w io.Writer, line Line, count int) { + if s.xtermLike { + // Use [ansi.ICH] as an optimization. + io.WriteString(w, ansi.InsertCharacter(count)) //nolint:errcheck + } else { + // Otherwise, use [ansi.IRM] mode. + io.WriteString(w, ansi.SetInsertReplaceMode) //nolint:errcheck + } + + for i := 0; count > 0; i++ { + s.putCell(w, line[i]) + count-- + } + + if !s.xtermLike { + io.WriteString(w, ansi.ResetInsertReplaceMode) //nolint:errcheck + } +} + +// 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(w io.Writer, 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(w, 0, y) + s.clearToEnd(w, nil, false) + s.putRange(w, 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(w, 0, y) + s.updatePen(w, blank) + io.WriteString(w, ansi.EraseLineRight) //nolint:errcheck + } else { + s.move(w, nFirstCell-1, y) + s.updatePen(w, blank) + io.WriteString(w, 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(w, firstCell, y) + s.putRange(w, 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-- + } + + el0Cost := len(ansi.EraseLineRight) + if nLastCell == firstCell && el0Cost < oLastCell-nLastCell { + s.move(w, firstCell, y) + if !cellEqual(newLine.At(firstCell), blank) { + s.putCell(w, newLine.At(firstCell)) + } + s.clearToEnd(w, blank, false) + } else if nLastCell != oLastCell && + !cellEqual(newLine.At(nLastCell), oldLine.At(oLastCell)) { + s.move(w, firstCell, y) + if oLastCell-nLastCell > el0Cost { + if s.putRange(w, oldLine, newLine, y, firstCell, nLastCell) { + s.move(w, nLastCell, y) + } + s.clearToEnd(w, blank, false) + } else { + n := max(nLastCell, oLastCell) + s.putRange(w, 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(w, firstCell, y) + s.putRange(w, 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(w, n+1, y) + ichCost := 3 + nLastCell - oLastCell + if s.xtermLike && (nLastCell < nLastNonBlank || ichCost > (m-n)) { + s.putRange(w, oldLine, newLine, y, n+1, m) + } else { + s.insertCells(w, newLine[n+1:], nLastCell-oLastCell) + } + } else if oLastCell > nLastCell { + s.move(w, n+1, y) + dchCost := 3 + oLastCell - nLastCell + if dchCost > len(ansi.EraseLineRight)+nLastNonBlank-(n+1) { + if s.putRange(w, oldLine, newLine, y, n+1, nLastNonBlank) { + s.move(w, nLastNonBlank+1, y) + } + s.clearToEnd(w, blank, false) + } else { + s.updatePen(w, blank) + s.deleteCells(w, 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(w io.Writer, count int) { + // [ansi.DCH] will shift in cells from the right margin so we need to + // ensure that they are the right style. + io.WriteString(w, ansi.DeleteCharacter(count)) //nolint:errcheck +} + +// clearToBottom clears the screen from the current cursor position to the end +// of the screen. +func (s *Screen) clearToBottom(w io.Writer, blank *Cell) { + row, col := s.cur.Y, s.cur.X + if row < 0 { + row = 0 + } + if col < 0 { + col = 0 + } + + s.updatePen(w, blank) + io.WriteString(w, ansi.EraseScreenBelow) //nolint:errcheck + s.curbuf.ClearInRect(Rect(col, row, s.curbuf.Width(), row+1)) + s.curbuf.ClearInRect(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(w io.Writer, 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(w, 0, top, false) + s.clearToBottom(w, blank) + if !s.opts.AltScreen { + // Move to the last line of the screen + s.moveCursor(w, 0, s.newbuf.Height()-1, false) + } + // TODO: Line hashing + } + } + + return +} + +// clearScreen clears the screen and put cursor at home. +func (s *Screen) clearScreen(w io.Writer, blank *Cell) { + s.updatePen(w, blank) + io.WriteString(w, ansi.CursorHomePosition) //nolint:errcheck + io.WriteString(w, 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(w io.Writer, blank *Cell, row int) { + s.updatePen(w, blank) + s.moveCursor(w, 0, row, false) + s.clearToBottom(w, blank) + s.cur.X, s.cur.Y = 0, row + s.curbuf.FillInRect(blank, Rect(0, row, s.curbuf.Width(), s.curbuf.Height())) +} + +// clearUpdate forces a screen redraw. +func (s *Screen) clearUpdate(w io.Writer, partial bool) { + blank := s.clearBlank() + var nonEmpty int + if s.opts.AltScreen { + nonEmpty = min(s.curbuf.Height(), s.newbuf.Height()) + s.clearScreen(w, blank) + } else { + nonEmpty = s.newbuf.Height() + s.clearBelow(w, blank, 0) + } + nonEmpty = s.clearBottom(w, nonEmpty, partial) + for i := 0; i < nonEmpty; i++ { + s.transformLine(w, i) + } +} + +// Render implements Window. +func (s *Screen) Render() { + s.mu.Lock() + b := new(bytes.Buffer) + s.render(b) + // Write the buffer + if b.Len() > 0 { + s.w.Write(b.Bytes()) //nolint:errcheck + } + s.mu.Unlock() +} + +func (s *Screen) render(b *bytes.Buffer) { + // Do we need alt-screen mode? + if s.opts.AltScreen != s.altScreenMode { + if s.opts.AltScreen { + b.WriteString(ansi.SetAltScreenSaveCursorMode) + } else { + b.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 { + b.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(b, 0, s.newbuf.Height()-1, false) + b.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(b, 0, 0, false) + b.WriteString(ansi.InsertLine(len(s.queueAbove))) + for _, line := range s.queueAbove { + b.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(b, partialClear) + s.clear = false + } else if len(s.touch) > 0 { + 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(b, nonEmpty, partialClear) + for i = 0; i < nonEmpty; i++ { + _, wasTouched := s.touch[i] + if wasTouched { + s.transformLine(b, i) + changedLines++ + } + } + + // Mark changed lines + if i <= s.newbuf.Height() { + delete(s.touch, i) + } + } + + // Sync windows and screen + for i := 0; i <= s.newbuf.Height(); i++ { + delete(s.touch, i) + } + + 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(b, nil) // nil indicates a blank cell with no styles + + // Move the cursor to the specified position. + if s.pos != undefinedPos { + s.move(b, s.pos.X, s.pos.Y) + s.pos = undefinedPos + } + + if b.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(b.Bytes()) + nb.WriteString(ansi.ShowCursor) + *b = *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() + + b := new(bytes.Buffer) + s.render(b) + s.updatePen(b, nil) + s.move(b, 0, s.newbuf.Height()-1) + s.clearToEnd(b, nil, true) + + if s.cursorHidden { + b.WriteString(ansi.ShowCursor) + s.cursorHidden = false + } + + if s.altScreenMode { + b.WriteString(ansi.ResetAltScreenSaveCursorMode) + s.altScreenMode = false + } + + // Write the buffer + _, err = s.w.Write(b.Bytes()) + if err != nil { + return + } + + s.reset() + return +} + +// reset resets the screen to its initial state. +func (s *Screen) reset() { + s.lastChar = -1 + 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][2]int) + if s.curbuf != nil { + s.curbuf.Clear() + } + if s.newbuf != nil { + s.newbuf.Clear() + } +} + +// 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.ClearInRect(Rect(oldw-2, 0, width-oldw, height)) + } else if width < oldw { + s.ClearInRect(Rect(width-1, 0, oldw-width, height)) + } + + if height > oldh { + s.ClearInRect(Rect(0, oldh-1, width, height-oldh)) + } else if height < oldh { + s.ClearInRect(Rect(0, height-1, width, oldh-height)) + } + + s.newbuf.Resize(width, height) + + s.opts.Width, s.opts.Height = width, height + + return true +} + +// MoveTo moves the cursor to the specified position. +func (s *Screen) MoveTo(x, y int) bool { + pos := Pos(x, y) + if !s.Bounds().Contains(pos) { + return false + } + s.pos = pos + 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().Width(), s.Bounds().Height() + 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 + FillInRect(cell *Cell, r Rectangle) bool + Clear() bool + ClearInRect(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 !w.Bounds().Contains(pos) { + 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 { + if !Pos(x, y).In(w.Bounds().Rectangle) { + 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.FillInRect(cell, w.Bounds()) +} + +// FillInRect fills the cells in the specified rectangle with the specified +// cell. +func (w *SubWindow) FillInRect(cell *Cell, r Rectangle) bool { + if !r.In(w.Bounds().Rectangle) { + return false + } + + w.scr.FillInRect(cell, r) + return true +} + +// Clear implements Window. +func (w *SubWindow) Clear() bool { + return w.ClearInRect(w.Bounds()) +} + +// ClearInRect clears the cells in the specified rectangle based on the current +// cursor background color. Use [SetPen] to set the background color. +func (w *SubWindow) ClearInRect(r Rectangle) bool { + if !r.In(w.Bounds().Rectangle) { + return false + } + + w.scr.ClearInRect(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().Rectangle) { + 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().Width() == width && w.Bounds().Height() == height { + return true + } + + x, y := w.bounds.Min.X, w.bounds.Min.Y + scrw, scrh := w.scr.Bounds().Width(), w.scr.Bounds().Height() + if x+width > scrw || y+height > scrh { + return false + } + + w.bounds = Rect(x, y, width, height) + return true +} diff --git a/examples/cellbuf/main.go b/examples/cellbuf/main.go index 10495e48..c99e212b 100644 --- a/examples/cellbuf/main.go +++ b/examples/cellbuf/main.go @@ -9,7 +9,6 @@ import ( "github.com/charmbracelet/x/cellbuf" "github.com/charmbracelet/x/input" "github.com/charmbracelet/x/term" - "github.com/charmbracelet/x/vt" ) func main() { @@ -23,31 +22,75 @@ func main() { log.Fatalf("making raw: %v", err) } - defer term.Restore(os.Stdin.Fd(), state) + defer term.Restore(os.Stdin.Fd(), state) //nolint:errcheck - drv, err := input.NewDriver(os.Stdin, os.Getenv("TERM"), 0) + const altScreen = true + if !altScreen { + h = 10 + } + + termType := os.Getenv("TERM") + scr := cellbuf.NewScreen(os.Stdout, &cellbuf.ScreenOptions{ + Width: w, + Height: h, + Term: termType, + RelativeCursor: !altScreen, + AltScreen: altScreen, + }) + + defer scr.Close() //nolint:errcheck + + drv, err := input.NewDriver(os.Stdin, termType, 0) if err != nil { log.Fatalf("creating input driver: %v", err) } - os.Stdout.WriteString(ansi.EnableAltScreenBuffer + ansi.EnableMouseCellMotion + ansi.EnableMouseSgrExt) - defer os.Stdout.WriteString(ansi.DisableMouseSgrExt + ansi.DisableMouseCellMotion + ansi.DisableAltScreenBuffer) + modes := []ansi.Mode{ + ansi.ButtonEventMouseMode, + ansi.SgrExtMouseMode, + } + + os.Stdout.WriteString(ansi.SetMode(modes...)) //nolint:errcheck + defer os.Stdout.WriteString(ansi.ResetMode(modes...)) //nolint:errcheck - var style cellbuf.Style - buf := vt.NewBuffer(w, h) - style.Reverse(true) x, y := (w/2)-8, h/2 - reset(buf, x, y) + render := func() { + scr.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) + scr.Render() + } + + resize := func(nw, nh int) { + if !altScreen { + nh = h + w = nw + } + scr.Resize(nw, nh) + } if runtime.GOOS != "windows" { // Listen for resize events go listenForResize(func() { - updateWinsize(buf) - reset(buf, x, y) + nw, nh, _ := term.GetSize(os.Stdout.Fd()) + resize(nw, nh) }) } + // First render + render() + for { evs, err := drv.ReadEvents() if err != nil { @@ -57,38 +100,33 @@ func main() { for _, ev := range evs { switch ev := ev.(type) { case input.WindowSizeEvent: - updateWinsize(buf) + resize(ev.Width, ev.Height) case input.MouseClickEvent: x, y = ev.X, ev.Y case input.KeyPressEvent: switch ev.String() { case "ctrl+c", "q": return - case "left": + case "left", "h": x-- - case "down": + case "down", "j": y++ - case "up": + case "up", "k": y-- - case "right": + case "right", "l": x++ } } } - reset(buf, x, y) + render() } } -func reset(buf cellbuf.Buffer, x, y int) { - cellbuf.Fill(buf, vt.NewCell('你')) - rect := cellbuf.Rect(x, y, 16, 1) - cellbuf.Paint(buf, cellbuf.WcWidth, "\x1b[7m !Hello, world! \x1b[m", &rect) - os.Stdout.WriteString(ansi.SetCursorPosition(1, 1) + cellbuf.Render(buf)) -} - -func updateWinsize(buf cellbuf.Resizable) (w, h int) { - w, h, _ = term.GetSize(os.Stdout.Fd()) - buf.Resize(w, h) - return +func init() { + f, err := os.OpenFile("cellbuf.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o666) + if err != nil { + log.Fatal(err) + } + log.SetOutput(f) } diff --git a/examples/go.mod b/examples/go.mod index 7186b4dc..e3188362 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -3,24 +3,25 @@ module github.com/charmbracelet/x/examples go 1.18 require ( - github.com/charmbracelet/x/ansi v0.4.5 + github.com/charmbracelet/colorprofile v0.1.8 + github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20241204155804-59cbf2850015 + github.com/charmbracelet/x/ansi v0.5.1 github.com/charmbracelet/x/cellbuf v0.0.6-0.20241106170917-eb0997d7d743 github.com/charmbracelet/x/input v0.2.0 - github.com/charmbracelet/x/vt v0.0.0-20241119170456-6066f8aa557d github.com/creack/pty v1.1.24 + github.com/lucasb-eyer/go-colorful v1.2.0 ) require ( - github.com/charmbracelet/colorprofile v0.1.7 // indirect + github.com/charmbracelet/x/windows v0.2.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect ) require ( - github.com/charmbracelet/x/term v0.2.0 + github.com/charmbracelet/x/term v0.2.1 github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/rivo/uniseg v0.4.7 // indirect + github.com/rivo/uniseg v0.4.7 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.20.0 // indirect diff --git a/examples/go.sum b/examples/go.sum index 831838cc..00361016 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,17 +1,17 @@ -github.com/charmbracelet/colorprofile v0.1.7 h1:q7PtMQrRBBnLNE2EbtbNUtouu979EivKcDGGaimhyO8= -github.com/charmbracelet/colorprofile v0.1.7/go.mod h1:d3UYToTrNmsD2p9/lbiya16H1WahndM0miDlJWXWf4U= -github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= -github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/colorprofile v0.1.8 h1:PywDeXsiAzlPtkiiKgMEVLvb6nlEuKrMj9+FJBtj4jU= +github.com/charmbracelet/colorprofile v0.1.8/go.mod h1:+jpmObxZl1Dab3H3IMVIPSZTsKcFpjJUv97G0dLqM60= +github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20241204155804-59cbf2850015 h1:adBE3DiFDXiklwG9LqTDPmJaEbZoHzjmiMLwiWaB/Fs= +github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20241204155804-59cbf2850015/go.mod h1:F/6E/LGdH3eHCJf2rG8/O3CjlW8cZFL5YJCknJs1GkI= +github.com/charmbracelet/x/ansi v0.5.1 h1:+mg6abP9skvsu/JQZrIJ9Z/4O1YDnLVkpfutar3dUnc= +github.com/charmbracelet/x/ansi v0.5.1/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= github.com/charmbracelet/x/cellbuf v0.0.6-0.20241106170917-eb0997d7d743 h1:iKVWAITVASXoMQGoesfzRP81cAkr/GAR3TkOYN2n3WU= github.com/charmbracelet/x/cellbuf v0.0.6-0.20241106170917-eb0997d7d743/go.mod h1:FaDNlvrPSNCEnih536+xi5f8iqxRQfDA20TVTs30CPU= -github.com/charmbracelet/x/input v0.2.0 h1:1Sv+y/flcqUfUH2PXNIDKDIdT2G8smOnGOgawqhwy8A= -github.com/charmbracelet/x/input v0.2.0/go.mod h1:KUSFIS6uQymtnr5lHVSOK9j8RvwTD4YHnWnzJUYnd/M= -github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= -github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= -github.com/charmbracelet/x/vt v0.0.0-20241119170456-6066f8aa557d h1:2SCDh93gnBiFHson6DgATBwQbJnSGvASLRVEekudl+o= -github.com/charmbracelet/x/vt v0.0.0-20241119170456-6066f8aa557d/go.mod h1:+CYC0tzYqYMtIryA0lcGQgCUaAiRLaS7Rxi9R+PFii8= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 h1:D5OO0lVavz7A+Swdhp62F9gbkibxmz9B2hZ/jVdMPf0= github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91/go.mod h1:Ey8PFmYwH+/td9bpiEx07Fdx9ZVkxfIjWXxBluxF4Nw= +github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= +github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= diff --git a/examples/layout/main.go b/examples/layout/main.go new file mode 100644 index 00000000..bdbf046f --- /dev/null +++ b/examples/layout/main.go @@ -0,0 +1,522 @@ +package main + +// This example demonstrates various Lip Gloss style and layout features. + +import ( + "fmt" + "image/color" + "log" + "os" + "strings" + + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/cellbuf" + "github.com/charmbracelet/x/input" + "github.com/charmbracelet/x/term" + "github.com/lucasb-eyer/go-colorful" + "github.com/rivo/uniseg" +) + +const ( + // In real life situations we'd adjust the document to fit the width we've + // detected. In the case of this example we're hardcoding the width, and + // later using the detected width only to truncate in order to avoid jaggy + // wrapping. + width = 96 + + // How wide to render various columns in the layout. + columnWidth = 30 +) + +var ( + // Whether the detected background color is dark. We detect this in init(). + hasDarkBG bool + + // A helper function for choosing either a light or dark color based on the + // detected background color. We create this in init(). + lightDark lipgloss.LightDarkFunc +) + +func init() { + // Detect the background color. + hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + + // Create a new helper function for choosing either a light or dark color + // based on the detected background color. + lightDark = lipgloss.LightDark(hasDarkBG) +} + +func main() { + // Style definitions. + var ( + + // General. + + subtle = lightDark(lipgloss.Color("#D9DCCF"), lipgloss.Color("#383838")) + highlight = lightDark(lipgloss.Color("#874BFD"), lipgloss.Color("#7D56F4")) + special = lightDark(lipgloss.Color("#43BF6D"), lipgloss.Color("#73F59F")) + + divider = lipgloss.NewStyle(). + SetString("•"). + Padding(0, 1). + Foreground(subtle). + String() + + url = lipgloss.NewStyle().Foreground(special).Render + + // Tabs. + + activeTabBorder = lipgloss.Border{ + Top: "─", + Bottom: " ", + Left: "│", + Right: "│", + TopLeft: "╭", + TopRight: "╮", + BottomLeft: "┘", + BottomRight: "└", + } + + tabBorder = lipgloss.Border{ + Top: "─", + Bottom: "─", + Left: "│", + Right: "│", + TopLeft: "╭", + TopRight: "╮", + BottomLeft: "┴", + BottomRight: "┴", + } + + tab = lipgloss.NewStyle(). + Border(tabBorder, true). + BorderForeground(highlight). + Padding(0, 1) + + activeTab = tab.Border(activeTabBorder, true) + + tabGap = tab. + BorderTop(false). + BorderLeft(false). + BorderRight(false) + + // Title. + + titleStyle = lipgloss.NewStyle(). + MarginLeft(1). + MarginRight(5). + Padding(0, 1). + Italic(true). + Foreground(lipgloss.Color("#FFF7DB")). + SetString("Lip Gloss") + + descStyle = lipgloss.NewStyle().MarginTop(1) + + infoStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderTop(true). + BorderForeground(subtle) + + // Dialog. + + dialogBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#874BFD")). + Padding(1, 0). + BorderTop(true). + BorderLeft(true). + BorderRight(true). + BorderBottom(true) + + buttonStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFF7DB")). + Background(lipgloss.Color("#888B7E")). + Padding(0, 3). + MarginTop(1) + + activeButtonStyle = buttonStyle. + Foreground(lipgloss.Color("#FFF7DB")). + Background(lipgloss.Color("#F25D94")). + MarginRight(2). + Underline(true) + + // List. + + list = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, true, false, false). + BorderForeground(subtle). + MarginRight(2). + Height(8). + Width(columnWidth + 1) + + listHeader = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(subtle). + MarginRight(2). + Render + + listItem = lipgloss.NewStyle().PaddingLeft(2).Render + + checkMark = lipgloss.NewStyle().SetString("✓"). + Foreground(special). + PaddingRight(1). + String() + + listDone = func(s string) string { + return checkMark + lipgloss.NewStyle(). + Strikethrough(true). + Foreground(lightDark(lipgloss.Color("#969B86"), lipgloss.Color("#696969"))). + Render(s) + } + + // Paragraphs/History. + + historyStyle = lipgloss.NewStyle(). + Align(lipgloss.Left). + Foreground(lipgloss.Color("#FAFAFA")). + Background(highlight). + Margin(1, 3, 0, 0). + Padding(1, 2). + Height(19). + Width(columnWidth) + + // Status Bar. + + statusNugget = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFDF5")). + Padding(0, 1) + + statusBarStyle = lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("#343433"), lipgloss.Color("#C1C6B2"))). + Background(lightDark(lipgloss.Color("#D9DCCF"), lipgloss.Color("#353533"))) + + statusStyle = lipgloss.NewStyle(). + Inherit(statusBarStyle). + Foreground(lipgloss.Color("#FFFDF5")). + Background(lipgloss.Color("#FF5F87")). + Padding(0, 1). + MarginRight(1) + + encodingStyle = statusNugget. + Background(lipgloss.Color("#A550DF")). + Align(lipgloss.Right) + + statusText = lipgloss.NewStyle().Inherit(statusBarStyle) + + fishCakeStyle = statusNugget.Background(lipgloss.Color("#6124DF")) + + // Page. + + docStyle = lipgloss.NewStyle().Padding(1, 2, 1, 2) + ) + + physicalWidth, physicalHeight, _ := term.GetSize(os.Stdout.Fd()) + doc := strings.Builder{} + + // Tabs. + { + row := lipgloss.JoinHorizontal( + lipgloss.Top, + activeTab.Render("Lip Gloss"), + tab.Render("Blush"), + tab.Render("Eye Shadow"), + tab.Render("Mascara"), + tab.Render("Foundation"), + ) + gap := tabGap.Render(strings.Repeat(" ", max(0, width-lipgloss.Width(row)-2))) + row = lipgloss.JoinHorizontal(lipgloss.Bottom, row, gap) + doc.WriteString(row + "\n\n") + } + + // Title. + { + var ( + colors = colorGrid(1, 5) + title strings.Builder + ) + + for i, v := range colors { + const offset = 2 + c := lipgloss.Color(v[0]) + fmt.Fprint(&title, titleStyle.MarginLeft(i*offset).Background(c)) + if i < len(colors)-1 { + title.WriteRune('\n') + } + } + + desc := lipgloss.JoinVertical(lipgloss.Left, + descStyle.Render("Style Definitions for Nice Terminal Layouts"), + infoStyle.Render("From Charm"+divider+url("https://github.com/charmbracelet/lipgloss")), + ) + + row := lipgloss.JoinHorizontal(lipgloss.Top, title.String(), desc) + doc.WriteString(row + "\n\n") + } + + // Dialog. + okButton := activeButtonStyle.Render("Yes") + cancelButton := buttonStyle.Render("Maybe") + + grad := applyGradient( + lipgloss.NewStyle(), + "Are you sure you want to eat marmalade?", + lipgloss.Color("#EDFF82"), + lipgloss.Color("#F25D94"), + ) + + question := lipgloss.NewStyle(). + Width(50). + Align(lipgloss.Center). + Render(grad) + + buttons := lipgloss.JoinHorizontal(lipgloss.Top, okButton, cancelButton) + dialogUI := lipgloss.JoinVertical(lipgloss.Center, question, buttons) + + dialog := lipgloss.Place(width, 9, + lipgloss.Center, lipgloss.Center, + "", + // dialogBoxStyle.Render(dialogUi), + lipgloss.WithWhitespaceChars("猫咪"), + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Foreground(subtle)), + ) + + doc.WriteString(dialog + "\n\n") + + // Color grid. + colors := func() string { + colors := colorGrid(14, 8) + + b := strings.Builder{} + for _, x := range colors { + for _, y := range x { + s := lipgloss.NewStyle().SetString(" ").Background(lipgloss.Color(y)) + b.WriteString(s.String()) + } + b.WriteRune('\n') + } + + return b.String() + }() + + lists := lipgloss.JoinHorizontal(lipgloss.Top, + list.Render( + lipgloss.JoinVertical(lipgloss.Left, + listHeader("Citrus Fruits to Try"), + listDone("Grapefruit"), + listDone("Yuzu"), + listItem("Citron"), + listItem("Kumquat"), + listItem("Pomelo"), + ), + ), + list.Width(columnWidth).Render( + lipgloss.JoinVertical(lipgloss.Left, + listHeader("Actual Lip Gloss Vendors"), + listItem("Glossier"), + listItem("Claire‘s Boutique"), + listDone("Nyx"), + listItem("Mac"), + listDone("Milk"), + ), + ), + ) + + doc.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lists, colors)) + + // Marmalade history. + { + const ( + historyA = "The Romans learned from the Greeks that quinces slowly cooked with honey would “set” when cool. The Apicius gives a recipe for preserving whole quinces, stems and leaves attached, in a bath of honey diluted with defrutum: Roman marmalade. Preserves of quince and lemon appear (along with rose, apple, plum and pear) in the Book of ceremonies of the Byzantine Emperor Constantine VII Porphyrogennetos." + historyB = "Medieval quince preserves, which went by the French name cotignac, produced in a clear version and a fruit pulp version, began to lose their medieval seasoning of spices in the 16th century. In the 17th century, La Varenne provided recipes for both thick and clear cotignac." + historyC = "In 1524, Henry VIII, King of England, received a “box of marmalade” from Mr. Hull of Exeter. This was probably marmelada, a solid quince paste from Portugal, still made and sold in southern Europe today. It became a favourite treat of Anne Boleyn and her ladies in waiting." + ) + + doc.WriteString(lipgloss.JoinHorizontal( + lipgloss.Top, + historyStyle.Align(lipgloss.Right).Render(historyA), + historyStyle.Align(lipgloss.Center).Render(historyB), + historyStyle.MarginRight(0).Render(historyC), + )) + + doc.WriteString("\n\n") + } + + // Status bar. + { + w := lipgloss.Width + + lightDarkState := "Light" + if hasDarkBG { + lightDarkState = "Dark" + } + + statusKey := statusStyle.Render("STATUS") + encoding := encodingStyle.Render("UTF-8") + fishCake := fishCakeStyle.Render("🍥 Fish Cake") + statusVal := statusText. + Width(width - w(statusKey) - w(encoding) - w(fishCake)). + Render("Ravishingly " + lightDarkState + "!") + + bar := lipgloss.JoinHorizontal(lipgloss.Top, + statusKey, + statusVal, + encoding, + fishCake, + ) + + doc.WriteString(statusBarStyle.Width(width).Render(bar)) + } + + if physicalWidth > 0 { + docStyle = docStyle.MaxWidth(physicalWidth) + } + + termType := os.Getenv("TERM") + scr := cellbuf.NewScreen(os.Stdout, &cellbuf.ScreenOptions{ + Term: termType, + Width: physicalWidth, + Height: physicalHeight, + Profile: colorprofile.Detect(os.Stdout, os.Environ()), + AltScreen: true, + }) + + defer scr.Close() //nolint:errcheck + + // Enable mouse events. + modes := []ansi.Mode{ + ansi.ButtonEventMouseMode, + ansi.SgrExtMouseMode, + } + + os.Stdout.WriteString(ansi.SetMode(modes...)) //nolint:errcheck + defer os.Stdout.WriteString(ansi.ResetMode(modes...)) //nolint:errcheck + + state, err := term.MakeRaw(os.Stdin.Fd()) + if err != nil { + log.Fatalf("making raw: %v", err) + } + + defer term.Restore(os.Stdin.Fd(), state) //nolint:errcheck + + drv, err := input.NewDriver(os.Stdin, termType, 0) + if err != nil { + log.Fatalf("creating input driver: %v", err) + } + + dialogWidth := lipgloss.Width(dialogUI) + dialogBoxStyle.GetHorizontalFrameSize() + dialogHeight := lipgloss.Height(dialogUI) + dialogBoxStyle.GetVerticalFrameSize() + dialogX, dialogY := physicalWidth/2-dialogWidth/2-docStyle.GetVerticalFrameSize()-1, 12 + render := func() { + cellbuf.Paint(scr, docStyle.Render(doc.String())) + cellbuf.PaintRect(scr, dialogBoxStyle.Render(dialogUI), cellbuf.Rect(dialogX, dialogY, dialogWidth, dialogHeight)) + scr.Render() + } + + // First render + render() + + for { + evs, err := drv.ReadEvents() + if err != nil { + log.Fatalf("reading events: %v", err) + } + + for _, ev := range evs { + switch ev := ev.(type) { + case input.WindowSizeEvent: + scr.Resize(ev.Width, ev.Height) + case input.MouseClickEvent: + dialogX, dialogY = ev.X, ev.Y + case input.KeyPressEvent: + switch ev.String() { + case "ctrl+c", "q": + return + case "left", "h": + dialogX-- + case "down", "j": + dialogY++ + case "up", "k": + dialogY-- + case "right", "l": + dialogX++ + } + } + } + + render() + } +} + +func colorGrid(xSteps, ySteps int) [][]string { + x0y0, _ := colorful.Hex("#F25D94") + x1y0, _ := colorful.Hex("#EDFF82") + x0y1, _ := colorful.Hex("#643AFF") + x1y1, _ := colorful.Hex("#14F9D5") + + x0 := make([]colorful.Color, ySteps) + for i := range x0 { + x0[i] = x0y0.BlendLuv(x0y1, float64(i)/float64(ySteps)) + } + + x1 := make([]colorful.Color, ySteps) + for i := range x1 { + x1[i] = x1y0.BlendLuv(x1y1, float64(i)/float64(ySteps)) + } + + grid := make([][]string, ySteps) + for x := 0; x < ySteps; x++ { + y0 := x0[x] + grid[x] = make([]string, xSteps) + for y := 0; y < xSteps; y++ { + grid[x][y] = y0.BlendLuv(x1[x], float64(y)/float64(xSteps)).Hex() + } + } + + return grid +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +// applyGradient applies a gradient to the given string string. +func applyGradient(base lipgloss.Style, input string, from, to color.Color) string { + // We want to get the graphemes of the input string, which is the number of + // characters as a human would see them. + // + // We definitely don't want to use len(), because that returns the + // bytes. The rune count would get us closer but there are times, like with + // emojis, where the rune count is greater than the number of actual + // characters. + g := uniseg.NewGraphemes(input) + var chars []string + for g.Next() { + chars = append(chars, g.Str()) + } + + // Genrate the blend. + a, _ := colorful.MakeColor(to) + b, _ := colorful.MakeColor(from) + var output strings.Builder + var hex string + for i := 0; i < len(chars); i++ { + hex = a.BlendLuv(b, float64(i)/float64(len(chars)-1)).Hex() + output.WriteString(base.Foreground(lipgloss.Color(hex)).Render(chars[i])) + } + + return output.String() +} + +func init() { + f, err := os.OpenFile("layout.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o666) + if err != nil { + log.Fatal(err) + } + log.SetOutput(f) +}