DEV Community

Andres Court
Andres Court

Posted on

Create a Text Editor With Go - Welcome Screen

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

Currently your file structure should look something like this:

Refactor: rename the exit module to utils

Since we will be adding more features to the utils module, it only makes sense if we rename it from exit to utils

mv exit utils
Enter fullscreen mode Exit fullscreen mode

File: utils/exit.go

package utils

import (
    "fmt"
    "os"
)

// SafeExit is a function that allows us to safely exit the program
//
// # It will call the provided function and exit with the provided error
// if no error is provided, it will exit with 0
//
// @param f - The function to call
// @param err - The error to exit with
func SafeExit(f func(), err error) {
    if f != nil {
        f()
    }

    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: %s\r\n", err)
        os.Exit(1)
    }

    os.Exit(0)
}
Enter fullscreen mode Exit fullscreen mode

File: linux/raw.go

func (r *UnixRawMode) EnableRawMode() (func(), error) {
    ...
    return func() {
        if err = unix.IoctlSetTermios(unix.Stdin, unix.TCSETS, &original); err != nil {
            utils.SafeExit(nil, fmt.Errorf("EnableRawMode: error restoring terminal flags: %w", err))
        }
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

File: main.go

func main() {
    defer utils.SafeExit(editorState.restoreFunc, nil)
    ...
    for {
        b, err := r.ReadByte()
        if err == io.EOF {
            break
        } else if err != nil {
            utils.SafeExit(editorState.restoreFunc, err)
        }
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Press Ctrl-Q to quit

At the moment, when we press q the program exits, lets change it to be Ctrl-Q.

We will need to first recognize if the key pressed corresponds to a control-key combo, so we will write a function in the utility module

File: utils/ctrl.go

package utils

func CtrlKey(key byte) byte {
    return key & 0x1f
}
Enter fullscreen mode Exit fullscreen mode

File: main.go

func main() {
    ...
    for {
        ...
        if b == utils.CtrlKey('q') {
            break
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Refactor keyboard input

First we want to make a new package editor, where we will be mostly working and manage our state there, and also we are going to refactor the read and process key press

File: editor/editor.go

package editor

import (
    "bufio"
    "os"

    "github.com/alcb1310/kilo-go/utils"
)

type EditorConfig struct {
    restoreFunc func()
    reader      *bufio.Reader
}

func NewEditor(f func()) *EditorConfig {
    return &EditorConfig{
        restoreFunc: f,
        reader:      bufio.NewReader(os.Stdin),
    }
}

func (e *EditorConfig) EditorLoop() {
    defer utils.SafeExit(e.restoreFunc, nil)

    for {
        e.editorProcessKeypress()
    }
}
Enter fullscreen mode Exit fullscreen mode

File: editor/input.go

package editor

import "github.com/alcb1310/kilo-go/utils"

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

    switch b {
    case utils.CtrlKey('q'):
        utils.SafeExit(e.restoreFunc, nil)
    }
}
Enter fullscreen mode Exit fullscreen mode

File: editor/terminal.go

package editor

func (e *EditorConfig) editorReadKey() (byte, error) {
    b, err := e.reader.ReadByte()

    return b, err
}
Enter fullscreen mode Exit fullscreen mode

File: main.go

package main

import (
    "fmt"
    "os"

    "github.com/alcb1310/kilo-go/editor"
    "github.com/alcb1310/kilo-go/linux"
)

var restoreFunc func()

func init() {
    var err error
    u := linux.NewUnixRawMode()
    restoreFunc, err = u.EnableRawMode()
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: %s\r\n", err)
        os.Exit(1)
    }
}

func main() {
    editor := editor.NewEditor(restoreFunc)
    editor.EditorLoop()
}
Enter fullscreen mode Exit fullscreen mode

So now our main function is very simple, where we just enable raw mode and start the editor, so lets keep it this way

Clear the screen

First we want to clear the screen so we don't have anything in it so we can work on, we will be using the VT100 User Guide which is a series of key combos that interacts with the screen.

File: editor/output.go

package editor

import (
    "fmt"
    "os"

    "github.com/alcb1310/kilo-go/utils"
)

func (e *EditorConfig) editorRefreshScreen() {
    fmt.Fprintf(os.Stdout, "%c[2J", utils.ESC)
}
Enter fullscreen mode Exit fullscreen mode

File: utils/constants.go

package utils

const (
    ESC = 0x1b
)
Enter fullscreen mode Exit fullscreen mode

File: editor/editor.go

func (e *EditorConfig) EditorLoop() {
    defer utils.SafeExit(e.restoreFunc, nil)

    for {
        e.editorRefreshScreen()
        e.editorProcessKeypress()
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the problem is that the cursor is left wherever it was before we clear the screen

Reposition the cursor

We will be using the H command that manages the cursor position, it takes two arguments, the row and column we want to have the cursor at, if no arguments are passed, then it is assumed to be 1 so ESC[H is the same as ESC[1;1H, remember that rows and columns start at number 1 and not 0

File: editor/output.go

func (e *EditorConfig) editorRefreshScreen() {
    fmt.Fprintf(os.Stdout, "%c[2J", utils.ESC)
    fmt.Fprintf(os.Stdout, "%c[H", utils.ESC)
}
Enter fullscreen mode Exit fullscreen mode

Clear the screen on exit

Lets use what we've achieved so far so when our program exits it will clear the screen. This way if an error occurs we will not have a bunch of garbage on the screen improving their experience, and also when on exit we will not show anything that was rendered.

File: utils/exit.go

func SafeExit(f func(), err error) {
    fmt.Fprintf(os.Stdout, "%c[2J", ESC)
    fmt.Fprintf(os.Stdout, "%c[H", ESC)
    ...
}
Enter fullscreen mode Exit fullscreen mode

Tildes

Finally we are in a point where we will start drawing thing to the screen. First we will give it a Vim feel by drawing some tildes (~) at the left of the screen of every line that come after the end of the file being edited

File: editor/output.go

func (e *EditorConfig) editorRefreshScreen() {
    fmt.Fprintf(os.Stdout, "%c[2J", utils.ESC)
    fmt.Fprintf(os.Stdout, "%c[H", utils.ESC)

    e.editorDrawRows()

    fmt.Fprintf(os.Stdout, "%c[H", utils.ESC)
}

func (e *EditorConfig) editorDrawRows() {
    for range 24 {
        fmt.Fprintf(os.Stdout, "~\r\n")
    }
}
Enter fullscreen mode Exit fullscreen mode

Window Size

At the moment we forced a total of 24 rows, but we want our editor to use all of the rows in your monitor, so we need to find the window size

File: utils/window.go

package utils

import (
    "fmt"
    "os"

    "golang.org/x/sys/unix"
)

func GetWindowSize() (rows int, cols int, err error) {
    ws, err := unix.IoctlGetWinsize(unix.Stdin, unix.TIOCGWINSZ)
    if err != nil {
        fmt.Fprintf(os.Stderr, "getWindowSize: Error getting window size: %v\r\n", err)
        return
    }

    rows = int(ws.Row)
    cols = int(ws.Col)

    return
}
Enter fullscreen mode Exit fullscreen mode

File: editor/editor.go

type EditorConfig struct {
    restoreFunc func()
    reader      *bufio.Reader
    rows, cols  int
}

func NewEditor(f func()) *EditorConfig {
    rows, cols, err := utils.GetWindowSize()
    if err != nil {
        utils.SafeExit(f, err)
    }

    return &EditorConfig{
        restoreFunc: f,
        reader:      bufio.NewReader(os.Stdin),
        rows:        rows,
        cols:        cols,
    }
}
Enter fullscreen mode Exit fullscreen mode

File: editor/output.go

func (e *EditorConfig) editorDrawRows() {
    for range e.rows {
        fmt.Fprintf(os.Stdout, "~\r\n")
    }
}
Enter fullscreen mode Exit fullscreen mode

The last line

At the moment we always print the \r\n sequence in all lines making us see a blank line at the bottom and loose the first line, lets fix that

File: editor/output.go

func (e *EditorConfig) editorDrawRows() {
    for y := range e.rows {
        fmt.Fprintf(os.Stdout, "~")

        if y < e.rows-1 {
            fmt.Fprintf(os.Stdout, "\r\n")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Enable logging

Because the nature of the application, if we need to print any information about the process, we will need to save it to a file, there comes the log/slog package, so lets setup the logger to work as we like

File: utils/logger.go

package utils

import (
    "fmt"
    "os"
    "path"
    "time"
)

func CreateLoggerFile(userTempDir string) (*os.File, error) {
    now := time.Now()
    date := fmt.Sprintf("%s.log", now.Format("2006-01-02"))

    if err := os.MkdirAll(path.Join(userTempDir, "kilo-go"), 0o755); err != nil {
        return nil, err
    }

    fileFullPath := path.Join(userTempDir, "kilo-go", date)
    file, err := os.OpenFile(fileFullPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o666)
    if err != nil {
        return nil, err
    }

    return file, nil
}
Enter fullscreen mode Exit fullscreen mode

File: main.go

func init() {
    var f *os.File
    var err error
    userTempDir, _ := os.UserConfigDir()
    if f, err = utils.CreateLoggerFile(userTempDir); err != nil {
        utils.SafeExit(nil, err)
    }

    handlerOptions := &slog.HandlerOptions{}
    handlerOptions.Level = slog.LevelDebug

    loggerHandler := slog.NewTextHandler(f, handlerOptions)
    slog.SetDefault(slog.New(loggerHandler))
    ...
}
Enter fullscreen mode Exit fullscreen mode

This will create a log file inside the .config/kilo-go directory with the current date as its name

Append Buffer

It is not a good idea to make a lot of Fprintf since all input/output operations are expensive and can cause unexpected behaviors or screen flickering.

We want to replace all of our Fprintf with code that appends all those strings to a buffer and then write this buffer at the end.

We are going to use one of Go features and create a Writer interface which will save in a byte array the information we pass.

File: appendbuffer/appendbuffer.go

package appendbuffer

type AppendBuffer struct {
    buf []byte
}

func New() *AppendBuffer {
    return &AppendBuffer{}
}

func (ab *AppendBuffer) Write(p []byte) (int, error) {
    ab.buf = append(ab.buf, p...)
    return len(p), nil
}

func (ab *AppendBuffer) Bytes() []byte {
    return ab.buf
}
Enter fullscreen mode Exit fullscreen mode

File: editor/output.go

package editor

import (
    "fmt"
    "os"

    ab "github.com/alcb1310/kilo-go/appendbuffer"
    "github.com/alcb1310/kilo-go/utils"
)

func (e *EditorConfig) editorRefreshScreen() {
    abuf := ab.New()

    fmt.Fprintf(abuf, "%c[2J", utils.ESC)
    fmt.Fprintf(abuf, "%c[H", utils.ESC)

    e.editorDrawRows(abuf)

    fmt.Fprintf(abuf, "%c[H", utils.ESC)

    fmt.Fprintf(os.Stdout, "%s", abuf.Bytes())
}

func (e *EditorConfig) editorDrawRows(abuf *ab.AppendBuffer) {
    for y := range e.rows {
        fmt.Fprintf(abuf, "~")

        if y < e.rows-1 {
            fmt.Fprintf(abuf, "\r\n")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Hide the cursor while repainting

There is another possible source of flickering, it's possible that the cursor might be displayed in the middle of the screen somewhere for a split second while it is drawing to the screen. To make sure that doesn't happen, we can hide it while repainting the screen, and show it again once it finishes

File: editor/output.go

func (e *EditorConfig) editorRefreshScreen() {
    ...
    fmt.Fprintf(abuf, "%c[?25l", utils.ESC)
    ...
    fmt.Fprintf(abuf, "%c[?25h", utils.ESC)
    ...
}
Enter fullscreen mode Exit fullscreen mode

Clears lines one at a time

Instead of clearing the entire screen before each refresh, it seems more optional to clear each line as we redraw them. Lets remove the <Esc>[2J escape sequence, and instead put a <Esc>[K sequence at the end of each line we draw

File: editor/output.go

func (e *EditorConfig) editorRefreshScreen() {
    abuf := ab.New()

    fmt.Fprintf(abuf, "%c[?25l", utils.ESC)
    fmt.Fprintf(abuf, "%c[H", utils.ESC)

    e.editorDrawRows(abuf)
    ...
}

func (e *EditorConfig) editorDrawRows(abuf *ab.AppendBuffer) {
    for y := range e.rows {
        fmt.Fprintf(abuf, "~")

        fmt.Fprintf(abuf, "%c[K", utils.ESC)
        if y < e.rows-1 {
            fmt.Fprintf(abuf, "\r\n")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Welcome message

It is finally time we will display a welcome message to our editor

File: editor/output.go

func (e *EditorConfig) editorDrawRows(abuf *ab.AppendBuffer) {
    for y := range e.rows {
        if y == e.rows/3 {
            welcomeMessage := fmt.Sprintf("Kilo editor -- version %s", utils.KILO_VERSION)
            fmt.Fprintf(abuf, "%s", welcomeMessage)
        } else {
            fmt.Fprintf(abuf, "~")
        }
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

File: utils/constants.go

package utils

const (
    ESC = 0x1b

    KILO_VERSION = "0.0.1"
)
Enter fullscreen mode Exit fullscreen mode

Center the message

Now that we've shown the welcome screen, it seems odd with the message not centered, so lets do that

File: editor/output.go

func (e *EditorConfig) editorDrawRows(abuf *ab.AppendBuffer) {
    for y := range e.rows {
        if y == e.rows/3 {
            welcomeMessage := fmt.Sprintf("Kilo editor -- version %s", utils.KILO_VERSION)
            welcomeLen := len(welcomeMessage)
            if welcomeLen > e.cols {
                welcomeLen = e.cols
            }

            padding := (e.cols - welcomeLen) / 2
            if padding > 0 {
                fmt.Fprintf(abuf, "~")
                padding--
            }

            for range padding {
                fmt.Fprintf(abuf, " ")
            }

            fmt.Fprintf(abuf, "%s", welcomeMessage)
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)