wip: bindings ui

This commit is contained in:
galister 2026-07-01 16:40:08 +09:00
parent 5014cda64a
commit f3bb2e070b
26 changed files with 1457 additions and 95 deletions

17
Cargo.lock generated
View File

@ -1444,6 +1444,7 @@ dependencies = [
"hyper", "hyper",
"keyvalues-parser", "keyvalues-parser",
"log", "log",
"lz4_flex",
"rust-embed", "rust-embed",
"serde", "serde",
"serde_json", "serde_json",
@ -3104,6 +3105,15 @@ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
] ]
[[package]]
name = "lz4_flex"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
dependencies = [
"twox-hash",
]
[[package]] [[package]]
name = "mach2" name = "mach2"
version = "0.4.3" version = "0.4.3"
@ -5969,6 +5979,12 @@ dependencies = [
"core_maths", "core_maths",
] ]
[[package]]
name = "twox-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
[[package]] [[package]]
name = "typeid" name = "typeid"
version = "1.0.3" version = "1.0.3"
@ -7221,6 +7237,7 @@ dependencies = [
"rust-ini", "rust-ini",
"serde", "serde",
"serde_json", "serde_json",
"serde_json5",
"smol", "smol",
"strum", "strum",
"walkdir", "walkdir",

View File

@ -23,6 +23,7 @@ regex = "1.12.2"
rust-embed = "8.9.0" rust-embed = "8.9.0"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1.0.145" serde_json = "1.0.145"
serde_json5 = "0.2.1"
slotmap = "1.1.1" slotmap = "1.1.1"
smol = "2.0.2" smol = "2.0.2"
strum = { version = "0.27.2", features = ["derive"] } strum = { version = "0.27.2", features = ["derive"] }

View File

@ -16,6 +16,7 @@ http-body-util = "0.1.3"
hyper = { version = "1.8.1", features = ["client", "http1", "http2"] } hyper = { version = "1.8.1", features = ["client", "http1", "http2"] }
keyvalues-parser = { git = "https://codeberg.org/CosmicHarper/vdf-rs.git", rev = "fc6dcbea9eb13cacb98dea40063f6f56cde6e145" } keyvalues-parser = { git = "https://codeberg.org/CosmicHarper/vdf-rs.git", rev = "fc6dcbea9eb13cacb98dea40063f6f56cde6e145" }
log.workspace = true log.workspace = true
lz4_flex = { version = "0.13.1", features = ["frame"] }
rust-embed.workspace = true rust-embed.workspace = true
serde = { workspace = true, features = ["rc"] } serde = { workspace = true, features = ["rc"] }
serde_json.workspace = true serde_json.workspace = true

Binary file not shown.

View File

@ -31,6 +31,13 @@
<RadioBox text="${text}" translation="${translation}" value="${value}" tooltip="${tooltip}" /> <RadioBox text="${text}" translation="${translation}" value="${value}" tooltip="${tooltip}" />
</template> </template>
<template name="ButtonText">
<Button id="${id}" height="32" tooltip="${translation}_HELP" padding="4" gap="8">
<sprite src_builtin="${icon}" height="24" width="24" />
<label align="left" translation="${translation}" weight="bold" min_width="200" />
</Button>
</template>
<template name="DangerButton"> <template name="DangerButton">
<Button id="${id}" color="#AA3333" height="32" tooltip="${translation}_HELP" padding="4" gap="8"> <Button id="${id}" color="#AA3333" height="32" tooltip="${translation}_HELP" padding="4" gap="8">
<sprite src_builtin="${icon}" height="24" width="24" /> <sprite src_builtin="${icon}" height="24" width="24" />

View File

@ -0,0 +1,21 @@
<layout>
<include src="../t_dropdown_button.xml" />
<!-- id, translation -->
<template name="ActionRow">
<div id="${id}" flex_direction="column" gap="4" width="100%" padding="6" round="6" color="#00000044" border="1" border_color="#FFFFFF33">
<label translation="${translation}" weight="bold" size="14" />
</div>
</template>
<elements>
<div flex_direction="column" gap="8" width="100%" align_items="center">
<label id="controller_type" text="OpenXR Bindings" weight="bold" size="18" />
<div id="list_parent" flex_direction="column" gap="6" width="100%"></div>
<div flex_direction="row" gap="4" align_self="end">
<Button id="btn_save" height="32" translation="APP_SETTINGS.SAVE" />
<Button id="btn_cancel" height="32" translation="APP_SETTINGS.CANCEL" />
</div>
</div>
</elements>
</layout>

View File

@ -0,0 +1,15 @@
<layout>
<template name="ProfileButton">
<Button id="${id}" height="36" padding="4" gap="8" text="${text}" />
</template>
<elements>
<div flex_direction="column" gap="8" width="100%" align_items="center">
<div id="list_parent" flex_direction="column" gap="4" width="100%"></div>
<div flex_direction="row" gap="4" align_self="end">
<Button id="btn_cancel" height="32" translation="APP_SETTINGS.CANCEL" />
</div>
</div>
</elements>
</layout>

View File

@ -38,6 +38,51 @@
"BLOCK_GAME_INPUT_IGNORE_WATCH_HELP": "Do not block input when watch is hovered", "BLOCK_GAME_INPUT_IGNORE_WATCH_HELP": "Do not block input when watch is hovered",
"BLOCK_POSES_ON_KBD_INTERACTION": "Block poses when interacting with keyboard", "BLOCK_POSES_ON_KBD_INTERACTION": "Block poses when interacting with keyboard",
"BLOCK_POSES_ON_KBD_INTERACTION_HELP": "Blocks the game from receiving poses when the keyboard is hovered and 'Block game input' is enabled", "BLOCK_POSES_ON_KBD_INTERACTION_HELP": "Blocks the game from receiving poses when the keyboard is hovered and 'Block game input' is enabled",
"BINDINGS": {
"COMP": {
"CLICK": "Click",
"FORCE": "Force",
"TOUCH": "Touch",
"VALUE": "Value",
"PROXIMITY": "Proximity",
"X_AXIS": "X axis",
"Y_AXIS": "Y axis"
},
"TYPE": {
"TRIGGER": "Trigger",
"TRACKPAD": "Trackpad",
"THUMBSTICK": "Thumbstick",
"JOYSTICK": "Joystick",
"SYSTEM": "System",
"THUMBREST": "Thumbrest",
"SHOULDER": "Shoulder",
"SQUEEZE": "Grip"
},
"CLICK": {
"ANY": "-",
"DOUBLE": "Double-click",
"TRIPLE": "Triple-click",
"TYPE": "Click count"
},
"ACTION": {
"CLICK": "Click*",
"GRAB": "Grab*",
"ALT_CLICK": "Custom shell exec (set alt_click in config)",
"SHOW_HIDE": "Show, hide, recenter*",
"TOGGLE_DASHBOARD": "Toggle dashboard",
"SPACE_DRAG": "Playspace drag",
"SPACE_ROTATE": "Playspace rotate",
"SPACE_RESET": "Playspace reset",
"CLICK_MODIFIER_RIGHT": "Right-click modifier",
"CLICK_MODIFIER_MIDDLE": "Middle-click modifier",
"MOVE_MOUSE": "Move mouse (if off by default)",
"SCROLL": "Scroll"
},
"LEFT": "Left",
"RIGHT": "Right",
"COMPONENT": "Actuation type",
"SUBPATH": "Actuating control"
},
"BROWSE_ONLINE_CATALOG": "Browse online catalog...", "BROWSE_ONLINE_CATALOG": "Browse online catalog...",
"BROWSE_SKYMAPS": "Browse skymaps", "BROWSE_SKYMAPS": "Browse skymaps",
"CAPTURE_METHOD": "Wayland screen capture", "CAPTURE_METHOD": "Wayland screen capture",
@ -62,6 +107,8 @@
"HANDSFREE_POINTER_HELP": "Input to use when motion\ncontrollers are unavailable.\nLeft pinch is grab, right is click.", "HANDSFREE_POINTER_HELP": "Input to use when motion\ncontrollers are unavailable.\nLeft pinch is grab, right is click.",
"HIDE_GRAB_HELP": "Hide grab help", "HIDE_GRAB_HELP": "Hide grab help",
"HIDE_USERNAME": "Hide username", "HIDE_USERNAME": "Hide username",
"INPUT_PROFILES": "Change Input Bindings",
"INPUT_PROFILES_HELP": "OpenXR controller input bindings",
"INVERT_SCROLL_DIRECTION_X": "Invert horizontal scroll direction", "INVERT_SCROLL_DIRECTION_X": "Invert horizontal scroll direction",
"INVERT_SCROLL_DIRECTION_Y": "Invert vertical scroll direction", "INVERT_SCROLL_DIRECTION_Y": "Invert vertical scroll direction",
"KEYBOARD_MIDDLE_CLICK": "Keyboard middle click", "KEYBOARD_MIDDLE_CLICK": "Keyboard middle click",
@ -134,7 +181,9 @@
"GRID_OPACITY_HELP": "Opacity of the floor grid when the skybox is enabled", "GRID_OPACITY_HELP": "Opacity of the floor grid when the skybox is enabled",
"XR_CLICK_SENSITIVITY": "XR trigger sensitivity", "XR_CLICK_SENSITIVITY": "XR trigger sensitivity",
"XR_CLICK_SENSITIVITY_HELP": "Press and release values for analog triggers", "XR_CLICK_SENSITIVITY_HELP": "Press and release values for analog triggers",
"XWAYLAND_BY_DEFAULT": "Run apps in Compatibility mode by default" "XWAYLAND_BY_DEFAULT": "Run apps in Compatibility mode by default",
"SAVE": "Save",
"CANCEL": "Cancel"
}, },
"APPLICATION_LAUNCHER": "Application launcher", "APPLICATION_LAUNCHER": "Application launcher",
"APPLICATION_STARTED": "Application started", "APPLICATION_STARTED": "Application started",

