diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc28f0c --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Cursor Rush + +A cross-platform application for customizing your cursor with gameified logic. + +## Features + +- Replace the system cursor with custom PNG images +- Minimize to system tray for background operation +- Cross-platform support (Windows, macOS, Linux) +- Steam integration + +## Project Structure + +``` +src/ +├── app/ # Application core logic +│ ├── mod.rs # Main app module exports +│ ├── state.rs # Application state management +│ ├── messages.rs # Message definitions +│ └── cursor.rs # Cursor-related functionality +├── ui/ # User interface components +│ ├── mod.rs # UI module exports +│ ├── view.rs # View rendering logic +│ └── tray.rs # System tray functionality +├── platform/ # Platform-specific implementations +│ ├── mod.rs # Platform module exports +│ ├── windows/ # Windows-specific code +│ │ ├── mod.rs # Windows module exports +│ │ ├── types.rs # Windows type definitions +│ │ ├── state.rs # Windows state management +│ │ ├── cursor.rs # Windows cursor handling +│ │ ├── window.rs # Windows window management +│ │ └── hook.rs # Windows mouse hook handling +│ ├── macos.rs # macOS-specific code +│ ├── linux.rs # Linux-specific code +│ └── unsupported.rs # Fallback for unsupported platforms +├── integrations/ # External integrations +│ ├── mod.rs # Integrations module exports +│ └── steam.rs # Steam integration +├── utils/ # Utility functions and helpers +│ ├── mod.rs # Utils module exports +│ ├── config.rs # Configuration management +│ └── logging.rs # Logging utilities +└── main.rs # Application entry point +``` + +## Building + +### Prerequisites + +- Rust 1.70.0 or later +- Cargo + +### Build Commands + +```bash +# Debug build +cargo build + +# Release build +cargo build --release +``` + +## Usage + +1. Place your cursor PNG files in the `assets` directory +2. Run the application +3. Select a cursor from the list +4. Press Ctrl+Shift+F12 to exit the application + +## Hotkeys + +- Ctrl+Shift+F12: Exit the application + +## System Tray + +- Left-click: Show the application window +- Right-click: Open the context menu + - Next cursor: Switch to the next cursor + - Show window: Show the application window + - Quit: Exit the application + + diff --git a/src/app/cursor.rs b/src/app/cursor.rs new file mode 100644 index 0000000..29f7811 --- /dev/null +++ b/src/app/cursor.rs @@ -0,0 +1,88 @@ +// Cursor management functionality + +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; +use ::image::open as image_open; +use ::image::GenericImageView; + +use crate::platform; + +// Define a struct to hold cursor information +#[derive(Clone)] +pub struct CursorInfo { + pub name: String, + pub path: PathBuf, +} + +// Function to load all PNG files from the assets directory +pub fn load_cursor_files() -> Vec { + let mut cursors = Vec::new(); + + println!("Looking for cursor PNG files in assets directory..."); + + // Check if assets directory exists + if !std::path::Path::new("assets").exists() { + println!("Assets directory not found!"); + return cursors; + } + + for entry in WalkDir::new("assets").into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + println!("Found file: {}", path.display()); + if path.is_file() && path.extension().map_or(false, |ext| ext == "png") { + let name = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unknown") + .to_string(); + + println!("Adding cursor: {} at path {}", name, path.display()); + cursors.push(CursorInfo { + name, + path: path.to_path_buf(), + }); + } + } + + println!("Found {} cursor PNG files", cursors.len()); + + // Sort cursors by name for consistent ordering + cursors.sort_by(|a, b| a.name.cmp(&b.name)); + cursors +} + +// Function to validate a cursor image +pub fn validate_cursor_image(path: &Path) -> bool { + // Load the image to validate it + match image_open(path) { + Ok(img) => { + let (width, height) = img.dimensions(); + + // Ensure reasonable dimensions + if width > 128 || height > 128 || width == 0 || height == 0 { + eprintln!("Image dimensions not suitable for cursor: {}x{}", width, height); + return false; + } + + true + }, + Err(e) => { + eprintln!("Failed to open image {}: {}", path.display(), e); + false + }, + } +} + +// Apply the cursor at the given path +pub fn apply_cursor(path: &Path) -> bool { + // Validate the cursor image + if validate_cursor_image(path) { + // Use platform-specific cursor image update (not hiding system cursor again) + if platform::update_cursor_image(path) { + println!("Successfully updated cursor image"); + return true; + } else { + eprintln!("Failed to update cursor image"); + } + } + false +} diff --git a/src/app/messages.rs b/src/app/messages.rs new file mode 100644 index 0000000..fd84eff --- /dev/null +++ b/src/app/messages.rs @@ -0,0 +1,14 @@ +// Application messages + +// Messages that can be sent in our application +#[derive(Debug, Clone)] +pub enum Message { + NextCursor, + SelectCursor(usize), + Quit, + TrayEvent, // Used as a no-op message + CloseRequested(iced::window::Id), // Message for window close requests with the real window ID + ShowWindow, // Message to show the window from tray + WindowHidden, // Message indicating window was successfully hidden + WindowShown, // Message indicating window was successfully shown +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..f335da2 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,46 @@ +// Application core module +// Contains the main application state and logic + +mod state; +mod messages; +mod cursor; + +pub use state::CursorRush; +pub use messages::Message; +pub use cursor::{CursorInfo, load_cursor_files, apply_cursor}; +pub use state::SharedState; + +use iced::{Subscription, Task}; +use once_cell::sync::Lazy; +use iced::futures::channel::mpsc; + +// Global channel for tray events +pub static TRAY_SENDER: Lazy>>> = + Lazy::new(|| std::sync::Mutex::new(None)); + +// Boot function to initialize the application +pub fn boot() -> (CursorRush, Task) { + state::boot_app() +} + +// Update function to handle messages +pub fn update(state: &mut CursorRush, message: Message) -> Task { + state::update_app(state, message) +} + +// Top-level subscription function to listen for events +pub fn subscription(_state: &CursorRush) -> Subscription { + state::app_subscription(_state) +} + +// Helper function to hide a window to the tray +pub fn hide_window_to_tray(window_id: iced::window::Id) -> Task { + // Use ONLY the iced method for hiding. Platform specific might interfere. + let hide_task: Task<()> = iced::window::change_mode(window_id, iced::window::Mode::Hidden); + + hide_task.map(|_: ()| { + println!("Window hide task completed (via iced)"); + // Return a specific message to confirm the window was hidden + Message::WindowHidden + }) +} diff --git a/src/app/state.rs b/src/app/state.rs new file mode 100644 index 0000000..631f7ff --- /dev/null +++ b/src/app/state.rs @@ -0,0 +1,420 @@ +// Application state management + +use std::{ + sync::{Arc, Mutex}, + thread, + time::Duration, +}; +use ::image::{RgbaImage, open as image_open}; +use iced::{Task, Subscription, window, event}; +use iced::futures::channel::mpsc; +use global_hotkey::{ + GlobalHotKeyManager, + hotkey::HotKey as GlobalHotKey, + HotKeyState, +}; + +// Update function to handle messages +pub fn update_app(state: &mut CursorRush, message: Message) -> Task { + match message { + Message::NextCursor => { + if !state.cursors.is_empty() { + state.current_index = (state.current_index + 1) % state.cursors.len(); + + // Apply the new cursor + if let Some(cursor) = state.cursors.get(state.current_index) { + apply_cursor(&cursor.path); + // No need to call render_cursor anymore as the mouse hook handles rendering + + // Load the new cursor image + let current_cursor_image = match image::open(&cursor.path) { + Ok(img) => Some(img.to_rgba8()), + Err(_) => None, + }; + + // Update shared state + if let Some(shared) = &state.shared_state { + if let Ok(mut shared_state) = shared.lock() { + shared_state.current_index = state.current_index; + shared_state.current_cursor_image = current_cursor_image; + } + } + } + } + Task::none() + }, + Message::SelectCursor(idx) => { + if idx < state.cursors.len() { + state.current_index = idx; + + // Apply the selected cursor + if let Some(cursor) = state.cursors.get(state.current_index) { + apply_cursor(&cursor.path); + // No need to call render_cursor anymore as the mouse hook handles rendering + + // Load the new cursor image + let current_cursor_image = match image_open(&cursor.path) { + Ok(img) => Some(img.to_rgba8()), + Err(_) => None, + }; + + // Update shared state + if let Some(shared) = &state.shared_state { + if let Ok(mut shared_state) = shared.lock() { + shared_state.current_index = state.current_index; + shared_state.current_cursor_image = current_cursor_image; + } + } + } + } + Task::none() + }, + Message::Quit => { + println!("Update: Handling Quit message"); + + // Set running flag to false to signal other threads (like hotkey listener) + if let Some(shared) = &state.shared_state { + if let Ok(mut shared_state) = shared.lock() { + shared_state.running = false; + } + } + + // Restore the system cursor before exiting + platform::restore_cursor(); + + // Cleanly drop the tray icon *before* exiting iced + // This removes the icon from the system tray immediately + if let Some(tray) = state.tray_icon.take() { + drop(tray); + println!("Tray icon explicitly dropped."); + } + + // Use iced's exit mechanism for a cleaner shutdown + println!("Requesting application exit via iced::exit..."); + iced::exit() + }, + Message::TrayEvent => { + // Sync with shared state + if let Some(shared) = &state.shared_state { + if let Ok(shared_state) = shared.lock() { + state.current_index = shared_state.current_index; + } + } + Task::none() + }, + Message::CloseRequested(window_id) => { + println!("Handling CloseRequested: Hiding window with ID: {:?}", window_id); + + // Store the real window ID for future use + // This is critical for showing the window later + state.main_window = Some(window_id); + println!("Stored window ID: {:?} for future use", window_id); + + // Update shared state first + if let Some(shared) = &state.shared_state { + match shared.lock() { + Ok(mut shared_state) => { + if shared_state.window_visible { // Only hide if visible + shared_state.window_visible = false; + println!("Set window_visible to false in shared state"); + println!("Hiding window with ID: {:?}, app continues running in tray", window_id); + return crate::app::hide_window_to_tray(window_id); + } else { + println!("Window already hidden, ignoring CloseRequested."); + return Task::none(); // Don't try to hide again + } + } + Err(e) => { + eprintln!("Error locking shared state for hiding: {}", e); + return Task::none(); + } + } + } else { + eprintln!("Error: Shared state not available for hiding."); + return Task::none(); + } + }, + Message::WindowHidden => { + println!("Window successfully hidden to tray"); + Task::none() + }, + Message::WindowShown => { + println!("Update: Received WindowShown message. Window should now be visible."); + Task::none() + }, + Message::ShowWindow => { + println!("Handling ShowWindow: Attempting to show window"); // More specific starting log + + if let Some(window_id) = state.main_window { + println!("Using stored window ID: {:?}", window_id); + + // It's generally safer to attempt the show operation even if the state *thinks* it's visible. + // Let's update the shared state *after* we know the task is being created. + + println!("Creating show/focus tasks for window ID: {:?}", window_id); + let show_task = iced::window::change_mode(window_id, iced::window::Mode::Windowed); + let focus_task = iced::window::gain_focus(window_id); + + // Update shared state *before* returning the task, but after creating it. + if let Some(shared) = &state.shared_state { + match shared.lock() { + Ok(mut shared_state) => { + if !shared_state.window_visible { // Only log if changing state + println!("Updating shared state: window_visible = true"); + shared_state.window_visible = true; + } else { + println!("Shared state already indicates window is visible."); + } + } + Err(e) => { + eprintln!("Error locking shared state during show: {}", e); + // Proceed with showing the window anyway, state might be out of sync + } + } + } else { + eprintln!("Shared state unavailable during show."); + // Proceed with showing the window anyway + } + + println!("Returning Task::batch to show and focus window."); + // Return the batch task + return Task::batch(vec![show_task, focus_task]) + .map(|_: ()| { + // This message confirms the Iced runtime *completed* the tasks. + println!("Iced runtime reports show/focus tasks completed successfully."); + Message::WindowShown // Send confirmation message + }); + + } else { + eprintln!("Error: Main window ID not found when trying to show. Cannot create tasks."); + Task::none() // Cannot proceed without window_id + } + }, + } +} + +use crate::platform; +use crate::ui::tray::setup_tray_icon; +use crate::app::{Message, CursorInfo, load_cursor_files, apply_cursor, TRAY_SENDER}; + +// Shared state between the UI and the tray icon +pub struct SharedState { + pub current_index: usize, + pub cursors: Vec, + pub current_cursor_image: Option, + pub running: bool, + pub window_visible: bool, // Track if the window is visible +} + +// Our application state +pub struct CursorRush { + pub cursors: Vec, + pub current_index: usize, + pub shared_state: Option>>, + pub main_window: Option, // Store the main window ID + pub tray_icon: Option, // Store the TrayIcon object to keep it alive +} + +// Make sure the default impl initializes tray_icon to None +impl Default for CursorRush { + fn default() -> Self { + Self { + cursors: Vec::new(), + current_index: 0, + shared_state: None, + main_window: None, + tray_icon: None, // Initialize as None + } + } +} + +// Top-level subscription function to listen for events +pub fn app_subscription(_state: &CursorRush) -> Subscription { + // Create a vector to hold all subscriptions + let mut subs = vec![ + // Listen for events but filter out mouse movement events to avoid spamming + event::listen().map(|event| { + match event { + // Skip cursor movement events to avoid spamming + iced::Event::Mouse(iced::mouse::Event::CursorMoved { .. }) => { + // Return TrayEvent which is essentially a no-op + Message::TrayEvent + }, + // Handle other events + _ => Message::TrayEvent, // Use TrayEvent as a no-op message + } + }), + + // Specifically subscribe to window close requests + // This is the key to handling the X button properly + window::close_requests().map(|window_id| { + println!("Window close requested via X button for window ID: {:?}", window_id); + Message::CloseRequested(window_id) + }), + ]; + + // Add a subscription for tray events + // We'll use a simple channel that the tray icon can send messages to + let tray_subscription = Subscription::run( + // Stream generator function + || { + // Create a channel that the tray icon can send messages to + let (tx, rx) = mpsc::unbounded::(); + + // Store the sender in the global state + *TRAY_SENDER.lock().unwrap() = Some(tx); + + // Return the receiver as a stream + rx + } + ); + + subs.push(tray_subscription); + + // Combine all subscriptions + Subscription::batch(subs) +} + +// Boot function implementation +pub fn boot_app() -> (CursorRush, Task) { + // Load cursor files from assets directory + let cursors = load_cursor_files(); + + if cursors.is_empty() { + eprintln!("No cursor PNG files found in assets directory!"); + return (CursorRush::default(), Task::none()); + } + + // Load the first cursor image + let current_cursor_image = if let Some(cursor) = cursors.first() { + match image_open(&cursor.path) { + Ok(img) => Some(img.to_rgba8()), + Err(_) => None, + } + } else { + None + }; + + // Hide the system cursor once at startup + if !platform::hide_system_cursor() { + eprintln!("Failed to hide system cursor at startup!"); + } + + // Apply the first cursor by default + if let Some(cursor) = cursors.first() { + apply_cursor(&cursor.path); + // No need to call render_cursor anymore as the mouse hook handles rendering + } + + // Create shared state + let shared_state = Arc::new(Mutex::new(SharedState { + current_index: 0, + cursors: cursors.clone(), + current_cursor_image, + running: true, + window_visible: true, // Window is initially visible + })); + + // Initialize the application state with loaded cursors + let mut state = CursorRush::default(); + state.cursors = cursors.clone(); // Clone here + state.shared_state = Some(shared_state.clone()); + + // Set up the tray icon AND store the result in the state + state.tray_icon = setup_tray_icon(&state.cursors, shared_state.clone()); // Pass the cloned cursors + if state.tray_icon.is_none() { + eprintln!("Failed to initialize tray icon, application might not function correctly in background."); + } + + setup_hotkeys(shared_state.clone()); + + (state, Task::none()) +} + +// Set up global hotkeys +fn setup_hotkeys(shared_state: Arc>) { + // Set up global hotkey manager + let hotkey_manager = match GlobalHotKeyManager::new() { + Ok(manager) => manager, + Err(e) => { + eprintln!("Failed to create hotkey manager: {}", e); + // Continue without hotkey support + GlobalHotKeyManager::new().expect("Critical failure: could not create hotkey manager") + } + }; + + // Register Ctrl+Shift+F12 as the exit hotkey (uncommon but easy to remember) + use global_hotkey::hotkey::{Code, Modifiers}; + + let hotkey = GlobalHotKey::new( + Some(Modifiers::CONTROL | Modifiers::SHIFT), + Code::F12, + ); + + // Register the hotkey with better error handling + match hotkey_manager.register(hotkey) { + Ok(_) => { + println!("Successfully registered Ctrl+Shift+F12 as exit hotkey"); + }, + Err(e) => { + eprintln!("Failed to register hotkey: {}", e); + // Try registering with a different hotkey as fallback + let fallback_hotkey = GlobalHotKey::new( + Some(Modifiers::CONTROL | Modifiers::ALT), + Code::F12, + ); + + match hotkey_manager.register(fallback_hotkey) { + Ok(_) => println!("Registered fallback hotkey Ctrl+Alt+F12 instead"), + Err(e) => eprintln!("Failed to register fallback hotkey: {}", e), + } + } + } + + println!("Press Ctrl+Shift+F12 to exit the application"); + + // Spawn a thread to listen for hotkeys + let state_clone = shared_state.clone(); + thread::spawn(move || { + println!("Hotkey listener thread started"); + let event_receiver = global_hotkey::GlobalHotKeyEvent::receiver(); + + loop { + match event_receiver.try_recv() { + Ok(event) => { + println!("Received hotkey event: {:?}", event); + if event.state == HotKeyState::Pressed { + println!("Exit hotkey pressed, sending Quit message..."); + // Send Quit message instead of direct exit + // Get the global sender + if let Some(tx) = TRAY_SENDER.lock().unwrap().as_ref() { + if let Err(e) = tx.unbounded_send(Message::Quit) { + eprintln!("Failed to send Quit message from hotkey thread: {}", e); + // Fallback to direct exit if channel fails + platform::restore_cursor(); + std::process::exit(0); + } + } else { + eprintln!("Hotkey: Global sender not initialized yet"); + // Fallback to direct exit if sender not available + platform::restore_cursor(); + std::process::exit(0); + } + // Don't exit directly here, let the main thread handle it. + } + }, + Err(_) => { + // No events or channel disconnected, sleep briefly + std::thread::sleep(Duration::from_millis(50)); + } + } + // Check shared state running flag + if let Ok(state) = state_clone.lock() { + if !state.running { + println!("Hotkey listener detected running=false, exiting thread."); + break; + } + } + } + }); +} diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs new file mode 100644 index 0000000..d320bd0 --- /dev/null +++ b/src/integrations/mod.rs @@ -0,0 +1,3 @@ +// External integrations module + +pub mod steam; diff --git a/src/integrations/steam.rs b/src/integrations/steam.rs new file mode 100644 index 0000000..0f30977 --- /dev/null +++ b/src/integrations/steam.rs @@ -0,0 +1,26 @@ +// Steam integration + +use steamworks::{ + sys::{SteamAPI_ISteamInventory_TriggerItemDrop, SteamAPI_SteamInventory_v003}, + AppId, Client, SingleClient, +}; + +#[allow(dead_code)] +pub struct Steam { + client: Client, + single: SingleClient, +} + +impl Steam { + pub fn new() -> Self { + let (client, single) = Client::init_app(AppId(1430270)).unwrap(); + Self { client, single } + } + + pub fn drop_item(&self) { + unsafe { + let mut r = 0; + SteamAPI_ISteamInventory_TriggerItemDrop(SteamAPI_SteamInventory_v003(), &mut r, 1); + } + } +} diff --git a/src/main.rs b/src/main.rs index a75841a..6ac58e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,828 +1,21 @@ -use iced::{ - widget::{button, text, Column, Row, container, image as iced_image}, - Element, Length, Subscription, Task, Theme, - futures::{channel::mpsc}, -}; -use std::{ - path::{Path, PathBuf}, - sync::{Arc, Mutex}, - thread, - time::Duration, -}; -use ::image::{GenericImageView, RgbaImage, open as image_open}; -use walkdir::WalkDir; -use tray_icon::{ - TrayIconBuilder, - TrayIcon, // Import the TrayIcon type itself - TrayIconEvent, // Import TrayIconEvent for handling left/right clicks - MouseButton, // Import MouseButton to distinguish between left and right clicks - menu::{Menu, MenuEvent}, -}; -use global_hotkey::{ - GlobalHotKeyManager, - hotkey::HotKey as GlobalHotKey, - HotKeyState, -}; +// Main entry point for the Cursor Rush application -// Import our platform-specific module +mod app; +mod ui; mod platform; - -pub mod steam; - -// Define a struct to hold cursor information -#[derive(Clone)] -struct CursorInfo { - name: String, - path: PathBuf, -} - -// We'll use a simpler approach for the cursor overlay - -// Define menu item IDs -const NEXT_CURSOR_ID: &str = "next_cursor"; -const SHOW_WINDOW_ID: &str = "show_window"; -const QUIT_ID: &str = "quit"; - -// Shared state between the UI and the tray icon -struct SharedState { - current_index: usize, - cursors: Vec, - current_cursor_image: Option, - running: bool, - window_visible: bool, // Track if the window is visible -} - -// Our application state -struct CursorRush { - cursors: Vec, - current_index: usize, - shared_state: Option>>, - main_window: Option, // Store the main window ID - tray_icon: Option, // Store the TrayIcon object to keep it alive -} - -// Make sure the default impl initializes tray_icon to None -impl Default for CursorRush { - fn default() -> Self { - Self { - cursors: Vec::new(), - current_index: 0, - shared_state: None, - main_window: None, - tray_icon: None, // Initialize as None - } - } -} - -// Messages that can be sent in our application -#[derive(Debug, Clone)] -enum Message { - NextCursor, - SelectCursor(usize), - Quit, - TrayEvent, // Used as a no-op message - CloseRequested(iced::window::Id), // Message for window close requests with the real window ID - ShowWindow, // Message to show the window from tray - WindowHidden, // Message indicating window was successfully hidden - WindowShown, // Message indicating window was successfully shown -} - -// Function to load all PNG files from the assets directory -fn load_cursor_files() -> Vec { - let mut cursors = Vec::new(); - - println!("Looking for cursor PNG files in assets directory..."); - - // Check if assets directory exists - if !std::path::Path::new("assets").exists() { - println!("Assets directory not found!"); - return cursors; - } - - for entry in WalkDir::new("assets").into_iter().filter_map(|e| e.ok()) { - let path = entry.path(); - println!("Found file: {}", path.display()); - if path.is_file() && path.extension().map_or(false, |ext| ext == "png") { - let name = path.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("Unknown") - .to_string(); - - println!("Adding cursor: {} at path {}", name, path.display()); - cursors.push(CursorInfo { - name, - path: path.to_path_buf(), - }); - } - } - - println!("Found {} cursor PNG files", cursors.len()); - - // Sort cursors by name for consistent ordering - cursors.sort_by(|a, b| a.name.cmp(&b.name)); - cursors -} - -// Function to validate a cursor image -fn validate_cursor_image(path: &Path) -> bool { - // Load the image to validate it - match image_open(path) { - Ok(img) => { - let (width, height) = img.dimensions(); - - // Ensure reasonable dimensions - if width > 128 || height > 128 || width == 0 || height == 0 { - eprintln!("Image dimensions not suitable for cursor: {}x{}", width, height); - return false; - } - - true - }, - Err(e) => { - eprintln!("Failed to open image {}: {}", path.display(), e); - false - }, - } -} - -// Apply the cursor at the given path -fn apply_cursor(path: &Path) -> bool { - // Validate the cursor image - if validate_cursor_image(path) { - // Use platform-specific cursor image update (not hiding system cursor again) - if platform::update_cursor_image(path) { - println!("Successfully updated cursor image"); - return true; - } else { - eprintln!("Failed to update cursor image"); - } - } - false -} - -// Global channel for tray events -use once_cell::sync::Lazy; - -static TRAY_SENDER: Lazy>>> = - Lazy::new(|| std::sync::Mutex::new(None)); - -// Top-level subscription function to listen for events -fn subscription(_state: &CursorRush) -> Subscription { - // Create a vector to hold all subscriptions - let mut subs = vec![ - // Listen for events but filter out mouse movement events to avoid spamming - iced::event::listen().map(|event| { - match event { - // Skip cursor movement events to avoid spamming - iced::Event::Mouse(iced::mouse::Event::CursorMoved { .. }) => { - // Return TrayEvent which is essentially a no-op - Message::TrayEvent - }, - // Handle other events - _ => Message::TrayEvent, // Use TrayEvent as a no-op message - } - }), - - // Specifically subscribe to window close requests - // This is the key to handling the X button properly - iced::window::close_requests().map(|window_id| { - println!("Window close requested via X button for window ID: {:?}", window_id); - Message::CloseRequested(window_id) - }), - ]; - - // Add a subscription for tray events - // We'll use a simple channel that the tray icon can send messages to - let tray_subscription = Subscription::run( - // Stream generator function - || { - // Create a channel that the tray icon can send messages to - let (tx, rx) = mpsc::unbounded::(); - - // Store the sender in the global state - *TRAY_SENDER.lock().unwrap() = Some(tx); - - // Return the receiver as a stream - rx - } - ); - - subs.push(tray_subscription); - - // Combine all subscriptions - Subscription::batch(subs) -} - -// Boot function to initialize the application -fn boot() -> (CursorRush, Task) { - // Load cursor files from assets directory - let cursors = load_cursor_files(); - - if cursors.is_empty() { - eprintln!("No cursor PNG files found in assets directory!"); - return (CursorRush::default(), Task::none()); - } - - // Load the first cursor image - let current_cursor_image = if let Some(cursor) = cursors.first() { - match image_open(&cursor.path) { - Ok(img) => Some(img.to_rgba8()), - Err(_) => None, - } - } else { - None - }; - - // Hide the system cursor once at startup - if !platform::hide_system_cursor() { - eprintln!("Failed to hide system cursor at startup!"); - } - - // Apply the first cursor by default - if let Some(cursor) = cursors.first() { - apply_cursor(&cursor.path); - // No need to call render_cursor anymore as the mouse hook handles rendering - } - - // Create shared state - let shared_state = Arc::new(Mutex::new(SharedState { - current_index: 0, - cursors: cursors.clone(), - current_cursor_image, - running: true, - window_visible: true, // Window is initially visible - })); - - // Initialize the application state with loaded cursors - let mut state = CursorRush::default(); - state.cursors = cursors.clone(); // Clone here - state.shared_state = Some(shared_state.clone()); - - // Get the global tray sender (will be initialized by the subscription) - // We'll set it up in the state after the subscription is running - - // Set up the tray icon AND store the result in the state - // The tray sender will be available from the global state - state.tray_icon = setup_tray_icon(&state.cursors, shared_state.clone()); // Pass the cloned cursors - if state.tray_icon.is_none() { - eprintln!("Failed to initialize tray icon, application might not function correctly in background."); - // Optionally, decide how to handle this failure (e.g., exit or run without tray) - } - - // We'll get the real window ID from the CloseRequested event - // No need to create a window ID here - - // Set up global hotkey manager - let hotkey_manager = match GlobalHotKeyManager::new() { - Ok(manager) => manager, - Err(e) => { - eprintln!("Failed to create hotkey manager: {}", e); - // Continue without hotkey support - GlobalHotKeyManager::new().expect("Critical failure: could not create hotkey manager") - } - }; - - // Register Ctrl+Shift+F12 as the exit hotkey (uncommon but easy to remember) - use global_hotkey::hotkey::{Code, Modifiers}; - - // We don't need a unique ID for the hotkey in this simple case - - let hotkey = GlobalHotKey::new( - Some(Modifiers::CONTROL | Modifiers::SHIFT), - Code::F12, - ); - - // Register the hotkey with better error handling - match hotkey_manager.register(hotkey) { - Ok(_) => { - println!("Successfully registered Ctrl+Shift+F12 as exit hotkey"); - }, - Err(e) => { - eprintln!("Failed to register hotkey: {}", e); - // Try registering with a different hotkey as fallback - let fallback_hotkey = GlobalHotKey::new( - Some(Modifiers::CONTROL | Modifiers::ALT), - Code::F12, - ); - - match hotkey_manager.register(fallback_hotkey) { - Ok(_) => println!("Registered fallback hotkey Ctrl+Alt+F12 instead"), - Err(e) => eprintln!("Failed to register fallback hotkey: {}", e), - } - } - } - - println!("Press Ctrl+Shift+F12 to exit the application"); - - // We're using the event-driven approach with the subscription function for mouse tracking - - // We no longer need the thread to forward messages from rx to tx_clone_ui - // since we're using the iced subscription system directly - - // Spawn a thread to listen for hotkeys - let state_clone = shared_state.clone(); - thread::spawn(move || { - println!("Hotkey listener thread started"); - let event_receiver = global_hotkey::GlobalHotKeyEvent::receiver(); - - loop { - match event_receiver.try_recv() { - Ok(event) => { - println!("Received hotkey event: {:?}", event); - if event.state == HotKeyState::Pressed { - println!("Exit hotkey pressed, sending Quit message..."); - // Send Quit message instead of direct exit - // Get the global sender - if let Some(tx) = TRAY_SENDER.lock().unwrap().as_ref() { - if let Err(e) = tx.unbounded_send(Message::Quit) { - eprintln!("Failed to send Quit message from hotkey thread: {}", e); - // Fallback to direct exit if channel fails - platform::restore_cursor(); - std::process::exit(0); - } - } else { - eprintln!("Hotkey: Global sender not initialized yet"); - // Fallback to direct exit if sender not available - platform::restore_cursor(); - std::process::exit(0); - } - // Don't exit directly here, let the main thread handle it. - } - }, - Err(_) => { - // No events or channel disconnected, sleep briefly - std::thread::sleep(Duration::from_millis(50)); - - // Check if we should exit (will be checked in the loop condition below) - } - } - // Check shared state running flag (optional, main thread handles exit) - if let Ok(state) = state_clone.lock() { - if !state.running { - println!("Hotkey listener detected running=false, exiting thread."); - break; - } - } - } - }); - - // Using the event-driven approach with the subscription function for cursor tracking - - (state, Task::none()) -} +mod integrations; fn main() -> iced::Result { // Initialize Steam - let _steam = steam::Steam::new(); + let _steam = integrations::steam::Steam::new(); - // Note: We can't use a consistent window ID across boot() and main() - // because iced creates its own window ID internally - // Instead, we'll capture the real window ID in the CloseRequested event - iced::application("Cursor Rush", update, view) - .theme(|_| Theme::Dark) - .subscription(subscription) + // Run the application + iced::application("Cursor Rush", app::update, ui::view_app) + .theme(|_| iced::Theme::Dark) + .subscription(app::subscription) .centered() // This is the key - we need to tell iced not to exit when the window close button is clicked .exit_on_close_request(false) // run_with expects a function that returns (State, Task), not the tuple directly - .run_with(boot) -} - -// Set up the system tray icon -// Simplified signature without passing the channel -fn setup_tray_icon( - cursors: &[CursorInfo], - shared_state: Arc>, -) -> Option { // Return Option - if cursors.is_empty() { - return None; // Return None if no cursors - } - - // Create a menu - let menu = Menu::with_items(&[ - &tray_icon::menu::MenuItem::with_id(NEXT_CURSOR_ID, "Next cursor", true, None), - &tray_icon::menu::MenuItem::with_id(SHOW_WINDOW_ID, "Show window", true, None), - &tray_icon::menu::MenuItem::with_id(QUIT_ID, "Quit", true, None), - ]).ok()?; // Use ok()? to convert Result to Option - - - // Use the first PNG as the tray icon - let icon_path = &cursors[0].path; - let img = image_open(icon_path).ok()?; // Use ok()? - let rgba_img = img.to_rgba8(); - let (width, height) = rgba_img.dimensions(); - let icon = tray_icon::Icon::from_rgba(rgba_img.into_raw(), width, height).ok()?; // Use ok()? - - // Build the tray icon - println!("Building tray icon..."); - let tray_result = TrayIconBuilder::new() - .with_tooltip("Cursor Rush") - .with_icon(icon) - .with_menu(Box::new(menu)) - .with_menu_on_left_click(false) // Disable menu on left click - only show on right click - .build(); - - println!("Tray icon configuration: menu_on_left_click = false"); - - // Store the tray icon itself - let tray = match tray_result { - Ok(tray_instance) => { - println!("Tray icon built successfully"); - tray_instance // Assign the TrayIcon object - }, - Err(e) => { - eprintln!("Failed to build tray icon: {}", e); - return None; // Return None on error - } - }; - - // Listen for tray menu events in a separate thread - let state_clone = shared_state.clone(); - println!("Spawning tray menu event listener thread..."); - thread::spawn(move || { - let menu_channel = MenuEvent::receiver(); - println!("Tray menu event listener waiting for events..."); - - while let Ok(event) = menu_channel.recv() { - println!("Tray menu event received: ID = {:?}", event.id); - let msg_to_send = match event.id.as_ref() { - NEXT_CURSOR_ID => { - println!("Tray: Next Cursor requested"); - match state_clone.lock() { - Ok(mut state) => { - if !state.cursors.is_empty() { - state.current_index = (state.current_index + 1) % state.cursors.len(); - if let Some(cursor) = state.cursors.get(state.current_index) { - println!("Tray: Updating cursor image: {}", cursor.name); - apply_cursor(&cursor.path); - // No need to call render_cursor anymore as the mouse hook handles rendering - state.current_cursor_image = image_open(&cursor.path).ok().map(|img| img.to_rgba8()); - } - } - }, - Err(e) => eprintln!("Tray: Failed to lock shared state for next cursor: {}", e), - } - // Send TrayEvent just to trigger UI update if needed - Some(Message::TrayEvent) - }, - SHOW_WINDOW_ID => { - println!("Tray: Show Window requested"); - // Send ShowWindow message to main thread - Some(Message::ShowWindow) - }, - QUIT_ID => { - println!("Tray: Quit requested"); - // Send Quit message to main thread - Some(Message::Quit) - }, - _ => { - println!("Tray: Unknown menu event ID: {:?}", event.id); - None // Don't send a message for unknown IDs - } - }; - - // Send the determined message to the main UI thread using the global sender - if let Some(msg) = msg_to_send { - // Get the global sender - if let Some(tx) = TRAY_SENDER.lock().unwrap().as_ref() { - if let Err(e) = tx.unbounded_send(msg.clone()) { // Use clone if msg is needed after send - eprintln!("Tray: Failed to send {:?} message: {}", msg, e); - break; // Stop thread if channel broken - } - // If Quit message was sent, break the loop to exit the listener thread - if matches!(msg, Message::Quit) { - println!("Tray: Quit message sent, exiting listener thread"); - break; - } - } else { - eprintln!("Tray: Global sender not initialized yet"); - } - } - } - - println!("Tray menu event listener thread finished"); - }); - - // Set up a direct event handler for tray icon events - // This is the preferred way to handle tray icon clicks - println!("Setting up tray icon click event handler..."); - - // Use set_event_handler to directly handle tray icon events - TrayIconEvent::set_event_handler(Some(move |event| { - // Only process important events, filter out mouse movements - match event { - // Handle click events - TrayIconEvent::Click { button, .. } => { - println!("Tray: Click detected with button: {:?}", button); - // Left click should show the window - if button == MouseButton::Left { - println!("Tray: Left click detected, sending ShowWindow message"); - // Get the global sender - if let Some(tx) = TRAY_SENDER.lock().unwrap().as_ref() { - if let Err(e) = tx.unbounded_send(Message::ShowWindow) { - eprintln!("Tray: Failed to send ShowWindow message via handler: {}", e); - } - } else { - eprintln!("Tray: Global sender not initialized yet for click handler"); - } - } - }, - // Handle double-click events - TrayIconEvent::DoubleClick { button, .. } => { - println!("Tray: Double click detected with button: {:?}", button); - // Double left click should also show the window - if button == MouseButton::Left { - println!("Tray: Double left click detected, sending ShowWindow message"); - // Get the global sender - if let Some(tx) = TRAY_SENDER.lock().unwrap().as_ref() { - if let Err(e) = tx.unbounded_send(Message::ShowWindow) { - eprintln!("Tray: Failed to send ShowWindow message via handler on double click: {}", e); - } - } else { - eprintln!("Tray: Global sender not initialized yet for double click handler"); - } - } - }, - // Silently ignore mouse movement events - TrayIconEvent::Move { .. } => { - // Don't log anything for mouse movements - }, - // Silently ignore mouse enter/leave events - TrayIconEvent::Enter { .. } | TrayIconEvent::Leave { .. } => { - // Don't log anything for mouse enter/leave - }, - // Log other events for debugging (but not mouse-related ones) - _ => { - println!("Tray: Event received: {:?}", event); - } - } - })); - - println!("Tray setup complete using set_event_handler for clicks."); // Updated log - Some(tray) // Return the TrayIcon object -} - -// Helper function to hide a window to the tray -fn hide_window_to_tray(window_id: iced::window::Id) -> Task { - println!("Attempting to hide window with ID: {:?}", window_id); - - // Use ONLY the iced method for hiding. Platform specific might interfere. - let hide_task: Task<()> = iced::window::change_mode(window_id, iced::window::Mode::Hidden); - - hide_task.map(|_: ()| { - println!("Window hide task completed (via iced)"); - // Return a specific message to confirm the window was hidden - Message::WindowHidden - }) -} - -// Update function to handle messages -fn update(state: &mut CursorRush, message: Message) -> Task { - match message { - Message::NextCursor => { - if !state.cursors.is_empty() { - state.current_index = (state.current_index + 1) % state.cursors.len(); - - // Apply the new cursor - if let Some(cursor) = state.cursors.get(state.current_index) { - apply_cursor(&cursor.path); - // No need to call render_cursor anymore as the mouse hook handles rendering - - // Load the new cursor image - let current_cursor_image = match image::open(&cursor.path) { - Ok(img) => Some(img.to_rgba8()), - Err(_) => None, - }; - - // Update shared state - if let Some(shared) = &state.shared_state { - if let Ok(mut shared_state) = shared.lock() { - shared_state.current_index = state.current_index; - shared_state.current_cursor_image = current_cursor_image; - } - } - } - } - Task::none() - }, - Message::SelectCursor(idx) => { - if idx < state.cursors.len() { - state.current_index = idx; - - // Apply the selected cursor - if let Some(cursor) = state.cursors.get(state.current_index) { - apply_cursor(&cursor.path); - // No need to call render_cursor anymore as the mouse hook handles rendering - - // Load the new cursor image - let current_cursor_image = match image_open(&cursor.path) { - Ok(img) => Some(img.to_rgba8()), - Err(_) => None, - }; - - // Update shared state - if let Some(shared) = &state.shared_state { - if let Ok(mut shared_state) = shared.lock() { - shared_state.current_index = state.current_index; - shared_state.current_cursor_image = current_cursor_image; - } - } - } - } - Task::none() - }, - Message::Quit => { - println!("Update: Handling Quit message"); - - // Set running flag to false to signal other threads (like hotkey listener) - if let Some(shared) = &state.shared_state { - if let Ok(mut shared_state) = shared.lock() { - shared_state.running = false; - } - } - - // Restore the system cursor before exiting - platform::restore_cursor(); - - // Cleanly drop the tray icon *before* exiting iced - // This removes the icon from the system tray immediately - if let Some(tray) = state.tray_icon.take() { - drop(tray); - println!("Tray icon explicitly dropped."); - } - - // Use iced's exit mechanism for a cleaner shutdown - println!("Requesting application exit via iced::exit..."); - iced::exit() - }, - Message::TrayEvent => { - // Sync with shared state - if let Some(shared) = &state.shared_state { - if let Ok(shared_state) = shared.lock() { - state.current_index = shared_state.current_index; - } - } - Task::none() - }, - Message::CloseRequested(window_id) => { - println!("Handling CloseRequested: Hiding window with ID: {:?}", window_id); - - // Store the real window ID for future use - // This is critical for showing the window later - state.main_window = Some(window_id); - println!("Stored window ID: {:?} for future use", window_id); - - // Update shared state first - if let Some(shared) = &state.shared_state { - match shared.lock() { - Ok(mut shared_state) => { - if shared_state.window_visible { // Only hide if visible - shared_state.window_visible = false; - println!("Set window_visible to false in shared state"); - println!("Hiding window with ID: {:?}, app continues running in tray", window_id); - return hide_window_to_tray(window_id); - } else { - println!("Window already hidden, ignoring CloseRequested."); - return Task::none(); // Don't try to hide again - } - } - Err(e) => { - eprintln!("Error locking shared state for hiding: {}", e); - return Task::none(); - } - } - } else { - eprintln!("Error: Shared state not available for hiding."); - return Task::none(); - } - }, - Message::WindowHidden => { - println!("Window successfully hidden to tray"); - Task::none() - }, - Message::WindowShown => { - println!("Update: Received WindowShown message. Window should now be visible."); - Task::none() - }, - Message::ShowWindow => { - println!("Handling ShowWindow: Attempting to show window"); // More specific starting log - - if let Some(window_id) = state.main_window { - println!("Using stored window ID: {:?}", window_id); - - // It's generally safer to attempt the show operation even if the state *thinks* it's visible. - // Let's update the shared state *after* we know the task is being created. - - println!("Creating show/focus tasks for window ID: {:?}", window_id); - let show_task = iced::window::change_mode(window_id, iced::window::Mode::Windowed); - let focus_task = iced::window::gain_focus(window_id); - - // Update shared state *before* returning the task, but after creating it. - if let Some(shared) = &state.shared_state { - match shared.lock() { - Ok(mut shared_state) => { - if !shared_state.window_visible { // Only log if changing state - println!("Updating shared state: window_visible = true"); - shared_state.window_visible = true; - } else { - println!("Shared state already indicates window is visible."); - } - } - Err(e) => { - eprintln!("Error locking shared state during show: {}", e); - // Proceed with showing the window anyway, state might be out of sync - } - } - } else { - eprintln!("Shared state unavailable during show."); - // Proceed with showing the window anyway - } - - println!("Returning Task::batch to show and focus window."); - // Return the batch task - return Task::batch(vec![show_task, focus_task]) - .map(|_: ()| { - // This message confirms the Iced runtime *completed* the tasks. - println!("Iced runtime reports show/focus tasks completed successfully."); - Message::WindowShown // Send confirmation message - }); - - } else { - eprintln!("Error: Main window ID not found when trying to show. Cannot create tasks."); - Task::none() // Cannot proceed without window_id - } - }, - - } -} - -// View function to render the UI -fn view(state: &CursorRush) -> Element { - let mut content = Column::new() - .spacing(16) - .push(text("Cursor Rush").size(24)); - - // Add a button for each cursor - if !state.cursors.is_empty() { - let current_cursor = &state.cursors[state.current_index]; - content = content.push(text(format!("Current cursor: {}", current_cursor.name))); - - // Display the current cursor image as a preview - let img_handle = iced_image::Handle::from_path(¤t_cursor.path); - let cursor_image = iced_image::Image::new(img_handle) - .width(Length::Fixed(64.0)) - .height(Length::Fixed(64.0)); - - content = content.push(cursor_image); - - // Create a row of cursor buttons - let mut cursor_row = Row::new().spacing(10); - - for (i, cursor) in state.cursors.iter().enumerate() { - // Create a small preview image for each cursor - let preview_handle = iced_image::Handle::from_path(&cursor.path); - let preview_image = iced_image::Image::new(preview_handle) - .width(Length::Fixed(32.0)) - .height(Length::Fixed(32.0)); - - let btn = button( - Row::new() - .spacing(5) - .push(preview_image) - .push(text(&cursor.name).size(16)) - ) - .on_press(Message::SelectCursor(i)); - - cursor_row = cursor_row.push(btn); - } - - content = content - .push(cursor_row) - .push(button("Next cursor").on_press(Message::NextCursor)) - .push(button("Quit").on_press(Message::Quit)); - - // Add instructions for the hotkey and close button behavior - content = content.push( - container( - Column::new() - .spacing(5) - .push(text("Press Ctrl+Shift+F12 to exit the application").size(16)) - .push(text("Close button (X) minimizes to system tray").size(16)) - ) - .padding(10) - ); - } else { - content = content.push(text("No cursors found in assets directory")); - } - - // Cursor position display removed to avoid spammy updates - - // Create the main container - container(content) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill) - .padding(20) - .into() + .run_with(app::boot) } diff --git a/src/platform/linux.rs b/src/platform/linux.rs new file mode 100644 index 0000000..6881b1a --- /dev/null +++ b/src/platform/linux.rs @@ -0,0 +1,21 @@ +// Linux implementation + +use std::path::Path; + +pub fn hide_system_cursor() -> bool { + println!("Linux system cursor hiding not yet implemented"); + false +} + +pub fn update_cursor_image(_path: &Path) -> bool { + println!("Linux cursor image update not yet implemented"); + false +} + +pub fn restore_cursor() { + println!("Linux cursor restoration not yet implemented"); +} + +pub fn render_cursor() { + // No-op +} diff --git a/src/platform/macos.rs b/src/platform/macos.rs new file mode 100644 index 0000000..89e2754 --- /dev/null +++ b/src/platform/macos.rs @@ -0,0 +1,21 @@ +// macOS implementation + +use std::path::Path; + +pub fn hide_system_cursor() -> bool { + println!("macOS system cursor hiding not yet implemented"); + false +} + +pub fn update_cursor_image(_path: &Path) -> bool { + println!("macOS cursor image update not yet implemented"); + false +} + +pub fn restore_cursor() { + println!("macOS cursor restoration not yet implemented"); +} + +pub fn render_cursor() { + // No-op +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs new file mode 100644 index 0000000..873c902 --- /dev/null +++ b/src/platform/mod.rs @@ -0,0 +1,33 @@ +// Platform-specific cursor handling + +// Windows implementation +#[cfg(windows)] +mod windows; + +// macOS implementation +#[cfg(target_os = "macos")] +mod macos; + +// Linux implementation +#[cfg(target_os = "linux")] +mod linux; + +// Unsupported platforms +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] +mod unsupported; + +// --- Public Exports --- + +// Export the platform-specific functions +#[cfg(windows)] +pub use windows::{hide_system_cursor, update_cursor_image, restore_cursor}; + +#[cfg(target_os = "macos")] +pub use macos::{hide_system_cursor, update_cursor_image, restore_cursor, render_cursor}; + +#[cfg(target_os = "linux")] +pub use linux::{hide_system_cursor, update_cursor_image, restore_cursor, render_cursor}; + +// Default implementation for other platforms +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] +pub use unsupported::{hide_system_cursor, update_cursor_image, restore_cursor, render_cursor}; diff --git a/src/platform/unsupported.rs b/src/platform/unsupported.rs new file mode 100644 index 0000000..92647c5 --- /dev/null +++ b/src/platform/unsupported.rs @@ -0,0 +1,21 @@ +// Default implementation for unsupported platforms + +use std::path::Path; + +pub fn hide_system_cursor() -> bool { + println!("System cursor hiding not supported"); + false +} + +pub fn update_cursor_image(_path: &Path) -> bool { + println!("Cursor image update not supported"); + false +} + +pub fn restore_cursor() { + println!("Cursor restoration not supported"); +} + +pub fn render_cursor() { + // No-op +} diff --git a/src/platform/windows/cursor.rs b/src/platform/windows/cursor.rs new file mode 100644 index 0000000..4f2e34e --- /dev/null +++ b/src/platform/windows/cursor.rs @@ -0,0 +1,492 @@ +// Windows platform cursor management + +use std::path::Path; +use std::ptr::null_mut; +use std::sync::atomic::Ordering; +use winapi::{ + um::winuser::{ + GetDC, ReleaseDC, GetCursorPos, LoadCursorW, SetSystemCursor, CopyIcon, + CreateCursor, SystemParametersInfoW, SPI_SETCURSORS, UpdateLayeredWindow, + SetWindowPos, HWND_TOPMOST, SWP_NOSIZE, SWP_NOMOVE, SWP_NOACTIVATE, + MAKEINTRESOURCEW, IDC_ARROW, ULW_ALPHA, DestroyWindow, + }, + um::wingdi::{ + CreateCompatibleDC, DeleteDC, SelectObject, DeleteObject, + AC_SRC_OVER, AC_SRC_ALPHA, BITMAPINFO, BITMAPINFOHEADER, + DIB_RGB_COLORS, BI_RGB, CreateDIBSection, + }, + um::consoleapi::SetConsoleCtrlHandler, + shared::minwindef::{TRUE, FALSE}, + shared::windef::{POINT, SIZE}, +}; +use ::image::{RgbaImage, open as image_open}; +use winapi::um::wingdi::BLENDFUNCTION; + +use super::types::*; +use super::state::*; +use super::window::create_cursor_window; +use super::hook::{install_mouse_hook_if_needed, uninstall_mouse_hook}; + +// Control handler function for console events (Ctrl+C, close, etc.) +extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 { + println!("Control event received, attempting cleanup..."); + cleanup(); // Call cleanup logic + FALSE // Allow other handlers (like default exit) to run +} + +// Function to update the cursor position +pub fn update_cursor_position(point: &POINT) { + // Lock the statics needed + let mut last_pos_guard = LAST_CURSOR_POS.lock().unwrap(); + let cursor_image_guard = CURRENT_CURSOR_IMAGE.lock().unwrap(); + + // Get Option then map to Option + let maybe_hwnd_wrapper = *CURSOR_WINDOW.lock().unwrap(); + let maybe_bitmap_wrapper = *CURSOR_BITMAP.lock().unwrap(); + let maybe_dc_wrapper = *CURSOR_DC.lock().unwrap(); + + // Check if we need to update (if position changed) + let position_changed = last_pos_guard.map_or(true, |last| last.x != point.x || last.y != point.y); + + if position_changed { + // Update last position *within the lock* + *last_pos_guard = Some(*point); + + // Update the cursor window position if all resources exist + if let (Some(hwnd_wrapper), Some(ref cursor_image), Some(_bitmap_wrapper), Some(dc_wrapper)) = + (maybe_hwnd_wrapper, cursor_image_guard.as_ref(), maybe_bitmap_wrapper, maybe_dc_wrapper) + { + // Extract the raw handles using .0 + let hwnd = hwnd_wrapper.0; + let dc = dc_wrapper.0; + + // Get cursor image dimensions + let (width, height) = cursor_image.dimensions(); + + unsafe { + let screen_dc = GetDC(null_mut()); + if !screen_dc.is_null() { + // Set up source and destination points and size + let src_point = POINT { x: 0, y: 0 }; + // Adjust position based on image dimensions (center hotspot) + let dst_point = POINT { + x: point.x - (width as i32 / 2), + y: point.y - (height as i32 / 2) + }; + let size = SIZE { + cx: width as i32, + cy: height as i32 + }; + + // Set up blend function for alpha blending + let blend = BLENDFUNCTION { + BlendOp: AC_SRC_OVER, + BlendFlags: 0, + SourceConstantAlpha: 255, // Fully opaque + AlphaFormat: AC_SRC_ALPHA, // Use per-pixel alpha + }; + + // Update the layered window atomically + let result = UpdateLayeredWindow( + hwnd, + screen_dc, + &dst_point as *const POINT as *mut POINT, + &size as *const SIZE as *mut SIZE, + dc, + &src_point as *const POINT as *mut POINT, + 0, // No color key + &blend as *const BLENDFUNCTION as *mut BLENDFUNCTION, + ULW_ALPHA, + ); + + // Re-assert TOPMOST after successful update, with SWP_NOACTIVATE to prevent stealing focus + if result != 0 { + SetWindowPos( + hwnd, + HWND_TOPMOST, + 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + } + + if result == 0 { + use std::io::Error; + let error = Error::last_os_error(); + eprintln!("UpdateLayeredWindow failed: {}", error); + } + + ReleaseDC(null_mut(), screen_dc); + } else { + eprintln!("Failed to get screen DC for UpdateLayeredWindow"); + } + } + } + } +} + +// Create a bitmap from an RGBA image +unsafe fn create_bitmap_from_image(image: &RgbaImage) -> Option<(SyncHBITMAP, SyncHDC)> { + let (width, height) = image.dimensions(); + let width_i32 = width as i32; + let height_i32 = height as i32; + + // Create a device context compatible with the screen + let screen_dc = GetDC(null_mut()); + if screen_dc.is_null() { + eprintln!("Failed to get screen DC"); + return None; + } + + // Create a compatible DC for our bitmap + let memory_dc = CreateCompatibleDC(screen_dc); + if memory_dc.is_null() { + eprintln!("Failed to create compatible DC"); + ReleaseDC(null_mut(), screen_dc); + return None; + } + + // Set up BITMAPINFO structure for DIB + let mut bitmap_info: BITMAPINFO = std::mem::zeroed(); + bitmap_info.bmiHeader.biSize = std::mem::size_of::() as u32; + bitmap_info.bmiHeader.biWidth = width_i32; + bitmap_info.bmiHeader.biHeight = -height_i32; // Negative for top-down DIB + bitmap_info.bmiHeader.biPlanes = 1; + bitmap_info.bmiHeader.biBitCount = 32; // 32 bits per pixel (BGRA) + bitmap_info.bmiHeader.biCompression = BI_RGB; + + // Create the DIB section + let mut bits_ptr: *mut std::ffi::c_void = null_mut(); + let bitmap = CreateDIBSection( + memory_dc, + &bitmap_info, + DIB_RGB_COLORS, + &mut bits_ptr, + null_mut(), + 0, + ); + + if bitmap.is_null() || bits_ptr.is_null() { + eprintln!("Failed to create DIB section"); + DeleteDC(memory_dc); + ReleaseDC(null_mut(), screen_dc); + return None; + } + + // Select the bitmap into the DC + let old_bitmap = SelectObject(memory_dc, bitmap as _); + if old_bitmap.is_null() { + eprintln!("Failed to select bitmap into DC"); + DeleteObject(bitmap as _); + DeleteDC(memory_dc); + ReleaseDC(null_mut(), screen_dc); + return None; + } + + // Copy the image data to the DIB, converting RGBA to BGRA with premultiplied alpha + let bits = std::slice::from_raw_parts_mut(bits_ptr as *mut u8, (width * height * 4) as usize); + for y in 0..height { + for x in 0..width { + let pixel = image.get_pixel(x, y); + let idx = ((y * width + x) * 4) as usize; + + // Get RGBA components + let r = pixel[0] as u16; + let g = pixel[1] as u16; + let b = pixel[2] as u16; + let a = pixel[3] as u16; + + // Premultiply alpha and convert to BGRA + bits[idx + 0] = ((b * a) / 255) as u8; // B + bits[idx + 1] = ((g * a) / 255) as u8; // G + bits[idx + 2] = ((r * a) / 255) as u8; // R + bits[idx + 3] = a as u8; // A + } + } + + // Clean up + ReleaseDC(null_mut(), screen_dc); + + // Return the bitmap and DC + Some((SyncHBITMAP(bitmap), SyncHDC(memory_dc))) +} + +// Cleanup function to restore cursors and clean up resources +pub fn cleanup() { + // Uninstall the mouse hook FIRST + uninstall_mouse_hook(); + + // Restore system cursors if they were hidden + if CURSOR_HIDDEN.swap(false, Ordering::SeqCst) { // Use swap to ensure it runs only once + println!("Restoring system cursors..."); + unsafe { + // Force Windows to reload default cursor settings + SystemParametersInfoW(SPI_SETCURSORS, 0, null_mut(), 0); + // Also try the A version just in case + super::state::reset_system_cursors(); + } + + let mut restoration_success = false; + + // Lock ORIGINAL_CURSORS once + let original_cursors_guard = ORIGINAL_CURSORS.lock().unwrap(); + if let Some(ref original_cursors) = *original_cursors_guard { + if !original_cursors.is_empty() { + println!("Attempting to restore {} saved original cursors", original_cursors.len()); + let mut method1_success = true; + unsafe { + for &(cursor_id, sync_hcursor) in original_cursors.iter() { + // Extract the raw handle using .0 + let original_hcursor = sync_hcursor.0; + // Make a fresh copy *each time* we set it + let cursor_copy = CopyIcon(original_hcursor); + if !cursor_copy.is_null() { + if SetSystemCursor(cursor_copy, cursor_id) == 0 { + eprintln!("Failed to restore system cursor {}", cursor_id); + // Don't destroy the copy on failure, let Windows manage SetSystemCursor's handle + method1_success = false; + } + // SetSystemCursor takes ownership if successful, CopyIcon needs no manual destroy + } else { + eprintln!("Failed to copy original cursor for ID {}", cursor_id); + method1_success = false; + } + } + } + if method1_success { + println!("Successfully restored original cursors from saved state."); + restoration_success = true; + } + } + } + // Drop the guard + drop(original_cursors_guard); + + // Method 2: Fallback using standard arrow (less critical now with API reset) + if !restoration_success { + println!("Falling back to setting all cursors to standard arrow (IDC_ARROW)"); + unsafe { + // Use MAKEINTRESOURCEW to convert IDC_ARROW to a resource pointer + let arrow_cursor = LoadCursorW(null_mut(), MAKEINTRESOURCEW(IDC_ARROW as u16)); + if !arrow_cursor.is_null() { + for &cursor_id in SYSTEM_CURSORS.iter() { + // Make a copy for each system cursor ID + let arrow_copy = CopyIcon(arrow_cursor); + if !arrow_copy.is_null() { + if SetSystemCursor(arrow_copy, cursor_id) == 0 { + eprintln!("Failed to set system cursor {} to arrow", cursor_id); + // System takes ownership if successful + } + } else { + eprintln!("Failed to copy arrow cursor for ID {}", cursor_id); + } + } + println!("Finished setting cursors to standard arrow."); + restoration_success = true; // Consider this a success even if some minor fails occurred + } else { + eprintln!("Failed to load standard arrow cursor (IDC_ARROW)."); + } + } + } + + // Method 3: Reset via API (already called, but can call again for good measure) + if !restoration_success { + println!("Using system API reset method as final fallback."); + super::state::reset_system_cursors(); // Explicitly call the reset function + } + + println!("System cursors restoration attempt complete."); + } + + // Clean up GDI resources (Bitmap and DC) + // Take ownership from the Mutex> + let bitmap_wrapper_to_delete = CURSOR_BITMAP.lock().unwrap().take(); + let dc_wrapper_to_delete = CURSOR_DC.lock().unwrap().take(); + + unsafe { + if let Some(bitmap_wrapper) = bitmap_wrapper_to_delete { + if DeleteObject(bitmap_wrapper.0 as _) != 0 { + println!("Cursor bitmap deleted during cleanup."); + } else { + eprintln!("Failed to delete cursor bitmap during cleanup."); + } + } + if let Some(dc_wrapper) = dc_wrapper_to_delete { + if DeleteDC(dc_wrapper.0) != 0 { + println!("Cursor DC deleted during cleanup."); + } else { + eprintln!("Failed to delete cursor DC during cleanup."); + } + } + } + + // Destroy the cursor window + let window_wrapper_to_destroy = CURSOR_WINDOW.lock().unwrap().take(); + if let Some(hwnd_wrapper) = window_wrapper_to_destroy { + unsafe { + if DestroyWindow(hwnd_wrapper.0) != 0 { + println!("Cursor window destroyed during cleanup."); + } else { + // GetLastError might provide info, e.g., if already destroyed + eprintln!("Failed to destroy cursor window during cleanup (Error {}).", winapi::um::errhandlingapi::GetLastError()); + } + } + } + + // Reset the last known cursor position + *LAST_CURSOR_POS.lock().unwrap() = None; + + // Unregister control handler (less critical now, but good practice) + if CTRL_HANDLER_REGISTERED.load(Ordering::SeqCst) { + unsafe { + if SetConsoleCtrlHandler(Some(ctrl_handler), FALSE) != 0 { + CTRL_HANDLER_REGISTERED.store(false, Ordering::SeqCst); + println!("Control handler unregistered."); + } else { + eprintln!("Failed to unregister control handler."); + } + } + } +} + +// --- Public API Functions --- + +// Hide the system cursor +pub fn hide_system_cursor() -> bool { + // Initialize once + INIT.get_or_init(|| { + // Register control handler for clean exit + unsafe { + if SetConsoleCtrlHandler(Some(ctrl_handler), TRUE) != 0 { + CTRL_HANDLER_REGISTERED.store(true, Ordering::SeqCst); + println!("Control handler registered for clean exit."); + } else { + eprintln!("Failed to register control handler: {}", std::io::Error::last_os_error()); + } + } + }); + + // Check if already hidden + if CURSOR_HIDDEN.load(Ordering::SeqCst) { + return true; // Already hidden + } + + // Save original cursors before hiding + let mut original_cursors = Vec::new(); + unsafe { + for &cursor_id in SYSTEM_CURSORS.iter() { + // Use MAKEINTRESOURCEW to convert IDC_ARROW to a resource pointer + let empty_cursor = CreateCursor( + null_mut(), // hInst + 0, 0, // hotspot x, y + 1, 1, // width, height (minimum size) + &[0xFF] as *const _ as *const std::ffi::c_void, // AND mask (all bits 1 = transparent) + &[0x00] as *const _ as *const std::ffi::c_void, // XOR mask (all bits 0 = black) + ); + + if !empty_cursor.is_null() { + // Get the original cursor first + let original_cursor = CopyIcon(LoadCursorW(null_mut(), MAKEINTRESOURCEW(cursor_id as u16))); + if !original_cursor.is_null() { + // Save the original cursor for restoration + original_cursors.push((cursor_id, SyncHCURSOR(original_cursor))); + } + + // Set the empty cursor + if SetSystemCursor(empty_cursor, cursor_id) == 0 { + eprintln!("Failed to set system cursor {}", cursor_id); + // Don't destroy the cursor on failure, let Windows manage it + } + // SetSystemCursor takes ownership of the cursor + } else { + eprintln!("Failed to create empty cursor"); + } + } + } + + // Store original cursors for restoration + if !original_cursors.is_empty() { + *ORIGINAL_CURSORS.lock().unwrap() = Some(original_cursors); + } + + // Mark as hidden + CURSOR_HIDDEN.store(true, Ordering::SeqCst); + + // Create the cursor window + unsafe { + let hwnd_wrapper = create_cursor_window(); + if let Some(hwnd) = hwnd_wrapper { + *CURSOR_WINDOW.lock().unwrap() = Some(hwnd); + } else { + eprintln!("Failed to create cursor window"); + return false; + } + } + + // Install the mouse hook + if !install_mouse_hook_if_needed() { + eprintln!("Failed to install mouse hook"); + return false; + } + + true +} + +// Update the cursor image +pub fn update_cursor_image(path: &Path) -> bool { + // Load the image + let image = match image_open(path) { + Ok(img) => img.to_rgba8(), + Err(e) => { + eprintln!("Failed to load cursor image: {}", e); + return false; + } + }; + + // Store the image for rendering + *CURRENT_CURSOR_IMAGE.lock().unwrap() = Some(image.clone()); + + // Create bitmap and DC + unsafe { + // Clean up old bitmap and DC if they exist + let mut bitmap_guard = CURSOR_BITMAP.lock().unwrap(); + let mut dc_guard = CURSOR_DC.lock().unwrap(); + + if bitmap_guard.is_some() { + if let Some(bitmap_wrapper) = bitmap_guard.take() { + DeleteObject(bitmap_wrapper.0 as _); + } + } + + if dc_guard.is_some() { + if let Some(dc_wrapper) = dc_guard.take() { + DeleteDC(dc_wrapper.0); + } + } + + // Create new bitmap and DC + if let Some((bitmap_wrapper, dc_wrapper)) = create_bitmap_from_image(&image) { + *bitmap_guard = Some(bitmap_wrapper); + *dc_guard = Some(dc_wrapper); + } else { + eprintln!("Failed to create bitmap from image"); + return false; + } + } + + // Force an update of the cursor position + unsafe { + let mut point = POINT { x: 0, y: 0 }; + if GetCursorPos(&mut point) != 0 { + update_cursor_position(&point); + } + } + + true +} + +// Restore the system cursor +pub fn restore_cursor() { + cleanup(); +} + +// Render cursor functionality is now handled by the mouse hook diff --git a/src/platform/windows/hook.rs b/src/platform/windows/hook.rs new file mode 100644 index 0000000..1b65d3d --- /dev/null +++ b/src/platform/windows/hook.rs @@ -0,0 +1,125 @@ +// Windows platform mouse hook management + +use std::ptr::null_mut; +use std::sync::atomic::Ordering; +use std::time::Instant; +use winapi::{ + um::winuser::{ + SetWindowsHookExW, UnhookWindowsHookEx, CallNextHookEx, SetWindowPos, + WH_MOUSE_LL, HC_ACTION, MSLLHOOKSTRUCT, + WM_MOUSEMOVE, WM_RBUTTONDOWN, HWND_TOPMOST, SWP_NOSIZE, SWP_NOMOVE, SWP_NOACTIVATE, + }, + um::libloaderapi::GetModuleHandleW, + shared::minwindef::{WPARAM, LPARAM, LRESULT, UINT}, +}; + +use super::types::*; +use super::state::*; +use super::cursor::update_cursor_position; + +// Low-level mouse hook procedure +pub unsafe extern "system" fn low_level_mouse_proc(n_code: i32, w_param: WPARAM, l_param: LPARAM) -> LRESULT { + if n_code == HC_ACTION { + // Extract mouse position from the hook struct for any mouse event + let p_msll_hook_struct = l_param as *const MSLLHOOKSTRUCT; + if !p_msll_hook_struct.is_null() { + let msll_hook_struct = *p_msll_hook_struct; + let current_pos = msll_hook_struct.pt; + + match w_param as UINT { + WM_MOUSEMOVE => { + let mut last_update_time_guard = LAST_MOUSE_MOVE_TIME.lock().unwrap(); + let now = Instant::now(); + + if last_update_time_guard.map_or(true, |last_time| now.duration_since(last_time) >= MOUSE_UPDATE_INTERVAL) { + // update_cursor_position already contains logic to only update if position actually changed via LAST_CURSOR_POS + update_cursor_position(¤t_pos); + *last_update_time_guard = Some(now); + } + }, + // Handle right mouse button down - immediately update cursor position and force topmost + WM_RBUTTONDOWN => { + // Force an immediate update of the cursor position when right-clicking + // This ensures the overlay is visible before the menu appears + update_cursor_position(¤t_pos); + + // Force the cursor window to be topmost + if let Some(hwnd_wrapper) = *CURSOR_WINDOW.lock().unwrap() { + SetWindowPos( + hwnd_wrapper.0, + HWND_TOPMOST, + 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, // SWP_NOACTIVATE is critical + ); + } + }, + // Handle other mouse events if needed + _ => { /* Other mouse messages can be handled here if needed */ } + } + } + } + + // Call the next hook in the chain. + // Pass the HHOOK of our own hook, loaded from CURRENT_HOOK_FOR_CALLBACK. + CallNextHookEx( + CURRENT_HOOK_FOR_CALLBACK.load(Ordering::Relaxed) as _, + n_code, + w_param, + l_param, + ) +} + +// Install mouse hook function +pub fn install_mouse_hook_if_needed() -> bool { + let mut hook_guard = MOUSE_HOOK_HANDLE.lock().unwrap(); + if hook_guard.is_none() { + unsafe { + // GetModuleHandleW(null_mut()) gets the HMODULE for the current process (EXE) + // which is correct for a hook procedure within the same process. + let h_instance = GetModuleHandleW(null_mut()); + if h_instance.is_null() { + eprintln!("Failed to get module handle for setting mouse hook: {}", std::io::Error::last_os_error()); + return false; + } + + let hook = SetWindowsHookExW( + WH_MOUSE_LL, // Hook type: Low-level mouse + Some(low_level_mouse_proc), // Pointer to hook procedure + h_instance, // HMODULE of the DLL containing the hook proc (or current process) + 0 // Thread ID (0 for all threads on current desktop) + ); + + if !hook.is_null() { + // Create wrapped handle + let sync_hook = SyncHHOOK(hook); + *hook_guard = Some(sync_hook); + CURRENT_HOOK_FOR_CALLBACK.store(hook as *mut _, Ordering::SeqCst); + println!("Low-level mouse hook installed successfully."); + return true; + } else { + eprintln!("Failed to install low-level mouse hook: {}", std::io::Error::last_os_error()); + return false; + } + } + } + true // Already installed +} + +// Uninstall mouse hook function +pub fn uninstall_mouse_hook() { + let mut hook_guard = MOUSE_HOOK_HANDLE.lock().unwrap(); + if let Some(hook_wrapper) = hook_guard.take() { // .take() removes it from Option and returns it + unsafe { + // Extract the raw handle using .0 + let hook = hook_wrapper.0; + if UnhookWindowsHookEx(hook) != 0 { + CURRENT_HOOK_FOR_CALLBACK.store(std::ptr::null_mut(), Ordering::SeqCst); + println!("Low-level mouse hook uninstalled successfully."); + } else { + eprintln!("Failed to uninstall low-level mouse hook: {}", std::io::Error::last_os_error()); + // Put it back if unhooking failed, though this is unlikely. + *hook_guard = Some(hook_wrapper); + } + } + } +} diff --git a/src/platform/windows/mod.rs b/src/platform/windows/mod.rs new file mode 100644 index 0000000..7e40c77 --- /dev/null +++ b/src/platform/windows/mod.rs @@ -0,0 +1,10 @@ +// Windows platform implementation module + +mod types; +mod cursor; +mod window; +mod hook; +mod state; + +// Re-export the public API +pub use cursor::{hide_system_cursor, update_cursor_image, restore_cursor}; diff --git a/src/platform/windows/state.rs b/src/platform/windows/state.rs new file mode 100644 index 0000000..9058b2d --- /dev/null +++ b/src/platform/windows/state.rs @@ -0,0 +1,64 @@ +// Windows platform state management + +use std::sync::{Mutex, atomic::{AtomicBool, AtomicPtr}}; +use std::time::Instant; +use winapi::{ + shared::windef::POINT, + shared::minwindef::DWORD, +}; +use ::image::RgbaImage; +use once_cell::sync::{Lazy, OnceCell}; + +use super::types::*; + +// --- Thread-Safe Static Variables --- + +// Use AtomicBool for simple flags +pub static CURSOR_HIDDEN: AtomicBool = AtomicBool::new(false); +pub static CTRL_HANDLER_REGISTERED: AtomicBool = AtomicBool::new(false); + +// Use Lazy> for complex Option types +pub static ORIGINAL_CURSORS: Lazy>>> = Lazy::new(|| Mutex::new(None)); +pub static CURRENT_CURSOR_IMAGE: Lazy>> = Lazy::new(|| Mutex::new(None)); +pub static LAST_CURSOR_POS: Lazy>> = Lazy::new(|| Mutex::new(None)); + +// Use Lazy>> for WinAPI handles with our thread-safe wrappers +pub static CURSOR_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)); +pub static CURSOR_BITMAP: Lazy>> = Lazy::new(|| Mutex::new(None)); +pub static CURSOR_DC: Lazy>> = Lazy::new(|| Mutex::new(None)); + +// Mouse hook related variables +pub static MOUSE_HOOK_HANDLE: Lazy>> = Lazy::new(|| Mutex::new(None)); +// This one is for CallNextHookEx to avoid locking inside the hook callback +pub static CURRENT_HOOK_FOR_CALLBACK: AtomicPtr = + AtomicPtr::new(std::ptr::null_mut()); +pub static LAST_MOUSE_MOVE_TIME: Lazy>> = Lazy::new(|| Mutex::new(None)); + +// Use OnceCell for INIT to avoid `static mut` +pub static INIT: OnceCell<()> = OnceCell::new(); + +// Reset system cursors using the Windows API +pub fn reset_system_cursors() { + unsafe { + // Use both versions for maximum compatibility + winapi::um::winuser::SystemParametersInfoW( + winapi::um::winuser::SPI_SETCURSORS, + 0, + std::ptr::null_mut(), + 0 + ); + + // Import the Windows API function to reset system cursors + #[link(name = "user32")] + extern "system" { + fn SystemParametersInfoA(uiAction: u32, uiParam: u32, pvParam: *mut std::ffi::c_void, fWinIni: u32) -> i32; + } + + SystemParametersInfoA( + SPI_SETCURSORS_A, + 0, + std::ptr::null_mut(), + SPIF_UPDATEINIFILE | SPIF_SENDCHANGE + ); + } +} diff --git a/src/platform/windows/types.rs b/src/platform/windows/types.rs new file mode 100644 index 0000000..537c3e6 --- /dev/null +++ b/src/platform/windows/types.rs @@ -0,0 +1,68 @@ +// Windows platform types and constants + +use std::time::Duration; +use winapi::{ + shared::windef::{HWND, HCURSOR, HBITMAP, HDC, HHOOK}, + shared::minwindef::DWORD, +}; + +// --- Thread-Safe Handle Wrappers --- +// These wrappers make raw Windows handles safe to share between threads + +// Wrapper for HWND (window handle) +#[derive(Copy, Clone, Debug)] +pub struct SyncHWND(pub HWND); +unsafe impl Send for SyncHWND {} +unsafe impl Sync for SyncHWND {} + +// Wrapper for HCURSOR (cursor handle) +#[derive(Copy, Clone, Debug)] +pub struct SyncHCURSOR(pub HCURSOR); +unsafe impl Send for SyncHCURSOR {} +unsafe impl Sync for SyncHCURSOR {} + +// Wrapper for HBITMAP (bitmap handle) +#[derive(Copy, Clone, Debug)] +pub struct SyncHBITMAP(pub HBITMAP); +unsafe impl Send for SyncHBITMAP {} +unsafe impl Sync for SyncHBITMAP {} + +// Wrapper for HDC (device context handle) +#[derive(Copy, Clone, Debug)] +pub struct SyncHDC(pub HDC); +unsafe impl Send for SyncHDC {} +unsafe impl Sync for SyncHDC {} + +// Wrapper for HHOOK (hook handle) +#[derive(Copy, Clone, Debug)] +pub struct SyncHHOOK(pub HHOOK); +unsafe impl Send for SyncHHOOK {} +unsafe impl Sync for SyncHHOOK {} + +// --- Constants --- + +// Constants for SystemParametersInfo +pub const SPI_SETCURSORS_A: u32 = 0x0057; +pub const SPIF_UPDATEINIFILE: u32 = 0x01; +pub const SPIF_SENDCHANGE: u32 = 0x02; + +// Window class name for our cursor window +pub const CURSOR_WINDOW_CLASS: &str = "CursorRushOverlay"; + +// Mouse update interval (throttle to ~120Hz) +pub const MOUSE_UPDATE_INTERVAL: Duration = Duration::from_millis(8); + +// Define all system cursor IDs we'll replace +pub static SYSTEM_CURSORS: [DWORD; 11] = [ + 32512, // IDC_ARROW + 32513, // IDC_IBEAM + 32515, // IDC_CROSS + 32514, // IDC_WAIT + 32649, // IDC_HAND + 32650, // IDC_APPSTARTING + 32648, // IDC_NO + 32646, // IDC_SIZEALL + 32645, // IDC_SIZENS + 32644, // IDC_SIZEWE + 32642, // IDC_SIZENWSE +]; diff --git a/src/platform/windows/window.rs b/src/platform/windows/window.rs new file mode 100644 index 0000000..832d5c5 --- /dev/null +++ b/src/platform/windows/window.rs @@ -0,0 +1,89 @@ +// Windows platform window management + +use std::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; +use std::ptr::null_mut; +use winapi::{ + um::winuser::{ + CreateWindowExW, DestroyWindow, ShowWindow, SetWindowPos, + RegisterClassExW, DefWindowProcW, LoadCursorW, + WS_EX_LAYERED, WS_EX_TRANSPARENT, WS_EX_TOPMOST, WS_EX_TOOLWINDOW, WS_EX_NOACTIVATE, + WS_POPUP, SW_SHOW, HWND_TOPMOST, SWP_NOSIZE, SWP_NOMOVE, + CS_HREDRAW, CS_VREDRAW, WNDCLASSEXW, MAKEINTRESOURCEW, + WM_DESTROY, WM_CLOSE, IDC_ARROW, + }, + shared::windef::HWND, + shared::minwindef::{UINT, WPARAM, LPARAM, LRESULT}, +}; + +use super::types::*; +use super::state::*; + +// Window procedure for our cursor window +pub unsafe extern "system" fn window_proc(hwnd: HWND, msg: UINT, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + match msg { + WM_CLOSE | WM_DESTROY => { + // Clean up resources associated with the window if necessary + DestroyWindow(hwnd); // Ensure window is destroyed on close message + // Remove window handle from our static variable upon destruction + *CURSOR_WINDOW.lock().unwrap() = None; + 0 + }, + // WM_MOUSEMOVE is handled by the low-level mouse hook + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +// Create a layered, topmost window for our cursor +pub unsafe fn create_cursor_window() -> Option { + // Convert class name to wide string + let class_name_wide: Vec = OsStr::new(CURSOR_WINDOW_CLASS) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + // Register window class if not already registered + let mut wc = std::mem::zeroed::(); + wc.cbSize = std::mem::size_of::() as u32; + wc.style = CS_HREDRAW | CS_VREDRAW; + wc.lpfnWndProc = Some(window_proc); + wc.hInstance = null_mut(); + wc.lpszClassName = class_name_wide.as_ptr(); + // Use MAKEINTRESOURCEW to convert IDC_ARROW to a resource pointer + wc.hCursor = LoadCursorW(null_mut(), MAKEINTRESOURCEW(IDC_ARROW as u16)); + + let atom = RegisterClassExW(&wc); + if atom == 0 { + // Check GetLastError() if registration fails + if winapi::um::errhandlingapi::GetLastError() != 1410 { // ERROR_CLASS_ALREADY_EXISTS + eprintln!("Failed to register window class, error: {}", winapi::um::errhandlingapi::GetLastError()); + return None; + } + } + + // Create window with layered, transparent, and topmost attributes + let hwnd = CreateWindowExW( + WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + class_name_wide.as_ptr(), + null_mut(), // Window title (none) + WS_POPUP, // Popup window with no border + 0, 0, // Initial Position (irrelevant, updated immediately) + 1, 1, // Initial Size (irrelevant, determined by bitmap) + null_mut(), // Parent window + null_mut(), // Menu + null_mut(), // Instance + null_mut(), // Additional data + ); + + if hwnd.is_null() { + eprintln!("Failed to create cursor window, error: {}", winapi::um::errhandlingapi::GetLastError()); + return None; + } + + // Show the window and ensure it's topmost + ShowWindow(hwnd, SW_SHOW); + SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); + + // Return the wrapped handle + Some(SyncHWND(hwnd)) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..c7aecaf --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,7 @@ +// UI module +// Contains UI components and rendering logic + +pub mod view; +pub mod tray; + +pub use view::view_app; diff --git a/src/ui/tray.rs b/src/ui/tray.rs new file mode 100644 index 0000000..967b29c --- /dev/null +++ b/src/ui/tray.rs @@ -0,0 +1,194 @@ +// System tray functionality + +use std::{ + sync::{Arc, Mutex}, + thread, +}; +use ::image::open as image_open; +use tray_icon::{ + TrayIconBuilder, + TrayIcon, + TrayIconEvent, + MouseButton, + menu::{Menu, MenuEvent}, +}; + +use crate::app::{Message, CursorInfo, TRAY_SENDER, SharedState}; + +// Define menu item IDs +const NEXT_CURSOR_ID: &str = "next_cursor"; +const SHOW_WINDOW_ID: &str = "show_window"; +const QUIT_ID: &str = "quit"; + +// Set up the system tray icon +pub fn setup_tray_icon( + cursors: &[CursorInfo], + shared_state: Arc>, +) -> Option { // Return Option + if cursors.is_empty() { + return None; // Return None if no cursors + } + + // Create a menu + let menu = Menu::with_items(&[ + &tray_icon::menu::MenuItem::with_id(NEXT_CURSOR_ID, "Next cursor", true, None), + &tray_icon::menu::MenuItem::with_id(SHOW_WINDOW_ID, "Show window", true, None), + &tray_icon::menu::MenuItem::with_id(QUIT_ID, "Quit", true, None), + ]).ok()?; // Use ok()? to convert Result to Option + + + // Use the first PNG as the tray icon + let icon_path = &cursors[0].path; + let img = image_open(icon_path).ok()?; // Use ok()? + let rgba_img = img.to_rgba8(); + let (width, height) = rgba_img.dimensions(); + let icon = tray_icon::Icon::from_rgba(rgba_img.into_raw(), width, height).ok()?; // Use ok()? + + // Build the tray icon + println!("Building tray icon..."); + let tray_result = TrayIconBuilder::new() + .with_tooltip("Cursor Rush") + .with_icon(icon) + .with_menu(Box::new(menu)) + .with_menu_on_left_click(false) // Disable menu on left click - only show on right click + .build(); + + println!("Tray icon configuration: menu_on_left_click = false"); + + // Store the tray icon itself + let tray = match tray_result { + Ok(tray_instance) => { + println!("Tray icon built successfully"); + tray_instance // Assign the TrayIcon object + }, + Err(e) => { + eprintln!("Failed to build tray icon: {}", e); + return None; // Return None on error + } + }; + + // Listen for tray menu events in a separate thread + let state_clone = shared_state.clone(); + println!("Spawning tray menu event listener thread..."); + thread::spawn(move || { + let menu_channel = MenuEvent::receiver(); + println!("Tray menu event listener waiting for events..."); + + while let Ok(event) = menu_channel.recv() { + println!("Tray menu event received: ID = {:?}", event.id); + let msg_to_send = match event.id.as_ref() { + NEXT_CURSOR_ID => { + println!("Tray: Next Cursor requested"); + match state_clone.lock() { + Ok(mut state) => { + if !state.cursors.is_empty() { + state.current_index = (state.current_index + 1) % state.cursors.len(); + if let Some(cursor) = state.cursors.get(state.current_index) { + println!("Tray: Updating cursor image: {}", cursor.name); + crate::app::apply_cursor(&cursor.path); + // No need to call render_cursor anymore as the mouse hook handles rendering + state.current_cursor_image = image_open(&cursor.path).ok().map(|img| img.to_rgba8()); + } + } + }, + Err(e) => eprintln!("Tray: Failed to lock shared state for next cursor: {}", e), + } + // Send TrayEvent just to trigger UI update if needed + Some(Message::TrayEvent) + }, + SHOW_WINDOW_ID => { + println!("Tray: Show Window requested"); + // Send ShowWindow message to main thread + Some(Message::ShowWindow) + }, + QUIT_ID => { + println!("Tray: Quit requested"); + // Send Quit message to main thread + Some(Message::Quit) + }, + _ => { + println!("Tray: Unknown menu event ID: {:?}", event.id); + None // Don't send a message for unknown IDs + } + }; + + // Send the determined message to the main UI thread using the global sender + if let Some(msg) = msg_to_send { + // Get the global sender + if let Some(tx) = TRAY_SENDER.lock().unwrap().as_ref() { + if let Err(e) = tx.unbounded_send(msg.clone()) { // Use clone if msg is needed after send + eprintln!("Tray: Failed to send {:?} message: {}", msg, e); + break; // Stop thread if channel broken + } + // If Quit message was sent, break the loop to exit the listener thread + if matches!(msg, Message::Quit) { + println!("Tray: Quit message sent, exiting listener thread"); + break; + } + } else { + eprintln!("Tray: Global sender not initialized yet"); + } + } + } + + println!("Tray menu event listener thread finished"); + }); + + // Set up a direct event handler for tray icon events + // This is the preferred way to handle tray icon clicks + println!("Setting up tray icon click event handler..."); + + // Use set_event_handler to directly handle tray icon events + TrayIconEvent::set_event_handler(Some(move |event| { + // Only process important events, filter out mouse movements + match event { + // Handle click events + TrayIconEvent::Click { button, .. } => { + println!("Tray: Click detected with button: {:?}", button); + // Left click should show the window + if button == MouseButton::Left { + println!("Tray: Left click detected, sending ShowWindow message"); + // Get the global sender + if let Some(tx) = TRAY_SENDER.lock().unwrap().as_ref() { + if let Err(e) = tx.unbounded_send(Message::ShowWindow) { + eprintln!("Tray: Failed to send ShowWindow message via handler: {}", e); + } + } else { + eprintln!("Tray: Global sender not initialized yet for click handler"); + } + } + }, + // Handle double-click events + TrayIconEvent::DoubleClick { button, .. } => { + println!("Tray: Double click detected with button: {:?}", button); + // Double left click should also show the window + if button == MouseButton::Left { + println!("Tray: Double left click detected, sending ShowWindow message"); + // Get the global sender + if let Some(tx) = TRAY_SENDER.lock().unwrap().as_ref() { + if let Err(e) = tx.unbounded_send(Message::ShowWindow) { + eprintln!("Tray: Failed to send ShowWindow message via handler on double click: {}", e); + } + } else { + eprintln!("Tray: Global sender not initialized yet for double click handler"); + } + } + }, + // Silently ignore mouse movement events + TrayIconEvent::Move { .. } => { + // Don't log anything for mouse movements + }, + // Silently ignore mouse enter/leave events + TrayIconEvent::Enter { .. } | TrayIconEvent::Leave { .. } => { + // Don't log anything for mouse enter/leave + }, + // Log other events for debugging (but not mouse-related ones) + _ => { + println!("Tray: Event received: {:?}", event); + } + } + })); + + println!("Tray setup complete using set_event_handler for clicks."); // Updated log + Some(tray) // Return the TrayIcon object +} diff --git a/src/ui/view.rs b/src/ui/view.rs new file mode 100644 index 0000000..e6cd934 --- /dev/null +++ b/src/ui/view.rs @@ -0,0 +1,77 @@ +// UI view components + +use iced::{ + widget::{button, text, Column, Row, container, image as iced_image}, + Element, Length, +}; + +use crate::app::{CursorRush, Message}; + +// View function to render the UI +pub fn view_app(state: &CursorRush) -> Element { + let mut content = Column::new() + .spacing(16) + .push(text("Cursor Rush").size(24)); + + // Add a button for each cursor + if !state.cursors.is_empty() { + let current_cursor = &state.cursors[state.current_index]; + content = content.push(text(format!("Current cursor: {}", current_cursor.name))); + + // Display the current cursor image as a preview + let img_handle = iced_image::Handle::from_path(¤t_cursor.path); + let cursor_image = iced_image::Image::new(img_handle) + .width(Length::Fixed(64.0)) + .height(Length::Fixed(64.0)); + + content = content.push(cursor_image); + + // Create a row of cursor buttons + let mut cursor_row = Row::new().spacing(10); + + for (i, cursor) in state.cursors.iter().enumerate() { + // Create a small preview image for each cursor + let preview_handle = iced_image::Handle::from_path(&cursor.path); + let preview_image = iced_image::Image::new(preview_handle) + .width(Length::Fixed(32.0)) + .height(Length::Fixed(32.0)); + + let btn = button( + Row::new() + .spacing(5) + .push(preview_image) + .push(text(&cursor.name).size(16)) + ) + .on_press(Message::SelectCursor(i)); + + cursor_row = cursor_row.push(btn); + } + + content = content + .push(cursor_row) + .push(button("Next cursor").on_press(Message::NextCursor)) + .push(button("Quit").on_press(Message::Quit)); + + // Add instructions for the hotkey and close button behavior + content = content.push( + container( + Column::new() + .spacing(5) + .push(text("Press Ctrl+Shift+F12 to exit the application").size(16)) + .push(text("Close button (X) minimizes to system tray").size(16)) + ) + .padding(10) + ); + } else { + content = content.push(text("No cursors found in assets directory")); + } + + // Create the main container + container(content) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .padding(20) + .into() +} diff --git a/src/utils/config.rs b/src/utils/config.rs new file mode 100644 index 0000000..78a5c4e --- /dev/null +++ b/src/utils/config.rs @@ -0,0 +1,34 @@ +// Application configuration + +use std::path::PathBuf; + +// Application configuration +pub struct Config { + pub assets_dir: PathBuf, + pub default_cursor: String, + pub auto_start: bool, + pub minimize_to_tray: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + assets_dir: PathBuf::from("assets"), + default_cursor: String::from("default"), + auto_start: false, + minimize_to_tray: true, + } + } +} + +// Load configuration from file or use defaults +pub fn load_config() -> Config { + // TODO: Implement loading from a config file + Config::default() +} + +// Save configuration to file +pub fn save_config(_config: &Config) -> Result<(), String> { + // TODO: Implement saving to a config file + Ok(()) +} diff --git a/src/utils/logging.rs b/src/utils/logging.rs new file mode 100644 index 0000000..bf2a00f --- /dev/null +++ b/src/utils/logging.rs @@ -0,0 +1,58 @@ +// Logging utilities + +// Log levels +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum LogLevel { + Debug, + Info, + Warning, + Error, +} + +// Global log level (could be made configurable) +static mut CURRENT_LOG_LEVEL: LogLevel = LogLevel::Info; + +// Set the global log level +pub fn set_log_level(level: LogLevel) { + unsafe { + CURRENT_LOG_LEVEL = level; + } +} + +// Get the current log level +pub fn get_log_level() -> LogLevel { + unsafe { + CURRENT_LOG_LEVEL + } +} + +// Log a message at the specified level +pub fn log(level: LogLevel, message: &str) { + if level >= unsafe { CURRENT_LOG_LEVEL } { + let prefix = match level { + LogLevel::Debug => "[DEBUG]", + LogLevel::Info => "[INFO]", + LogLevel::Warning => "[WARNING]", + LogLevel::Error => "[ERROR]", + }; + + println!("{} {}", prefix, message); + } +} + +// Convenience functions for different log levels +pub fn debug(message: &str) { + log(LogLevel::Debug, message); +} + +pub fn info(message: &str) { + log(LogLevel::Info, message); +} + +pub fn warning(message: &str) { + log(LogLevel::Warning, message); +} + +pub fn error(message: &str) { + log(LogLevel::Error, message); +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..952511b --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,4 @@ +// Utility functions and helpers + +pub mod config; +pub mod logging;