use std::path::PathBuf;
use bevy::{
prelude::*,
tasks::{AsyncComputeTaskPool, Task, futures_lite::future},
window::{PrimaryWindow, RawHandleWrapper},
};
use jackdaw_feathers::{
button::{ButtonVariant, IconButtonProps, icon_button},
icons::{EditorFont, Icon},
tokens,
};
use rfd::AsyncFileDialog;
use crate::{
AppState,
project::{self, ProjectRoot},
};
pub struct ProjectSelectPlugin;
impl Plugin for ProjectSelectPlugin {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(AppState::ProjectSelect), spawn_project_selector)
.add_systems(
Update,
poll_folder_dialog.run_if(in_state(AppState::ProjectSelect)),
);
}
}
#[derive(Component)]
struct ProjectSelectorRoot;
#[derive(Resource)]
pub struct PendingAutoOpen {
pub path: PathBuf,
}
#[derive(Resource)]
struct FolderDialogTask(Task<Option<rfd::FileHandle>>);
fn spawn_project_selector(
mut commands: Commands,
editor_font: Res<EditorFont>,
icon_font: Res<jackdaw_feathers::icons::IconFont>,
pending: Option<Res<PendingAutoOpen>>,
) {
if let Some(pending) = pending {
let path = pending.path.clone();
commands.remove_resource::<PendingAutoOpen>();
commands.queue(move |world: &mut World| {
select_project(world, path);
});
return;
}
let recent = project::read_recent_projects();
let font = editor_font.0.clone();
let icon_font_handle = icon_font.0.clone();
let cwd = std::env::current_dir().unwrap_or_default();
let cwd_has_project = cwd.join(".jsn/project.jsn").is_file()
|| cwd.join("project.jsn").is_file()
|| cwd.join("assets").is_dir();
commands.spawn((ProjectSelectorRoot, Camera2d));
commands
.spawn((
ProjectSelectorRoot,
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..Default::default()
},
BackgroundColor(tokens::WINDOW_BG),
))
.with_children(|parent| {
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
padding: UiRect::all(Val::Px(32.0)),
row_gap: Val::Px(24.0),
min_width: Val::Px(420.0),
max_width: Val::Px(520.0),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(8.0)),
..Default::default()
})
.insert(BackgroundColor(tokens::PANEL_BG))
.insert(BorderColor::all(tokens::BORDER_SUBTLE))
.with_children(|card| {
card.spawn((
Text::new("jackdaw"),
TextFont {
font: font.clone(),
font_size: 28.0,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
));
card.spawn((
Text::new("Select a project to open"),
TextFont {
font: font.clone(),
font_size: tokens::FONT_LG,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
));
if cwd_has_project {
let cwd_name = cwd
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| cwd.to_string_lossy().to_string());
let cwd_clone = cwd.clone();
spawn_project_row(
card,
&cwd_name,
&cwd.to_string_lossy(),
font.clone(),
icon_font_handle.clone(),
cwd_clone,
true,
);
}
if !recent.projects.is_empty() {
card.spawn((
Text::new("Recent Projects"),
TextFont {
font: font.clone(),
font_size: tokens::FONT_MD,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
Node {
margin: UiRect::top(Val::Px(8.0)),
..Default::default()
},
));
for entry in &recent.projects {
if cwd_has_project && entry.path == cwd {
continue;
}
spawn_project_row(
card,
&entry.name,
&entry.path.to_string_lossy(),
font.clone(),
icon_font_handle.clone(),
entry.path.clone(),
false,
);
}
}
let browse_entity = card
.spawn((
Node {
padding: UiRect::axes(Val::Px(20.0), Val::Px(10.0)),
border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_MD)),
margin: UiRect::top(Val::Px(8.0)),
justify_content: JustifyContent::Center,
..Default::default()
},
BackgroundColor(tokens::SELECTED_BG),
children![(
Text::new("Browse..."),
TextFont {
font: font.clone(),
font_size: tokens::FONT_LG,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
)],
))
.id();
card.commands().entity(browse_entity).observe(
|hover: On<Pointer<Over>>, mut bg: Query<&mut BackgroundColor>| {
if let Ok(mut bg) = bg.get_mut(hover.event_target()) {
bg.0 = tokens::SELECTED_BORDER;
}
},
);
card.commands().entity(browse_entity).observe(
|out: On<Pointer<Out>>, mut bg: Query<&mut BackgroundColor>| {
if let Ok(mut bg) = bg.get_mut(out.event_target()) {
bg.0 = tokens::SELECTED_BG;
}
},
);
card.commands()
.entity(browse_entity)
.observe(spawn_browse_dialog);
});
});
}
fn spawn_project_row(
parent: &mut ChildSpawnerCommands,
name: &str,
path_display: &str,
font: Handle<Font>,
icon_font: Handle<Font>,
project_path: PathBuf,
is_cwd: bool,
) {
let row_entity = parent
.spawn((
Node {
flex_direction: FlexDirection::Row,
width: Val::Percent(100.0),
padding: UiRect::all(Val::Px(10.0)),
border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_MD)),
align_items: AlignItems::Center,
..Default::default()
},
BackgroundColor(tokens::TOOLBAR_BG),
))
.id();
let info_column = parent
.commands()
.spawn((
Node {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
row_gap: Val::Px(2.0),
..Default::default()
},
children![
(
Node {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(8.0),
align_items: AlignItems::Center,
..Default::default()
},
children![
(
Text::new(name.to_string()),
TextFont {
font: font.clone(),
font_size: tokens::FONT_LG,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
),
if_cwd_badge(is_cwd, font.clone()),
],
),
(
Text::new(path_display.to_string()),
TextFont {
font: font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
),
],
Pickable::IGNORE,
))
.id();
parent.commands().entity(row_entity).add_child(info_column);
if !is_cwd {
let remove_path = project_path.clone();
let x_button = parent
.commands()
.spawn(icon_button(
IconButtonProps::new(Icon::X).variant(ButtonVariant::Ghost),
&icon_font,
))
.id();
parent.commands().entity(x_button).observe(
move |mut click: On<Pointer<Click>>, mut commands: Commands| {
click.propagate(false);
let path = remove_path.clone();
project::remove_recent(&path);
commands.entity(row_entity).try_despawn();
},
);
parent.commands().entity(row_entity).add_child(x_button);
}
parent.commands().entity(row_entity).observe(
|hover: On<Pointer<Over>>, mut bg: Query<&mut BackgroundColor>| {
if let Ok(mut bg) = bg.get_mut(hover.event_target()) {
bg.0 = tokens::HOVER_BG;
}
},
);
parent.commands().entity(row_entity).observe(
|out: On<Pointer<Out>>, mut bg: Query<&mut BackgroundColor>| {
if let Ok(mut bg) = bg.get_mut(out.event_target()) {
bg.0 = tokens::TOOLBAR_BG;
}
},
);
parent.commands().entity(row_entity).observe(
move |_: On<Pointer<Click>>, mut commands: Commands| {
let path = project_path.clone();
commands.queue(move |world: &mut World| {
select_project(world, path);
});
},
);
}
fn if_cwd_badge(is_cwd: bool, font: Handle<Font>) -> impl Bundle {
let text = if is_cwd { "current dir" } else { "" };
(
Text::new(text.to_string()),
TextFont {
font,
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_ACCENT),
)
}
fn spawn_browse_dialog(
_: On<Pointer<Click>>,
mut commands: Commands,
raw_handle: Query<&RawHandleWrapper, With<PrimaryWindow>>,
) {
let mut dialog = AsyncFileDialog::new().set_title("Select project folder");
if let Ok(rh) = raw_handle.single() {
let handle = unsafe { rh.get_handle() };
dialog = dialog.set_parent(&handle);
}
let task = AsyncComputeTaskPool::get().spawn(async move { dialog.pick_folder().await });
commands.insert_resource(FolderDialogTask(task));
}
fn poll_folder_dialog(world: &mut World) {
let Some(mut task_res) = world.get_resource_mut::<FolderDialogTask>() else {
return;
};
let Some(result) = future::block_on(future::poll_once(&mut task_res.0)) else {
return;
};
world.remove_resource::<FolderDialogTask>();
if let Some(handle) = result {
let path = handle.path().to_path_buf();
select_project(world, path);
}
}
fn select_project(world: &mut World, root: PathBuf) {
let config = project::load_project_config(&root)
.unwrap_or_else(|| project::create_default_project(&root));
project::touch_recent(&root, &config.project.name);
world.insert_resource(ProjectRoot {
root: root.clone(),
config,
});
let mut to_despawn = Vec::new();
let mut query = world.query_filtered::<Entity, With<ProjectSelectorRoot>>();
for entity in query.iter(world) {
to_despawn.push(entity);
}
for entity in to_despawn {
if let Ok(ec) = world.get_entity_mut(entity) {
ec.despawn();
}
}
let mut next_state = world.resource_mut::<NextState<AppState>>();
next_state.set(AppState::Editor);
}