Go3 Advent Calendar 2017の16日目です。
皆さんはGo製のツールで好きなツールといったらなんでしょうか?
色々あるかと思いますが、僕はpecoがいちばん好きです。
ということである日に「そうだ、pecoっぽいものを作ろう」という欲求がムクムク湧いてきましたので、やってみました。
pecoの構成要素
pecoの大まかな構成要素としては
- 検索フォーム
- 検索結果
- 上下矢印キーで選択対象を移動
- Enterを押したら対象を実行
という感じになります。
そして新しいツールを閃いた
これらの構成要素を使うような新しいツールをウンウン唸って考えて、捻り出したのがこれです。
どんなツールかというと、shellの履歴ファイルを参照し、選択したコマンドを元にシェルスクリプトを生成するというツールになっています。
こんな感じのイメージです。
> mino create hoge で、シェルスクリプトで実行させたいコマンドを選択し、Enterを謳歌することでhoge.shを作成します。
実行は > mino hoge とすることで、minoコマンドからも行うことが出来ます。
実装
さて、こっから実装の話に入ります。
機能としては
の2種類に分かれるので、コマンドのargsによって create_macro_cmd.go と exec_macro_cmd.go というそれぞれを叩くようにしています。
シェルスクリプトを作成
肝となるのは
- コマンド履歴ファイルの読み込み
- コマンド選択のUI
になります。
コマンド履歴ファイルの読み込みについては、「.zsh_historyや.bash_historyを読み込んでくりゃいいや」くらいに思ってました。
ただ、.zsh_historyについてはとんでもないハマり方をしたので、興味のある方はこちらを読んでみて下さい。
次にコマンド選択時のUIです。
ここで、pecoのUIを再現する時に「このUIどうやってんだ?」と思ったのですが、どうやらtermbox-goというTUI作成ライブラリを使っていました。
ドキュメントがあまりないのですが、_demos にデモが詰まってるのでこの辺りを参考にするとハッピーになれます。
頑張ったらゲームも作れるすげーやつです。
とりあえず、pecoの screen.go にtermboxを使った描画系が詰まっているので、ここを見ていきます。
https://github.com/peco/peco/blob/master/screen.go
NewTermbox() でTermbox構造体を生成し、それに連なるメソッドを書いている構造になりますね。
それを大胆に参考にした結果、こうなりました。
https://github.com/syossan27/mino/blob/master/screen.go
Display() で描画を開始し、キーイベントによって選択行を変更したり、選択状態にしたりというメソッドに処理を繋げていっています。
描画で一番重要なところが Draw() になります。ここで、検索フォームの表示やらコマンドの履歴を表示したりやらしてます。
| func (t *Termbox) Draw() { | |
| termbox.Clear(color["default"], color["default"]) | |
| commandHistory := t.Commands | |
| // 検索フォームの表示 | |
| displaySearchForm := "QUERY>" | |
| t.Print(0, 0, style["fg"], style["bg"], displaySearchForm) | |
| t.PrintSearchQuery(len(displaySearchForm), 0, style["fg"], style["bg"]) | |
| // 検索条件がある場合、検索条件に合致するコマンド履歴一覧を生成する | |
| // コマンド履歴配列が変更されるため、内部バッファのサイズも更新 | |
| if len(t.Filter.SearchQuery) != 0 { | |
| commandHistory = t.Filter.FilterResult(t.Commands) | |
| t.Buffer.Size = len(commandHistory) | |
| } | |
| // commandHistoryを順番に表示 | |
| for i := t.Buffer.Offset; i < len(commandHistory); i++ { | |
| command := commandHistory[i] | |
| // 選択済みかどうか | |
| number, exist := t.Selection.GetSelectedNumber(command.Index) | |
| // 表示させる文字列 | |
| var appendExecOrder string | |
| var displayCommand string | |
| var displayColorSet map[string]termbox.Attribute | |
| var displaySearchColorSet map[string]termbox.Attribute | |
| if exist { | |
| // 選択済の場合 | |
| appendExecOrder = strconv.Itoa(number) + ": " | |
| displayCommand = appendExecOrder + command.Content | |
| displayColorSet = map[string]termbox.Attribute { | |
| "fg": style["selectedFg"], | |
| "bg": style["selectedBg"], | |
| } | |
| displaySearchColorSet = map[string]termbox.Attribute { | |
| "fg": style["selectedSearchFg"], | |
| "bg": style["selectedSearchBg"], | |
| } | |
| } else { | |
| // 未選択の場合 | |
| displayCommand = command.Content | |
| displayColorSet = map[string]termbox.Attribute { | |
| "fg": style["fg"], | |
| "bg": style["bg"], | |
| } | |
| displaySearchColorSet = map[string]termbox.Attribute { | |
| "fg": style["searchFg"], | |
| "bg": style["searchBg"], | |
| } | |
| } | |
| // コマンド履歴の表示 | |
| if t.Buffer.CommandPosition == t.Selection.Index - t.Buffer.Offset { | |
| // カーソルの表示 | |
| t.PrintFullWidth(0, t.Buffer.CommandPosition, style["selectionFg"], style["selectionBg"], displayCommand) | |
| // 検索条件に合致した箇所に着色 | |
| // カーソル表示時は着色する色は固定 | |
| t.Print(len(appendExecOrder) + command.FilterIndex, t.Buffer.CommandPosition, color["cyan"], style["selectionBg"], string(t.Filter.SearchQuery)) | |
| // カーソルにあるコマンドを仮保存 | |
| t.Selection.Command = command | |
| } else { | |
| // 選択済か、未選択のコマンド履歴の表示 | |
| t.PrintFullWidth(0, t.Buffer.CommandPosition, displayColorSet["fg"], displayColorSet["bg"], displayCommand) | |
| // 検索条件に合致した箇所に着色 | |
| t.Print(len(appendExecOrder) + command.FilterIndex, t.Buffer.CommandPosition, displaySearchColorSet["fg"], displaySearchColorSet["bg"], string(t.Filter.SearchQuery)) | |
| } | |
| t.Buffer.CommandPosition++ | |
| } | |
| termbox.Flush() | |
| t.Buffer.ClearCommandPosition() | |
| } |
非常に長くて汚いコードですね。見てて書き直したくなってきました。
実際に描画を行う際には、 Print() や PrintFullWidth() を通して描画します。
文字の描画幅を決める際に go-runewidth を使っていますが、これを使うとマルチバイト文字の幅をrune◯文字分として取得できます。便利。
あとは、選択したコマンドに対する責務は selection.go に集めてます。
選択されたコマンドの情報がここに集まってくる感じです。
特に難しいことはしていません、非常に泥臭いです。
シェルスクリプトの実行
やってることは凄くシンプルで、 exec.Command でシェルスクリプト実行して結果を出力してます。
一応、aliasとかに配慮して source 使ってshellの設定ファイル読み込んでます。
ただ、これ対話式のコマンドを選択していた場合に対応出来てないんで、なんか良い方法ないかなと今でも考えています。
まとめ
そんなこんなで作ったminoですが、なんか役に立つのか立たないのか自分でも分からなくなって途中で放置しています。
「こんな感じにしてみると良い」とかいう感想があったら教えて下さいますと幸いです。
実際、pecoっぽいUIにするところまでは1日くらいで出来たので、是非読んでくださった方もTermboxを使って良い感じのUIのツールを作ってみてください。