おもこん

おもこんは「思いつくままにコンピュターの話し」の省略形です

徒然Ruby(37)Ruby/GTK4

Ruby/Gtkの記事を先日書いたときに、「これはかなり使える」という手応えを感じたので、WordBook(Railsで作った単語帳プログラム)のGTK 4版を作りました。 プログラムは「徒然なるままにRuby」のGitHubレポジトリに置いてあります。

レポジトリをダウンロードし、ディレクト_example/word_book_gtk4をカレントディレクトリにして、ruby wordbook.rbを実行してください。

今回の記事はRuby/GTK4の使い方に関するものです。 ただし、私自身Ruby/GTK4を良く分かっているわけではないので、内容に誤りがあるかもしれません。 この記事をもとにプログラムする場合は自己責任で行ってください。 それによる損害については何の保証もできないことをあらかじめご了承ください。

Ruby/Gtk4の完成度

Ruby/Gtk4を使ってみて、完成の域にあると感じました。 一般にはGTK 4のRubyへのバインディングは開発中という印象があると思いますが、そうではありません。 もっと、Ruby/GTK4を世界にアピールすべきだと思います。

Ruby/GTK4はgemで提供されます。

$ gem install gtk4

これでgemがインストールされます。

今回の単語帳プログラムでは、GTK 4で追加された「GtkColumnView」を試してみました。 結果、見事に動作しました。 素晴らしい!!

この記事でプログラムを全て紹介するのは、量的に無理があります。 そこで、プログラムをご覧になりたい方は、「徒然なるままにRuby」のレポジトリ_example/word_book_gtk4を参照してください。 ここでは、RubyでGtk4を使うためのポイントを書きます。 それでも相当長い記事になりますが、ご容赦ください。

GTKオブジェクト指向

GTKC言語で書かれたライブラリですが、オブジェクト指向で書かれており、Ruby同様にクラスとインスタンスがあります。 クラスには親子関係があり、ほとんどのクラスのトップ(ファンダメンタルという)はGObjectです。 「ほとんど」と書いたのは、GObject以外にもファンダメンタルなクラスがあるからで、しかもそれらも良く使われます。

このオブジェクト指向のプログラミングに関するドキュメントはGObject APIリファランスです。 これは結構難しいです。 それよりは分かりやすいチュートリアルGObject tutorial for beginnersがあります。 このへんが理解できていると、GTKもより確かなものにできます。

GtkApplicationの書き方

GtkApplicationはアプリケーションのクラスです。 Rubyのクラスと同様に、このクラスのインスタンスを作り、それを走らせることによってアプリケーションが動き出します。

require 'gtk4'
application = Gtk::Application.new("com.github.toshiocp.wordbook", :default_flags)
application.run
  • 第1引数はアプリケーションIDでユニークな文字列でなければならない。URIと逆順のパターンで書く
  • 第2引数はアプリケーションフラグ。通常は「:default_flag」でよい

上記のプログラムはウィンドウが無いためにアプリケーションはすぐに終了してしまいます。 実行しても何も起こらないのはそのためで、エラーではありません。 ウィンドウの設定は後ほど述べます。

アプリケーションが起動されると(runメソッドが実行されると)startupとactivateというシグナルが発せられます。

startupはGtkApplicationのサブクラスを定義してクラスメソッド(バーチャル関数)を書き換えるときに使います。 GtkApplicationをそのまま使うときはほとんど必要ないと思います。

activateシグナルのハンドラでは

などをします。 Ruby/GTKでは、ハンドラはsignal_connectメソッドのブロックで表します。 runする前に定義しておくことが必要です。 次のプログラムの「... ... ...」の部分にsignal_connectのハンドラを記述します。

require 'gtk4'
application = Gtk::Application.new("com.github.toshiocp.wordbook", :default_flags)
application.signal_connect "activate" do |app|
... ... ...
end
application.run

ブロックのパラメータappにはインスタンスapplicationが代入されます。

シグナル

ここで、シグナルについて説明しておきたいと思います。 シグナルはGObjectの持つ機能です。 その子孫クラスもすべてシグナルを定義することができます。

GTKでは複数のスレッドが非同期に動きます。 このスレッドはRubyのスレッドとは違います。 そして、GtkApplicationのrunメソッドが呼び出されてからリターンするまで様々なスレッドが動きます。 このスレッドは様々なイベント(例えばボタンがクリックされたとかウィンドウが閉じたなど)が起こるタイミングで起動されます。

イベントはすべてシグナルという形で伝えられます。 例えばボタンがクリックされたときには「clicked」という名前のシグナルが発せられます。 あらかじめclickedシグナルに特定のプログラム(ハンドラという)を結びつけておけば、シグナル発生時にそのプログラムが実行されます。 シグナルは、ユーザが作成したクラスに定義することもできますが、ここではそのトピックはとりあげません。

シグナルの使い方は次のような流れになります。

  • 各クラスがどのようなシグナルを持っているかをあらかじめ調べておく。例えばGtkButtonはclickedシグナルを持っている
  • シグナルが発せられたときに起動したいプログラム(ハンドラ)を考える
  • signal_connectメソッドでシグナルとハンドラを結びつける

ポイントはsignal_connectハンドラの使い方になります。

インスタンス.signal_connect シグナル名 do |インスタンス, ... ... |
  ハンドラ
end

この形で使います。 例えばボタン(このインスタンスが変数bに代入されているとする)がクリックされたときに"Hello"と標準出力に出力したければ

b.signal_connect "clicked" do
  print "hello\n"
end

というプログラムになります。 ブロックがハンドラになります。

ブロックの引数の第1引数は常にそのシグナルを発したインスタンスになります。 その後の引数がどうなるかは、GTK 4のドキュメントの、各クラスのシグナルの項目に書かれています。 例えばGtkDialogのresponseシグナルの項目を見ると ハンドラは次の形になっています。

void
response (
  GtkDialog* self,
  gint response_id,
  gpointer user_data
)

これはCの関数の形で書かれています。

  • 第1引数のselfはそのボタンのインスタンスのこと
  • 第2引数のresponse_idは、例えば、ダイアログのOKボタンが押されたとか、Cancelボタンが押されたとかの情報。 どのボタンにどのIDが割り振られるかはダイアログの定義時にプログラマーが決める
  • 第3引数のuser_dataは、すべてのシグナルのハンドラに出てくるが、Rubyでプログラムする場合はほとんど必要ない

例えばダイアログのインスタンスを変数dに代入していたとするとプログラムは次のようになります。

d.signal_connect "response" do |dialog, response|
  if response == Gtk::ResponseType::OK
    OKボタンが押されたときの処理
  elsif response == Gtk::ResponseType::CANCEL
    キャンセルボタンが押されたときの処理
  else
    それ以外の状態でダイアログが閉じたときの処理
  end
end

ダイアログの使い方としては、(1)ダイアログのインスタンスを生成(2)シグナルとハンドラを結合(3)ダイアログを表示、という順になります。

GtkApplicationWindowの書き方

アプリケーションのメインウィンドウにはGtkApplicationWindowクラスを使うのが良いです。 このクラスはGtkWindowのサブクラスで、GtkWindowよりアプリケーションのメインウィンドウに適した機能を持っています。

ただウィンドウを表示するだけなら、GtkApplicationWindowのインスタンスを生成し、showメソッドを呼び出せば良いです。

require 'gtk4'
application = Gtk::Application.new("com.github.toshiocp.wordbook", :default_flags)
application.signal_connect "activate" do |app|
  Gtk::ApplicationWindow.new(app).show
end
application.run

これでウィンドウが表示されます。

newメソッドにはアプリケーションのインスタンスappを渡すことを忘れないでください。 この引数により、アプリケーションとウィンドウのインスタンスが結び付けられます。 GtkApplicationWindowではなくGtkWindowを用いる場合もアプリケーションとの結びつけが必要なケースがあります。 例えばウィンドウのボタンとアプリケーションに設定したアクションオブジェクトを結びつけたいときは、これが必要になります。 GtkWindowのset_application(アプリケーション)メソッドでアプリケーションとウィンドウが結び付けられます。

ウィジェット

ウィンドウにはボタン、エントリー(入力枠)などを配置します。 ウィジェットとは画面に表示するウィンドウ、ボタン、エントリーなどを指します。 オブジェクトの考え方からいえば、ウィジェットはGtkWidgetクラスの子孫クラスのことになります。 今回作成した単語帳「wordbook」で使用したウィジェット

  • GtkApplicationWindow ⇒ メインウィンドウ
  • GtkBox ⇒ 複数のウィジェットを縦または横に配置するためのコンテナ
  • GtkButton ⇒ ボタン
  • GtkSearchEntry ⇒ 検索用の入力枠
  • GtkLabel ⇒ ラベル。文字列を表示するためのもの
  • GtkScrolledWindow ⇒ 内部に配置したウィジェットをスクロールする
  • GtkColumnView ⇒ 表
  • GtkColumnViewColumn ⇒ 表の中の一列
  • GtkWindow ⇒ 一般的なウィンドウ
  • GtkTextView ⇒ 複数行にわたるテキストを編集する

です。 GTK 4で用意されているウィジェットのサンプルはGTK 4のAPIリファランスのウィジェット・ギャラリーを参照してください。

UIファイル

ウィジェットを配置するのをプログラムでやるのは非常に手間がかかるので、代わりにUIファイルを使うのが良い方法です。 UIファイルはXML形式でウィジェットを表現します。 例えば、ウィンドウの中にボタンを配置するのは次のようにします。

<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <object id="window" class="GtkWindow">
    <child>
      <object id="button_append" class="GtkButton">
      </object>
    </child>
  </object>
</interface>
  • UIファイルの先頭にXMLの定義を書く(単にコピペすれば良い)
  • 最も外側のタグはinterfaceタグ
  • ウィジェットなどのオブジェクトはobjectタグで表す。そのclassアトリビュートがクラス名を表す。 idタグはUIファイル内でユニークなオブジェクト名で、後ほどGtk::Builderクラスのオブジェクトで利用する。 もしそこで使う予定がなければidを省略して良い
  • ウィンドウの内側にボタンを配置する場合は、childタグを用いてウィンドウのobjectタグ内にボタンのobjectタグを書く

この例のウィンドウとボタンのように、ウィジェットには親子関係が発生します。 外側のウィジェットが親、その中に配置されるウィジェットが子です。 この親子関係がつながると孫以下のウィジェットも出てきます。 このウィジェットの親子関係と、クラスの親子関係は全く別のものなので、混同しないようにしてください。

UIファイルではpropertyタグが頻繁に現れます。 これはオブジェクトのプロパティを設定するためのものです。 オブジェクトのプロパティはGTK 4のAPIリファランスに書かれています。 例えばGtkWindowのプロパティは、リファランスから

リファランス⇒class⇒Window⇒property

とたどります。 ここには多くのプロパティがありますが、例をあげると

  • title ⇒ ウィンドウの上部のバーに表示されるタイトル
  • default-width ⇒ ウィンドウのデフォルトの幅
  • default-height ⇒ ウィンドウのデフォルトの高さ

です。 title、default-widthなどの文字列はプロパティ名です。 プロパティはRubyのハッシュのように、キーと値の形式になっていて、キーが「プロパティ名」で値が「プロパティの値」です。 プロパティの値には文字列、数値、真偽値などがありますが、UIファイル内では(テキストファイルなので当然ながら)すべて文字列で表現します。

  • 文字列 ⇒ その文字列で表現
  • 数値 ⇒ その数値の文字列、例えば0、-1、100、1.23など
  • 真偽値 ⇒ true、false、yes、no、1、0など。大文字も可

これらの説明はGTK 4のAPIリファランスのBuilderのところに書かれています。 案外書き方には自由度があることがわかります。

UIファイルではプロパティは次のように書きます。

<object id="window" class="GtkApplicationWindow">
  <property name="title">単語帳</property>
  <property name="default-width">1200</property>
  <property name="default-height">800</property>
</object>

nameアトリビュートにはプロパティ名、タグで囲まれた部分にプロパティ値を書きます。

UIファイルでは主にobjectとpropertyタグを用いますが、他にどのようなタグがあるかを調べるのは少々複雑です。 GTK 4のAPIリファランスでは、次の場所を調べてください。

  • Gtkbuilder(Builderクラス)の説明
  • ウィジェットの説明の中で「クラス名 as GtkBuildable」の項目。 例えば、GtkDialogのその箇所を見ると、action-widgetタグが使えることが分かる。 ここで説明されているタグは、各ウィジェット固有のものになる

wordbookには2つのuiファイルがあり、それぞれ別のウィンドウを記述しています。 非常に長いUIファイルになっていますので、ここには書きません。 興味のある方は、この記事の最初に書いた方法でレポジトリをダウンロードしてソースファイルを見てください。

ビルダーの使い方

UIファイルからウィジェットインスタンスを生成するにはGtkBuilderを使います。 RubyではGtk::Builderクラスのインスタンスを生成するときにウィジェットインスタンスも生成されます。 そして、ウィジェットインスタンスを取り出すには[]メソッドを使います。

builder = Gtk::Builder.new(file: "wordbook.ui")
window = builder["window"]
button = builder["button_append"]

一行目でUIファイル「wordbook.ui」を読み込み、ウィジェットインスタンスを生成するとともに、Builderクラスのインスタンスを変数builderに代入します。 ウィジェットはビルダーインスタンス[]メソッドで取り出します。 このメソッドの引数はUIファイルのオブジェクトのidアトリビュートです。 idアトリビュートを書かなかったウィジェットを取り出すことはできません。

ビルダーで作成するウィジェットに対して頻繁に生成/消滅をする場合(例えばダイアログ)は、いちいちファイルにアクセスするのは時間がもったいないです。 ファイルを読み込んだ文字列に対してビルダーを使えば、ファイルの読み書きが無くなって効率が良くなります。

Edit_window_ui = File.read("edit_window.ui")
builder = Gtk::Builder.new(string: Edit_window_ui)

newメソッドの引数にstring:をつけると文字列からUIデータを取り込みます。 このstring:が何なのか疑問に思う人がいるかもしれません。 これはEdit_window_uiとセットになってハッシュを表しています。 Rubyのメソッドのきまりでは、引数の最後のハッシュは波括弧を省略することができます。 省略しなければ

Edit_window_ui = File.read("edit_window.ui")
builder = Gtk::Builder.new({string: Edit_window_ui})

または

Edit_window_ui = File.read("edit_window.ui")
builder = Gtk::Builder.new({:string => Edit_window_ui})

と表せます。

UIファイルにクロージャやシグナルハンドラが書かれていることがあります。 例えばwordbook.uiの160行にはクロージャがあります。

<closure type="gchararray" function="nl2sp">

シグナルハンドラについては、次のような例を考えてみましょう。 なお、このプログラムのファイルはレポジトリの_example/ruby_gtk4/ui_signal.rbです。

require 'gtk4'

ui_string = <<~EOS
<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <object id="window" class="GtkWindow">
    <child>
      <object class="GtkButton">
        <property name="label">Button</property>
        <signal name="clicked" handler="button_cb"></signal>
      </object>
    </child>
  </object>
</interface>
EOS

def button_cb(button)
  print "clicked\n"
end

application = Gtk::Application.new("com.github.toshiocp.test", :default_flags)
application.signal_connect "activate" do |app|
  builder = Gtk::Builder.new(string: ui_string)
  window = builder["window"]
  window.set_application(app)
  window.show
end
application.run

このUI文字列は、ボタンのclickedシグナルとbutton_cbハンドラをsignalタグで結びつけています。 このとき、ハンドラはトップレベルの同名メソッドになります。 例ではボタンがクリックされると、端末画面に「clicked」と表示されます。

このように、UIファイルまたはUI文字列のボタンオブジェクトにsignalタグを書いておけば、ハンドラのメソッドを書いておくだけですみ、signal_connectメソッドは要らなくなります。 このように、本体のプログラムの負担はますます軽くなり、UIファイルの比率がますます高くなっていきます。

アクションとアクセラレータ

アクションとはその名の通り「動作」「行動」のことです。 Gtkではアクションがアクティブになるとactivateシグナルが発せられます。 プログラマーはあらかじめactivateシグナルにハンドラを結びつけておきます。

では、どのようなときにアクションがアクティブになるのでしょうか?

  • メニューとアクションが結びついているときには、そのメニューをクリックすると対応するアクションがアクティブになる
  • アクセラレータ(Ctrl-Cのようなキーで、それがアクションに結びついたもの)により対応するアクションがアクティブになる
  • ボタンなどのウィジェットがアクションに結びついているとき、そのボタンがクリックされると対応するアクションがアクティブになる

一般にアクションに結びついているウィジェットはGtkActionableインターフェースをインプリメント(実装)しています。 GTK 4 API リファランスのGtkActionableを見ると、インプリメントされているウィジェットが書かれています。 ユーザが新たにウィジェットを定義し、GtkActionableをインプリメントすることも可能です。

ボタンのクリックにハンドラを対応させるにはclickedシグナルと結びつける方法と、アクションを介して結びつける方法があります。 そのハンドラの動作にアクセラレータやメニューも結びつけたいときはアクションを介するのが効率的です。 wordbookでは、「追加」「終了」「キャンセル」「削除」「保存」ボタンがアクションと結び付けられています。

