ホームページ開発ツール>Xojo / Real Studio Trial and Error・CocoaのDeclareでリッチテキストを扱う・アンドゥを試す

 Xojo / Real Studio Trial and Error

CocoaのDeclareでリッチテキストを扱う・アンドゥを試す

注:本ページは旧版です。改訂版はこちらです。

目次
 はじめに

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

 NSTextViewのアンドゥを試してみました。

 なお検証には、Xojo 2015 Release 4.1を用いています。(Mac mini mid 2010 + OS X 10.11.4 El Capitan)
注1)アンドゥ機能は、リッチテキストに限定されるものではありませんが、話題の流れ上、ここに置いています。
注2)今回のアンドゥ対象はテキストのみで、画像のドロップ等は対象となっていません。画像ドロップのアンドゥ参照)

 方針

 Cocoaは標準でアンドゥの仕組み(NSUndoManager)を持っていて、NSTextViewからも利用できます。
 しかも、NSTextViewはNSUndoManagerへの登録を内部で行ってくれているため、操作ごとに自分で登録作業をする必要はありません。
 なので、設定をオンにするだけで、文字入力だけでなく、文字修飾や段落設定等も含めたアンドゥが実現できます。

 一方、文字入力に関しては、一連の文字列の入力がアンドゥの単位となるため、使いにくいというケースも考えられます。
(OS標準のテキストエディットがそうなっていますので、アンドゥしてみると分かります。)
 そこで、一文字ずつアンドゥする方法がないか調べたところ、以下のサイトで公開されていました。

 参考サイト(1):UITextViewで一文字ずつUndo/Redoする方法 - Qiita

 Xcodeで上記コードを試してみた(注3)ところ、期待通りのものであることが確認できましたが、ここではMethod Swizzlingという手法を使っていて、結局、Xojoでの実装方法がわからず、断念しました。
