おもこん

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

徒然Ruby(39)Fiber

Fiberは「ノンプリエンプティブな軽量スレッド」とRubyのマニュアルに記載されています。

  • ノンプリエンプティブ(non preemptive)とは、マルチタスクの切り替えをプログラム自身にまかせること
  • プリエンプティブ(preemptive)とは、マルチタスクの切り替えをOSが行うこと

preemptiveは金融の用語のようです。 「先買いの、先買権のある」と辞書にありますが(ジーニアス英和辞典)、株式の売却時に優先的に買い取る権利を含む契約で、敵対勢力に株式を渡さないための方策だそうです。 このことについては、私は門外漢なので、正確な情報ではないことをお断りしておきます。 IT用語においては、「プリエンプティブ」はCPU時間を割り当てるときにOSが優先的にCPU時間を買取ってタスクに割り当てる、ということから使われるようになったのではないでしょうか。

さて、Fiberを使う場合、メインのプログラムとFiberのブロックの2つのタスクが動きます。 そして、プログラム中のresumeやyieldというメソッドがタスクの切り替えをします。 したがって、切り替えは完全にプログラムによってコントロールされており、その点でいつ切り替わるかはOS次第というプロセスとは異なります。 また、ここでいうタスクはRubyのThreadとは異なるので注意してください。

簡単なプログラム例

ファイバーの定義

ファイバーを定義するには、Fiber.newを使い、ファイバー自体はそのブロックに記述します。

fiber = Fiber.new do
  "abc".each_char do |c|
    print "#{c}\n"
    Fiber.yield
  end
  nil
end

このブロックはメインプログラムの中で呼ばれるfiber.resumeメソッドによって実行されます。

  • 最初にfiber.resumeが呼ばれたとき、ブロックの最初からFiber.yeildまでが実行される
  • Fiber.yieldの実行により、ブロックは一旦実行が止まり、メインルーチンのfiber.resumeの次からに実行が移る
  • 次にfiber.resumeが呼ばれたときは、ブロックのFiber.yieldから実行され、再びFiber.yieldに達するか、ブロックの最後に達するまでそれが続く その後は実行はメインルーチンのfiber.resumeの次に移る
  • ブロックの最後まで達したファイバーはfield.resumeが呼ばれても実行できない。これはエラーになる
簡単なプログラム例

次の例は、メインとファイバーのブロックで交互にprint文を実行するメソッドです。

def example1
  fiber = Fiber.new do
    "abc".each_char do |c|
      print "#{c}\n"
      Fiber.yield
    end
    nil
  end

  101.upto(103) do |j|
    fiber.resume
    print "#{j}\n"
  end
end

# main

example1

このプログラムの実行順を図にしました。

Fiberの実行順

実行結果はつぎのようになります。

$ ruby _example/example39.rb
a
101
b
102
c
103
$ 

ポイントは、Fiber.yieldfiber.resumeで切り替わるところです。

ファイバーの使いどころ

ファイバーはどのようなプログラムに適しているのでしょうか? ファイバーはコルーチンと呼ばれることもありますが、ウィキペディアコルーチンでは、

サブルーチンと異なり、状態管理を意識せずに行えるため、協調的処理、イテレータ、無限リスト、パイプなど、継続状況を持つプログラムが容易に記述できる。

と書かれています。

ここでは、このうちイテレータとパイプについて考えてみたいと思います。 なお、ここでの記述については、誤りを含んでいるかもしれませんので、ご注意ください。 また、誤りにお気づきの方はコメントでご指摘いただければありがたいです。

イテレータ

イテレータというと、Rubyのeachメソッドを思い浮かべるのではないでしょうか。 eachメソッドは、そのオブジェクトの要素を取り出してブロック・パラメータに代入し、繰り返しブロックを実行します。 このような繰り返し処理を「イテレータ」といいます。 eachメソッドの方式は「内部イテレータ」といいます。

これに対して「外部イテレータ」というのがあります。Enumeratorクラスのnextメソッドはその例です。 nextメソッドは呼ばれるたびに「次のデータ」を返します。

def example2
  a = [1,2,3].to_enum
  p a.next #=> 1
  p a.next #=> 2
  p a.next #=> 3
end

example2

このプログラムでは配列[1,2,3]to_enumメソッドでEnumeratorオブジェクトに変換しています。 Enumeratorオブジェクトのnextメソッドは呼ばれるたびに、1,2,3と順にその要素を返していきます。 ひとつのメソッド「next」が呼ばれるたびに異なる要素を返すので、これもイテレータと呼ばれるのです。 より正確には「外部イテレータ」です。

外部イテレータはFiberで簡単に実装できます。

def example3
  fiber = Fiber.new do
    [1,2,3].each do |i|
      Fiber.yield(i)
    end
  end

  p fiber.resume
  p fiber.resume
  p fiber.resume
