diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 10e620cd4..2961447f6 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -5,6 +5,7 @@ package gocui import ( + "bytes" "fmt" "io" "slices" @@ -693,6 +694,23 @@ type lineType struct { // matches the current width; nil means nothing is cached yet. wrappedCells [][]cell wrappedColumns int + + // asWritten is the text of the line as its writer wrote it, escape sequences + // left out, kept from the first character on that the cells spell differently: + // a tab, which the cells hold as the spaces it fills, or a carriage return, + // which they hold as the overwrite it caused. nil while the cells spell the + // line as it was written, as they do for most lines. + asWritten []byte +} + +// textAsWritten returns the line's text as its writer wrote it, escape sequences +// left out. A reader that parses a view's content rather than showing it wants +// this form; the cells' text is for showing. +func (l *lineType) textAsWritten() string { + if l.asWritten != nil { + return string(l.asWritten) + } + return l.cells.String() } // trailingFillAttributes describes the fg/bg colors that draw() should @@ -950,8 +968,7 @@ func (v *View) SetWritePos(x, y int) { y = 0 } - v.buf.wx = x - v.buf.wy = y + v.buf.seekWrite(x, y) // Changing the write position makes a pending newline obsolete v.buf.pendingNewline = false @@ -1035,6 +1052,35 @@ func (b *viewBuffer) writeCells(cells []cell) { b.wx += len(cells) } +// seekWrite moves the write cursor to (x, y). Writing there starts the line over, +// so whatever it kept of its text as written is dropped; the text a line keeps is +// the text written to it from its start. A carriage return continues a line +// instead, and moves the cursor without this (see write). +func (b *viewBuffer) seekWrite(x, y int) { + b.wx = x + b.wy = y + if y < len(b.lines) { + b.lines[y].asWritten = nil + } +} + +// startAsWritten begins keeping the current line's text as written (see +// lineType.asWritten), at the first character the cells won't spell the same way. +// Up to here they spell it exactly, so their text is what was written so far. +func (b *viewBuffer) startAsWritten() { + if line := &b.lines[b.wy]; line.asWritten == nil { + line.asWritten = append([]byte{}, line.cells.String()...) + } +} + +// noteAsWritten records text the writer wrote to the current line, once the line +// keeps its text as written at all. +func (b *viewBuffer) noteAsWritten(text []byte) { + if line := &b.lines[b.wy]; line.asWritten != nil { + line.asWritten = append(line.asWritten, text...) + } +} + // Write appends a byte slice into the view's internal buffer. Because // View implements the io.Writer interface, it can be passed as parameter // of functions like fmt.Fprintf, fmt.Fprintln, io.Copy, etc. Clear must @@ -1081,8 +1127,7 @@ func (b *viewBuffer) write(v *View, p []byte) { } advanceToNextLine := func() { - b.wx = 0 - b.wy++ + b.seekWrite(0, b.wy+1) if b.wy >= len(b.lines) { b.lines = append(b.lines, lineType{}) } @@ -1115,6 +1160,10 @@ func (b *viewBuffer) write(v *View, p []byte) { b.ei.notifyRowAdvance() case characterEquals(chr, '\r'): finishLine() + // The cells will hold what follows as an overwrite of what came + // before; the text as written keeps the return itself. + b.startAsWritten() + b.noteAsWritten(chr) b.wx = 0 b.ei.notifyColumnReset() default: @@ -1231,7 +1280,8 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo isEscape, err := b.ei.parseOne(ch) if err != nil { - for _, chr := range b.ei.characters() { + characters := b.ei.characters() + for _, chr := range characters { c := cell{ fgColor: v.FgColor, bgColor: v.BgColor, @@ -1240,6 +1290,7 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo } cells = append(cells, c) } + b.noteAsWritten([]byte(strings.Join(characters, ""))) b.ei.reset() } else { repeatCount := 1 @@ -1265,10 +1316,15 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo repeatCount = cf.n ch = []byte{' '} width = 1 + b.noteAsWritten(bytes.Repeat(ch, repeatCount)) } else if isEscape { // do not output anything return truncateLine, nil } else if characterEquals(ch, '\t') { + // The cells hold a tab as the spaces it fills; the text as written + // keeps the tab itself. + b.startAsWritten() + b.noteAsWritten(ch) // fill tab-sized space tabWidth := v.TabWidth if tabWidth < 1 { @@ -1277,6 +1333,8 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo ch = []byte{' '} width = 1 repeatCount = tabWidth - (x % tabWidth) + } else { + b.noteAsWritten(ch) } c := cell{ fgColor: b.ei.curFgColor, @@ -1860,6 +1918,24 @@ func (v *View) BufferLines() []string { return lines } +// LinesAsWritten returns the lines of the view's internal buffer as their writer +// wrote them, escape sequences left out, where BufferLines returns them as the +// cells spell them. The two differ where the cells can't spell what was written: +// a tab, which they hold as the spaces it fills, and a carriage return, which +// they hold as the overwrite it caused. A reader that parses the content rather +// than showing it wants this form. git, for one, terminates a path containing a +// space with a tab in a diff header, and a parser of the diff has to see the tab. +func (v *View) LinesAsWritten() []string { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + lines := make([]string, len(v.buf.lines)) + for i := range v.buf.lines { + lines[i] = v.buf.lines[i].textAsWritten() + } + return lines +} + // BufferLineForViewLine maps a view line index (which counts wrapped lines) to // the index of the corresponding line in the unwrapped internal buffer (as // returned by BufferLines). Several view lines map to the same buffer line when diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index f559e562b..57d18abde 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -124,6 +124,83 @@ func TestOverwriteLinesAfterContentEndingInANewline(t *testing.T) { assert.Equal(t, []string{"x", "b"}, v.BufferLines()) } +func TestLinesAsWritten(t *testing.T) { + tests := []struct { + name string + stringsToWrite []string + expectedShown []string + expectedAsWritten []string + }{ + { + name: "a line the cells spell as written", + stringsToWrite: []string{"abc\n"}, + expectedShown: []string{"abc"}, + expectedAsWritten: []string{"abc"}, + }, + { + name: "a tab is kept rather than the spaces it fills", + stringsToWrite: []string{"a\tb\n"}, + expectedShown: []string{"a b"}, + expectedAsWritten: []string{"a\tb"}, + }, + { + name: "a carriage return is kept rather than the overwrite it causes", + stringsToWrite: []string{"abc\rde\n"}, + expectedShown: []string{"dec"}, + expectedAsWritten: []string{"abc\rde"}, + }, + { + // git writes a CRLF file's lines as "+foo\r", the color reset, "\n". + name: "escape sequences are left out", + stringsToWrite: []string{"\x1b[32m+foo\r\x1b[m\n"}, + expectedShown: []string{"+foo"}, + expectedAsWritten: []string{"+foo\r"}, + }, + { + // ConPTY writes a run of spaces as a cursor-forward escape. + name: "a cursor-forward escape stands for the spaces it skips", + stringsToWrite: []string{"\ta\x1b[2Cb\n"}, + expectedShown: []string{" a b"}, + expectedAsWritten: []string{"\ta b"}, + }, + { + name: "a line written in two parts", + stringsToWrite: []string{"a\t", "b\n"}, + expectedShown: []string{"a b"}, + expectedAsWritten: []string{"a\tb"}, + }, + { + name: "only the lines with a tab or a return are kept separately", + stringsToWrite: []string{"x\n", "y\tz\n", "w\n"}, + expectedShown: []string{"x", "y z", "w"}, + expectedAsWritten: []string{"x", "y\tz", "w"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + v := NewView("name", 0, 0, 20, 10, OutputNormal) + for _, s := range test.stringsToWrite { + v.writeString(s) + } + assert.Equal(t, test.expectedShown, v.BufferLines()) + assert.Equal(t, test.expectedAsWritten, v.LinesAsWritten()) + }) + } +} + +func TestLinesAsWrittenOfAnOverwrittenLine(t *testing.T) { + v := NewView("name", 0, 0, 20, 10, OutputNormal) + v.writeString("a\tb\nc\td") + + // Overwriting a line starts it over: what it kept of its earlier text goes, + // and the line below is left alone. + v.OverwriteLines(0, "xy") + + assert.Equal(t, []string{"xy", "c d"}, v.BufferLines()) + assert.Equal(t, []string{"xy", "c\td"}, v.LinesAsWritten()) +} + func TestUpdatedCursorAndOrigin(t *testing.T) { tests := []struct { prevOrigin int