昔 Go の勉強と思って作ったターミナルでポモドーロするツールを年末だし0から書き直すことにした。
そこでまずメインとなるタイマーの部分を書き直してみることにした。
まず今の挙動は次の gif 画像です。
今のタイマーの問題
さきほどの gif を見てわかるようにターミナルをリサイズした時にタイマーは真ん中にきますが文字の大きさは変わりません。
自分は普段会社でも家でも4Kのモニターにつないで作業しています。
そのためターミナルの文字サイズも結構小さく、1つの画面内に Chrome や ターミナルなど複数のウィンドウを配置して作業しています。
そのためその時々でターミナルのサイズが異なります。
できれば画面サイズに合わせて良い感じに出してほしい!!!! と思ったわけです。
今回作ったもの
gif を見てわかるように画面サイズに応じて文字サイズが良い感じになってそうです。
ここからは技術的にどう実現したかを書いていきます。
ターミナルへ描画をするためのパッケージ
今までは nsf/termbox-go を使っていました。
このパッケージは2019/12/29現在 peco でも使われているパッケージです。
今回もこの termbox-go を使おうと思ったのですが README.md に このパッケージは保守しないようなことが書いてあり代わりに tcell が良いって聞いたよって書いてありました。
https://github.com/nsf/termbox-go#important
そこで今回はこの tcell を使ってみることにしました。
タイマーをどのようにターミナルに表示するかを考える
tcell を使って実装していく前にまずは1番小さい時にどのように表示をするかを考えます。
なぜなら最小を決めれば後は画面サイズにあわせて拡大率を決めて拡大するだけだからです。
今回は↓のように縦横が数字の場合は 5 x 4, 区切り文字の場合は 5 x 1 を最小にしました。
つまり描画した時の最小は横が (数字(5) x 4 + 区切り(1) x 1 + 余白(1) x 4) = 21, 縦が 5 になります。
実装
まず数字を定義をします。
さきほどの 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) の部分で拡大するようにしています。
描画する部分の実装は終わったので最後に呼び出し部分を実装します。
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 は他にもマウスのクリックなどのイベントもとれるため色々できそうな感じです。