2015年4月
      1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30    
無料ブログはココログ

カテゴリー「wxRuby」の記事

2012年7月 6日 (金)

wxRuby での例外処理のワナ

前回 (画像を貼り付ける(6) - ListItem のずれない identifier) で、大きな問題には対処しましたが、あれでも特定の条件下ではエラーが出ます。

それで思い出したのですが、ここまで説明を忘れていた大事なテクニックとして、例外処理(エラー処理)があります。といっても raise() を使えばよいのですが、これではダメなのです。ダメだということは、どこかで読んでいたのですが、実際にコードを書いているとやっぱりやってしまいました。

何が問題かというと、raise() という method 名が ruby と wxRuby で衝突しているのです!!!

Wx::Window#raise() という method があるので、普段 ruby で記述しているように、関数的 method として、地の文でいきなり raise() とすると、例外処理ではなく、Wx::Window#raise() が呼び出されてしまいます。通常通り例外処理に繋げるには、レシーバを明示して、

   Kernel.raise()

としなければなりません。ここ、試験に出ます :-p

去年、wxRuby の勉強を始めて web を漁っていたころ、どこかのサイトで、wxRuby の今後の version では、Wx::Window#raise() の方の method 名を変えるとかどうとか書いてあるのを見たような記憶があるのですが、いずれにしても、wxRuby で書くときには、Kernel.raise() としておいたほうが安全なようです。

2012年7月 5日 (木)

画像を貼り付ける(6) - ListItem のずれない identifier

ListItem#id は、ListCtrl の要素が削除されてしまったり、sort されてしまったりすると、identifier として使えないことがわかりました。では、代わりに何を使うのか。

今回に関してだけの話であれば、ListItem#data に、元画像の file 名を格納しているので、これが一意な identifier に使えますが、他の用途で ListCtrl を使うときには、そうはいかないかもしれない。また、今回書いてみたコードでは、identifier で検索をかけるしかないのですが、identifier が文字列だと、比較・検索のコストが高くなりそうなので、できれば整数値が使えるといいですね。

wxWidget レベルでの ListItem の instance の window id とか、wxRuby レベルでの ListItem object の object_id が使えれば楽チンですが、ListCtrl の中で ListItem そのものが格納される実装になっているか不明ですし、object の中身は同一でも、生成・破壊が繰り返されていて object_id は変わっているということも考えられるので、安易には使えません。

結局、自前で整数値の identifier を設定することにしました。ListItem にユーザーレベルで自由に使えるデータは ListItem#data にしか設定できませんが、これを identifier に使ってしまうと、画像 file 名を格納しておくところがなくなってしまいます。そこで、以下のような Struct を使って、ListItem#data に複数の値を格納することにしました。

ListItemData = Struct.new(:static_index, :path)
item.data = ListItemData.new( idx, img_file )

こうして、idx に identifier としての整数値を、img_file に画像 file 名としての文字列を渡せば ListCtrl#sort_item() をかけても ListItem#data.static_index は変わりません。

上記のギミックを組み入れて、修正したのが以下のコードです。


thumb_child2.rb


# encoding: UTF-8

require 'wx'

class MyApp < Wx::App
  def on_init
    @main_frame = ThumbnailsFrame.new
    @main_frame.show
  end
end

class ThumbnailsFrame < Wx::Frame
  THUMB_W = 160; THUMB_H = 120
  THUMB_SIZE = Wx::Size.new( THUMB_W, THUMB_H )

  def initialize
    super(nil, :title => "Thumbnails", :size => [720, 480])
    set_sizer(Wx::BoxSizer.new( Wx::VERTICAL ))
    create_status_bar

    load_button = Wx::Button.new(self, :label => "Load...")
    evt_button( load_button ) { |evt| on_load }
    get_sizer.add(load_button, 0, Wx::ALIGN_CENTER_HORIZONTAL)

    @thumb_list = Wx::ListCtrl.new(self, :style => Wx::LC_ICON)
    get_sizer.add_item(@thumb_list, :proportion => 1, :flag => Wx::EXPAND)

    evt_list_item_activated( @thumb_list ) { |e| thumb_activated( e ) }
    @thumb_list.evt_size { |e| refresh_thumb_list }
  end

  private
  def on_load
    dlg = Wx::DirDialog.new(self)
    if ( dlg.show_modal == Wx::ID_OK )
      set_status_text("サムネイルを作成中...")
      load_thumbnails(dlg.get_path)
      set_status_text("")
    end
  end

  ListItemData = Struct.new(:static_index, :path)
  def load_thumbnails(dir)
    image_list = Wx::ImageList.new(THUMB_W, THUMB_H)
    @thumb_list.set_image_list(image_list, Wx::IMAGE_LIST_NORMAL)

    item = Wx::ListItem.new
    glob_pat = %w[jpg png].map{|x| "#{dir}/*.#{x}".gsub(/\\/, '/')}.join("\0")
    Dir.glob( glob_pat ).sort.each do |img_file|
      idx = image_list.add(thumbnail_bitmap(img_file))
      unless ( idx < 0 )
        item.id = idx; item.image = idx
        item.data = ListItemData.new( idx, img_file )
        @thumb_list.insert_item( item )
      end
    end
  end

  def thumbnail_bitmap(file)
    img = Wx::Image.new(file)
    img_w, img_h = img.get_width, img.get_height
    ratio_w, ratio_h = THUMB_W.to_f/img_w, THUMB_H.to_f/img_h

    if ( ratio_w < ratio_h )
      new_h = (img_h * ratio_w).to_i
      img.rescale(THUMB_W, new_h)
      img.resize(THUMB_SIZE, Wx::Point.new( 0,(THUMB_H - new_h)/2 ))
    else
      new_w = (img_w * ratio_h).to_i
      img.rescale(new_w, THUMB_H)
      img.resize(THUMB_SIZE, Wx::Point.new( (THUMB_W - new_w)/2,0 ))
    end
    Wx::Bitmap.from_image(img)
  end

  def thumb_activated( evt )
    @service ||= ->( item_static_index, service_tag ) {
      target_index = @thumb_list.find { |idx|
        @thumb_list.item( idx ).data.static_index == item_static_index
      }
      unless ( target_index )
        Kernel.raise( "Item with static index : #{item_static_index} dose not exist.\n" )
      end

      target_item = @thumb_list.item( target_index )
      case service_tag
      when :path; target_item.data.path
      when :delete
        path = target_item.data.path
        @thumb_list.delete_item( target_index )
        File.delete( path )
        refresh_thumb_list
      when :id; target_item.id
      when :static_index; item_static_index
      else
        Kernel.raise( "Undefined service tag \"#{service_tag}\" is specified.\n" )
      end
    }
    ViewFrame.new( self, @service.curry[ evt.item.data.static_index ] ).show
  end

  def refresh_thumb_list
    @thumb_list.each do |i|
      item = @thumb_list.item(i)
      item.text = "dynamic = #{item.id} : static = #{item.data.static_index}"
      @thumb_list.set_item( item )
    end
    @thumb_list.sort_items{ |a,b| a.static_index<=>b.static_index }
  end
end

class ViewFrame < Wx::Frame
  def initialize( parent, service )
    @parent_service = service
    path = @parent_service[ :path ]
    title = "dynamic on fork = #{@parent_service[ :id ]} : #{path}"
    super( parent, -1, title, :size => [450,500] )

    sizer_top = Wx::BoxSizer.new( Wx::VERTICAL )
    @sizer_button = Wx::BoxSizer.new( Wx::HORIZONTAL )
    @sizer_button.add_stretch_spacer
    @sizer_button.add( make_resize_button(), 0, Wx::ALIGN_CENTER_HORIZONTAL )
    @sizer_button.add( make_delete_button(), 0, Wx::ALIGN_CENTER_HORIZONTAL )
    @sizer_button.add_stretch_spacer

    set_sizer( sizer_top )
    sizer_top.add( @sizer_button, 0, Wx::EXPAND )
    sizer_top.add( @panel = ImagePanel.new( self ), 1, Wx::EXPAND )
    @panel.load_image( path )
  end

  def make_resize_button
    button = Wx::Button.new(self, -1, "Original Size" )
    evt_button( button ) { |e| resize_to_original( e ) }
    button
  end

  def make_delete_button
    button = Wx::Button.new(self, -1, "Delete Me" )
    evt_button( button ) { |e| delete_me( e ) }
    button
  end

  def resize_to_original( evt )
    size = @panel.get_original_size
    size.height += @sizer_button.size.height
    self.set_client_size( size )
  end

  def delete_me( evt )
    dlg = Wx::MessageDialog.new(self, "本当に削除してよろしいですか ?", "Confirm" )
    if dlg.show_modal == Wx::ID_OK
      @parent_service[ :delete ]
      self.close
    end
  end
end

class ImagePanel < Wx::Panel
  OriginalImage = Struct.new( :image, :width, :height )

  def initialize( *args )
    super
    evt_size { |evt| draw_image }
    evt_paint { |evt| draw_image }
  end

  def load_image( file_name )
    image = Wx::Image.new( file_name )
    @org_img = OriginalImage.new( image, image.width, image.height )
    draw_image
  end

  def get_original_size
    Wx::Size.new( @org_img.width, @org_img.height )
  end

  def draw_image
    return unless( @org_img )

    width = self.get_client_size.width
    height = self.get_client_size.height
    ratio_w, ratio_h = width.to_f/@org_img.width, height.to_f/@org_img.height

    new_scale = if ( ratio_w < ratio_h )
      [ width, (@org_img.height * ratio_w).to_i ]
    else
      [ (@org_img.width * ratio_h).to_i, height ]
    end
    bitmap = @org_img.image.copy.rescale( *new_scale ).convert_to_bitmap
    position = [ (width - new_scale.first)/2, (height - new_scale.last)/2 ]
    paint do |dc|
      dc.clear
      dc.draw_bitmap( bitmap, position.first, position.last, true )
    end
  end
end

MyApp.new.main_loop

まず、@thumb_list に要素を追加・登録する段階で、ListItem#data に固有の ID を設定します。

  ListItemData = Struct.new(:static_index, :path)
  def load_thumbnails(dir)
    image_list = Wx::ImageList.new(THUMB_W, THUMB_H)
    @thumb_list.set_image_list(image_list, Wx::IMAGE_LIST_NORMAL)

    item = Wx::ListItem.new
    glob_pat = %w[jpg png].map{|x| "#{dir}/*.#{x}".gsub(/\\/, '/')}.join("\0")
    Dir.glob( glob_pat ).sort.each do |img_file|
      idx = image_list.add(thumbnail_bitmap(img_file))
      unless ( idx < 0 )
        item.id = idx; item.image = idx
        item.data = ListItemData.new( idx, img_file )
        @thumb_list.insert_item( item )
      end
    end
  end

ID を割り振る段階では、item.id も item.date = ListItemDate.new() も、同じ整数値を設定しますが、前回示したように、item.id は要素を削除すれば自動的に変化します。一方 ListItemData#static_index は明示的に変更しない限り変化しません。

これで、ListItem に、勝手にシステムが書き換えない ID を割り振ることができるようになりました。ViewFrame.new() するときに、この、書き換わらない ID を渡すようにすれば解決なのですが、改めて考えるとここで ID を渡すのもなんだか不恰好です。ViewFrame の方では、自分が表示している画像が、ListCtrl で管理されている ListItem に対応しているなんてコテコテした情報は扱いたくありません。せっかくのオブジェクト指向なので、不要な情報は隠蔽した方が実装も安定します。

そこで、今回は、コテコテした情報操作は全て親に当たる ThumbnailsFrame の方で担当して、ViewFrame の方からは、与えられた call back 関数を呼び出せば、なんだかよくわからないけれどうまく処理される、といった実装にしてみました。つまり、ViewFrame.new() には、親子関係を繋ぐための情報としての ThumbnailsFrame と、call back 関数だけを渡します。ruby ですので、手続き型オブジェクトを call back として渡すのですが、ここで、ViewFrame の instance を作るごとに、手渡す ListItem の instance に専用の手続き型オブジェクトを作って渡します。そうすると、ViewFrame からは、何も考えずに、call back すれば、自分の扱っている ListItem をちゃんと同定して処理してもらえるわけです。

