Compare commits

12 Commits
main ... dev

Author SHA1 Message Date
Schmarni
f8f068ab90 chore: update deps
Signed-off-by: Schmarni <marnistromer@gmail.com>
2025-10-31 08:28:03 +01:00
Schmarni
6bd56061d7 chore: bump deps
Signed-off-by: Schmarni <marnistromer@gmail.com>
2025-10-26 01:33:52 +02:00
Schmarni
795a97b35e chore: update stardust crates
Signed-off-by: Schmarni <marnistromer@gmail.com>
2025-10-20 19:56:53 +02:00
Nova
91f0f32acc update: asteroids version 2025-09-17 23:05:15 -07:00
Schmarni
9422eec188 chore: use the new asteroids name
Signed-off-by: Schmarni <marnistromer@gmail.com>
2025-09-16 04:47:03 +02:00
Schmarni
daec8ce722 feat(hexagon_launcher): improve text placement
Signed-off-by: Schmarni <marnistromer@gmail.com>
2025-09-06 04:14:46 +02:00
Schmarni
524974f269 fix(hexagon_launcher/text): rotate the text to be oriented correctly
Signed-off-by: Schmarni <marnistromer@gmail.com>
2025-09-06 03:51:18 +02:00
Schmarni
4a35234abe fix(hexagon_launcher/text): fix text alignment and set bounding size to a more reasonable value
Signed-off-by: Schmarni <marnistromer@gmail.com>
2025-09-06 03:44:44 +02:00
Nova
b03a345bf3 refactor: make everything asteroids
fix: hexagon spiral

upgrade: asteroids for hotpatch

refactor: better icon handling

feat: cache icons

fix(hexagon): refinement

fix(hexagon): field transform

refactor: broken mess ahaha

fix(single): it woooorks!!!

fix: hexagon launcher!!!

refactor(hexagon_launcher): skip instrumentation dbg

fix: sirius

