diff --git a/Cargo.lock b/Cargo.lock index 8de544b..789303e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -946,6 +946,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1153,6 +1162,29 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1285,6 +1317,7 @@ dependencies = [ "global-hotkey", "muda", "notify-rust", + "serial_test", "tauri-winres", "thiserror 2.0.18", "tracing", @@ -1318,6 +1351,15 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -1357,6 +1399,27 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "semver" version = "1.0.27" @@ -1422,6 +1485,32 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serial_test" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 0f237d1..2f748f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,5 +31,8 @@ muda = "0.17" winreg = "0.55" windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_Graphics_Gdi", "Win32_Graphics_Dwm", "Win32_System_Threading", "Win32_UI_Accessibility", "Win32_System_Console"] } +[dev-dependencies] +serial_test = "3" + [build-dependencies] tauri-winres = "0.3.5" diff --git a/README.md b/README.md index 26ea39a..d522cf1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Windows utility enabling Quake Mode behaviorโ€”any window slides in/out from scr - ๐Ÿ–ฅ๏ธ **System tray** โ€” Status, Untrack, Start with Windows, Exit - ๐Ÿ”” **Desktop notification** โ€” Toast when window tracked - ๐Ÿš€ **Auto-launch** โ€” Optional startup with Windows (Registry-based) +- ๐Ÿ–ฑ๏ธ **Edge trigger** โ€” Show/hide by moving cursor to screen edge (toggleable) ## Installation @@ -28,7 +29,7 @@ cargo install quake-modoki --locked | `Ctrl+Alt+Q` | Track current window | | `F8` | Toggle window visibility | -Tray icon menu: Untrack / Start with Windows / Exit +Tray icon menu: Untrack / Edge trigger / Start with Windows / Exit ## Development diff --git a/src/autolaunch.rs b/src/autolaunch.rs index 4b45ee3..acf8f50 100644 --- a/src/autolaunch.rs +++ b/src/autolaunch.rs @@ -58,8 +58,10 @@ pub fn toggle() -> Result { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; #[test] + #[serial] fn test_is_enabled_initially_false() { // Clean up first let _ = disable(); @@ -67,6 +69,7 @@ mod tests { } #[test] + #[serial] fn test_enable_disable_cycle() { // Clean state let _ = disable(); @@ -82,6 +85,7 @@ mod tests { } #[test] + #[serial] fn test_toggle() { // Clean state let _ = disable(); diff --git a/src/edge.rs b/src/edge.rs new file mode 100644 index 0000000..992239d --- /dev/null +++ b/src/edge.rs @@ -0,0 +1,552 @@ +//! Edge trigger module: show/hide window when cursor reaches screen edge + +use std::time::Instant; +use thiserror::Error; +use winreg::RegKey; +use winreg::enums::{HKEY_CURRENT_USER, KEY_READ}; + +use crate::animation::Direction; +use crate::tracking::WindowBounds; +use windows::Win32::Foundation::{POINT, RECT}; + +const SETTINGS_KEY: &str = r"Software\QuakeModoki"; +const EDGE_ENABLED: &str = "EdgeEnabled"; + +#[derive(Debug, Error)] +pub enum EdgeError { + #[error("Registry access failed: {0}")] + Registry(#[from] std::io::Error), +} + +/// Edge trigger configuration +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EdgeConfig { + pub threshold_px: i32, + pub show_delay_ms: u32, + pub hide_delay_ms: u32, +} + +impl Default for EdgeConfig { + fn default() -> Self { + Self { + threshold_px: 1, + show_delay_ms: 100, + hide_delay_ms: 300, + } + } +} + +/// Edge trigger state machine +#[derive(Debug, Clone, Default)] +pub enum EdgeState { + #[default] + Idle, + PendingShow { + since: Instant, + }, + Active, + PendingHide { + since: Instant, + }, +} + +/// Action to perform after state transition +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EdgeAction { + Show, + Hide, +} + +/// Check if cursor at edge (within threshold of work area boundary) +/// Returns true if cursor within threshold of edge matching direction +pub fn detect_edge(cursor: POINT, work_area: &RECT, direction: Direction, threshold: i32) -> bool { + match direction { + Direction::Left => cursor.x <= work_area.left + threshold, + Direction::Right => cursor.x >= work_area.right - threshold - 1, + Direction::Top => cursor.y <= work_area.top + threshold, + Direction::Bottom => cursor.y >= work_area.bottom - threshold - 1, + } +} + +/// Check if cursor inside window bounds +pub fn cursor_in_window(cursor: POINT, bounds: &WindowBounds) -> bool { + cursor.x >= bounds.x + && cursor.x < bounds.x + bounds.width + && cursor.y >= bounds.y + && cursor.y < bounds.y + bounds.height +} + +/// Check and transition state machine +/// Returns Some(action) when show/hide needed, None otherwise +pub fn check_and_transition( + state: &mut EdgeState, + config: &EdgeConfig, + direction: Direction, + visible: bool, + cursor: POINT, + work_area: &RECT, + bounds: Option<&WindowBounds>, +) -> Option { + let at_edge = detect_edge(cursor, work_area, direction, config.threshold_px); + let in_window = bounds.is_some_and(|b| cursor_in_window(cursor, b)); + + match state { + EdgeState::Idle => { + if !visible && at_edge { + *state = EdgeState::PendingShow { + since: Instant::now(), + }; + } + None + } + EdgeState::PendingShow { since } => { + if !at_edge { + // Left edge before delay + *state = EdgeState::Idle; + None + } else if since.elapsed().as_millis() >= config.show_delay_ms as u128 { + // Delay elapsed, trigger show + *state = EdgeState::Active; + Some(EdgeAction::Show) + } else { + None + } + } + EdgeState::Active => { + if visible && !in_window && !at_edge { + // Cursor left window and edge, start hide delay + *state = EdgeState::PendingHide { + since: Instant::now(), + }; + } + None + } + EdgeState::PendingHide { since } => { + if in_window || at_edge { + // Returned to window/edge, cancel hide + *state = EdgeState::Active; + None + } else if since.elapsed().as_millis() >= config.hide_delay_ms as u128 { + // Delay elapsed, trigger hide + *state = EdgeState::Idle; + Some(EdgeAction::Hide) + } else { + None + } + } + } +} + +/// Reset state machine to Idle +pub fn reset_state(state: &mut EdgeState) { + *state = EdgeState::Idle; +} + +// ========== Registry Persistence ========== + +/// Check if edge trigger enabled in registry +pub fn is_enabled() -> bool { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + hkcu.open_subkey_with_flags(SETTINGS_KEY, KEY_READ) + .ok() + .and_then(|key| key.get_value::(EDGE_ENABLED).ok()) + != Some(0) +} + +/// Enable/disable edge trigger +pub fn set_enabled(enabled: bool) -> Result<(), EdgeError> { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let (key, _) = hkcu.create_subkey(SETTINGS_KEY)?; + key.set_value(EDGE_ENABLED, &(enabled as u32))?; + Ok(()) +} + +/// Toggle edge trigger, returns new state +pub fn toggle() -> Result { + let new_state = !is_enabled(); + set_enabled(new_state)?; + Ok(new_state) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::thread::sleep; + use std::time::Duration; + + fn make_rect(left: i32, top: i32, right: i32, bottom: i32) -> RECT { + RECT { + left, + top, + right, + bottom, + } + } + + fn make_point(x: i32, y: i32) -> POINT { + POINT { x, y } + } + + fn make_bounds(x: i32, y: i32, width: i32, height: i32) -> WindowBounds { + WindowBounds { + x, + y, + width, + height, + } + } + + // ========== Edge Detection Tests ========== + + #[test] + fn test_detect_edge_left() { + let work_area = make_rect(0, 0, 1920, 1080); + // At edge (x=0) + assert!(detect_edge( + make_point(0, 500), + &work_area, + Direction::Left, + 1 + )); + // Within threshold (x=1) + assert!(detect_edge( + make_point(1, 500), + &work_area, + Direction::Left, + 1 + )); + // Outside threshold (x=2) + assert!(!detect_edge( + make_point(2, 500), + &work_area, + Direction::Left, + 1 + )); + } + + #[test] + fn test_detect_edge_right() { + let work_area = make_rect(0, 0, 1920, 1080); + // At edge (x=1919) + assert!(detect_edge( + make_point(1919, 500), + &work_area, + Direction::Right, + 1 + )); + // Within threshold (x=1918) + assert!(detect_edge( + make_point(1918, 500), + &work_area, + Direction::Right, + 1 + )); + // Outside threshold (x=1917) + assert!(!detect_edge( + make_point(1917, 500), + &work_area, + Direction::Right, + 1 + )); + } + + #[test] + fn test_detect_edge_top() { + let work_area = make_rect(0, 0, 1920, 1080); + assert!(detect_edge( + make_point(500, 0), + &work_area, + Direction::Top, + 1 + )); + assert!(detect_edge( + make_point(500, 1), + &work_area, + Direction::Top, + 1 + )); + assert!(!detect_edge( + make_point(500, 2), + &work_area, + Direction::Top, + 1 + )); + } + + #[test] + fn test_detect_edge_bottom() { + let work_area = make_rect(0, 0, 1920, 1080); + assert!(detect_edge( + make_point(500, 1079), + &work_area, + Direction::Bottom, + 1 + )); + assert!(detect_edge( + make_point(500, 1078), + &work_area, + Direction::Bottom, + 1 + )); + assert!(!detect_edge( + make_point(500, 1077), + &work_area, + Direction::Bottom, + 1 + )); + } + + #[test] + fn test_detect_edge_threshold_larger() { + let work_area = make_rect(0, 0, 1920, 1080); + // threshold=5: x โˆˆ [0..5] triggers + assert!(detect_edge( + make_point(5, 500), + &work_area, + Direction::Left, + 5 + )); + assert!(!detect_edge( + make_point(6, 500), + &work_area, + Direction::Left, + 5 + )); + } + + // ========== Cursor in Window Tests ========== + + #[test] + fn test_cursor_in_window_inside() { + let bounds = make_bounds(100, 100, 400, 300); + assert!(cursor_in_window(make_point(200, 200), &bounds)); + assert!(cursor_in_window(make_point(100, 100), &bounds)); // top-left corner + } + + #[test] + fn test_cursor_in_window_outside() { + let bounds = make_bounds(100, 100, 400, 300); + assert!(!cursor_in_window(make_point(99, 200), &bounds)); // left + assert!(!cursor_in_window(make_point(500, 200), &bounds)); // right edge (exclusive) + assert!(!cursor_in_window(make_point(200, 99), &bounds)); // top + assert!(!cursor_in_window(make_point(200, 400), &bounds)); // bottom edge (exclusive) + } + + // ========== State Machine Tests ========== + + #[test] + fn test_state_idle_to_pending_show() { + let config = EdgeConfig { + threshold_px: 1, + show_delay_ms: 100, + hide_delay_ms: 300, + }; + let work_area = make_rect(0, 0, 1920, 1080); + let mut state = EdgeState::Idle; + + // Cursor at left edge, window not visible + let action = check_and_transition( + &mut state, + &config, + Direction::Left, + false, + make_point(0, 500), + &work_area, + None, + ); + assert_eq!(action, None); + assert!(matches!(state, EdgeState::PendingShow { .. })); + } + + #[test] + fn test_state_pending_show_to_idle_on_leave() { + let config = EdgeConfig { + threshold_px: 1, + show_delay_ms: 100, + hide_delay_ms: 300, + }; + let work_area = make_rect(0, 0, 1920, 1080); + let mut state = EdgeState::PendingShow { + since: Instant::now(), + }; + + // Cursor leaves edge + let action = check_and_transition( + &mut state, + &config, + Direction::Left, + false, + make_point(100, 500), + &work_area, + None, + ); + assert_eq!(action, None); + assert!(matches!(state, EdgeState::Idle)); + } + + #[test] + fn test_state_pending_show_to_active() { + let config = EdgeConfig { + threshold_px: 1, + show_delay_ms: 10, + hide_delay_ms: 300, + }; + let work_area = make_rect(0, 0, 1920, 1080); + let mut state = EdgeState::PendingShow { + since: Instant::now(), + }; + + // Wait for delay + sleep(Duration::from_millis(15)); + + // Cursor still at edge + let action = check_and_transition( + &mut state, + &config, + Direction::Left, + false, + make_point(0, 500), + &work_area, + None, + ); + assert_eq!(action, Some(EdgeAction::Show)); + assert!(matches!(state, EdgeState::Active)); + } + + #[test] + fn test_state_active_to_pending_hide() { + let config = EdgeConfig { + threshold_px: 1, + show_delay_ms: 100, + hide_delay_ms: 300, + }; + let work_area = make_rect(0, 0, 1920, 1080); + let bounds = make_bounds(0, 0, 400, 1080); + let mut state = EdgeState::Active; + + // Cursor outside window and not at edge + let action = check_and_transition( + &mut state, + &config, + Direction::Left, + true, + make_point(500, 500), + &work_area, + Some(&bounds), + ); + assert_eq!(action, None); + assert!(matches!(state, EdgeState::PendingHide { .. })); + } + + #[test] + fn test_state_pending_hide_cancel_on_return() { + let config = EdgeConfig { + threshold_px: 1, + show_delay_ms: 100, + hide_delay_ms: 300, + }; + let work_area = make_rect(0, 0, 1920, 1080); + let bounds = make_bounds(0, 0, 400, 1080); + let mut state = EdgeState::PendingHide { + since: Instant::now(), + }; + + // Cursor returns to window + let action = check_and_transition( + &mut state, + &config, + Direction::Left, + true, + make_point(200, 500), + &work_area, + Some(&bounds), + ); + assert_eq!(action, None); + assert!(matches!(state, EdgeState::Active)); + } + + #[test] + fn test_state_pending_hide_to_idle() { + let config = EdgeConfig { + threshold_px: 1, + show_delay_ms: 100, + hide_delay_ms: 10, + }; + let work_area = make_rect(0, 0, 1920, 1080); + let bounds = make_bounds(0, 0, 400, 1080); + let mut state = EdgeState::PendingHide { + since: Instant::now(), + }; + + // Wait for delay + sleep(Duration::from_millis(15)); + + // Cursor still outside + let action = check_and_transition( + &mut state, + &config, + Direction::Left, + true, + make_point(500, 500), + &work_area, + Some(&bounds), + ); + assert_eq!(action, Some(EdgeAction::Hide)); + assert!(matches!(state, EdgeState::Idle)); + } + + #[test] + fn test_state_idle_stays_idle_when_visible() { + let config = EdgeConfig::default(); + let work_area = make_rect(0, 0, 1920, 1080); + let mut state = EdgeState::Idle; + + // Cursor at edge but window already visible + let action = check_and_transition( + &mut state, + &config, + Direction::Left, + true, + make_point(0, 500), + &work_area, + None, + ); + assert_eq!(action, None); + assert!(matches!(state, EdgeState::Idle)); + } + + // ========== Registry Tests ========== + + #[test] + #[serial] + fn test_is_enabled_default_false() { + // Ensure disabled first + let _ = set_enabled(false); + assert!(!is_enabled()); + } + + #[test] + #[serial] + fn test_set_enabled_roundtrip() { + set_enabled(true).expect("set enabled failed"); + assert!(is_enabled()); + + set_enabled(false).expect("set disabled failed"); + assert!(!is_enabled()); + } + + #[test] + #[serial] + fn test_toggle() { + let _ = set_enabled(false); + + let new_state = toggle().expect("toggle failed"); + assert!(new_state); + assert!(is_enabled()); + + let new_state = toggle().expect("toggle failed"); + assert!(!new_state); + assert!(!is_enabled()); + } +} diff --git a/src/main.rs b/src/main.rs index 76759a4..e36ae2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod animation; mod autolaunch; +mod edge; mod error; mod focus; mod notification; @@ -16,17 +17,18 @@ use animation::{AnimConfig, run_animation}; use global_hotkey::hotkey::{Code, HotKey, Modifiers}; use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; use tray::TrayState; -use windows::Win32::Foundation::{HWND, LPARAM, RECT}; +use windows::Win32::Foundation::{HWND, LPARAM, POINT, RECT}; use windows::Win32::Graphics::Gdi::{ - GetMonitorInfoW, MONITOR_DEFAULTTOPRIMARY, MONITORINFO, MonitorFromWindow, + GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITOR_DEFAULTTOPRIMARY, MONITORINFO, + MonitorFromPoint, MonitorFromWindow, }; use windows::Win32::System::Console::{ CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT, SetConsoleCtrlHandler, }; use windows::Win32::UI::WindowsAndMessaging::{ - DispatchMessageW, EnumWindows, GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW, - IsWindowVisible, MSG, MWMO_INPUTAVAILABLE, MsgWaitForMultipleObjectsEx, PM_REMOVE, - PeekMessageW, QS_ALLINPUT, SetForegroundWindow, TranslateMessage, WM_ENDSESSION, + DispatchMessageW, EnumWindows, GetCursorPos, GetForegroundWindow, GetWindowTextLengthW, + GetWindowTextW, IsWindowVisible, MSG, MWMO_INPUTAVAILABLE, MsgWaitForMultipleObjectsEx, + PM_REMOVE, PeekMessageW, QS_ALLINPUT, SetForegroundWindow, TranslateMessage, WM_ENDSESSION, WM_QUERYENDSESSION, WM_QUIT, }; use windows::core::BOOL; @@ -66,6 +68,7 @@ fn main() -> anyhow::Result<()> { // Initialize system tray let tray = TrayState::new().map_err(|e| anyhow::anyhow!("TrayState: {e}"))?; tray.set_autolaunch_checked(autolaunch::is_enabled()); + tray.set_edge_trigger_checked(edge::is_enabled()); info!("System tray initialized"); let manager = @@ -109,6 +112,10 @@ fn run_event_loop(toggle_id: u32, track_id: u32, tray: &TrayState) -> anyhow::Re let menu_rx = tray::menu_receiver(); let mut msg = MSG::default(); + // Edge trigger state + let edge_config = edge::EdgeConfig::default(); + let mut edge_state = edge::EdgeState::default(); + loop { // Check shutdown flag (set by ctrl_handler) if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) { @@ -125,7 +132,10 @@ fn run_event_loop(toggle_id: u32, track_id: u32, tray: &TrayState) -> anyhow::Re while let Ok(event) = hotkey_rx.try_recv() { if event.state() == HotKeyState::Pressed { match event.id() { - id if id == toggle_id => toggle_window(), + id if id == toggle_id => { + toggle_window(); + edge::reset_state(&mut edge_state); // Hotkey wins, reset edge + } id if id == track_id => register_foreground_with_tray(tray), _ => {} } @@ -134,7 +144,23 @@ fn run_event_loop(toggle_id: u32, track_id: u32, tray: &TrayState) -> anyhow::Re // Check menu events (non-blocking) while let Ok(event) = menu_rx.try_recv() { - handle_menu_event(&event, tray); + handle_menu_event(&event, tray, &mut edge_state); + } + + // Edge trigger check (polling) + if edge::is_enabled() + && tracking::is_tracked_valid() + && let Some(action) = check_edge_trigger(&mut edge_state, &edge_config) + { + match action { + edge::EdgeAction::Show if !WINDOW_VISIBLE.load(Ordering::SeqCst) => { + toggle_window(); + } + edge::EdgeAction::Hide if WINDOW_VISIBLE.load(Ordering::SeqCst) => { + toggle_window(); + } + _ => {} + } } // Process Win32 messages @@ -148,7 +174,10 @@ fn run_event_loop(toggle_id: u32, track_id: u32, tray: &TrayState) -> anyhow::Re info!("Session ending"); return Ok(()); } - m if m == focus::WM_FOCUS_CHANGED => handle_focus_lost(), + m if m == focus::WM_FOCUS_CHANGED => { + handle_focus_lost(); + edge::reset_state(&mut edge_state); // Focus lost resets edge state + } _ => unsafe { let _ = TranslateMessage(&msg); DispatchMessageW(&msg); @@ -158,6 +187,48 @@ fn run_event_loop(toggle_id: u32, track_id: u32, tray: &TrayState) -> anyhow::Re } } +/// Check edge trigger and return action if any +fn check_edge_trigger( + state: &mut edge::EdgeState, + config: &edge::EdgeConfig, +) -> Option { + // Get cursor position + let mut cursor = POINT::default(); + if unsafe { GetCursorPos(&mut cursor) }.is_err() { + return None; + } + + // Get work area for monitor containing cursor + let monitor = unsafe { MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST) }; + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if !unsafe { GetMonitorInfoW(monitor, &mut info) }.as_bool() { + return None; + } + let work_area = info.rcWork; + + // Get window bounds and direction + let bounds = tracking::load_bounds(); + let direction = bounds + .as_ref() + .map(|b| tracking::calc_direction(b, &work_area)) + .unwrap_or(animation::Direction::Left); + + let visible = WINDOW_VISIBLE.load(Ordering::SeqCst); + + edge::check_and_transition( + state, + config, + direction, + visible, + cursor, + &work_area, + bounds.as_ref(), + ) +} + fn list_windows() { unsafe extern "system" fn enum_callback(hwnd: HWND, _: LPARAM) -> BOOL { unsafe { @@ -302,7 +373,7 @@ fn handle_focus_lost() { } /// Handle tray menu events -fn handle_menu_event(event: &muda::MenuEvent, tray: &TrayState) { +fn handle_menu_event(event: &muda::MenuEvent, tray: &TrayState, edge_state: &mut edge::EdgeState) { let id = event.id(); if tray.is_exit(id) { @@ -317,6 +388,7 @@ fn handle_menu_event(event: &muda::MenuEvent, tray: &TrayState) { error!("Focus unhook error: {e}"); } WINDOW_VISIBLE.store(false, Ordering::SeqCst); + edge::reset_state(edge_state); tray.update_status(None); } else if tray.is_autolaunch(id) { // Toggle auto-launch @@ -329,6 +401,18 @@ fn handle_menu_event(event: &muda::MenuEvent, tray: &TrayState) { error!("Auto-launch toggle failed: {e}"); } } + } else if tray.is_edge_trigger(id) { + // Toggle edge trigger + match edge::toggle() { + Ok(enabled) => { + tray.set_edge_trigger_checked(enabled); + edge::reset_state(edge_state); + info!(enabled, "Edge trigger toggled"); + } + Err(e) => { + error!("Edge trigger toggle failed: {e}"); + } + } } } diff --git a/src/tray.rs b/src/tray.rs index 3114501..86ac9a1 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -18,9 +18,11 @@ pub struct TrayState { _icon: TrayIcon, menu_untrack: MenuId, menu_autolaunch: MenuId, + menu_edge_trigger: MenuId, menu_exit: MenuId, status_item: MenuItem, autolaunch_item: CheckMenuItem, + edge_trigger_item: CheckMenuItem, } impl TrayState { @@ -31,11 +33,14 @@ impl TrayState { let untrack_item = MenuItem::with_id("untrack", "Untrack", true, None); let autolaunch_item = CheckMenuItem::with_id("autolaunch", "Start with Windows", true, false, None); + let edge_trigger_item = + CheckMenuItem::with_id("edge_trigger", "Edge Trigger", true, false, None); let exit_item = MenuItem::with_id("exit", "Exit", true, None); // Store IDs let menu_untrack = untrack_item.id().clone(); let menu_autolaunch = autolaunch_item.id().clone(); + let menu_edge_trigger = edge_trigger_item.id().clone(); let menu_exit = exit_item.id().clone(); // Build menu @@ -48,6 +53,8 @@ impl TrayState { .map_err(|e| TrayError::Menu(e.to_string()))?; menu.append(&autolaunch_item) .map_err(|e| TrayError::Menu(e.to_string()))?; + menu.append(&edge_trigger_item) + .map_err(|e| TrayError::Menu(e.to_string()))?; menu.append(&PredefinedMenuItem::separator()) .map_err(|e| TrayError::Menu(e.to_string()))?; menu.append(&exit_item) @@ -68,9 +75,11 @@ impl TrayState { _icon: tray, menu_untrack, menu_autolaunch, + menu_edge_trigger, menu_exit, status_item, autolaunch_item, + edge_trigger_item, }) } @@ -102,6 +111,16 @@ impl TrayState { pub fn is_exit(&self, id: &MenuId) -> bool { *id == self.menu_exit } + + /// Check if event matches edge trigger menu + pub fn is_edge_trigger(&self, id: &MenuId) -> bool { + *id == self.menu_edge_trigger + } + + /// Set edge trigger checkbox state + pub fn set_edge_trigger_checked(&self, checked: bool) { + self.edge_trigger_item.set_checked(checked); + } } /// Get menu event receiver