end

example3

Fiber.yieldに引数をつけると、その引数の値が対応するfiber.resumeの値になります。 これによって、ファイバーから外部にデータを渡すことができます。

この例では、ファイバー外部でresumeを呼ぶたびにファイバー内部のイテレータが繰り返し処理をするので、順に要素が返されます。

なお、Rubyのドキュメント

Enumerator(の外部イテレータ)は Fiber を用いて実装されています。

と書かれています。

パイプ

パイプというのは、Bashなどのシェルプログラムで2つのプロセスをつなぎ、片方の出力を他方の入力につなげる機能です。 例えば

  • cat: ファイルを読み込み、標準出力に書き出す
  • wc: 文字数、単語数、行数などを計算して書き出す

をパイプ「|」で結びつけると、

$ cat example3.rb | wc
     39      59     455

となり、ファイルexample3.rbは

  • 行数が39
  • 単語数が59(単語は空白や改行で区切られる文字列)
  • 文字数が455

であることがわかります。 このとき

  • catによってexample3.rbの内容が標準出力に送られ
  • その出力はパイプ「|」によって次のコマンドの標準入力に結び付けられ
  • それはwcによって行数、単語数、文字数として標準出力に出力される

ということになります。

一般にあるプロセスの出力をバッファに保存し、それを別のプロセスの入力につなげる問題を「生産者ー消費者問題」といいます。 並行動作するプロセスでこれを行う場合、セマフォを使って実現します。 セマフォは一般に短いプログラムで、セマフォの実行中は他のタスクに切り替わらないことが保証されています。

ファイバーを使う場合は、タスクが切り替えのタイミングを決められるのでセマフォは不要で、プログラムも簡単になります。 また、消費者側をメインにし生産者側をファイバーにする「消費者起動方式」が理解しやすいです。

catとwcに相当するプログラムをFiberで作ってみましょう。 ただし、単純化するためwc部分は行数のみをカウントすることにします。

def example4 filename
  fiber = Fiber.new do
    File.open(filename) do |file|
      while (s = file.gets)
        Fiber.yield(s)
      end
    end
    nil
  end

  nline = 0
  while fiber.resume
    nline +=1
  end
  print "#{nline}\n"
end

example4("example39.rb")

ファイバー側はファイルをオープン後、一行入力してはyieldします。 EOFになるとfile.getsnilを返すのでwhileループが終了します。 ですから、最後にfiber.resumeで呼ばれたときは、whileループを脱出するのでFiber.yieldは実行されません。 そのときにはFiber.newのブロックの値(ブロックの最後に評価された値)がfiber.resumeの値として返されます。 すなわち、nilが返ります。

メインルーチンはfiber.resumeの値をチェックして真(この場合は文字列が返っている)ならばnlineをカウントアップします。 最後にnilが返ってきてwhileループを抜け、nlineの値をプリントします。

ファイバーとメソッド

ファイバーはイテレータやパイプなどを分かりやすく表現することができるのですが、同様のことはインスタンス変数とメソッドで実現することができます。 そのため、あまりファイバーは使われないのが実情ではないでしょうか。

しかし、最後の例のようなファイルをオープンするケースをメソッドで実装する場合、オープンとクローズは別メソッドで行うのが多いです。 つまり、オープン、一行読込、クローズの3つのメソッドを用いることになります。 Fiberは、その中でオープン、クローズも行えるので一つで済み、実装が簡単です。

ファイバーは他の言語ではコルーチンと呼ばれることもあります。 例えば、Luaではコルーチンと呼んでいます。 ネットでもファイバーよりもコルーチンで検索するほうが多くの情報にヒットします。

Ruby-GNOME レポジトリへのコントリビューション

今まで他のレポジトリにプルリクエストを出すことは、ほとんどしてきませんでした。 というのは、自分が貢献(コントリビューション)できそうな部分がほとんどなかったからです。 ところが、Ruby-GNOMEプロジェクトについては、多少活動できそうなので、はじめの一歩を踏み出してみました。 つまり、プルリクエストを出しました。

現在Ruby-GNOMEのメインはGTK 4をRubyバインディングすることですが、ほとんど一人の方がコミットしているのみです。 そのため、ドキュメントの方面はあまり手がついていません。 自分は少し前にRuby/GTK4の記事を書いたので、サンプルプログラムの作成ならば協力ができると思いました。

現在は、application1というディレクトリをプルリクエストして、マージしていただきました。 しかし、うかつ者の自分のしたことですから、プロジェクトのオーナーから沢山のサジェスチョンが来ました。 ひとつのプルリクエストがきちんとしたものに仕上がるのに多くの手間がかかり、プロジェクトの方にも迷惑をかけてしまったと思います。

まあ、要領はつかめたので、次回からは前よりはしっかりしたものを作れると思います。

