RubyCocoa 入門

Satoshi Nakagawa

RubyCocoa とは?

RubyCocoa は、Mac OS X アプリケーションを開発するためのフレームワークです。

RubyCocoa によって、

ができるようになります。

RubyCocoa のインストール

Tiger の場合、以下の URL から最新版の dmg をダウンロードして、インストールしてください。

Leopard では、RubyCocoa は OS に標準添付されているので、インストールの必要はありません。

はじめての Mac OS X アプリケーション

では、さっそくアプリケーションを作ってみましょう。

まず、Xcode を立ち上げます。(/Developer/Applications/Xcode.app)

立ち上がったら、メインメニューから 「ファイル」→「新規プロジェクト」を選んでください。

新規プロジェクトのダイアログが出てくるので、Application の下にある Cocoa-Ruby Application をダブルクリックします。

プロジェクトの作成場所を聞かれるので、ここではプロジェクト名に「Tutorial」、ディレクトリに「~/RubyCocoa/Tutorial/」と入力し、完了ボタンを押して次に進みます。

これでプロジェクトの作成は完了です。以下のようなプロジェクトウィンドウが表示されます。

ここで、右ペインの下の方にあるノッチを上に移動して、コードエディタが見えるようにしておきましょう。

コントローラの作成

はじめに、これからいろんな処理を書いていくコントローラを作成します。

Classes の右クリックメニューから、「追加」→「新規ファイル」を実行してください。

Ruby カテゴリの下の「Ruby NSObject subclass」をダブルクリックして、「AppController.rb」というファイル名でファイルを作成してください。

AppController.rb を選択し、以下のようなコードが書かれていることを確認してください。(不要なコメントは削除)

これが、これから処理を書いていくコントローラクラスです。

さっそくコントローラにコードを書いていきましょう。 以下のように、ib_outlet を追加してください。

class AppController < OSX::NSObject
  ib_outlet :window
end

アウトレットは Cocoa 用語で、他のオブジェクトへの参照を意味しています。 つまり、ここではコントローラからウィンドウへの参照を追加したということになります。

コントローラをUIに結びつける

次に、UIとこのコントローラクラスを結びつける作業を行っていきます。

Resources 配下にある MainMenu.nib をダブルクリックし、InterfaceBuilder を立ち上げます。

立ち上がると、5つのウィンドウが開きます。

左上
アプリケーションのメインメニュー。ここでメインメニューを編集できる。
左中央
InterfaceBuilder のメインウィンドウ。ここでアプリケーションのさまざまな設定を行う。
左下
アプリケーションのメインウィンドウ。ここにライブラリパレットからコントロールをドロップすると、さまざまなコントロールを配置できる。
中央
インスペクタ。選択中のオブジェクトの設定をここで行う。
ライブラリパレット。Cocoaで利用できるコントロールが表示されている。

ライブラリパレットで、一番上から少しスクロールしたところにある Object (NSObject) を探してください。

パレットから Object をドラッグし、メインウィンドウにドロップします。

これで、メインウィンドウに Object が追加されました。

次に、追加された Object を選択した状態でインスペクタを表示します。 Cmd+6 を押すか、上のツールバーの左から6番目の i アイコンを押して、クラスパレットを表示してください。

Class の選択肢に、先ほど追加した「AppController」が選べるようになっているので、それを選びます。

これで、アプリケーションの起動時に AppController クラスのインスタンスが自動的に作られるようになります。

ちなみに、最初から登録されている Window もアプリケーションの起動時に自動的に作成されるため、開発者がコードを書いて生成する必要はありません。

さて、ここから AppController の window アウトレットを実際の Window に結びつけてみましょう。

メインウィンドウを表示し、AppController の上で Ctrl キーを押した状態でマウスを押し込み、Window に向けてドラッグし、離してください。

そうすると、Outlets という小さなウィンドウが表示され、ib_outlet に書いた window が選べるようになっています。window をクリックして選んでください。

これで、AppController の window アウトレットから Window を参照できるようになりました。

AppController が選択された状態で、インスペクタ上で Cmd+5 を押すか、あるいは上のツールバーの左から5番目の → アイコンを押すと、アウトレットの接続状態を確認できます。

ここまでで、InterfaceBuilder での操作はひとまず終了です。 編集結果を保存して、InterfaceBuilder を終了してください。

コントローラクラスの編集

それでは、ここから実行するコードを書いていきましょう。

起動完了の通知を受信する awakeFromNib メソッドを追加します。

