Mac OS X で wxPerl

(2005/12/25 記)

 Perl に GUI をかぶせる話の続き。Perl/Tcl/Tk を試してみたのだが、TCL とちゃんぽんでコードを書かないといけないところがどうも使いづらい。Tiger (Mac OS 10.4) に wxPerl がバンドルされた、というニュースを聞いたので、10.3 でも wxPerl を試してみようと思った。

1. 準備

 ダウンロードページから、Mac OS 10.3 用のバイナリをダウンロードして、wxWidgets と wxPerl をそれぞれインストール。同じページの "Documentation" のところにサンプルとドキュメントがあるので、それも別途ダウンロードする。ドキュメントは wxPerl に特化したものではなく、オリジナルの C++ 向けのドキュメントである。

 ちなみに、www.perl.com に Jouke Visser 氏によるwxPerl: Another GUI for Perl という記事がある。これは 2001年9月に書かれた記事だが、この中で筆者は「現在、wxPerl には一つの大きな欠点がある。ドキュメントが非常に貧弱なことだ」と述べている。残念なことに、2005年12月の時点でもこの状況はほとんど変わっていない。wxPerl のユーザーがそれだけ少ないということか? C++ 用のドキュメントと wxPerl のサンプルを見比べながら、各クラスの使い方を読み解いて行く作業が必要になる。

2. コードを書く

 Perl/Tcl/Tk のページで紹介したのとほぼ同じプログラムを wxPerl で書いてみた。NPointsSphere_wx.pl。点を3回対称になるよう配置するオプションが1つ増えているが、まあそれはどうでもよい。

NPointsSphere_wx.pl Screen Shot

3. コードの解説

3-1. プログラムの設計

 wxPerl はオブジェクト指向のフレームワークなので、あまり行き当たりばったりのコーディングをするわけにはいかず、クラスの設計をする必要がある。このあたりはちょっと Perl の特徴と相容れないところがあり、wxPerl があまりポピュラーになっていない一つの理由かも知れない。

 今回は下のようなクラスを作った。

 それぞれのクラスは、Perl のパッケージ機能を使って実装する。

3-2. MyCanvas の実装

 MyCanvaswxWindow のサブクラスとして実装する。

use Wx; # wxPerl プログラムの最初に必ず必要 package MyCanvas; # MyCanvas パッケージを宣言 use base qw(Wx::Window); # Wx::Window のサブクラス

 new() サブルーチンで、イベントに対してサブルーチンをバインドする。

use Wx::Event qw(EVT_PAINT EVT_LEFT_DOWN EVT_MOTION EVT_LEFT_UP); # 必要なシンボルのインポート sub new { my $ref = shift; # $ref はクラスオブジェクト my $self = $ref->SUPER::new(@_); # スーパークラスのコンストラクタを呼ぶ # Declare the events EVT_PAINT($self, \&OnPaint); # ウィンドウの中身を描くイベント EVT_LEFT_DOWN($self, \&OnMouseDown); # マウスの左ボタンが押された EVT_MOTION($self, \&OnMouseMove); # マウスの移動 EVT_LEFT_UP($self, \&OnMouseUp); # マウスの左ボタンが離された return $self; }

 「必要なシンボルのインポート」というところが厄介。あとでもいろいろ出てくるが、インポートのやり方が Wx qw(...) だったり Wx qw(:...) だったり Wx::クラス名 qw(...) だったりと様々で、その説明がどこに書かれているのかよくわからない。今回のコーディングでは、サンプルコードを見てわからない場合は試行錯誤(!)をする羽目になった。

 各イベントハンドラでは、実際には MyFrame のサブルーチンを呼んでいる。まず、このウィンドウが属している MyFrame を得るため、トップレベルのウィンドウを返すサブルーチンを作った。どうせ1つしかウィンドウはないのだからグローバル変数にしておけばいいようなものだが、まああまりグローバル変数に頼るのもみっともないかなと思ったもので。

