Appleテキストエディタの技術的内部構造
(papereditor.app)- PaperはAppleプラットフォームで TextKit 1 ベースの
TextViewエディタとして動作し、NSTextStorage、NSTextContainer、NSLayoutManager、TextView、ScrollViewが一体となって編集体験を構成する NSAttributedStringは文字列と範囲ごとの 属性(attribute) によってリッチテキストを表現し、PaperはMarkdown構造用のメタ属性と画面表示用のスタイル属性を分離している- スタイル更新は文書を開くとき、テキスト変更時、設定変更時で異なり、レイアウトに影響する属性はトランザクションでまとめ、装飾属性 はレイアウト再計算なしで更新する
- 入力性能はMarkdownの特殊文字の周辺を編集しているかを見て段落全体の再パースを避ける方式で改善されるが、コードブロック のように文書全体のスタイルへ影響しうる構造では例外処理が必要になる
- コピー・ペーストはUTIとpasteboard上の複数データ表現の上で動作し、MarkdownのようなApple未定義の 半公開UTI はアプリ間で優先順位が一致せず、RTF変換やUX上の問題を招くことがある
Apple TextView 編集スタック
- Paperは古い TextKit 1 フレームワークの上に作られているが、同じ概念と抽象化はTextKit 2にもそのまま存在するか、より良いAPIの下に存在している
NSTextViewとUITextViewには違いがあるが、APIは十分に似ているためひとつのTextView系として扱えるTextViewはOSのリリースごとに複雑化していく大きなコンポーネントであり、TextEditアプリはほぼ単一のTextViewで構成されている-
エディタを構成する中核レイヤー
NSTextStorage- 元のテキスト文字列を保存する
- テキスト範囲に付いた文字列-値ペアの 属性 を保存する
- テキストと属性の変更イベントを発生させる
NSTextContainer- グリフが入る領域の形状とサイズを定義する
- たいていは長方形だが任意の形状も可能
NSLayoutManagerNSTextStorageの属性範囲を見てグリフのサイズと間隔を計算する- フォントからベクターグリフを取り出し、1文字を1つ以上のグリフに変換する
- 各行の開始・終了、行数、テキスト全体の高さを計算する
TextViewNSLayoutManagerが作成したグリフレイアウトを描画する- ビューの高さを現在のテキストレイアウトの高さと同期する
- 入力、選択範囲、キャレット、入力属性、
textContainerInset、音声入力、コピー・ペースト、スペルチェックなどを管理する
ScrollViewTextViewの可視部分、スクロール、スクロールバー、ズームを管理するTextViewのtextContainerInsetとは別にcontentInsetを持てる
-
AppKitとUIKitのスクロール差異
- AppKit では
NSScrollViewがNSClipViewと2つのNSScrollerを含み、NSClipViewがNSTextViewを保持して複数クラスが協調してスクロール動作を作る - UIKit では
UITextViewがUIScrollViewを継承するため、スクロールロジックまで自前で含んでいる - キャレットが
contentInsetによって制限された可視領域の外へ移動すると、UITextViewは自動スクロールする - iOSエディタでキャレットがキーボードの後ろに隠れると次の行までスクロールされる動作は、下側の
contentInsetが現在のキーボード高に動的設定されるためである
- AppKit では
NSAttributedString と属性参照
NSAttributedStringはAppleフレームワークにおける リッチテキスト編集 の基盤であり、2つの要素から構成される- プレーンテキスト文字列
- 文字列内のテキスト範囲に付いた文字列-値属性ペア
- 属性は主にスタイリングに使われるが、開発者は必要に応じて任意の文字列-値ペアも付与できる
NSRangeはlocationとlengthから構成され、NSMakeRange(10, 5)は位置10から始まる5文字の範囲、つまり位置10〜14を意味する- 同じ位置に同じ属性が複数範囲で指定された場合は、最後に適用した範囲が優先される
-
特定位置の属性と範囲確認
- 特定位置の属性値は
attribute:atIndex:effectiveRange:で確認する - 戻り値が
nilならその属性は存在せず、値があれば例ではNSFontまたはUIFontオブジェクトのような属性値になる - 最後の引数に
NSRangeポインタを渡すと、同じ値の属性が連続する範囲、または属性のない空白範囲を受け取れる - ドキュメント上、
effectiveRangeは必ずしも最大範囲ではなく実装依存である - 保証された最大範囲が必要なら
attribute:atIndex:longestEffectiveRange:inRange:を使うべきである - 特定位置におけるすべての属性の組み合わせと、その組み合わせが続く範囲を取得するメソッドもある
- 範囲内の属性走査には
enumerateAttribute:inRange:options:usingBlock:を使える
- 特定位置の属性値は
PaperのMarkdownスタイリングパイプライン
- Paperのスタイリングは、テキスト範囲にフレームワーク定義の特殊属性を適用する方式である
- スタイリングの前にテキスト構造を識別するため、カスタムメタ属性 も使う
-
メタ属性とスタイル属性
- メタ属性
- MarkdownパーサーがMarkdown構文の個々の部分を識別するために定義する
- 純粋に意味情報のためのカスタムな文字列-値ペアである
- テキストの視覚的な見た目には影響しない
- Markdown用の簡略化された AST のように見なせる
- スタイル属性
- メタ属性で示された部分の上に適用される視覚属性である
- AppKitとUIKitが定義した組み込みの文字列-値ペアである
- メタ属性
-
属性更新を引き起こすイベント
- 属性は
NSTextStorageのMarkdownテキストと、テキストに影響する設定変化に合わせて同期される - 文書を開く 場合はメタ属性とスタイル属性を全面更新する
- テキスト変更 では影響を受けた部分のメタ属性とスタイル属性を部分更新する
- 設定変更 ではメタ属性は変更せず、スタイル属性だけを全面更新する
- 属性は
-
更新順序とレイアウトコスト
- まずテキスト編集トランザクションを開始して属性変更をまとめる
- トランザクションなしで属性を変更すると、そのたびに
NSLayoutManagerの高コストなレイアウト再計算が発生する - Markdown文字列をパースしてメタ属性で示される断片に分割し、設定変更時はMarkdown構造が変わらないのでこの段階を省略する
- グリフ位置やサイズに影響しうる視覚属性を先に更新し、
NSParagraphStyleは行と段落レイアウトに影響する最重要属性として扱われる - トランザクション終了後に、レイアウトへ影響しない 装飾属性 を更新する
- 装飾属性はAppleの用語ではレンダリング属性に相当し、
NSTextStorageではなくNSLayoutManagerに存在するためトランザクションを認識しない
-
入力属性
- 入力属性(typing attributes) は、空の選択範囲ではキャレット直前位置の属性と、空でない選択範囲では選択開始位置の属性と結び付いている
- 文字を入力すると、入力属性が新しく挿入されたテキストへ自動適用される
- MarkdownエディタではスタイルがMarkdown構文から導かれるため重要度は低い
- リッチテキストエディタでは、スタイルをオフにするかキャレットを別位置へ移動するまでキャレットにスタイルが付いたままになるため、より重要である
- PaperのPreview Modeは、Markdownエディタの中でもトグル可能な入力属性が強調されるリッチテキスト編集体験を提供する
入力性能とメタ属性活用
- メタ属性、レイアウト属性、装飾属性の分離は、エディタ内の一部変更を高速に処理するのに有利である
- ライト・ダークモード切り替えは装飾属性だけを更新するためレイアウトを引き起こさず、非常に高速である
- 文字サイズ調整のような設定変更は文書全体のレイアウト再計算を必要とするが、Markdown構造全体を再パースするよりは高速である
-
Markdown入力最適化
- テキストエディタにおける最重要の性能要素は 入力速度 である
- Markdownの特性上、1回のテキスト変更が段落全体のスタイルに影響することがある
- キー入力ごとに段落全体を再パースして再スタイリングする方式は技術的には最も正確だが、長い段落では編集速度を落とす可能性がある
- Paperは、次に入力される文字と周辺文字を見て動作を分けるアルゴリズムを使う
- 特殊なMarkdown記号を入力するか、編集位置の周囲に特殊記号がある場合は段落全体を更新する
- それ以外では入力属性に依存して、ほとんどの入力状況でエディタ速度を高める
-
コードブロック例外と値オブジェクトキャッシュ
- コードブロック はPaperで唯一の複数段落にまたがるMarkdown構造である
- コードブロックを含む文書では、1回のキー入力が文書全体のスタイルへ影響する可能性がある
- Paperは一定文字数を超える文書ではコードブロック処理を無視し、コードに関心のない大多数のユーザー向けに高速なエディタを維持する
- この選択により、開発者寄りのユーザーにとってもPaperはより有用になる
- 複雑な属性値オブジェクトはキー入力ごとに再割り当てされるが、テキスト影響設定が変わらない限り不変なのでキャッシュして再利用する
NSFont/UIFontNSColor/UIColorNSParagraphStyle
-
メタ属性が使われる機能
- 書式ショートカット
- 選択したMarkdownテキストの既存スタイル情報を知る必要があり、トグル操作を可能にする
- 選択範囲が同じスタイルを完全に包んでいれば、そのスタイルを削除する
- 選択範囲に同じスタイルがなければ、そのスタイルを追加する
- 選択範囲が同じスタイルを一部だけ包んでいる場合は、そのスタイルを選択範囲へ移動する
- headingやblockquoteのように段落タイプを定義する競合スタイルは混在できないため、新スタイル追加前に削除する必要がある
- チャプター移動
- 前または次のチャプター境界へ移動する際、キャレット位置を基準にheadingを見つけるためメタ属性を使う
- Outline
- すべてのheadingを走査し、outline項目を押すとそのチャプターへキャレットが移動する
- チャプター再配置
- outline上でチャプターを並べ替えられる
- 形式変換
- MarkdownをRTF、HTML、DOCXへ変換するにはテキスト構造を把握している必要がある
- Paperは外部ライブラリを含まないため、事前にパースされたテキストモデルを走査して出力形式を構築する
- 書式ショートカット
テキストコンテナの数学と選択動作
-
行長と余白計算
- テキストコンテナの中核ルールは、望ましい 行長 を維持し、残り空間を両側のinsetに分配することである
- headingタグが通常テキストフローの外に配置される場合のように、対称性を見かけ上合わせる必要がある状況もある
- このときテキストコンテナを左へ移動し、段落は
NSParagraphStyleでインデントする - 十分な空間があれば余白を視覚的に対称に近く保つ
- 追加空間がなければ、右側paddingが残る間は指定行長を維持する方向で対称性を崩す
- paddingすら残らなければ、望ましい行幅より最小余白が優先される
minとmaxの組み合わせで、段階的に折りたたまれる余白を実装できる
-
選択アンカー
- テキスト選択には常に アンカー位置 がある
- Macではクリックしてドラッグするとクリック位置が基準となり、その位置を越えると選択の増減方向が逆転する
- iOSでは片方の選択ハンドルをドラッグすると反対側がアンカーとなり、その逆も同様である
- キーボードで選択を拡張するときも同じ論理が適用される
Option+左・右矢印で単語境界へ移動し、Shiftも併用すると単語単位で選択する- クリック・ドラッグで選択した後にキーボードでさらに拡張・縮小しても、最初のクリック位置がアンカーのままである
-
選択affinity
- 選択affinity は改行境界で挿入位置が前行末尾文字の後ろに現れるか、次行先頭文字の前に現れるかを決める
- 矢印キーでキャレットを動かすと、改行位置の空白付近では単純に行を切り替える
- ショートカットで行末へ移動すると、キャレットは同じ行に留まり、改行空白の右側に付くことがある
TextViewはこれに似た選択affinity動作を他の状況でも行う
-
Undo coalescing
- Undo coalescing は、入力したテキストをいつ1つのundo可能な操作としてまとめるかを決めるアルゴリズムである
TextViewは別の操作に中断されずに入力された内容をまとめる単純なアルゴリズムを使っているように見える- 途中で休んでから入力を続けても1回のundoにまとまり、改行もcoalescingを分断しない
- Sublime は入力中に別々の単語をundo単位でまとめる方式を好み、長い単語を入力するときはさらに小さいundo単位に分けることもある
- この動作に正解はなく、エディタごとに異なる戦略を取りうる
UTI、pasteboard、アプリ間データ交換
-
Uniform Type Identifiers
- UTI は、データ型が親データ型を継承するように
conform toする階層システムである public.*型はAppleが定義し、public.html、public.jpegのような広く使われる形式を識別する- 開発者は衝突回避のため、逆ドメイン名体系で独自識別子を作成できる
- この階層システムの利点は、アプリがあらゆるテキスト形式を扱えるなら個別形式を列挙せず
public.textだけ宣言すればよい点にある - Paperはすべてのテキストファイルを開けると宣言しており、highlightingはなくても
.html、.rtfのようなテキスト形式を開ける - ファイルはクロスプラットフォームな概念なので、実質的にファイル拡張子が識別子として使われる
- 互換性のため、すべてのUTIは1つ以上の関連ファイル拡張子を定義できる
- Markdownは広く使われているがAppleが定義していない形式のため、半公開UTI に関する厄介なケースが生じる
- UTI は、データ型が親データ型を継承するように
-
pasteboardと複数表現
- Appleの pasteboard は、UTIをシリアライズ済みテキストまたはバイナリデータへ対応付ける辞書である
- Xcode Additional ToolsのClipboard Viewerでpasteboard内容をリアルタイム確認できる
- 1回のコピー操作で同一データの複数表現が同時に書き込まれる
- 一部アプリは後方互換性のため
NeXT Rich Text Format v1.0 pasteboard typeのようなレガシー非UTI識別子も使う - エディタは通常、自分が処理できる中で最も豊かな形式を選ぶ
- リッチテキストエディタの
Paste and Match StyleまたはPaste as Plain Textはpasteboard上のプレーンテキスト形式を使うようにする - 貼り付けたテキストに適用されるスタイルは通常、入力属性から取得される
- ドラッグ&ドロップも別のpasteboardを基盤に動作する
- 標準copy-pasteにはgeneral pasteboardが使われ、カスタムなアプリ間相互作用には独自pasteboardを作成できる
-
RTFと
NSAttributedString- RTFは本質的に
NSAttributedStringのシリアライズ形であり、逆にNSAttributedStringはRTFのプログラミングインターフェースである TextViewはNSAttributedStringの子クラスであるNSTextStorageの上で動作するため、pasteboardと基本的に互換性がある- 別途コーディングしなくても内容をpasteboardへコピーできる
- RTFは本質的に
-
Markdown UTIのアプリ間問題
- Markdownエディタ間のコピー・ペーストでは、両アプリが標準プロトコルを実装していても問題が起きうる
- 最初のエディタがMarkdownを
public.text、リッチテキスト表現をpublic.rtfで書き出すと、2番目のエディタはそれがネイティブMarkdownだと分からずpublic.rtfを選ぶことがある - この場合、不要な二重変換が起こり、アプリごとにMarkdown↔RTF変換方式が異なるため、余分な改行のような細かな書式問題が生じうる
- MarkdownとRTFの根本的なスタイル差も露呈しうる
- 自然に動作させるには、両アプリが
net.daringfireball.markdownUTI を書き出し、public.rtfより優先させる必要がある - PaperはMarkdown UTIを書き出そうとしたが、他アプリがリッチテキストよりこれを優先しないように見えた
- Pagesは
net.daringfireball.markdownをpublic.rtfより優先するが、リッチテキストへ変換せず元のMarkdown文字列をそのまま挿入する挙動を示す - このためPaperはMarkdown UTIを削除した
-
PaperがRTFを提供する理由とiOS共有
- Paperはリッチテキストエディタへ自然にコピー・ペーストできるようRTFを提供する
- 良きOS市民として、コピーされたデータを表す複数形式を提供すべきであり、受信アプリが処理可能な最も豊かな形式を選べるようにする
- PaperでMarkdownテキストをコピーしてMailアプリに貼り付けると、Markdownのままではなく見栄えよく整形されたリッチテキストとして貼り付けられる
- iOSの 共有 機能はcopy-pasteに似ているが、UIが付加された形である
- iOS共有では、アプリが複数形式でデータを書き出し、受信アプリが必要な形式を選択する
- pasteboardと異なり、iOS共有の上部アプリ一覧に表示されるには、アプリがカスタムUIを持つshare extensionを明示的に提供する必要がある
1件のコメント
Hacker News のコメント
本当に素晴らしい。これから TextKit の入門資料としては、https://www.objc.io/issues/5-ios7/getting-to-know-textkit/ より先にこの記事を見ることになりそう
現在の iOS と macOS のテキストフィールドでデフォルトで使われている新しいレイアウトエンジンで、今後は唯一の選択肢になる可能性が高い
TextEdit アプリはほぼすべてが単一の TextView で構成されている
Windows 側の対応物は RichEdit コントロールベースの WordPad だと思う。もうひとつ面白いのは、RTF が事実上 NSAttributedString のシリアライズ形式だということ
Windows の RichEdit コントロールも同様で、実際には Windows の実装のほうが先に出ていたように見える: https://en.wikipedia.org/wiki/Rich_Text_Format
編集トランザクションの外で装飾属性を扱うという部分が少し分かりにくい。「NSLayoutManager 自体にあり、NSTextStorage にはないのでトランザクションのことも知らない」という説明があったが、色のような 装飾属性 は通常 NSTextStorage に入る
著者が Markdown 文字に適用した色を、NSLayoutManager の一時属性サポートで処理しているという意味だろうか? 通常はスペルミスの単語に色を付けるときに使う機能だが、その目的が何なのか気になる
一時属性を使う利点は、NSTextStorage のトランザクション全体を呼び出すよりも変更がはるかに速いことにある。たとえば Paper ではライト/ダークモードの切り替えが非常に速い
本当に良い記事で、個人的にも今 NSTextView を扱っているところなのでタイムリーだった
こういう記事を書くのに必要な情報はどうやって得たのか気になる。他人のコードなのか、つらい経験なのか、developer.apple.com なのか?
DOM ドキュメントの時代に Notion や GitBook を見ていても、テキストのパースや操作で魔法のようなことをするときは、しばしば 属性付き文字列 に戻ることになる
本当にエレガントな構造なのに、なぜこれほど知られていないのか理解できない。記事も信じられないほど良い
以前、テキストエディタを完全にゼロから自作しようとしたことがある身としては、こういう記事があったらものすごい資料になったと思う
このアプリを本当に愛用している。Obsidian や iA Writer を含め、ほかのすべての Markdown アプリ を置き換えた
もっと多くの iOS コンポーネント にこうしたドキュメントがあればいいのに
2024年になっても、まだ誰かが Cocoa をやっているなんてありがたい
長く Android アプリを開発してきた立場からすると、Apple が物事に少し違った、より慎重なやり方で取り組んでいるのが興味深かった
Android では基本的に、レイアウトとレンダリングに関するすべてを Layout クラスとそのサブクラスが処理し、TextView が編集と選択ロジックの一部を実装する。EditText と TextView の唯一の違いは、EditText が TextView にすでに入っている編集機能を有効にしていること
このようなややモノリシックなアプローチと貧弱な API の問題は、アプリのテキストレンダリングをより細かく制御したいときに行き詰まる点にある。たとえば、レイアウト後に個々の グリフ にアクセスしたくても不可能