post Image
Go でテキストエディタを開発しよう!!

この記事は Go3 Advent Calender12日目の記事です。

Target

この記事のターゲットは以下です。

  • テキストエディタを開発することに必要なことを知る
  • Go でテキストエディタを開発することに必要なことを知る
  • 筆者がテキストエディタ開発で体験したこと(得られたこと)を知る

Background

私は、テキストエディタが好きです。
テキストエディタが好きだから、テキストエディタを作ります。
みなさんも、テキストエディタが大好きですよね??

まだ、テキストエディタを作ったことのない読者様、
テキストエディタを作って、テキストエディットについて、
もっと理解をしてみませんか??

ほぼ全ての人間の作るプログラム(ソースコード)はテキストエディタから生まれます。
テキストエディタは、私たちプログラマの創造を具現化する Interface です。
さあ、一緒にテキストエディットの世界へ行きましょう!!

なお、筆者の開発しているテキストエディタは以下の GitHub リポジトリにあります。
※ この記事では古い version のソースコードを紹介していますので、最新のソースコードは以下をご覧ください
akif999/kennel

Elements of making text editor

まずは、Go に関わらない部分からお話します。
テキストエディタを開発してみるにあたって、とても重要だと思ったことは以下です。

テキストエディタの挙動を知る(仕様)

私たちプログラマでもテキストエディタの挙動について、あまり意識をしていないことから、
確かな動きを把握していないことが最初の出発点でした。
例えば以下のようなことです。

  • ひたすら左キーを入力し続けた場合カーソルはどのように動くべきか
  • BackSpace を押し続けた場合どのようにウィンドウの文字は削除されるか
  • Enter を押したときカーソルの位置によってどのようにウィンドウの文字が改行されるか

このように列挙すると、ごく当たり前のようなことばかりですが、
これが実装し始めるとどのように動くかわからなくなったりします。
ゆえに、まずは作りたいものがどのようにうごかないといけないか(= 仕様)を知る必要がありました。

テキストエディタのアーキテクチャを設計する(設計)

テキストエディタは小規模なプログラムへの機能追加によっても実現可能ですが、
基礎的な機能を実装する時点でそれなりの規模になります。
よって、ある程度の段階で全体のアーキテクチャレベルでの設計は必要になると考えています。

筆者はこの点については、アーキテクチャモデルの再利用によって設計を実施してみました。
この点については、別の記事に詳しくまとめていますので、ご興味があればご覧ください。

Elements of making test editor by Go

ここからは、Go でテキストエディタを実現するあたっての部分について説明します。

Termbox

Go でテキストエディタを開発するにあたって、ユーザへの入出力部分に以下のライブラリを使用しました。
nsf/termbox-go

この termbox-go は、ターミナルウィンドウへの入出力を行う Interface を提供してくれます。
それにより、テキストエディタを作る際も、その Interface へ入出力を任せ、
私たちはアプリケーションを実装することに注力できます。

Go

そもそも私がテキストエディタを作るにあたって Go を選んだ理由は以下です。

  • 書いていて楽しい
  • テキストエディタに重要なパフォーマンスに優れる
  • シンプルなプログラミングが可能なので、美しい設計を実現しやすい
  • 並行処理が容易である

SourceCode

そして、Go でテキストエディタをプロトタイピングしたときのコードは以下のようになりました。
(まだ、main.go のみで完結させていた時のソースコードです)

package main

import (
    "bufio"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "os"

    termbox "github.com/nsf/termbox-go"
)

const (
    Up = iota
    Down
    Left
    Right
)

var (
    undoBuf = &bufStack{}
    redoBuf = &bufStack{}
)

type bufStack struct {
    bufs []*buffer
}

type buffer struct {
    cursor cursor
    lines  []*line
}

type cursor struct {
    x int
    y int
}

type line struct {
    text []rune
}

