DEV Community

Andres Court
Andres Court

Posted on

Create a Text Editor in Go - A Text Editor

You can access the code of this chapter in the Kilo-Go github repository in the texteditor branch.

Currently your file structure should look something like this:

Insert ordinary characters

First we want to be able to insert new characters, and if needed to be able to create a new line

File: editor/input.go

func (e *EditorConfig) editorProcessKeypress() {
    ...
    switch b {
    ...
    default:
        e.editorInsertChar(byte(b))
    }
}
Enter fullscreen mode Exit fullscreen mode

File: editor/row.go

func (e *EditorConfig) editorAppendRow(s string) {
    row := EditorRow{
        chars:  s,
        render: make([]byte, len(s)),
    }
    e.cx = 0
    e.editorUpdateRow(&row)
    e.rows = append(e.rows, row)
    e.numrows++
}

func (e *EditorConfig) editorRowInsertChar(row *EditorRow, at int, c byte) {
    row.render = make([]byte, len(row.chars)+1)
    row.chars = row.chars[:at] + string(c) + row.chars[at:]
    e.editorUpdateRow(row)
}
Enter fullscreen mode Exit fullscreen mode

File: editor/operations.go

package editor

func (e *EditorConfig) editorInsertChar(c byte) {
    if e.cy >= len(e.rows) {
        e.editorAppendRow("")
    }
    e.editorRowInsertChar(&e.rows[e.cy], e.cx, c)
    e.cx++
}
Enter fullscreen mode Exit fullscreen mode

Prevent inserting special characters

Now that we can insert characters, if we try to insert Backspace or Enter the editor does not understand them and we want to disable them for now

  • Backspace doesn't have a backslash-escape representation like (\r, \n, etc) but we know it is represented by the byte 127
  • Enter key is represented by the \r escape sequence
  • For the time being we will not be doing anything when the user press the Esc key, but we just want to disable it so no side effects occur if the user press it. Also by the way we've wrote the editorReadKey method, we will disable any function keys being pressed

File: utils/constants.go

const (
    ESC   = 0x1b
    ENTER = '\r'
    ...
)

const (
    BACKSPACE  = 127
    ARROW_LEFT = iota + 1000
    ...
)
Enter fullscreen mode Exit fullscreen mode

File: utils/input.go

