ホームページ>開発ツール>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(一文字ずつアンドゥ・参考)(注:あまり筋の良いものではないので、あくまで参考扱いです。)
- リッチテキストのプロジェクトをベースとする
- NSTextViewのアンドゥをオン(setAllowsUndo:YES)にする
- メニューハンドラを実装して、アンドゥ/リドゥ処理を記述
- EnableMenuItemsイベントを実装して、アンドゥ/リドゥの終点に達したら当該メニューをDisableにする
- 例1をベース
- TextAreaのKeyDownイベントにbeginUndoGrouping、KeyUpイベントにendUndoGroupingを記述
- EnableMenuItemsイベントを無効化する
Xojoでの実装(例1)
実行してみたところ、アンドゥが機能することを確認しました。
- 前回プロジェクトをベースとする
- MainMenuBarのEditMenuにEditRedoを追加
- 以下を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
- 以下を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)
- 以下を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
- 以下を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のプロジェクトをベースとする
- Window1のEnableMenuItemsイベントを削除または全文をコメントアウトする
- 以下を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
- 以下を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
- 以下を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または例2のプロジェクトをベースとする
- 以下をWindow1のOpenイベントに追加(既プロジェクトで記述したコードの後に追加)
// UndoManagerに登録するメソッドを動的に生成 RegisterMethod()
- 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
- 以下を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)
- 以下を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
- 以下を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]