アクションのスコープ、名前、ステート、ターゲット

アクションには「アプリケーション(app)」または「ウィンドウ(win)」というスコープがあります。 例えば「アプリケーションを終了させるアクション」はアプリケーションに関わるアクションなので、スコープをappにします。 「トップウィンドウをフルスクリーンにする/しない」というアクションはウィンドウに関わるアクションなので、スコープをwinにします。 多くのアクションはappスコープです。 appスコープのアクションはGtkApplicationに登録します。 winスコープのアクションはGtkApplicationWindowに登録します。

アクションは状態を保持することができます。 例えばフルスクリーンのon/offを切り替えるアクションは現在のスクリーンの状態を保持しています。 このような状態のことを「ステート」、状態を持っているアクションは「ステートフル」であるといいます。 また、ボタンの色を赤、黄、緑に変えるようなアクションでは、どの状態にするかのパラメータをつけることができます。 そのアクションの名前をcolored-buttonとし、赤、黄、緑に変えるときのパラメータをredyellowgreenとします。 そのとき、これらを組み合わせたものをdetailed name(詳しい名前)といい、colored-button::redcolored-button::yellowcolored-button::greenになります。 ::の後ろは、アクションが状態を変えるためのパラメータで、「ターゲット」といいます。 以上から、アクションは次の3つに分けて考えることができます。

  • ステートレス。状態を持たないアクション。単にハンドラを起動するだけ。
  • ステートフルでパラメータなし。例えばフルスクリーンの切り替えのようなトグル動作をするもの
  • ステートフルでパラメータあり。上のボタンの色を変えるアクションのようなもの

また、アクションにスコープをつけてapp.colored-button::redと書くこともあります。 スコープをつけるかどうかはそのアクションを書くときの場面によります。

多くのアクションはステートレスで、wordbookのアクションもすべてステートレスです。 この記事ではステートレス・アクションのみを取り上げます。 ステートフル・アクションについてはAPIリファランスなどを参照してください。

アクションの書き方

ここでは、ボタンをクリックしたときにアクションをアクティブにし、ハンドラを起動するプログラムを書いてみましょう。 アクションのクラスにはGSimpleActionとGPropertyActionがありますが、ほとんどの場合GSimpleActionを使うことになると思います。

次のプログラムでは「Hello」と表示されたボタンをクリックすると、端末画面にHelloが表示されます。 プログラムはレポジトリの_example/ruby_gtk4/action.rbにあります。

require 'gtk4'

ui_string = <<~EOS
<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <object id="window" class="GtkWindow">
    <child>
      <object class="GtkButton">
        <property name="label">Hello</property>
        <property name="action-name">app.hello</property>
        </object>
    </child>
  </object>
</interface>
EOS

def print_hello
  print "Hello\n"
end

application = Gtk::Application.new("com.github.toshiocp.test", :default_flags)
application.signal_connect "activate" do |app|
  builder = Gtk::Builder.new(string: ui_string)
  window = builder["window"]
  window.set_application(app)

  action = Gio::SimpleAction.new("hello")
  action.signal_connect("activate") do |_action, _parameter|
    print_hello
  end
  app.add_action(action)

  window.show
end
application.run
  • UI文字列のボタンにaction-nameプロパティの値を"app.hello"に設定する。 この値はスコープ付きのアクション名。これで、ボタンがクリックされたときに"app.hello"アクションがアクティブになる
  • アクションの定義はアプリケーションのactivateハンドラの中で行う。
    • helloという名前のアクションを生成(ここにはスコープは要らない)
    • アクションとハンドラ「print_hello」をsignal_connectメソッドでつなげる。 ブロックの2つのパラメータは使われていない。 それらは、アクション・インスタンスとアクション・パラメータ(ある場合のみ有効で、ない場合はnil)である
    • add_actionメソッドでアプリケーションにアクションを登録する。 これによって、アクションのスコープがアプリケーションであることが確定する
  • スコープがウィンドウであるアクションを追加するには、window.add_action(アクション名)を代わりに使う

アクションは通常5,6個は必要になると思います。 そのときは、Rubyの配列とeachコマンドを使うなどしてプログラムが大きくならないよう工夫してください。

アクセラレータの書き方

アクセラレータはキー入力とハンドラを結びつけたものです。 例えば「Ctrl-q」と押すとプログラムが終了するようなものです。

前のサブセクションで書いたアクションのプログラムにアクセラレータを加えてみましょう。 そのためには、アプリケーションのactivateハンドラの中(window.showよりは前のどこか)に

app.set_accels_for_action("app.hello", ["<Control>h", "<Alt>h"])

を加えます。 このメソッドの第1引数はスコープ付きアクション名、第2引数がキーの配列です。 この例のように、同一のアクションに複数のキーを割り当てることができます(たいていはひとつで十分ですが)。 ひとつのキーだけを割り当てるときにも第2引数は配列でなければならないことに注意してください。

コントロールキーは<Control><Ctrl><Ctl>などとします。 Altキーは<Alt>、シフトキーとコントロールキーを同時に押すときは<Shift><Control>などとします。 どのような記述が可能かについてはgtk_accelerator_parseを参考にしてください。

アクセラレータを加えたプログラムはレポジトリの_example/ruby_gtk4/accel.rbにあります。

CSSの使い方

GTK 4ではウィジェットの「見た目」をCSSCascading Style Sheetsスタイルシートともいう)で指定することができます。 CSSはウェブで用いられてきましたが、原理的にはGTK 4のようなGUIにもインプリメントできるものです。 どのようなスタイルシートが適用できるかは、APIリファランスを参照してください。

セレクタ

CSSを適用するにはセレクタが必要です。 GTK 4ではノード、オブジェクト名(インスタンス名)、スタイルクラス(文脈からCSSのクラスを指していることが明らかな場合は単に「クラス」ということもある)がセレクタを構成します。

ノード名とクラス名はAPIリファランスの各ウィジェット・クラスの説明の「CSS nodes」のところに書かれています。 例えば、GtkButtonのノード名は「button」です。 また、スタイルクラスには「image-button」や「text-button」などがあります。 ボタンは画像を貼り付けたボタンと、文字列(ラベル)を貼り付けたボタンに分けることができ、さきほどのクラスはそれぞれのボタンのグループを表します。 クラスはノードの後ろにドット(.)で区切って記述します。

button.text-button

このセレクタは、ボタンのうち「文字列を書いたボタン」のみを対象にします。 また、独自のスタイルクラスをオブジェクトに追加するにはadd_css_class(クラス名)メソッドを使います。

