徒然Ruby(34)Shoes -- RubyとGUI - おもこん

おもこん

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

徒然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