feat(rust): commands module with click-through toggle helpers

This commit is contained in:
Michael Chihlas
2026-05-08 19:19:31 -04:00
parent 87843e3be4
commit c1085e073a
2 changed files with 118 additions and 0 deletions

View File

@@ -0,0 +1 @@
pub mod overlay;

View File

@@ -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<R: Runtime>(
app: &AppHandle<R>,
new_state: bool,
) -> Result<Option<bool>, 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<R: Runtime>(
app: AppHandle<R>,
current: bool,
) -> Result<Option<bool>, 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<bool>` kept in app state.
pub async fn toggle_via_global_shortcut<R: Runtime>(
app: &AppHandle<R>,
) -> Result<(), String> {
let state = app.state::<crate::AppState>();
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::<crate::AppState>();
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<crate::HotkeyStatus, String> {
let state = app.state::<crate::AppState>();
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<R: Runtime>(
app: AppHandle<R>,
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())
}