ターミナルに良い感じにタイマーを描画したい

昔 Go の勉強と思って作ったターミナルでポモドーロするツールを年末だし0から書き直すことにした。
そこでまずメインとなるタイマーの部分を書き直してみることにした。

まず今の挙動は次の gif 画像です。
f:id:hatappi1225:20191228222150g:plain

今のタイマーの問題

さきほどの gif を見てわかるようにターミナルをリサイズした時にタイマーは真ん中にきますが文字の大きさは変わりません。
自分は普段会社でも家でも4Kのモニターにつないで作業しています。
そのためターミナルの文字サイズも結構小さく、1つの画面内に Chrome や ターミナルなど複数のウィンドウを配置して作業しています。
そのためその時々でターミナルのサイズが異なります。

できれば画面サイズに合わせて良い感じに出してほしい!!!! と思ったわけです。

今回作ったもの

f:id:hatappi1225:20191228214615g:plain

gif を見てわかるように画面サイズに応じて文字サイズが良い感じになってそうです。
ここからは技術的にどう実現したかを書いていきます。

ターミナルへ描画をするためのパッケージ

今までは nsf/termbox-go を使っていました。

github.com

このパッケージは2019/12/29現在 peco でも使われているパッケージです。
今回もこの termbox-go を使おうと思ったのですが README.md に このパッケージは保守しないようなことが書いてあり代わりに tcell が良いって聞いたよって書いてありました。
https://github.com/nsf/termbox-go#important

そこで今回はこの tcell を使ってみることにしました。

github.com

タイマーをどのようにターミナルに表示するかを考える

tcell を使って実装していく前にまずは1番小さい時にどのように表示をするかを考えます。
なぜなら最小を決めれば後は画面サイズにあわせて拡大率を決めて拡大するだけだからです。

今回は↓のように縦横が数字の場合は 5 x 4, 区切り文字の場合は 5 x 1 を最小にしました。
つまり描画した時の最小は横が (数字(5) x 4 + 区切り(1) x 1 + 余白(1) x 4) = 21, 縦が 5 になります。
f:id:hatappi1225:20191229013900p:plain

実装

まず数字を定義をします。
さきほどの 5x4 を表現していて人間の目にもわかりやすくなってますww

var numbers = []string{
    `
####
#--#
#--#
#--#
####
  `,
    `
---#
---#
---#
---#
---#
`,
    `
####
---#
####
#---
####
`,
    `
####
---#
####
---#
####
`,
    `
#-#-
#-#-
####
--#-
--#-
`,
    `
####
#---
####
---#
####
`,
    `
#---
#---
####
#--#
####
`,
    `
####
---#
---#
---#
---#
`,
    `
####
#--#
####
#--#
####
`,
    `
####
#--#
####
---#
####
`,
}

次に区切り文字を定義します。

var separator = `
-
#
-
#
-
`

数字と区切り文字の定義ができたのでこれらをターミナルに表示する部分を実装します。
ここで定義している DrawTimer に必要な引数を渡すとターミナルにタイマーが表示されます。

func DrawTimer(s tcell.Screen, x, y, mag, min, sec int) {
    minStr := fmt.Sprintf("%02d", min)
    secStr := fmt.Sprintf("%02d", sec)

    drawNumber(s, x, y, mag, string(minStr[0]))

    x += (number_width + whitespace_width) * mag
    drawNumber(s, x, y, mag, string(minStr[1]))

    x += (number_width + whitespace_width) * mag
    drawSeparater(s, x, y, mag)

    x += (separater_width + whitespace_width) * mag
    drawNumber(s, x, y, mag, string(secStr[0]))

    x += (number_width + whitespace_width) * mag
    drawNumber(s, x, y, mag, string(secStr[1]))
}

func drawNumber(s tcell.Screen, x, y, mag int, nStr string) {
    n, _ := strconv.Atoi(nStr)
    t := strings.Split(strings.Replace(numbers[n], "\n", "", -1), "")
    draw(s, t, number_width, number_height, x, y, mag)
}

func drawSeparater(s tcell.Screen, x, y, mag int) {
    t := strings.Split(strings.Replace(separator, "\n", "", -1), "")
    draw(s, t, separater_width, separater_height, x, y, mag)
}

