hkoba blog

プログラマーです。プログラミング言語ミーハーです。よろしくどうぞ(能代口調)

snit で Tcl/Tk が少し楽に書けようになるのでご紹介

この記事は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/TkTcl/Tk入門はじめようWidget の節がとっつきやすくて良いでしょう。英語で良ければ Tcler's Wiki も貴重な情報源です。

目次

  1. はじめに - 今さら何に Tcl/Tk を使うのか
  2. snit とは
  3. 生の Tcl/Tk で GUI を作ると、何が辛いか
  4. snit で書くと、どう改善されるか
  5. 最後に

はじめに - 今さら何に 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 を組む時に悩むこと

  1. GUI のレイアウト・構造を改良しようとした時に、コード変更の影響範囲を局在化するのが面倒
  2. 名前空間をデータコンテナとして使う必要が有り、その規約を考えるのが面倒。

snit はこの2つ両方に役立ちますが、今回は前者に焦点を当てて解説します。

v1. 最初は widget path 直書きでも苦にならない

はじめに、 ls コマンドを実行するボタンと、その実行結果を表示するテキスト領域だけからなるスクリプトを書いてみます。

f:id:hkoba501:20171209202808p:plain

至って平凡な、雑な GUI wrapper です。

#!/usr/bin/wish

# ボタンを作る
pack [button .b1 -text "Push Me!" -command [list Run ls -C]]

# テキスト領域を作る
pack [text .console -height 8] -fill both -expand yes

# callback 手続きを実装する
proc Run args {
    .console insert end [exec {*}$args]\n
    .console see end
}

v2. widget path 直書きではレイアウト変更が辛くなる。どうするか。

さて、このツールのテキスト領域にスクロールバーを足したくなりました。ここでは BWidgetScrolledWindowを用います。

f:id:hkoba501:20171209202816p:plain

コードはどう変わるでしょうか? .console を呼び出していた個所を全て .sw.console に書き換える必要がありました。 (問題が目立つよう、 敢えて変数を使わずに 書きます)

#!/usr/bin/wish

# ボタンを作る
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

# callback 手続きを実装する
proc Run args {
    .sw.console insert end [exec {*}$args]\n
    .sw.console see end
}

GUI ツールにとってレイアウト変更は茶飯事なので、レイアウト変更に伴うコードの変更をどのように管理するかは重要です。

(snit などの OOP ライブラリを使わない) 古典的な Tcl/Tk アプリでは、

  1. (callbackである) Run の引数仕様を変え、第一引数に widget path も渡すようにする
  2. global 変数を使う

の二通りの解決策があります。

引数仕様を変える方法

Run の引数にテキスト領域を渡すためには、widget の作成順序を変えて、ボタンを後から作る必要があります。その影響で packレイアウトのオプションも変更する必要も生じます。

(※本当は、この程度のケースでは作成と widget path の決定を分離して、作成順序自体は変えない、という方法も有ります。ですが、その方法が常に使えるとは限らないですし、読みにくくもなるので…)

#!/usr/bin/wish

# テキスト領域を作る(スクロールバー付き)
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

# callback 手続きを実装する
proc Run {myConsole args} {
    $myConsole insert end [exec {*}$args]\n
    $myConsole see end
}

このように、コードの変更量は決して少なくありません。しかも、Run が参照する widget が増える度に、引数仕様も変わることになります。

global 変数を使う方法

前節で見たように、引数として widget path を渡す方法はコードの変更量が多くなりがちなため、(部品として使うことを意図しない)tcltk アプリケーションでは、私の主観では global変数を用いて widget path の抽象化を行うものが主流のようです。

ここではテキスト領域を myConsole という名前の global 変数 に格納することにします。

#!/usr/bin/wish

# ボタンを作る
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

# callback 手続きを実装する
proc Run args {
  global myConsole
  $myConsole insert end [exec {*}$args]\n
  $myConsole see end
}

最初のバージョンとの差は、比較的少なめと言えるでしょう。

ただし、 global 変数と聞いて身構える人も多いかもしれません。その予感はとても正しいものです。地獄へようこそ。