注3)iOS用なのでOSX用に、replaceRange:withText:を、shouldChangeTextInRange:replacementString:に変更した。
 とはいえ、参考サイト(1)から、文字の入力前にbeginUndoGrouping、入力後にendUndoGroupingすればいけるのではないかと思い直して、XojoのTextAreaのKeyDownイベントにbeginUndoGrouping、KeyUpイベントにendUndoGroupingを記述してみたところ、一文字ずつのアンドゥが可能とはなりました。

 ただし、この方法には問題があります。
 それは「キーボードショートカットでアンドゥを実行している場合、アンドゥの終点以降もキー操作を続けると異常終了する」というものです。
 理由は、アンドゥの終点まで達したらアンドゥメニューをDisableにしているので、それ以降はキー入力がKeyDownイベントに渡ってくるようになり、本来アンドゥ対象でないショートカットをアンドゥに登録しようとして誤動作することによります。

 これを回避する一つの方法は、アンドゥメニューをDisableにしない、というものです。
 手軽ですが、本来アンドゥできないにもかかわらず、メニューが有効のまま、というのはユーザを混乱させるかもしれません。
 また、別の方法として、文字以外のキー入力はアンドゥ対象としない、というのもあります。
 ただしこの場合は、原理的対応ではなく、対症療法的対応となりますので、あらかじめ有効/無効なキー入力を区別した上で、無効なキー入力を全て自分で排除する必要があります。

 これらを踏まえて今回は、設定をオンにするだけのシンプルなものと、(アンドゥメニューをDisableにせずに)一文字ずつアンドゥするものを試してみることにしました。
 それぞれの仕様は以下の通りとしました。

 例1(シンプル)
 例2(一文字ずつアンドゥ・参考)(注:あまり筋の良いものではないので、あくまで参考扱いです。)

 Xojoでの実装(例1)

  1. 前回プロジェクトをベースとする
  2. MainMenuBarのEditMenuにEditRedoを追加
  3. 以下をWindow1の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
    
  4. 以下をWindow1のOpenイベントに追加(前回プロジェクトで記述したコードの後に追加)
    // Delegate設定
    Declare Sub setDelegate Lib "Cocoa" Selector "setDelegate:" (receiver As Ptr, id As Ptr)
    setDelegate(pnt1, nil)  // なぜか設定しないとUndoできない。Delegate先はnilで構わない。
    
    // Undoを有効に
    declare sub setAllowsUndo lib "Cocoa" selector "setAllowsUndo:" (receiver as Ptr, flg As Boolean)
    setAllowsUndo(pnt1, true)
    
  5. 以下をWindow1のメニューハンドラ(Menu Handlers)に追加
    メニュー項目名: EditRedo
    
    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
    
  6. 以下をWindow1のメニューハンドラ(Menu Handlers)に追加
    メニュー項目名: EditUndo
    
    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
    
 実行してみたところ、アンドゥが機能することを確認しました。


 Xojoでの実装(例2・参考)

  1. 例1のプロジェクトをベースとする
  2. Window1のEnableMenuItemsイベントを削除または全文をコメントアウトする
  3. 以下をTextArea1のKeyDownイベントに追加
    // NSUndoManagerの取得
    declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
    Dim pnt1 As Ptr = undoManager(Window1.Handle)
    
    // UndoGrouping開始
    declare sub beginUndoGrouping lib "Cocoa" selector "beginUndoGrouping" (receiver as Ptr)
    beginUndoGrouping(pnt1)
    
    // KeyUpでendUndoGroupingを実行させるためのフラグをオン
    BeginUndo=true
    
  4. 以下をTextArea1のKeyUpイベントに追加
    // beginUndoGroupingした時だけ実行
    if BeginUndo=true then
        
        // NSUndoManagerの取得
        declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
        Dim pnt1 As Ptr = undoManager(Window1.Handle)
        
        // UndoGrouping終了
        declare sub endUndoGrouping lib "Cocoa" selector "endUndoGrouping" (receiver as Ptr)
        endUndoGrouping(pnt1)
        
        // フラグをクリア
        BeginUndo=false
        
    end if
    
  5. 以下をWindow1のプロパティに追加
    プロパティ名: BeginUndo
    データ型: Boolean
    標準値: なし
    
 実行してみたところ、アンドゥが機能することを確認しました。


 画像ドロップのアンドゥ

 画像ドロップ操作のアンドゥ登録は自動では行われませんので、自分で行うことになります。

 参考サイト(2):(旧) Cocoaの日々: rubberBand(その19)Undoの実装 / NSUndoManager

 上記サイトにあるように、アンドゥだけならsetAttributedString:メソッドと画像ドロップ前のデータを登録するだけで事足りました。
(前回プロジェクト>TextArea1のDropObjectイベント>最後の部分)
// 画像を追加したストレージを書き戻す(既存分)
Declare Sub setAttributedString Lib "Cocoa" Selector "setAttributedString:" (receiver As Ptr, identifier As Ptr)

// Undo登録用に現在のデータを複製しておく(追加分)
Declare Function mutableCopy Lib "Cocoa" Selector "mutableCopy" (receiver As Ptr) As Ptr
Dim oldAttrString As Ptr = mutableCopy(attrString)
// NSUndoManagerの取得(追加分)
declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
Dim pnt11 As Ptr = undoManager(Window1.Handle)
// UndoManagerにUndo時に使うメソッドとデータを登録(追加分)
declare function prepareWithInvocationTarget lib "Cocoa" selector "prepareWithInvocationTarget:" (receiver as Ptr, target As Ptr) As Ptr
Dim pnt12 As Ptr = prepareWithInvocationTarget(pnt11, pnt2)
setAttributedString(pnt12, oldAttrString)

