Add editor packages
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package buffer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"code.chimeric.al/dwrz/wen/internal/editor/buffer/line"
|
||||
)
|
||||
|
||||
type Buffer struct {
|
||||
cursor *Cursor
|
||||
lines []*line.Line
|
||||
log *slog.Logger
|
||||
name string
|
||||
offset *offset
|
||||
saved time.Time
|
||||
}
|
||||
|
||||
// offset is the 0-indexed first visible display column (glyph) and row (line)
|
||||
// of the buffer viewport.
|
||||
type offset struct {
|
||||
glyph int
|
||||
line int
|
||||
}
|
||||
|
||||
func (b *Buffer) CursorLine() *line.Line {
|
||||
return b.lines[b.cursor.line]
|
||||
}
|
||||
|
||||
func (b *Buffer) Name() string {
|
||||
return b.name
|
||||
}
|
||||
|
||||
type NewBufferParams struct {
|
||||
Name string
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// Create creates or truncates the named file on disk.
|
||||
// It returns a Buffer initialized with a single empty line,
|
||||
// so CursorLine is always valid.
|
||||
func Create(p NewBufferParams) (*Buffer, error) {
|
||||
var b = &Buffer{
|
||||
cursor: &Cursor{},
|
||||
lines: []*line.Line{{}},
|
||||
log: p.Log,
|
||||
name: p.Name,
|
||||
offset: &offset{},
|
||||
}
|
||||
|
||||
// Create the file.
|
||||
f, err := os.Create(b.name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to close file: %w", err)
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Open reads the named file into a Buffer. If the file is empty, the Buffer
|
||||
// is initialized with a single empty line so CursorLine is always valid.
|
||||
func Open(p NewBufferParams) (*Buffer, error) {
|
||||
var b = &Buffer{
|
||||
cursor: &Cursor{},
|
||||
lines: []*line.Line{},
|
||||
log: p.Log,
|
||||
name: p.Name,
|
||||
offset: &offset{},
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(b.name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
|
||||
// Trim a single trailing newline so saving doesn't add an extra blank line.
|
||||
data = bytes.TrimSuffix(data, []byte("\n"))
|
||||
data = bytes.TrimSuffix(data, []byte("\r"))
|
||||
|
||||
if len(data) > 0 {
|
||||
for _, raw := range bytes.Split(data, []byte("\n")) {
|
||||
raw = bytes.TrimSuffix(raw, []byte("\r"))
|
||||
b.lines = append(b.lines, line.New(string(raw)))
|
||||
}
|
||||
}
|
||||
|
||||
// Add a line to write in, if the file is empty.
|
||||
if len(b.lines) == 0 {
|
||||
b.lines = append(b.lines, &line.Line{})
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package buffer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"code.chimeric.al/dwrz/wen/internal/editor/buffer/line"
|
||||
)
|
||||
|
||||
func newTestBuffer(lines ...string) *Buffer {
|
||||
var b = &Buffer{
|
||||
cursor: &Cursor{},
|
||||
lines: make([]*line.Line, 0, len(lines)),
|
||||
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
offset: &offset{},
|
||||
}
|
||||
for _, s := range lines {
|
||||
b.lines = append(b.lines, line.New(s))
|
||||
}
|
||||
if len(b.lines) == 0 {
|
||||
b.lines = append(b.lines, &line.Line{})
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func bufferLines(b *Buffer) []string {
|
||||
var out = make([]string, len(b.lines))
|
||||
for i, l := range b.lines {
|
||||
out[i] = l.String()
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
type CursorMoveTest struct {
|
||||
lines []string
|
||||
action func(b *Buffer)
|
||||
want Cursor
|
||||
}
|
||||
|
||||
func TestCursorMovement(t *testing.T) {
|
||||
var tests = []CursorMoveTest{
|
||||
{
|
||||
lines: []string{"ab", "cd"},
|
||||
action: func(b *Buffer) { b.CursorRight() },
|
||||
want: Cursor{line: 0, index: 1, glyph: 1},
|
||||
},
|
||||
{
|
||||
lines: []string{"a", "b"},
|
||||
action: func(b *Buffer) {
|
||||
b.CursorRight()
|
||||
b.CursorRight()
|
||||
},
|
||||
want: Cursor{line: 1, index: 0, glyph: 0},
|
||||
},
|
||||
{
|
||||
lines: []string{"ab", "cd"},
|
||||
action: func(b *Buffer) {
|
||||
b.CursorDown()
|
||||
b.CursorLeft()
|
||||
},
|
||||
want: Cursor{line: 0, index: 2, glyph: 2},
|
||||
},
|
||||
{
|
||||
lines: []string{"hello", "hi"},
|
||||
action: func(b *Buffer) {
|
||||
b.CursorEnd()
|
||||
b.CursorDown()
|
||||
},
|
||||
want: Cursor{line: 1, index: 2, glyph: 2},
|
||||
},
|
||||
{
|
||||
lines: []string{"abc", "世界"},
|
||||
action: func(b *Buffer) {
|
||||
b.CursorRight()
|
||||
b.CursorDown()
|
||||
},
|
||||
want: Cursor{line: 1, index: 0, glyph: 0},
|
||||
},
|
||||
{
|
||||
lines: []string{"abc", "世界"},
|
||||
action: func(b *Buffer) {
|
||||
b.CursorEnd()
|
||||
b.CursorDown()
|
||||
},
|
||||
want: Cursor{line: 1, index: 3, glyph: 2},
|
||||
},
|
||||
}
|
||||
|
||||
for n, test := range tests {
|
||||
b := newTestBuffer(test.lines...)
|
||||
test.action(b)
|
||||
if got := b.Cursor(); got != test.want {
|
||||
t.Errorf(
|
||||
"#%d: expected cursor %+v, but got %+v",
|
||||
n, test.want, got,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type EditTest struct {
|
||||
lines []string
|
||||
action func(b *Buffer)
|
||||
wantLines []string
|
||||
wantCursor Cursor
|
||||
}
|
||||
|
||||
func TestEdit(t *testing.T) {
|
||||
var tests = []EditTest{
|
||||
{
|
||||
lines: []string{""},
|
||||
action: func(b *Buffer) {
|
||||
b.Insert('a')
|
||||
b.Insert('世')
|
||||
},
|
||||
wantLines: []string{"a世"},
|
||||
wantCursor: Cursor{line: 0, index: 4, glyph: 3},
|
||||
},
|
||||
{
|
||||
lines: []string{"hello"},
|
||||
action: func(b *Buffer) { b.Newline() },
|
||||
wantLines: []string{"", "hello"},
|
||||
wantCursor: Cursor{line: 1, index: 0, glyph: 0},
|
||||
},
|
||||
{
|
||||
lines: []string{"abcd"},
|
||||
action: func(b *Buffer) {
|
||||
b.CursorRight()
|
||||
b.CursorRight()
|
||||
b.Newline()
|
||||
},
|
||||
wantLines: []string{"ab", "cd"},
|
||||
wantCursor: Cursor{line: 1, index: 0, glyph: 0},
|
||||
},
|
||||
{
|
||||
lines: []string{"abcd"},
|
||||
action: func(b *Buffer) {
|
||||
b.CursorEnd()
|
||||
b.Newline()
|
||||
},
|
||||
wantLines: []string{"abcd", ""},
|
||||
wantCursor: Cursor{line: 1, index: 0, glyph: 0},
|
||||
},
|
||||
{
|
||||
lines: []string{"ab", ""},
|
||||
action: func(b *Buffer) {
|
||||
b.CursorDown()
|
||||
b.Backspace()
|
||||
},
|
||||
wantLines: []string{"ab"},
|
||||
wantCursor: Cursor{line: 0, index: 2, glyph: 2},
|
||||
},
|
||||
{
|
||||
lines: []string{"ab", "cd"},
|
||||
action: func(b *Buffer) {
|
||||
b.CursorDown()
|
||||
b.Backspace()
|
||||
},
|
||||
wantLines: []string{"abcd"},
|
||||
wantCursor: Cursor{line: 0, index: 2, glyph: 2},
|
||||
},
|
||||
}
|
||||
|
||||
for n, test := range tests {
|
||||
b := newTestBuffer(test.lines...)
|
||||
test.action(b)
|
||||
if got := bufferLines(b); !reflect.DeepEqual(got, test.wantLines) {
|
||||
t.Errorf(
|
||||
"#%d: expected lines %q, but got %q",
|
||||
n, test.wantLines, got,
|
||||
)
|
||||
}
|
||||
if got := b.Cursor(); got != test.wantCursor {
|
||||
t.Errorf(
|
||||
"#%d: expected cursor %+v, but got %+v",
|
||||
n, test.wantCursor, got,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package buffer
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.chimeric.al/dwrz/wen/internal/editor/buffer/glyph"
|
||||
)
|
||||
|
||||
// Cursor tracks the position in a Buffer.
|
||||
// glyph is the 0-indexed terminal display column (tabs are 8 columns, wide
|
||||
// runes are 2 columns).
|
||||
// index is the 0-indexed byte offset in the current line's UTF-8 string.
|
||||
// line is the 0-indexed row in Buffer.lines.
|
||||
type Cursor struct {
|
||||
glyph int
|
||||
index int
|
||||
line int
|
||||
}
|
||||
|
||||
func (b *Buffer) Cursor() Cursor {
|
||||
return *b.cursor
|
||||
}
|
||||
|
||||
func (c Cursor) Glyph() int {
|
||||
return c.glyph
|
||||
}
|
||||
|
||||
func (c Cursor) Index() int {
|
||||
return c.index
|
||||
}
|
||||
|
||||
func (c Cursor) Line() int {
|
||||
return c.line
|
||||
}
|
||||
|
||||
func (b *Buffer) CursorDown() {
|
||||
b.log.Debug("↓ before", slog.Any("cursor", b.cursor))
|
||||
defer b.log.Debug("↓ after", slog.Any("cursor", b.cursor))
|
||||
|
||||
// If we're on the last line, don't move down.
|
||||
if b.cursor.line >= len(b.lines)-1 {
|
||||
return
|
||||
}
|
||||
|
||||
// Move to the next line.
|
||||
b.cursor.line++
|
||||
|
||||
// Adjust the column.
|
||||
var (
|
||||
line = b.CursorLine()
|
||||
length = line.Length()
|
||||
width = line.Width()
|
||||
)
|
||||
switch {
|
||||
// If we've moved to a shorter line, snap to its end.
|
||||
case b.cursor.glyph > width:
|
||||
b.cursor.index = length
|
||||
b.cursor.glyph = width
|
||||
|
||||
default:
|
||||
b.cursor.index, b.cursor.glyph = line.FindGlyphIndex(
|
||||
b.cursor.glyph,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Buffer) CursorLeft() {
|
||||
b.log.Debug("← before", slog.Any("cursor", b.cursor))
|
||||
defer b.log.Debug("← after", slog.Any("cursor", b.cursor))
|
||||
|
||||
switch {
|
||||
// If we're at the beginning of the line, move to the line above;
|
||||
// unless we're at the start of the buffer.
|
||||
case b.cursor.index == 0 && b.cursor.line > 0:
|
||||
b.cursor.line--
|
||||
|
||||
line := b.CursorLine()
|
||||
b.cursor.index = line.Length()
|
||||
b.cursor.glyph = line.Width()
|
||||
|
||||
// Move left by one rune.
|
||||
case b.cursor.index > 0:
|
||||
var (
|
||||
line = b.CursorLine()
|
||||
r rune
|
||||
size int
|
||||
)
|
||||
// Reverse until we hit the start of a rune.
|
||||
for i := b.cursor.index - 1; i >= 0; i-- {
|
||||
r, size = line.DecodeRune(i)
|
||||
if r != utf8.RuneError {
|
||||
b.cursor.index -= size
|
||||
b.cursor.glyph -= glyph.Width(r)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Buffer) CursorRight() {
|
||||
b.log.Debug("→ before", slog.Any("cursor", b.cursor))
|
||||
defer b.log.Debug("→ after", slog.Any("cursor", b.cursor))
|
||||
|
||||
var (
|
||||
line = b.CursorLine()
|
||||
length = line.Length()
|
||||
)
|
||||
|
||||
switch {
|
||||
// If we're at the end of the line, move to the line below;
|
||||
// unless we're at the end of the buffer.
|
||||
case b.cursor.index == length && b.cursor.line < len(b.lines)-1:
|
||||
b.cursor.line++
|
||||
b.cursor.index = 0
|
||||
b.cursor.glyph = 0
|
||||
|
||||
// Move the index right by one rune.
|
||||
case b.cursor.index < length:
|
||||
r, size := line.DecodeRune(b.cursor.index)
|
||||
if r == utf8.RuneError {
|
||||
b.cursor.index++
|
||||
b.cursor.glyph += glyph.Width(r)
|
||||
} else {
|
||||
b.cursor.index += size
|
||||
b.cursor.glyph += glyph.Width(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Buffer) CursorUp() {
|
||||
b.log.Debug("↑ before", slog.Any("cursor", b.cursor))
|
||||
defer b.log.Debug("↑ after", slog.Any("cursor", b.cursor))
|
||||
|
||||
// If we're on the first line, don't move up.
|
||||
if b.cursor.line == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
b.cursor.line--
|
||||
|
||||
// Adjust the column.
|
||||
var (
|
||||
line = b.CursorLine()
|
||||
length = line.Length()
|
||||
width = line.Width()
|
||||
)
|
||||
switch {
|
||||
case b.cursor.glyph > width:
|
||||
b.cursor.index = length
|
||||
b.cursor.glyph = width
|
||||
|
||||
default:
|
||||
b.cursor.index, b.cursor.glyph = line.FindGlyphIndex(
|
||||
b.cursor.glyph,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Buffer) PageDown(height int) {
|
||||
if b.cursor.line+height >= len(b.lines) {
|
||||
b.cursor.line = len(b.lines) - 1
|
||||
} else {
|
||||
b.cursor.line += height
|
||||
}
|
||||
|
||||
var (
|
||||
line = b.CursorLine()
|
||||
length = line.Length()
|
||||
width = line.Width()
|
||||
)
|
||||
switch {
|
||||
case b.cursor.glyph > width:
|
||||
b.cursor.index = length
|
||||
b.cursor.glyph = width
|
||||
|
||||
default:
|
||||
b.cursor.index, b.cursor.glyph = line.FindGlyphIndex(
|
||||
b.cursor.glyph,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Buffer) PageUp(height int) {
|
||||
if b.cursor.line-height > 0 {
|
||||
b.cursor.line -= height
|
||||
} else {
|
||||
b.cursor.line = 0
|
||||
}
|
||||
|
||||
var (
|
||||
line = b.CursorLine()
|
||||
length = line.Length()
|
||||
width = line.Width()
|
||||
)
|
||||
switch {
|
||||
case b.cursor.glyph > width:
|
||||
b.cursor.index = length
|
||||
b.cursor.glyph = width
|
||||
|
||||
default:
|
||||
b.cursor.index, b.cursor.glyph = line.FindGlyphIndex(
|
||||
b.cursor.glyph,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Buffer) CursorHome() {
|
||||
b.cursor.glyph = 0
|
||||
b.cursor.index = 0
|
||||
}
|
||||
|
||||
func (b *Buffer) CursorEnd() {
|
||||
var line = b.CursorLine()
|
||||
|
||||
b.cursor.index = line.Length()
|
||||
b.cursor.glyph = line.Width()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package buffer
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.chimeric.al/dwrz/wen/internal/editor/buffer/glyph"
|
||||
"code.chimeric.al/dwrz/wen/internal/editor/buffer/line"
|
||||
)
|
||||
|
||||
func (b *Buffer) Backspace() {
|
||||
var cl = b.CursorLine()
|
||||
|
||||
switch {
|
||||
// Don't do anything at the beginning of the buffer.
|
||||
case b.cursor.line == 0 && b.cursor.index == 0:
|
||||
|
||||
// Delete empty lines.
|
||||
case cl.Length() == 0:
|
||||
b.lines = append(
|
||||
b.lines[:b.cursor.line],
|
||||
b.lines[b.cursor.line+1:]...,
|
||||
)
|
||||
|
||||
b.CursorUp()
|
||||
b.CursorEnd()
|
||||
|
||||
// Append to the previous line.
|
||||
case b.cursor.line != 0 && b.cursor.index == 0:
|
||||
index := b.cursor.line
|
||||
|
||||
b.CursorUp()
|
||||
b.CursorEnd()
|
||||
|
||||
b.lines[index-1].Append(cl.String())
|
||||
|
||||
b.lines = append(
|
||||
b.lines[:index],
|
||||
b.lines[index+1:]...,
|
||||
)
|
||||
|
||||
// Delete a rune.
|
||||
default:
|
||||
b.CursorLeft()
|
||||
b.CursorLine().DeleteRune(b.cursor.index)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Buffer) Insert(r rune) {
|
||||
switch {
|
||||
case r == '\n' || r == '\r':
|
||||
b.Newline()
|
||||
|
||||
case r == '\t':
|
||||
b.CursorLine().Insert(b.cursor.index, r)
|
||||
b.cursor.index += utf8.RuneLen(r)
|
||||
b.cursor.glyph += glyph.Width(r)
|
||||
|
||||
// Ignore all other non-printable characters.
|
||||
case !unicode.IsPrint(r):
|
||||
return
|
||||
|
||||
default:
|
||||
b.CursorLine().Insert(b.cursor.index, r)
|
||||
b.cursor.index += utf8.RuneLen(r)
|
||||
b.cursor.glyph += glyph.Width(r)
|
||||
}
|
||||
}
|
||||
|
||||
// At the start of a line, create a new line above.
|
||||
// At the end of the line, create a new line below.
|
||||
// In the middle of a line, any remaining runes go to the next line.
|
||||
// TODO: using the cursor index will probably break with marks.
|
||||
func (b *Buffer) Newline() {
|
||||
var cl = b.CursorLine()
|
||||
|
||||
switch {
|
||||
case b.cursor.index == 0:
|
||||
b.lines = append(
|
||||
b.lines[:b.cursor.line+1],
|
||||
b.lines[b.cursor.line:]...,
|
||||
)
|
||||
b.lines[b.cursor.line] = line.New("")
|
||||
|
||||
default:
|
||||
text := cl.String()
|
||||
rest := line.New(text[b.cursor.index:])
|
||||
|
||||
b.lines = append(
|
||||
b.lines[:b.cursor.line+1],
|
||||
b.lines[b.cursor.line:]...,
|
||||
)
|
||||
b.lines[b.cursor.line] = line.New(text[:b.cursor.index])
|
||||
b.lines[b.cursor.line+1] = rest
|
||||
}
|
||||
|
||||
b.CursorDown()
|
||||
b.CursorHome()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package glyph
|
||||
|
||||
import "github.com/mattn/go-runewidth"
|
||||
|
||||
// Width returns the terminal column width of a rune.
|
||||
// Tabs use a fixed width of 8 columns.
|
||||
// TODO: handle Unicode marks.
|
||||
func Width(r rune) int {
|
||||
switch r {
|
||||
case '\t':
|
||||
return 8
|
||||
default:
|
||||
return runewidth.RuneWidth(r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package line
|
||||
|
||||
import "strings"
|
||||
|
||||
func (l *Line) Append(s string) {
|
||||
l.data = l.data + s
|
||||
}
|
||||
|
||||
func (l *Line) DeleteRune(index int) (deleted rune) {
|
||||
if index < 0 || index >= len(l.data) {
|
||||
return
|
||||
}
|
||||
|
||||
var str strings.Builder
|
||||
for i, r := range l.data {
|
||||
if i == index {
|
||||
deleted = r
|
||||
continue
|
||||
}
|
||||
str.WriteRune(r)
|
||||
}
|
||||
|
||||
l.data = str.String()
|
||||
|
||||
return deleted
|
||||
}
|
||||
|
||||
func (l *Line) Insert(index int, nr rune) {
|
||||
if index < 0 || index > len(l.data) {
|
||||
return
|
||||
}
|
||||
|
||||
var str strings.Builder
|
||||
switch {
|
||||
|
||||
// Handle empty lines and the end of the line.
|
||||
case index == len(l.data):
|
||||
str.WriteString(l.data)
|
||||
str.WriteRune(nr)
|
||||
|
||||
default:
|
||||
for i, r := range l.data {
|
||||
if i == index {
|
||||
str.WriteRune(nr)
|
||||
}
|
||||
str.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
l.data = str.String()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package line
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.chimeric.al/dwrz/wen/internal/editor/buffer/glyph"
|
||||
)
|
||||
|
||||
type Line struct {
|
||||
data string
|
||||
}
|
||||
|
||||
func New(s string) *Line {
|
||||
return &Line{data: s}
|
||||
}
|
||||
|
||||
func (l *Line) DecodeRune(index int) (r rune, size int) {
|
||||
return utf8.DecodeRuneInString(l.data[index:])
|
||||
}
|
||||
|
||||
// FindGlyphIndex returns the byte index and display column (g) for a target
|
||||
// display column. If target falls inside a multi-column rune, it returns the
|
||||
// start of that rune. If target is past the end of the line, it returns the
|
||||
// byte length and total display width of the line.
|
||||
func (l *Line) FindGlyphIndex(target int) (index, g int) {
|
||||
var li, lg int
|
||||
for i, r := range l.data {
|
||||
// We've reached the target.
|
||||
if g == target {
|
||||
|
||||
return i, g
|
||||
}
|
||||
// We've gone too far.
|
||||
// Return the preceding index and glyph.
|
||||
if g > target {
|
||||
return li, lg
|
||||
}
|
||||
|
||||
// Otherwise, we haven't reached the target.
|
||||
// Save this index and glyph.
|
||||
li = i
|
||||
lg = g
|
||||
|
||||
// Then increment the glyph.
|
||||
g += glyph.Width(r)
|
||||
}
|
||||
// Target falls in-between the last glyph and the end of the line.
|
||||
// Use the last glyph.
|
||||
if target >= lg && target < g {
|
||||
return li, lg
|
||||
}
|
||||
|
||||
// We weren't able to find the glyph.
|
||||
// Return the last possible index and glyph.
|
||||
return len(l.data), g
|
||||
}
|
||||
|
||||
// Length returns the byte length of the line's UTF-8 string.
|
||||
func (l *Line) Length() int {
|
||||
return len(l.data)
|
||||
}
|
||||
|
||||
// Render returns the visible substring starting at display column offset and
|
||||
// fitting within width display columns. Tabs are expanded to 8 spaces. If
|
||||
// offset splits a multi-column rune, the remaining columns of that rune are
|
||||
// padded with spaces.
|
||||
func (l *Line) Render(offset, width int) string {
|
||||
if offset < 0 || width <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
text = strings.ReplaceAll(l.data, "\t", " ")
|
||||
str strings.Builder
|
||||
)
|
||||
for _, r := range text {
|
||||
if r == utf8.RuneError {
|
||||
continue
|
||||
}
|
||||
|
||||
rw := glyph.Width(r)
|
||||
if offset > 0 {
|
||||
offset -= rw
|
||||
if offset < 0 {
|
||||
// The offset split a wide rune.
|
||||
// Pad the remaining column.
|
||||
pad := -offset
|
||||
if pad > width {
|
||||
pad = width
|
||||
}
|
||||
for i := 0; i < pad; i++ {
|
||||
str.WriteRune(' ')
|
||||
}
|
||||
width -= pad
|
||||
offset = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if width-rw < 0 {
|
||||
break
|
||||
}
|
||||
|
||||
str.WriteRune(r)
|
||||
width -= rw
|
||||
}
|
||||
|
||||
return str.String()
|
||||
}
|
||||
|
||||
func (l *Line) Runes() []rune {
|
||||
return []rune(l.data)
|
||||
}
|
||||
|
||||
func (l *Line) String() string {
|
||||
return l.data
|
||||
}
|
||||
|
||||
// Width returns the terminal display column width of the line.
|
||||
func (l *Line) Width() (count int) {
|
||||
for _, r := range l.data {
|
||||
rw := glyph.Width(r)
|
||||
if r == utf8.RuneError {
|
||||
continue
|
||||
}
|
||||
count += rw
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package line
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
type GlyphIndexTest struct {
|
||||
data string
|
||||
glyph int
|
||||
index int
|
||||
target int
|
||||
}
|
||||
|
||||
func TestGlyphIndex(t *testing.T) {
|
||||
var tests = []GlyphIndexTest{
|
||||
{
|
||||
data: "",
|
||||
index: 0,
|
||||
glyph: 0,
|
||||
target: 0,
|
||||
},
|
||||
{
|
||||
data: "\n",
|
||||
index: 0,
|
||||
glyph: 0,
|
||||
target: 0,
|
||||
},
|
||||
{
|
||||
data: "\t🤔",
|
||||
index: 0,
|
||||
glyph: 0,
|
||||
target: 0,
|
||||
},
|
||||
{
|
||||
data: "\t🤔",
|
||||
index: 0,
|
||||
glyph: 0,
|
||||
target: 1,
|
||||
},
|
||||
{
|
||||
data: "\t🤔",
|
||||
index: 1,
|
||||
glyph: 8,
|
||||
target: 8,
|
||||
},
|
||||
{
|
||||
data: "\t🤔**",
|
||||
index: 5, // \t = 0, 🤔 = 1-4, * = 5
|
||||
glyph: 10, // \t = 0, 🤔 = 8, * = 10.
|
||||
target: 10, // *
|
||||
},
|
||||
{
|
||||
data: "\t🤔*",
|
||||
index: 6, // \t = 0, 🤔 = 1-4, * = 5
|
||||
glyph: 11, // \t = 0, 🤔 = 8, * = 10.
|
||||
target: 11, // *
|
||||
},
|
||||
|
||||
{
|
||||
data: "你是谁?",
|
||||
index: 3, // 你 size is 3 bytes.
|
||||
glyph: 2, // 你 width is 2.
|
||||
target: 2, // 是
|
||||
},
|
||||
{
|
||||
data: "磨杵成针",
|
||||
index: 12, // 针
|
||||
glyph: 8, // 针
|
||||
target: 8, // Doesn't exist; return end of last rune.
|
||||
},
|
||||
{
|
||||
data: "你好!",
|
||||
index: 6, // !
|
||||
glyph: 4, // !
|
||||
target: 5,
|
||||
},
|
||||
}
|
||||
|
||||
for n, test := range tests {
|
||||
i, g := New(test.data).FindGlyphIndex(test.target)
|
||||
if i != test.index {
|
||||
t.Errorf(
|
||||
"#%d: expected index %d, but got %d",
|
||||
n, test.index, i,
|
||||
)
|
||||
}
|
||||
if g != test.glyph {
|
||||
t.Errorf(
|
||||
"#%d: expected glyph %d, but got %d",
|
||||
n, test.glyph, g,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type WidthTest struct {
|
||||
data string
|
||||
expected int
|
||||
}
|
||||
|
||||
func TestWidth(t *testing.T) {
|
||||
var tests = []WidthTest{
|
||||
{
|
||||
data: "",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
data: "\n",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
data: "\t",
|
||||
expected: 8,
|
||||
},
|
||||
{
|
||||
data: "Hello, World!",
|
||||
expected: 13,
|
||||
},
|
||||
{
|
||||
data: "Hello, 世界!",
|
||||
expected: 12,
|
||||
},
|
||||
{
|
||||
data: "💻💩",
|
||||
expected: 4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
if w := New(test.data).Width(); w != test.expected {
|
||||
t.Errorf(
|
||||
"expected width %d, but got %d",
|
||||
test.expected, w,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type RenderTest struct {
|
||||
data string
|
||||
offset int
|
||||
width int
|
||||
expected string
|
||||
}
|
||||
|
||||
func TestRender(t *testing.T) {
|
||||
var tests = []RenderTest{
|
||||
{
|
||||
data: "",
|
||||
offset: 0,
|
||||
width: 10,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
data: "hello",
|
||||
offset: 0,
|
||||
width: 10,
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
data: "hello",
|
||||
offset: 2,
|
||||
width: 2,
|
||||
expected: "ll",
|
||||
},
|
||||
{
|
||||
data: "\ta",
|
||||
offset: 0,
|
||||
width: 9,
|
||||
expected: " a",
|
||||
},
|
||||
{
|
||||
data: "世界",
|
||||
offset: 0,
|
||||
width: 3,
|
||||
expected: "世",
|
||||
},
|
||||
{
|
||||
data: "世界",
|
||||
offset: 1,
|
||||
width: 3,
|
||||
expected: " 界",
|
||||
},
|
||||
{
|
||||
data: "hello",
|
||||
offset: -1,
|
||||
width: 5,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
data: "hello",
|
||||
offset: 0,
|
||||
width: 0,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for n, test := range tests {
|
||||
got := New(test.data).Render(test.offset, test.width)
|
||||
if got != test.expected {
|
||||
t.Errorf(
|
||||
"#%d: expected %q, but got %q",
|
||||
n, test.expected, got,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type InsertTest struct {
|
||||
data string
|
||||
index int
|
||||
r rune
|
||||
expected string
|
||||
}
|
||||
|
||||
func TestInsert(t *testing.T) {
|
||||
var tests = []InsertTest{
|
||||
{
|
||||
data: "",
|
||||
index: 0,
|
||||
r: 'a',
|
||||
expected: "a",
|
||||
},
|
||||
{
|
||||
data: "bc",
|
||||
index: 0,
|
||||
r: 'a',
|
||||
expected: "abc",
|
||||
},
|
||||
{
|
||||
data: "ac",
|
||||
index: 1,
|
||||
r: 'b',
|
||||
expected: "abc",
|
||||
},
|
||||
{
|
||||
data: "ab",
|
||||
index: 2,
|
||||
r: 'c',
|
||||
expected: "abc",
|
||||
},
|
||||
{
|
||||
data: "世",
|
||||
index: 3,
|
||||
r: '界',
|
||||
expected: "世界",
|
||||
},
|
||||
}
|
||||
|
||||
for n, test := range tests {
|
||||
l := New(test.data)
|
||||
l.Insert(test.index, test.r)
|
||||
if got := l.String(); got != test.expected {
|
||||
t.Errorf(
|
||||
"#%d: expected %q, but got %q",
|
||||
n, test.expected, got,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DeleteRuneTest struct {
|
||||
data string
|
||||
index int
|
||||
deleted rune
|
||||
expected string
|
||||
}
|
||||
|
||||
func TestDeleteRune(t *testing.T) {
|
||||
var tests = []DeleteRuneTest{
|
||||
{
|
||||
data: "",
|
||||
index: 0,
|
||||
deleted: 0,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
data: "abc",
|
||||
index: 0,
|
||||
deleted: 'a',
|
||||
expected: "bc",
|
||||
},
|
||||
{
|
||||
data: "abc",
|
||||
index: 1,
|
||||
deleted: 'b',
|
||||
expected: "ac",
|
||||
},
|
||||
{
|
||||
data: "世界",
|
||||
index: 0,
|
||||
deleted: '世',
|
||||
expected: "界",
|
||||
},
|
||||
{
|
||||
data: "世界",
|
||||
index: 3,
|
||||
deleted: '界',
|
||||
expected: "世",
|
||||
},
|
||||
}
|
||||
|
||||
for n, test := range tests {
|
||||
l := New(test.data)
|
||||
r := l.DeleteRune(test.index)
|
||||
if r != test.deleted {
|
||||
t.Errorf(
|
||||
"#%d: expected deleted %q, but got %q",
|
||||
n, test.deleted, r,
|
||||
)
|
||||
}
|
||||
if got := l.String(); got != test.expected {
|
||||
t.Errorf(
|
||||
"#%d: expected %q, but got %q",
|
||||
n, test.expected, got,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package buffer
|
||||
|
||||
// Output holds the visible lines for a terminal viewport and the cursor
|
||||
// position adjusted for scrolling.
|
||||
// Unlike Cursor, Glyph and Line are 1-indexed terminal coordinates for ANSI
|
||||
// cursor positioning.
|
||||
type Output struct {
|
||||
Glyph int
|
||||
Line int
|
||||
Lines []string
|
||||
}
|
||||
|
||||
// TODO: wrap lines.
|
||||
// TODO: add highlighting.
|
||||
func (b *Buffer) Render(height, width int) *Output {
|
||||
// Vertical scroll.
|
||||
if b.cursor.line < b.offset.line {
|
||||
b.offset.line = b.cursor.line
|
||||
}
|
||||
if b.cursor.line >= height+b.offset.line {
|
||||
b.offset.line = b.cursor.line - height + 1
|
||||
}
|
||||
|
||||
// Horizontal scroll.
|
||||
if b.cursor.glyph < b.offset.glyph {
|
||||
b.offset.glyph = b.cursor.glyph
|
||||
}
|
||||
if b.cursor.glyph >= width+b.offset.glyph {
|
||||
b.offset.glyph = b.cursor.glyph - width + 1
|
||||
}
|
||||
|
||||
// Generate lines.
|
||||
var lines = []string{}
|
||||
for i := b.offset.line; i < height+b.offset.line; i++ {
|
||||
// Return empty lines for indexes past the buffer's lines.
|
||||
if i >= len(b.lines) {
|
||||
lines = append(lines, "")
|
||||
continue
|
||||
}
|
||||
|
||||
line := b.lines[i].Render(b.offset.glyph, width)
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
return &Output{
|
||||
// Terminals are 1-indexed.
|
||||
Glyph: b.cursor.glyph - b.offset.glyph + 1,
|
||||
Line: b.cursor.line - b.offset.line + 1,
|
||||
Lines: lines,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package buffer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TODO: save to temporary file, then move over current file.
|
||||
// TODO: autosave; track dirty buffer.
|
||||
func (b *Buffer) Save() error {
|
||||
var buf bytes.Buffer
|
||||
switch len(b.lines) {
|
||||
case 1:
|
||||
if l := b.lines[0]; l.Length() != 0 {
|
||||
if _, err := buf.WriteString(l.String()); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to write to buffer: %v", err,
|
||||
)
|
||||
}
|
||||
if _, err := buf.WriteRune('\n'); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to write to buffer: %v", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
default:
|
||||
for _, l := range b.lines {
|
||||
if _, err := buf.WriteString(l.String()); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to write to buffer: %v", err,
|
||||
)
|
||||
}
|
||||
if _, err := buf.WriteRune('\n'); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to write to buffer: %v", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(
|
||||
b.name,
|
||||
buf.Bytes(),
|
||||
0666,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to write file: %v", err)
|
||||
}
|
||||
|
||||
b.saved = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Buffer) SaveAs(name string) error {
|
||||
old := b.name
|
||||
b.name = name
|
||||
|
||||
if err := b.Save(); err != nil {
|
||||
b.name = old
|
||||
return fmt.Errorf("failed to save file as %s: %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user