wayvr/dash-frontend/src/views/bindings.rs

729 lines
19 KiB
Rust

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<context_menu::Cell>),
UpdateThreshold(Rc<str>, f32, f32),
}
pub struct Params<'a> {
pub globals: WguiGlobals,
pub layout: &'a mut Layout,
pub parent_id: WidgetID,
pub profile_id: Rc<str>,
pub profile: Rc<Profile>,
pub close_callback: Box<dyn FnOnce()>,
}
pub struct View {
parser_state: ParserState,
tasks: Tasks<Task>,
list_parent: WidgetID,
globals: WguiGlobals,
profiles: Vec<OpenXrInputProfile>,
cur_profile_idx: usize,
context_menu: context_menu::ContextMenu,
schema: Rc<Profile>,
close_callback: Option<Box<dyn FnOnce()>>,
}
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<Self> {
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(&params.layout.state, "list_parent")?.id;
let tasks = Tasks::new();
tasks.handle_button(
&parser_state.fetch_component_as::<ComponentButton>("btn_save")?,
Task::Save,
);
tasks.handle_button(
&parser_state.fetch_component_as::<ComponentButton>("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<View>,
profile_id: Rc<str>,
profile: Rc<Profile>,
) {
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<Task>,
pub idx: usize,
}
fn input_controls_for_action(
mp: &mut MacroParams,
parent: WidgetID,
action: Rc<str>,
profile: &Profile,
current: &mut OpenXrInputAction,
) -> anyhow::Result<()> {
let id = mp.idx.to_string();
mp.idx += 1;
let mut params: HashMap<Rc<str>, Rc<str>> = 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<str>,
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::<Rc<[IdentifierType]>>();
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<str>,
side: Side,
available: Rc<[IdentifierType]>,
current: Option<IdentifierType>,
) -> anyhow::Result<()> {
let mut params: HashMap<Rc<str>, Rc<str>> = 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<str>,
side: Side,
available: Rc<[Component]>,
current: Option<Component>,
) -> anyhow::Result<bool> {
if available.is_empty() {
return Ok(false);
}
let mut params: HashMap<Rc<str>, Rc<str>> = 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<str>, current: ClickType) -> anyhow::Result<()> {
let mut params: HashMap<Rc<str>, Rc<str>> = 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<str>,
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<str>, Rc<str>> = 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::<ComponentSlider>(&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<B: 'static + BindingsDropdown>(
mp: &mut MacroParams,
parent: WidgetID,
mut params: HashMap<Rc<str>, Rc<str>>,
action: Rc<str>,
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::<WidgetLabel>(&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::<ComponentButton>(&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::<Vec<_>>();
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<OneOrMany<String>>,
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}"
)));
}
}