Markdown エディター/ビューアー Marxdown の開発中に遭遇した問題と、その対処についての話です。
TL;DR
Tauri で decorations: false を使ってタイトルバーを自作すると、Windows 11 の Snap Layouts のフライアウト↓が表示されなくなります。
原因は、Windows が最大化ボタンの判定に使う WM_NCHITTEST が、親ウィンドウではなくカーソル直下の最も深いウィンドウに対して行われるためです。
そこで、最大化ボタンの位置に透明な子ウィンドウを置き、HTMAXBUTTON を返す方法で解決しました。
はじめに
Marxdown では、タイトルバーもアプリのデザインに合わせて自作したかったため、Tauri の decorations: false を使っています。
ところが、Windows 標準のタイトルバーを外すと、ウィンドウ最大化にマウスを乗せても、Snap Layouts のフライアウトが表示されなくなります。
私はこの機能を使ってウィンドウを整理することが多く、自分で Marxdown を試していて、非常に不便に感じました。
簡単に対応できるかなと思っていたのですが、一筋縄ではいきませんでした。
原因 - 親ウィンドウの HTMAXBUTTON は見てもらえない
Windows が Snap Layouts を表示するとき、最大化ボタンかどうかを WM_NCHITTEST の結果から判断します。
最大化ボタンなら、HTMAXBUTTON を返します。
最初は単純に、親ウィンドウ → WM_NCHITTEST → HTMAXBUTTON とすればよいと思い、以下のように親ウィンドウをサブクラス化して、HTMAXBUTTON を返すようにしてみましたが、Snap Layouts は表示されませんでした。
unsafe extern "system" fn parent_proc(...) -> LRESULT {
match msg {
WM_NCHITTEST => LRESULT(HTMAXBUTTON as isize), // ★
_ => DefSubclassProc(hwnd, msg, wparam, lparam, id, data),
}
}
なぜか
Tauri のウィンドウでは、画面上で実際にカーソルの下にいるのは親ウィンドウではなく WebView2 の子ウィンドウです。
そのため、親ウィンドウが HTMAXBUTTON を返しても、Windows がそれを見ないので Snap Layouts は表示されません。
カーソル
↓
WebView2
↓
HTCLIENT ← Windows がこちらを見る
↓
親ウィンドウ
↓
HTMAXBUTTON ← ここは見てもらえない
解決策 - カーソル直下に「最大化ボタン」を作る
親ウィンドウに答えさせるのではなく、カーソル直下のウィンドウ自身に HTMAXBUTTON を返させます。
具体的には、最大化ボタンの位置に透明な WS_CHILD ウィンドウを配置します。
カーソル
↓
透明なオーバーレイ
↓
HTMAXBUTTON ← Windows がこれを見て Snap Layouts 表示
実装のコアロジック
// 1. ウィンドウクラスを登録
let class = WNDCLASSEXW {
lpfnWndProc: Some(overlay_proc),
hbrBackground: HBRUSH(
unsafe { GetStockObject(NULL_BRUSH) }.0
),
lpszClassName: OVERLAY_CLASS,
..Default::default()
};
unsafe { RegisterClassExW(&class) };
// 2. 最大化ボタンの位置に子ウィンドウを作成
CreateWindowExW(
WINDOW_EX_STYLE(0),
OVERLAY_CLASS,
PCWSTR::null(),
WS_CHILD | WS_CLIPSIBLINGS, // ★子ウィンドウ
x, y, width, height, // ボタンの座標
Some(parent), // 親を指定
None, None, Some(raw),
)
// 3. オーバーレイのプロシージャ内
unsafe extern "system" fn overlay_proc(...) -> LRESULT {
match msg {
// ★ HTMAXBUTTON を返す → Windows に伝わる → Snap Layouts のフライアウトが表示される!
WM_NCHITTEST => LRESULT(HTMAXBUTTON as isize),
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
}
}
なお、この実装方法は、tauri-plugin-frame、tauri-plugin-decoration といった Tauri 向けのプラグインでも採用されているようですが、Tauri 本体にはまだ組み込まれていません(GitHub issue: tauri#4531)。
そのほかに必要な考慮
WebView 側にマウスイベントを届ける
オーバーレイが最大化ボタンの上にいるため、その領域には WebView 側のマウスイベントが届きません。
そこで、Win32 側でホバーを検出して、フロントエンドに状態を通知します。
WM_NCMOUSEMOVE => {
target.set_hovered(hwnd, true);
LRESULT(0)
}
WM_NCLBUTTONUP => {
if target.pressed.swap(false, Ordering::Relaxed) {
let _ = PostMessageW(
Some(parent),
WM_SYSCOMMAND,
WPARAM(SC_MAXIMIZE as usize),
LPARAM(0),
);
}
LRESULT(0)
}
// ホバー時に領域外へ出たことを知らせてもらう
fn track_leave(hwnd: HWND) {
let mut tme = TRACKMOUSEEVENT {
cbSize: std::mem::size_of::<TRACKMOUSEEVENT>() as u32,
dwFlags: TME_LEAVE | TME_NONCLIENT, // ★非クライアント領域対応
hwndTrack: hwnd,
dwHoverTime: 0,
};
let _ = unsafe { TrackMouseEvent(&mut tme) };
}
フロントエンドから最大化ボタンの矩形が通知されてから表示する
オーバーレイを作った直後から表示すると、最大化ボタンの位置がまだ確定していない場合に、間違った場所をクリックできなくしてしまいます。
そこで、作成時には WS_VISIBLE を付けません。
// CreateWindowExW に WS_VISIBLE を付けない
let created = unsafe {
CreateWindowExW(
WINDOW_EX_STYLE(0),
OVERLAY_CLASS,
...,
WS_CHILD | WS_CLIPSIBLINGS, // ★WS_VISIBLE なし
...,
)
};
// フロントエンドから矩形情報が届いて reposition() が呼ばれた時点で表示
SetWindowPos(
overlay,
Some(HWND_TOP),
px(x), px(y), px(width), px(height),
SWP_ASYNCWINDOWPOS | SWP_NOACTIVATE | SWP_SHOWWINDOW, // ★ここで表示
);
DPI とスレッドに注意する
WebView から取得した座標と Win32 の座標系が異なるため、DPI スケーリングも考慮します。
let dpi = unsafe { GetDpiForWindow(parent) };
let scale = if dpi == 0 { 1.0 } else { f64::from(dpi) / 96.0 };
let px = |v: f64| (v * scale).round() as i32;
let physical_x = px(css_x);
let physical_y = px(css_y);
また、Tauri の IPC から直接 UI スレッドのウィンドウを操作すると、ハングする可能性があります。
そのため SetWindowPos では、SWP_ASYNCWINDOWPOS を使っています。
// set_target() は IPC スレッドから呼ばれる
// UI スレッドが持つウィンドウを同期で操作するとハング
pub fn set_target(app: &tauri::AppHandle, label: &str, x: f64, y: f64, width: f64, height: f64) {
let Some(target) = SnapTargets::get(app, label) else { return; };
target.store_rect(x, y, width, height);
if let Some(overlay) = target.overlay() {
if let Ok(parent) = unsafe { GetParent(overlay) } {
target.reposition(parent);
// SetWindowPos 内で SWP_ASYNCWINDOWPOS を付ける
}
}
}
Rust の Arc を Win32 ウィンドウに渡す
もう一つ、実装中に特に気を付けたのが Win32 のウィンドウプロシージャと Rust の所有権の橋渡しです。
今回の実装では Arc を lpCreateParams 経由で渡しています。
// 1. 作成時:Arc をポインタに変換
let raw = Arc::into_raw(Arc::clone(&target)) as *const c_void;
// 2. 格納:CreateWindowExW の lpCreateParams に渡す
CreateWindowExW(..., Some(raw), ...)
// 3. 利用:WM_NCCREATE で取得 → プロシージャで使用
if msg == WM_NCCREATE {
let create = lparam.0 as *const CREATESTRUCTW;
SetWindowLongPtrW(hwnd, GWLP_USERDATA, (*create).lpCreateParams as isize);
}
let data = GetWindowLongPtrW(hwnd, GWLP_USERDATA);
let target = &*(data as *const SnapTarget);
// 4. 破棄時:参照を外した後に解放(順序重要!)
WM_NCDESTROY => {
SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); // ★先に外す
let result = DefWindowProcW(hwnd, msg, wparam, lparam);
drop(Arc::from_raw(data as *const SnapTarget)); // ★後に破棄
result
}
破棄順に注意が必要で、先に GWLP_USERDATA からポインタを外してから Arc を解放します。
ここを間違えると、メモリリークや Use-After-Free の原因になります。
制約
ウィンドウ上端付近では効かない
ウィンドウ上端の数 px は、親ウィンドウ側が HTTOP などのリサイズ領域として扱います。
そのため Snap Layouts の判定領域として使えない部分があります。
ホバーとクリックは自前で処理する必要がある
WebView側にマウスイベントを届ける の通り、オーバーレイが被った領域には WebView のマウスイベントが届かないため、WebView 側に届ける自前実装が必要になります。
最後に
Marxdown では、こういったところも含めて、Windows 上で気持ちよく Markdown を扱えるエディター/ビューアーを目指して開発しています。
興味があれば、ぜひ触ってみてください。
フィードバックやリクエスト、バグ報告などをいただけると大変うれしいです。
Marxdown — Markdown を見る・書くなら、これ。
