diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index ddb356b20..9f40ff17d 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -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 { diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 66d0cde3a..d0af41576 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -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 } diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 121f656d8..61dc26b52 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -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{