自分はかねがねドキュメントの重要性を主張してきました。 どんなにすばらしいプログラムでもドキュメントがないとユーザの裾野が広がりません。 というのは、プログラムのユーザはハイレベルとは限らないからです。 できるだけ敷居を下げることが普及の条件だと思っています。

これからしばらくは、Ruby-GNOMEのサンプルプログラムの作成をするつもりです。

徒然Ruby(38)RDoc

今回はRubyプログラムから自動的にドキュメントを作成するRDocについて書きたいと思います。 私はこのことについて、エキスパートではありません。 この記事も、初心者の体験談だと考えてください。

どのようなプログラムに有効か?

クラスを定義するプログラムに対して有効です。 トップレベルのメソッドだけからなるプログラムだとあまり意味がありません。

というのは、RDocはプログラムを解析して、クラスとメソッドを抜き出してドキュメントを作るものだからです。 クラスのないプログラム、例えばトップレベルのメソッドだけで作ったプログラムでは、抜き出すものがありません。 そのようなプログラムにRDocを適用しても、できあがったドキュメントはとても悲しいものになってしまいます。

RDocの使い方。

Rubyプログラムのあるディレクトリで「rdoc」とコマンドラインに打ち込めば最低限の動作はします。 docディレクトリにHTMLファイルができあがるので、ダブルクリックして見てみましょう。 クラスとメソッドが表示されますね。

名前だけでは寂しいですから、ちょっと説明を書き加えましょう。 そのためには、そのクラスまたはメソッドの直前にコメントを書きます。 例えば次のようなコメントをクラス定義の直前に書き加えます。

=begin rdoc
方程式 ax+by=1 を表すクラス
インスタンスを生成するときにaとbを引数で与える。
solveメソッドで解を得ることができる。
=end

このように、=begin=endで挟まれた部分はコメントになります。 #を使ったコメントでもOKです。 このようなコメントはできあがったHTMLのクラスのところに表示されるようになります。

メソッドについても同様です。

マークアップ

RDoc独自のマークアップがあります。

  • 段落の間は空行で区切る
  • -または+で始めるとリストを作ることができる
  • []で囲んだ文字を項目とし、項目リストを作ることができる。::で項目と説明を区切る方法も可能
  • 1.で順序付きリスト、a.で文字による順序付きリストになる
  • ===・・・で見出しになる

その他についてはRubyのドキュメントを参考にしてください。

オプション

オプションはたくさんありますが、良く使われるのはタイトルとメイン画面(ホーム画面)でしょう。

  • --titleオプションでタイトルを設定。これがブラウザのタブになる(HTMLのtitleタグ相当)
  • --mainで指定したファイルがホーム画面になる。README.mdなどを指定することが多い

Rakeとの連動

RakefileにRDocでドキュメントを生成するタスクを定義できます。 例えば、

require 'rdoc/task'

RDoc::Task.new do |rdoc|
  rdoc.main = "README.md"
  rdoc.title = "Math Programs"
  rdoc.rdoc_dir = "doc"
  rdoc.rdoc_files.include("README.md", "*.rb")
end

これで、

  • メイン画面がREADME.md
  • タイトルが「Math Programs」
  • HTML生成先のディレクトリが「doc」
  • 対象ファイルがREADME.mdとRubyファイル(拡張子がrbのファイル)

となります。 rakeの引数にrdocなどをつけるとタスクが起動されます。

  • rdoc: RDocでドキュメントを生成
  • clobber_rdoc: ドキュメントをクリア(削除)=>初期状態に戻る
  • rerdoc: ドキュメントを一から生成。HTMLドキュメントのディレクトリ内は再生成したものだけになる

GitHubに「Math-programs」というレポジトリを作りました。 そこのdocディレクトリ以下にRDocで生成したドキュメントがあるので、参考にしてください。 このタイプのドキュメントはRubyを使っている人は良く見ているはずです。 例えばRakeのドキュメントがそうです。

みなさんもライブラリを作ったら、RDocでドキュメントを作ってみましょう。

徒然Ruby(37)Ruby/GTK4

【追記 2022/12/9】プログラムのバグ修正と「gtk4」gemのバージョン4.0.4(最初にこの記事を書いたときには4.0.3)について、「徒然なるままにRuby」のGitHubレポジトリに書き加えました。 追加、修正については申し訳ありませんがこちらをご覧になってください。 はてなブログの以下の記事は修正されていません。【追記以上】

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!

徒然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ファイルではGtkWindowオブジェクトを記述しています。 これは一般的なウィンドウ・オブジェクトです。 そのため、ウィンドウとアプリケーションを繋げなければなりません。 それをするのが、GtkWindowクラスのset_applicationメソッドです。

GtkWindowの代わりにGtkApplicationWindowを記述することもできますが、UIファイル内でGtkApplicationと結びつけることはできません。 そのため、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