v3. 部品を増やす度に、各手続きにも global 宣言が増えていく

次はコマンドの成功・失敗をボタンの近くにラベルで表示するようにしてみましょう。(ボタンとラベルはツールバー風に横向きに並べたいので、フレームを作ってその中にボタンとラベルを置くことにします)

f:id:hkoba501:20171209202823p:plainf:id:hkoba501:20171209202830p:plain

このコードで注目するべき点は Run callback の実装の先頭に並ぶ global 宣言です。もし callback が Run2, Run3 ... と増えた場合、それぞれに同様の global 宣言を並べる必要が有ります。極端に言えば callback の個数 x 参照する widget の個数だけ、global 宣言を書く必要が有るのです。嫌ではないですか?私は嫌です。

#!/usr/bin/wish

# ツールバー用のフレームを作る
pack [set bf [frame .bf]] -fill x

# OK/NG を出すラベルを作る
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

# callback 手続きを実装する
proc Run args {
    # XXX: 各 callback に、参照する widget 全ての global 宣言が必要になる→増えると大変
    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 で与えます。

#!/usr/bin/wish

# snit をロードする
package require snit

# 自作widget `myapp` を定義する
snit::widget myapp {

    # テキスト領域を保持するためのインスタンス変数を宣言する
    variable myConsole

    # widget の実体化の際に呼ばれる手続きを実装
    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
    }

    # callback メソッドを実装する
    method Run args {
        $myConsole insert end [exec {*}$args]\n
        $myConsole see end
    }
}

# 自作 widget `myapp` を .win として実体化・レイアウト
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

        # OK/NG を出すラベルを作る
        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
    }

    # callback メソッドを実装する
    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 自体は複数個実体化することが可能になっています。後は実行するコマンドをインスタンス毎にオプションで設定できるようにするだけです。

f:id:hkoba501:20171210135045p:plain

snit::widget にオプションを追加するには option 宣言を使います。

snit::widget myapp {

    # widget オプションの宣言。 options(-exec) でアクセス可能
    option -exec [list ls -C]
    ...

    constructor args {
        ...

        # 実体化時の引数列を options 配列へ代入
        $self configurelist $args
    }

    # callback メソッドを実装する
    method Run args {

        # 引数が渡されない時はオプション `-exec` の内容を用いる
        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]} {

    # このファイルを実行した時だけ、GUI を実体化
    ...

} else {
    # source した時は実行しない。
}

コード全体

#!/usr/bin/wish

package require snit
package require BWidget

snit::widget myapp {

    # widget オプションの宣言。 options(-exec) でアクセス可能
    option -exec [list ls -C]

    # ここで宣言した変数は全メソッドでアクセス可能に
    variable myOkNg
    variable myConsole

    constructor args {
        # ツールバー用のフレームを作る
        pack [set bf [frame $win.bf]] -fill x

        # OK/NG を出すラベルを作る
        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

        # 実体化時の引数列を options 配列へ代入
        $self configurelist $args
    }

    # callback メソッドを実装する
    method Run args {

        # 引数が渡されない時はオプション `-exec` の内容を用いる
        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]} {

    # このファイルを実行した時だけ、GUI を実体化
    # 起動時の引数として渡されたディレクトリ毎に、タブを作る

    # global 変数を無駄に増やさないため, apply を使う
    apply {{{dir "/"} args} {
        
        # タブの親
        pack [ttk::notebook .win] -fill both -expand yes
        
        foreach dir [list $dir {*}$args] {

            # タブの中に `myapp` を実体化
            .win add [myapp .win.n[incr i] -exec "ls -C $dir"] -text "$dir "
            # XXX: タブの最後の文字が隠れるケースが有ったので、末尾にスペースを足した:-<
        }

    }} {*}$::argv

} else {
    # source した時は実行しない。
}

最後に

本当は html + css で Tcl/Tk を書く方法とか、 windows だと X11 を使う代わりに putty と sshcomm の組み合わせを使う方法の話とかも有るのですがそれは別の機会に、ということで…

こんな長い記事に最後までお付き合い頂き、ありがとうございました。