何もしてない時は、何もしない

30fps で描画する TUI なのに、触っていない間の CPU は 0%。秘密は "描かない技術" にあります。

6 ペイン開いて、Claude Code を 2 つ起動して、vim で編集中、別のペインで htop が動いている。

ここでユーザーが何も触らないと、ccmux の CPU 使用率は top で見て 0% 台になります。30fps の TUI なのに。

「派手な TUI = CPU 食う」のイメージを覆すタネは、シンプルな 1 文に収まります:

何も変わってないなら、描かない。

30fps は上限であって、ノルマではない

「30fps で動く」と聞くと「毎秒 30 回描くんでしょ」と思いがちですが、そうじゃない。**「最大で毎秒 30 回まで」**が正しい。

描く必要が無ければ、描かない。ccmux のメインループを最短で書くと、こう:

src/main.rs
loop {
app.drain_pty_events();
if app.dirty {
app.dirty = false;
terminal.draw(|f| ui::render(app, f))?;
}
// 最大 33ms 待つ。途中でイベントが来れば即戻る。
if event::poll(Duration::from_millis(33))? {
handle_event(...)?;
}
}

核はたった 2 点:

  1. dirty フラグが立ってる時だけ描く
  2. event::poll(33ms) でスレッドを寝かせる

dirty フラグ: 一枚の旗で済ませる

「何かが変わった」を bool 一枚で表現するだけ:

pub struct App {
pub dirty: bool,
// ...
}

イベントが来たら立てる、描いたら下ろす:

Event::Key(key) => {
app.handle_key_event(key)?;
app.dirty = true;
}
Event::Resize(_, _) => {
app.dirty = true;
}

PTY の出力(Claude の返答や ls の結果)も同じ扱い:

AppEvent::PtyOutput(_) => {
self.dirty = true;
}

event::poll はスレッドを眠らせる

event::poll(Duration) は crossterm(端末入力ライブラリ)が提供する API で、OS レベルでスレッドをブロックします。CPU を食わずに、最大その時間だけ寝る。

  • 33ms 何も起きなければ false を返す
  • 途中でキー入力・リサイズ・マウスが来たら即 true

これが「30fps 上限」の正体。33ms ≒ 1/30 秒。寝ている間は、プロセスは CPU スケジューラに順番が回ってこないので、文字通り 0% です。

イベント 100 個 → 描画 1 回

「Claude Code が大量に文字を吐いてる時、毎バイトで描き直してたら間に合わないのでは?」

ここも自動で解決されます。PTY 読み取りスレッドが AppEvent::PtyOutput を 100 回送ってきても、メインループで dirty = true が 100 回上書きされるだけ。描画は次のループで一度だけ

33ms の間に何件来ても、画面は 1 回しか更新されない。イベントの合体が仕組みとして組み込まれています。

実測

手元で雑に計測した値:

状態CPU
起動直後、何もしない0.0 〜 0.1%
Claude Code を 4 ペインで応答待ち0.1 〜 0.3%
vim でタイピング中0.5 〜 1.5%
ペイン境界をドラッグ(30fps 出る)2 〜 5%
yes コマンドで大量出力10 〜 20%(ここは負ける)

触ってない時が 0%、というのは「描いてないから」に尽きます。

まとめ: 描かないことが最速

TUI を速くする一番の方法は、描画を速くすることではなく描画する回数を減らすこと

  • dirty フラグで「本当に必要な時だけ描く」
  • event::poll(タイムアウト) で「来るまで寝る」
  • イベントが合体して「1 ループで 1 描画」

この 3 つで、派手な TUI も省電力になる。htopbottom が 0% に見えるのは、同じサボり方を丁寧にやっているからです。


「描画エンジンを最適化する」より、そもそも描画しない条件を丁寧に見つけるほうが効くことは結構あります。次はマウスドラッグでの選択とクリップボードコピーの話でも書こうかな。