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") };
    • 今度は外側が白になりました

出力を DirectComposition 経由にしたところ。外側が白い四角になる

  • 白いのは、ウィンドウ自身の 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 を直接使ってウィンドウを作れるなら、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” というコメントがあります)。これが線に見えます
  • 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 で反映します
    • 下のクリックとドラッグの対処も、このサブクラスに足していきます

4. すぐ表示する

  • 普段は隠しておき、音量が変わったら(別スレッドで受け取って)すぐ出したいです

  • egui の流儀だと、別スレッドから ctx.send_viewport_cmd(ViewportCommand::Visible(true)) を送って request_repaint() します。ところが、出るまでに毎回約 100ms かかりました

    • eframe は、見えていないウィンドウの再描画を 100ms 間隔に間引きます(INVISIBLE_WINDOW_REPAINT_INTERVAL。隠れたウィンドウが CPU を使い切る問題 egui#7776 の対策)
    • Visible(true) は次のフレームで処理されるので、そのフレームまで待たされます
  • 表示だけは、別スレッドから 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.4ms104.1ms105.0ms
    別スレッドから SetWindowPos2.2ms2.9ms3.8ms
  • ほかに気を付けたこと

    • 隠す前に、全く透明なフレームを 1 回描いておきます。そうしないと、次に表示した瞬間に前回の絵が一瞬出ます
    • 隠れている間の描画で request_repaint() を呼び続けないようにします。隠れていても頼めば 100ms ごとに描画が呼ばれるので、そこでまた頼むと、隠れたまま毎秒 10 回描き続けます(FlexFlyout では、非表示で 30 秒に CPU を 234ms 使っていたのが 0ms になりました)

5. クリックとドラッグ

同じ画素をもう一度押すと捨てられる

  • ボタンを押して、ポインターをウィンドウの外へ出し、全く同じ画素に戻ってもう一度押すと、効きません

    • 手で操作していても、たまに「押したのに効かない」として起きることがあります
  • 2 つの振る舞いが重なっています

    • winit は、ポインターが出たとき(WM_MOUSELEAVE)に最後の位置を消しません。次に同じ位置へ動いても、位置が変わっていないとして移動を知らせません
    • egui-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 でも同じ条件がかかります)
    • フォーカスを奪わないように作ったウィンドウは、フォーカスを持つことがないので、何も起きません
  • タイトルバーをつかんだときと同じ移動を、自分で始めます

    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 を渡します(位置はクライアント座標)
    • ボタンやバーの上では始めず、押したあと少し動かしてから始めるようにしました

まとめ

  • eframe には描画と入力を任せ、ウィンドウの属性と表示のタイミングだけを Win32 で補いました。補った原因は、どれも winit・egui-winit の作りです
  • 今の版(eframe 0.37・winit 0.30)での話です。版を上げたら、補ったところを外して、まだ要るか確かめるつもりです
  • FlexFlyout の実装は window.rs にまとまっています

お疲れ様でした!