(2008/11/02 記)
wxWidgets アプリを Xcode 上で開発できるめどが立ったので、Cocoa で作った「ドキュメントベースのアプリケーション」を wxWidgets に移植してみる。
Cocoa では、複数のドキュメントを開いたり編集したり保存したり、という作業を含むアプリケーションは NSDocument を使った「ドキュメントベースのアプリケーション」として実装する。wxWidgets でもこれに対応するものがあって、中心となるクラスは wxDocument である。
もう少し詳しくクラスを対応づけると、下の表のようになる。
Cocoa | wxWidgets | 機能 |
---|---|---|
NSDocument | wxDocument | ドキュメントとファイルを対応付ける。ファイルの読み込み・保存。Undo/Redo 機能とファイル保存の連携 ("dirty flag")。 |
NSWindowController | wxView | ドキュメントとウィンドウを橋渡しする。 |
NSWindow | wxFrame | 画面上のいわゆる「ウィンドウ」。 |
NSView | wxWindow | 「ウィンドウ」内の GUI 部品。 |
コーディングを始める前に、アプリケーションのスタイルを決めておかないといけない。というのも、wxWidgets でのドキュメントベースのアプリケーションには3つのスタイルがあるからだ。上記の Document/View overview によれば、次のようになってますが…
スタイル | 説明 |
---|---|
MDI | 1つの親ウィンドウがあって、その中にすべてのドキュメントのサブウィンドウが含まれている形式。Windows でのドキュメントベースのアプリケーションはこの形式が多い。 |
SDI | 各ドキュメントが別々のウィンドウで開く形式。Mac のアプリケーションは基本的にはこの形式。 |
single-window | 一度に1つのドキュメントだけが開ける形式。別のドキュメントを開くときは、現在開いているドキュメントを先に閉じる。 |
SDI ってこれでいいのかな? ここで "single-window" といっているやつが SDI で、「各ドキュメントが別々のウィンドウで開く形式」って "MTI" (Multiple Top-Level Interface) と呼ぶんじゃないのかな。Windows の用語には慣れてないのでよくわからないんだけど。
wxWidgets ドキュメントの別のページには「MDI は、Microsoft によって『時代遅れのインターフェイスである』と警告されているが、未だに広く使われている」と書いてある。なあんだ、Microsoft もあれはイマイチだと思ってたんかい、じゃああんたのオススメは何なんだよ、と問い詰めたくなるところだ。ただ、僕が今まで使った Windows のアプリケーションはほとんど MDI だったことだし、Windows のユーザーは MDI に慣れていると仮定して、一応今回は MDI を採用してみることにする。
ところで、クロスプラットフォームのアプリではありがちなことだが、Windows ユーザーにとって慣れ親しんだ流儀で作っても、Mac ユーザーにとって使いにくくなってしまってはよろしくない(もちろんその逆もよくない)。それぞれのプラットフォームでなるべく自然に使えるように仕上げるのがプログラマの仕事だろう。このあたりは、#if...#endif
を駆使して作り込むことになる。
wxWidgets 付属サンプルの docvwmdi
("Document view MDI" の意味だろう)を読み解きながら、wxDocument の使い方を理解していく。このサンプルは、Drawing と Text の2種類のドキュメントを作成するという仕様になっている。下は Mac と Windows XP でのスクリーンショット。Windows では2つのドキュメントを取り囲む枠("DocView Demo" というタイトルのもの)があり、これが MDI の「親ウィンドウ」になる。Mac ではこのウィンドウは非表示になっている(が、Dock のアイコンをクリックしてアプリを全面に出すと突然表示されたりして、やや中途半端)。
プログラムは3つのファイル doc.cpp, view.cpp, docview.cpp
(とそれぞれのヘッダファイル)から成っていて、次のようなクラスが定義されている。
クラス名 | 親クラス | 役割 | 定義ファイル |
---|---|---|---|
MyApp |
wxApp |
アプリケーション本体。 | docview.h, docview.cpp |
MyFrame |
wxDocMDIParentFrame |
MDI のトップレベルウィンドウ。 | docview.h, docview.cpp |
DrawingView |
wxView |
Drawing 用のウィンドウを制御。 | view.h, view.cpp |
TextEditView |
wxView |
Text 用のウィンドウを制御。 | view.h, view.cpp |
MyCanvas |
wxScrolledWindow |
Drawing で実際の描画を担当する GUI 部品。 | view.h, view.cpp |
MyTextWindow |
wxTextCtrl |
Text で実際の描画を担当する GUI 部品。 | view.h, view.cpp |
DrawingDocument |
wxDocument |
Drawing のドキュメントクラス。 | doc.h, doc.cpp |
DoodleLine |
wxObject |
線の始点・終点を表す構造体。 | doc.h |
DoodleSegment |
wxObject |
ひとつながりの折れ線。 | doc.h, doc.cpp |
DrawingCommand |
wxCommand |
折れ線を描くコマンド。Undo/Redo に対応。 | doc.h, doc.cpp |
TextEditDocument |
wxDocument |
Text のドキュメントクラス。 | doc.h, doc.cpp |
上の表で、「ウィンドウ」「GUI 部品」と呼んでいるのはそれぞれ wxFrame, wxWindow
のサブクラスなので、wxWidgets 流には「フレーム」「ウィンドウ」と呼ぶのが正しい。しかし、Cocoa に慣れていると、「ウィンドウ」は NSWindow
(≒ wxFrame
), 「フレーム」は NSWindow
とか NSView
(≒ wxWindow
) の frame
メソッドを思い出させるので、どうも混乱する。このため、不一致は承知で上の表記にしてある。
wxWidgets のドキュメントの取り扱いを理解するため、wxDocument
, wxView
のサブクラスから調べていく。テキスト・ドローの2種類のドキュメントに対応する2種類のドキュメントクラスがあるが、TextEditDocument
は、テキスト編集の GUI 部品 (wxTextCtrl
) の機能に依存しているところが大きいので、DrawingDocument
を見てみる。このクラスは思いのほか単純で、定義されているメソッドは以下の5つだけ。
DrawingDocument(void)
~DrawingDocument(void)
wxOutputStream& SaveObject(wxOutputStream& stream)
wxInputStream& LoadObject(wxInputStream& stream)
wxList& GetDoodleSegments(void) const
コンストラクタは空、デストラクタは内部データである DoodleSegment
の配列を破棄するだけで、特別なことは何もしていない。GetDoodleSegments
はこの配列へのアクセスメソッド。SaveObject, LoadObject
はファイルの読み書きで、Cocoa でいうところの dataOfType:error:
, readFromData:ofType:error:
あたりに対応する。結局このクラスは、データの保持とファイルとのやりとりだけを担当していることになる。
次に DrawingView
。Cocoa でいう NSWindowController
のサブクラスに相当するわけだが、これもそれほど複雑ではない。定義されているメソッドは7つ。
DrawingView()
~DrawingView()
bool OnCreate(wxDocument *doc, long flags)
void OnDraw(wxDC *dc)
void OnUpdate(wxView *sender, wxObject *hint = (wxObject *) NULL)
bool OnClose(bool deleteWindow = true)
void OnCut(wxCommandEvent& event)
コンストラクタはメンバを NULL
で初期化するだけ、デストラクタは空。OnCreate, OnDraw, OnUpdate, OnClose
は wxView
メソッドをオーバーライドしている。wxView overview によると、この4つのメソッドのオーバーライドは必須となっている。OnCut
は独自のメソッドで、 "Cut" コマンドが(メニューやキーボードショートカットで)指示されたときに呼び出される。
OnCreate
には次のようなコードがあって、要するにウィンドウ (wxFrame
ですな)を初期化している。Cocoa 流なら、initWithWindowNibName:
で nib ファイルをロードするところ。
frame = wxGetApp().CreateChildFrame(doc, this, true); frame->SetTitle(_T("DrawingView")); canvas = GetMainFrame()->CreateCanvas(this, frame);
CreateChildFrame
は、MDI のドキュメントウィンドウ(外側の親ウィンドウの中に含まれる1つのサブウィンドウ)となる wxDocMDIChildFrame
型のウィンドウを作成するメソッド。このサンプルでは、TextEditView
からも呼ばれるために MyApp
のメソッドとして実装されているが、そうでなければ DrawingView
のクラスメソッドとして実装してもよかったところ。
1行おいて次の CreateCanvas
は、実際にドキュメントの中身を描画する MyCanvas
をドキュメントウィンドウ内に作成するもので、これは DrawingView
のメソッドとして実装した方が見やすかっただろう(このサンプルでは MDI の親ウィンドウである MyFrame
のメソッドとして実装されているけど、MyFrame
と MyCanvas
って、実は直接関係ないじゃん?)
OnDraw
, OnUpdate
は、描画・画面更新を行う。OnDraw
は、docvwmdi のコードの中では MyCanvas::OnDraw
から呼び出されているが、印刷機能を実装したときには wxWidgets フレームワークの内部からも呼ばれるようだ。OnUpdate
は wxDocument::UpdateAllViews
の中から呼ばれることは確認したが、ほかにも呼ばれることがあるかどうかはわからない。
OnClose
はけっこう込み入っている。
bool DrawingView::OnClose(bool deleteWindow) { if (!GetDocument()->Close()) return false; // Clear the canvas in case we're in single-window mode, // and the canvas stays. canvas->ClearBackground(); canvas->view = (wxView *) NULL; canvas = (MyCanvas *) NULL; wxString s(wxTheApp->GetAppName()); if (frame) frame->SetTitle(s); SetFrame((wxFrame*)NULL); Activate(false); if (deleteWindow) { delete frame; return true; } return true; }
対応する wxDocument
の Close
メソッドを呼び出し、失敗すれば何もせず false
を返す。あとは、deleteWindow
が true
ならウィンドウを破棄するが、false
ならウィンドウとの対応を解消するだけでウィンドウ自体には手をつけない。このあたりのコードは「決まり文句」として覚えておく必要があるかもしれないな。
OnCut
はなかなか面白い。これは、最後に描画した線を削除するメソッドで、実装は次の通り。
void DrawingView::OnCut(wxCommandEvent& WXUNUSED(event) ) { DrawingDocument *doc = (DrawingDocument *)GetDocument(); doc->GetCommandProcessor()->Submit(new DrawingCommand(_T("Cut Last Segment"), DOODLE_CUT, doc, (DoodleSegment *) NULL)); }
DrawingCommand
というのがポイント。これは wxCommand
のサブクラスで、Do, Undo
というメソッドを持っている。上の呼び出しで作られた DrawingCommand
のインスタンスの場合、Do
は(注文通り)最後に描画した線を削除するが、Undo
は逆にその線を追加する。つまり、"Do" ("Redo" も同じ)/"Undo" の動作を1つのオブジェクトにパッケージ化したようなものである。
この wxCommand
(のサブクラス)のインスタンスを管理するのが wxCommandProcessor
というクラス。wxCommandProcessor
は、何もしなくても wxDocument
ごとに自動的に作られており、wxDocument::GetCommandProcessor()
メソッドでアクセスすることができる。これに Submit
メソッドで wxCommand
のインスタンスを送ると、そのコマンドの Do
メソッドが実行され、同時に undo のためにコマンドが内部に保持される。"Undo" が(ユーザーによるメニュー選択またはキーボードショートカットで)指示されると、最後に実行された wxCommand
のインスタンスが取り出されて Undo
メソッドが実行され、同じインスタンスが今度は redo のために保持される。このメカニズムは、ドキュメントが変更されたかどうかの追跡にも使える(つまり、undo スタックが空だったらドキュメントは変更されていない、ということになる)。
Mac プログラマでも、これに似た undo/redo のメカニズムを PowerPlant (おお懐かしい)あたりで使ったことがあるかもしれない。しかし、Cocoa の NSUndoManager
のメカニズムとはだいぶ違っている。Cocoa では、Do/Undo の動作をプログラマが自分で集めてパッケージ化する必要はなく、ただある動作に対して "Undo" するためのメソッド呼び出しを registerUndoWithTarget:selector:object:
とか prepareWithInvocationTarget:
を使って登録するだけで、あとは NSUndoManager
がよろしくやってくれる。Cocoa から wxWidgets にアプリケーションを移植するとき、この違いはかなり大きな障害になるだろう。