mod input;
mod layout;
pub mod legacy;
mod render;
use crate::theme::{menu_theme, MenuTheme};
use layout::hit_test;
use std::cell::RefCell;
use std::ptr::null_mut;
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
use windows_sys::Win32::Graphics::Gdi::{
BeginPaint, CreatePen, CreateSolidBrush, DeleteObject, EndPaint, HBRUSH, HFONT, HPEN,
PAINTSTRUCT, PS_SOLID,
};
use windows_sys::Win32::Graphics::Gdi::{MonitorFromPoint, MONITOR_DEFAULTTONEAREST};
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
ReleaseCapture, SetCapture, VK_DOWN, VK_ESCAPE, VK_LEFT, VK_LMENU, VK_LWIN, VK_MENU, VK_RETURN,
VK_RIGHT, VK_RMENU, VK_RWIN, VK_SPACE, VK_UP,
};
use windows_sys::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, CreateWindowExW, DefWindowProcW, DestroyWindow, KillTimer, LoadCursorW,
PostMessageW, SetCursor, SetForegroundWindow, SetWindowsHookExW, ShowWindow,
UnhookWindowsHookEx, HHOOK, IDC_ARROW, MSLLHOOKSTRUCT, SW_SHOWNOACTIVATE, WH_MOUSE_LL, WM_APP,
WM_CAPTURECHANGED, WM_DESTROY, WM_ERASEBKGND, WM_KEYDOWN, WM_LBUTTONDOWN, WM_LBUTTONUP,
WM_MBUTTONDOWN, WM_MOUSEMOVE, WM_PAINT, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_TIMER,
WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP,
};
use winspaces_common::log_info;
use winspaces_win32::dpi::{px, scale_for_point, work_area};
use winspaces_win32::dwm::{
extend_frame_full, set_backdrop, set_dark_mode, set_round_corners, Backdrop,
};
use winspaces_win32::gdi::font::{create_font, FACE_ICONS, FACE_TEXT};
use winspaces_win32::module::{app_instance, win_build};
use winspaces_win32::text::encode_wide;
use winspaces_win32::window_class::register_class;
const MENU_CLASS_NAME: &str = "WinSpacesMenu";
const WM_MENU_CLOSE: u32 = WM_APP + 41;
const TIMER_OPEN_SUB: usize = 1;
const TIMER_CLOSE_SUB: usize = 2;
const ROW_H: i32 = 38;
const HEADER_H: i32 = 30;
const SEP_H: i32 = 9;
const PAD_V: i32 = 6;
const HOVER_INSET: i32 = 5;
const HOVER_RADIUS: i32 = 8;
const ICON_X: i32 = 14;
const TEXT_X: i32 = 46;
const LABEL_GAP: i32 = 28;
const RIGHT_PAD: i32 = 16;
const CHEVRON_W: i32 = 22;
const MIN_W: i32 = 230;
const MAX_W: i32 = 520;
const BG_ALPHA: u32 = 232;
pub enum MenuEntry {
Header(String),
Separator,
Item(MenuItemData),
}
pub struct MenuItemData {
pub id: usize,
pub glyph: Option<u16>,
pub label: String,
pub shortcut: Option<String>,
pub checked: bool,
pub submenu: Option<Vec<MenuEntry>>,
}
impl MenuEntry {
pub(crate) fn is_selectable(&self) -> bool {
matches!(self, MenuEntry::Item(_))
}
}
#[derive(Clone, Copy)]
pub(crate) struct Row {
pub(crate) top: i32,
pub(crate) height: i32,
}
pub(crate) struct MenuWindow {
pub(crate) hwnd: HWND,
pub(crate) x: i32,
pub(crate) y: i32,
pub(crate) width: i32,
pub(crate) height: i32,
pub(crate) entries: Vec<MenuEntry>,
pub(crate) rows: Vec<Row>,
pub(crate) hover: Option<usize>,
pub(crate) sel: Option<usize>,
}
pub(crate) struct Fonts {
pub(crate) text: HFONT,
pub(crate) small: HFONT,
pub(crate) glyph: HFONT,
pub(crate) glyph_small: HFONT,
}
pub(crate) struct Paints {
pub(crate) hover_brush: HBRUSH,
pub(crate) hover_pen: HPEN,
pub(crate) sep_brush: HBRUSH,
}
pub(crate) struct MenuState {
pub(crate) owner: HWND,
pub(crate) root: MenuWindow,
pub(crate) sub: Option<MenuWindow>,
pub(crate) sub_parent: Option<usize>,
pub(crate) pending_sub: Option<usize>,
pub(crate) fonts: Fonts,
pub(crate) paints: Paints,
pub(crate) scale: f32,
pub(crate) acrylic: bool,
pub(crate) light: bool,
pub(crate) theme: MenuTheme,
pub(crate) sub_delay_ms: u32,
pub(crate) work: RECT,
pub(crate) mouse_hook: HHOOK,
}
thread_local! {
pub(crate) static MENU_STATE: RefCell<Option<MenuState>> = const { RefCell::new(None) };
}
pub fn is_menu_open() -> bool {
MENU_STATE.with(|s| s.try_borrow().map(|st| st.is_some()).unwrap_or(false))
}
pub(crate) fn root_hwnd() -> HWND {
MENU_STATE.with(|s| {
s.try_borrow()
.ok()
.and_then(|st| st.as_ref().map(|st| st.root.hwnd))
.unwrap_or(null_mut())
})
}
pub fn forward_key(vk: u32) -> bool {
let hwnd = root_hwnd();
if hwnd.is_null() {
return false;
}
match vk as u16 {
VK_ESCAPE | VK_UP | VK_DOWN | VK_LEFT | VK_RIGHT | VK_RETURN | VK_SPACE => unsafe {
PostMessageW(hwnd, WM_KEYDOWN, vk as usize, 0);
true
},
VK_LWIN | VK_RWIN | VK_MENU | VK_LMENU | VK_RMENU => unsafe {
PostMessageW(hwnd, WM_MENU_CLOSE, 0, 0);
true
},
_ => false,
}
}
pub fn handle_owner_deactivate() {
let hwnd = root_hwnd();
if !hwnd.is_null() {
unsafe {
PostMessageW(hwnd, WM_MENU_CLOSE, 0, 0);
}
}
}
unsafe extern "system" fn mouse_ll_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
if code >= 0 {
let msg = wparam as u32;
if msg == WM_LBUTTONDOWN || msg == WM_RBUTTONDOWN || msg == WM_MBUTTONDOWN {
let info = &*(lparam as *const MSLLHOOKSTRUCT);
let pt = info.pt;
let dismiss = MENU_STATE.with(|s| {
s.try_borrow()
.ok()
.and_then(|b| {
b.as_ref()
.map(|state| hit_test(state, pt) == layout::Hit::Outside)
})
.unwrap_or(false)
});
if dismiss {
let hwnd = root_hwnd();
if !hwnd.is_null() {
PostMessageW(hwnd, WM_MENU_CLOSE, 0, 0);
}
return 1;
}
}
}
CallNextHookEx(null_mut(), code, wparam, lparam)
}
pub fn close_menu() {
unsafe {
let state = MENU_STATE.with(|s| s.borrow_mut().take());
if let Some(state) = state {
if !state.mouse_hook.is_null() {
UnhookWindowsHookEx(state.mouse_hook);
}
KillTimer(state.root.hwnd, TIMER_OPEN_SUB);
KillTimer(state.root.hwnd, TIMER_CLOSE_SUB);
ReleaseCapture();
if let Some(sub) = &state.sub {
DestroyWindow(sub.hwnd);
}
DestroyWindow(state.root.hwnd);
DeleteObject(state.fonts.text as _);
DeleteObject(state.fonts.small as _);
DeleteObject(state.fonts.glyph as _);
DeleteObject(state.fonts.glyph_small as _);
DeleteObject(state.paints.hover_brush as _);
DeleteObject(state.paints.hover_pen as _);
DeleteObject(state.paints.sep_brush as _);
}
}
}
unsafe fn create_paints(theme: &MenuTheme) -> Paints {
Paints {
hover_brush: CreateSolidBrush(theme.hover),
hover_pen: CreatePen(PS_SOLID, 1, theme.hover),
sep_brush: CreateSolidBrush(theme.separator),
}
}
unsafe fn delete_fonts(fonts: &Fonts) {
DeleteObject(fonts.text as _);
DeleteObject(fonts.small as _);
DeleteObject(fonts.glyph as _);
DeleteObject(fonts.glyph_small as _);
}
unsafe fn create_fonts(scale: f32) -> Fonts {
Fonts {
text: create_font(FACE_TEXT, -px(scale, 15), 400),
small: create_font(FACE_TEXT, -px(scale, 12), 400),
glyph: create_font(FACE_ICONS, -px(scale, 16), 400),
glyph_small: create_font(FACE_ICONS, -px(scale, 12), 400),
}
}
pub fn show(owner: HWND, entries: Vec<MenuEntry>, anchor: POINT) {
if win_build() >= 22000 {
show_custom(owner, entries, anchor);
} else {
legacy::show(owner, &entries, anchor);
}
}
#[allow(clippy::not_unsafe_ptr_arg_deref)]
fn show_custom(owner: HWND, entries: Vec<MenuEntry>, anchor: POINT) {
unsafe {
close_menu();
let hmon = MonitorFromPoint(anchor, MONITOR_DEFAULTTONEAREST);
let scale = scale_for_point(anchor);
let work = work_area(hmon).unwrap_or(RECT {
left: 0,
top: 0,
right: 0,
bottom: 0,
});
let fonts = create_fonts(scale);
let (rows, width, height) = layout::layout_window(&entries, &fonts, scale);
let mut x = anchor.x;
if x + width > work.right {
x = anchor.x - width;
}
x = x.clamp(work.left, (work.right - width).max(work.left));
let mut y = anchor.y;
if y + height > work.bottom {
y = anchor.y - height;
}
y = y.clamp(work.top, (work.bottom - height).max(work.top));
let acrylic = win_build() >= 22621;
let light = crate::theme::resolves_light(crate::theme::load_pref());
let hwnd = create_menu_window(owner, x, y, width, height, acrylic, light);
if hwnd.is_null() {
log_info!("Failed to create custom menu window");
delete_fonts(&fonts);
return;
}
let mouse_hook = SetWindowsHookExW(
WH_MOUSE_LL,
Some(mouse_ll_proc),
windows_sys::Win32::System::LibraryLoader::GetModuleHandleW(null_mut()),
0,
);
let theme = menu_theme(light);
let paints = create_paints(&theme);
let sub_delay_ms = input::submenu_show_delay();
MENU_STATE.with(|s| {
*s.borrow_mut() = Some(MenuState {
owner,
root: MenuWindow {
hwnd,
x,
y,
width,
height,
entries,
rows,
hover: None,
sel: None,
},
sub: None,
sub_parent: None,
pending_sub: None,
fonts,
paints,
scale,
acrylic,
light,
theme,
sub_delay_ms,
work,
mouse_hook,
});
});
SetForegroundWindow(owner);
ShowWindow(hwnd, SW_SHOWNOACTIVATE);
SetCapture(hwnd);
SetCursor(LoadCursorW(null_mut(), IDC_ARROW));
}
}
pub(crate) unsafe fn create_menu_window(
owner: HWND,
x: i32,
y: i32,
width: i32,
height: i32,
acrylic: bool,
light: bool,
) -> HWND {
register_class(MENU_CLASS_NAME, Some(menu_wnd_proc));
let class_name = encode_wide(MENU_CLASS_NAME);
let hwnd = CreateWindowExW(
WS_EX_TOOLWINDOW | WS_EX_TOPMOST | WS_EX_NOACTIVATE,
class_name.as_ptr(),
std::ptr::null(),
WS_POPUP,
x,
y,
width,
height,
owner,
null_mut(),
app_instance(),
null_mut(),
);
if hwnd.is_null() {
return hwnd;
}
set_dark_mode(hwnd, !light);
set_round_corners(hwnd);
if acrylic {
extend_frame_full(hwnd);
set_backdrop(hwnd, Backdrop::Acrylic);
}
hwnd
}
unsafe fn on_paint(hwnd: HWND) {
let mut ps: PAINTSTRUCT = std::mem::zeroed();
let hdc = BeginPaint(hwnd, &mut ps);
MENU_STATE.with(|s| {
let borrow = s.borrow();
let Some(state) = borrow.as_ref() else {
return;
};
if hwnd == state.root.hwnd {
render::render_window(&state.root, state, hdc, &ps.rcPaint);
} else if let Some(sub) = &state.sub {
if hwnd == sub.hwnd {
render::render_window(sub, state, hdc, &ps.rcPaint);
}
}
});
EndPaint(hwnd, &ps);
}
unsafe extern "system" fn menu_wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match msg {
WM_ERASEBKGND => 1,
WM_PAINT => {
on_paint(hwnd);
0
}
WM_MOUSEMOVE => {
input::on_mouse_move();
0
}
WM_LBUTTONDOWN | WM_RBUTTONDOWN => {
input::on_mouse_down();
0
}
WM_LBUTTONUP | WM_RBUTTONUP => {
input::on_mouse_up();
0
}
WM_KEYDOWN => {
input::on_key(wparam as u32);
0
}
WM_TIMER => {
input::on_timer(wparam);
0
}
WM_MENU_CLOSE => {
close_menu();
0
}
WM_CAPTURECHANGED => {
if is_menu_open() {
close_menu();
}
0
}
WM_DESTROY => {
let is_root = MENU_STATE.with(|s| {
s.try_borrow()
.map(|st| st.as_ref().is_some_and(|m| m.root.hwnd == hwnd))
.unwrap_or(false)
});
if is_root {
log_info!("Menu root window destroyed outside close_menu; running teardown");
close_menu();
}
0
}
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
}
}