class AppController < OSX::NSObject
  ib_outlet :window

  def awakeFromNib
    @window.alphaValue = 0.8
  end
end

アプリケーションの起動処理が完了すると、NSApp は各オブジェクトの awakeFromNib メソッドを呼び出します。 起動時に行いたい処理は、ここに書くといいでしょう。

実行してみよう

これで作業は完了です。 さっそく実行してみましょう。 Xcode 上で Cmd+R を押すと、アプリケーションを実行できます。

こんな風に半透明のウィンドウが表示されたら成功です。

@window.alphaValue に代入する値を変えてみて、透明度が変わることを確認してみてください。

プロジェクトのバックアップをとる

ここで作ったアプリケーションは、これから作っていくアプリケーションのひな形として使えるので、バックアップをとっておいてください。

Finder 上でプロジェクトのディレクトリを選択して、Cmd+C → Cmd+V でディレクトリごとコピーします。

QuartzComposer のデモを動かしてみる

OSX.require_framework 'QuartzComposer'

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window
	
  def awakeFromNib
    @window.alphaValue = 0.8
    v = @window.contentView = QCView.alloc.init
    v.loadCompositionFromFile(
      "/Developer/Examples/Quartz Composer/" +
      "Compositions/Graphic Animations/Cube Replicator.qtz"
    )
    v.startRendering
  end
end

計算器を作ってみる

次に開発するアプリケーションとして、簡単な計算器を作ってみましょう。

MainMenu.nib をダブルクリックして InterfaceBuilder を立ち上げます。

そして、ライブラリパレットからコントロールを持ってきて、メインウィンドウの上に以下のように配置してください。

3つあるテキストフィールドには Text Field (NSTextField) を、左から2番目のポップアップボタンには Pop Up Button (NSPopUpButton) を、右から2番目のプッシュボタンには Push Button (NSButton) を使います。

ポップアップボタンの上でダブルクリックすると、以下のようにポップアップするので、各項目をダブルクリックして編集します。 ここでは、余分な項目を Cmd+X でカットして、「+」と「-」の2項目になるようにします。 (項目を追加するには、Cmd+C → Cmd+V で既存の項目をコピーするか、ライブラリパレットから Menu Item (NSMenuItem) を持ってきてドロップしてください)

プッシュボタンも同様に、ダブルクリックしてキャプションを「=」に変えておきます。

ここでいったん InterfaceBuilder から離れ、以下のようにアウトレットとアクションメソッドを追加してください。

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window, :xText, :yText, :opCombo, :resultText

  def onCalc(sender)
  end
  ib_action :onCalc
end

InterfaceBuilder に戻ります。

メインウィンドウで AppController を選択し、そこから Ctrl を押し込んだ状態でドラッグをはじめ、一番左のテキストフィールドでドロップしてください。

さきほど追加しておいたアウトレットが表示されるので、ここでは xText を選択します。

以下、同じ操作を繰り返して、ポップアップボタンに opCombo、中央のテキストフィールドに yText、一番右のテキストフィールドに resultText を結びつけてください。

最後に、ボタンを押したときのアクションを設定します。

ウィンドウ上のボタンを選択した状態で Ctrl を押し込んで、ボタンからドラッグを始め、App Controller にドロップしてください。

先ほど ib_action で指定しておいた onCalc メソッドが表示されるので、選択します。

これでボタンを押すと、onCalc メソッドが実行されるようになりました。

ここまでで、InterfaceBuilder での設定は完了です。 セーブして終了してください。

計算器のロジックを作る

onCalc メソッドを実行してみたいので、以下のようにデバッグ用の puts の呼び出しを追加しましょう。

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window, :xText, :yText, :opCombo, :resultText

  def onCalc(sender)
    puts 'onCalc'
  end
  ib_action :onCalc
end

まず、Xcode 上で Cmd+Shift+R を押してコンソールを表示します。 そして、= ボタンを押すと本当に onCalc が呼ばれるのか、Cmd+R で実行し、= ボタンを押して確認してみましょう。 もし実行されていれば、コンソールに「onCalc」と表示されるはずです。

このように、アプリケーションを開発する過程で、puts や p を用いて、実行ログにテキストを出力することが可能です。 これを利用して、少しずつ動作を確かめつつ、開発を進めていくとよいでしょう。

次に、テキストフィールドの値を取得してみましょう。 まずは、@xText がどのクラスであるかを確かめます。

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window, :xText, :yText, :opCombo, :resultText

  def onCalc(sender)
    p @xText
  end
  ib_action :onCalc