func main() {
    filename := ""
    fmt.Print(len(os.Args))
    if len(os.Args) > 1 {
        filename = os.Args[1]
    }
    err := startUp()
    if err != nil {
        log.Fatal(err)
    }
    defer termbox.Close()

    buf := new(buffer)
    if filename == "" {
        buf.lines = []*line{&line{[]rune{}}}
    } else {
        file, err := os.Open(filename)
        if err != nil {
            log.Fatal(err)
        }
        buf.readFileToBuf(file)
    }
    buf.updateLines()
    buf.updateCursor()
    buf.pushBufToUndoRedoBuffer()
    termbox.Flush()

mainloop:
    for {
        switch ev := termbox.PollEvent(); ev.Type {
        case termbox.EventKey:
            switch ev.Key {
            case termbox.KeyEnter:
                buf.lineFeed()
            // mac delete-key is this
            case termbox.KeyCtrlH:
                fallthrough
            case termbox.KeyBackspace2:
                buf.backSpace()
            case termbox.KeyArrowUp:
                buf.moveCursor(Up)
            case termbox.KeyArrowDown:
                buf.moveCursor(Down)
            case termbox.KeyArrowLeft:
                buf.moveCursor(Left)
            case termbox.KeyArrowRight:
                buf.moveCursor(Right)
            case termbox.KeyCtrlZ:
                buf.undo()
            case termbox.KeyCtrlY:
                buf.redo()
            case termbox.KeyCtrlS:
                buf.writeBufToFile()
            case termbox.KeyEsc:
                break mainloop
            default:
                buf.insertChr(ev.Ch)
            }
        }
        buf.updateLines()
        buf.updateCursor()
        buf.pushBufToUndoRedoBuffer()
        termbox.Flush()
    }
}

func startUp() error {
    err := termbox.Init()
    if err != nil {
        return err
    }
    termbox.Clear(termbox.ColorWhite, termbox.ColorBlack)
    termbox.SetCursor(0, 0)
    return nil
}

func (b *buffer) lineFeed() {
    p := b.cursor.y + 1
    // split line by the cursor and store these
    fh, lh := b.lines[b.cursor.y].split(b.cursor.x)

    t := make([]*line, len(b.lines), cap(b.lines)+1)
    copy(t, b.lines)
    b.lines = append(t[:p+1], t[p:]...)
    b.lines[p] = new(line)

    // write back previous line and newline
    b.lines[p-1].text = fh
    b.lines[p].text = lh

    b.cursor.x = 0
    b.cursor.y++
}

func (b *buffer) backSpace() {
    if b.cursor.x == 0 && b.cursor.y == 0 {
        // nothing to do
    } else {
        if b.cursor.x == 0 {
            // store current line
            t := b.lines[b.cursor.y].text
            // delete current line
            b.lines = append(b.lines[:b.cursor.y], b.lines[b.cursor.y+1:]...)
            b.cursor.y--
            // // join stored lines to previous line-end
            plen := b.lines[b.cursor.y].text
            b.lines[b.cursor.y].text = append(b.lines[b.cursor.y].text, t...)
            b.cursor.x = len(plen)
        } else {
            b.lines[b.cursor.y].deleteChr(b.cursor.x)
            b.cursor.x--
        }
    }
}

func (b *buffer) insertChr(r rune) {
    b.lines[b.cursor.y].insertChr(r, b.cursor.x)
    b.cursor.x++
}

func (l *line) insertChr(r rune, p int) {
    t := make([]rune, len(l.text), cap(l.text)+1)
    copy(t, l.text)
    l.text = append(t[:p+1], t[p:]...)
    l.text[p] = r
}

func (l *line) deleteChr(p int) {
    p = p - 1
    l.text = append(l.text[:p], l.text[p+1:]...)
}

func (b *buffer) updateLines() {
    termbox.Clear(termbox.ColorWhite, termbox.ColorBlack)
    for y, l := range b.lines {
        for x, r := range l.text {
            termbox.SetCell(x, y, r, termbox.ColorWhite, termbox.ColorBlack)
        }
    }
}

func (b *buffer) moveCursor(d int) {
    switch d {
    case Up:
        // guard of top of "rows"
        if b.cursor.y > 0 {
            b.cursor.y--
            // guard of end of "row"
            if b.cursor.x > len(b.lines[b.cursor.y].text) {
                b.cursor.x = len(b.lines[b.cursor.y].text)
            }
        }
        break
    case Down:
        // guard of end of "rows"
        if b.cursor.y < b.linenum()-1 {
            b.cursor.y++
            // guard of end of "row"
            if b.cursor.x > len(b.lines[b.cursor.y].text) {
                b.cursor.x = len(b.lines[b.cursor.y].text)
            }
        }
        break
    case Left:
        if b.cursor.x > 0 {
            b.cursor.x--
        } else {
            // guard of top of "rows"
            if b.cursor.y > 0 {
                b.cursor.y--
                b.cursor.x = len(b.lines[b.cursor.y].text)
            }
        }
        break
    case Right:
        if b.cursor.x < b.lines[b.cursor.y].runenum() {
            b.cursor.x++
        } else {
            // guard of end of "rows"
            if b.cursor.y < b.linenum()-1 {
                b.cursor.x = 0
                b.cursor.y++
            }
        }
        break
    default:
    }
}

