pub mod clipboard;
#[cfg(feature = "d2d")]
mod d2d;
pub mod tray;
pub use tray::{Tray, TrayCtx, TrayMenuItem};
use std::cell::Cell;
use std::ffi::c_void;
use std::mem::size_of;
use std::path::PathBuf;
use tiny_skia::Pixmap;
use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::{HINSTANCE, HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
use windows::Win32::Graphics::Dwm::DwmExtendFrameIntoClientArea;
use windows::Win32::Graphics::Gdi::{
BeginPaint, EndPaint, GetDC, GetDeviceCaps, InvalidateRect, ReleaseDC, ScreenToClient,
SetDIBitsToDevice, UpdateWindow, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DEFAULT_CHARSET,
DIB_RGB_COLORS, LOGFONTW, PAINTSTRUCT, VREFRESH,
};
use windows::Win32::Media::{timeBeginPeriod, timeEndPeriod};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::Controls::{MARGINS, WM_MOUSELEAVE};
use windows::Win32::UI::HiDpi::{
AdjustWindowRectExForDpi, GetDpiForSystem, GetDpiForWindow, GetSystemMetricsForDpi,
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
};
use windows::Win32::UI::Input::Ime::{
ImmGetContext, ImmReleaseContext, ImmSetCandidateWindow, ImmSetCompositionFontW,
ImmSetCompositionWindow, CANDIDATEFORM, CFS_CANDIDATEPOS, CFS_POINT, COMPOSITIONFORM,
};
use windows::Win32::UI::Input::KeyboardAndMouse::{
GetDoubleClickTime, GetKeyState, ReleaseCapture, SetCapture, TrackMouseEvent, TME_LEAVE,
TRACKMOUSEEVENT, VK_BACK, VK_CONTROL, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_HOME, VK_LEFT,
VK_RETURN, VK_RIGHT, VK_SHIFT, VK_SPACE, VK_TAB, VK_UP,
};
use windows::Win32::UI::Input::Touch::{
CloseTouchInputHandle, GetTouchInputInfo, RegisterTouchWindow, HTOUCHINPUT,
REGISTER_TOUCH_WINDOW_FLAGS, TOUCHEVENTF_DOWN, TOUCHEVENTF_MOVE, TOUCHEVENTF_UP, TOUCHINPUT,
};
use windows::Win32::UI::Shell::{
DragAcceptFiles, DragFinish, DragQueryFileW, DragQueryPoint, ShellExecuteW, HDROP,
};
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetClientRect,
GetMessageExtraInfo, GetMessageTime, GetMessageW, GetSystemMetrics, GetWindowLongPtrW,
GetWindowRect, IsIconic, IsZoomed, LoadCursorW, LoadIconW, MsgWaitForMultipleObjectsEx,
PeekMessageW, PostMessageW, PostQuitMessage, RegisterClassExW, SetCursor, SetTimer,
SetWindowLongPtrW, SetWindowPos, ShowWindow, SystemParametersInfoW, TranslateMessage,
CREATESTRUCTW, CW_USEDEFAULT, GWLP_USERDATA, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTCAPTION,
HTCLIENT, HTLEFT, HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, IDC_ARROW, IDC_HAND, IDC_IBEAM,
MINMAXINFO, MSG, MWMO_INPUTAVAILABLE, NCCALCSIZE_PARAMS, PM_REMOVE, QS_ALLINPUT,
SM_CXDOUBLECLK, SM_CXFRAME, SM_CXPADDEDBORDER, SM_CXSCREEN, SM_CYDOUBLECLK, SM_CYFRAME,
SM_CYSCREEN, SM_REMOTESESSION, SPI_GETCLIENTAREAANIMATION, SWP_FRAMECHANGED, SWP_NOACTIVATE,
SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SW_MAXIMIZE, SW_MINIMIZE, SW_RESTORE, SW_SHOW,
SW_SHOWNORMAL, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, WINDOW_EX_STYLE, WINDOW_STYLE, WM_APP,
WM_CAPTURECHANGED, WM_CHAR, WM_CLOSE, WM_DESTROY, WM_DPICHANGED, WM_DROPFILES,
WM_GETMINMAXINFO, WM_IME_COMPOSITION, WM_IME_ENDCOMPOSITION, WM_IME_STARTCOMPOSITION,
WM_KEYDOWN, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_NCCALCSIZE,
WM_NCCREATE, WM_NCHITTEST, WM_NCMOUSEMOVE, WM_PAINT, WM_QUIT, WM_RBUTTONDOWN, WM_RBUTTONUP,
WM_SETCURSOR, WM_SIZE, WM_TIMER, WM_TOUCH, WNDCLASSEXW, WS_MAXIMIZEBOX, WS_OVERLAPPEDWINDOW,
WS_THICKFRAME,
};
use super::{AppHandler, WindowConfig};
use crate::event::{CursorShape, Key, KeyEvent, MouseButton, PointerEvent, PointerKind, WindowOp};
use crate::geometry::{Color, Point, Size};
thread_local! {
static ACTIVE_HWND: Cell<isize> = const { Cell::new(0) };
}
pub(super) fn active_hwnd() -> isize {
ACTIVE_HWND.with(|h| h.get())
}
unsafe fn os_animations_enabled() -> bool {
let mut on = windows::core::BOOL(1);
let ok = SystemParametersInfoW(
SPI_GETCLIENTAREAANIMATION,
0,
Some(&mut on as *mut _ as *mut core::ffi::c_void),
SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
)
.is_ok();
if ok {
on.as_bool()
} else {
true
}
}
pub(crate) fn run(
cfg: WindowConfig,
mut handler: Box<dyn AppHandler>,
waker: Option<std::sync::Arc<crate::sync::WakerShared>>,
single: Option<crate::single_instance::SingleInstance>,
) {
let os_default = if cfg.screenshot.is_some() {
true
} else {
unsafe { os_animations_enabled() }
};
crate::anim::set_enabled(cfg.animations.unwrap_or(os_default));
if let Some(path) = cfg.screenshot.clone() {
super::run_offscreen(&cfg, &mut handler, &path);
return;
}
if let Some(si) = &single {
if !crate::single_instance::acquire(&si.app_id) {
let argv: Vec<String> = std::env::args().collect();
crate::single_instance::forward(&si.app_id, &argv);
return;
}
}
unsafe { run_windowed(cfg, handler, waker, single) };
}
trait WinRenderBackend {
#[allow(dead_code)]
fn resize(&mut self, w: i32, h: i32);
unsafe fn paint(&mut self, hwnd: HWND, bg: Color, handler: &mut dyn AppHandler) -> bool;
}
struct SkiaBackend {
pixmap: Option<Pixmap>,
buf_w: i32,
buf_h: i32,
}
impl SkiaBackend {
fn new() -> Self {
Self {
pixmap: None,
buf_w: 0,
buf_h: 0,
}
}
fn ensure(&mut self, w: i32, h: i32) {
let w = w.max(1);
let h = h.max(1);
if self.buf_w == w && self.buf_h == h && self.pixmap.is_some() {
return;
}
self.pixmap = Some(Pixmap::new(w as u32, h as u32).expect("分配 pixmap 失败"));
self.buf_w = w;
self.buf_h = h;
}
}
impl WinRenderBackend for SkiaBackend {
fn resize(&mut self, w: i32, h: i32) {
self.ensure(w, h);
}
unsafe fn paint(&mut self, hwnd: HWND, bg: Color, handler: &mut dyn AppHandler) -> bool {
let mut rc = RECT::default();
let _ = GetClientRect(hwnd, &mut rc);
let w = rc.right - rc.left;
let h = rc.bottom - rc.top;
if w <= 0 || h <= 0 {
let mut ps = PAINTSTRUCT::default();
let _ = BeginPaint(hwnd, &mut ps);
let _ = EndPaint(hwnd, &ps);
return false;
}
self.ensure(w, h);
let size = Size::new(self.buf_w, self.buf_h);
let pixmap = self.pixmap.as_mut().unwrap();
pixmap.fill(to_skia_color(bg));
{
let mut tgt = crate::render::PixmapTarget { pixmap };
handler.render(&mut tgt, size);
}
let pixmap = self.pixmap.as_mut().unwrap();
swap_rb_inplace(pixmap.data_mut());
let bits = pixmap.data().as_ptr() as *const c_void;
let bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: size_of::<BITMAPINFOHEADER>() as u32,
biWidth: self.buf_w,
biHeight: -self.buf_h, biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0,
..Default::default()
},
..Default::default()
};
let mut ps = PAINTSTRUCT::default();
let hdc = BeginPaint(hwnd, &mut ps);
let scanlines = SetDIBitsToDevice(
hdc,
0,
0,
self.buf_w as u32,
self.buf_h as u32,
0,
0,
0,
self.buf_h as u32,
bits,
&bmi,
DIB_RGB_COLORS,
);
debug_assert!(scanlines != 0, "SetDIBitsToDevice 呈现失败");
let _ = EndPaint(hwnd, &ps);
false }
}
struct WindowState {
handler: Box<dyn AppHandler>,
bg: Color,
capturing: bool,
backend: Box<dyn WinRenderBackend>,
last_click: ClickTracker,
touch: Touch,
tray: Option<tray::TrayState>,
frameless: bool,
mouse_tracked: bool,
pending_surrogate: Option<u16>,
min_w: i32,
min_h: i32,
}
#[derive(Default, Clone, Copy)]
struct Touch {
down: bool,
start: (i32, i32),
last: (i32, i32),
scrolling: bool,
last_t: u32,
vy: f32,
}
const TOUCH_THRESHOLD: i32 = 12;
const TOUCH_VEL_SMOOTH: f32 = 0.4;
#[derive(Default, Clone, Copy)]
struct ClickTracker {
time_ms: u32,
x: i32,
y: i32,
button: i32,
count: u8,
}
impl ClickTracker {
fn bump(
&mut self,
button: i32,
x: i32,
y: i32,
now_ms: u32,
dbl_ms: u32,
dx: i32,
dy: i32,
) -> u8 {
let continued = self.count > 0
&& self.button == button
&& now_ms.wrapping_sub(self.time_ms) <= dbl_ms
&& (x - self.x).abs() <= dx
&& (y - self.y).abs() <= dy;
let count = if continued {
(self.count + 1).min(3)
} else {
1
};
*self = ClickTracker {
time_ms: now_ms,
x,
y,
button,
count,
};
count
}
}
impl WindowState {
fn new(handler: Box<dyn AppHandler>, bg: Color) -> Self {
Self {
handler,
bg,
capturing: false,
backend: Box::new(SkiaBackend::new()),
last_click: ClickTracker::default(),
touch: Touch::default(),
tray: None,
frameless: false,
mouse_tracked: false,
pending_surrogate: None,
min_w: 0,
min_h: 0,
}
}
unsafe fn paint(&mut self, hwnd: HWND) {
let bg = self.bg;
let downgrade = self.backend.paint(hwnd, bg, self.handler.as_mut());
if downgrade {
self.backend = Box::new(SkiaBackend::new());
let _ = InvalidateRect(Some(hwnd), None, false);
}
}
}
fn swap_rb_inplace(data: &mut [u8]) {
let n = data.len() / 4;
let p = data.as_mut_ptr() as *mut u32;
for i in 0..n {
unsafe {
let v = p.add(i).read_unaligned();
let s = (v & 0xFF00_FF00) | ((v & 0x0000_00FF) << 16) | ((v & 0x00FF_0000) >> 16);
p.add(i).write_unaligned(s);
}
}
}
use super::to_skia_color;
const CLASS_NAME: PCWSTR = w!("WindUiWindowClass");
const WM_APP_WAKE: u32 = WM_APP + 2;
struct Win32Wake {
hwnd: isize,
}
unsafe impl Send for Win32Wake {}
impl crate::sync::RawWakeSignal for Win32Wake {
fn signal(&self) {
unsafe {
let _ = PostMessageW(
Some(HWND(self.hwnd as *mut _)),
WM_APP_WAKE,
WPARAM(0),
LPARAM(0),
);
}
}
}
unsafe fn run_windowed(
mut cfg: WindowConfig,
handler: Box<dyn AppHandler>,
waker: Option<std::sync::Arc<crate::sync::WakerShared>>,
single: Option<crate::single_instance::SingleInstance>,
) {
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
let hmodule = GetModuleHandleW(None).expect("GetModuleHandleW 失败");
let hinst = HINSTANCE(hmodule.0);
let cursor = LoadCursorW(None, IDC_ARROW).unwrap_or_default();
#[allow(clippy::manual_dangling_ptr)]
let hicon = LoadIconW(Some(hinst), PCWSTR(1usize as *const u16)).unwrap_or_default();
let wc = WNDCLASSEXW {
cbSize: size_of::<WNDCLASSEXW>() as u32,
lpfnWndProc: Some(wnd_proc),
hInstance: hinst,
lpszClassName: CLASS_NAME,
hCursor: cursor,
hIcon: hicon,
hIconSm: hicon,
..Default::default()
};
let atom = RegisterClassExW(&wc);
debug_assert!(atom != 0, "RegisterClassExW 失败");
let mut state = Box::new(WindowState::new(handler, cfg.bg));
state.min_w = cfg.min_width;
state.min_h = cfg.min_height;
let state_ptr = Box::into_raw(state);
let title: Vec<u16> = cfg.title.encode_utf16().chain(std::iter::once(0)).collect();
let sys_dpi = {
let d = GetDpiForSystem();
if d == 0 {
96
} else {
d
}
};
let init_scale = sys_dpi as f32 / 96.0;
let (phys_w, phys_h) = frame_size_for_client(cfg.width, cfg.height, init_scale, sys_dpi);
let win_style = if cfg.resizable {
WS_OVERLAPPEDWINDOW
} else {
WINDOW_STYLE(WS_OVERLAPPEDWINDOW.0 & !(WS_THICKFRAME.0 | WS_MAXIMIZEBOX.0))
};
let hwnd = match CreateWindowExW(
WINDOW_EX_STYLE::default(),
CLASS_NAME,
PCWSTR(title.as_ptr()),
win_style,
CW_USEDEFAULT,
CW_USEDEFAULT,
phys_w,
phys_h,
None,
None,
Some(hinst),
Some(state_ptr as *const c_void),
) {
Ok(h) => h,
Err(e) => {
drop(Box::from_raw(state_ptr));
panic!("CreateWindowExW 失败: {e:?}");
}
};
let dpi = GetDpiForWindow(hwnd);
let scale = if dpi == 0 { 1.0 } else { dpi as f32 / 96.0 };
if (scale - init_scale).abs() > 0.01 {
let (w, h) = frame_size_for_client(cfg.width, cfg.height, scale, dpi);
let _ = SetWindowPos(
hwnd,
None,
0,
0,
w,
h,
SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOMOVE,
);
}
if let Some(s) = state_from(hwnd) {
s.handler.set_scale(scale);
}
#[cfg(feature = "d2d")]
{
let env_force = std::env::var("WINDUI_D2D").is_ok_and(|v| v != "0" && !v.is_empty());
let is_remote = GetSystemMetrics(SM_REMOTESESSION) != 0;
if (cfg.accelerated || env_force) && !is_remote {
let mut rc = RECT::default();
let _ = GetClientRect(hwnd, &mut rc);
let (cw, ch) = (rc.right - rc.left, rc.bottom - rc.top);
match d2d::try_create(hwnd, cw, ch) {
Some(b) => {
if let Some(s) = state_from(hwnd) {
s.backend = Box::new(b);
}
}
None => eprintln!("[windui] D2D 设备创建失败,回退软渲染(Skia)"),
}
}
}
if cfg.centered {
let screen_w = GetSystemMetrics(SM_CXSCREEN);
let screen_h = GetSystemMetrics(SM_CYSCREEN);
let mut rc = RECT::default();
let _ = GetWindowRect(hwnd, &mut rc);
let win_w = rc.right - rc.left;
let win_h = rc.bottom - rc.top;
let x = (screen_w - win_w) / 2;
let y = (screen_h - win_h) / 2;
let _ = SetWindowPos(hwnd, None, x, y, 0, 0, SWP_NOZORDER | SWP_NOSIZE);
}
let _ = RegisterTouchWindow(hwnd, REGISTER_TOUCH_WINDOW_FLAGS(0));
DragAcceptFiles(hwnd, true);
if let Some(t) = cfg.tray.take() {
if let Some(ts) = tray::install(hwnd, t) {
if let Some(s) = state_from(hwnd) {
s.tray = Some(ts);
}
}
}
if let Some(w) = &waker {
w.bind(Box::new(Win32Wake {
hwnd: hwnd.0 as isize,
}));
}
if let Some(si) = single {
crate::single_instance::install_listener(&si.app_id, hwnd.0 as isize, si.on_second);
}
if let Some(s) = state_from(hwnd) {
for (i, d) in s.handler.intervals().iter().enumerate() {
let ms = (d.as_millis() as u32).max(1);
let _ = SetTimer(Some(hwnd), i + 1, ms, None);
}
}
if cfg.frameless {
if let Some(s) = state_from(hwnd) {
s.frameless = true;
}
let margins = MARGINS {
cxLeftWidth: 0,
cxRightWidth: 0,
cyTopHeight: 1,
cyBottomHeight: 0,
};
let _ = DwmExtendFrameIntoClientArea(hwnd, &margins);
let _ = SetWindowPos(
hwnd,
None,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED,
);
}
let _ = ShowWindow(hwnd, SW_SHOW);
let _ = UpdateWindow(hwnd);
run_message_loop(hwnd);
#[cfg(feature = "d2d")]
d2d::release_shared_device();
}
struct TimerResolution;
impl TimerResolution {
fn raise() -> Self {
unsafe {
let _ = timeBeginPeriod(1);
}
TimerResolution
}
}
impl Drop for TimerResolution {
fn drop(&mut self) {
unsafe {
let _ = timeEndPeriod(1);
}
}
}
unsafe fn run_message_loop(hwnd: HWND) {
let frame_ms = frame_interval_ms(hwnd);
let mut msg = MSG::default();
let mut last_frame = std::time::Instant::now();
let mut hires: Option<TimerResolution> = None;
loop {
let animating = !IsIconic(hwnd).as_bool()
&& state_from(hwnd)
.map(|s| s.handler.wants_animation())
.unwrap_or(false);
if animating {
if hires.is_none() {
hires = Some(TimerResolution::raise());
}
let elapsed = last_frame.elapsed().as_millis();
let wait = frame_ms.saturating_sub(elapsed) as u32;
MsgWaitForMultipleObjectsEx(None, wait, QS_ALLINPUT, MWMO_INPUTAVAILABLE);
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
if msg.message == WM_QUIT {
return; }
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if last_frame.elapsed().as_millis() >= frame_ms {
let _ = InvalidateRect(Some(hwnd), None, false);
let _ = UpdateWindow(hwnd);
last_frame = std::time::Instant::now();
}
} else {
hires = None;
let r = GetMessageW(&mut msg, None, 0, 0);
if !r.as_bool() {
return; }
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
last_frame = std::time::Instant::now(); }
}
}
unsafe fn frame_interval_ms(hwnd: HWND) -> u128 {
let hdc = GetDC(Some(hwnd));
let hz = if hdc.is_invalid() {
0
} else {
let v = GetDeviceCaps(Some(hdc), VREFRESH);
let _ = ReleaseDC(Some(hwnd), hdc);
v
};
let fps = if hz <= 1 { 60 } else { hz.min(240) };
(1000 / fps.max(1)) as u128
}
unsafe extern "system" fn wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
ACTIVE_HWND.with(|h| h.set(hwnd.0 as isize));
match msg {
WM_NCCREATE => {
let cs = lparam.0 as *const CREATESTRUCTW;
if !cs.is_null() {
let state_ptr = (*cs).lpCreateParams as isize;
SetWindowLongPtrW(hwnd, GWLP_USERDATA, state_ptr);
}
DefWindowProcW(hwnd, msg, wparam, lparam)
}
WM_PAINT => {
if let Some(state) = state_from(hwnd) {
state.paint(hwnd);
}
LRESULT(0)
}
WM_GETMINMAXINFO => {
if let Some(state) = state_from(hwnd) {
if state.min_w > 0 || state.min_h > 0 {
let dpi = GetDpiForWindow(hwnd).max(96);
let scale = dpi as f32 / 96.0;
let (pw, ph) = if state.frameless {
(
(state.min_w as f32 * scale).round() as i32,
(state.min_h as f32 * scale).round() as i32,
)
} else {
frame_size_for_client(state.min_w, state.min_h, scale, dpi)
};
let mmi = lparam.0 as *mut MINMAXINFO;
if !mmi.is_null() {
if pw > 0 {
(*mmi).ptMinTrackSize.x = pw;
}
if ph > 0 {
(*mmi).ptMinTrackSize.y = ph;
}
}
return LRESULT(0);
}
}
DefWindowProcW(hwnd, msg, wparam, lparam)
}
WM_SIZE => {
let w = (lparam.0 & 0xffff) as i32;
let h = ((lparam.0 >> 16) & 0xffff) as i32;
if let Some(state) = state_from(hwnd) {
state.backend.resize(w, h);
}
let _ = InvalidateRect(Some(hwnd), None, false);
LRESULT(0)
}
WM_NCCALCSIZE if wparam.0 != 0 && is_frameless(hwnd) => handle_nccalcsize(hwnd, lparam),
WM_NCHITTEST if is_frameless(hwnd) => handle_nchittest(hwnd, lparam),
WM_SETCURSOR => {
if (lparam.0 & 0xffff) as u32 == HTCLIENT {
if let Some(state) = state_from(hwnd) {
apply_cursor(state.handler.cursor());
return LRESULT(1); }
}
DefWindowProcW(hwnd, msg, wparam, lparam)
}
WM_MOUSEMOVE => {
track_mouse_leave(hwnd);
handle_pointer(hwnd, PointerKind::Move, MouseButton::Left, lparam);
LRESULT(0)
}
WM_NCMOUSEMOVE => {
handle_nc_mouse_move(hwnd, lparam);
DefWindowProcW(hwnd, msg, wparam, lparam)
}
WM_MOUSELEAVE => {
if let Some(s) = state_from(hwnd) {
s.mouse_tracked = false;
}
clear_hover(hwnd);
LRESULT(0)
}
WM_LBUTTONDOWN => {
handle_pointer(hwnd, PointerKind::Down, MouseButton::Left, lparam);
LRESULT(0)
}
WM_LBUTTONUP => {
handle_pointer(hwnd, PointerKind::Up, MouseButton::Left, lparam);
LRESULT(0)
}
WM_RBUTTONDOWN => {
handle_pointer(hwnd, PointerKind::Down, MouseButton::Right, lparam);
LRESULT(0)
}
WM_RBUTTONUP => {
handle_pointer(hwnd, PointerKind::Up, MouseButton::Right, lparam);
LRESULT(0)
}
WM_MOUSEWHEEL => {
handle_wheel(hwnd, wparam, lparam);
LRESULT(0)
}
WM_KEYDOWN => {
handle_key(hwnd, wparam);
LRESULT(0)
}
WM_CHAR => {
handle_char(hwnd, wparam);
LRESULT(0)
}
WM_CAPTURECHANGED => {
handle_capture_changed(hwnd);
LRESULT(0)
}
WM_DPICHANGED => {
handle_dpi_changed(hwnd, wparam, lparam);
LRESULT(0)
}
WM_TOUCH => {
handle_touch_input(hwnd, wparam, lparam);
LRESULT(0)
}
WM_DROPFILES => {
handle_drop_files(hwnd, wparam);
LRESULT(0)
}
WM_TIMER => {
let id = wparam.0;
let need = state_from(hwnd)
.map(|s| s.handler.on_interval_fired(id.saturating_sub(1)))
.unwrap_or(false);
if need {
let _ = InvalidateRect(Some(hwnd), None, false);
}
LRESULT(0)
}
WM_APP_WAKE => {
let _ = InvalidateRect(Some(hwnd), None, false);
LRESULT(0)
}
tray::WM_TRAYICON => {
if let Some(state) = state_from(hwnd) {
if let Some(ts) = state.tray.as_mut() {
tray::handle_message(ts, lparam);
}
}
LRESULT(0)
}
WM_IME_STARTCOMPOSITION => {
let repaint = state_from(hwnd)
.map(|s| s.handler.set_ime_composing(true))
.unwrap_or(false);
if repaint {
let _ = InvalidateRect(Some(hwnd), None, false);
}
handle_ime_position(hwnd);
DefWindowProcW(hwnd, msg, wparam, lparam)
}
WM_IME_COMPOSITION => {
handle_ime_position(hwnd);
DefWindowProcW(hwnd, msg, wparam, lparam)
}
WM_IME_ENDCOMPOSITION => {
let repaint = state_from(hwnd)
.map(|s| s.handler.set_ime_composing(false))
.unwrap_or(false);
if repaint {
let _ = InvalidateRect(Some(hwnd), None, false);
}
DefWindowProcW(hwnd, msg, wparam, lparam)
}
WM_CLOSE => {
let (allow, repaint) = {
let Some(state) = state_from(hwnd) else {
return LRESULT(0);
};
let allow = state.handler.on_close_request();
let repaint = !allow;
(allow, repaint)
};
if repaint {
let _ = InvalidateRect(Some(hwnd), None, false);
}
if allow {
let _ = DestroyWindow(hwnd);
}
LRESULT(0)
}
WM_DESTROY => {
PostQuitMessage(0);
let ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut WindowState;
if !ptr.is_null() {
SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0);
drop(Box::from_raw(ptr));
}
LRESULT(0)
}
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
}
}
unsafe fn apply_cursor(shape: CursorShape) {
let id = match shape {
CursorShape::Hand => IDC_HAND,
CursorShape::Text => IDC_IBEAM,
CursorShape::Arrow => IDC_ARROW,
};
if let Ok(cur) = LoadCursorW(None, id) {
let _ = SetCursor(Some(cur));
}
}
unsafe fn handle_drop_files(hwnd: HWND, wparam: WPARAM) {
let hdrop = HDROP(wparam.0 as *mut c_void);
let mut pt = POINT::default();
let _ = DragQueryPoint(hdrop, &mut pt);
let count = DragQueryFileW(hdrop, 0xFFFF_FFFF, None);
let mut paths = Vec::with_capacity(count as usize);
for i in 0..count {
let len = DragQueryFileW(hdrop, i, None);
if len == 0 {
continue;
}
let mut buf = vec![0u16; len as usize + 1];
let got = DragQueryFileW(hdrop, i, Some(&mut buf));
if got > 0 {
paths.push(PathBuf::from(String::from_utf16_lossy(
&buf[..got as usize],
)));
}
}
DragFinish(hdrop);
if paths.is_empty() {
return;
}
let repaint = {
let Some(state) = state_from(hwnd) else {
return;
};
let _guard = super::EventDispatchGuard::enter();
state.handler.on_drop_files(Point::new(pt.x, pt.y), paths)
};
if repaint {
let _ = InvalidateRect(Some(hwnd), None, false);
}
apply_dialog_request(hwnd);
if state_from(hwnd)
.map(|s| s.handler.wants_close())
.unwrap_or(false)
{
let _ = DestroyWindow(hwnd);
}
}
unsafe fn is_frameless(hwnd: HWND) -> bool {
state_from(hwnd).map(|s| s.frameless).unwrap_or(false)
}
unsafe fn handle_nccalcsize(hwnd: HWND, lparam: LPARAM) -> LRESULT {
if IsZoomed(hwnd).as_bool() {
let params = &mut *(lparam.0 as *mut NCCALCSIZE_PARAMS);
let dpi = GetDpiForWindow(hwnd).max(96);
let cx = GetSystemMetricsForDpi(SM_CXFRAME, dpi)
+ GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi);
let cy = GetSystemMetricsForDpi(SM_CYFRAME, dpi)
+ GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi);
params.rgrc[0].left += cx;
params.rgrc[0].right -= cx;
params.rgrc[0].top += cy;
params.rgrc[0].bottom -= cy;
}
LRESULT(0)
}
unsafe fn handle_nchittest(hwnd: HWND, lparam: LPARAM) -> LRESULT {
let sx = (lparam.0 & 0xffff) as i16 as i32;
let sy = ((lparam.0 >> 16) & 0xffff) as i16 as i32;
let mut pt = POINT { x: sx, y: sy };
let _ = ScreenToClient(hwnd, &mut pt);
let mut rc = RECT::default();
let _ = GetClientRect(hwnd, &mut rc);
let (w, h) = (rc.right, rc.bottom);
let interactive = state_from(hwnd)
.map(|s| s.handler.interactive_at(Point::new(pt.x, pt.y)))
.unwrap_or(false);
if interactive {
return LRESULT(HTCLIENT as isize);
}
let dpi = GetDpiForWindow(hwnd).max(96);
let m = ((8.0 * dpi as f32 / 96.0) as i32).max(4);
let (left, right) = (pt.x < m, pt.x >= w - m);
let (top, bottom) = (pt.y < m, pt.y >= h - m);
let ht: i32 = if top && left {
HTTOPLEFT as i32
} else if top && right {
HTTOPRIGHT as i32
} else if bottom && left {
HTBOTTOMLEFT as i32
} else if bottom && right {
HTBOTTOMRIGHT as i32
} else if left {
HTLEFT as i32
} else if right {
HTRIGHT as i32
} else if top {
HTTOP as i32
} else if bottom {
HTBOTTOM as i32
} else {
let drag = state_from(hwnd)
.map(|s| s.handler.window_drag_at(Point::new(pt.x, pt.y)))
.unwrap_or(false);
if drag {
HTCAPTION as i32
} else {
HTCLIENT as i32
}
};
LRESULT(ht as isize)
}
unsafe fn apply_window_op(hwnd: HWND) {
let op = state_from(hwnd).and_then(|s| s.handler.take_window_op());
match op {
Some(WindowOp::Minimize) => {
let _ = ShowWindow(hwnd, SW_MINIMIZE);
}
Some(WindowOp::ToggleMaximize) => {
let cmd = if IsZoomed(hwnd).as_bool() {
SW_RESTORE
} else {
SW_MAXIMIZE
};
let _ = ShowWindow(hwnd, cmd);
}
None => {}
}
}
unsafe fn apply_dialog_request(hwnd: HWND) {
let req = state_from(hwnd).and_then(|s| s.handler.take_dialog_request());
let Some(req) = req else { return };
req.run();
let _ = InvalidateRect(Some(hwnd), None, false);
}
pub fn open_url(url: &str) {
let verb = w!("open");
let target: Vec<u16> = url.encode_utf16().chain(std::iter::once(0)).collect();
unsafe {
ShellExecuteW(
None,
verb,
PCWSTR(target.as_ptr()),
PCWSTR::null(),
PCWSTR::null(),
SW_SHOWNORMAL,
);
}
}
unsafe fn frame_size_for_client(
logical_w: i32,
logical_h: i32,
scale: f32,
dpi: u32,
) -> (i32, i32) {
let cw = (logical_w as f32 * scale).round() as i32;
let ch = (logical_h as f32 * scale).round() as i32;
let mut rc = RECT {
left: 0,
top: 0,
right: cw,
bottom: ch,
};
let _ = AdjustWindowRectExForDpi(
&mut rc,
WS_OVERLAPPEDWINDOW,
false,
WINDOW_EX_STYLE::default(),
dpi,
);
(rc.right - rc.left, rc.bottom - rc.top)
}
unsafe fn handle_pointer(hwnd: HWND, kind: PointerKind, button: MouseButton, lparam: LPARAM) {
if is_touch_event() {
return;
}
let x = (lparam.0 & 0xffff) as i16 as i32;
let y = ((lparam.0 >> 16) & 0xffff) as i16 as i32;
let click_count = if matches!(kind, PointerKind::Down) {
let btn = match button {
MouseButton::Left => 1,
MouseButton::Right => 2,
MouseButton::Middle => 3,
};
let now = GetMessageTime() as u32;
let dbl = GetDoubleClickTime();
let dx = GetSystemMetrics(SM_CXDOUBLECLK) / 2;
let dy = GetSystemMetrics(SM_CYDOUBLECLK) / 2;
state_from(hwnd)
.map(|s| s.last_click.bump(btn, x, y, now, dbl, dx, dy))
.unwrap_or(1)
} else {
1
};
dispatch_pointer_event(
hwnd,
PointerEvent {
kind,
pos: Point::new(x, y),
button,
click_count,
},
);
}
unsafe fn track_mouse_leave(hwnd: HWND) {
let Some(state) = state_from(hwnd) else {
return;
};
if state.mouse_tracked {
return;
}
state.mouse_tracked = true;
let mut tme = TRACKMOUSEEVENT {
cbSize: core::mem::size_of::<TRACKMOUSEEVENT>() as u32,
dwFlags: TME_LEAVE,
hwndTrack: hwnd,
dwHoverTime: 0,
};
let _ = TrackMouseEvent(&mut tme);
}
unsafe fn clear_hover(hwnd: HWND) {
dispatch_pointer_event(
hwnd,
PointerEvent::single(PointerKind::Move, Point::new(-1, -1), MouseButton::Left),
);
}
unsafe fn handle_nc_mouse_move(hwnd: HWND, lparam: LPARAM) {
let mut pt = POINT {
x: (lparam.0 & 0xffff) as i16 as i32,
y: ((lparam.0 >> 16) & 0xffff) as i16 as i32,
};
let _ = ScreenToClient(hwnd, &mut pt);
dispatch_pointer_event(
hwnd,
PointerEvent::single(PointerKind::Move, Point::new(pt.x, pt.y), MouseButton::Left),
);
}
unsafe fn handle_wheel(hwnd: HWND, wparam: WPARAM, lparam: LPARAM) {
let delta = ((wparam.0 >> 16) & 0xffff) as i16 as i32;
let mut pt = POINT {
x: (lparam.0 & 0xffff) as i16 as i32,
y: ((lparam.0 >> 16) & 0xffff) as i16 as i32,
};
let _ = ScreenToClient(hwnd, &mut pt);
dispatch_pointer_event(
hwnd,
PointerEvent::single(
PointerKind::Wheel(delta),
Point::new(pt.x, pt.y),
MouseButton::Left,
),
);
}
unsafe fn dispatch_pointer_event(hwnd: HWND, ev: PointerEvent) {
let (repaint, active, was_capturing, close) = {
let Some(state) = state_from(hwnd) else {
return;
};
let _guard = super::EventDispatchGuard::enter();
let repaint = state.handler.on_pointer(ev);
(
repaint,
state.handler.capture_active(),
state.capturing,
state.handler.wants_close(),
)
};
if repaint {
let _ = InvalidateRect(Some(hwnd), None, false);
}
if active && !was_capturing {
SetCapture(hwnd);
if let Some(s) = state_from(hwnd) {
s.capturing = true;
}
} else if !active && was_capturing {
let _ = ReleaseCapture();
if let Some(s) = state_from(hwnd) {
s.capturing = false;
}
}
apply_window_op(hwnd);
apply_dialog_request(hwnd);
if close {
let _ = DestroyWindow(hwnd);
}
}
unsafe fn handle_dpi_changed(hwnd: HWND, wparam: WPARAM, lparam: LPARAM) {
let dpi = (wparam.0 & 0xffff) as u32;
let scale = if dpi == 0 { 1.0 } else { dpi as f32 / 96.0 };
let prc = lparam.0 as *const RECT;
if !prc.is_null() {
let r = &*prc;
let _ = SetWindowPos(
hwnd,
None,
r.left,
r.top,
r.right - r.left,
r.bottom - r.top,
SWP_NOZORDER | SWP_NOACTIVATE,
);
}
if let Some(s) = state_from(hwnd) {
s.handler.set_scale(scale);
}
let _ = InvalidateRect(Some(hwnd), None, false);
}
unsafe fn is_touch_event() -> bool {
const SIGNATURE: usize = 0xFF51_5700;
const MASK: usize = 0xFFFF_FF00;
(GetMessageExtraInfo().0 as usize & MASK) == SIGNATURE
}
unsafe fn handle_touch_input(hwnd: HWND, wparam: WPARAM, lparam: LPARAM) {
let count = wparam.0 & 0xffff;
if count == 0 {
return;
}
let hti = HTOUCHINPUT(lparam.0 as *mut c_void);
let mut inputs = [TOUCHINPUT::default(); 8];
let n = count.min(inputs.len());
let ok = GetTouchInputInfo(hti, &mut inputs[..n], size_of::<TOUCHINPUT>() as i32).is_ok();
let _ = CloseTouchInputHandle(hti);
if !ok {
return;
}
let ti = inputs[0];
let mut pt = POINT {
x: ti.x / 100,
y: ti.y / 100,
};
let _ = ScreenToClient(hwnd, &mut pt);
let kind = if ti.dwFlags.0 & TOUCHEVENTF_DOWN.0 != 0 {
PointerKind::Down
} else if ti.dwFlags.0 & TOUCHEVENTF_UP.0 != 0 {
PointerKind::Up
} else if ti.dwFlags.0 & TOUCHEVENTF_MOVE.0 != 0 {
PointerKind::Move
} else {
return;
};
let t = GetMessageTime() as u32;
handle_touch(hwnd, kind, pt.x, pt.y, t);
}
unsafe fn handle_touch(hwnd: HWND, kind: PointerKind, x: i32, y: i32, t: u32) {
match kind {
PointerKind::Down => {
cancel_fling(hwnd);
if let Some(s) = state_from(hwnd) {
s.touch = Touch {
down: true,
start: (x, y),
last: (x, y),
last_t: t,
..Touch::default()
};
}
}
PointerKind::Move => {
let (down, start, last, last_t, scrolling, vy) = match state_from(hwnd) {
Some(s) => (
s.touch.down,
s.touch.start,
s.touch.last,
s.touch.last_t,
s.touch.scrolling,
s.touch.vy,
),
None => return,
};
if !down {
return;
}
let dy = y - last.1;
let dt = t.wrapping_sub(last_t) as i32;
let vy = if dt > 0 {
let inst = dy as f32 / dt as f32;
vy * (1.0 - TOUCH_VEL_SMOOTH) + inst * TOUCH_VEL_SMOOTH
} else {
vy
};
let past = scrolling
|| (x - start.0).abs() >= TOUCH_THRESHOLD
|| (y - start.1).abs() >= TOUCH_THRESHOLD;
if let Some(s) = state_from(hwnd) {
s.touch.last = (x, y);
s.touch.last_t = t;
s.touch.vy = vy;
if past {
s.touch.scrolling = true;
}
}
if past {
dispatch_pan(hwnd, Point::new(x, y), dy);
}
}
PointerKind::Up => {
let (down, start, scrolling, vy) = match state_from(hwnd) {
Some(s) => (s.touch.down, s.touch.start, s.touch.scrolling, s.touch.vy),
None => return,
};
if let Some(s) = state_from(hwnd) {
s.touch.down = false;
s.touch.scrolling = false;
}
if down && scrolling {
dispatch_fling(hwnd, Point::new(x, y), vy);
} else if down {
dispatch_pointer_event(
hwnd,
PointerEvent::single(
PointerKind::Down,
Point::new(start.0, start.1),
MouseButton::Left,
),
);
dispatch_pointer_event(
hwnd,
PointerEvent::single(PointerKind::Up, Point::new(x, y), MouseButton::Left),
);
}
}
_ => {}
}
}
unsafe fn dispatch_pan(hwnd: HWND, pos: Point, dy: i32) {
let repaint = {
let Some(state) = state_from(hwnd) else {
return;
};
state.handler.on_pan(pos, dy)
};
if repaint {
let _ = InvalidateRect(Some(hwnd), None, false);
}
}
unsafe fn dispatch_fling(hwnd: HWND, pos: Point, vy: f32) {
let started = {
let Some(state) = state_from(hwnd) else {
return;
};
state.handler.start_fling(pos, vy)
};
if started {
let _ = InvalidateRect(Some(hwnd), None, false);
}
}
unsafe fn cancel_fling(hwnd: HWND) {
let repaint = {
let Some(state) = state_from(hwnd) else {
return;
};
state.handler.cancel_fling()
};
if repaint {
let _ = InvalidateRect(Some(hwnd), None, false);
}
}
unsafe fn handle_ime_position(hwnd: HWND) {
let caret = match state_from(hwnd) {
Some(s) => s.handler.ime_caret(),
None => return,
};
let Some((x, y, h)) = caret else { return };
let himc = ImmGetContext(hwnd);
if himc.0.is_null() {
return; }
let mut lf = LOGFONTW {
lfHeight: h,
lfCharSet: DEFAULT_CHARSET,
..Default::default()
};
for (dst, src) in lf
.lfFaceName
.iter_mut()
.zip("Microsoft YaHei UI".encode_utf16())
{
*dst = src;
}
let _ = ImmSetCompositionFontW(himc, &lf);
let cf = COMPOSITIONFORM {
dwStyle: CFS_POINT,
ptCurrentPos: POINT { x, y },
rcArea: RECT::default(),
};
let _ = ImmSetCompositionWindow(himc, &cf);
let cand = CANDIDATEFORM {
dwIndex: 0,
dwStyle: CFS_CANDIDATEPOS,
ptCurrentPos: POINT { x, y: y + h },
rcArea: RECT::default(),
};
let _ = ImmSetCandidateWindow(himc, &cand);
let _ = ImmReleaseContext(hwnd, himc);
}
unsafe fn handle_capture_changed(hwnd: HWND) {
let repaint = {
let Some(state) = state_from(hwnd) else {
return;
};
if !state.capturing {
return;
}
state.capturing = false;
state.handler.on_capture_lost()
};
if repaint {
let _ = InvalidateRect(Some(hwnd), None, false);
}
}
unsafe fn handle_key(hwnd: HWND, wparam: WPARAM) {
let vk = wparam.0 as u16;
let shift = (GetKeyState(VK_SHIFT.0 as i32) as u16 & 0x8000) != 0;
let ctrl = (GetKeyState(VK_CONTROL.0 as i32) as u16 & 0x8000) != 0;
let key = if vk == VK_TAB.0 {
Key::Tab
} else if vk == VK_RETURN.0 {
Key::Enter
} else if vk == VK_ESCAPE.0 {
Key::Escape
} else if vk == VK_SPACE.0 {
Key::Space
} else if vk == VK_BACK.0 {
Key::Backspace
} else if vk == VK_DELETE.0 {
Key::Delete
} else if vk == VK_LEFT.0 {
Key::Left
} else if vk == VK_RIGHT.0 {
Key::Right
} else if vk == VK_UP.0 {
Key::Up
} else if vk == VK_DOWN.0 {
Key::Down
} else if vk == VK_HOME.0 {
Key::Home
} else if vk == VK_END.0 {
Key::End
} else {
Key::Other(vk as u32)
};
let ev = KeyEvent {
key,
pressed: true,
shift,
ctrl,
};
dispatch_key_event(hwnd, ev);
}
fn accumulate_char(pending: &mut Option<u16>, unit: u16) -> Option<char> {
if (0xD800..=0xDBFF).contains(&unit) {
*pending = Some(unit); return None;
}
if (0xDC00..=0xDFFF).contains(&unit) {
let hi = pending.take()?;
let cp = 0x10000 + (((hi as u32 - 0xD800) << 10) | (unit as u32 - 0xDC00));
return char::from_u32(cp);
}
*pending = None; char::from_u32(unit as u32)
}
unsafe fn handle_char(hwnd: HWND, wparam: WPARAM) {
let unit = wparam.0 as u16;
let c = {
let Some(state) = state_from(hwnd) else {
return;
};
accumulate_char(&mut state.pending_surrogate, unit)
};
let Some(c) = c else { return };
if c.is_control() {
return;
}
let ev = KeyEvent {
key: Key::Char(c),
pressed: true,
shift: false,
ctrl: false,
};
dispatch_key_event(hwnd, ev);
}
unsafe fn dispatch_key_event(hwnd: HWND, ev: KeyEvent) {
let (repaint, close) = {
let Some(state) = state_from(hwnd) else {
return;
};
let _guard = super::EventDispatchGuard::enter();
(state.handler.on_key(ev), state.handler.wants_close())
};
if repaint {
let _ = InvalidateRect(Some(hwnd), None, false);
}
apply_window_op(hwnd);
apply_dialog_request(hwnd);
if close {
let _ = DestroyWindow(hwnd);
}
}
unsafe fn state_from<'a>(hwnd: HWND) -> Option<&'a mut WindowState> {
let ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut WindowState;
if ptr.is_null() {
None
} else {
Some(&mut *ptr)
}
}
#[cfg(test)]
mod tests {
use super::{accumulate_char, ClickTracker};
#[test]
fn bmp_char_passes_through() {
let mut pend = None;
assert_eq!(accumulate_char(&mut pend, b'A' as u16), Some('A'));
assert_eq!(
accumulate_char(&mut pend, 0x4E16),
Some('世'),
"BMP 中文字符"
);
assert_eq!(pend, None, "BMP 字符不留挂起状态");
}
#[test]
fn surrogate_pair_combines_to_emoji() {
let mut pend = None;
assert_eq!(accumulate_char(&mut pend, 0xD83D), None, "高代理项先暂存");
assert_eq!(pend, Some(0xD83D));
assert_eq!(
accumulate_char(&mut pend, 0xDE00),
Some('😀'),
"低代理项合成 emoji"
);
assert_eq!(pend, None, "合成后清空挂起");
}
#[test]
fn lone_low_surrogate_is_dropped() {
let mut pend = None;
assert_eq!(accumulate_char(&mut pend, 0xDE00), None, "孤立低代理项丢弃");
assert_eq!(pend, None);
}
#[test]
fn dangling_high_surrogate_recovers_on_bmp() {
let mut pend = None;
assert_eq!(accumulate_char(&mut pend, 0xD83D), None);
assert_eq!(accumulate_char(&mut pend, b'X' as u16), Some('X'));
assert_eq!(pend, None, "悬挂高代理项被清除");
}
#[test]
fn second_high_surrogate_replaces_first() {
let mut pend = None;
assert_eq!(accumulate_char(&mut pend, 0xD83D), None);
assert_eq!(
accumulate_char(&mut pend, 0xD83C),
None,
"第二个高代理项替换第一个"
);
assert_eq!(pend, Some(0xD83C));
assert_eq!(accumulate_char(&mut pend, 0xDF08), Some('🌈'));
}
const DBL: u32 = 500;
const DX: i32 = 4;
const DY: i32 = 4;
#[test]
fn double_then_triple_then_reset() {
let mut t = ClickTracker::default();
assert_eq!(t.bump(1, 10, 10, 1000, DBL, DX, DY), 1, "首击=单击");
assert_eq!(t.bump(1, 11, 11, 1100, DBL, DX, DY), 2, "时限内同位=双击");
assert_eq!(t.bump(1, 12, 12, 1200, DBL, DX, DY), 3, "继续=三击");
assert_eq!(t.bump(1, 12, 12, 1300, DBL, DX, DY), 3, "封顶于三击");
assert_eq!(t.bump(1, 12, 12, 2000, DBL, DX, DY), 1, "超时重置为单击");
}
#[test]
fn continuation_across_u32_wraparound() {
let mut t = ClickTracker::default();
let near_max = u32::MAX - 100;
assert_eq!(t.bump(1, 10, 10, near_max, DBL, DX, DY), 1, "首击");
let wrapped = near_max.wrapping_add(150);
assert_eq!(
t.bump(1, 10, 10, wrapped, DBL, DX, DY),
2,
"跨回绕仍判为双击"
);
}
#[test]
fn reset_on_far_move_or_other_button() {
let mut t = ClickTracker::default();
assert_eq!(t.bump(1, 10, 10, 1000, DBL, DX, DY), 1);
assert_eq!(t.bump(1, 30, 10, 1100, DBL, DX, DY), 1, "漂移过大不算连击");
assert_eq!(t.bump(2, 30, 10, 1150, DBL, DX, DY), 1, "换按键不算连击");
}
}