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

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: %srn", err)
        os.Exit(1)
    }

    os.Exit(0)
}

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
}

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)
        }
        ...
    }
}

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
}

File: main.go

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

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()
    }
}

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)
    }
}

File: editor/terminal.go

package editor

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

    return b, err
}

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: %srn", err)
        os.Exit(1)
    }
}

func main() {
    editor := editor.NewEditor(restoreFunc)
    editor.EditorLoop()
}

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)
}

File: utils/constants.go

package utils

const (
    ESC = 0x1b
)

File: editor/editor.go

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

    for {
        e.editorRefreshScreen()
        e.editorProcessKeypress()
    }
}

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)
}

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)
    ...
}

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, "~rn")
    }
}

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: %vrn", err)
        return
    }

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

    return
}

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,
    }
}

File: editor/output.go

func (e *EditorConfig) editorDrawRows() {
    for range e.rows {
        fmt.Fprintf(os.Stdout, "~rn")
    }
}

The last line

At the moment we always print the rn 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, "rn")
        }
    }
}

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
}

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))
    ...
}

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
}

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, "rn")
        }
    }
}

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)
    ...
}

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 [2J escape sequence, and instead put a [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, "rn")
        }
    }
}

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, "~")
        }
        ...
    }
}

File: utils/constants.go

package utils

const (
    ESC = 0x1b

    KILO_VERSION = "0.0.1"
)

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)
        ...
    }
}
Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Jeff Su: 4 ChatGPT Hacks that Cut My Workload in Half

Next Post

[Boost]

Related Posts