diff --git a/internal/editor/buffer/buffer.go b/internal/editor/buffer/buffer.go new file mode 100644 index 0000000..1dae036 --- /dev/null +++ b/internal/editor/buffer/buffer.go @@ -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 +} diff --git a/internal/editor/buffer/buffer_test.go b/internal/editor/buffer/buffer_test.go new file mode 100644 index 0000000..4440feb --- /dev/null +++ b/internal/editor/buffer/buffer_test.go @@ -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, + ) + } + } +} diff --git a/internal/editor/buffer/cursor.go b/internal/editor/buffer/cursor.go new file mode 100644 index 0000000..6acea49 --- /dev/null +++ b/internal/editor/buffer/cursor.go @@ -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() +} diff --git a/internal/editor/buffer/edit.go b/internal/editor/buffer/edit.go new file mode 100644 index 0000000..33b3cca --- /dev/null +++ b/internal/editor/buffer/edit.go @@ -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() +} diff --git a/internal/editor/buffer/glyph/glyph.go b/internal/editor/buffer/glyph/glyph.go new file mode 100644 index 0000000..57dc979 --- /dev/null +++ b/internal/editor/buffer/glyph/glyph.go @@ -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) + } +} diff --git a/internal/editor/buffer/line/edit.go b/internal/editor/buffer/line/edit.go new file mode 100644 index 0000000..61dd2ad --- /dev/null +++ b/internal/editor/buffer/line/edit.go @@ -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() +} diff --git a/internal/editor/buffer/line/line.go b/internal/editor/buffer/line/line.go new file mode 100644 index 0000000..e3f9d07 --- /dev/null +++ b/internal/editor/buffer/line/line.go @@ -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 +} diff --git a/internal/editor/buffer/line/line_test.go b/internal/editor/buffer/line/line_test.go new file mode 100644 index 0000000..fe99a2b --- /dev/null +++ b/internal/editor/buffer/line/line_test.go @@ -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, + ) + } + } +} diff --git a/internal/editor/buffer/render.go b/internal/editor/buffer/render.go new file mode 100644 index 0000000..0e931bf --- /dev/null +++ b/internal/editor/buffer/render.go @@ -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, + } +} diff --git a/internal/editor/buffer/save.go b/internal/editor/buffer/save.go new file mode 100644 index 0000000..fabcf9c --- /dev/null +++ b/internal/editor/buffer/save.go @@ -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 +} diff --git a/internal/editor/buffers.go b/internal/editor/buffers.go new file mode 100644 index 0000000..f4f1733 --- /dev/null +++ b/internal/editor/buffers.go @@ -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 +} diff --git a/internal/editor/canvas/canvas.go b/internal/editor/canvas/canvas.go new file mode 100644 index 0000000..542c765 --- /dev/null +++ b/internal/editor/canvas/canvas.go @@ -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, + } +} diff --git a/internal/editor/canvas/render.go b/internal/editor/canvas/render.go new file mode 100644 index 0000000..40ba204 --- /dev/null +++ b/internal/editor/canvas/render.go @@ -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(' ') + } +} diff --git a/internal/editor/canvas/reset.go b/internal/editor/canvas/reset.go new file mode 100644 index 0000000..dfa0856 --- /dev/null +++ b/internal/editor/canvas/reset.go @@ -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) +} diff --git a/internal/editor/editor.go b/internal/editor/editor.go new file mode 100644 index 0000000..dee9fa7 --- /dev/null +++ b/internal/editor/editor.go @@ -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 +} diff --git a/internal/editor/event/error.go b/internal/editor/event/error.go new file mode 100644 index 0000000..4851b67 --- /dev/null +++ b/internal/editor/event/error.go @@ -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(), + } +} diff --git a/internal/editor/event/event.go b/internal/editor/event/event.go new file mode 100644 index 0000000..74d1b86 --- /dev/null +++ b/internal/editor/event/event.go @@ -0,0 +1,7 @@ +package event + +import "time" + +type Event interface { + Time() time.Time +} diff --git a/internal/editor/event/message.go b/internal/editor/event/message.go new file mode 100644 index 0000000..e497611 --- /dev/null +++ b/internal/editor/event/message.go @@ -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, + } +} diff --git a/internal/editor/event/quit.go b/internal/editor/event/quit.go new file mode 100644 index 0000000..b8c7fce --- /dev/null +++ b/internal/editor/event/quit.go @@ -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{} +} diff --git a/internal/editor/input.go b/internal/editor/input.go new file mode 100644 index 0000000..c38ffa5 --- /dev/null +++ b/internal/editor/input.go @@ -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 +} diff --git a/internal/editor/prompt.go b/internal/editor/prompt.go new file mode 100644 index 0000000..d79977d --- /dev/null +++ b/internal/editor/prompt.go @@ -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") + } +} diff --git a/internal/editor/prompt/prompt.go b/internal/editor/prompt/prompt.go new file mode 100644 index 0000000..212e60b --- /dev/null +++ b/internal/editor/prompt/prompt.go @@ -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) + } +} diff --git a/internal/editor/run.go b/internal/editor/run.go new file mode 100644 index 0000000..046daa0 --- /dev/null +++ b/internal/editor/run.go @@ -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) + } + } + } +}