src

Go monorepo.
Log | Files | Refs

commit 79ce513f3ab61b9a3cb5ca539ad03ed007a8a8ba
parent fda86a519e22051317e398ef8b2de048acb7973a
Author: dwrz <dwrz@dwrz.net>
Date:   Fri,  2 Dec 2022 16:21:00 +0000

Add editor pkg

Diffstat:
Apkg/editor/buffer/buffer.go | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/editor/buffer/cursor.go | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/editor/buffer/edit.go | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/editor/buffer/glyph/glyph.go | 12++++++++++++
Apkg/editor/buffer/line/edit.go | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/editor/buffer/line/line.go | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/editor/buffer/line/line_test.go | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/editor/buffer/render.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Apkg/editor/buffer/save.go | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/editor/buffers.go | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/editor/editor.go | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/editor/input.go | 275+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/editor/message.go | 5+++++
Apkg/editor/quit.go | 16++++++++++++++++
Apkg/editor/render.go | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/editor/run.go | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) + } + } + } +}