egui で透明・最前面・フォーカスを奪わないウィンドウを Windows で作る
この記事にはEnglish版もあります。
目次
音量キーを押したときに、再生中の曲の情報を出すアプリ(FlexFlyout)を、Rust の egui(eframe)で作りました。 この表示のウィンドウには、Windows 標準の音量表示と同じように「外側が透ける」「最前面に出る」「作業中のアプリからフォーカスを奪わない」「すぐ出る」が要ります。 eframe の指定だけでは足りなかったので、Win32 を直接呼んで補ったところをまとめておきます。

求めた条件と、実現した方法
| 求めた条件 | eframe の指定だけだと | 補った方法 |
|---|---|---|
| 角丸の板の外側が透ける | 黒か白になる | DirectComposition 経由で出力し、背景のブラシを黒にする |
| 枠や影が無い | 上端に 1 ピクセルの白い線、周りに枠と薄い影 | winit の影を切る |
| クリックしてもフォーカスを奪わない | しばらくすると奪うようになる | サブクラスで拡張スタイルを守る |
| 音量キーを押したらすぐ出る | 毎回約 100ms 待つ | 別スレッドから Win32 で直接表示する |
| ボタンを押せば必ず効く | 同じ画素をもう一度押すと捨てられる | ポインターが出たときに、画面外への移動を渡す |
| 背景をつかんで動かせる | StartDrag が何もしない | タイトルバーをつかんだときと同じ移動を自分で始める |
確かめた環境
- eframe・egui・egui-winit 0.37.0、winit 0.30.13、wgpu 30.0.1、windows クレート 0.62
- Windows 11 Enterprise 評価版 26H2 の仮想マシン(GPU なし。DirectX 12 はソフトウェアの描画〔WARP〕で動いています)。ドラッグは実機でも確かめました
- 透明と線の画像・表示までの時間は、記事用に切り出した短いプログラムで撮り直し、測り直したものです(2026-11-12)
- 仮想マシンでの確認は、Claude Code に操作させて行いました(やり方は前の記事)
eframe で指定したこと
まず、eframe で指定できるものは全部指定します。
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([420.0, 120.0])
.with_decorations(false) // 枠なし
.with_transparent(true) // 透明
.with_always_on_top() // 最前面
.with_taskbar(false) // タスクバーに出さない
.with_active(false) // 作ったときに前面にしない
.with_resizable(false),
..Default::default()
};App::clear_colorでは[0.0; 4](全く透明)を返し、角丸の板だけを描きます- 以下の Win32 の設定は、eframe の
CreationContextからウィンドウのハンドル(HWND)を取って行います(raw-window-handleのHasWindowHandleで取れます)
1. 外側を透けさせる
上の指定だけだと、外側は黒になります。

wgpu の DirectX 12 の既定の出力(
Dx12SwapchainKind::DxgiFromHwnd)は、透明に対応していません- wgpu-types のコメントにも “This does not support transparency” と書いてあります
出力を DirectComposition 経由(
DxgiFromVisual)に変えます。wgpu が初期化される前に、環境変数で指定します// main の先頭で(ほかのスレッドを作る前に) // SAFETY: まだほかのスレッドは無い unsafe { std::env::set_var("WGPU_DX12_PRESENTATION_SYSTEM", "Visual") };- 今度は外側が白になりました

白いのは、ウィンドウ自身の GDI の描画面(リダイレクション サーフェス)が下に残っているためです
- この描画面を作らせない
WS_EX_NOREDIRECTIONBITMAPは、ウィンドウを作るときにしか付けられません。winit には指定(with_no_redirection_bitmap)がありますが、eframe からは渡せません
- この描画面を作らせない
そこで、ウィンドウクラスの背景のブラシを黒にします
// windows クレート 0.62 SetClassLongPtrW(hwnd, GCLP_HBRBACKGROUND, GetStockObject(BLACK_BRUSH).0 as isize);- winit は透明を指定したウィンドウに
DwmEnableBlurBehindWindowを呼んでいて(空の領域なので、ぼかしは掛かりません)、こうしたウィンドウでは GDI が塗った黒が透明として扱われます - これで外側が透けました。ただし、周りに灰色の枠と薄い影が残っています
- winit は透明を指定したウィンドウに

- 古い仕組みに頼った方法なので、winit を直接使ってウィンドウを作れるなら、
with_no_redirection_bitmap(true)の方が素直だと思います(自分は試していません)
2. 枠・影・上端の線を消す
- 灰色の枠は、Windows 11 がウィンドウに付ける枠です。
DwmSetWindowAttributeでDWMWA_BORDER_COLORをDWMWA_COLOR_NONEにすると消えます - ところが、枠を消しても、上端の白い 1 ピクセルの線と薄い影が残ります