func (b *buffer) updateCursor() {
    termbox.SetCursor(b.cursor.x, b.cursor.y)
}

func (b *buffer) linenum() int {
    return len(b.lines)
}

func (l *line) runenum() int {
    return len(l.text)
}

func (l *line) split(pos int) ([]rune, []rune) {
    return l.text[:pos], l.text[pos:]
}

func (l *line) joint() *line {
    return nil
}
func (b *buffer) pushBufToUndoRedoBuffer() {
    tb := new(buffer)
    tb.cursor.x = b.cursor.x
    tb.cursor.y = b.cursor.y
    for i, l := range b.lines {
        tl := new(line)
        tb.lines = append(tb.lines, tl)
        tb.lines[i].text = l.text
    }
    undoBuf.bufs = append(undoBuf.bufs, tb)
}

func (b *buffer) undo() {
    if len(undoBuf.bufs) == 0 {
        return
    }
    if len(undoBuf.bufs) > 1 {
        redoBuf.bufs = append(redoBuf.bufs, undoBuf.bufs[len(undoBuf.bufs)-1])
        undoBuf.bufs = undoBuf.bufs[:len(undoBuf.bufs)-1]
    }
    tb := undoBuf.bufs[len(undoBuf.bufs)-1]
    undoBuf.bufs = undoBuf.bufs[:len(undoBuf.bufs)-1]
    b.cursor.x = tb.cursor.x
    b.cursor.y = tb.cursor.y
    for i, l := range tb.lines {
        tl := new(line)
        b.lines = append(b.lines, tl)
        b.lines[i].text = l.text
    }
}

func (b *buffer) redo() {
    if len(redoBuf.bufs) == 0 {
        return
    }
    tb := redoBuf.bufs[len(redoBuf.bufs)-1]
    redoBuf.bufs = redoBuf.bufs[:len(redoBuf.bufs)-1]
    b.cursor.x = tb.cursor.x
    b.cursor.y = tb.cursor.y
    for i, l := range tb.lines {
        tl := new(line)
        b.lines = append(b.lines, tl)
        b.lines[i].text = l.text
    }
}

func (b *buffer) readFileToBuf(reader io.Reader) error {
    scanner := bufio.NewScanner(reader)
    for scanner.Scan() {
        l := new(line)
        l.text = []rune(scanner.Text())
        b.lines = append(b.lines, l)
    }
    if err := scanner.Err(); err != nil {
        return err
    }
    return nil
}

func (b *buffer) writeBufToFile() {
    content := make([]byte, 1024)
    for _, l := range b.lines {
        l.text = append(l.text, '\n')
        content = append(content, string(l.text)...)
    }
    ioutil.WriteFile("./output.txt", content, os.ModePerm)
}

プロトタイピングを行った時のコードで、ゴミ混じりですがこれが一番最初に最低限の機能が動くようになったものです。
この時点で300行程度のコード量ですが、以下の機能は実装できていました。

  • 文字の挿入と削除
  • カーソル移動
  • 改行
  • undo redo
  • ファイルの読み書き

Go のシンプルかつ強力な言語仕様のおかげで、私のような経験の浅いプログラマでも、
このようにテキストエディタの開発にチャレンジすることができました。

Roundup

それでは、まとめとして Go でテキストエディタを開発してみて得られたものを以下へ示します。

  • テキストエディタというソフトウェアへの理解が深まった
  • アーキテクチャ設計をする機会が得られた
  • データ構造についてのアイデアのストックができた
  • Go の slice 操作のテクニックが身についた
  • 一般的なテキストエディタの実装について想像の及ぶ範囲が増えた

Reference


『 Go 』Article List
Category List

Eye Catch Image
Read More

Androidに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

AWSに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Bitcoinに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

CentOSに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

dockerに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

GitHubに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Goに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Javaに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

JavaScriptに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Laravelに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Pythonに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Rubyに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Scalaに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Swiftに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Unityに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Vue.jsに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Wordpressに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

機械学習に関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。