オブジェクト名はそのウィジェットのnameプロパティに設定されている文字列です。 それを設定するには、UIファイルでnameプロパティを指定するか、プログラム中でname=メソッドを使います。 異なるオブジェクトに同じ名前をつけることはできません。 オブジェクト名はウェブのCSSでいえば、IDにあたります。 オブジェクト名はノード名の後ろにナンバー記号(#)で区切って記述します。

button#delete_button

これは「delete_button」という名前をもったボタンを指します。 対象になるボタンは唯ひとつです。

ウェブのセレクタの構文でGTK 4でも使えるものがあります。

  • * ⇒ 任意のノード
  • box button ⇒ GtkBoxの子孫ウィジェットのGtkButton
  • box > button ⇒ GtkBoxの子ウィジェットのGtkButton。直下の子のみで孫以下は入らない

この他にもありますが、詳細はAPIリファランスを参照してください。

CSS プロパティ

CSSプロパティはセレクタに続いて波括弧{}で囲んで指定します。 例えばボタンの色(文字色)を赤にしたいときは

button {color; red;}

とします。 このあたりはウェブのCSSと同じです。 詳細はAPIリファランスを参照してください。

CSSの適用方法

CSSは個々のウィジェットに適用する方法とアプリケーション画面全体に適用する方法がありますが、ここでは後者を説明します。

css_text = "button {color: blue;}"
provider = Gtk::CssProvider.new
provider.load_from_data(css_text)
Gtk::StyleContext.add_provider_for_display(window.display, provider, :user)

このプログラムでは、CSS文字列をcss_text変数に代入しています。 このケースでは短い文字列なので、3行目のload_from_dataメソッドの引数に直接代入しても構いません。 2行目でGtkCssProviderのインスタンスを生成し、3行目でCSS文字列をセットしています。 最終行の変数windowはトップレベルのウィンドウで、GtkApplicationWindowまたはGtkWindowのインスタンスを指しています。 displayメソッドはそのウィンドウからGdkDisplayインスタンスを取得します。 引数の最後の:userは、GTK 4では、GTK_STYLE_PROVIDER_PRIORITY_USERという定数として定義されています。 この定数はCSSを適用するプライオリティ(優先順位)の中で最も適用される度合いが高いものです。

エントリー

アプリケーションのユーザに文字を入力してもらうためのウィジェットがエントリーです。 wordbookアプリ(単語帳アプリ)では、英単語と日本語訳の入力にエントリーを用いています。

エントリーをウィンドウのパーツとして埋め込むのはUIファイルの中で記述するのが最も良く使われる方法です。

... ... ...
<child>
  <object id="entry" class="GtkEntry">
  </object>
</child>
... ... ...

埋め込んだGtkEntryのオブジェクトは、プログラム中ではビルダーで取り出します。

builder = Gtk::Builder.new(file: UIファイル名)
entry = builder["entry"]

ユーザがエントリーに入力した文字列を入手するにはシグナルを使います。

  • エントリーのactivateシグナルを使う。 エントリーの入力でEnterキーが押されるとこのシグナルが発せられる。
  • 入力終了したら、ボタンをユーザがクリックし、そのclickedシグナルを用いる。 ボタン以外の方法(例えばキー入力とアクセラレータ)もある

シグナルのハンドラの中でtextメソッドを使えば入力文字列を取り出すことができます。

s = entry.text

エントリーには、検索に特化したGtkSearchEntryやパスワード入力用のGtkPasswordEntryもあります。

テキストビュー

テキストビューは複数行にわたるテキストを編集するウィジェットです。 スクリーンエディタの画面のようなものと考えれば良いです。 GTK 4にはGtkTextViewクラスが用意されています。

テキストビューをウィンドウの中に埋め込むには、UIファイルを用います。 テキストビューではスクロールが必要になりますので、GtkScrolledWindowオブジェクトの子オブジェクトがGtkTextViewになるようにします。

<child>
  <object class="GtkScrolledWindow">
    <property name="hexpand">true</property>
    <property name="vexpand">true</property>
    <child>
      <object id="textview" class="GtkTextView">
        <property name="hexpand">true</property>
        <property name="vexpand">true</property>
        <property name="wrap-mode">GTK_WRAP_WORD</property>
      </object>
    </child>
  </object>
</child>

この例ではスクロールウィンドウもテキストビューもできる範囲で幅と高さをいっぱいにとるようにhexpandvexpandをTRUEに設定しています。 これで問題ないケースがほとんどでしょうが、実装の実情に応じて変更してください。 wrap-modeプロパティは画面の右端に達したときにテキストが折り返すときのモードを設定したものです。 例では、単語単位で折り返すようになっていますが、これは最も普通の設定です。

テキストビューにはactivateシグナルはないので、ボタンなどのシグナルを使ってテキストを取り出すようにします。 テキストはtextメソッドで取り出すことができます。

フォントを設定したい場合は、CSSを使います。 GtkTextViewのノード名はtextviewです。 例を示します。

textview {font-family: "Noto Sans CJK JP"; font-size: 12pt; font-style: normal;}
  • font-family ⇒ フォント名を指定
  • font-size ⇒ フォントサイズ
  • font-style ⇒ ノーマル(normal)、イタリック(italic)、斜体(oblique)を指定

その他のフォント情報の設定についてはAPIリファランスを参照してください。

GTKのコンポジット・オブジェクト

GTKのアプリケーションはメイン・ウィンドウ以外にダイアログ(ダイアログは一種のウィンドウ)やサブ・ウィンドウを使います。 それらのウィンドウには多くのウィジェットが埋め込まれています。 個々のウィジェットをまとまりなく記述すればプログラムは複雑になり、とても管理できないでしょう。 それを避けるためにGTK 4はコンポジット・オブジェクトという仕組みを提供しています。 コンポジット・オブジェクトはウィンドウとそこに配置されるウィジェットをまとめたオブジェクトです。

この仕組みはCでプログラムする上では非常に助かるものです。 しかし、Rubyの場合は「Rubyのクラス」を使ってウィンドウとそこに配置されたウィジェットをまとめていくことができます。 この方が分かりやすいかもしれません。 この「Rubyのクラス」をこの記事では「ラッパークラス」と呼ぶことにします。 ラッパークラスは次のセクションで説明します。

コンポジット・オブジェクトは(インスタンスではなく)クラスのレベルで複数のオブジェクトを組み合わせます。 そのトップになるクラスは、UIファイルではtemplateタグで表します。 それ以外は通常のUIファイルと同じです。

UIデータを取り込むにはset_templateメソッドを用います。 簡単なサンプルプログラムを使って説明します。 サンプルプログラムはレポジトリの_example/ruby_gtk4/composite_window.rbにあります。

require 'gtk4'

class TopWindow < Gtk::ApplicationWindow
  type_register

  class << self
    def init
      ui_string = <<~EOS
      <?xml version="1.0" encoding="UTF-8"?>
      <interface>
        <template class="TopWindow" parent="GtkApplicationWindow">
          <child>
            <object class="GtkBox">
              <property name="orientation">GTK_ORIENTATION_VERTICAL</property>
              <property name="spacing">5</property>
              <child>
                <object id="search_entry" class="GtkSearchEntry">
                </object>
              </child>
              <child>
                <object id="print_button" class="GtkButton">
                  <property name="label">端末に出力</property>
                </object>
              </child>
            </object>
          </child>
        </template>
      </interface>
      EOS
      set_template(:data => GLib::Bytes.new(ui_string))
      bind_template_child("search_entry")
      bind_template_child("print_button")
    end
  end

  def initialize(app)
    super(application: app)
    print_button.signal_connect("clicked") { print "#{search_entry.text}\n" }
    signal_connect("close-request") { app.quit; true }
  end
end

application = Gtk::Application.new("com.github.toshiocp.subclass", :default_flags)
application.signal_connect "activate" do |app|
  TopWindow.new(app).show
end

application.run

GtkApplicationWindowの子クラスとしてTopWindowを定義します。 TopWindowクラスにはGtkApplication内に配置されるGtkEntryとGtkButtonも組み込まれています。 そして、TopWindowのインスタンスをnewメソッドで生成すると同時にGtkEntryとGtkButtonも生成されます。

TopWindowはGTKのクラスとして定義するので、タイプシステムに登録しなければなりません。 それをするのがtype_registerメソッドです。

UI文字列でTopWindowの構造を定義します。 この構造はクラスにテンプレートとして設定するので、クラスメソッド(クラスの特異メソッド)のinitメソッドを使います。 class << selfによってクラスメソッドを定義します。 このメソッドは最初のインスタンスを生成するときに呼び出されます。

UI文字列は通常のUI文字列と大筋変わりませんが、templateタグの部分が違います。 最も外側のTopWindowの定義はtemplateタグを使い、TopWindowの親クラスGtkApplicationWindowをparentアトリビュートで指定します。

UI文字列からクラスの構造をテンプレートとして登録するにはset_templateメソッドを使います。 このテンプレートにセットするのは文字列ではなく、GBytesというオブジェクトです。 ui_stringからGBytesを生成するには、GLib::Bytes.new(ui_string)とします。 set_templateの引数は:dataをキーとするハッシュの形で与えます。

なお、このプログラムでは文字列からGBytesを経由してテンプレートに代入しましたが、resourceという形を使うこともできます。 その方法はRuby-gnomeのgtk3ディレクトリ以下のサンプルプログラムを参照してください。

bind_template_childメソッドは、テンプレート内のウィジェットにアクセスするアクセサーを生成します。 例えば、UIファイル上でidがsearch_entryであるGtkEntryは、bind_template_child("search_entry")によって、TopWindowクラスのsearch_entryメソッドで参照できるようになります。

コンストラクタ(インスタンスを生成するときにその初期化をするメソッド)は、initializeメソッドが行います。 これは一般にRubyでinitializeメソッドを用いるのと同じです。 superを使って親クラスのインスタンス初期化を行いますが、super(application: app)のように引数がハッシュになることに注意してください。

ボタンのシグナルとハンドラを結びつける方法は、既に説明したので省略します。

close-requestシグナルはウィンドウが閉じる際に発せられるシグナルです。 このプログラムでは、ウィンドウのクローズボタン(右上のxボタン)をクリックしたときに発生します。 このシグナルをそのままにしてウィンドウを閉じるとエラーが発生します。 私にはエラーの原因は分かっていませんが、次のようなことではないかと想像しています。

  • GtkApplicationWindowが閉じる。TopWindowはまだ残っている
  • GtkApplication終了時にTopWindowの終了処理をする
  • TopWindowは子オブジェクトのGtkApplicationを終了させようとするが、すでに無くなっている
  • エラーになる

確認はできていません。 いずれにしてもエラーになります。 それで、close-requiestを捕まえ、ウィンドウを閉じるのをストップします。 ハンドラがtrueを返せば、以後のウィンドウを閉じるルーチンはスキップされるのを利用します。 同時に、ハンドラの中でアプリケーションを停止(quit)します。 これで、アプリケーションから順にオブジェクトの解放が行われていくのでエラーを回避できます。

さて、プログラムを動かしてみましょう。

$ ruby composite_window.rb

エントリに文字列を入力し「端末に出力」のボタンを押すと、端末にエントリの文字列が出力されます。 ウィンドウのクローズボタン(右上のxボタン)をクリックし、終了します。

ラッパークラス

コンポジット・オブジェクトの組み立ては、RubyGTKの仕組みに立ち入るので、やや複雑な処理になりました。 ラッパークラスは、単にRubyのクラスでウィジェットをまとめるだけなので、分かりやすいものです。

例として、wrapper.rbを作りました。 このプログラムでは、ラッパークラスMainWindowを作り、そのインスタンス変数にGtkApplicationWindow以下のオブジェクトを代入します。 このウィンドウに対する操作はMainWindowのインスタンスメソッドにまとめます。 プログラムはレポジトリの_example/ruby_gtk4/wrapper.rbです。

require 'gtk4'

class MainWindow
  def initialize(app)
    ui_string = <<~EOS
      <?xml version="1.0" encoding="UTF-8"?>
      <interface>
        <object id="window" class="GtkApplicationWindow">
          <child>
            <object class="GtkBox">
              <property name="orientation">GTK_ORIENTATION_VERTICAL</property>
              <property name="spacing">5</property>
              <child>
                <object id="search_entry" class="GtkSearchEntry">
                </object>
              </child>
              <child>
                <object id="print_button" class="GtkButton">
                  <property name="label">端末に出力</property>
                </object>
              </child>
            </object>
          </child>
        </object>
      </interface>
      EOS
    builder = Gtk::Builder.new(string: ui_string)
    @window = builder["window"]
    @window.set_application(app)
    @print_button = builder["print_button"]
    @search_entry = builder["search_entry"]
    @print_button.signal_connect("clicked") { do_print }
  end
  def window
    @window
  end

  private

  def do_print
    print "#{@search_entry.text}\n"
  end
end

application = Gtk::Application.new("com.github.toshiocp.subclass", :default_flags)
application.signal_connect "activate" do |app|
  MainWindow.new(app).window.show
end

application.run

MainWindowクラスのインスタンスはwindowメソッドでGtkApplicationWindowインスタンスを返します。 外部からウィンドウに対するメソッドを呼び出したいときはwindowメソッドでインスタンスを呼び、さらにそのインスタンスのメソッドを呼び出します。

プログラムの説明はここまで読み進めた読者には必要ないでしょう。 大事なことは、ウィンドウごとにクラスでまとめれば、よりわかりやすいプログラムになるということです。

GObjectプロパティの定義の仕方

wordbookにGtkColumnViewを使うことを決めた時、そのリストにはGListStoreを使うのが良いと思いました。

  • GListStoreはGObjectまたはその子孫しかリスト・アイテムにすることができない
  • そのアイテム・オブジェクトは、ファクトリ(後述)を簡単にするためにプロパティを持っていることが望ましい

アイテムを表すクラスWBRecordはこの2つの条件を満たすように作られました。

class WBRecord < GLib::Object
  type_register
  def initialize(*record)
    super()
    if record.size == 1 && record[0].instance_of?(Array)
      record = record[0]
    end
    unless record[0].instance_of?(String) && record[1].instance_of?(String) && record[2].instance_of?(String)
      record = ["", "", ""]
    end
    set_property("en", record[0])
    set_property("jp", record[1])
    set_property("note", record[2])
  end
  def to_a
    [en, jp, note]
  end
  install_property(GLib::Param::String.new("en", # name
    "en", # nick
    "English", # blurb
    "",     # default value
    GLib::Param::READWRITE # flag
    )
  )
  install_property(GLib::Param::String.new("jp", # name
    "jp", # nick
    "Japanese", # blurb
    "",     # default value
    GLib::Param::READWRITE # flag
    )
  )
  install_property(GLib::Param::String.new("note", # name
    "note", # nick
    "Notes of the English word", # blurb
    "",     # default value
    GLib::Param::READWRITE # flag
    )
  )
  private
  def en=(e); @en=e; end
  def jp=(j); @jp=j; end
  def note=(n); @note=n; end
  def en; @en; end
  def jp; @jp; end
  def note; @note; end
end

WBRecordはGLib::Objectのサブクラスとし、type_registerでタイプシステムに登録します。 これでWBRecordはGTKの世界のオブジェクトとして扱うことができるようになります。 そして、GListStoreのリスト・アイテムにすることもできるようになります。

プロパティを定義するにはinstall_propertyメソッドを使います。

その引数にはGParamSpecオブジェクトを与えます。 GParamSpecはGObjectとは異なる系列のオブジェクトです。 両者の間に親子関係はありません。 また、GParamSpecの子クラスとしてGParamSpecString(文字列型のパラメータ)、GParamSpecInt(整数型のパラメータ)、GParamSpecDouble(倍精度型のパラメータ)などがあります。

上のプログラムでは文字列型のパラメータを使っています。

GLib::Param::String.new(名前、ニックネーム、説明、デフォルト値、読み書きに関するフラグ)

のようにしてGPramSpecオブジェクトを生成します。 パラメータ型によって、引数が違うので、GObjectのAPIリファランスを参照してください。 例えば、文字列型のパラメータ生成は(Functionのセクションの)param_spec_stringのところを見ます。

パラメータをセットするにはset_property、参照するにはget_propertyメソッドが使えます。 これらのメソッドは外部からパブリック・メソッドとして使います。

プロパティはインスタンスごとに保持される値で、プロパティと同名のインスタンス変数に保持されます。 set_propertyget_propertyはその内部で、アクセサーを使ってインスタンス変数にアクセスします。 そのために、privateメソッドでアクセサーを定義しておきます。 上のプログラムではdef en=(e); @en=e; endインスタンス変数への代入、def en; @en; endインスタンス変数の参照になります。

プロパティを定義することにより、GTKレベルのオブジェクトからプロパティ値を代入または参照することができるようになります。

GtkColumnViewの使い方

GtkColumnViewはGTK 4で新たに導入されたクラスです。 GTKのなかでも複雑で理解しにくいクラスです。 GtkColumnViewは表で、データベースのように列がフィールドを表し、行がレコードを表します。

GtkColumnViewは表示のための仕組みで、データを保存する仕組みはリストです。 リストに追加する個々のデータをアイテムといいます。 リストにはいくつかの種類がありますし、自分で新しいリストの仕組みを作ることも可能です。 しかし、GListStoreというリストは「任意のGObject子孫クラス」のリストを作ることができる汎用のリストですので、それを利用すれば新たなリストを作成する必要はほとんどありません。 wordbookでは、前セクションで紹介したWBRecordをGListStoreのアイテムとしました。

さて、wordbookはGtkColumnViewの機能をフルに使っているのでUIファイルが大きく、説明するとなると相当の分量が必要です。 このセクションでは、2列のシンプルなGtkColumnViewの例を作り、それを説明しようと思います。 プログラムはレポジトリの_example/ruby_gtk4/column.rbにあります。

GtkColumnView

プログラムを以下に示します。 長いですが、そのほとんどはUI文字列です。

require 'gtk4'

ui_string = <<EOS
<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <object id="window" class="GtkApplicationWindow">
    <property name="title">GtkColumnView</property>
    <property name="default-width">600</property>
    <property name="default-height">400</property>
    <child>
      <object class="GtkScrolledWindow">
        <child>
          <object class="GtkColumnView">
            <property name="model">
              <object class="GtkNoSelection">
                <property name="model">
                  <object id="liststore" class="GListStore"></object>
                </property>
              </object>
            </property>
            <child>
              <object class="GtkColumnViewColumn">
                <property name="title">英語</property>
                <property name="expand">false</property>
                <property name="fixed-width">250</property>
                <property name="factory">
                  <object class="GtkBuilderListItemFactory">
                    <property name="bytes"><![CDATA[
<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <template class="GtkListItem">
    <property name="child">
      <object class="GtkLabel">
        <property name="hexpand">TRUE</property>
        <property name="xalign">0</property>
        <binding name="label">
          <lookup name="en" type = "EngJap">
            <lookup name="item">GtkListItem</lookup>
          </lookup>
        </binding>
      </object>
    </property>
  </template>
</interface>
                    ]]></property>
                  </object>
                </property>
              </object>
            </child>
            <child>
              <object class="GtkColumnViewColumn">
                <property name="title">日本語</property>
                <property name="expand">true</property>
                <property name="factory">
                  <object class="GtkBuilderListItemFactory">
                    <property name="bytes"><![CDATA[
<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <template class="GtkListItem">
    <property name="child">
      <object class="GtkLabel">
        <property name="hexpand">TRUE</property>
        <property name="xalign">0</property>
        <binding name="label">
          <lookup name="jp" type = "EngJap">
            <lookup name="item">GtkListItem</lookup>
          </lookup>
        </binding>
      </object>
    </property>
  </template>
</interface>
                    ]]></property>
                  </object>
                </property>
              </object>
            </child>
          </object>
        </child>
      </object>
    </child>
  </object>
</interface>
EOS

class EngJap < GLib::Object
  type_register
  attr_accessor :en, :jp
  def initialize(en,jp)
    super()
    set_property("en", en)
    set_property("jp", jp)
  end
  install_property(GLib::Param::String.new("en", "en", "English", "", GLib::Param::READWRITE))
  install_property(GLib::Param::String.new("jp", "jp", "Japanese", "", GLib::Param::READWRITE))
  private :en, :en=, :jp, :jp=
end

application = Gtk::Application.new("com.github.toshiocp.subclass", :default_flags)

application.signal_connect "activate" do |app|
  builder = Gtk::Builder.new(string: ui_string)
  window = builder["window"]
  window.set_application(app)
  liststore = builder["liststore"]
  # サンプルデータ
  liststore.append(EngJap.new("hello", "こんにちは"))
  liststore.append(EngJap.new("good-bye", "さようなら"))
  window.show
end

application.run
  • UI文字列内で、GtkApplicationWindow、GtkScrolledWindow、GtkColumnView、2つのGtkColumnViewColumnの順に親子関係になっている
  • GtkColumnViewが参照するリストは、GtkNoSelection、GListStoreとなっている。 直接GListStoreを参照するのでなく、GtkNoSelectionを介して参照する。 このような中間におくセレクション関係のリストには、GtkSingleSelection(一行だけ選択できる)やGtkMultiSelection(複数行選択できる)がある。 それぞれ、選択した行をダブルクリックするとシグナルを発することができる。
  • GtkColumnViewColumnは「GtkColumnViewの1列」を表すオブジェクト。各プロパティは
    • title ⇒ 行のヘッダーに現れるタイトル
    • expand ⇒ 幅を可能な限り広げる
    • fixed-width ⇒ expandプロパティがfalseのときの幅
    • factory ⇒ 列内の1つひとつのアイテムを生成するファクトリの指定
  • ファクトリには2種類あるが、ここではGtkBuilderListItemFactoryを使っている。 そのbytesプロパティに、アイテムを生成する方法を書いたUI文字列のGBytesオブジェクトを代入する。 その文字列はXML<![CDATA[... ...]]タグで表している。 内部の文字列は外部のUI文字列とは独立なので、インデントは左端から始まる
  • GBytesの内容は外側から、GtkListItem(テンプレート)、GtkLabelとなっている。 GtkListItemは各アイテムを表すオブジェクトであるが、これはウィジェットではない。 表示されるウィジェットはその子オブジェクトのGtkLabelである。 GtkListItemがtemplateタグになっているのは、コンポジット・ウィジェットの定義と同様に、GtkListItemインスタンスを生成するためにクラスが保持するテンプレートだからである。
  • bindingタグはデータを結合するためのもので、結合元はタグで囲んだ内側(コンテンツ)、結合先はnameアトリビュートで指定する。 この場合は、GtkListItemの子オブジェクトのGtkLabelのlabelプロパティ
  • lookupタグはオブジェクトのプロパティを参照してその値を返す。 オブジェクトのタイプはtypeアトリビュートで、プロパティ名はnameアトリビュートで指定する。 このプログラムではEngJapクラス(プログラム内で作成したカスタム・クラス)のenプロパティを参照する
  • lookupタグのitemアトリビュートはGtkListItemのitemプロパティを指す。 GtkListItemのitemプロパティはGtkColumnViewが指定したリストの中でGtkListItem(の何行目か)に対応するアイテムを指している。 lookupタグで囲まれたGtkListItemはファクトリが生成したインスタンス
  • EngJapクラスをGObjectの子オブジェクトとして定義し、プロパティenとjpを定義する(WBRecordと同様)
  • アプリケーションのアクティベートハンドラ内で、UIファイルを組み立て、リストに2つのサンプルデータを追加し、ウィンドウを表示

プログラムは短いですがUI文字列が長いので、読むのが大変です。 UI文字列が実質的にプログラムの代わりをしていることがわかります。

wordbookでは以下の機能をGtkColumnViewまわりに追加しています。

  • GtkSingleSelectionを使い1行セレクトできる。 その行をダブルクリックすると行内容を編集するウィンドウが現れるように、シグナルハンドラを設定している
  • ソートをサポートしている。各行のヘッダをクリックすることによりその行を基準に(表全体を)昇順または降順に並べ替える
  • フィルターをサポートしており、エントリーで入力した文字列に(英単語のデータが)マッチする行のみを表示する

Wordbookアプリ

Wordbookのプログラムは_example/word_book_gtk4ディレクトリに置いてあります。 そこには全部で5つのファイルがあります。

Wordbook メイン画面

Wordbook 編集画面

wordbookは単語帳アプリで、英語、日本語訳、備考を書きこむことができます。 データはCSV形式で、「word.csv」に保存されます。 また、アクセラレータ(キー操作)をサポートしているので、マウスを使わずに素早くコマンドを入力できます。

  • Ctrl-n ⇒ 新規単語の入力画面を表示
  • Ctrl-w ⇒ アクティブ・ウィンドウを閉じる
  • Ctrl-q ⇒ アプリ終了
  • Ctrl-k ⇒ 編集画面で、編集をキャンセル(Ctrl-cがコピペと重なるのでCtrl-kとした)
  • Ctrl-d ⇒ 編集画面で、編集中のデータをリストから削除
  • Ctrl-s ⇒ 編集画面で、編集データをリストに保存(追加または変更)
  • 編集時には、一般によく用いられる「Ctrl-a(全選択)」「Ctrl-c(コピペ)」なども可能

リスト内のデータを変更したいときは、その行をダブルクリックするとその行に関する編集画面が開きます。 変更をして「保存」ボタンをクリックまたはCtrl-sで修正内容がリストに反映されます。

まとめ

最近のいくつかの記事でRubyにグラフィック機能を拡張するgemについて書きましたが、総合的に見て、Ruby/Gtk4が最も良くできています。 それは、その背後にあるGTK 4が充実しているからです。

GTK 4はそのベースにGObject、GLib2などの膨大なライブラリを含んでおり、その習得には相当の時間を要します。

Ruby/GTK4のインターフェース部分は比較的容易に理解ができるでしょう。 ただ、問題はRuby/GTK4のドキュメントがない(少ない?)ことです。 それがあれば、もっと多くの人がRuby/GTK4を使うことでしょう。

今回の記事が多少なりともRuby/GTK4のユーザの役にたてば幸いです。 また、この記事とは別に本格的にRuby/GTK4のチュートリアルを書くのも有意義かな、と考えています。 実現には相当の時間が必要でしょうが・・・

長い記事をお読みいただきありがとうございました。 Ruby/GTK4でRubyGUIアプリを作って楽しみましょう。

Happy progrmming!

「はじめてのJekyll + GitHub Pages」マイナーアップデート

「はじめてのJekyll + GitHub Pages」をマイナーアップデートしました。

アップデートの内容は

  • 誤りの訂正
  • site.baseurlについての記述の追加
  • その他

です。

このサイトのGitHubレポジトリはこちらです。

あらためて全部読み返しました。 結構時間がかかりました。 訂正箇所を探しながら読んだということもありますが、書いてからしばらくたって内容を忘れていたのが大きかったです。

初心者むけと銘打っていながら、ところどころで深堀していて、やっていることが矛盾している部分もありました。

続編も書いてみたいと思っています。 今は「徒然Ruby」に時間をかけているので、すぐには無理ですが・・・

それから、Jekyllについては「おもこん」でも書いていますが、「はじめてのJekyll + GitHub Pages」の方がよりメンテナンスができています。 「おもこん」よりもそちらを見てください。

徒然Ruby(36)Ruby/GTK -- RubyとGUI

今回もRubyGUIについて書きます。 内容はGTK 3とGTK 4をRubyで使うライブラリRuby/GTKです。

GTKと「Rubyで動くGTK」とは

GTKオープンソースGUIライブラリです。 オリジナルはCで書かれており、Linuxで開発されました。 その後Windowsでも動くようになり、また言語もPythonPerlなどで使えるようになりました。

GTKの現在の安定版はGTK 4(バージョン4.8.2)です。 以前の版がGTK 3でその最新版は3.24.35です。 GTK 4がリリースされて2年以上経ちますので、これから使うとしたらGTK 4になると思います。 ですが、今回はGTK 3とGTK 4の両方を試してみました。

RubyGTKを動かすプロジジェクトはGitHub上で開発が進んでいます。

ruby-gnome/ruby-gnome

このGitHubの中でgtk3とgtk4の両方のgemが開発されています。 gemのバージョンは4.0.3となっています。 ドキュメントが少ないため、どの程度まで開発が進んでいるのかは良くわかりませんでした。

とにかく、試すしかないか、という感じです。

インストール

gemコマンドでインストールします。 gem名は「gtk3」と「gtk4」です。 どちらか一方をインストールすれば十分ですが、私は両方を試してみることにしましたので、2つともインストールします。

gtk4のインストール

$ gem install gtk4
Fetching red-colors-0.3.0.gem
... ... ...
Fetching gtk4-4.0.3.gem
... ... ...
... ... ...
13 gems installed

gtk3のインストール

$ gem install gtk3
Fetching gtk3-4.0.3.gem
... ... ...
... ... ...
2 gems installed

ドキュメント

Ruby/GTK3とRuby/GTK4をまとめてRuby/GTKと書くことにします。 Ruby/GTKGTKの機能をRubyで実現しようとするもので、GTKへの理解が前提となります。 そこで、GTKAPIリファランスのリンクを示しておきます。

GTKに馴染みのない方はこのリファランスの「Additional documentation」にある「Getting started with GTK」をまず読んでください。 ここがGTKのすべての出発点になります(それも難しいかもしれませんが)。 GTK 4については、GitHubレポジトリGtk4-tutorial、 またはそのHTML版もあります。

Ruby/GTK自体のドキュメントはありますが、完全ではありません。

例えばGTKのWindowオブジェクトにはそのデフォルトサイズを指定するgtk_window_set_default_sizeという関数があります。 これはRubyインスタンス・メソッドに相当するものです。 Ruby/GTK4にはこの記載はありませんが、set_default_sizeというRubyメソッドで使うことができます。

想像ですが、Ruby/GTKはこのメソッドをプログラムによる自動生成で作っているのではないでしょうか。 そうであれば、通常のメソッド定義の構文は使っていないことになります。 そして、ドキュメント自体もプログラム(おそらくRDoc)による自動生成ならば、メソッドをドキュメントに拾い出すことはできないでしょう。 この点については、ソースコードを確認できていないのであくまで推測です。

いずれにせよ、ドキュメントにないGTKのメソッドがRubyで使えるかどうかは、実際に試してみるしかありません。

Hello world

手始めはいつも「Hello world」です。 このプログラムではGTK 3とGTK 4を両方試せるようになっていて、引数に3を入れるとGTK 3にそれ以外ではGTK 4を使うようになっています。

$ ruby hello.rb 3 #=> GTK 3を使う
$ ruby hello.rb   #=> GTK 4を使う

先にコードを示して、その後説明します。

@gtk_version = (ARGV.size == 1 && ARGV[0] == "3") ? 3 : 4

require "gtk#{@gtk_version}"
print "Require GTK #{@gtk_version}\n"

application = Gtk::Application.new("com.github.toshiocp.hello", :default_flags)

application.signal_connect "activate" do |app|
  window = Gtk::ApplicationWindow.new(app)
  window.set_default_size 400,300
  window.title = "Hello"

  label = Gtk::Label.new("Hello World")
  if @gtk_version == 4
    window.child = label
    window.show
  else
    window.add(label)
    window.show_all
  end
end

application.run
  • はじめに引数を調べ、@gtk_versionに3または4を代入
  • require "gtk3"またはrequire "gtk4"を実行。これによりRuby/GTKが使えるようになる

GTK 3の使い方は徐々に変わってきて、gtk_applicationを使うのが良い方法になってきました。 GTK 4も同じ使う方をしますので、ここではそれにならってプログラムしています。

  • Gtk::Applicationオブジェクト(以下アプリケーションという)を生成する
  • アプリケーションにはIDをつける。 アプリケーションIDはURLを逆にするようなパターンで書き、世界で他に同一のものがないようにする。 例えばメールアドレスは世界にひとつしかないのでそれを使うことも可能である。 abc@example.comというメールアドレスを自分が持っているとする。 アプリケーションIDはcom.example.abc.helloとすれば良い。 (注:だれでも同じ文字列を使ってアプリケーションを作れるから、このIDが世界中でユニークだということを保証することはできない。 しかしGTKのシステムがアプリケーションIDがユニークであることを前提に作られているので、アプリ制作者がIDの付け方に十分注意を払う必要がある)。 上記のhello.rbではGitHubのIDを用いている
  • :default_flagsは「アプリケーションのデフォルト動作」ということ。 このフラグはGApplication ver2.74から使うようになった。 (GIO ドキュメント) 以前は:flags_noneを用いていた。 Cでプログラムする場合は「引数がファイル名である(:handles_open)」「引数が任意の文字列である(:handles_command_line)」などの定数もあるが、Rubyではあまり必要ないかもしれない(Rubyで引数処理できるから・・・正確ではないかも・・・)
  • アプリケーションが起動され、(:default_flagsで)アクティブになるとactivateシグナルが発せられる。 そのシグナルを受けて動作するプログラムを「ハンドラ」という。 シグナルとハンドラをつなぎ合わせるメソッドがsignal_connectメソッド。 そのメソッドのブロックにハンドラを記述する。
  • ハンドラでは、まずApplicationWindowオブジェクトを生成する。 このオブジェクトはアプリケーションと連携したウィンドウで、newメソッドには引数にアプリケーションを与える。 デフォルトサイズを400x300に、タイトル(タイトルバーに表示される文字列)を「Hello」に設定する
  • Labelオブジェクトを生成する。 ラベルオブジェクトはウィンドウの中で文字列を表示する。 その文字列をnewメソッドの引数に与える
  • GTK 4ではchildメソッドでラベルをウィンドウの子としてつなげる。 画面上ではウィンドウの中にラベルが配置されることになる。 このようにウィンドウ内のオブジェクト(ウィジェットという)の中に別のウィジェットが入り込むとき、外が親、中が子という親子関係が発生する。 この「ウィジェットの親子関係」は「クラスの親子関係」とは異なるものである。 GTK 3ではaddメソッドを使う(GTK 3ではこのような親子関係を作るときコンテナが必要なことがあり、GTK 4よりも複雑)。
  • GTK 4ではトップレベルのウィンドウをshowメソッドで表示するだけで良い。 というのは、トップレベルのウィンドウ以外のオブジェクトはデフォルトで「visible(表示)」にプロパティが設定されるからだ。 GTK 3ではデフォルトが「visible」ではないので、window.showを使うとウィンドウだけが表示され、ラベルが見えなくなってしまう。 window.show_allを使うとウィンドウとその子孫ウィジェットが表示できる。 なお、window.showに加えてlabel.showとすればウィンドウ・ラベルともに表示できるが、show_allを使うほうが簡単

Hello world

さて、「Hello world」を表示するだけにもかかわらず、説明がこんなに長くなってしまいました。 考えてみると、その多くの部分はGTKの説明です。 このことはRuby/GTKを使えるようになるには、GTKの理解が重要だということを示しています。 残念ながらGTK 4の日本語の解説資料はほとんどありません。 英語で最も頼りになるのはGTKのドキュメントです。 ですが、それも分かりやすいわけではありません。 GTKは学習コストが高いなあ、と思います。 ただ、それは非常に大きな首尾一貫したシステムだからで、GTKをマスターすればソフトウェアのスキルが格段に上がることは疑いないと思います。

電卓

簡単なGUIのプログラムである電卓を作ってみます。 lib_calc.rbはGlimmerと同じものを使います。 calc.rbだけをGTK対応に変更します。

まず、プログラムを示しましょう。

@gtk_version = (ARGV.size == 1 && ARGV[0] == "3") ? 3 : 4

require "gtk#{@gtk_version}"
print "Require GTK #{@gtk_version}\n"

require_relative "lib_calc.rb"

def get_answer a
  if a.instance_of?(Float) &&  a.to_i == a
    a.to_i.to_s
  else
    a.to_s
  end
end

application = Gtk::Application.new("com.github.toshiocp.calc", :default_flags)

application.signal_connect "activate" do |app|
  calc = Calc.new
  window = Gtk::ApplicationWindow.new(app)
  window.default_width = 400
  if @gtk_version == 4
    window.default_height = 120
  else
    window.default_height = 80
  end
  window.title = "Calc"

  vbox = Gtk::Box.new(:vertical, 5)
  window.child = vbox

  hbox = Gtk::Box.new(:horizontal, 5)
  label = Gtk::Label.new("")
  if @gtk_version == 4
    vbox.append(hbox)
    vbox.append(label)
  else
    vbox.pack_start(hbox)
    vbox.pack_start(label)
  end
    
  entry = Gtk::Entry.new
  entry_buffer = entry.buffer
  button_calc = Gtk::Button.new(label: "計算")
  button_clear = Gtk::Button.new(label: "クリア")
  button_quit = Gtk::Button.new(label: "終了")
  if @gtk_version == 4
    hbox.append(entry)
    hbox.append(button_calc)
    hbox.append(button_clear)
    hbox.append(button_quit)
  else
    hbox.pack_start(entry)
    hbox.pack_start(button_calc)
    hbox.pack_start(button_clear)
    hbox.pack_start(button_quit)
  end
    
  button_calc.signal_connect "clicked" do
    label.text = get_answer(calc.run(entry_buffer.text))
  end
  button_clear.signal_connect "clicked" do
    entry_buffer.text = ""
  end
  button_quit.signal_connect "clicked" do
    if @gtk_version == 4
      window.destroy
    else
      window.close
    end
  end

  if @gtk_version == 4
    window.show
  else
    window.show_all
  end
end

application.run

Calc

最初のあたりはGTK 3/4両方に対応するための処理、それからget_answerは以前と同じメソッドです。 GTKに関するプログラムはapplicationの定義以降です。 activateシグナルのハンドラがプログラムの大部分なのでそこを説明します。

  • ウィンドウのデフォルトサイズは幅と高さを別々に定義することができる。 GTK 3とGTK 4では高さの設定が異なるので、分けて定義する
  • ボックス・オブジェクトを使う。 ボックス・オブジェクトは縦または横に複数のオブジェクトを並べるためのコンテナ。 縦に並べるときは:vertical横に並べるときは:horizontalを生成時に引数に渡す。 引数の2番めはオブジェクト間のスペースをピクセル単位で指定する。 ここでは縦に並べるボックスを、1番めがボックス、2番めをラベルにして定義。 内側のボックスは横に並べるボックスで、その中にエントリーと3つのボタンを含める。 GTK 4ではappendメソッドを、GTK 3ではpack_startメソッドを使ってオブジェクトをボックスに追加していく
  • エントリとエントリ内のバッファは別オブジェクトになっていて、それぞれGtk::EntryクラスとGtk::EntryBufferクラスである。 このプログラムではGtk::EntryBufferオブジェクトをGtk::Entryクラスのbufferメソッドで取り出している。 エントリで編集された文字列はバッファの中に保存されている。
  • ボタンがクリックされたときにclickedシグナルが発生する。 このシグナルに対するハンドラを定義してそれらをsignal_connectメソッドで結びつける。
    • 計算ボタンのハンドラでは(1)entry_bufferの文字列を取り出し(2)calc.runで計算し(3)get_answerで文字列化し(4)ラベルのテキストに代入する
    • クリアボタンのハンドラでは、entry_bufferの文字列を空文字列にする
    • 終了ボタンのハンドラではトップレベルのウィンドウを閉じる。GTK 4ではdestroyGTK 3ではcloseメソッドを用いる

プログラムが長くなった原因はウィジェットを並べるコマンドを長々と書かなければならなかったからです。 これを解決するためにGTKにはウィジェットを別ファイル(UIファイル)にXMLで書くことができます。 次のセクションではこのことについて述べます。

ビルダーの使用

ウィジェット入れ子になった構造をXMLで表したファイルをUIファイルといい、拡張子をuiにします。 これを用いると本体のRubyプログラムを簡潔にすることができます。 電卓のUIファイルは次のとおりです。

<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <object id="window" class="GtkWindow">
    <property name="title">Calc</property>
    <property name="default-width">400</property>
    <property name="default-height">200</property>

    <child>
      <object id="vbox" class="GtkBox">
        <property name="orientation">GTK_ORIENTATION_VERTICAL</property>
        <child>
          <object id="hbox" class="GtkBox">
            <property name="orientation">GTK_ORIENTATION_HORIZONTAL</property>
            <child>
              <object id="entry" class="GtkEntry">
              </object>
            </child>
            <child>
              <object id="button_calc" class="GtkButton">
                <property name="label">計算</property>
              </object>
            </child>
            <child>
              <object id="button_clear" class="GtkButton">
                <property name="label">クリア</property>
              </object>
            </child>
            <child>
              <object id="button_quit" class="GtkButton">
                <property name="label">終了</property>
              </object>
            </child>
          </object>
        </child>
        <child>
          <object id="label" class="GtkLabel">
          </object>
        </child>
      </object>
    </child>
  </object>
</interface>

最初にXMLの定義を書き、次の行からウィジェットの定義を書きます。 一番外のタグはinterfaceです。 その中にobjectタグでウィジェットを表し、childタグでその親子関係を表します。 propertyタグではオブジェクトのプロパティを定義します。

  • objectタグのアトリビュート
    • id ⇒ そのオブジェクトの名前。同じ名前を別のオブジェクトにつけてはいけない。この名前を使ってRubyプログラムでオブジェクトを取り出す
    • class ⇒ そのオブジェクトのGTK上のクラス「GtkWindow」や「GtkButton」のようにGtkというプレフィックスがつく
  • propertyタグはそのオブジェクトのプロパティを設定する
    • そのオブジェクトが持つプロパティを調べるにはGTKのドキュメントを参照する。例えばGtkButtonにはlabelプロパティがある。 GTK 4のドキュメントであれば「GtkButtonクラスのプロパティの説明」を参照する
    • nameアトリビュートにはそのプロパティ名を入れる。GTKのドキュメントでは「GtkButton:label」となっているが、コロンの後の「label」のみを指定する
    • プロパティには文字列、数字、真偽などがある。数字は100のように文字列でその数字を書けばよく、真偽はtrue/falseなどを書く(ye/noなどもOK)

XMLファイルを見ると、ウィジェットの親子関係が反映されていることが分かると思います。 注意するのは、ボックス内に並べるウィジェットひとつひとつに<child>タグが必要なことです。 まとめてひとつの<child>タグにすることはできません。

UIファイルを読み込んでオブジェクトをメソッドが組み立ててくれるので、Ruby内では記述の必要なオブジェクト(たとえばシグナルを設定するオブジェクト)だけをUIファイルから取り出すだけですみます。 ボックスのようなものは取り出す必要がありません。 そのおかげでプログラムはかなりすっきりします。

@gtk_version = (ARGV.size == 1 && ARGV[0] == "3") ? 3 : 4

require "gtk#{@gtk_version}"
print "Require GTK #{@gtk_version}\n"

require_relative "lib_calc.rb"

def get_answer a
  if a.instance_of?(Float) &&  a.to_i == a
    a.to_i.to_s
  else
    a.to_s
  end
end

application = Gtk::Application.new("com.github.toshiocp.calc", :default_flags)

application.signal_connect "activate" do |app|
  calc = Calc.new

  builder = Gtk::Builder.new(file: "calc.ui")
  window = builder["window"]
  entry = builder["entry"]
  entry_buffer = entry.buffer
  button_calc = builder["button_calc"]
  button_clear = builder["button_clear"]
  button_quit = builder["button_quit"]
  label = builder["label"]

  window.set_application(app)
  if @gtk_version == 3
    window.default_height = 80
  end

  button_calc.signal_connect "clicked" do
    label.text = get_answer(calc.run(entry_buffer.text))
  end
  button_clear.signal_connect "clicked" do
    entry_buffer.text = ""
  end
  button_quit.signal_connect "clicked" do
    if @gtk_version == 4
      window.destroy
    else
      window.close
    end
  end

  if @gtk_version == 4
    window.show
  else
    window.show_all
  end
end

application.run

UIファイルの取り込みにはGtk::Bulderクラスを使います。 Builderクラスのオブジェクトを生成するときにUIファイル内のウィジェットも生成されます。 そこからウィジェット(オブジェクト)を取得するには[]メソッドを使います。 ちょうどハッシュのキーを使って値を取り出すようにします。

UIファイルではGtkApplicationWindowを記述することができません。 なぜなら、それを生成するにはApplicationオブジェクトが必要だからです。 そこで、UIファイルではGtkWindowオブジェクトを記述します。 これは一般的なウィンドウ・オブジェクトです。 そのため、ウィンドウとアプリケーションを繋げなければなりません。 それをするのが、GtkWindowクラスのset_applicationメソッドです。

それ以外はUIファイルを使わないcalcのプログラムと同じです。

UIファイルはウィジェットが多く複雑なときにとても便利です。 本体のプログラムが冗長になるのを防いでくれます。

GTKでできること

Ruby/GTKがどこまでできるかはもっと使ってみないとわかりませんが、ここまでのところを見るとかなりGTKのつくりを反映していると思いました。 GObject IntrospectionというGTKのCライブラリなどを他の言語で使えるようにするバインディングソフトがあり、Ruby/GTKもそれを使っています。 ということは、ライブラリのRubyへの翻訳はプログラムを使って自動化されているということです。 おそらく、GTKでできることはRubyでもほとんどできるのかもしれません。

例えばGNOMEの標準エディタであるGEdit、あるいはお絵描きソフトのGimpGTKで書かれています。 ということは相当完成度の高いアプリを書くことができるということです。 それらのソフトはCで書かれていますが、Rubyでも同程度のものの作成が期待できます。

GTKのドキュメントを見ると数多くのウィジェットが用意されています。 GTKの全体を理解するのにはとても時間がかかりますが、得られるものも大きいはずです。

あとはRuby/GTKのドキュメントの整備が課題だと思います。 GTKもドキュメントが分かりにくいと考える人が多いですが、Ruby/GTKの場合は更に深刻で、ほとんどのメソッドの記述が無い状態です。

これはマンパワーの問題が大きいのだと思います。 大企業ではプロジェクトを推進するのに十分な人材を充てることができますが、オープンソースではそうでないケースが多いです。 そうなると優れたソフトウェアほど開発に注力しなければならず、ドキュメントがなかなか充実しません。 特に、易しい記述のガイドが少なくなりがちで、ソフトウェアを使う人の裾野が広がらないのです。

プロジェクトの開発においては、ソフトウェア自体の開発以外にドキュメントの整備やソフトウェア普及の活動などトータルな計画が必要だと思います。

徒然Ruby(35)Glimmer -- RubyとGUI

今回もRubyGUIのトピックです。 Glimmerを取り上げます。

Glimmerとは

GlimmerはRubyにおけるDSLDomain specific language, ドメイン固有言語)です。 すなわち、Rubyを用いてその中にプログラミング言語を作ったものです。

