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] [workspace]
members = ["app_grid", "hexagon_launcher", "protostar", "single", "sirius"] resolver = "2"
members = ["hexagon_launcher", "protostar", "single", "sirius"]
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1.32.0", features = ["rt", "tokio-macros", "sync"] } tokio = { version = "1.47.1", features = ["rt", "tokio-macros", "sync"] }
serde = "1.0.197" serde = "1.0.219"
[workspace.dependencies.stardust-xr-fusion] [workspace.dependencies.stardust-xr-fusion]
git = "https://github.com/StardustXR/core.git" git = "https://github.com/StardustXR/core.git"
@@ -12,3 +13,22 @@ branch = "dev"
[workspace.dependencies.stardust-xr-molecules] [workspace.dependencies.stardust-xr-molecules]
git = "https://github.com/StardustXR/molecules.git" git = "https://github.com/StardustXR/molecules.git"
branch = "dev" 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 # 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 ## Manual Installation
2. Untangle the code (eg: make it so that the user can decide sizes, models, etc... ) Clone the repository and after the server is running:
3. Implement [configuration files](https://docs.rs/confy/latest/confy/) in the examples ```sh
cargo run
```

View File

@@ -1,16 +1,16 @@
[package] [package]
name = "app_grid" name = "app_grid"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[dependencies] [dependencies]
tokio = { version = "1.32.0", features = ["rt", "tokio-macros", "sync"] } tokio = { version = "1.47.1", features = ["rt", "tokio-macros", "sync"] }
protostar = { path = "../protostar" } protostar = { path = "../protostar" }
color-eyre = "0.6.2" color-eyre = "0.6.5"
clap = "4.4.6" clap = "4.5.46"
manifest-dir-macros = "0.1.18" manifest-dir-macros = "0.1.18"
glam = "0.24.2" glam = "0.24.2"
tween = "2.0.1" tween = "2.1.0"
tracing-subscriber = "0.3.17" tracing-subscriber = "0.3.20"
stardust-xr-fusion = { workspace = true } stardust-xr-fusion = { workspace = true }
stardust-xr-molecules = { 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 manifest_dir_macros::directory_relative_path;
use protostar::{ use protostar::{
application::Application, 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::{ use stardust_xr_fusion::{
ClientHandle,
client::Client, client::Client,
core::values::{color::rgba_linear, ResourceID, Vector3}, core::values::{ResourceID, Vector3, color::rgba_linear},
drawable::{ drawable::{
MaterialParameter, Model, ModelPartAspect, Text, TextBounds, TextFit, TextStyle, XAlign, MaterialParameter, Model, ModelPartAspect, Text, TextBounds, TextFit, TextStyle, XAlign,
YAlign, YAlign,
}, },
fields::{Field, Shape}, fields::{Field, Shape},
node::NodeType, root::{ClientState, FrameInfo, RootAspect},
root::{ClientState, FrameInfo, RootAspect, RootHandler}, spatial::{Spatial, SpatialAspect, SpatialRef, SpatialRefAspect, Transform},
spatial::{Spatial, SpatialAspect, SpatialRefAspect, Transform},
}; };
use stardust_xr_molecules::{Grabbable, GrabbableSettings}; use stardust_xr_molecules::{FrameSensitive, Grabbable, GrabbableSettings, UIElement};
use std::f32::consts::PI; use std::f32::consts::PI;
const APP_LIMIT: usize = 300; const APP_LIMIT: usize = 300;
@@ -32,16 +32,34 @@ async fn main() -> Result<()> {
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.pretty() .pretty()
.init(); .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 client
.set_base_prefixes(&[directory_relative_path!("../res")]) .get_root()
.set_base_prefixes(&[directory_relative_path!("../res").to_string()])
.unwrap(); .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::select! {
_ = tokio::signal::ctrl_c() => (), _ = tokio::signal::ctrl_c() => (),
e = event_loop => e??, e = event_loop => e?,
}; };
Ok(()) Ok(())
} }
@@ -51,7 +69,7 @@ struct AppGrid {
//style: TextStyle, //style: TextStyle,
} }
impl AppGrid { impl AppGrid {
fn new(client: &Client) -> Self { fn new(client: &ClientHandle) -> Self {
let apps = get_desktop_files() let apps = get_desktop_files()
.filter_map(|d| parse_desktop_file(d).ok()) .filter_map(|d| parse_desktop_file(d).ok())
.filter(|d| !d.no_display) .filter(|d| !d.no_display)
@@ -74,7 +92,7 @@ impl AppGrid {
AppGrid { apps } AppGrid { apps }
} }
} }
impl RootHandler for AppGrid { impl AppGrid {
fn frame(&mut self, info: FrameInfo) { fn frame(&mut self, info: FrameInfo) {
for app in &mut self.apps { for app in &mut self.apps {
app.frame(&info); 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 { match &icon.icon_type {
IconType::Png => { IconType::Png => {
// let t = Transform::from_rotation_scale( // let t = Transform::from_rotation_scale(
@@ -127,7 +145,7 @@ pub struct App {
} }
impl App { impl App {
pub fn create_from_desktop_file( pub fn create_from_desktop_file(
parent: &impl SpatialRefAspect, parent: &SpatialRef,
position: impl Into<Vector3<f32>>, position: impl Into<Vector3<f32>>,
desktop_file: DesktopFile, desktop_file: DesktopFile,
) -> Result<Self> { ) -> Result<Self> {
@@ -145,12 +163,12 @@ impl App {
}, },
)?; )?;
grabbable.content_parent().set_spatial_parent(parent)?; grabbable.content_parent().set_spatial_parent(parent)?;
field.set_spatial_parent(grabbable.content_parent())?; field.set_spatial_parent(&grabbable.content_parent())?;
let icon = icon let icon = icon
.map(|i| model_from_icon(grabbable.content_parent(), &i)) .map(|i| model_from_icon(&grabbable.content_parent(), &i))
.unwrap_or_else(|| { .unwrap_or_else(|| {
Ok(Model::create( Ok(Model::create(
grabbable.content_parent(), &grabbable.content_parent(),
Transform::from_rotation(Quat::from_rotation_y(PI)), Transform::from_rotation(Quat::from_rotation_y(PI)),
&ResourceID::new_namespaced("protostar", "cartridge"), &ResourceID::new_namespaced("protostar", "cartridge"),
)?) )?)
@@ -198,7 +216,10 @@ impl App {
// } // }
fn frame(&mut self, info: &FrameInfo) { 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() { if self.grabbable.grab_action().actor_stopped() {
self.grabbable.cancel_angular_velocity(); self.grabbable.cancel_angular_velocity();
@@ -210,8 +231,8 @@ impl App {
// } // }
let application = self.application.clone(); let application = self.application.clone();
let space = self.content_parent().alias(); let space = self.content_parent().clone();
let root = self.root.alias(); let root = self.root.clone();
tokio::task::spawn(async move { tokio::task::spawn(async move {
let Ok(transform) = space.get_transform(&root).await else { let Ok(transform) = space.get_transform(&root).await else {

View File

@@ -1,7 +1,10 @@
[package] [package]
name = "hexagon_launcher" name = "hexagon_launcher"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[features]
tracy = ["dep:tracing-tracy", "stardust-xr-asteroids/tracy"]
[dependencies] [dependencies]
protostar = { path = "../protostar" } protostar = { path = "../protostar" }
@@ -9,9 +12,19 @@ color-eyre = "0.6.2"
clap = "4.4.6" clap = "4.4.6"
manifest-dir-macros = "0.1.18" manifest-dir-macros = "0.1.18"
glam = { version = "0.25.0", features = ["mint"] } glam = { version = "0.25.0", features = ["mint"] }
tween = "2.0.1" mint = "0.5.9"
tracing-subscriber = "0.3.17" 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 } tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
stardust-xr-fusion = { 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 std::ops::Add;
use crate::{APP_SIZE, PADDING}; use single::{APP_SIZE, PADDING};
use tween::TweenTime;
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
pub struct Hex { pub struct Hex {
@@ -26,9 +27,9 @@ impl Hex {
} }
pub fn get_coords(&self) -> [f32; 3] { 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 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] [x, y, 0.0]
} }
@@ -39,6 +40,42 @@ impl Hex {
pub fn scale(self, factor: isize) -> Self { pub fn scale(self, factor: isize) -> Self {
Hex::new(self.q * factor, self.r * factor, self.s * factor) 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 { impl Add for Hex {
type Output = Hex; type Output = Hex;

View File

@@ -1,230 +1,149 @@
pub mod app; mod hex;
pub mod hex;
use app::App;
use color_eyre::eyre::Result;
use glam::Quat; use glam::Quat;
use hex::{HEX_CENTER, HEX_DIRECTION_VECTORS}; use hex::Hex;
use manifest_dir_macros::directory_relative_path; use mint::{Quaternion, Vector3};
use protostar::xdg::{get_desktop_files, parse_desktop_file, DesktopFile}; use protostar::xdg::{DesktopFile, get_desktop_files};
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use serde::{Deserialize, Serialize}; 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::{ use stardust_xr_fusion::{
client::Client, drawable::MaterialParameter,
core::values::{ fields::{CylinderShape, Shape},
color::{color_space::LinearRgb, rgba_linear, Rgba}, project_local_resources,
ResourceID, spatial::Transform,
},
drawable::{MaterialParameter, Model, ModelPartAspect},
node::{NodeError, NodeType},
root::{ClientState, FrameInfo, RootAspect, RootHandler},
spatial::{Spatial, SpatialAspect, Transform},
}; };
use stardust_xr_molecules::{ use std::f32::consts::{FRAC_PI_2, PI};
button::{Button, ButtonSettings}, use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt};
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);
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> { async fn main() {
color_eyre::install().unwrap(); 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 let registry = tracing_subscriber::registry();
.get_root() #[cfg(feature = "tracy")]
.alias() let registry = registry.with({
.wrap(AppHexGrid::new(&client).await)?; 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! { client::run::<HexagonLauncher>(&[&project_local_resources!("../res")]).await
_ = tokio::signal::ctrl_c() => (),
e = event_loop => e??,
}
Ok(())
} }
#[derive(Debug, Default, Clone, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct State { pub struct HexagonLauncher {
unfurled: bool, /// if the hexagon launcher is expanded
} open: bool,
pos: Vector3<f32>,
struct AppHexGrid { rot: Quaternion<f32>,
movable_root: Spatial, #[serde(skip)]
/// position in the vector is mapped to hex coordinates
apps: Vec<App>, 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 = impl Default for HexagonLauncher {
Spatial::create(client.get_root(), Transform::identity(), false).unwrap(); 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(); impl ClientState for HexagonLauncher {
tokio::time::sleep(Duration::from_millis(10)).await; // give it a bit of time to send the messages properly const APP_ID: &'static str = "org.protostar.hexagon_launcher";
let mut desktop_files: Vec<DesktopFile> = get_desktop_files() fn initial_state_update(&mut self) {
.filter_map(|d| parse_desktop_file(d).ok()) // Load desktop files
self.apps = get_desktop_files()
.filter_map(|d| DesktopFile::parse(d).ok())
.filter(|d| !d.no_display) .filter(|d| !d.no_display)
.filter_map(|d| App::new(d).ok())
.collect(); .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(); // Sort by name
let mut radius = 1; self.apps
while !desktop_files.is_empty() { .sort_by_key(|app| app.app.name().unwrap_or_default().to_string());
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,
}
} }
} }
impl RootHandler for AppHexGrid { impl Reify for HexagonLauncher {
fn frame(&mut self, info: FrameInfo) { #[tracing::instrument(skip_all)]
self.button.frame(&info); fn reify(&self) -> impl Element<Self> {
if self.button.button.pressed() { // Build UI based on current state
self.button Grabbable::new(
.model Shape::Cylinder(CylinderShape {
.part("Hex") radius: APP_SIZE / 2.0,
.unwrap() length: 0.01,
.set_material_parameter("color", MaterialParameter::Color(BTN_SELECTED_COLOR)) }),
.unwrap(); self.pos,
self.state.unfurled = !self.state.unfurled; self.rot,
for app in &mut self.apps { |state: &mut Self, pos, rot| {
app.apply_state(&self.state); state.pos = pos;
} state.rot = rot;
} else if self.button.button.released() { },
self.button )
.model .field_transform(Transform::from_rotation(Quat::from_rotation_x(FRAC_PI_2)))
.part("Hex") .pointer_mode(PointerMode::Align)
.unwrap() .reparentable(true)
.set_material_parameter("color", MaterialParameter::Color(BTN_COLOR)) .build()
.unwrap(); .child(
} Button::new(|state: &mut HexagonLauncher| {
for app in &mut self.apps { state.open = !state.open;
app.frame(&info, &self.state); })
} .pos([0.0, 0.0, 0.005])
} .size([APP_SIZE / 2.0; 2])
.build(),
fn save_state(&mut self) -> Result<ClientState> { )
self.movable_root .child(
.set_relative_transform( Model::namespaced("protostar", "hexagon/hexagon")
self.button.grabbable.content_parent(), .transform(Transform::from_rotation_scale(
Transform::from_translation([0.0; 3]), Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI),
) [MODEL_SCALE; 3],
.unwrap(); ))
ClientState::new( .part(ModelPart::new("Hex").mat_param(
Some(self.state.clone()), "color",
&self.movable_root, MaterialParameter::Color(if self.open {
[( BTN_SELECTED_COLOR
"content_parent".to_string(), } else {
self.button.grabbable.content_parent(), BTN_COLOR
)] }),
.into_iter() ))
.collect(), .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)
}))
})
})
.into_iter()
.flatten(),
) )
} }
} }
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()
},
)?;
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(
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,
})
}
fn frame(&mut self, info: &FrameInfo) {
let _ = self.grabbable.update(info);
self.button.update();
}
}

View File

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

View File

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

View File

@@ -95,87 +95,11 @@ pub fn get_desktop_files() -> impl Iterator<Item = PathBuf> {
#[test] #[test]
fn test_get_desktop_files() { fn test_get_desktop_files() {
let desktop_files = get_desktop_files().collect::<Vec<_>>(); let desktop_files = get_desktop_files().collect::<Vec<_>>();
assert!(desktop_files assert!(
.iter() desktop_files
.any(|file| file.ends_with("com.belmoussaoui.ashpd.demo.desktop"))); .iter()
} .any(|file| file.ends_with("com.belmoussaoui.ashpd.demo.desktop"))
);
pub fn parse_desktop_file(path: PathBuf) -> Result<DesktopFile, String> {
// Open the file in read-only mode
let file = match fs::File::open(
env::current_dir()
.map_err(|e| e.to_string())?
.join(path.clone()),
) {
Ok(file) => file,
Err(err) => return Err(format!("Failed to open file: {}", err)),
};
let reader = BufReader::new(file);
// Create temporary variables to hold the parsed values
let mut name = None;
let mut command = None;
let mut categories = Vec::new();
let mut icon = None;
let mut no_display = false;
let mut desktop_entry_found = false;
let re = Regex::new(r"^\[([^\]]*)\]$").unwrap();
// Loop through each line of the file
for line in reader.lines() {
let line = match line {
Ok(line) => line,
Err(err) => return Err(format!("Failed to read line: {}", err)),
};
// Skip empty lines and lines that start with "#" (comments)
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(captures) = re.captures(&line) {
let entry = captures.get(1).unwrap();
desktop_entry_found = entry.as_str().contains("Desktop Entry");
}
if !desktop_entry_found {
continue;
}
// Split the line into a key-value pair by looking for the first "=" character
let parts = line.split_once('=');
let (key, value) = match parts {
Some((key, value)) => (key, value),
None => continue,
};
// Parse the key-value pair based on the key
match key {
"Name" => name = Some(value.to_string()),
"Exec" => command = Some(value.to_string()),
"Categories" => {
categories = value
.split(';')
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
.collect()
}
"Icon" => icon = Some(value.to_string()),
"NoDisplay" => no_display = value == "true",
_ => (), // Ignore unknown keys
}
}
// Create and return a new DesktopFile instance with the parsed values
Ok(DesktopFile {
path,
name,
command,
categories,
icon,
no_display,
})
} }
#[test] #[test]
@@ -187,7 +111,7 @@ fn test_parse_desktop_file() {
fs::write(&file, data).unwrap(); fs::write(&file, data).unwrap();
// Parse the test desktop file // Parse the test desktop file
let desktop_file = parse_desktop_file(file).unwrap(); let desktop_file = DesktopFile::parse(file).unwrap();
// Check the parsed values // Check the parsed values
assert_eq!(desktop_file.name, Some("Test".to_string())); assert_eq!(desktop_file.name, Some("Test".to_string()));
@@ -198,8 +122,8 @@ fn test_parse_desktop_file() {
); );
assert_eq!(desktop_file.icon, Some("test.png".to_string())); assert_eq!(desktop_file.icon, Some("test.png".to_string()));
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone)] #[serde(into = "PathBuf", from = "PathBuf")]
pub struct DesktopFile { pub struct DesktopFile {
path: PathBuf, path: PathBuf,
pub name: Option<String>, pub name: Option<String>,
@@ -209,6 +133,97 @@ pub struct DesktopFile {
pub no_display: bool, 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()
.map_err(|e| e.to_string())?
.join(path.clone()),
) {
Ok(file) => file,
Err(err) => return Err(format!("Failed to open file: {err}")),
};
let reader = BufReader::new(file);
// Create temporary variables to hold the parsed values
let mut name = None;
let mut command = None;
let mut categories = Vec::new();
let mut icon = None;
let mut no_display = false;
let mut desktop_entry_found = false;
let re = Regex::new(r"^\[([^\]]*)\]$").unwrap();
// Loop through each line of the file
for line in reader.lines() {
let line = match line {
Ok(line) => line,
Err(err) => return Err(format!("Failed to read line: {err}")),
};
// Skip empty lines and lines that start with "#" (comments)
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(captures) = re.captures(&line) {
let entry = captures.get(1).unwrap();
desktop_entry_found = entry.as_str().contains("Desktop Entry");
}
if !desktop_entry_found {
continue;
}
// Split the line into a key-value pair by looking for the first "=" character
let parts = line.split_once('=');
let (key, value) = match parts {
Some((key, value)) => (key, value),
None => continue,
};
// Parse the key-value pair based on the key
match key {
"Name" => name = Some(value.to_string()),
"Exec" => command = Some(value.to_string()),
"Categories" => {
categories = value
.split(';')
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
.collect()
}
"Icon" => icon = Some(value.to_string()),
"NoDisplay" => no_display = value == "true",
_ => (), // Ignore unknown keys
}
}
// Create and return a new DesktopFile instance with the parsed values
Ok(DesktopFile {
path,
name,
command,
categories,
icon,
no_display,
})
}
}
const ICON_SIZES: [u16; 7] = [512, 256, 128, 64, 48, 32, 24]; const ICON_SIZES: [u16; 7] = [512, 256, 128, 64, 48, 32, 24];
impl DesktopFile { impl DesktopFile {

View File

@@ -1,7 +1,8 @@
[package] [package]
name = "single" name = "single"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
rust-version = "1.88"
[dependencies] [dependencies]
protostar = { path = "../protostar" } protostar = { path = "../protostar" }
@@ -10,7 +11,11 @@ clap = "4.4.6"
manifest-dir-macros = "0.1.18" manifest-dir-macros = "0.1.18"
glam = "0.24.2" glam = "0.24.2"
tween = "2.0.1" tween = "2.0.1"
mint = "0.5.9"
tracing = "0.1.41"
tracing-subscriber = "0.3.17" tracing-subscriber = "0.3.17"
tokio = { workspace = true } tokio = { workspace = true }
serde = { workspace = true }
stardust-xr-fusion = { workspace = true } stardust-xr-fusion = { workspace = true }
stardust-xr-molecules = { 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 clap::Parser;
use color_eyre::{eyre::Result, Report}; use protostar::xdg::DesktopFile;
use manifest_dir_macros::directory_relative_path; use serde::{Deserialize, Serialize};
use protostar::xdg::parse_desktop_file; use single::App;
use stardust_xr_fusion::{client::Client, node::NodeType, root::RootAspect}; use stardust_xr_fusion::project_local_resources;
use std::path::PathBuf; use std::path::PathBuf;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use crate::single::Single;
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[clap(author, version, about, long_about = None)] #[clap(author, version, about, long_about = None)]
struct Args { struct Args {
@@ -18,27 +15,39 @@ struct Args {
} }
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> { async fn main() {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.compact() .compact()
.with_env_filter(EnvFilter::from_env("LOG_LEVEL")) .with_env_filter(EnvFilter::from_env("LOG_LEVEL"))
.init(); .init();
color_eyre::install()?; client::run::<Single>(&[&project_local_resources!("../res")]).await
let args = Args::parse(); }
let (client, event_loop) = Client::connect_with_async_loop().await?;
client.set_base_prefixes(&[directory_relative_path!("../res")])?; #[derive(Default, Debug, Serialize, Deserialize)]
pub struct Single {
let protostar = Single::create_from_desktop_file( app: Option<App>,
client.get_root(), }
[0.0, 0.0, 0.0], impl Migrate for Single {
parse_desktop_file(args.desktop_file).map_err(Report::msg)?, type Old = Self;
)?; }
impl ClientState for Single {
let _root = client.get_root().alias().wrap(protostar)?; const APP_ID: &'static str = "org.stardustxr.protostar.single";
tokio::select! { fn initial_state_update(&mut self) {
_ = tokio::signal::ctrl_c() => (), let desktop_file_path = Args::parse().desktop_file;
e = event_loop => e??,
}; let app = App::new(DesktopFile::parse(desktop_file_path).unwrap()).unwrap();
Ok(()) 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] [package]
name = "sirius" name = "sirius"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[features]
tracy = ["dep:tracing-tracy", "stardust-xr-asteroids/tracy"]
[dependencies] [dependencies]
protostar = { path = "../protostar" } protostar = { path = "../protostar" }
color-eyre = "0.6.2" color-eyre = "0.6.2"
clap = "4.4.6" clap = "4.4.6"
manifest-dir-macros = "0.1.18" manifest-dir-macros = "0.1.18"
glam = "0.24.2" glam = { version = "0.25.0", features = ["mint"] }
tween = "2.0.1" mint = "0.5.9"
tracing-subscriber = "0.3.17" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
walkdir = "2.4.0" tracing-tracy = { version = "0.11.4", optional = true }
tokio = { workspace = true } tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
stardust-xr-fusion = { 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 clap::Parser;
use color_eyre::eyre::Result; use glam::Quat;
use glam::{Quat, Vec3}; use mint::{Quaternion, Vector3};
use manifest_dir_macros::directory_relative_path; use protostar::xdg::DesktopFile;
use protostar::{
application::Application,
xdg::{parse_desktop_file, DesktopFile, Icon, IconType},
};
use serde::{Deserialize, Serialize}; 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::{ use stardust_xr_fusion::{
client::Client, drawable::MaterialParameter, fields::Shape, project_local_resources, spatial::Transform,
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},
}; };
use stardust_xr_molecules::{ use std::path::PathBuf;
button::{Button, ButtonSettings}, use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt};
Grabbable, GrabbableSettings,
};
use std::{f32::consts::PI, path::PathBuf};
use tween::{QuartInOut, Tweener};
use walkdir::WalkDir; use walkdir::WalkDir;
const APP_SIZE: f32 = 0.06; #[tokio::main(flavor = "current_thread")]
const ACTIVATION_DISTANCE: f32 = 0.5; 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)] #[derive(Debug, Parser)]
#[clap(author, version, about, long_about = None)] #[clap(author, version, about, long_about = None)]
@@ -38,67 +43,45 @@ struct Args {
apps_directory: PathBuf, apps_directory: PathBuf,
} }
#[tokio::main(flavor = "current_thread")] #[derive(Debug, Serialize, Deserialize)]
async fn main() -> Result<()> { pub struct Sirius {
color_eyre::install()?;
let args = Args::parse();
if !args.apps_directory.is_dir() {
panic!(
"{} is not a direcotry",
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, visible: bool,
pos: Vector3<f32>,
rot: Quaternion<f32>,
#[serde(skip)]
apps: Vec<App>,
} }
struct Sirius { impl Default for Sirius {
button: Button, fn default() -> Self {
model: Model, Self {
clients: Vec<App>, visible: false,
state: State, pos: [0.0; 3].into(),
grabbable: Grabbable, rot: Quat::IDENTITY.into(),
apps: Vec::new(),
}
}
} }
impl Sirius {
fn new(client: &Client, args: Args) -> Result<Self, NodeError> {
let root = Spatial::create(client.get_root(), Transform::identity(), false).unwrap();
let field = impl Migrate for Sirius {
Field::create(&root, Transform::identity(), Shape::Box([0.1; 3].into())).unwrap(); type Old = Self;
let grabbable = Grabbable::create( }
&root,
Transform::identity(), impl ClientState for Sirius {
&field, const APP_ID: &'static str = "org.protostar.sirius";
GrabbableSettings::default(),
)?; fn initial_state_update(&mut self) {
let button = Button::create( let args = Args::parse();
grabbable.content_parent(), if !args.apps_directory.is_dir() {
Transform::identity(), panic!(
[0.1; 2], "{} is not a directory",
ButtonSettings::default(), args.apps_directory.to_string_lossy()
)?; )
}
let walkdir = WalkDir::new(args.apps_directory.canonicalize().unwrap()); let walkdir = WalkDir::new(args.apps_directory.canonicalize().unwrap());
let clients: Vec<App> = walkdir self.apps = walkdir
.into_iter() .into_iter()
.filter_map(|path| path.ok()) .filter_map(|path| path.ok())
.map(|entry| entry.into_path()) .map(|entry| entry.into_path())
@@ -107,365 +90,62 @@ impl Sirius {
&& path.extension().is_some() && path.extension().is_some()
&& path.extension().unwrap() == "desktop" && path.extension().unwrap() == "desktop"
}) })
.filter_map(|path| { .filter_map(|path| App::new(DesktopFile::parse(path).ok()?).ok())
App::create_from_desktop_file(
grabbable.content_parent(),
[0.0; 3],
parse_desktop_file(path).ok()?,
)
.ok()
})
.collect(); .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 { impl Reify for Sirius {
fn frame(&mut self, info: FrameInfo) { fn reify(&self) -> impl Element<Self> {
for app in &mut self.clients { Grabbable::new(
app.frame(&info); Shape::Box([0.1; 3].into()),
} self.pos,
self.rot,
self.grabbable.update(&info).unwrap(); |state: &mut Self, pos, rot| {
self.button.update(); state.pos = pos;
if self.button.pressed() { state.rot = rot;
println!("Touch started"); },
self.state.visible = !self.state.visible; )
match self.state.visible { .pointer_mode(PointerMode::Align)
true => { .reparentable(true)
for (pos, star) in self.clients.iter().enumerate() { .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; let mut starpos = (pos as f32 + 1.0) / 10.0;
match starpos % 0.2 == 0.0 { match starpos % 0.2 == 0.0 {
true => starpos = -starpos / 2.0, true => starpos = -starpos / 2.0,
false => starpos = (starpos - 0.1) / 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() { Spatial::default().pos([starpos, 0.1, 0.0]).build().child(
println!("Touch ended"); app.reify_substate(move |state: &mut Sirius| state.apps.get_mut(pos)),
self.model )
.part("?????") })
.unwrap() })
.set_material_parameter( .into_iter()
"color", .flatten(),
MaterialParameter::Color(rgba_linear!(1.0, 0.0, 0.0, 1.0)),
)
.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),
)
.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);
}
});
}
}
}