Merge remote-tracking branch 'origin/main' into next-dash-interface

[skip ci]
This commit is contained in:
Aleksander
2025-12-23 19:24:08 +01:00
46 changed files with 1858 additions and 606 deletions

81
Cargo.lock generated
View File

@@ -557,7 +557,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d123397e75f904758fef490775a00b0ada545ab409cb0163d919799e5a30119b"
dependencies = [
"autocxx-engine",
"env_logger",
"env_logger 0.9.3",
"indexmap 1.9.3",
"syn 2.0.111",
]
@@ -1751,6 +1751,16 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.9.3"
@@ -1764,6 +1774,19 @@ dependencies = [
"termcolor",
]
[[package]]
name = "env_logger"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equator"
version = "0.4.2"
@@ -2955,6 +2978,30 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jiff"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -4272,6 +4319,21 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -5048,6 +5110,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
version = "1.3.0"
@@ -6376,16 +6444,18 @@ dependencies = [
]
[[package]]
name = "wayvr_ipc"
name = "wayvrctl"
version = "0.1.0"
source = "git+https://github.com/olekolek1000/wayvr-ipc.git?rev=6d253ef9e36db0f181566030a4990454ecb60395#6d253ef9e36db0f181566030a4990454ecb60395"
dependencies = [
"anyhow",
"bytes",
"clap",
"env_logger 0.11.8",
"log",
"serde",
"serde_json",
"smallvec",
"shell-words",
"tokio",
"wayvr-ipc",
]
[[package]]
@@ -7055,7 +7125,6 @@ dependencies = [
"wayland-client",
"wayland-egl",
"wayvr-ipc",
"wayvr_ipc",
"wgui",
"winit",
"wlx-capture",

View File

@@ -18,17 +18,21 @@ members = [
"wlx-capture",
"dash-frontend",
"wayvr-ipc",
"wayvrctl",
]
resolver = "3"
[workspace.dependencies]
anyhow = "1.0.100"
glam = { version = "0.30.9", features = ["mint", "serde"] }
clap = { version = "4.5.53", features = ["derive"] }
idmap = "0.2.2"
idmap-derive = "0.2.22"
log = "0.4.29"
regex = "1.12.2"
rust-embed = "8.9.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0.145"
slotmap = "1.1.1"
vulkano = { version = "0.35.2", default-features = false, features = [
"macros",

View File

@@ -7,12 +7,12 @@ edition = "2024"
anyhow.workspace = true
wgui = { path = "../wgui/" }
glam = { workspace = true, features = ["mint", "serde"] }
log = { workspace = true }
rust-embed = { workspace = true }
log.workspace = true
rust-embed.workspace = true
chrono = "0.4.42"
gio = "0.21.5"
gtk = "0.18.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
serde.workspace = true
serde_json.workspace = true
wlx-common = { path = "../wlx-common" }
wayvr_ipc = { workspace = true }

View File

@@ -1,70 +1,70 @@
{
"HOME_SCREEN": "ホーム",
"MONADO_RUNTIME": "「Monado」ランタイム",
"APPLICATIONS": "アプリケーション",
"GAMES": "ゲーム",
"SETTINGS": "設定",
"PROCESSES": "プロセス",
"HELLO_USER": "こんにちは、{USER}",
"GENERAL_SETTINGS": "全般設定",
"APPLICATION_LAUNCHER": "アプリケーションランチャー",
"APP_SETTINGS": {
"HIDE_USERNAME": "ユーザー名を表示しない",
"OPAQUE_BACKGROUND": "不透明な背景",
"RUN_IN_XWAYLAND_MODE_BY_DEFAULT": "XWaylandモードでデフォルトで実行する",
"WLX_OVERLAY_S_SETTINGS": "WlxOverlay-Sの設定",
"HEADSET_SETTINGS": "ヘッドセット設定",
"BRIGHTNESS": "明るさ",
"WLX": {
"NOTIFICATIONS_ENABLED": "通知",
"NOTIFICATIONS_SOUND_ENABLED": "通知音",
"KEYBOARD_SOUND_ENABLED": "キーボード音",
"BLOCK_GAME_INPUT": "ゲーム入力をブロック",
"SPACE_DRAG_MULTIPLIER": "スペースドラッグ乗数",
"SPACE_DRAG_ROTATION_ENABLED": "スペースドラッグでの回転",
"SHOW_SKYBOX": "スカイボックス",
"ENABLE_PASSTHROUGH": "パススルー"
},
"RESTART_SOFTWARE": "ソフトウェアを再起動"
},
"HELLO": "こんにちは!",
"AUDIO": {
"VOLUME": "音量",
"SETTINGS": "オーディオ設定",
"AUTO_SWITCH_TO_VR_AUDIO": "VRオーディオに自動切り替え",
"SPEAKERS": "スピーカー",
"MICROPHONES": "マイク",
"CARDS": "カード",
"SELECT_AUDIO_CARD_PROFILE": "オーディオカードプロファイルを選択",
"NO_VR_SPEAKERS_FOUND_SWITCH_MANUALLY": "VRスピーカーが見つかりませんでした。手動で切り替えてください。",
"NO_VR_MICROPHONE_SWITCH_MANUALLY": "VRマイクが見つかりませんでした。手動で切り替えてください。",
"FAILED_TO_SWITCH_MICROPHONE": "マイクの切り替えに失敗しました",
"MICROPHONE_SET_SUCCESSFULLY": "マイクの設定が完了しました",
"SPEAKERS_SET_SUCCESSFULLY": "スピーカーを設定しました",
"DEVICE_FOUND_AND_INITIALIZED_BUT_NOT_SWITCHED": "デバイスが見つかり、初期化されましたが、切り替えられていません"
},
"ACTIONS": {
"RECENTER_PLAYSPACE": "プレイスペースを再中央"
},
"LIST_OF_DISPLAYS": "ディスプレイリスト",
"LIST_OF_PROCESSES": "プロセスのリスト",
"NO_DISPLAYS_FOUND": "ディスプレイが見つかりません",
"ADD_DISPLAY": "ディスプレイを追加",
"POPUP_ADD_DISPLAY": {
"RESOLUTION": "解像度"
},
"WIDTH": "幅",
"HEIGHT": "高さ",
"DISPLAY_PORTRAIT_MODE": "縦向きモード",
"HIDE": "隠す",
"REMOVE": "削除",
"SHOW": "表示",
"DISPLAY_OPTIONS": "表示オプション",
"PROCESS_LIST": {
"NO_PROCESSES_FOUND": "プロセスが見つかりませんでした",
"LOCATED_ON": "に",
"TERMINATE_PROCESS_NAMED_X": "プロセス \"{PROCESS_NAME}\" を終了します"
},
"FAILED_TO_LAUNCH_APPLICATION": "アプリケーションの起動に失敗しました:",
"APPLICATION_LAUNCHED_ON": "{DISPLAY_NAME}でアプリケーションが起動しました。"
}
"HOME_SCREEN": "ホーム",
"MONADO_RUNTIME": "「Monado」ランタイム",
"APPLICATIONS": "アプリケーション",
"GAMES": "ゲーム",
"SETTINGS": "設定",
"PROCESSES": "プロセス",
"HELLO_USER": "こんにちは、{USER}",
"GENERAL_SETTINGS": "全般設定",
"APPLICATION_LAUNCHER": "アプリケーションランチャー",
"APP_SETTINGS": {
"HIDE_USERNAME": "ユーザー名を表示しない",
"OPAQUE_BACKGROUND": "不透明な背景",
"RUN_IN_XWAYLAND_MODE_BY_DEFAULT": "XWaylandモードでデフォルトで実行する",
"WLX_OVERLAY_S_SETTINGS": "WlxOverlay-Sの設定",
"HEADSET_SETTINGS": "ヘッドセット設定",
"BRIGHTNESS": "明るさ",
"WLX": {
"NOTIFICATIONS_ENABLED": "通知",
"NOTIFICATIONS_SOUND_ENABLED": "通知音",
"KEYBOARD_SOUND_ENABLED": "キーボード音",
"BLOCK_GAME_INPUT": "ゲーム入力をブロック",
"SPACE_DRAG_MULTIPLIER": "スペースドラッグ乗数",
"SPACE_DRAG_ROTATION_ENABLED": "スペースドラッグでの回転",
"SHOW_SKYBOX": "スカイボックス",
"ENABLE_PASSTHROUGH": "パススルー"
},
"RESTART_SOFTWARE": "ソフトウェアを再起動"
},
"HELLO": "こんにちは!",
"AUDIO": {
"VOLUME": "音量",
"SETTINGS": "オーディオ設定",
"AUTO_SWITCH_TO_VR_AUDIO": "VRオーディオに自動切り替え",
"SPEAKERS": "スピーカー",
"MICROPHONES": "マイク",
"CARDS": "カード",
"SELECT_AUDIO_CARD_PROFILE": "オーディオカードプロファイルを選択",
"NO_VR_SPEAKERS_FOUND_SWITCH_MANUALLY": "VRスピーカーが見つかりませんでした。手動で切り替えてください。",
"NO_VR_MICROPHONE_SWITCH_MANUALLY": "VRマイクが見つかりませんでした。手動で切り替えてください。",
"FAILED_TO_SWITCH_MICROPHONE": "マイクの切り替えに失敗しました",
"MICROPHONE_SET_SUCCESSFULLY": "マイクの設定が完了しました",
"SPEAKERS_SET_SUCCESSFULLY": "スピーカーを設定しました",
"DEVICE_FOUND_AND_INITIALIZED_BUT_NOT_SWITCHED": "デバイスが見つかり、初期化されましたが、切り替えられていません"
},
"ACTIONS": {
"RECENTER_PLAYSPACE": "プレイスペースを再中央"
},
"LIST_OF_DISPLAYS": "ディスプレイリスト",
"LIST_OF_PROCESSES": "プロセスのリスト",
"NO_DISPLAYS_FOUND": "ディスプレイが見つかりません",
"ADD_DISPLAY": "ディスプレイを追加",
"POPUP_ADD_DISPLAY": {
"RESOLUTION": "解像度"
},
"WIDTH": "幅",
"HEIGHT": "高さ",
"DISPLAY_PORTRAIT_MODE": "縦向きモード",
"HIDE": "隠す",
"REMOVE": "削除",
"SHOW": "表示",
"DISPLAY_OPTIONS": "表示オプション",
"PROCESS_LIST": {
"NO_PROCESSES_FOUND": "プロセスが見つかりませんでした",
"LOCATED_ON": "に",
"TERMINATE_PROCESS_NAMED_X": "プロセス \"{PROCESS_NAME}\" を終了します"
},
"FAILED_TO_LAUNCH_APPLICATION": "アプリケーションの起動に失敗しました:",
"APPLICATION_LAUNCHED_ON": "{DISPLAY_NAME}でアプリケーションが起動しました。"
}

View File

@@ -6,7 +6,7 @@ edition = "2024"
[dependencies]
bytes = "1.9.0"
smallvec = "1.13.2"
serde = { version = "1", features = ["derive"] }
serde.workspace = true
anyhow = "1.0.93"
log = "0.4.22"
@@ -14,7 +14,7 @@ log = "0.4.22"
interprocess = { version = "2.2.2", features = ["tokio"], optional = true }
tokio = { version = "1.43.1", features = ["macros"], optional = true }
tokio-util = { version = "0.7.13", optional = true }
serde_json = "1.0.135"
serde_json.workspace = true
[features]
default = ["client"]

View File

@@ -1,8 +1,7 @@
use bytes::BufMut;
use interprocess::local_socket::{
self,
tokio::{prelude::*, Stream},
GenericNamespaced,
self, GenericNamespaced,
tokio::{Stream, prelude::*},
};
use serde::Serialize;
use smallvec::SmallVec;
@@ -486,6 +485,14 @@ impl WayVRClient {
send_only!(client, &PacketClient::WlxHaptics(params));
Ok(())
}
pub async fn fn_wlx_modify_panel(
client: WayVRClientMutex,
params: packet_client::WlxModifyPanelParams,
) -> anyhow::Result<()> {
send_only!(client, &PacketClient::WlxModifyPanel(params));
Ok(())
}
}
impl Drop for WayVRClient {

View File

@@ -48,6 +48,22 @@ pub struct WlxHapticsParams {
pub frequency: f32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum WlxModifyPanelCommand {
SetText(String),
SetColor(String),
SetImage(String),
SetVisible(bool),
SetStickyState(bool),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WlxModifyPanelParams {
pub overlay: String,
pub element: String,
pub command: WlxModifyPanelCommand,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum PacketClient {
Handshake(Handshake),
@@ -68,4 +84,5 @@ pub enum PacketClient {
WvrProcessTerminate(packet_server::WvrProcessHandle),
WlxHaptics(WlxHapticsParams),
WlxInputState(Serial),
WlxModifyPanel(WlxModifyPanelParams),
}

View File

@@ -1,167 +1,164 @@
#[macro_export]
macro_rules! gen_id {
(
(
$container_name:ident,
$instance_name:ident,
$cell_name:ident,
$handle_name:ident) => {
//ThingCell
pub struct $cell_name {
pub obj: $instance_name,
pub generation: u64,
}
//ThingCell
pub struct $cell_name {
pub obj: $instance_name,
pub generation: u64,
}
//ThingVec
pub struct $container_name {
// Vec<Option<ThingCell>>
pub vec: Vec<Option<$cell_name>>,
//ThingVec
pub struct $container_name {
// Vec<Option<ThingCell>>
pub vec: Vec<Option<$cell_name>>,
cur_generation: u64,
}
cur_generation: u64,
}
//ThingHandle
#[derive(Default, Clone, Copy, PartialEq, Hash, Eq)]
pub struct $handle_name {
idx: u32,
generation: u64,
}
//ThingHandle
#[derive(Default, Clone, Copy, PartialEq, Hash, Eq)]
pub struct $handle_name {
idx: u32,
generation: u64,
}
#[allow(dead_code)]
impl $handle_name {
pub fn reset(&mut self) {
self.generation = 0;
}
#[allow(dead_code)]
impl $handle_name {
pub fn reset(&mut self) {
self.generation = 0;
}
pub fn is_set(&self) -> bool {
self.generation > 0
}
pub fn is_set(&self) -> bool {
self.generation > 0
}
pub fn id(&self) -> u32 {
self.idx
}
pub fn id(&self) -> u32 {
self.idx
}
pub fn new(idx: u32, generation: u64) -> Self {
Self { idx, generation }
}
}
pub fn new(idx: u32, generation: u64) -> Self {
Self { idx, generation }
}
}
//ThingVec
#[allow(dead_code)]
impl $container_name {
pub fn new() -> Self {
Self {
vec: Vec::new(),
cur_generation: 0,
}
}
//ThingVec
#[allow(dead_code)]
impl $container_name {
pub fn new() -> Self {
Self {
vec: Vec::new(),
cur_generation: 0,
}
}
pub fn iter(&self, callback: &dyn Fn($handle_name, &$instance_name)) {
for (idx, opt_cell) in self.vec.iter().enumerate() {
if let Some(cell) = opt_cell {
let handle = $container_name::get_handle(&cell, idx);
callback(handle, &cell.obj);
}
}
}
pub fn iter(&self, callback: &dyn Fn($handle_name, &$instance_name)) {
for (idx, opt_cell) in self.vec.iter().enumerate() {
if let Some(cell) = opt_cell {
let handle = $container_name::get_handle(&cell, idx);
callback(handle, &cell.obj);
}
}
}
pub fn iter_mut(
&mut self,
callback: &mut dyn FnMut($handle_name, &mut $instance_name),
) {
for (idx, opt_cell) in self.vec.iter_mut().enumerate() {
if let Some(cell) = opt_cell {
let handle = $container_name::get_handle(&cell, idx);
callback(handle, &mut cell.obj);
}
}
}
pub fn iter_mut(&mut self, callback: &mut dyn FnMut($handle_name, &mut $instance_name)) {
for (idx, opt_cell) in self.vec.iter_mut().enumerate() {
if let Some(cell) = opt_cell {
let handle = $container_name::get_handle(&cell, idx);
callback(handle, &mut cell.obj);
}
}
}
pub fn get_handle(cell: &$cell_name, idx: usize) -> $handle_name {
$handle_name {
idx: idx as u32,
generation: cell.generation,
}
}
pub fn get_handle(cell: &$cell_name, idx: usize) -> $handle_name {
$handle_name {
idx: idx as u32,
generation: cell.generation,
}
}
fn find_unused_idx(&mut self) -> Option<u32> {
for (num, obj) in self.vec.iter().enumerate() {
if obj.is_none() {
return Some(num as u32);
}
}
None
}
fn find_unused_idx(&mut self) -> Option<u32> {
for (num, obj) in self.vec.iter().enumerate() {
if obj.is_none() {
return Some(num as u32);
}
}
None
}
pub fn add(&mut self, obj: $instance_name) -> $handle_name {
self.cur_generation += 1;
let generation = self.cur_generation;
pub fn add(&mut self, obj: $instance_name) -> $handle_name {
self.cur_generation += 1;
let generation = self.cur_generation;
let unused_idx = self.find_unused_idx();
let unused_idx = self.find_unused_idx();
let idx = if let Some(idx) = unused_idx {
idx
} else {
self.vec.len() as u32
};
let idx = if let Some(idx) = unused_idx {
idx
} else {
self.vec.len() as u32
};
let handle = $handle_name { idx, generation };
let handle = $handle_name { idx, generation };
let cell = $cell_name { obj, generation };
let cell = $cell_name { obj, generation };
if let Some(idx) = unused_idx {
self.vec[idx as usize] = Some(cell);
} else {
self.vec.push(Some(cell))
}
if let Some(idx) = unused_idx {
self.vec[idx as usize] = Some(cell);
} else {
self.vec.push(Some(cell))
}
handle
}
handle
}
pub fn remove(&mut self, handle: &$handle_name) {
// Out of bounds, ignore
if handle.idx as usize >= self.vec.len() {
return;
}
pub fn remove(&mut self, handle: &$handle_name) {
// Out of bounds, ignore
if handle.idx as usize >= self.vec.len() {
return;
}
// Remove only if the generation matches
if let Some(cell) = &self.vec[handle.idx as usize] {
if cell.generation == handle.generation {
self.vec[handle.idx as usize] = None;
}
}
}
// Remove only if the generation matches
if let Some(cell) = &self.vec[handle.idx as usize] {
if cell.generation == handle.generation {
self.vec[handle.idx as usize] = None;
}
}
}
pub fn get(&self, handle: &$handle_name) -> Option<&$instance_name> {
// Out of bounds, ignore
if handle.idx as usize >= self.vec.len() {
return None;
}
pub fn get(&self, handle: &$handle_name) -> Option<&$instance_name> {
// Out of bounds, ignore
if handle.idx as usize >= self.vec.len() {
return None;
}
if let Some(cell) = &self.vec[handle.idx as usize] {
if cell.generation == handle.generation {
return Some(&cell.obj);
}
}
if let Some(cell) = &self.vec[handle.idx as usize] {
if cell.generation == handle.generation {
return Some(&cell.obj);
}
}
None
}
None
}
pub fn get_mut(&mut self, handle: &$handle_name) -> Option<&mut $instance_name> {
// Out of bounds, ignore
if handle.idx as usize >= self.vec.len() {
return None;
}
pub fn get_mut(&mut self, handle: &$handle_name) -> Option<&mut $instance_name> {
// Out of bounds, ignore
if handle.idx as usize >= self.vec.len() {
return None;
}
if let Some(cell) = &mut self.vec[handle.idx as usize] {
if cell.generation == handle.generation {
return Some(&mut cell.obj);
}
}
if let Some(cell) = &mut self.vec[handle.idx as usize] {
if cell.generation == handle.generation {
return Some(&mut cell.obj);
}
}
None
}
}
};
None
}
}
};
}
/* Example usage:

15
wayvrctl/Cargo.toml Normal file
View File

@@ -0,0 +1,15 @@
[package]
name = "wayvrctl"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow.workspace = true
log.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
env_logger = "0.11.8"
tokio = "1.48.0"
wayvr-ipc = { path = "../wayvr-ipc" }
shell-words = "1.1.1"

267
wayvrctl/src/helper.rs Normal file
View File

@@ -0,0 +1,267 @@
use std::collections::HashMap;
use anyhow::Context;
use serde::Serialize;
use wayvr_ipc::{
client::{WayVRClient, WayVRClientMutex},
ipc, packet_client, packet_server,
};
pub struct WayVRClientState {
pub wayvr_client: WayVRClientMutex,
pub serial_generator: ipc::SerialGenerator,
pub pretty_print: bool,
}
fn handle_empty_result(result: anyhow::Result<()>) {
if let Err(e) = result {
log::error!("{e:?}");
}
}
fn handle_result<T: Serialize>(pretty_print: bool, result: anyhow::Result<T>) {
match result {
Ok(t) => {
let maybe_json = if pretty_print {
serde_json::to_string_pretty(&t)
} else {
serde_json::to_string(&t)
};
match maybe_json {
Ok(json_string) => println!("{}", json_string),
Err(e) => log::error!("Failed to serialize JSON: {e:?}"),
}
}
Err(e) => log::error!("{e:?}"),
}
}
pub async fn wvr_display_create(
state: &mut WayVRClientState,
width: u16,
height: u16,
name: String,
scale: Option<f32>,
attach_to: packet_client::AttachTo,
) {
handle_result(
state.pretty_print,
WayVRClient::fn_wvr_display_create(
state.wayvr_client.clone(),
state.serial_generator.increment_get(),
packet_client::WvrDisplayCreateParams {
width,
height,
name,
scale,
attach_to,
},
)
.await
.context("failed to create display"),
);
}
pub async fn wvr_display_list(state: &mut WayVRClientState) {
handle_result(
state.pretty_print,
WayVRClient::fn_wvr_display_list(
state.wayvr_client.clone(),
state.serial_generator.increment_get(),
)
.await
.context("failed to fetch displays"),
);
}
pub async fn wvr_display_get(
state: &mut WayVRClientState,
handle: packet_server::WvrDisplayHandle,
) {
handle_result(
state.pretty_print,
WayVRClient::fn_wvr_display_get(
state.wayvr_client.clone(),
state.serial_generator.increment_get(),
handle,
)
.await
.context("failed to fetch display"),
);
}
pub async fn wvr_display_window_list(
state: &mut WayVRClientState,
handle: packet_server::WvrDisplayHandle,
) {
handle_result(
state.pretty_print,
WayVRClient::fn_wvr_display_window_list(
state.wayvr_client.clone(),
state.serial_generator.increment_get(),
handle,
)
.await
.context("failed to list window displays"),
);
}
pub async fn wvr_display_remove(
state: &mut WayVRClientState,
handle: packet_server::WvrDisplayHandle,
) {
handle_result(
state.pretty_print,
WayVRClient::fn_wvr_display_remove(
state.wayvr_client.clone(),
state.serial_generator.increment_get(),
handle,
)
.await
.context("failed to remove display"),
);
}
pub async fn wvr_display_set_visible(
state: &mut WayVRClientState,
handle: packet_server::WvrDisplayHandle,
visible: bool,
) {
handle_empty_result(
WayVRClient::fn_wvr_display_set_visible(state.wayvr_client.clone(), handle, visible)
.await
.context("failed to set display visibility"),
)
}
pub async fn wvr_window_set_visible(
state: &mut WayVRClientState,
handle: packet_server::WvrWindowHandle,
visible: bool,
) {
handle_empty_result(
WayVRClient::fn_wvr_window_set_visible(state.wayvr_client.clone(), handle, visible)
.await
.context("failed to set window visibility"),
)
}
pub async fn wvr_process_get(
state: &mut WayVRClientState,
handle: packet_server::WvrProcessHandle,
) {
handle_result(
state.pretty_print,
WayVRClient::fn_wvr_process_get(
state.wayvr_client.clone(),
state.serial_generator.increment_get(),
handle,
)
.await
.context("failed to get process"),
);
}
pub async fn wvr_process_list(state: &mut WayVRClientState) {
handle_result(
state.pretty_print,
WayVRClient::fn_wvr_process_list(
state.wayvr_client.clone(),
state.serial_generator.increment_get(),
)
.await
.context("failed to list processes"),
)
}
pub async fn wvr_process_terminate(
state: &mut WayVRClientState,
handle: packet_server::WvrProcessHandle,
) {
handle_empty_result(
WayVRClient::fn_wvr_process_terminate(state.wayvr_client.clone(), handle)
.await
.context("failed to terminate process"),
)
}
pub async fn wvr_process_launch(
state: &mut WayVRClientState,
exec: String,
name: String,
env: Vec<String>,
target_display: packet_server::WvrDisplayHandle,
args: String,
userdata: HashMap<String, String>,
) {
handle_result(
state.pretty_print,
WayVRClient::fn_wvr_process_launch(
state.wayvr_client.clone(),
state.serial_generator.increment_get(),
packet_client::WvrProcessLaunchParams {
env,
exec,
name,
target_display,
args,
userdata,
},
)
.await
.context("failed to launch process"),
)
}
pub async fn wlx_haptics(
state: &mut WayVRClientState,
intensity: f32,
duration: f32,
frequency: f32,
) {
handle_empty_result(
WayVRClient::fn_wlx_haptics(
state.wayvr_client.clone(),
packet_client::WlxHapticsParams {
intensity,
duration,
frequency,
},
)
.await
.context("failed to trigger haptics"),
)
}
pub async fn wlx_panel_modify(
state: &mut WayVRClientState,
overlay: String,
element: String,
command: packet_client::WlxModifyPanelCommand,
) {
handle_empty_result(
WayVRClient::fn_wlx_modify_panel(
state.wayvr_client.clone(),
packet_client::WlxModifyPanelParams {
overlay,
element,
command,
},
)
.await
.context("failed to modify panel"),
)
}
pub async fn wlx_input_state(state: &mut WayVRClientState) {
handle_result(
state.pretty_print,
WayVRClient::fn_wlx_input_state(
state.wayvr_client.clone(),
state.serial_generator.increment_get(),
)
.await
.context("failed to get input state"),
)
}

327
wayvrctl/src/main.rs Normal file
View File

@@ -0,0 +1,327 @@
use std::{
collections::HashMap,
process::{self, ExitCode},
time::Duration,
};
use anyhow::Context;
use clap::Parser;
use env_logger::Env;
use wayvr_ipc::{client::WayVRClient, ipc, packet_client};
use crate::helper::{
WayVRClientState, wlx_haptics, wlx_input_state, wlx_panel_modify, wvr_display_create,
wvr_display_get, wvr_display_list, wvr_display_remove, wvr_display_set_visible,
wvr_display_window_list, wvr_process_get, wvr_process_launch, wvr_process_list,
wvr_process_terminate, wvr_window_set_visible,
};
mod helper;
#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
env_logger::init_from_env(Env::default().default_filter_or("info"));
let args = Args::parse();
let mut state = WayVRClientState {
wayvr_client: WayVRClient::new(&format!("wayvrctl-{}", process::id()))
.await
.inspect_err(|e| {
log::error!("Failed to initialize WayVR connection: {e:?}");
process::exit(1);
})
.unwrap(),
serial_generator: ipc::SerialGenerator::new(),
pretty_print: args.pretty,
};
let maybe_err = if let Subcommands::Batch { fail_fast } = args.command {
run_batch(&mut state, fail_fast).await
} else {
run_once(&mut state, args).await
};
if let Err(e) = maybe_err {
log::error!("{e:?}");
return ExitCode::FAILURE;
} else {
std::thread::sleep(Duration::from_millis(20));
}
ExitCode::SUCCESS
}
async fn run_batch(state: &mut WayVRClientState, fail_fast: bool) -> anyhow::Result<()> {
let stdin = std::io::stdin();
for (line_no, line) in stdin.lines().enumerate() {
let line = line.context("error reading stdin")?;
if line.trim().is_empty() || line.trim_start().starts_with('#') {
continue;
}
if let Err(e) = parse_run_line(state, &line)
.await
.with_context(|| format!("error on line {}", line_no + 1))
{
if fail_fast {
return Err(e);
} else {
log::error!("{e:?}");
}
}
}
Ok(())
}
async fn parse_run_line(state: &mut WayVRClientState, line: &str) -> anyhow::Result<()> {
let mut argv = shell_words::split(&line).with_context(|| format!("parse error"))?;
// clap expects argv[0] to be the binary name
argv.insert(0, env!("CARGO_PKG_NAME").to_string());
let args = Args::try_parse_from(argv).with_context(|| format!("invalid arguments"))?;
run_once(state, args).await?;
Ok(())
}
async fn run_once(state: &mut WayVRClientState, args: Args) -> anyhow::Result<()> {
match args.command {
Subcommands::Batch { .. } => {
log::warn!("Ignoring recursive batch command");
}
Subcommands::InputState => {
wlx_input_state(state).await;
}
Subcommands::DisplayCreate {
width,
height,
name,
scale,
} => {
wvr_display_create(
state,
width,
height,
name,
scale,
packet_client::AttachTo::None,
)
.await;
}
Subcommands::DisplayList => {
wvr_display_list(state).await;
}
Subcommands::DisplayGet { handle } => {
let handle = serde_json::from_str(&handle).context("Invalid handle")?;
wvr_display_get(state, handle).await;
}
Subcommands::DisplayWindowList { handle } => {
let handle = serde_json::from_str(&handle).context("Invalid handle")?;
wvr_display_window_list(state, handle).await;
}
Subcommands::DisplayRemove { handle } => {
let handle = serde_json::from_str(&handle).context("Invalid handle")?;
wvr_display_remove(state, handle).await;
}
Subcommands::DisplaySetVisible {
handle,
visible_0_or_1,
} => {
let handle = serde_json::from_str(&handle).context("Invalid handle")?;
wvr_display_set_visible(state, handle, visible_0_or_1 != 0).await;
}
Subcommands::WindowSetVisible {
handle,
visible_0_or_1,
} => {
let handle = serde_json::from_str(&handle).context("Invalid handle")?;
wvr_window_set_visible(state, handle, visible_0_or_1 != 0).await;
}
Subcommands::ProcessGet { handle } => {
let handle = serde_json::from_str(&handle).context("Invalid handle")?;
wvr_process_get(state, handle).await;
}
Subcommands::ProcessList => {
wvr_process_list(state).await;
}
Subcommands::ProcessTerminate { handle } => {
let handle = serde_json::from_str(&handle).context("Invalid handle")?;
wvr_process_terminate(state, handle).await;
}
Subcommands::ProcessLaunch {
exec,
name,
env,
target_display,
args,
} => {
let handle = serde_json::from_str(&target_display).context("Invalid target_display")?;
wvr_process_launch(state, exec, name, env, handle, args, HashMap::new()).await;
}
Subcommands::Haptics {
intensity,
duration,
frequency,
} => {
wlx_haptics(state, intensity, duration, frequency).await;
}
Subcommands::PanelModify {
overlay,
element,
command,
} => {
let command = match command {
SubcommandPanelModify::SetText { text } => {
packet_client::WlxModifyPanelCommand::SetText(text.join(" "))
}
SubcommandPanelModify::SetColor { hex_color } => {
packet_client::WlxModifyPanelCommand::SetColor(hex_color)
}
SubcommandPanelModify::SetImage { absolute_path } => {
packet_client::WlxModifyPanelCommand::SetImage(absolute_path)
}
SubcommandPanelModify::SetVisible { visible_0_or_1 } => {
packet_client::WlxModifyPanelCommand::SetVisible(visible_0_or_1 != 0)
}
SubcommandPanelModify::SetStickyState {
sticky_state_0_or_1,
} => packet_client::WlxModifyPanelCommand::SetStickyState(sticky_state_0_or_1 != 0),
};
wlx_panel_modify(state, overlay, element, command).await;
}
}
Ok(())
}
/// A command-line interface for WayVR IPC
#[derive(clap::Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// The command to run
#[command(subcommand)]
command: Subcommands,
/// Pretty-print JSON output
#[arg(short, long)]
pretty: bool,
}
#[derive(clap::Parser, Debug)]
enum Subcommands {
/// Read commands from stdout, one per line.
Batch {
/// Stop on the first error
#[arg(short, long)]
fail_fast: bool,
},
/// Get the positions of HMD & controllers
InputState,
/// Create a new WayVR display
DisplayCreate {
width: u16,
height: u16,
name: String,
#[arg(short, long)]
scale: Option<f32>,
//attach_to: packet_client::AttachTo,
},
/// List WayVR displays
DisplayList,
/// Retrieve information about a single WayVR display
DisplayGet {
/// A display handle JSON returned by DisplayList or DisplayCreate
handle: String,
},
/// List windows attached to a WayVR display
DisplayWindowList {
/// A display handle JSON returned by DisplayList or DisplayCreate
handle: String,
},
/// Delete a WayVR display
DisplayRemove {
/// A display handle JSON returned by DisplayList or DisplayCreate
handle: String,
},
/// Change the visibility of a WayVR display
DisplaySetVisible {
/// A display handle JSON returned by DisplayList or DisplayCreate
handle: String,
visible_0_or_1: u8,
},
// DisplaySetLayout skipped
/// Change the visibility of a window on a WayVR display
WindowSetVisible {
/// A JSON window handle returned by DisplayWindowList
handle: String,
visible_0_or_1: u8,
},
/// Retrieve information about a WayVR-managed process
ProcessGet {
/// A JSON process handle returned by ProcessList or ProcessLaunch
handle: String,
},
/// List all processes managed by WayVR
ProcessList,
/// Terminate a WayVR-managed process
ProcessTerminate {
/// A JSON process handle returned by ProcessList or ProcessLaunch
handle: String,
},
/// Launch a new process inside WayVR
ProcessLaunch {
exec: String,
name: String,
env: Vec<String>,
/// A display handle JSON returned by DisplayList or DisplayCreate
target_display: String,
args: String,
},
/// Trigger haptics on the user's controller
Haptics {
#[arg(short, long, default_value = "0.25")]
intensity: f32,
#[arg(short, long, default_value = "0.1")]
duration: f32,
#[arg(short, long, default_value = "0.1")]
frequency: f32,
},
/// Apply a modification to a panel element
PanelModify {
/// The name of the overlay (XML file name without extension)
overlay: String,
/// The id of the element to modify, as set in the XML
element: String,
/// Command to execute
#[command(subcommand)]
command: SubcommandPanelModify,
},
}
#[derive(clap::Parser, Debug)]
enum SubcommandPanelModify {
/// Set the text of a <label> or <Button>
SetText {
/// Text that needs to be set
#[arg(num_args = 1.., action = clap::ArgAction::Append)]
text: Vec<String>,
},
/// Set the color of a <rectangle> or <label> or monochrome <sprite>
SetColor {
/// Color in HTML hex format (#rrggbb or #rrggbbaa)
hex_color: String,
},
/// Set the content of a <sprite> or <image>. Max size for <sprite> is 256x256.
SetImage {
/// Absolute path to a svg, gif, png, jpeg or webp image.
absolute_path: String,
},
/// Set the visibility of a <div>, <rectangle>, <label>, <sprite> or <image>
SetVisible { visible_0_or_1: u8 },
/// Set the sticky state of a <Button>. Intended for buttons without `sticky="1"`.
SetStickyState { sticky_state_0_or_1: u8 },
}

View File

@@ -4,10 +4,10 @@ version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
anyhow.workspace = true
cosmic-text = "0.15.0"
etagere = "0.2.15"
glam = { workspace = true }
glam.workspace = true
image = { version = "0.25.9", default-features = false, features = [
"gif",
"jpeg",
@@ -15,19 +15,19 @@ image = { version = "0.25.9", default-features = false, features = [
"rayon",
"webp",
] }
log = { workspace = true }
log.workspace = true
lru = "0.16.2"
ouroboros = "0.18.5"
parking_lot = "0.12.5"
regex = { workspace = true }
regex.workspace = true
resvg = { version = "0.45.1", default-features = false }
roxmltree = "0.21.1"
rustc-hash = "2.1.1"
serde_json = "1.0.145"
slotmap = { workspace = true }
serde_json.workspace = true
slotmap.workspace = true
smallvec = "1.15.1"
taffy = "0.9.2"
vulkano = { workspace = true }
vulkano-shaders = { workspace = true }
rust-embed = { workspace = true }
vulkano.workspace = true
vulkano-shaders.workspace = true
rust-embed.workspace = true
flate2 = "1.1.5"

View File

@@ -160,7 +160,17 @@ _2nd gradient color_
### `<sprite>`
### Image widget, supports raster and svg vector
### Image widget for small images,
Supported formats: svg, png, jpeg, gif, webp
Maximum image size: 256x256 pixels
For large or frequently changing images (e.g. album art), or if borders/rounding is desired, consider the `<image>` tag instead.
Sprite images are atlassed, making them very efficient to render.
Adding large sprites permanently increases the atlas size (and thus VRAM usage) for the session. (Atlas space can be re-used, but the atlas won't shrink back.)
#### Parameters
@@ -180,6 +190,44 @@ _Internal (assets) image path_
_wgui internal image path. Do not use directly unless it's related to the core wgui assets._
## image widget
### `<image>`
### Image widget for large images
Supported formats: svg, png, jpeg, gif, webp
Maximum image size: Max texture size for the GPU (usually 8K+)
For small images such as icons, consider using the `<sprite>` tag instead.
`<image>` requires a single draw call per widget, while `<sprite>` widgets all share a single draw call per panel.
#### Parameters
`src`: **string**
_External (filesystem) image path. Falls back to Internal (assets) if not found._
`src_ext`: **string**
_External (filesystem) image path_
`src_builtin`: **string**
_Internal (assets) image path_
`src_internal`: **string**
_wgui internal image path. Do not use directly unless it's related to the core wgui assets._
`round`: **float** (default: 0) | **percent** (0-100%)
`border`: **float**
`border_color`: #FFAABB | #FFAABBCC
---
# Components

View File

@@ -9,7 +9,10 @@ use crate::{
event::EventAlterables,
globals::Globals,
layout::Widget,
renderer_vk::text::{TextShadow, custom_glyph::CustomGlyph},
renderer_vk::text::{
TextShadow,
custom_glyph::{CustomGlyph, CustomGlyphData},
},
stack::{self, ScissorBoundary, ScissorStack, TransformStack},
widget::{self, ScrollbarInfo, WidgetState},
};
@@ -175,6 +178,17 @@ pub struct Rectangle {
pub round_units: u8,
}
#[derive(Clone)]
pub struct ImagePrimitive {
pub content: CustomGlyphData,
pub content_key: usize,
pub border: f32, // width in pixels
pub border_color: Color,
pub round_units: u8,
}
pub struct PrimitiveExtent {
pub(super) boundary: Boundary,
pub(super) transform: Mat4,
@@ -185,6 +199,7 @@ pub enum RenderPrimitive {
Rectangle(PrimitiveExtent, Rectangle),
Text(PrimitiveExtent, Rc<RefCell<Buffer>>, Option<TextShadow>),
Sprite(PrimitiveExtent, Option<CustomGlyph>), //option because we want as_slice
Image(PrimitiveExtent, ImagePrimitive),
ScissorSet(ScissorBoundary),
}

View File

@@ -1,13 +1,13 @@
use crate::{
assets::AssetPath,
components::{button, tooltip, Component},
components::{Component, button, tooltip},
drawing::Color,
i18n::Translation,
layout::WidgetID,
parser::{
parse_check_f32, parse_check_i32, parse_children, parse_f32, print_invalid_attrib, process_component,
AttribPair, ParserContext, ParserFile, parse_check_f32, parse_check_i32, parse_children, parse_f32,
print_invalid_attrib, process_component,
style::{parse_color_opt, parse_round, parse_style, parse_text_style},
AttribPair, ParserContext, ParserFile,
},
widget::util::WLength,
};

View File

@@ -3,6 +3,7 @@ mod component_checkbox;
mod component_slider;
mod style;
mod widget_div;
mod widget_image;
mod widget_label;
mod widget_rectangle;
mod widget_sprite;
@@ -15,8 +16,8 @@ use crate::{
layout::{Layout, LayoutParams, LayoutState, Widget, WidgetID, WidgetMap, WidgetPair},
parser::{
component_button::parse_component_button, component_checkbox::parse_component_checkbox,
component_slider::parse_component_slider, widget_div::parse_widget_div, widget_label::parse_widget_label,
widget_rectangle::parse_widget_rectangle, widget_sprite::parse_widget_sprite,
component_slider::parse_component_slider, widget_div::parse_widget_div, widget_image::parse_widget_image,
widget_label::parse_widget_label, widget_rectangle::parse_widget_rectangle, widget_sprite::parse_widget_sprite,
},
widget::ConstructEssentials,
};
@@ -898,6 +899,9 @@ fn parse_child<'a>(
"sprite" => {
new_widget_id = Some(parse_widget_sprite(file, ctx, child_node, parent_id, &attribs)?);
}
"image" => {
new_widget_id = Some(parse_widget_image(file, ctx, child_node, parent_id, &attribs)?);
}
"Button" => {
new_widget_id = Some(parse_component_button(file, ctx, child_node, parent_id, &attribs)?);
}

View File

@@ -0,0 +1,77 @@
use crate::{
assets::AssetPath,
layout::WidgetID,
parser::{
AttribPair, ParserContext, ParserFile, parse_children, parse_widget_universal, print_invalid_attrib,
style::{parse_color, parse_round, parse_style},
},
renderer_vk::text::custom_glyph::{CustomGlyphContent, CustomGlyphData},
widget::image::{WidgetImage, WidgetImageParams},
};
pub fn parse_widget_image<'a>(
file: &ParserFile,
ctx: &mut ParserContext,
node: roxmltree::Node<'a, 'a>,
parent_id: WidgetID,
attribs: &[AttribPair],
) -> anyhow::Result<WidgetID> {
let mut params = WidgetImageParams::default();
let style = parse_style(attribs);
let mut glyph = None;
for pair in attribs {
let (key, value) = (pair.attrib.as_ref(), pair.value.as_ref());
match key {
"src" | "src_ext" | "src_builtin" | "src_internal" => {
let asset_path = match key {
"src" => AssetPath::FileOrBuiltIn(value),
"src_ext" => AssetPath::File(value),
"src_builtin" => AssetPath::BuiltIn(value),
"src_internal" => AssetPath::WguiInternal(value),
_ => unreachable!(),
};
if !value.is_empty() {
glyph = match CustomGlyphContent::from_assets(&mut ctx.layout.state.globals, asset_path) {
Ok(glyph) => Some(glyph),
Err(e) => {
log::warn!("failed to load {value}: {e}");
None
}
}
}
}
"round" => {
parse_round(
value,
&mut params.round,
ctx.doc_params.globals.get().defaults.rounding_mult,
);
}
"border" => {
params.border = value.parse().unwrap_or_else(|_| {
print_invalid_attrib(key, value);
0.0
});
}
"border_color" => {
parse_color(value, &mut params.border_color);
}
_ => {}
}
}
if let Some(glyph) = glyph {
params.glyph_data = Some(CustomGlyphData::new(glyph));
} else {
log::warn!("No source for image node!");
}
let (widget, _) = ctx.layout.add_child(parent_id, WidgetImage::create(params), style)?;
parse_widget_universal(ctx, &widget, attribs);
parse_children(file, ctx, node, widget.id)?;
Ok(widget.id)
}

View File

@@ -9,6 +9,7 @@ use crate::{
drawing::{self},
font_config,
gfx::{WGfx, cmd::GfxCommandBuffer},
renderer_vk::image::{ImagePipeline, ImageRenderer},
};
use super::{
@@ -26,6 +27,7 @@ struct RendererPass<'a> {
text_areas: Vec<TextArea<'a>>,
text_renderer: TextRenderer,
rect_renderer: RectRenderer,
image_renderer: ImageRenderer,
scissor: Option<drawing::Boundary>,
pixel_scale: f32,
}
@@ -34,16 +36,19 @@ impl RendererPass<'_> {
fn new(
text_atlas: &mut TextAtlas,
rect_pipeline: RectPipeline,
image_pipeline: ImagePipeline,
scissor: Option<drawing::Boundary>,
pixel_scale: f32,
) -> anyhow::Result<Self> {
let text_renderer = TextRenderer::new(text_atlas)?;
let rect_renderer = RectRenderer::new(rect_pipeline)?;
let image_renderer = ImageRenderer::new(image_pipeline)?;
Ok(Self {
submitted: false,
text_renderer,
rect_renderer,
image_renderer,
text_areas: Vec::new(),
scissor,
pixel_scale,
@@ -90,6 +95,7 @@ impl RendererPass<'_> {
self.submitted = true;
self.rect_renderer.render(gfx, viewport, &vk_scissor, cmd_buf)?;
self.image_renderer.render(gfx, viewport, &vk_scissor, cmd_buf)?;
{
let mut font_system = font_system.system.lock();
@@ -119,18 +125,21 @@ pub struct SharedContext {
atlas_map: SlotMap<SharedContextKey, SharedAtlas>,
rect_pipeline: RectPipeline,
text_pipeline: TextPipeline,
image_pipeline: ImagePipeline,
}
impl SharedContext {
pub fn new(gfx: Arc<WGfx>) -> anyhow::Result<Self> {
let rect_pipeline = RectPipeline::new(gfx.clone(), gfx.surface_format)?;
let text_pipeline = TextPipeline::new(gfx.clone(), gfx.surface_format)?;
let image_pipeline = ImagePipeline::new(gfx.clone(), gfx.surface_format)?;
Ok(Self {
gfx,
atlas_map: SlotMap::with_key(),
rect_pipeline,
text_pipeline,
image_pipeline,
})
}
@@ -237,6 +246,7 @@ impl Context {
passes.push(RendererPass::new(
&mut atlas.text_atlas,
shared.rect_pipeline.clone(),
shared.image_pipeline.clone(),
next_scissor,
self.pixel_scale,
)?);
@@ -293,6 +303,11 @@ impl Context {
transform: extent.transform,
});
}
drawing::RenderPrimitive::Image(extent, image) => {
pass
.image_renderer
.add_image(extent.boundary, image.clone(), &extent.transform);
}
drawing::RenderPrimitive::ScissorSet(boundary) => {
next_scissor = Some(boundary.0);
needs_new_pass = true;

View File

@@ -0,0 +1,244 @@
use std::{collections::HashMap, sync::Arc};
use cosmic_text::SubpixelBin;
use glam::Mat4;
use smallvec::smallvec;
use vulkano::{
buffer::{BufferContents, BufferUsage, Subbuffer},
command_buffer::CommandBufferUsage,
format::Format,
image::view::ImageView,
pipeline::graphics::{self, vertex_input::Vertex},
};
use crate::{
drawing::{Boundary, ImagePrimitive},
gfx::{
BLEND_ALPHA, WGfx,
cmd::GfxCommandBuffer,
pass::WGfxPass,
pipeline::{WGfxPipeline, WPipelineCreateInfo},
},
renderer_vk::{
model_buffer::ModelBuffer,
text::custom_glyph::{CustomGlyphData, RasterizeCustomGlyphRequest, RasterizedCustomGlyph},
},
};
use super::viewport::Viewport;
#[repr(C)]
#[derive(BufferContents, Vertex, Copy, Clone, Debug)]
pub struct ImageVertex {
#[format(R32_UINT)]
pub in_model_idx: u32,
#[format(R32_UINT)]
pub in_rect_dim: [u16; 2],
#[format(R32_UINT)]
pub in_border_color: u32,
#[format(R32_UINT)]
pub round_border: [u8; 4],
}
/// Cloneable pipeline & shaders to be shared between `RectRenderer` instances.
#[derive(Clone)]
pub struct ImagePipeline {
gfx: Arc<WGfx>,
pub(super) inner: Arc<WGfxPipeline<ImageVertex>>,
}
impl ImagePipeline {
pub fn new(gfx: Arc<WGfx>, format: Format) -> anyhow::Result<Self> {
let vert = vert_image::load(gfx.device.clone())?;
let frag = frag_image::load(gfx.device.clone())?;
let pipeline = gfx.create_pipeline::<ImageVertex>(
&vert,
&frag,
WPipelineCreateInfo::new(format)
.use_blend(BLEND_ALPHA)
.use_instanced()
.use_updatable_descriptors(smallvec![2]),
)?;
Ok(Self { gfx, inner: pipeline })
}
}
struct ImageVertexWithContent {
vert: ImageVertex,
content: CustomGlyphData,
content_key: usize, // identifies an image tag.
}
struct CachedPass {
content_id: usize,
vert_buffer: Subbuffer<[ImageVertex]>,
inner: WGfxPass<ImageVertex>,
res: [u32; 2],
}
pub struct ImageRenderer {
pipeline: ImagePipeline,
image_verts: Vec<ImageVertexWithContent>,
model_buffer: ModelBuffer,
cached_passes: HashMap<usize, CachedPass>,
}
impl ImageRenderer {
pub fn new(pipeline: ImagePipeline) -> anyhow::Result<Self> {
Ok(Self {
model_buffer: ModelBuffer::new(&pipeline.gfx)?,
pipeline,
image_verts: vec![],
cached_passes: HashMap::new(),
})
}
pub fn begin(&mut self) {
self.image_verts.clear();
self.model_buffer.begin();
}
pub fn add_image(&mut self, boundary: Boundary, image: ImagePrimitive, transform: &Mat4) {
let in_model_idx = self
.model_buffer
.register_pos_size(&boundary.pos, &boundary.size, transform);
self.image_verts.push(ImageVertexWithContent {
vert: ImageVertex {
in_model_idx,
in_rect_dim: [boundary.size.x as u16, boundary.size.y as u16],
in_border_color: cosmic_text::Color::from(image.border_color).0,
round_border: [
image.round_units,
(image.border) as u8,
0, // unused
0,
],
},
content: image.content,
content_key: image.content_key,
});
}
fn upload_image(
gfx: Arc<WGfx>,
res: [u32; 2],
img: &ImageVertexWithContent,
) -> anyhow::Result<Option<Arc<ImageView>>> {
let raster = match RasterizedCustomGlyph::try_from(&RasterizeCustomGlyphRequest {
data: img.content.clone(),
width: res[0] as _,
height: res[1] as _,
x_bin: SubpixelBin::Zero,
y_bin: SubpixelBin::Zero,
scale: 1.0, // unused
}) {
Some(x) => x,
None => {
log::error!("Unable to rasterize custom image");
return Ok(None);
}
};
let mut cmd_buf = gfx.create_xfer_command_buffer(CommandBufferUsage::OneTimeSubmit)?;
let image = cmd_buf.upload_image(
raster.width as _,
raster.height as _,
Format::R8G8B8A8_UNORM,
&raster.data,
)?;
let image_view = ImageView::new_default(image)?;
cmd_buf.build_and_execute_now()?;
Ok(Some(image_view))
}
pub fn render(
&mut self,
gfx: &Arc<WGfx>,
viewport: &mut Viewport,
vk_scissor: &graphics::viewport::Scissor,
cmd_buf: &mut GfxCommandBuffer,
) -> anyhow::Result<()> {
let res = viewport.resolution();
self.model_buffer.upload(gfx)?;
for img in self.image_verts.iter() {
let pass = match self.cached_passes.get_mut(&img.content_key) {
Some(x) => {
if x.content_id != img.content.id || x.res != res {
// image changed
let Some(image_view) = Self::upload_image(self.pipeline.gfx.clone(), res, img)? else {
continue;
};
x.inner
.update_sampler(2, image_view, self.pipeline.gfx.texture_filter)?;
}
x
}
None => {
let vert_buffer = self.pipeline.gfx.empty_buffer(
BufferUsage::VERTEX_BUFFER | BufferUsage::TRANSFER_DST,
(std::mem::size_of::<ImageVertex>()) as _,
)?;
let Some(image_view) = Self::upload_image(self.pipeline.gfx.clone(), res, img)? else {
continue;
};
let set0 = viewport.get_image_descriptor(&self.pipeline);
let set1 = self.model_buffer.get_image_descriptor(&self.pipeline);
let set2 = self
.pipeline
.inner
.uniform_sampler(2, image_view, self.pipeline.gfx.texture_filter)?;
let pass = self.pipeline.inner.create_pass(
[res[0] as _, res[1] as _],
vert_buffer.clone(),
0..4,
0..self.image_verts.len() as _,
vec![set0, set1, set2],
vk_scissor,
)?;
self.cached_passes.insert(
img.content_key,
CachedPass {
content_id: img.content.id,
vert_buffer,
inner: pass,
res,
},
);
self.cached_passes.get_mut(&img.content_key).unwrap()
}
};
pass.vert_buffer.write()?[0..1].clone_from_slice(&[img.vert]);
cmd_buf.run_ref(&pass.inner)?;
}
Ok(())
}
}
pub mod vert_image {
vulkano_shaders::shader! {
ty: "vertex",
path: "src/renderer_vk/shaders/image.vert",
}
}
pub mod frag_image {
vulkano_shaders::shader! {
ty: "fragment",
path: "src/renderer_vk/shaders/image.frag",
}
}

View File

@@ -1,4 +1,5 @@
pub mod context;
pub mod image;
pub mod model_buffer;
pub mod rect;
pub mod text;

View File

@@ -8,7 +8,7 @@ use vulkano::{
use crate::{
gfx,
renderer_vk::{rect::RectPipeline, text::text_atlas::TextPipeline},
renderer_vk::{image::ImagePipeline, rect::RectPipeline, text::text_atlas::TextPipeline},
};
pub struct ModelBuffer {
@@ -19,6 +19,8 @@ pub struct ModelBuffer {
buffer_capacity_f32: u32,
rect_descriptor: Option<Arc<DescriptorSet>>,
text_descriptor: Option<Arc<DescriptorSet>>,
image_descriptor: Option<Arc<DescriptorSet>>,
}
impl ModelBuffer {
@@ -40,6 +42,8 @@ impl ModelBuffer {
buffer,
buffer_capacity_f32: INITIAL_CAPACITY_F32,
rect_descriptor: None,
text_descriptor: None,
image_descriptor: None,
})
}
@@ -109,8 +113,15 @@ impl ModelBuffer {
pub fn get_text_descriptor(&mut self, pipeline: &TextPipeline) -> Arc<DescriptorSet> {
self
.rect_descriptor
.text_descriptor
.get_or_insert_with(|| pipeline.inner.buffer(3, self.buffer.clone()).unwrap())
.clone()
}
pub fn get_image_descriptor(&mut self, pipeline: &ImagePipeline) -> Arc<DescriptorSet> {
self
.image_descriptor
.get_or_insert_with(|| pipeline.inner.buffer(1, self.buffer.clone()).unwrap())
.clone()
}
}

View File

@@ -0,0 +1,55 @@
#version 450
#extension GL_GOOGLE_include_directive : enable
precision highp float;
layout(location = 0) in vec2 in_uv;
layout(location = 1) in vec4 in_border_color;
layout(location = 2) in float in_border_size; // in units
layout(location = 3) in float in_radius; // in units
layout(location = 4) in vec2 in_rect_size;
layout(location = 0) out vec4 out_color;
#define UNIFORM_PARAMS_SET 0
#include "uniform.glsl"
layout(set = 2, binding = 0) uniform sampler2D image;
void main() {
float rect_aspect = in_rect_size.x / in_rect_size.y;
vec2 center = in_rect_size / 2.0;
vec2 coords = in_uv * in_rect_size;
float radius = in_radius;
vec2 sdf_rect_dim = center - vec2(radius);
float sdf = length(max(abs(coords - center), sdf_rect_dim) - sdf_rect_dim) -
in_radius;
vec4 color = texture(image, in_uv);
float pixel_size = 1.0 / uniforms.pixel_scale;
if (in_border_size < in_radius) {
// rounded border
float f = in_border_size > 0.0 ? smoothstep(in_border_size + pixel_size,
in_border_size, -sdf) *
in_border_color.a
: 0.0;
out_color = mix(color, in_border_color, f);
} else {
// square border
vec2 a = abs(coords - center);
float aa = center.x - in_border_size;
float bb = center.y - in_border_size;
out_color = (a.x > aa || a.y > bb) ? in_border_color : color;
}
if (in_radius > 0.0) {
// rounding cutout alpha
out_color.a *= 1.0 - smoothstep(-pixel_size, 0.0, sdf);
}
}

View File

@@ -0,0 +1,52 @@
#version 450
#extension GL_GOOGLE_include_directive : enable
precision highp float;
layout(location = 0) in uint in_model_idx;
layout(location = 1) in uint in_rect_dim;
layout(location = 2) in uint in_border_color;
layout(location = 3) in uint round_border;
layout(location = 0) out vec2 out_uv;
layout(location = 1) out vec4 out_border_color;
layout(location = 2) out float out_border_size;
layout(location = 3) out float out_radius;
layout(location = 4) out vec2 out_rect_size;
#define UNIFORM_PARAMS_SET 0
#define MODEL_BUFFER_SET 1
#include "model_buffer.glsl"
#include "uniform.glsl"
void main() {
uint v = uint(gl_VertexIndex); // 0-3
uint rect_width = in_rect_dim & 0xffffu;
uint rect_height = (in_rect_dim & 0xffff0000u) >> 16u;
vec2 rect_size = vec2(float(rect_width), float(rect_height));
float rect_aspect = rect_size.x / rect_size.y;
// 0.0 - 1.0 normalized
uvec2 corner_pos_u = uvec2(v & 1u, (v >> 1u) & 1u);
vec2 corner_pos = vec2(corner_pos_u);
out_uv = corner_pos;
mat4 model_matrix = model_buffer.models[in_model_idx];
out_rect_size = rect_size;
gl_Position = uniforms.projection * model_matrix * vec4(corner_pos, 0.0, 1.0);
out_border_color =
vec4(float((in_border_color & 0x00ff0000u) >> 16u) / 255.0,
float((in_border_color & 0x0000ff00u) >> 8u) / 255.0,
float(in_border_color & 0x000000ffu) / 255.0,
float((in_border_color & 0xff000000u) >> 24u) / 255.0);
float radius = float(round_border & 0xffu);
out_radius = radius;
float border_size = float((round_border & 0xff00u) >> 8);
out_border_size = border_size;
}

View File

@@ -53,8 +53,8 @@ impl CustomGlyphContent {
/// Clone and reuse this to avoid atlasing the same content twice.
#[derive(Debug, Clone)]
pub struct CustomGlyphData {
pub(super) id: usize,
pub(super) content: Arc<CustomGlyphContent>,
pub(crate) id: usize,
pub(crate) content: Arc<CustomGlyphContent>,
}
impl CustomGlyphData {
@@ -157,7 +157,7 @@ pub struct RasterizedCustomGlyph {
}
impl RasterizedCustomGlyph {
pub(super) fn try_from(input: &RasterizeCustomGlyphRequest) -> Option<Self> {
pub(crate) fn try_from(input: &RasterizeCustomGlyphRequest) -> Option<Self> {
match input.data.content.as_ref() {
CustomGlyphContent::Svg(tree) => rasterize_svg(tree, input),
CustomGlyphContent::Image(data) => Some(rasterize_image(data)),

View File

@@ -5,7 +5,10 @@ use vulkano::{
descriptor_set::DescriptorSet,
};
use crate::{gfx::WGfx, renderer_vk::util::WMat4};
use crate::{
gfx::WGfx,
renderer_vk::{image::ImagePipeline, util::WMat4},
};
use super::{rect::RectPipeline, text::text_atlas::TextPipeline};
@@ -16,6 +19,7 @@ pub struct Viewport {
params_buffer: Subbuffer<[Params]>,
text_descriptor: Option<Arc<DescriptorSet>>,
rect_descriptor: Option<Arc<DescriptorSet>>,
image_descriptor: Option<Arc<DescriptorSet>>,
}
impl Viewport {
@@ -36,6 +40,7 @@ impl Viewport {
params_buffer,
text_descriptor: None,
rect_descriptor: None,
image_descriptor: None,
})
}
@@ -57,6 +62,15 @@ impl Viewport {
.clone()
}
pub fn get_image_descriptor(&mut self, pipeline: &ImagePipeline) -> Arc<DescriptorSet> {
self
.image_descriptor
.get_or_insert_with(|| {
pipeline.inner.buffer(0, self.params_buffer.clone()).unwrap() // safe unwrap
})
.clone()
}
/// Updates the `Viewport` with the given `resolution` and `projection`.
pub fn update(&mut self, resolution: [u32; 2], projection: &glam::Mat4, pixel_scale: f32) -> anyhow::Result<()> {
if self.params.screen_resolution == resolution

113
wgui/src/widget/image.rs Normal file
View File

@@ -0,0 +1,113 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use slotmap::Key;
use crate::{
drawing::{self, ImagePrimitive, PrimitiveExtent},
event::CallbackDataCommon,
globals::Globals,
layout::WidgetID,
renderer_vk::text::custom_glyph::CustomGlyphData,
widget::{WidgetStateFlags, util::WLength},
};
use super::{WidgetObj, WidgetState};
static AUTO_INCREMENT: AtomicUsize = AtomicUsize::new(0);
#[derive(Debug, Default)]
pub struct WidgetImageParams {
pub glyph_data: Option<CustomGlyphData>,
pub border: f32,
pub border_color: drawing::Color,
pub round: WLength,
}
#[derive(Debug, Default)]
pub struct WidgetImage {
params: WidgetImageParams,
id: WidgetID,
content_key: usize,
}
impl WidgetImage {
pub fn create(params: WidgetImageParams) -> WidgetState {
WidgetState::new(
WidgetStateFlags::default(),
Box::new(Self {
params,
id: WidgetID::null(),
content_key: AUTO_INCREMENT.fetch_add(1, Ordering::Relaxed),
}),
)
}
pub fn set_content(&mut self, common: &mut CallbackDataCommon, content: Option<CustomGlyphData>) {
if self.params.glyph_data == content {
return;
}
self.params.glyph_data = content;
common.mark_widget_dirty(self.id);
}
pub fn get_content(&self) -> Option<CustomGlyphData> {
self.params.glyph_data.clone()
}
}
impl WidgetObj for WidgetImage {
fn draw(&mut self, state: &mut super::DrawState, _params: &super::DrawParams) {
let boundary = drawing::Boundary::construct_relative(state.transform_stack);
let Some(content) = self.params.glyph_data.clone() else {
return;
};
let round_units = match self.params.round {
WLength::Units(units) => units as u8,
WLength::Percent(percent) => (f32::min(boundary.size.x, boundary.size.y) * percent / 2.0) as u8,
};
state.primitives.push(drawing::RenderPrimitive::Image(
PrimitiveExtent {
boundary,
transform: state.transform_stack.get().transform,
},
ImagePrimitive {
content,
content_key: self.content_key,
border: self.params.border,
border_color: self.params.border_color,
round_units,
},
));
}
fn measure(
&mut self,
_globals: &Globals,
_known_dimensions: taffy::Size<Option<f32>>,
_available_space: taffy::Size<taffy::AvailableSpace>,
) -> taffy::Size<f32> {
taffy::Size::ZERO
}
fn get_id(&self) -> WidgetID {
self.id
}
fn set_id(&mut self, id: WidgetID) {
self.id = id;
}
fn get_type(&self) -> super::WidgetType {
super::WidgetType::Sprite
}
fn debug_print(&self) -> String {
String::default()
}
}

View File

@@ -17,6 +17,7 @@ use crate::{
};
pub mod div;
pub mod image;
pub mod label;
pub mod rectangle;
pub mod sprite;

View File

@@ -2,6 +2,7 @@ use slotmap::Key;
use crate::{
drawing::{self, GradientMode, PrimitiveExtent},
event::CallbackDataCommon,
layout::WidgetID,
widget::{WidgetStateFlags, util::WLength},
};
@@ -35,6 +36,10 @@ impl WidgetRectangle {
}),
)
}
pub fn set_color(&mut self, common: &mut CallbackDataCommon, color: drawing::Color) {
self.params.color = color;
common.mark_widget_dirty(self.id);
}
}
impl WidgetObj for WidgetRectangle {

View File

@@ -5,6 +5,7 @@ use slotmap::Key;
use crate::{
drawing::{self, PrimitiveExtent},
event::CallbackDataCommon,
globals::Globals,
layout::WidgetID,
renderer_vk::text::{
@@ -39,16 +40,22 @@ impl WidgetSprite {
)
}
pub const fn set_color(&mut self, color: drawing::Color) {
pub fn set_color(&mut self, common: &mut CallbackDataCommon, color: drawing::Color) {
self.params.color = Some(color);
common.mark_widget_dirty(self.id);
}
pub const fn get_color(&self) -> Option<drawing::Color> {
self.params.color
}
pub fn set_content(&mut self, content: Option<CustomGlyphData>) {
pub fn set_content(&mut self, common: &mut CallbackDataCommon, content: Option<CustomGlyphData>) {
if self.params.glyph_data == content {
return;
}
self.params.glyph_data = content;
common.mark_widget_dirty(self.id);
}
pub fn get_content(&self) -> Option<CustomGlyphData> {

View File

@@ -5,7 +5,7 @@ edition = "2024"
[dependencies]
serde = { version = "1.0.228", features = ["derive", "rc"] }
serde = { workspace = true, features = ["rc"] }
glam = { workspace = true }
chrono = "0.4.42"
idmap = { workspace = true, features = ["serde"] }

View File

@@ -20,11 +20,16 @@ categories = ["games"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = { workspace = true }
anyhow.workspace = true
clap.workspace = true
log.workspace = true
slotmap.workspace = true
serde = { workspace = true, features = ["rc"] }
serde_json.workspace = true
ash = "^0.38.0" # must match vulkano
chrono = "0.4.42"
chrono-tz = "0.10.4"
clap = { version = "4.5.53", features = ["derive"] }
config = "0.15.19"
dbus = { version = "0.9.9" }
futures = "0.3.31"
@@ -35,7 +40,6 @@ input-linux = "0.7.1"
json = { version = "0.12.4", optional = true }
json5 = "1.3.0"
libc = "0.2.178"
log = { workspace = true }
openxr = { git = "https://github.com/Ralith/openxrs", rev = "d0afdd3365bc1e14de28f6a3a21f457e788a702e", features = [
"linked",
"mint",
@@ -51,10 +55,7 @@ rodio = { version = "0.21.1", default-features = false, features = [
"hound",
] }
rosc = { version = "0.11.4", optional = true }
serde = { version = "1.0.228", features = ["derive", "rc"] }
serde_json = "1.0.145"
serde_yaml = "0.9.34"
slotmap = { workspace = true }
smallvec = "1.15.1"
strum = { version = "0.27.2", features = ["derive"] }
sysinfo = { version = "0.37" }

View File

@@ -54,6 +54,22 @@ pub enum PlayspaceTask {
FixFloor,
}
#[derive(Debug, Clone)]
pub enum ModifyPanelCommand {
SetText(String),
SetColor(String),
SetImage(String),
SetVisible(bool),
SetStickyState(bool),
}
#[derive(Debug, Clone)]
pub struct ModifyPanelTask {
pub overlay: String,
pub element: String,
pub command: ModifyPanelCommand,
}
pub type ModifyOverlayTask = dyn FnOnce(&mut AppState, &mut OverlayWindowConfig) + Send;
pub type CreateOverlayTask = dyn FnOnce(&mut AppState) -> Option<OverlayWindowConfig> + Send;
pub enum OverlayTask {
@@ -66,6 +82,7 @@ pub enum OverlayTask {
CleanupMirrors,
Modify(OverlaySelector, Box<ModifyOverlayTask>),
Create(OverlaySelector, Box<CreateOverlayTask>),
ModifyPanel(ModifyPanelTask),
Drop(OverlaySelector),
}

View File

@@ -41,7 +41,10 @@ use std::{
sync::Arc,
};
use time::get_millis;
use wayvr_ipc::{packet_client, packet_server};
use wayvr_ipc::{
packet_client::{self},
packet_server,
};
use xkbcommon::xkb;
use crate::{
@@ -94,6 +97,7 @@ pub enum WayVRSignal {
BroadcastStateChanged(packet_server::WvrStateChanged),
DropOverlay(crate::windowing::OverlayID),
Haptics(super::input::Haptics),
CustomTask(crate::backend::task::ModifyPanelTask),
}
pub enum BlitMethod {

View File

@@ -479,6 +479,38 @@ impl Connection {
));
}
fn handle_wlx_panel(
params: &mut TickParams,
custom_params: packet_client::WlxModifyPanelParams,
) {
use crate::backend::task::{ModifyPanelCommand, ModifyPanelTask};
params
.state
.signals
.send(super::WayVRSignal::CustomTask(ModifyPanelTask {
overlay: custom_params.overlay,
element: custom_params.element,
command: match custom_params.command {
packet_client::WlxModifyPanelCommand::SetText(text) => {
ModifyPanelCommand::SetText(text)
}
packet_client::WlxModifyPanelCommand::SetImage(sprite) => {
ModifyPanelCommand::SetImage(sprite)
}
packet_client::WlxModifyPanelCommand::SetStickyState(sticky) => {
ModifyPanelCommand::SetStickyState(sticky)
}
packet_client::WlxModifyPanelCommand::SetVisible(visible) => {
ModifyPanelCommand::SetVisible(visible)
}
packet_client::WlxModifyPanelCommand::SetColor(color) => {
ModifyPanelCommand::SetColor(color)
}
},
}));
}
fn process_payload(&mut self, params: &mut TickParams, payload: Payload) -> anyhow::Result<()> {
let packet: PacketClient = ipc::data_decode(&payload)?;
@@ -531,6 +563,9 @@ impl Connection {
PacketClient::WlxHaptics(haptics_params) => {
Self::handle_wlx_haptics(params, haptics_params);
}
PacketClient::WlxModifyPanel(custom_params) => {
Self::handle_wlx_panel(params, custom_params);
}
}
Ok(())

View File

@@ -135,7 +135,7 @@ pub struct WayVRDashboard {
#[derive(Deserialize, Serialize)]
pub struct WayVRConfig {
#[serde(default = "def_false")]
#[serde(default = "def_true")]
pub run_compositor_at_start: bool,
#[serde(default = "Default::default")]

View File

@@ -1,5 +1,9 @@
# Custom UI Elements
Elements on custom panels may be modified at runtime using `wayvrctl`.
For more, refer to: `wayvrctl panel-modify --help`
### Labels
#### Clock label
@@ -14,43 +18,6 @@ See the Custom Timezones section for more info on timezones. Skip `_timezone` to
<label _source="clock" _display="time" _timezone="0" [...] />
```
#### Fifo label
Fifo label creates a fifo on your system that other programs can pipe output into.
- The label will look for the last complete line to use as its text.
- If the pipe breaks due to an IO error, re-creation is attempted after 15 seconds.
- `_path` supports environment variables, but not `~`!
```xml
<label _source="fifo" _path="$XDG_RUNTIME_DIR/my-test-label" [...] />
```
Example script to test with:
```bash
for i in {0..99}; do echo "i is $i" > $XDG_RUNTIME_DIR/my-test-label; sleep 1; done
```
#### Shell Exec label
This label executes a shell script using the `sh` shell.
- Write lines to the script's stdout to update the label text.
- The label will look for the last complete line to use as its text.
- Long-running scripts are allowed, but the label is only updated while the HMD is active.
- If the script exits successfully (code 0), it will be re-ran on the next frame. Otherwise, it will be re-ran in 15s.
- Control the pacing from inside the script itself. For example, adding a sleep 5 will make the script execute at most once per 5 seconds.
- `_exec` supports everything that `sh` supports!
```xml
<label _source="shell" _exec="$HOME/.local/bin/my-test-script.sh" [...] />
```
```bash
#!/usr/bin/bash
echo "This is my script's output!"
```
#### Battery label
This is a label type that's used internally to display battery states.

View File

@@ -1,6 +1,6 @@
use std::{
cell::RefCell,
process::{Command, Stdio},
process::{Child, Command, Stdio},
rc::Rc,
str::FromStr,
sync::{Arc, atomic::Ordering},
@@ -10,10 +10,7 @@ use std::{
use anyhow::Context;
use wgui::{
components::button::ComponentButton,
event::{
self, CallbackData, CallbackMetadata, EventCallback, EventListenerKind, MouseButtonIndex,
},
i18n::Translation,
event::{CallbackData, CallbackMetadata, EventCallback, EventListenerKind, MouseButtonIndex},
layout::Layout,
parser::CustomAttribsInfoOwned,
widget::EventResult,
@@ -23,7 +20,6 @@ use wlx_common::overlays::ToastTopic;
use crate::{
RUNNING,
backend::task::{OverlayTask, PlayspaceTask, TaskType},
gui::panel::helper::PipeReaderThread,
overlays::toast::Toast,
state::AppState,
subsystem::hid::VirtualKey,
@@ -359,15 +355,13 @@ pub(super) fn setup_custom_button<S: 'static>(
carry_over: RefCell::new(None),
});
let piped = attribs.get_value("_update_label").is_some_and(|s| s == "1");
layout.add_event_listener::<AppState, S>(
attribs.widget_id,
EventListenerKind::InternalStateChange,
Box::new({
let state = state.clone();
move |common, _data, _, _| {
shell_on_tick(&state, common, piped);
move |_, _, _, _| {
shell_on_tick(&state);
Ok(EventResult::Pass)
}
}),
@@ -429,8 +423,7 @@ pub(super) fn setup_custom_button<S: 'static>(
#[derive(Default)]
struct ShellButtonMutableState {
reader: Option<PipeReaderThread>,
pid: Option<u32>,
child: Option<Child>,
}
struct ShellButtonState {
@@ -443,17 +436,18 @@ struct ShellButtonState {
fn shell_on_action(state: &ShellButtonState) -> anyhow::Result<()> {
let mut mut_state = state.mut_state.borrow_mut();
if mut_state.reader.as_ref().is_some_and(|r| !r.is_finished())
&& let Some(pid) = mut_state.pid.as_ref()
if let Some(child) = mut_state.child.as_mut()
&& let Ok(None) = child.try_wait()
{
log::info!("ShellExec triggered while child is still running; sending SIGUSR1");
let _ = Command::new("kill")
.arg("-s")
.arg("USR1")
.arg(pid.to_string())
.arg(child.id().to_string())
.spawn()
.unwrap()
.wait();
return Ok(());
}
@@ -464,26 +458,19 @@ fn shell_on_action(state: &ShellButtonState) -> anyhow::Result<()> {
.spawn()
.with_context(|| format!("Failed to run shell script: '{}'", &state.exec))?;
mut_state.pid = Some(child.id());
mut_state.reader = Some(PipeReaderThread::new_from_child(child));
mut_state.child = Some(child);
Ok(())
}
fn shell_on_tick(state: &ShellButtonState, common: &mut event::CallbackDataCommon, piped: bool) {
fn shell_on_tick(state: &ShellButtonState) {
let mut mut_state = state.mut_state.borrow_mut();
let Some(reader) = mut_state.reader.as_mut() else {
let Some(child) = mut_state.child.as_mut() else {
return;
};
if piped && let Some(text) = reader.get_last_line() {
state
.button
.set_text(common, Translation::from_raw_text(&text));
}
if reader.is_finished() {
mut_state.reader = None;
if let Ok(Some(_)) = child.try_wait() {
mut_state.child = None;
}
}

View File

@@ -1,118 +0,0 @@
use regex::Regex;
use std::{
fs,
io::{BufRead, BufReader},
process::Child,
sync::{
Arc, LazyLock,
mpsc::{self, Receiver},
},
thread::JoinHandle,
};
static ENV_VAR_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)}|\$([A-Z_][A-Z0-9_]*)").unwrap() // want panic
});
pub(super) fn expand_env_vars(template: &str) -> String {
ENV_VAR_REGEX
.replace_all(template, |caps: &regex::Captures| {
let var_name = caps.get(1).or_else(|| caps.get(2)).unwrap().as_str();
std::env::var(var_name)
.inspect_err(|e| log::warn!("Unable to substitute env var {var_name}: {e:?}"))
.unwrap_or_default()
})
.into_owned()
}
pub(super) struct PipeReaderThread {
receiver: Receiver<String>,
handle: JoinHandle<bool>,
}
impl PipeReaderThread {
pub fn new_from_child(mut c: Child) -> Self {
const BUF_LEN: usize = 128;
let (sender, receiver) = mpsc::sync_channel::<String>(4);
let handle = std::thread::spawn({
move || {
let stdout = c.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
loop {
let mut buf = String::with_capacity(BUF_LEN);
match reader.read_line(&mut buf) {
Ok(0) => {
// EOF reached
break;
}
Ok(_) => {
let _ = sender.try_send(buf);
}
Err(e) => {
log::error!("Error reading pipe: {e:?}");
break;
}
}
}
c.wait()
.inspect_err(|e| log::error!("Failed to wait for child process: {e:?}"))
.is_ok_and(|c| c.success())
}
});
Self { receiver, handle }
}
pub fn new_from_fifo(path: Arc<str>) -> Self {
const BUF_LEN: usize = 128;
let (sender, receiver) = mpsc::sync_channel::<String>(4);
let handle = std::thread::spawn({
move || {
let Ok(mut reader) = fs::File::open(&*path)
.inspect_err(|e| {
log::warn!("Failed to open fifo: {e:?}");
})
.map(|r| BufReader::new(r))
else {
return false;
};
loop {
let mut buf = String::with_capacity(BUF_LEN);
match reader.read_line(&mut buf) {
Ok(0) => {
// EOF reached
break;
}
Ok(_) => {
let _ = sender.try_send(buf);
}
Err(e) => {
log::error!("Error reading fifo: {e:?}");
break;
}
}
}
true
}
});
Self { receiver, handle }
}
pub fn get_last_line(&mut self) -> Option<String> {
self.receiver.try_iter().last()
}
pub fn is_finished(&self) -> bool {
self.handle.is_finished()
}
pub fn check_success(self) -> bool {
self.handle.join().unwrap_or(false)
}
}

View File

@@ -1,17 +1,7 @@
use std::{
cell::RefCell,
fs,
os::unix::fs::FileTypeExt,
process::{Command, Stdio},
rc::Rc,
sync::Arc,
time::{Duration, Instant},
};
use std::rc::Rc;
use anyhow::Context;
use chrono::Local;
use chrono_tz::Tz;
use interprocess::os::unix::fifo_file::create_fifo;
use wgui::{
drawing,
event::{self, EventCallback},
@@ -21,9 +11,7 @@ use wgui::{
widget::{EventResult, label::WidgetLabel},
};
use crate::{gui::panel::helper::PipeReaderThread, state::AppState};
use super::helper::expand_env_vars;
use crate::state::AppState;
#[allow(clippy::too_many_lines)]
pub(super) fn setup_custom_label<S: 'static>(
@@ -37,42 +25,6 @@ pub(super) fn setup_custom_label<S: 'static>(
};
let callback: EventCallback<AppState, S> = match source {
"shell" => {
let Some(exec) = attribs.get_value("_exec") else {
log::warn!("label with shell source but no exec attribute!");
return;
};
let state = ShellLabelState {
exec: exec.to_string(),
mut_state: RefCell::new(PipeLabelMutableState {
reader: None,
next_try: Instant::now(),
}),
carry_over: RefCell::new(None),
};
Box::new(move |common, data, _, _| {
let _ = shell_on_tick(&state, common, data).inspect_err(|e| log::error!("{e:?}"));
Ok(EventResult::Pass)
})
}
"fifo" => {
let Some(path) = attribs.get_value("_path") else {
log::warn!("label with fifo source but no path attribute!");
return;
};
let state = FifoLabelState {
path: expand_env_vars(path).into(),
carry_over: RefCell::new(None),
mut_state: RefCell::new(PipeLabelMutableState {
reader: None,
next_try: Instant::now(),
}),
};
Box::new(move |common, data, _, _| {
fifo_on_tick(&state, common, data);
Ok(EventResult::Pass)
})
}
"battery" => {
let Some(device) = attribs
.get_value("_device")
@@ -186,126 +138,6 @@ pub(super) fn setup_custom_label<S: 'static>(
);
}
struct PipeLabelMutableState {
reader: Option<PipeReaderThread>,
next_try: Instant,
}
struct ShellLabelState {
exec: String,
mut_state: RefCell<PipeLabelMutableState>,
carry_over: RefCell<Option<String>>,
}
fn shell_on_tick(
state: &ShellLabelState,
common: &mut event::CallbackDataCommon,
data: &mut event::CallbackData,
) -> anyhow::Result<()> {
let mut mut_state = state.mut_state.borrow_mut();
if let Some(reader) = mut_state.reader.as_mut() {
if let Some(text) = reader.get_last_line() {
let label = data.obj.get_as_mut::<WidgetLabel>().unwrap();
label.set_text(common, Translation::from_raw_text(&text));
}
if reader.is_finished() && !mut_state.reader.take().unwrap().check_success() {
mut_state.next_try = Instant::now() + Duration::from_secs(15);
}
return Ok(());
} else if mut_state.next_try > Instant::now() {
return Ok(());
}
let child = Command::new("sh")
.arg("-c")
.arg(&state.exec)
.stdout(Stdio::piped())
.spawn()
.with_context(|| format!("Failed to run shell script: '{}'", &state.exec))?;
mut_state.reader = Some(PipeReaderThread::new_from_child(child));
Ok(())
}
struct FifoLabelState {
path: Arc<str>,
mut_state: RefCell<PipeLabelMutableState>,
carry_over: RefCell<Option<String>>,
}
impl FifoLabelState {
fn try_remove_fifo(&self) -> anyhow::Result<()> {
let meta = match fs::metadata(&*self.path) {
Ok(meta) => meta,
Err(e) => {
if fs::exists(&*self.path).unwrap_or(true) {
anyhow::bail!("Could not stat existing file at {}: {e:?}", &self.path);
}
return Ok(());
}
};
if !meta.file_type().is_fifo() {
anyhow::bail!("Existing file at {} is not a FIFO", &self.path);
}
if let Err(e) = fs::remove_file(&*self.path) {
anyhow::bail!("Unable to remove existing FIFO at {}: {e:?}", &self.path);
}
Ok(())
}
}
impl Drop for FifoLabelState {
fn drop(&mut self) {
if let Err(e) = self.try_remove_fifo() {
log::debug!("{e:?}");
}
}
}
fn fifo_on_tick(
state: &FifoLabelState,
common: &mut event::CallbackDataCommon,
data: &mut event::CallbackData,
) {
let mut mut_state = state.mut_state.borrow_mut();
let Some(reader) = mut_state.reader.as_mut() else {
if mut_state.next_try > Instant::now() {
return;
}
if let Err(e) = state.try_remove_fifo() {
mut_state.next_try = Instant::now() + Duration::from_secs(15);
log::warn!("Requested FIFO path is taken: {e:?}");
return;
}
if let Err(e) = create_fifo(&*state.path, 0o777) {
mut_state.next_try = Instant::now() + Duration::from_secs(15);
log::warn!("Failed to create FIFO: {e:?}");
return;
}
mut_state.reader = Some(PipeReaderThread::new_from_fifo(state.path.clone()));
return;
};
if let Some(text) = reader.get_last_line() {
let label = data.obj.get_as_mut::<WidgetLabel>().unwrap();
label.set_text(common, Translation::from_raw_text(&text));
}
if reader.is_finished() && !mut_state.reader.take().unwrap().check_success() {
mut_state.next_try = Instant::now() + Duration::from_secs(15);
}
}
const BAT_LOW: drawing::Color = drawing::Color::new(0.69, 0.38, 0.38, 1.);
const BAT_NORMAL: drawing::Color = drawing::Color::new(0.55, 0.84, 0.79, 1.);
const BAT_CHARGING: drawing::Color = drawing::Color::new(0.38, 0.50, 0.62, 1.);

View File

@@ -33,7 +33,6 @@ use crate::{
use super::timer::GuiTimer;
pub mod button;
mod helper;
mod label;
const DEFAULT_MAX_SIZE: f32 = 2048.0;

View File

@@ -1,15 +1,31 @@
use std::{sync::Arc, time::Duration};
use anyhow::Context;
use glam::{Affine3A, Quat, Vec3, vec3};
use wgui::{
components::button::ComponentButton,
event::{CallbackDataCommon, EventAlterables},
i18n::Translation,
parser::{Fetchable, parse_color_hex},
renderer_vk::text::custom_glyph::{CustomGlyphContent, CustomGlyphData},
taffy,
widget::{
image::WidgetImage, label::WidgetLabel, rectangle::WidgetRectangle, sprite::WidgetSprite,
},
};
use wlx_common::windowing::OverlayWindowState;
use crate::{
backend::task::ModifyPanelCommand,
gui::{
panel::{GuiPanel, NewGuiPanelParams},
timer::GuiTimer,
},
state::AppState,
windowing::window::{OverlayCategory, OverlayWindowConfig},
windowing::{
backend::OverlayEventData,
window::{OverlayCategory, OverlayWindowConfig},
},
};
struct CustomPanelState {}
@@ -36,6 +52,21 @@ pub fn create_custom(app: &mut AppState, name: Arc<str>) -> Option<OverlayWindow
let scale = panel.layout.content_size.x / 40.0 * 0.05;
panel.on_notify = Some(Box::new({
let name = name.clone();
move |panel, app, event_data| {
let OverlayEventData::CustomCommand { element, command } = event_data else {
return Ok(());
};
if let Err(e) = apply_custom_command(panel, app, &element, &command) {
log::warn!("Could not apply {command:?} on {name}/{element}: {e:?}");
};
Ok(())
}
}));
Some(OverlayWindowConfig {
name,
category: OverlayCategory::Panel,
@@ -52,3 +83,103 @@ pub fn create_custom(app: &mut AppState, name: Arc<str>) -> Option<OverlayWindow
..OverlayWindowConfig::from_backend(Box::new(panel))
})
}
fn apply_custom_command(
panel: &mut GuiPanel<CustomPanelState>,
app: &mut AppState,
element: &str,
command: &ModifyPanelCommand,
) -> anyhow::Result<()> {
let mut alterables = EventAlterables::default();
let mut com = CallbackDataCommon {
alterables: &mut alterables,
state: &panel.layout.state,
};
match command {
ModifyPanelCommand::SetText(text) => {
if let Ok(mut label) = panel
.parser_state
.fetch_widget_as::<WidgetLabel>(&panel.layout.state, element)
{
label.set_text(&mut com, Translation::from_raw_text(text));
} else if let Ok(button) = panel
.parser_state
.fetch_component_as::<ComponentButton>(&element)
{
button.set_text(&mut com, Translation::from_raw_text(text));
} else {
anyhow::bail!("No <label> or <Button> with such id.");
}
}
ModifyPanelCommand::SetImage(path) => {
if let Ok(pair) = panel
.parser_state
.fetch_widget(&panel.layout.state, element)
{
let content = CustomGlyphContent::from_assets(
&mut app.wgui_globals,
wgui::assets::AssetPath::File(&path),
)
.context("Could not load content from supplied path.")?;
let data = CustomGlyphData::new(content);
if let Some(mut sprite) = pair.widget.get_as_mut::<WidgetSprite>() {
sprite.set_content(&mut com, Some(data));
} else if let Some(mut image) = pair.widget.get_as_mut::<WidgetImage>() {
image.set_content(&mut com, Some(data));
} else {
anyhow::bail!("No <sprite> or <image> with such id.");
}
} else {
anyhow::bail!("No <sprite> or <image> with such id.");
}
}
ModifyPanelCommand::SetColor(color) => {
let color = parse_color_hex(&color)
.context("Invalid color format, must be a html hex color!")?;
if let Ok(pair) = panel
.parser_state
.fetch_widget(&panel.layout.state, element)
{
if let Some(mut rect) = pair.widget.get_as_mut::<WidgetRectangle>() {
rect.set_color(&mut com, color);
} else if let Some(mut label) = pair.widget.get_as_mut::<WidgetLabel>() {
label.set_color(&mut com, color, true);
} else if let Some(mut sprite) = pair.widget.get_as_mut::<WidgetSprite>() {
sprite.set_color(&mut com, color);
} else {
anyhow::bail!("No <rectangle> or <label> or <sprite> with such id.");
}
} else {
anyhow::bail!("No <rectangle> or <label> or <sprite> with such id.");
}
}
ModifyPanelCommand::SetVisible(visible) => {
let wid = panel
.parser_state
.get_widget_id(&element)
.context("No widget with such id.")?;
let display = if *visible {
taffy::Display::Flex
} else {
taffy::Display::None
};
com.alterables
.set_style(wid, wgui::event::StyleSetRequest::Display(display));
}
ModifyPanelCommand::SetStickyState(sticky_down) => {
let button = panel
.parser_state
.fetch_component_as::<ComponentButton>(element)
.context("No <Button> with such id.")?;
button.set_sticky_state(&mut com, *sticky_down);
}
}
panel.layout.process_alterables(alterables)?;
Ok(())
}

View File

@@ -107,7 +107,7 @@ where
.widgets
.get_as::<WidgetSprite>(self.top_sprite_id)
{
sprite.set_content(Some(new.sprite.clone()));
sprite.set_content(common, Some(new.sprite.clone()));
}
}

View File

@@ -473,7 +473,7 @@ pub fn create_watch(app: &mut AppState) -> anyhow::Result<OverlayWindowConfig> {
.get_as::<WidgetSprite>(btn.sprite)
&& let Some(glyph) = panel.state.overlay_cat_icons.get(meta.category)
{
sprite.set_content(Some(glyph.clone()));
sprite.set_content(&mut com, Some(glyph.clone()));
}
btn.button.set_sticky_state(&mut com, meta.visible);
@@ -525,7 +525,7 @@ pub fn create_watch(app: &mut AppState) -> anyhow::Result<OverlayWindowConfig> {
&& let Some(glyph) = panel.state.device_role_icons.get(dev.role)
&& let Some(mut s) = panel.layout.state.widgets.get_as::<WidgetSprite>(*s)
{
s.set_content(Some(glyph.clone()));
s.set_content(&mut com, Some(glyph.clone()));
com.alterables
.set_style(*div, StyleSetRequest::Display(taffy::Display::Flex));
} else {

View File

@@ -461,6 +461,10 @@ where
wayvr::WayVRSignal::Haptics(haptics) => {
wayvr.pending_haptics = Some(haptics);
}
wayvr::WayVRSignal::CustomTask(custom_task) => {
app.tasks
.enqueue(TaskType::Overlay(OverlayTask::ModifyPanel(custom_task)));
}
}
}

View File

@@ -12,7 +12,10 @@ use wlx_common::{
};
use crate::{
backend::input::{HoverResult, PointerHit},
backend::{
input::{HoverResult, PointerHit},
task::ModifyPanelCommand,
},
graphics::{ExtentExt, RenderResult},
state::AppState,
subsystem::hid::WheelDelta,
@@ -123,6 +126,10 @@ pub enum OverlayEventData {
pos: Positioning,
editing: bool,
},
CustomCommand {
element: String,
command: ModifyPanelCommand,
},
}
pub trait OverlayBackend: Any {

View File

@@ -290,6 +290,27 @@ where
self.dropped_overlays.push_back(o);
}
}
OverlayTask::ModifyPanel(task) => {
if let Some(oid) = self.lookup(&task.overlay)
&& let Some(o) = self.mut_by_id(oid)
{
if !matches!(o.config.category, OverlayCategory::Panel) {
log::warn!(
"Received command for '{}', but this overlay does not support commands",
&task.overlay
);
return Ok(());
}
o.config.backend.notify(
app,
OverlayEventData::CustomCommand {
element: task.element,
command: task.command,
},
)?;
}
}
}
Ok(())
}