やったこと
リッチなコマンドラインツールを作るとなると常に画面に情報を出力し続けたり、ターミナルの大きさに合わせて表示も変化させたいものです。今回は以下の内容を実装しました。
- ターミナルの大きさに合わせて枠を表示する(
|
や-
,+
を使用) - ターミナルの大きさを変更しても自動で枠が適切な(画面に沿った)大きさで表示される
以下イメージ(一番下の行はコマンドを入力するためのスペースとして使えるように敢えて残しています)
まずは完成形から
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"golang.org/x/crypto/ssh/terminal"
)
func GetWindowSize(fd int) *Size {
ws := &Window{}
var err error
ws.Row, ws.Column, err = terminal.GetSize(fd)
if err != nil {
fmt.Printf("get window sieze error: %v", err)
os.Exit(1)
}
return &ws.Size
}
type Size struct {
Row int
Column int
}
type Window struct {
Size
}
func main() {
signalChan := make(chan os.Signal, 1)
// catch SIGINT(Ctrl+C), KILL signal, and window size changes
signal.Notify(
signalChan,
syscall.SIGINT,
syscall.SIGTERM,
syscall.SIGWINCH,
)
ws := GetWindowSize(syscall.Stdin)
makeWindow(ws.Row, ws.Column)
exitChan := make(chan int)
go func() {
for {
s := <-signalChan
switch s {
// SIGINT(Ctrl+C)
case syscall.SIGINT:
exitChan <- 130
// kILL signal
case syscall.SIGTERM:
exitChan <- 143
case syscall.SIGWINCH:
ws := GetWindowSize(syscall.Stdin)
makeWindow(ws.Row, ws.Column)
default:
exitChan <- 1
}
}
}()
code := <-exitChan
os.Exit(code)
}
func makeWindow(row int, column int) {
fmt.Print("\033[H\033[2J")
printFilledLine(row, '+', '-')
for i := 1; i < (column-1)-1; i++ {
printFilledLine(row, '|', ' ')
}
printFilledLine(row, '+', '-')
}
func printFilledLine(row int, edgeChar byte, fillChar byte) {
l := make([]byte, row)
l[0] = edgeChar
for i := 1; i < (row - 1); i++ {
l[i] = fillChar
}
l[row-1] = edgeChar
fmt.Println(string(l))
}
こちらの資料やコードが元になっていますので併せて見ていただけると参考になるでしょう。
Golangにおける端末制御 リッチなターミナルUIの実現方法
https://github.com/c-bata/go-prompt/blob/master/_tools/sigwinch/main.go
1. ターミナルのサイズの取得
ターミナルの大きさに合わせて枠を表示するためにはターミナルの大きさを知る必要があります。golang.org/x/crypto/ssh/terminal
のGetSize
にファイルディスクリプタの数値を入れることにより、その画面の大きさを取得することができます。
標準入力(Stdin: 0), 標準出力(Stdout: 1), 標準エラー出力(Stderr: 2)の値についてはsyscall
パッケージに定義されているのでその値を使うと良いでしょう。
func GetWindowSize(fd int) *Size {
ws := &Window{}
var err error
ws.Row, ws.Column, err = terminal.GetSize(fd)
if err != nil {
fmt.Printf("get window sieze error: %v", err)
os.Exit(1)
}
return &ws.Size
}
ただし、以下のようにパイプを使用した場合はterminal.GetSize(fd)
が-1, -1, err
を返す点には注意が必要です。
> ls -l | go run main.go
get window sieze error: inappropriate ioctl for deviceexit status 1
2. ターミナルの画面サイズ変化の検知
ターミナルの画面サイズを取得することには成功しました。しかし常に画面のサイズに沿った枠線を表示するには画面サイズが変化したことを検知する必要があります。
実際の処理はmain関数の部分が該当しますので順に説明します。
signal.Notify は第一引数にチャネルを渡すことにより、後の引数で指定してシグナルの着信をチャネルに通知します。
本命は SIGWINCHで画面サイズの変更を通知します。
func main() {
signalChan := make(chan os.Signal, 1)
// catch SIGINT(Ctrl+C), KILL signal, and window size changes
signal.Notify(
signalChan,
syscall.SIGINT,
syscall.SIGTERM,
syscall.SIGWINCH,
)
・・・(略)・・・
3. 画面サイズ変更に伴う枠線の描画
最初にプログラムの終了のタイミングを制御するためのチャネルを作成します。ここでチャネルから送られる値が終了時のステータスコードになるわけですが、
code := <-exitChan
でチャネルの待受をしていないと処理が終了してしまうので、このチャネルにはプログラムがすぐに終了してしまうことを防ぐ意味合いもあります。
SIGWINCH
を受信した際には画面サイズの取得と画面サイズに沿った枠線の描画を行い次の通知を待つようにしています(Ctrl+Cやkillされた際には処理を終了させます)。
makeWindow
とprintFilledLine
は文字列の整形くらいのことしかしていないので細かい説明は割愛しますが、fmt.Print("\033[H\033[2J")
を出力することによりループごとに画面のクリアとカーソルの左上への移動を実現しています、詳しくはエスケープシーケンスについて調べてみると良いでしょう。
exitChan := make(chan int)
go func() {
for {
s := <-signalChan
switch s {
// SIGINT(Ctrl+C)
case syscall.SIGINT:
exitChan <- 130
// kILL signal
case syscall.SIGTERM:
exitChan <- 143
case syscall.SIGWINCH:
ws := GetWindowSize(syscall.Stdin)
makeWindow(ws.Row, ws.Column)
default:
exitChan <- 1
}
}
}()
code := <-exitChan
os.Exit(code)
}
4. 終わりに
今回は簡単なことしかしていませんがGoの知識に合わせてシステムコール、ファイルディスクリプタ、エスケープシーケンスについて調べるとよりリッチなコマンドラインツールの作成の一助になるでしょう。