RubyDSLを比較的簡単に作ることのできる言語です。 例えばRakeなどは一種のDSLで、taskやfileなどの独自のコマンドを持っています。 これらのコマンドはRubyのメソッドやブロックを用いていることが多いです。

GlimmerはGUIに関する言語をRubyにおいて作ったものです。 これは言語ですから、GUIライブラリとは異なります。 GUIライブラリを土台にして、その上にライブラリを動かす言語を作っています。 GUIライブラリはJava/SWTJRuby使用)、Opal(Rails使用、ウェブブラウザ上のGUI)、libui、Tk、Gtk3(開発初期段階)などが可能です。 今回はlibui上のGlimmerを紹介します。

libuiは開発段階がmid-alpha(アルファ版の中段階)となっています。 まだ安定版は出ていませんので、このライブラリを採用するかどうかは迷うかもしれません。 もしも安定版を使いたい場合は「Glimmer dsl for SWT」を考えても良いかもしれません。 ただ、こちらはJRubyなので起動が遅い、メモリ使用量が多いなどのデメリットもあります。 長所、短所を見比べて選ぶことになると思います。

「Glimmer-dsl-libui」のGitHubページのREADME.mdが詳しいドキュメント(英語)になっています。 読むのが大変なくらい長いですが、詳しく丁寧なのでとても助かります。