この、「各 ViewFrame に、それぞれの ListItem#data.static_index に対応する専用の手続きオブジェクトを作る」という作業に、関数のカリー化を使用します。

カリー化というのは、関数型言語の世界の言葉 (?) のようですが、n 個の引数を取る関数をベースにして、一つ目の引数の値を決め打ちで固定して、残りの n-1 個の可変引数を取る別の関数を生成する (?) 概念です。

ruby では Proc に、そのものズバリ、Proc#curry という method があります。これを ThumbnailsFrame#thumb_activated() で使用しています。

  def thumb_activated( evt )
    @service ||= ->( item_static_index, service_tag ) {
      target_index = @thumb_list.find { |idx|
        @thumb_list.item( idx ).data.static_index == item_static_index
      }
      unless ( target_index )
        Kernel.raise( "Item with static index : #{item_static_index} dose not exist.\n" )
      end

      target_item = @thumb_list.item( target_index )
      case service_tag
      when :path; target_item.data.path
      when :delete
        path = target_item.data.path
        @thumb_list.delete_item( target_index )
        File.delete( path )
        refresh_thumb_list
      when :id; target_item.id
      when :static_index; item_static_index
      else
        Kernel.raise( "Undefined service tag \"#{service_tag}\" is specified.\n" )
      end
    }
    ViewFrame.new( self, @service.curry[ evt.item.data.static_index ] ).show
  end

@service が、ベースになる Proc オブジェクトです。ViewFrame に渡す ListItem の ID と、ViewFrame から ThumbnailsFrame に要求する機能の tag を引数として受け取ります。これをカリー化して、一番目の引数を、該当する ListItem の ID で固定した手続きオブジェクトを ViewFrame.new() に渡します。こうすると、ViewFrame 側から call back するだけで、該当する ListItem を操作できるし、ViewFrame からは実装を隠蔽できるわけです。

上記のような点を修正したことで、子画面を複数開いてから全部削除していっても、意図通りの画像が削除できるようになりました。

2012年7月 1日 (日)

画像を貼り付ける(5) -削除するとListCtrl がずれる

前回 のコードのままだと、どんな不具合が生じるのか。わかりやすいように画面のキャプチャー画像を用意しました。

まず、script を起動して、image_0 ~ image_9 まで が含まれるフォルダを選択します。(この image_0.jpg ~ image_9.jpg までは、適当に用意したものです) そうすると、このようなサムネイル一覧画面になります。

Thumbnails

画像の下に表示されている文字列は、ThumbnailsFrame#refresh_thumb_list() の中で ListItem#text の値として設定しているものです。

  def refresh_thumb_list
    @thumb_list.each do |i|
      item = @thumb_list.item(i)
      item.text = "current index = #{item.id}"
      @thumb_list.set_item( item )
    end
    @thumb_list.sort_items{ |a,b| a <=> b }
  end

ListItem.id には ListCtrl 内で、その ListItem が何番目であるかという整数値が格納されているので、これを設定しているだけです。

ここで、0 と 4 と 6 のサムネイルをダブルクリックすると、子画面が開きます。それぞれ自由に拡大縮小できます。(ひとつだけ、拡大表示にしてあります)

Num_0 Num_4Num_6_2

各子画面のタイトルバーに、"forked index = " という表示があります。これは、ThumbnailsFrame#thumb_activated() で、子画面クラス ViewFrame.new() を実行するときに、evt.index として渡しているものです。

  def thumb_activated( evt )
    ViewFrame.new( self, evt.index, @thumb_list.item_data( evt.index ) ).show
  end

evt は、サムネイルがダブルクリックされたという情報が渡される ListEvent の instance です。evt.index からは、その ListCtrl の何番目の ListItem がダブルクリックされたかという情報が得られますので、結局、その ListItem#id と同じ情報が入ります。ですから、当然、サムネイル画面の "current index = " という情報と数値は一致します。

ここで、"4" を表示している子画面の [Delete Me] ボタンをクリックすると、確認ダイアログを出した上で、該当する画像ファイルとサムネイルを削除します。ThumbnailsFrane#refresh_thumb_list() を経由して、サムネイル一覧画面も更新されるので、以下のようになります。

Thumbs_new

ThumbnailsFrane#refresh_thumb_list() の中で、item.text を更新していますので、ちゃんと current index も先頭から順にならんでいます。その結果、最初のサムネイル一覧画面では "6" の画像を表示するサムネイルには "current index = 6" と表示されていたものが、削除後には "current index = 5" と表示されています。

ところが、まだ閉じられずに残っている子画面の "6" では、タイトルバーに "forked index = 6; ..." と表示されているのです。これは当然といえば当然です。"6" の子画面を表示している ViewFrame の instance には、自分が動き出してから "4" の画像が削除され、ListCtrl の中身が詰められてしまったという情報が何も伝わっていないからです。

ためしにこの段階で "8" のサムネイルをダブルクリックしてみると、次の子画面が開きます。

Num_8

ViewFrame.new() で、現在の "8" 画像に対する index が正しく伝わっているので、タイトルバーには "forked index = 7" と表示されます。

さて、この状態で、まだ閉じずに残してある "6" の子画面で、[Delete Me] ボタンをクリックすると、どの画像が削除されるでしょうか?

子画面側は、ListCtrl の管理を親である ThumbnailsFrame にまかせているので、親に、index = 6 の要素を削除する依頼を出します。これを受ける ThubnailsFrame#delete_item() では、現在の @thumb_list における index = 6 の要素を削除します。

  def delete_item( index )
    path = @thumb_list.item( index ).data
    @thumb_list.delete_item( index )
    File.delete( path )
    refresh_thumb_list
  end

ご想像の通り、これで実際に削除されるのは "7" の画像になります。

結局、子画面が自分を示すための identifier として使っている ListItem#id は、途中でどんどん動的に変わっていくもので、identifier としては適当でなかったということです。では、何を identifier にすればよいのか、いろいろ考えてとりあえず動かせるようになったコードを、次回掲載します。

2012年6月27日 (水)

画像を貼り付ける(4) -サムネイルから子画面を開く

前回の記事で、どのサイトを参考にしたのかわからなくなったと書きましたが、やっと探し出せました。"Alone Like a Rhinoceros Horn のこの記事 "を参考に書き始めたわけです。