View File

@ -548,7 +548,7 @@ impl SettingType {
} }
// creates a simple div with horizontal, centered flow // creates a simple div with horizontal, centered flow
fn horiz_cell(layout: &mut Layout, parent: WidgetID) -> anyhow::Result<WidgetID> { pub fn horiz_cell(layout: &mut Layout, parent: WidgetID) -> anyhow::Result<WidgetID> {
let (pair, _) = layout.add_child( let (pair, _) = layout.add_child(
parent, parent,
WidgetDiv::create(), WidgetDiv::create(),

View File

@ -1,13 +1,82 @@
use crate::tab::settings::{ use std::{collections::HashMap, rc::Rc};
SettingType, SettingsMountParams, SettingsTab,
macros::{ use wgui::{
options_category, options_checkbox, options_dropdown, options_range_f32, options_slider_f32, options_slider_i32, components::button::ComponentButton, globals::WguiGlobals, layout::WidgetID, parser::Fetchable, task::Tasks,
},
}; };
pub struct State {} use crate::util::popup_manager::PopupHolder;
impl SettingsTab for State {} use crate::{
frontend::FrontendTasks,
tab::settings::{
SettingType, SettingsMountParams, SettingsTab,
macros::{
options_category, options_checkbox, options_dropdown, options_range_f32, options_slider_f32, options_slider_i32,
},
},
views::{ViewUpdateParams, input_profiles},
};
#[derive(Clone)]
enum Task {
OpenInputProfiles,
}
pub struct State {
popup_input_profiles: PopupHolder<input_profiles::View>,
frontend_tasks: FrontendTasks,
globals: WguiGlobals,
tasks: Tasks<Task>,
}
impl SettingsTab for State {
fn update(&mut self, par: &mut ViewUpdateParams) -> anyhow::Result<()> {
self.popup_input_profiles.update(par)?;
for task in self.tasks.drain() {
match task {
Task::OpenInputProfiles => {
input_profiles::mount_popup(
self.frontend_tasks.clone(),
self.globals.clone(),
self.popup_input_profiles.clone(),
);
}
}
}
Ok(())
}
}
fn create_input_profiles_button(
mp: &mut crate::tab::settings::macros::MacroParams,
parent: WidgetID,
tasks: Tasks<Task>,
_popup: &PopupHolder<input_profiles::View>,
) -> 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("APP_SETTINGS.INPUT_PROFILES"));
params.insert(Rc::from("icon"), Rc::from("dashboard/controller.svg"));
mp.parser_state
.instantiate_template(mp.doc_params, "ButtonText", mp.layout, parent, params)?;
let btn = mp.parser_state.fetch_component_as::<ComponentButton>(&id)?;
btn.on_click(Rc::new({
let tasks = tasks.clone();
move |_common, _e| {
tasks.push(Task::OpenInputProfiles);
Ok(())
}
}));
Ok(())
}
impl State { impl State {
pub fn mount(par: SettingsMountParams) -> anyhow::Result<State> { pub fn mount(par: SettingsMountParams) -> anyhow::Result<State> {
@ -41,6 +110,19 @@ impl State {
} }
options_slider_i32(par.mp, c, SettingType::ClickFreezeTimeMs, 0, 500, 50)?; options_slider_i32(par.mp, c, SettingType::ClickFreezeTimeMs, 0, 500, 50)?;
Ok(State {})
let tasks = Tasks::<Task>::new();
let popup = PopupHolder::<input_profiles::View>::default();
if par.feats.openxr {
create_input_profiles_button(par.mp, c, tasks.clone(), &popup)?;
}
Ok(State {
popup_input_profiles: popup,
frontend_tasks: par.frontend_tasks.clone(),
globals: par.mp.doc_params.globals.clone(),
tasks,
})
} }
} }

View File

@ -1,6 +1,6 @@
use crate::tab::settings::{ use crate::tab::settings::{
SettingType, SettingsMountParams, SettingsTab, SettingType, SettingsMountParams, SettingsTab,
macros::{options_category, options_checkbox, options_range_f32, options_slider_f32}, macros::{options_category, options_checkbox, options_range_f32},
}; };
pub struct State {} pub struct State {}

View File

@ -1,5 +1,6 @@
pub mod cached_fetcher; pub mod cached_fetcher;
pub mod networking; pub mod networking;
pub mod openxr_bindings_schema;
pub mod pactl_wrapper; pub mod pactl_wrapper;
pub mod popup_manager; pub mod popup_manager;
pub mod steam_utils; pub mod steam_utils;

View File

@ -0,0 +1,255 @@
use std::{collections::BTreeMap, io::Read, rc::Rc};
use anyhow::{Context, bail};
use serde::{Deserialize, Serialize};
use strum::{AsRefStr, EnumProperty, EnumString};
use wgui::i18n::Translation;
static BINDINGS_LZ4: &[u8] = include_bytes!("../../assets/bindings.json.lz4");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BindingsFile {
#[serde(rename = "$schema")]
pub schema: Option<String>,
pub profiles: BTreeMap<String, Rc<Profile>>,
}
impl BindingsFile {
pub fn load_embedded() -> Self {
let mut decoder = lz4_flex::frame::FrameDecoder::new(BINDINGS_LZ4);
let mut json = Vec::new();
decoder.read_to_end(&mut json).unwrap(); // safe
serde_json::from_slice(&json).unwrap() // safe
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
pub title: Rc<str>,
#[serde(rename = "type")]
pub kind: ProfileType,
pub steamvr_controllertype: Option<String>,
pub monado_device: Option<String>,
#[serde(default)]
pub extended_by: Vec<String>,
#[serde(default)]
pub subaction_paths: Vec<String>,
#[serde(default)]
pub subpaths: BTreeMap<String, Subpath>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProfileType {
TrackedController,
#[serde(other)]
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subpath {
#[serde(rename = "type")]
pub kind: SubpathType,
pub localized_name: String,
#[serde(default)]
pub components: Vec<Component>,
pub side: Option<Side>,
}
impl Subpath {
pub fn get_effective_components(&self) -> Rc<[Component]> {
let mut v = vec![];
for c in self.components.iter() {
match c {
// position is not an openxr component, it's just a monado thing
Component::Position => {
v.push(Component::X);
v.push(Component::Y);
}
Component::Other => {}
other => v.push(*other),
}
}
v.into()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SubpathType {
Button,
Trigger,
Joystick,
Pose,
Trackpad,
Vibration,
#[serde(other)]
Other,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, EnumString, AsRefStr, EnumProperty)]
#[strum(ascii_case_insensitive)]
pub enum IdentifierType {
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.TYPE.TRIGGER"))]
Trigger,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.TYPE.TRACKPAD"))]
Trackpad,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.TYPE.THUMBSTICK"))]
Thumbstick,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.TYPE.JOYSTICK"))]
Joystick,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.TYPE.SYSTEM"))]
System,
A,
B,
X,
Y,
Start,
Home,
End,
Select,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.TYPE.THUMBREST"))]
Thumbrest,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.TYPE.SHOULDER"))]
Shoulder,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.TYPE.SQUEEZE"))]
Squeeze,
}
impl IdentifierType {
pub fn translation(&self) -> Translation {
self
.get_str("Translation")
.map(Translation::from_translation_key)
.or_else(|| self.get_str("Text").map(Translation::from_raw_text))
.unwrap_or_else(|| Translation::from_raw_text(self.as_ref()))
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, EnumString, AsRefStr, EnumProperty)]
#[serde(rename_all = "snake_case")]
#[strum(ascii_case_insensitive)]
pub enum Component {
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.COMP.CLICK"))]
Click,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.COMP.FORCE"))]
Force,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.COMP.TOUCH"))]
Touch,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.COMP.VALUE"))]
Value,
Position,
Pose,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.COMP.PROXIMITY"))]
Proximity,
Haptic,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.COMP.X_AXIS"))]
/// Not an actual component, used to turn a 2D Position into a 1D Value
X,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.COMP.Y_AXIS"))]
/// Not an actual component, used to turn a 2D Position into a 1D Value
Y,
#[serde(other)]
Other,
}
impl Component {
pub fn translation(&self) -> Translation {
self
.get_str("Translation")
.map(Translation::from_translation_key)
.or_else(|| self.get_str("Text").map(Translation::from_raw_text))
.unwrap_or_else(|| Translation::from_raw_text(self.as_ref()))
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, EnumString, AsRefStr)]
#[serde(rename_all = "snake_case")]
pub enum Side {
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ParsedOpenXrInputPath {
pub side: Side,
pub identifier: IdentifierType,
pub component: Component,
}
impl ParsedOpenXrInputPath {
pub fn to_subpath(&self) -> String {
format!("/input/{}", self.identifier.as_ref().to_lowercase())
}
}
impl<'a> TryFrom<&'a str> for ParsedOpenXrInputPath {
type Error = anyhow::Error;
fn try_from(path: &str) -> anyhow::Result<Self> {
let (side, rest) = if let Some(rest) = path.strip_prefix("/user/hand/left/") {
(Side::Left, rest)
} else if let Some(rest) = path.strip_prefix("/user/hand/right/") {
(Side::Right, rest)
} else {
bail!("missing hand prefix");
};
let (input, rest) = rest.split_once('/').context("path too short")?;
if input != "input" {
bail!("missing input prefix");
}
let (identifier, component) = rest.rsplit_once('/').context("missing identifier or component")?;
if identifier.is_empty() || component.is_empty() {
bail!("identifier or component empty");
}
let component = Component::try_from(component).context("bad component")?;
let identifier = IdentifierType::try_from(identifier).context("bad subpath")?;
Ok(Self {
side,
identifier,
component,
})
}
}
#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, PartialEq, EnumString, AsRefStr, EnumProperty)]
#[strum(ascii_case_insensitive)]
pub enum ClickType {
#[default]
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.CLICK.ANY"))]
Any,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.CLICK.DOUBLE"))]
Double,
#[strum(props(Translation = "APP_SETTINGS.BINDINGS.CLICK.TRIPLE"))]
Triple,
}
impl ClickType {
pub fn translation(&self) -> Translation {
self
.get_str("Translation")
.map(Translation::from_translation_key)
.or_else(|| self.get_str("Text").map(Translation::from_raw_text))
.unwrap_or_else(|| Translation::from_raw_text(self.as_ref()))
}
}

View File

@ -0,0 +1,656 @@
use std::{borrow::Cow, collections::HashMap, rc::Rc};
use wgui::{
assets::AssetPath,
components::button::{ButtonClickEvent, ComponentButton},
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::{ClickType, Component, IdentifierType, ParsedOpenXrInputPath, Profile, Side},
popup_manager::{MountPopupOnceParams, PopupHolder},
},
views::{ViewTrait, ViewUpdateParams},
};
#[derive(Clone)]
enum Task {
Save,
Cancel,
OpenContextMenu(glam::Vec2, Vec<context_menu::Cell>),
}
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),
});
}
}
}
// Dropdown handling
if let TickResult::Action(name) = self.context_menu.tick(&mut par.layout, &mut self.parser_state)?
&& let (Some(action), Some(_), Some(action_name), Some(side), Some(value)) = {
let mut s = name.splitn(5, ';');
(s.next(), s.next(), s.next(), s.next(), s.next())
} {
let side = side.to_lowercase();
let value = value.to_lowercase();
log::warn!("{action_name}");
let mut cur_profile = &mut self.profiles[self.cur_profile_idx];
let action_mut = get_action_mut(&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();
{
let mut title_label = parser_state.fetch_widget_as::<WidgetLabel>(&params.layout.state, "controller_type")?;
title_label.set_text_simple(
&mut params.globals.get(),
Translation::from_raw_text_rc(params.profile.title.clone()),
);
}
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.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 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_translation_key("APP_SETTINGS.INPUT_PROFILES"),
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))
}),
)));
}
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,
)?;
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)
}
fn input_controls_for_hand(
mp: &mut MacroParams,
parent: WidgetID,
current: Option<&str>,
side: Side,
action: Rc<str>,
click_type: ClickType,
profile: &Profile,
) -> 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
.map(|cur| ParsedOpenXrInputPath::try_from(cur).log_warn(cur).ok())
.flatten();
let parent = horiz_cell(mp.layout, parent)?;
let available_components = current
.as_ref()
.map(|par| profile.subpaths.get(&par.to_subpath()))
.flatten()
.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/")
.map(|ident| IdentifierType::try_from(ident).ok())
.flatten()
})
.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, click_type)?;
Ok(())
}
fn subpath_dropdown(
mp: &mut MacroParams,
parent: WidgetID,
action: Rc<str>,
side: Side,
available: Rc<[IdentifierType]>,
current: Option<IdentifierType>,
) -> 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.{}", side.as_ref().to_uppercase())),
);
params.insert(Rc::from("tooltip"), Rc::from("APP_SETTINGS.BINDINGS.SUBPATH"));
mp.parser_state
.instantiate_template(mp.doc_params, "DropdownButton", mp.layout, parent, params)?;
let title = current
.map(|c| c.translation())
.unwrap_or_else(|| Translation::from_translation_key("APP_SETTINGS.OPTION.NONE"));
{
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(), title);
}
let btn = mp.parser_state.fetch_component_as::<ComponentButton>(&id)?;
btn.on_click(Rc::new({
let available = available.clone();
let tasks = mp.tasks.clone();
move |_common, e: ButtonClickEvent| {
let side = side.as_ref();
let mut cells = available
.iter()
.map(|item| {
let value = item.as_ref();
let title = item.translation();
context_menu::Cell {
action_name: Some(format!("subpath;{id};{action};{side};{value}").into()),
title,
tooltip: None,
attribs: vec![],
}
})
.collect::<Vec<_>>();
cells.insert(
0,
context_menu::Cell {
action_name: Some(format!("clear;{id};{action};{side};-").into()),
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 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 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("text"), Rc::from(""));
params.insert(Rc::from("tooltip"), Rc::from("APP_SETTINGS.BINDINGS.COMPONENT"));
mp.parser_state
.instantiate_template(mp.doc_params, "DropdownButton", mp.layout, parent, params)?;
let title = current
.map(|c| c.translation())
.unwrap_or_else(|| Translation::from_raw_text_rc(Default::default()));
{
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(), title);
}
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| {
tasks.push(Task::OpenContextMenu(
e.mouse_pos_absolute.unwrap_or_default(),
available
.iter()
.filter_map(|item| {
let value = item.as_ref();
let title = item.translation();
let side = side.as_ref();
Some(context_menu::Cell {
action_name: Some(format!("comp;{id};{action};{side};{value}").into()),
title,
tooltip: None,
attribs: vec![],
})
})
.collect(),
));
Ok(())
}
}));
Ok(true)
}
fn clicks_dropdown(mp: &mut MacroParams, parent: WidgetID, action: Rc<str>, current: ClickType) -> 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("text"), Rc::from(""));
params.insert(Rc::from("tooltip"), Rc::from("APP_SETTINGS.BINDINGS.CLICK.TYPE"));
mp.parser_state
.instantiate_template(mp.doc_params, "DropdownButton", mp.layout, parent, params)?;
let title = current.translation();
{
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(), title);
}
let btn = mp.parser_state.fetch_component_as::<ComponentButton>(&id)?;
btn.on_click(Rc::new({
let tasks = mp.tasks.clone();
move |_common, e: ButtonClickEvent| {
tasks.push(Task::OpenContextMenu(
e.mouse_pos_absolute.unwrap_or_default(),
[ClickType::Any, ClickType::Double, ClickType::Triple]
.iter()
.filter_map(|item| {
let value = item.as_ref();
let title = item.translation();
Some(context_menu::Cell {
action_name: Some(format!("click;{id};{action};-;{value}").into()),
title,
tooltip: None,
attribs: vec![],
})
})
.collect(),
));
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}"
)));
}
}

