ホームページ開発ツール>Xojo / Real Studio Trial and Error・CocoaのDeclareでUndoManagerを利用する一考察

 Xojo / Real Studio Trial and Error

CocoaのDeclareでUndoManagerを利用する一考察

目次
 はじめに

 以下は、Xojo Cocoaビルドについての話題です。

 一般的な操作(注1)において、CocoaのNSUndoManagerがXojoからも使えるかどうか、調べてみました。(注2)

 なお検証には、Xojo 2016 Release 3を用いています。(Mac mini mid 2010 + OS X 10.12.6 Sierra)
注1)テキスト入力の取り消し/やり直しについては以前検証したが、ここではそれ以外の、メソッドで実現するような操作を想定している。そのため、以前の続きではなく、新たなトピックとした。
注2)今回は、単純なケースを対象としていて、より汎用的な用途においては不明な点もあるため、「一考察」とした。

 方針

 基本的なやり方は、以前のトピックの「ペースト(ドロップ)時のアンドゥ」と同じですが、改めて整理しておくと、UndoManagerには、Undo/Redo時に実行するメソッドと復元に必要なデータ(パラメータ)を登録します。
 異なる点は、上記トピックでは処理用メソッドをUndoManagerに直接指定(注3)していましたが、より汎用化させるため、登録するのは処理用メソッドそのものではなく、メソッド名とパラメータを引数とするダミーメソッドとし、実際の処理は、その引数を受け取ったXojo側のメソッドで行うようにしたことです。
注3)XojoのメソッドをUndoManagerに直接登録することはできない(?)ので、ランタイムAPIを用いて動的に作成したCocoaのメソッドを介している。
 さて、サンプルですが、現段階では実現の可否確認が先決なので、極くシンプルなものを試してみることにしました。
 分かり易いのは、何らかの項目の追加と削除あたりではなかろうかと思われます。例えば、以下のサイトに示されているようなケースです。

 参考サイト(1):NSUndoManagerを使って簡単にアンドゥ、リドゥを実装する

 ここでは、リストボックスの行追加(挿入)と行削除を用いることとしました。

 以上を踏まえ、サンプルの仕様は以下の通りとしました。

 Xojoでの実装
