[Go language] Create a TUI app with Elm Architecture Create a slightly rich ToDo app with bubble tea

Foreword

In this article, we will create a ToDo app using a framework called bubbletea that allows you to create a TUI app in Elm Architecture like.

You can view, add, edit, complete, and delete tasks / completed tasks. Image image ↓ イメージ画像

About Elm Architecture

For those who do not know Elm Architecture, Official Guide (Japanese translation) and [This article](https://qiita.com/kazurego7/items/ I think it's easier to understand if you read 27a2b6f8b4a1bfac4bd3) briefly. (This article rarely describes Elm Architecture.)

I have a little touch with Elm, so I have a weak understanding of Elm Architecture. So if you have any mistakes, please let us know in the comments or Twitter.

Features implemented in this article

In this article, I'm thinking of displaying the task list and explaining (guide) up to the addition of tasks. From there, I just add code. For those who want to read only the code and feel the atmosphere, those who want to know in detail how to implement other functions, and those who throw Masakari, yuzuy / todo-cli See todo-cli)!

Let's start implementing it now!

Implementation

Writer's environment

version
OS macOS Catalina 10.15.7
iTerm 3.3.12
Go 1.15.2
bubbletea 0.7.0
bubbles 0.7.0

Note: About bubble tea. There were some destructive changes during the implementation of this app, so if you have a bug when using a different version of bubbletea, please refer to Repository. please.

List of tasks

In this section, we will display the task list and implement it to the point where you can select the task with the cursor.

Let's go get the package first.

// bubbletea
go get github.com/charmbracelet/bubbletea

// utility
go get github.com/charmbracelet/bubbles

Model

First, define the structure of the task.

main.go


type Task struct {
    ID        int
    Name      string
    IsDone    bool
    CreatedAt time.Time
}

It has a minimal structure, so if you want something else like Finished At, add it.

Next, define model. This is the model of Elm Architecture. Elm Architecture manages the state of the app with a model. In this chapter Since there are two states to handle, the task list and the cursor position, the implementation is as follows.

main.go


type model struct {
    cursor int
    tasks  []*Task
}

This alone will not treat bubble tea as a model. To treat it as a model, you need to have model implement tea.Model.

Definition of tea.Model

// Model contains the program's state as well as it's core functions.
type Model interface {
	// Init is the first function that will be called. It returns an optional
	// initial command. To not perform an initial command return nil.
	Init() Cmd

	// Update is called when a message is received. Use it to inspect messages
	// and, in response, update the model and/or send a command.
	Update(Msg) (Model, Cmd)

	// View renders the program's UI, which is just a string. The view is
	// rendered after every Update.
	View() string
}

First, we will implement it from the initialization function ʻInit (), but since there is no command to be executed first in this app, it is okay to just return nil. (Initialization of model`struct is done separately.) (This article doesn't deal with commands very much, so don't worry too much about commands. If you are interested, Elm Official Guide (Japanese translation) You may want to refer to /).)

main.go


import (
    ...
    tea "github.com/charmbracelet/bubbletea"
)

...

func (m model) Init() tea.Cmd {
    return nil
}

Update

ʻUpdate ()changes the state ofmodel` based on the user's operation (Msg).

main.go


func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "j":
            if m.cursor < len(m.tasks) {
                m.cursor++
            }
        case "k":
            if m.cursor > 1 {
                m.cursor--
            }
        case "q":
            return m, tea.Quit
        }
    }

    return m, nil
}

Handling tea.KeyMsg (user key operation), When "j" is pressed, increase the value of cursor by 1. Decrease the value of cursor by 1 when "k" is pressed. It defines that when "q" is pressed, it returns the tea.Quit command to exit the app.

Since it is a problem if the cursor goes up or down infinitely, conditions such as m.cursor> 1 are added. By the way, if you use case" j "," down " or case" k "," up ", you can move the cursor up and down with the arrow keys, so please use it as you like.

View

View () will generate the text to draw based on the model. I will write it with string.

main.go


func (m model) View() string {
    s := "--YOUR TASKS--\n\n"

    for i, v := range m.tasks {
        cursor := " "
        if i == m.cursor-1 {
            cursor = ">"
        }

        timeLayout := "2006-01-02 15:04"
        s += fmt.Sprintf("%s #%d %s (%s)\n", cursor, v.ID, v.Name, v.CreatedAt.Format(timeLayout))
    }

    s += "\nPress 'q' to quit\n"

    return s
}