ここでは「Glimmer-dsl-libui」をインストールし、使ってみた経験を書きたいと思います。 この記事はドキュメントではないので、詳細については上記のGitHubを参照してください。

インストール

Gemをインストールするだけです。 今回はGemfileを作ってBundlerでインストールしました。 Glimmerのプログラムを作る作業ディレクトリを作成し、そこにGemfileを作ります。

source "https://rubygems.org"

gem 'glimmer-dsl-libui', '~> 0.5.24'

そのディレクトリにカレントディレクトリを移動して、Bundlerでインストールします。

$ bundle install
Fetching gem metadata from https://rubygems.org/.........
Resolving dependencies...
Using bundler 2.3.23
Using matrix 0.4.2
Fetching os 1.1.4
Fetching libui 0.0.15
... ... ...
Installing libui 0.0.15
... ... ...
Fetching glimmer 2.7.3
Installing glimmer 2.7.3
Fetching glimmer-dsl-libui 0.5.24
Installing glimmer-dsl-libui 0.5.24
Bundle complete! 1 Gemfile dependency, 15 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
$

正しくインストールできていれば、次のコマンドでウィンドウが表示されます。

$ ruby -r glimmer-dsl-libui -e "require 'examples/meta_example'"

Glimmerのexample

Hello world

ものごとの始まりはいつも「Hello world」の表示です。 プログラムを見てみましょう。

require 'glimmer-dsl-libui'

include Glimmer

w = window('hello') {
  label('Hello world')
}

w.show

Glimmerを使うには、glimmer-dsl-libuiをrequireする必要があります。 また、Glimmerモジュールをインクルードしておきます。

  • winodwメソッドは引数にウィンドウのタイトル、幅、高さを指定する。 今回は幅と高さは省略している。 ブロックにウィンドウ内に配置するオブジェクト(Glimmerでは「コントロール」と呼ぶ)を配置する。
  • labelメソッドはウィンドウ上のラベル(文字列の表示をするオブジェクト)を定義する
  • windowメソッドの返すウィンドウ・オブジェクトをwに代入する。 そのオブジェクトに対してshowメソッドを呼び出すと、ウィンドウが表示される

表示された画面は次のようになりました。

Hello world

Glimmerの使い方

requireとincludeはhello.rbのサンプル同様に指定します。

ウィンドウとメニュー

メインウィンドウはwindowメソッドで定義します。

window(タイトル, 幅, 高さ, メニュー有無) {... ... ...}

メニューの有無はtrue/falseで指定します。 デフォルトはtrueです。 trueでもメニューを定義しなければメニューは現れません。

メニューを定義するにはmenuメソッド(メニューバーに現れる項目)、menu_itemメソッド(メニューバーをクリックしたときに現れるメニューの項目)を使います。

menu(項目名)

menu_item(項目名) {... ... ...}

menu_itemではメニューがクリックされたときの動作をブロックに書きます。 動作はリスナー(クリックを聞いているもの、クリックされると動作するもの)といいます。 リスナーはon_clickedメソッドで書きます。

window、menu、menu_itemなどのメソッドはコントロールを定義し、on_clickedはリスナーを定義しますが、両者でブロックの書き方を変えるのがGlimmerの流儀です。

  • コントロール ⇒ 波括弧で書く
  • リスナー ⇒ do〜endで書く

これは分かりやすくするための習慣で、異なる書き方をしてもエラーにはなりません。 ウィンドウとメニューの例menu.rbのコードを以下に示します。

require 'glimmer-dsl-libui'

include Glimmer

menu('File') {
  menu_item('Open') {
    on_clicked do
      file =  open_file
      @label.text = File.read(file)
    end
  }
  quit_menu_item
}

window('Menu', 800, 600) {
  @label = label('')
}.show

open_fileメソッドはファイル・オープン・ダイアログを表示し、選択されたファイルのパス名を返します。

quit_menu_itemは終了メニューです。 これだけ書いておけばメニューからクリックにより終了できます。

これに似たメニューアイテムにabout_menu_itemがあります。 このメソッドは「About」という名前のメニューアイテムを追加します。 on_clickedリスナーはブロックの中に書きます。

画面のスクリーンショットは、Openメニューからファイル「menu.rb」を読み込んだ後に、終了メニューをクリックする直前の様子です。

メニュー

ボックス

コントロールを縦または横に並べるコンテナとしてhorizontal_box(横に並べる)とvertical_box(縦に並べる)があります。 それぞれのメソッドのブロックの中にコントロールを記述します。

ホリゾンタル・ボックス内のコントロールに与えられる幅は等しくなります。 バーティカル・ボックス内のコントロールに与えられる高さは等しくなります。

ボックス内の幅、高さが等分にならないようにするには、コントロールのブロック内に

stretchy false

を入れておきます。 これにより次のコントロールが詰めて配置されます。

コントロール

windowあるいはボックスに配置できるコントロールには、以下のようなものがあります(全てではない)。

  • label
  • button
  • color_button ⇒ カラー選択のダイアログが開く
  • font_button ⇒ フォント選択のダイアログが開く
  • checkbox
  • radio_buttons
  • combobox ⇒ 複数の選択肢(リスト)からひとつを選ぶことができる
  • entry ⇒ 文字列の入力ができる
  • search_entry
  • non_wrapping_multiline_entry ⇒ 複数行のテキストを編集できる
  • msg_box ⇒ message_boxも同じ。ダイアログが開く
  • progress_bar ⇒ プログレス・バー(作業の進捗を示す棒)を表示

この中のボタンとエントリーを使うと、前回のShoesで作った電卓プログラムを作れます。

ボタンは次のような構文で使います。

button(ボタンに表示する文字列) { ... ... ...}
  • ブロックのところにはリスナーのon_clickedを入れることができる。 on_clickedメソッドはボタンがクリックされたときの動作をそのブロックに記述する
  • またbuttonメソッドは、ボタンのオブジェクトを生成して返す
  • textプロパティがある。 プロパティを設定する(.text=)、プロパティの設定を読む(.text)ことができる。 Rubyのattr_accessorで定義したメソッドと、ここでいうプロパティは同じ

エントリーは次のような構文で使います。

entry {... ... ...}
  • ブロックのところにはリスナーのon_changedを入れることができる。 on_changedメソッドはentryの内容が変更されたときの動作をそのブロックに記述する
  • またentryメソッドは、エントリーのオブジェクトを生成して返す
  • readonlyとtextプロパティがある。 プロパティを設定する(.readonly=または.text=)、プロパティの設定を読む(.readonlyまたは.text)ことができる。

なお、ブロックにはパラメータをひとつ付けることができ、そのパラメータは各メソッドの返すオブジェクトと同じものです。 例えば、

entry {|e| ... ... ...}

eはentryが返すエントリーオブジェクトと同じものです。

ファイルはcalc.rblib_calc.rbの2つです。 lib_calc.rbは前回の記事(Shoesの記事)で出てきたものと同じですので省略します。 以下にcalc.rbソースコードを示します。

require 'glimmer-dsl-libui'
require_relative 'lib_calc.rb'

include Glimmer

def get_answer a
  if a.instance_of?(Float) &&  a.to_i == a
    a.to_i.to_s
  else
    a.to_s
  end
end

window('calc', 400, 80) { |w|
  calc = Calc.new
  vertical_box {
    horizontal_box {
      @e = entry
      horizontal_box {
        button("計算") {
          on_clicked do
            @answer.text = "  "+get_answer(calc.run(@e.text))
          end
        }
        button("クリア") {
          on_clicked do
            @e.text = ""
          end
        }
        button("終了") {
          on_clicked do
            w.destroy
            LibUI.quit
            0
          end
        }
      }
    }    
    @answer = label("")
  }
}.show

windowメソッドの部分を説明しましょう。

  • Calcクラスのインスタンスを生成し、ローカル変数calcに代入する。
  • バーティカルボックスに2つの要素を入れる。 (1)ホリゾンルボックス(2)ラベル(初期値は空文字列、@answer変数にそのオブジェクトを代入しておく)
  • ホリゾンルボックスには2つの要素を入れる。 (1)エントリー、そのオブジェクトは@eに代入(2)ホリゾンルボックス
  • 内側のホリゾンルボックスにはボタンを3つ入れる。
  • 「計算」ボタンはクリックされたときに@answer(これはラベルオブジェクト)のテキストにエントリの文字列@e.textから計算した結果を代入する
  • 「クリア」ボタンはクリックされたときにエントリの文字列@e.textを空文字列にする
  • 「終了」ボタンはクリックされたときにウィンドウ・オブジェクトを閉じ(w.destroy)、libuiのメインループを終了させ(LibUI.quit)0を返す

実行すると次のような画面になります。 「(2+3)*4」を実行したところです。

Calc

プログラムの大筋はShoesと似ている感じがします。

表をウィンドウ上に作るためのメソッドが用意されています。 二重配列で表のデータを作り、その配列を渡すことでウィンドウ上に表が現れます。

まず、text_columnメソッドでタイトル行を定義し、表本体は二重配列をcell_rowsメソッドに引数として渡します。

以下に例を示します。 この例は政府統計から取ってきたもので、通信機器利用を世帯単位で調査したものです。

政府統計へのリンク

require 'glimmer-dsl-libui'

include Glimmer

tbl = [
  ["20-29","79.0","99.0","41.8","30.9","51.5","4.6","0.8"],
  ["30-39","74.9","99.0","51.5","44.1","47.3","3.5","0.9"],
  ["40-49","78.1","97.8","53.0","37.6","43.1","3.6","1.7"],
  ["50-59","80.2","96.4","46.0","32.4","27.8","3.4","2.4"],
  ["60-69","75.3","94.3","36.3","26.4","14.3","1.4","2.6"],
  ["70-79","68.7","86.7","26.5","20.4","9.1","1.9","5.5"],
  ["80-","67.5","84.5","25.6","22.0","8.8","2.1","5.7"] 
  ]
  
window('インターネット利用機器調査', 800, 600) {
  margined true
  vertical_box{
    label("政府統計 令和3年通信利用動向調査より") {
      stretchy false
    }
    label("※ 複数回答あり") {
      stretchy false
    }
    horizontal_box {
      table {
        text_column('世帯主年齢')
        text_column('PC')
        text_column('携帯スマホ')
        text_column('タブレット')
        text_column('テレビ')
        text_column('ゲーム機')
        text_column('その他')
        text_column('無回答')
      
        cell_rows tbl
      }
    }
  }
}.show

実行すると次のような画面が現れます。

政府統計の表

ここで用いたのは基本的なメソッドのみです。 他のメソッドなどについてはGlimmer-dsl-libuiのドキュメントを参考にして下さい。

蛇足ですが、この統計を見ると携帯・スマホが普及していることがわかります。 これから、ウェブサイトのスマホ対応は必須だということが分かりますね。 政府統計は公開されているので、個人や会社がコストをかけずに統計を手に入れることができ、便利です。 マーケティングのコストダウンにも繋がると思います。

図形の描画

図形描画のためのキャンバスにあたるコントロールがareaコントロールです。 areaの内部にpathコントロール、さらにpathの内部に正方形(square)長方形(rectangle)円(circle)円弧(arc)直線(line)ベジェ曲線(bezier)などを描くことができます。

また、静的な図形(一度描き、そのまま居座り続ける図形)だけでなく、動的な図形(書き直しのきく図形)も可能です。 動的な図形の場合は、その図形のために用いたメモリを描画後に解放し、メモリ効率を良くします。 ここでは、動的な図形は扱いませんが、難しくはないのでドキュメントを参考にしていただけば、すぐに理解できると思います。

図形描画の例を以下に示します。

require 'glimmer-dsl-libui'

include Glimmer
include Math

window('図形描画', 800, 600) {
  margined true
  
  vertical_box {
    area {
      path {
        arc(200, 300, 150, 90, 180, true)
        fill r: 200, g: 200, b: 255, a: 1.0
      }
      path {
        circle(200, 300, 150)
        rectangle(200,100,500,400)
        stroke r: 0, g: 0, b: 0
      }
      path {
        polygon(500,300-150, 500-150*cos(PI/6),300+150*sin(PI/6), 500+150*cos(PI/6), 300+150*sin(PI/6))
        fill r: 255, g: 200, b: 200, a: 1.0
      }
      path {
        polygon(500,300-150, 500-150*cos(PI/6),300+150*sin(PI/6), 500+150*cos(PI/6), 300+150*sin(PI/6))
        stroke r: 0, g: 0, b: 0
      }
    }
  }
}.show

areaはボックスの中に入れて使います。 pathはareaの中に入れ、更にその中にrectangleなどの図形を入れ、描き方(fillまたはstroke)を指定します。

上記のプログラムを実行すると次の画面が現れます。

図形描画

  • arc(中心, 半径, 描き始めの角度で右向きが0度で反時計回りに測る, 中心角, 円弧の角の進む方向で反時計回りがtrueで時計回りがfalse)
  • circle(中心, 半径)
  • rectangle(左上のx座標, 左上のy座標, 幅, 高さ) 座標はareaの左上隅が(0,0)で、上から下に向かう方向が正、左から右に向かう方向が正
  • polygon(各点のx座標, y座標, ... ...)
  • fill r:赤, g:緑, b:青, a:透明度 各色は0から255までの数(つまり8ビット)a(アルファ・チャンネル、不透明度)は0から1 (注:ドキュメントには明確には書いてない。が、おそらく合っていると思う)。図形の境界および内部を塗りつぶす
  • stroke r:赤, g:緑, b:青 各色は0から255まで。図形の境界線を描画

