59
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Go3Advent Calendar 2018

Day 12

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

Last updated at Posted at 2018-12-10

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

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

59
30
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
59
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?