diff --git a/src/env_var_descriptions.rs b/src/env_var_descriptions.rs index 32ca636..d71b94b 100644 --- a/src/env_var_descriptions.rs +++ b/src/env_var_descriptions.rs @@ -5,6 +5,7 @@ pub static ENV_VAR_DESCRIPTIONS: Map<&str, &str> = phf_map! { "XRT_COMPOSITOR_SCALE_PECENTAGE" => "Render resolution percentage. A percentage higher than the native resolution (>100) will help with antialiasing and image clarity.", "XRT_COMPOSITOR_COMPUTE" => "Set to 1 to use GPU compute for the OpenXR compositor.", + "U_PACING_APP_USE_MIN_FRAME_PERIOD" => "Set to 1 to unlimit the compositor refresh from a power of two of your HMD refresh, typically provides a large performance boost.", "SURVIVE_GLOBALSCENESOLVER" => "Continuously recalibrate lighthouse tracking during use. In the current state it's recommended to disable this feature by setting this value to 0.", // "SURVIVE_TIMECODE_OFFSET_MS" => "", diff --git a/src/paths.rs b/src/paths.rs index c04cb7c..5295b67 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -133,3 +133,10 @@ pub fn get_ipc_file_path(xrservice_type: &XRServiceType) -> String { } ) } + +pub fn get_steamvr_bin_dir_path() -> String { + format!( + "{data}/Steam/steamapps/common/SteamVR/bin/linux64", + data = get_xdg_data_dir() + ) +} diff --git a/src/profiles/lighthouse.rs b/src/profiles/lighthouse.rs index 21d725b..f6a1c34 100644 --- a/src/profiles/lighthouse.rs +++ b/src/profiles/lighthouse.rs @@ -14,6 +14,7 @@ pub fn lighthouse_profile() -> Profile { environment.insert("XRT_COMPOSITOR_COMPUTE".into(), "1".into()); environment.insert("XRT_DEBUG_GUI".into(), "1".into()); environment.insert("XRT_CURATED_GUI".into(), "1".into()); + environment.insert("U_PACING_APP_USE_MIN_FRAME_PERIOD".into(), "1".into()); environment.insert( "LD_LIBRARY_PATH".into(), format!("{pfx}/lib:{pfx}/lib64", pfx = prefix), diff --git a/src/profiles/openhmd.rs b/src/profiles/openhmd.rs index eaded5f..d7ac3e9 100644 --- a/src/profiles/openhmd.rs +++ b/src/profiles/openhmd.rs @@ -17,6 +17,7 @@ pub fn openhmd_profile() -> Profile { environment.insert("XRT_COMPOSITOR_COMPUTE".into(), "1".into()); environment.insert("XRT_DEBUG_GUI".into(), "1".into()); environment.insert("XRT_CURATED_GUI".into(), "1".into()); + environment.insert("U_PACING_APP_USE_MIN_FRAME_PERIOD".into(), "1".into()); environment.insert( "LD_LIBRARY_PATH".into(), format!("{pfx}/lib:{pfx}/lib64", pfx = prefix), diff --git a/src/profiles/survive.rs b/src/profiles/survive.rs index 1e11e90..67fa07d 100644 --- a/src/profiles/survive.rs +++ b/src/profiles/survive.rs @@ -19,6 +19,7 @@ pub fn survive_profile() -> Profile { environment.insert("SURVIVE_TIMECODE_OFFSET_MS".into(), "-6.94".into()); environment.insert("XRT_DEBUG_GUI".into(), "1".into()); environment.insert("XRT_CURATED_GUI".into(), "1".into()); + environment.insert("U_PACING_APP_USE_MIN_FRAME_PERIOD".into(), "1".into()); environment.insert( "LD_LIBRARY_PATH".into(), format!("{pfx}/lib:{pfx}/lib64", pfx = prefix), diff --git a/src/profiles/wivrn.rs b/src/profiles/wivrn.rs index 10f356f..25e5cdd 100644 --- a/src/profiles/wivrn.rs +++ b/src/profiles/wivrn.rs @@ -15,6 +15,7 @@ pub fn wivrn_profile() -> Profile { ); environment.insert("XRT_DEBUG_GUI".into(), "1".into()); environment.insert("XRT_CURATED_GUI".into(), "1".into()); + environment.insert("U_PACING_APP_USE_MIN_FRAME_PERIOD".into(), "1".into()); Profile { uuid: "wivrn-default".into(), name: format!("WiVRn - {name} Default", name = APP_NAME), diff --git a/src/profiles/wmr.rs b/src/profiles/wmr.rs index 7278ff5..bf7d07c 100644 --- a/src/profiles/wmr.rs +++ b/src/profiles/wmr.rs @@ -17,6 +17,7 @@ pub fn wmr_profile() -> Profile { environment.insert("XRT_COMPOSITOR_COMPUTE".into(), "1".into()); environment.insert("XRT_DEBUG_GUI".into(), "1".into()); environment.insert("XRT_CURATED_GUI".into(), "1".into()); + environment.insert("U_PACING_APP_USE_MIN_FRAME_PERIOD".into(), "1".into()); environment.insert( "LD_LIBRARY_PATH".into(), format!("{pfx}/lib:{pfx}/lib64", pfx = prefix), diff --git a/src/ui/app.rs b/src/ui/app.rs index ad1136b..b905c9d 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -8,6 +8,7 @@ use super::job_worker::job::WorkerJob; use super::job_worker::JobWorker; use super::libsurvive_setup_window::LibsurviveSetupWindow; use super::main_view::MainViewMsg; +use super::util::open_with_default_handler; use crate::builders::build_basalt::get_build_basalt_jobs; use crate::builders::build_libsurvive::get_build_libsurvive_jobs; use crate::builders::build_mercury::get_build_mercury_job; @@ -34,19 +35,20 @@ use crate::file_builders::openvrpaths_vrpath::{ use crate::file_utils::setcap_cap_sys_nice_eip; use crate::linux_distro::get_distro; use crate::log_parser::MonadoLog; -use crate::paths::get_ipc_file_path; +use crate::paths::{get_data_dir, get_ipc_file_path}; use crate::profile::{Profile, XRServiceType}; use crate::profiles::lighthouse::lighthouse_profile; use crate::profiles::openhmd::openhmd_profile; use crate::profiles::survive::survive_profile; use crate::profiles::wivrn::wivrn_profile; use crate::profiles::wmr::wmr_profile; +use crate::stateless_action; use crate::ui::build_window::{BuildWindowMsg, BuildWindowOutMsg}; use crate::ui::debug_view::DebugViewInit; use crate::ui::libsurvive_setup_window::LibsurviveSetupMsg; use crate::ui::main_view::{MainView, MainViewInit, MainViewOutMsg}; use crate::xr_devices::XRDevice; -use crate::{stateless_action, withclones}; +use gtk::glib::clone; use gtk::prelude::*; use relm4::actions::{AccelsPlus, ActionGroupName, RelmAction, RelmActionGroup}; use relm4::adw::traits::MessageDialogExt; @@ -124,6 +126,8 @@ pub enum Msg { Quit, ParseLog(Vec), ConfigFbt, + DebugOpenPrefix, + DebugOpenData, } impl App { @@ -619,6 +623,15 @@ impl SimpleComponent for App { )); self.application.quit(); } + Msg::DebugOpenData => { + open_with_default_handler(&format!("file://{}", get_data_dir())); + } + Msg::DebugOpenPrefix => { + open_with_default_handler(&format!( + "file://{}", + self.get_selected_profile().prefix + )); + } } } @@ -712,59 +725,76 @@ impl SimpleComponent for App { let mut actions = RelmActionGroup::::new(); - { - withclones![sender]; - stateless_action!(actions, BuildProfileAction, { + stateless_action!( + actions, + BuildProfileAction, + clone!(@strong sender => move |_| { sender.input_sender().emit(Msg::BuildProfile(false, false)); - }); - } - { - withclones![sender]; - stateless_action!(actions, BuildProfileCleanAction, { + }) + ); + stateless_action!( + actions, + BuildProfileCleanAction, + clone!(@strong sender => move |_| { sender.input_sender().emit(Msg::BuildProfile(true, false)); - }); - } - { - withclones![sender]; - stateless_action!(actions, BuildProfileDebugAction, { + }) + ); + stateless_action!( + actions, + BuildProfileDebugAction, + clone!(@strong sender => move |_| { sender.input_sender().emit(Msg::BuildProfile(false, true)); - }); - } - { - withclones![sender]; - stateless_action!(actions, BuildProfileCleanDebugAction, { + }) + ); + stateless_action!( + actions, + BuildProfileCleanDebugAction, + clone!(@strong sender => move |_| { sender.input_sender().emit(Msg::BuildProfile(true, true)); - }); - } - { - withclones![sender]; - stateless_action!(actions, ConfigFbtAction, { + }) + ); + stateless_action!( + actions, + ConfigFbtAction, + clone!(@strong sender => move |_| { sender.input_sender().emit(Msg::ConfigFbt); - }); - } + }) + ); { let abd_sender = model.about_dialog.sender().clone(); - stateless_action!(actions, AboutAction, { + stateless_action!(actions, AboutAction, move |_| { abd_sender.send(()).unwrap(); }); } - { - withclones![sender]; - stateless_action!(actions, QuitAction, { + stateless_action!( + actions, + QuitAction, + clone!(@strong sender => move |_| { sender.input(Msg::Quit); - }); - } - { - withclones![sender]; - actions.add_action(RelmAction::::new_stateful( - &model.enable_debug_view, - move |_, state| { - let s = *state; - *state = !s; - sender.input(Msg::EnableDebugViewChanged(*state)); - }, - )) - } + }) + ); + stateless_action!( + actions, + DebugOpenDataAction, + clone!(@strong sender => move |_| { + sender.input(Msg::DebugOpenData); + }) + ); + stateless_action!( + actions, + DebugOpenPrefixAction, + clone!(@strong sender => move |_| { + sender.input(Msg::DebugOpenPrefix); + }) + ); + actions.add_action(RelmAction::::new_stateful( + &model.enable_debug_view, + clone!(@strong sender => move |_, state| { + let s = *state; + *state = !s; + sender.input(Msg::EnableDebugViewChanged(*state)); + }), + )); root.insert_action_group(AppActionGroup::NAME, Some(&actions.into_action_group())); @@ -780,13 +810,13 @@ impl SimpleComponent for App { model.config.clone(), )); - { - withclones![sender]; - glib::timeout_add_local(Duration::from_millis(1000), move || { + glib::timeout_add_local( + Duration::from_millis(1000), + clone!(@strong sender => move || { sender.input(Msg::ClockTicking); glib::ControlFlow::Continue - }); - } + }), + ); model.split_view = Some(widgets.split_view.clone()); @@ -803,3 +833,6 @@ new_stateless_action!(pub BuildProfileCleanDebugAction, AppActionGroup, "buildpr new_stateless_action!(pub ConfigFbtAction, AppActionGroup, "configfbt"); new_stateless_action!(pub QuitAction, AppActionGroup, "quit"); new_stateful_action!(pub DebugViewToggleAction, AppActionGroup, "debugviewtoggle", (), bool); + +new_stateless_action!(pub DebugOpenDataAction, AppActionGroup, "debugopendata"); +new_stateless_action!(pub DebugOpenPrefixAction, AppActionGroup, "debugopenprefix"); diff --git a/src/ui/debug_view.rs b/src/ui/debug_view.rs index 0721c3f..4eb7657 100644 --- a/src/ui/debug_view.rs +++ b/src/ui/debug_view.rs @@ -1,6 +1,7 @@ use crate::log_level::LogLevel; use crate::log_parser::MonadoLog; -use crate::withclones; +use crate::ui::app::{DebugOpenDataAction, DebugOpenPrefixAction}; +use gtk::glib::clone; use gtk::prelude::*; use relm4::prelude::*; use relm4::{ComponentSender, SimpleComponent}; @@ -57,6 +58,15 @@ impl SimpleComponent for DebugView { type Input = DebugViewMsg; type Output = (); + menu! { + debug_menu: { + section! { + "Open Envision _Data Folder" => DebugOpenDataAction, + "Open _Prefix Folder" => DebugOpenPrefixAction, + }, + } + } + view! { gtk::Box { set_orientation: gtk::Orientation::Vertical, @@ -66,6 +76,11 @@ impl SimpleComponent for DebugView { set_hexpand: true, set_vexpand: false, add_css_class: "flat", + pack_end: debug_menu_btn = >k::MenuButton { + set_icon_name: "view-more-symbolic", + set_tooltip_text: Some("Debug Actions..."), + set_menu_model: Some(&debug_menu), + }, pack_end: search_toggle = >k::ToggleButton { set_icon_name: "edit-find-symbolic", set_tooltip_text: Some("Filter Log"), @@ -255,24 +270,18 @@ impl SimpleComponent for DebugView { if let Some(btn) = log_level_dropdown.first_child() { btn.add_css_class("flat"); } - { - withclones![sender]; - log_level_dropdown.connect_selected_notify(move |dd| { - sender.input(Self::Input::LogLevelChanged( - *LogLevel::iter() - .as_slice() - .get(dd.selected() as usize) - .unwrap(), - )); - }); - } + log_level_dropdown.connect_selected_notify(clone!(@strong sender => move |dd| { + sender.input(Self::Input::LogLevelChanged( + *LogLevel::iter() + .as_slice() + .get(dd.selected() as usize) + .unwrap(), + )); + })); - { - withclones![sender]; - adw::StyleManager::default().connect_dark_notify(move |_| { - sender.input(Self::Input::SetColorScheme); - }); - } + adw::StyleManager::default().connect_dark_notify(clone!(@strong sender => move |_| { + sender.input(Self::Input::SetColorScheme); + })); Self::set_color_scheme(&textbuf); let mut model = Self { diff --git a/src/ui/factories/tracker_role_group_factory.rs b/src/ui/factories/tracker_role_group_factory.rs index aa2ad75..2edf607 100644 --- a/src/ui/factories/tracker_role_group_factory.rs +++ b/src/ui/factories/tracker_role_group_factory.rs @@ -2,9 +2,9 @@ use crate::{ file_builders::monado_config_v0::{TrackerRole, XrtTrackerRole}, ui::fbt_config_editor::FbtConfigEditorMsg, ui::preference_rows::{combo_row, entry_row}, - withclones, }; use adw::prelude::*; +use gtk::glib::clone; use relm4::{factory::AsyncFactoryComponent, prelude::*, AsyncFactorySender}; #[derive(Debug)] @@ -54,27 +54,29 @@ impl AsyncFactoryComponent for TrackerRoleModel { } }, add: { - withclones![sender]; let tr = self.tracker_role.clone(); - &entry_row("Device serial", self.tracker_role.device_serial.as_str(), move |row| { - let mut ntr = tr.clone(); - ntr.device_serial = row.text().to_string(); - sender.input(Self::Input::Changed(ntr)); - }) + &entry_row( + "Device serial", + self.tracker_role.device_serial.as_str(), + clone!(@strong sender => move |row| { + let mut ntr = tr.clone(); + ntr.device_serial = row.text().to_string(); + sender.input(Self::Input::Changed(ntr)); + }) + ) }, add: { - withclones![sender]; let tr = self.tracker_role.clone(); &combo_row("Tracker role", None, &tr.role.clone().to_picker_string(), XrtTrackerRole::iter() .map(XrtTrackerRole::to_picker_string) .map(String::from) .collect::>(), - move |row| { + clone!(@strong sender => move |row| { let mut ntr = tr.clone(); ntr.role = XrtTrackerRole::from_number(&row.selected()); sender.input(Self::Input::Changed(ntr)); - } + }) ) }, } diff --git a/src/ui/fbt_config_editor.rs b/src/ui/fbt_config_editor.rs index c81a536..70abbe8 100644 --- a/src/ui/fbt_config_editor.rs +++ b/src/ui/fbt_config_editor.rs @@ -4,9 +4,9 @@ use crate::{ dump_monado_config_v0, get_monado_config_v0, MonadoConfigV0, TrackerRole, }, ui::factories::tracker_role_group_factory::TrackerRoleModelInit, - withclones, }; use adw::prelude::*; +use gtk::glib::clone; use relm4::{factory::AsyncFactoryVecDeque, prelude::*}; #[tracker::track] @@ -113,18 +113,12 @@ impl SimpleComponent for FbtConfigEditor { .label("Save") .css_classes(["suggested-action"]) .build(); - { - withclones![sender]; - add_btn.connect_clicked(move |_| { - sender.input(Self::Input::TrackerRoleNew); - }); - }; - { - withclones![sender]; - save_btn.connect_clicked(move |_| { - sender.input(Self::Input::Save); - }); - }; + add_btn.connect_clicked(clone!(@strong sender => move |_| { + sender.input(Self::Input::TrackerRoleNew); + })); + save_btn.connect_clicked(clone!(@strong sender => move |_| { + sender.input(Self::Input::Save); + })); let mut model = Self { win: None, diff --git a/src/ui/job_worker/internal_worker.rs b/src/ui/job_worker/internal_worker.rs index dd623e1..a29b656 100644 --- a/src/ui/job_worker/internal_worker.rs +++ b/src/ui/job_worker/internal_worker.rs @@ -1,4 +1,4 @@ -use crate::{profile::Profile, withclones}; +use crate::profile::{LighthouseDriver, Profile}; use super::{ job::{CmdWorkerData, FuncWorkerOut, WorkerJob}, @@ -89,7 +89,7 @@ impl Worker for InternalJobWorker { let mut job = self.jobs.pop_front().unwrap(); match &mut job { WorkerJob::Cmd(data) => { - withclones![data]; + let data = data.clone(); if let Ok(mut cmd) = Command::new(data.command) .args(data.args) .envs(data.environment) @@ -161,10 +161,15 @@ impl InternalJobWorker { ) -> relm4::WorkerHandle { let mut env = prof.environment.clone(); if !env.contains_key("LH_DRIVER") { - env.insert( - "LH_DRIVER".into(), - prof.lighthouse_driver.to_string().to_lowercase(), - ); + match prof.lighthouse_driver { + // don't set LH_DRIVER for steamvr driver, set this instead + LighthouseDriver::SteamVR => { + env.insert("STEAMVR_LH_ENABLE".into(), "true".into()); + } + d => { + env.insert("LH_DRIVER".into(), d.to_string().to_lowercase()); + } + } } let mut launch_opts = prof.xrservice_launch_options.trim(); let debug_launch_opts = if debug { diff --git a/src/ui/macros.rs b/src/ui/macros.rs index a66695d..1f00012 100644 --- a/src/ui/macros.rs +++ b/src/ui/macros.rs @@ -1,13 +1,6 @@ -#[macro_export] -macro_rules! withclones { - ($($var:ident),+) => { - $(let $var = $var.clone();)+ - }; -} - #[macro_export] macro_rules! stateless_action { - ($group:ident, $name:ident, $ex:expr) => { - $group.add_action(RelmAction::<$name>::new_stateless(move |_| $ex)); + ($group:ident, $name:ident, $fun:expr) => { + $group.add_action(RelmAction::<$name>::new_stateless($fun)); }; } diff --git a/src/ui/main_view.rs b/src/ui/main_view.rs index 4fe1534..d9d70c6 100644 --- a/src/ui/main_view.rs +++ b/src/ui/main_view.rs @@ -4,6 +4,7 @@ use super::install_wivrn_box::{InstallWivrnBox, InstallWivrnBoxInit, InstallWivr use super::profile_editor::{ProfileEditor, ProfileEditorMsg, ProfileEditorOutMsg}; use super::stardust::stardust_view::StardustView; use super::steam_launch_options_box::{SteamLaunchOptionsBox, SteamLaunchOptionsBoxMsg}; +use super::steamvr_calibration_box::SteamVrCalibrationBox; use crate::config::Config; use crate::file_utils::mount_has_nosuid; use crate::gpu_profile::{ @@ -18,6 +19,7 @@ use crate::ui::app::{ }; use crate::ui::profile_editor::ProfileEditorInit; use crate::ui::stardust::stardust_view::StardustViewInit; +use crate::ui::steamvr_calibration_box::SteamVrCalibrationBoxMsg; use crate::ui::util::{limit_dropdown_width, warning_heading}; use crate::xr_devices::XRDevice; use gtk::prelude::*; @@ -48,6 +50,8 @@ pub struct MainView { #[tracker::do_not_track] profile_editor: Option>, #[tracker::do_not_track] + steamvr_calibration_box: Controller, + #[tracker::do_not_track] root_win: gtk::Window, #[tracker::do_not_track] stardust_view: Controller, @@ -338,6 +342,7 @@ impl SimpleComponent for MainView { }, model.steam_launch_options_box.widget(), model.install_wivrn_box.widget(), + model.steamvr_calibration_box.widget(), gtk::Box { set_orientation: gtk::Orientation::Vertical, set_hexpand: true, @@ -347,6 +352,13 @@ impl SimpleComponent for MainView { add_css_class: "padded", #[track = "model.changed(Self::selected_profile())"] set_visible: model.selected_profile.lighthouse_driver == LighthouseDriver::Survive, + set_hexpand: true, + set_vexpand: false, + set_spacing: 12, + add_css_class: "card", + add_css_class: "padded", + #[track = "model.changed(Self::selected_profile())"] + set_visible: model.selected_profile.lighthouse_driver == LighthouseDriver::Survive, gtk::Label { add_css_class: "heading", set_hexpand: true, @@ -376,7 +388,27 @@ impl SimpleComponent for MainView { } }, }, - } + gtk::Label { + add_css_class: "dim-label", + set_hexpand: true, + set_label: concat!( + "Libsurvive needs to import your SteamVR calibration to work ", + "properly. You need to have used SteamVR with this setup ", + "before to be able to import its calibration." + ), + set_xalign: 0.0, + set_wrap: true, + set_wrap_mode: gtk::pango::WrapMode::Word, + }, + gtk::Button { + add_css_class: "suggested-action", + set_label: "Calibrate", + set_halign: gtk::Align::Start, + connect_clicked[sender] => move |_| { + sender.output(Self::Output::OpenLibsurviveSetup).expect("Sender output failed"); + } + }, + }, } } -> { set_name: Some("main_view"), @@ -479,6 +511,9 @@ impl SimpleComponent for MainView { } Self::Input::XRServiceActiveChanged(active, profile) => { self.set_xrservice_active(active); + self.steamvr_calibration_box + .sender() + .emit(SteamVrCalibrationBoxMsg::XRServiceActiveChanged(active)); if !active { sender.input(Self::Input::UpdateDevices(vec![])); } @@ -506,6 +541,11 @@ impl SimpleComponent for MainView { } Self::Input::UpdateSelectedProfile(prof) => { self.set_selected_profile(prof.clone()); + self.steamvr_calibration_box + .sender() + .emit(SteamVrCalibrationBoxMsg::SetVisible( + prof.lighthouse_driver == LighthouseDriver::SteamVR, + )); self.install_wivrn_box .sender() .emit(InstallWivrnBoxMsg::UpdateSelectedProfile(prof.clone())); @@ -644,6 +684,13 @@ impl SimpleComponent for MainView { }); } + let steamvr_calibration_box = SteamVrCalibrationBox::builder().launch(()).detach(); + steamvr_calibration_box + .sender() + .emit(SteamVrCalibrationBoxMsg::SetVisible( + init.selected_profile.lighthouse_driver == LighthouseDriver::SteamVR, + )); + let mut model = Self { xrservice_active: false, enable_debug_view: init.config.debug_view_enabled, @@ -661,6 +708,7 @@ impl SimpleComponent for MainView { profile_not_editable_dialog, profile_delete_confirm_dialog, root_win: init.root_win.clone(), + steamvr_calibration_box, profile_editor: None, stardust_view: StardustView::builder() .launch(StardustViewInit { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8929c25..e5f0204 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -16,5 +16,6 @@ pub mod preference_rows; pub mod profile_editor; pub mod stardust; pub mod steam_launch_options_box; +mod steamvr_calibration_box; pub mod util; pub mod wivrn_conf_editor; diff --git a/src/ui/preference_rows.rs b/src/ui/preference_rows.rs index 0f28430..171b9b0 100644 --- a/src/ui/preference_rows.rs +++ b/src/ui/preference_rows.rs @@ -1,8 +1,7 @@ -use crate::withclones; use adw::prelude::*; use gtk::{ gio, - glib::{self, GString}, + glib::{self, clone, GString}, }; use relm4::prelude::*; @@ -94,33 +93,26 @@ pub fn path_row) + 'static + Clone>( .valign(gtk::Align::Center) .build(); row.add_suffix(&clear_btn); - { - withclones![path_label, cb]; - clear_btn.connect_clicked(move |_| { - path_label.set_label("(None)"); - cb(None) - }); - } + clear_btn.connect_clicked(clone!(@strong path_label, @strong cb => move |_| { + path_label.set_label("(None)"); + cb(None) + })); let filedialog = gtk::FileDialog::builder() .modal(true) .title(format!("Select Path for {}", title)) .build(); - { - withclones![path_label]; - row.connect_activated(move |_| { - withclones![path_label, cb]; - filedialog.select_folder(root_win.as_ref(), gio::Cancellable::NONE, move |res| { - if let Ok(file) = res { - if let Some(path) = file.path() { - let path_s = path.to_str().unwrap().to_string(); - path_label.set_text(path_s.as_str()); - cb(Some(path_s)) - } + row.connect_activated(clone!(@strong path_label => move |_| { + filedialog.select_folder(root_win.as_ref(), gio::Cancellable::NONE, clone!(@strong path_label, @strong cb => move |res| { + if let Ok(file) = res { + if let Some(path) = file.path() { + let path_s = path.to_str().unwrap().to_string(); + path_label.set_text(path_s.as_str()); + cb(Some(path_s)) } - }) - }); - } + } + })) + })); row } diff --git a/src/ui/profile_editor.rs b/src/ui/profile_editor.rs index 7fde772..20fbdc9 100644 --- a/src/ui/profile_editor.rs +++ b/src/ui/profile_editor.rs @@ -6,9 +6,9 @@ use crate::{ env_var_descriptions::env_var_descriptions_as_paragraph, profile::{LighthouseDriver, Profile, XRServiceType}, ui::preference_rows::{combo_row, entry_row, path_row, switch_row}, - withclones, }; use adw::prelude::*; +use gtk::glib::clone; use relm4::{factory::AsyncFactoryVecDeque, prelude::*}; use std::{cell::RefCell, rc::Rc}; @@ -80,35 +80,30 @@ impl SimpleComponent for ProfileEditor { set_vexpand: true, add: maingrp = &adw::PreferencesGroup { set_title: "General", - add: { - withclones![prof]; - &entry_row("Profile Name", model.profile.borrow().name.as_str(), move |row| { + add: &entry_row( + "Profile Name", + model.profile.borrow().name.as_str(), + clone!(@strong prof => move |row| { prof.borrow_mut().name = row.text().to_string(); }) - }, - add: { - withclones![prof]; - &switch_row( - "Update on Build", None, - model.profile.borrow().pull_on_build, - move |_, state| { - prof.borrow_mut().pull_on_build = state; - gtk::glib::Propagation::Proceed - } - ) - }, - add: { - withclones![prof]; - &path_row( - "Install Prefix", - None, - Some(model.profile.borrow().prefix.clone()), - Some(init.root_win.clone()), - move |n_path| { - prof.borrow_mut().prefix = n_path.unwrap_or_default() - }, - ) - }, + ), + add: &switch_row( + "Update on Build", None, + model.profile.borrow().pull_on_build, + clone!(@strong prof => move |_, state| { + prof.borrow_mut().pull_on_build = state; + gtk::glib::Propagation::Proceed + }) + ), + add: &path_row( + "Install Prefix", + None, + Some(model.profile.borrow().prefix.clone()), + Some(init.root_win.clone()), + clone!(@strong prof => move |n_path| { + prof.borrow_mut().prefix = n_path.unwrap_or_default() + }), + ), }, add: xrservicegrp = &adw::PreferencesGroup { set_title: "XR Service", @@ -119,227 +114,176 @@ impl SimpleComponent for ProfileEditor { "For launch options, you can insert %command% as ", "a placeholder for the actual XR Service command.", )), - add: { - withclones![prof]; - &combo_row( - "XR Service Type", - Some("Monado is for PCVR headsets, while WiVRn is for Andorid standalone headsets"), - model.profile.borrow().xrservice_type.to_string().as_str(), - XRServiceType::iter() - .map(XRServiceType::to_string) - .collect::>(), - move |row| { - prof.borrow_mut().xrservice_type = - XRServiceType::from_number(row.selected()); - }, - ) - }, - add: { - withclones![prof]; - &entry_row( - "XR Service Launch Options", - model.profile.borrow().xrservice_launch_options.as_str(), - move |row| { - prof.borrow_mut().xrservice_launch_options = row.text().trim().to_string(); - } - ) - }, - add: { - withclones![prof]; - &combo_row( - "Lighthouse Driver", - Some(concat!( - "Driver for lighhouse tracked XR devices (ie: Valve Index, HTC Vive...). Only applicable for Monado.\n\n", - "Vive: 3DOF tracking\n\n", - "Survive: 6DOF reverse engineered lighthouse tracking provided by Libsurvive\n\n", - "SteamVR: 6DOF lighthouse tracking using the proprietary SteamVR driver", - )), - model.profile.borrow().lighthouse_driver.to_string().as_str(), - LighthouseDriver::iter() - .map(LighthouseDriver::to_string) - .collect::>(), - move |row| { - prof.borrow_mut().lighthouse_driver = - LighthouseDriver::from_number(row.selected()); - } - ) - }, - add: { - withclones![prof]; - &path_row( - "XR Service Path", - None, - Some(model.profile.borrow().xrservice_path.clone()), - Some(init.root_win.clone()), - move |n_path| { - prof.borrow_mut().xrservice_path = n_path.unwrap_or_default() - }, - ) - }, - add: { - withclones![prof]; - &entry_row( - "XR Service Repo", - model.profile.borrow().xrservice_repo.clone().unwrap_or_default().as_str(), - move |row| { - let n_val = row.text().to_string(); - prof.borrow_mut().xrservice_repo = (!n_val.is_empty()).then_some(n_val); - } - ) - }, + add: &combo_row( + "XR Service Type", + Some("Monado is for PCVR headsets, while WiVRn is for Andorid standalone headsets"), + model.profile.borrow().xrservice_type.to_string().as_str(), + XRServiceType::iter() + .map(XRServiceType::to_string) + .collect::>(), + clone!(@strong prof => move |row| { + prof.borrow_mut().xrservice_type = + XRServiceType::from_number(row.selected()); + }), + ), + add: &entry_row( + "XR Service Launch Options", + model.profile.borrow().xrservice_launch_options.as_str(), + clone!(@strong prof => move |row| { + prof.borrow_mut().xrservice_launch_options = row.text().trim().to_string(); + }) + ), + add: &combo_row( + "Lighthouse Driver", + Some(concat!( + "Driver for lighhouse tracked XR devices (ie: Valve Index, HTC Vive...). Only applicable for Monado.\n\n", + "Vive: 3DOF tracking\n\n", + "Survive: 6DOF reverse engineered lighthouse tracking provided by Libsurvive\n\n", + "SteamVR: 6DOF lighthouse tracking using the proprietary SteamVR driver", + )), + model.profile.borrow().lighthouse_driver.to_string().as_str(), + LighthouseDriver::iter() + .map(LighthouseDriver::to_string) + .collect::>(), + clone!(@strong prof => move |row| { + prof.borrow_mut().lighthouse_driver = + LighthouseDriver::from_number(row.selected()); + }) + ), + add: &path_row( + "XR Service Path", + None, + Some(model.profile.borrow().xrservice_path.clone()), + Some(init.root_win.clone()), + clone!(@strong prof => move |n_path| { + prof.borrow_mut().xrservice_path = n_path.unwrap_or_default() + }), + ), + add: &entry_row( + "XR Service Repo", + model.profile.borrow().xrservice_repo.clone().unwrap_or_default().as_str(), + clone!(@strong prof => move |row| { + let n_val = row.text().to_string(); + prof.borrow_mut().xrservice_repo = (!n_val.is_empty()).then_some(n_val); + }) + ), }, add: model.xrservice_cmake_flags_rows.widget(), add: opencompgrp = &adw::PreferencesGroup { set_title: "OpenComposite", set_description: Some("OpenVR driver built on top of OpenXR\n\nWhen specifying a repository, you can set a specific git ref (branch, tag, commit...) by appending a '#' followed by the ref"), - add: { - withclones![prof]; - &path_row( - "OpenComposite Path", None, - Some(model.profile.borrow().opencomposite_path.clone()), - Some(init.root_win.clone()), - move |n_path| { - prof.borrow_mut().opencomposite_path = n_path.unwrap_or_default(); - } - ) - }, - add: { - withclones![prof]; - &entry_row( - "OpenComposite Repo", - model.profile.borrow().opencomposite_repo.clone().unwrap_or_default().as_str(), - move |row| { - let n_val = row.text().to_string(); - prof.borrow_mut().opencomposite_repo = (!n_val.is_empty()).then_some(n_val); - } - ) - }, + add: &path_row( + "OpenComposite Path", None, + Some(model.profile.borrow().opencomposite_path.clone()), + Some(init.root_win.clone()), + clone!(@strong prof => move |n_path| { + prof.borrow_mut().opencomposite_path = n_path.unwrap_or_default(); + }) + ), + add: &entry_row( + "OpenComposite Repo", + model.profile.borrow().opencomposite_repo.clone().unwrap_or_default().as_str(), + clone!(@strong prof => move |row| { + let n_val = row.text().to_string(); + prof.borrow_mut().opencomposite_repo = (!n_val.is_empty()).then_some(n_val); + }) + ), }, add: libsurvivegrp = &adw::PreferencesGroup { set_title: "Libsurvive", set_description: Some("Lighthouse tracking driver\n\nWhen specifying a repository, you can set a specific git ref (branch, tag, commit...) by appending a '#' followed by the ref"), - add: { - withclones![prof]; - &switch_row( - "Enable Libsurvive", None, - model.profile.borrow().features.libsurvive.enabled, - move |_, state| { - prof.borrow_mut().features.libsurvive.enabled = state; - gtk::glib::Propagation::Proceed - } - ) - }, - add: { - withclones![prof]; - &path_row( - "Libsurvive Path", None, - model.profile.borrow().features.libsurvive.path.clone(), - Some(init.root_win.clone()), - move |n_path| { - prof.borrow_mut().features.libsurvive.path = n_path; - } - ) - }, - add: { - withclones![prof]; - &entry_row( - "Libsurvive Repo", - model.profile.borrow().features.libsurvive.repo.clone().unwrap_or_default().as_str(), - move |row| { - let n_val = row.text().to_string(); - prof.borrow_mut().features.libsurvive.repo = (!n_val.is_empty()).then_some(n_val); - } - ) - }, + add: &switch_row( + "Enable Libsurvive", None, + model.profile.borrow().features.libsurvive.enabled, + clone!(@strong prof => move |_, state| { + prof.borrow_mut().features.libsurvive.enabled = state; + gtk::glib::Propagation::Proceed + }) + ), + add: &path_row( + "Libsurvive Path", None, + model.profile.borrow().features.libsurvive.path.clone(), + Some(init.root_win.clone()), + clone!(@strong prof => move |n_path| { + prof.borrow_mut().features.libsurvive.path = n_path; + }) + ), + add: &entry_row( + "Libsurvive Repo", + model.profile.borrow().features.libsurvive.repo.clone().unwrap_or_default().as_str(), + clone!(@strong prof => move |row| { + let n_val = row.text().to_string(); + prof.borrow_mut().features.libsurvive.repo = (!n_val.is_empty()).then_some(n_val); + }) + ), }, add: openhmdgrp = &adw::PreferencesGroup { set_title: "OpenHMD", set_description: Some("Legacy driver for older Oculus HMDs\n\nWhen specifying a repository, you can set a specific git ref (branch, tag, commit...) by appending a '#' followed by the ref"), - add: { - withclones![prof]; - &switch_row( - "Enable OpenHMD", None, - model.profile.borrow().features.openhmd.enabled, - move |_, state| { - prof.borrow_mut().features.openhmd.enabled = state; - gtk::glib::Propagation::Proceed - } - ) - }, - add: { - withclones![prof]; - &path_row( - "OpenHMD Path", None, - model.profile.borrow().features.openhmd.path.clone(), - Some(init.root_win.clone()), - move |n_path| { - prof.borrow_mut().features.openhmd.path = n_path; - } - ) - }, - add: { - withclones![prof]; - &entry_row( - "OpenHMD Repo", - model.profile.borrow().features.openhmd.repo.clone().unwrap_or_default().as_str(), - move |row| { - let n_val = row.text().to_string(); - prof.borrow_mut().features.openhmd.repo = (!n_val.is_empty()).then_some(n_val); - } - ) - }, + add: &switch_row( + "Enable OpenHMD", None, + model.profile.borrow().features.openhmd.enabled, + clone!(@strong prof => move |_, state| { + prof.borrow_mut().features.openhmd.enabled = state; + gtk::glib::Propagation::Proceed + }) + ), + add: &path_row( + "OpenHMD Path", None, + model.profile.borrow().features.openhmd.path.clone(), + Some(init.root_win.clone()), + clone!(@strong prof => move |n_path| { + prof.borrow_mut().features.openhmd.path = n_path; + }) + ), + add: &entry_row( + "OpenHMD Repo", + model.profile.borrow().features.openhmd.repo.clone().unwrap_or_default().as_str(), + clone!(@strong prof => move |row| { + let n_val = row.text().to_string(); + prof.borrow_mut().features.openhmd.repo = (!n_val.is_empty()).then_some(n_val); + }) + ), }, add: basaltgrp = &adw::PreferencesGroup { set_title: "Basalt", set_description: Some("Camera based SLAM tracking driver\n\nWhen specifying a repository, you can set a specific git ref (branch, tag, commit...) by appending a '#' followed by the ref"), - add: { - withclones![prof]; - &switch_row( - "Enable Basalt", None, - model.profile.borrow().features.basalt.enabled, - move |_, state| { - prof.borrow_mut().features.basalt.enabled = state; - gtk::glib::Propagation::Proceed - } - ) - }, - add: { - withclones![prof]; - &path_row( - "Basalt Path", None, - model.profile.borrow().features.basalt.path.clone(), - Some(init.root_win.clone()), - move |n_path| { - prof.borrow_mut().features.basalt.path = n_path; - } - ) - }, - add: { - withclones![prof]; - &entry_row( - "Basalt Repo", - model.profile.borrow().features.basalt.repo.clone().unwrap_or_default().as_str(), - move |row| { - let n_val = row.text().to_string(); - prof.borrow_mut().features.basalt.repo = n_val.is_empty().then_some(n_val); - } - ) - }, + add: &switch_row( + "Enable Basalt", None, + model.profile.borrow().features.basalt.enabled, + clone!(@strong prof => move |_, state| { + prof.borrow_mut().features.basalt.enabled = state; + gtk::glib::Propagation::Proceed + }) + ), + add: &path_row( + "Basalt Path", None, + model.profile.borrow().features.basalt.path.clone(), + Some(init.root_win.clone()), + clone!(@strong prof => move |n_path| { + prof.borrow_mut().features.basalt.path = n_path; + }) + ), + add: &entry_row( + "Basalt Repo", + model.profile.borrow().features.basalt.repo.clone().unwrap_or_default().as_str(), + clone!(@strong prof => move |row| { + let n_val = row.text().to_string(); + prof.borrow_mut().features.basalt.repo = n_val.is_empty().then_some(n_val); + }) + ), }, add: mercurygrp = &adw::PreferencesGroup { set_title: "Mercury", set_description: Some("Camera and OpenCV based hand tracking driver"), - add: { - withclones![prof]; - &switch_row( - "Enable Mercury", None, - model.profile.borrow().features.mercury_enabled, - move |_, state| { - prof.borrow_mut().features.mercury_enabled = state; - gtk::glib::Propagation::Proceed - } - ) - }, + add: &switch_row( + "Enable Mercury", None, + model.profile.borrow().features.mercury_enabled, + clone!(@strong prof => move |_, state| { + prof.borrow_mut().features.mercury_enabled = state; + gtk::glib::Propagation::Proceed + }) + ), }, add: model.env_rows.widget(), } @@ -465,16 +409,17 @@ impl SimpleComponent for ProfileEditor { .halign(gtk::Align::End) .build(); - withclones![sender, name_entry, popover]; - add_btn.connect_clicked(move |_| { - let key_gstr = name_entry.text(); - let key = key_gstr.trim(); - if !key.is_empty() { - popover.popdown(); - name_entry.set_text(""); - sender.input($event(key.to_string())); - } - }); + add_btn.connect_clicked( + clone!(@strong sender, @strong name_entry, @strong popover => move |_| { + let key_gstr = name_entry.text(); + let key = key_gstr.trim(); + if !key.is_empty() { + popover.popdown(); + name_entry.set_text(""); + sender.input($event(key.to_string())); + } + }) + ); btn }}; } diff --git a/src/ui/stardust/stardust_view.rs b/src/ui/stardust/stardust_view.rs index f2ec034..502449d 100644 --- a/src/ui/stardust/stardust_view.rs +++ b/src/ui/stardust/stardust_view.rs @@ -12,10 +12,9 @@ use crate::{ build_window::{BuildStatus, BuildWindow, BuildWindowMsg, BuildWindowOutMsg}, job_worker::{internal_worker::JobWorkerOut, job::WorkerJob, JobWorker}, }, - withclones, }; use adw::prelude::*; -use gtk::prelude::*; +use gtk::{prelude::*, glib::clone}; use relm4::{ actions::{ActionGroupName, RelmAction, RelmActionGroup}, new_action_group, new_stateless_action, @@ -468,22 +467,16 @@ impl SimpleComponent for StardustView { let mut actions = RelmActionGroup::::new(); - { - withclones![sender]; - stateless_action!(actions, BuildStardustAction, { - sender - .input_sender() - .emit(Self::Input::BuildStardust { update: false }); - }); - } - { - withclones![sender]; - stateless_action!(actions, UpdateStardustAction, { - sender - .input_sender() - .emit(Self::Input::BuildStardust { update: true }); - }); - } + stateless_action!(actions, BuildStardustAction, clone!(@strong sender => move |_| { + sender + .input_sender() + .emit(Self::Input::BuildStardust { update: false }); + })); + stateless_action!(actions, UpdateStardustAction, clone!(@strong sender => move |_| { + sender + .input_sender() + .emit(Self::Input::BuildStardust { update: true }); + })); root.insert_action_group( StardustActionGroup::NAME, diff --git a/src/ui/steamvr_calibration_box.rs b/src/ui/steamvr_calibration_box.rs new file mode 100644 index 0000000..58c8988 --- /dev/null +++ b/src/ui/steamvr_calibration_box.rs @@ -0,0 +1,210 @@ +use std::{ + collections::{HashMap, VecDeque}, + path::Path, + thread::sleep, + time::Duration, +}; + +use crate::paths::get_steamvr_bin_dir_path; + +use super::job_worker::{ + internal_worker::JobWorkerOut, + job::{FuncWorkerOut, WorkerJob}, + JobWorker, +}; +use relm4::{ + gtk::{self, prelude::*}, + ComponentParts, ComponentSender, RelmWidgetExt, SimpleComponent, +}; + +#[tracker::track] +pub struct SteamVrCalibrationBox { + calibration_running: bool, + calibration_result: Option, + calibration_success: bool, + visible: bool, + xrservice_active: bool, + #[tracker::do_not_track] + server_worker: Option, + #[tracker::do_not_track] + calibration_worker: Option, +} + +#[derive(Debug)] +pub enum SteamVrCalibrationBoxMsg { + SetVisible(bool), + RunCalibration, + OnServerWorkerExit(i32), + OnCalWorkerExit(i32), + XRServiceActiveChanged(bool), + NoOp, +} + +#[relm4::component(pub)] +impl SimpleComponent for SteamVrCalibrationBox { + type Init = (); + type Input = SteamVrCalibrationBoxMsg; + type Output = (); + + view! { + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_hexpand: true, + set_vexpand: false, + set_spacing: 12, + add_css_class: "card", + add_css_class: "padded", + #[track = "model.changed(Self::visible())"] + set_visible: model.visible, + gtk::Label { + add_css_class: "heading", + set_hexpand: true, + set_xalign: 0.0, + set_label: "SteamVR Calibration", + set_wrap: true, + set_wrap_mode: gtk::pango::WrapMode::Word, + }, + gtk::Label { + add_css_class: "dim-label", + set_hexpand: true, + set_label: concat!( + "Run a quick SteamVR calibration.\n\n", + "\u{2022} Plug in your HMD and place it on the floor, ", + "approximately in the middle of your play area\n", + "\u{2022} Make sure your controllers and other VR devices ", + "are turned off\n", + "\u{2022} Click the Calibrate button and wait for the ", + "process to finish\n\n", + "Note that the orientation of your HMD during this process ", + "will dictate the forward direction of your play area.", + ), + set_xalign: 0.0, + set_wrap: true, + set_wrap_mode: gtk::pango::WrapMode::Word, + }, + gtk::Label { + add_css_class: "error", + add_css_class: "success", + set_hexpand: true, + #[track = "model.changed(Self::calibration_result())"] + set_visible: model.calibration_result.is_some(), + #[track = "model.changed(Self::calibration_result())"] + set_label: model.calibration_result.as_ref().unwrap_or(&String::new()), + #[track = "model.changed(Self::calibration_success())"] + set_class_active: ("error", !model.calibration_success), + #[track = "model.changed(Self::calibration_success())"] + set_class_active: ("success", model.calibration_success), + set_xalign: 0.0, + set_wrap: true, + set_wrap_mode: gtk::pango::WrapMode::Word, + }, + gtk::Button { + add_css_class: "suggested-action", + set_label: "Calibrate", + set_halign: gtk::Align::Start, + #[track = "model.changed(Self::calibration_running()) || model.changed(Self::xrservice_active())"] + set_sensitive: !model.xrservice_active && !model.calibration_running, + connect_clicked[sender] => move |_| { + sender.input(Self::Input::RunCalibration); + } + }, + }, + } + + fn update(&mut self, message: Self::Input, sender: ComponentSender) { + self.reset(); + + match message { + Self::Input::SetVisible(state) => { + self.set_visible(state); + } + Self::Input::XRServiceActiveChanged(active) => { + self.set_xrservice_active(active); + } + Self::Input::RunCalibration => { + self.set_calibration_result(None); + let steamvr_bin_dir = get_steamvr_bin_dir_path(); + if !Path::new(&steamvr_bin_dir).is_dir() { + self.set_calibration_success(false); + self.set_calibration_result(Some("SteamVR not found".into())); + return; + } + let mut env: HashMap = HashMap::new(); + env.insert("LD_LIBRARY_PATH".into(), steamvr_bin_dir.clone()); + let vrcmd = format!("{steamvr_bin_dir}/vrcmd"); + let mut server_worker = { + let mut jobs: VecDeque = VecDeque::new(); + jobs.push_back(WorkerJob::new_cmd( + Some(env.clone()), + vrcmd.clone(), + Some(vec!["--pollposes".into()]), + )); + JobWorker::new(jobs, sender.input_sender(), |msg| match msg { + JobWorkerOut::Log(_) => Self::Input::NoOp, + JobWorkerOut::Exit(code) => Self::Input::OnServerWorkerExit(code), + }) + }; + let mut cal_worker = { + let mut jobs: VecDeque = VecDeque::new(); + jobs.push_back(WorkerJob::new_func(Box::new(move || { + sleep(Duration::from_secs(2)); + FuncWorkerOut { + success: true, + out: vec![], + } + }))); + jobs.push_back(WorkerJob::new_cmd( + Some(env), + vrcmd, + Some(vec!["--resetroomsetup".into()]), + )); + JobWorker::new(jobs, sender.input_sender(), |msg| match msg { + JobWorkerOut::Log(_) => Self::Input::NoOp, + JobWorkerOut::Exit(code) => Self::Input::OnCalWorkerExit(code), + }) + }; + + server_worker.start(); + cal_worker.start(); + self.server_worker = Some(server_worker); + self.calibration_worker = Some(cal_worker); + } + Self::Input::OnServerWorkerExit(_) => { + self.calibration_running = false; + } + Self::Input::OnCalWorkerExit(code) => { + self.calibration_success = code == 0; + self.calibration_result = if code == 0 { + Some("Calibration completed".into()) + } else { + Some(format!("Calibration failed with code {code}")) + }; + if let Some(sw) = self.server_worker.as_ref() { + sw.stop(); + } + } + Self::Input::NoOp => {} + } + } + + fn init( + init: Self::Init, + root: &Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let model = Self { + tracker: 0, + visible: false, + calibration_result: None, + calibration_running: false, + calibration_success: false, + xrservice_active: false, + server_worker: None, + calibration_worker: None, + }; + + let widgets = view_output!(); + + ComponentParts { model, widgets } + } +} diff --git a/src/ui/util.rs b/src/ui/util.rs index db23d92..17906d0 100644 --- a/src/ui/util.rs +++ b/src/ui/util.rs @@ -1,4 +1,4 @@ -use gtk4::prelude::*; +use gtk4::{gio, prelude::*}; pub fn limit_dropdown_width(dd: >k4::DropDown, chars: i32) { let mut dd_child = dd @@ -44,3 +44,9 @@ pub fn warning_heading() -> gtk4::Box { b } + +pub fn open_with_default_handler(uri: &str) { + if let Err(e) = gio::AppInfo::launch_default_for_uri(uri, gio::AppLaunchContext::NONE) { + eprintln!("Error opening uri {}: {}", uri, e) + }; +}