Bundle a view's cell buffer and write state into a viewBuffer

The fields that make up a view's content and the act of writing to it —
the cell buffer (lines), the write cursor (wx/wy), the escape-sequence
decoder (ei) and the held-newline flag (pendingNewline) — were loose
fields on View. Bundle them into a viewBuffer struct that View holds by
pointer. This is a behaviour-preserving prep refactor: every access just
goes through v.buf now. It sets up rendering into a second, off-screen
viewBuffer that can be swapped in atomically, so an async re-render never
exposes a half-written buffer to readers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-08-08 19:12:31 +02:00
parent 114d18dc04
commit 2a6cb8d78e
3 changed files with 153 additions and 134 deletions

View file

@ -1497,7 +1497,7 @@ func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error {
// drawListFooter draws the footer of a list view, showing something like '1 of 10'
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
if len(v.lines) == 0 {
if len(v.buf.lines) == 0 {
return nil
}
@ -1747,13 +1747,13 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
if newY < 0 {
newY = 0
newCy = -v.oy
} else if newY >= len(v.lines) {
newY = len(v.lines) - 1
} else if newY >= len(v.buf.lines) {
newY = len(v.buf.lines) - 1
newCy = newY - v.oy
}
visibleLineWidth := 0
for _, c := range v.lines[newY].cells {
for _, c := range v.buf.lines[newY].cells {
visibleLineWidth += c.width
}
if visibleLineWidth < newX {

View file

@ -25,17 +25,44 @@ const (
RIGHT = 8 // view is overlapping at right edge
)
// viewBuffer holds a view's content as cells, together with the cursor and
// escape-sequence decoder state used to turn incoming bytes into those cells.
// A view normally has a single buffer (the one it displays), but bundling this
// state lets a re-render build a second, off-screen buffer and swap it in
// atomically once the new content is ready, so no reader ever sees a
// half-written buffer.
type viewBuffer struct {
// the view's content: one []cell per unwrapped line
lines []lineType
// write cursor into lines
wx, wy int
// decodes ESC sequences as bytes are written
ei *escapeInterpreter
// If the last character written was a newline, we don't write it but instead
// set pendingNewline to true. If more text is written, we write the newline
// then. This avoids an extra blank line at the end of the view.
pendingNewline bool
}
// A View is a window. It maintains its own internal buffer and cursor
// position.
type View struct {
name string
x0, y0, x1, y1 int // left top right bottom
ox, oy int // view offsets
cx, cy int // cursor position
rx, ry int // Read() offsets
wx, wy int // Write() offsets
lines []lineType // All the data
x0, y0, x1, y1 int // left top right bottom
ox, oy int // view offsets
cx, cy int // cursor position
rx, ry int // Read() offsets
outMode OutputMode
// buf bundles the view's cell buffer and the cursor / escape-parser state
// used to write into it (see the viewBuffer type). Bundling these makes it
// possible to build a second, off-screen buffer during a re-render and swap
// it in atomically once ready, so no reader ever sees a half-written buffer.
buf *viewBuffer
// The y position of the first line of a range selection.
// This is not relative to the view's origin: it is relative to the first line
// of the view's content, so you can scroll the view and this value will remain
@ -74,17 +101,9 @@ type View struct {
// true and viewLines to nil
viewLines []viewLine
// If the last character written was a newline, we don't write it but
// instead set pendingNewline to true. If more text is written, we write the
// newline then. This is to avoid having an extra blank at the end of the view.
pendingNewline bool
// writeMutex protects locks the write process
writeMutex sync.Mutex
// ei is used to decode ESC sequences on Write
ei *escapeInterpreter
// Visible specifies whether the view is visible.
Visible bool
@ -461,7 +480,7 @@ type SearchPosition struct {
}
type viewLine struct {
linesX, linesY int // coordinates relative to v.lines
linesX, linesY int // coordinates relative to v.buf.lines
line []cell
// Colors used to extend the bg past this wrapped segment's content.
@ -470,7 +489,7 @@ type viewLine struct {
trailingFillAttributes *trailingFillAttributes
}
// lineType is one of v.lines: the cells of a source line, plus optional
// lineType is one of v.buf.lines: the cells of a source line, plus optional
// trailingFillAttributes recording the colors used to extend the bg
// past the line's content when the writer emitted '\x1b[K'.
type lineType struct {
@ -536,7 +555,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
Editor: DefaultEditor,
tainted: true,
outMode: mode,
ei: newEscapeInterpreter(mode),
buf: &viewBuffer{ei: newEscapeInterpreter(mode)},
searcher: &searcher{},
TextArea: &TextArea{},
rangeSelectStartY: -1,
@ -547,7 +566,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault
v.InactiveViewSelBgColor = ColorDefault
v.TitleColor, v.FrameColor = ColorDefault, ColorDefault
v.ei.screenColMax = v.InnerWidth()
v.buf.ei.screenColMax = v.InnerWidth()
return v
}
@ -558,7 +577,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
// content can consult this snapshot instead of reading the view's live
// dimensions (which the UI thread mutates during layout).
func (v *View) SetContentWidth(width int) {
v.ei.screenColMax = width
v.buf.ei.screenColMax = width
}
// Dimensions returns the dimensions of the View
@ -748,16 +767,16 @@ func (v *View) SetWritePos(x, y int) {
y = 0
}
v.wx = x
v.wy = y
v.buf.wx = x
v.buf.wy = y
// Changing the write position makes a pending newline obsolete
v.pendingNewline = false
v.buf.pendingNewline = false
}
// WritePos returns the current write position of the view's internal buffer.
func (v *View) WritePos() (x, y int) {
return v.wx, v.wy
return v.buf.wx, v.buf.wy
}
// SetReadPos sets the read position of the view's internal buffer.
@ -785,52 +804,52 @@ func (v *View) makeWriteable(x, y int) {
// TODO: make this more efficient
// line `y` must be index-able (that's why `<=`)
for len(v.lines) <= y {
if cap(v.lines) > len(v.lines) {
newLen := cap(v.lines)
for len(v.buf.lines) <= y {
if cap(v.buf.lines) > len(v.buf.lines) {
newLen := cap(v.buf.lines)
if newLen > y {
newLen = y + 1
}
v.lines = v.lines[:newLen]
v.buf.lines = v.buf.lines[:newLen]
} else {
v.lines = append(v.lines, lineType{})
v.buf.lines = append(v.buf.lines, lineType{})
}
}
// cell `x` need not be index-able (that's why `<`)
// append should be used by `lines[y]` user if he wants to write beyond `x`
for len(v.lines[y].cells) < x {
if cap(v.lines[y].cells) > len(v.lines[y].cells) {
newLen := cap(v.lines[y].cells)
for len(v.buf.lines[y].cells) < x {
if cap(v.buf.lines[y].cells) > len(v.buf.lines[y].cells) {
newLen := cap(v.buf.lines[y].cells)
if newLen > x {
newLen = x
}
v.lines[y].cells = v.lines[y].cells[:newLen]
v.buf.lines[y].cells = v.buf.lines[y].cells[:newLen]
} else {
v.lines[y].cells = append(v.lines[y].cells, cell{})
v.buf.lines[y].cells = append(v.buf.lines[y].cells, cell{})
}
}
}
// writeCells copies []cell to (v.wx, v.wy), and advances v.wx accordingly.
// writeCells copies []cell to (v.buf.wx, v.buf.wy), and advances v.buf.wx accordingly.
// !!! caller MUST ensure that specified location (x, y) is writeable by calling makeWriteable
func (v *View) writeCells(cells []cell) {
var newLen int
// use maximum len available
line := v.lines[v.wy].cells[:cap(v.lines[v.wy].cells)]
maxCopy := len(line) - v.wx
line := v.buf.lines[v.buf.wy].cells[:cap(v.buf.lines[v.buf.wy].cells)]
maxCopy := len(line) - v.buf.wx
if maxCopy < len(cells) {
copy(line[v.wx:], cells[:maxCopy])
copy(line[v.buf.wx:], cells[:maxCopy])
line = append(line, cells[maxCopy:]...)
newLen = len(line)
} else { // maxCopy >= len(cells)
copy(line[v.wx:], cells)
newLen = v.wx + len(cells)
if newLen < len(v.lines[v.wy].cells) {
newLen = len(v.lines[v.wy].cells)
copy(line[v.buf.wx:], cells)
newLen = v.buf.wx + len(cells)
if newLen < len(v.buf.lines[v.buf.wy].cells) {
newLen = len(v.buf.lines[v.buf.wy].cells)
}
}
v.lines[v.wy].cells = line[:newLen]
v.wx += len(cells)
v.buf.lines[v.buf.wy].cells = line[:newLen]
v.buf.wx += len(cells)
}
// Write appends a byte slice into the view's internal buffer. Because
@ -848,35 +867,35 @@ func (v *View) Write(p []byte) (n int, err error) {
func (v *View) write(p []byte) {
v.tainted = true
// write only ever touches lines from v.wy onwards, so any cached wrapping
// write only ever touches lines from v.buf.wy onwards, so any cached wrapping
// below that stays valid.
v.firstDirtyLine = min(v.firstDirtyLine, v.wy)
v.firstDirtyLine = min(v.firstDirtyLine, v.buf.wy)
v.clearHover()
// Fill with empty cells, if writing outside current view buffer
v.makeWriteable(v.wx, v.wy)
v.makeWriteable(v.buf.wx, v.buf.wy)
finishLine := func() {
v.autoRenderHyperlinksInCurrentLine()
}
advanceToNextLine := func() {
v.wx = 0
v.wy++
if v.wy >= len(v.lines) {
v.lines = append(v.lines, lineType{})
v.buf.wx = 0
v.buf.wy++
if v.buf.wy >= len(v.buf.lines) {
v.buf.lines = append(v.buf.lines, lineType{})
}
}
if v.pendingNewline {
if v.buf.pendingNewline {
advanceToNextLine()
v.ei.notifyRowAdvance()
v.pendingNewline = false
v.buf.ei.notifyRowAdvance()
v.buf.pendingNewline = false
}
until := len(p)
if !v.Editable && until > 0 && p[until-1] == '\n' {
v.pendingNewline = true
v.buf.pendingNewline = true
until--
}
@ -892,15 +911,15 @@ func (v *View) write(p []byte) {
case characterEquals(chr, '\n') || isCRLF(chr):
finishLine()
advanceToNextLine()
v.ei.notifyRowAdvance()
v.buf.ei.notifyRowAdvance()
case characterEquals(chr, '\r'):
finishLine()
v.wx = 0
v.ei.notifyColumnReset()
v.buf.wx = 0
v.buf.ei.notifyColumnReset()
default:
truncateLine, cells := v.parseInput(chr, width, v.wx, v.wy)
if cd, ok := v.ei.instruction.(cursorDown); ok {
v.ei.instructionRead()
truncateLine, cells := v.parseInput(chr, width, v.buf.wx, v.buf.wy)
if cd, ok := v.buf.ei.instruction.(cursorDown); ok {
v.buf.ei.instructionRead()
for range cd.n {
v.autoRenderHyperlinksInCurrentLine()
advanceToNextLine()
@ -911,7 +930,7 @@ func (v *View) write(p []byte) {
}
v.writeCells(cells)
if truncateLine {
v.lines[v.wy].cells = v.lines[v.wy].cells[:v.wx]
v.buf.lines[v.buf.wy].cells = v.buf.lines[v.buf.wy].cells[:v.buf.wx]
}
// Soft-wrap tracking. truncateLine is true exactly when the
// cells are from \x1b[K filling to end of line — ConPTY
@ -922,12 +941,12 @@ func (v *View) write(p []byte) {
for _, c := range cells {
totalWidth += c.width
}
v.ei.notifyCellsWritten(totalWidth)
v.buf.ei.notifyCellsWritten(totalWidth)
}
}
}
if v.pendingNewline {
if v.buf.pendingNewline {
finishLine()
} else {
v.autoRenderHyperlinksInCurrentLine()
@ -981,7 +1000,7 @@ func (v *View) autoRenderHyperlinksInCurrentLine() {
return
}
line := v.lines[v.wy].cells
line := v.buf.lines[v.buf.wy].cells
start := 0
for {
linkStart := findLinkStart(line[start:])
@ -998,7 +1017,7 @@ func (v *View) autoRenderHyperlinksInCurrentLine() {
link.WriteString(line[linkEnd].chr)
}
for i := linkStart; i < linkEnd; i++ {
v.lines[v.wy].cells[i].hyperlink = link.String()
v.buf.lines[v.buf.wy].cells[i].hyperlink = link.String()
}
start = linkEnd
}
@ -1011,9 +1030,9 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
cells := []cell{}
truncateLine := false
isEscape, err := v.ei.parseOne(ch)
isEscape, err := v.buf.ei.parseOne(ch)
if err != nil {
for _, chr := range v.ei.characters() {
for _, chr := range v.buf.ei.characters() {
c := cell{
fgColor: v.FgColor,
bgColor: v.BgColor,
@ -1022,28 +1041,28 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
}
cells = append(cells, c)
}
v.ei.reset()
v.buf.ei.reset()
} else {
repeatCount := 1
if _, ok := v.ei.instruction.(eraseInLineFromCursor); ok {
if _, ok := v.buf.ei.instruction.(eraseInLineFromCursor); ok {
// Discard any old content past the cursor and record the
// fill colors so draw() paints the trailing area with them.
// This extends the bg to the right edge in both the
// content-fits and content-wraps cases — for the latter,
// the metadata is what reaches every wrapped segment past
// the last word.
v.ei.instructionRead()
v.buf.ei.instructionRead()
truncateLine = true
v.lines[v.wy].trailingFillAttributes = &trailingFillAttributes{
fg: v.ei.curFgColor,
bg: v.ei.curBgColor,
v.buf.lines[v.buf.wy].trailingFillAttributes = &trailingFillAttributes{
fg: v.buf.ei.curFgColor,
bg: v.buf.ei.curBgColor,
}
return truncateLine, []cell{}
} else if cf, ok := v.ei.instruction.(cursorForward); ok {
} else if cf, ok := v.buf.ei.instruction.(cursorForward); ok {
// emit `n` space cells under the parser-tracked SGR — used
// to materialize ConPTY's compressed runs of spaces (which
// it emits as ECH+CUF instead of literal whitespace).
v.ei.instructionRead()
v.buf.ei.instructionRead()
repeatCount = cf.n
ch = []byte{' '}
width = 1
@ -1061,9 +1080,9 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
repeatCount = tabWidth - (x % tabWidth)
}
c := cell{
fgColor: v.ei.curFgColor,
bgColor: v.ei.curBgColor,
hyperlink: v.ei.hyperlink.String(),
fgColor: v.buf.ei.curFgColor,
bgColor: v.buf.ei.curBgColor,
hyperlink: v.buf.ei.hyperlink.String(),
chr: string(ch),
width: width,
}
@ -1091,9 +1110,9 @@ func (v *View) Read(p []byte) (n int, err error) {
}
v.readBuffer = nil
}
for v.ry < len(v.lines) {
for v.rx < len(v.lines[v.ry].cells) {
s := v.lines[v.ry].cells[v.rx].chr
for v.ry < len(v.buf.lines) {
for v.rx < len(v.buf.lines[v.ry].cells) {
s := v.buf.lines[v.ry].cells[v.rx].chr
count := len(s)
copy(p[offset:], s)
v.rx++
@ -1115,7 +1134,7 @@ func (v *View) Read(p []byte) (n int, err error) {
// only use this if the calling function has a lock on writeMutex
func (v *View) clear() {
v.rewind()
v.lines = nil
v.buf.lines = nil
v.clearViewLines()
}
@ -1157,7 +1176,7 @@ func (v *View) CopyContent(from *View) {
// This is a shallow clone -- the per-row cell data is immutable once written
// and stays shared, so the cost is proportional to the number of rows, not
// their contents.
v.lines = slices.Clone(from.lines)
v.buf.lines = slices.Clone(from.buf.lines)
v.viewLines = slices.Clone(from.viewLines)
v.SetOriginX(from.ox)
v.SetOriginY(from.oy)
@ -1180,13 +1199,13 @@ func (v *View) Reset() {
defer v.writeMutex.Unlock()
v.rewind()
v.lines = nil
v.buf.lines = nil
}
// This is for when we've done a restart for the sake of avoiding a flicker and
// we've reached the end of the new content to display: we need to clear the remaining
// content from the previous round. We do this by setting v.viewLines to nil so that
// we just render the new content from v.lines directly
// we just render the new content from v.buf.lines directly
func (v *View) FlushStaleCells() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
@ -1195,8 +1214,8 @@ func (v *View) FlushStaleCells() {
}
func (v *View) rewind() {
v.ei.reset()
v.ei.resetScreenCursor()
v.buf.ei.reset()
v.buf.ei.resetScreenCursor()
v.SetReadPos(0, 0)
v.SetWritePos(0, 0)
@ -1268,14 +1287,14 @@ func (v *View) updateSearchPositions() {
for _, result := range v.searcher.modelSearchResults {
// This code only works when v.Wrap is false.
if result.Y >= len(v.lines) {
if result.Y >= len(v.buf.lines) {
break
}
// If a view line exists for this line index:
if v.lines[result.Y].cells != nil {
if v.buf.lines[result.Y].cells != nil {
// search this view line for the search string
positions := searchPositionsForLine(v.lines[result.Y].cells, result.Y)
positions := searchPositionsForLine(v.buf.lines[result.Y].cells, result.Y)
if len(positions) > 0 {
// If we found any occurrences, add them
v.searcher.searchPositions = append(v.searcher.searchPositions, positions...)
@ -1422,7 +1441,7 @@ func (v *View) refreshViewLinesIfNeeded() {
}
lineIdx := 0
lines := v.lines
lines := v.buf.lines
for i := range lines {
line := &lines[i]
@ -1546,8 +1565,8 @@ func (v *View) BufferLines() []string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
lines := make([]string, len(v.lines))
for i, l := range v.lines {
lines := make([]string, len(v.buf.lines))
for i, l := range v.buf.lines {
lines[i] = l.cells.String()
}
return lines
@ -1559,7 +1578,7 @@ func (v *View) Buffer() string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return linesToString(v.lines)
return linesToString(v.buf.lines)
}
// ViewBufferLines returns the lines in the view's internal
@ -1579,7 +1598,7 @@ func (v *View) ViewBufferLines() []string {
// LinesHeight is the count of view lines (i.e. lines excluding wrapping)
func (v *View) LinesHeight() int {
return len(v.lines)
return len(v.buf.lines)
}
// ViewLinesHeight is the count of view lines (i.e. lines including wrapping)
@ -1610,11 +1629,11 @@ func (v *View) Line(y int) (string, bool) {
return "", false
}
if y < 0 || y >= len(v.lines) {
if y < 0 || y >= len(v.buf.lines) {
return "", false
}
return v.lines[y].cells.String(), true
return v.buf.lines[y].cells.String(), true
}
// Word returns a string with the word of the view's internal buffer
@ -1625,11 +1644,11 @@ func (v *View) Word(x, y int) (string, bool) {
return "", false
}
if x < 0 || y < 0 || y >= len(v.lines) || x >= len(v.lines[y].cells) {
if x < 0 || y < 0 || y >= len(v.buf.lines) || x >= len(v.buf.lines[y].cells) {
return "", false
}
str := v.lines[y].cells.String()
str := v.buf.lines[y].cells.String()
nl := strings.LastIndexFunc(str[:x], indexFunc)
if nl == -1 {
@ -1655,12 +1674,12 @@ func indexFunc(r rune) bool {
// SetHighlight toggles highlighting of separate lines, for custom lists
// or multiple selection in views.
func (v *View) SetHighlight(y int, on bool) {
if y < 0 || y >= len(v.lines) {
if y < 0 || y >= len(v.buf.lines) {
return
}
cells := make([]cell, 0, len(v.lines[y].cells))
for _, c := range v.lines[y].cells {
cells := make([]cell, 0, len(v.buf.lines[y].cells))
for _, c := range v.buf.lines[y].cells {
if on {
c.bgColor = v.SelBgColor
c.fgColor = v.SelFgColor
@ -1672,7 +1691,7 @@ func (v *View) SetHighlight(y int, on bool) {
}
v.tainted = true
v.firstDirtyLine = min(v.firstDirtyLine, y)
v.lines[y].cells = cells
v.buf.lines[y].cells = cells
v.clearHover()
}
@ -1784,7 +1803,7 @@ func (v *View) SelectedLine() string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if len(v.lines) == 0 {
if len(v.buf.lines) == 0 {
return ""
}
@ -1796,7 +1815,7 @@ func (v *View) SelectedLines() []string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if len(v.lines) == 0 {
if len(v.buf.lines) == 0 {
return nil
}
@ -1811,7 +1830,7 @@ func (v *View) SelectedLines() []string {
}
func (v *View) lineContentAtIdx(idx int) string {
return v.lines[idx].cells.String()
return v.buf.lines[idx].cells.String()
}
func (v *View) SelectedPoint() (int, int) {
@ -1884,8 +1903,8 @@ func (v *View) ClearTextArea() {
func (v *View) overwriteLines(y int, content string) {
// break by newline, then for each line, write it, then add that erase command
v.wx = 0
v.wy = y
v.buf.wx = 0
v.buf.wy = y
v.clearViewLines()
lines := strings.ReplaceAll(content, "\n", "\x1b[K\n")
@ -1897,7 +1916,7 @@ func (v *View) overwriteLines(y int, content string) {
v.writeString(lines)
}
// only call this function if you don't care where v.wx and v.wy end up
// only call this function if you don't care where v.buf.wx and v.buf.wy end up
func (v *View) OverwriteLines(y int, content string) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
@ -1905,7 +1924,7 @@ func (v *View) OverwriteLines(y int, content string) {
v.overwriteLines(y, content)
}
// only call this function if you don't care where v.wx and v.wy end up
// only call this function if you don't care where v.buf.wx and v.buf.wy end up
func (v *View) OverwriteLinesAndClearEverythingElse(lineCount int, y int, content string) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
@ -1915,11 +1934,11 @@ func (v *View) OverwriteLinesAndClearEverythingElse(lineCount int, y int, conten
v.overwriteLines(y, content)
for i := range y {
v.lines[i] = lineType{}
v.buf.lines[i] = lineType{}
}
for i := v.wy + 1; i < len(v.lines); i += 1 {
v.lines[i] = lineType{}
for i := v.buf.wy + 1; i < len(v.buf.lines); i += 1 {
v.buf.lines[i] = lineType{}
}
}
@ -1927,7 +1946,7 @@ func (v *View) setContentLineCount(lineCount int) {
if lineCount > 0 {
v.makeWriteable(0, lineCount-1)
}
v.lines = v.lines[:lineCount]
v.buf.lines = v.buf.lines[:lineCount]
}
// If the current search result is no longer visible after a scroll up, select the last search
@ -2061,7 +2080,7 @@ func (v *View) scrollMargin() int {
// Returns true if the view contains a line containing the given text with the given
// foreground color
func (v *View) ContainsColoredText(fgColor string, text string) bool {
for _, line := range v.lines {
for _, line := range v.buf.lines {
if containsColoredTextInLine(fgColor, text, line.cells) {
return true
}

View file

@ -101,13 +101,13 @@ func TestWriteString(t *testing.T) {
for _, test := range tests {
v := NewView("name", 0, 0, 10, 10, OutputNormal)
for _, l := range test.existingLines {
v.lines = append(v.lines, lineType{cells: stringToCells(l)})
v.buf.lines = append(v.buf.lines, lineType{cells: stringToCells(l)})
}
for _, s := range test.stringsToWrite {
v.writeString(s)
}
var resultingLines [][]string
for _, l := range v.lines {
for _, l := range v.buf.lines {
resultingLines = append(resultingLines, cellsToStrings(l.cells))
}
assert.Equal(t, test.expectedLines, resultingLines)
@ -144,19 +144,19 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
v.writeString("htt")
// No hyperlinks are generated for incomplete URLs
assert.Equal(t, "", v.lines[0].cells[0].hyperlink)
assert.Equal(t, "", v.buf.lines[0].cells[0].hyperlink)
// Writing more characters to the same line makes the link complete (even
// though we didn't see a newline yet)
v.writeString("ps://example.com")
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
v.Clear()
// Valid but incomplete URL
v.writeString("https://exa")
assert.Equal(t, "https://exa", v.lines[0].cells[0].hyperlink)
assert.Equal(t, "https://exa", v.buf.lines[0].cells[0].hyperlink)
// Writing more characters to the same fixes the link
v.writeString("mple.com")
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
}
func TestContainsColoredText(t *testing.T) {
@ -233,7 +233,7 @@ func TestContainsColoredText(t *testing.T) {
for j, cells := range test.lines {
lines[j] = lineType{cells: cells}
}
v := &View{lines: lines}
v := &View{buf: &viewBuffer{lines: lines}}
assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i)
}
}
@ -248,8 +248,8 @@ func TestWriteCursorPositionEscape(t *testing.T) {
// "a", then "skip to row 3" (i.e. one blank row), then "b".
v.writeString("a\r\n\x1b[3;1Hb\r\n")
got := make([][]string, 0, len(v.lines))
for _, l := range v.lines {
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
@ -269,8 +269,8 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
// ConPTY is on row 3 here; CUP to row 5 should skip exactly one row.
v.writeString("c\x1b[5;1Hd\n")
got := make([][]string, 0, len(v.lines))
for _, l := range v.lines {
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
assert.Equal(t, [][]string{
@ -292,8 +292,8 @@ func TestWriteCursorForwardEscape(t *testing.T) {
// "a" + ECH 5 + CUF 5 + "b" — visually "a b".
v.writeString("a\x1b[5X\x1b[5Cb\n")
got := make([][]string, 0, len(v.lines))
for _, l := range v.lines {
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
@ -312,8 +312,8 @@ func TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) {
v.writeString("abcdefghij\n")
v.writeString("\x1b[4;1Hxyz\n")
got := make([][]string, 0, len(v.lines))
for _, l := range v.lines {
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
assert.Equal(t, [][]string{