View File

@ -0,0 +1,150 @@
use std::{collections::HashMap, rc::Rc};
use anyhow::Context;
use wgui::{
assets::AssetPath,
components::button::ComponentButton,
globals::WguiGlobals,
i18n::Translation,
layout::{Layout, WidgetID},
parser::{Fetchable, ParseDocumentParams},
task::Tasks,
};
use crate::{
frontend::{FrontendTask, FrontendTasks},
util::{
openxr_bindings_schema,
popup_manager::{MountPopupOnceParams, PopupHolder},
},
views::{self, ViewTrait, ViewUpdateParams, bindings},
};
#[derive(Clone)]
enum Task {
SelectProfile(Rc<str>),
Cancel,
}
pub struct Params<'a> {
pub globals: WguiGlobals,
pub layout: &'a mut Layout,
pub parent_id: WidgetID,
pub frontend_tasks: &'a FrontendTasks,
pub close_callback: Box<dyn FnOnce()>,
}
pub struct View {
tasks: Tasks<Task>,
frontend_tasks: FrontendTasks,
globals: WguiGlobals,
close_callback: Option<Box<dyn FnOnce()>>,
bindings_popup: PopupHolder<bindings::View>,
bindings_file: openxr_bindings_schema::BindingsFile,
}
impl ViewTrait for View {
fn update(&mut self, par: &mut ViewUpdateParams) -> anyhow::Result<()> {
self.bindings_popup.update(par)?;
for task in self.tasks.drain() {
match task {
Task::SelectProfile(profile_id) => {
let profile = self
.bindings_file
.profiles
.get(&*profile_id)
.context("Selected non-existing profile. UI bug?")?;
views::bindings::mount_popup(
self.frontend_tasks.clone(),
self.globals.clone(),
self.bindings_popup.clone(),
profile_id.clone(),
profile.clone(),
);
}
Task::Cancel => {
if let Some(close_callback) = self.close_callback.take() {
close_callback();
}
}
}
}
Ok(())
}
}
impl View {
pub fn new(params: Params) -> anyhow::Result<Self> {
let doc_params = &ParseDocumentParams {
globals: params.globals.clone(),
path: AssetPath::BuiltIn("gui/view/input_profiles.xml"),
extra: Default::default(),
};
let mut 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_cancel")?,
Task::Cancel,
);
let bindings_file = openxr_bindings_schema::BindingsFile::load_embedded();
for (idx, (profile_id, profile)) in bindings_file.profiles.iter().enumerate() {
let id = format!("profile_btn_{idx}");
let profile_name: Rc<str> = profile.title.clone();
let mut cell_params: HashMap<Rc<str>, Rc<str>> = HashMap::new();
cell_params.insert(Rc::from("id"), Rc::from(id.clone()));
cell_params.insert(Rc::from("text"), Rc::from(profile_name));
parser_state.instantiate_template(doc_params, "ProfileButton", params.layout, list_parent, cell_params)?;
let btn = parser_state.fetch_component_as::<ComponentButton>(&id)?;
let tasks_clone = tasks.clone();
btn.on_click(Rc::new({
let profile_id: Rc<str> = profile_id.clone().into();
move |_common, _e| {
tasks_clone.push(Task::SelectProfile(profile_id.clone()));
Ok(())
}
}));
}
Ok(Self {
tasks,
frontend_tasks: params.frontend_tasks.clone(),
globals: params.globals.clone(),
close_callback: Some(params.close_callback),
bindings_popup: Default::default(),
bindings_file,
})
}
}
pub fn mount_popup(frontend_tasks: FrontendTasks, globals: WguiGlobals, popup: PopupHolder<View>) {
frontend_tasks
.clone()
.push(FrontendTask::MountPopupOnce(MountPopupOnceParams::new(
Translation::from_translation_key("APP_SETTINGS.INPUT_PROFILES"),
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,
frontend_tasks: &frontend_tasks,
close_callback,
})?;
popup.set_view(data.handle, view, None);
Ok(popup.get_close_callback(data.layout))
}),
)));
}