setAttributedString(pnt2, attrString)  // (既存分)
 ですが、これだけだとリドゥができません。(しかも、場合によっては異常終了します。)
 リドゥに対応させるには、(これも上記サイトにあるように)アンドゥ登録したメソッド内でもNSUndoManagerへの登録処理が必要になります。

 ただし、setAttributedString:をXojo側からオーバーライドするのは厄介そうなので、setAttributedString:とリドゥ用の登録処理を記述したラッパーメソッドを用意して、それをNSUndoManagerに登録することとしました。
 なお、ラッパーメソッドはXojoのメソッドなので、NSUndoManagerに直接登録することはできない(と思われる)ため、例によってObjective-CのランタイムAPIを用いた動的クラス生成を使用します。
  1. 例1または例2のプロジェクトをベースとする
  2. 以下をWindow1のOpenイベントに追加(既プロジェクトで記述したコードの後に追加)
    // UndoManagerに登録するメソッドを動的に生成
    RegisterMethod()
    
  3. TextArea1のDropObjectイベントを書き換え
    if Obj.FolderItemAvailable then
        
        // 文字列を指定してクラスオブジェクトを取得する。最初に一回宣言しておけばよい。
        Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
        
        // パスの取得
        Dim filepath As String = obj.FolderItem.NativePath
        
        // NSTextViewの取得
        Dim pnt1 As Ptr
        declare function documentView lib "Cocoa" selector "documentView" (obj_id as Integer) as Ptr  // Return NSTextView*
        pnt1 = documentView(TextArea1.Handle)
        
        // NSTextStorageの取得
        Dim pnt2 As Ptr
        declare function textStorage lib "Cocoa" selector "textStorage" (obj_id as Ptr) As Ptr  // Return NSTextStorage*
        pnt2 = textStorage(pnt1)
        
        Declare Function alloc Lib "Cocoa" Selector "alloc" (receiver As Ptr) As Ptr
        
        Dim wrap As Ptr = NSClassFromString("NSFileWrapper")
        wrap = alloc(wrap)
        Declare Function pathInit Lib "Cocoa" Selector "initWithPath:" (receiver As Ptr, path As CFStringRef) As Ptr
        wrap = pathInit(wrap, filepath)
        
        Dim attachment As Ptr = NSClassFromString("NSTextAttachment")
        attachment = alloc(attachment)
        Declare Function wrapInit Lib "Cocoa" Selector "initWithFileWrapper:" (receiver As Ptr, wrapper As Ptr) As Ptr
        attachment = wrapInit(attachment, wrap)
        
        Dim attachChar As Ptr = NSClassFromString("NSAttributedString")
        Declare Function attributedStringWithAttachment Lib "Cocoa" Selector "attributedStringWithAttachment:" (receiver As Ptr, attachment As Ptr) As Ptr
        attachChar = attributedStringWithAttachment(attachChar, attachment)
        
        Dim attrString As Ptr = NSClassFromString("NSMutableAttributedString")
        attrString = alloc(attrString)
        Declare Function initWithString Lib "Cocoa" Selector "initWithAttributedString:" (receiver As Ptr, str As Ptr) As Ptr
        attrString = initWithString(attrString, pnt2)
        
        Declare Function mutableCopy Lib "Cocoa" Selector "mutableCopy" (receiver As Ptr) As Ptr
        Dim oldAttrString As Ptr = mutableCopy(attrString)  // Undo登録用に現在のデータを複製しておく	
    	
        Declare Sub beginEditing Lib "Cocoa" Selector "beginEditing" (receiver As Ptr)
        beginEditing(attrString)
        
        Declare Sub insertAttributedString Lib "Cocoa" Selector "insertAttributedString:atIndex:" (receiver As Ptr, attachChar As Ptr, index As Integer)
        insertAttributedString(attrString, attachChar, TextArea1.SelStart)  // Xojo側からキャレット位置を取得。NSTextView側から取得した方がいいかも。
        
        Declare Sub endEditing Lib "Cocoa" Selector "endEditing" (receiver As Ptr)
        endEditing(attrString)
        
        // NSUndoManagerの取得
        declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
        Dim pnt11 As Ptr = undoManager(Window1.Handle)
        
        // UndoManagerにUndo時に使うメソッドとデータを登録
        declare function prepareWithInvocationTarget lib "Cocoa" selector "prepareWithInvocationTarget:" (receiver as Ptr, target As Ptr) As Ptr
        Dim pnt12 As Ptr = prepareWithInvocationTarget(pnt11, StAttrStrInstance)
        Declare Sub tStragesetAttrString Lib "Cocoa" Selector "tStrage:setAttrString:" (receiver As Ptr, tStrage As Ptr, attrString As Ptr)
        tStragesetAttrString(pnt12, pnt2, oldAttrString)
        
        // 画像を追加したストレージを書き戻す(先頭二つの引数はランタイムから呼ばれた時用なので、ここではnilを指定)
        mySetAttributedString(nil, nil, pnt2, attrString)
            
    end if
    
  4. 以下をWindow1の共有メソッド(Shared Methods)に追加
    メソッド名: mySetAttributedString
    引数: id as Ptr, sel as Ptr, tStrage As Ptr, attrString As Ptr
    
    // NSUndoManagerの取得
    declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
    Dim pnt11 As Ptr = undoManager(Window1.Handle)
      
    // Redo用
    declare function prepareWithInvocationTarget lib "Cocoa" selector "prepareWithInvocationTarget:" (receiver as Ptr, target As Ptr) As Ptr
    Dim pnt12 As Ptr = prepareWithInvocationTarget(pnt11, StAttrStrInstance)
    Declare Sub tStragesetAttrString Lib "Cocoa" Selector "tStrage:setAttrString:" (receiver As Ptr, tStrage As Ptr, attrString As Ptr)
    tStragesetAttrString(pnt12, tStrage, attrString)
    
    // 画像を追加したストレージを書き戻す
    Declare Sub setAttributedString Lib "Cocoa" Selector "setAttributedString:" (receiver As Ptr, identifier As Ptr)
    setAttributedString(tStrage, attrString)
    
  5. 以下をWindow1の共有メソッド(Shared Methods)に追加
    メソッド名: 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 StAttrStrInstance <> nil then
        return
    end if
      
    // クラス名をmyIdentifier、メタクラス名をNSObjectにして、生成
    Dim newClassId As Ptr = objc_allocateClassPair(NSClassFromString("NSObject"), "myIdentifier", 0)
    // ランタイムに登録(参照を可能とするため)
    objc_registerClassPair newClassId
    // UndoManagerから呼ばれるメソッドの受け口となるメソッドを追加(tStrage:setAttrString:をXojo側で用意したmySetAttributedStringメソッドで受け取る。)
    if not class_addMethod (newClassId, NSSelectorFromString("tStrage:setAttrString:"), AddressOf mySetAttributedString, "@@:@@") 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))
    
    // インスタンスを保持
    StAttrStrInstance = targetId
    
  6. 以下をWindow1の共有プロパティ(Shared Properties)に追加
    プロパティ名: StAttrStrInstance
    データ型: Ptr
    標準値: なし
    
 実行してみたところ、アンドゥが機能することを確認しました。


 おわりに

 一文字ずつのアンドゥの方は、日本語の変換中も逐一記録されるので、ちょっと気持ち悪いかも。

 文字以外のキー入力はアンドゥ対象としない方法については、いずれ試してみたいと思います。
 また、Method Swizzlingの仕組みをXojoで実現する方法があるのかについても、機会があったら調べてみたいと思います。
(例によってmacoslibもざっと眺めてみたのですが、それらしいものは見つけられませんでした。)


 お世話になったサイト

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

 参考サイト(1):UITextViewで一文字ずつUndo/Redoする方法 - Qiita
 参考サイト(2):(旧) Cocoaの日々: rubberBand(その19)Undoの実装 / NSUndoManager


 更新履歴

 2016.04.19 画像ドロップのアンドゥと、参考サイト(2)を追加。
 2016.04.15 はじめに、の注を注1に変更し、注2を追加。
 2016.04.08 新規作成


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