use std::path::{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},
text_edit::{TextEditProps, TextEditValue, text_edit},
tokens,
};
use rfd::{AsyncFileDialog, FileHandle};
use crate::{
AppState,
new_project::{ScaffoldError, TemplateLinkage, TemplatePreset, scaffold_project},
project::{self, ProjectRoot},
scene_io,
};
pub struct ProjectSelectPlugin;
impl Plugin for ProjectSelectPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<NewProjectState>()
.init_resource::<crate::build_status::BuildStatus>()
.add_systems(OnEnter(AppState::ProjectSelect), spawn_project_selector)
.add_systems(
Update,
(
poll_folder_dialog,
poll_template_folder_dialog,
refresh_build_progress_ui,
)
.run_if(in_state(AppState::ProjectSelect)),
)
.add_systems(
Update,
(
poll_new_project_tasks,
refresh_build_progress_snapshot,
drive_static_editor_build,
),
)
.add_systems(
Last,
(apply_pending_install, apply_pending_static_open)
.run_if(in_state(AppState::ProjectSelect)),
);
}
}
#[derive(Component)]
struct ProjectSelectorRoot;
#[derive(Resource)]
pub struct PendingAutoOpen {
pub path: PathBuf,
pub skip_build: bool,
}
#[derive(Resource)]
struct FolderDialogTask(Task<Option<rfd::FileHandle>>);
#[derive(Resource)]
struct TemplateFolderDialogTask(Task<Option<rfd::FileHandle>>);
#[derive(Component)]
struct NewProjectModalRoot;
#[derive(Component)]
struct NewProjectNameInput;
#[derive(Component)]
struct NewProjectTemplateInput;
#[derive(Component)]
struct NewProjectLocalTemplateInput;
#[derive(Component)]
struct NewProjectLocalBrowseButton;
#[derive(Component)]
struct NewProjectBranchInput;
#[derive(Component)]
struct NewProjectLocationText;
#[derive(Component)]
struct NewProjectStatusText;
#[derive(Component)]
struct BuildAfterScaffoldCheckbox;
#[derive(Component)]
struct NewProjectProgressContainer;
#[derive(Component)]
struct NewProjectProgressCrateLabel;
#[derive(Component)]
struct NewProjectProgressBarSlot;
#[derive(Component)]
struct NewProjectLogText;
#[derive(Component)]
struct NewProjectCancelButton;
#[derive(Component)]
struct NewProjectCreateButton;
#[derive(Component)]
struct NewProjectBrowseButton;
#[derive(Component, Clone, Copy)]
struct NewProjectLinkageButton(TemplateLinkage);
#[derive(Default)]
struct StaticEditorBuild {
pending: Option<(PathBuf, bool)>,
task: Option<Task<Result<PathBuf, crate::ext_build::BuildError>>>,
auto_reload: bool,
}
#[derive(Resource, Default)]
struct NewProjectState {
preset: Option<TemplatePreset>,
linkage: TemplateLinkage,
location: PathBuf,
folder_task: Option<Task<Option<FileHandle>>>,
scaffold_task: Option<Task<Result<PathBuf, ScaffoldError>>>,
build_task: Option<(
Task<Result<PathBuf, crate::ext_build::BuildError>>,
TemplateLinkage,
)>,
pending_install: Option<PathBuf>,
pending_static_open: Option<PathBuf>,
static_editor: StaticEditorBuild,
build_after_scaffold: bool,
build_progress: Option<std::sync::Arc<std::sync::Mutex<crate::ext_build::BuildProgress>>>,
build_progress_snapshot: Option<crate::ext_build::BuildProgress>,
pending_project: Option<PathBuf>,
status: Option<String>,
}
fn default_projects_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join("Projects"))
.unwrap_or_else(|| PathBuf::from("."))
}
fn spawn_project_selector(
mut commands: Commands,
editor_font: Res<EditorFont>,
icon_font: Res<jackdaw_feathers::icons::IconFont>,
pending: Option<Res<PendingAutoOpen>>,
) {
commands.spawn((ProjectSelectorRoot, Camera2d));
if let Some(pending) = pending {
let path = pending.path.clone();
let skip_build = pending.skip_build;
commands.remove_resource::<PendingAutoOpen>();
commands.queue(move |world: &mut World| {
enter_project_with(world, path, skip_build);
});
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,
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 new_row = card
.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(8.0),
margin: UiRect::top(Val::Px(8.0)),
..Default::default()
})
.id();
spawn_new_project_button(
card,
new_row,
"+ New Extension",
font.clone(),
TemplatePreset::Extension,
);
spawn_new_project_button(
card,
new_row,
"+ New Game",
font.clone(),
TemplatePreset::Game,
);
spawn_new_project_button(
card,
new_row,
"+ From URL…",
font.clone(),
TemplatePreset::Custom(String::new()),
);
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(4.0)),
justify_content: JustifyContent::Center,
..Default::default()
},
BackgroundColor(tokens::SELECTED_BG),
children![(
Text::new("Open existing project..."),
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| {
enter_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();
enter_project(world, path);
}
}
pub fn enter_project(world: &mut World, root: PathBuf) {
enter_project_with(world, root, false);
}
pub fn enter_project_with(world: &mut World, root: PathBuf, skip_build: bool) {
if skip_build || !root.join("Cargo.toml").is_file() {
transition_to_editor(world, root);
return;
}
if matches!(
crate::new_project::detect_template_kind(&root),
crate::new_project::TemplateKind::StaticGameWithEditorFeature
) {
let project_name = root
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("project")
.to_owned();
let scaffold_modal_already_open = {
let mut q = world.query_filtered::<Entity, With<NewProjectModalRoot>>();
q.iter(world).next().is_some()
};
if !scaffold_modal_already_open {
open_project_progress_modal(world, &project_name);
}
let progress = std::sync::Arc::new(std::sync::Mutex::new(
crate::ext_build::BuildProgress::default(),
));
let mut state = world.resource_mut::<NewProjectState>();
state.pending_project = Some(root.clone());
state.status = Some(format!("Building editor for `{project_name}`…"));
state.build_progress = Some(std::sync::Arc::clone(&progress));
state.build_progress_snapshot = Some(crate::ext_build::BuildProgress::default());
state.static_editor.pending = Some((root, true));
return;
}
if !crate::ext_build::manifest_declares_cdylib(&root) {
info!(
"Project at {} has a Cargo.toml but no cdylib target; \
opening without building.",
root.display()
);
transition_to_editor(world, root);
return;
}
let project_name = root
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("project")
.to_owned();
let scaffold_modal_already_open = {
let mut q = world.query_filtered::<Entity, With<NewProjectModalRoot>>();
q.iter(world).next().is_some()
};
if !scaffold_modal_already_open {
open_project_progress_modal(world, &project_name);
}
let progress = std::sync::Arc::new(std::sync::Mutex::new(
crate::ext_build::BuildProgress::default(),
));
{
let mut state = world.resource_mut::<NewProjectState>();
state.pending_project = Some(root.clone());
state.status = Some(format!("Building `{project_name}`…"));
state.build_progress = Some(std::sync::Arc::clone(&progress));
state.build_progress_snapshot = Some(crate::ext_build::BuildProgress::default());
}
let root_for_task = root;
let progress_for_task = std::sync::Arc::clone(&progress);
let task = AsyncComputeTaskPool::get().spawn(async move {
crate::ext_build::build_extension_project_with_progress(
&root_for_task,
Some(progress_for_task),
TemplateLinkage::Dylib,
)
});
world.resource_mut::<NewProjectState>().build_task = Some((task, TemplateLinkage::Dylib));
}
fn transition_to_editor(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);
let scene_path = root.join("assets").join("scene.jsn");
if scene_path.is_file() {
crate::scene_io::load_scene_from_file(world, &scene_path);
}
}
fn spawn_new_project_button(
card: &mut ChildSpawnerCommands,
parent: Entity,
label: &str,
font: Handle<Font>,
preset: TemplatePreset,
) {
let button = card
.commands()
.spawn((
Node {
padding: UiRect::axes(Val::Px(16.0), Val::Px(8.0)),
border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_MD)),
justify_content: JustifyContent::Center,
..Default::default()
},
BackgroundColor(tokens::TOOLBAR_BG),
children![(
Text::new(label.to_string()),
TextFont {
font,
font_size: tokens::FONT_MD,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
)],
))
.id();
card.commands().entity(parent).add_child(button);
card.commands().entity(button).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;
}
},
);
card.commands().entity(button).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;
}
},
);
card.commands()
.entity(button)
.observe(move |_: On<Pointer<Click>>, mut commands: Commands| {
let preset = preset.clone();
commands.queue(move |world: &mut World| {
open_new_project_modal(world, preset);
});
});
}
fn spawn_linkage_button(
world: &mut World,
parent: Entity,
label: &str,
linkage: TemplateLinkage,
initial: TemplateLinkage,
font: Handle<Font>,
) {
let selected = linkage == initial;
let bg_color = if selected {
tokens::SELECTED_BG
} else {
tokens::TOOLBAR_BG
};
let border_color = if selected {
tokens::SELECTED_BORDER
} else {
tokens::BORDER_SUBTLE
};
let button = world
.spawn((
NewProjectLinkageButton(linkage),
Node {
padding: UiRect::axes(Val::Px(16.0), Val::Px(8.0)),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_MD)),
justify_content: JustifyContent::Center,
..Default::default()
},
BackgroundColor(bg_color),
BorderColor::all(border_color),
children![(
Text::new(label.to_string()),
TextFont {
font,
font_size: tokens::FONT_MD,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
)],
ChildOf(parent),
))
.id();
world
.entity_mut(button)
.observe(on_linkage_button_click)
.observe(
|hover: On<Pointer<Over>>,
buttons: Query<&NewProjectLinkageButton>,
state: Res<NewProjectState>,
mut bg: Query<&mut BackgroundColor>| {
let Ok(button) = buttons.get(hover.event_target()) else {
return;
};
if button.0 == state.linkage {
return;
}
if let Ok(mut bg) = bg.get_mut(hover.event_target()) {
bg.0 = tokens::HOVER_BG;
}
},
)
.observe(
|out: On<Pointer<Out>>,
buttons: Query<&NewProjectLinkageButton>,
state: Res<NewProjectState>,
mut bg: Query<&mut BackgroundColor>| {
let Ok(button) = buttons.get(out.event_target()) else {
return;
};
if button.0 == state.linkage {
return;
}
if let Ok(mut bg) = bg.get_mut(out.event_target()) {
bg.0 = tokens::TOOLBAR_BG;
}
},
);
}
fn on_linkage_button_click(
click: On<Pointer<Click>>,
buttons: Query<&NewProjectLinkageButton>,
mut commands: Commands,
) {
let Ok(button) = buttons.get(click.event_target()) else {
return;
};
let linkage = button.0;
commands.queue(move |world: &mut World| {
world.resource_mut::<NewProjectState>().linkage = linkage;
let mut repaint: Vec<(Entity, bool)> = Vec::new();
{
let mut q = world.query::<(Entity, &NewProjectLinkageButton)>();
for (entity, btn) in q.iter(world) {
repaint.push((entity, btn.0 == linkage));
}
}
for (entity, is_selected) in repaint {
let bg_color = if is_selected {
tokens::SELECTED_BG
} else {
tokens::TOOLBAR_BG
};
let border_color = if is_selected {
tokens::SELECTED_BORDER
} else {
tokens::BORDER_SUBTLE
};
if let Ok(mut ec) = world.get_entity_mut(entity) {
ec.insert(BackgroundColor(bg_color));
ec.insert(BorderColor::all(border_color));
}
}
let Some(preset) = world.resource::<NewProjectState>().preset.clone() else {
return;
};
let new_local = preset
.local_template_path(linkage)
.map(|p| p.display().to_string())
.unwrap_or_default();
set_local_template_input_text(world, new_local);
let new_url = preset.git_url_with_subdir(linkage);
set_template_input_text(world, new_url);
});
}
fn set_template_input_text(world: &mut World, new_text: String) {
use jackdaw_feathers::text_edit::{TextInputQueue, set_text_input_value};
let mut q = world.query_filtered::<Entity, With<NewProjectTemplateInput>>();
let Some(outer) = q.iter(world).next() else {
return;
};
let Some((_wrapper, inner)) = find_text_edit_entities_for_template(world, outer) else {
return;
};
if let Some(mut queue) = world.get_mut::<TextInputQueue>(inner) {
set_text_input_value(&mut queue, new_text);
}
}
fn find_text_edit_entities_for_template(world: &World, outer: Entity) -> Option<(Entity, Entity)> {
use jackdaw_feathers::text_edit::TextEditWrapper;
let children = world.get::<Children>(outer)?;
for child in children.iter() {
if let Some(wrapper) = world.get::<TextEditWrapper>(child) {
return Some((child, wrapper.0));
}
if let Some(grandchildren) = world.get::<Children>(child) {
for gc in grandchildren.iter() {
if let Some(wrapper) = world.get::<TextEditWrapper>(gc) {
return Some((gc, wrapper.0));
}
}
}
}
None
}
pub fn close_new_project_modal(world: &mut World) {
let mut q = world.query_filtered::<Entity, With<NewProjectModalRoot>>();
let entities: Vec<Entity> = q.iter(world).collect();
for entity in entities {
if let Ok(ec) = world.get_entity_mut(entity) {
ec.despawn();
}
}
let mut state = world.resource_mut::<NewProjectState>();
state.preset = None;
state.linkage = TemplateLinkage::default();
state.folder_task = None;
state.scaffold_task = None;
state.status = None;
}
pub fn open_project_progress_modal(world: &mut World, project_name: &str) {
close_new_project_modal(world);
let editor_font = world.resource::<EditorFont>().0.clone();
let scrim = world
.spawn((
NewProjectModalRoot,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..Default::default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.55)),
GlobalZIndex(100),
))
.id();
let card = world
.spawn((
Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(12.0),
padding: UiRect::all(Val::Px(24.0)),
min_width: Val::Px(480.0),
max_width: Val::Px(720.0),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_MD)),
..Default::default()
},
BackgroundColor(tokens::PANEL_BG),
BorderColor::all(tokens::BORDER_SUBTLE),
ChildOf(scrim),
))
.id();
world.spawn((
Text::new(format!("Opening `{project_name}`")),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_LG,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
ChildOf(card),
));
world.spawn((
Text::new(
"Building the per-project editor binary. First run with a fresh \
cargo cache can take 5 to 10 minutes (bevy is ~500 crates). \
Subsequent opens are incremental and finish in seconds.",
),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(card),
));
world.spawn((
NewProjectStatusText,
Text::new(String::new()),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(card),
));
let progress_container = world
.spawn((
NewProjectProgressContainer,
Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(6.0),
margin: UiRect::top(Val::Px(8.0)),
display: Display::Flex,
..Default::default()
},
ChildOf(card),
))
.id();
world.spawn((
NewProjectProgressCrateLabel,
Text::new("Preparing build...".to_string()),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(progress_container),
));
let bar_slot = world
.spawn((
NewProjectProgressBarSlot,
Node {
width: Val::Percent(100.0),
..Default::default()
},
ChildOf(progress_container),
))
.id();
world.spawn((
jackdaw_feathers::progress::progress_bar(0.0),
ChildOf(bar_slot),
));
world.spawn((
NewProjectLogText,
Text::new(String::new()),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
Node {
max_height: Val::Px(220.0),
overflow: Overflow::clip(),
..Default::default()
},
ChildOf(progress_container),
));
}
pub fn open_new_project_modal(world: &mut World, preset: TemplatePreset) {
close_new_project_modal(world);
let location = default_projects_dir();
let initial_linkage = TemplateLinkage::default();
{
let mut state = world.resource_mut::<NewProjectState>();
state.preset = Some(preset.clone());
state.linkage = initial_linkage;
state.location = location.clone();
state.status = None;
state.build_after_scaffold = true;
}
let editor_font = world.resource::<EditorFont>().0.clone();
let icon_font = world
.resource::<jackdaw_feathers::icons::IconFont>()
.0
.clone();
let (heading, name_placeholder) = match preset {
TemplatePreset::Extension => ("New Extension", "my_extension"),
TemplatePreset::Game => ("New Game", "my_game"),
TemplatePreset::Custom(_) => ("New Project", "my_project"),
};
let scrim = world
.spawn((
NewProjectModalRoot,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..Default::default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.55)),
GlobalZIndex(100),
))
.id();
let card = world
.spawn((
Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(12.0),
padding: UiRect::all(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()
},
BackgroundColor(tokens::PANEL_BG),
BorderColor::all(tokens::BORDER_SUBTLE),
ChildOf(scrim),
))
.id();
world.spawn((
Text::new(heading.to_string()),
TextFont {
font: editor_font.clone(),
font_size: 24.0,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
ChildOf(card),
));
world.spawn((
Text::new("Name"),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(card),
));
world.spawn((
NewProjectNameInput,
ChildOf(card),
text_edit(
TextEditProps::default()
.with_placeholder(name_placeholder.to_string())
.with_default_value(name_placeholder.to_string())
.auto_focus(),
),
));
world.spawn((
Text::new("Location"),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(card),
));
let location_row = world
.spawn((
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..Default::default()
},
ChildOf(card),
))
.id();
world.spawn((
NewProjectLocationText,
Text::new(location.to_string_lossy().into_owned()),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_MD,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
Node {
flex_grow: 1.0,
..Default::default()
},
ChildOf(location_row),
));
let browse = world
.spawn((
NewProjectBrowseButton,
Node {
padding: UiRect::axes(Val::Px(12.0), Val::Px(6.0)),
border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_MD)),
..Default::default()
},
BackgroundColor(tokens::TOOLBAR_BG),
children![(
Text::new("Browse…"),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
)],
ChildOf(location_row),
))
.id();
world.entity_mut(browse).observe(on_browse_new_location);
if preset.supports_linkage_selector() {
world.spawn((
Text::new("Template type"),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(card),
));
let linkage_row = world
.spawn((
Node {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(8.0),
..Default::default()
},
ChildOf(card),
))
.id();
spawn_linkage_button(
world,
linkage_row,
"Static",
TemplateLinkage::Static,
initial_linkage,
editor_font.clone(),
);
spawn_linkage_button(
world,
linkage_row,
"Dylib (experimental)",
TemplateLinkage::Dylib,
initial_linkage,
editor_font.clone(),
);
world.spawn((
Text::new(
"Static: plainly-compiled rlib/bin (recommended, ships with the bundled \
templates/game-static and templates/extension-static). \
Dylib: hot-reloadable cdylib, experimental and requires the editor's \
`dylib` feature.",
),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(card),
));
}
let show_build_checkbox = !matches!(
(initial_linkage, &preset),
(TemplateLinkage::Static, TemplatePreset::Extension)
);
if show_build_checkbox {
world.spawn((
BuildAfterScaffoldCheckbox,
ChildOf(card),
jackdaw_feathers::checkbox::checkbox(
jackdaw_feathers::checkbox::CheckboxProps::new("Open editor after creating")
.checked(true),
&editor_font,
&icon_font,
),
));
}
world.spawn((
Text::new("Template"),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(card),
));
world.spawn((
Text::new("Local path"),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
Node {
margin: UiRect::top(Val::Px(4.0)),
..Default::default()
},
ChildOf(card),
));
let local_row = world
.spawn((
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..Default::default()
},
ChildOf(card),
))
.id();
let initial_local_path = preset
.local_template_path(initial_linkage)
.map(|p| p.display().to_string())
.unwrap_or_default();
world.spawn((
NewProjectLocalTemplateInput,
ChildOf(local_row),
text_edit(
TextEditProps::default()
.with_placeholder(
"Local folder (e.g. ~/Workspace/jackdaw/templates/game)".to_string(),
)
.with_default_value(initial_local_path)
.allow_empty(),
),
));
let local_browse = world
.spawn((
NewProjectLocalBrowseButton,
Node {
padding: UiRect::axes(Val::Px(12.0), Val::Px(6.0)),
border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_MD)),
..Default::default()
},
BackgroundColor(tokens::TOOLBAR_BG),
children![(
Text::new("Browse..."),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
)],
ChildOf(local_row),
))
.id();
world
.entity_mut(local_browse)
.observe(on_browse_template_folder);
world.spawn((
Text::new("Git URL"),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
Node {
margin: UiRect::top(Val::Px(4.0)),
..Default::default()
},
ChildOf(card),
));
world.spawn((
NewProjectTemplateInput,
ChildOf(card),
text_edit(
TextEditProps::default()
.with_placeholder("https://github.com/.../your_template".to_string())
.with_default_value(preset.git_url_with_subdir(initial_linkage))
.allow_empty(),
),
));
world.spawn((
Text::new("Branch"),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
Node {
margin: UiRect::top(Val::Px(4.0)),
..Default::default()
},
ChildOf(card),
));
world.spawn((
NewProjectBranchInput,
ChildOf(card),
text_edit(
TextEditProps::default()
.with_placeholder("main".to_string())
.with_default_value(crate::new_project::template_branch())
.allow_empty(),
),
));
world.spawn((
Text::new("If both are filled, the local path is used."),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
Node {
margin: UiRect::top(Val::Px(4.0)),
..Default::default()
},
ChildOf(card),
));
world.spawn((
NewProjectStatusText,
Text::new(String::new()),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(card),
));
let progress_container = world
.spawn((
NewProjectProgressContainer,
Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(6.0),
margin: UiRect::top(Val::Px(8.0)),
display: Display::None,
..Default::default()
},
ChildOf(card),
))
.id();
world.spawn((
NewProjectProgressCrateLabel,
Text::new(String::new()),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(progress_container),
));
let bar_slot = world
.spawn((
NewProjectProgressBarSlot,
Node {
width: Val::Percent(100.0),
..Default::default()
},
ChildOf(progress_container),
))
.id();
world.spawn((
jackdaw_feathers::progress::progress_bar(0.0),
ChildOf(bar_slot),
));
world.spawn((
NewProjectLogText,
Text::new(String::new()),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
Node {
max_height: Val::Px(220.0),
overflow: Overflow::clip(),
..Default::default()
},
ChildOf(progress_container),
));
let actions = world
.spawn((
Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::FlexEnd,
column_gap: Val::Px(8.0),
margin: UiRect::top(Val::Px(8.0)),
..Default::default()
},
ChildOf(card),
))
.id();
let cancel = world
.spawn((
NewProjectCancelButton,
Node {
padding: UiRect::axes(Val::Px(16.0), Val::Px(8.0)),
border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_MD)),
..Default::default()
},
BackgroundColor(tokens::TOOLBAR_BG),
children![(
Text::new("Cancel"),
TextFont {
font: editor_font.clone(),
font_size: tokens::FONT_MD,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
)],
ChildOf(actions),
))
.id();
world.entity_mut(cancel).observe(on_cancel_new_project);
let create = world
.spawn((
NewProjectCreateButton,
Node {
padding: UiRect::axes(Val::Px(20.0), Val::Px(8.0)),
border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_MD)),
..Default::default()
},
BackgroundColor(tokens::SELECTED_BG),
children![(
Text::new("Create"),
TextFont {
font: editor_font,
font_size: tokens::FONT_MD,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
)],
ChildOf(actions),
))
.id();
world.entity_mut(create).observe(on_create_new_project);
}
fn on_cancel_new_project(_: On<Pointer<Click>>, mut commands: Commands) {
commands.queue(close_new_project_modal);
}
fn on_browse_new_location(
_: On<Pointer<Click>>,
mut commands: Commands,
raw_handle: Query<&RawHandleWrapper, With<PrimaryWindow>>,
) {
let mut dialog = AsyncFileDialog::new().set_title("Choose parent directory");
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.queue(move |world: &mut World| {
world.resource_mut::<NewProjectState>().folder_task = Some(task);
});
}
fn on_browse_template_folder(
_: On<Pointer<Click>>,
mut commands: Commands,
raw_handle: Query<&RawHandleWrapper, With<PrimaryWindow>>,
) {
let mut dialog = AsyncFileDialog::new().set_title("Select template folder");
if let Ok(rh) = raw_handle.single() {
let handle = unsafe { rh.get_handle() };
dialog = dialog.set_parent(&handle);
}
let start_dir = crate::new_project::jackdaw_dev_checkout()
.map(|p| p.join("templates"))
.or_else(dirs::home_dir);
if let Some(dir) = start_dir {
dialog = dialog.set_directory(dir);
}
let task = AsyncComputeTaskPool::get().spawn(async move { dialog.pick_folder().await });
commands.queue(move |world: &mut World| {
world.insert_resource(TemplateFolderDialogTask(task));
});
}
fn set_local_template_input_text(world: &mut World, new_text: String) {
use jackdaw_feathers::text_edit::{TextInputQueue, set_text_input_value};
let mut q = world.query_filtered::<Entity, With<NewProjectLocalTemplateInput>>();
let Some(outer) = q.iter(world).next() else {
return;
};
let Some((_wrapper, inner)) = find_text_edit_entities_for_template(world, outer) else {
return;
};
if let Some(mut queue) = world.get_mut::<TextInputQueue>(inner) {
set_text_input_value(&mut queue, new_text);
}
}
fn poll_template_folder_dialog(world: &mut World) {
let Some(mut task_res) = world.get_resource_mut::<TemplateFolderDialogTask>() else {
return;
};
let Some(result) = future::block_on(future::poll_once(&mut task_res.0)) else {
return;
};
world.remove_resource::<TemplateFolderDialogTask>();
if let Some(handle) = result {
let path = handle.path().to_path_buf();
set_local_template_input_text(world, path.to_string_lossy().into_owned());
}
}
fn on_create_new_project(
_: On<Pointer<Click>>,
mut commands: Commands,
name_inputs: Query<Entity, With<NewProjectNameInput>>,
template_inputs: Query<Entity, With<NewProjectTemplateInput>>,
local_template_inputs: Query<Entity, With<NewProjectLocalTemplateInput>>,
branch_inputs: Query<Entity, With<NewProjectBranchInput>>,
text_edit_values: Query<&TextEditValue>,
build_checkbox: Query<
&jackdaw_feathers::checkbox::CheckboxState,
With<BuildAfterScaffoldCheckbox>,
>,
) {
let Some(name_entity) = name_inputs.iter().next() else {
return;
};
let name = text_edit_values
.get(name_entity)
.map(|v| v.0.trim().to_string())
.unwrap_or_default();
let local_path_from_input = local_template_inputs
.iter()
.next()
.and_then(|e| text_edit_values.get(e).ok())
.map(|v| v.0.trim().to_string())
.unwrap_or_default();
let git_url_from_input = template_inputs
.iter()
.next()
.and_then(|e| text_edit_values.get(e).ok())
.map(|v| v.0.trim().to_string())
.unwrap_or_default();
let branch_from_input = branch_inputs
.iter()
.next()
.and_then(|e| text_edit_values.get(e).ok())
.map(|v| v.0.trim().to_string())
.unwrap_or_default();
let checkbox_value = build_checkbox
.iter()
.next()
.map(|state| state.checked)
.unwrap_or(true);
commands.queue(move |world: &mut World| {
let (location, linkage) = {
let mut state = world.resource_mut::<NewProjectState>();
if state.preset.is_none() {
return;
}
if state.scaffold_task.is_some() {
return; }
let build_after_scaffold = if matches!(
(state.linkage, state.preset.as_ref()),
(TemplateLinkage::Static, Some(TemplatePreset::Extension))
) {
false
} else {
checkbox_value
};
state.build_after_scaffold = build_after_scaffold;
(state.location.clone(), state.linkage)
};
let name = name.clone();
if name.is_empty() {
world.resource_mut::<NewProjectState>().status =
Some("Please enter a project name.".into());
return;
}
let local_path = local_path_from_input.clone();
let git_url = git_url_from_input.clone();
let template_url = if !local_path.is_empty() {
if !std::path::Path::new(&local_path).is_dir() {
world.resource_mut::<NewProjectState>().status =
Some(format!("Local template path does not exist: {local_path}"));
return;
}
local_path
} else if !git_url.is_empty() {
git_url
} else {
world.resource_mut::<NewProjectState>().status =
Some("Provide a template (local path or git URL).".into());
return;
};
let name_for_task = name.clone();
let location_for_task = location.clone();
let url_for_task = template_url.clone();
let branch_for_task = if branch_from_input.is_empty() {
None
} else {
Some(branch_from_input.clone())
};
world.resource_mut::<NewProjectState>().status = Some(format!("Scaffolding `{name}`..."));
let task = AsyncComputeTaskPool::get().spawn(async move {
scaffold_project(
&name_for_task,
&location_for_task,
&url_for_task,
branch_for_task.as_deref(),
linkage,
)
});
world.resource_mut::<NewProjectState>().scaffold_task = Some(task);
});
}
fn poll_new_project_tasks(
mut commands: Commands,
mut state: ResMut<NewProjectState>,
mut location_texts: Query<&mut Text, With<NewProjectLocationText>>,
mut status_texts: Query<
&mut Text,
(With<NewProjectStatusText>, Without<NewProjectLocationText>),
>,
) {
if let Some(task) = state.folder_task.as_mut()
&& let Some(result) = future::block_on(future::poll_once(task))
{
state.folder_task = None;
if let Some(handle) = result {
state.location = handle.path().to_path_buf();
}
}
if let Some(task) = state.scaffold_task.as_mut()
&& let Some(result) = future::block_on(future::poll_once(task))
{
state.scaffold_task = None;
match result {
Ok(project_path) => {
info!("Scaffolded project at {}", project_path.display());
let project_name = project_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("project")
.to_owned();
if !state.build_after_scaffold {
info!(
"Scaffolded `{project_name}` at {}; skipping build per modal toggle.",
project_path.display()
);
let dialog_root = project_path.clone();
let dialog_linkage = state.linkage;
let dialog_preset = state.preset.clone();
commands.queue(move |world: &mut World| {
close_new_project_modal(world);
open_skip_build_dialog(
world,
&dialog_root,
dialog_linkage,
dialog_preset.as_ref(),
);
});
state.pending_project = None;
state.scaffold_task = None;
state.status = None;
return;
}
state.status = Some(format!("Building `{project_name}` in background…"));
state.pending_project = Some(project_path.clone());
let linkage = state.linkage;
match linkage {
TemplateLinkage::Static => {
let progress = std::sync::Arc::new(std::sync::Mutex::new(
crate::ext_build::BuildProgress::default(),
));
state.build_progress = Some(std::sync::Arc::clone(&progress));
state.build_progress_snapshot =
Some(crate::ext_build::BuildProgress::default());
state.static_editor.pending = Some((project_path.clone(), true));
}
TemplateLinkage::Dylib => {
let progress = std::sync::Arc::new(std::sync::Mutex::new(
crate::ext_build::BuildProgress::default(),
));
state.build_progress = Some(std::sync::Arc::clone(&progress));
state.build_progress_snapshot =
Some(crate::ext_build::BuildProgress::default());
let project_for_task = project_path;
let progress_for_task = std::sync::Arc::clone(&progress);
let task = AsyncComputeTaskPool::get().spawn(async move {
crate::ext_build::build_extension_project_with_progress(
&project_for_task,
Some(progress_for_task),
linkage,
)
});
state.build_task = Some((task, linkage));
}
}
}
Err(err) => {
warn!("Scaffold failed: {err}");
state.status = Some(format!("Create failed: {err}"));
}
}
}
if let Some((task, build_linkage)) = state.build_task.as_mut()
&& let Some(result) = future::block_on(future::poll_once(task))
{
let linkage = *build_linkage;
state.build_task = None;
match result {
Ok(artifact_or_project) => match linkage {
TemplateLinkage::Dylib => {
info!("Build produced {}", artifact_or_project.display());
state.pending_install = Some(artifact_or_project);
}
TemplateLinkage::Static => {
info!("Static build succeeded: {}", artifact_or_project.display());
state.pending_project = None;
}
},
Err(err) => {
warn!("Build failed: {err}");
state.status = Some(format!(
"Build failed: {err}.\n\
Fix the issue and try opening the project again."
));
state.pending_project = None;
}
}
}
let desired_location = state.location.to_string_lossy().into_owned();
for mut text in location_texts.iter_mut() {
if text.0 != desired_location {
text.0 = desired_location.clone();
}
}
let desired_status = state.status.as_deref().unwrap_or("").to_string();
for mut text in status_texts.iter_mut() {
if text.0 != desired_status {
text.0 = desired_status.clone();
}
}
}
fn refresh_build_progress_snapshot(mut state: ResMut<NewProjectState>) {
let Some(ref arc) = state.build_progress else {
return;
};
let snap = {
let Ok(guard) = arc.lock() else {
return;
};
guard.clone()
};
state.build_progress_snapshot = Some(snap);
}
fn refresh_build_progress_ui(
state: Res<NewProjectState>,
mut containers: Query<&mut Node, With<NewProjectProgressContainer>>,
mut crate_labels: Query<
&mut Text,
(
With<NewProjectProgressCrateLabel>,
Without<NewProjectLogText>,
),
>,
mut log_texts: Query<
&mut Text,
(
With<NewProjectLogText>,
Without<NewProjectProgressCrateLabel>,
),
>,
bar_slots: Query<&Children, With<NewProjectProgressBarSlot>>,
children_q: Query<&Children>,
mut fill_q: Query<
&mut Node,
(
With<jackdaw_feathers::progress::ProgressBarFill>,
Without<NewProjectProgressContainer>,
),
>,
) {
let snapshot = state.build_progress_snapshot.as_ref();
let show = snapshot.is_some();
for mut node in containers.iter_mut() {
let desired = if show { Display::Flex } else { Display::None };
if node.display != desired {
node.display = desired;
}
}
let Some(progress) = snapshot else {
return;
};
let crate_line = match (&progress.current_crate, progress.artifacts_total) {
(Some(name), Some(total)) => {
format!("Compiling {name} ({}/{})", progress.artifacts_done, total)
}
(Some(name), None) => format!("Compiling {name} ({} so far)", progress.artifacts_done),
(None, Some(total)) => format!("Preparing build… (0/{total})"),
(None, None) => "Preparing build…".to_string(),
};
for mut t in crate_labels.iter_mut() {
if t.0 != crate_line {
t.0 = crate_line.clone();
}
}
let fraction = progress.fraction().unwrap_or(0.0).clamp(0.0, 1.0);
let desired_width = Val::Percent(fraction * 100.0);
for bar_children in bar_slots.iter() {
for bar_entity in bar_children.iter() {
let Ok(inner) = children_q.get(bar_entity) else {
continue;
};
for fill_entity in inner.iter() {
if let Ok(mut node) = fill_q.get_mut(fill_entity)
&& node.width != desired_width
{
node.width = desired_width;
}
}
}
}
let mut joined = String::new();
for (i, line) in progress.recent_log_lines.iter().enumerate() {
if i > 0 {
joined.push('\n');
}
joined.push_str(line);
}
for mut t in log_texts.iter_mut() {
if t.0 != joined {
t.0 = joined.clone();
}
}
}
fn apply_pending_install(world: &mut World) {
let artifact_opt = world
.resource_mut::<NewProjectState>()
.pending_install
.take();
let Some(artifact) = artifact_opt else {
return;
};
let result = crate::extensions_dialog::handle_install_from_path(world, artifact);
match result {
Ok(_) => {
let project = world
.resource_mut::<NewProjectState>()
.pending_project
.clone();
close_new_project_modal(world);
if let Some(p) = project {
transition_to_editor(world, p);
}
}
Err(ref err) if err.is_symbol_mismatch() => {
let project_name = world
.resource::<NewProjectState>()
.pending_project
.as_deref()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.unwrap_or("project")
.to_owned();
warn!("Install failed (SDK mismatch) for `{project_name}`");
close_new_project_modal(world);
world.trigger(
jackdaw_feathers::dialog::OpenDialogEvent::new("SDK mismatch", "OK")
.with_description(format!(
"Project `{project_name}` was built against a different jackdaw \
SDK build. Run this from the project directory to refresh:\n\n\
\tcargo clean -p {project_name}\n\
\tcargo build\n\n\
Then re-open the project."
))
.without_cancel(),
);
}
Err(err) => {
warn!("Install failed: {err}");
close_new_project_modal(world);
world.trigger(
jackdaw_feathers::dialog::OpenDialogEvent::new("Install failed", "OK")
.with_description(format!("{err}"))
.without_cancel(),
);
}
}
}
fn open_skip_build_dialog(
world: &mut World,
root: &Path,
linkage: TemplateLinkage,
preset: Option<&TemplatePreset>,
) {
let display_path = root.display().to_string();
let project_name = root
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("project")
.to_owned();
let description = match (linkage, preset) {
(TemplateLinkage::Dylib, _) => format!(
"Files written to {display_path}.\n\nOpen the project from the launcher's recent-projects list when you're ready to build the cdylib and load it into the editor."
),
(TemplateLinkage::Static, Some(TemplatePreset::Game)) => format!(
"Files written to {display_path}.\n\nTo open in jackdaw with custom components, run:\n\n cd {display_path}\n cargo editor\n\n(equivalent to `cargo run --bin editor --features editor`).\n\nThe launcher's recent-projects list opens the project later."
),
(TemplateLinkage::Static, Some(TemplatePreset::Extension)) => format!(
"Files written to {display_path}.\n\nTo run your extension in jackdaw, run:\n\n cd {display_path}\n cargo run\n\nThe launcher's recent-projects list opens the project for level editing only and does NOT auto-build the extension binary."
),
(TemplateLinkage::Static, Some(TemplatePreset::Custom(_)) | None) => format!(
"Files written to {display_path}.\n\nFollow the template's README for the right cargo command. The launcher's recent-projects list can open the project for level editing later."
),
};
world.trigger(
jackdaw_feathers::dialog::OpenDialogEvent::new(format!("`{project_name}` created"), "OK")
.with_description(description)
.without_cancel(),
);
}
fn apply_pending_static_open(world: &mut World) {
let project = world
.resource_mut::<NewProjectState>()
.pending_static_open
.take();
let Some(project) = project else {
return;
};
close_new_project_modal(world);
enter_project(world, project);
}
fn drive_static_editor_build(world: &mut World) {
use crate::build_status::BuildState;
let pending = world
.resource_mut::<NewProjectState>()
.static_editor
.pending
.take();
if let Some((root, auto_reload)) = pending {
let already_running = world
.resource::<NewProjectState>()
.static_editor
.task
.is_some();
if already_running {
} else {
let progress = world
.resource::<NewProjectState>()
.build_progress
.as_ref()
.map(std::sync::Arc::clone)
.unwrap_or_else(|| {
std::sync::Arc::new(std::sync::Mutex::new(
crate::ext_build::BuildProgress::default(),
))
});
let progress_for_task = std::sync::Arc::clone(&progress);
let root_for_task = root.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
crate::ext_build::build_static_editor_with_progress(
&root_for_task,
Some(progress_for_task),
)
});
{
let mut state = world.resource_mut::<NewProjectState>();
state.static_editor.task = Some(task);
state.static_editor.auto_reload = auto_reload;
if state.build_progress.is_none() {
state.build_progress = Some(std::sync::Arc::clone(&progress));
state.build_progress_snapshot =
Some(crate::ext_build::BuildProgress::default());
}
}
world
.resource_mut::<crate::build_status::BuildStatus>()
.state = BuildState::Building {
project: root,
started: std::time::Instant::now(),
progress,
};
}
}
let result_opt = {
let mut state = world.resource_mut::<NewProjectState>();
state
.static_editor
.task
.as_mut()
.and_then(|task| future::block_on(future::poll_once(task)))
};
if let Some(result) = result_opt {
let auto_reload = {
let mut state = world.resource_mut::<NewProjectState>();
state.static_editor.task = None;
state.static_editor.auto_reload
};
let project = match &world.resource::<crate::build_status::BuildStatus>().state {
BuildState::Building { project, .. } => project.clone(),
_ => return,
};
match result {
Ok(bin) => {
info!("Static editor build finished: {}", bin.display());
world
.resource_mut::<crate::build_status::BuildStatus>()
.state = BuildState::Ready {
project,
bin,
auto_reload,
};
}
Err(err) => {
warn!("Static editor build failed: {err}");
world
.resource_mut::<crate::build_status::BuildStatus>()
.state = BuildState::Failed {
project,
log_tail: format!("{err}"),
};
}
}
}
let auto_handoff = matches!(
world.resource::<crate::build_status::BuildStatus>().state,
BuildState::Ready {
auto_reload: true,
..
}
);
if auto_handoff {
let (bin, project) = match std::mem::take(
&mut world
.resource_mut::<crate::build_status::BuildStatus>()
.state,
) {
BuildState::Ready { bin, project, .. } => (bin, project),
other => {
world
.resource_mut::<crate::build_status::BuildStatus>()
.state = other;
return;
}
};
close_new_project_modal(world);
do_handoff(world, &bin, &project);
}
}
pub(crate) fn do_handoff(world: &mut World, bin: &Path, project_root: &Path) {
info!(
"Handing off to static editor {} (cwd={})",
bin.display(),
project_root.display()
);
let has_scene_path = world
.get_resource::<scene_io::SceneFilePath>()
.is_some_and(|p| p.path.is_some());
if has_scene_path {
scene_io::save_scene(world);
}
let project_name = project::load_project_config(project_root)
.map(|cfg| cfg.project.name)
.unwrap_or_else(|| {
project_root
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("project")
.to_string()
});
project::touch_recent(project_root, &project_name);
let spawn_result = std::process::Command::new(bin)
.current_dir(project_root)
.env("JACKDAW_PROJECT", project_root)
.spawn();
match spawn_result {
Ok(_child) => {
info!("Static editor spawned; exiting launcher");
world.write_message(bevy::app::AppExit::Success);
}
Err(e) => {
warn!(
"Failed to spawn static editor binary {}: {e}",
bin.display()
);
let mut status = world.resource_mut::<crate::build_status::BuildStatus>();
if let crate::build_status::BuildState::Ready { auto_reload, .. } = &mut status.state {
*auto_reload = false;
}
world.resource_mut::<NewProjectState>().status =
Some(format!("Failed to spawn editor binary: {e}"));
}
}
}