func (e *EditorConfig) editorProcessKeypress() {
    b, err := e.editorReadKey()
    if err != nil {
        utils.SafeExit(e.restoreFunc, err)
    }

    switch b {
    case utils.ENTER:
        slog.Info("ENTER")
    ...
    case utils.DEL_KEY, utils.BACKSPACE:
        slog.Info("DEL_KEY")
    ...
    case utils.ESC:
        // for now we will ignore when the user press the escape key
        break
    ...
}
Enter fullscreen mode Exit fullscreen mode

Save to disk

Now that we can make changes to our files, it is time to be able to save them to disk

File: editor/editor.go

func (e *EditorConfig) EditorLoop() {
    ...
    e.editorSetStatusMessage("HELP: Ctrl-S = save | Ctrl-Q = quit")
    ...
}
Enter fullscreen mode Exit fullscreen mode

File: editor/input.go

func (e *EditorConfig) editorProcessKeypress() {
    ...
    switch b {
    ...
    case utils.CtrlKey('s'):
        e.editorSave()
    ...
    }
}
Enter fullscreen mode Exit fullscreen mode

File: editor/file.go

func (e *EditorConfig) editorSave() {
    data := make([]byte, 0)
    for _, row := range e.rows {
        data = append(data, row.chars...)
        data = append(data, '\r', '\n')
    }
    err := os.WriteFile(e.filename, data, 0644)
    if err != nil {
        slog.Error("editorSave, error saving file", "error", err)
        e.editorSetStatusMessage(fmt.Sprintf("Error saving file: %v", err))
        return
    }

    e.editorSetStatusMessage("File saved")
}
Enter fullscreen mode Exit fullscreen mode

Note: if an error occurs while saving the file, we will not exit the application, we will show the error to the user and log it

Dirty flag

We want to keep track if the file we are showing the user differs from the one saved, so we can alert them if for example they want to quit without saving the file

File: editor/editor.go

type EditorConfig struct {
    ...
    isDirty       bool
}

func NewEditor(f func()) *EditorConfig {
    ...
    return &EditorConfig{
        ...
        isDirty:       false,
    }
}
Enter fullscreen mode Exit fullscreen mode

File: editor/output.go

func (e *EditorConfig) editorDrawStatusBar(abuf *ab.AppendBuffer) {
    status := e.filename
    if status == "" {
        status = "[No Name]"
    }

    if e.isDirty {
        status += " (modified)"
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

File: editor/row.go

func (e *EditorConfig) editorAppendRow(s string) {
    ...
    e.isDirty = true
}

func (e *EditorConfig) editorRowInsertChar(row *EditorRow, at int, c byte) {
    ...
    e.isDirty = true
}
Enter fullscreen mode Exit fullscreen mode

File: editor/file.go

func (e *EditorConfig) editorOpen(filename string) {
    ...
    e.isDirty = false
}

func (e *EditorConfig) editorSave() {
    ...
    e.isDirty = false
}
Enter fullscreen mode Exit fullscreen mode

Quit confirmation

Now that we control the isDirty flag, we will want to let the user know that they are trying to quit without saving the file

File: utils/constants.go

const (
    ...
    KILO_QUIT_TIMES = 3
)
Enter fullscreen mode Exit fullscreen mode

File: editor/input.go

var quit_times = utils.KILO_QUIT_TIMES

func (e *EditorConfig) editorProcessKeypress() {
    ...
    switch b {
    ...
    case utils.CtrlKey('q'):
        if e.isDirty && quit_times > 0 {
            e.editorSetStatusMessage(fmt.Sprintf("WARNING! File has unsaved changes. Ctrl-Q %d more times to quit", quit_times))
            quit_times--
            return
        }
        utils.SafeExit(e.restoreFunc, nil)
    ...
    }

    quit_times = utils.KILO_QUIT_TIMES
}
Enter fullscreen mode Exit fullscreen mode

Simple backspacing

Lets implement the Backspace and Delete keys

File: editor/input.go

func (e *EditorConfig) editorProcessKeypress() {
    ...
    switch b {
    ...
    case utils.DEL_KEY, utils.BACKSPACE:
        if b == utils.DEL_KEY {
            e.editorMoveCursor(utils.ARROW_RIGHT)
        }
        e.editorDeleteChar()
    ...
}
Enter fullscreen mode Exit fullscreen mode

File: editor/operations.go

func (e *EditorConfig) editorDeleteChar() {
    if e.cy >= len(e.rows) {
        return
    }
    e.editorRowDeleteChar(&e.rows[e.cy], e.cx)
    if e.cx > 0 {
        e.cx--
    }
}
Enter fullscreen mode Exit fullscreen mode

File: editor/row.go

func (e *EditorConfig) editorRowDeleteChar(row *EditorRow, at int) {
    if at < 0 || at >= len(row.chars) {
        return
    }
    row.render = make([]byte, len(row.chars)-1)
    row.chars = row.chars[:at] + row.chars[at+1:]
    e.editorUpdateRow(row)
    e.isDirty = true
}
Enter fullscreen mode Exit fullscreen mode

Backspacing at the start of a line

Currently, editorDelChar doesn't do anything when the cursor is at the beginning of a line. When the user backspaces at the beginning of a line, we want to append the contents of that line to the previous line, and then delete the current line

File: editor/row.go

func (e *EditorConfig) editorDelRow(at int) {
    if at < 0 || at >= len(e.rows) {
        return
    }
    e.rows = append(e.rows[:at], e.rows[at+1:]...)
    e.numrows--
    e.isDirty = true
}

func (e *EditorConfig) editorRowAppendString(row *EditorRow, s string) {
    row.chars += s
    row.render = make([]byte, len(row.chars))
    e.editorUpdateRow(row)
    e.isDirty = true
}
Enter fullscreen mode Exit fullscreen mode

File: editor/operations.go

func (e *EditorConfig) editorDeleteChar() {
    if e.cy >= len(e.rows) {
        return
    }
    if e.cx == 0 && e.cy == 0 {
        return
    }

    if e.cx > 0 {
        e.editorRowDeleteChar(&e.rows[e.cy], e.cx)
        e.cx--
    } else {
        e.cx = len(e.rows[e.cy-1].chars)
        e.editorRowAppendString(&e.rows[e.cy-1], e.rows[e.cy].chars)
        e.editorDelRow(e.cy)
        e.cy--
    }
}
Enter fullscreen mode Exit fullscreen mode

The Enter key

The last keyboard input operation we will be going through is the implementation of the Enter key, which allows the user to insert new lines into the text, or split a line into two lines.

File: editor/row.go

func (e *EditorConfig) editorInsertRow(at int, s string) {
    e.rows = append(e.rows[:at], append([]EditorRow{{chars: s}}, e.rows[at:]...)...)
    e.numrows++
    e.isDirty = true
}
Enter fullscreen mode Exit fullscreen mode

File: editor/operations.go

func (e *EditorConfig) editorInsertChar(c byte) {
    if e.cy == len(e.rows) {
        e.editorInsertRow(e.numrows, "")
    }
    ...
}

func (e *EditorConfig) editorInsertNewline() {
    if e.cy == len(e.rows) {
        e.editorInsertRow(e.numrows, "")
    } else {
        row := &e.rows[e.cy]
        e.editorInsertRow(e.cy+1, row.chars[e.cx:])
        row.chars = row.chars[:e.cx]
        row.render = make([]byte, len(row.chars))
        e.editorUpdateRow(row)

        row = &e.rows[e.cy+1]
        row.render = make([]byte, len(row.chars))
        e.editorUpdateRow(row)
    }
    e.cy++
    e.cx = 0
}
Enter fullscreen mode Exit fullscreen mode

File: editor/input.go

func (e *EditorConfig) editorProcessKeypress() {
    ...
    switch b {
    case utils.ENTER:
        e.editorInsertNewline()
    ...
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

Prompting the user

We want know to have the ability to prompt the user for input, so when they want to create a new file we can ask them for its name

File: utils/constants.go

const (
    ...
    KILO_DEFAULT_STATUS_MESSAGE = "HELP: Ctrl-S = save | Ctrl-Q = quit"
)
Enter fullscreen mode Exit fullscreen mode

File: utils/ctrl.go

func IsCtrlKey(key int) bool {
    return key <= 0x1f || key == BACKSPACE
}
Enter fullscreen mode Exit fullscreen mode

File: editor/editor.go

func (e *EditorConfig) EditorLoop() {
    ...
    e.editorSetStatusMessage(utils.KILO_DEFAULT_STATUS_MESSAGE)
    ...
}
Enter fullscreen mode Exit fullscreen mode

File: editor/input.go

func (e *EditorConfig) editorProcessKeypress() {
    ...
    switch b {
    ...
    case utils.CtrlKey('c'):
        term := e.editorPrompt(": ")
        slog.Info("editorProcessKeypress, search term", "term", term)
    ...
    }
    ...
}

func (e *EditorConfig) editorPrompt(prompt string) string {
    var buf string
    for {
        e.editorSetStatusMessage(prompt + buf)
        e.editorRefreshScreen()

        b, err := e.editorReadKey()
        if err != nil {
            utils.SafeExit(e.restoreFunc, err)
        }

        switch b {
        case utils.BACKSPACE, utils.DEL_KEY:
            if len(buf) > 0 {
                buf = buf[:len(buf)-1]
            }
        case utils.ESC:
            slog.Info("editorPrompt, ESC")
            e.editorSetStatusMessage(utils.KILO_DEFAULT_STATUS_MESSAGE)
            return ""
        case utils.ENTER:
            e.editorSetStatusMessage(utils.KILO_DEFAULT_STATUS_MESSAGE)
            return buf
        default:
            if !utils.IsCtrlKey(b) || b < 128 {
                buf += string(rune(b))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Note: We've added the Ctrl-C key map to test the prompt

Save as

Now that we can prompt things to the user, let's implement the Save as functionality, and it will only work at the moment when we run our editor without arguments

File: editor/file.go

func (e *EditorConfig) editorSave() {
    if e.filename == "" {
        e.filename = e.editorPrompt("Save as: ")
        if e.filename == "" {
            e.editorSetStatusMessage("Save aborted")
            return
        }
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)