ここに書いていない図形のメソッドはドキュメントを参考にしてください。 また、アニメーションもできます。 例えばサンプルプログラムの「テトリス」を試してみてください。

データ結合

Glimmerは画面(View)とモデル(Model: データを保持しているもの)をプレゼンター(Presennter)が管理するMVPの方法をとることができます。 これはRailsなどのMVCと同じ考え方です。 具体的にはビューとモデルのデータに双方向の結合(バインディング)または片方向の結合を設定できます。 片方向の場合はモデルからビューへの方向のみ可能です。 それぞれ<=>または<=という演算子を用います。

まず、モデルを用意します。 モデルはRubyのオブジェクトで、ビューに対応させたいデータはattr_accessorで定義をしておきます。 例えば、インスタンス変数@dataをビューに対応させたいときは

class A
  attr_accessor :data
end

このようにします。 それにより、クラスAのインスタンスaとすると、a.data@dataを参照でき、a.data=@dataに代入することができます。

双方向のデータ結合をentryのテキストと行いたいときは

entry {
  text <=> [a, :data]
}

のように、右辺は配列で`[オブジェクト, インスタンス変数名のシンボル]とします。 左辺のtextはentryのプロパティ、すなわち入力された文字列です。

片方向のデータ結合は、例えば

label {
  text <= [a.data]
}

のようにします。

例としてentryとlabelがオブジェクトaのインスタンス変数@dataバインディングされたプログラムを紹介します。

require 'glimmer-dsl-libui'

include Glimmer

class A
  attr_accessor :data
  def initialize
    @data = ""
  end
end

window('バインディング', 800, 100) {
  margined true
  
  a = A.new
  vertical_box {
    entry {
      text <=> [a, :data]
    }
    label {
      text <= [a, :data]
    }
  }
}.show

同じデータがentryとlabelのテキストと結合されているので、エントリに文字列を打ち込むとラベルにもそれが反映されます。

データ結合

glimmer-dsl-libuiに付属の例

インストールしたglimmer-dsl-libuiのgemにはexampleフォルダに沢山の例が入っています。 インストール先は次のようにして調べられます。

$ gem Environment
RubyGems Environment:
  - RUBYGEMS VERSION: 3.3.7
  - RUBY VERSION: 3.1.2 (2022-04-12 patchlevel 20) [x86_64-linux]
  - INSTALLATION DIRECTORY: /(ユーザディレクトリ)/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0
... ... ...
... ... ...

INSTALLATION DIRECTORYと書いてあるのがgemのインストール先です。

この例を調べることにより、Glimmerの書き方が理解できます。 実行してみて面白いと思った例を以下にあげてみます。

  • area_based_custom_controls.rb
  • class_based_custom_controls.rb
  • control_gallery.rb
  • custom_draw_text.rb
  • dynamic_area.rb
  • editable_table.rb ⇒ ダブルクリックで編集できる
  • form_table.rb
  • histogram.rb
  • meta_example.rb
  • shape_coloring.rb
  • tetris.rb ⇒ 以前大流行したテトリス
  • timer.rb

実行してみて面白いだけでなく、プログラムを見ることによってGlimmerの書き方を習得することができます。

まとめ

Glimmer-dsl-libuiはShoes4と比べると起動が速く、精神的なストレスがありません。 まだ安定版がないので、使うかどうかを迷うかもしれませんが、開発版の段階でもある程度は実用になると思います。

Glimmerのベースになっているlibuiというライブラリ(こちらも開発段階)はGtk3ベースと書いてありました。 Gtk4になって2年くらいになるので、ぜひともGtk4に対応してもらいたいです。 Gtk3とGtk4ではいろいろな違いがあり、Gtk4はかなり進歩していますから。

なお、Glimmer-dsl-libuiは2022年10月15日の「福岡 mruby Kaigi」で特別賞を受賞しています。 今後の開発に期待したいと思います。

徒然Ruby(34)Shoes -- RubyとGUI

Rubyはグラフィックについて弱い印象があります。 しかし、グラフィックはデバイスに関することなので、言語そのものには直接の関係はないはずで、あるとすればライブラリです。 今後グラフィック関係のgemが開発されることに期待しましょう。

そのような状況の中で、現時点でグラフィックやグラフィック・ユーザ・インターフェース(GUI)のRubyをめぐる状況を調べていました。 今回はShoesというライブラリを取り上げます。

Shoesとは?

2022/11/15の時点で、Shoesには安定版のバージョン3.3と開発版のバージョン4がありますが、安定/開発だけではない違いがあります。 バージョン3はCを使っていましたが、バージョン4はJRubyのgemとなっています。つまり、Javaベースです。 また、その開発は現時点では活発とはいえません。 Shoes3の最後のコミットが2020年1月9日、Shoes4が2019年4月5日です。

今回はShoes4を試してみました。 PC環境はUBUNTU22.10です。 解説記事というよりは、使用記です。

インストール

Javaのインストール

Java(open-jdk)がインストールされているかを確認します。

$ java --version
openjdk 11.0.17 2022-10-18
OpenJDK Runtime Environment (build 11.0.17+8-post-Ubuntu-1ubuntu2)
OpenJDK 64-Bit Server VM (build 11.0.17+8-post-Ubuntu-1ubuntu2, mixed mode, sharing)

このようにコマンドラインからjava --versionでバージョン表示がされれば、すでにインストールはできています。 なお、これはUBUNTUでのデフォルトのバージョンです(OpenJDK-11JDK)。 もっと新しいバージョンもあります(最も高いバージョンは2022/11/15の時点でOpenJDK-20-JDK)ので、それを使いたい場合はaptでインストールします。

先程のコマンドでバージョンが表示されなければJavaをaptでインストールしてください。

JRubyのインストール

私の場合はrbenvを使ってRubyをインストールしているので、JRubyにもrbenvを使います。 インストール可能なバージョンを確認します。

$ rbenv install -l
2.6.10
2.7.6
3.0.4
3.1.2
jruby-9.3.4.0
mruby-3.0.0
rbx-5.0
truffleruby-22.1.0
truffleruby+graalvm-22.1.0

Only latest stable releases for each Ruby implementation are shown.
Use 'rbenv install --list-all / -L' to show all local versions.

JRuby-9.3.4.0があるので、それをインストールします。

$ rbenv install jruby-9.3.4.0

この段階で私のUBUNTUには、「Ruby 3.1.2」「JRuby 9.3.4.0」の2つのRubyがインストールされました。 この使い分けには「rbenv local」コマンドを使います。 あるディレクトリでJRubyを使いたい場合は、そのディレクトリに移動して

$ rbenv local jruby-9.3.4.0

とします。 このディレクトリでrubyを起動するとJRubyが呼ばれます。 なお、このときディレクトリ内にRubyバージョンを書いた隠しファイル「.ruby-version」が置かれ、rbenvはそれを手がかりにRubyを起動するのです。

Shoes4のインストール

JRubyを指定したディレクトリ(上記の rbenv local jruby-9.3.4.0 したディレクトリ)でshoesのgemをインストールします。

$ gem install shoes --pre
Fetching shoes-4.0.0.rc1.gem
Fetching shoes-core-4.0.0.rc1.gem
Fetching shoes-package-4.0.0.rc1.gem
Fetching furoshiki-0.6.1.gem
Fetching shoes-swt-4.0.0.rc1.gem
Successfully installed shoes-core-4.0.0.rc1
Successfully installed furoshiki-0.6.1
Successfully installed shoes-package-4.0.0.rc1
Successfully installed shoes-swt-4.0.0.rc1
Building native extensions. This could take a while...
Successfully installed shoes-4.0.0.rc1
Parsing documentation for shoes-core-4.0.0.rc1
Installing ri documentation for shoes-core-4.0.0.rc1
Parsing documentation for furoshiki-0.6.1
Installing ri documentation for furoshiki-0.6.1
Parsing documentation for shoes-package-4.0.0.rc1
Installing ri documentation for shoes-package-4.0.0.rc1
Parsing documentation for shoes-swt-4.0.0.rc1
Installing ri documentation for shoes-swt-4.0.0.rc1
Parsing documentation for shoes-4.0.0.rc1
Installing ri documentation for shoes-4.0.0.rc1
Done installing documentation for shoes-core, furoshiki, shoes-package, shoes-swt, shoes after 5 seconds
5 gems installed

間違って、Ruby 3.1.2 が起動するディレクトリでgem install shoesとすると、Shoes3のgemがインストールされるので注意してください。 また、このgemだけではShoes3は動かないようです。

Hello world

手始めはいつも「Hello world」の表示です。 次のプログラムをShoes4で動かします。

Shoes.app title: "Hello" do
  stack do
    para "Hello world"
  end
end
  • クラスShoesの特異メソッドappを呼び出すとグラフィック画面に表示するウィンドウを作成する
  • 引数のtitleはアプリケーション名(上部の「アクティビティ」や日付のあるバーに表示される)とウィンドウのタイトルになる
  • ブロックの中にウィンドウのパーツを書く(パーツをShoesではエレメントという)
  • stackは上下にエレメントを並べるコンテナ(このプログラムでは無くても良い)
  • paraは段落(paragraph)のことで、文字列を表示する

このプログラムをhello.rbのファイル名で保存し(Jrubyが動くディレクトリに保存し---以下Jrubyを前提とする)コマンドラインからshoesを起動すると次のような画面が現れる。

$ shoes hello.rb

hello

Shoes.app内のself

Shoesの使い方を説明します。

ウィンドウを作成するにはShoes.appの特異メソッドのブロックにエレメントを作成するメソッドを書きます。 このとき、ブロックのselfはShoes::APPクラスのオブジェクトに変更されます。 なお、Rubyの原則ではメソッドのブロックのselfはメソッド外側のselfと同じです。 この特異メソッドでは原則と異なる扱いになるように設定されているということです。

ブロック内の関数形式のメソッドはselfをレシーバとするので、Shoes.appのブロック内の関数形式のメソッドはShoes::APPクラスのインスタンスメソッドになります。 hello.rbで使ったparaというメソッドもShoes::APPインスタンスメソッドです。

hello.rbではstackメソッドも使いました。 stackメソッドのブロックではselfの変更はしません。 ほとんどのShoes4のメソッドはそのブロックでselfの変更をしませんが、マニュアルによるとwindowメソッドもselfの変更をするそうです。

エレメント

ウィンドウ内にエレメントを置くメソッドには次のようなものがあります。

  • para ⇒ 段落(テキストと考えて良い)
  • button ⇒ ボタン
  • edit_line ⇒ 一行入力の枠
  • oval ⇒ 円や楕円。その他にも図形を描画するメソッドlineやrectなどがある
  • list_box ⇒ リストボックス

他にも沢山エレメントがあるので、Shoesのマニュアルを参照してください。

これらのメソッドの返り値はそれぞれのオブジェクトを返します。 例えば、paraメソッドはShoes::Paraクラスのオブジェクトを返します。 このオブジェクトに対してメソッドを使うことができます。

@para = para "こんにちは" #=> 「こんにちは」をウィンドウ内に段落として表示
@para.text = "さようなら" #=> その段落の文字列を「さようなら」に変更する

どのようなメソッドがあるかを調べるにはAPIドキュメントを見れば良いのですが、なかなか探しにくいかもしれません。 Shoes::ParaクラスがAPIドキュメントでは書かれていませんが、これはShoes::TextBlockのサブクラスです。 このことは、ソースファイルを見るか、あるいはShoes::Para.ancestorsShoes::Para.superclassといったメソッドを実行して調べることで分かります。 Shoes::TextBlockのメソッドにはtexttext=があるので、これらはShoes::Paraでも使えることが分かります。

buttonメソッドには文字列の引数を与え、表示されたボタンのラベル(ボタンに書かれる文字列)を指定できます。 また、buttonメソッドが返すShoes::Buttonクラスのオブジェクトにはclickメソッドがあり、クリックされたときの動作を記述できます。

@button = button "ボタン"
@button.click do
  (ボタンがクリックされたときの動作を記述)
end

クリック時の動作はbuttonメソッドにブロックを付けてそこに記述することもできます。

スロット

スロットはエレメントを並べるためのコンテナで、フローとスタックがあります。

  • フロー(flow): エレメントを横に並べる
  • スタック(stack):エレメントを縦に並べる

エレメントはflowまたはstackメソッドのブロックに記述します。

電卓プログラム

簡単な電卓プログラムを書きました。 2つのファイルcalc.rblib_calc.rbから成ります。 calc.rbがShoesを使ってウィンドウを表示し、lib_calc.rbが文字列を構文解析して計算をします。

このプログラムを実行すると次のような画面が現れます。

$ shoes calc.rb

Shoes calc 初期状態

また、計算を実行すると次のようになります。

Shoes calc 計算式の入力と実行

  • 入力枠に数式を書く
  • 四則以外に累乗(**)、三角関数、指数関数、対数関数が可能
  • 前回計算した値を文字vで参照できる
  • 変数を使うことができる。a=10+2のようにイコールで代入する
  • 「計算」ボタンをクリックすると計算を実行する
  • 「クリア」ボタンをクリックすると入力枠内の文字列を消去する
  • 「終了」ボタンをクリックするとウィンドウを閉じる
  • タブを押すことによって入力枠やボタンをフォーカスが移動するので、ボタンにフォーカスをあててエンターキーを押すことでクリックの代わりにすることができる

以下にcalc.rbのプログラムを示します。

require_relative 'lib_calc.rb'

def get_answer a
  if a.instance_of?(Float) &&  a.to_i == a
    a.to_i.to_s
  else
    a.to_s
  end
end

Shoes.app title: "calc", width: 400, height: 80 do
  @calc = Calc.new
  flow do
    @edit_line = edit_line "", margin_left: 10
    @do_calc = button "計算", margin_left: 10
    @clear = button "クリア", margin_left: 3
    @close = button "終了", margin_left: 10
  end
  stack do
    @answer = para "", margin_left: 10
  end

  @do_calc.click do
    @answer.text = get_answer(@calc.run(@edit_line.text))
  end
  @clear.click {@edit_line.text = ""}
  @close.click {close}
end
  • Calcクラスはlib_calc.rbで定義されている
  • Calcのインスタンスメソッドrunは引数に文字列を与えるとその計算をして答え(Floatオブジェクト)を返す。 エラーが発生したときは答えの代わりにエラーメッセージを返す
  • get_answerメソッドは答えが整数のとき、Integerクラスに変えてから文字列にしている。 このことにより、例えば「12.0」でなく「12」という文字列にする
  • Shoes.appメソッドの中は2つのスロット(フローとスタック)を設定している
  • フローには入力枠、「計算」ボタン、「クリア」ボタン、「終了」ボタンを入れている。 それぞれ左マージンを10ピクセルまたは3ピクセル与え、エレメント間のスペースを作っている
  • スタックには答えを表示するための段落を設けている
  • @do_calcは「計算」ボタンのオブジェクト。 clickメソッドで、入力枠の文字列から@calc.runで計算し、get_answerで文字列化して@answerの段落エレメントの文字列に代入している
  • @clearは「クリア」ボタンのオブジェクトで、クリックされたときに@edit_lineオブジェクト(入力枠)の文字列を空文字列にする
  • @closeは「終了」ボタンのオブジェクトで、クリックされたときにcloseメソッドを呼び出す。 closeメソッドはウィンドウを閉じる。

Shoesのプログラムはこのように簡単です。 clickメソッドは、ボタンクリックのイベントに対するハンドラを定義しています。 この段階ではイベント処理をしているのではなく、イベント処理のハンドラのセットをしているだけです。

開発が活発でないのは残念ですが、電卓のような簡単なプログラムであれば開発には十分です。 ちょっと気になるのはJRubyの起動に時間がかかることです。

最後にlib_calc.rbのソースを示しますが、長いので説明は省略します。 なお、プログラムのコードはGitHubのBlog-about-Rubyレポジトリにあります。 ディレクトリは_example/shoes/です。

class Calc
  include Math

  def initialize
    @table = {}
    @value = 0.0
  end

  # calculate s
  # error => return error message
  # success => return the result as a string
  def run(s)
    a = parse(s)
    if a.instance_of? Float
      @value = a # keep the result of the calcukation.
      a = a.to_i if a.to_i == a
    end
    a.to_s
  end

  # error => return nil
  # success => return array like:
  # [[:id, "var"], [:=, nil], [:num, 12.34], [:+, nil], ... ... ...]
  def lex(s)
    result = []
    while true
      break if s == ""
      case s[0]
      when /[[:alpha:]]/
        m = /\A([[:alpha:]]+)(.*)\Z/m.match(s)
        name = m[1]; s = m[2]
        if name =~ /sin|cos|tan|asin|acos|atan|exp|log|sqrt|PI|E|v/
          result << [$&.to_sym, nil]
        else
          result << [:id, name]
        end
      when /[[:digit:]]/
        m = /\A([[:digit:]]+(\.[[:digit:]]*)?)(.*)\Z/m.match(s)
        result << [:num, m[1].to_f]
        s = m[3]
      when /[+\-*\/()=]/
        if s =~ /^\*\*/
          result << [s[0,2].to_sym,nil]
          s = s[2..-1]
        else
          result << [s[0].to_sym, nil]
          s = s[1..-1]
        end
      when /\s/
        s = s[1..-1] 
      else
        @error_message = "Unexpected character."
        result = nil
        s = "" # remove the rest of the string.
      end
    end
    result
  end

  # BNF
  # program: statement;
  # statement: ID '=' expression
  #   | expression
  #   ;
  # expression: expression '+' factor1
  #   | expression '-' factor1
  #   | factor0
  #   ;
  # factor0: factor1
  #   | '-' factor1
  #   ;
  # factor1: factor1 '*' power
  #   | factor1 '/' power
  #   | power
  #   ;
  # power: primary ** power
  #   | primary
  #   ;
  # primary: NUM | 'PI' | 'E' | '(' expression ')' | function '(' expression ')' | 'v';
  # function: 'sin' | 'cos' | 'tan' | 'asin' | 'acos' | 'atan' | 'exp' | 'log' ;

  # parser
  # error => return error message
  # success => return the result of the calculation (Float)
  def parse(s)
    tokens = lex(s)
    return @error_message unless tokens # lex error
    tokens.reverse!
    a = statement(tokens) # error
    return "syntax error." unless tokens == []
    a ? a : @error_message
  end

  private

  # error => return false and the error message is assigned to @error_message
  # success => return the result of the calculation (Float)
  def statement(tokens)
    token = tokens.pop.to_a
    case token[0]
    when :id
      a = token[1]
      b = tokens.pop.to_a
      if  b[0] == :'='
        return false unless c = expression(tokens)
        install(a, c)
        c
      else
        tokens.push(b) if b[0]
        tokens.push(token)
        expression(tokens)
      end
    when nil # token is now empty.
      syntax_error
      false
    else
      tokens.push(token)
      expression(tokens)
    end
  end

  def expression(tokens)
    return false unless (a = factor0(tokens))
    while true
      token = tokens.pop.to_a
      case token[0]
      when :'+'
        b = factor1(tokens)
        unless b
          break false
        end
        a = a+b
      when :'-'
        b = factor1(tokens)
        unless b
          break false
        end
        a = a-b
      when nil
        return a
      else
        tokens.push(token)
        break a
      end
    end
  end

  def factor0(tokens)
    token = tokens.pop.to_a
    case token[0]
    when :'-'
      b = factor1(tokens)
      b ? -b : false
    when nil
      syntax_error
      false
    else
      tokens.push(token)
      factor1(tokens)
    end
  end

  def factor1(tokens)
    return false unless (a = power(tokens))
    while true
      token = tokens.pop.to_a
      case token[0]
      when :'*'
        b = power(tokens)
        unless b
          break false
        end
        a = a*b
      when :'/'
        b = power(tokens)
        unless b
          break false
        end
        if b == 0
          @error_message = "Division by 0.\n"
          break false
        end
        a = a/b
      when nil
        break a
      else
        tokens.push(token)
        break a
      end
    end
  end

  def power(tokens)
    return false unless (a = primary(tokens))
    token = tokens.pop.to_a
    case token[0]
    when :'**'
      b = power(tokens)
      if b
        a**b
      else
        false
      end
    when nil
      a
    else
      tokens.push(token)
      a
    end
  end

  def primary(tokens)
    token = tokens.pop.to_a
    case token[0]
    when :id
      a = lookup(token[1])
      @error_message = "Variable #{token[1]} not defined.\n" unless a
      a ? a : false
    when :num
      token[1]
    when :PI
      PI
    when :E
      E
    when :'('
      b = expression(tokens)
      return false unless b
      unless tokens.pop.to_a[0] == :')'
        syntax_error
        return false
      end
      b
    when :sin, :cos, :tan, :asin, :acos, :atan, :exp, :log, :sqrt
      f = token[0]
      unless tokens.pop.to_a[0] == :'('
        syntax_error
        return false
      end
      b = expression(tokens)
      return false unless b
      unless tokens.pop.to_a[0] == :')'
        syntax_error
        return false
      end
      method(f).call(b)
    when :v
      @value
    when nil
      syntax_error
      false
    else
      syntax_error
      false
    end
  end

  def install(name, value)
    @table[name] = value
  end
  def lookup(name)
    @table[name]
  end

  def syntax_error
    @error_message = "syntax error."
  end
end

徒然Ruby(33)Rails7 システムテスト

Rails7におけるシステムテストについて書きます。

テンプレートの作成

コマンドラインからシステムテストのテンプレートを作成します。

$ bin/rails generate system_test words
    invoke test_unit
    create test/system/words_test.rb
$

/test/system/words_test.rbシステムテストを記述するファイルです。 ファイルを開くとWordsTestクラスの定義があります。 テストはその中に書いていきます。

require "application_system_test_case"

class WordsTest < ApplicationSystemTestCase
... ... ...
... ... ...
end

アサーション

クラスの親子関係は次のようになります。

WordsTest < ApplicationSystemTestCase < ActionDispatch::SystemTestCase < ActiveSupport::TestCase

このことから、アサーションについて次のことがわかります。

  • ActiveSupport::TestCaseがインクルードしたアサーションシステムテストでも使うことができる(以前の3つのテストと同様)
    • assert_changes
    • assert_difference
    • assert_no_changes
    • assert_no_difference
    • assert_not
    • assert_nothing_raised
  • 機能テストや結合テストではActionDispatch::IntegrationTestスーパークラスになっていたが、システムテストではスーパークラスではない。 ActionDispatch::IntegrationTestがインクルードしていたActionDispatch::Assertions::ResponseAssertionsActionDispatch::Assertions::RoutingAssertionsは、 システムテストではインクルードされないので、以下のアサーションは使えない。
    • assert_response
    • assert_redirect_to
    • assert_generates
    • assert_recognizes
    • assert_routing
  • ActionDispatch::SystemTestCaseCapybara::Minitest::Assertionsをインクルードしているので、Capybaraのアサーションを使うことができる。 このアサーションRailsAPIリファランスには説明がない。 CapybaraのAPIリファランスにその説明がある。 よく使いそうなアサーション(個人の独断による)を以下に示す。
    • assert_selector(セレクタ名, オプション): セレクタが送られてきたHTMLページ(以下ページと略記)にあるかどうかをチェック。 オプションでよく使うのは「text: 文字列または正規表現」「count: 整数」(そのセレクタの出現回数のチェック)「visible: (true|falseなど)」(可視かどうか)など。
    • assert_text(文字列, オプション): 文字列がページにあるかどうかをチェック。 オプションには「count: 整数」などが可。 デフォルトでは文字列は「可視文字列」、つまり改行やコントロールコードなどは含まない
    • assert_button(文字列): 引数がボタンのidまたは表示文字列であるようなボタンがあるかどうかのチェック
    • assert_field(文字列, オプション): 入力フィールドで引数の文字列にそのラベルまたは名前またはidが一致するものがあるかどうかをチェック。 オプションには「type: "textarea"」などの入力タイプの指定ができる
    • assert_link(文字列, オプション): リンクで、そのidまたはテキストが引数の文字列に一致するものがあるかどうかをチェック。 オプションにはリンク先「href: 文字列または正規表現」を指定できる
    • assert_table(文字列, オプション): 引数の文字列に一致するidまたはキャプションを持つ表があるかどうかをチェック。 オプションでは表の内容のチェックができるが、詳しくはAPIリファランスを参照してほしい
    • assert_title(文字列): 文字列に一致するタイトルを持っているかどうかをチェック

良く使われるメソッド

結合テストで使えたgetメソッドなどはシステムテストでは使えません。 代わりにCapybaraのメソッドを使います。 良く使われると思われるメソッドを以下に簡単に説明します。 その詳細や、他のメソッドについてはCapybaraのドキュメントを参照してください。

  • visit(URLまたはパス): URLへのGETメソッドでのアクセスをシミュレート
  • fill_in(フィールド名, with: 文字列): フォーム・フィールドに文字列をセットする
  • click_link(テキスト): リンクのクリックをシミュレート。テキストはそのaタグのコンテンツ(開始タグと終了タグで挟まれた部分)
  • click_button(テキスト): ボタンのクリックをシミュレート。テキストはそのボタンに表示される文字列
  • click_on(テキスト): リンクまたはボタンのクリックをシミュレート。click_linkclick_buttonの両方をまとめたメソッド
  • accept_confirm ブロック: ブロック(多くはリンクやボタンのクリック)を実行したときに、確認ダイアログが現れる場合、確認を受け入れる(OKをクリックなど)
  • dismiss_confirm ブロック: ブロック(多くはリンクやボタンのクリック)を実行したときに、確認ダイアログが現れる場合、確認を拒否する(NOやCancelをクリックなど)

最後の2つは、例えば削除に対する「are you sure?」などの確認ダイアログが出る場合に必要なメソッドです。 click_onだけではエラーになります。

ここまでで説明したメソッドは、ユーザがクリックやキーボード入力するのをシミュレートします。 テストでは、ユーザの行動をシミュレートしますので、現実の運用に近い形でのテストになります。

システムテストの例

ここでは、ルートにアクセスしてから、検索、追加、変更、削除を一通り行うことをシミュレートしてテストします。 プログラムリストにコメントしてありますが、次の順に推移することを想定しています。

  • ルートにアクセス
  • 正規表現「.」で検索
  • 単語を追加
  • その単語を変更
  • その単語を削除
  • index画面に戻る
require "application_system_test_case"

class WordsTest < ApplicationSystemTestCase
  test "flow from the root through every action" do
    # ルートにアクセス=>indexアクションへ
    visit root_url
    assert_selector "h1", text: "単語帳"

    # searchアクションへ
    fill_in "search", with: "."
    click_button "検索"

    assert_selector "h1", text: "単語検索結果"
    ["tall","高い","house",""].each do |s|
      assert_text s
    end

    # newアクションへ
    click_link "追加"

    fill_in "word[en]", with: "stop"
    fill_in "word[jp]", with: "止まる"
    fill_in "word[note]", with: "The machine stopped.\n機械が止まった。\n"
    wc = Word.count

    # createアクションへ
    click_button "作成"

    # showアクションへリダイレクト
    assert_text "単語を保存しました"
    assert_equal 1, Word.count-wc
    ["stop","止まる","The machine stopped.","機械が止まった。"].each do |s|
      assert_text s
    end

    # editアクションへ
    click_link "変更"

    fill_in "word[jp]", with: "止める"
    fill_in "word[note]", with: "I stopped speaking.\n私は話すのをやめた。\n"

    # updateアクションへ
    click_button "変更"

    # showアクションへリダイレクト
    ["stop","止める","I stopped speaking.","私は話すのをやめた。"].each do |s|
      assert_text s
    end
    wc = Word.count

    # deleteアクションへ
    accept_confirm do
      click_link "削除"
    end

    # indexアクションに戻る
    assert_text "単語を削除しました"
    assert_equal -1, Word.count-wc
    assert_selector "h1", text: "単語帳"
  end
end

プログラムを読んでもらえば、ユーザがクリックしたりキーボード入力するのをそのままシミュレートしていることが分かると思います。 いくつかポイントになる点を書きます。

assert_textでは可視文字列のみをチェックする(デフォルト動作)ので、改行をチェックすることはできません。 2行にわたる文字列は、2回に分けて1行ずつチェックします。

createアクションでデータベースの単語数が増えるのをassert_equalでチェックしました。 assert_differenceでも可能ですが、そのブロック内にデータベースに書き込むタイミングを含まなければならないことに注意が必要です。 次のプログラムではフェイルする可能性が高いです。

assert_difference("Word.count") do
  click_button "作成"
end
assert_text "単語を保存しました"

これは、ボタンクリック直後ではまだデータベースの書き込みが終わっていないため、Word.countがブロックの前後で変化していないためです。 これは次のように書くことで解決できます。

assert_difference("Word.count") do
  click_button "作成"
  assert_text "単語を保存しました"
end

assert_textが画面が変わるまで待ってからチェックをしてくれるので、ブロック実行中にデータベース書き込みは行われています。 Capybaraでは、このようにアサーションが画面遷移を待つようになっているので、それに注意が必要です。 assert_differenceを使うことは可能ですが、どちらかといえばassert_equalを使うほうが分かりやすいように思います。

削除メニューをクリックすると確認ダイアログが現れます。 Capybaraではこのようにダイアログが現れる場合はaccept_confermのブロックにリンクやボタンのクリックを書かなければなりません。 そうでないとエラーになります。

もし、ダイアログでCancelやNoボタンを押すことをシミュレートしたい場合はdismiss_confirmを使います。

テストの実行

テストの実行にはtest:systemを引数にbin/railsを実行します。

$ bin/rails test:system
yarn install v1.22.19
[1/4] Resolving packages...
success Already up-to-date.
Done in 0.11s.
yarn run v1.22.19
$ esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets

  app/assets/builds/application.js      394.1kb
  app/assets/builds/application.js.map  721.7kb

Done in 0.20s.
yarn install v1.22.19
[1/4] Resolving packages...
success Already up-to-date.
Done in 0.10s.
yarn run v1.22.19
$ sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules
Done in 1.86s.
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 20243

# Running:

DEBUGGER: Attaching after process 8081 fork to child process 8111
Capybara starting Puma...
* Version 5.6.5 , codename: Birdie's Version
* Min threads: 0, max threads: 4
* Listening on http://127.0.0.1:38767
.

Finished in 3.190794s, 0.3134 runs/s, 5.9546 assertions/s.
1 runs, 19 assertions, 0 failures, 0 errors, 0 skips

多くのメッセージが出ていますが、その多くはテストの準備作業です。 テスト自体は下から14行目の「Running 1 tests in a single process (parallelization threshold is 50)」以下になります。 下から4行目のドットがテストの成功を示しています。 さらに、その詳細は、最終行のテスト1、アサーション19、フェイルとエラーは0だったことが分かります。

ヘッドレスブラウザ

システムテストではブラウザ画面が表示されます。 これは画面を見ることができて良いともいえますが、逆に煩わしいともいえます。 画面表示の部分を無くしたヘッドレスブラウザを使うことでこの問題を解決できます。

設定についてはRails Guideを参照してください。

まとめ

この記事の例から分かると思いますが、システムテストでは実際の運用に近い形でのテストができます。 本当にシミュレーションという感じです。 演劇でいえば舞台稽古、コンサートでいえばリハーサルのようなものです。

したがって、Railsウェブアプリケーションは最終的にはシステムテストを書かなければならないと思います。 そうであれば、結合テストは省略される可能性もあるでしょう。 これはそれぞれのプロジェクトでの考え方になると思います。

今回でRailsの記事が7本になりました。 ここでRailsは終わりになります。 次回以降は別のトピックを取り上げたいと思います。

徒然Ruby(32)Rails7 テスト

前回作ったWordbook(リソースフル)のテストを書いてみます。 RailsのテストはminitestをRails用に拡張したものです。

テストの種類

Railsでは、4つのテストが用意されています。

今回の記事ではシステムテストを除いた3つのテストについて書きます。 システムテストは次回の記事で扱います。

日本語訳は書籍によって異なるかもしれません。 例えば「結合テスト」を「統合テスト」と訳すこともあります。 この記事の訳は「Railsガイド]に基づいています。

また、これらの用語は「一般論としてのテスト」にも使われますが、その使い方には幅があります。 Railsの場合は

を指します。

モデルのテスト

モデルのテストでは

  • データベースから読み出しが正しくできているか
  • データベースに保存できているか
  • バリデーションが正しく機能しているか

などをテストします。

これらは、コントローラやビューから切り離して単体でテストすることができます。 その意味では「単体テスト」です。 また、データベースまわりのテストが主になるので、そのためのいくつかの仕組みが用意されています。

フィクスチャ

フィクスチャはいわゆるサンプルデータです。 Yaml形式でデータを記述しておくと、Railsがテスト前にデータベースに保存してくれます。 /test/fixtures/words.ymlに2つほどサンプルデータを書いてみました。

# Word model fixtures

tall:
  en: tall
  jp: 高い
  note: |
    He is tall.
    彼は背が高い。

house:
  en: house
  jp:note: |
    I stayed in my house.
    私は家にいました。

Yamlはコロン(:)でキーと値のペアを表します。 Rubyのデータ構造でいえばハッシュにあたります。

{tall: {en: "tall", jp: "高い", note: "He is tall.\n彼は背が高い。\n"}}
{house: {en: "house", jp: "", note: "I stayed in my house.\n私は家にいました。\n"}

Yamlの縦棒(|)は次の行からのデータが改行も含めてのデータであることを示します。 :tall:houseはフィクスチャを指定するときの名前になります。 データベースに登録されるデータはenからnoteまでの部分です。

フィクスチャを変数に代入するには、そのモデル名の複数形を小文字で書いたメソッドを用います。

word = words(:tall)

これで変数wordにフィクスチャtallのモデルが代入されます。

モデルのテスト

モデルのテストは/test/models/word_test.rbに書きます。

require "test_helper"

class WordTest < ActiveSupport::TestCase
  test "fixture tall should be read" do
    word = Word.find_by(en: "tall")
    assert_equal "高い", word.jp
    assert_equal "He is tall.\n彼は背が高い。\n", word.note
  end

  test "A valid data should be saved uniquely" do
    w1 = Word.new(en: "room", jp: "部屋", note: "")
    w2 = Word.new(en: "room", jp: "空き", note: "")
    assert w1.save
    refute w2.save
  end

  test "three invalid data should not be saved" do
    w1 = Word.new(en: "", jp: "空き", note: "")
    w2 = Word.new(en: "@@@", jp: "アットマーク", note: "")
    w3 = Word.new(en: "page", jp: "", note: "Turn to page four.\n4ページを開きなさい。")
    [w1, w2, w3].each do |word|
      assert word.invalid?
      refute word.save
    end
  end
end

テストはWordTestクラスの定義の中に書きます。 クラスWordTestActiveSupport::TestCaseのサブクラスです。 さらに、ActiveSupport::TestCaseMinitest::Testのサブクラスです。 したがって、WordTestクラス内では、それらの祖先クラスのすべてのメソッドを使うことができます。

アサーションの詳細は上記のリンクやRails Guideを参照して下さい。 また、minitestについては本ブログまたは「徒然Ruby」(GitHub Pages)にも記事があります(GitHub Pagesの方が新しい版)。

カテゴリー(categories)の「minitest」をご覧ください。

クラス内の各テストを記述するのにtestメソッドを使っています。 これは「"test_"+引数」を名前にしたメソッドを定義します。 つまり、次の2つは同じことになります。

test "show hello" do
  print "Hello world.\n"
end

def test_show_hello
  print "Hello world.\n"
end

testメソッドの引数にはそのテストの内容や目的を書きます。 それによって、defステートメントよりも「テストらしくみえる」「テストの内容を示すことができる」ということが可能です。

2つめのテストを見てみましょう。 ここでは、assertrefuteの2つのメソッドが使われています。 両者ともminitestのメソッドです。

  • assert ⇒ 引数が真ならばテストをパス、偽ならばフェイル
  • refute ⇒ 引数が偽ならばテストをパス、真ならばフェイル

テストがフェイルした場合は、そこでテストは打ち切られ、フェイルのメッセージが画面に出力されます。

test "A valid data should be saved uniquely" do
  w1 = Word.new(en: "room", jp: "部屋", note: "")
  w2 = Word.new(en: "room", jp: "空き", note: "")
  assert w1.save
  refute w2.save
end

w1とw2は英単語が同じで、オブジェクトとしては異なるWordオブジェクトです。 w1をデータベースにsaveメソッドで保存するのは成功しますので、w1.saveは真を返します。 assert w1.saveは成功するわけです。

次にw2を保存しようとすると、バリデーションに引っかかります。 英単語はユニーク、すなわちデータベース中にひとつしか許されませんので、w2は保存できません。 このときsaveメソッドは偽を返します。 refuteは引数が偽のときにパスするので、このテストも通過するはずです。

次に、1番めのテストを見てみましょう。 ここでは、あらかじめデータベースに保存されているフィクスチャの呼び出しをチェックしています。 assert_equalは2つの引数をとり、それらが==であるときにテストをパスします。 テストでは英単語がtallであるデータを読み出して、日本語訳(jp)と備考(note)がフィクスチャと一致するかをテストしています。

3つめのテストはバリデーションのテストです。

  • 英単語(en)は空ではいけない
  • 英単語(en)はアルファベットのみの文字列でなければいけない
  • 日本語訳(jp)は空ではいけない

これらに違反するWordモデルを作成して、invalid?メソッドでインバリッドであるかをテストし、さらにsaveが失敗することをテストします。

このように、モデルのテストは主に

  • 読み書きが正しくできるか
  • バリデーションが正常に機能するか

を見ます。

より複雑なデータベースでは、テーブル間のリレーションが機能しているかどうかもテストします。 ここではそのような複雑なテストについては省略します。

テストの実行

テストの実行はコマンドラインから./bin/rails test (テストプログラム)の形で実行します。

$ ./bin/rails test test/models/word_test.rb
Running 3 tests in a single process (parallelization threshold is 50)
Run options: --seed 543

# Running:

...

Finished in 0.145926s, 20.5584 runs/s, 68.5281 assertions/s.
3 runs, 10 assertions, 0 failures, 0 errors, 0 skips

途中のドットはテストが成功したことを表します。 テストが3つあったのでドットも3つ表示されます。 もしテストがフェイルすればFが、テストプログラムに誤りがあればE(エラー)がドットの代わりに表示されます。

最後の行にテスト結果が出ています。

  • 3 runs ⇒ 3つのテストを実行
  • 10 assertions ⇒ 10個のアサーション(個別のテスト)を実行
  • 0 failures ⇒ 0個のフェイル
  • 0 errors ⇒ 0個のエラー

機能テスト

機能テスト(functional test)はコントローラの動作をテストします。 また、ルーティングとビューはコントローラと一体ですので、それらのテストもここで行うことができます。 テストは/test/controllers/words_contraller_test.rbに記述します。

ルーティングとコントローラのテストとしては

  • HTTPリクエストに対して正しいコントローラとアクションが呼び出されているか
  • コントローラが正しくHTTPレスポンスを返す、またはリダイレクトしているか
  • その他

が考えられます。

また、ビューのテストとしては、正しいHTMLタグが必要数だけ出力されているか、が考えられます。

テスト全体を以下に示します。

require "test_helper"

class WordsControllerTest < ActionDispatch::IntegrationTest
  test "should get index" do
    get words_url
    assert_response :success
  end

  test "should get show" do
    get word_url(words(:tall))
    assert_response :success
    assert_select "nav a", {text: /変更|削除/, count: 2}
    assert_select "nav a.disabled", {text: /変更|削除/, count: 0}

    id = words(:tall).id+words(:house).id
    get word_url(id)
    assert_response :success
    assert_select "nav a.disabled", {text: /変更|削除/, count: 2}
    assert_equal "データベースにはid=#{id}のデータは登録されていません", flash.now[:alert]
  end

  test "should get new" do
    get new_word_url
    assert_response :success
  end

  test "should create word" do
    assert_difference("Word.count") do
      post words_url, params: { word: { en: "stop", jp: "止まる", note: "" } }
    end
    assert_redirected_to word_path(Word.last)
  end

  test "should get edit" do
    get edit_word_url(words(:tall))
    assert_response :success
  end

  test "should update word" do
    word = words(:tall)
    patch word_url(word), params: {word: {en: "tall", jp: "背が高い", note: ""}}
    assert_redirected_to word_path(word)
  end

  test "should delete word" do
    word = words(:tall)
    assert_difference("Word.count", -1) do
      delete word_url(words(:tall))
    end
    assert_redirected_to words_path
  end

  test "should get search" do
    get words_search_url, params: {search: "."}
    assert_response :success
  end
end

テストを記述するクラスはWordsControllerTestで、ActionDispatch::IntegrationTestのサブクラスです。 なお、このActionDispatch::IntegrationTestは機能テストのクラスのスーパークラスであるだけでなく、結合テストのクラスのスーパークラスにもなっています。 したがって、両テストで同じメソッドを使うことができます。 クラスの親子関係は次のようになります。

WordsControllerTest < ActionDispatch::IntegrationTest < ActiveSupport::TestCase < Minitest::Test

機能テストでは、アサーション単体テストよりも増えています。

アサーションの詳細は上記のリンクまたはRails Guideを参照してください。

HTTPメソッドをシミュレートするメソッド

機能テストではHTTPメソッドをシミュレートするメソッドを使うことができます。 それらの名前はHTTPメソッドと同じです。 例えばgetはHTTPのGETメソッドをシミュレートします。

get パス名(URL), オプション

の形で用います。 オプションではパラメータなどを渡すことができます。 詳細はAPIリファランスを参照してください。

indexアクションのテストを見てみましょう

test "should get index" do
  get words_url
  assert_response :success
end

getメソッドでwords_urlすなわちhttp://www.example.com/wordsにアクセスします。 なお、words_urlRailsのヘルパーメソッドで、words_pathのような働きをします。 words_path絶対パス/wordsを返すのに対し、words_urlは絶対URLを返します。 他のルーティングprefixに対しても同様のXXX_urlメソッド(XXXはPrefix)を使うことができます。 なお、テストでは仮のホストとしてhttp://www.example.comが用いられています。

このアドレスにGETメソッドでアクセスするとindexアクションにルーティングされます。

assert_responseはHTTPレスポンスのステータスコードをチェックします。 :successは200番台のコードにマッチします。

このテストではindexアクションにアクセスすると200番台のステータスコードでデータが送られてくることを確認しました。 もちろん、もっと詳しくチェックすることも可能です。 例えば、HTMLで送られてくるタグや文字列をチェックすることもできます。 しかし、あまり細かくテストしても意味はありませんから、この程度でも十分だと思います。

getの他に、post、patch、deleteなどのメソッドがあり、それぞれPOST、PATCH、DELETEのHTTPメソッドのリクエストをシミュレートします。 postの例としてcreateアクションのテストを見てみましょう。

test "should create word" do
  assert_difference("Word.count") do
    post words_url, params: { word: { en: "stop", jp: "止まる", note: "" } }
  end
  assert_redirected_to word_path(Word.last)
end

createアクションへは、あらかじめnewアクションで送られたフォームにデータを書き入れたものをPOSTメソッドで送ります。 そのデータはparamsキーを持つハッシュの形で送ります。

word: { en: "stop", jp: "止まる", note: "" }

これは、word[en]が「stop」、word[jp]が「止まる」、word[note]が空文字列のパラメータを表します。 words_urlhttp://www.example.com/wordsになり、ここにPOSTでアクセスするとcreateアクションにルーティングされます。

ここでは、assert_differenceメソッドでテストしています。 このメソッドは「ブロック実行後の引数の値」から「ブロック実行前の引数の値」を引いた数をチェックします。 デフォルトでは差が1です。 createアクションが実行されると、データベースのWordレコードが1つ増えますから、レコード数「Word.count」の差が1になります。 無事データが保存されればこのテストは通過します。

また、createアクションが成功するとリダイレクトが起こります。 最後に追加したWordモデルのshowアクションにリダイレクトされるので、そのアドレスword_path(Word.last)へのリダイレクトかどうかをチェックしています。 なお、アドレスにword_url(Word.last)を用いても構いません。

削除の場合も同様にassert_differenceを使い、差が-1になるかどうかをチェックします。

ビューのテスト

ビューをテストするにはassert_selectメソッドを使います。 このメソッドはRails guideに書かれていますが、APIリファランスには記述がありません。 このメソッドはRails::Dom::TestingというGemのメソッドです。

これらの情報を参考にしてください。

assert_select CSSセレクタ [, テスト]

という形で使います。

第2引数の「テスト」を省略すると、そのセレクタがHTMLデータに存在すればテストは通過します。 「テスト」がある場合は、そのテスト結果によりパス、フェイルが決まります。 「テスト」は、ハッシュで与えるのが基本的です。

その他にもありますが、詳細はドキュメントを参照してください。 ここではshowアクションのテストを見てみます。

test "should get show" do
  get word_url(words(:tall))
  assert_response :success
  assert_select "nav a", {text: /変更|削除/, count: 2}
  assert_select "nav a.disabled", {text: /変更|削除/, count: 0}

  id = words(:tall).id+words(:house).id
  get word_url(id)
  assert_response :success
  assert_select "nav a.disabled", {text: /変更|削除/, count: 2}
  assert_equal "データベースにはid=#{id}のデータは登録されていません", flash.now[:alert]
end

フィクスチャのtallをパラメータにしてGETアクセスし、showアクションにルーティングさせます。 レスポンスは正常になるはずですので、assert_responseでチェックします。 その場合「変更」と「削除」メニューが「disabledでなく」表示されるので、2つのassert_selectでテストします。

  • まず、ナビゲーションバーに「変更」または「削除」へのリンク(aタグ)が2個あることをテスト
  • 次に、その「変更」または「削除」へのリンクでdisabledのものは0個であることをテスト

次に「データベースに存在しないid」でshowアクションにアクセスした場合をチェックします。 初期状態ではtallとhouseのフィクスチャのみがデータベースにあるので、それらのidの和であれば、データベースにそのidは存在しないはずです。 そのidをパラメータにしてアクセスすると、レスポンスはsuccessですが、データは表示されません。 また、ナビゲーションバーの「変更」と「削除」リンクはdisabledになっています。 assert_selectでそれをチェックします。 また、フラッシュが表示されるはずなので、assert_equalでフラッシュの内容をチェックします。

今回はshowメソッドのみで「変更」「削除」のenabled/disabledのチェックをしましたが、他のアクションでは常にdisabledのはずなので、それをテストに加えるのも良いと思います。

ビューのテストはあまりしつこくやっても意味は無いのですが、このように画面によってdisabledになるようなリンクをテストするのは有意義です。

結合テスト

結合テストでは複数のアクションがアクセスの流れの中で正しく呼び出されるかをテストします。 機能テストとの違いは、ひとつのアクションのテストか、複数かの違いになります。

まず、雛形を作ります。

$ ./bin/rails generate integration_test word_flows
      invoke  test_unit
      create    test/integration/word_flows_test.rb

/test/integration/word_flows_test.rbにテストを記述します。 ここでは3つの流れについてテストをします。

  • 単語を作成: new ⇒ create ⇒ show
  • 単語を変更: edit ⇒ update ⇒ show
  • 単語を削除: delete ⇒ index

結合テストではリダイレクトのレスポンスが送られたときに、そのリダイレクト先にアクセスするfollow_redirect!メソッドを使います。

require "test_helper"

class WordFlowsTest < ActionDispatch::IntegrationTest
  test "flow from new to show through create" do
    # access to the new action
    get new_word_url
    assert_response :success
    # access to the create action
    post words_url, params: { word: { en: "stop", jp: "止まる", note: "" } }
    assert_response :redirect
    follow_redirect!
    # redirect to the show action
    assert_response :success
    assert_equal "単語を保存しました", flash[:success]
    assert_select "ul.list-group" do
      assert_select "li", "stop"
      assert_select "li", "止まる"
      assert_select "li", ""
    end
  end

  test "flow from edit to show through update" do
    # access to the edit action
    word = words(:tall)
    get edit_word_url(word)
    assert_response :success
    # access to the update action
    patch word_url(word), params: { word: { en: "tall", jp: "背の高い", note: "How tall is she?\n彼女はどのくらい背がありますか?" } }
    assert_response :redirect
    follow_redirect!
    # redirect to the show action
    assert_response :success
    assert_select "ul.list-group" do
      assert_select "li", "tall"
      assert_select "li", "背の高い"
      assert_select "li", "How tall is she?\n彼女はどのくらい背がありますか?"
    end
  end

  test "flow from delete to index" do
    # access to the delete action
    word = words(:house)
    delete word_url(word)
    assert_response :redirect
    follow_redirect!
    # redirect to the index action
    assert_response :success
    assert_select "h1", "単語帳"
  end
end

最初のテストだけ説明すれば十分だと思います。 2番めと3番めについてはソースコードを追ってみてください。

最初のテスト部分を再掲します。

test "flow from new to show through create" do
  # access to the new action
  get new_word_url
  assert_response :success
  # access to the create action
  post words_url, params: { word: { en: "stop", jp: "止まる", note: "" } }
  assert_response :redirect
  follow_redirect!
  # redirect to the show action
  assert_response :success
  assert_equal "単語を保存しました", flash[:success]
  assert_select "ul.list-group" do
    assert_select "li", "stop"
    assert_select "li", "止まる"
    assert_select "li", ""
  end
end
  • getメソッドを使い、newアクションにアクセスする
  • 応答はsuccess。入力フォームが表示されるがそのチェックは省略
  • 単語stopをパラメータにセットしてpostメソッドでcreateアクションにアクセス
  • 応答はredirect
  • follow_redirect!メソッドでリダイレクト先(showアクションになるはず)にアクセスする
  • 応答はsuccess
  • 「単語を保存しました」のフラッシュが表示されるはずなのでassert_equalでチェック
  • 保存された単語が順序なしリストで表示されるはずなので、それをチェック。 まず、assert_selectul.list-groupセレクタをキャッチする。 そのブロックでは、キャッチされたセレクタ内のみを対象としてassert_selectが働く。 リストの項目として「stop」「止まる」「」(空文字列)があるはずなのでチェック。 なお、assert_selectの第2引数が文字列のときは、そのセレクタ(HTMLタグ)で囲まれたコンテンツの文字列との一致をチェックする

このようにして、複数のアクションにまたがるフローをチェックできます。

結合テストではクライアントのリクエストをシミュレートしてそれに対する応答をチェックしました。 しかし、現実にはブラウザで表示された画面の中でボタンやリンクのクリックなどが行われます。 そこまでのシミュレートは結合テストではできません。 そのチェックはシステムテストならば可能です。 次回はシステムテストについての記事を掲載する予定です。