refactor: make it FAST on asteroids
2025-08-31 21:46:43 -07:00
Schmarni
f5008ff5b5 refactor: update everything to latest fusion and to rust edition 2024 (#7)
Signed-off-by: Schmarni <marnistromer@gmail.com>
2025-04-07 13:59:41 -07:00
Cyberneticmelon
e28a8e8e1e Updated documentation 2025-04-03 17:40:37 -04:00
Nova
c5c4840452 feat: update to latest fusion 2025-02-21 14:13:32 -08:00
20 changed files with 2934 additions and 1972 deletions

2733
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
[workspace]
members = ["app_grid", "hexagon_launcher", "protostar", "single", "sirius"]
resolver = "2"
members = ["hexagon_launcher", "protostar", "single", "sirius"]
[workspace.dependencies]
tokio = { version = "1.32.0", features = ["rt", "tokio-macros", "sync"] }
serde = "1.0.197"
tokio = { version = "1.47.1", features = ["rt", "tokio-macros", "sync"] }
serde = "1.0.219"
[workspace.dependencies.stardust-xr-fusion]
git = "https://github.com/StardustXR/core.git"
@@ -12,3 +13,22 @@ branch = "dev"
[workspace.dependencies.stardust-xr-molecules]
git = "https://github.com/StardustXR/molecules.git"
branch = "dev"
[workspace.dependencies.stardust-xr-asteroids]
git = "https://github.com/StardustXR/asteroids.git"
branch = "dev"
[profile]
[profile.dev]
opt-level = 3
[profile.wasm-dev]
inherits = "dev"
opt-level = 1
[profile.server-dev]
inherits = "dev"
[profile.android-dev]
inherits = "dev"

View File

@@ -1,11 +1,24 @@
# protostar
A collection of application launchers for Stardust XR
> [!IMPORTANT]
> Requires the [Stardust XR Server](https://github.com/StardustXR/server) to be running. For launching 2D applications, [Flatland](https://github.com/StardustXR/flatland) also needs to be running.
Prototype application launcher for Stardust XR
If you installed the Stardust XR server via:
```note
sudo dnf group install stardust-xr
```
Or if you installed via the [installation script](https://github.com/cyberneticmelon/usefulscripts/blob/main/stardustxr_setup.sh), Protostar comes pre-installed
Protostar provides an easy to use crate to write applications launchers. See the [examples](examples) to learn more!
# How to Use
Protostar itself can be used to build various kinds of app launchers, but two are built in. Most likely the one you will want to use will be `hexagon_launcher`. After launching, in flastcreen mode drag applications out of the app launcher, hold down `Shift + ~`
![updated_drag](https://github.com/StardustXR/website/blob/main/static/img/updated_flat_drag.GIF)
**Quest 3 Hand tracking**:
Pinch to drag and drop, grasp with full hand for grabbing, point and click with pointer finger to click or pinch from a distance
# TODO:
![hand_pinching](https://github.com/StardustXR/website/blob/main/static/img/hand_pinching.GIF)
1. Implement our own (more reliable) way to get icons
2. Untangle the code (eg: make it so that the user can decide sizes, models, etc... )
3. Implement [configuration files](https://docs.rs/confy/latest/confy/) in the examples
## Manual Installation
Clone the repository and after the server is running:
```sh
cargo run
```

View File

@@ -1,16 +1,16 @@
[package]
name = "app_grid"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
tokio = { version = "1.32.0", features = ["rt", "tokio-macros", "sync"] }
tokio = { version = "1.47.1", features = ["rt", "tokio-macros", "sync"] }
protostar = { path = "../protostar" }
color-eyre = "0.6.2"
clap = "4.4.6"
color-eyre = "0.6.5"
clap = "4.5.46"
manifest-dir-macros = "0.1.18"
glam = "0.24.2"
tween = "2.0.1"
tracing-subscriber = "0.3.17"
tween = "2.1.0"
tracing-subscriber = "0.3.20"
stardust-xr-fusion = { workspace = true }
stardust-xr-molecules = { workspace = true }

View File

@@ -3,21 +3,21 @@ use glam::{Quat, Vec3};
use manifest_dir_macros::directory_relative_path;
use protostar::{
application::Application,
xdg::{get_desktop_files, parse_desktop_file, DesktopFile, Icon, IconType},
xdg::{DesktopFile, Icon, IconType, get_desktop_files, parse_desktop_file},
};
use stardust_xr_fusion::{
ClientHandle,
client::Client,
core::values::{color::rgba_linear, ResourceID, Vector3},
core::values::{ResourceID, Vector3, color::rgba_linear},
drawable::{
MaterialParameter, Model, ModelPartAspect, Text, TextBounds, TextFit, TextStyle, XAlign,
YAlign,
},
fields::{Field, Shape},
node::NodeType,
root::{ClientState, FrameInfo, RootAspect, RootHandler},
spatial::{Spatial, SpatialAspect, SpatialRefAspect, Transform},
root::{ClientState, FrameInfo, RootAspect},
spatial::{Spatial, SpatialAspect, SpatialRef, SpatialRefAspect, Transform},
};
use stardust_xr_molecules::{Grabbable, GrabbableSettings};
use stardust_xr_molecules::{FrameSensitive, Grabbable, GrabbableSettings, UIElement};
use std::f32::consts::PI;
const APP_LIMIT: usize = 300;
@@ -32,16 +32,34 @@ async fn main() -> Result<()> {
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.pretty()
.init();
let (client, event_loop) = Client::connect_with_async_loop().await?;
let owned_client = Client::connect().await?;
let client = owned_client.handle();
let async_loop = owned_client.async_event_loop();
client
.set_base_prefixes(&[directory_relative_path!("../res")])
.get_root()
.set_base_prefixes(&[directory_relative_path!("../res").to_string()])
.unwrap();
let _root = client.get_root().alias().wrap(AppGrid::new(&client))?;
let mut grid = AppGrid::new(&client);
let mut owned_client = async_loop.stop().await.unwrap();
let event_loop = owned_client.sync_event_loop(|handle, _| {
let Some(event) = handle.get_root().recv_root_event() else {
return;
};
match event {
stardust_xr_fusion::root::RootEvent::Ping { response } => response.send(Ok(())),
stardust_xr_fusion::root::RootEvent::Frame { info } => {
grid.frame(info);
}
stardust_xr_fusion::root::RootEvent::SaveState { response } => {
response.send(grid.save_state());
}
}
});
tokio::select! {
_ = tokio::signal::ctrl_c() => (),
e = event_loop => e??,
e = event_loop => e?,
};
Ok(())
}
@@ -51,7 +69,7 @@ struct AppGrid {
//style: TextStyle,
}
impl AppGrid {
fn new(client: &Client) -> Self {
fn new(client: &ClientHandle) -> Self {
let apps = get_desktop_files()
.filter_map(|d| parse_desktop_file(d).ok())
.filter(|d| !d.no_display)
@@ -74,7 +92,7 @@ impl AppGrid {
AppGrid { apps }
}
}
impl RootHandler for AppGrid {
impl AppGrid {
fn frame(&mut self, info: FrameInfo) {
for app in &mut self.apps {
app.frame(&info);
@@ -85,7 +103,7 @@ impl RootHandler for AppGrid {
}
}
fn model_from_icon(parent: &Spatial, icon: &Icon) -> Result<Model> {
fn model_from_icon(parent: &SpatialRef, icon: &Icon) -> Result<Model> {
match &icon.icon_type {
IconType::Png => {
// let t = Transform::from_rotation_scale(
@@ -127,7 +145,7 @@ pub struct App {
}
impl App {
pub fn create_from_desktop_file(
parent: &impl SpatialRefAspect,
parent: &SpatialRef,
position: impl Into<Vector3<f32>>,
desktop_file: DesktopFile,
) -> Result<Self> {
@@ -145,12 +163,12 @@ impl App {
},
)?;
grabbable.content_parent().set_spatial_parent(parent)?;
field.set_spatial_parent(grabbable.content_parent())?;
field.set_spatial_parent(&grabbable.content_parent())?;
let icon = icon
.map(|i| model_from_icon(grabbable.content_parent(), &i))
.map(|i| model_from_icon(&grabbable.content_parent(), &i))
.unwrap_or_else(|| {
Ok(Model::create(
grabbable.content_parent(),
&grabbable.content_parent(),
Transform::from_rotation(Quat::from_rotation_y(PI)),
&ResourceID::new_namespaced("protostar", "cartridge"),
)?)
@@ -198,7 +216,10 @@ impl App {
// }
fn frame(&mut self, info: &FrameInfo) {
let _ = self.grabbable.update(info);
if !self.grabbable.handle_events() {
return;
}
self.grabbable.frame(info);
if self.grabbable.grab_action().actor_stopped() {
self.grabbable.cancel_angular_velocity();
@@ -210,8 +231,8 @@ impl App {
// }
let application = self.application.clone();
let space = self.content_parent().alias();
let root = self.root.alias();
let space = self.content_parent().clone();
let root = self.root.clone();
tokio::task::spawn(async move {
let Ok(transform) = space.get_transform(&root).await else {

View File

@@ -1,7 +1,10 @@
[package]
name = "hexagon_launcher"
version = "0.1.0"
edition = "2021"
edition = "2024"
[features]
tracy = ["dep:tracing-tracy", "stardust-xr-asteroids/tracy"]
[dependencies]
protostar = { path = "../protostar" }
@@ -9,9 +12,19 @@ color-eyre = "0.6.2"
clap = "4.4.6"
manifest-dir-macros = "0.1.18"
glam = { version = "0.25.0", features = ["mint"] }
tween = "2.0.1"
tracing-subscriber = "0.3.17"
mint = "0.5.9"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
tracing-tracy = { version = "0.11.4", default-features = false, features = [
"enable",
"flush-on-exit",
"only-localhost",
"sampling",
"ondemand",
], optional = true }
tokio = { workspace = true }
serde = { workspace = true }
stardust-xr-fusion = { workspace = true }
stardust-xr-molecules = { workspace = true }
stardust-xr-asteroids = { workspace = true }
tracing = "0.1.41"
single = { path = "../single" }
rayon = "1.11.0"

View File

@@ -1,299 +0,0 @@
use color_eyre::eyre::Result;
use glam::{EulerRot, Quat, Vec3};
use protostar::{
application::Application,
xdg::{DesktopFile, Icon, IconType},
};
use stardust_xr_fusion::{
core::values::{ResourceID, Vector3},
drawable::{
MaterialParameter, Model, ModelPartAspect, Text, TextBounds, TextFit, TextStyle, XAlign,
YAlign,
},
fields::{CylinderShape, Field, Shape},
node::NodeType,
root::FrameInfo,
spatial::{Spatial, SpatialAspect, SpatialRefAspect, Transform},
};
use stardust_xr_molecules::{Grabbable, GrabbableSettings};
use std::f32::consts::PI;
use tween::{QuartInOut, Tweener};
use crate::{State, ACTIVATION_DISTANCE, APP_SIZE, DEFAULT_HEX_COLOR};
// Model handling
fn model_from_icon(parent: &Spatial, icon: &Icon) -> Result<Model> {
match &icon.icon_type {
IconType::Png => {
let t = Transform::from_rotation_scale(
Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI),
[APP_SIZE / 2.0; 3],
);
let model = Model::create(
parent,
t,
&ResourceID::new_namespaced("protostar", "hexagon/hexagon"),
)?;
model
.part("Hex")?
.set_material_parameter("color", MaterialParameter::Color(DEFAULT_HEX_COLOR))?;
model.part("Icon")?.set_material_parameter(
"diffuse",
MaterialParameter::Texture(ResourceID::Direct(icon.path.clone())),
)?;
Ok(model)
}
IconType::Gltf => Ok(Model::create(
parent,
Transform::from_scale([0.05; 3]),
&ResourceID::new_direct(icon.path.clone())?,
)?),
_ => panic!("Invalid Icon Type"),
}
}
pub struct App {
application: Application,
parent: Spatial,
position: Vector3<f32>,
grabbable: Grabbable,
_field: Field,
// field_lines: Lines,
icon: Model,
label: Option<Text>,
grabbable_shrink: Option<Tweener<f32, f64, QuartInOut>>,
grabbable_grow: Option<Tweener<f32, f64, QuartInOut>>,
grabbable_move: Option<Tweener<f32, f64, QuartInOut>>,
}
impl App {
pub fn create_from_desktop_file(
parent: &Spatial,
position: impl Into<Vector3<f32>>,
desktop_file: DesktopFile,
state: &State,
) -> Result<Self> {
let position = position.into();
let field = Field::create(
parent,
Transform::identity(),
Shape::Cylinder(CylinderShape {
length: 0.01,
radius: APP_SIZE / 2.0,
}),
)?;
// let circle = circle(32, 0.0, APP_SIZE / 2.0).thickness(0.001);
// let field_lines = Lines::create(
// &field,
// Transform::identity(),
// &[
// circle
// .clone()
// .transform(Mat4::from_translation([0.0, 0.0, 0.005].into())),
// circle
// .clone()
// .transform(Mat4::from_translation([0.0, 0.0, -0.005].into())),
// ],
// )?;
let application = Application::create(desktop_file)?;
let icon = application.icon(128, false);
let grabbable = Grabbable::create(
parent,
Transform::from_translation(position),
&field,
GrabbableSettings {
max_distance: 0.05,
zoneable: false,
..Default::default()
},
)?;
if !state.unfurled {
grabbable.set_enabled(false)?;
}
grabbable.content_parent().set_spatial_parent(parent)?;
field.set_spatial_parent(grabbable.content_parent())?;
let icon = icon
.map(|i| model_from_icon(grabbable.content_parent(), &i))
.unwrap_or_else(|| {
Ok(Model::create(
grabbable.content_parent(),
Transform::from_rotation_scale(
Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI),
[APP_SIZE * 0.5; 3],
),
&ResourceID::new_namespaced("protostar", "hexagon/hexagon"),
)?)
})?;
if !state.unfurled {
icon.set_enabled(false)?;
}
let label_style = TextStyle {
character_height: APP_SIZE * 2.0,
bounds: Some(TextBounds {
bounds: [1.0; 2].into(),
fit: TextFit::Wrap,
anchor_align_x: XAlign::Center,
anchor_align_y: YAlign::Center,
}),
text_align_x: XAlign::Center,
text_align_y: YAlign::Center,
..Default::default()
};
let label = application.name().and_then(|name| {
Text::create(
&icon,
Transform::from_translation_rotation(
[0.0, 0.1, -(APP_SIZE * 4.0)],
Quat::from_rotation_x(PI * 0.5),
),
name,
label_style,
)
.ok()
});
if !state.unfurled {
if let Some(label) = label.as_ref() {
label.set_enabled(false)?;
}
}
Ok(App {
parent: parent.alias(),
position,
grabbable,
_field: field,
// field_lines,
label,
application,
icon,
grabbable_shrink: None,
grabbable_grow: None,
grabbable_move: None,
})
}
pub fn content_parent(&self) -> &Spatial {
self.grabbable.content_parent()
}
pub fn apply_state(&mut self, state: &State) {
self.grabbable.set_enabled(state.unfurled).unwrap();
if state.unfurled {
self.icon.set_enabled(true).unwrap();
if let Some(label) = self.label.as_ref() {
label.set_enabled(true).unwrap()
}
self.grabbable_move = Some(Tweener::quart_in_out(0.0001, 1.0, 0.25));
} else {
self.grabbable_move = Some(Tweener::quart_in_out(1.0, 0.0001, 0.25)); //TODO make the scale a parameter
}
}
pub fn frame(&mut self, info: &FrameInfo, state: &State) {
let _ = self.grabbable.update(info);
if let Some(grabbable_move) = &mut self.grabbable_move {
if !grabbable_move.is_finished() {
let scale = grabbable_move.move_by(info.delta.into());
self.grabbable
.content_parent()
.set_relative_transform(
&self.parent,
Transform::from_translation(Vec3::from(self.position) * scale),
)
.unwrap();
} else {
if grabbable_move.final_value() == 0.0001 {
self.icon.set_enabled(false).unwrap();
if let Some(label) = self.label.as_ref() {
label.set_enabled(false).unwrap()
}
}
self.grabbable_move = None;
}
}
if let Some(grabbable_shrink) = &mut self.grabbable_shrink {
if !grabbable_shrink.is_finished() {
let scale = grabbable_shrink.move_by(info.delta.into());
self.grabbable
.content_parent()
.set_relative_transform(&self.parent, Transform::from_scale([scale; 3]))
.unwrap();
} else {
self.grabbable
.content_parent()
.set_spatial_parent(&self.parent)
.unwrap();
if state.unfurled {
self.grabbable_grow = Some(Tweener::quart_in_out(0.0001, 1.0, 0.25));
self.grabbable.cancel_angular_velocity();
self.grabbable.cancel_linear_velocity();
}
self.grabbable_shrink = None;
self.grabbable
.content_parent()
.set_relative_transform(
&self.parent,
Transform::from_translation(self.position),
)
.unwrap();
self.grabbable
.content_parent()
.set_relative_transform(&self.parent, Transform::from_rotation(Quat::default()))
.unwrap();
self.icon
.set_local_transform(Transform::from_rotation(
Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI),
))
.unwrap();
}
} else if let Some(grabbable_grow) = &mut self.grabbable_grow {
if !grabbable_grow.is_finished() {
let scale = grabbable_grow.move_by(info.delta.into());
self.grabbable
.content_parent()
.set_relative_transform(&self.parent, Transform::from_scale([scale; 3]))
.unwrap();
} else {
self.grabbable
.content_parent()
.set_spatial_parent(&self.parent)
.unwrap();
self.grabbable_grow = None;
}
} else if self.grabbable.grab_action().actor_stopped() {
self.grabbable_shrink = Some(Tweener::quart_in_out(APP_SIZE * 0.5, 0.0001, 0.25));
let application = self.application.clone();
let space = self.content_parent().alias();
let parent = self.parent.alias();
//TODO: split the executable string for the args
tokio::task::spawn(async move {
let distance_vector = space
.get_transform(&parent)
.await
.unwrap()
.translation
.unwrap();
let distance = Vec3::from(distance_vector).length_squared();
if distance > ACTIVATION_DISTANCE {
let client = space.node().client().unwrap();
let space_rot = space
.get_transform(client.get_root())
.await
.unwrap()
.rotation
.unwrap();
let (_, y_rot, _) = Quat::from(space_rot).to_euler(EulerRot::XYZ);
let _ = space.set_relative_transform(
client.get_root(),
Transform::from_rotation_scale(Quat::from_rotation_y(y_rot), [1.0; 3]),
);
let _ = application.launch(&space);
}
});
}
}
}

View File

@@ -1,7 +1,8 @@
#![allow(dead_code)]
use std::ops::Add;
use crate::{APP_SIZE, PADDING};
use tween::TweenTime;
use single::{APP_SIZE, PADDING};
#[derive(Clone, Copy, Debug, Default)]
pub struct Hex {
@@ -26,9 +27,9 @@ impl Hex {
}
pub fn get_coords(&self) -> [f32; 3] {
let x = 3.0 / 2.0 * (APP_SIZE + PADDING) / 2.0 * (-self.q - self.s).to_f32();
let x = 3.0 / 2.0 * (APP_SIZE + PADDING) / 2.0 * (-self.q - self.s) as f32;
let y = 3.0_f32.sqrt() * (APP_SIZE + PADDING) / 2.0
* ((-self.q - self.s).to_f32() / 2.0 + self.s.to_f32());
* ((-self.q - self.s) as f32 / 2.0 + self.s as f32);
[x, y, 0.0]
}
@@ -39,6 +40,42 @@ impl Hex {
pub fn scale(self, factor: isize) -> Self {
Hex::new(self.q * factor, self.r * factor, self.s * factor)
}
/// outputs a hexagon at an outward spiral at position i, where i=0 is the center.
pub fn spiral(i: usize) -> Self {
if i == 0 {
return HEX_CENTER;
}
// Find which ring we're in and position within ring
let mut cells_before = 1; // Count center
let mut radius = 1;
while cells_before + (radius * 6) <= i {
cells_before += radius * 6;
radius += 1;
}
// Calculate steps needed within current ring
let pos_in_ring = i - cells_before;
// Start at top of ring (same as original code)
let mut hex = HEX_CENTER + HEX_DIRECTION_VECTORS[4].scale(radius as isize);
// Walk around sides just like original code
let mut steps_taken = 0;
for side in 0..6 {
for _ in 0..radius {
if steps_taken == pos_in_ring {
return hex;
}
hex = hex.neighbor(side);
steps_taken += 1;
}
}
hex
}
}
impl Add for Hex {
type Output = Hex;

View File

@@ -1,230 +1,149 @@
pub mod app;
pub mod hex;
mod hex;
use app::App;
use color_eyre::eyre::Result;
use glam::Quat;
use hex::{HEX_CENTER, HEX_DIRECTION_VECTORS};
use manifest_dir_macros::directory_relative_path;
use protostar::xdg::{get_desktop_files, parse_desktop_file, DesktopFile};
use hex::Hex;
use mint::{Quaternion, Vector3};
use protostar::xdg::{DesktopFile, get_desktop_files};
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use serde::{Deserialize, Serialize};
use single::{APP_SIZE, App, BTN_COLOR, BTN_SELECTED_COLOR, MODEL_SCALE};
use stardust_xr_asteroids::{
ClientState, CustomElement, Element, Migrate, Reify, Transformable, client,
elements::{Button, Grabbable, Model, ModelPart, PointerMode, Spatial},
};
use stardust_xr_fusion::{
client::Client,
core::values::{
color::{color_space::LinearRgb, rgba_linear, Rgba},
ResourceID,
},
drawable::{MaterialParameter, Model, ModelPartAspect},
node::{NodeError, NodeType},
root::{ClientState, FrameInfo, RootAspect, RootHandler},
spatial::{Spatial, SpatialAspect, Transform},
drawable::MaterialParameter,
fields::{CylinderShape, Shape},
project_local_resources,
spatial::Transform,
};
use stardust_xr_molecules::{
button::{Button, ButtonSettings},
Grabbable, GrabbableSettings, PointerMode,
};
use std::{f32::consts::PI, sync::Arc, time::Duration};
const APP_SIZE: f32 = 0.06;
const PADDING: f32 = 0.005;
const MODEL_SCALE: f32 = 0.03;
const ACTIVATION_DISTANCE: f32 = 0.05;
const DEFAULT_HEX_COLOR: Rgba<f32, LinearRgb> = rgba_linear!(0.211, 0.937, 0.588, 1.0);
const BTN_SELECTED_COLOR: Rgba<f32, LinearRgb> = rgba_linear!(0.0, 1.0, 0.0, 1.0);
const BTN_COLOR: Rgba<f32, LinearRgb> = rgba_linear!(1.0, 1.0, 0.0, 1.0);
use std::f32::consts::{FRAC_PI_2, PI};
use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
async fn main() {
color_eyre::install().unwrap();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.pretty()
.init();
let (client, event_loop) = Client::connect_with_async_loop().await?;
client
.set_base_prefixes(&[directory_relative_path!("../res")])
.unwrap();
let _root = client
.get_root()
.alias()
.wrap(AppHexGrid::new(&client).await)?;
let registry = tracing_subscriber::registry();
#[cfg(feature = "tracy")]
let registry = registry.with({
use tracing_subscriber::Layer;
tracing_tracy::TracyLayer::new(tracing_tracy::DefaultConfig::default())
.with_filter(tracing::level_filters::LevelFilter::DEBUG)
});
let log_layer = tracing_subscriber::fmt::Layer::new()
.with_thread_names(true)
.with_ansi(true)
.with_line_number(true)
.with_filter(EnvFilter::from_default_env());
registry.with(log_layer).init();
tokio::select! {
_ = tokio::signal::ctrl_c() => (),
e = event_loop => e??,
}
Ok(())
client::run::<HexagonLauncher>(&[&project_local_resources!("../res")]).await
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct State {
unfurled: bool,
}
struct AppHexGrid {
movable_root: Spatial,
#[derive(Debug, Serialize, Deserialize)]
pub struct HexagonLauncher {
/// if the hexagon launcher is expanded
open: bool,
pos: Vector3<f32>,
rot: Quaternion<f32>,
#[serde(skip)]
/// position in the vector is mapped to hex coordinates
apps: Vec<App>,
button: CenterButton,
state: State,
}
impl AppHexGrid {
async fn new(client: &Arc<Client>) -> Self {
let state = client.get_state().data().unwrap_or_default();
let movable_root =
Spatial::create(client.get_root(), Transform::identity(), false).unwrap();
impl Default for HexagonLauncher {
fn default() -> Self {
Self {
open: false,
pos: [0.0; 3].into(),
rot: Quat::IDENTITY.into(),
apps: Vec::new(),
}
}
}
impl Migrate for HexagonLauncher {
type Old = Self;
}
let button = CenterButton::new(client, client.get_state()).unwrap();
tokio::time::sleep(Duration::from_millis(10)).await; // give it a bit of time to send the messages properly
impl ClientState for HexagonLauncher {
const APP_ID: &'static str = "org.protostar.hexagon_launcher";
let mut desktop_files: Vec<DesktopFile> = get_desktop_files()
.filter_map(|d| parse_desktop_file(d).ok())
fn initial_state_update(&mut self) {
// Load desktop files
self.apps = get_desktop_files()
.filter_map(|d| DesktopFile::parse(d).ok())
.filter(|d| !d.no_display)
.filter_map(|d| App::new(d).ok())
.collect();
desktop_files.sort_by_key(|d| d.clone().name.unwrap_or_default());
self.apps.par_iter().for_each(|app| {
app.load_icon();
});
let mut apps = Vec::new();
let mut radius = 1;
while !desktop_files.is_empty() {
let mut hex = HEX_CENTER + HEX_DIRECTION_VECTORS[4].scale(radius);
for i in 0..6 {
if desktop_files.is_empty() {
break;
};
for _ in 0..radius {
if desktop_files.is_empty() {
break;
};
apps.push(
App::create_from_desktop_file(
button.grabbable.content_parent(),
hex.get_coords(),
desktop_files.pop().unwrap(),
&state,
)
.unwrap(),
);
hex = hex.neighbor(i);
}
}
radius += 1;
}
AppHexGrid {
movable_root,
apps,
button,
state,
}
// Sort by name
self.apps
.sort_by_key(|app| app.app.name().unwrap_or_default().to_string());
}
}
impl RootHandler for AppHexGrid {
fn frame(&mut self, info: FrameInfo) {
self.button.frame(&info);
if self.button.button.pressed() {
self.button
.model
.part("Hex")
.unwrap()
.set_material_parameter("color", MaterialParameter::Color(BTN_SELECTED_COLOR))
.unwrap();
self.state.unfurled = !self.state.unfurled;
for app in &mut self.apps {
app.apply_state(&self.state);
}
} else if self.button.button.released() {
self.button
.model
.part("Hex")
.unwrap()
.set_material_parameter("color", MaterialParameter::Color(BTN_COLOR))
.unwrap();
}
for app in &mut self.apps {
app.frame(&info, &self.state);
}
}
fn save_state(&mut self) -> Result<ClientState> {
self.movable_root
.set_relative_transform(
self.button.grabbable.content_parent(),
Transform::from_translation([0.0; 3]),
)
.unwrap();
ClientState::new(
Some(self.state.clone()),
&self.movable_root,
[(
"content_parent".to_string(),
self.button.grabbable.content_parent(),
)]
.into_iter()
.collect(),
)
}
}
struct CenterButton {
button: Button,
grabbable: Grabbable,
model: Model,
}
impl CenterButton {
fn new(client: &Arc<Client>, state: &ClientState) -> Result<Self, NodeError> {
// (APP_SIZE + PADDING) / 2.0,
let button = Button::create(
client.get_root(),
Transform::identity(),
[(APP_SIZE + PADDING) / 2.0; 2],
ButtonSettings {
visuals: None,
..Default::default()
impl Reify for HexagonLauncher {
#[tracing::instrument(skip_all)]
fn reify(&self) -> impl Element<Self> {
// Build UI based on current state
Grabbable::new(
Shape::Cylinder(CylinderShape {
radius: APP_SIZE / 2.0,
length: 0.01,
}),
self.pos,
self.rot,
|state: &mut Self, pos, rot| {
state.pos = pos;
state.rot = rot;
},
)?;
let grabbable = Grabbable::create(
client.get_root(),
Transform::none(),
button.touch_plane().field(),
GrabbableSettings {
max_distance: 0.025,
pointer_mode: PointerMode::Align,
magnet: false,
..Default::default()
},
)?;
button
.touch_plane()
.root()
.set_spatial_parent(grabbable.content_parent())?;
let model = Model::create(
grabbable.content_parent(),
Transform::from_rotation_scale(
)
.field_transform(Transform::from_rotation(Quat::from_rotation_x(FRAC_PI_2)))
.pointer_mode(PointerMode::Align)
.reparentable(true)
.build()
.child(
Button::new(|state: &mut HexagonLauncher| {
state.open = !state.open;
})
.pos([0.0, 0.0, 0.005])
.size([APP_SIZE / 2.0; 2])
.build(),
)
.child(
Model::namespaced("protostar", "hexagon/hexagon")
.transform(Transform::from_rotation_scale(
Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI),
[MODEL_SCALE; 3],
),
&ResourceID::new_namespaced("protostar", "hexagon/hexagon"),
)?;
model
.part("Hex")?
.set_material_parameter("color", MaterialParameter::Color(BTN_COLOR))?;
if let Some(content_parent) = state.spatial_anchors(client).get("content_parent") {
grabbable
.content_parent()
.set_relative_transform(content_parent, Transform::identity())?;
}
Ok(CenterButton {
button,
grabbable,
model,
))
.part(ModelPart::new("Hex").mat_param(
"color",
MaterialParameter::Color(if self.open {
BTN_SELECTED_COLOR
} else {
BTN_COLOR
}),
))
.build(),
)
.children(
self.open
.then(|| {
self.apps.iter().enumerate().map(|(i, app)| {
Spatial::default()
.pos(Hex::spiral(i + 1).get_coords())
.build()
.child(app.reify_substate(move |state: &mut HexagonLauncher| {
state.apps.get_mut(i)
}))
})
}
fn frame(&mut self, info: &FrameInfo) {
let _ = self.grabbable.update(info);
self.button.update();
})
.into_iter()
.flatten(),
)
}
}

View File

@@ -1,7 +1,7 @@
[package]
name = "protostar"
version = "0.4.0"
edition = "2021"
edition = "2024"
[dependencies]
clap = { version = "4.1.3", features = ["derive"] }
@@ -17,7 +17,7 @@ lazy_static = "1.4.0"
linicon-theme = "1.2.0"
manifest-dir-macros = "0.1.16"
mint = "0.5.9"
nix = "0.27.1"
nix = { version = "0.27.1", features = ["process"] }
regex = "1.7.1"
resvg = "0.29.0"
rustc-hash = "1.1.0"

View File

@@ -1,6 +1,7 @@
use crate::xdg::{DesktopFile, Icon, IconType};
use nix::libc::setsid;
use nix::{libc::setsid, unistd::ForkResult};
use regex::Regex;
use serde::{Deserialize, Serialize};
use stardust_xr_fusion::{
node::{NodeError, NodeResult},
root::{ClientState, RootAspect},
@@ -8,10 +9,10 @@ use stardust_xr_fusion::{
};
use std::{
os::unix::process::CommandExt,
process::{Command, Stdio},
process::{Command, Stdio, exit},
};
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Application {
desktop_file: DesktopFile,
}
@@ -44,9 +45,9 @@ impl Application {
icon.and_then(|i| i.cached_process(preferred_px_size).ok())
}
pub fn launch(&self, launch_space: &impl SpatialRefAspect) -> NodeResult<()> {
let client = launch_space.node().client()?;
let launch_space = launch_space.alias();
pub fn launch<T: SpatialRefAspect + Clone>(&self, launch_space: &T) -> NodeResult<()> {
let client = launch_space.client().clone();
let launch_space = launch_space.clone();
let executable = self
.desktop_file
@@ -66,17 +67,24 @@ impl Application {
return;
};
for (k, v) in connection_env.into_iter() {
// this should be fine, probably?
unsafe {
std::env::set_var(k, v);
}
}
// this should be fine, probably?
unsafe {
std::env::set_var("STARDUST_STARTUP_TOKEN", startup_token);
}
// Strip/ignore field codes https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s07.html
let re = Regex::new(r"%[fFuUdDnNickvm]").unwrap();
let exec: std::borrow::Cow<'_, str> = re.replace_all(&executable, "");
unsafe {
Command::new("sh")
if let ForkResult::Child = nix::unistd::fork().expect("fork died???? how?????") {
let _ = Command::new("sh")
.arg("-c")
.arg(exec.to_string())
.stdin(Stdio::null())
@@ -88,6 +96,8 @@ impl Application {
})
.spawn()
.expect("Failed to start child process");
exit(0);
}
}
});

View File

@@ -95,12 +95,57 @@ pub fn get_desktop_files() -> impl Iterator<Item = PathBuf> {
#[test]
fn test_get_desktop_files() {
let desktop_files = get_desktop_files().collect::<Vec<_>>();
assert!(desktop_files
assert!(
desktop_files
.iter()
.any(|file| file.ends_with("com.belmoussaoui.ashpd.demo.desktop")));
.any(|file| file.ends_with("com.belmoussaoui.ashpd.demo.desktop"))
);
}
pub fn parse_desktop_file(path: PathBuf) -> Result<DesktopFile, String> {
#[test]
fn test_parse_desktop_file() {
// Create a temporary directory and a test desktop file
let dir = tempdir::TempDir::new("test").unwrap();
let file = dir.path().join("test.desktop");
let data = "[Desktop Entry]\nName=Test\nExec=test\nCategories=A;B;C\nIcon=test.png";
fs::write(&file, data).unwrap();
// Parse the test desktop file
let desktop_file = DesktopFile::parse(file).unwrap();
// Check the parsed values
assert_eq!(desktop_file.name, Some("Test".to_string()));
assert_eq!(desktop_file.command, Some("test".to_string()));
assert_eq!(
desktop_file.categories,
vec!["A".to_string(), "B".to_string(), "C".to_string()]
);
assert_eq!(desktop_file.icon, Some("test.png".to_string()));
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(into = "PathBuf", from = "PathBuf")]
pub struct DesktopFile {
path: PathBuf,
pub name: Option<String>,
pub command: Option<String>,
pub categories: Vec<String>,
pub icon: Option<String>,
pub no_display: bool,
}
impl From<DesktopFile> for PathBuf {
fn from(df: DesktopFile) -> Self {
df.path
}
}
impl From<PathBuf> for DesktopFile {
fn from(path: PathBuf) -> Self {
Self::parse(path).unwrap()
}
}
impl DesktopFile {
pub fn parse(path: PathBuf) -> Result<Self, String> {
// Open the file in read-only mode
let file = match fs::File::open(
env::current_dir()
@@ -108,7 +153,7 @@ pub fn parse_desktop_file(path: PathBuf) -> Result<DesktopFile, String> {
.join(path.clone()),
) {
Ok(file) => file,
Err(err) => return Err(format!("Failed to open file: {}", err)),
Err(err) => return Err(format!("Failed to open file: {err}")),
};
let reader = BufReader::new(file);
@@ -127,7 +172,7 @@ pub fn parse_desktop_file(path: PathBuf) -> Result<DesktopFile, String> {
for line in reader.lines() {
let line = match line {
Ok(line) => line,
Err(err) => return Err(format!("Failed to read line: {}", err)),
Err(err) => return Err(format!("Failed to read line: {err}")),
};
// Skip empty lines and lines that start with "#" (comments)
@@ -176,37 +221,7 @@ pub fn parse_desktop_file(path: PathBuf) -> Result<DesktopFile, String> {
icon,
no_display,
})
}
#[test]
fn test_parse_desktop_file() {
// Create a temporary directory and a test desktop file
let dir = tempdir::TempDir::new("test").unwrap();
let file = dir.path().join("test.desktop");
let data = "[Desktop Entry]\nName=Test\nExec=test\nCategories=A;B;C\nIcon=test.png";
fs::write(&file, data).unwrap();
// Parse the test desktop file
let desktop_file = parse_desktop_file(file).unwrap();
// Check the parsed values
assert_eq!(desktop_file.name, Some("Test".to_string()));
assert_eq!(desktop_file.command, Some("test".to_string()));
assert_eq!(
desktop_file.categories,
vec!["A".to_string(), "B".to_string(), "C".to_string()]
);
assert_eq!(desktop_file.icon, Some("test.png".to_string()));
}
#[derive(Debug, Clone)]
pub struct DesktopFile {
path: PathBuf,
pub name: Option<String>,
pub command: Option<String>,
pub categories: Vec<String>,
pub icon: Option<String>,
pub no_display: bool,
}
}
const ICON_SIZES: [u16; 7] = [512, 256, 128, 64, 48, 32, 24];

View File

@@ -1,7 +1,8 @@
[package]
name = "single"
version = "0.1.0"
edition = "2021"
edition = "2024"
rust-version = "1.88"
[dependencies]
protostar = { path = "../protostar" }
@@ -10,7 +11,11 @@ clap = "4.4.6"
manifest-dir-macros = "0.1.18"
glam = "0.24.2"
tween = "2.0.1"
mint = "0.5.9"
tracing = "0.1.41"
tracing-subscriber = "0.3.17"
tokio = { workspace = true }
serde = { workspace = true }
stardust-xr-fusion = { workspace = true }
stardust-xr-molecules = { workspace = true }
stardust-xr-asteroids = { workspace = true }

163
single/src/app.rs Normal file
View File

@@ -0,0 +1,163 @@
use glam::{Quat, Vec3};
use mint::{Quaternion, Vector3};
use protostar::application::Application;
use protostar::xdg::{DesktopFile, Icon, IconType};
use serde::{Deserialize, Serialize};
use stardust_xr_asteroids::elements::{
Grabbable, Lines, Model, ModelPart, PointerMode, Text, line_from_points,
};
use stardust_xr_asteroids::{CustomElement, Element, Reify, Transformable};
use stardust_xr_fusion::drawable::{TextBounds, TextFit};
use stardust_xr_fusion::node::NodeError;
use stardust_xr_fusion::values::ResourceID;
use stardust_xr_fusion::{
drawable::{MaterialParameter, XAlign, YAlign},
fields::{CylinderShape, Shape},
spatial::Transform,
};
use std::f32::consts::{FRAC_PI_2, PI};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::app_launcher::AppLauncher;
use crate::{ACTIVATION_DISTANCE, APP_SIZE, DEFAULT_HEX_COLOR, MODEL_SCALE};
#[derive(Debug, Serialize, Deserialize)]
pub struct App {
pub app: Application,
#[serde(skip)]
icon: OnceLock<Icon>,
pos: Vector3<f32>,
rot: Quaternion<f32>,
#[serde(skip)]
launched: AtomicBool,
}
impl App {
pub fn new(desktop_entry: DesktopFile) -> Result<Self, NodeError> {
let app = Application::create(desktop_entry)?;
Ok(App {
app,
icon: OnceLock::default(),
pos: [0.0; 3].into(),
rot: Quat::IDENTITY.into(),
launched: AtomicBool::new(false),
})
}
pub fn load_icon(&self) {
if self.icon.get().is_none()
&& let Some(icon) = self
.app
.icon(64, true)
.and_then(|i| i.cached_process(64).ok())
{
let _ = self.icon.set(icon);
}
}
// Helper functions for creating app components
fn create_model(&self) -> impl Element<Self> {
match self.icon.get().as_ref().map(|i| (i.icon_type.clone(), i)) {
Some((IconType::Gltf, icon)) => Model::direct(icon.path.clone())
.unwrap()
.transform(Transform::from_rotation_scale(
Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI),
[MODEL_SCALE; 3],
))
.build(),
other => {
let model = Model::namespaced("protostar", "hexagon/hexagon")
.transform(Transform::from_rotation_scale(
Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI),
[APP_SIZE / 2.0; 3],
))
.part(
ModelPart::new("Hex")
.mat_param("color", MaterialParameter::Color(DEFAULT_HEX_COLOR)),
);
match other {
Some((IconType::Png, icon)) => model.part(ModelPart::new("Icon").mat_param(
"diffuse",
MaterialParameter::Texture(ResourceID::Direct(icon.path.clone())),
)),
_ => model,
}
.build()
}
}
}
}
impl Reify for App {
#[tracing::instrument(skip_all)]
fn reify(&self) -> impl Element<Self> {
// The field shape for the grabbable
let field_shape = Shape::Cylinder(CylinderShape {
radius: APP_SIZE / 2.0,
length: 0.01,
});
let converted = Vec3::from(self.pos);
let length = converted.length();
let direction = converted.normalize_or_zero();
Lines::new([line_from_points(vec![
Vec3::from([0.0; 3]),
(length < ACTIVATION_DISTANCE) as u32 as f32
* direction * length.clamp(0.0, ACTIVATION_DISTANCE),
])])
.build()
.child(
Grabbable::new(
field_shape,
self.pos,
self.rot,
move |state: &mut Self, pos, rot| {
state.pos = pos;
state.rot = rot;
},
)
.grab_stop({
move |state: &mut Self| {
let pos_vec = Vec3::from(state.pos);
if pos_vec.length() > ACTIVATION_DISTANCE {
// state.app.launch(launch_space)
state.launched.store(true, Ordering::Relaxed);
} else {
state.pos = [0.0; 3].into();
state.rot = Quat::IDENTITY.into();
}
}
})
.field_transform(Transform::from_rotation(Quat::from_rotation_x(FRAC_PI_2)))
.pointer_mode(PointerMode::Align)
.max_distance(0.05)
.reparentable(false)
.build()
.child(self.create_model())
.children(self.launched.load(Ordering::Relaxed).then(|| {
AppLauncher::new(&self.app)
.done(|state: &mut Self| {
state.launched.store(false, Ordering::Relaxed);
state.pos = [0.0; 3].into();
state.rot = Quat::IDENTITY.into();
})
.build()
}))
.child(
Text::new(self.app.name().unwrap_or_default())
.character_height(0.005)
.bounds(TextBounds {
bounds: [0.04, 0.04].into(),
fit: TextFit::Wrap,
anchor_align_x: XAlign::Center,
anchor_align_y: YAlign::Bottom,
})
.align_x(XAlign::Center)
.align_y(YAlign::Bottom)
.pos([0.0, -APP_SIZE * 0.35, 0.002])
.build(),
),
)
}
}

View File

@@ -0,0 +1,64 @@
use protostar::application::Application;
use stardust_xr_asteroids::{Context, CustomElement, ValidState};
use stardust_xr_fusion::{
node::{NodeError, NodeType},
root::FrameInfo,
spatial::{Spatial, SpatialAspect, SpatialRef, Transform},
};
use std::fmt::Debug;
pub struct AppLauncher<State: ValidState>(Application, Box<dyn Fn(&mut State) + Send + Sync>);
impl<State: ValidState> AppLauncher<State> {
pub fn new(app: &Application) -> Self {
AppLauncher(app.clone(), Box::new(|_| {}))
}
pub fn done<F: Fn(&mut State) + Send + Sync + 'static>(mut self, f: F) -> Self {
self.1 = Box::new(f);
self
}
}
impl<State: ValidState> CustomElement<State> for AppLauncher<State> {
type Inner = (Spatial, bool);
type Resource = ();
type Error = NodeError;
fn create_inner(
&self,
_asteroids_context: &stardust_xr_asteroids::Context,
info: stardust_xr_asteroids::CreateInnerInfo,
_resource: &mut Self::Resource,
) -> Result<Self::Inner, Self::Error> {
let spatial = Spatial::create(
info.parent_space.client().get_root(),
Transform::identity(),
false,
)?;
spatial.set_relative_transform(info.parent_space, Transform::from_translation([0.0; 3]))?;
Ok((spatial, false))
}
fn diff(&self, _old_self: &Self, _inner: &mut Self::Inner, _resource: &mut Self::Resource) {}
fn frame(
&self,
_context: &Context,
_info: &FrameInfo,
state: &mut State,
inner: &mut Self::Inner,
) {
if !inner.1 {
let _ = self.0.launch(&inner.0);
(self.1)(state);
inner.1 = true;
}
}
fn spatial_aspect(&self, inner: &Self::Inner) -> SpatialRef {
inner.0.clone().as_spatial_ref()
}
}
impl<State: ValidState> Debug for AppLauncher<State> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("ImperativeSpatial").finish()
}
}

15
single/src/lib.rs Normal file
View File

@@ -0,0 +1,15 @@
mod app;
mod app_launcher;
pub use app::App;
use stardust_xr_fusion::values::color::{Rgba, color_space::LinearRgb, rgba_linear};
// Constants from original implementation
pub const APP_SIZE: f32 = 0.06;
pub const PADDING: f32 = 0.005;
pub const MODEL_SCALE: f32 = 0.03;
pub const ACTIVATION_DISTANCE: f32 = 0.05;
pub const DEFAULT_HEX_COLOR: Rgba<f32, LinearRgb> = rgba_linear!(0.211, 0.937, 0.588, 1.0);
pub const BTN_SELECTED_COLOR: Rgba<f32, LinearRgb> = rgba_linear!(0.0, 1.0, 0.0, 1.0);
pub const BTN_COLOR: Rgba<f32, LinearRgb> = rgba_linear!(1.0, 1.0, 0.0, 1.0);

View File

@@ -1,15 +1,12 @@
mod single;
use stardust_xr_asteroids::{ClientState, CustomElement, Element, Migrate, Reify, client, elements::Spatial};
use clap::Parser;
use color_eyre::{eyre::Result, Report};
use manifest_dir_macros::directory_relative_path;
use protostar::xdg::parse_desktop_file;
use stardust_xr_fusion::{client::Client, node::NodeType, root::RootAspect};
use protostar::xdg::DesktopFile;
use serde::{Deserialize, Serialize};
use single::App;
use stardust_xr_fusion::project_local_resources;
use std::path::PathBuf;
use tracing_subscriber::EnvFilter;
use crate::single::Single;
#[derive(Debug, Parser)]
#[clap(author, version, about, long_about = None)]
struct Args {
@@ -18,27 +15,39 @@ struct Args {
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
async fn main() {
tracing_subscriber::fmt()
.compact()
.with_env_filter(EnvFilter::from_env("LOG_LEVEL"))
.init();
color_eyre::install()?;
let args = Args::parse();
let (client, event_loop) = Client::connect_with_async_loop().await?;
client.set_base_prefixes(&[directory_relative_path!("../res")])?;
let protostar = Single::create_from_desktop_file(
client.get_root(),
[0.0, 0.0, 0.0],
parse_desktop_file(args.desktop_file).map_err(Report::msg)?,
)?;
let _root = client.get_root().alias().wrap(protostar)?;
tokio::select! {
_ = tokio::signal::ctrl_c() => (),
e = event_loop => e??,
};
Ok(())
client::run::<Single>(&[&project_local_resources!("../res")]).await
}
#[derive(Default, Debug, Serialize, Deserialize)]
pub struct Single {
app: Option<App>,
}
impl Migrate for Single {
type Old = Self;
}
impl ClientState for Single {
const APP_ID: &'static str = "org.stardustxr.protostar.single";
fn initial_state_update(&mut self) {
let desktop_file_path = Args::parse().desktop_file;
let app = App::new(DesktopFile::parse(desktop_file_path).unwrap()).unwrap();
app.load_icon();
self.app.replace(app);
}
}
impl Reify for Single {
#[tracing::instrument(skip_all)]
fn reify(&self) -> impl Element<Self> {
Spatial::default().build().maybe_child(
self.app
.as_ref()
.map(|app| app.reify_substate(|state: &mut Self| state.app.as_mut())),
)
}
}

View File

@@ -1,250 +0,0 @@
use color_eyre::eyre::Result;
use glam::{Quat, Vec3};
use protostar::{
application::Application,
xdg::{DesktopFile, Icon, IconType},
};
use stardust_xr_fusion::{
core::values::{color::rgba_linear, ResourceID, Vector3},
drawable::{
MaterialParameter, Model, ModelPartAspect, Text, TextBounds, TextFit, TextStyle, XAlign,
YAlign,
},
fields::{Field, Shape},
node::NodeType,
root::{ClientState, FrameInfo, RootHandler},
spatial::{Spatial, SpatialAspect, SpatialRefAspect, Transform},
};
use stardust_xr_molecules::{Grabbable, GrabbableSettings};
use std::f32::consts::PI;
use tween::{QuartInOut, Tweener};
const MODEL_SCALE: f32 = 0.05;
const ACTIVATION_DISTANCE: f32 = 0.5;
fn model_from_icon(parent: &Spatial, icon: &Icon) -> Result<Model> {
match &icon.icon_type {
IconType::Png => {
let t = Transform::from_rotation_scale(
Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI),
[MODEL_SCALE; 3],
);
let model = Model::create(
parent,
t,
&ResourceID::new_namespaced("protostar", "hexagon/hexagon"),
)?;
model.part("Hex")?.set_material_parameter(
"color",
MaterialParameter::Color(rgba_linear!(0.0, 1.0, 1.0, 1.0)),
)?;
model.part("Icon")?.set_material_parameter(
"diffuse",
MaterialParameter::Texture(ResourceID::Direct(icon.path.clone())),
)?;
Ok(model)
}
IconType::Gltf => Ok(Model::create(
parent,
Transform::from_scale([0.05; 3]),
&ResourceID::new_direct(icon.path.clone())?,
)?),
_ => panic!("Invalid Icon Type"),
}
}
pub struct Single {
application: Application,
root: Spatial,
position: Vector3<f32>,
grabbable: Grabbable,
_field: Field,
icon: Model,
label: Option<Text>,
grabbable_shrink: Option<Tweener<f32, f64, QuartInOut>>,
grabbable_grow: Option<Tweener<f32, f64, QuartInOut>>,
grabbable_move: Option<Tweener<f32, f64, QuartInOut>>,
currently_shown: bool,
}
impl Single {
pub fn create_from_desktop_file(
parent: &impl SpatialRefAspect,
position: impl Into<Vector3<f32>>,
desktop_file: DesktopFile,
) -> Result<Self> {
let root = Spatial::create(parent, Transform::identity(), false)?;
let position = position.into();
let field = Field::create(
&root,
Transform::identity(),
Shape::Box([MODEL_SCALE * 2.0; 3].into()),
)?;
let application = Application::create(desktop_file)?;
let icon = application.icon(128, false);
let grabbable = Grabbable::create(
&root,
Transform::from_translation(position),
&field,
GrabbableSettings {
max_distance: 0.01,
..Default::default()
},
)?;
grabbable.content_parent().set_spatial_parent(&root)?;
field.set_spatial_parent(grabbable.content_parent())?;
let icon = icon
.map(|i| model_from_icon(grabbable.content_parent(), &i))
.unwrap_or_else(|| {
Ok(Model::create(
grabbable.content_parent(),
Transform::from_scale([MODEL_SCALE; 3]),
&ResourceID::new_namespaced("protostar", "default_icon"),
)?)
})?;
let label_style = TextStyle {
character_height: MODEL_SCALE * 4.0,
bounds: Some(TextBounds {
bounds: [1.0; 2].into(),
fit: TextFit::Wrap,
anchor_align_x: XAlign::Center,
anchor_align_y: YAlign::Center,
}),
text_align_x: XAlign::Center,
text_align_y: YAlign::Center,
..Default::default()
};
let label = application.name().and_then(|name| {
Text::create(
&icon,
Transform::from_translation_rotation(
[0.0, 0.1, -(MODEL_SCALE * 8.0)],
Quat::from_rotation_x(PI * 0.5),
),
name,
label_style,
)
.ok()
});
Ok(Single {
root,
position,
grabbable,
_field: field,
label,
application,
icon,
grabbable_shrink: None,
grabbable_grow: None,
grabbable_move: None,
currently_shown: true,
})
}
pub fn content_parent(&self) -> &Spatial {
self.grabbable.content_parent()
}
}
impl RootHandler for Single {
fn frame(&mut self, info: FrameInfo) {
let _ = self.grabbable.update(&info);
if let Some(grabbable_move) = &mut self.grabbable_move {
if !grabbable_move.is_finished() {
let scale = grabbable_move.move_by(info.delta.into());
self.grabbable
.content_parent()
.set_relative_transform(
&self.root,
Transform::from_translation([
self.position.x * scale,
self.position.y * scale,
self.position.z * scale,
]),
)
.unwrap();
} else {
if grabbable_move.final_value() == 0.0001 {
self.icon.set_enabled(false).unwrap();
if let Some(label) = self.label.as_ref() {
label.set_enabled(false).unwrap()
}
}
self.grabbable_move = None;
}
}
if let Some(grabbable_shrink) = &mut self.grabbable_shrink {
if !grabbable_shrink.is_finished() {
let scale = grabbable_shrink.move_by(info.delta.into());
self.grabbable
.content_parent()
.set_relative_transform(&self.root, Transform::from_scale([scale; 3]))
.unwrap();
} else {
self.grabbable
.content_parent()
.set_spatial_parent(&self.root)
.unwrap();
if self.currently_shown {
self.grabbable_grow = Some(Tweener::quart_in_out(0.0001, 1.0, 0.25));
self.grabbable.cancel_angular_velocity();
self.grabbable.cancel_linear_velocity();
}
self.grabbable_shrink = None;
self.grabbable
.content_parent()
.set_relative_transform(&self.root, Transform::from_translation(self.position))
.unwrap();
self.grabbable
.content_parent()
.set_relative_transform(&self.root, Transform::from_rotation(Quat::default()))
.unwrap();
self.icon
.set_local_transform(Transform::from_rotation(
Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI),
))
.unwrap();
}
} else if let Some(grabbable_grow) = &mut self.grabbable_grow {
if !grabbable_grow.is_finished() {
let scale = grabbable_grow.move_by(info.delta.into());
self.grabbable
.content_parent()
.set_relative_transform(&self.root, Transform::from_scale([scale; 3]))
.unwrap();
} else {
self.grabbable
.content_parent()
.set_spatial_parent(&self.root)
.unwrap();
self.grabbable_grow = None;
}
} else if self.grabbable.grab_action().actor_stopped() {
self.grabbable_shrink = Some(Tweener::quart_in_out(MODEL_SCALE, 0.0001, 0.25));
let application = self.application.clone();
let space = self.content_parent().alias();
let root = self.root.alias();
//TODO: split the executable string for the args
tokio::task::spawn(async move {
let distance_vector = space
.get_transform(&root)
.await
.unwrap()
.translation
.unwrap();
let distance = Vec3::from(distance_vector).length_squared();
if distance > ACTIVATION_DISTANCE {
let _ = application.launch(&space);
}
});
}
}
fn save_state(&mut self) -> color_eyre::eyre::Result<ClientState> {
ClientState::from_root(self.content_parent())
}
}

View File

@@ -1,18 +1,24 @@
[package]
name = "sirius"
version = "0.1.0"
edition = "2021"
edition = "2024"
[features]
tracy = ["dep:tracing-tracy", "stardust-xr-asteroids/tracy"]
[dependencies]
protostar = { path = "../protostar" }
color-eyre = "0.6.2"
clap = "4.4.6"
manifest-dir-macros = "0.1.18"
glam = "0.24.2"
tween = "2.0.1"
tracing-subscriber = "0.3.17"
walkdir = "2.4.0"
glam = { version = "0.25.0", features = ["mint"] }
mint = "0.5.9"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
tracing-tracy = { version = "0.11.4", optional = true }
tokio = { workspace = true }
serde = { workspace = true }
stardust-xr-fusion = { workspace = true }
stardust-xr-molecules = { workspace = true }
stardust-xr-asteroids = { workspace = true }
tracing = "0.1.41"
walkdir = "2.4.0"
single = { path = "../single" }

View File

@@ -1,35 +1,40 @@
use clap::{self, Parser};
use color_eyre::eyre::Result;
use glam::{Quat, Vec3};
use manifest_dir_macros::directory_relative_path;
use protostar::{
application::Application,
xdg::{parse_desktop_file, DesktopFile, Icon, IconType},
};
use clap::Parser;
use glam::Quat;
use mint::{Quaternion, Vector3};
use protostar::xdg::DesktopFile;
use serde::{Deserialize, Serialize};
use single::{App, BTN_COLOR, BTN_SELECTED_COLOR};
use stardust_xr_asteroids::{
ClientState, CustomElement, Element, Migrate, Reify, Transformable, client,
elements::{Button, Grabbable, Model, ModelPart, PointerMode, Spatial},
};
use stardust_xr_fusion::{
client::Client,
core::values::{color::rgba_linear, ResourceID, Vector3},
drawable::{
MaterialParameter, Model, ModelPartAspect, Text, TextBounds, TextFit, TextStyle, XAlign,
YAlign,
},
fields::{Field, Shape},
node::{NodeError, NodeType},
root::{ClientState, FrameInfo, RootAspect, RootHandler},
spatial::{Spatial, SpatialAspect, SpatialRefAspect, Transform},
drawable::MaterialParameter, fields::Shape, project_local_resources, spatial::Transform,
};
use stardust_xr_molecules::{
button::{Button, ButtonSettings},
Grabbable, GrabbableSettings,
};
use std::{f32::consts::PI, path::PathBuf};
use tween::{QuartInOut, Tweener};
use std::path::PathBuf;
use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt};
use walkdir::WalkDir;
const APP_SIZE: f32 = 0.06;
const ACTIVATION_DISTANCE: f32 = 0.5;
#[tokio::main(flavor = "current_thread")]
async fn main() {
color_eyre::install().unwrap();
let registry = tracing_subscriber::registry();
#[cfg(feature = "tracy")]
let registry = registry.with({
use tracing_subscriber::Layer;
tracing_tracy::TracyLayer::new(tracing_tracy::DefaultConfig::default())
.with_filter(tracing::level_filters::LevelFilter::DEBUG)
});
let log_layer = tracing_subscriber::fmt::Layer::new()
.with_thread_names(true)
.with_ansi(true)
.with_line_number(true)
.with_filter(EnvFilter::from_default_env());
registry.with(log_layer).init();
client::run::<Sirius>(&[&project_local_resources!("../res")]).await
}
#[derive(Debug, Parser)]
#[clap(author, version, about, long_about = None)]
@@ -38,67 +43,45 @@ struct Args {
apps_directory: PathBuf,
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
color_eyre::install()?;
#[derive(Debug, Serialize, Deserialize)]
pub struct Sirius {
visible: bool,
pos: Vector3<f32>,
rot: Quaternion<f32>,
#[serde(skip)]
apps: Vec<App>,
}
impl Default for Sirius {
fn default() -> Self {
Self {
visible: false,
pos: [0.0; 3].into(),
rot: Quat::IDENTITY.into(),
apps: Vec::new(),
}
}
}
impl Migrate for Sirius {
type Old = Self;
}
impl ClientState for Sirius {
const APP_ID: &'static str = "org.protostar.sirius";
fn initial_state_update(&mut self) {
let args = Args::parse();
if !args.apps_directory.is_dir() {
panic!(
"{} is not a direcotry",
"{} is not a directory",
args.apps_directory.to_string_lossy()
)
}
let (client, event_loop) = Client::connect_with_async_loop().await?;
client.set_base_prefixes(&[directory_relative_path!("../res")])?;
let _wrapped_root = client
.get_root()
.alias()
.wrap(Sirius::new(&client, args)?)?;
tokio::select! {
_ = tokio::signal::ctrl_c() => (),
e = event_loop => e??,
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct State {
visible: bool,
}
struct Sirius {
button: Button,
model: Model,
clients: Vec<App>,
state: State,
grabbable: Grabbable,
}
impl Sirius {
fn new(client: &Client, args: Args) -> Result<Self, NodeError> {
let root = Spatial::create(client.get_root(), Transform::identity(), false).unwrap();
let field =
Field::create(&root, Transform::identity(), Shape::Box([0.1; 3].into())).unwrap();
let grabbable = Grabbable::create(
&root,
Transform::identity(),
&field,
GrabbableSettings::default(),
)?;
let button = Button::create(
grabbable.content_parent(),
Transform::identity(),
[0.1; 2],
ButtonSettings::default(),
)?;
let walkdir = WalkDir::new(args.apps_directory.canonicalize().unwrap());
let clients: Vec<App> = walkdir
self.apps = walkdir
.into_iter()
.filter_map(|path| path.ok())
.map(|entry| entry.into_path())
@@ -107,365 +90,62 @@ impl Sirius {
&& path.extension().is_some()
&& path.extension().unwrap() == "desktop"
})
.filter_map(|path| {
App::create_from_desktop_file(
grabbable.content_parent(),
[0.0; 3],
parse_desktop_file(path).ok()?,
)
.ok()
})
.filter_map(|path| App::new(DesktopFile::parse(path).ok()?).ok())
.collect();
let model = Model::create(
grabbable.content_parent(),
Transform::identity(),
&ResourceID::new_namespaced("protostar", "button"),
)?;
field.set_spatial_parent(grabbable.content_parent())?;
let state = State { visible: false };
Ok(Sirius {
button,
model,
clients,
state,
grabbable,
})
}
// fn left_hand(input_data: &InputData, _: &()) -> bool {
// match &input_data.input {
// InputDataType::Hand(h) => !h.right,
// _ => false,
// }
// }
}
impl RootHandler for Sirius {
fn frame(&mut self, info: FrameInfo) {
for app in &mut self.clients {
app.frame(&info);
}
self.grabbable.update(&info).unwrap();
self.button.update();
if self.button.pressed() {
println!("Touch started");
self.state.visible = !self.state.visible;
match self.state.visible {
true => {
for (pos, star) in self.clients.iter().enumerate() {
impl Reify for Sirius {
fn reify(&self) -> impl Element<Self> {
Grabbable::new(
Shape::Box([0.1; 3].into()),
self.pos,
self.rot,
|state: &mut Self, pos, rot| {
state.pos = pos;
state.rot = rot;
},
)
.pointer_mode(PointerMode::Align)
.reparentable(true)
.build()
.child(
Button::new(|state: &mut Sirius| {
state.visible = !state.visible;
})
.pos([0.0, 0.0, 0.005])
.size([0.1; 2])
.build(),
)
.child(
Model::namespaced("protostar", "button")
.transform(Transform::identity())
.part(ModelPart::new("?????").mat_param(
"color",
MaterialParameter::Color(if self.visible {
BTN_SELECTED_COLOR
} else {
BTN_COLOR
}),
))
.build(),
)
.children(
self.visible
.then(|| {
self.apps.iter().enumerate().map(|(pos, app)| {
let mut starpos = (pos as f32 + 1.0) / 10.0;
match starpos % 0.2 == 0.0 {
true => starpos = -starpos / 2.0,
false => starpos = (starpos - 0.1) / 2.0,
}
println!("{}", starpos);
star.content_parent()
.set_relative_transform(
self.grabbable.content_parent(),
Transform::from_translation([starpos, 0.1, 0.0]),
)
.unwrap();
}
}
false => {
for star in &self.clients {
star.content_parent()
.set_relative_transform(
self.grabbable.content_parent(),
Transform::from_translation([0.0; 3]),
)
.ok();
}
}
}
self.model
.part("?????")
.unwrap()
.set_material_parameter(
"color",
MaterialParameter::Color(rgba_linear!(0.0, 1.0, 0.0, 1.0)),
)
.unwrap();
self.model
.part("?????")
.unwrap()
.set_material_parameter(
"emission_factor",
MaterialParameter::Color(rgba_linear!(0.0, 0.75, 0.0, 0.75)),
)
.unwrap();
}
if self.button.released() {
println!("Touch ended");
self.model
.part("?????")
.unwrap()
.set_material_parameter(
"color",
MaterialParameter::Color(rgba_linear!(1.0, 0.0, 0.0, 1.0)),
Spatial::default().pos([starpos, 0.1, 0.0]).build().child(
app.reify_substate(move |state: &mut Sirius| state.apps.get_mut(pos)),
)
.unwrap();
self.model
.part("?????")
.unwrap()
.set_material_parameter(
"emission_factor",
MaterialParameter::Color(rgba_linear!(0.5, 0.0, 0.0, 0.5)),
)
.unwrap();
}
}
fn save_state(&mut self) -> Result<ClientState> {
ClientState::new(
Some(self.state.clone()),
self.grabbable.content_parent(),
[(
"content_parent".to_string(),
self.grabbable.content_parent(),
)]
.into_iter()
.collect(),
)
}
}
fn model_from_icon(parent: &Spatial, icon: &Icon) -> Result<Model> {
match &icon.icon_type {
IconType::Png => {
let t = Transform::from_rotation_scale(
Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI),
[APP_SIZE * 0.5; 3],
);
let model = Model::create(
parent,
t,
&ResourceID::new_namespaced("protostar", "hexagon/hexagon"),
)?;
model.part("Hex")?.set_material_parameter(
"color",
MaterialParameter::Color(rgba_linear!(0.0, 1.0, 1.0, 1.0)),
)?;
model.part("Icon")?.set_material_parameter(
"diffuse",
MaterialParameter::Texture(ResourceID::Direct(icon.path.clone())),
)?;
Ok(model)
}
IconType::Gltf => Ok(Model::create(
parent,
Transform::from_scale([0.05; 3]),
&ResourceID::new_direct(icon.path.clone())?,
)?),
_ => panic!("Invalid Icon Type"),
}
}
pub struct App {
application: Application,
parent: Spatial,
position: Vector3<f32>,
grabbable: Grabbable,
_field: Field,
icon: Model,
label: Option<Text>,
grabbable_shrink: Option<Tweener<f32, f64, QuartInOut>>,
grabbable_grow: Option<Tweener<f32, f64, QuartInOut>>,
grabbable_move: Option<Tweener<f32, f64, QuartInOut>>,
currently_shown: bool,
}
impl App {
pub fn create_from_desktop_file(
parent: &Spatial,
position: impl Into<Vector3<f32>>,
desktop_file: DesktopFile,
) -> Result<Self> {
let position = position.into();
let field = Field::create(
parent,
Transform::identity(),
Shape::Box([APP_SIZE; 3].into()),
)?;
let application = Application::create(desktop_file)?;
let icon = application.icon(128, false);
let grabbable = Grabbable::create(
parent,
Transform::from_translation(position),
&field,
GrabbableSettings {
max_distance: 0.01,
zoneable: false,
..Default::default()
},
)?;
grabbable.content_parent().set_spatial_parent(parent)?;
field.set_spatial_parent(grabbable.content_parent())?;
let icon = icon
.map(|i| model_from_icon(grabbable.content_parent(), &i))
.unwrap_or_else(|| {
Ok(Model::create(
grabbable.content_parent(),
Transform::from_rotation_scale(
Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI),
[APP_SIZE * 0.5; 3],
),
&ResourceID::new_namespaced("protostar", "hexagon/hexagon"),
)?)
})?;
let label_style = TextStyle {
character_height: APP_SIZE * 2.0,
bounds: Some(TextBounds {
bounds: [1.0; 2].into(),
fit: TextFit::Wrap,
anchor_align_x: XAlign::Center,
anchor_align_y: YAlign::Center,
}),
text_align_x: XAlign::Center,
text_align_y: YAlign::Center,
..Default::default()
};
let label = application.name().and_then(|name| {
Text::create(
&icon,
Transform::from_translation_rotation(
[0.0, 0.1, -(APP_SIZE * 4.0)],
Quat::from_rotation_x(PI * 0.5),
),
name,
label_style,
)
.ok()
});
Ok(App {
parent: parent.alias(),
position,
grabbable,
_field: field,
label,
application,
icon,
grabbable_shrink: None,
grabbable_grow: None,
grabbable_move: None,
currently_shown: true,
})
}
pub fn content_parent(&self) -> &Spatial {
self.grabbable.content_parent()
}
pub fn toggle(&mut self) {
self.grabbable.set_enabled(!self.currently_shown).unwrap();
if self.currently_shown {
self.grabbable_move = Some(Tweener::quart_in_out(1.0, 0.0001, 0.25)); //TODO make the scale a parameter
} else {
self.icon.set_enabled(true).unwrap();
if let Some(label) = self.label.as_ref() {
label.set_enabled(true).unwrap()
}
self.grabbable_move = Some(Tweener::quart_in_out(0.0001, 1.0, 0.25));
}
self.currently_shown = !self.currently_shown;
}
fn frame(&mut self, info: &FrameInfo) {
let _ = self.grabbable.update(info);
if let Some(grabbable_move) = &mut self.grabbable_move {
if !grabbable_move.is_finished() {
let scale = grabbable_move.move_by(info.delta.into());
self.grabbable
.content_parent()
.set_relative_transform(
&self.parent,
Transform::from_translation(Vec3::from(self.position) * scale),
})
.into_iter()
.flatten(),
)
.unwrap();
} else {
if grabbable_move.final_value() == 0.0001 {
self.icon.set_enabled(false).unwrap();
if let Some(label) = self.label.as_ref() {
label.set_enabled(false).unwrap()
}
}
self.grabbable_move = None;
}
}
if let Some(grabbable_shrink) = &mut self.grabbable_shrink {
if !grabbable_shrink.is_finished() {
let scale = grabbable_shrink.move_by(info.delta.into());
self.grabbable
.content_parent()
.set_relative_transform(&self.parent, Transform::from_scale([scale; 3]))
.unwrap();
} else {
self.grabbable
.content_parent()
.set_spatial_parent(&self.parent)
.unwrap();
if self.currently_shown {
self.grabbable_grow = Some(Tweener::quart_in_out(0.0001, 1.0, 0.25));
self.grabbable.cancel_angular_velocity();
self.grabbable.cancel_linear_velocity();
}
self.grabbable_shrink = None;
self.grabbable
.content_parent()
.set_relative_transform(
&self.parent,
Transform::from_translation(self.position),
)
.unwrap();
self.grabbable
.content_parent()
.set_relative_transform(&self.parent, Transform::from_rotation(Quat::default()))
.unwrap();
self.icon
.set_local_transform(Transform::from_rotation(
Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI),
))
.unwrap();
}
} else if let Some(grabbable_grow) = &mut self.grabbable_grow {
if !grabbable_grow.is_finished() {
let scale = grabbable_grow.move_by(info.delta.into());
self.grabbable
.content_parent()
.set_relative_transform(&self.parent, Transform::from_scale([scale; 3]))
.unwrap();
} else {
self.grabbable
.content_parent()
.set_spatial_parent(&self.parent)
.unwrap();
self.grabbable_grow = None;
}
} else if self.grabbable.grab_action().actor_stopped() {
self.grabbable_shrink = Some(Tweener::quart_in_out(APP_SIZE * 0.5, 0.0001, 0.25));
let application = self.application.clone();
let space = self.content_parent().alias();
let parent = self.parent.alias();
//TODO: split the executable string for the args
tokio::task::spawn(async move {
let distance_vector = space
.get_transform(&parent)
.await
.unwrap()
.translation
.unwrap();
let distance = Vec3::from(distance_vector).length_squared();
if distance > ACTIVATION_DISTANCE {
let _ = application.launch(&space);
}
});
}
}
}