Since the cursor is not counted by the index number, whether the cursor points to the task is determined by comparing the indexes ʻi and m.cursor-1`.

You now have enough material to view your tasks! Let's define the main function so that the app can be started!

main

The main function initializes the model struct and starts the app.

main.go


func main() {
    m := model{
        cursor: 1,
        tasks:  []*Task{
            {
                ID:        1,
                Name:      "First task!",
                CreatedAt: time.Now(),
            },
            {
                ID:        2,
                Name:      "Write an article about bubbletea",
                CreatedAt: time.Now(),
            },
        }
    }

    p := tea.NewProgram(m)
    if err := p.Start(); err != nil {
        fmt.Printf("app-name: %s", err.Error())
        os.Exit(1)
    }
}

Normally, tasks are read from files, etc., but it takes time to implement them, so this time we will hard-code them. Generate a program with tea.NewProgram () and start it with p.Start ().

Let's execute it with the go run command immediately! A list of tasks should be displayed, and you should be able to move the cursor up and down with the "j" and "k" keys!

Add task

Well, I was able to display the list of tasks, but with this, the ToDo application is unlikely to be named. In this section we will implement one of the most important features of the ToDo app, the addition of tasks.

Model

Up until now, it was okay to just hold the cursor position and task list, but when implementing the addition of a task, it is necessary to provide a field that receives input from the user and holds it.

bubbleteaでテキスト入力を実装するにはgithub.com/charmbracelet/bubbles/textinputというパッケージを利用します。

main.go


import (
    ...
    input "github.com/charmbracelet/bubbles/textinput"
)

type model struct {
    ...
    newTaskNameInput input.Model
}

Since it is not possible to distinguish between the task list display mode (hereinafter referred to as normal mode) and the task addition mode (hereinafter referred to as additional mode) by this alone, a field called mode is also added.

main.go


type model struct {
    mode int
    ...
    newTaskNameInput input.Model
}

Let's also define the identifier for mode.

main.go


const (
    normalMode = iota
    additionalMode
)

Update

I'd like to start changing the ʻUpdate ()` function immediately, but there is one missing element when adding a task. Since this ToDo app wants to manage task ids with serial numbers, it is necessary to keep the latest task ids.

~~ Declare it as a global variable, initialize it with main (), increment it with ʻUpdate (), and so on. (I thought while writing, but it may be managed as a modelfield.) ~~ [Twitter](https://twitter.com/ababupdownba/status/1320139661579218945?s=20) pointed out by [@ababupdownba](https://twitter.com/ababupdownba), after allmodel` It's better to make it a field, so I will implement it there.

main.go


type model struct {
    ...
    latestTaskID int
}

func main() {
    ...
    //This is a hard-coding festival, but if you want to implement it properly, enter the initial value when reading from a file etc.
    m.latestTaskID = 2
    ...
}

By the way, regarding ʻUpdate ()` of the main subject, unlike the normal mode, all character keys are treated as input in the additional mode.

Therefore, define model.addingTaskUpdate () separately frommodel.Update (), and if model.mode == additionalMode, process it there.

In normal mode, it changes to additional mode when "a" is pressed. In add mode, let's make sure that the task is added when "enter" is pressed, so that it returns to normal mode when "ctrl + q" is pressed.

main.go


func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    if m.mode == additionalMode {
        return m.addingTaskUpdate(msg)
    }

    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        ...
        case "a":
            m.mode = additionalMode
        ...
    }

    return m, nil
}

func (m model) addingTaskUpdate(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+q":
            m.mode = normalMode
            m.newTaskNameInput.Reset()
            return m, nil
        case "enter":
            m.latestTaskID++
            m.tasks = append(m.tasks, &Task{
                ID  :      m.latestTaskID,
                Name:      m.newTaskNameInput.Value(),
                CreatedAt: time.Now(),
            })

            m.mode = normalMode
            m.newTaskNameInput.Reset()

            return m, nil
        }
    }

    var cmd tea.Cmd
    m.newTaskNameInput, cmd = input.Update(msg, m.newTaskNameInput)

    return m, cmd
}

The value typed with m.newTaskNameInput.Value () is retrieved and reset with m.newTaskNameInput.Reset (). ʻInput.Update ()handles keystrokes and updatesm.newTaskNameInput`.

View

You need to separate the same process as ʻUpdate ()ofView ()`. But this can be implemented with only 6 line changes!

main.go


func (m model) View() string {
    if m.mode == additionalMode {
        return m.addingTaskView()
    }
    ...
}

func (m model) addingTaskView() string {
    return fmt.Sprintf("Additional Mode\n\nInput a new task name\n\n%s", input.View(m.newTaskNameInput))
}

ʻInput.View () is a variable that converts ʻinput.Model to string for View ().

main

Let's add initialization of the new fields mode and newTaskNameInput.

main.go


func main() {
    newTaskNameInput := input.NewModel()
    newTaskNameInput.Placeholder = "New task name..."
    newTaskNameInput.Focus()

    m := model{
        mode: normalMode,
        ...
        newTaskNameInput: newTaskNameInput,
    }
    ...
}

Placeholder is a string to display when nothing is entered.

Calling Focus () will focus on that ʻinput.Model`. This seems to be used when handling multiple inputs on one screen. This time, we won't let you enter more than one, so I think it's okay if you think about it as a magic.

Now you have implemented the addition of tasks! Let's execute it with the go run command etc. as before!

Afterword

In this article, I explained how to make a slightly rich ToDo application using bubble tea. It was easier to implement than I expected, and I wanted to create a complex CLI tool like tig, but for me who couldn't take the first step. Was a very attractive framework.

bubbletea has many other features for creating rich TUI applications, so be sure to check out the Repository!

Recommended Posts

[Go language] Create a TUI app with Elm Architecture Create a slightly rich ToDo app with bubble tea
Create a Todo app with the Django REST framework
Create a Todo app with Django ③ Create a task list page
Create a Todo app with Django ⑤ Create a task editing function
Create a Todo app with Django ① Build an environment with Docker
Create a Todo app with Django ④ Implement folder and task creation functions
Create a GUI app with Python's Tkinter
Create a simple web app with flask
How to create a multi-platform app with kivy
Create a web server in Go language (net/http) (2)
[Practice] Make a Watson app with Python! # 1 [Language discrimination]