func draw(s tcell.Screen, t []string, w, h, x, y, mag int) {
    st := tcell.StyleDefault
    st = st.Background(tcell.ColorGreen)
    gl := ' '

    for row := 0; row < h; row++ { 
        for col := 0; col < w; col++ {
            if t[(row*w)+(col)] == "#" {
                for pRow := 0; pRow < mag; pRow++ { // (1)
                    for pCol := 0; pCol < mag; pCol++ { // (2)
                        s.SetCell(x+(col*mag)+pCol, y+(row*mag)+pRow, st, gl)
                    }
                }
            }
        }
    }

    s.Show()
}

ターミナルへの描画は draw 関数で行っています。
描画する部分は結構愚直な感じですw
例えば数字を描画する場合は以下の画像のように左上から右下へと順番にターミナルに描画していきます。
また画面の大きさに応じて拡大する必要があるので (1), (2) の部分で拡大するようにしています。

f:id:hatappi1225:20191229204450p:plain

描画する部分の実装は終わったので最後に呼び出し部分を実装します。

func main() {
    if err := realMain(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %+v\n", err)
        os.Exit(1)
    }
}

func realMain() error {
    var duration int
    flag.IntVar(&duration, "duration", 300, "duration. max value is 3600")
    flag.Parse()

    if duration > 3600 {
        return fmt.Errorf("duration max value is 3600")
    }

    tcell.SetEncodingFallback(tcell.EncodingFallbackASCII)
    s, err := tcell.NewScreen()
    if err != nil {
        return err
    }

    if err = s.Init(); err != nil {
        return err
    }
    defer s.Fini()

    s.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorWhite))
    s.Clear()

    quit := make(chan struct{})
    go func() {
        for {
            ev := s.PollEvent()
            switch ev := ev.(type) {
            case *tcell.EventKey:
                switch ev.Key() {
                case tcell.KeyEscape, tcell.KeyEnter:
                    close(quit)
                    return
                }
            }
        }
    }()

    t := time.NewTicker(1 * time.Second)
    defer t.Stop()

    for {
        w, h := s.Size()

        min := duration / 60
        sec := duration % 60

        x := float64(w) / 16
        y := float64(h) / 16

        cw := float64(w) * 14 / 16
        ch := float64(h) * 14 / 16

        mag, err := getMagnification(cw, ch)
        if err != nil {
            return err
        }

        x = math.Round(x + ((cw - (TIMER_WIDTH * mag)) / 2))
        y = math.Round(y + ((ch - (TIMER_HEIGHT * mag)) / 2))

        s.Clear()
        DrawTimer(s, int(x), int(y), int(mag), min, sec)

        select {
        case <-quit:
            return nil
        case <-t.C:
        }

        duration -= 1

        if duration == 0 {
            t.Stop()
        }
    }
}

func getMagnification(w, h float64) (float64, error) {
    x := math.Round(w / draw.TIMER_WIDTH)
    y := math.Round(h / draw.TIMER_HEIGHT)
    mag := math.Max(x, y)

    for {
        if mag < 1.0 {
            return 0.0, errors.New("screen is small")
        }

        if w >= TIMER_WIDTH*mag && h >= TIMER_HEIGHT*mag {
            break
        }

        mag -= 1.0
    }

    return mag, nil
}

ここで解説するところはまずは中盤の gorutine で起動している部分です。
この部分はプログラムを実行中 tcell がユーザーが何らかのキーを押した時や画面サイズを変更したなどのイベントをキャッチして何かをアクションすることができます。
今回は Escape と Enter キーが押された時に quit channel を close します。

後半の for は実際にタイマーのカウントダウンをしている部分の処理で画面サイズを取得して拡大率を計算して先ほど作成した DrawTimer にいれています。
またこのループ内で quit チャネルを受信しているので close された時にこのループも終了するようになっています。

拡大率の計算は少し工夫をしていてまず画面一杯にタイマーを表示すると少し見栄えが悪いので少し余白をいれるために縦横の 14/16 をタイマーを描画するエリアにします。
次にそのエリアにちょうど良い大きさになるように倍率を合わせていきます。
それを行っているのが getMagnification です。

これらを組み合わせて冒頭でみた gif のようなものができあがりました。

最後に

tcell を試してみるためにタイマーを作ってみました。
tcell は他にもマウスのクリックなどのイベントもとれるため色々できそうな感じです。