第一原理から学ぶコードスニペットのためのマジックムーブ
Magic Move for Code Snippets from first principles (rahulrav.com)
要約
筆者は、macOSのKeynoteに搭載されている「マジックムーブ」機能でコードスニペットをアニメーション化する際に生じる課題に直面しました。Keynoteはコードを文字の羅列としてしか認識しないため、コードの変更を滑らかにアニメーション化するには、多くの手間のかかる回避策が必要でした。この問題を解決するため、筆者はHeckelの差分検出アルゴリズムを応用し、コードをトークン化して視覚的な連続性を重視したアニメーションを実現するアプローチを開発しました。これは、プレゼンテーションツールにおけるコードアニメーションの新たな可能性を示唆しています。
全文翻訳
コードスニペットのためのマジックムーブをゼロから実現
2026年9月6日、月曜日
[画像: Magic Move Demo]
はじめに
ローンチ以来、Framework 16を日常的に使用しています。長らくUbuntuとWindowsのデュアルブートという不安定な truce を維持していましたが、2025年末についに決断し、両方を消去して CachyOS に乗り換えました。なぜ乗り換えたのかは、おそらく別のブログ記事で詳しく書くべきですが、要するに、これ以上ないほど満足しています。Arch / CachyOS は、まさに新鮮な空気でした。
仕事では、日常的にM5 MacBookを使用しています。最近、macOSはますます使いにくく感じられるようになりました。Appleが『Liquid Glass』という見た目の美しさのためにデスクトップパフォーマンスを犠牲にしたという妥協は、私には理解できません。
Linuxへの完全移行は驚くほどスムーズでした。私が依存しているほとんどのツールにはネイティブのLinuxビルドがあり、数少ないネイティブビルドがないものについても、しっかりとした代替手段がありました。しかし、一つだけ痛みを伴う犠牲があり、立ち止まってしまいました。それは Apple Keynote(特に Magic Move)です。
カンファレンスで頻繁に講演する者にとって、プレゼンテーションツールは虚栄心ではなく、物語を構築するための足場です。長年、Keynoteを頼りにして複雑な技術的概念を楽々と見せることができました。そのワークフローから離れるのは辛いものでした。
Magic Moveとその限界
しかし、その輝かしさにもかかわらず、Magic Moveには常に明白な盲点がありました。それは コードスニペットのアニメーション です。
Keynoteで2つのコードスニペット間の遷移を試みたことがある人なら、その恐怖を知っているでしょう。Keynoteはコードを理解せず、グリフを理解します。val がスライドの1行目から対角線上に移動して calculate() の al に変化し、句読点がアルファベットスープのようにぐちゃぐちゃになるのを、呆然と眺めることになります。
プロフェッショナルに見せるためには、壊れやすく、制御不能な回避策に頼らざるを得ません。行を不可視のバウンディングボックスに分割したり、透明なダミー文字を挿入したり、ピクセル単位でトークンを手動で調整したりして、3行のリファクタリングをきれいに見せるだけです。魔法のように見せることは 可能 ですが、それは純粋な力任せの努力によるものです。それでも、その見返りは本物でした。ほとんどのカンファレンストークの後、人々はアーキテクチャについて尋ねるのではなく、「どうやってあのクールなアニメーションを実現したんですか?」と尋ねてきました。
Jetpack Compose チームで働いていると、UIツールキットは今や完全にマルチプラットフォーム になっていますが、これは常に話題になります。実際、これはほぼ恒例行事です。Google I/Oや主要なカンファレンスシーズンごとに、チームの誰かが必ず、ぐちゃぐちゃになった遷移画面を見て、「Composeでスライドデッキ全体を書けば、コードが正しくアニメーションするんじゃないか?」と宣言します。
数人の勇敢な魂が、ComposeのShared Element transitions を使って実際にそれを実現しました。私はWYSIWYGツールを完全に放棄する勇気はありませんでした。Keynoteは、レイアウト、タイポグラフィ、そしてコード ではない 部分(Appleがサブスクリプションの背後に多くのテンプレートを徐々に囲い込んでいるとしても)においては、依然として比類のないものです。
きっかけ
数週間前、HNでBento というプロジェクトに目が留まりました。これは完全にローカルでオフライン、拡張可能なプレゼンテーションツールキットです。このプロジェクトは 非常に洗練されており、注目に値します。Bentoは、形状や数式に対してすでに モーフィング をサポートしています。位置、色、さらにはグラデーションをトゥイーンできます。私の視点から欠けていた唯一の機能は、コードスニペットのモーフィングをサポートできることでした。
リサーチ
私は袖をまくり上げて、これを構築するために何が必要かを見てみることにしました。このようなプロジェクトの最初のステップは、既存の文献を調査することです。差分コンテンツはよく研究された問題であることが判明しました。開発者として、例えば git diff を実行するたびに、私たちは毎日これを行っています。
一般的な候補は Myers と Patience の diff で、どちらも2つのシーケンス間の最短編集距離を計算します。また、Kotlin / Androidコミュニティのメンバーによるこの問題への過去の試み(例:Re-creating Magic Move with Compose)もありました。
直感:数学が悪いアニメーションを作る場合
過去の試みを見た後、私の直感では、Myers diffはこのユースケースにとって間違った問題を解決しているということでした。
Meyers diffは数学的にエレガントです。それは、String AをString Bに変換するための 最短の編集シーケンス を見つけます。しかし、数学的に最適な差分は、視覚的に不快なアニメーションになることがよくあります。変数が名前変更されたり移動されたりした場合、Myersは単一の編集ステップを節約するために、ここで3文字を削除し、そこで2文字を挿入し、識別子を半分にスライスすることを選択するかもしれません。
アニメーションでは、人間の知覚は最小編集距離よりもはるかに重要です。最小の操作は望みません。視覚的な連続性 が欲しいのです。トークンがスムーズに新しい位置に滑り込んだり、優雅にフェードアウトしたりすることを望みます。
それが、1978年のPaul Heckelの論文『A technique for isolating differences between files』(Communications of the ACM、Volume 21、Issue 4、1978)につながりました。この論文は、まったく異なる視点から差分処理に取り組みました。アルゴリズムは O(n) 時間で実行され、アニメーションに最適化されているように感じられます。KotlinとJetpack Composeに精通しているため、この論文をコードスニペットをプレーンテキストとして扱うのではなく、トークンストリーム 間の差分を計算するために適応させる実験をすることにしました。
diff するものを定義する
最初のステップは、トークナイザーを使用してコードスニペットをトークンストリームに変換することです。
コードのトークン化を考えるとき、最初の本能はしばしば「ASTを構築/使用しよう」となります。
しかし、ASTはコードスニペットにとっては落とし穴です。なぜなら:
- 重いパーサー/コンパイラインフラストラクチャをバンドルせずに、複数のプログラミング言語をサポートしたい。
- コードスニペットは本質的に乱雑です。スピーカーはしばしば部分的なスニペットを使用し、インポートや末尾のブレースを省略し、厳密なパーサーをクラッシュさせるような疑似コードに頼ることがあります。
代わりに、トークンとフラグメントの意味の一般的な表現が欲しいのです。テキストエディタはまったく同じ課題に直面しています。それらは、数十の言語にわたって高速で耐障害性のある構文ハイライトを必要とします。それらが解決する多くの方法の1つは、TextMateグラマー を使用することです。元々は TextMate(伝説的なmacOSテキストエディタ)のために設計されたこの形式は、後にSublime Text、Visual Studio Code、Eclipseに採用され、事実上の業界標準となりました。
このグラマーは、各トークンを識別し、それに scope を割り当てることができる正規表現の優先リストです。したがって、次のようなスニペットがある場合:
val x = 10
print(x)
トークンストリームは次のようになります:
Token(content='val', scopes='[source.kotlin, storage.type.kotlin]', lineNumber=0, startIndex=0, endIndex=3)
Token(content=' x ', scopes='[source.kotlin]', lineNumber=0, startIndex=3, endIndex=6)
Token(content='=', scopes='[source.kotlin, keyword.operator.assignment.kotlin]', lineNumber=0, startIndex=6, endIndex=7)
Token(content=' ', scopes='[source.kotlin]', lineNumber=0, startIndex=7, endIndex=8)
Token(content='10', scopes='[source.kotlin, constant.numeric.integer.kotlin]', lineNumber=0, startIndex=8, endIndex=10)
Token(content='print', scopes='[source.kotlin, support.function.kotlin]', lineNumber=1, startIndex=0, endIndex=5)
Token(content='(', scopes='[source.kotlin, meta.group.kotlin, punctuation.section.group.begin.kotlin]', lineNumber=1, startIndex=5, endIndex=6)
Token(content='x', scopes='[source.kotlin, meta.group.kotlin]', lineNumber=1, startIndex=6, endIndex=7)
Token(content=')', scopes='[source.kotlin, meta.group.kotlin, punctuation.section.group.end.kotlin]', lineNumber=1, startIndex=7, endIndex=8)
Token クラスは次のようなもので表すことができます:
class Token(
/** 解析されたトークンの実際のコンテンツ。 */
val content: String,
val scopes: List<String>,
val lineNumber: Int,
val startIndex: Int,
val endIndex: Int
) {
// ...
}
TextMate スコープは、Token が何を表すかの全体像を提供します。この例では、最初のスコープは常にルート( source.kotlin)であり、Kotlinソースコードを見ていることを示しています。リストの最後のスコープは、トークンが果たす具体的な役割(例:storage.type.kotlin または constant.numeric.integer.kotlin)を示します。TextMateは標準スコープの包括的な階層を定義しており、テキストエディタは各トークンにどの色を塗るかを決定する方法です。
構造的類似性
Magic Move効果を生み出すために Token のシーケンスを差分処理する際、行番号と文字オフセットが構造的アイデンティティを決定すべきではないことが明らかになります。トークンは行をまたいで自由に移動できます。リファクタリングを示すプレゼンテーション中には、しばしばそうします。
差分処理を行う際に Token を真に定義する特性は、その content、プライマリ scope(リストの最後の項目)、および depth(つまり scopes.size)です。深さは構造的な洞察を提供します。例えば、トップレベル スコープのトークンと、関数本体の奥深くに入れ子になったトークンを区別します。
したがって、私たちの Token クラスは実際には次のようになります:
class Token(
/** 解析されたトークンの実際のコンテンツ。 */
val content: String,
/** プライマリスコープ */
val scope: String,
/** プライマリスコープの深さ。 */
val depth: Int,
/* アニメーションのための追加コンテキスト(ただし、構造的類似性ではない)。 */
val lineNumber: Int,
val startIndex: Int,
val endIndex: Int
) {
// ...
}
Heckel差分アルゴリズム
1. アンカー(確実な島)の発見
その核心において、Heckel差分アルゴリズムはシンプルです。最初の目標は、アンカー、つまり previous および current シーケンスの両方に 一度だけ 現れるトークンを見つけることです。トークンが両方のスニペットに一度だけ出現する場合、変換の前後にまったく同じトークンを表していると確信できます。
2つのスニペットを考えます:previous と変換された current。
// Previous
val x = 10
は次を生成します:
Token(content='val', scope='storage.type.kotlin', depth=2, lineNumber=0, startIndex=0, endIndex=3)
Token(content=' x ', scope='source.kotlin', depth=1, lineNumber=0, startIndex=3, endIndex=6)
Token(content='=', scope='keyword.operator.assignment.kotlin', depth=2, lineNumber=0, startIndex=6, endIndex=7)
Token(content=' ', scope='source.kotlin', depth=1, lineNumber=0, startIndex=7, endIndex=8)
Token(content='10', scope='constant.numeric.integer.kotlin', depth=2, lineNumber=0, startIndex=8, endIndex=10)
そして:
// Current
val x = 20
は次を生成します:
Token(content='val', scope='storage.type.kotlin', depth=2, lineNumber=0, startIndex=0, endIndex=3)
Token(content=' x ', scope='source.kotlin', depth=1, lineNumber=0, startIndex=3, endIndex=6)
Token(content='=', scope='keyword.operator.assignment.kotlin', depth=2, lineNumber=0, startIndex=6, endIndex=7)
Token(content=' ', scope='source.kotlin', depth=1, lineNumber=0, startIndex=7, endIndex=8)
Token(content='20', scope='constant.numeric.integer.kotlin', depth=2, lineNumber=0, startIndex=8, endIndex=10)
最後の定数( 10 対 20)を除き、両方のリストのすべてのトークンは一度だけ出現するため、これらがアンカーとなり、同じアイデンティティを共有します。
2. フォワードパス(前方への進行)
アンカーを固定したら、その直接の隣接要素を確認します。
各アンカーについて、current の直後のトークン( currentIdx + 1)が previous シーケンスの直後のトークン( previousIdx + 1)と一致するかどうかを確認します。一致する場合、それらはペアになります。トークンが一致しなくなるまで前方に進み続けます。
3. バックワードパス(後方への進行)
次に、フォワードパスを逆方向に実行します。アンカーから開始し、オフセット currentIdx - 1 と previousIdx - 1 を確認します。先行するトークンが一致する場合、それらをペアにし、後方に歩き続けます。
4. 最終パス
これらのパスの終わりまでに、残りのすべての一致したトークンと再配置されたトークンがマッチングされます。previous で一致しなかったものはすべて削除済みトークンとしてマークされ、current で一致しなかったものはすべて挿入済みトークンとしてマークされます。
もう少し複雑な例
次のような場合:
// Previous
val x = 10
fun convert(input: Int) {
// ...
}
と:
val x = 10
fun convert(input: Int) {
println("The actual implementation")
}
実際のマッチングは次のようになります:
Match(previous=Token(content='val', scope='storage.type.kotlin', depth=2, lineNumber=0, startIndex=0, endIndex=3), previousIdx=0, current=Token(content='val', scope='storage.type.kotlin', depth=2, lineNumber=0, startIndex=0, endIndex=3), currentIdx=0)
// ...
Match(previous=Token(content=' ', scope='meta.block.kotlin', depth=3, lineNumber=2, startIndex=0, endIndex=2), previousIdx=16, current=Token(content=' ', scope='meta.block.kotlin', depth=3, lineNumber=2, startIndex=0, endIndex=2), currentIdx=16)
Delete(token=Token(content='//', scope='comment.line.double-slash.kotlin', depth=5, lineNumber=2, startIndex=2, endIndex=4), index=17)
Delete(token=Token(content=' ...', scope='punctuation.definition.comment.kotlin', depth=4, lineNumber=2, startIndex=4, endIndex=8), index=18)
Insert(token=Token(content='println', scope='support.function.kotlin', depth=4, lineNumber=2, startIndex=2, endIndex=9), index=17)
Insert(token=Token(content='(', scope='punctuation.section.group.begin.kotlin', depth=5, lineNumber=2, startIndex=9, endIndex=10), index=18)
Insert(token=Token(content='"', scope='punctuation.definition.string.begin.kotlin', depth=6, lineNumber=2, startIndex=10, endIndex=11), index=19)
Insert(token=Token(content='The actual implementation', scope='string.quoted.double.kotlin', depth=5, lineNumber=2, startIndex=11, endIndex=36), index=20)
Insert(token=Token(content='"', scope='punctuation.definition.string.end.kotlin', depth=6, lineNumber=2, startIndex=36, endIndex=37), index=21)
Insert(token=Token(content=')', scope='punctuation.section.group.end.kotlin', depth=5, lineNumber=2, startIndex=37, endIndex=38), index=22)
Match(previous=Token(content='}', scope='punctuation.section.group.end.kotlin', depth=4, lineNumber=3, startIndex=0, endIndex=1), previousIdx=19, current=Token(content='}', scope='punctuation.section.group.end.kotlin', depth=4, lineNumber=3, startIndex=0, endIndex=1), currentIdx=23)
ブロック本体まで、トークンがきれいに両方のシーケンス間で一致していることがわかります。
コメントトークン( // と ...)を Delete し、新しい println(...) 呼び出しを Insert します。重要なのは、ブロックの終わりとして機能したため、末尾の } がきれいに一致することです。
スムーズなアニメーションのためのヒント: 削除されたトークンを個別にトークンごとにアニメーションさせると、アニメーションがオーケストレーションされていないように見えます。私たちの実装では、連続した削除トークンはまとめてバッチ処理されて一斉にフェードアウトする一方、新しく挿入されたトークンはスムーズにフェードインします。アンカー化されたトークンは、新しい座標に優雅に滑り込みます。結果は、混沌とするのではなく、まとまりがあり映画的だと感じられます。
ソースコード
完全な実装は https://github.com/tikurahul/warp で利用可能です。
エピローグ
Bento にも貢献することができました。Bentoリリース 1.0.19 には、コードスニペットのモーフィング機能が追加されました。
私のプルリクエストはこちらです。