Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,15 @@ fn modifier_overlay_ready(
overlay.ready(window.label())
}

#[tauri::command]
fn modifier_overlay_present(
window: tauri::WebviewWindow,
overlay: State<'_, modifier_overlay::ModifierOverlay>,
revision: u64,
) -> Result<(), String> {
overlay.present(window.label(), revision)
}

#[tauri::command]
fn forget_device(model: State<'_, AppModel>, device_id: String) -> Result<AppState, String> {
{
Expand Down Expand Up @@ -1257,6 +1266,7 @@ pub fn run() {
reject_pairing,
disconnect_all,
modifier_overlay_ready,
modifier_overlay_present,
forget_device,
save_settings,
set_telemetry_consent,
Expand Down
235 changes: 213 additions & 22 deletions src-tauri/src/modifier_overlay.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
use std::sync::{Arc, Mutex};
use std::{
sync::{Arc, Mutex},
time::Duration,
};

use serde::Serialize;
use tauri::{
Emitter, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder,
utils::config::Color, Emitter, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow,
WebviewWindowBuilder,
};

use crate::input::ModifierKey;
Expand All @@ -14,6 +18,8 @@ const OVERLAY_WINDOW_LABEL: &str = "modifier-overlay";
const OVERLAY_WIDTH: f64 = 480.0;
const OVERLAY_HEIGHT: f64 = 70.0;
const OVERLAY_MARGIN: f64 = 16.0;
const WINDOWS_BOOTSTRAP_POSITION: f64 = -32_000.0;
const READINESS_TIMEOUT: Duration = Duration::from_secs(5);

pub trait ModifierKeyOverlayNotifier: Send + Sync {
fn set_active_modifiers(&self, active_modifiers: &[ModifierKey]);
Expand All @@ -37,18 +43,34 @@ struct ModifierOverlayInner {
shared: SharedModel,
window: WebviewWindow,
state: Mutex<ModifierOverlayState>,
window_actions: Mutex<()>,
}

#[derive(Default)]
struct ModifierOverlayState {
revision: u64,
active_modifiers: Vec<ModifierKey>,
ready: bool,
readiness_failure_reported: bool,
}

#[derive(Debug, Clone, Copy, PartialEq)]
struct BootstrapPolicy {
visible: bool,
position: Option<(f64, f64)>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PresentationAction {
Hide,
Ignore,
Show,
}

impl ModifierOverlay {
pub fn install(app: tauri::AppHandle, shared: SharedModel) -> Result<Self, String> {
let window = WebviewWindowBuilder::new(
let policy = current_bootstrap_policy();
let mut builder = WebviewWindowBuilder::new(
&app,
OVERLAY_WINDOW_LABEL,
WebviewUrl::App("index.html?view=modifier-overlay".into()),
Expand All @@ -64,21 +86,27 @@ impl ModifierOverlay {
.skip_taskbar(true)
.focusable(false)
.focused(false)
.visible(false)
.build()
.map_err(|error| error.to_string())?;
.background_color(Color(0, 0, 0, 0))
.visible(policy.visible);
if let Some((x, y)) = policy.position {
builder = builder.position(x, y);
}
let window = builder.build().map_err(|error| error.to_string())?;
configure_platform_window(&window)?;
window
.set_ignore_cursor_events(true)
.map_err(|error| error.to_string())?;
Ok(Self {
let overlay = Self {
inner: Arc::new(ModifierOverlayInner {
app,
shared,
window,
state: Mutex::new(ModifierOverlayState::default()),
window_actions: Mutex::new(()),
}),
})
};
overlay.start_readiness_watchdog();
Ok(overlay)
}

pub fn notifier(&self) -> Arc<dyn ModifierKeyOverlayNotifier> {
Expand All @@ -98,19 +126,48 @@ impl ModifierOverlay {
state.ready = true;
snapshot(&state)
};
if !snapshot.labels.is_empty() {
self.position_window()?;
if snapshot.labels.is_empty() {
self.hide_if_current_empty(snapshot.revision)?;
}
Ok(snapshot)
}

pub fn present(&self, window_label: &str, revision: u64) -> Result<(), String> {
if window_label != OVERLAY_WINDOW_LABEL {
return Err(
"Modifier overlay presentation is only available to its overlay window.".into(),
);
}
let _window_action = self
.inner
.window_actions
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let action = {
let state = self
.inner
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
presentation_action(&state, revision)
};
if action == PresentationAction::Show {
self.position_window()?;
self.inner
.window
.show()
.map_err(|error| error.to_string())?;
}
Ok(())
}

pub fn end_session(&self) {
self.end_control_session();
}

fn update(&self, active_modifiers: &[ModifierKey]) {
let normalized = canonical_modifiers(active_modifiers);
let (snapshot, ready) = {
let snapshot = {
let mut state = self
.inner
.state
Expand All @@ -121,7 +178,7 @@ impl ModifierOverlay {
}
state.active_modifiers = normalized;
state.revision = state.revision.wrapping_add(1);
(snapshot(&state), state.ready)
snapshot(&state)
};
if let Err(error) = self
.inner
Expand All @@ -131,21 +188,57 @@ impl ModifierOverlay {
self.report_failure(&error.to_string());
}
if snapshot.labels.is_empty() {
if let Err(error) = self.inner.window.hide() {
self.report_failure(&error.to_string());
}
} else if ready {
if let Err(error) = self.position_window().and_then(|_| {
self.inner
.window
.show()
.map_err(|window_error| window_error.to_string())
}) {
if let Err(error) = self.hide_if_current_empty(snapshot.revision) {
self.report_failure(&error);
}
}
}

fn hide_if_current_empty(&self, revision: u64) -> Result<(), String> {
let _window_action = self
.inner
.window_actions
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let action = {
let state = self
.inner
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
presentation_action(&state, revision)
};
if action == PresentationAction::Hide {
self.inner
.window
.hide()
.map_err(|error| error.to_string())?;
}
Ok(())
}

fn start_readiness_watchdog(&self) {
let overlay = self.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(READINESS_TIMEOUT).await;
let should_report = {
let mut state = overlay
.inner
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let should_report = readiness_watchdog_should_report(&state);
if should_report {
state.readiness_failure_reported = true;
}
should_report
};
if should_report {
overlay.report_failure("the overlay page did not initialize in time");
}
});
}

fn position_window(&self) -> Result<(), String> {
let cursor = self
.inner
Expand Down Expand Up @@ -187,6 +280,41 @@ impl ModifierOverlay {
}
}

fn current_bootstrap_policy() -> BootstrapPolicy {
bootstrap_policy(cfg!(target_os = "windows"))
}

fn bootstrap_policy(is_windows: bool) -> BootstrapPolicy {
if is_windows {
BootstrapPolicy {
visible: true,
position: Some((WINDOWS_BOOTSTRAP_POSITION, WINDOWS_BOOTSTRAP_POSITION)),
}
} else {
BootstrapPolicy {
visible: false,
position: None,
}
}
}

fn presentation_action(state: &ModifierOverlayState, revision: u64) -> PresentationAction {
if revision != state.revision {
return PresentationAction::Ignore;
}
if state.active_modifiers.is_empty() {
PresentationAction::Hide
} else if state.ready {
PresentationAction::Show
} else {
PresentationAction::Ignore
}
}

fn readiness_watchdog_should_report(state: &ModifierOverlayState) -> bool {
!state.ready && !state.readiness_failure_reported
}

#[cfg(target_os = "macos")]
fn configure_platform_window(window: &WebviewWindow) -> Result<(), String> {
window
Expand Down Expand Up @@ -223,6 +351,11 @@ impl ModifierKeyOverlayNotifier for ModifierOverlay {
}
}
}
let _window_action = self
.inner
.window_actions
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Err(error) = self.inner.window.hide() {
self.report_failure(&error.to_string());
}
Expand Down Expand Up @@ -344,4 +477,62 @@ mod tests {
PhysicalPosition::new(1424, 16)
);
}

#[test]
fn windows_bootstraps_visible_and_offscreen() {
assert_eq!(
bootstrap_policy(true),
BootstrapPolicy {
visible: true,
position: Some((WINDOWS_BOOTSTRAP_POSITION, WINDOWS_BOOTSTRAP_POSITION)),
}
);
assert_eq!(
bootstrap_policy(false),
BootstrapPolicy {
visible: false,
position: None,
}
);
}

#[test]
fn presentation_requires_ready_current_nonempty_state() {
let mut state = ModifierOverlayState {
revision: 4,
active_modifiers: vec![ModifierKey::Ctrl],
..ModifierOverlayState::default()
};
assert_eq!(presentation_action(&state, 4), PresentationAction::Ignore);

state.ready = true;
assert_eq!(presentation_action(&state, 3), PresentationAction::Ignore);
assert_eq!(presentation_action(&state, 4), PresentationAction::Show);

state.active_modifiers.clear();
assert_eq!(presentation_action(&state, 4), PresentationAction::Hide);
}

#[test]
fn stale_empty_revision_cannot_hide_newer_nonempty_state() {
let state = ModifierOverlayState {
revision: 5,
active_modifiers: vec![ModifierKey::Shift],
ready: true,
..ModifierOverlayState::default()
};
assert_eq!(presentation_action(&state, 4), PresentationAction::Ignore);
assert_eq!(presentation_action(&state, 5), PresentationAction::Show);
}

#[test]
fn readiness_watchdog_reports_only_once_before_ready() {
let mut state = ModifierOverlayState::default();
assert!(readiness_watchdog_should_report(&state));
state.readiness_failure_reported = true;
assert!(!readiness_watchdog_should_report(&state));
state.readiness_failure_reported = false;
state.ready = true;
assert!(!readiness_watchdog_should_report(&state));
}
}
Loading