end

これで実行して、= ボタンを押してみると、実行ログには以下のように出力されました。 NSTextField クラスであることがわかります。

#<OSX::NSTextField:0x273d74 class='NSTextField' id=0x6b0ef0>

ここで、NSTextField クラスにどんなメソッドがあるか調べてみましょう。

Xcode のメインメニューから、「ヘルプ」→「製品ドキュメント」を実行します。

ウィンドウ右上のテキストフィールドに「NSTextField」と入力すると、下のリストの一番上に NSTextField が表示されるはずです。 その行をクリックすると、右下に NSTextField のリファレンスが表示されます。

確認してみると、どうやら文字列を取得するメソッドがなさそうなので、さらに親クラスの NSControl のリファレンスを見てみましょう。 Inherits from の右にある NSControl のリンクをクリックします。

ざっと見てみると、「Setting the control's value」のセクションに「stringValue」というメソッドがあることがわかります。

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window, :xText, :yText, :opCombo, :resultText

  def onCalc(sender)
    p @xText.stringValue
  end
  ib_action :onCalc
end

上のようにコードを書き換えて実行してみると、NSCFString クラスのオブジェクトを取得できたことがわかりました。

#<OSX::NSCFString:0x273482 class='NSCFString' id=0xa080f988>

NSCFString は NSString の別名で、Cocoa の文字列クラスです。 NSString#to_s を呼ぶことで、ruby の文字列に変換することができます。 やってみましょう。

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window, :xText, :yText, :opCombo, :resultText

  def onCalc(sender)
    p @xText.stringValue.to_s
  end
  ib_action :onCalc
end
""

今度は、ruby の文字列が取得できたことがわかります。 いまは一番左のテキストフィールドに何も入力されていないので空白ですが、何か入力して = ボタンを押してみると、その文字列が正しく取得できていることを確認できます。

次に、@resultText に計算結果の文字列を設定するための方法を探ってみましょう。

@resultText は、@xText などと同じく NSTextField なので、Xcode のヘルプを確認して、文字列の値を設定するためのメソッドを探してみてください。

NSTextField をざっと見てみてもどうやらなさそうなので、NSControl を見ます。 そうすると、setStringValue というメソッドが見つかるでしょう。

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window, :xText, :yText, :opCombo, :resultText

  def onCalc(sender)
    @resultText.setStringValue('abc')
  end
  ib_action :onCalc
end

このようにコードを変更すれば、= ボタンを押すと「abc」という文字列が一番右のテキストフィールドに表示されるはずです。 確認してみてください。

なお、このように ruby オブジェクトを Cocoa クラスのメソッドの引数に渡す場合には、できるだけ Cocoa のクラスに自動変換してくれます。 この場合には、String → NSString の変換が内部で自動的に行われています。

次に、コンボボックスの選択状態を取得する方法を調べるのですが、同様に @opCombo を p してみるところから、調べてみてください。

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window, :xText, :yText, :opCombo, :resultText

  def onCalc(sender)
    p @opCombo.selectedItem.title.to_s
  end
  ib_action :onCalc
end

その結果、上のようにすれば選択項目のテキストを取得できることがわかるでしょう。

さて、これで値を取得して、結果を表示する方法はすべてわかったので、コードを書いていくことにしましょう。 これまでの経過をふまえて考えると、以下のようなコードができあがると思います。

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window, :xText, :yText, :opCombo, :resultText

  def onCalc(sender)
    x = @xText.stringValue.to_s.to_f
    y = @yText.stringValue.to_s.to_f
    case @opCombo.selectedItem.title.to_s
      when '+'; r = x + y
      when '-'; r = x - y
    end
    @resultText.setStringValue(r)
  end
  ib_action :onCalc
end

これで計算器アプリケーションの開発は完了です。 実際に動かしてみて、動作を確かめてください。 また、時間があれば、かけ算や割り算もできるように改良してみるのもいいと思います。

デスクトップをインクリメンタル検索するアプリケーション

ここまでは、比較的単純なコントロールのみを使い、基礎を学んできましたが、ここで NSTableView (Windows でいう ListView) を使った、少し実用的なアプリケーションとして、デスクトップをインクリメンタル検索するアプリケーションを作ってみましょう。

さきほどバックアップしておいた、Tutorial プロジェクトから始めます。