View File

@ -2,11 +2,13 @@ use wlx_common::{async_executor::AsyncExecutor, config::GeneralConfig, dash_inte
pub mod app_launcher; pub mod app_launcher;
pub mod audio_settings; pub mod audio_settings;
pub mod bindings;
pub mod dialog_box; pub mod dialog_box;
pub mod download_file; pub mod download_file;
pub mod game_cover; pub mod game_cover;
pub mod game_launcher; pub mod game_launcher;
pub mod game_list; pub mod game_list;
pub mod input_profiles;
pub mod remote_skymap_downloader; pub mod remote_skymap_downloader;
pub mod remote_skymap_list; pub mod remote_skymap_list;
pub mod running_games_list; pub mod running_games_list;

View File

@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
readonly ALLOWED_PROFILES="$(
cat <<'EOF'
/interaction_profiles/hp/mixed_reality_controller
/interaction_profiles/htc/vive_controller
/interaction_profiles/htc/vive_cosmos_controller
/interaction_profiles/htc/vive_focus3_controller
/interaction_profiles/khr/generic_controller
/interaction_profiles/khr/simple_controller
/interaction_profiles/ml/ml2_controller
/interaction_profiles/microsoft/motion_controller
/interaction_profiles/mndx/flipvr
/interaction_profiles/mndx/hydra
/interaction_profiles/mndx/pssense_controller_mndx
/interaction_profiles/oculus/touch_controller
/interaction_profiles/oppo/mr_controller_oppo
/interaction_profiles/samsung/odyssey_controller
/interaction_profiles/valve/index_controller
EOF
)"
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
output_dir="${script_dir}/assets"
output_json="${output_dir}/bindings.json"
output_lz4="${output_json}.lz4"
repo_url="https://gitlab.freedesktop.org/monado/monado.git"
repo_branch="main"
bindings_path="src/xrt/auxiliary/bindings/bindings.json"
tmpdir="$(mktemp -d)"
tmp_output=""
cleanup() {
rm -rf "$tmpdir"
if [[ -n "$tmp_output" && -f "$tmp_output" ]]; then
rm -f "$tmp_output"
fi
}
trap cleanup EXIT
command -v git >/dev/null || { echo "git is required" >&2; exit 1; }
command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; }
command -v lz4 >/dev/null || { echo "lz4 is required" >&2; exit 1; }
git clone \
--depth 1 \
--branch "$repo_branch" \
--filter=blob:none \
--sparse \
"$repo_url" \
"$tmpdir/monado"
git -C "$tmpdir/monado" sparse-checkout set --no-cone "$bindings_path"
input_json="$tmpdir/monado/$bindings_path"
mkdir -p "$output_dir"
tmp_output="$(mktemp "${output_json}.tmp.XXXXXX")"
jq_filter='
($allowed_lines
| split("\n")
| map(sub("\r$"; ""))
| map(select(length > 0))
| reduce .[] as $key ({}; .[$key] = true)
) as $allowed
| .profiles |= with_entries(select($allowed[.key] == true))
'
jq -c \
--arg allowed_lines "$ALLOWED_PROFILES" \
"$jq_filter" \
"$input_json" > "$tmp_output"
mv "$tmp_output" "$output_json"
tmp_output=""
lz4 -f --rm "$output_json" "$output_lz4"
echo "Wrote compressed filtered bindings to: $output_lz4"

