diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..2b55280 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod overlay; diff --git a/src-tauri/src/commands/overlay.rs b/src-tauri/src/commands/overlay.rs new file mode 100644 index 0000000..046c085 --- /dev/null +++ b/src-tauri/src/commands/overlay.rs @@ -0,0 +1,117 @@ +use std::time::Duration; + +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager, Runtime}; + +pub const OVERLAY_LABEL: &str = "overlay"; +pub const EVT_CLICK_THROUGH_TOGGLED: &str = "click-through-toggled"; +pub const EVT_CLICK_THROUGH_NO_OVERLAY: &str = "click-through-no-overlay"; + +#[derive(Clone, Serialize)] +pub struct ClickThroughToggledPayload { + #[serde(rename = "clickThrough")] + pub click_through: bool, +} + +/// Applies a specific click-through state to the overlay (sets ignore_cursor_events, +/// performs the opacity pulse, emits the toggled event). Returns the applied state. +/// +/// Tauri 2's WebviewWindow doesn't expose getters for `ignore_cursor_events` or +/// `opacity`, so callers must compute the new state themselves: the JS-side +/// `toggle_click_through` command receives `current` from the front end and +/// passes `!current`; the global shortcut handler maintains its own cache via +/// `AppState.click_through`. +pub async fn apply_state( + app: &AppHandle, + new_state: bool, +) -> Result, String> { + let Some(overlay) = app.get_webview_window(OVERLAY_LABEL) else { + let _ = app.emit(EVT_CLICK_THROUGH_NO_OVERLAY, ()); + return Ok(None); + }; + + overlay + .set_ignore_cursor_events(new_state) + .map_err(|e| e.to_string())?; + + // Opacity dip half of the visual pulse. The JS click-through-toggled + // handler is responsible for restoring opacity to the user's actual + // target — Rust deliberately does NOT call set_opacity again, so there + // is no possibility of the global-shortcut path "snapping back" to a + // wrong value (we don't have a current-opacity getter). + let _ = overlay.set_opacity(0.4); + tokio::time::sleep(Duration::from_millis(180)).await; + + app.emit( + EVT_CLICK_THROUGH_TOGGLED, + ClickThroughToggledPayload { + click_through: new_state, + }, + ) + .map_err(|e| e.to_string())?; + + Ok(Some(new_state)) +} + +/// Tauri command — invoked from JS. The JS side passes the *current* state so +/// we can compute the inverse without needing a getter on the Tauri side. +#[tauri::command] +pub async fn toggle_click_through( + app: AppHandle, + current: bool, +) -> Result, String> { + apply_state(&app, !current).await +} + +/// Stateless setter — used by the global shortcut handler, which doesn't have +/// access to the JS-side current value. Reads the *intended* state from a +/// shared `Mutex` kept in app state. +pub async fn toggle_via_global_shortcut( + app: &AppHandle, +) -> Result<(), String> { + let state = app.state::(); + let new_value = { + let mut guard = state.click_through.lock().expect("poisoned"); + *guard = !*guard; + *guard + }; + apply_state(app, new_value).await?; + Ok(()) +} + +/// Sync the Rust-side click-through cache with what the JS side just applied. +/// Called on overlay open/close and whenever the JS toggle changes user-side. +#[tauri::command] +pub fn sync_click_through_cache( + app: AppHandle, + value: bool, +) -> Result<(), String> { + let state = app.state::(); + let mut guard = state.click_through.lock().map_err(|_| "poisoned".to_string())?; + *guard = value; + Ok(()) +} + +/// Pull the hotkey-registration outcome that was decided during setup. +/// Pull-style avoids the listener-not-yet-registered race that an event-based +/// approach would have. +#[tauri::command] +pub fn get_hotkey_status(app: AppHandle) -> Result { + let state = app.state::(); + let guard = state.hotkey_status.lock().map_err(|_| "poisoned".to_string())?; + Ok(guard.clone()) +} + +/// Set window opacity. Tauri 2's JS WebviewWindow does NOT expose setOpacity +/// (only the Rust side does), so the JS layer routes through this command. +#[tauri::command] +pub fn set_window_opacity( + app: AppHandle, + label: String, + opacity: f32, +) -> Result<(), String> { + let Some(window) = app.get_webview_window(&label) else { + return Err(format!("window not found: {label}")); + }; + window.set_opacity(opacity).map_err(|e| e.to_string()) +}