commit 79ce513f3ab61b9a3cb5ca539ad03ed007a8a8ba
parent fda86a519e22051317e398ef8b2de048acb7973a
Author: dwrz <dwrz@dwrz.net>
Date: Fri, 2 Dec 2022 16:21:00 +0000
Add editor pkg
Diffstat:
16 files changed, 1426 insertions(+), 0 deletions(-)
diff --git a/pkg/editor/buffer/buffer.go b/pkg/editor/buffer/buffer.go
@@ -0,0 +1,122 @@
+package buffer
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "time"
+
+ "code.dwrz.net/src/pkg/editor/buffer/line"
+ "code.dwrz.net/src/pkg/log"
+)
+
+type Buffer struct {
+ cursor *Cursor
+ lines []*line.Line
+ log *log.Logger
+ file *os.File
+ name string
+ offset *offset
+ stat os.FileInfo
+ saved time.Time
+}
+
+// Offset is the first visible column and row of the buffer.
+type offset struct {
+ glyph int
+ line int
+}
+
+// TODO: return true if the underlying file has changed.
+// Use file size and mod time.
+// Need to distinguish underlying file changes from changes made via edits.
+func (b *Buffer) Changed() {
+ return
+}
+
+func (b *Buffer) Close() error {
+ return b.file.Close()
+}
+
+func (b *Buffer) CursorLine() *line.Line {
+ return b.lines[b.cursor.line]
+}
+
+func (b *Buffer) Line(i int) *line.Line {
+ return b.lines[i]
+}
+
+func (b *Buffer) Name() string {
+ return b.name
+}
+
+type NewBufferParams struct {
+ Name string
+ Log *log.Logger
+}
+
+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)
+ }
+ b.file = f
+
+ // Scan the lines.
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ b.lines = append(b.lines, line.New(scanner.Text()))
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("failed to scan: %v", err)
+ }
+
+ // 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
+}
+
+func Open(p NewBufferParams) (*Buffer, error) {
+ var b = &Buffer{
+ cursor: &Cursor{},
+ lines: []*line.Line{},
+ log: p.Log,
+ name: p.Name,
+ offset: &offset{},
+ }
+
+ // Open the file.
+ f, err := os.Open(b.name)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open file: %w", err)
+ }
+ b.file = f
+
+ // Scan the lines.
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ b.lines = append(b.lines, line.New(scanner.Text()))
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("failed to scan: %v", err)
+ }
+
+ // 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/pkg/editor/buffer/cursor.go b/pkg/editor/buffer/cursor.go
@@ -0,0 +1,181 @@
+package buffer
+
+import (
+ "unicode/utf8"
+
+ "code.dwrz.net/src/pkg/editor/buffer/glyph"
+)
+
+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.Printf("↓ before: %#v", b.cursor)
+ defer b.log.Debug.Printf("↓ after: %#v", 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.Printf("← before: %#v", b.cursor)
+ defer b.log.Debug.Printf("← after: %#v", 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.Printf("→ before: %#v", b.cursor)
+ defer b.log.Debug.Printf("→ after: %#v", 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)
+ }
+
+ b.cursor.index += size
+ b.cursor.glyph += glyph.Width(r)
+ }
+}
+
+func (b *Buffer) CursorUp() {
+ b.log.Debug.Printf("↑ before: %#v", b.cursor)
+ defer b.log.Debug.Printf("↑ after: %#v", 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
+ }
+}
+
+func (b *Buffer) PageUp(height int) {
+ if b.cursor.line-height > 0 {
+ b.cursor.line -= height
+ } else {
+ b.cursor.line = 0
+ }
+}
+
+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/pkg/editor/buffer/edit.go b/pkg/editor/buffer/edit.go
@@ -0,0 +1,100 @@
+package buffer
+
+import (
+ "unicode"
+ "unicode/utf8"
+
+ "code.dwrz.net/src/pkg/editor/buffer/glyph"
+ "code.dwrz.net/src/pkg/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/pkg/editor/buffer/glyph/glyph.go b/pkg/editor/buffer/glyph/glyph.go
@@ -0,0 +1,12 @@
+package glyph
+
+import "github.com/mattn/go-runewidth"
+
+func Width(r rune) int {
+ switch r {
+ case '\t':
+ return 8
+ default:
+ return runewidth.RuneWidth(r)
+ }
+}
diff --git a/pkg/editor/buffer/line/edit.go b/pkg/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/pkg/editor/buffer/line/line.go b/pkg/editor/buffer/line/line.go
@@ -0,0 +1,101 @@
+package line
+
+import (
+ "strings"
+ "unicode/utf8"
+
+ "code.dwrz.net/src/pkg/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:])
+}
+
+// GlyphIndex attempts to find the preceding index for a target glyph.
+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)
+ }
+
+ // We weren't able to find the glyph.
+ // Return the last possible index and glyph.
+ return len(l.data), g
+}
+
+func (l *Line) Length() int {
+ return len(l.data)
+}
+
+func (l *Line) Render(offset, width int) string {
+ var text = strings.ReplaceAll(l.data, "\t", " ")
+ if offset < 0 || offset > len(text) {
+ return ""
+ }
+
+ var str strings.Builder
+ for _, r := range text {
+ rw := glyph.Width(r)
+ if offset > 0 {
+ offset -= rw
+ continue
+ }
+ if r == utf8.RuneError {
+ continue
+ }
+
+ // Exhausted column zero.
+ if width-rw < -1 {
+ 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
+}
+
+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/pkg/editor/buffer/line/line_test.go b/pkg/editor/buffer/line/line_test.go
@@ -0,0 +1,131 @@
+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.
+ },
+ }
+
+ 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,
+ )
+ }
+ }
+}
diff --git a/pkg/editor/buffer/render.go b/pkg/editor/buffer/render.go
@@ -0,0 +1,45 @@
+package buffer
+
+type Output struct {
+ Glyph int
+ Line int
+ Lines []string
+}
+
+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
+ }
+
+ // 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
+ }
+
+ // 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/pkg/editor/buffer/save.go b/pkg/editor/buffer/save.go
@@ -0,0 +1,52 @@
+package buffer
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "time"
+)
+
+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(),
+ os.ModePerm,
+ ); err != nil {
+ return fmt.Errorf("failed to write file: %v", err)
+ }
+
+ b.saved = time.Now()
+
+ return nil
+}
diff --git a/pkg/editor/buffers.go b/pkg/editor/buffers.go
@@ -0,0 +1,90 @@
+package editor
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "code.dwrz.net/src/pkg/editor/buffer"
+)
+
+// load files into editor buffers.
+func (e *Editor) load(files []string) {
+ // Attempt to deduplicate any files.
+ var unique = map[string]struct{}{}
+ for _, f := range files {
+ path, err := filepath.Abs(f)
+ if err != nil {
+ e.log.Error.Printf(
+ "failed to get absolute path for %s: %v",
+ f, err,
+ )
+
+ path = filepath.Clean(f)
+ }
+
+ unique[path] = struct{}{}
+ }
+
+ // Load the files.
+ // Set the first successfully loaded file as the active buffer.
+ var setActive bool
+ for name := range unique {
+ 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.messages <- Message{
+ Text: fmt.Sprintf(
+ "failed to load buffer %s: %v",
+ name, err,
+ ),
+ }
+ continue
+ }
+
+ e.log.Debug.Printf("loaded buffer %s", name)
+
+ e.setBuffer(b)
+
+ if !setActive {
+ e.setActiveBuffer(name)
+ }
+ }
+
+ e.messages <- Message{Text: "loaded buffers"}
+}
+
+// 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
+}
+
+// setActiveBuffer sets the named buffer as the active buffer.
+func (e *Editor) setActiveBuffer(name string) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+
+ b, exists := e.buffers[name]
+ if !exists {
+ e.errs <- fmt.Errorf(
+ "failed to set active buffer: buffer %s does not exist",
+ name,
+ )
+ }
+
+ e.active = b
+ e.log.Debug.Printf("set active buffer %s", b.Name())
+}
diff --git a/pkg/editor/editor.go b/pkg/editor/editor.go
@@ -0,0 +1,63 @@
+package editor
+
+import (
+ "fmt"
+ "os"
+ "sync"
+
+ "code.dwrz.net/src/pkg/editor/buffer"
+ "code.dwrz.net/src/pkg/log"
+ "code.dwrz.net/src/pkg/terminal"
+)
+
+type Editor struct {
+ errs chan error
+ in *os.File
+ input chan input
+ log *log.Logger
+ messages chan Message
+ out *os.File
+ terminal *terminal.Terminal
+ tmpdir string
+
+ mu sync.Mutex
+ active *buffer.Buffer
+ buffers map[string]*buffer.Buffer
+}
+
+type Parameters struct {
+ In *os.File
+ Log *log.Logger
+ Out *os.File
+ TempDir string
+ Terminal *terminal.Terminal
+}
+
+func New(p Parameters) (*Editor, error) {
+ var editor = &Editor{
+ buffers: map[string]*buffer.Buffer{},
+ errs: make(chan error),
+ in: p.In,
+ input: make(chan input),
+ log: p.Log,
+ messages: make(chan Message),
+ out: p.Out,
+ terminal: p.Terminal,
+ tmpdir: p.TempDir,
+ }
+
+ // 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/pkg/editor/input.go b/pkg/editor/input.go
@@ -0,0 +1,275 @@
+package editor
+
+import (
+ "bufio"
+ "fmt"
+ "unicode/utf8"
+
+ "code.dwrz.net/src/pkg/terminal"
+)
+
+type command int
+
+const (
+ Backspace command = iota
+ CursorDown
+ CursorLeft
+ CursorRight
+ CursorUp
+ Delete
+ End
+ Home
+ Insert
+ Open
+ PageDown
+ PageUp
+ Quit
+ Save
+)
+
+type input struct {
+ Command command
+ Rune rune
+}
+
+// TODO: reading one rune at a time is slow, especially when pasting large
+// quantities of text into the active buffer. It would be nice to take more
+// input at once, while still being able to handle escape sequences without
+// too many edge cases.
+func (e *Editor) readInput() {
+ var buf = bufio.NewReader(e.in)
+ for {
+ r, size, err := buf.ReadRune()
+ if err != nil {
+ e.errs <- fmt.Errorf("failed to read stdin: %w", err)
+ }
+ e.log.Debug.Printf(
+ "read rune %s %v (%d)",
+ string(r), []byte(string(r)), size,
+ )
+ switch r {
+ case utf8.RuneError:
+ e.log.Error.Printf(
+ "rune error: %s (%d)", string(r), size,
+ )
+
+ // Handle escape sequences.
+ case terminal.Escape:
+ e.parseEscapeSequence(buf)
+
+ case terminal.Delete:
+ e.input <- input{Command: Backspace}
+
+ case 'q' & terminal.Control:
+ e.input <- input{Command: Quit}
+
+ case 's' & terminal.Control:
+ e.input <- input{Command: Save}
+
+ default:
+ e.input <- input{Command: Insert, Rune: r}
+ }
+ }
+}
+
+func (e *Editor) parseEscapeSequence(buf *bufio.Reader) {
+ r1, _, err := buf.ReadRune()
+ if err != nil {
+ e.errs <- fmt.Errorf("failed to read stdin: %w", err)
+ return
+ }
+
+ // Ignore invalid escape sequences.
+ if r1 != '[' && r1 != 'O' {
+ e.input <- input{Command: Insert, Rune: r1}
+ return
+ }
+
+ // We've received an input of Esc + [ or Esc + O.
+ // Determine the escape sequence.
+ r2, _, err := buf.ReadRune()
+ if err != nil {
+ e.errs <- fmt.Errorf("failed to read stdin: %w", err)
+ return
+ }
+
+ // Check letter escape sequences.
+ switch r2 {
+ case 'A':
+ e.input <- input{Command: CursorUp}
+ return
+ case 'B':
+ e.input <- input{Command: CursorDown}
+ return
+ case 'C':
+ e.input <- input{Command: CursorRight}
+ return
+ case 'D':
+ e.input <- input{Command: CursorLeft}
+ return
+
+ case 'O':
+ r3, _, err := buf.ReadRune()
+ if err != nil {
+ e.errs <- fmt.Errorf("failed to read stdin: %w", err)
+ }
+ switch r3 {
+ case 'P': // F1
+ return
+ case 'Q': // F2
+ return
+ case 'R': // F3
+ return
+ case 'S': // F4
+ return
+ default:
+ // No match.
+ e.input <- input{Command: Insert, Rune: r1}
+ e.input <- input{Command: Insert, Rune: r2}
+ e.input <- input{Command: Insert, Rune: r3}
+ return
+ }
+ }
+
+ // Check for single digit numerical escape sequences.
+ r3, _, err := buf.ReadRune()
+ if err != nil {
+ e.errs <- fmt.Errorf("failed to read stdin: %w", err)
+ }
+ switch {
+ case r2 == '1' && r3 == '~':
+ e.input <- input{Command: Home}
+ return
+ case r2 == '2' && r3 == '~':
+ e.input <- input{Command: Insert}
+ return
+ case r2 == '3' && r3 == '~':
+ e.input <- input{Command: Delete}
+ return
+ case r2 == '4' && r3 == '~':
+ e.input <- input{Command: End}
+ return
+ case r2 == '5' && r3 == '~':
+ e.input <- input{Command: PageUp}
+ return
+ case r2 == '6' && r3 == '~':
+ e.input <- input{Command: PageDown}
+ return
+ case r2 == '7' && r3 == '~':
+ e.input <- input{Command: Home}
+ return
+ case r2 == '8' && r3 == '~':
+ e.input <- input{Command: End}
+ return
+ case r2 == '9' && r3 == '~':
+ e.input <- input{Command: End}
+ return
+ }
+
+ // Check for double digit numerical escape sequences.
+ r4, _, err := buf.ReadRune()
+ if err != nil {
+ e.errs <- err
+ }
+ switch {
+ case r2 == '1' && r3 == '0' && r4 == '~':
+ return
+ case r2 == '1' && r3 == '1' && r4 == '~':
+ return
+ case r2 == '1' && r3 == '2' && r4 == '~':
+ return
+ case r2 == '1' && r3 == '3' && r4 == '~':
+ return
+ case r2 == '1' && r3 == '4' && r4 == '~':
+ return
+ case r2 == '1' && r3 == '4' && r4 == '~':
+ return
+ case r2 == '1' && r3 == '6' && r4 == '~':
+ return
+ case r2 == '1' && r3 == '7' && r4 == '~':
+ return
+ case r2 == '1' && r3 == '8' && r4 == '~':
+ return
+ case r2 == '1' && r3 == '9' && r4 == '~':
+ return
+ case r2 == '2' && r3 == '0' && r4 == '~':
+ return
+ case r2 == '2' && r3 == '1' && r4 == '~':
+ return
+ case r2 == '2' && r3 == '2' && r4 == '~':
+ return
+ case r2 == '2' && r3 == '3' && r4 == '~':
+ return
+ case r2 == '2' && r3 == '4' && r4 == '~':
+ return
+ case r4 == '~':
+ return
+ }
+
+ // No match.
+ e.input <- input{Command: Insert, Rune: r1}
+ e.input <- input{Command: Insert, Rune: r2}
+ e.input <- input{Command: Insert, Rune: r3}
+ e.input <- input{Command: Insert, Rune: r4}
+
+}
+
+func (e *Editor) processInput(input input) error {
+ size, err := e.terminal.Size()
+ if err != nil {
+ return fmt.Errorf("failed to get terminal size: %w", err)
+ }
+
+ switch input.Command {
+ case Backspace:
+ e.active.Backspace()
+
+ case CursorDown:
+ e.active.CursorDown()
+
+ case CursorLeft:
+ e.active.CursorLeft()
+
+ case CursorRight:
+ e.active.CursorRight()
+
+ case CursorUp:
+ e.active.CursorUp()
+
+ case Insert:
+ e.active.Insert(input.Rune)
+
+ case End:
+ e.active.CursorEnd()
+
+ case Home:
+ e.active.CursorHome()
+
+ case PageDown:
+ e.active.PageDown(int(size.Rows))
+
+ case PageUp:
+ e.active.PageUp(int(size.Rows))
+
+ case Save:
+ if err := e.active.Save(); err != nil {
+ go func() {
+ e.messages <- Message{
+ Text: fmt.Sprintf(
+ "failed to save: %v", err,
+ ),
+ }
+ }()
+ }
+ go func() {
+ e.messages <- Message{
+ Text: fmt.Sprintf("saved file"),
+ }
+ }()
+
+ default:
+ e.log.Debug.Printf("unrecognized input: %#v", input)
+ }
+
+ return nil
+}
diff --git a/pkg/editor/message.go b/pkg/editor/message.go
@@ -0,0 +1,5 @@
+package editor
+
+type Message struct {
+ Text string
+}
diff --git a/pkg/editor/quit.go b/pkg/editor/quit.go
@@ -0,0 +1,16 @@
+package editor
+
+import (
+ "code.dwrz.net/src/pkg/terminal"
+)
+
+func (e *Editor) quit() {
+ e.out.Write([]byte(terminal.ClearScreen))
+ e.out.Write([]byte(terminal.CursorTopLeft))
+
+ if err := e.terminal.Reset(); err != nil {
+ e.log.Error.Printf(
+ "failed to reset terminal attributes: %v", err,
+ )
+ }
+}
diff --git a/pkg/editor/render.go b/pkg/editor/render.go
@@ -0,0 +1,120 @@
+package editor
+
+import (
+ "bytes"
+ "fmt"
+ "path"
+ "strings"
+
+ "github.com/mattn/go-runewidth"
+
+ "code.dwrz.net/src/pkg/color"
+ "code.dwrz.net/src/pkg/terminal"
+)
+
+func (e *Editor) render(msg *Message) error {
+ size, err := e.terminal.Size()
+ if err != nil {
+ return fmt.Errorf("failed to get terminal size: %w", err)
+ }
+
+ var (
+ buf bytes.Buffer
+ bars = 2
+ height = int(size.Rows) - bars
+ width = int(size.Columns)
+ output = e.active.Render(height, width)
+ )
+
+ // Move the cursor to the top left.
+ buf.Write([]byte(terminal.CursorHide))
+ buf.Write([]byte(terminal.CursorTopLeft))
+
+ // Print each line.
+ for _, line := range output.Lines {
+ buf.Write([]byte(terminal.EraseLine))
+ buf.WriteString(line)
+ buf.WriteString("\r\n")
+ }
+
+ // Draw the status bar.
+ buf.Write([]byte(terminal.EraseLine))
+ buf.WriteString(e.statusBar(width, output.Line, output.Glyph))
+ buf.WriteString("\r\n")
+
+ // Draw the message bar.
+ buf.Write([]byte(terminal.EraseLine))
+ buf.WriteString(e.messageBar(msg, width))
+
+ // Set the cursor.
+ buf.Write([]byte(
+ fmt.Sprintf(terminal.SetCursorFmt, output.Line, output.Glyph)),
+ )
+ buf.Write([]byte(terminal.CursorShow))
+
+ e.out.Write(buf.Bytes())
+
+ return nil
+}
+
+// TODO: show a character cursor, not the terminal cursor.
+func (e *Editor) statusBar(width, y, x int) string {
+ var bar strings.Builder
+
+ bar.WriteString(color.Inverse)
+
+ // Icon
+ icon := "文 "
+ bar.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(e.active.Name())
+ nw := runewidth.StringWidth(name)
+ if nw <= width {
+ bar.WriteString(name)
+ width -= nw
+ } else {
+ for _, r := range name {
+ rw := runewidth.RuneWidth(r)
+ if width-rw >= 0 {
+ bar.WriteRune(r)
+ width -= rw
+ } else {
+ break
+ }
+ }
+ }
+
+ // Add empty spaces to the end of the line.
+ for i := width; i >= 0; i-- {
+ bar.WriteRune(' ')
+ }
+
+ // Cursor
+ bar.WriteString(cursor)
+
+ bar.WriteString(color.Reset)
+
+ return bar.String()
+}
+
+// TODO: handle messages that are too long for one line.
+// TODO: should status bar render independently?
+func (e *Editor) messageBar(msg *Message, width int) string {
+ switch {
+ case msg == nil:
+ return fmt.Sprintf("%*s", width, " ")
+ case len(msg.Text) > width:
+ return fmt.Sprintf("%*s", width, msg.Text)
+ default:
+ return fmt.Sprintf("%s %*s", msg.Text, width-len(msg.Text), " ")
+ }
+
+}
diff --git a/pkg/editor/run.go b/pkg/editor/run.go
@@ -0,0 +1,62 @@
+package editor
+
+import (
+ "fmt"
+ "time"
+
+ "code.dwrz.net/src/pkg/build"
+ "code.dwrz.net/src/pkg/terminal"
+)
+
+func (e *Editor) Run(files []string) error {
+ e.terminal.SetRaw()
+ e.out.Write([]byte(terminal.ClearScreen))
+
+ // Log build info.
+ e.log.Debug.Printf(
+ "文 version %s; built at %s on %s",
+ build.Commit, build.Time, build.Hostname,
+ )
+
+ // Reset the terminal before exiting.
+ defer e.quit()
+
+ // Start reading user input.
+ go e.readInput()
+
+ // Open all the files.
+ go e.load(files)
+
+ // Set the initial message.
+ go func() {
+ time.Sleep(1 * time.Second)
+ e.messages <- Message{Text: "Ctrl-Q: Quit"}
+ }()
+
+ // Main loop.
+ for {
+ select {
+ case err := <-e.errs:
+ return err
+
+ case msg := <-e.messages:
+ e.log.Debug.Printf("%s", msg.Text)
+ if err := e.render(&msg); err != nil {
+ return fmt.Errorf("failed to render: %w", err)
+ }
+
+ case input := <-e.input:
+ if input.Command == Quit {
+ return nil
+ }
+ if err := e.processInput(input); err != nil {
+ return fmt.Errorf(
+ "failed to process input: %w", err,
+ )
+ }
+ if err := e.render(nil); err != nil {
+ return fmt.Errorf("failed to render: %w", err)
+ }
+ }
+ }
+}