MainMenu.nib をダブルクリックし、InterfaceBuilder を起動します。 そして、以下のようにメインウィンドウにコントロールを配置してください。(Text Field (NSTextField) と Table View (NSScrollView))

ここでいったん InterfaceBuilder から離れ、以下のようにコードを編集してアウトレットを追加します。

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window, :text, :table
end

ここからまた InterfaceBuilder に戻り、アウトレットの接続を行っていきます。

先ほどと同じように操作を行って、Text Field を text に、Table View を table にそれぞれ結びつけてください。

Table View と結びつけるときには、テーブルの内側にマウスカーソルを移動して、Table View と表示されている状態で離してください。 間違えて、Scroll View や Table Column と結びつけないように注意してください。

次に、テキストフィールドから AppController に Ctrl+ドラッグして、delegate アウトレットで結びつけておきます。 これで、テキストフィールドの変更イベントが AppController に通知されるようになります。

さらに、テーブルビューを選択して、Cmd+1 を押して、インスペクタを開きます。 インスペクタのタイトルが「Scroll View Attributes」であることに注意してください。

その状態でテーブルビューをダブルクリックすると、以下のような表示になり、インスペクタのタイトルが「Table View Attributes」に変わっているはずです。

その状態で、テーブルビューから Ctrl+ドラッグを開始して、AppController に接続します。 ここでは、dataSource を選択しておきます。

次に、テキストフィールドを選択し、Cmd+3 を押して Size のインスペクタを開きます。 該当箇所をクリックして、以下のようになるように設定してください。

さらに、テーブルビューを選択し、同じように Cmd+3 を押して、以下のように設定してください。

このように設定しておくことで、ウィンドウがリサイズされたときに、コントロールの位置と大きさを適切に保つことができます。

これで、InterfaceBuilder での設定は完了です。 セーブして終了してください。

次に、コントローラのコードを以下のように書き換えます。

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window, :text, :table

  # NSTextField delegate

  def controlTextDidChange(note)
    puts 'textChanged: ' + @text.stringValue.to_s
  end

  # NSTableView dataSource

  def numberOfRowsInTableView(sender)
    3
  end

  def tableView_objectValueForTableColumn_row(sender, col, row)
    'abc'
  end
end

テキストフィールドの内容が変更されると、controlTextDidChange が呼び出されます。

numberOfRowsInTableView と tableView_objectValueForTableColumn_row は、ともに NSTableView の dataSource が実装するメソッドで、それぞれ行数と、(row, col)に対応するデータを返します。 (リファレンスの NSTableDataSource のページで、numberOfRowsInTableView: と tableView:objectValueForTableColumn:row: を確認してみてください)

ここで返す情報を元に、NSTableView が実際の表示を行います。 (MVC でいうと、NSTableView が V、AppController が C ということです)

Cmd+R で実行してみると、テキストフィールドを書き換えると controlTextDidChange が呼ばれ、テーブルビューには 3行の「abc」が表示されていることがわかります。

では、ここから少しずつ問題を片付けていきましょう。

まず、デスクトップのパスを取得するには、NSString#stringByExpandingTildeInPath が使えそうです(リファレンスを確認してみてください)。 以下のようにすることで、デスクトップの絶対パスを取得することができます。(もちろん、File.expand_path でも構いません)

d = NSString.stringWithString('~/Desktop')
d = d.stringByExpandingTildeInPath.to_s

さらに、デスクトップ以下にあるファイルをキーワードで検索するには、以下のようにすればよさそうです。 (実際に awakeFromNib の中で実行してみてください)

d = NSString.stringWithString('~/Desktop')
d = d.stringByExpandingTildeInPath.to_s

keyword = 'ruby'
items = Dir::glob(d + "/**/*#{keyword}*", File::FNM_CASEFOLD)
p items

さらに進めて、テキストフィールドの文字列を含むファイル名のファイルを検索するコードは、以下のようになります。 あと、ファイル名とディレクトリを分け、大文字小文字を無視してソートするようにしました。

d = NSString.stringWithString('~/Desktop')
d = d.stringByExpandingTildeInPath.to_s

s = @text.stringValue.to_s
@items = Dir::glob(d + "/**/*#{s}*", File::FNM_CASEFOLD).map do |i|
  [File.basename(i), File.dirname(i)]
end
@items.sort! {|a,b| a[0].upcase <=> b[0].upcase }

さて、インクリメンタルサーチをするためには、テキストフィールドの内容が変わるたびにこの処理を実行すればいいので、上で書いたコードを controlTextDidChange の中で実行するようにします。 また、その情報をテーブルビューに表示するようにしてみます。

