LoginSignup
12
3

More than 3 years have passed since last update.

コマンドプロンプトの文字幅をキャリブレーションして、崩れない TUI 画面を作ろう

Last updated at Posted at 2019-12-04

本文書は Go5 Advent Calendar 2019、5日目の記事です。Go5-5!

さて、最近、コマンドプロンプトで動作する CSVビューア とか、ツイッタクライアント を書いてます。

この手のツールを書く時、本格的なフレームワークライブラリもいいのですが、欧米人の書いたやつはなんか表示のズレが多く、めんどくさいことが多い(大偏見)ので自分は

  • go-tty … キーコード入力ライブラリ
  • go-colorable … ANSIエスケープシーケンスエミュレーター
  • go-runewidth … 文字がコンソールで何セル使用するかを得る

という三種の神器的なライブラリでやることが多いです。この3セットで書いてたら、普通に Windows / Linux 双方対応でき、お手軽に OS への依存性を除くことができるので、自分は重宝しています。

が、それでも画面崩れるんですよね。ツイッタクライアントで画面を見てると、たまに1行に文字を出力しすぎて画面がスクロールしてしまう。調べてみると、一部の文字が原因。そう文字幅が想定と違うんです。

一例をあげると、たとえば「✧」(U+2727 : WHITE FOUR POINTED STAR)。これ、Unicode の規格では1セル分扱いのハズなんですが

自宅のWindows10でのスクリーンショット
image.png
→ 1セル分しかカーソルが移動しない(%U+nnnn% は nyagos の拡張機能)

会社のWindows7でのスクリーンショット
image.png
→ 2セル分カーソルが移動している

環境によって表示した時のカーソル移動量が違うんですね。これはどうしたものかと思っていたら、先生から啓示が出ました「フォントです」「フォントですか」。つまり、Windows 7だからこの幅、10だからこの幅みたいな判断もできない。

ここに Windowsコマンドプロンプト向けのバッタもん runewidth( go-lunewhydos という名前まで考えてた)開発計画は一旦頓挫したのでした。

ならば、端末ごとにデータベース作ればいいんじゃね?

いわゆる「キャリブレーション」というワードが脳内にポップアップしました。昔、ブラウン管のモニターの表示を調整するのに、ユーザオペレーションで画面位置を調整していましたし、今でも PS4 のゲームとかで表示調整する時に「龍がギリギリ見えるところを選択してください」みたいなことをやりますよね。ユーザ操作まではいらないけども、端末ごとに1アクションして、結果をデータベースとして残すようにすればよいのです。

ということで、Unicode での公式幅と違う幅の文字を検出し、それを参照するライブラリ go-termgap (端末のギャップ)を作成しました。

原理

  • \r +目的の文字」を表示してから、カーソル位置を調べる」ということを全Unicode文字に対して行う
    • ただし、サロゲートペアな文字については、そもそも Windows のコマンドプロンプトでは表示できないので、省いてよい

わかりやすいな

どないして、カーソル位置を取得すんねん

Goから OS の API を呼ぶというと、一般には import "cgo" を使うことが普通とされています。ですが、これ C言語が標準装備された UNIX ではよいのですが、Windows では要件が増えてビルドするための敷居があがってしまいます。

Windows の場合、それよりも "syscall" を使って、DLL をロードして、そちらから API を呼び出すのがよいでしょう。これならビルド要件はあがりません。

で、さらに最近は、そこまでせずとも、実は "golang.org/x/sys/windows" に既に多くの DLL 関数が定義されていて、そこに定義されていたら、それを使うだけで済んでしまいます。

それを使って実装してみたのが、こちら:

func X()(int,error){
    handle, err := windows.GetStdHandle(windows.STD_ERROR_HANDLE)
    if err != nil { return 0 ,err }

    var buffer windows.ConsoleScreenBufferInfo
    windows.GetConsoleScreenBufferInfo(handle, &buffer)
    return int(buffer.CursorPosition.X),nil
}

さて、この文字幅計測関数を、サロゲートペアになっていない全ユニコード(※)で実際に動かしてみましょう!

demo.gif

思ったほど時間がかかりませんでした。1分もかかってない。最初に1回やっておくだけでいいならば十分許容範囲です。これをどこかに保存しておけばよいわけです。普通に "encoding/json" でも使っておきましょうか。

これをどこにおくか…ですが、Go言語にはユーザ向けのディレクトリを得る関数が3つ用意されています。

func os.UserCacheDir() (string, error)
func os.UserConfigDir() (string, error)
func os.UserHomeDir() (string, error)

Windows では、

  • os.UserCacheDir()%USERPROFILE%\AppData\Local
  • os.UserConfigDir()%USERPROFILE%\AppData\Roaming

となっていることが多いようです。Roaming フォルダーは、例えば PC を移行した時にユーザに紐づく情報としてもってゆくデータを入れる場所らしいです。こんな端末に紐付いたデータは置くべきではないので、Local の方にフォルダーを掘って、そこに保存することにしましょう
( → %USERPROFILE%\AppData\Local\nyaos_org\termgap.json とした)

※(2020/1/3追記)Windowsのフォルダーの使いみちについて詳しい記事がありました。→ Windowsのディレクトリ構成ガイドライン - torutkのブログ

ライブラリ利用編

では、作ったライブラリを利用してみましょう!

    db, err := termgap.New()
    if err != nil {
        return err
    }
    w, err := db.RuneWidth('\u2727')
    if err != nil {
        return err
    }
    fmt.Printf("[\u2727]'s width=%d.\n", w)

めんどくさ! いちいち、インスタンス作らなきゃいけないのかよ!しかも、文字幅データベース(JSON ファイル)が作成されてなかったら、New() でエラー!?つかえん!

というわけで、ラッパーライブラリを作りました。これならどうでしょう。

import (
    "fmt"
    "github.com/zetamatta/go-termgap/hybrid"
)

func main() {
    fmt.Printf("[A]'s width=%d\n", hybrid.RuneWidth('A'))
    fmt.Printf("[\u2727]'s width=%d\n", hybrid.RuneWidth('\u2727'))
}

はい、インラインで使えますね。これなら許容範囲です。

でも、文字幅データベースが作られてない環境だとどうなるんだ?Panic ?

ノンノン、Panic など愚の骨頂。文字幅データベースがないなら、かわりにどっかからデフォルト値をもってくればよいのです。たとえば人様のライブラリを使って…(悪い顔)

※ だから、パッケージ名が hybrid なんです、はい

なお、Linux の場合ですが、キャリブレーションの方はともかくとして、データベースを利用する側は JSON ファイルが見つからず、普通に go-runewidth にぜんぶ丸投げするだけなので、特にビルド上の問題などはありません。

以上、人様のライブラリにおんぶにだっこしたい同志各位のご参考になれば幸いです。

12
3
0

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
12
3