これは winit の影のためです
- egui-winit は、枠なしのウィンドウには必ず影を頼みます(
with_undecorated_shadow)。ViewportBuilderからは切れません - winit は Windows で影を出すために、ウィンドウの上端に 1 ピクセルの枠の領域を残します(winit のソースにも “This leads to a small black 1px border on the top” というコメントがあります)。これが線に見えます
- egui-winit は、枠なしのウィンドウには必ず影を頼みます(
winit のウィンドウを取って影を切り、枠を計算し直させます
// eframe の App を作るところで if let Some(window) = cc.winit_window() { use winit::platform::windows::WindowExtWindows as _; window.set_undecorated_shadow(false); } // 枠を計算し直させる(これが無いと、枠の領域が残ったまま) SetWindowPos(hwnd, None, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED)?;winitを依存に足す必要があります(eframe と同じ版、default-features = falseで足ります)- 線・影・灰色の枠がまとめて消えて、冒頭の完成形になりました。この環境では、影を切れば枠の色の指定は要りませんでした(FlexFlyout では念のため両方しています)
影が全部消えるので、普通の(透けない)枠なしのウィンドウで影が欲しいときは使えません
3. フォーカスを奪わない
with_active(false)は、作ったときに前面にしないだけです。クリックすると、前面のアプリからフォーカスを奪いますそこで、作った後に拡張スタイルの
WS_EX_NOACTIVATE(クリックでも前面にならない)とWS_EX_TOOLWINDOW(Alt+Tab に出ない)を足し、winit が付けるWS_EX_APPWINDOW(タスクバーにボタンを出す)を外しましたこれで最初は効きますが、しばらくすると、またクリックでフォーカスを奪うようになります
- winit は、自分が持つウィンドウの状態(表示・最前面など)が変わるたびに、スタイルと拡張スタイルを自分の値で丸ごと書き直します。外から足したものは、そのたびに消えます
ウィンドウをサブクラス化(
SetWindowSubclass)して、スタイルが書き直されるたびに足し戻しますconst FLYOUT_EX_STYLE: WINDOW_EX_STYLE = WINDOW_EX_STYLE(WS_EX_NOACTIVATE.0 | WS_EX_TOOLWINDOW.0); // SetWindowSubclass(hwnd, Some(keep_flyout_styles), 1, 0) で、 // winit の手続きの前に入れる unsafe extern "system" fn keep_flyout_styles( hwnd: HWND, message: u32, wparam: WPARAM, lparam: LPARAM, _id: usize, _data: usize, ) -> LRESULT { match message { // クリックで前面にしない(スタイルを書き換えている途中でも) WM_MOUSEACTIVATE => return LRESULT(MA_NOACTIVATE as isize), // 拡張スタイルが変わる直前に、要るものを足し、要らないものを外す WM_STYLECHANGING if wparam.0 as i32 == GWL_EXSTYLE.0 => { let styles = unsafe { (lparam.0 as *mut STYLESTRUCT).as_mut() }; if let Some(styles) = styles { styles.styleNew = (styles.styleNew | FLYOUT_EX_STYLE.0) & !WS_EX_APPWINDOW.0; } } _ => {} } unsafe { DefSubclassProc(hwnd, message, wparam, lparam) } }- 最初の1回は、
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, …)で自分で足し、上と同じSWP_FRAMECHANGED付きのSetWindowPosで反映します - 下のクリックとドラッグの対処も、このサブクラスに足していきます
- 最初の1回は、
4. すぐ表示する
普段は隠しておき、音量が変わったら(別スレッドで受け取って)すぐ出したいです
egui の流儀だと、別スレッドから
ctx.send_viewport_cmd(ViewportCommand::Visible(true))を送ってrequest_repaint()します。ところが、出るまでに毎回約 100ms かかりました- eframe は、見えていないウィンドウの再描画を 100ms 間隔に間引きます(
INVISIBLE_WINDOW_REPAINT_INTERVAL。隠れたウィンドウが CPU を使い切る問題 egui#7776 の対策) Visible(true)は次のフレームで処理されるので、そのフレームまで待たされます
- eframe は、見えていないウィンドウの再描画を 100ms 間隔に間引きます(
表示だけは、別スレッドから Win32 で直接行うことにしました。見えるようになれば間引きは外れて、eframe は普通に描きます
// HWND はポインターで Send でないので、isize にして別スレッドへ渡しておく sender.send(message)?; // 先に中身を送ってから unsafe { SetWindowPos(HWND(raw as *mut c_void), Some(HWND_TOPMOST), x, y, width, height, SWP_NOACTIVATE | SWP_SHOWWINDOW | SWP_ASYNCWINDOWPOS) }?; ctx.request_repaint(); // egui を起こすSWP_ASYNCWINDOWPOSは、ウィンドウのスレッドに処理を頼むだけで待ちません。UI のスレッドが忙しくても、呼んだ側は止まりません- 隠すのは UI のスレッドから
ShowWindow(hwnd, SW_HIDE)で行います。winit は表示の状態を毎回IsWindowVisibleで読むので、外から出し入れしても eframe の判断と食い違いません
頼んでから、見えた状態で最初のフレームを描くまでの時間を、20 回ずつ測りました
表示のしかた 最小 平均 最大 egui の Visible(true)103.4ms 104.1ms 105.0ms 別スレッドから SetWindowPos2.2ms 2.9ms 3.8ms ほかに気を付けたこと
- 隠す前に、全く透明なフレームを 1 回描いておきます。そうしないと、次に表示した瞬間に前回の絵が一瞬出ます
- 隠れている間の描画で
request_repaint()を呼び続けないようにします。隠れていても頼めば 100ms ごとに描画が呼ばれるので、そこでまた頼むと、隠れたまま毎秒 10 回描き続けます(FlexFlyout では、非表示で 30 秒に CPU を 234ms 使っていたのが 0ms になりました)
5. クリックとドラッグ
同じ画素をもう一度押すと捨てられる
ボタンを押して、ポインターをウィンドウの外へ出し、全く同じ画素に戻ってもう一度押すと、効きません
- 手で操作していても、たまに「押したのに効かない」として起きることがあります
2 つの振る舞いが重なっています
- winit は、ポインターが出たとき(
WM_MOUSELEAVE)に最後の位置を消しません。次に同じ位置へ動いても、位置が変わっていないとして移動を知らせません - egui-winit は、ポインターが出ると位置を捨て、位置が分からない間に来たボタンの押下を捨てます
- winit は、ポインターが出たとき(
サブクラスで、
WM_MOUSELEAVEを winit に渡す前に、画面外の座標へのWM_MOUSEMOVEを渡します。winit の最後の位置が変わるので、戻ってきたときには必ず移動が知らされます// windows クレートでは大きな Win32_UI_Controls にしか無いので、自分で定義 const WM_MOUSELEAVE: u32 = 0x02A3; // keep_flyout_styles の match に足す WM_MOUSELEAVE => { // (-32768, -32768): 決してウィンドウの中にならない位置 let outside = (i16::MIN as u16 as u32) | ((i16::MIN as u16 as u32) << 16); unsafe { DefSubclassProc(hwnd, WM_MOUSEMOVE, WPARAM(0), LPARAM(outside as isize)) }; // このあと、本来の WM_MOUSELEAVE も winit に渡す }
StartDrag が何もしない
背景をつかんで動かせるように、egui の
ctx.send_viewport_cmd(ViewportCommand::StartDrag)を送っても、何も起きません。エラーも出ません- egui-winit は、ウィンドウにフォーカスがあるときだけ winit の
drag_window()を呼びます(X11 での不具合の対策ですが、Windows でも同じ条件がかかります) - フォーカスを奪わないように作ったウィンドウは、フォーカスを持つことがないので、何も起きません
- egui-winit は、ウィンドウにフォーカスがあるときだけ winit の
タイトルバーをつかんだときと同じ移動を、自分で始めます
let mut point = POINT::default(); unsafe { let _ = GetCursorPos(&mut point); let _ = ReleaseCapture(); let position = (point.x as i16 as u16 as u32) | ((point.y as i16 as u16 as u32) << 16); // このフレームの描画が終わってから始まるよう、Post で送る let _ = PostMessageW(Some(hwnd), WM_NCLBUTTONDOWN, WPARAM(HTCAPTION as usize), LPARAM(position as isize)); }- Windows の移動のループは、ボタンを離したこと(
WM_LBUTTONUP)を飲み込みます。そのままだと、egui はボタンが押されたままだと思い続けます - なので、サブクラスで移動の終わり(
WM_EXITSIZEMOVE)を受けたら、winit の手続きにWM_LBUTTONUPを渡します(位置はクライアント座標) - ボタンやバーの上では始めず、押したあと少し動かしてから始めるようにしました
- Windows の移動のループは、ボタンを離したこと(
まとめ
- eframe には描画と入力を任せ、ウィンドウの属性と表示のタイミングだけを Win32 で補いました。補った原因は、どれも winit・egui-winit の作りです
- 今の版(eframe 0.37・winit 0.30)での話です。版を上げたら、補ったところを外して、まだ要るか確かめるつもりです
- FlexFlyout の実装は window.rs にまとまっています
お疲れ様でした!