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
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.ancestors
やShoes::Para.superclass
といったメソッドを実行して調べることで分かります。
Shoes::TextBlock
のメソッドにはtext
やtext=
があるので、これらはShoes::Para
でも使えることが分かります。
buttonメソッドには文字列の引数を与え、表示されたボタンのラベル(ボタンに書かれる文字列)を指定できます。
また、buttonメソッドが返すShoes::Button
クラスのオブジェクトにはclick
メソッドがあり、クリックされたときの動作を記述できます。
@button = button "ボタン" @button.click do (ボタンがクリックされたときの動作を記述) end
クリック時の動作はbuttonメソッドにブロックを付けてそこに記述することもできます。
スロット
スロットはエレメントを並べるためのコンテナで、フローとスタックがあります。
- フロー(flow): エレメントを横に並べる
- スタック(stack):エレメントを縦に並べる
エレメントはflowまたはstackメソッドのブロックに記述します。
電卓プログラム
簡単な電卓プログラムを書きました。
2つのファイルcalc.rb
とlib_calc.rb
から成ります。
calc.rb
がShoesを使ってウィンドウを表示し、lib_calc.rb
が文字列を構文解析して計算をします。
このプログラムを実行すると次のような画面が現れます。
$ shoes calc.rb
また、計算を実行すると次のようになります。
- 入力枠に数式を書く
- 四則以外に累乗(
**
)、三角関数、指数関数、対数関数が可能 - 前回計算した値を文字
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