Make the buffer-writing methods operate on a viewBuffer

write, writeCells, makeWriteable, parseInput and
autoRenderHyperlinksInCurrentLine produced cells into v.buf; move them onto
viewBuffer so they can write into any buffer, not just the displayed one.
The display-side effects that don't belong to content production —
tainting, clearing hover, updating search positions — stay behind in the
View.write wrapper, which delegates the actual writing to v.buf.write(v).
Render config the writer needs (Editable, colors, width, tab width,
hyperlink auto-render) is read from the passed View. Behaviour-preserving:
the wrapper still always targets v.buf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-08-08 19:15:16 +02:00
parent 2a6cb8d78e
commit cc5d5057a7

View file

@ -800,56 +800,56 @@ func (v *View) ReadPos() (x, y int) {
} }
// makeWriteable creates empty cells if required to make position (x, y) writeable. // makeWriteable creates empty cells if required to make position (x, y) writeable.
func (v *View) makeWriteable(x, y int) { func (b *viewBuffer) makeWriteable(x, y int) {
// TODO: make this more efficient // TODO: make this more efficient
// line `y` must be index-able (that's why `<=`) // line `y` must be index-able (that's why `<=`)
for len(v.buf.lines) <= y { for len(b.lines) <= y {
if cap(v.buf.lines) > len(v.buf.lines) { if cap(b.lines) > len(b.lines) {
newLen := cap(v.buf.lines) newLen := cap(b.lines)
if newLen > y { if newLen > y {
newLen = y + 1 newLen = y + 1
} }
v.buf.lines = v.buf.lines[:newLen] b.lines = b.lines[:newLen]
} else { } else {
v.buf.lines = append(v.buf.lines, lineType{}) b.lines = append(b.lines, lineType{})
} }
} }
// cell `x` need not be index-able (that's why `<`) // 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` // append should be used by `lines[y]` user if he wants to write beyond `x`
for len(v.buf.lines[y].cells) < x { for len(b.lines[y].cells) < x {
if cap(v.buf.lines[y].cells) > len(v.buf.lines[y].cells) { if cap(b.lines[y].cells) > len(b.lines[y].cells) {
newLen := cap(v.buf.lines[y].cells) newLen := cap(b.lines[y].cells)
if newLen > x { if newLen > x {
newLen = x newLen = x
} }
v.buf.lines[y].cells = v.buf.lines[y].cells[:newLen] b.lines[y].cells = b.lines[y].cells[:newLen]
} else { } else {
v.buf.lines[y].cells = append(v.buf.lines[y].cells, cell{}) b.lines[y].cells = append(b.lines[y].cells, cell{})
} }
} }
} }
// writeCells copies []cell to (v.buf.wx, v.buf.wy), and advances v.buf.wx accordingly. // writeCells copies []cell to (b.wx, b.wy), and advances b.wx accordingly.
// !!! caller MUST ensure that specified location (x, y) is writeable by calling makeWriteable // !!! caller MUST ensure that specified location (x, y) is writeable by calling makeWriteable
func (v *View) writeCells(cells []cell) { func (b *viewBuffer) writeCells(cells []cell) {
var newLen int var newLen int
// use maximum len available // use maximum len available
line := v.buf.lines[v.buf.wy].cells[:cap(v.buf.lines[v.buf.wy].cells)] line := b.lines[b.wy].cells[:cap(b.lines[b.wy].cells)]
maxCopy := len(line) - v.buf.wx maxCopy := len(line) - b.wx
if maxCopy < len(cells) { if maxCopy < len(cells) {
copy(line[v.buf.wx:], cells[:maxCopy]) copy(line[b.wx:], cells[:maxCopy])
line = append(line, cells[maxCopy:]...) line = append(line, cells[maxCopy:]...)
newLen = len(line) newLen = len(line)
} else { // maxCopy >= len(cells) } else { // maxCopy >= len(cells)
copy(line[v.buf.wx:], cells) copy(line[b.wx:], cells)
newLen = v.buf.wx + len(cells) newLen = b.wx + len(cells)
if newLen < len(v.buf.lines[v.buf.wy].cells) { if newLen < len(b.lines[b.wy].cells) {
newLen = len(v.buf.lines[v.buf.wy].cells) newLen = len(b.lines[b.wy].cells)
} }
} }
v.buf.lines[v.buf.wy].cells = line[:newLen] b.lines[b.wy].cells = line[:newLen]
v.buf.wx += len(cells) b.wx += len(cells)
} }
// Write appends a byte slice into the view's internal buffer. Because // Write appends a byte slice into the view's internal buffer. Because
@ -872,30 +872,40 @@ func (v *View) write(p []byte) {
v.firstDirtyLine = min(v.firstDirtyLine, v.buf.wy) v.firstDirtyLine = min(v.firstDirtyLine, v.buf.wy)
v.clearHover() v.clearHover()
v.buf.write(v, p)
v.updateSearchPositions()
}
// write parses p into cells and appends them to the buffer at its write cursor.
// It only touches the buffer; the View wrapper above handles display-side
// effects (tainting, hover, search). v supplies render config (Editable, colors,
// width, tab width, hyperlink auto-rendering).
func (b *viewBuffer) write(v *View, p []byte) {
// Fill with empty cells, if writing outside current view buffer // Fill with empty cells, if writing outside current view buffer
v.makeWriteable(v.buf.wx, v.buf.wy) b.makeWriteable(b.wx, b.wy)
finishLine := func() { finishLine := func() {
v.autoRenderHyperlinksInCurrentLine() b.autoRenderHyperlinksInCurrentLine(v)
} }
advanceToNextLine := func() { advanceToNextLine := func() {
v.buf.wx = 0 b.wx = 0
v.buf.wy++ b.wy++
if v.buf.wy >= len(v.buf.lines) { if b.wy >= len(b.lines) {
v.buf.lines = append(v.buf.lines, lineType{}) b.lines = append(b.lines, lineType{})
} }
} }
if v.buf.pendingNewline { if b.pendingNewline {
advanceToNextLine() advanceToNextLine()
v.buf.ei.notifyRowAdvance() b.ei.notifyRowAdvance()
v.buf.pendingNewline = false b.pendingNewline = false
} }
until := len(p) until := len(p)
if !v.Editable && until > 0 && p[until-1] == '\n' { if !v.Editable && until > 0 && p[until-1] == '\n' {
v.buf.pendingNewline = true b.pendingNewline = true
until-- until--
} }
@ -911,26 +921,26 @@ func (v *View) write(p []byte) {
case characterEquals(chr, '\n') || isCRLF(chr): case characterEquals(chr, '\n') || isCRLF(chr):
finishLine() finishLine()
advanceToNextLine() advanceToNextLine()
v.buf.ei.notifyRowAdvance() b.ei.notifyRowAdvance()
case characterEquals(chr, '\r'): case characterEquals(chr, '\r'):
finishLine() finishLine()
v.buf.wx = 0 b.wx = 0
v.buf.ei.notifyColumnReset() b.ei.notifyColumnReset()
default: default:
truncateLine, cells := v.parseInput(chr, width, v.buf.wx, v.buf.wy) truncateLine, cells := b.parseInput(v, chr, width, b.wx, b.wy)
if cd, ok := v.buf.ei.instruction.(cursorDown); ok { if cd, ok := b.ei.instruction.(cursorDown); ok {
v.buf.ei.instructionRead() b.ei.instructionRead()
for range cd.n { for range cd.n {
v.autoRenderHyperlinksInCurrentLine() b.autoRenderHyperlinksInCurrentLine(v)
advanceToNextLine() advanceToNextLine()
} }
} }
if cells == nil { if cells == nil {
continue continue
} }
v.writeCells(cells) b.writeCells(cells)
if truncateLine { if truncateLine {
v.buf.lines[v.buf.wy].cells = v.buf.lines[v.buf.wy].cells[:v.buf.wx] b.lines[b.wy].cells = b.lines[b.wy].cells[:b.wx]
} }
// Soft-wrap tracking. truncateLine is true exactly when the // Soft-wrap tracking. truncateLine is true exactly when the
// cells are from \x1b[K filling to end of line — ConPTY // cells are from \x1b[K filling to end of line — ConPTY
@ -941,18 +951,16 @@ func (v *View) write(p []byte) {
for _, c := range cells { for _, c := range cells {
totalWidth += c.width totalWidth += c.width
} }
v.buf.ei.notifyCellsWritten(totalWidth) b.ei.notifyCellsWritten(totalWidth)
} }
} }
} }
if v.buf.pendingNewline { if b.pendingNewline {
finishLine() finishLine()
} else { } else {
v.autoRenderHyperlinksInCurrentLine() b.autoRenderHyperlinksInCurrentLine(v)
} }
v.updateSearchPositions()
} }
// exported functions use the mutex. Non-exported functions are for internal use // exported functions use the mutex. Non-exported functions are for internal use
@ -995,12 +1003,12 @@ var lineEndCharacters = map[string]bool{
")": true, ")": true,
} }
func (v *View) autoRenderHyperlinksInCurrentLine() { func (b *viewBuffer) autoRenderHyperlinksInCurrentLine(v *View) {
if !v.AutoRenderHyperLinks { if !v.AutoRenderHyperLinks {
return return
} }
line := v.buf.lines[v.buf.wy].cells line := b.lines[b.wy].cells
start := 0 start := 0
for { for {
linkStart := findLinkStart(line[start:]) linkStart := findLinkStart(line[start:])
@ -1017,7 +1025,7 @@ func (v *View) autoRenderHyperlinksInCurrentLine() {
link.WriteString(line[linkEnd].chr) link.WriteString(line[linkEnd].chr)
} }
for i := linkStart; i < linkEnd; i++ { for i := linkStart; i < linkEnd; i++ {
v.buf.lines[v.buf.wy].cells[i].hyperlink = link.String() b.lines[b.wy].cells[i].hyperlink = link.String()
} }
start = linkEnd start = linkEnd
} }
@ -1026,13 +1034,13 @@ func (v *View) autoRenderHyperlinksInCurrentLine() {
// parseInput parses char by char the input written to the View. It returns nil // parseInput parses char by char the input written to the View. It returns nil
// while processing ESC sequences. Otherwise, it returns a cell slice that // while processing ESC sequences. Otherwise, it returns a cell slice that
// contains the processed data. // contains the processed data.
func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) { func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bool, []cell) {
cells := []cell{} cells := []cell{}
truncateLine := false truncateLine := false
isEscape, err := v.buf.ei.parseOne(ch) isEscape, err := b.ei.parseOne(ch)
if err != nil { if err != nil {
for _, chr := range v.buf.ei.characters() { for _, chr := range b.ei.characters() {
c := cell{ c := cell{
fgColor: v.FgColor, fgColor: v.FgColor,
bgColor: v.BgColor, bgColor: v.BgColor,
@ -1041,28 +1049,28 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
} }
cells = append(cells, c) cells = append(cells, c)
} }
v.buf.ei.reset() b.ei.reset()
} else { } else {
repeatCount := 1 repeatCount := 1
if _, ok := v.buf.ei.instruction.(eraseInLineFromCursor); ok { if _, ok := b.ei.instruction.(eraseInLineFromCursor); ok {
// Discard any old content past the cursor and record the // Discard any old content past the cursor and record the
// fill colors so draw() paints the trailing area with them. // fill colors so draw() paints the trailing area with them.
// This extends the bg to the right edge in both the // This extends the bg to the right edge in both the
// content-fits and content-wraps cases — for the latter, // content-fits and content-wraps cases — for the latter,
// the metadata is what reaches every wrapped segment past // the metadata is what reaches every wrapped segment past
// the last word. // the last word.
v.buf.ei.instructionRead() b.ei.instructionRead()
truncateLine = true truncateLine = true
v.buf.lines[v.buf.wy].trailingFillAttributes = &trailingFillAttributes{ b.lines[b.wy].trailingFillAttributes = &trailingFillAttributes{
fg: v.buf.ei.curFgColor, fg: b.ei.curFgColor,
bg: v.buf.ei.curBgColor, bg: b.ei.curBgColor,
} }
return truncateLine, []cell{} return truncateLine, []cell{}
} else if cf, ok := v.buf.ei.instruction.(cursorForward); ok { } else if cf, ok := b.ei.instruction.(cursorForward); ok {
// emit `n` space cells under the parser-tracked SGR — used // emit `n` space cells under the parser-tracked SGR — used
// to materialize ConPTY's compressed runs of spaces (which // to materialize ConPTY's compressed runs of spaces (which
// it emits as ECH+CUF instead of literal whitespace). // it emits as ECH+CUF instead of literal whitespace).
v.buf.ei.instructionRead() b.ei.instructionRead()
repeatCount = cf.n repeatCount = cf.n
ch = []byte{' '} ch = []byte{' '}
width = 1 width = 1
@ -1080,9 +1088,9 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
repeatCount = tabWidth - (x % tabWidth) repeatCount = tabWidth - (x % tabWidth)
} }
c := cell{ c := cell{
fgColor: v.buf.ei.curFgColor, fgColor: b.ei.curFgColor,
bgColor: v.buf.ei.curBgColor, bgColor: b.ei.curBgColor,
hyperlink: v.buf.ei.hyperlink.String(), hyperlink: b.ei.hyperlink.String(),
chr: string(ch), chr: string(ch),
width: width, width: width,
} }
@ -1944,7 +1952,7 @@ func (v *View) OverwriteLinesAndClearEverythingElse(lineCount int, y int, conten
func (v *View) setContentLineCount(lineCount int) { func (v *View) setContentLineCount(lineCount int) {
if lineCount > 0 { if lineCount > 0 {
v.makeWriteable(0, lineCount-1) v.buf.makeWriteable(0, lineCount-1)
} }
v.buf.lines = v.buf.lines[:lineCount] v.buf.lines = v.buf.lines[:lineCount]
} }