use std::{ f32::consts::PI, ops::Add, process::{self, Child}, sync::Arc, time::{Duration, Instant}, }; use glam::{Quat, Vec4}; use serde::Deserialize; use crate::{ backend::{ common::OverlaySelector, input::PointerMode, overlay::RelativeTo, task::{ColorChannel, SystemTask, TaskType}, }, config::{save_layout, save_settings, AStrSetExt}, hid::VirtualKey, overlays::{ toast::{Toast, ToastTopic}, watch::WATCH_NAME, }, state::AppState, }; #[cfg(feature = "wayvr")] use crate::overlays::wayvr::WayVRAction; use super::{ExecArgs, ModularControl, ModularData}; #[derive(Deserialize, Clone)] pub enum PressRelease { Release, Press, } #[derive(Deserialize, Clone, Copy)] pub enum ViewAngleKind { /// The cosine of the angle at which the watch becomes fully transparent MinOpacity, /// The cosine of the angle at which the watch becomes fully opaque MaxOpacity, } #[derive(Deserialize, Clone, Copy)] pub enum Axis { X, Y, Z, } #[derive(Deserialize, Clone)] pub enum HighlightTest { AllowSliding, AutoRealign, NotificationSounds, Notifications, RorateLock, } #[derive(Deserialize, Clone)] pub enum SystemAction { ToggleAllowSliding, ToggleAutoRealign, ToggleNotificationSounds, ToggleNotifications, ToggleRotateLock, PlayspaceResetOffset, PlayspaceFixFloor, RecalculateExtent, PersistConfig, PersistLayout, } #[derive(Deserialize, Clone)] pub enum WatchAction { /// Hide the watch until Show/Hide binding is used Hide, /// Switch the watch to the opposite controller SwitchHands, /// Change the fade behavior of the watch ViewAngle { kind: ViewAngleKind, delta: f32, }, Rotation { axis: Axis, delta: f32, }, Position { axis: Axis, delta: f32, }, } #[derive(Deserialize, Clone)] pub enum OverlayAction { /// Reset the overlay to be in front of the HMD with its original scale Reset, /// Toggle the visibility of the overlay ToggleVisible, /// Toggle the ability to grab and recenter the overlay ToggleImmovable, /// Toggle the ability of the overlay to reacto to laser pointer ToggleInteraction, /// Change the opacity of the overlay Opacity { delta: f32 }, } #[derive(Deserialize, Clone)] pub enum WindowAction { /// Create a new mirror window, or show/hide an existing one ShowMirror, /// Create a new UI window, or show/hide an existing one ShowUi, /// Destroy a previously created window, if it exists Destroy, } #[derive(Deserialize, Clone)] #[serde(tag = "type")] pub enum ButtonAction { Exec { command: ExecArgs, toast: Option>, }, VirtualKey { keycode: VirtualKey, action: PressRelease, }, Watch { action: WatchAction, }, Overlay { target: OverlaySelector, action: OverlayAction, }, #[cfg(feature = "wayvr")] WayVR(WayVRAction), Window { target: Arc, action: WindowAction, }, Toast { message: Arc, body: Option>, seconds: Option, }, ColorAdjust { channel: ColorChannel, delta: f32, }, DragMultiplier { delta: f32, }, System { action: SystemAction, }, } pub(super) struct PressData { last_down: Instant, last_mode: PointerMode, child: Option, } impl Clone for PressData { fn clone(&self) -> Self { Self { last_down: self.last_down, last_mode: self.last_mode, child: None, } } } impl Default for PressData { fn default() -> Self { Self { last_down: Instant::now(), last_mode: PointerMode::Left, child: None, } } } #[derive(Deserialize, Default, Clone)] pub struct ButtonData { #[serde(skip)] pub(super) press: PressData, pub(super) click_down: Option>, pub(super) click_up: Option>, pub(super) long_click_up: Option>, pub(super) right_down: Option>, pub(super) right_up: Option>, pub(super) long_right_up: Option>, pub(super) middle_down: Option>, pub(super) middle_up: Option>, pub(super) long_middle_up: Option>, pub(super) scroll_down: Option>, pub(super) scroll_up: Option>, pub(super) highlight: Option, } pub fn modular_button_init(button: &mut ModularControl, data: &ButtonData) { button.state = Some(ModularData::Button(Box::new(data.clone()))); button.on_press = Some(modular_button_dn); button.on_release = Some(modular_button_up); button.on_scroll = Some(modular_button_scroll); button.test_highlight = Some(modular_button_highlight); } fn modular_button_dn( button: &mut ModularControl, _: &mut (), app: &mut AppState, mode: PointerMode, ) { // want panic let ModularData::Button(data) = button.state.as_mut().unwrap() else { panic!("modular_button_dn: button state is not Button"); }; data.press.last_down = Instant::now(); data.press.last_mode = mode; let actions = match mode { PointerMode::Left => data.click_down.as_ref(), PointerMode::Right => data.right_down.as_ref(), PointerMode::Middle => data.middle_down.as_ref(), _ => None, }; if let Some(actions) = actions { for action in actions { handle_action(action, &mut data.press, app); } } } fn modular_button_up(button: &mut ModularControl, _: &mut (), app: &mut AppState) { // want panic let ModularData::Button(data) = button.state.as_mut().unwrap() else { panic!("modular_button_up: button state is not Button"); }; let now = Instant::now(); let duration = now - data.press.last_down; let long_press = duration.as_secs_f32() > app.session.config.long_press_duration; let actions = match data.press.last_mode { PointerMode::Left => { if long_press { data.long_click_up.as_ref() } else { data.click_up.as_ref() } } PointerMode::Right => { if long_press { data.long_right_up.as_ref() } else { data.right_up.as_ref() } } PointerMode::Middle => { if long_press { data.long_middle_up.as_ref() } else { data.middle_up.as_ref() } } _ => None, }; if let Some(actions) = actions { for action in actions { handle_action(action, &mut data.press, app); } } } fn modular_button_scroll(button: &mut ModularControl, _: &mut (), app: &mut AppState, delta: f32) { // want panic let ModularData::Button(data) = button.state.as_mut().unwrap() else { panic!("modular_button_scroll: button state is not Button"); }; let actions = if delta < 0.0 { data.scroll_down.as_ref() } else { data.scroll_up.as_ref() }; if let Some(actions) = actions { for action in actions { handle_action(action, &mut data.press, app); } } } fn modular_button_highlight( button: &ModularControl, _: &mut (), app: &mut AppState, ) -> Option { // want panic let ModularData::Button(data) = button.state.as_ref().unwrap() else { panic!("modular_button_highlight: button state is not Button"); }; if let Some(test) = &data.highlight { let lit = match test { HighlightTest::AllowSliding => app.session.config.allow_sliding, HighlightTest::AutoRealign => app.session.config.realign_on_showhide, HighlightTest::NotificationSounds => app.session.config.notifications_sound_enabled, HighlightTest::Notifications => app.session.config.notifications_enabled, HighlightTest::RorateLock => !app.session.config.space_rotate_unlocked, }; if lit { return Some(Vec4::new(1.0, 1.0, 1.0, 0.5)); } } None } fn handle_action(action: &ButtonAction, press: &mut PressData, app: &mut AppState) { match action { ButtonAction::Exec { command, toast } => run_exec(command, toast, press, app), ButtonAction::Watch { action } => run_watch(action, app), ButtonAction::Overlay { target, action } => run_overlay(target, action, app), ButtonAction::Window { target, action } => run_window(target, action, app), #[cfg(feature = "wayvr")] ButtonAction::WayVR(action) => { app.tasks.enqueue(TaskType::WayVR(action.clone())); } ButtonAction::VirtualKey { keycode, action } => app .hid_provider .send_key(*keycode, matches!(*action, PressRelease::Press)), ButtonAction::Toast { message, body, seconds, } => { Toast::new( ToastTopic::System, message.clone(), body.clone().unwrap_or_else(|| "".into()), ) .with_timeout(seconds.unwrap_or(5.)) .submit(app); } ButtonAction::ColorAdjust { channel, delta } => { let channel = *channel; let delta = *delta; app.tasks .enqueue(TaskType::System(SystemTask::ColorGain(channel, delta))); } ButtonAction::System { action } => run_system(action, app), ButtonAction::DragMultiplier { delta } => { app.session.config.space_drag_multiplier += delta; } } } const ENABLED_DISABLED: [&str; 2] = ["enabled", "disabled"]; fn run_system(action: &SystemAction, app: &mut AppState) { match action { SystemAction::ToggleAllowSliding => { app.session.config.allow_sliding = !app.session.config.allow_sliding; Toast::new( ToastTopic::System, format!( "Sliding is {}.", ENABLED_DISABLED[app.session.config.allow_sliding as usize] ) .into(), "".into(), ) .submit(app); } SystemAction::ToggleAutoRealign => { app.session.config.realign_on_showhide = !app.session.config.realign_on_showhide; Toast::new( ToastTopic::System, format!( "Auto realign is {}.", ENABLED_DISABLED[app.session.config.realign_on_showhide as usize] ) .into(), "".into(), ) .submit(app); } SystemAction::ToggleRotateLock => { app.session.config.space_rotate_unlocked = !app.session.config.space_rotate_unlocked; Toast::new( ToastTopic::System, format!( "Space rotate axis lock now {}.", ENABLED_DISABLED[!app.session.config.space_rotate_unlocked as usize] ) .into(), "".into(), ) .submit(app); } SystemAction::PlayspaceResetOffset => { app.tasks .enqueue(TaskType::System(SystemTask::ResetPlayspace)); } SystemAction::PlayspaceFixFloor => { let now = Instant::now(); let sec = Duration::from_secs(1); for i in 0..5 { let at = now.add(i * sec); let display = 5 - i; Toast::new( ToastTopic::System, format!("Fixing floor in {}", display).into(), "Place either controller on the floor.".into(), ) .with_timeout(1.0) .submit_at(app, at); } app.tasks .enqueue_at(TaskType::System(SystemTask::FixFloor), now.add(5 * sec)); } SystemAction::RecalculateExtent => { todo!() } SystemAction::ToggleNotifications => { app.session.config.notifications_enabled = !app.session.config.notifications_enabled; Toast::new( ToastTopic::System, format!( "Notifications are {}.", ENABLED_DISABLED[app.session.config.notifications_enabled as usize] ) .into(), "".into(), ) .submit(app); } SystemAction::ToggleNotificationSounds => { app.session.config.notifications_sound_enabled = !app.session.config.notifications_sound_enabled; Toast::new( ToastTopic::System, format!( "Notification sounds are {}.", ENABLED_DISABLED[app.session.config.notifications_sound_enabled as usize] ) .into(), "".into(), ) .submit(app); } SystemAction::PersistConfig => { if let Err(e) = save_settings(&app.session.config) { log::error!("Failed to save config: {:?}", e); } } SystemAction::PersistLayout => { if let Err(e) = save_layout(&app.session.config) { log::error!("Failed to save layout: {:?}", e); } } } } fn run_exec(args: &ExecArgs, toast: &Option>, press: &mut PressData, app: &mut AppState) { if let Some(proc) = press.child.as_mut() { match proc.try_wait() { Ok(Some(code)) => { if !code.success() { log::error!("Child process exited with code: {}", code); } press.child = None; } Ok(None) => { log::warn!("Unable to launch child process: previous child not exited yet"); return; } Err(e) => { press.child = None; log::error!("Error checking child process: {:?}", e); } } } let args = args.iter().map(|s| s.as_ref()).collect::>(); match process::Command::new(args[0]).args(&args[1..]).spawn() { Ok(proc) => { press.child = Some(proc); if let Some(toast) = toast.as_ref() { Toast::new(ToastTopic::System, toast.clone(), "".into()).submit(app); } } Err(e) => { log::error!("Failed to spawn process {:?}: {:?}", args, e); } }; } fn run_watch(data: &WatchAction, app: &mut AppState) { match data { WatchAction::Hide => { app.tasks.enqueue(TaskType::Overlay( OverlaySelector::Name(WATCH_NAME.into()), Box::new(|app, o| { if o.saved_transform.is_none() { o.want_visible = false; o.saved_transform = Some(o.transform); Toast::new( ToastTopic::System, "Watch hidden".into(), "Use show/hide binding to restore.".into(), ) .with_timeout(3.) .submit(app); } else { o.want_visible = true; o.saved_transform = None; Toast::new(ToastTopic::System, "Watch restored".into(), "".into()) .submit(app); } }), )); audio_thump(app); } WatchAction::SwitchHands => { app.tasks.enqueue(TaskType::Overlay( OverlaySelector::Name(WATCH_NAME.into()), Box::new(|app, o| { if let RelativeTo::Hand(0) = o.relative_to { o.relative_to = RelativeTo::Hand(1); o.spawn_rotation = app.session.config.watch_rot * Quat::from_rotation_x(PI) * Quat::from_rotation_z(PI); o.spawn_point = app.session.config.watch_pos; o.spawn_point.x *= -1.; } else { o.relative_to = RelativeTo::Hand(0); o.spawn_rotation = app.session.config.watch_rot; o.spawn_point = app.session.config.watch_pos; } o.dirty = true; Toast::new( ToastTopic::System, "Watch switched".into(), "Check your other hand".into(), ) .with_timeout(3.) .submit(app); }), )); audio_thump(app); } WatchAction::ViewAngle { kind, delta } => match kind { ViewAngleKind::MinOpacity => { let diff = (app.session.config.watch_view_angle_max - app.session.config.watch_view_angle_min) + delta; app.session.config.watch_view_angle_min = (app.session.config.watch_view_angle_max - diff) .clamp(0.0, app.session.config.watch_view_angle_max - 0.05); } ViewAngleKind::MaxOpacity => { let diff = app.session.config.watch_view_angle_max - app.session.config.watch_view_angle_min; app.session.config.watch_view_angle_max = (app.session.config.watch_view_angle_max + delta).clamp(0.05, 1.0); app.session.config.watch_view_angle_min = (app.session.config.watch_view_angle_max - diff) .clamp(0.0, app.session.config.watch_view_angle_max - 0.05); } }, WatchAction::Rotation { axis, delta } => { let rot = match axis { Axis::X => Quat::from_rotation_x(delta.to_radians()), Axis::Y => Quat::from_rotation_y(delta.to_radians()), Axis::Z => Quat::from_rotation_z(delta.to_radians()), }; app.tasks.enqueue(TaskType::Overlay( OverlaySelector::Name(WATCH_NAME.into()), Box::new(move |app, o| { o.spawn_rotation *= rot; app.session.config.watch_rot = o.spawn_rotation; o.dirty = true; }), )); } WatchAction::Position { axis, delta } => { let delta = *delta; let axis = match axis { Axis::X => 0, Axis::Y => 1, Axis::Z => 2, }; app.tasks.enqueue(TaskType::Overlay( OverlaySelector::Name(WATCH_NAME.into()), Box::new(move |app, o| { o.spawn_point[axis] += delta; app.session.config.watch_pos = o.spawn_point; o.dirty = true; }), )); } } } fn run_overlay(overlay: &OverlaySelector, action: &OverlayAction, app: &mut AppState) { match action { OverlayAction::Reset => { app.tasks.enqueue(TaskType::Overlay( overlay.clone(), Box::new(|app, o| { o.reset(app, true); Toast::new( ToastTopic::System, format!("{} has been reset!", o.name).into(), "".into(), ) .submit(app); }), )); } OverlayAction::ToggleVisible => { app.tasks.enqueue(TaskType::Overlay( overlay.clone(), Box::new(|app, o| { o.want_visible = !o.want_visible; if o.recenter { o.show_hide = o.want_visible; o.reset(app, false); } let mut state_dirty = false; if !o.want_visible { state_dirty |= app.session.config.show_screens.arc_rm(o.name.as_ref()); } else if o.want_visible { state_dirty |= app.session.config.show_screens.arc_set(o.name.clone()); } if state_dirty { match save_layout(&app.session.config) { Ok(_) => log::debug!("Saved state"), Err(e) => log::error!("Failed to save state: {:?}", e), } } }), )); } OverlayAction::ToggleImmovable => { app.tasks.enqueue(TaskType::Overlay( overlay.clone(), Box::new(|app, o| { o.recenter = !o.recenter; o.grabbable = o.recenter; o.show_hide = o.recenter; if !o.recenter { Toast::new( ToastTopic::System, format!("{} is now locked in place!", o.name).into(), "".into(), ) .submit(app); } else { Toast::new( ToastTopic::System, format!("{} is now unlocked!", o.name).into(), "".into(), ) .submit(app); } }), )); audio_thump(app); } OverlayAction::ToggleInteraction => { app.tasks.enqueue(TaskType::Overlay( overlay.clone(), Box::new(|app, o| { o.interactable = !o.interactable; if !o.interactable { Toast::new( ToastTopic::System, format!("{} is now non-interactable!", o.name).into(), "".into(), ) .submit(app); } else { Toast::new( ToastTopic::System, format!("{} is now interactable!", o.name).into(), "".into(), ) .submit(app); } }), )); audio_thump(app); } OverlayAction::Opacity { delta } => { let delta = *delta; app.tasks.enqueue(TaskType::Overlay( overlay.clone(), Box::new(move |_, o| { o.alpha = (o.alpha + delta).clamp(0.1, 1.0); o.dirty = true; log::debug!("{}: alpha {}", o.name, o.alpha); }), )); } } } fn run_window(window: &Arc, action: &WindowAction, app: &mut AppState) { use crate::overlays::custom; match action { WindowAction::ShowMirror => { #[cfg(feature = "wayland")] app.tasks.enqueue(TaskType::CreateOverlay( OverlaySelector::Name(window.clone()), Box::new({ let name = window.clone(); move |app| { Toast::new( ToastTopic::System, "Check your desktop for popup.".into(), "".into(), ) .with_sound(true) .submit(app); crate::overlays::mirror::new_mirror(name.clone(), false, &app.session) } }), )); #[cfg(not(feature = "wayland"))] log::warn!("Mirror not available without Wayland feature."); } WindowAction::ShowUi => { app.tasks.enqueue(TaskType::CreateOverlay( OverlaySelector::Name(window.clone()), Box::new({ let name = window.clone(); move |app| custom::create_custom(app, name) }), )); } WindowAction::Destroy => { app.tasks .enqueue(TaskType::DropOverlay(OverlaySelector::Name(window.clone()))); } } } const THUMP_AUDIO_WAV: &[u8] = include_bytes!("../../res/380885.wav"); fn audio_thump(app: &mut AppState) { app.audio.play(THUMP_AUDIO_WAV); }