sve 開発で学んだ、設計を壊さずに機能と性能を積み上げる考え方
Rust (Claud Code) でTUIのテキストエディタを自作しています。
名前は sve です。Simple / Vim / Emacs の略で、1つのエディタ本体の上に複数の操作体系を載せることを目指しています。
最初は「自分用の軽いエディタを作れたらいいな」くらいの気持ちでした。
でも実際に進めていくと、
- Vim/Emacs風の操作をどう共通基盤に載せるか
- マルチカーソルや検索UIをどう分離するか
- 折り返し(wrap)や巨大ファイルでどう性能を維持するか
- 付け焼き刃で直すとどこから設計が崩れるか
といった、エディタ実装の面白さと難しさがどんどん見えてきました。
この記事では、sve を作る中で得られた知見を、実装の細部ではなく設計と進め方の観点で整理して書きます。
この記事で書くこと
- なぜ
sveを「Vim専用」「Emacs専用」にしなかったのか - 操作体系を増やしても壊れにくい設計とは何か
- マルチカーソルや resident UI をどう扱うと破綻しにくいか
- 折り返しや巨大ファイルの性能改善で、何をやるべきで何を急ぐべきでないか
- AIにコーディングを委譲するとき、何を固定し、何を自由にさせるべきか
- パフォーマンスの大切さ
sve はどんなエディタか
sve は Rust 製の terminal editor です。
目標は単純で、
- 本体はできるだけシンプル
- 操作体系は差し替え可能
- 巨大ファイルでも破綻しにくい
- 将来的に Vim / Emacs / 独自操作を共存させる
というものです。
ここで大事にしているのは、Vimっぽい機能をVim専用コードとして増やさないことです。
同じように、Emacs系の機能をEmacs専用の巨大分岐で増やすことも避けています。
最初からそう考えていたわけではありません。
作っていく中で、「専用分岐を足すほど、あとで必ず苦しくなる」とわかってきました。
一番大きかった学び
「キーマップの違い」と「エディタの意味論」は分けた方がいい
エディタを作り始めると、ついこう考えがちです。
- Vimモードならここで分岐
- Emacsモードならここで分岐
- 独自モードなら別ルート
でもこれは、最初は速く作れても、後でかなりつらくなります。
たとえば、
-
wは Vim では word move -
Ctrl+fは Emacs では forward - 独自キーマップでは別の意味
のように、入力は違っても最終的にやりたいことは似ている場合が多いです。
そこで sve では、なるべく
- キー入力の解釈
- コマンド解決
- カーソル移動・選択・編集の意味論
- 描画・viewport・折り返し
を分けるようにしました。
つまり、
「どのキーが押されたか」と
「エディタとして何をするか」を
できるだけ直接結びつけない
という考え方です。
この分離があると、後から
vimemacsemacs-hybrid- 将来的な
helix風 - さらに独自キーマップ
を足しやすくなります。
Vimっぽい機能を追加して学んだこと
本当に欲しいのは「Vim専用実装」ではなく「Vimが載る共通基盤」
Vimのキーバインドや text object を追加していると、どうしても誘惑があります。
-
dwだけ特別扱いする -
cwだけ if 分岐で直す -
ddとyyは別ルートで処理する -
pはとりあえず見た目だけ合わせる
でも、これを繰り返すとすぐに壊れます。
実際、sve でも一時期は
-
yy pは良い -
dd pはダメ -
cc pもダメ
のような状態になりました。
原因は単純で、見えているコマンドを個別に直していたからです。
本当は、
- yank / delete / change で register に入る payload は何か
- それは charwise か linewise か
-
pは「由来」ではなく「payload の種別」で動くべきではないか
を整理しなければいけませんでした。
つまり修正対象は
yyddccp
ではなく、
clipboard / register に入るデータの意味論
だったわけです。
これはかなり大きい学びでした。
text object 実装で学んだこと
境界条件は最初に明文化した方がいい
Vim風の text object を追加すると、表面上は簡単に見えます。
diwdawciwyawdi"da)- など
でも実際には、どこまでを対象にするかで仕様がかなり揺れます。
たとえば aw だけでも、
- 後ろの空白を優先するか
- 前の空白を含めるか
- 改行は含むか
- 複数単語に count が乗ったときどうするか
を決めないと、実装するたびにぶれます。
cw と ce も同じでした。
単語中にいるときは似て見えても、
単語の後ろの空白にいるときは明確に違います。
なので学んだのは、
Vim互換っぽい機能ほど、実装前に境界条件を短く文章化した方がよい
ということです。
一覧だけ渡してAIに実装させるより、
- これは no-op でよい
- ここは trailing whitespace を消さない
- ここは後ろの空白を優先
- エスケープは今回は考慮しない
と1行ずつ添えた方が、結果が圧倒的に安定します。
resident UI とマルチカーソルで学んだこと
「機能」と「一時UI」は別物として扱った方がよい
検索やマルチカーソルのような機能を作るとき、最初は
- resident を開く
- resident の状態を持つ
- Esc で閉じる
のように考えがちです。
でもやってみると、resident という分類だけでは雑すぎました。
たとえば、
- 検索 resident は Esc で閉じたい
- マルチカーソル resident も閉じたい
- でもゲームや独立ツール的 resident は閉じたくない
ということが起きます。
つまり本当に必要なのは、
resident かどうか
ではなく
一時UIかどうか
という属性でした。
この発見は、UI設計全体に効きました。
Esc で閉じたいのは「一時的な操作面」だけであり、
永続的な pane や独立機能は閉じたくない。
なので、UIを作るときは
- editor
- transient / dismissible
- tool / app-like
のような分類で考えた方が、後から自然に育ちます。
折り返し(wrap)の性能改善で学んだこと
一般論より、今どこが遅いかを測った方が早い
エディタを作っていると、折り返しが遅くなるのは本当に避けにくいです。
しかも、一般的なアドバイスはたいてい正しいです。
- viewport だけ計算する
- visible slice を使う
- logical row と visual row を分ける
- キャッシュする
- 行単位で dirty を絞る
でも、これだけでは足りません。
なぜなら、ある時点の sve ではすでに
- visible slice
- projection cache
- 局所更新
- row split / row join の差分更新
をかなり入れていて、
もう「View層がないから遅い」みたいな初歩段階ではなかったからです。
実際に遅かったのは、その時々で
- full rebuild
- prefix scan
- wrap projection bootstrap
- selection-replace fanout
- same-row batch の storage apply
- chunk build
- line index 更新
- cold gap move
のどれかでした。
つまり学んだのは、
一般論は大事だが、今の実装で本当に遅い場所は計測しないとわからない
ということです。
これは本当にそうでした。
「たぶんここが遅いだろう」で進めると、
局所指標は改善しても、体感では全く変わらないことが何度もありました。
性能改善で特に大事だったこと
局所指標ではなく、体感に近い全体指標を見る
これはかなり痛い学びでした。
ある最適化で、局所的には
-
prefix_sumが改善 - projection 内部指標が改善
していたのに、実際に比べると
- first Enter 全体は遅くなっている
ということがありました。
つまり、
- その小さな改善は本物だった
- でも、それが全体では主因ではなかった
- あるいは副作用の方が大きかった
わけです。
なので、今ではできるだけ
- 局所指標
- 操作全体の ms
- 実機体感
をセットで見ています。
特に sve では、ユーザー体感として
- モッサリレベル 0 = 壊滅的に遅い
- モッサリレベル 10 = 非常に速い
という感覚スケールも使っています。
一見アナログですが、これが意外と大事で、
数値は改善しているのに、体感が変わらない
を見逃しにくくなります。
パフォーマンスは後付けしにくい
エディタは開発初期から性能を意識した方がいい
エディタを作っていて強く感じたのは、パフォーマンスは後から少し直せばいい、では済みにくいということです。
もちろん、最初から全部を最適化する必要はありません。
でも少なくとも、
- logical-authoritative な state と描画用 state を分ける
- viewport / projection / renderer の責務を分ける
- 巨大ファイルや wrap を前提にしたデータの流れにする
- 全体再計算ではなく局所更新できる余地を残す
といった設計は、かなり早い段階から意識しておいた方がよいです。
実際、あとから性能を上げようとしても、責務が混ざっていたり、UI と model が密結合していたりすると、改善そのものが難しくなります。
sve でも、速度改善で本当に効いたのは「雑に速い処理を足すこと」ではなく、初期から分離していた層構造を使って、どの責務が重いかを局所的に詰めることでした。
つまり、パフォーマンスは「開発後半にだけ考えるもの」ではなく、初期設計の時点で壊れにくい形を選んでおくべきテーマだと思っています。
Gap Buffer についての考え
すぐに別データ構造へ逃げない方がよかった
巨大ファイルや折り返しの話をしていると、よく
- Piece Tree にした方がよいのでは
- Rope の方がよいのでは
- Gap Buffer は限界では
という話になります。
これは自然な疑問です。
自分でも何度も考えました。
でも sve では、少なくとも今の段階では
Gap Buffer のまま局所更新と責務整理を詰める方が正しかった
と思っています。
理由は単純で、実際に遅い場所を調べると、
- データ構造そのもの
- ではなく
- projection rebuild
- fanout buffer_edit
- gap move の cold path
- scratch buffer の初回確保
のような 周辺ロジックの責務や cold path が主因であることが多かったからです。
もちろん、将来は変わるかもしれません。
でも少なくとも「今困っている遅さ」を解くために、
すぐ Piece Tree へ飛ぶ必要はありませんでした。
AIに実装を委譲して学んだこと
曖昧な依頼より、短くても意味論を固定した依頼の方が強い
sve の開発では、AIにかなり実装を委譲しています。
これもやってみて学んだことが多かったです。
最初は長いプロンプトを書いていました。
でも途中から気づいたのは、
- 恒常ルールは Instructions に置く
- 性能タスクの共通観点は Skill に置く
- 毎回の依頼には「今回の差分だけ」書く
方が圧倒的に安定する、ということです。
たとえば Vim 機能を追加するときも、
悪い依頼はこうです。
- Vimっぽく実装してください
- いい感じにお願いします
これだと、AIが勝手に解釈してぶれます。
良い依頼はこうです。
-
yy pは linewise paste -
dd pも同じ semantics に揃える -
pは payload の種別だけで分岐する -
cwは単語中では trailing whitespace を消さない - 今回は forward search しない
- escape は考慮しない
のように、ぶれそうなところだけ短く固定する依頼です。
これは本当に効きました。
今の sve の道は悪くないのか
結論から言うと、かなり悪くないと思っています。
理由はこうです。
- 問題が起きたら、まず測る
- 効かなかった最適化は戻す
- コマンドごとの特例を避ける
- logical-authoritative と projection/render を分ける
- Vim/Emacs を「別製品」ではなく「別キーマップ family」として扱う
- マルチカーソルや resident を「一時UI」と「本体機能」に分けて考える
この方向は、少なくとも自分の中ではかなり納得感があります。
もし今の sve が悪い道に入っているなら、
- 症状ごとに if を増やす
- すぐ全データ構造を変える
- Vim専用分岐をコアに増やす
- UI とモデルを混ぜる
みたいになっていたはずです。
でも今はそうなっていません。
なので感覚としては、
根本から間違っているわけではなく、今は仕上げの詰めをしている段階
です。
これからやりたいこと
まだまだやりたいことは多いです。
- Vim 系のコマンド追加
- モードごとのカーソル表示
- 行番号や配色の改善
-
gg,z<Enter>,z-,H/M/L -
a,A,I,O,s,S,C - text object の拡張
- wrap on の回帰を潰す
- startup の回帰を潰す
機能としてもやりたいことは多いですが、
今後もたぶん一番大事なのは、
壊れにくい共通基盤に乗せていくこと
だと思っています。(まだ、このあと Emacs が待ってるんですけどね)
まとめ
RustでTUIテキストエディタを自作して得られた知見を一言でまとめると、こうです。
- 入力体系と意味論は分けた方がよい
- Vim専用実装を増やすより、Vimが載る基盤を作る方が長く効く
- 一般論より、今どこが遅いかを測る方が早い
- 局所指標が改善しても、全体と体感が改善するとは限らない
- Gap Buffer はすぐ捨てなくてよい
- AIには長文より、境界条件を短く固定した依頼の方が効く
自作エディタは大変ですが、その分だけ学びが多いです。
特に、「最初は気づかなかった設計上のズレ」が、機能追加や性能改善を通じて少しずつ見えてくるのが面白いところだと思います。
もし同じようにエディタや TUI アプリを作っている方がいたら、
このあたりの知見が少しでも参考になれば嬉しいです。
おまけ: 個人的に大事だった合言葉
開発を進める中で、よく意識していたのはこの3つです。
- まず測る
- 特例で逃げない
- いま本当に遅い場所だけを直す
地味ですが、結局これが一番効きました。