commit 68b2d7381b8b28b4063a81ea01e6b8d8ca7e71c3
parent cec155286bfbbbc676d1aac72e0b557b52a1ef82
Author: dwrz <dwrz@dwrz.net>
Date:   Wed, 25 Jan 2023 14:53:58 +0000
Add editor prompt
Squashed commit of the following:
commit 542a1071ccaea792834fedb71a88c77307a95676
Author: dwrz <dwrz@dwrz.net>
Date:   Wed Jan 25 14:53:02 2023 +0000
    Refactor prompt
commit 1e88dbb12c89b99ed85fea2454438275c41b47ec
Author: dwrz <dwrz@dwrz.net>
Date:   Tue Jan 24 16:17:04 2023 +0000
    Fix find glyph index edge case
commit bf64de6ef1bc91d604af3757707d898277aab471
Author: dwrz <dwrz@dwrz.net>
Date:   Mon Jan 23 19:22:37 2023 +0000
    Refactor event loop
commit 1e4c541dbf1cf07b5b4e3e0dce314213a9460bf6
Author: dwrz <dwrz@dwrz.net>
Date:   Mon Jan 23 16:49:17 2023 +0000
    Add prompt
Diffstat:
18 files changed, 451 insertions(+), 101 deletions(-)
diff --git a/pkg/editor/buffer/cursor.go b/pkg/editor/buffer/cursor.go
@@ -8,7 +8,6 @@ import (
 
 type Cursor struct {
 	glyph int
-
 	index int
 	line  int
 }
diff --git a/pkg/editor/buffer/edit.go b/pkg/editor/buffer/edit.go
@@ -44,7 +44,6 @@ func (b *Buffer) Backspace() {
 		b.CursorLeft()
 		b.CursorLine().DeleteRune(b.cursor.index)
 	}
-
 }
 
 func (b *Buffer) Insert(r rune) {
diff --git a/pkg/editor/buffer/line/line.go b/pkg/editor/buffer/line/line.go
@@ -25,6 +25,7 @@ func (l *Line) FindGlyphIndex(target int) (index, g int) {
 	for i, r := range l.data {
 		// We've reached the target.
 		if g == target {
+
 			return i, g
 		}
 		// We've gone too far.
@@ -41,6 +42,11 @@ func (l *Line) FindGlyphIndex(target int) (index, g int) {
 		// 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.
diff --git a/pkg/editor/buffer/line/line_test.go b/pkg/editor/buffer/line/line_test.go
@@ -68,6 +68,12 @@ func TestGlyphIndex(t *testing.T) {
 			glyph:  8,  // 针
 			target: 8,  // Doesn't exist; return end of last rune.
 		},
+		{
+			data:   "你好!",
+			index:  6, // !
+			glyph:  4, // !
+			target: 5,
+		},
 	}
 
 	for n, test := range tests {
diff --git a/pkg/editor/buffer/save.go b/pkg/editor/buffer/save.go
@@ -50,3 +50,25 @@ func (b *Buffer) Save() error {
 
 	return nil
 }
+
+func (b *Buffer) SaveAs(name string) error {
+	f, err := os.Create(name)
+	if err != nil {
+		return fmt.Errorf("failed to create file: %w", err)
+	}
+
+	// Close the previous file.
+	if err := b.file.Close(); err != nil {
+		b.log.Error.Printf("failed close file: %v", err)
+	}
+
+	// Set the new file and name.
+	b.file = f
+	b.name = name
+
+	if err := b.Save(); err != nil {
+		return fmt.Errorf("failed to save file: %w", err)
+	}
+
+	return nil
+}
diff --git a/pkg/editor/buffers.go b/pkg/editor/buffers.go
@@ -5,10 +5,9 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
-	"time"
 
 	"code.dwrz.net/src/pkg/editor/buffer"
-	"code.dwrz.net/src/pkg/editor/message"
+	"code.dwrz.net/src/pkg/editor/event"
 )
 
 // load files into editor buffers.
@@ -45,7 +44,7 @@ func (e *Editor) load(files []string) {
 		}
 		// If there was an error, report it to the user.
 		if err != nil {
-			e.messages <- message.New(fmt.Sprintf(
+			e.events <- event.NewMessage(fmt.Sprintf(
 				"failed to load buffer %s: %v",
 				name, err,
 			))
@@ -61,21 +60,21 @@ func (e *Editor) load(files []string) {
 		}
 	}
 
-	e.messages <- message.New("loaded buffers")
-
-	// Set the initial message.
-	go func() {
-		time.Sleep(1 * time.Second)
-		e.messages <- message.New("Ctrl-Q: Quit")
-	}()
+	e.events <- event.NewMessage("loaded buffers")
 }
 
-// setBuffer stores a buffer in the editor's buffer map.
-func (e *Editor) setBuffer(b *buffer.Buffer) {
+func (e *Editor) renameBuffer(b *buffer.Buffer, name string) error {
 	e.mu.Lock()
 	defer e.mu.Unlock()
 
-	e.buffers[b.Name()] = b
+	delete(e.buffers, b.Name())
+	e.buffers[name] = b
+
+	if err := b.SaveAs(name); err != nil {
+		return fmt.Errorf("failed to save as %s: %v", name, err)
+	}
+
+	return nil
 }
 
 // setActiveBuffer sets the named buffer as the active buffer.
@@ -85,12 +84,19 @@ func (e *Editor) setActiveBuffer(name string) {
 
 	b, exists := e.buffers[name]
 	if !exists {
-		e.errs <- fmt.Errorf(
-			"failed to set active buffer: buffer %s does not exist",
-			name,
-		)
+		e.events <- event.NewError(fmt.Errorf(
+			"failed to set active buffer: %s does not exist", name,
+		))
 	}
 
 	e.active = b
 	e.log.Debug.Printf("set active buffer %s", 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/pkg/editor/canvas/render.go b/pkg/editor/canvas/render.go
@@ -8,11 +8,11 @@ import (
 
 	"code.dwrz.net/src/pkg/color"
 	"code.dwrz.net/src/pkg/editor/buffer"
-	"code.dwrz.net/src/pkg/editor/message"
+	"code.dwrz.net/src/pkg/editor/prompt"
 	"code.dwrz.net/src/pkg/terminal"
 )
 
-func (c *Canvas) Render(b *buffer.Buffer, msg *message.Message) error {
+func (c *Canvas) Render(b *buffer.Buffer, prompt *prompt.Prompt) error {
 	c.mu.Lock()
 	defer c.mu.Unlock()
 
@@ -44,12 +44,21 @@ func (c *Canvas) Render(b *buffer.Buffer, msg *message.Message) error {
 	c.statusBar(b, width, cursor.Line(), cursor.Glyph())
 
 	// Draw the message bar.
-	c.messageBar(msg, width)
+	c.messageBar(prompt, width)
 
 	// Set the cursor.
-	c.buf.WriteString(fmt.Sprintf(
-		terminal.SetCursorFmt, output.Line, output.Glyph,
-	))
+	if prompt.IsPrompt() {
+		// FIX: +1's.
+		c.buf.WriteString(fmt.Sprintf(
+			terminal.SetCursorFmt, size.Rows+1,
+			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())
@@ -105,16 +114,27 @@ func (c *Canvas) statusBar(b *buffer.Buffer, width, y, x int) {
 }
 
 // TODO: handle messages that are too long for one line.
-func (c *Canvas) messageBar(msg *message.Message, width int) {
+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.
+	)
 	switch {
-	case msg == nil:
+	case text == "" && line == "":
 		c.buf.WriteString(fmt.Sprintf("%*s", width, " "))
-	case len(msg.Text) > width:
-		c.buf.WriteString(fmt.Sprintf("%*s", width, msg.Text))
+
+	// TODO: handle text + line; offset.
+	case len(text) > width:
+		c.buf.WriteString(fmt.Sprintf("%*s", width, text))
+
 	default:
+		output := fmt.Sprintf("%s %s", text, line)
+
 		c.buf.WriteString(fmt.Sprintf(
-			"%s %*s", msg.Text, width-len(msg.Text), " ",
+			"%s %*s",
+			output, width-len(output)-1, " ", // FIX -1
 		))
 	}
 }
diff --git a/pkg/editor/editor.go b/pkg/editor/editor.go
@@ -7,7 +7,8 @@ import (
 
 	"code.dwrz.net/src/pkg/editor/buffer"
 	"code.dwrz.net/src/pkg/editor/canvas"
-	"code.dwrz.net/src/pkg/editor/message"
+	"code.dwrz.net/src/pkg/editor/event"
+	"code.dwrz.net/src/pkg/editor/prompt"
 	"code.dwrz.net/src/pkg/log"
 	"code.dwrz.net/src/pkg/terminal"
 	"code.dwrz.net/src/pkg/terminal/input"
@@ -15,10 +16,9 @@ import (
 
 type Editor struct {
 	canvas   *canvas.Canvas
-	errs     chan error
 	input    chan *input.Event
 	log      *log.Logger
-	messages chan *message.Message
+	events   chan event.Event
 	reader   *input.Reader
 	terminal *terminal.Terminal
 	tmpdir   string
@@ -26,6 +26,7 @@ type Editor struct {
 	mu      sync.Mutex
 	active  *buffer.Buffer
 	buffers map[string]*buffer.Buffer
+	prompt  *prompt.Prompt
 }
 
 type Parameters struct {
@@ -39,12 +40,12 @@ type Parameters struct {
 func New(p Parameters) (*Editor, error) {
 	var editor = &Editor{
 		buffers:  map[string]*buffer.Buffer{},
-		errs:     make(chan error),
+		events:   make(chan event.Event),
 		input:    make(chan *input.Event),
 		log:      p.Log,
-		messages: make(chan *message.Message),
 		terminal: p.Terminal,
 		tmpdir:   p.TempDir,
+		prompt:   prompt.New(prompt.Parameters{Log: p.Log}),
 	}
 
 	// Setup user input.
diff --git a/pkg/editor/event/error.go b/pkg/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/pkg/editor/event/event.go b/pkg/editor/event/event.go
@@ -0,0 +1,7 @@
+package event
+
+import "time"
+
+type Event interface {
+	Time() time.Time
+}
diff --git a/pkg/editor/event/message.go b/pkg/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/pkg/editor/event/quit.go b/pkg/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/pkg/editor/input.go b/pkg/editor/input.go
@@ -3,42 +3,48 @@ package editor
 import (
 	"fmt"
 
-	"code.dwrz.net/src/pkg/editor/message"
+	"code.dwrz.net/src/pkg/editor/event"
 	"code.dwrz.net/src/pkg/terminal/input"
 )
 
-func (e *Editor) bufferInput(event *input.Event) error {
+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 event.Rune != input.Null {
-		switch event.Rune {
+	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:
-			// Get the filename.
-			if err := e.active.Save(); err != nil {
-				go func() {
-					e.messages <- message.New(fmt.Sprintf(
-						"failed to save: %v", err,
-					))
-				}()
-			}
 			go func() {
-				e.messages <- message.New("saved file")
+				if err := e.active.Save(); err != nil {
+					e.events <- event.NewMessage(
+						fmt.Sprintf(
+							"failed to save: %v",
+							err,
+						),
+					)
+				}
+				e.events <- event.NewMessage("saved file")
 			}()
 
+		case 'x' & input.Control:
+			e.prompt.Prompt("Save as:", e.saveAs)
+
 		default:
-			e.active.Insert(event.Rune)
+			e.active.Insert(in.Rune)
 		}
 
 		return nil
 	}
 
-	switch event.Key {
+	switch in.Key {
 	case input.Down:
 		e.active.CursorDown()
 
@@ -51,8 +57,6 @@ func (e *Editor) bufferInput(event *input.Event) error {
 	case input.Up:
 		e.active.CursorUp()
 
-	case input.Insert:
-
 	case input.End:
 		e.active.CursorEnd()
 
@@ -66,23 +70,47 @@ func (e *Editor) bufferInput(event *input.Event) error {
 		e.active.PageUp(int(size.Rows))
 
 	default:
-		e.log.Debug.Printf("unrecognized input: %#v", event)
+		e.log.Debug.Printf("unrecognized input: %#v", 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.Printf("unrecognized input: %#v", event)
 	}
 
-	// If a newline was received, take the input.
-	// Pass it back to the caller.
-	// Make it the caller's job to set the prompt again if not happy
-	// with return value.
-
 	return nil
 }
diff --git a/pkg/editor/message/message.go b/pkg/editor/message/message.go
@@ -1,15 +0,0 @@
-package message
-
-import "time"
-
-type Message struct {
-	Text string
-	Time time.Time
-}
-
-func New(text string) *Message {
-	return &Message{
-		Text: text,
-		Time: time.Now(),
-	}
-}
diff --git a/pkg/editor/prompt.go b/pkg/editor/prompt.go
@@ -0,0 +1,33 @@
+package editor
+
+import (
+	"fmt"
+
+	"code.dwrz.net/src/pkg/editor/buffer/line"
+	"code.dwrz.net/src/pkg/editor/event"
+)
+
+func (e *Editor) saveAs(l line.Line) error {
+	if err := e.active.SaveAs(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/pkg/editor/prompt/prompt.go b/pkg/editor/prompt/prompt.go
@@ -0,0 +1,163 @@
+package prompt
+
+import (
+	"fmt"
+	"sync"
+	"unicode"
+	"unicode/utf8"
+
+	"code.dwrz.net/src/pkg/editor/buffer/glyph"
+	"code.dwrz.net/src/pkg/editor/buffer/line"
+	"code.dwrz.net/src/pkg/log"
+)
+
+type Prompt struct {
+	log *log.Logger
+
+	mu     sync.Mutex
+	err    error
+	cb     CB
+	cursor cursor
+	line   *line.Line
+	text   string
+}
+
+type CB func(l line.Line) error
+
+type cursor struct {
+	glyph int
+	index int
+}
+
+var NoOp CB = func(l line.Line) error { return nil }
+
+type Parameters struct {
+	Log *log.Logger
+}
+
+func New(p Parameters) *Prompt {
+	return &Prompt{log: p.Log}
+}
+
+func (p *Prompt) Complete() {
+	if err := p.cb(*p.line); err != nil {
+		p.log.Error.Printf("callback error: %v", 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() {
+	// No error -- we should be done.
+	p.cb = nil
+	p.err = nil
+	p.line = nil
+	p.text = ""
+	p.cursor = cursor{}
+}
+
+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.Printf("new prompt: %#v", p)
+}
+
+// TODO: mutex? Channel? Better canvas library?
+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)
+		}
+
+		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/pkg/editor/quit.go b/pkg/editor/quit.go
@@ -1,11 +0,0 @@
-package editor
-
-func (e *Editor) quit() {
-	e.canvas.Reset()
-
-	if err := e.terminal.Reset(); err != nil {
-		e.log.Error.Printf(
-			"failed to reset terminal attributes: %v", err,
-		)
-	}
-}
diff --git a/pkg/editor/run.go b/pkg/editor/run.go
@@ -5,7 +5,7 @@ import (
 	"fmt"
 
 	"code.dwrz.net/src/pkg/build"
-	"code.dwrz.net/src/pkg/terminal/input"
+	"code.dwrz.net/src/pkg/editor/event"
 )
 
 func (e *Editor) Run(ctx context.Context, files []string) error {
@@ -21,12 +21,20 @@ func (e *Editor) Run(ctx context.Context, files []string) error {
 	)
 
 	// Reset the terminal before exiting.
-	defer e.quit()
+	defer func() {
+		e.canvas.Reset()
+
+		if err := e.terminal.Reset(); err != nil {
+			e.log.Error.Printf(
+				"failed to reset terminal attributes: %v", err,
+			)
+		}
+	}()
 
 	// Start reading user input.
 	go func() {
 		if err := e.reader.Run(ctx); err != nil {
-			e.errs <- err
+			e.events <- event.NewError(err)
 		}
 	}()
 
@@ -40,25 +48,46 @@ func (e *Editor) Run(ctx context.Context, files []string) error {
 			e.log.Debug.Printf("context done: stopping")
 			return nil
 
-		case err := <-e.errs:
-			return err
+		case ev := <-e.events:
+			switch t := ev.(type) {
+			case *event.Error:
+				return t
 
-		case msg := <-e.messages:
-			e.log.Debug.Printf("%s", msg.Text)
-			if err := e.canvas.Render(e.active, msg); err != nil {
-				return fmt.Errorf("failed to render: %w", err)
-			}
+			case *event.Message:
+				e.prompt.SetText(t.Text())
 
-		case event := <-e.input:
-			if event.Rune == 'q'&input.Control {
+				if err := e.canvas.Render(
+					e.active, e.prompt,
+				); err != nil {
+					return fmt.Errorf(
+						"failed to render: %w", err,
+					)
+				}
+
+			case *event.Quit:
 				return nil
 			}
-			if err := e.bufferInput(event); err != nil {
-				return fmt.Errorf(
-					"failed to process input: %w", err,
-				)
+
+		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, nil); err != nil {
+
+			if err := e.canvas.Render(
+				e.active, e.prompt,
+			); err != nil {
 				return fmt.Errorf("failed to render: %w", err)
 			}
 		}