From 64e3d3e0498a294f5373a8ba4fc4e6f3d1f29a72 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 9 Jun 2026 11:08:23 +0200 Subject: [PATCH] Bundle a view's cell buffer and write state into a viewBuffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 4.8 (1M context) --- pkg/gocui/gui.go | 8 +- pkg/gocui/view.go | 260 ++++++++++++++++++++++------------------- pkg/gocui/view_test.go | 34 +++--- 3 files changed, 160 insertions(+), 142 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index be52c8584..4aaa0a984 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1467,7 +1467,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 } @@ -1717,13 +1717,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 de9e4aaff..ee34a83c4 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -27,15 +27,41 @@ const ( // A View is a window. It maintains its own internal buffer and cursor // position. +// 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 +} + 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 @@ -75,7 +101,7 @@ type View struct { viewLines []viewLine // Number of leading entries in viewLines that correspond to the current - // buffer (v.lines), as built by the most recent refreshViewLinesIfNeeded. + // buffer (v.buf.lines), as built by the most recent refreshViewLinesIfNeeded. // refreshViewLinesIfNeeded overwrites viewLines in place without truncating, // so when a re-render is shorter than the previous one the tail keeps stale // entries from that previous render (drawn deliberately, to avoid flicker @@ -99,17 +125,9 @@ type View struct { // view-line→buffer-line mapping reports no result. holdViewLines bool - // 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 @@ -487,7 +505,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. @@ -565,7 +583,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, @@ -576,7 +594,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 } @@ -587,7 +605,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 @@ -777,16 +795,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. @@ -814,52 +832,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 @@ -879,37 +897,37 @@ func (v *View) write(p []byte) { v.tainted = true // write only ever touches lines from v.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{}) } // An OSC 456 diff-metadata sequence applies only to the line it prefixes // (the pager re-emits one per line and never closes it), so drop it at the // line boundary rather than letting it carry onto a line with no metadata. - v.ei.metadata.Reset() + v.buf.ei.metadata.Reset() } - 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-- } @@ -925,15 +943,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() @@ -944,7 +962,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 @@ -955,12 +973,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() @@ -1014,7 +1032,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:]) @@ -1031,7 +1049,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 } @@ -1044,9 +1062,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, @@ -1055,28 +1073,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 @@ -1094,10 +1112,10 @@ 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(), - metadata: v.ei.metadata.String(), + fgColor: v.buf.ei.curFgColor, + bgColor: v.buf.ei.curBgColor, + hyperlink: v.buf.ei.hyperlink.String(), + metadata: v.buf.ei.metadata.String(), chr: string(ch), width: width, } @@ -1125,9 +1143,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++ @@ -1149,7 +1167,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() } @@ -1191,7 +1209,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) @@ -1214,13 +1232,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() @@ -1241,8 +1259,8 @@ func (v *View) SetHoldViewLines(hold bool) { } 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) @@ -1314,14 +1332,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...) @@ -1472,7 +1490,7 @@ func (v *View) refreshViewLinesIfNeeded() { } lineIdx := 0 - lines := v.lines + lines := v.buf.lines for i := range lines { line := &lines[i] @@ -1597,8 +1615,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 @@ -1610,7 +1628,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 @@ -1630,7 +1648,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) @@ -1661,11 +1679,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 @@ -1676,11 +1694,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 { @@ -1706,7 +1724,7 @@ func (v *View) HyperLinkInLine(y int, urlScheme string) (string, bool) { return "", false } - for _, c := range v.lines[linesY].cells { + for _, c := range v.buf.lines[linesY].cells { if strings.HasPrefix(c.hyperlink, urlScheme) { return c.hyperlink, true } @@ -1728,7 +1746,7 @@ func (v *View) DiffLineMetadataInLine(y int) (string, bool) { return "", false } - for _, c := range v.lines[linesY].cells { + for _, c := range v.buf.lines[linesY].cells { if c.metadata != "" { return c.metadata, true } @@ -1749,12 +1767,12 @@ func (v *View) BufferLineForViewLine(y int) (int, bool) { } // bufferLineForViewLine maps a (wrapped) view line index to the index of the -// corresponding line in the unwrapped internal buffer (v.lines). It is the +// corresponding line in the unwrapped internal buffer (v.buf.lines). It is the // shared core of the public readers that look up information about the buffer // line under a given view line (its buffer index, its hyperlink, its diff // metadata); they all need the same view-line→buffer-line mapping to stay // consistent with the buffer they then read. The caller must hold writeMutex, -// so that the mapping and the subsequent read of v.lines see the same buffer +// so that the mapping and the subsequent read of v.buf.lines see the same buffer // even if a concurrent re-render is rebuilding it. func (v *View) bufferLineForViewLine(y int) (int, bool) { v.refreshViewLinesIfNeeded() @@ -1769,7 +1787,7 @@ func (v *View) bufferLineForViewLine(y int) (int, bool) { // Bound on freshViewLineCount rather than len(v.viewLines): the entries past // it are a stale tail retained for flicker-avoidance (see freshViewLineCount) // and don't correspond to the current buffer. Within the fresh range every - // entry's linesY was just built from v.lines, so it is guaranteed in range. + // entry's linesY was just built from v.buf.lines, so it is guaranteed in range. if y < 0 || y >= v.freshViewLineCount { return 0, false } @@ -1786,12 +1804,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 @@ -1803,7 +1821,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() } @@ -1915,7 +1933,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 "" } @@ -1927,7 +1945,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 } @@ -1942,7 +1960,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) { @@ -2015,8 +2033,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") @@ -2028,7 +2046,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() @@ -2036,7 +2054,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() @@ -2046,11 +2064,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{} } } @@ -2058,7 +2076,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 @@ -2192,7 +2210,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 530f48b8f..1f1b2bad1 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 TestDiffLineMetadata(t *testing.T) { @@ -178,8 +178,8 @@ func TestDiffLineMetadata(t *testing.T) { payload string ok bool } - got := make([]result, len(v.lines)) - for y := range v.lines { + got := make([]result, len(v.buf.lines)) + for y := range v.buf.lines { payload, ok := v.DiffLineMetadataInLine(y) got[y] = result{payload, ok} } @@ -337,7 +337,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) } } @@ -352,8 +352,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)) } @@ -373,8 +373,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{ @@ -396,8 +396,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)) } @@ -416,8 +416,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{