そこからあれこれと試行錯誤して、ようやくここまできました。今回で、サムネイル画面から、ダブルクリックで子画面を開いて、その画面を拡大・縮小したり、もとのファイルを削除できるようにします。ネットから収拾した「イケナイ」画像の山から、不要な画像や重複した画像を削除して整理できます。まあ、わざわざ作らなくても OS 標準のツールとエクスプローラーで充分なのですが(^^; 


thumb_child.rb


# encoding: UTF-8


require 'wx'

class MyApp < Wx::App
  def on_init
    @main_frame = ThumbnailsFrame.new
    @main_frame.show
  end
end

class ThumbnailsFrame < Wx::Frame
  THUMB_W = 160; THUMB_H = 120
  THUMB_SIZE = Wx::Size.new( THUMB_W, THUMB_H )

  def initialize
    super(nil, :title => "Thumbnails", :size => [720, 480])
    set_sizer(Wx::BoxSizer.new( Wx::VERTICAL ))
    create_status_bar

    load_button = Wx::Button.new(self, :label => "Load...")
    evt_button( load_button ) { |evt| on_load }
    get_sizer.add(load_button, 0, Wx::ALIGN_CENTER_HORIZONTAL)

    @thumb_list = Wx::ListCtrl.new(self, :style => Wx::LC_ICON)
    get_sizer.add_item(@thumb_list, :proportion => 1, :flag => Wx::EXPAND)

    evt_list_item_activated( @thumb_list ) { |e| thumb_activated( e ) }
    @thumb_list.evt_size { |e| refresh_thumb_list }
  end

  def delete_item( index )
    path = @thumb_list.item( index ).data
    @thumb_list.delete_item( index )
    File.delete( path )
    refresh_thumb_list
  end

  private
  def on_load
    dlg = Wx::DirDialog.new(self)
    if ( dlg.show_modal == Wx::ID_OK )
      set_status_text("サムネイルを作成中...")
      load_thumbnails(dlg.get_path)
      set_status_text("")
    end
  end

  def load_thumbnails(dir)
    image_list = Wx::ImageList.new(THUMB_W, THUMB_H)
    @thumb_list.set_image_list(image_list, Wx::IMAGE_LIST_NORMAL)

    item = Wx::ListItem.new
    glob_pat = %w[jpg png].map{|x| "#{dir}/*.#{x}".gsub(/\\/, '/')}.join("\0")
    Dir.glob( glob_pat ).sort.each do |img_file|
      idx = image_list.add(thumbnail_bitmap(img_file))
      unless ( idx < 0 )
        item.id = idx
        item.image = idx
        item.data = img_file
        @thumb_list.insert_item( item )
      end
    end
  end

  def thumbnail_bitmap(file)
    img = Wx::Image.new(file)
    img_w, img_h = img.get_width, img.get_height
    ratio_w, ratio_h = THUMB_W.to_f/img_w, THUMB_H.to_f/img_h

    if ( ratio_w < ratio_h )
      new_h = (img_h * ratio_w).to_i
      img.rescale(THUMB_W, new_h)
      img.resize(THUMB_SIZE, Wx::Point.new( 0,(THUMB_H - new_h)/2 ))
    else
      new_w = (img_w * ratio_h).to_i
      img.rescale(new_w, THUMB_H)
      img.resize(THUMB_SIZE, Wx::Point.new( (THUMB_W - new_w)/2,0 ))
    end
    Wx::Bitmap.from_image(img)
  end

  def thumb_activated( evt )
    ViewFrame.new( self, evt.index, @thumb_list.item_data( evt.index ) ).show
  end

  def refresh_thumb_list
    @thumb_list.each do |i|
      item = @thumb_list.item(i)
      item.text = "current index = #{item.id}"
      @thumb_list.set_item( item )
    end
    @thumb_list.sort_items{ |a,b| a <=> b }
  end
end

class ViewFrame < Wx::Frame
  def initialize( parent, index, path )
    @index = index
    title = "forked index = #{index} : #{path}"

    super( parent, -1, title, :size => [450,500] )

    sizer_top = Wx::BoxSizer.new( Wx::VERTICAL )
    @sizer_button = Wx::BoxSizer.new( Wx::HORIZONTAL )
    @sizer_button.add_stretch_spacer
    @sizer_button.add( make_resize_button(), 0, Wx::ALIGN_CENTER_HORIZONTAL )
    @sizer_button.add( make_delete_button(), 0, Wx::ALIGN_CENTER_HORIZONTAL )
    @sizer_button.add_stretch_spacer

    set_sizer( sizer_top )
    sizer_top.add( @sizer_button, 0, Wx::EXPAND )
    sizer_top.add( @panel = ImagePanel.new( self ), 1, Wx::EXPAND )
    @panel.load_image( path )
#    @sizer_height = @sizer_button.size.height
  end

  def make_resize_button
    button = Wx::Button.new( self, -1, "Original Size" )
    evt_button( button ) { |e| resize_to_original( e ) }
    button
  end

  def make_delete_button
    button = Wx::Button.new(self, -1, "Delete Me" )
    evt_button( button ) { |e| delete_me( e ) }
    button
  end

  def resize_to_original( evt )
    size = @panel.get_original_size
    size.height += @sizer_button.size.height
    self.set_client_size( size )
  end

  def delete_me( evt )
    dlg = Wx::MessageDialog.new(self, "本当に削除してよろしいですか ?", "Confirm" )
    if dlg.show_modal == Wx::ID_OK
      self.parent.delete_item( @index )
      self.close
    end
  end

end

class ImagePanel < Wx::Panel
  OriginalImage = Struct.new( :image, :width, :height )

  def initialize( *args )
    super
    evt_size { |evt| draw_image }
    evt_paint { |evt| draw_image }
  end

  def load_image( file_name )
    image = Wx::Image.new( file_name )
    @org_img = OriginalImage.new( image, image.width, image.height )
    draw_image
  end

  def get_original_size
    Wx::Size.new( @org_img.width, @org_img.height )
  end

  def draw_image
    return unless( @org_img )

    width = self.get_client_size.width
    height = self.get_client_size.height
    ratio_w, ratio_h = width.to_f/@org_img.width, height.to_f/@org_img.height

    new_scale = if ( ratio_w < ratio_h )
      [ width, (@org_img.height * ratio_w).to_i ]
    else
      [ (@org_img.width * ratio_h).to_i, height ]
    end
    bitmap = @org_img.image.copy.rescale( *new_scale ).convert_to_bitmap
    position = [ (width - new_scale.first)/2, (height - new_scale.last)/2 ]
    paint do |dc|
      dc.clear
      dc.draw_bitmap( bitmap, position.first, position.last, true )
    end
  end
end

MyApp.new.main_loop

 前回までと比べて、Thumbnails_Frame の部分にはいくつか手を加えてあります。まず、initialize()

    evt_list_item_activated( @thumb_list ) { |e| thumb_activated( e ) }
    @thumb_list.evt_size { |e| refresh_thumb_list }

evt_list_item_activated() で、特定のサムネイルがダブルクリックされたときの処理を割り付けています。Wx::ListCtrl のドキュメント を参照すると evt_list_item_activated( id ) には、引数として id を渡すのですが、これが何の id か、当初わかりませんでした。てっきり、ダブルクリックする対象としての ListItem の instance を渡す ( wxRuby では、object の instance を渡すと、WxWidget における window id を渡したのと同じ挙動をすることになっています) のかと思ったのですが、いろいろ渡してみたところ、ListCtrl の instance を渡せばよいことがわかりました。

2 行目の @thumb_list.evt_size{} を追加したことで、サムネイル表示 window を拡大・縮小しても、そのたびサムネイルが再配置されます。これは前回まではできていなかったことです。

@tuhmb_list.evt_size{} 内から呼び出される ThumbnailsFrame#refresh_thumb_list() は以下のようになっています。

  def refresh_thumb_list
    @thumb_list.each do |i|
      item = @thumb_list.item(i)
      item.text = "current index = #{item.id.to_s}"
      @thumb_list.set_item( item )
    end
    @thumb_list.sort_items{ |a,b| a <=> b }

  end

前半の @thumb_list.each do ... end の部分は、なければないで動きます。必須なのは @thumb_list.sort_items{ |a,b| ... } の部分。ちなみに、a, b には、ListItem#get_data の部分が渡されます。ThumbnailsFrame#load_thumbnails() の中で、ListItem#data に画像ファイル名を設定していますので、実際には文字列比較が行われています。data に値を設定していないときにどのような挙動になるのかは確認していません。

@thumb_list に要素を追加・削除した場合には、画面表示の前に sort_items{} が必要になるのはわかりますが、window size を変えるだけであれば sort しなくてもすみそうなものなのですが・・・。

なぜか、sort_items{} 以外の method を呼んでも、window size 変更時にサムネイルが再配置されません。どうにも納得できないので、他に良い方法があれば、是非教えてください。

サムネイルをダブルクリックしたときの動作は、ThumbnailsFrame#thumb_activated() に記述しています。なんのことはない、ViewFrame を開いて、子画面に画像を表示するだけです。"画像を貼り付ける(2) - まじめに draw_bitmap" で書いたコードに近いのですが、window size を変更した後にドットバイドットで原寸大表示できる window size に変更するボタンと、元画像ファイルを削除するボタンを実装しました。

原寸大表示の実処理は ViewFrame#resize_to_original() で記述しています。@panel に、元画像の画像のサイズを問い合わせて、それにボタン表示領域の高さを加えたサイズを ViewFrame の表示領域サイズとして設定します。

resize の度ごとに @sizer_button の高さを参照するのも無駄なので、当初は ViewFrame#initialize() の中で、@size_heght として buffering してみたのですが、このタイミングでは @sizer_button の高さは 0 にしかなりません。どうやら、実際に表示されるまでは、sizer のサイズは確定しないようですね。おもしろいですね。

delete ボタンをクリックすると、ViewFrame#delete_me() の中で確認ダイアログを出した上で、親の method である ThumbnailsFrame#delete_item() を呼び出します。 ThumbnailsFrame#delete_item() 内で画像ファイルを削除し、@thumb_list から ListItem を削除します。サムネイルを一枚減らさなければならないので、refresh_thumb_list() を call し、sort_items() だけでなく、ListItem#text に通し番号を付け直します。この text は、サムネイル画面で、サムネイル画像の説明文字列として表示されます。

これで、特定のフォルダ下の全画像をサムネイル表示し、子画面で個別の画像を表示し、不要な画像を削除するプログラムができました・・・と言いたいところなのですが。実は、このコードでは、意図通りの動作をしません。試しにサムネイル一覧から、別の画像で子画面を 2 枚開いて、その 2 枚を削除してみると・・・意図したのと違う画像が削除されてしまいます。

次回は、どこに問題があるのかを解説し、次々回で、対策を施したコードを示す予定です。

ではでは。

2012年5月28日 (月)

画像を貼り付ける(3) - ListCtrl でサムネイル

 前回までで、個別の画像を表示する方法はなんとなくわかりましたが、今度は当然のように、複数の画像を一覧して選びたくなりました。(いわゆるサムネイル一覧)

 これが、意外とてこずってしまい、そのため参考にしたサイトがどこだったか、わからなくなってしまいました。 Wx::ListCtrl を使ってサムネイル一覧を表示、画像そのものは Wx::ImageList  を経由して扱う、というやり方はどこかのサイトからカンニングしたので、是非リンクを張りたかったのですが。

 とりあえずコードです。実行したら、画面上部の [Load...] ボタンをクリック、複数の画像ファイルが存在するフォルダを選択すると、画像とファイル名の一覧が表示されます。Window の拡大縮小でレイアウトがついてこないなど、まだ不満はありますが、まずここからです。

# encoding: UTF-8

require 'wx'

class MyApp < Wx::App
  def on_init
    @main_frame = ThumbnailsFrame.new
    @main_frame.show
  end
end

class ThumbnailsFrame < Wx::Frame
  THUMB_W = 120
  THUMB_H =  90

  def initialize
    super(nil, :title => "Thumbnails", :size => [640, 480])
    set_sizer(Wx::BoxSizer.new( Wx::VERTICAL ))

    load_button = Wx::Button.new(self, :label => "Load...")
    get_sizer.add_item(load_button)
    evt_button( load_button ) { |evt| on_load }

    @thumb_list = Wx::ListCtrl.new(self, :style => Wx::LC_ICON)
    get_sizer.add_item(@thumb_list, :proportion => 1, :flag => Wx::EXPAND)

    create_status_bar
  end

  private
  def on_load
    dlg = Wx::DirDialog.new(self)
    if dlg.show_modal == Wx::ID_OK
      load_thumbnails(dlg.get_path)
    end
  end

  def load_thumbnails(dir)
    @thumb_list.clear_all
    image_list = Wx::ImageList.new(THUMB_W, THUMB_H)
    @thumb_list.set_image_list(image_list, Wx::IMAGE_LIST_NORMAL)

    item = Wx::ListItem.new

    ptn = "#{dir}/*.jpg\0#{dir}/*.png".gsub( /\\/, '/' )

    Dir.glob( ptn ).sort.each do |img_file|
      set_status_text("サムネイルを作成中...#{img_file}")

      idx = image_list.add(thumbnail_bitmap(img_file))
      unless ( idx < 0 )
        item.id = idx
        item.image = idx
        item.text = img_file
        @thumb_list.insert_item( item )
      else
        print "add image error : #{img_file}\n"
      end
      item.clear
    end
    set_status_text("サムネイルの作成が完了しました。")
  end

  def thumbnail_bitmap(file)
    img = Wx::Image.new(file)
    img_w, img_h = img.get_width, img.get_height
    ratio_w, ratio_h = THUMB_W.to_f/img_w.to_f, THUMB_H.to_f/img_h.to_f

    if ( ratio_w < ratio_h )
      new_h = (img_h * ratio_w).to_i
      img.rescale(THUMB_W, new_h)
      img.resize([THUMB_W,THUMB_H],Wx::Point.new( 0,(THUMB_H - new_h)/2 ))
    else
      new_w = (img_w * ratio_h).to_i
      img.rescale(new_w, THUMB_H)
      img.resize([THUMB_W,THUMB_H],Wx::Point.new( (THUMB_W - new_w)/2,0 ))
    end

    Wx::Bitmap.from_image(img)
  end
end

MyApp.new.main_loop

Wx::ListCtrl には、:style => Wx::LC_ICON 以外に LC_LIST とか LC_REPORT という形式もあるようですが、まず LC_ICON で使い方を覚えるのが基本のようです。で、直接サムネイル画像を list に追加していくのであればわかり易いのですが、 Wx::ImageList を経由する方法しか用意されていません。Wx::ListCtrl 以外にも Wx::TreeCtrl でも同様に使うらしいので、内部的にはメリットがあるのでしょう。

 ともかく、Wx::ImageList#add で、image_list に画像を追加登録すると、返り値として「image list の何番目か」という整数値が得られます。Wx::ListCtrl や、Wx::TreeList に使用する画像を指定するには、ListCtrl に、画像を溜め込んだ ImageList を割付けた上で、( ListCtrl#set_image_list( image_list, Wx::IMAGE_LIST_NORMAL) ) 「何番目か」という index 値を用いて指定します。画像を指定する Wx::ListCtrl#insert_item() にはいくつかの形式があって、直接 index や、文字列ラベルを与えて登録することもできますが、ここでは、Wx::ListItem の instance を使用して登録しています。上記のコードのように、ListItem の instance を作成しておいて、必要な属性を設定したうえで、この item を ListCtrl#insert_item() に与えるのが一番応用が効きます。ImageList 内の画像を指定する index 値を ListItem に設定して、ListCtrl に指定するという回りくどい構造になっています。

 ここまでだけであれば、WxRuby のドキュメント を順にたどっていけば、英語であることで幾分時間はかかるものの、もっと簡単だったのですが、ドキュメントを読んでいるだけではわからないトラップがあったので、ずいぶんと時間がかかってしまいました。

 「トラップ」というのは、ImageList に追加する画像の縦横サイズは、Wx::ImageLIst.new() で最初に指定した画像の縦横サイズに合わせておかないと、扱えないということです。

 正確には、完全に一致していなくても大丈夫な場合もあるようなのですが、一致させておくのが無難なようです。画像サイズを操作するのは、Wx::Image の方が得意なので、Image の method を用い、最後に Wx::Bitmap.from_image() で Wx::Bitmap に変換してから ImageList#add() を使います。このとき、登録する Bitmap 画像の縦横サイズが不適切だと、追加に失敗し、-1 が返ります。これもドキュメントにはありませんでした。

 ThumbnailsFrame#thumbnail_bitmap() の中で、rescale や resize を繰り返しているのは、元画像の縦横比を保った上でサムネイルを拡大・縮小し、ImageList に登録するための縦横サイズに合わせて、余白を追加する作業を行っています。試しに、"img.resize(...)" の行だけを削除して、ThumbnailsFrame#load_thumbnails() 内の "unless ( idx < 0 ) ..." を外して実行してみて下さい。表示されるサムネイル数がぐっと減ったり、画像が重複して表示されたり、と、なんともわかりにくい結果になります。

 次は、画面サイズに合わせてレイアウトを変えたり、サムネイルをクリックするイベントを拾い上げて、別 window で、その画像を表示したりといった機能を実装してみたいものです。

2012年3月22日 (木)

画像を貼り付ける(2) - まじめに draw_bitmap

Device Context に画像を貼り付ける方法で、以下のコードでとりあえず動きます。

# encoding: UTF-8

require 'wx'

class MyApp < Wx::App
  def on_init
    @main_frame = FrameMain.new
    @main_frame.show
  end
end

class FrameMain < Wx::Frame
  def initialize( title = "" )
    super( nil, -1, title, :size => [450,500] )
    sizer_top = Wx::BoxSizer.new( Wx::VERTICAL )
    set_sizer( sizer_top )
    sizer_top.add( make_button(), 0, Wx::ALIGN_CENTER_HORIZONTAL )
    sizer_top.add( @panel = MyPanel.new( self ), 1, Wx::EXPAND )
  end

  def make_button
    button = Wx::Button.new(self, -1, "Browse image file" )
    evt_button( button ) { |e| browse_file( e ) }
    button
  end

  def browse_file( evt )
    dlg = Wx::FileDialog.new( self, 'file 選択' )
    if ( dlg.show_modal == Wx::ID_OK )
      @panel.load_image( dlg.get_path )
    end
  end
end

class MyPanel < Wx::Panel
  def load_image( file_name )
    img = Wx::Image.new( file_name )
    img = img.rescale( img.width/2, img.height/2 )
    bitmap = img.convert_to_bitmap
    paint do |dc|
      dc.clear
      dc.draw_bitmap( bitmap, 0, 0, true )
    end
  end
end

MyApp.new.main_loop

前々回のコードで、Wx::StaticBitmap() を使っていたところを DC#draw_bitmap() で描画するように書き換えました。draw_bitmap() の前に、DC#clear で画面を一旦消すようにしています。例によって、困った時の"wxRubyでGUIプログラミング" 頼り、で、Wx::Window#paint の使い方をカンニングしました。

Wx::Window#paint のドキュメント を参照すると、evt_paint {...} の中で呼ばれると、PaintDC を、それ以外の文脈で呼ばれると、ClientDC をブロック引数として拾い上げるそうなので、このコードであれば、ClientDC に描画しているのでしょう。

上のコードを実行すると、ある画像を表示してから、もう一度 button をクリックして別のファイルを選ぶと、ちゃんと前の画像を消去して新しく選んだ画像を表示してくれます。

ところがどっこい、これで一旦ウィンドウを縮小し、再度拡大すると、欠けた部分の画像が再描画されません。これでは使えない!

あれこれ試して、この問題を解決したのが次のコードです。

# encoding: UTF-8

require 'wx'

class MyApp < Wx::App
  def on_init
    @main_frame = FrameMain.new
    @main_frame.show
  end
end

class FrameMain < Wx::Frame
  def initialize( title = "" )
    super( nil, -1, title, :size => [450,500] )

    sizer_top = Wx::BoxSizer.new( Wx::VERTICAL )
    set_sizer( sizer_top )
    sizer_top.add( make_button(), 0, Wx::ALIGN_CENTER_HORIZONTAL )
    sizer_top.add( @panel = MyPanel.new( self ), 1, Wx::EXPAND )
  end

  def make_button
    button = Wx::Button.new(self, -1, "Browse image file" )
    evt_button( button ) { |e| browse_file( e ) }
    button
  end

  def browse_file( evt )
    dlg = Wx::FileDialog.new( self, 'file 選択' )
    if ( dlg.show_modal == Wx::ID_OK )
      path = dlg.get_path
      @panel.load_image( path )
      set_title( path )
    end
  end
end

class MyPanel < Wx::Panel
  def initialize( *args )
    super
    evt_size { |evt| draw_image }
  end

  def load_image( file_name )
    @img ||= Struct.new( :image, :width, :height ).new
    image = Wx::Image.new( file_name )
    @img.image = image
    @img.width = image.width; @img.height = image.height
    draw_image
  end

  def draw_image
    return unless( @img )

    width = self.get_client_size.width
    height = self.get_client_size.height
    ratio = [ width.to_f/@img.width, height.to_f/@img.height ]
    new_scale = if ( ratio.first < ratio.last )
      [ width, (@img.height * ratio.first).to_i ]
    else
      [ (@img.width * ratio.last).to_i, height ]
    end
    bitmap = @img.image.copy.rescale( *new_scale ).convert_to_bitmap
    position = [ (width - new_scale.first)/2, (height - new_scale.last)/2 ]
    paint do |dc|
      dc.clear
      dc.draw_bitmap( bitmap, position.first, position.last, true )
    end
  end
end

MyApp.new.main_loop

まず、DC#draw_bitmap で描画するルーチンを MyPanel#draw_image としてくくりだしました。MyPanel#load_image から呼び出すのは当然として、MyPanel#initialize で evt_size{ } にも event handler として割り付けています。つまり、ウィンドウの拡大縮小に伴って、MyPanel のサイズが変わると evnet が発生するので、その度画像を際描画できます。

単に画像が欠けて再描画されるだけでもよいですが、せっかくなので、ウィンドウサイズに応じて画像そもののが拡大縮小されるようにしてみました。(やっと、当初の目標であった、任意の画像を任意に拡大縮小できるところまできました)

画像の拡大・縮小処理は、MyPanel#draw_image の中でやっていますが、要は、もともとの jpeg 画像の縦横 pixel 数と、現在の MyPanel の表示エリアサイズ( Wx::Window#get_client_sizeで取得しています ) を比較して、画面からはみ出さないような画像のサイズを求めています。pixel サイズは整数、拡大率は実数、Wx::Image#rescale() に与える引数は整数で、rescale() の実装が暗黙の型変換をしてくれないようなので、適宜 to_f, to_i で整数⇔実数変換をしています。

Device Context について、やっぱりまだまだよくわからないことが多いです。前々回で参考にしたサイトの"white wheelsのメモ" では、

「デバイスコンテキストはwx.PaintDC(window)で得られるのですが、
"Paintイベント"のときにしか取得できないので、onPaintイベントハンドラを作成します。」

とあったので、当初は、evt_size{ } ではなく、evt_paint{ } で draw_image を呼び出すようにしてみました。ところが、なんともうまくいかないのですね。それで evt_paint{ } を使わずに、evt_size{ } で実装してみたら成功したわけです。

( MyPanel#initialize 中の evt_size{ } を evt_paint{ |evt| draw_image } に変えてみてください。うまく動かなくて困ります)

"Paintイベント"でなくても Device Context は取得できているようですし・・・。 まあ、よくわからないながらも、いろいろと書いているうちに Device Context が理解できるようになることを期待しつつ、今日はここまでとします。

2012年3月20日 (火)

画像を貼り付ける(1) - お手軽 StaticBitmap

wxRuby でウィンドウ内に画像を貼り付けたい。そもそも、ruby/Tk で間に合わなくて、あれこれ調べた結果 wxRuby までたどり着いたので、画像処理なんて高度なことはできなくてもよいのですが、任意の画像ファイルを読み込んで、適宜拡大・縮小するくらいのことはしたいわけです。ruby/Tk では素のままでは jpeg を扱えなくて、jpeg を使える拡張を施しても、画像の拡大・縮小で行き詰りました。

この週末で、漸く画像の貼り付けまで手が届きました。手元の参考書 を見ても、とっかかりがつかめなかったので、"Wx::Image" で検索をかけてみましたが、wxRuby の情報は少ないですね。仕方がないので wxPython の情報を参考に try & error です。

まず、とりあえず簡単に表示するものです。"white wheelsのメモ" を参考にさせていただきました。

# encoding: UTF-8

require 'wx'

class MyApp < Wx::App
  def on_init
    @main_frame = FrameMain.new
    @main_frame.show
  end
end

class FrameMain < Wx::Frame
  def initialize( title = "" )
    super( nil, -1, title, :size => [450,500] )
    sizer_top = Wx::BoxSizer.new( Wx::VERTICAL )
    set_sizer( sizer_top )
    sizer_top.add( make_button(), 0, Wx::ALIGN_CENTER_HORIZONTAL )
    sizer_top.add( @panel = MyPanel.new( self ), 1, Wx::EXPAND )
  end

  def make_button
    button = Wx::Button.new(self, -1, "Browse image file" )
    evt_button( button ) { |e| browse_file( e ) }
    button
  end

  def browse_file( evt )
    dlg = Wx::FileDialog.new( self, 'file 選択' )
    if ( dlg.show_modal == Wx::ID_OK )
      @panel.load_image( dlg.get_path )
    end
  end
end

class MyPanel < Wx::Panel
  def load_image( file_name )
    img = Wx::Image.new( file_name )
    img = img.rescale( img.width/2, img.height/2 )
    Wx::StaticBitmap.new( self, -1, img.convert_to_bitmap, [0,0], [img.width, img.height ] )
  end
end

MyApp.new.main_loop

FrameMain#initialize の中で、Wx::Button と MyPanel を new して、BoxSizer( HORIZONTAL ) で縦に並べて貼り付けています。

button をクリックすると、FrameMain#browse_file に飛んで、file 選択 dialog が開きます。取得した file 名を MyPanel#load_image に渡します。本題の画像表示は、MyPanel#load_image 内で処理します。

Wx::Image#new のマニュアルを参照すると、読み込む画像ファイルの形式を指定することも可能ですが、type に default で Wx::BITMAP_TYPE_ANY が指定されているので、file 名を指定するだけで、読み込めます。

ここで重要なのが、Wx::Image は、そのままでは画面表示に使えないことです。あちこち読めば明記してあるのですが、いかんせん、英語のドキュメントばかりだと、気づくのに暫くかかります。Wx::Image のマニュアル の先頭にも、「Platform に依存しない形で画像データを扱えるようにカプセル化されているが、少なくとも現状では、そのままでは Device Context に描画できない」といったことが書かれています。

と、いうことで、Image のままでできる処理は済ませた上で、最後に Wx::Bitmap に変換します。上記のコードであれば、先に img.rescale() で、画像を縦・横ともに 1/2 に縮小処理を済ませておきます。

画面に表示するのに、一番手っ取り早そうなのが、Wx::StaticBitmap#new() です。第三引数に画像データを渡しますが、ここで、img.convert_to_bitmap で、Wx::Image を Wx::Bitmap に変換したものを渡します。これで画面に表示されるのですが、Panel が self の文脈で StaticBitmap.new() するだけで、Panel に画像が表示されるギミックが全くわかりません。StaticBitmap と Panel の instance がどこで結びついているのか? 恐らく内部で DC を呼び出したりしてるのでしょうが。

で、上記のコードは、 ウィンドウサイズを、画像が隠れるほど縮小してから元に戻しても、隠れた部分の画像も無事再描画してくれますし、決して悪くないのですが、一旦表示した画像を適宜拡大・縮小することができません (少なくとも方法がわかりません)。もっと悪いことに、button をもう一度クリックして、別のファイルを選んでも、前の画像が消えません (これも、少なくとも、消し方がわかりません)。

と、いうことで、一度表示してそれっきりならともかく、途中で画像を変えたり、拡大・縮小するには、この方法では不足です。次回はとうとう Device Context に手を出してみます。

Device Context はいまだによくわからないんですが、まぁ、わからなくても使えればよいということで・・・。

2012年2月 9日 (木)

ようやく1本書き上げました(10) - 一応完結

主だった技術的な問題が整理できたので、残りの機能を実装します。

Img_10_01

[file 出力] と [debug log] のタブに、[Browse] ボタンがありますが、これは、処理結果を出力するファイル名を、キーボードからの直接入力でなく、ファイルダイアログから選択するボタンです。直ぐ上の MyPanelOutOrScan の [Browse] ボタンで、出力先のフォルダを選ぶのと同じです。

この機能は、[file 出力]タブ、[debug log]タブそれぞれの中身を表示している panel に共通になるように、wxFormBuilder を使う際に名前空間をそろえるように配慮しておきました。そのため、共通の親クラスである、MyPanelAbstractOrLogBase に実装すれば、両方のパネルで動作します。

class MyPanelAbstractOrLogBase < MyPanel
  def initialize( *args )
    xrc = args.shift
    super( *args )
    load_subclass( xrc )
    @text_ctrl_file = find_by_name( 'text_ctrl_file' )
    @text_ctrl_view = find_by_name( 'text_ctrl_view' )
    evt_button( find_by_name( 'btn_file_browse' ) ) { |e| browse_file( e ) }

  end
  attr :text_ctrl_file, :text_ctrl_view

  def browse_file( evt )
    dlg = Wx::FileDialog.new( self, 'file 選択', Wx.get_app.get_ui_data( :dir ) )
    if ( dlg.show_modal == Wx::ID_OK )
      @text_ctrl_file.clear
      @text_ctrl_file.set_value( File.basename( dlg.get_path ) )
    end
  end

end

XRC の 'btn_file_browse' という名前の Button を拾い出して、クリックされると browse_file( evt ) にイベントを渡します。MyApp のサービスルーチン MyApp#get_ui_data() 経由で、MyPanelOutOrScan で指定されたフォルダ名を取得し、これを引数としてfile 選択ダイアログを開き、戻り値を TextCtrl に設定しています。

[file 出力]タブ、[debug log]タブには、もうひとつ、[file を出力] というボタンがあります。これらの機能そのものは、アプリケーションの全体的な機能として実装したいので、MyApp#scan_web() と同様に、MyApp# out_abstract_or_log() として実装します。そして、AppGlobalEvent でイベントを受けて実行する形にします。

class MyApp < Wx::App
  def on_init
    XrcUtil.load_resource_relative( 'MyProject2.xrc' )
    @frame_main = FrameMain.new
    evt_app_global( MyEventID::ScanOnly ) { |evt| scan_web( evt ) }
    evt_app_global( MyEventID::OutAbstractSpecified ){ out_abstract_or_log( :abstract ) }
    evt_app_global( MyEventID::OutLogSpecified ){ out_abstract_or_log( :log ) }
    @frame_main.show
  end

  def scan_web( evt )
-中略-

  end

  # note の abstract 或いは log のページに表示された内容(に相当するもの)
  # を UTF-8 変換される前の状態で、指定された file 名で出力する。
  def out_abstract_or_log( type )
    specified_file = get_ui_data(
     (type == :abstract)? :abstract_specified : :log_specified
    )

    if ( specified_file.empty? )
      Wx::MessageDialog.new( @frame_main, "出力 file 名を指定してください.",
        "Error", Wx::OK | Wx::ICON_ERROR ).show_modal
      return
    end

    full_path = if ( File.dirname( specified_file ) == '.' )
      add_file_to_dir( get_ui_data( :dir ), specified_file )
    else
      specified_file
    end

    begin
      File.open( full_path, "w:binary" ){ |f|
        f.print (type == :rd)? @abstract_output : @log
      }
      Wx::MessageDialog.new( @frame_main, "#{specified_file} を出力しました",
        "Message", Wx::OK | Wx::ICON_INFORMATION ).show_modal
    rescue
      Wx::MessageDialog.new( @frame_main, "#{full_path} の出力に失敗しました",
        "Error", Wx::OK | Wx::ICON_ERROR ).show_modal
      raise if ( $DEBUG )
    end
  end


  def get_ui_data( *args ); @frame_main.get_ui_data( *args ); end
  def get_ui_ctrl( *args ); @frame_main.get_ui_ctrl( *args ); end

private
  # path delimiter として '\' と '/' の混在を考慮し
  # directory 名と file 名を連結して、delimiter をすべて '/' に
  # 統一して返す。

  def add_file_to_dir( dir, file )
    if ( dir.empty? )
      file
    else
      dir = dir.gsub( /\\/, '/' ) if dir.include?( '\\' )
      ( dir[-1] == '/' )? dir + file : dir + '/' + file
    end
  end

end

これで、イベントを受ける側はできましたから、後は panel 側からイベントを投げられるようにするだけです。

class MyPanelAbstract < MyPanelAbstractOrLogBase
  def initialize( *args )
    super( 'panel_abstract', *args )
    evt_button( find_by_name( 'btn_out_file' ) ){
      AppGlobalEvent.new( MyEventID::OutAbstractSpecified ).throw_from( self )
    }

  end
end

class MyPanelLog < MyPanelAbstractOrLogBase
  def initialize( *args )
    super( 'panel_log', *args )
    evt_button( find_by_name( 'btn_out_file' ) ){
      AppGlobalEvent.new( MyEventID::OutLogSpecified ).throw_from( self )
    }

  end
end

最後に残ったボタンは、MyPanelOutOrScan の [Scan → Default file で出力] です。一気に、このボタンにもイベントを割り付けましょう。

class MyApp < Wx::App
  def on_init
    XrcUtil.load_resource_relative( 'MyProject2.xrc' )
    @frame_main = FrameMain.new
    evt_app_global( MyEventID::ScanOnly ) { |evt| scan_web( evt ) }
    evt_app_global( MyEventID::OutAbstractSpecified ){ out_abstract_or_log( :abstract ) }
    evt_app_global( MyEventID::OutLogSpecified ){ out_abstract_or_log( :log ) }
    evt_app_global( MyEventID::ScanAndOutAbstractDefault ){ |evt|
      is_success = scan_web( evt )
      out_abstract_or_log( :abstract ) if ( is_success )
    }


    @frame_main.show
  end
-中略-

end

class MyPanelOutOrScan < MyPanel
  def initialize( *args )
    super
    load_subclass( 'panel_out_or_scan' )
    @text_ctrl_dir = find_by_name( 'text_ctrl_dir' )
    evt_button( find_by_name( 'btn_browse_dir' ) ) { |e| browse_dir( e ) }
    evt_button( find_by_name( 'btn_scan_only' ) ) { |e|
      AppGlobalEvent.new( MyEventID::ScanOnly ).throw_from( self )
    }
    evt_button( find_by_name( 'btn_out_default_file' ) ) { |e|
      AppGlobalEvent.new( MyEventID::ScanAndOutAbstractDefault ).throw_from( self )
    }

  end
-中略-

end

これで、 [Scan → Default file で出力] をクリックすると、web を scan して、すぐに abstract file を出力します。

以下に全ソースを掲載します。これで下請けの sub_a.rb さえ作りこめば出版社の web site から好きに情報を取り出せます。(もちろん、この下請けの作りこみの方がずっと面倒なのですが)


GUI_10a.rb


# encoding: utf-8

require "wx"

SUBs = Dir.glob( File.dirname( __FILE__ ) + '/sub*.rb' )
SUBs.each{ |s| require_relative s }
Journals = SUBs.map{ |s| File.basename( s, '.*' ).upcase }.map{ |j| self.class.const_get(j).new }

# XmlResource を利用するための function
module XrcUtil
module_function
  # Wx::App で最初に xrc file を取り込む function.
  # この function を実行する source file からの相対 path で指定する。
  def load_resource_relative( xrc )
    Wx::XmlResource.get.load( File.dirname( __FILE__ ) + '/' + xrc )
  end

  # Wx::Window を継承した object で、xrc 内で定義されているものを
  # 'name' field の値で識別して返す。
  def find_by_name( name )
    Wx::Window.find_window_by_name( name, self )
  end

  # もともとの load_subclass method が、load する window の種類に
  # よって使い分けを要求されるため、自動判別するために wrap する。
  def load_subclass( name, parent = nil )
    case self
    when Wx::Dialog
      Wx::XmlResource.get.load_dialog_subclass( self, parent, name )
    when Wx::Frame
      Wx::XmlResource.get.load_frame_subclass( self, parent, name )
    when Wx::Wizard
      Wx::XmlResource.get.load_wizard_subclass( self, parent, name )
    when Wx::Panel
      Wx::XmlResource.get.load_panel_subclass( self, parent, name )
    end
  end
end

class AppGlobalEvent < Wx::CommandEvent
  EVT_TYPE = Wx::EvtHandler.register_class(self, nil, 'evt_app_global', 1)

  def initialize( evt_id )
    super(EVT_TYPE)
    self.id = evt_id
  end

  def throw_from( me )
    me.event_handler.process_event(self)
  end
end

module MyEventID
  (<<-_TYPE).split(' ').each_with_index{ |e,i| const_set( e, i + 1 ) }
    ScanAndOutAbstractDefault ScanOnly OutAbstractSpecified OutLogSpecified
  _TYPE
end

class MyApp < Wx::App
  def on_init
    XrcUtil.load_resource_relative( 'MyProject2.xrc' )
    @frame_main = FrameMain.new
    evt_app_global( MyEventID::ScanOnly ) { |evt| scan_web( evt ) }
    evt_app_global( MyEventID::OutAbstractSpecified ){ out_abstract_or_log( :abstract ) }
    evt_app_global( MyEventID::OutLogSpecified ){ out_abstract_or_log( :log ) }
    evt_app_global( MyEventID::ScanAndOutAbstractDefault ){ |evt|
      is_success = scan_web( evt )
      out_abstract_or_log( :abstract ) if ( is_success )
    }

    @frame_main.show
  end

  # 指定された url を scan し abstract file と、debug log を取得。
  # 取得内容を note のそれぞれのページに、UTF-8 変換して表示。
  # abstract および log の出力 file 名入力欄に dafault file 名を設定。
  def scan_web( evt )
    journal = Journals[ get_ui_data( :select_journal_index ) ]
    url = get_ui_data( :url )
    if ( url.empty? )
      Wx::MessageDialog.new( @frame_main, "url を指定してください.", "Error",
        Wx::OK | Wx::ICON_ERROR ).show_modal
      return( false )
    end

    begin
      journal.scan_web_page( url )
    rescue
      Wx::MessageDialog.new( @frame_main, $!.inspect,
        "Error", Wx::OK | Wx::ICON_ERROR ).show_modal
      raise if ( $DEBUG )
      get_ui_ctrl( :text_view_abstract ).clear
      get_ui_ctrl( :text_specify_abstract ).clear
      get_ui_ctrl( :text_view_log ).clear
      get_ui_ctrl( :text_specify_log ).clear
      return( false )
    end

    @abstract_output = journal.abstract
    abstract = get_ui_ctrl( :text_view_abstract )
    abstract.clear; abstract.change_value( NKF.nkf( '-w', @abstract_output ) )
    get_ui_ctrl( :text_specify_abstract ).set_value( journal.default_out_file_name )

    @log = journal.log
    log = get_ui_ctrl( :text_view_log )
    log.clear; log.change_value( NKF.nkf( '-w', @log ) )
    get_ui_ctrl( :text_specify_log ).set_value( 'scan.log' )
    true
  end

  # note の abstract 或いは log のページに表示された内容(に相当するもの)
  # を UTF-8 変換される前の状態で、指定された file 名で出力する。
  def out_abstract_or_log( type )
    specified_file = get_ui_data(
     (type == :abstract)? :abstract_specified : :log_specified
    )

    if ( specified_file.empty? )
      Wx::MessageDialog.new( @frame_main, "出力 file 名を指定してください.",
        "Error", Wx::OK | Wx::ICON_ERROR ).show_modal
      return
    end

    full_path = if ( File.dirname( specified_file ) == '.' )
      add_file_to_dir( get_ui_data( :dir ), specified_file )
    else
      specified_file
    end

    begin
      File.open( full_path, "w:binary" ){ |f|
        f.print (type == :rd)? @abstract_output : @log
      }
      Wx::MessageDialog.new( @frame_main, "#{specified_file} を出力しました",
        "Message", Wx::OK | Wx::ICON_INFORMATION ).show_modal
    rescue
      Wx::MessageDialog.new( @frame_main, "#{full_path} の出力に失敗しました",
        "Error", Wx::OK | Wx::ICON_ERROR ).show_modal
      raise if ( $DEBUG )
    end
  end

  def get_ui_data( *args ); @frame_main.get_ui_data( *args ); end
  def get_ui_ctrl( *args ); @frame_main.get_ui_ctrl( *args ); end

private
  # path delimiter として '\' と '/' の混在を考慮し
  # directory 名と file 名を連結して、delimiter をすべて '/' に
  # 統一して返す。
  def add_file_to_dir( dir, file )
    if ( dir.empty? )
      file
    else
      dir = dir.gsub( /\\/, '/' ) if dir.include?( '\\' )
      ( dir[-1] == '/' )? dir + file : dir + '/' + file
    end
  end
end

class FrameMain < Wx::Frame
  def initialize
    super( nil, -1, $0, :size => [450,500] )
    set_background_colour( Wx::Colour.new( 240, 240, 255 ) )
    sizer_top = Wx::BoxSizer.new( Wx::VERTICAL )
    set_sizer( sizer_top )

    sizer_top.add( @panel_url = MyPanelUrl.new( self ), 0, Wx::EXPAND )
    sizer_top.add( @rbox_select_journal = make_rbox, 0,
      Wx::LEFT | Wx::RIGHT | Wx::EXPAND, 5 )
    sizer_top.add( @panel_out_or_scan = MyPanelOutOrScan.new( self ), 0, Wx::EXPAND )
    sizer_top.add( make_view_note, 1, Wx::EXPAND )
  end

  def make_rbox
    Wx::RadioBox.new( self, -1, 'Select Journal',
      :choices => Journals.map{ |j| j.name.encode(__ENCODING__) },
      :style => Wx::RA_SPECIFY_COLS, :major_dimension => 2
    )
  end

  def make_view_note
    note = Wx::Notebook.new( self, -1 )
    note.add_page( @panel_abstract = MyPanelAbstract.new( note, -1 ), 'file 出力', true )
    note.add_page( @panel_log = MyPanelLog.new( note, -1 ), 'debug log', false )
    note
  end

  # Frame 自身の control 或いは、Frame に貼り付けた Panel などの
  # 下部構造に属する contol のうち、外部から読み出しを要するものに
  # 対する access を集約する method. 書き込みはできない。
  def get_ui_data( tag )
    case tag
    when :url; @panel_url.text_ctrl_url.get_value

    when :select_journal_name; @rbox_select_journal.get_string_selection
    when :select_journal_index; @rbox_select_journal.get_selection

    when :dir; @panel_out_or_scan.text_ctrl_dir.get_value

    when :abstract_specified; @panel_abstract.text_ctrl_file.get_value
    when :log_specified; @panel_log.text_ctrl_file.get_value
    else; nil
    end
  end

  # Frame 自身の control 或いは、Frame に貼り付けた Panel などの
  # 下部構造に属する contol のうち、外部から書き込みを要するものに
  # 対する access を集約する method.
  # 読み出しのみでよいものは、get_ui_data で。
  def get_ui_ctrl( tag )
    case tag
    when :text_specify_abstract; @panel_abstract.text_ctrl_file
    when :text_specify_log; @panel_log.text_ctrl_file
    when :text_view_abstract; @panel_abstract.text_ctrl_view
    when :text_view_log; @panel_log.text_ctrl_view
    else; nil
    end
  end
end

class MyPanel < Wx::Panel
  include XrcUtil
end

class MyPanelUrl < MyPanel
  def initialize( *args )
    super
    load_subclass( 'panel_url' )
    @text_ctrl_url = find_by_name( 'text_ctrl_url' )

    evt_button( find_by_name( 'btn_url_paste' ) ){ |evt|
      @text_ctrl_url.clear
      @text_ctrl_url.paste
    }
  end
  attr :text_ctrl_url
end

class MyPanelOutOrScan < MyPanel
  def initialize( *args )
    super
    load_subclass( 'panel_out_or_scan' )
    @text_ctrl_dir = find_by_name( 'text_ctrl_dir' )
    evt_button( find_by_name( 'btn_browse_dir' ) ) { |e| browse_dir( e ) }
    evt_button( find_by_name( 'btn_scan_only' ) ) { |e|
      AppGlobalEvent.new( MyEventID::ScanOnly ).throw_from( self )
    }
    evt_button( find_by_name( 'btn_out_default_file' ) ) { |e|
      AppGlobalEvent.new( MyEventID::ScanAndOutAbstractDefault ).throw_from( self )
    }
  end
  attr :text_ctrl_dir

  def browse_dir( evt )
    dlg = Wx::DirDialog.new( self, 'Directory 選択', @text_ctrl_dir.get_value )
    if ( dlg.show_modal == Wx::ID_OK )
      @text_ctrl_dir.clear
      @text_ctrl_dir.set_value( dlg.get_path )
    end
  end
end

# note の abstract および log 表示ページ用の Panel
# 処理内容が殆ど共通なので、それぞれのページで異なる
# 部分のみを、ここから継承して記述を加える。
class MyPanelAbstractOrLogBase < MyPanel
  def initialize( *args )
    xrc = args.shift
    super( *args )
    load_subclass( xrc )
    @text_ctrl_file = find_by_name( 'text_ctrl_file' )
    @text_ctrl_view = find_by_name( 'text_ctrl_view' )
    evt_button( find_by_name( 'btn_file_browse' ) ) { |e| browse_file( e ) }
  end
  attr :text_ctrl_file, :text_ctrl_view

  def browse_file( evt )
    dlg = Wx::FileDialog.new( self, 'file 選択', Wx.get_app.get_ui_data( :dir ) )
    if ( dlg.show_modal == Wx::ID_OK )
      @text_ctrl_file.clear
      @text_ctrl_file.set_value( File.basename( dlg.get_path ) )
    end
  end
end

class MyPanelAbstract < MyPanelAbstractOrLogBase
  def initialize( *args )
    super( 'panel_abstract', *args )
    evt_button( find_by_name( 'btn_out_file' ) ){
      AppGlobalEvent.new( MyEventID::OutAbstractSpecified ).throw_from( self )
    }
  end
end

class MyPanelLog < MyPanelAbstractOrLogBase
  def initialize( *args )
    super( 'panel_log', *args )
    evt_button( find_by_name( 'btn_out_file' ) ){
      AppGlobalEvent.new( MyEventID::OutLogSpecified ).throw_from( self )
    }
  end
end

my_app = MyApp.new
my_app.main_loop

これで、ようやく1本書き上げました。

2012年2月 5日 (日)

ようやく1本書き上げました(9) - application 全体に関係するイベントの別案

前回は、application 全体に関与する処理をグローバル変数あるいは Wx.get_app() を経由して、MyApp 内の担当 method を直接呼び出す形でイベントハンドリングしました。

しかし、なんとなくかっこよくありません。もう少しエレガントな方法(にこだわりだすと大抵はまってしまうのですが)がないものかと考えてみました。

いつも参考にさせていただいている「wxRubyでGUIプログラミング」のこちらのページ が、ひとつのよい例です。この例では、application の終了方法が 2 つ用意されています。ひとつは window 上隅の close ボタン、もうひとつはメニューから 'QUIT' を選ぶ方法です。

close ボタンをクリックすると、システムレベルで EVT_CLOSE イベントが発生します。メニューから 'QUIT' を選ぶと、メニューからイベントが発生し、そのイベントのハンドラ内からユーザーレベルで EVT_CLOSE イベントを発生させます。どちらの EVT_CLOSE イベントも MyApp の中で拾い上げられて処理ルーチンが動き出します。

やっぱり、複数の場所から共通して呼び出すルーチンは、このようなイベント - ハンドラシステムで駆動したいものです。システムレベルで、このようなインターフェイスを用意していれば、楽にイベントを管理できます。ボタンやメニューなどシステムに組み込まれているイベント処理が、CPU のハードウェア割り込みに相当するとすれば、ユーザーレベルのイベントシステムを経由した処理ルーチンの呼び出しは、ソフトウェア割り込みのようなものです。

wxRuby のドキュメント をあちこち調べると、どうやら Wx::CommandEvent を継承して、新しいイベントクラスを作るのがよさそうです。既存のイベントクラスを流用できないかとも考えたのですが、ID の重複を避けるのが簡単ではないようなので、新しいものを作ることにしました。BwxRuby を install したときに、lib の下の方にサンプルスクリプトが install されていますが、そのうちの samples/event/event.rb が大変参考になります。これを踏まえて、以下のようなクラスを作りました。

class AppGlobalEvent < Wx::CommandEvent
  EVT_TYPE = Wx::EvtHandler.register_class(self, nil, 'evt_app_global', 1)

  def initialize( evt_id )
    super(EVT_TYPE)
    self.id = evt_id
  end

  def throw_from( me )
    me.event_handler.process_event(self)
  end
end

イベント処理の内部では、ボタンやメニュー、マウスなど、それぞれのイベントの種類の区別を、それぞれ固有の整数値で区別しているようで、Event.new_event_type() で未使用の整数値を確保して使うようです。これを、上のコードの Wx::EvtHandler.register_class() の第二引数に与えるのですが、nil にしておくと、内部で自動的に Event.new_event_type() が call されます。第三引数は、イベントを拾い上げる側の method 名を文字列で与えます。この名前を使って、evt_button( ID ){ ... } のように、このユーザー定義イベントを拾うことができます。

このクラスを使ってイベント処理をすると、前回のソースコードはこのようになります。


GUI_09a.rb


# encoding: utf-8

require "wx"

SUBs = Dir.glob( File.dirname( __FILE__ ) + '/sub*.rb' )
SUBs.each{ |s| require_relative s }
Journals = SUBs.map{ |s| File.basename( s, '.*' ).upcase }.map{ |j| self.class.const_get(j).new }

# XmlResource を利用するための function
module XrcUtil
module_function
  # Wx::App で最初に xrc file を取り込む function.
  # この function を実行する source file からの相対 path で指定する。
  def load_resource_relative( xrc )
    Wx::XmlResource.get.load( File.dirname( __FILE__ ) + '/' + xrc )
  end

  # Wx::Window を継承した object で、xrc 内で定義されているものを
  # 'name' field の値で識別して返す。
  def find_by_name( name )
    Wx::Window.find_window_by_name( name, self )
  end

  # もともとの load_subclass method が、load する window の種類に
  # よって使い分けを要求されるため、自動判別するために wrap する。
  def load_subclass( name, parent = nil )
    case self
    when Wx::Dialog
      Wx::XmlResource.get.load_dialog_subclass( self, parent, name )
    when Wx::Frame
      Wx::XmlResource.get.load_frame_subclass( self, parent, name )
    when Wx::Wizard
      Wx::XmlResource.get.load_wizard_subclass( self, parent, name )
    when Wx::Panel
      Wx::XmlResource.get.load_panel_subclass( self, parent, name )
    end
  end
end

class AppGlobalEvent < Wx::CommandEvent
  EVT_TYPE = Wx::EvtHandler.register_class(self, nil, 'evt_app_global', 1)

  def initialize( evt_id )
    super(EVT_TYPE)
    self.id = evt_id
  end

  def throw_from( me )
    me.event_handler.process_event(self)
  end
end

module MyEventID
  (<<-_TYPE).split(' ').each_with_index{ |e,i| const_set( e, i + 1 ) }
    ScanAndOutAbstractDefault ScanOnly OutAbstractSpecified OutLogSpecified
  _TYPE
end


class MyApp < Wx::App
  def on_init
    XrcUtil.load_resource_relative( 'MyProject2.xrc' )
    @frame_main = FrameMain.new
    evt_app_global( MyEventID::ScanOnly ) { |evt| scan_web( evt ) }

    @frame_main.show
  end

  # 指定された url を scan し、abstract file と、debug log を取得。
  # 取得内容を note のそれぞれのページに、UTF-8 変換して表示。
  # abstract および log の出力 file 名入力欄に dafault file 名を設定。
  def scan_web( evt )
    journal = Journals[ get_ui_data( :select_journal_index ) ]
    url = get_ui_data( :url )
    if ( url.empty? )
      Wx::MessageDialog.new( @frame_main, "url を指定してください.", "Error",
        Wx::OK | Wx::ICON_ERROR ).show_modal
      return( false )
    end

    begin
      journal.scan_web_page( url )
    rescue
      Wx::MessageDialog.new( @frame_main, $!.inspect,
        "Error", Wx::OK | Wx::ICON_ERROR ).show_modal
      raise if ( $DEBUG )
      get_ui_ctrl( :text_view_rd ).clear
      get_ui_ctrl( :text_specify_rd ).clear
      get_ui_ctrl( :text_view_log ).clear
      get_ui_ctrl( :text_specify_log ).clear
      return( false )
    end

    @abstract_output = journal.abstract

    abstract = get_ui_ctrl( :text_view_abstract )
    abstract.clear; abstract.change_value( NKF.nkf( '-w', @abstract_output ) )
    get_ui_ctrl( :text_specify_abstract ).set_value( journal.default_out_file_name )

    log = get_ui_ctrl( :text_view_log )
    log.clear; log.change_value( NKF.nkf( '-w', journal.log ) )
    get_ui_ctrl( :text_specify_log ).set_value( 'scan.log' )
    true
  end

  def get_ui_data( *args ); @frame_main.get_ui_data( *args ); end
  def get_ui_ctrl( *args ); @frame_main.get_ui_ctrl( *args ); end
end

class FrameMain < Wx::Frame
  def initialize
    super( nil, -1, $0, :size => [450,500] )
    set_background_colour( Wx::Colour.new( 240, 240, 255 ) )
    sizer_top = Wx::BoxSizer.new( Wx::VERTICAL )
    set_sizer( sizer_top )

    sizer_top.add( @panel_url = MyPanelUrl.new( self ), 0, Wx::EXPAND )
    sizer_top.add( @rbox_select_journal = make_rbox, 0,
      Wx::LEFT | Wx::RIGHT | Wx::EXPAND, 5 )
    sizer_top.add( @panel_out_or_scan = MyPanelOutOrScan.new( self ), 0, Wx::EXPAND )
    sizer_top.add( make_view_note, 1, Wx::EXPAND )
  end

  def make_rbox
    Wx::RadioBox.new( self, -1, 'Select Journal',
      :choices => Journals.map{ |j| j.name.encode(__ENCODING__) },
      :style => Wx::RA_SPECIFY_COLS, :major_dimension => 2
    )
  end

  def make_view_note
    note = Wx::Notebook.new( self, -1 )
    note.add_page( @panel_abstract = MyPanelAbstract.new( note, -1 ), 'file 出力', true )
    note.add_page( @panel_log = MyPanelLog.new( note, -1 ), 'debug log', false )
    note
  end

  # Frame 自身の control 或いは、Frame に貼り付けた Panel などの
  # 下部構造に属する contol のうち、外部から読み出しを要するものに
  # 対する access を集約する method. 書き込みはできない。
  def get_ui_data( tag )
    case tag
    when :url; @panel_url.text_ctrl_url.get_value

    when :select_journal_name; @rbox_select_journal.get_string_selection
    when :select_journal_index; @rbox_select_journal.get_selection

    when :dir; @panel_out_or_scan.text_ctrl_dir.get_value

    when :abstract_specified; @panel_abstract.text_ctrl_file.get_value
    when :log_specified; @panel_log.text_ctrl_file.get_value
    else; nil
    end
  end

  # Frame 自身の control 或いは、Frame に貼り付けた Panel などの
  # 下部構造に属する contol のうち、外部から書き込みを要するものに
  # 対する access を集約する method.
  # 読み出しのみでよいものは、get_ui_data で。
  def get_ui_ctrl( tag )
    case tag
    when :text_specify_abstract; @panel_abstract.text_ctrl_file
    when :text_specify_log; @panel_log.text_ctrl_file
    when :text_view_abstract; @panel_abstract.text_ctrl_view
    when :text_view_log; @panel_log.text_ctrl_view
    else; nil
    end
  end
end

class MyPanel < Wx::Panel
  include XrcUtil
end

class MyPanelUrl < MyPanel
  def initialize( *args )
    super
    load_subclass( 'panel_url' )
    @text_ctrl_url = find_by_name( 'text_ctrl_url' )

    evt_button( find_by_name( 'btn_url_paste' ) ){ |evt|
      @text_ctrl_url.clear
      @text_ctrl_url.paste
    }
  end
  attr :text_ctrl_url
end

class MyPanelOutOrScan < MyPanel
  def initialize( *args )
    super
    load_subclass( 'panel_out_or_scan' )
    @text_ctrl_dir = find_by_name( 'text_ctrl_dir' )
    evt_button( find_by_name( 'btn_browse_dir' ) ) { |e| browse_dir( e ) }
    evt_button( find_by_name( 'btn_scan_only' ) ) { |e|
      AppGlobalEvent.new( MyEventID::ScanOnly ).throw_from( self )

    }
  end
  attr :text_ctrl_dir

  def browse_dir( evt )
    dlg = Wx::DirDialog.new( self, 'Directory 選択', @text_ctrl_dir.get_value )
    if ( dlg.show_modal == Wx::ID_OK )
      @text_ctrl_dir.clear
      @text_ctrl_dir.set_value( dlg.get_path )
    end
  end
end

# note の abstract および log 表示ページ用の Panel
# 処理内容が殆ど共通なので、それぞれのページで異なる
# 部分のみを、ここから継承して記述を加える。
class MyPanelAbstractOrLogBase < MyPanel
  def initialize( *args )
    xrc = args.shift
    super( *args )
    load_subclass( xrc )
    @text_ctrl_file = find_by_name( 'text_ctrl_file' )
    @text_ctrl_view = find_by_name( 'text_ctrl_view' )
  end
  attr :text_ctrl_file, :text_ctrl_view
end

class MyPanelAbstract < MyPanelAbstractOrLogBase
  def initialize( *args )
    super( 'panel_abstract', *args )
  end
end

class MyPanelLog < MyPanelAbstractOrLogBase
  def initialize( *args )
    super( 'panel_log', *args )
  end
end

my_app = MyApp.new
my_app.main_loop

MyPanelOutOrScan 内で 'btn_scan_only' ボタンのクリックイベントを受け取ると、AppGlobalEvent.new() で、そのイベント ID がMyEventID::ScanOnly であるユーザーイベントを発生させます。AppGlobalEvent はユーザー定義イベントなので、システム側で勝手に発生することは有り得ず、そのためたまたまシステムが発生させたイベント ID が MyEventID::ScanOnly と重なる AppGlobalEvent を引っ掛けてしまう恐れもありません。ユーザー側で、使いたいイベントに(固有でありさえすれば)好きな整数値を割り付けることができます。

発生させたイベントをシステムに投げるには、イベントを発生させたオブジェクトの中でevent_handler.process_event( イベント ) を実行します。長ったらしいので、AppGlobalEvent に throw_from() という wrapper method を作りましたが、引数として呼び出し元の self を渡さないとならないので、あまり楽になった気はしません。(ある method を呼び出した側のオブジェクトを知る方法がわからなかったので…、呼び出し側 method なら caller() でわかるのですが)

throw_from() で投げた AppGlobalEvent は、MyApp#initialize() 内の、evt_app_global( MyEventID::ScanOnly ){ } で拾い上げて、MyApp#scan_web() につなげます。

前回の最後にも書きましたが、こんな手の込んだことをしなくても、get_app() 経由で直接 scan_web() を呼び出せば十分なのですが、CommandEvent を継承した Event 経由での呼び出しには、ひとつ利点がありそうです。

Event handing overview のページに書いてありますが、CommandEvent を発生させてシステムに投げると、そのイベントは、Wx::オブジェクトの親子関係を遡って伝播していきます。親子関係は、Frame や Panel, Button などを new するときに、第一引数として親を知らせる形で木構造を構築します。

MyPanelOutOrScan で発生した AppGlobalEvent は、MyPanelOutOrScan 自身にまず伝わり、次に FrameMain に伝わり、最後に top の application である MyApp に伝わります。この伝播順の途中でイベントを拾い上げればよいので、今回は MyApp#initialize() に evt_app_global() を記述しましたが、FrameMain#initialize() に evt_app_global() を記述してもよいし、(意味はないですが) MyPanelOutOrScan に evt_app_global() を記述してもイベントは拾えます。

この仕組みが便利に使えそうだと思うのは、たとえば、TopApp の下に、同じ画面デザインの Frame_A と Frame_B がぶら下がるようなアプリケーションを書いた場合です。Frame_A/B 両方で、Event_1 と Event_2 が発生するとして、Event_1 は Frame_A/B に共通の処理を、Event_2 は Frame_A/B で別の処理をしたい場合、evt_app_global( Event_1 ) は TopApp 内に、evt_app_global( Event_2 )は、Frame_A と Frame_B の内部にそれぞれ別に記述すれば、Event を発生させる部分の記述には注意を要しません。或いは、Event_2 も TopApp で拾うように記述しておいて、Frame_A だけ、Frame_A 内に evt_app_global( Event_2 ) を追加すれば、イベントの伝播を intercept できます。呼び出し元の記述を一切変更せずに、比較的に柔軟な変更ができそうです。

今回作っているアプリケーションでは、ここまで複雑なことは必要としませんが、このまま AppGlobalEvent を使った形で実装を進めて行きたいと思います。

2012年1月31日 (火)

ようやく1本書き上げました(8) - application 全体に関係するイベント

前回例として挙げた、MyPanelOutOrScan の panel で、[出力せず Scan のみ] ボタンをクリックした場合のイベント処理を考えて見ます。

panel_url の text_ctrl_url から文字列を取得して、RadioBox から、html の内容を処理する下請けオブジェクトを特定して、下請けオブジェクト経由で web から取得した文字列を MyPanelAbstract の text_ctrl_view と text_ctrl_file に設定するという処理が必要になります。今回の application では、この処理を発生させるきっかけは、 [出力せず Scan のみ] ボタンたげですが、同じ処理をメニューから実行させるようにも実装できます。同じ処理を application の複数の場所から実行する設計でも全くおかしくないので、処理するルーチンは、どこからでも呼び出しやすい場所に記述するべきでしょう。

いくつかやりようはあるのかも知れませんが、とりあえず今回は、MyApp#scan_web( evt ) として実装してみます。なんらかのイベントに対するハンドラとして割り付けることを想定しているので、引数として Event を受けます。

  # 指定された url を scan し、rd file と、debug log を取得。
  # 取得内容を note のそれぞれのページに、UTF-8 変換して表示。
  # rd および log の出力 file 名入力欄に dafault file 名を設定。
  def scan_web( evt )
    journal = Journals[ get_ui_data( :select_journal_index ) ]
    url = get_ui_data( :url )
    if ( url.empty? )
      Wx::MessageDialog.new( @frame_main, "url を指定してください.", "Error",
        Wx::OK | Wx::ICON_ERROR ).show_modal
      return( false )
    end

    begin
      journal.scan_web_page( url )
    rescue
      Wx::MessageDialog.new( @frame_main, $!.inspect,
        "Error", Wx::OK | Wx::ICON_ERROR ).show_modal
      raise if ( $DEBUG )
      get_ui_ctrl( :text_view_rd ).clear
      get_ui_ctrl( :text_specify_rd ).clear
      get_ui_ctrl( :text_view_log ).clear
      get_ui_ctrl( :text_specify_log ).clear
      return( false )
    end

    @abstract_output = journal.abstract
    abstract = get_ui_ctrl( :text_view_abstract )
    abstract.clear; abstract.change_value( NKF.nkf( '-w', @abstract_output ) )

    get_ui_ctrl( :text_specify_abstract ).set_value( journal.default_out_file_name )

    @log = journal.log
    log = get_ui_ctrl( :text_view_log )
    log.clear; log.change_value( NKF.nkf( '-w', @log ) )

    get_ui_ctrl( :text_specify_log ).set_value( 'scan.log' )
    true
  end

注意が必要な点として、wxRbuy では日本語は UFT-8 でないと表示が乱れますので、上記で強調表示した行で、下請けルーチンから得た文字列を UFT-8 に変換してから値を設定しています。文字コードの変換にはいくつか方法があるはずですが、添付ライブラリの NKF だと、返還前の文字コードが何であっても、それなりに変換してくれるので、ちょっと古いライブラリらしいのですが使っています。ruby 1.9 組み込みの文字コード変換機能では、元の文字コードが確定していないと使い辛かったり、厳密すぎて変換に失敗することなどがあるので、比較的ルーズなこのライブラリにしました。

ここで、FrameMain および、その下の各 panel から情報を得なければ処理が進みませんので、MyApp#get_ui_data( tag ), MyApp#get_ui_ctrl( tag ) という method を経由して、それぞれ data の値と、data を操作する control を取得します。実態は、完全に下請けに出すだけの wrapper で、このようにしました。

  def get_ui_data( *args ); @frame_main.get_ui_data( *args ); end
  def get_ui_ctrl( *args ); @frame_main.get_ui_ctrl( *args ); end

実体となる FrameMain#get_ui_data と FrameMain#get_ui_ctrl は、こんな感じで実装してみました。

  # Frame 自身の control 或いは、Frame に貼り付けた Panel などの
  # 下部構造に属する contol のうち、外部から読み出しを要するものに
  # 対する access を集約する method. 書き込みはできない。
  def get_ui_data( tag )
    case tag
    when :url; @panel_url.text_ctrl_url.get_value

    when :select_journal_name; @rbox_select_journal.get_string_selection
    when :select_journal_index; @rbox_select_journal.get_selection

    when :dir; @panel_out_or_scan.text_ctrl_dir.get_value

    when :abstract_specified; @panel_abstract.text_ctrl_file.get_value
    when :log_specified; @panel_log.text_ctrl_file.get_value
    else; nil
    end
  end

  # Frame 自身の control 或いは、Frame に貼り付けた Panel などの
  # 下部構造に属する contol のうち、外部から書き込みを要するものに
  # 対する access を集約する method.
  # 読み出しのみでよいものは、get_ui_data で。
  def get_ui_ctrl( tag )
    case tag
    when :text_specify_abstract; @panel_abstract.text_ctrl_file
    when :text_specify_log; @panel_log.text_ctrl_file
    when :text_view_abstract; @panel_abstract.text_ctrl_view
    when :text_view_log; @panel_log.text_ctrl_view
    else; nil
    end
  end

まだ、各 panel から情報を吸い上げられるような method を実装していないので、すぐに動作する code ではありませんが、雰囲気はつかんでもらえると思います。

さて、それでは、どうやって MyApp#scan_web を呼び出すか。まず、一番安直な方法から。実際のところ、今回くらいの規模であれば、これが正解かとも思うのですが・・・。


MyPanelOutOrScan#initialize の中に記述します


    evt_button( find_by_name( 'btn_scan_only' ) ) { |e| $my_app.scan_web( e ) }

source code の最後で、MyApp.new したときの返り値を、グローバル変数としての $my_app に代入しておくのがミソです。これで、この先この application が複雑になっても、グローバル変数を経由して、どこからでも呼び出すことができます。

このグローバル変数経由でイベント処理を行うパターンでのソースコードはこのようになります。


GUI_08a.rb


# encoding: utf-8

require "wx"

SUBs = Dir.glob( File.dirname( __FILE__ ) + '/sub*.rb' )
SUBs.each{ |s| require_relative s }
Journals = SUBs.map{ |s| File.basename( s, '.*' ).upcase }.map{ |j| self.class.const_get(j).new }

# XmlResource を利用するための function
module XrcUtil
module_function
  # Wx::App で最初に xrc file を取り込む function.
  # この function を実行する source file からの相対 path で指定する。
  def load_resource_relative( xrc )
    Wx::XmlResource.get.load( File.dirname( __FILE__ ) + '/' + xrc )
  end

  # Wx::Window を継承した object で、xrc 内で定義されているものを
  # 'name' field の値で識別して返す。
  def find_by_name( name )
    Wx::Window.find_window_by_name( name, self )
  end

  # もともとの load_subclass method が、load する window の種類に
  # よって使い分けを要求されるため、自動判別するために wrap する。
  def load_subclass( name, parent = nil )
    case self
    when Wx::Dialog
      Wx::XmlResource.get.load_dialog_subclass( self, parent, name )
    when Wx::Frame
      Wx::XmlResource.get.load_frame_subclass( self, parent, name )
    when Wx::Wizard
      Wx::XmlResource.get.load_wizard_subclass( self, parent, name )
    when Wx::Panel
      Wx::XmlResource.get.load_panel_subclass( self, parent, name )
    end
  end
end

class MyApp < Wx::App
  def on_init
    XrcUtil.load_resource_relative( 'MyProject2.xrc' )
    @frame_main = FrameMain.new
    @frame_main.show
  end

  # 指定された url を scan し、abstract file と、debug log を取得。
  # 取得内容を note のそれぞれのページに、UTF-8 変換して表示。
  # abstract および log の出力 file 名入力欄に dafault file 名を設定。
  def scan_web( evt )
    journal = Journals[ get_ui_data( :select_journal_index ) ]
    url = get_ui_data( :url )
    if ( url.empty? )
      Wx::MessageDialog.new( @frame_main, "url を指定してください.", "Error",
        Wx::OK | Wx::ICON_ERROR ).show_modal
      return( false )
    end

    begin
      journal.scan_web_page( url )
    rescue
      Wx::MessageDialog.new( @frame_main, $!.inspect,
        "Error", Wx::OK | Wx::ICON_ERROR ).show_modal
      raise if ( $DEBUG )
      get_ui_ctrl( :text_view_rd ).clear
      get_ui_ctrl( :text_specify_rd ).clear
      get_ui_ctrl( :text_view_log ).clear
      get_ui_ctrl( :text_specify_log ).clear
      return( false )
    end

    @abstract_output = journal.abstract
    abstract = get_ui_ctrl( :text_view_abstract )
    abstract.clear; abstract.change_value( NKF.nkf( '-w', @abstract_output ) )
    get_ui_ctrl( :text_specify_abstract ).set_value( journal.default_out_file_name )

    @log = journal.log
    log = get_ui_ctrl( :text_view_log )
    log.clear; log.change_value( NKF.nkf( '-w', @log ) )
    get_ui_ctrl( :text_specify_log ).set_value( 'scan.log' )
    true
  end

  def get_ui_data( *args ); @frame_main.get_ui_data( *args ); end
  def get_ui_ctrl( *args ); @frame_main.get_ui_ctrl( *args ); end
end

class FrameMain < Wx::Frame
  def initialize
    super( nil, -1, $0, :size => [450,500] )
    set_background_colour( Wx::Colour.new( 240, 240, 255 ) )
    sizer_top = Wx::BoxSizer.new( Wx::VERTICAL )
    set_sizer( sizer_top )

    sizer_top.add( @panel_url = MyPanelUrl.new( self ), 0, Wx::EXPAND )
    sizer_top.add( @rbox_select_journal = make_rbox, 0,
      Wx::LEFT | Wx::RIGHT | Wx::EXPAND, 5 )
    sizer_top.add( @panel_out_or_scan = MyPanelOutOrScan.new( self ), 0, Wx::EXPAND )
    sizer_top.add( make_view_note, 1, Wx::EXPAND )
  end

  def make_rbox
    Wx::RadioBox.new( self, -1, 'Select Journal',
      :choices => Journals.map{ |j| j.name.encode(__ENCODING__) },
      :style => Wx::RA_SPECIFY_COLS, :major_dimension => 2
    )
  end

  def make_view_note
    note = Wx::Notebook.new( self, -1 )
    note.add_page( @panel_abstract = MyPanelAbstract.new( note, -1 ), 'file 出力', true )
    note.add_page( @panel_log = MyPanelLog.new( note, -1 ), 'debug log', false )
    note
  end

  # Frame 自身の control 或いは、Frame に貼り付けた Panel などの
  # 下部構造に属する contol のうち、外部から読み出しを要するものに
  # 対する access を集約する method. 書き込みはできない。
  def get_ui_data( tag )
    case tag
    when :url; @panel_url.text_ctrl_url.get_value

    when :select_journal_name; @rbox_select_journal.get_string_selection
    when :select_journal_index; @rbox_select_journal.get_selection

    when :dir; @panel_out_or_scan.text_ctrl_dir.get_value

    when :abstract_specified; @panel_abstract.text_ctrl_file.get_value
    when :log_specified; @panel_log.text_ctrl_file.get_value
    else; nil
    end
  end

  # Frame 自身の control 或いは、Frame に貼り付けた Panel などの
  # 下部構造に属する contol のうち、外部から書き込みを要するものに
  # 対する access を集約する method.
  # 読み出しのみでよいものは、get_ui_data で。
  def get_ui_ctrl( tag )
    case tag
    when :text_specify_abstract; @panel_abstract.text_ctrl_file
    when :text_specify_log; @panel_log.text_ctrl_file
    when :text_view_abstract; @panel_abstract.text_ctrl_view
    when :text_view_log; @panel_log.text_ctrl_view
    else; nil
    end
  end
end

class MyPanel < Wx::Panel
  include XrcUtil
end

class MyPanelUrl < MyPanel
  def initialize( *args )
    super
    load_subclass( 'panel_url' )
    @text_ctrl_url = find_by_name( 'text_ctrl_url' )

    evt_button( find_by_name( 'btn_url_paste' ) ){ |evt|
      @text_ctrl_url.clear
      @text_ctrl_url.paste
    }
  end
  attr :text_ctrl_url
end

class MyPanelOutOrScan < MyPanel
  def initialize( *args )
    super
    load_subclass( 'panel_out_or_scan' )
    @text_ctrl_dir = find_by_name( 'text_ctrl_dir' )
    evt_button( find_by_name( 'btn_browse_dir' ) ) { |e| browse_dir( e ) }
    evt_button( find_by_name( 'btn_scan_only' ) ) { |e|
      $my_app.scan_web( e )
    }
  end
  attr :text_ctrl_dir

  def browse_dir( evt )
    dlg = Wx::DirDialog.new( self, 'Directory 選択', @text_ctrl_dir.get_value )
    if ( dlg.show_modal == Wx::ID_OK )
      @text_ctrl_dir.clear
      @text_ctrl_dir.set_value( dlg.get_path )
    end
  end
end

# note の abstract および log 表示ページ用の Panel
# 処理内容が殆ど共通なので、それぞれのページで異なる
# 部分のみを、ここから継承して記述を加える。
class MyPanelAbstractOrLogBase < MyPanel
  def initialize( *args )
    xrc = args.shift
    super( *args )
    load_subclass( xrc )
    @text_ctrl_file = find_by_name( 'text_ctrl_file' )
    @text_ctrl_view = find_by_name( 'text_ctrl_view' )
  end
  attr :text_ctrl_file, :text_ctrl_view
end

class MyPanelAbstract < MyPanelAbstractOrLogBase
  def initialize( *args )
    super( 'panel_abstract', *args )
  end
end

class MyPanelLog < MyPanelAbstractOrLogBase
  def initialize( *args )
    super( 'panel_log', *args )
  end
end

$my_app = MyApp.new
$my_app.main_loop

これはこれでよいとして、もう少し複雑な application になると、管理が難しいかも知れないと思い、別の方法も考えてみました。次回は、その別の方法で書いてみます。

P.S. 考えてみたら、$my_app をグローバル変数にして参照しなくても、Wx.get_app()  で参照すれぱ、ソースのどこからでも参照できますね。

その他のカテゴリー