プログラミング
第一原理から学ぶコードスニペットのためのマジックムーブ
Magic Move for Code Snippets from first principles (rahulrav.com)
要約
筆者は、macOSのKeynoteに搭載されている「マジックムーブ」機能でコードスニペットをアニメーション化する際に生じる課題に直面しました。Keynoteはコードを文字の羅列としてしか認識しないため、コードの変更を滑らかにアニメーション化するには、多くの手間のかかる回避策が必要でした。この問題を解決するため、筆者はHeckelの差分検出アルゴリズムを応用し、コードをトークン化して視覚的な連続性を重視したアニメーションを実現するアプローチを開発しました。これは、プレゼンテーションツールにおけるコードアニメーションの新たな可能性を示唆しています。
全文翻訳
2026年9月6日 月曜日
第一原理から学ぶコードスニペットのためのマジックムーブ
はじめに
私は発売以来、Framework 16を日常的に使用しています。長らく、UbuntuとWindowsのデュアルブートという脆い平和を維持していましたが、2025年末に、ついにその truce を破り、両方をワイプしてCachyOSにすべてを賭けました。なぜ私が切り替えたのかは、おそらく専用のブログ記事に値するでしょうが、短いバージョンを言うと、これ以上ないほど幸せです。Arch / CachyOS は、まさに新鮮な空気でした。
仕事では、私の日常的なドライバーはM5 MacBookです。最近、macOSは生活する上でますますフラストレーションを感じるようになっています。AppleがLiquid Glassのアイキャンディのためにデスクトップパフォーマンスを犠牲にした方法を、私は決して理解できません。
Linuxへの完全移行は、驚くほど痛みがありませんでした。私が頼りにしているツールのほとんどはネイティブのLinuxビルドを持っており、そうでない数少ないものについては、堅実な代替手段が登場しました。しかし、私を立ち止まらせた痛みを伴う犠牲が一つありました。Apple Keynote(そして、より具体的にはマジックムーブ)です。
私は比較的頻繁にカンファレンスで話すので、プレゼンテーションツールは虚栄心ではなく、物語の足場です。長年、Keynoteに頼って複雑な技術的概念を楽々と見せるという贅沢を享受してきました。そのワークフローから離れることは痛かったです。
マジックムーブとその欠点
しかし、その輝きにもかかわらず、マジックムーブには常に明白な盲点がありました。コードスニペットのアニメーション化です。Keynoteで2つのコードスニペット間の遷移を試したことがある人なら、その恐怖を知っているでしょう。Keynoteはコードを理解しません。それはグリフを理解します。
行1のvalがスライドを斜めに横切り、calculate()のalに変化し、句読点がアルファベットスープのようにスクランブルするのを、落胆して見てください。プロフェッショナルに見せるためには、行を不可視のバウンディングボックスに分割したり、透明なダミー文字を挿入したり、ピクセル単位でトークンを微調整したりといった、壊れやすく、制御不能な回避策を強いられます。魔法のように見せることはできますが、それは純粋な力任せによってのみです。
それでも、その見返りは本物でした。ほぼすべてのカンファレンストークの後、人々はアーキテクチャについて尋ねるのではなく、「どうやってあのクールなアニメーションをやったんだ?」と尋ねてきました。
Jetpack Composeチームで働いていると、このUIツールキットは現在完全にマルチプラットフォームです。これは常に話題になります。実際、それはほとんど毎年恒例の行事です。Google I/Oや主要なカンファレンスシーズンごとに、チームの誰かが必然的に、乱雑な遷移を見つめ、「なぜ私たちのスライドデッキ全体をComposeで書いて、コードが正しくアニメーションするようにしないのか?」と宣言します。
数人の勇敢な魂が、WYSIWYGツールを完全に放棄しないようにComposeのShared Element Transitionsを使用して実際にそれを行いました。私は、レイアウト、タイポグラフィ、そしてコード以外のプレゼンテーションの部分(Appleがサブスクリプションの背後にテンプレートをゆっくりとゲートキーピングしているとしても)では、まだ比類のないKeynoteを完全に放棄するほど勇敢ではありませんでした。
トリガー
数週間前、HNで私の注意を強く引いたプロジェクトがありました。Bentoという名前の、完全にローカルでオフライン、拡張可能なプレゼンテーションツールキットです。このプロジェクトは非常に洗練されており、間違いなく注目に値します。Bentoは、形状と数式のアニメーション(morph)をすでにサポートしています。位置、色、さらにはグラデーションもトゥイーンできます。私の視点から欠けていた唯一の機能は、コードスニペットのアニメーション化をサポートできることでした。
研究
私は袖をまくり上げて、これを構築するために何が必要かを見てみることにしました。このようなプロジェクトの最初のステップは、既存の文献を調査することです。差分コンテンツはよく研究された問題であるため、かなりの量があることがわかりました。例えば、git diffを実行するときに、開発者として毎日これを行っています。一般的な候補はMyersとPatienceのdiffであり、どちらも2つのシーケンス間の最短編集距離を計算します。
この問題については、Kotlin / Androidコミュニティのメンバーによる以前の試み(例: Composeでマジックムーブを再作成する)もありました。
直感:数学が悪いアニメーションを作る場合
過去の試みを見た後、私の直感では、Myers diffは、このユースケースにとって間違った問題を解決しているということでした。Myers diffは数学的にエレガントです。それはString AをString Bに変えるための最短の編集シーケンスを見つけます。しかし、数学的に最適なdiffは、視覚的に不快なアニメーションを作ることがよくあります。変数が名前変更されたり移動されたりした場合、Myersは単一の編集ステップを節約するために、ここで3文字を削除し、そこで2文字を挿入し、識別子を半分にスライスすることを選択するかもしれません。アニメーションでは、人間の知覚は最小編集距離よりもはるかに重要です。最小の操作ではなく、視覚的な連続性を求めています。トークンが新しい位置にスムーズに滑り込んだり、優雅にフェードアウトしたりすることを望みます。
それが、Paul Heckelによる1978年の論文「A technique for isolating differences between files」(Communications of the ACM, Volume 21, Issue 4, 1978)につながりました。この論文は、まったく異なる視点からdiffingに取り組みました。アルゴリズムはO(n)時間で実行され、アニメーションに最適化されているように感じられます。
KotlinとJetpack Composeに精通しているため、コードスニペットをプレーンテキストとして扱うのではなく、この論文をトークンストリーム間の差を計算するために適応させる実験をすることにしました。
diffするものの定義
最初のステップは、トークナイザーを使用してコードスニペットをトークンストリームに変換することです。コードをトークン化することを考えると、すぐに思いつくのは「ASTを構築/使用しよう」ということです。しかし、ASTはコードスニペットにとって罠です。なぜなら:
- 重いパーサー/コンパイラインフラストラクチャをバンドルせずに、複数のプログラミング言語をサポートしたい。
- コードスニペットは本質的に乱雑です。スピーカーはしばしば部分的なスニペットを使用し、インポートや末尾のブレースを省略し、厳密なパーサーをクラッシュさせる疑似コードに依存します。
代わりに、トークンとフラグメントの意味の一般的な表現が必要です。テキストエディタはまったく同じ課題に直面しています。数十の言語にわたって、高速で耐障害性のある構文ハイライトが必要です。それらを解決する多くの方法の1つは、TextMateグラマーを使用することです。元々TextMate(伝説的なmacOSテキストエディタ)のために設計されたこの形式は、後にSublime Text、Visual Studio Code、Eclipseに採用され、事実上の業界標準となりました。
このグラマーは、基本的に各トークンを識別し、スコープを割り当てることができる正規表現の優先リストです。したがって、次のようなスニペットがある場合:
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スコープは、トークンが何を表すかについての完全な画像を提供します。この例では、最初のスコープは常にルート(source.kotlin)であり、Kotlinソースコードを見ていることを示しています。リストの最後のスコープは、トークンが果たす特定の役割(例:storage.type.kotlinまたはconstant.numeric.integer.kotlin)を示します。
TextMate