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

 Xojo / Real Studio Trial and Error

CocoaのDeclareでリッチテキストを扱う・アンドゥの問題と対策

目次
 はじめに

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

 TextAreaが複数存在する場合や、TextFieldを使用する場合、前回手法によるアンドゥには幾つか問題点が確認されましたので、対策も含めて纏めておきました。

 なお検証には、Xojo 2016 Release 1.1を用いています。(Mac mini mid 2010 + OS X 10.11.5 El Capitan)


 複数TextAreaへの対応

 前回のサンプルのように、「メインウィンドウに、一つの、アンドゥ対象TextArea」であれば、特に問題はないのですが、複数の場合は、
  1. カテゴリ拡張のため、全てのTextAreaがMethod Swizzling対象となり、(何もしないと)アンドゥ登録を行おうとしてしまう。
  2. 条件によっては、日本語の入力に支障が出る。
 1.項については、もしアンドゥ登録したくないTextAreaがある場合は、アンドゥ登録前にアンドゥを有効にする設定かどうか判定し、無効なら登録をスキップするステップを追加することで、対応可能になります。
Method Swizzling用に置き換えたメソッドの第1引数が、現在入力中のTextAreaのインスタンスなので、これのallowsUndoプロパティを見て、trueならアンドゥ登録、falseならパスする。
 なお、TextAreaのアンドゥはデフォルトで有効になっているので、無効にしたい場合は明示的に指定する必要があります。

 2.項について、確認できたのは、
 (1) TextAreaを配置したメインウィンドウ上でMethod Swizzling設定を実行し、
 (2) その後、(TextAreaを配置した)別のウィンドウやコンテナコントロールを表示/生成した場合、
 (3) 後から表示/生成した方のTextAreaで、日本語入力時に変換途中の文字列が表示されなくなり(変換確定後は表示される)、アンドゥ登録もされない。
 (4) ただし、英数字(1バイト文字)は表示/アンドゥ登録ともに問題ない。

 どうも、Method Swizzling設定時にTextAreaのインスタンスが配置されていると、そのインスタンスのみ、日本語入力時の変換表示/アンドゥ登録が正常に行われる、と言う感触です。(試行錯誤の結果なので、本当のところはよく分からない。)
 なので、メインウィンドウが表示される前に設定すればいいのでは?、と思って、AppのOpenイベントに記述するようにしたら、全てのTextAreaで正常に処理されるようになりました。


 TextFieldへの対応

 参考サイト(1):Text Fields, Text Views, and the Field Editor
 参考サイト(2):Text Editing

 XojoのTextFieldはNSTextFieldを継承している(と思われる)ため、文字の入力にはフィールドエディタ(NSTextView)を使用しているのですが、このフィールドエディタはウィンドウのデフォルトではなく、独自に割り当てられたもののようです。
 このフィールドエディタはアンドゥが有効となっているため、(NSTextViewのMethod Swizzlingによって)アンドゥ登録されるのですが、実際にアンドゥしてみると以下のような事態となりました。(他にもあるかも。)
  1. TextFieldを単独で使った場合は、実際のキー操作以上のステップが登録される。
  2. 別のTextAreaと跨った場合は、キー操作は記録されているものの、文字の復元は行われない。
 この問題を最も手っ取り早く解消する方法は、TextFieldの代わりに、「マルチラインプロパティをオフにして高さを1行分にしたTextAreaを使う」というものです。
 これですと、TextArea間を跨ったアンドゥも問題なく行われます。(ただし、フォーカスの移動は記録されませんので、必要なら自分で登録する必要があります。)

 またもし、TextFieldがアンドゥ不要でしたら、以下の方法も考えられます。
  1. TextFieldに、アンドゥを無効にしたカスタムフィールドエディタを割り当てる。
  2. (TextFieldもどきの)TextAreaのインスタンスを個別にアンドゥ無効にする。
