use std::{borrow::Cow, collections::HashMap, rc::Rc}; use glam::Vec2; use wgui::{ assets::AssetPath, components::{ button::{ButtonClickEvent, ComponentButton}, slider::ComponentSlider, }, globals::WguiGlobals, i18n::Translation, layout::{Layout, WidgetID}, log::LogErr, parser::{Fetchable, ParseDocumentParams, ParserState}, task::Tasks, widget::label::WidgetLabel, windowing::context_menu::{self, TickResult}, }; use wlx_common::{ config_io, openxr_actions::{OneOrMany, OpenXrInputAction, OpenXrInputProfile, load_xr_input_profiles}, }; use crate::{ frontend::{FrontendTask, FrontendTasks}, tab::settings::horiz_cell, util::{ openxr_bindings_schema::{ BindingsDropdown, ClickType, Component, IdentifierType, ParsedOpenXrInputPath, Profile, Side, SubpathType, }, popup_manager::{MountPopupOnceParams, MountPopupOnceParamsExtra, PopupHolder, PopupPadding}, wgui_simple, }, views::{ViewTrait, ViewUpdateParams}, }; #[derive(Clone)] enum Task { Save, Cancel, OpenContextMenu(glam::Vec2, Vec), UpdateThreshold(Rc, f32, f32), } pub struct Params<'a> { pub globals: WguiGlobals, pub layout: &'a mut Layout, pub parent_id: WidgetID, pub profile_id: Rc, pub profile: Rc, pub close_callback: Box, } pub struct View { parser_state: ParserState, tasks: Tasks, list_parent: WidgetID, globals: WguiGlobals, profiles: Vec, cur_profile_idx: usize, context_menu: context_menu::ContextMenu, schema: Rc, close_callback: Option>, } impl ViewTrait for View { fn update(&mut self, par: &mut ViewUpdateParams) -> anyhow::Result<()> { for task in self.tasks.drain() { match task { Task::Save => { let content = serde_json::to_string_pretty(&self.profiles) .map_err(|e| anyhow::anyhow!("Failed to serialize profiles: {e}"))?; config_io::save("openxr_actions.json5", &content) .map_err(|e| anyhow::anyhow!("Failed to save bindings: {e}"))?; if let Some(close_callback) = self.close_callback.take() { close_callback(); } } Task::Cancel => { if let Some(close_callback) = self.close_callback.take() { close_callback(); } } Task::OpenContextMenu(position, cells) => { self.context_menu.open(context_menu::OpenParams { on_custom_attribs: None, position, blueprint: context_menu::Blueprint::Cells(cells), }); } Task::UpdateThreshold(action_name, lo, hi) => { let cur_profile = &mut self.profiles[self.cur_profile_idx]; let action_mut = get_action_mut(cur_profile, &*action_name); action_mut.threshold = Some([lo, hi]); } } } // Dropdown handling if let TickResult::Action(name) = self.context_menu.tick(par.layout, &mut self.parser_state)? && let (Some(action), Some(action_name), Some(side), Some(value)) = { let mut s = name.splitn(4, ';'); (s.next(), s.next(), s.next(), s.next()) } { let side = side.to_lowercase(); let value = value.to_lowercase(); log::warn!("{action_name}"); let cur_profile = &mut self.profiles[self.cur_profile_idx]; let action_mut = get_action_mut(cur_profile, action_name); let side_mut = if side == "right" { &mut action_mut.right } else { &mut action_mut.left }; match action { "clear" => { *side_mut = None; } "subpath" => { reconstruct_path(&self.schema, side_mut, &side, Some(value.as_str()), None); } "comp" => { reconstruct_path(&self.schema, side_mut, &side, None, Some(value.as_str())); } "click" => match value.as_str() { "triple" => { action_mut.triple_click = Some(true); action_mut.double_click = None; } "double" => { action_mut.triple_click = None; action_mut.double_click = Some(true); } _ => { action_mut.triple_click = None; action_mut.double_click = None; } }, _ => log::warn!("Unknown action {action}"), } self.refresh(par.layout)?; } Ok(()) } } impl View { pub fn new(params: Params) -> anyhow::Result { let doc_params = &ParseDocumentParams { globals: params.globals.clone(), path: AssetPath::BuiltIn("gui/view/bindings.xml"), extra: Default::default(), }; let mut profiles = load_xr_input_profiles(); let cur_profile_idx = profiles .iter() .position(|i| i.profile.as_str() == &*params.profile_id) .unwrap_or_else(|| { let idx = profiles.len(); profiles.push(OpenXrInputProfile { profile: params.profile_id.to_string(), ..Default::default() }); idx }); let parser_state = wgui::parser::parse_from_assets(doc_params, params.layout, params.parent_id)?; let list_parent = parser_state.fetch_widget(¶ms.layout.state, "list_parent")?.id; let tasks = Tasks::new(); tasks.handle_button( &parser_state.fetch_component_as::("btn_save")?, Task::Save, ); tasks.handle_button( &parser_state.fetch_component_as::("btn_cancel")?, Task::Cancel, ); let mut me = Self { parser_state, tasks, list_parent, globals: params.globals.clone(), profiles, cur_profile_idx, context_menu: context_menu::ContextMenu::default(), schema: params.profile, close_callback: Some(params.close_callback), }; me.ensure_pose_and_haptics(); me.refresh(params.layout)?; Ok(me) } fn refresh(&mut self, layout: &mut Layout) -> anyhow::Result<()> { let action_names = [ "click", "grab", "alt_click", "show_hide", "toggle_dashboard", "space_drag", "space_rotate", "space_reset", "click_modifier_right", "click_modifier_middle", "move_mouse", "scroll", ]; let mut mp = MacroParams { parser_state: &mut self.parser_state, doc_params: &ParseDocumentParams { globals: self.globals.clone(), path: AssetPath::BuiltIn("gui/view/bindings.xml"), extra: Default::default(), }, layout, tasks: self.tasks.clone(), idx: 0, }; mp.layout.remove_children(self.list_parent); for action in action_names { let current = get_action_mut(&mut self.profiles[self.cur_profile_idx], action); input_controls_for_action(&mut mp, self.list_parent, action.into(), &self.schema, current)?; } Ok(()) } fn ensure_pose_and_haptics(&mut self) { let cur_profile = &mut self.profiles[self.cur_profile_idx]; if cur_profile.pose.is_none() { let schema = &self.schema; let mut aim_pose_name: Option<&str> = None; let mut first_pose_name: Option<&str> = None; for (key, subpath) in &schema.subpaths { if subpath.kind != SubpathType::Pose { continue; } let name = key.strip_prefix("/input/"); let Some(name) = name else { continue }; if first_pose_name.is_none() { first_pose_name = Some(name); } if name == "aim" { aim_pose_name = Some(name); } } // no aim pose → use first one if let Some(name) = aim_pose_name.or(first_pose_name) { let pose_action = cur_profile.pose.get_or_insert_default(); if pose_action.left.is_none() { let left_path = format!("/user/hand/left/input/{name}/pose"); pose_action.left = Some(OneOrMany::One(left_path)); } if pose_action.right.is_none() { let right_path = format!("/user/hand/right/input/{name}/pose"); pose_action.right = Some(OneOrMany::One(right_path)); } } } if cur_profile.haptic.is_none() { let schema = &self.schema; let mut first_haptic_name: Option<&str> = None; for (key, subpath) in &schema.subpaths { if subpath.kind != SubpathType::Vibration { continue; } let name = key.strip_prefix("/output/"); let Some(name) = name else { continue }; if first_haptic_name.is_none() { first_haptic_name = Some(name); } } if let Some(name) = first_haptic_name { let haptic_action = cur_profile.haptic.get_or_insert_with(OpenXrInputAction::default); if haptic_action.left.is_none() { let left_path = format!("/user/hand/left/output/{name}"); haptic_action.left = Some(OneOrMany::One(left_path)); } if haptic_action.right.is_none() { let right_path = format!("/user/hand/right/output/{name}"); haptic_action.right = Some(OneOrMany::One(right_path)); } } } } } fn get_action_mut<'a>(profile: &'a mut OpenXrInputProfile, action_name: &str) -> &'a mut OpenXrInputAction { let action = match action_name { "click" => &mut profile.click, "grab" => &mut profile.grab, "alt_click" => &mut profile.alt_click, "show_hide" => &mut profile.show_hide, "toggle_dashboard" => &mut profile.toggle_dashboard, "space_drag" => &mut profile.space_drag, "space_rotate" => &mut profile.space_rotate, "space_reset" => &mut profile.space_reset, "click_modifier_right" => &mut profile.click_modifier_right, "click_modifier_middle" => &mut profile.click_modifier_middle, "move_mouse" => &mut profile.move_mouse, "scroll" => &mut profile.scroll, _ => panic!("unknown action_name: {action_name}"), }; action.get_or_insert_with(OpenXrInputAction::default) } pub fn mount_popup( frontend_tasks: FrontendTasks, globals: WguiGlobals, popup: PopupHolder, profile_id: Rc, profile: Rc, ) { frontend_tasks .clone() .push(FrontendTask::MountPopupOnce(MountPopupOnceParams::new( Translation::from_raw_text_rc(profile.title.clone()), Box::new(move |data| { let close_callback = popup.get_close_callback(data.layout); let view = View::new(Params { globals: globals.clone(), layout: data.layout, parent_id: data.id_content, profile_id, profile, close_callback, })?; popup.set_view(data.handle, view, None); Ok(popup.get_close_callback(data.layout)) }), MountPopupOnceParamsExtra { padding: PopupPadding::None, }, ))); } struct MacroParams<'a> { pub layout: &'a mut Layout, pub parser_state: &'a mut ParserState, pub doc_params: &'a ParseDocumentParams<'a>, pub tasks: Tasks, pub idx: usize, } fn input_controls_for_action( mp: &mut MacroParams, parent: WidgetID, action: Rc, profile: &Profile, current: &mut OpenXrInputAction, ) -> anyhow::Result<()> { let id = mp.idx.to_string(); mp.idx += 1; let mut params: HashMap, Rc> = HashMap::new(); params.insert(Rc::from("id"), Rc::from(id.as_ref())); params.insert( Rc::from("translation"), Rc::from(format!( "APP_SETTINGS.BINDINGS.ACTION.{}", action.as_ref().to_uppercase() )), ); mp.parser_state .instantiate_template(mp.doc_params, "ActionRow", mp.layout, parent, params)?; let parent = mp.parser_state.get_widget_id(&id)?; let click_type = if current.triple_click.unwrap_or_default() { ClickType::Triple } else if current.double_click.unwrap_or_default() { ClickType::Double } else { ClickType::Any }; let current_left = current.left.as_ref().map(|x| match x { OneOrMany::One(s) => s.as_str(), OneOrMany::Many(s) => s.first().unwrap().as_str(), // safe }); input_controls_for_hand( mp, parent, current_left, Side::Left, action.clone(), click_type, profile, current.threshold, )?; let current_right = current.right.as_ref().map(|x| match x { OneOrMany::One(s) => s.as_str(), OneOrMany::Many(s) => s.first().unwrap().as_str(), // safe }); input_controls_for_hand( mp, parent, current_right, Side::Right, action, click_type, profile, current.threshold, ) } fn input_controls_for_hand( mp: &mut MacroParams, parent: WidgetID, current: Option<&str>, side: Side, action: Rc, click_type: ClickType, profile: &Profile, threshold: Option<[f32; 2]>, ) -> anyhow::Result<()> { let subaction_path = match side { Side::Left => "/user/hand/left", Side::Right => "/user/hand/right", }; if !profile.subaction_paths.iter().any(|p| p == subaction_path) { return Ok(()); // skip } let current = current.and_then(|cur| ParsedOpenXrInputPath::try_from(cur).log_warn(cur).ok()); let parent = horiz_cell(mp.layout, parent)?; let available_components = current .as_ref() .and_then(|par| profile.subpaths.get(&par.to_subpath())) .map(|subp| subp.get_effective_components()) .unwrap_or_default(); let available_subpaths = profile .subpaths .iter() .filter(|(_, path)| path.side.is_none_or(|s| s == side)) .filter_map(|(key, _)| { key .strip_prefix("/input/") .and_then(|ident| IdentifierType::try_from(ident).ok()) }) .collect::>(); subpath_dropdown( mp, parent, action.clone(), side, available_subpaths, current.as_ref().map(|x| x.identifier), )?; if !component_dropdown( mp, parent, action.clone(), side, available_components, current.as_ref().map(|x| x.component), )? { return Ok(()); } clicks_dropdown(mp, parent, action.clone(), click_type)?; if let Some(component) = current.as_ref().map(|x| x.component) && component.is_analog() { threshold_slider(mp, parent, action, threshold)?; } Ok(()) } fn subpath_dropdown( mp: &mut MacroParams, parent: WidgetID, action: Rc, side: Side, available: Rc<[IdentifierType]>, current: Option, ) -> anyhow::Result<()> { let mut params: HashMap, Rc> = HashMap::new(); params.insert(Rc::from("tooltip"), Rc::from("APP_SETTINGS.BINDINGS.SUBPATH")); params.insert(Rc::from("min_width"), Rc::from("100")); // left/right hand icon wgui_simple::create_icon( mp.layout, parent, Vec2::new(32.0, 32.0), AssetPath::BuiltIn(&format!("dashboard/hand_{}.svg", side.as_ref().to_lowercase())), )?; let current_text = current .map(|c| c.translation()) .unwrap_or_else(|| Translation::from_translation_key("APP_SETTINGS.OPTION.NONE")); create_dropdown(mp, parent, params, action, side, current_text, available)?; Ok(()) } fn component_dropdown( mp: &mut MacroParams, parent: WidgetID, action: Rc, side: Side, available: Rc<[Component]>, current: Option, ) -> anyhow::Result { if available.is_empty() { return Ok(false); } let mut params: HashMap, Rc> = HashMap::new(); params.insert(Rc::from("text"), Rc::from("・")); params.insert(Rc::from("tooltip"), Rc::from("APP_SETTINGS.BINDINGS.COMPONENT")); params.insert(Rc::from("min_width"), Rc::from("100")); let current_text = current .map(|c| c.translation()) .unwrap_or_else(|| Translation::from_raw_text_rc(Default::default())); create_dropdown(mp, parent, params, action, side, current_text, available)?; Ok(true) } fn clicks_dropdown(mp: &mut MacroParams, parent: WidgetID, action: Rc, current: ClickType) -> anyhow::Result<()> { let mut params: HashMap, Rc> = HashMap::new(); params.insert(Rc::from("text"), Rc::from("・")); params.insert(Rc::from("tooltip"), Rc::from("APP_SETTINGS.BINDINGS.CLICK.TYPE")); params.insert(Rc::from("min_width"), Rc::from("100")); let current_text = current.translation(); let available = [ClickType::Any, ClickType::Double, ClickType::Triple].into(); create_dropdown(mp, parent, params, action, Side::Left, current_text, available)?; Ok(()) } fn threshold_slider( mp: &mut MacroParams, parent: WidgetID, action: Rc, current: Option<[f32; 2]>, ) -> anyhow::Result<()> { let id = mp.idx.to_string(); mp.idx += 1; let current = current.unwrap_or([0.4, 0.6]); let mut params: HashMap, Rc> = HashMap::new(); params.insert(Rc::from("id"), Rc::from(id.as_ref())); params.insert(Rc::from("tooltip"), Rc::from("APP_SETTINGS.BINDINGS.THRESHOLD")); params.insert(Rc::from("value"), Rc::from(format!("{:.2}", current[0]))); params.insert(Rc::from("value2"), Rc::from(format!("{:.2}", current[1]))); params.insert(Rc::from("min"), Rc::from("0.0")); params.insert(Rc::from("max"), Rc::from("1.0")); params.insert(Rc::from("step"), Rc::from("0.1")); mp.parser_state .instantiate_template(mp.doc_params, "ThresholdSlider", mp.layout, parent, params)?; let slider = mp.parser_state.fetch_component_as::(&id)?; slider.on_value_changed(Box::new({ let tasks = mp.tasks.clone(); move |_common, e| { if matches!(e.index, wgui::components::slider::ValueIndex::Primary) { tasks.push(Task::UpdateThreshold(action.clone(), e.value, current[1])); } else { tasks.push(Task::UpdateThreshold(action.clone(), current[0], e.value)); } } })); Ok(()) } fn create_dropdown( mp: &mut MacroParams, parent: WidgetID, mut params: HashMap, Rc>, action: Rc, side: Side, current_text: Translation, available: Rc<[B]>, ) -> anyhow::Result<()> { let id = mp.idx.to_string(); mp.idx += 1; params.insert(Rc::from("id"), Rc::from(id.as_ref())); mp.parser_state .instantiate_template(mp.doc_params, "DropdownButton", mp.layout, parent, params)?; { let mut label = mp .parser_state .fetch_widget_as::(&mp.layout.state, &format!("{id}_value"))?; label.set_text_simple(&mut mp.layout.state.globals.get(), current_text); } let btn = mp.parser_state.fetch_component_as::(&id)?; btn.on_click(Rc::new({ let tasks = mp.tasks.clone(); let available = available.clone(); move |_common, e: ButtonClickEvent| { let mut cells = available .iter() .map(|item| { let title = item.translation(); context_menu::Cell { action_name: Some(item.action_str(&*action, side)), title, tooltip: None, attribs: vec![], } }) .collect::>(); if let Some(action_str) = B::clear_str(&*action, side) { cells.insert( 0, context_menu::Cell { action_name: Some(action_str), title: Translation::from_translation_key("APP_SETTINGS.OPTION.NONE"), tooltip: None, attribs: vec![], }, ); } tasks.push(Task::OpenContextMenu(e.mouse_pos_absolute.unwrap_or_default(), cells)); Ok(()) } })); Ok(()) } fn reconstruct_path( schema: &Profile, side_mut: &mut Option>, side: &str, subpath: Option<&str>, comp: Option<&str>, ) { if side_mut.is_none() { let Some(subpath) = subpath else { return; }; if let Some(comp) = comp { *side_mut = Some(OneOrMany::One(format!("/user/hand/{side}/input/{subpath}/{comp}"))); } else { let key = format!("/input/{subpath}"); let Some(schema_subpath) = schema.subpaths.get(&key) else { return; }; let comps = schema_subpath.get_effective_components(); let comp = comps.first().unwrap().as_ref(); // safe *side_mut = Some(OneOrMany::One(format!("/user/hand/{side}/input/{subpath}/{comp}"))); } } else { let first = match side_mut.as_ref().unwrap() { OneOrMany::One(x) => x.as_str(), OneOrMany::Many(x) => x.first().unwrap().as_str(), }; let Ok(parsed) = ParsedOpenXrInputPath::try_from(first) else { return; }; let new_subpath = subpath.map_or_else(|| Cow::Owned(parsed.identifier.as_ref().to_lowercase()), Cow::Borrowed); let mut parsed_compo = parsed.component; let key = format!("/input/{new_subpath}"); let Some(schema_subpath) = schema.subpaths.get(&key) else { return; }; let effective_compo = schema_subpath.get_effective_components(); if !effective_compo.contains(&parsed_compo) { parsed_compo = *effective_compo.first().unwrap(); } let new_comp = comp.map_or_else(|| Cow::Owned(parsed_compo.as_ref().to_lowercase()), Cow::Borrowed); *side_mut = Some(OneOrMany::One(format!( "/user/hand/{side}/input/{new_subpath}/{new_comp}" ))); } }