Add editor packages

This commit is contained in:
dwrz
2026-10-26 02:32:03 +00:00
parent 38714f6c80
commit 4ed2ee97e0
23 changed files with 2114 additions and 0 deletions
+99
View File
@@ -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
}
+184
View File
@@ -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,
)
}
}
}
+218
View File
@@ -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()
}
+99
View File
@@ -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()
}
+15
View File
@@ -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)
}
}
+51
View File
@@ -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()
}
+131
View File
@@ -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
}
+319
View File
@@ -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,
)
}
}
}
+51
View File
@@ -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,
}
}
+66
View File
@@ -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
}
+116
View File
@@ -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
}
+33
View File
@@ -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,
}
}
+147
View File
@@ -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(' ')
}
}
+8
View File
@@ -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)
}
+83
View File
@@ -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
}
+19
View File
@@ -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(),
}
}
+7
View File
@@ -0,0 +1,7 @@
package event
import "time"
type Event interface {
Time() time.Time
}
+23
View File
@@ -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,
}
}
+15
View File
@@ -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{}
}
+113
View File
@@ -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
}
+33
View File
@@ -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")
}
}
+165
View File
@@ -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)
}
}
+119
View File
@@ -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)
}
}
}
}