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
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package editor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/buffer"
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
// load files into editor buffers.
|
||||||
|
func (e *Editor) load(files []string) {
|
||||||
|
// Attempt to deduplicate any files.
|
||||||
|
var (
|
||||||
|
unique = map[string]struct{}{}
|
||||||
|
paths []string
|
||||||
|
)
|
||||||
|
for _, f := range files {
|
||||||
|
path, err := filepath.Abs(f)
|
||||||
|
if err != nil {
|
||||||
|
e.log.Error(
|
||||||
|
"failed to get absolute path",
|
||||||
|
slog.String("file", f),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
|
||||||
|
path = filepath.Clean(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := unique[path]; !exists {
|
||||||
|
unique[path] = struct{}{}
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the files.
|
||||||
|
// Set the first successfully loaded file as the active buffer.
|
||||||
|
var setActive bool
|
||||||
|
for _, name := range paths {
|
||||||
|
p := buffer.NewBufferParams{
|
||||||
|
Name: name,
|
||||||
|
Log: e.log,
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := buffer.Open(p)
|
||||||
|
// Create the file if it doesn't exist.
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
b, err = buffer.Create(p)
|
||||||
|
}
|
||||||
|
// If there was an error, report it to the user.
|
||||||
|
if err != nil {
|
||||||
|
e.prompt.SetText(fmt.Sprintf(
|
||||||
|
"failed to load buffer %s: %v",
|
||||||
|
name, err,
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
e.log.Debug("loaded buffer", slog.String("name", name))
|
||||||
|
|
||||||
|
e.setBuffer(b)
|
||||||
|
|
||||||
|
if !setActive {
|
||||||
|
e.setActiveBuffer(name)
|
||||||
|
setActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.prompt.Text() == "" {
|
||||||
|
e.prompt.SetText("loaded buffers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Editor) renameBuffer(b *buffer.Buffer, name string) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
oldName := b.Name()
|
||||||
|
if err := b.SaveAs(name); err != nil {
|
||||||
|
return fmt.Errorf("failed to save as %s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(e.buffers, oldName)
|
||||||
|
e.buffers[name] = b
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setActiveBuffer sets the named buffer as the active buffer.
|
||||||
|
func (e *Editor) setActiveBuffer(name string) {
|
||||||
|
e.mu.Lock()
|
||||||
|
b, exists := e.buffers[name]
|
||||||
|
if !exists {
|
||||||
|
e.mu.Unlock()
|
||||||
|
e.events <- event.NewError(fmt.Errorf(
|
||||||
|
"failed to set active buffer: %s does not exist", name,
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.active = b
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
e.log.Debug("set active buffer", slog.String("name", b.Name()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// setBuffer stores a buffer in the editor's buffer map.
|
||||||
|
func (e *Editor) setBuffer(b *buffer.Buffer) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
e.buffers[b.Name()] = b
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package canvas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"code.chimeric.al/dwrz/wen/pkg/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Canvas struct {
|
||||||
|
log *slog.Logger
|
||||||
|
out *os.File
|
||||||
|
terminal *terminal.Terminal
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
type Parameters struct {
|
||||||
|
Log *slog.Logger
|
||||||
|
Out *os.File
|
||||||
|
Terminal *terminal.Terminal
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(p Parameters) *Canvas {
|
||||||
|
return &Canvas{
|
||||||
|
log: p.Log,
|
||||||
|
out: p.Out,
|
||||||
|
terminal: p.Terminal,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package canvas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/buffer"
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/prompt"
|
||||||
|
"code.chimeric.al/dwrz/wen/pkg/color"
|
||||||
|
"code.chimeric.al/dwrz/wen/pkg/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bars is the number of terminal rows reserved at the bottom of the screen
|
||||||
|
// for the status bar and message bar.
|
||||||
|
const Bars = 2
|
||||||
|
|
||||||
|
func (c *Canvas) Render(b *buffer.Buffer, prompt *prompt.Prompt) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
size, err := c.terminal.Size()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get terminal size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
cursor = b.Cursor()
|
||||||
|
height = int(size.Rows) - Bars
|
||||||
|
width = int(size.Columns)
|
||||||
|
output = b.Render(height, width)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Move the cursor to the top left.
|
||||||
|
c.buf.WriteString(terminal.CursorHide)
|
||||||
|
c.buf.WriteString(terminal.CursorTopLeft)
|
||||||
|
|
||||||
|
// Print each line.
|
||||||
|
for _, line := range output.Lines {
|
||||||
|
c.buf.WriteString(terminal.EraseLine)
|
||||||
|
c.buf.WriteString(line)
|
||||||
|
c.buf.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw the status bar.
|
||||||
|
c.statusBar(b, width, cursor.Line(), cursor.Glyph())
|
||||||
|
|
||||||
|
// Draw the message bar.
|
||||||
|
c.messageBar(prompt, width)
|
||||||
|
|
||||||
|
// Set the cursor.
|
||||||
|
if prompt.IsPrompt() {
|
||||||
|
c.buf.WriteString(fmt.Sprintf(
|
||||||
|
terminal.SetCursorFmt, size.Rows,
|
||||||
|
runewidth.StringWidth(prompt.Text())+prompt.Cursor()+2,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
c.buf.WriteString(fmt.Sprintf(
|
||||||
|
terminal.SetCursorFmt, output.Line, output.Glyph,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.buf.WriteString(terminal.CursorShow)
|
||||||
|
|
||||||
|
c.out.Write(c.buf.Bytes())
|
||||||
|
|
||||||
|
c.buf.Reset()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Canvas) statusBar(b *buffer.Buffer, width, y, x int) {
|
||||||
|
c.buf.WriteString(terminal.EraseLine)
|
||||||
|
c.buf.WriteString(color.Inverse)
|
||||||
|
|
||||||
|
// Icon
|
||||||
|
icon := "文 "
|
||||||
|
c.buf.WriteString(icon)
|
||||||
|
width -= runewidth.StringWidth(icon)
|
||||||
|
|
||||||
|
// Calculate the length of the cursor, so we can determine how much
|
||||||
|
// space we have left for the name of the buffer.
|
||||||
|
cursor := fmt.Sprintf(" %d:%d", y, x)
|
||||||
|
width -= runewidth.StringWidth(cursor)
|
||||||
|
|
||||||
|
// Filename.
|
||||||
|
// TODO: handle long filenames (shorten filepath).
|
||||||
|
name := path.Base(b.Name())
|
||||||
|
nw := runewidth.StringWidth(name)
|
||||||
|
if nw <= width {
|
||||||
|
c.buf.WriteString(name)
|
||||||
|
width -= nw
|
||||||
|
} else {
|
||||||
|
for _, r := range name {
|
||||||
|
rw := runewidth.RuneWidth(r)
|
||||||
|
if width-rw >= 0 {
|
||||||
|
c.buf.WriteRune(r)
|
||||||
|
width -= rw
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add empty spaces to the end of the line.
|
||||||
|
for i := width; i > 0; i-- {
|
||||||
|
c.buf.WriteRune(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor
|
||||||
|
c.buf.WriteString(cursor)
|
||||||
|
|
||||||
|
c.buf.WriteString(color.Reset)
|
||||||
|
c.buf.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: handle messages that are too long for one line.
|
||||||
|
func (c *Canvas) messageBar(p *prompt.Prompt, width int) {
|
||||||
|
c.buf.WriteString(terminal.EraseLine)
|
||||||
|
|
||||||
|
var (
|
||||||
|
text = p.Text()
|
||||||
|
line = p.Line(0, width) // TODO: handle offset.
|
||||||
|
output string
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case text == "" && line == "":
|
||||||
|
output = ""
|
||||||
|
case line == "":
|
||||||
|
output = text
|
||||||
|
default:
|
||||||
|
output = fmt.Sprintf("%s %s", text, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range output {
|
||||||
|
rw := runewidth.RuneWidth(r)
|
||||||
|
if width-rw < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c.buf.WriteRune(r)
|
||||||
|
width -= rw
|
||||||
|
}
|
||||||
|
for i := width; i > 0; i-- {
|
||||||
|
c.buf.WriteRune(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package canvas
|
||||||
|
|
||||||
|
import "code.chimeric.al/dwrz/wen/pkg/terminal"
|
||||||
|
|
||||||
|
func (c *Canvas) Reset() {
|
||||||
|
c.out.WriteString(terminal.ClearScreen)
|
||||||
|
c.out.WriteString(terminal.CursorTopLeft)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package editor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/buffer"
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/canvas"
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/event"
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/prompt"
|
||||||
|
"code.chimeric.al/dwrz/wen/pkg/terminal"
|
||||||
|
"code.chimeric.al/dwrz/wen/pkg/terminal/input"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: add undo and redo.
|
||||||
|
// TODO: add search and replace.
|
||||||
|
type Editor struct {
|
||||||
|
canvas *canvas.Canvas
|
||||||
|
input chan *input.Event
|
||||||
|
log *slog.Logger
|
||||||
|
events chan event.Event
|
||||||
|
reader *input.Reader
|
||||||
|
terminal *terminal.Terminal
|
||||||
|
tmpdir string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
active *buffer.Buffer
|
||||||
|
buffers map[string]*buffer.Buffer
|
||||||
|
prompt *prompt.Prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
type Parameters struct {
|
||||||
|
In *os.File
|
||||||
|
Log *slog.Logger
|
||||||
|
Out *os.File
|
||||||
|
TempDir string
|
||||||
|
Terminal *terminal.Terminal
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: read text piped in from stdin.
|
||||||
|
// TODO: pipe buffer texts through shell command.
|
||||||
|
func New(p Parameters) (*Editor, error) {
|
||||||
|
var editor = &Editor{
|
||||||
|
buffers: map[string]*buffer.Buffer{},
|
||||||
|
events: make(chan event.Event),
|
||||||
|
input: make(chan *input.Event),
|
||||||
|
log: p.Log,
|
||||||
|
terminal: p.Terminal,
|
||||||
|
tmpdir: p.TempDir,
|
||||||
|
prompt: prompt.New(prompt.Parameters{Log: p.Log}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup user input.
|
||||||
|
editor.reader = input.New(input.Parameters{
|
||||||
|
Chan: editor.input,
|
||||||
|
In: p.In,
|
||||||
|
Log: p.Log,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Setup the canvas.
|
||||||
|
editor.canvas = canvas.New(canvas.Parameters{
|
||||||
|
Log: p.Log,
|
||||||
|
Out: p.Out,
|
||||||
|
Terminal: p.Terminal,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create the initial scratch buffer.
|
||||||
|
scratch, err := buffer.Create(buffer.NewBufferParams{
|
||||||
|
Name: editor.tmpdir + "/scratch",
|
||||||
|
Log: editor.log,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to create scratch buffer: %v", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
editor.active = scratch
|
||||||
|
editor.buffers[scratch.Name()] = scratch
|
||||||
|
|
||||||
|
return editor, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package event
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
error
|
||||||
|
t time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Time() time.Time {
|
||||||
|
return e.t
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewError(err error) *Error {
|
||||||
|
return &Error{
|
||||||
|
error: err,
|
||||||
|
t: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package event
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Event interface {
|
||||||
|
Time() time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package event
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
t time.Time
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Message) Time() time.Time {
|
||||||
|
return e.t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Message) Text() string {
|
||||||
|
return e.text
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMessage(s string) *Message {
|
||||||
|
return &Message{
|
||||||
|
t: time.Now(),
|
||||||
|
text: s,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package event
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Quit struct {
|
||||||
|
t time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Quit) Time() time.Time {
|
||||||
|
return e.t
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQuit() *Quit {
|
||||||
|
return &Quit{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package editor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/canvas"
|
||||||
|
"code.chimeric.al/dwrz/wen/pkg/terminal/input"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *Editor) bufferInput(in *input.Event) error {
|
||||||
|
size, err := e.terminal.Size()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get terminal size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.Rune != input.Null {
|
||||||
|
switch in.Rune {
|
||||||
|
case input.Delete:
|
||||||
|
e.active.Backspace()
|
||||||
|
|
||||||
|
case 'q' & input.Control:
|
||||||
|
e.prompt.Prompt("Quit? (y/n)", e.quit)
|
||||||
|
|
||||||
|
case 's' & input.Control:
|
||||||
|
if err := e.active.Save(); err != nil {
|
||||||
|
e.prompt.SetText(fmt.Sprintf(
|
||||||
|
"failed to save: %v", err,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
e.prompt.SetText("saved file")
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'x' & input.Control:
|
||||||
|
e.prompt.Prompt("Save as:", e.saveAs)
|
||||||
|
|
||||||
|
default:
|
||||||
|
e.active.Insert(in.Rune)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch in.Key {
|
||||||
|
case input.Down:
|
||||||
|
e.active.CursorDown()
|
||||||
|
|
||||||
|
case input.Left:
|
||||||
|
e.active.CursorLeft()
|
||||||
|
|
||||||
|
case input.Right:
|
||||||
|
e.active.CursorRight()
|
||||||
|
|
||||||
|
case input.Up:
|
||||||
|
e.active.CursorUp()
|
||||||
|
|
||||||
|
case input.End:
|
||||||
|
e.active.CursorEnd()
|
||||||
|
|
||||||
|
case input.Home:
|
||||||
|
e.active.CursorHome()
|
||||||
|
|
||||||
|
case input.PageDown:
|
||||||
|
e.active.PageDown(int(size.Rows) - canvas.Bars)
|
||||||
|
|
||||||
|
case input.PageUp:
|
||||||
|
e.active.PageUp(int(size.Rows) - canvas.Bars)
|
||||||
|
|
||||||
|
default:
|
||||||
|
e.log.Debug("unrecognized input", slog.Any("input", in))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Editor) promptInput(event *input.Event) error {
|
||||||
|
if event.Rune != input.Null {
|
||||||
|
switch event.Rune {
|
||||||
|
case input.Escape:
|
||||||
|
e.prompt.Escape()
|
||||||
|
|
||||||
|
case input.CarriageReturn:
|
||||||
|
e.prompt.Complete()
|
||||||
|
|
||||||
|
case input.Delete:
|
||||||
|
e.prompt.Backspace()
|
||||||
|
|
||||||
|
default:
|
||||||
|
e.prompt.Insert(event.Rune)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event.Key {
|
||||||
|
case input.Left:
|
||||||
|
e.prompt.CursorLeft()
|
||||||
|
|
||||||
|
case input.Right:
|
||||||
|
e.prompt.CursorRight()
|
||||||
|
|
||||||
|
case input.End:
|
||||||
|
e.prompt.CursorEnd()
|
||||||
|
|
||||||
|
case input.Home:
|
||||||
|
e.prompt.CursorHome()
|
||||||
|
|
||||||
|
default:
|
||||||
|
e.log.Debug("unrecognized input", slog.Any("input", event))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package editor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/buffer/line"
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *Editor) saveAs(l line.Line) error {
|
||||||
|
if err := e.renameBuffer(e.active, l.String()); err != nil {
|
||||||
|
return fmt.Errorf("failed to save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Editor) quit(l line.Line) error {
|
||||||
|
switch l.String() {
|
||||||
|
case "y":
|
||||||
|
go func() {
|
||||||
|
e.events <- event.NewQuit()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "n":
|
||||||
|
return nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid selection")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package prompt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/buffer/glyph"
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/buffer/line"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Prompt struct {
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
err error
|
||||||
|
cb CB
|
||||||
|
cursor cursor
|
||||||
|
line *line.Line
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CB func(l line.Line) error
|
||||||
|
|
||||||
|
// cursor tracks the position in the prompt input line.
|
||||||
|
// glyph is the 0-indexed terminal display column.
|
||||||
|
// index is the 0-indexed byte offset in line's UTF-8 string.
|
||||||
|
type cursor struct {
|
||||||
|
glyph int
|
||||||
|
index int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Parameters struct {
|
||||||
|
Log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(p Parameters) *Prompt {
|
||||||
|
return &Prompt{log: p.Log}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete runs the prompt callback with the current input line.
|
||||||
|
// If the callback returns an error, the prompt stays active and displays the
|
||||||
|
// error; otherwise the prompt state is cleared.
|
||||||
|
func (p *Prompt) Complete() {
|
||||||
|
if err := p.cb(*p.line); err != nil {
|
||||||
|
p.log.Error("callback error", slog.Any("error", err))
|
||||||
|
p.err = err
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No error -- we should be done.
|
||||||
|
p.cb = nil
|
||||||
|
p.err = nil
|
||||||
|
p.line = nil
|
||||||
|
p.text = ""
|
||||||
|
p.cursor = cursor{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Prompt) Cursor() int {
|
||||||
|
return p.cursor.glyph
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Prompt) Escape() {
|
||||||
|
p.cb = nil
|
||||||
|
p.err = nil
|
||||||
|
p.line = nil
|
||||||
|
p.text = ""
|
||||||
|
p.cursor = cursor{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPrompt reports whether an interactive prompt is active (as opposed to a
|
||||||
|
// passive status message).
|
||||||
|
func (p *Prompt) IsPrompt() bool {
|
||||||
|
return p.line != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Prompt) Prompt(s string, cb CB) {
|
||||||
|
p.text = s
|
||||||
|
p.line = &line.Line{}
|
||||||
|
p.cb = cb
|
||||||
|
|
||||||
|
p.log.Debug("new prompt", slog.Any("prompt", p))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Prompt) SetText(s string) {
|
||||||
|
p.text = s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Prompt) Text() string {
|
||||||
|
if p.err != nil {
|
||||||
|
return fmt.Sprintf("[%v] %s", p.err, p.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.text
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Prompt) Line(offset, width int) string {
|
||||||
|
if p.line == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.line.Render(offset, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Prompt) CursorLeft() {
|
||||||
|
if p.cursor.index > 0 {
|
||||||
|
var (
|
||||||
|
r rune
|
||||||
|
size int
|
||||||
|
)
|
||||||
|
// Reverse until we hit the start of a rune.
|
||||||
|
for i := p.cursor.index - 1; i >= 0; i-- {
|
||||||
|
r, size = p.line.DecodeRune(i)
|
||||||
|
if r != utf8.RuneError {
|
||||||
|
p.cursor.index -= size
|
||||||
|
p.cursor.glyph -= glyph.Width(r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Prompt) CursorRight() {
|
||||||
|
if p.cursor.index < p.line.Length() {
|
||||||
|
r, size := p.line.DecodeRune(p.cursor.index)
|
||||||
|
if r == utf8.RuneError {
|
||||||
|
p.cursor.index++
|
||||||
|
p.cursor.glyph += glyph.Width(r)
|
||||||
|
} else {
|
||||||
|
p.cursor.index += size
|
||||||
|
p.cursor.glyph += glyph.Width(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Prompt) CursorHome() {
|
||||||
|
p.cursor.glyph = 0
|
||||||
|
p.cursor.index = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Prompt) CursorEnd() {
|
||||||
|
p.cursor.index = p.line.Length()
|
||||||
|
p.cursor.glyph = p.line.Width()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Prompt) Backspace() {
|
||||||
|
if p.cursor.index > 0 {
|
||||||
|
p.CursorLeft()
|
||||||
|
p.line.DeleteRune(p.cursor.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Prompt) Insert(r rune) {
|
||||||
|
switch {
|
||||||
|
// Ignore all other non-printable characters.
|
||||||
|
case !unicode.IsPrint(r):
|
||||||
|
return
|
||||||
|
|
||||||
|
default:
|
||||||
|
p.line.Insert(p.cursor.index, r)
|
||||||
|
p.cursor.index += utf8.RuneLen(r)
|
||||||
|
p.cursor.glyph += glyph.Width(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package editor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
|
"code.chimeric.al/dwrz/wen/internal/editor/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *Editor) Run(ctx context.Context, files []string) error {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := e.terminal.SetRaw(); err != nil {
|
||||||
|
return fmt.Errorf("failed to set raw mode: %w", err)
|
||||||
|
}
|
||||||
|
e.canvas.Reset()
|
||||||
|
|
||||||
|
// Log build info.
|
||||||
|
var commit, time string
|
||||||
|
bi, ok := debug.ReadBuildInfo()
|
||||||
|
if ok {
|
||||||
|
for _, s := range bi.Settings {
|
||||||
|
switch s.Key {
|
||||||
|
case "vcs.revision":
|
||||||
|
commit = s.Value
|
||||||
|
case "vcs.time":
|
||||||
|
time = s.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.log.Debug(
|
||||||
|
"文 version",
|
||||||
|
slog.String("commit", commit),
|
||||||
|
slog.String("time", time),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset the terminal before exiting.
|
||||||
|
defer func() {
|
||||||
|
e.canvas.Reset()
|
||||||
|
|
||||||
|
if err := e.terminal.Reset(); err != nil {
|
||||||
|
e.log.Error(
|
||||||
|
"failed to reset terminal attributes",
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Start reading user input.
|
||||||
|
// The reader may be blocked in a read when the editor ends.
|
||||||
|
// The reader stops when the process stops.
|
||||||
|
go func() {
|
||||||
|
if err := e.reader.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
e.events <- event.NewError(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Open the files and render the initial state.
|
||||||
|
e.load(files)
|
||||||
|
if err := e.canvas.Render(e.active, e.prompt); err != nil {
|
||||||
|
return fmt.Errorf("failed to render: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main loop.
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
e.log.DebugContext(ctx, "context done: stopping")
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case ev := <-e.events:
|
||||||
|
switch t := ev.(type) {
|
||||||
|
case *event.Error:
|
||||||
|
return t
|
||||||
|
|
||||||
|
case *event.Message:
|
||||||
|
e.prompt.SetText(t.Text())
|
||||||
|
|
||||||
|
if err := e.canvas.Render(
|
||||||
|
e.active, e.prompt,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"failed to render: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case *event.Quit:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case event := <-e.input:
|
||||||
|
if e.prompt.IsPrompt() {
|
||||||
|
if err := e.promptInput(event); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"failed to process input: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := e.bufferInput(event); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"failed to process input: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.canvas.Render(
|
||||||
|
e.active, e.prompt,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("failed to render: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user