sub GetParentFrame { my $self = shift; # $self はメソッド呼び出しの暗黙の引数 my ($win, $win1); $win = $self; while (defined($win1 = $win->GetParent())) { $win = $win1; # 親がいなくなるまで階層を上にたどる } return $win; }

 続いてペイントイベントのハンドラ。wxWidget では、ペイントは DC(Device Context)に対して行う。ペイントハンドラの最初にまず PaintDCのインスタンスを作成し、BeginDrawing()EndDrawing()の間に描画するコードを置く。このハンドラでは、描画コードそのものは MyFrame に任せて、それ以外の準備/後始末を実装している。

sub OnPaint { my $self = shift; # Creating a PaintDC is required in every OnPaint handler # ($dc will be automatically DESTROY'ed when it becomes out of scope) my $dc = Wx::PaintDC->new($self); # DCを作る $dc->BeginDrawing(); $self->GetParentFrame()->Draw($dc, $self->GetClientSize()); # MyFrame のサブルーチンを呼ぶ $dc->EndDrawing(); }

 マウスイベントのハンドラは、画面をトラックボールに見立てて見る向きを変える機能を実装する。ここでも、実際の処理は MyFrame に任せてある。マウスイベントのハンドラは、$self の他に第2引数として wxMouseEvent オブジェクトが渡される。GetPosition() は、イベントが発生した時のマウスの位置を、サブウィンドウの左上を (0, 0) とした相対座標で返す。

sub OnMouseDown { my ($self, $mevent) = @_; my $pt = $mevent->GetPosition(); $self->GetParentFrame()->SetDragStartPosition($pt); } sub OnMouseMove { my ($self, $mevent) = @_; if ($mevent->Dragging()) { my $pt = $mevent->GetPosition(); $self->GetParentFrame()->SetDragPosition($pt); } } sub OnMouseUp { my ($self, $mevent) = @_; my $pt = $mevent->GetPosition(); $self->GetParentFrame()->EndDrag($pt); }

3-3. MyFrame の実装

 MyFramewxFrame のサブクラスとして実装する。シンボルのインポートがずいぶん煩雑だが、致し方ない。use Wx; で全部インポートしておいてくれよ、と言いたくなるのだが、インタプリタだからあまりシンボルの数が多くなると動作が遅くなったりするのだろうか。

package MyFrame; use base qw(Wx::Frame); use Wx qw(:sizer); # wxSizer 関連のシンボル use Wx qw(wxRA_SPECIFY_ROWS); # wxRadioBox 関連のシンボル use Wx::Event qw(EVT_BUTTON); # wxButton 関連のシンボル use Wx qw(:pen :brush); # wxDC 関連のシンボル use Wx qw(:filedialog wxID_OK wxID_CANCEL); # wxFileDialog 関連のシンボル

 new() では、(1) サブウィンドウの作成、(2) ボタンイベントへのサブルーチンのバインド、(3) 画面表示や計算に必要な内部変数の初期化を行う。

 まず、スーパークラスのコンストラクタを呼ぶ。

sub new { my ($ref) = @_; # Call the constructor of the superclass (wxFrame) my $self = $ref->SUPER::new(undef, -1, 'NPointsOnSphere with wxPerl', [-1, -1], [560, 432]);

 次に、サブウィンドウを作成する。ウィンドウサイズの変更に応じてサブウィンドウのサイズを自動的に変更するため、wxSizerを使う。MyFrame のレイアウトは下図のようになっているので、まず水平方向に2分割してそれぞれに「入れ物」として wxPanel を置く。左のパネルは垂直方向に2分割して上に MyCanvas、下にメッセージを表示する wxStaticText を置く。右のパネルには各種コントロールを上から順に並べ、一番下に Quit ボタンを置く。なお、static text などのコントロールはあとで必要になるので、ポインタをインスタンス変数に保存しておく。インスタンス変数は Perl の仕様にはないので、$self->{_....} とハッシュで実現する。

Window Layout

my $sizer = Wx::BoxSizer->new(wxHORIZONTAL); $self->SetSizer($sizer); # 水平方向のサイズ制御 my $panel = Wx::Panel->new($self, -1, [0, 0], [400, 432]); # 左のパネル $sizer->Add($panel, 1, wxEXPAND); # 水平方向にサイズ変化可能 (1) # 垂直方向はウィンドウサイズに合わせて伸び縮みする (wxEXPAND) my $vsizer = Wx::BoxSizer->new(wxVERTICAL); $panel->SetSizer($vsizer); # 左パネルの垂直方向のサイズ制御 $self->{_canvas} = MyCanvas->new($panel, -1, [0, 0], [400, 400]); # MyCanvas, アドレスをインスタンス変数 _canvas として保持 $vsizer->Add($self->{_canvas}, 1, wxEXPAND); # 垂直方向にサイズ変化可能 (1) # 水平方向は左パネルのサイズに合わせて伸び縮みする (wxEXPAND) $self->{_caption} = Wx::StaticText->new($panel, -1, "", [0, 400], [400, 32]); # wxStaticText, アドレスをインスタンス変数 _caption として保持 $vsizer->Add($self->{_caption}, 0, wxEXPAND); # 垂直方向のサイズ固定 (0) # 水平方向は左パネルのサイズに合わせて伸び縮みする (wxEXPAND) # 以下右パネルについて同様(省略)

 プッシュボタンのイベントに対してサブルーチンをバインドする。

# Bind event handling functions EVT_BUTTON($self, $self->{_runButton}->GetId(), \&OnRun); EVT_BUTTON($self, $self->{_stopButton}->GetId(), \&OnStop); EVT_BUTTON($self, $self->{_exportButton}->GetId(), \&OnExport); EVT_BUTTON($self, $self->{_quitButton}->GetId(), \&OnQuit);

 内部情報の初期化。これらもインスタンス変数として実装している。

# Initialize instance variables $self->{_quat} = [0, 0, 0, 1]; $self->{_tempQuat} = [0, 0, 0, 1]; $self->{_depth} = 8.0; $self->{_height} = 2.4; return $self; }

 各サブルーチンは全部解説すると煩雑になるので、汎用性のあるところだけ解説する。

 DrawMyCanvas::OnPaint から呼ばれ、実際の描画を行う。引数として wxPaintDC とウィンドウサイズを受け取る。実際の描画処理(塗りつぶした円や文字を書く)は wxPaintDC のメソッドを使って行っている。なお、インスタンス変数 $self->{_objlist} は、各点の3次元座標にトラックボールの回転変換を施した座標を保持しており、点の座標またはトラックボールの回転変換が変化するごとに更新する。

sub Draw { my ($self, $dc, $size) = @_; my $width = $size->GetWidth(); # MyCanvas の幅 my $height = $size->GetHeight(); # MyCanvas の高さ my $screenSize = ($width > $height ? $height : $width); $dc->SetBackground(wxWHITE_BRUSH); $dc->Clear(); # 背景をクリア $self->{_screenWidth} = $width; $self->{_screenHeight} = $height; $self->{_screenSize} = $screenSize; my ($i, $n, $xc, $yc, $r, $k, $tw, $th, $label); my $list = $self->{_objlist}; my $depth = $self->{_depth}; my $realheight = $self->{_height}; $dc->SetPen(wxBLACK_PEN); $dc->SetBrush(wxCYAN_BRUSH); for ($i = 0; $i <= $#{$list}; $i++) { $k = ($depth + $list->[$i]->[3]) / ($depth * $realheight) * $screenSize; $r = $k * 0.08; $xc = $list->[$i]->[1] * $k + $width / 2; $yc = $list->[$i]->[2] * $k + $height / 2; $dc->DrawCircle($xc, $yc, $r); # 円を描く $label = $list->[$i]->[0] + 1; ($tw, $th) = $dc->GetTextExtent($label); $dc->DrawText($label, $xc - $tw / 2, $yc - $th / 2); # 文字を描く } }

 OnRun は Run ボタンを押すと呼ばれ、実際の計算を行う。計算はサブルーチン UniformSphere() で実行するが、1ステップごとに画面を更新し、かつ Stop ボタンの状態を調べるため、wxApp のメソッド Yield() を呼ぶ。$main::appMyApp のインスタンスで、メインループの実行前に代入しておく(後述)。

sub OnRun { my $self = shift; ...(snip)... $self->UniformSphere(); # 計算を実行 } sub UniformSphere { my ($self) = @_; ...(snip)... $self->{_stopFlag} = 0; # Stop ボタンのためのフラグ while (1) { ...(snip)... $self->{_caption}->SetLabel(sprintf(...)); # Static text の内容を更新 $self->UpdateDisplayList(0); # 画面表示のための内部情報を再計算 $self->{_canvas}->Update(); # MyCanvas->OnDraw() を経由して Draw() が呼ばれ、画面が再描画される $main::app->Yield(); # 一度システムに制御を戻す last if $self->{_stopFlag}; # Stop ボタンが押されたら中断 last if $converged || $iter >= $maxIter; } ...(snip)... } sub OnStop { # Stop ボタンのハンドラ my ($self) = @_; $self->{_stopFlag} = 1; # Stop フラグを立てる }

SetDragStartPosition, SetDragPosition, EndDrag はそれぞれ MyCanvasOnMouseDown, OnMouseMove, OnMouseUp から呼ばれる。注意が必要なのは SetDragPosition で、これは「ドラッグされた」イベントではなくて「マウスが動いた」イベントのハンドラから呼ばれるので、ドラッグ中であるかどうかは別に判定が必要。ここでは、SetDragStartPosition でドラッグ開始位置をインスタンス変数 $self->{_xstart}, $self->{_ystart} に入れることを利用して、これらの変数が値を持っているかどうかでドラッグ中かどうかを判定している。

 トラックボールの処理は、マウスのドラッグを3次元回転(四元数表現)に直したものを $self->{_tempQuat}、それまでの回転を集積したものを $self->{_quat} とし、これらを掛け合わせた回転で画面表示を行うことで実現している。ドラッグが終了した時点で $self->{_quat}$self->{_tempQuat} を掛ける。

sub SetDragStartPosition { my ($self, $pt) = @_; my ($x, $y); $x = ($pt->x() - $self->{_screenWidth} * 0.5) / $self->{_screenSize}; $y = ($pt->y() - $self->{_screenHeight} * 0.5) / $self->{_screenSize}; $self->{_xstart} = $x; # スクリーンサイズで規格化された値 $self->{_ystart} = $y; } sub SetDragPosition { my ($self, $pt) = @_; my ($x, $y); return if (!defined($self->{_xstart}) || !defined($self->{_ystart})); $x = ($pt->x() - $self->{_screenWidth} * 0.5) / $self->{_screenSize}; $y = ($pt->y() - $self->{_screenHeight} * 0.5) / $self->{_screenSize}; calcTrackballRotation($self->{_xstart}, $self->{_ystart}, $x, $y, $self->{_tempQuat}); $self->UpdateDisplayList(0); # 回転量が変化したので内部情報を更新 $self->{_canvas}->Update(); } sub EndDrag { my ($self, $pt) = @_; return if (!defined($self->{_xstart}) || !defined($self->{_ystart})); quatMul($self->{_quat}, $self->{_tempQuat}, $self->{_quat}); # $self->{_quat} を更新 @{$self->{_tempQuat}} = (0, 0, 0, 1); $self->UpdateDisplayList(0); $self->{_canvas}->Update(); $self->{_xstart} = $self->{_ystart} = undef; }

3-4. MyApp の実装

 wxApp のサブクラスとして実装する。OnInit をオーバーライドし、MyFrame のインスタンスを作って表示する。

package MyApp; use base qw(Wx::App); sub OnInit { my $frame = MyFrame->new; $frame->Show(1); }

3-5. メインプログラム

 MyApp のインスタンスを作って MainLoop サブルーチンを呼ぶだけ。ただし、後で MyApp のインスタンスが必要になるので、グローバル変数 $main::app に保持しておく。wxWidgets のドキュメントによれば wxTheApp というグローバル変数があるはずなのだが、wxPerl でどうやったら使えるのかわからなかった。

package main; $main::app = MyApp->new; $main::app->MainLoop;

4. プログラムの実行

 ターミナルから wxPerl NPointsSphere_wx.pl とする。wxPerl のドキュメントに書いてあるが、perl NPointsSphere_wx.pl ではダメなので注意。