この記事はOSS紹介 Advent Calendar 2017 の 13日目の記事です。
qiita.com
概要
snit という OOP ライブラリを使えば Tcl/Tk のプログラミングも大分マシになる…そういう話を書きます。(今どき Web じゃないの? Electron じゃないの?というツッコミは無視します ;-) 古臭い Tcl/Tk 製 GUI wrapper を snit で書き直すとどうなるか、具体的なコードを挙げてご紹介します。(紹介記事なのに長すぎてすみません) 記事のコードは Tcl 8.5 以上を前提としますが、 snit 自体はそれ以前の Tcl/Tk でも利用可能です。ソースは gist にもあります。
なお、ここでは Tcl/Tk 自体の解説は省略しますが、日本語だと もっと Tcl/Tk のTcl/Tk入門 、はじめよう、 Widget の節がとっつきやすくて良いでしょう。英語で良ければ Tcler's Wiki も貴重な情報源です。
目次
- はじめに - 今さら何に Tcl/Tk を使うのか
- snit とは
- 生の Tcl/Tk で GUI を作ると、何が辛いか
- snit で書くと、どう改善されるか
- 最後に
はじめに - 今さら何に Tcl/Tk を使うのか
社内の Unix サーバー上の CLI ツールに GUI を用意して、あまり Unix に慣れていない普通の社員でも簡単に・間違えずに呼び出せるようにしたい、という需要は、しばしば有るでしょう。また、Unix に慣れた人にとっても、作業の種類によっては、GUI が助けになるケースが有ります。
もし GUI の提供を受ける対象ユーザーが社外の顧客であったり、社員のみであっても人数が多い場合は、 Web ブラウザで操作出来るツールにするのがベストでしょう。ただし Web から使用できるようにする場合は認証の実装がそれなりに工数を食うのではないでしょうか。その上、その Web アプリが (基本的に) 全部同じユーザ権限で動作することになるので、 Unix 側でのユーザー管理を活かせない、という点も残念です。(suexec するならともかく)
それに対して GUI の提供相手が全て社内の人間で、その Unix サーバーに SSH なりでログインするアカウントを持っているケースであれば、別の選択肢も候補に入ってきます。SSH の ForwardX11 や RemoteDesktop です。
GUI を書く時に SSH ForwardX11 や RDP を使う最大のメリットは、認証とユーザ・権限管理のコードを自分で書く必要が無いことです。(それらは Unix へのログインで済んでいる) ですから、真に書きたい処理に集中することが出来ます。
そのようなユースケースで、特に GUI の見栄えを要求されないケースについては、一つの選択肢として Tcl/Tk は今でも検討に値するのではないでしょうか?
描画の応答性についても、LAN であれば X11 はまだまだ使える速度が出ます。特に Tcl/Tk の質素(!)な GUI であれば、十分な速度が出るでしょう。
.oO(…余談ですが、 VS Code とか Gnome のファイラーを ForwardX11 すると、LAN 環境でも異様に遅い気がします。何とかならんのでしょうか…特に Mac の XQuartz や Windows の MobaXterm からだと遅さが特に…)
snit とは
snit とは Tcl/Tk で委譲(delegation)に基づくオブジェクト指向プログラミングを行うためのライブラリです。Pure Tcl で書かれているため、Tcl/Tk が導入済みならすぐに使えます。古くから tcllib に同梱されているので、apt や yum/dnf でも簡単にインストール出来ます。snit を詳しく学びたい方はチュートリアル形式の公式 FAQ snitfaq を参照して下さい。
抄訳(抜粋)ですが、snitfaq の What is Snit を訳してみます。
Snit は抽象データ型と megawidget※を定義するための、pure Tcl で書かれたフレームワークです…Snit の一番の目的は、オブジェクトどうしのグルー(糊)になることです…Snit は理論的な純粋さやコードの短さをめざしたものではありません。強力なことを簡単かつ一貫性の有る形で書くことを、それについて深く悩むことなく出来るようにすること、そうして自分のアプリケーションを作ることに集中できるようにすることが目的です。…
(※. widget を組み合わせて作った widget を指す tcltk 用語)
生の Tcl/Tk で GUI を作ると、何が辛いか
Tcl/Tk で GUI を組む時に悩むこと
- GUI のレイアウト・構造を改良しようとした時に、コード変更の影響範囲を局在化するのが面倒
- 名前空間をデータコンテナとして使う必要が有り、その規約を考えるのが面倒。
snit はこの2つ両方に役立ちますが、今回は前者に焦点を当てて解説します。
v1. 最初は widget path 直書きでも苦にならない
はじめに、 ls
コマンドを実行するボタンと、その実行結果を表示するテキスト領域だけからなるスクリプトを書いてみます。
至って平凡な、雑な GUI wrapper です。
pack [button .b1 -text "Push Me!" -command [list Run ls -C]]
pack [text .console -height 8] -fill both -expand yes
proc Run args {
.console insert end [exec {*}$args]\n
.console see end
}
v2. widget path 直書きではレイアウト変更が辛くなる。どうするか。
さて、このツールのテキスト領域にスクロールバーを足したくなりました。ここでは BWidgetの ScrolledWindowを用います。
コードはどう変わるでしょうか? .console
を呼び出していた個所を全て .sw.console
に書き換える必要がありました。 (問題が目立つよう、 敢えて変数を使わずに 書きます)
pack [button .b1 -text "Push Me!" -command [list Run ls -C]]
package require BWidget
ScrolledWindow .sw
.sw setwidget [text .sw.console -height 8]
pack .sw -fill both -expand yes
proc Run args {
.sw.console insert end [exec {*}$args]\n
.sw.console see end
}
GUI ツールにとってレイアウト変更は茶飯事なので、レイアウト変更に伴うコードの変更をどのように管理するかは重要です。
(snit などの OOP ライブラリを使わない) 古典的な Tcl/Tk アプリでは、
- (callbackである) Run の引数仕様を変え、第一引数に widget path も渡すようにする
- global 変数を使う
の二通りの解決策があります。
引数仕様を変える方法
Run の引数にテキスト領域を渡すためには、widget の作成順序を変えて、ボタンを後から作る必要があります※。その影響で packレイアウトのオプションも変更する必要も生じます。
(※本当は、この程度のケースでは作成と widget path の決定を分離して、作成順序自体は変えない、という方法も有ります。ですが、その方法が常に使えるとは限らないですし、読みにくくもなるので…)
package require BWidget
ScrolledWindow .sw
set myConsole [text .sw.console -height 8]
.sw setwidget $myConsole
pack .sw -fill both -expand yes\
-side bottom
pack [button .b1 -text "Push Me!" -command [list Run $myConsole ls -C]]\
-side top
proc Run {myConsole args} {
$myConsole insert end [exec {*}$args]\n
$myConsole see end
}
このように、コードの変更量は決して少なくありません。しかも、Run が参照する widget が増える度に、引数仕様も変わることになります。
global 変数を使う方法
前節で見たように、引数として widget path を渡す方法はコードの変更量が多くなりがちなため、(部品として使うことを意図しない)tcltk アプリケーションでは、私の主観では global変数を用いて widget path の抽象化を行うものが主流のようです。
ここではテキスト領域を myConsole
という名前の global 変数 に格納することにします。
pack [button .b1 -text "Push Me!" -command [list Run ls -C]]
package require BWidget
ScrolledWindow .sw
set myConsole [text .sw.console -height 8]
.sw setwidget $myConsole
pack .sw -fill both -expand yes
proc Run args {
global myConsole
$myConsole insert end [exec {*}$args]\n
$myConsole see end
}
最初のバージョンとの差は、比較的少なめと言えるでしょう。
ただし、 global 変数と聞いて身構える人も多いかもしれません。その予感はとても正しいものです。地獄へようこそ。
v3. 部品を増やす度に、各手続きにも global 宣言が増えていく
次はコマンドの成功・失敗をボタンの近くにラベルで表示するようにしてみましょう。(ボタンとラベルはツールバー風に横向きに並べたいので、フレームを作ってその中にボタンとラベルを置くことにします)
このコードで注目するべき点は Run
callback の実装の先頭に並ぶ global
宣言です。もし callback が Run2
, Run3
... と増えた場合、それぞれに同様の global 宣言を並べる必要が有ります。極端に言えば callback の個数 x 参照する widget の個数だけ、global 宣言を書く必要が有るのです。嫌ではないですか?私は嫌です。
pack [set bf [frame .bf]] -fill x
pack [set myOkNg [label $bf.l1 -text "OK"]] -side left
pack [button $bf.b1 -text "Push Me!" -command [list Run ls -C]] -side left
package require BWidget
ScrolledWindow .sw
set myConsole [text .sw.console -height 8]
.sw setwidget $myConsole
pack .sw -fill both -expand yes -side top
proc Run args {
global myConsole
global myOkNg
set err [catch {exec {*}$args} result]
$myConsole insert end $result\n
$myConsole see end
if {$err} {
$myOkNg configure -text "NG" -background red
} else {
$myOkNg configure -text "OK" -background gray85
}
}
global 頼みだと複数インスタンスを作れない
global を使う抽象化の、実用上の最大の問題がこれです。例えばこの GUI を タブ
で複数個、同時に使えるようにしたい!と思った時、全く打つ手がありません。
snit で書くと、どう改善されるか
では実際に、 v1〜v3 のコードをそれぞれ snit で書き直した上で、 v4 としてタブ化にも取り組んでみます。
v1. ボタンとテキスト領域のみ
ここでは snit の snit::widget コマンドを使って、 myapp
という名前の自作 widget として GUI 自体を定義します。snit::widget は何個でもインスタンスを生成することが出来るので、タブ化したい!といった欲求にも自然に対応できます。snit::widget の {...}
の中には method 定義や variable 宣言、option 宣言などを書くことが出来ます。
global 宣言の代わりになるものは、 snit::widget 定義の中の variable 宣言です。 variable 宣言を行った変数は、全ての method の中で参照出来るようになります。
constructor はこの widget を実体化する時に一度だけ呼ばれる手続きです。手っ取り早くは GUI の構築コードをここに入れてしまえば良いでしょう。constructor の中で自分自身を参照するには $self
を使います。ただし widget path だけは $win
を使う必要が有ります。
callback の呼び出し方は Run ls -C
の代わりにメソッドとして $self Run ls -C
のようにします。その実装は snit::method で与えます。
package require snit
snit::widget myapp {
variable myConsole
constructor args {
pack [button $win.b1 -text "Push Me!" -command [list $self Run ls -C]]
set myConsole [text $win.console -height 8]
pack $myConsole -fill both -expand yes
}
method Run args {
$myConsole insert end [exec {*}$args]\n
$myConsole see end
}
}
pack [myapp .win] -fill both -expand yes
v2. スクロールバーを追加
生 Tcl/Tk の時と同様に BWidget の ScrolledWindow を使ってスクロールバーを加えてみます。ですが、既に snit::widget の基本の書き方に従って記述してあったので、変更は constructor 内のみで済みます。
constructor args {
pack [button $win.b1 -text "Push Me!" -command [list $self Run ls -C]]
package require BWidget
set sw [ScrolledWindow $win.sw]
pack $sw -fill both -expand yes
set myConsole [text $sw.console -height 8]
$sw setwidget $myConsole
}
v3. OK/NG ラベルを追加
成功・失敗を表示するためのラベルをメンバー変数 myOkNg
にすれば、先程の global 地獄は解消出来ます。
snit::widget myapp {
variable myOkNg
variable myConsole
constructor args {
pack [set bf [frame $win.bf]] -fill x
pack [set myOkNg [label $bf.l1 -text "OK"]] -side left
pack [button $bf.b1 -text "Push Me!" -command [list $self Run ls -C]] -side left
package require BWidget
set sw [ScrolledWindow $win.sw]
pack $sw -fill both -expand yes
set myConsole [text $sw.console -height 8]
$sw setwidget $myConsole
}
method Run args {
set err [catch {exec {*}$args} result]
$myConsole insert end $result\n
$myConsole see end
if {$err} {
$myOkNg configure -text "NG" -background red
} else {
$myOkNg configure -text "OK" -background gray85
}
}
}
v4. タブ化
最後に、生の tcltk + global 路線ではたどり着けなかったタブ化です。既に myapp
自体は複数個実体化することが可能になっています。後は実行するコマンドをインスタンス毎にオプションで設定できるようにするだけです。
snit::widget にオプションを追加するには option 宣言を使います。
snit::widget myapp {
option -exec [list ls -C]
...
constructor args {
...
$self configurelist $args
}
method Run args {
if {$args eq ""} {
set args $options(-exec)
}
...
myapp に -exec
オプションを渡せるようになったので、タブ毎に異なるコマンドを実行させることが出来ます。
pack [ttk::notebook .win] -fill both -expand yes
.win add [myapp .win.n1 -exec "ls -C /"] -text "/ "
.win add [myapp .win.n2 -exec "ls -C /home"] -text "/home "
...
おまけとして、この例では、このファイル自体を直接コマンドとして起動することも、ライブラリとして別の Tcl/Tk スクリプトに読み込んで使うことも出来るようにしてあります。
if {![info level] && $::argv0 eq [info script]} {
...
} else {
}
コード全体
package require snit
package require BWidget
snit::widget myapp {
option -exec [list ls -C]
variable myOkNg
variable myConsole
constructor args {
pack [set bf [frame $win.bf]] -fill x
pack [set myOkNg [label $bf.l1 -text "OK"]] -side left
pack [button $bf.b1 -text "Push Me!" -command [list $self Run]] -side left
set sw [ScrolledWindow $win.sw]
pack $sw -fill both -expand yes
set myConsole [text $sw.console -height 8]
$sw setwidget $myConsole
$self configurelist $args
}
method Run args {
if {$args eq ""} {
set args $options(-exec)
}
set err [catch {exec {*}$args} result]
$myConsole insert end $result\n
$myConsole see end
if {$err} {
$myOkNg configure -text "NG" -background red
} else {
$myOkNg configure -text "OK" -background gray85
}
}
}
if {![info level] && $::argv0 eq [info script]} {
apply {{{dir "/"} args} {
pack [ttk::notebook .win] -fill both -expand yes
foreach dir [list $dir {*}$args] {
.win add [myapp .win.n[incr i] -exec "ls -C $dir"] -text "$dir "
}
}} {*}$::argv
} else {
}
最後に
本当は html + css で Tcl/Tk を書く方法とか、 windows だと X11 を使う代わりに putty と sshcomm の組み合わせを使う方法の話とかも有るのですがそれは別の機会に、ということで…
こんな長い記事に最後までお付き合い頂き、ありがとうございました。