【ソースコードのコピー&ペーストについて】
ソースコード(グレー背景部分の全文)をコピーし、指定のウィンドウ/クラスにペーストすると、(新規作成して名前等を個別にコピー&ペーストしなくても)復元されます。
ただし、この方法は、メソッドでは問題ないようですが、イベント/アクション/プロパティでは不安定?なので、ペーストできない場合は、各項目のカッコ内を適用して下さい。
  1. Xojoで新規プロジェクトを作成
  2. Window1にListBox(名前はListBox1)を追加
  3. 以下をListBox1にペースト(できなければ、Sub - Endの間をChangeイベントに記述)
    Sub Change() Handles Change
      // 挿入と削除ボタンは、行が選択されている時だけ有効にする
      if me.ListIndex>=0 then
        PushButton2.Enabled=true
        PushButton3.Enabled=true
      else
        PushButton2.Enabled=false
        PushButton3.Enabled=false
      end if
    End Sub
    
  4. Window1にListBox(名前はListBox2)を追加(注:デバッグ用であり、必須ではない。)
  5. Window1にPushButton(名前はPushButton1、キャプションはAdd Row)を追加
  6. 以下をPushButton1にペースト(できなければ、Sub - Endの間をActionイベントに記述)
    Sub Action() Handles Action
      LB1_InsertRow(Str(ListBox1.ListCount))
    End Sub
    
  7. Window1にPushButton(名前はPushButton2、キャプションはInsert Row)を追加
  8. 以下をPushButton2にペースト(できなければ、Sub - Endの間をActionイベントに記述)
    Sub Action() Handles Action
      LB1_InsertRow(Str(ListBox1.ListIndex))
    End Sub
    
  9. Window1にPushButton(名前はPushButton3、キャプションはRemove Row)を追加
  10. 以下をPushButton3にペースト(できなければ、Sub - Endの間をActionイベントに記述)
    Sub Action() Handles Action
      // 指定行の各列の内容を連結する
      Dim st As String = Str(ListBox1.ListIndex)+Chr(9)+JoinRowText(ListBox1.ListIndex)
      
      LB1_RemoveRow(st)
    End Sub
    
  11. 以下をWindow1にペースト(できなければ、Sub - Endの間をEnableMenuItemsイベントに記述)
    Sub EnableMenuItems() Handles EnableMenuItems
      // NSUndoManagerの取得
      declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
      Dim pnt1 As Ptr = undoManager(Window1.Handle)
      
      declare function canUndo lib "Cocoa" selector "canUndo" (receiver as Ptr) as Boolean  // Return BOOL
      if canUndo(pnt1) then
        EditUndo.Enabled=true
      else
        EditUndo.Enabled=false
      end if
      
      declare function canRedo lib "Cocoa" selector "canRedo" (receiver as Ptr) as Boolean  // Return BOOL
      if canRedo(pnt1) then
        EditRedo.Enabled=true
      else
        EditRedo.Enabled=false
      end if
    End Sub
    
  12. 以下をWindow1にペースト(できなければ、Sub - Endの間をOpenイベントに記述)
    Sub Open() Handles Open
      Dim cls As Window1Undo = new Window1Undo(AddressOf xojoUndoManager)  // クラスのインスタンスを生成(Actionを受け取るメソッドを、引数で指定する)
      
      // ListBox1に初期値をセット
      InitListBox1()
      
      // 挿入と削除ボタンを無効化
      PushButton2.Enabled=false
      PushButton3.Enabled=false
    End Sub
    
  13. 以下をWindow1にペースト(できなければ、Sub - Endの間をEditRedoメニューハンドラに記述)
    Function EditRedo() As Boolean
      declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
      Dim pnt1 As Ptr = undoManager(Window1.Handle)
      
      declare sub redo lib "Cocoa" selector "redo" (receiver as Ptr)
      redo(pnt1)
      
      Return True
    End Function
    
  14. 以下をWindow1にペースト(できなければ、Sub - Endの間をEditUndoメニューハンドラに記述)
    Function EditUndo() As Boolean
      declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
      Dim pnt1 As Ptr = undoManager(Window1.Handle)
      
      declare sub undo lib "Cocoa" selector "undo" (receiver as Ptr)
      undo(pnt1)
      
      Return True
    End Function
    
  15. 以下をWindow1にペースト
    Private Sub InitListBox1()
      Listbox1.AddRow ""
      Listbox1.Cell(Listbox1.LastIndex,0)="abc"
      Listbox1.Cell(Listbox1.LastIndex,1)="def"
      Listbox1.Cell(Listbox1.LastIndex,2)="ghi"
      
      Listbox1.AddRow ""
      Listbox1.Cell(Listbox1.LastIndex,0)="jkl"
      Listbox1.Cell(Listbox1.LastIndex,1)="mno"
      Listbox1.Cell(Listbox1.LastIndex,2)="pqr"
      
      Listbox1.AddRow ""
      Listbox1.Cell(Listbox1.LastIndex,0)="123"
      Listbox1.Cell(Listbox1.LastIndex,1)="456"
      Listbox1.Cell(Listbox1.LastIndex,2)="789"
    End Sub
    
  16. 以下をWindow1にペースト
    Private Function JoinRowText(idx As Integer) as String
      Dim i As Integer
      Dim st As String
      
      // まず、先頭列の内容をセット
      st=ListBox1.Cell(idx,0)
      
      // タブを区切り記号として、2列目以降の内容を連結
      for i=1 to ListBox1.ColumnCount-1
        st=st+Chr(9)+ListBox1.Cell(idx,i)
      next
      
      return st
    End Function
    
  17. 以下をWindow1にペースト
    Private Sub LB1_InsertRow(paramStr As String)
      // Undo/Redo時に実行するメソッドをUndoManagerに登録
      SetUndoMgr("LB1_RemoveRow",paramStr)
      
      // 行番号を取得
      Dim idx As Integer = Val(NthField(paramStr,Chr(9),1))
      
      // 行を挿入
      ListBox1.InsertRow(idx,"ins.")
      
      // 各セルの内容があれば、復元
      if CountFields(paramStr,Chr(9))>1 then
        Dim i As Integer
        for i=0 to ListBox1.ColumnCount-1
          ListBox1.Cell(idx,i)=NthField(paramStr,Chr(9),i+1+1)
        next
      end if
    End Sub
    
  18. 以下をWindow1にペースト
    Private Sub LB1_RemoveRow(paramStr As String)
      // Undo/Redo時に実行するメソッドをUndoManagerに登録
      SetUndoMgr("LB1_InsertRow",paramStr)
      
      // 行番号を取得
      Dim idx As Integer = Val(NthField(paramStr,Chr(9),1))
      
      // 行を削除
      ListBox1.RemoveRow(idx)
    End Sub
    
  19. 以下をWindow1にペースト
    Private Sub SetUndoMgr(mName As String, paramStr As String)
      // 文字列を指定してクラスオブジェクトを取得する。最初に一回宣言しておけばよい。
      Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
      
      // 文字列を直接登録することはできない?ようで、一度NSString型に変換する(Ptr型で扱えるようにするため)
      Dim pntSt1 As Ptr = NSClassFromString("NSString")
      declare function stringWithString lib "Cocoa" selector "stringWithString:" (receiver as Ptr, target As CFStringRef) As Ptr
      pntSt1 = stringWithString(pntSt1, mName)
      Dim pntSt2 As Ptr = NSClassFromString("NSString")
      pntSt2 = stringWithString(pntSt2, paramStr)
      
      // NSUndoManagerの取得
      declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
      Dim pnt1 As Ptr = undoManager(self.Handle)
      
      // UndoManagerにUndo/Redo時に使うメソッドとパラメータを登録
      declare function prepareWithInvocationTarget lib "Cocoa" selector "prepareWithInvocationTarget:" (receiver as Ptr, target As Ptr) As Ptr
      Dim pnt2 As Ptr = prepareWithInvocationTarget(pnt1, Window1Undo.registMethodInst)
      Declare Sub registMethodWithParam Lib "Cocoa" Selector "registMethod:withParam:" (receiver As Ptr, mName As Ptr, param As Ptr)
      registMethodWithParam(pnt2, pntSt1, pntSt2)
      
      ListBox2.AddRow "set:"+mName  // デバッグ用
    End Sub
    
  20. 以下をWindow1にペースト
    Public Sub xojoUndoManager(mName As String, paramStr As String)
      Listbox2.AddRow "un/redo:"+mName  // デバッグ用
      Listbox2.Cell(Listbox2.LastIndex,1)=paramStr  // デバッグ用
      
      select case mName
      case "LB1_InsertRow"  // 行を挿入(追加を含む)
        
        // Undo実行
        LB1_InsertRow(paramStr)
        
      case "LB1_RemoveRow"  // 行を削除
        
        // Undo実行
        LB1_RemoveRow(paramStr)
        
      end select
    End Sub
    
  21. MainMenuBar>編集メニュー>取り消すの次に、項目(Name:EditRedo、Text:やり直す)を追加
  22. 新規クラスを作成(名前は、ここでは「Window1Undo」とした。)
  23. 以下をWindow1Undoにペースト(できなければ移譲に、名前:ActionDelegate、引数:mName As String, paramStr As String、を追加)
    Private Sub ActionDelegate(mName As String, paramStr As String)
    
  24. 以下をWindow1Undoにペースト
    Public Sub Constructor(action As ActionDelegate)
      // UndoManagerに登録するメソッドを動的に生成
      RegisterMethod()
      
      // クラス生成元でActionを受け取るメソッドを登録
      ActionHandler = action
    End Sub
    
  25. 以下をWindow1Undoにペースト
    Private Shared Sub ActionEvent(mName As String, paramStr As String)
      ActionHandler.Invoke(mName,paramStr)  // クラス生成元でActionを受け取るメソッドを呼び出す
    End Sub
    
  26. 以下をWindow1Undoにペースト
    Private Shared Sub myRegistMethodParam(id As Ptr, SEL As CString, mName As Ptr, paramStr As Ptr)
      // NSString型から文字列を取り出す
      Declare Function UTF8String Lib "Cocoa" Selector "UTF8String" (receiver As Ptr) As CString
      Dim st1 As String = UTF8String(mName)
      Dim st2 As String = UTF8String(paramStr)
      
      // インスタンスに渡す
      ActionEvent(st1,st2)
    End Sub
    
    注)SELの型をPtrとしていたが、CStringの方が相応しいので変更した。(未使用なので変更しなくても実害はなし。)(2018.07.30)
  27. 以下をWindow1Undoにペースト
    Private Shared Sub RegisterMethod()
      // 文字列を指定してクラスオブジェクト/セレクタを取得する。最初に一回宣言しておけばよい。
      Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
      Declare Function NSSelectorFromString Lib "Cocoa" (aSelName As CFStringRef) As Ptr
      
      // Declare宣言
      Declare Function objc_allocateClassPair Lib "Cocoa" (superclass As Ptr, name As CString, extraBytes As Integer) as Ptr
      Declare Sub objc_registerClassPair Lib "Cocoa" (cls As Ptr)
      Declare Function class_addMethod Lib "Cocoa" (cls As Ptr, name As Ptr, imp As Ptr, types As CString) As Boolean
      
      // 既にインスタンス作成済なら戻る
      if registMethodInst <> nil then
        return
      end if
      
      // クラス名をmyIdentifier、メタクラス名をNSObjectにして、生成
      Dim newClassId As Ptr = objc_allocateClassPair(NSClassFromString("NSObject"), "myIdentifier", 0)
      // ランタイムに登録(参照を可能とするため)
      objc_registerClassPair newClassId
      // UndoManagerから呼ばれるメソッドの受け口となるメソッドを追加(registMethod:withParam:をXojo側で用意したmyRegistMethodParamメソッドで受け取る。)
      if not class_addMethod (newClassId, NSSelectorFromString("registMethod:withParam:"), AddressOf myRegistMethodParam, "v@:@@") then
        msgBox "error."
        return
      end if
      
      // 上記で生成したクラスのインスタンスを作成
      Declare Function alloc Lib "Cocoa" selector "alloc" (class_id As Ptr) As Ptr
      Declare Function init Lib "Cocoa" selector "init" (obj_id As Ptr) As Ptr
      Dim targetId As Ptr = init(alloc(newClassId))
      
      // インスタンスを保持
      registMethodInst = targetId
    End Sub
    
  28. 以下をWindow1Undoにペースト(できなければ共有プロパティに、名前:ActionHandler、データ型:ActionDelegate、を追加)
    Private Shared Property ActionHandler as ActionDelegate
    
  29. 以下をAccessoryPanelにペースト(できなければ共有プロパティに、名前:registMethodInst、データ型:Ptr、を追加)
    Public Shared Property registMethodInst as Ptr
    
 実行してみたところ、取り消し/やり直しが機能することを確認しました。
S Shot1


 おわりに

 メソッドの振り分けは必要になるものの、スタックの管理をしなくていいのは楽です。

 ただし、今回のようにパラメータがシンプルならいいのですが、実際にはもっと複雑なケースも考えられます。
 かといって、パラメータ用のスタックをXojo側に置いたりすると、NSUndoManagerを使うメリットが半減してしまうので、悩ましいところではあります。
配列やDictionaryは、オーバーヘッドを覚悟して、NSArrayやNSDictionaryに変換してしまう手はありそうだが…
 この辺は、実際に実装しながら試行錯誤するしかないかも。


 お世話になったサイト

 貴重な情報をご提供頂いている皆様に、お礼申し上げます。(以下、順不同)

 参考サイト(1):NSUndoManagerを使って簡単にアンドゥ、リドゥを実装する


 更新履歴

 2018.07.30 Xojoでの実装の26項を改訂
 2017.10.14 新規作成


[Home]  [MacSoft]  [Donation]  [History]  [Privacy Policy]  [Affiliate Policy]