View File

@ -36,6 +36,7 @@ regex.workspace = true
rust-embed.workspace = true rust-embed.workspace = true
serde = { workspace = true, features = ["rc"] } serde = { workspace = true, features = ["rc"] }
serde_json.workspace = true serde_json.workspace = true
serde_json5.workspace = true
slotmap.workspace = true slotmap.workspace = true
strum.workspace = true strum.workspace = true
vulkano.workspace = true vulkano.workspace = true
@ -68,7 +69,6 @@ ovr_overlay = { git = "https://github.com/galister/ovr_overlay_oyasumi", rev = "
prost = { version = "0.14.3", optional = true } prost = { version = "0.14.3", optional = true }
pure-rust-locales = "0.8.2" pure-rust-locales = "0.8.2"
rosc = { version = "0.11.4", optional = true } rosc = { version = "0.11.4", optional = true }
serde_json5 = "0.2.1"
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
signal-hook = "0.3.18" signal-hook = "0.3.18"
smallvec = "1.15.1" smallvec = "1.15.1"

View File

@ -7,8 +7,10 @@ use std::{
use glam::{Affine3A, Quat, Vec3, bool}; use glam::{Affine3A, Quat, Vec3, bool};
use libmonado::{self as mnd, DeviceLogic}; use libmonado::{self as mnd, DeviceLogic};
use openxr::{self as xr, Quaternionf, Vector2f, Vector3f}; use openxr::{self as xr, Quaternionf, Vector2f, Vector3f};
use serde::{Deserialize, Serialize}; use wlx_common::{
use wlx_common::{config::HandsfreePointer, config_io}; config::HandsfreePointer,
openxr_actions::{OneOrMany, load_xr_input_profiles},
};
use crate::{ use crate::{
backend::input::{Haptics, InputState, Pointer, TrackedDevice, TrackedDeviceRole}, backend::input::{Haptics, InputState, Pointer, TrackedDevice, TrackedDeviceRole},
@ -647,7 +649,7 @@ macro_rules! add_custom_lr {
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] #[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
fn suggest_bindings(instance: &xr::Instance, hands: &[&OpenXrHandSource; 3]) { fn suggest_bindings(instance: &xr::Instance, hands: &[&OpenXrHandSource; 3]) {
let profiles = load_action_profiles(); let profiles = load_xr_input_profiles();
for profile in profiles { for profile in profiles {
log::warn!("Loading profile {}", &profile.profile); log::warn!("Loading profile {}", &profile.profile);
@ -723,67 +725,3 @@ fn suggest_bindings(instance: &xr::Instance, hands: &[&OpenXrHandSource; 3]) {
} }
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
enum OneOrMany<T> {
One(T),
Many(Vec<T>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OpenXrActionConfAction {
left: Option<OneOrMany<String>>,
right: Option<OneOrMany<String>>,
handsfree: Option<OneOrMany<String>>,
threshold: Option<[f32; 2]>,
double_click: Option<bool>,
triple_click: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OpenXrActionConfProfile {
profile: String,
pose: Option<OpenXrActionConfAction>,
click: Option<OpenXrActionConfAction>,
grab: Option<OpenXrActionConfAction>,
alt_click: Option<OpenXrActionConfAction>,
show_hide: Option<OpenXrActionConfAction>,
toggle_dashboard: Option<OpenXrActionConfAction>,
space_drag: Option<OpenXrActionConfAction>,
space_rotate: Option<OpenXrActionConfAction>,
space_reset: Option<OpenXrActionConfAction>,
click_modifier_right: Option<OpenXrActionConfAction>,
click_modifier_middle: Option<OpenXrActionConfAction>,
move_mouse: Option<OpenXrActionConfAction>,
scroll: Option<OpenXrActionConfAction>,
haptic: Option<OpenXrActionConfAction>,
}
const DEFAULT_PROFILES: &str = include_str!("openxr_actions.json5");
fn load_action_profiles() -> Vec<OpenXrActionConfProfile> {
let mut profiles: Vec<OpenXrActionConfProfile> =
serde_json5::from_str(DEFAULT_PROFILES).unwrap(); // want panic
let Some(conf) = config_io::load("openxr_actions.json5") else {
return profiles;
};
match serde_json5::from_str::<Vec<OpenXrActionConfProfile>>(&conf) {
Ok(override_profiles) => {
for new in override_profiles {
if let Some(i) = profiles.iter().position(|old| old.profile == new.profile) {
profiles[i] = new;
} else {
profiles.push(new);
}
}
}
Err(e) => {
log::error!("Failed to load openxr_actions.json5: {e}");
}
}
profiles
}

View File

@ -453,15 +453,24 @@ impl<T> OverlayWindowManager<T> {
for o in self.overlays.values() { for o in self.overlays.values() {
if o.config.global { if o.config.global {
if let Some(state) = &o.config.active_state { if let Some(state) = &o.config.active_state {
app.session.config.global_set.insert(o.config.name.clone(), state.clone()); app.session
.config
.global_set
.insert(o.config.name.clone(), state.clone());
} }
} }
} }
for (name, state) in &self.global_set.hidden_overlays { for (name, state) in &self.global_set.hidden_overlays {
app.session.config.global_set.insert(name.clone(), state.clone()); app.session
.config
.global_set
.insert(name.clone(), state.clone());
} }
for (name, state) in &self.global_set.inactive_overlays { for (name, state) in &self.global_set.inactive_overlays {
app.session.config.global_set.insert(name.clone(), state.clone()); app.session
.config
.global_set
.insert(name.clone(), state.clone());
} }
// BackendAttrib // BackendAttrib

View File

@ -222,20 +222,20 @@ impl OverlayWindowConfig {
.saved_transform .saved_transform
.unwrap_or(self.default_state.transform); .unwrap_or(self.default_state.transform);
let (parent_transform, align_to_hmd) = match state.positioning { let (parent_transform, align_to_hmd) = match state.positioning {
Positioning::Floating | Positioning::FollowHead { .. } => (app.input_state.hmd, false), Positioning::Floating | Positioning::FollowHead { .. } => (app.input_state.hmd, false),
Positioning::FollowHand { Positioning::FollowHand {
hand, align_to_hmd, .. hand, align_to_hmd, ..
} => (app.input_state.pointers[hand as usize].pose, align_to_hmd), } => (app.input_state.pointers[hand as usize].pose, align_to_hmd),
Positioning::Anchored => (app.anchor, false), Positioning::Anchored => (app.anchor, false),
Positioning::Static => { Positioning::Static => {
if hard_reset { if hard_reset {
(app.input_state.hmd, false) (app.input_state.hmd, false)
} else { } else {
(Affine3A::IDENTITY, false) (Affine3A::IDENTITY, false)
}
} }
} };
};
if hard_reset { if hard_reset {
state.saved_transform = None; state.saved_transform = None;

View File

@ -18,6 +18,7 @@ idmap-derive.workspace = true
log.workspace = true log.workspace = true
serde = { workspace = true, features = ["rc"] } serde = { workspace = true, features = ["rc"] }
serde_json.workspace = true serde_json.workspace = true
serde_json5.workspace = true
strum.workspace = true strum.workspace = true
xdg.workspace = true xdg.workspace = true

View File

@ -66,3 +66,9 @@ pub fn load(filename: &str) -> Option<String> {
std::fs::read_to_string(path).ok() std::fs::read_to_string(path).ok()
} }
pub fn save(filename: &str, content: &str) -> anyhow::Result<()> {
let path = get_config_file_path(filename);
std::fs::write(path, content)?;
Ok(())
}

View File

@ -10,6 +10,7 @@ pub mod dash_interface_emulated;
pub mod desktop_finder; pub mod desktop_finder;
mod handle; mod handle;
pub mod locale; pub mod locale;
pub mod openxr_actions;
pub mod overlays; pub mod overlays;
pub mod timestep; pub mod timestep;
pub mod windowing; pub mod windowing;

View File

@ -0,0 +1,66 @@
use serde::{Deserialize, Serialize};
use crate::config_io;
const DEFAULT_XR_INPUT_PROFILES: &str = include_str!("../assets/openxr_actions.json5");
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum OneOrMany<T> {
One(T),
Many(Vec<T>),
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct OpenXrInputAction {
pub left: Option<OneOrMany<String>>,
pub right: Option<OneOrMany<String>>,
pub handsfree: Option<OneOrMany<String>>,
pub threshold: Option<[f32; 2]>,
pub double_click: Option<bool>,
pub triple_click: Option<bool>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct OpenXrInputProfile {
pub profile: String,
pub pose: Option<OpenXrInputAction>,
pub click: Option<OpenXrInputAction>,
pub grab: Option<OpenXrInputAction>,
pub alt_click: Option<OpenXrInputAction>,
pub show_hide: Option<OpenXrInputAction>,
pub toggle_dashboard: Option<OpenXrInputAction>,
pub space_drag: Option<OpenXrInputAction>,
pub space_rotate: Option<OpenXrInputAction>,
pub space_reset: Option<OpenXrInputAction>,
pub click_modifier_right: Option<OpenXrInputAction>,
pub click_modifier_middle: Option<OpenXrInputAction>,
pub move_mouse: Option<OpenXrInputAction>,
pub scroll: Option<OpenXrInputAction>,
pub haptic: Option<OpenXrInputAction>,
}
pub fn load_xr_input_profiles() -> Vec<OpenXrInputProfile> {
let mut profiles: Vec<OpenXrInputProfile> = serde_json5::from_str(DEFAULT_XR_INPUT_PROFILES).unwrap(); // want panic
let Some(conf) = config_io::load("openxr_actions.json5") else {
return profiles;
};
match serde_json5::from_str::<Vec<OpenXrInputProfile>>(&conf) {
Ok(override_profiles) => {
for new in override_profiles {
if let Some(i) = profiles.iter().position(|old| old.profile == new.profile) {
profiles[i] = new;
} else {
profiles.push(new);
}
}
}
Err(e) => {
log::error!("Failed to load openxr_actions.json5: {e}");
}
}
profiles
}