LTやプレゼンって練習で時間内に出来たのに、本番ではなぜあんなにも時間をオーバーしてしまうんだ!!
という経験、みなさんにもありませんか?
TL;DR
- LT(Lightning Talk)で毎回時間オーバーしてしまう問題を、ある "気づき" から自作デスクトップアプリで解決した話
- Tauri 2 + React + Rust 製。macOS / Windows 両対応
- フルスクリーンアプリの上にタイマー表示をするために、macOSネイティブAPIを叩いたり、色々と修羅の道だった話を書きます
- リポジトリ:shinya/lightning-timer(GitHub Releasesからダウンロードできます)
はじめに:僕はLTで時間を溶かす人でした
突然ですが、皆さんはLTで時間オーバーしたことありますか?
僕はあります。しかも何度も。
社内のイベントでLTを月1回してたり、外部の勉強会でも度々登壇していて、それなりに登壇数は多い方だと思うのですが、時間オーバーすることが多かったです。
「目の前にタイマーは置いてある。なのに、なぜ時間オーバー?」
一応目の前にタイマーは置いてるんですよね。
でも、実際見ていないっていう。
ある時、自分の発表中の視線の動きを思い返して気づいたんですが、発表中の視線って、スクリーンと聴衆の間をひたすら往復しているんですよね。
スクリーンの実際に投影されている内容を確認して、顔を動かして聴衆の反応を見て、またスクリーンに戻って・・・以下略。
その間、机の上に置いたタイマーは完全に視野の外に落ちています。我に返ってタイマーに目を向けた時は残り数秒みたいな状態に。自分の中でこれがあるあるすぎて泣けてきます。
・・・皆さんの中には共感してるくれる人もいると信じています。
その人はぜひこの記事を最後まで読んでください。共感しなくても読んでください笑。
で、この「タイマーを置いても無意識に見ていない」という問題は
「自分の意志で解決する問題じゃない」と思いました。視線がスクリーンと聴衆にロックされている前提で、視線の中にタイマーを物理的に置くしかないと思いました。
そうして生まれたのが、本記事で紹介する「Lightning Timer」です。
既存タイマーアプリではダメだった話
「いやいや、タイマーアプリなんて世の中に山ほどあるでしょ」と思いますよね。僕も思ってました。まずはそれで解決をしようと思い、試みました。一通り試しましたが、結論全部ダメでした。
何故かを書いていきます。
【ダメ】 スライドアプリの発表者ノートの時間表示
KeynoteもPowerPointも、発表者ノート画面に経過時間を出してくれます。
これをまずマジで見ない。発表者ノートには次の話題のメモも書いているので、視線がそこに行った時はメモを読んでいるときもあるんですが、時間表示なんて気にしてない。そしてそもそも、発表者ノートを書いててもあんまり読まないんですよね。聴衆の方のリアクションを見ながら話す内容をアドリブ的に修正していくタイプの自分は、結局発表者ノートをあまりみませんでした。そもそも内容は頭の中に入ってるので、見る必要もあまりなかったりします。
そしてもう一つの致命的な問題が、発表者ノートは経過時間しか表示してくれないこと。
3分経過 → "あと2分か" と頭で計算
僕が知りたいのは残り時間なんです。「経過3分→残り2分」と頭で変換するのは、発表中の脳には重すぎる処理でした。何も無いときは一瞬で計算できるんですけどね・・・。
【ダメ】 macOSやWebなど、PCで使えるタイマーアプリ
普通のタイマーアプリやWeb上のタイマーサービスも試しました。これらは「残り時間表示」はしてくれるんですが、結論、結局見ない。さっき書いた通り、視線がスライドと聴衆の往復から外れないので。
そして、それ以前に致命的すぎる問題があります。
スライドをフルスクリーンにすると、タイマーアプリが消える。
そりゃそうなんですけど。フルスクリーンアプリは他のウィンドウを覆い隠す挙動が標準なので。当たり前なんですがPC単体でタイマーを起動しても意味がなかったです。
【ダメ】 別デバイス(iPad、スマホ、スマートウォッチ等)にタイマー表示する
「じゃあ別デバイスでタイマーを表示すればいいのでは?」と。これも試しました。結論、やっぱり見ない。発表中に別デバイスに視線を移すアクションが、聴衆との視線リズムを壊すので、無意識に避けるんですよね。
というように、自分は発表が始まると意識がタイマーから外れるという致命的な欠陥を持っていました。
結論:「視線の中にタイマーを"物理的に"置くしかない」
ここまでの全部の失敗を踏まえて、辿り着いた結論はこうです。
視線がスライドにロックされている前提で、スライドそのものの上にタイマーを重ねるしかない
そんなアプリが世の中に全然なかったので、自分で作りました。それが Lightning Timer です。
作ったもの:Lightning Timer
一言で言うと、フルスクリーン表示したスライドの上にタイマー表示を重ねてできるタイマーです
主な機能:
- レイヤーモード:スライドのフルスクリーン表示の上に、半透明のMM:SSを浮かべる(クリックスルーなのでスライド操作の邪魔をしない)
- 3つの表示モード:normal (800x200) / compact (400x200) / minimal (200x100)
-
キーボード中心UI:
S/Space/Enterでstart、Rでreset、Vでモード切替、Lでレイヤートグル、Cmd+Enterでフルスクリーン - タイムアップ通知:時間が来たら別ウィンドウでドカッと知らせる+アラーム音
- macOS(Apple Silicon / Intel)/ Windows 両対応
ダウンロードはこちらからどうぞ。
現在最新版のダウンロード数:1(僕のみ🤣)。この記事を読んでくださっているあなたが、世界で2人目のユーザーになる権利を持っています!
技術選定:なぜ Tauri 2 だったか
デスクトップアプリを作るとなると、選択肢としては Electron / Tauri / ネイティブ(Swift, .NET)があります。今回はTauri 2を選びました。理由はざっくり3つ。
1. 軽い
Electronは正直重くてしんどいと思いました。タイマーごときに数百MBはちょっと・・・となります。Tauriのバイナリは桁違いに軽い。タイマーアプリらしい軽快さに直結します。
2. Rust から macOS のネイティブAPIに降りられる
これが今回のキモでした。後の章で詳しく書きますが、「フルスクリーンアプリの上に表示する」というニッチな要件は、結局 macOS の NSWindow / NSPanel レベルのAPIを叩かないと実現できません。Tauri なら objc2 クレートを使ってRustから直接ネイティブAPIを叩けます。
3. 個人開発の楽しさ
Rust を書いてみたかった。それに尽きます。修羅の道、上等。
【技術コラム・その1】「スライドのフルスクリーンの上」はそんなに簡単じゃなかった
ここから技術ネタに入っていきます。
「ウィンドウを最前面に出すだけでしょ?」と最初は思ったんです。全然違いました。
macOS には「Space」という機能があって、アプリをフルスクリーンにすると、そのアプリは独立した Space に切り離されます。Keynoteをフルスクリーンにすると、Keynoteだけが置かれた専用のSpaceが新しく作られて、僕らはそっちに連れて行かれているわけです。
そして、普通のウィンドウは他アプリのフルスクリーンSpaceには連れて行ってもらえません。Keynoteをフルスクリーンにした瞬間、タイマーは置いてけぼり。これがまず最初の壁でした。
解決策:macOSにお願いして「他人のSpaceにもついていって」と言う
OSレベルで「このウィンドウは特別。全Spaceに帯同してほしい」と宣言する仕組みが、macOSには用意されています。これを使うと:
- フルスクリーンアプリのSpaceにもタイマーがついていく
- ただし普通のウィンドウじゃダメで、「サブウィンドウ的な存在」として宣言する必要がある
- さらに「フォーカスを奪わない」ように設定しないと、裏のKeynoteを操作した瞬間にタイマー側にフォーカスが飛んできて操作の邪魔になる
文章にするとシンプルですが、これらは Tauri の標準APIには無いので、Rust から直接 macOS のネイティブAPIを叩いて設定しています。
落とし穴:毎回再適用が必要
このオーバーレイ設定、ウィンドウを再表示するたびに毎回再適用が必要でした。一度設定したら永続するわけじゃない。なので、レイヤーモードを表示するコマンドの中で毎回呼んでます。
副作用:Mac App Store 配布は不可
これらの設定はAppleが「private API」と呼んでいるカテゴリで、Mac App Storeでの配布は審査で蹴られます。今回は Developer ID 署名 + 公証(notarization)の道を選び、GitHub Releases から配布しています。
加えて、Developer Program に入っていればセキュリティの警告も出ずに配布は出来ます。
RustやTauriのことがわかる人向け補足
実装は
objc2クレートで AppKit を直接叩いてます。コア部分はこんな感じ:// src-tauri/src/main.rs より抜粋 fn apply_macos_overlay_behavior(window: &tauri::WebviewWindow, panel_nonactivating: bool) { let _ = window.with_webview(move |webview| unsafe { let ns_window_ptr = webview.ns_window() as *mut objc2::runtime::AnyObject; if panel_nonactivating { // NSWindow -> NSPanel へクラススワップ if let Some(panel_class) = AnyClass::get(c"NSPanel") { object_setClass(ns_window_ptr, panel_class); } // NonactivatingPanel スタイルマスク追加(フォーカスを奪わない) let current_mask: usize = msg_send![ns_window_ptr, styleMask]; let new_mask = current_mask | (1usize << 7); let _: () = msg_send![ns_window_ptr, setStyleMask: new_mask]; let _: () = msg_send![ns_window_ptr, setFloatingPanel: true]; let _: () = msg_send![ns_window_ptr, setHidesOnDeactivate: false]; } // CanJoinAllSpaces | Stationary | FullScreenAuxiliary let behavior: usize = (1 << 0) | (1 << 4) | (1 << 8); let _: () = msg_send![ns_window_ptr, setCollectionBehavior: behavior]; // 最前面に出すためのウィンドウレベル(NSScreenSaverWindowLevel相当) let level: isize = 1000; let _: () = msg_send![ns_window_ptr, setLevel: level]; }); }設定の中身:
NSWindowをNSPanelにクラススワップ:object_setClassで動的にクラスを差し替えてます。NSPanelは補助ウィンドウ向けのクラスで、これにすることでNonactivatingPanelスタイルマスク(1 << 7)が活きます。これでキーウィンドウになっても親アプリをアクティブ化しないウィンドウになるcollectionBehaviorにCanJoinAllSpaces (1<<0) | Stationary (1<<4) | FullScreenAuxiliary (1<<8):これで他アプリのフルスクリーン Space に帯同しますsetLevel: 1000:NSScreenSaverWindowLevel相当。これで最前面に固定ハマりポイント:
NSPanel化すると WebView がクリックを受け取らなくなるので、layer_ctrl(操作用の小窓)の方はpanel_nonactivating = falseで呼んでクラススワップをスキップしています。透過オーバーレイ本体だけ NSPanel 化、操作用の伴走窓は NSWindow のまま という使い分けが必要でした。設定永続化されない件は、
show_layer_windowコマンドの中でapply_macos_overlay_behaviorを毎回呼ぶことで対応してます。配布は
tauri.conf.jsonのmacOSPrivateApi: true+tauriクレートのmacos-private-apiフィーチャーで有効化。npm run tauri:build:universalで Apple Silicon / Intel ユニバーサルバイナリが一発で吐けます。
【技術コラム・その2】クリックスルーなのにドラッグしたい矛盾
「スライドの上にタイマーが乗ってるのに、タイマー上をクリックしてもスライドの操作ができる」という体験を作りたい。これがレイヤーモードの肝です。
これを「クリックスルー」と言います。マウス操作が透けて、下のウィンドウに届く挙動ですね。Tauriにはこの設定が用意されているので、その有効化は1行で済みました。
なのでクリックスルーはすぐに完成。でも問題が発生しました。
クリックスルー = ユーザーがウィンドウを動かせない
タイマーの位置をユーザーが調整したい時ってあるじゃないですか。「スライドの右下のロゴと被ってるからもう少し左に……」みたいな。クリックを受け取らない以上、ウィンドウ自体もドラッグできないんです。透けて下に行っちゃうので。
解決策:二窓構成にする
最終的な解は「伴走する小さなコントローラーウィンドウを別に出す」でした。
- タイマー本体:透過・クリックスルー・MM:SSだけ表示
- コントローラー(伴走窓):不透明・クリック可能・ドラッグ用のグリップと×ボタンだけ表示
ユーザーが伴走窓をドラッグして動かすと、本体のタイマーがそれに追従して動く。これでクリックスルーを維持したまま位置調整できるようになりました。
さらにハマったポイント:ドラッグがガクガクした
最初は「グリップ要素にJavaScriptで mousedown を仕込んで、そこからドラッグ開始のAPIを呼ぶ」という実装をしました。動かない。正確にはガクガクします。
原因は遅延でした。JavaScript → Rust → OS と経由する間に、最初の一瞬のマウス操作を取りこぼしていたんです。
最終的には Tauri が用意しているHTML属性ベースのドラッグ宣言に切り替えて解決しました。要素に1個属性をつけるだけで、JavaScriptを経由せずブラウザレイヤーで直接OSにドラッグ要求が飛ぶ仕組みです。スッと動くようになりました。
ただ早く動かすとまだ微妙にカクつきますが、まあ許容範囲とすることにしました
教訓:マウス操作のパスにJSの往復を挟むと遅すぎることがある。フレームワークが用意してくれている宣言的APIがあるなら、素直にそれを使った方が良い。
RustやTauriのことがわかる人向け補足
クリックスルーは
WebviewWindow::set_ignore_cursor_events(true)一発:let _ = layer_window.set_ignore_cursor_events(true);二窓構成は
layer(透過オーバーレイ) とlayer_ctrl(操作用の伴走窓)に分けています。layer_ctrlのWindowEvent::Movedを Rust 側でハンドルして、sync_layer_to_ctrlでlayerを相対位置に再配置する構造:// 概略 WindowEvent::Moved(_) => { if window.label() == "layer_ctrl" { sync_layer_to_ctrl(&app); } }ハマりどころのドラッグ問題、最初は JS から
appWindow.startDragging()を呼んでました:// ❌ これだと初回 mousedown を取りこぼしてカクつく grip.addEventListener('mousedown', () => { getCurrentWindow().startDragging(); });透過ウィンドウだと特に顕著で、IPC レイテンシで最初の mousedown と OSドラッグ開始の間にギャップができる。Stack Overflow も似た症状の Issue がいくつかあって、皆 IPC往復の遅さで詰んでいる雰囲気でした。
解決は
data-tauri-drag-region属性に切り替え:<!-- public/layer_ctrl.html --> <div class="grip" data-tauri-drag-region title="Drag to move"></div>Tauri は WebView 側でこの属性を持つ要素の mousedown を直接拾って、ネイティブの
performWindowDragWithEvent:に変換してくれます。JS往復ゼロ。Tauri 2 で透過ウィンドウをドラッグさせるなら、迷わずこっちを使うべきでした。
【技術コラム・その3】キーが2回連続で効かない事件
これはめちゃくちゃ地味ですが結構苦労した部分です。
個人的には一番苦しんだところ。
問題
「V キーで表示モードを切り替える」機能を実装しました。normal → compact → minimal と順送りで切り替わるやつです。(上のGIFアニメ参照)
これが、1回押すと切り替わるが、2回連続で押すと2回目が効かない。
最初は「キーリピート抑制してるからかな?」と思って実装を見直したんですが違いました。フォーカスが外れていたんです。ウィンドウのサイズを変更した瞬間、なぜかウィンドウのフォーカスが消えて、次のキー入力が効かなくなる。
フォーカスを戻そうとしたら、戻ってこない
「じゃあサイズ変更後にフォーカスを戻せばいいだけじゃん」と。Tauri にも JavaScript側にも「ウィンドウにフォーカスを当てる」APIがあります。呼びました。戻ってきません。
呼び方を変えてみたり、表示状態を一度リセットしてから呼んだり、JS側からRust側にお願いしてみたり、色々試しました。全部ダメでした・・・。
Tauri の日本語情報、マジで少ない問題
ここからしばらく地獄を見ました。
検索してもTauri 2 のフォーカス問題に関する日本語記事はほぼ皆無。Stack Overflow も近い症状はあるけど解決策が古いバージョンのものだったり、別のフレームワークの話だったり。GitHubのIssuesを漁っても "works on my machine" 系の応酬で答えが見つからない。
ひたすら コード書き換え → ビルド → 検証 のループ。
これに何時間も溶けました。
Claude Code との壁打ちで突破
正直、自分一人の力では完全に詰んでました。
ここで AIエージェントのClaude Codeと壁打ちを始めました。「フォーカスを戻すAPIを呼んでもmacOSで戻らない、何が起きてる?」と。
何往復かやり取りした結果、以下の結果に辿り着きました:
macOSでは「アプリがアクティブ」「ウィンドウがキーウィンドウ」「中のWebViewがキー入力の受け取り手」の3つが全部別の概念で、それぞれ復元してやらないとキーボードイベントは復活しない。
普通の「フォーカスする」APIは2番目までやってくれるけど、3番目(WebViewをキー入力の受け取り手に戻す)は自分でやる必要がある。
なるほど、めっちゃ複雑やん、と。
解決策:3段階でフォーカスを戻す
最終的な解決策は、フォーカスを「アプリ → ウィンドウ → WebView」の3段階で順番に戻す処理を、macOS のネイティブAPIで自前実装することでした。
実行した瞬間、V キー連打でカチカチカチッとモードが切り替わるようになりました。ガッツポーズしました。マジで嬉しかったです。
この体験から学んだこと
- ニッチな技術領域では、フレームワークの抽象化に頼りすぎない。一段下のレイヤー(macOSの場合は AppKit)の概念を理解しておくと突破できる
- AIとのペアデバッグはこういう場面ではマジで強い。特に日本語情報が少ない領域では、自分の頭の中の情報密度を上げるパートナーとして圧倒的に有効。Claude Codeへの信頼が完全に上がった瞬間でした。
もし同じような「フレームワークのAPIで詰んだ」経験をしている方がいたら、AIに「このAPIはネイティブの何に相当する処理をしている?足りない処理は何?」と聞いてみてください。仕組みのレイヤーを行き来する思考を加速してくれると思います。
RustやTauriのことがわかる人向け補足
試したけどダメだった呼び出しはこの3パターン:
// ❌ 標準のset_focus window.set_focus()?; // ❌ show → focus の順でも変わらず window.show()?; window.set_focus()?;// ❌ JS側からinvokeしても改善せず await invoke('focus_window');結局、Tauri の
set_focusは内部的にはmakeKeyAndOrderFront相当までしかやってくれない。WKWebView をfirstResponderに戻すところまでは面倒見てくれないので、自前で書きました:fn force_focus_window(window: &tauri::WebviewWindow) { let _ = window.show(); #[cfg(target_os = "macos")] { let _ = window.with_webview(|webview| unsafe { let mtm = MainThreadMarker::new_unchecked(); let ns_app = NSApplication::sharedApplication(mtm); // 1. アプリそのものを最前面化 #[allow(deprecated)] ns_app.activateIgnoringOtherApps(true); // 2. ウィンドウをキーウィンドウにして前面化 let ns_window: &objc2_app_kit::NSWindow = &*(webview.ns_window() as *const objc2_app_kit::NSWindow); ns_window.makeKeyAndOrderFront(None); // 3. WKWebView を First Responder に戻す(これが効いた) let wk_webview = webview.inner(); let responder = &*(wk_webview as *const objc2_app_kit::NSResponder); ns_window.makeFirstResponder(Some(responder)); }); } #[cfg(not(target_os = "macos"))] { let _ = window.set_focus(); } }3段構えの中身:
NSApplication::activateIgnoringOtherApps(true)でアプリ自体を強制アクティベート(他アプリを退ける)NSWindow::makeKeyAndOrderFront(nil)でキーウィンドウ化 + 最前面へNSWindow::makeFirstResponder(WKWebView)で WebView を First Responder に戻す ← これが効いた
activateIgnoringOtherAppsは新しめの macOS では deprecated 警告が出ます(代替のactivate(options:)がある)が、Tauri のターゲット環境ではこれで安定動作するので#[allow(deprecated)]で抑止しています。ちなみに、この処理は
set_window_sizeで物理サイズ変更したあとに必ず呼ぶ流れにしてあります(モード切替フロー全体はApp.tsxのtransitionToMode経由)。サイズ変更とフォーカス復元はワンセットで扱うのが Tauri 2 + macOS でのお作法、と理解しています。
UI 設計の苦悩:タイマーは小さくあるべき
技術話が続いたので、最後にデザインの話を少し。
このアプリ、そもそもタイマーは小さくあるべきという思想で作っています。発表中にデカいタイマーが視界を埋めると邪魔ですよね。
なので:
- デフォルト 800x200(横長)
- compact モード 400x200
- minimal モード 200x100
- layer モード(透過オーバーレイ・任意位置・文字の大きさは自由に調整できる)
と3段階で、徐々に小さくできる構成+自由に大きさを決められるレイヤー構成にしました。
ボタンを置く余地がない
minimal が 200x100 です。ボタン置けません。
なので、操作はキーボード中心に振り切りました。
| キー | 動作 |
|---|---|
S / Space / Enter
|
スタート/一時停止 |
R |
リセット |
V |
表示モード切替 |
L |
レイヤーモードトグル |
Cmd+Enter |
フルスクリーン |
| 数字キー | MM:SS の左シフト入力 |
数字キーは「電卓モード」ではなく「4桁を左にシフトしながら詰める」方式にしてます。5 → 0:05 、続けて 0 → 0:50、続けて 3 → 5:03 みたいな。タイマー専用UXとしてはこっちが直感的だなと。
想定ユーザー:つい時間オーバーしてしまうすべての人
このアプリ、開発のきっかけはLT登壇でしたが、使ってみるとこういうシーンでも刺さりそうだと気づきました。
- オンラインミーティングで時間管理する話者:画面共有中、Keynote/PowerPoint/Google Slidesの上に被せられる
- 配信者:OBSの配信画面の上に出して、自分の手元PCには残り時間が見える
- 講師業の方:オンラインセミナーで持ち時間を意識しながら話したい
要するに、「時計を気にしなければいけないが、時計を見る行為がしにくい」全てのシーンに効きます。
おわりに
このアプリは、僕個人の「LTで時間オーバーする」という超ニッチな悩みから始まりました。同じ悩みを持つ人が世の中にどれくらいいるかわかりませんが、少なくとも僕一人は本気で困っていたので作りました。
今後も登壇する場面では、積極的に使っていこうと思います。
2026/5/23 ではオープンソースカンファレンス(OSC)で別アプリ「Bokuchi」の内容で登壇するんですが、そこでも当然 Lightning Timer を実戦投入します。
会場で僕のスライドの隅にタイマーが浮いていたら、それです。
お願い
もし、上記の内容に共感して、「自分もわかる!」となった人はぜひ使ってみてください。
- GitHub Releases からダウンロードしてみてください(macOS / Windows 両方対応しています)
- リポジトリにスターつけてくれると、めちゃくちゃ嬉しいです
- Issue 大歓迎。「こんな機能が欲しい」「ここがイケてない」どちらも。エンジニアのかゆいところに手が届くタイマーツールにしていきたいので、皆さんのリクエストが何より助かります
- SNSでのシェアも嬉しいです。同じく「LTで時間溶かしがちな人」に届くと信じてます
それでは、皆さんのLTがタイムオーバーしないことを祈って!
参考
- Tauri 2 公式ドキュメント
- objc2クレート
- AppKit: NSWindow, NSPanel, NSResponder, NSApplication