アンドゥを無効にしたカスタムフィールドエディタを使用したところ、ListBoxのインラインエディタもアンドゥが無効になりました。(実験の結果なので、常にそうなるかは不明。)

 サンプルの仕様

 以上を踏まえて今回は、複数TextAreaに対応したものと、アンドゥ無効カスタムフィールドエディタの割り当てを試してみることにしました。
 それぞれの仕様は以下の通りとしました。

 例1(複数TextArea対応)
 例2(アンドゥ無効カスタムフィールドエディタ)

 Xojoでの実装(例1)
  1. Xojoで新規プロジェクトを作成
  2. 以下をAppのOpenイベントに記述
    // カテゴリによる機能拡張
    NSTextViewCategory.CategorySet()
    
  3. 新規クラスを作成(名前は、ここでは「NSTextViewCategory」とした。)
  4. 以下をNSTextViewCategoryの共有メソッド(Shared Methods)に追加
    メソッド名: CategorySet
     
    // UndoManagerに登録するメソッドを動的に生成
    RegisterMethod()
    
    // カテゴリ機能を使ってメソッドをNSTextViewに追加
    RegisterMethodNSTextView()
    
    // メソッドの入れ替え
    setupHook()
    
  5. 前回プロジェクトから、insTextStrage、myDeleteBackward、myInsertText、myPaste、myPerformDragOperation、mySetAttributedString、mySetMarkedTextSelectedRange、myShouldChangeTextInRangeReplacementString、RegisterMethod、RegisterMethodNSTextView、setupHookの各共有メソッド、hookDone、myWindow、nstextviewDone、stAttrStrInstanceの各共有プロパティ、NSRange(構造体)をコピー&ペースト
  6. 新規クラスを作成(名前は、ここでは「CustomTextArea」) し、SuperをTextAreaに設定
  7. CustomTextAreaの新規イベント定義に、EnableMenuItemsを追加
  8. 以下をCustomTextAreaの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
    
    // インスタンスに継承
    EnableMenuItems()
    
  9. 以下をCustomTextAreaのメニューハンドラ(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
    
  10. 以下をCustomTextAreaのメニューハンドラ(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
    
  11. 以下をCustomTextAreaのメソッドに追加
    メソッド名: Init
    引数: flg1 As Boolean, flg2 As Boolean, flg3 As Boolean
    
    // NSTextViewの取得
    declare function documentView lib "Cocoa" selector "documentView" (obj_id as Integer) as Ptr  // Return NSTextView*
    Dim pnt1 As Ptr = documentView(me.Handle)
    
    // インスペクターバーを表示(スタイルなしの場合も表示は必要)
    declare sub setUsesInspectorBar lib "Cocoa" selector "setUsesInspectorBar:" (receiver as Ptr, flag as Boolean)
    setUsesInspectorBar(pnt1, true)
    
    // ルーラーを表示/非表示
    declare sub setRulerVisible lib "Cocoa" selector "setRulerVisible:" (receiver as Ptr, flag as Boolean)
    setRulerVisible(pnt1, flg1)
    
    // リッチテキスト有効/無効
    declare sub setRichText lib "Cocoa" selector "setRichText:" (receiver as Ptr, flag as Boolean)
    setRichText(pnt1, flg2)
    
    // フォントや色を変更可能/不可
    declare sub setUsesFontPanel lib "Cocoa" selector "setUsesFontPanel:" (receiver as Ptr, flag as Boolean)
    setUsesFontPanel(pnt1, flg2)
    
    // ---------------- Undo
    
    // Delegate設定
    Declare Sub setDelegate Lib "Cocoa" Selector "setDelegate:" (receiver As Ptr, id As Ptr)
    setDelegate(pnt1, nil)  // なぜかコメントアウトするとUndoできなくなる。戻り値は設定値でもnilでも同じ。
    
    // Undo有効/無効
    declare sub setAllowsUndo lib "Cocoa" selector "setAllowsUndo:" (receiver as Ptr, flg As Boolean)
    setAllowsUndo(pnt1, flg3)
    
  12. 以下をWindow1のプロパティに追加
    プロパティ名: myWindow
    データ型: Window
    標準値: なし
    
  13. Window1にTextArea(名前は「TextArea1」) を配置し、SuperをNSTextViewCategory CustomTextAreaに設定。
  14. 以下をWindow1のActivateイベントに追加
    // UndoManagerを指定
    NSTextViewCategory.myWindow = self
    
  15. 以下をWindow1のOpenイベントに追加
    // 初期化
    NSTextViewCategory.myWindow = self
    TextArea1.Init(true, true, true)
    
    // UndoManagerを指定
    TextArea1.myWindow = self
    
  16. 新規ウィンドウを作成(名前は「Window2」)
  17. Window2にTextArea(名前は「TextArea1」) を配置し、SuperをNSTextViewCategory CustomTextAreaに設定。
  18. 以下をWindow2のActivateイベントに追加
    // UndoManagerを指定
    NSTextViewCategory.myWindow = self
    
  19. 以下をWindow2のOpenイベントに追加
    // 初期化
    NSTextViewCategory.myWindow = self
    TextArea1.Init(true, true, true)
    
    // UndoManagerを指定
    TextArea1.myWindow = self
    
 実行してみたところ、アンドゥが機能することを確認しました。


 Xojoでの実装(例2)
  1. Xojoで新規プロジェクトを作成
  2. 例1で作成したNSTextViewCategoryクラスをコピー&ペースト
  3. Window1にTextFieldを配置
  4. 以下をWindow1のOpenイベントに記述
    // Delegate設定
    Declare Sub setDelegate Lib "Cocoa" Selector "setDelegate:" (receiver As Integer, id As Ptr)
    setDelegate(me.Handle, CustomFieldEditor.RegisterMethod())  // Field Editorの設定用
    
  5. NSTextViewCategoryの、insTextStrage、myDeleteBackward、myInsertText、mySetAttributedString、mySetMarkedTextSelectedRange、myShouldChangeTextInRangeReplacementStringメソッドに、アンドゥ登録判定を追加。(長くなるので、こちらに纏めました。)
  6. 新規クラスを作成(名前は、ここでは「CustomFieldEditor」とした。)
  7. 以下をCustomFieldEditorの共有メソッド(Shared Methods)に追加
    メソッド名: myWindowWillReturnFieldEditor
    引数: id As Ptr, SEL As CString, sender As Ptr, obj As Ptr
    戻り値型: Ptr
    
    // 文字列を指定してクラスオブジェクトを取得する。最初に一回宣言しておけばよい。
    Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
    
    // 渡ってきたオブジェクトがNSTextFieldなら
    Dim pnt0 As Ptr = NSClassFromString("NSTextField")
    Declare Function isKindOfClass Lib "Cocoa" selector "isKindOfClass:" (class_id As Ptr, cls As Ptr) As Boolean
    if isKindOfClass(obj, pnt0) then
        'if myCustomFieldEditor = nil then  // Xojoが割り当て直してしまう?ようで、呼ばれる度に設定しないと置き換わらない。
        
        Dim pnt1 As Ptr = RegisterFieldEditor()  // インスタンス生成後は既存のポインタを返すだけなので、メモリを浪費する訳ではない。
        
        // フィールドエディタとして設定
        declare sub setFieldEditor lib "Cocoa" selector "setFieldEditor:" (receiver as Ptr, flg As Boolean)
        setFieldEditor(pnt1, true)
        
        // Undoを無効に
        declare sub setAllowsUndo lib "Cocoa" selector "setAllowsUndo:" (receiver as Ptr, flg As Boolean)
        setAllowsUndo(pnt1, false)
        
        return pnt1
        
        'end if
    end if
    
    return nil
    
    注)SELの型をPtrとしていたが、CStringの方が相応しいので変更した。(未使用なので変更しなくても実害はなし。)(2018.07.30)
  8. 以下をCustomFieldEditorの共有メソッド(Shared Methods)に追加
    メソッド名: RegisterFieldEditor
    
    // 文字列を指定してクラスオブジェクト/セレクタを取得する。最初に一回宣言しておけばよい。
    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 myCustomFieldEditor <> nil then
        return myCustomFieldEditor
    end if
    
    // クラス名をmyFieldEditor、メタクラス名をNSTextViewにして、生成
    Dim newClassId As Ptr = objc_allocateClassPair(NSClassFromString("NSTextView"), "myFieldEditor", 0)
    // ランタイムに登録(参照を可能とするため)
    objc_registerClassPair newClassId
    // Delegateはないので何もしない
    
    // 上記で生成したクラスのインスタンスを作成
    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 subclassId As Ptr = init(alloc(newClassId))
    
    // インスタンスを保持
    myCustomFieldEditor = subclassId
    
    // インスタンスを返す
    return subclassId
    
  9. 以下をCustomFieldEditorの共有メソッド(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 FieldEditorDelegate <> nil then
        return FieldEditorDelegate
    end if
    
    // クラス名を引数のname(名前は任意だが、重複を避けるためにアイテムのIdentifierにしている。)、メタクラス名をNSObjectにして、生成
    Dim newClassId As Ptr = objc_allocateClassPair(NSClassFromString("NSObject"), "myIdentifierWin", 0)
    // ランタイムに登録(参照を可能とするため)
    objc_registerClassPair newClassId
    // Delegateの対象となるメソッドを追加(windowWillReturnFieldEditor:toObject:をXojo側で用意したmyWindowWillReturnFieldEditorメソッドで受け取る。)
    if not class_addMethod (newClassId, NSSelectorFromString("windowWillReturnFieldEditor:toObject:"), AddressOf myWindowWillReturnFieldEditor, "@@:@@@") then
        msgBox "error."
        return nil
    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 delegateId As Ptr = init(alloc(newClassId))
    
    // インスタンスを保持
    FieldEditorDelegate = delegateId
    
    // インスタンスを返す
    return delegateId
    
  10. 以下をCustomFieldEditorの共有プロパティ(Shared Properties)に追加
    プロパティ名: FieldEditorDelegate
    データ型: Ptr
    標準値: なし
    
  11. 以下をCustomFieldEditorの共有プロパティ(Shared Properties)に追加
    プロパティ名: myCustomFieldEditor
    データ型: Ptr
    標準値: なし
    
 実行してみたところ、TextFieldのアンドゥが無効になることを確認しました。


 おわりに

 随分と面倒なことになってきたので、デフォルトのアンドゥで事足りるのであれば、そのまま使うのが無難かもしれません。


 お世話になったサイト

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

 参考サイト(1):Text Fields, Text Views, and the Field Editor
 参考サイト(2):Text Editing


 更新履歴

 2018.07.30 Xojoでの実装(例2)の7項を改訂
 2017.08.15 Xojoでの実装(例1)の、13,17項で、CustomTextAreaとすべきところがNSTextViewCategoryとなっていたので修正。
 2016.07.15 新規作成


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