ポイントは、データが更新されるたびに @table.reloadData を呼び、テーブルビューを再描画しているところです。

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window, :text, :table

  def initialize
    @items = []
  end

  # NSTextField delegate

  def controlTextDidChange(note)
    s = @text.stringValue.to_s
    d = NSString.stringWithString('~/Desktop')
    d = d.stringByExpandingTildeInPath.to_s
    @items = Dir::glob(d + "/**/*#{s}*", File::FNM_CASEFOLD).map do |i|
      [File.basename(i), File.dirname(i)]
    end
    @items.sort! {|a,b| a[0].upcase <=> b[0].upcase }
    @table.reloadData
  end

  # NSTableView dataSource

  def numberOfRowsInTableView(sender)
    @items.length
  end

  def tableView_objectValueForTableColumn_row(sender, col, row)
    if col == @table.tableColumns.to_a[0]
      @items[row][0]
    else
      @items[row][1]
    end
  end
end

これで、ほぼ完成というところでしょうか。 実行してみましょう。

このように、デスクトップ配下のファイルをインクリメンタルに検索できていることがわかります。

ただし、起動直後に全ファイルがリストされていたほうが使いやすいので、awakeFromNib にそのためのコードを追加しておきましょう。

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window, :text, :table

  def initialize
    @items = []
  end

  def awakeFromNib
    controlTextDidChange(self)
  end

  # NSTextField delegate

  def controlTextDidChange(note)
    s = @text.stringValue.to_s
    d = NSString.stringWithString('~/Desktop')
    d = d.stringByExpandingTildeInPath.to_s
    @items = Dir::glob(d + "/**/*#{s}*", File::FNM_CASEFOLD).map do |i|
      [File.basename(i), File.dirname(i)]
    end
    @items.sort! {|a,b| a[0].upcase <=> b[0].upcase }
    @table.reloadData
  end

  # NSTableView dataSource

  def numberOfRowsInTableView(sender)
    @items.length
  end

  def tableView_objectValueForTableColumn_row(sender, col, row)
    if col == @table.tableColumns.to_a[0]
      @items[row][0]
    else
      @items[row][1]
    end
  end
end

これで、デスクトップをインクリメンタル検索するアプリケーションの開発は完了です。

おまけ: WebKit を使った Google サーチブラウザ

require 'cgi'
require 'open-uri'
OSX.require_framework 'WebKit'

class AppController < OSX::NSObject
  include OSX
  ib_outlet :window, :text, :table, :webview
	
  def initialize
    @items = []
  end
	
  def textEntered(sender)
    s = @text.stringValue.to_s
    @items = search_on_google(s)
    @table.reloadData
  end
	
  # NSTableView dataSource
	
  def numberOfRowsInTableView(sender)
    @items.length
  end
	
  def tableView_objectValueForTableColumn_row(sender, col, row)
    if col == @table.tableColumns.to_a[0]
      @items[row][0]
    else
      @items[row][1]
    end
  end
	
  # NSTableView delegate
	
  def tableViewSelectionDidChange(note)
    sel = @table.selectedRow
    return if sel < 0
    url = @items[sel][0]
    u = NSURL.URLWithString(url)
    req = NSURLRequest.requestWithURL(u)
    @webview.mainFrame.loadRequest(req)
  end
	
  private
	
  def search_on_google(words)
    q = CGI.escape(words)
    url = "http://www.google.com/search?num=20&ie=UTF-8&oe=UTF-8&q=#{q}"
    res = ''
    open(url) {|f| res = f.read }
    if res
      items = res.scan(/<a href="([^\"]+)" class=l>(.+?)<\/a>/)
      items.map do |i|
        url,title = i
        url = CGI.unescapeHTML(url)
        title = title.gsub(/<\/?b>/, '')    # cut off <b> and </b>
        title = CGI.unescapeHTML(title)
        [url, title]
      end
    else
      []
    end
  end
  
end

おわりに

以上、かけ足でしたが RubyCocoa 入門として、2つのアプリケーションの開発を行いました。

今回は、ruby と Objective-C との関係など詳細はとりあげませんでしたが、RubyCocoa wiki の「RubyCocoa プログラミング」と「リファレンス」のページを読んでいただければ、さらに理解が深められるのではないかと思います。

Satoshi Nakagawa
Creative Commons License
This Work is licensed under a Creative Commons Attribution-ShareAlike 2.1 Japan License.