Bevy Forge 0.1.0: visual scene editor for Bevy 0.20 BSN
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "bevy_forge_editor"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Bevy Forge — a visual scene editor for the Bevy engine (Bevy 0.20 BSN)"
|
||||
|
||||
[[bin]]
|
||||
name = "bevy-forge"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
bevy_forge_core = { path = "../bevy_forge_core" }
|
||||
eframe.workspace = true
|
||||
egui.workspace = true
|
||||
image.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Deferred editor actions emitted by panels and handled in one place by the app.
|
||||
|
||||
use bevy_forge_core::{ExportFormat, ProjectTemplate};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::state::ViewMode;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
NewScene(ProjectTemplate),
|
||||
OpenProjectDialog,
|
||||
OpenProject(PathBuf),
|
||||
OpenSceneDialog,
|
||||
OpenScene(PathBuf),
|
||||
Save,
|
||||
OpenSaveAsDialog,
|
||||
SaveSceneTo(PathBuf),
|
||||
OpenExportDialog(ExportFormat),
|
||||
ExportTo { format: ExportFormat, path: PathBuf },
|
||||
CopyExport(ExportFormat),
|
||||
Undo,
|
||||
Redo,
|
||||
DeleteSelection,
|
||||
DuplicateSelection,
|
||||
FocusSelection,
|
||||
SetViewMode(ViewMode),
|
||||
ToggleViewMode,
|
||||
FitView,
|
||||
ToggleGrid,
|
||||
ToggleSnap,
|
||||
ToggleAssets,
|
||||
ToggleLeftPanel,
|
||||
ToggleRightPanel,
|
||||
OpenCommandPalette,
|
||||
ShowShortcuts,
|
||||
ShowAbout,
|
||||
RequestQuit,
|
||||
QuitNow,
|
||||
ReloadAssets,
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
//! Application shell: owns state, orders panels, dispatches deferred actions,
|
||||
//! handles saving/exporting and the unsaved-changes close guard.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bevy_forge_core::{
|
||||
samples, AssetIndex, EditorConfig, ExportFormat, Project, ProjectTemplate, SceneDoc, Session,
|
||||
};
|
||||
use eframe::egui;
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::assets_panel::AssetsPanelState;
|
||||
use crate::dialogs::DialogsState;
|
||||
use crate::hierarchy::HierarchyPanelState;
|
||||
use crate::inspector::InspectorPanelState;
|
||||
use crate::palette::PaletteState;
|
||||
use crate::state::{EditorContext, ExportState, Screen, UiState, ViewMode};
|
||||
use crate::texture_cache::TextureCache;
|
||||
use crate::toast::Toasts;
|
||||
use crate::viewport2d::Viewport2dState;
|
||||
use crate::viewport3d::Viewport3dState;
|
||||
use crate::welcome::WelcomeState;
|
||||
use crate::{
|
||||
assets_panel, dialogs, hierarchy, inspector, menu, palette, shortcuts, status_bar, theme,
|
||||
viewport2d, viewport3d, welcome, widgets,
|
||||
};
|
||||
|
||||
pub struct ForgeApp {
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
pub project: Option<Project>,
|
||||
pub assets: AssetIndex,
|
||||
pub textures: TextureCache,
|
||||
pub toasts: Toasts,
|
||||
pub config: EditorConfig,
|
||||
pub export: ExportState,
|
||||
pub screen: Screen,
|
||||
pub hierarchy: HierarchyPanelState,
|
||||
pub inspector: InspectorPanelState,
|
||||
pub assets_panel: AssetsPanelState,
|
||||
pub viewport2d: Viewport2dState,
|
||||
pub viewport3d: Viewport3dState,
|
||||
pub palette: PaletteState,
|
||||
pub dialogs: DialogsState,
|
||||
pub welcome: WelcomeState,
|
||||
pub current_scene_path: Option<PathBuf>,
|
||||
pub last_title: String,
|
||||
pub allow_close: bool,
|
||||
pub quit_now: bool,
|
||||
last_autosave: Option<Instant>,
|
||||
/// Navigation interrupted by the unsaved-changes prompt.
|
||||
pending_navigation: Option<PendingNavigation>,
|
||||
/// If set, capture a PNG after the UI settles and quit (CI/screenshot mode).
|
||||
pub screenshot_path: Option<PathBuf>,
|
||||
screenshot_frames: u32,
|
||||
screenshot_requested: bool,
|
||||
}
|
||||
|
||||
impl ForgeApp {
|
||||
pub fn new(cc: &eframe::CreationContext<'_>, initial: Option<PathBuf>) -> Self {
|
||||
theme::apply(&cc.egui_ctx);
|
||||
let config = EditorConfig::load_lossy();
|
||||
let mut app = Self::from_parts(SceneDoc::new("Untitled"), config);
|
||||
if let Some(initial) = initial {
|
||||
if initial.is_dir() {
|
||||
if !app.open_project(&initial) {
|
||||
app.screen = Screen::Welcome;
|
||||
}
|
||||
} else {
|
||||
match bevy_forge_core::project::find_project_root(&initial) {
|
||||
Some(root) => {
|
||||
if app.open_project(&root) {
|
||||
app.open_scene(&initial);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
app.toasts.error(format!(
|
||||
"Could not find a Bevy project containing {}",
|
||||
initial.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(last) = app.config.last_project.clone() {
|
||||
app.welcome.project_path = last.display().to_string();
|
||||
}
|
||||
app.screenshot_path = std::env::var_os("BEVY_FORGE_SCREENSHOT")
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from);
|
||||
if app.screenshot_path.is_some() {
|
||||
// Screenshot mode: never paint a pointer tooltip over the clean UI.
|
||||
cc.egui_ctx.style_mut(|style| {
|
||||
style.interaction.tooltip_delay = f32::INFINITY;
|
||||
});
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
/// Construct an editor with a preloaded scene, for headless UI tests.
|
||||
pub fn new_for_test(ctx: &egui::Context, doc: SceneDoc) -> Self {
|
||||
theme::apply(ctx);
|
||||
let mut app = Self::from_parts(doc, EditorConfig::default());
|
||||
app.screen = Screen::Editor;
|
||||
app
|
||||
}
|
||||
|
||||
fn from_parts(doc: SceneDoc, config: EditorConfig) -> Self {
|
||||
let mut ui = UiState::default();
|
||||
ui.show_grid = config.show_grid;
|
||||
ui.snap_to_grid = config.snap_to_grid;
|
||||
ui.show_assets = config.show_assets;
|
||||
ui.grid_size = config.grid_size;
|
||||
Self {
|
||||
session: Session::new(doc),
|
||||
ui,
|
||||
project: None,
|
||||
assets: AssetIndex::default(),
|
||||
textures: TextureCache::new(),
|
||||
toasts: Toasts::new(),
|
||||
config,
|
||||
export: ExportState::default(),
|
||||
screen: Screen::Welcome,
|
||||
hierarchy: HierarchyPanelState::default(),
|
||||
inspector: InspectorPanelState::default(),
|
||||
assets_panel: AssetsPanelState::default(),
|
||||
viewport2d: Viewport2dState::default(),
|
||||
viewport3d: Viewport3dState::default(),
|
||||
palette: PaletteState::default(),
|
||||
dialogs: DialogsState::default(),
|
||||
welcome: WelcomeState::default(),
|
||||
current_scene_path: None,
|
||||
last_title: String::new(),
|
||||
allow_close: false,
|
||||
quit_now: false,
|
||||
last_autosave: Some(Instant::now()),
|
||||
pending_navigation: None,
|
||||
screenshot_path: None,
|
||||
screenshot_frames: 0,
|
||||
screenshot_requested: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Project & scene lifecycle
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
pub fn open_project(&mut self, path: &Path) -> bool {
|
||||
match Project::open(path) {
|
||||
Ok(project) => {
|
||||
self.assets = AssetIndex::scan(&project.assets_dir);
|
||||
self.textures.clear();
|
||||
self.config.push_recent(&project.root);
|
||||
let _ = self.config.save();
|
||||
self.project = Some(project);
|
||||
self.current_scene_path = None;
|
||||
self.screen = Screen::Editor;
|
||||
self.ui.selection.clear();
|
||||
self.ui.error_banner = None;
|
||||
let first_scene = self
|
||||
.project
|
||||
.as_ref()
|
||||
.and_then(|project| project.list_scenes().into_iter().next());
|
||||
if let Some(scene) = first_scene {
|
||||
if !self.open_scene(&scene) {
|
||||
// Never leave the previous project's document paired
|
||||
// with the newly opened project after a failed load.
|
||||
self.session.replace(samples::empty_scene());
|
||||
self.current_scene_path = None;
|
||||
self.ui.selection.clear();
|
||||
self.apply_auto_view_mode();
|
||||
self.ui.status = "Could not open the project's first scene".into();
|
||||
}
|
||||
} else {
|
||||
self.session.replace(samples::empty_scene());
|
||||
self.current_scene_path = None;
|
||||
self.ui.selection.clear();
|
||||
self.apply_auto_view_mode();
|
||||
self.ui.status = "New empty scene".into();
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
self.toasts.error(error.to_string());
|
||||
self.welcome.error = Some(error.to_string());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_scene(&mut self, path: &Path) -> bool {
|
||||
let Some(project) = &self.project else {
|
||||
self.toasts
|
||||
.error("Open a Bevy project before opening a scene");
|
||||
return false;
|
||||
};
|
||||
match project.load_scene(path) {
|
||||
Ok(doc) => {
|
||||
self.session.replace(doc);
|
||||
self.current_scene_path = Some(path.to_path_buf());
|
||||
self.ui.selection.clear();
|
||||
if let Some(id) = choose_initial_selection(self.session.doc()) {
|
||||
self.ui.selection.select_only(id);
|
||||
self.hierarchy.scroll_to = Some(id);
|
||||
}
|
||||
self.screen = Screen::Editor;
|
||||
self.apply_auto_view_mode();
|
||||
// 2D auto-frames new scenes. 3D keeps its pleasant default orbit
|
||||
// camera; F/Fit frames the whole scene on demand.
|
||||
self.ui.fit_view_requested = false;
|
||||
self.ui.status = format!("Opened {}", path.display());
|
||||
self.toasts.success(format!("Opened {}", file_name(path)));
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
self.ui.error_banner = Some(error.to_string());
|
||||
self.toasts.error(error.to_string());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_scene(&mut self, template: ProjectTemplate) {
|
||||
let doc = samples::for_template(template);
|
||||
self.session.replace(doc);
|
||||
self.current_scene_path = None;
|
||||
self.screen = Screen::Editor;
|
||||
self.ui.selection.clear();
|
||||
if let Some(id) = choose_initial_selection(self.session.doc()) {
|
||||
self.ui.selection.select_only(id);
|
||||
self.hierarchy.scroll_to = Some(id);
|
||||
}
|
||||
self.apply_auto_view_mode();
|
||||
self.ui.fit_view_requested = false;
|
||||
self.ui.status = "New scene — remember to save".into();
|
||||
}
|
||||
|
||||
pub fn save_scene(&mut self) {
|
||||
let Some(project) = &self.project else {
|
||||
self.open_save_as_dialog();
|
||||
return;
|
||||
};
|
||||
let path = self
|
||||
.current_scene_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| project.scene_path(&self.session.doc().name));
|
||||
self.save_scene_to(&path);
|
||||
}
|
||||
|
||||
pub fn save_scene_to(&mut self, path: &Path) {
|
||||
let Some(project) = &self.project else {
|
||||
self.toasts
|
||||
.error("Open a Bevy project before saving scenes");
|
||||
return;
|
||||
};
|
||||
let mut path = path.to_path_buf();
|
||||
if path.is_relative() {
|
||||
path = project.root.join(&path);
|
||||
}
|
||||
if !path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.ends_with(".forge.ron"))
|
||||
{
|
||||
self.toasts.error("Scene files must end with .forge.ron");
|
||||
self.ui.error_banner = Some("Scene files must end with .forge.ron".into());
|
||||
return;
|
||||
}
|
||||
match project.save_scene(self.session.doc(), Some(&path)) {
|
||||
Ok(written) => {
|
||||
self.session.mark_saved();
|
||||
self.current_scene_path = Some(written.clone());
|
||||
self.ui.status = format!("Saved {}", written.display());
|
||||
self.toasts
|
||||
.success(format!("Saved {}", file_name(&written)));
|
||||
}
|
||||
Err(error) => self.toasts.error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_save_as_dialog(&mut self) {
|
||||
let default = self
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|project| project.scene_path(&self.session.doc().name))
|
||||
.unwrap_or_else(|| {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join(format!("{}.forge.ron", slug(&self.session.doc().name)))
|
||||
});
|
||||
self.dialogs.save_as_path = default.display().to_string();
|
||||
self.dialogs.save_as_open = true;
|
||||
}
|
||||
|
||||
pub fn export(&mut self, format: ExportFormat, path: &Path) {
|
||||
self.export.format = format;
|
||||
let options = bevy_forge_core::CodegenOptions {
|
||||
function_name: self.export.function_name.clone(),
|
||||
include_header: true,
|
||||
};
|
||||
match format.generate(self.session.doc(), &options) {
|
||||
Ok(code) => {
|
||||
let path = if path.is_relative() {
|
||||
self.project
|
||||
.as_ref()
|
||||
.map(|project| project.root.join(path))
|
||||
.unwrap_or_else(|| path.to_path_buf())
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(error) = std::fs::create_dir_all(parent) {
|
||||
self.toasts
|
||||
.error(format!("Could not create export folder: {error}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
match std::fs::write(&path, code) {
|
||||
Ok(()) => {
|
||||
self.toasts
|
||||
.success(format!("Exported {}", file_name(&path)));
|
||||
self.ui.status = format!("Exported {}", path.display());
|
||||
self.export.dialog_open = false;
|
||||
}
|
||||
Err(error) => {
|
||||
self.export.error = Some(error.to_string());
|
||||
self.toasts.error(format!("Export failed: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.export.error = Some(error.to_string());
|
||||
self.toasts.error(error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_export_dialog(&mut self, format: ExportFormat) {
|
||||
self.export.format = format;
|
||||
self.export.function_name = function_name_for(&self.session.doc().name);
|
||||
let path = self
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|project| project.root.join("export"))
|
||||
.unwrap_or_else(|| PathBuf::from("export"));
|
||||
self.export.path = path
|
||||
.join(format!("{}.rs", self.export.function_name))
|
||||
.display()
|
||||
.to_string();
|
||||
self.export.regenerate(self.session.doc());
|
||||
self.export.dialog_open = true;
|
||||
}
|
||||
|
||||
pub fn copy_export(&mut self, ctx: &egui::Context, format: ExportFormat) {
|
||||
let name = if self.export.function_name.trim().is_empty() {
|
||||
function_name_for(&self.session.doc().name)
|
||||
} else {
|
||||
self.export.function_name.clone()
|
||||
};
|
||||
let options = bevy_forge_core::CodegenOptions {
|
||||
function_name: name,
|
||||
include_header: true,
|
||||
};
|
||||
match format.generate(self.session.doc(), &options) {
|
||||
Ok(code) => {
|
||||
ctx.copy_text(code);
|
||||
self.toasts.success("Copied generated code");
|
||||
}
|
||||
Err(error) => self.toasts.error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_auto_view_mode(&mut self) {
|
||||
let doc = self.session.doc();
|
||||
let is_3d = doc.entities.values().any(|node| {
|
||||
node.components.iter().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
bevy_forge_core::ComponentData::Mesh3d(_)
|
||||
| bevy_forge_core::ComponentData::Camera3d
|
||||
| bevy_forge_core::ComponentData::DirectionalLight(_)
|
||||
)
|
||||
})
|
||||
});
|
||||
self.ui.view_mode = if is_3d {
|
||||
ViewMode::ThreeD
|
||||
} else {
|
||||
ViewMode::TwoD
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Actions
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fn delete_selection(&mut self) {
|
||||
let ids: Vec<_> = self.ui.selection.ids().collect();
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.session.edit("Delete entities", |doc| {
|
||||
for id in ids {
|
||||
doc.remove_entity(id);
|
||||
}
|
||||
});
|
||||
self.ui.selection.retain_existing(self.session.doc());
|
||||
self.ui.status = "Deleted entities".into();
|
||||
}
|
||||
|
||||
fn duplicate_selection(&mut self) {
|
||||
let ids: Vec<_> = self.ui.selection.ids().collect();
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut new_ids = Vec::new();
|
||||
self.session.edit("Duplicate entities", |doc| {
|
||||
for id in ids {
|
||||
if let Some(new_id) = doc.duplicate_entity(id) {
|
||||
new_ids.push(new_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(primary) = new_ids.first().copied() {
|
||||
self.ui.selection.select_many(new_ids, Some(primary));
|
||||
}
|
||||
self.ui.status = "Duplicated entities".into();
|
||||
}
|
||||
|
||||
fn request_navigation(&mut self, navigation: PendingNavigation) {
|
||||
if self.screen == Screen::Editor && self.session.is_dirty() {
|
||||
self.pending_navigation = Some(navigation);
|
||||
self.dialogs.confirm_quit_open = false;
|
||||
} else {
|
||||
self.perform_navigation(navigation);
|
||||
}
|
||||
}
|
||||
|
||||
fn perform_navigation(&mut self, navigation: PendingNavigation) {
|
||||
match navigation {
|
||||
PendingNavigation::NewScene(template) => self.new_scene(template),
|
||||
PendingNavigation::OpenProject(path) => {
|
||||
self.open_project(&path);
|
||||
}
|
||||
PendingNavigation::OpenScene(path) => {
|
||||
self.open_scene(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_navigation_ui(&mut self, ctx: &egui::Context) {
|
||||
let Some(navigation) = self.pending_navigation.clone() else {
|
||||
return;
|
||||
};
|
||||
let action = match &navigation {
|
||||
PendingNavigation::NewScene(_) => "Create a new scene",
|
||||
PendingNavigation::OpenProject(_) => "Open another project",
|
||||
PendingNavigation::OpenScene(_) => "Open another scene",
|
||||
};
|
||||
let can_save = self.project.is_some() && self.current_scene_path.is_some();
|
||||
let mut discard = false;
|
||||
let mut save_and_continue = false;
|
||||
let mut cancel = false;
|
||||
let response = egui::Modal::new(egui::Id::new("unsaved_navigation")).show(ctx, |ui| {
|
||||
ui.set_width(440.0);
|
||||
ui.heading("Unsaved changes");
|
||||
ui.add_space(4.0);
|
||||
ui.label(format!(
|
||||
"The current scene has unsaved changes and its undo history will be lost. {action} anyway?"
|
||||
));
|
||||
if !can_save {
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
egui::RichText::new("This scene has no saved file yet; use Save As first if you want to keep it.")
|
||||
.color(theme::MUTED)
|
||||
.size(11.5),
|
||||
);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal(|ui| {
|
||||
if can_save
|
||||
&& ui
|
||||
.button("Save and continue")
|
||||
.on_hover_text("Save the current scene, then continue")
|
||||
.clicked()
|
||||
{
|
||||
save_and_continue = true;
|
||||
ui.close();
|
||||
}
|
||||
if ui.add(widgets::danger_button("Discard and continue")).clicked() {
|
||||
discard = true;
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
cancel = true;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
if response.should_close() && !discard && !save_and_continue {
|
||||
cancel = true;
|
||||
}
|
||||
if cancel {
|
||||
self.pending_navigation = None;
|
||||
self.ui.status = "Navigation cancelled".into();
|
||||
}
|
||||
if save_and_continue {
|
||||
self.save_scene();
|
||||
if !self.session.is_dirty() {
|
||||
self.pending_navigation = None;
|
||||
self.perform_navigation(navigation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if discard {
|
||||
self.pending_navigation = None;
|
||||
self.perform_navigation(navigation);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_actions(&mut self, ctx: &egui::Context, actions: Vec<Action>) {
|
||||
// Keep only the last view-mode action if a menu produced several.
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::NewScene(template) => {
|
||||
self.request_navigation(PendingNavigation::NewScene(template))
|
||||
}
|
||||
Action::OpenProjectDialog => {
|
||||
self.dialogs.open_project_open = true;
|
||||
if self.welcome.project_path.is_empty() {
|
||||
self.welcome.project_path = self
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|project| project.root.display().to_string())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
self.dialogs.open_project_path = self.welcome.project_path.clone();
|
||||
}
|
||||
Action::OpenProject(path) => {
|
||||
self.request_navigation(PendingNavigation::OpenProject(path))
|
||||
}
|
||||
Action::OpenSceneDialog => {
|
||||
self.dialogs.open_scene_path = self
|
||||
.current_scene_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_default();
|
||||
self.dialogs.open_scene_open = true;
|
||||
}
|
||||
Action::OpenScene(path) => {
|
||||
self.request_navigation(PendingNavigation::OpenScene(path))
|
||||
}
|
||||
Action::Save => self.save_scene(),
|
||||
Action::OpenSaveAsDialog => self.open_save_as_dialog(),
|
||||
Action::SaveSceneTo(path) => {
|
||||
self.dialogs.save_as_open = false;
|
||||
self.save_scene_to(&path);
|
||||
}
|
||||
Action::OpenExportDialog(format) => self.open_export_dialog(format),
|
||||
Action::ExportTo { format, path } => {
|
||||
self.export(format, &path);
|
||||
}
|
||||
Action::CopyExport(format) => self.copy_export(ctx, format),
|
||||
Action::Undo => {
|
||||
if let Some(label) = self.session.undo() {
|
||||
self.ui.selection.retain_existing(self.session.doc());
|
||||
self.ui.status = format!("Undid {label}");
|
||||
}
|
||||
}
|
||||
Action::Redo => {
|
||||
if let Some(label) = self.session.redo() {
|
||||
self.ui.selection.retain_existing(self.session.doc());
|
||||
self.ui.status = format!("Redid {label}");
|
||||
}
|
||||
}
|
||||
Action::DeleteSelection => self.delete_selection(),
|
||||
Action::DuplicateSelection => self.duplicate_selection(),
|
||||
Action::FocusSelection => {
|
||||
self.ui.fit_view_requested = true;
|
||||
}
|
||||
Action::SetViewMode(mode) => self.ui.view_mode = mode,
|
||||
Action::ToggleViewMode => self.ui.view_mode = self.ui.view_mode.toggled(),
|
||||
Action::FitView => self.ui.fit_view_requested = true,
|
||||
Action::ToggleGrid => {
|
||||
self.ui.show_grid = !self.ui.show_grid;
|
||||
self.config.show_grid = self.ui.show_grid;
|
||||
let _ = self.config.save();
|
||||
}
|
||||
Action::ToggleSnap => {
|
||||
self.ui.snap_to_grid = !self.ui.snap_to_grid;
|
||||
self.config.snap_to_grid = self.ui.snap_to_grid;
|
||||
let _ = self.config.save();
|
||||
}
|
||||
Action::ToggleAssets => {
|
||||
self.ui.show_assets = !self.ui.show_assets;
|
||||
self.config.show_assets = self.ui.show_assets;
|
||||
let _ = self.config.save();
|
||||
}
|
||||
Action::ToggleLeftPanel => self.ui.show_left_panel = !self.ui.show_left_panel,
|
||||
Action::ToggleRightPanel => self.ui.show_right_panel = !self.ui.show_right_panel,
|
||||
Action::OpenCommandPalette => {
|
||||
self.palette.open = true;
|
||||
self.palette.query.clear();
|
||||
self.palette.selected = 0;
|
||||
}
|
||||
Action::ShowShortcuts => self.dialogs.shortcuts_open = true,
|
||||
Action::ShowAbout => self.dialogs.about_open = true,
|
||||
Action::RequestQuit => {
|
||||
if self.session.is_dirty() {
|
||||
self.dialogs.confirm_quit_open = true;
|
||||
} else {
|
||||
self.quit_now = true;
|
||||
}
|
||||
}
|
||||
Action::QuitNow => {
|
||||
self.allow_close = true;
|
||||
self.quit_now = true;
|
||||
}
|
||||
Action::ReloadAssets => {
|
||||
if let Some(project) = &self.project {
|
||||
self.assets = AssetIndex::scan(&project.assets_dir);
|
||||
self.toasts
|
||||
.success(format!("Indexed {} assets", self.assets.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fn editor_ui(&mut self, ctx: &egui::Context) {
|
||||
let mut actions = Vec::new();
|
||||
let modal_open = self.palette.open
|
||||
|| self.dialogs.save_as_open
|
||||
|| self.dialogs.open_project_open
|
||||
|| self.dialogs.open_scene_open
|
||||
|| self.dialogs.confirm_quit_open
|
||||
|| self.dialogs.shortcuts_open
|
||||
|| self.dialogs.about_open
|
||||
|| self.export.dialog_open
|
||||
|| self.pending_navigation.is_some();
|
||||
let mut cx = EditorContext {
|
||||
session: &mut self.session,
|
||||
project: self.project.as_ref(),
|
||||
assets: &self.assets,
|
||||
textures: &mut self.textures,
|
||||
ui: &mut self.ui,
|
||||
toasts: &mut self.toasts,
|
||||
export: &mut self.export,
|
||||
};
|
||||
|
||||
actions.extend(menu::show(ctx, &cx));
|
||||
if let Some(error) = cx.ui.error_banner.clone() {
|
||||
let mut dismiss = false;
|
||||
egui::TopBottomPanel::top("error_banner")
|
||||
.frame(
|
||||
egui::Frame::new()
|
||||
.fill(egui::Color32::from_rgb(0x3a, 0x1f, 0x24))
|
||||
.inner_margin(egui::Margin::symmetric(10, 5)),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("⚠").color(theme::ERROR).strong());
|
||||
ui.label(egui::RichText::new(error).color(theme::TEXT).size(12.0));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Dismiss").clicked() {
|
||||
dismiss = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if dismiss {
|
||||
cx.ui.error_banner = None;
|
||||
}
|
||||
}
|
||||
if !modal_open {
|
||||
actions.extend(shortcuts::handle(ctx, cx.session));
|
||||
}
|
||||
status_bar::show(ctx, &cx);
|
||||
|
||||
// Side panels come before the asset drawer so they keep the full editor
|
||||
// height; the drawer then occupies only the central column. This keeps
|
||||
// the selected hierarchy row and the inspector reachable at 1440x900
|
||||
// and at the 1000x640 minimum size.
|
||||
if cx.ui.show_left_panel {
|
||||
actions.extend(hierarchy::show(ctx, &mut cx, &mut self.hierarchy));
|
||||
}
|
||||
if cx.ui.show_right_panel {
|
||||
actions.extend(inspector::show(ctx, &mut cx, &mut self.inspector));
|
||||
}
|
||||
actions.extend(assets_panel::show(ctx, &mut cx, &mut self.assets_panel));
|
||||
|
||||
let mode = cx.ui.view_mode;
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame::new().fill(theme::BG))
|
||||
.show(ctx, |ui| match mode {
|
||||
ViewMode::TwoD => {
|
||||
actions.extend(viewport2d::show(ui, &mut cx, &mut self.viewport2d));
|
||||
}
|
||||
ViewMode::ThreeD => {
|
||||
actions.extend(viewport3d::show(ui, &mut cx, &mut self.viewport3d));
|
||||
}
|
||||
});
|
||||
|
||||
actions.extend(palette::show(ctx, &mut cx, &mut self.palette));
|
||||
actions.extend(dialogs::show(ctx, &mut cx, &mut self.dialogs));
|
||||
drop(cx);
|
||||
|
||||
self.handle_actions(ctx, actions);
|
||||
self.autosave();
|
||||
}
|
||||
|
||||
fn welcome_ui(&mut self, ctx: &egui::Context) {
|
||||
let actions = welcome::show(ctx, &mut self.welcome, &self.config);
|
||||
self.handle_actions(ctx, actions);
|
||||
}
|
||||
|
||||
fn autosave(&mut self) {
|
||||
if self.config.autosave_secs == 0 || !self.session.is_dirty() {
|
||||
return;
|
||||
}
|
||||
let Some(project) = &self.project else {
|
||||
return;
|
||||
};
|
||||
let due = self
|
||||
.last_autosave
|
||||
.map(|last| last.elapsed().as_secs() >= self.config.autosave_secs)
|
||||
.unwrap_or(true);
|
||||
if !due {
|
||||
return;
|
||||
}
|
||||
let path = project.autosave_path();
|
||||
if project.save_scene(self.session.doc(), Some(&path)).is_ok() {
|
||||
self.last_autosave = Some(Instant::now());
|
||||
self.ui.status = format!("Autosaved to {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn update_title(&mut self, ctx: &egui::Context) {
|
||||
let title = match (&self.project, self.screen) {
|
||||
(Some(project), Screen::Editor) => format!(
|
||||
"Bevy Forge — {} — {}{}",
|
||||
project.name,
|
||||
self.session.doc().name,
|
||||
if self.session.is_dirty() { " *" } else { "" }
|
||||
),
|
||||
_ => "Bevy Forge — Bevy scene editor".to_string(),
|
||||
};
|
||||
if title != self.last_title {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||||
self.last_title = title;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_screenshot(&mut self, ctx: &egui::Context) {
|
||||
let Some(path) = self.screenshot_path.clone() else {
|
||||
return;
|
||||
};
|
||||
if ctx.input(|input| {
|
||||
input
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| matches!(event, egui::Event::Screenshot { .. }))
|
||||
}) {
|
||||
let image = ctx.input(|input| {
|
||||
input.events.iter().find_map(|event| match event {
|
||||
egui::Event::Screenshot { image, .. } => Some(image.clone()),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some(image) = image {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let result = image::save_buffer_with_format(
|
||||
&path,
|
||||
image.as_raw(),
|
||||
image.width() as u32,
|
||||
image.height() as u32,
|
||||
image::ColorType::Rgba8,
|
||||
image::ImageFormat::Png,
|
||||
);
|
||||
match result {
|
||||
Ok(()) => eprintln!("bevy-forge: screenshot written to {}", path.display()),
|
||||
Err(error) => eprintln!("bevy-forge: screenshot failed: {error}"),
|
||||
}
|
||||
}
|
||||
self.screenshot_path = None;
|
||||
self.allow_close = true;
|
||||
self.quit_now = true;
|
||||
return;
|
||||
}
|
||||
self.screenshot_frames = self.screenshot_frames.saturating_add(1);
|
||||
if self.screenshot_frames == 30 && !self.screenshot_requested {
|
||||
self.screenshot_requested = true;
|
||||
// Screenshot mode is for docs/CI: no transient toasts in the frame.
|
||||
self.toasts.clear();
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
|
||||
}
|
||||
if self.screenshot_frames < 32 {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_close_request(&mut self, ctx: &egui::Context) {
|
||||
if ctx.input(|input| input.viewport().close_requested())
|
||||
&& self.session.is_dirty()
|
||||
&& !self.allow_close
|
||||
&& self.screen == Screen::Editor
|
||||
{
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
self.dialogs.confirm_quit_open = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ForgeApp {
|
||||
/// Render one frame. Exposed so headless tests can drive the full UI without
|
||||
/// an eframe window or GPU.
|
||||
pub fn ui(&mut self, ctx: &egui::Context) {
|
||||
match self.screen {
|
||||
Screen::Welcome => self.welcome_ui(ctx),
|
||||
Screen::Editor => self.editor_ui(ctx),
|
||||
}
|
||||
self.pending_navigation_ui(ctx);
|
||||
self.toasts.show(ctx);
|
||||
// Autosave is time-based; without a periodic repaint an idle window
|
||||
// would never run the timer.
|
||||
if self.session.is_dirty() && self.project.is_some() && self.config.autosave_secs > 0 {
|
||||
ctx.request_repaint_after(Duration::from_secs(1));
|
||||
}
|
||||
self.update_title(ctx);
|
||||
}
|
||||
|
||||
/// Dispatch a single deferred action (used by the palette, tests and menus).
|
||||
pub fn dispatch_action(&mut self, ctx: &egui::Context, action: Action) {
|
||||
self.handle_actions(ctx, vec![action]);
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for ForgeApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.handle_close_request(ctx);
|
||||
self.ui(ctx);
|
||||
self.handle_screenshot(ctx);
|
||||
if self.quit_now {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum PendingNavigation {
|
||||
NewScene(ProjectTemplate),
|
||||
OpenProject(PathBuf),
|
||||
OpenScene(PathBuf),
|
||||
}
|
||||
|
||||
fn file_name(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("file")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn slug(name: &str) -> String {
|
||||
bevy_forge_core::project::slugify(name)
|
||||
}
|
||||
|
||||
/// Pick the entity that makes the first frame most informative: a named content
|
||||
/// entity (mesh/sprite) rather than a camera, a light or a giant backdrop.
|
||||
fn choose_initial_selection(doc: &bevy_forge_core::SceneDoc) -> Option<bevy_forge_core::EntityId> {
|
||||
use bevy_forge_core::ComponentData;
|
||||
|
||||
let mut best: Option<(i32, bevy_forge_core::EntityId)> = None;
|
||||
for node in doc.entities.values() {
|
||||
if node.is_hidden() {
|
||||
continue;
|
||||
}
|
||||
let mut score = 0;
|
||||
let mut area = 0.0;
|
||||
for component in &node.components {
|
||||
match component {
|
||||
ComponentData::Camera2d | ComponentData::Camera3d => score -= 120,
|
||||
ComponentData::DirectionalLight(_) | ComponentData::PointLight(_) => score -= 30,
|
||||
ComponentData::Mesh3d(_) => score += 50,
|
||||
ComponentData::Sprite(sprite) => {
|
||||
score += 40;
|
||||
if let Some(size) = sprite.custom_size {
|
||||
area = (size.x * size.y).abs();
|
||||
}
|
||||
}
|
||||
ComponentData::Text2d(_) => score += 15,
|
||||
ComponentData::Custom(_) => score += 20,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !node.children.is_empty() {
|
||||
score += 30;
|
||||
}
|
||||
if node.name.eq_ignore_ascii_case("player")
|
||||
|| node.name.to_ascii_lowercase().contains("crystal")
|
||||
{
|
||||
score += 100;
|
||||
}
|
||||
if area > 50_000.0 {
|
||||
score -= 60;
|
||||
}
|
||||
if score <= 0 {
|
||||
continue;
|
||||
}
|
||||
let candidate = (score, node.id);
|
||||
if best
|
||||
.map(|(best_score, _)| score > best_score)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
best = Some(candidate);
|
||||
}
|
||||
}
|
||||
best.map(|(_, id)| id)
|
||||
}
|
||||
|
||||
fn function_name_for(scene_name: &str) -> String {
|
||||
let mut name = slug(scene_name).replace('-', "_");
|
||||
if name
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_digit())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
name = format!("scene_{name}");
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_project() -> (tempfile::TempDir, Project) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("Cargo.toml"),
|
||||
"[package]\nname = \"autosave-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("assets")).unwrap();
|
||||
let project = Project::open(dir.path()).unwrap();
|
||||
(dir, project)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_writes_when_due_and_dirty() {
|
||||
let ctx = egui::Context::default();
|
||||
let (_dir, project) = temp_project();
|
||||
let mut app = ForgeApp::new_for_test(&ctx, SceneDoc::new("Autosave"));
|
||||
app.assets = AssetIndex::scan(&project.assets_dir);
|
||||
app.project = Some(project);
|
||||
app.session.edit("add", |doc| {
|
||||
doc.add_entity("Root", None);
|
||||
});
|
||||
app.config.autosave_secs = 1;
|
||||
app.last_autosave = Some(Instant::now() - Duration::from_secs(5));
|
||||
app.autosave();
|
||||
let path = app.project.as_ref().unwrap().autosave_path();
|
||||
assert!(path.is_file(), "autosave file should exist at {path:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_navigation_is_prompted_before_replacing_the_scene() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = ForgeApp::new_for_test(&ctx, SceneDoc::new("Dirty"));
|
||||
app.session.edit("add", |doc| {
|
||||
doc.add_entity("Keep me", None);
|
||||
});
|
||||
app.request_navigation(PendingNavigation::NewScene(ProjectTemplate::Empty));
|
||||
assert!(app.pending_navigation.is_some());
|
||||
assert_eq!(app.session.doc().entities.len(), 1);
|
||||
assert_eq!(app.session.doc().name, "Dirty");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
//! Bevy Forge editor library. Exposed for headless panel tests.
|
||||
|
||||
pub mod action;
|
||||
pub mod app;
|
||||
pub mod assets_panel;
|
||||
pub mod dialogs;
|
||||
pub mod hierarchy;
|
||||
pub mod inspector;
|
||||
pub mod menu;
|
||||
pub mod palette;
|
||||
pub mod shortcuts;
|
||||
pub mod state;
|
||||
pub mod status_bar;
|
||||
pub mod texture_cache;
|
||||
pub mod theme;
|
||||
pub mod toast;
|
||||
pub mod viewport2d;
|
||||
pub mod viewport3d;
|
||||
pub mod welcome;
|
||||
pub mod widgets;
|
||||
|
||||
pub use action::Action;
|
||||
pub use app::ForgeApp;
|
||||
pub use state::{Screen, Selection, UiState, ViewMode};
|
||||
@@ -0,0 +1,34 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let initial = std::env::args_os().nth(1).map(PathBuf::from);
|
||||
let size = std::env::var("BEVY_FORGE_WINDOW_SIZE")
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
let (width, height) = value.split_once('x')?;
|
||||
Some([
|
||||
width.trim().parse::<f32>().ok()?,
|
||||
height.trim().parse::<f32>().ok()?,
|
||||
])
|
||||
})
|
||||
.unwrap_or([1440.0, 900.0]);
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size(size)
|
||||
.with_min_inner_size([1000.0_f32, 640.0_f32])
|
||||
.with_title("Bevy Forge")
|
||||
.with_app_id("bevy-forge"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"Bevy Forge",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
bevy_forge_editor::theme::apply(&cc.egui_ctx);
|
||||
Ok(Box::new(bevy_forge_editor::ForgeApp::new(cc, initial)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
//! Top menu bar: File, Edit, View and Help menus plus the project/scene badge.
|
||||
//!
|
||||
//! Menu entries only emit deferred [`Action`]s; the app owns all state changes.
|
||||
//! The context is borrowed immutably, so toggles edit local copies and emit the
|
||||
//! matching toggle action. Every action item closes its menu with `ui.close()`.
|
||||
//!
|
||||
//! Shortcut hints use [`egui::Modifiers::COMMAND`] conventions (`Ctrl` on
|
||||
//! Linux/Windows, `Cmd` on macOS) and match [`crate::shortcuts`].
|
||||
|
||||
use bevy_forge_core::{ExportFormat, ProjectTemplate};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::state::{EditorContext, ViewMode};
|
||||
use crate::theme;
|
||||
use crate::widgets;
|
||||
|
||||
/// Label of the platform's primary command modifier.
|
||||
const COMMAND: &str = if cfg!(target_os = "macos") {
|
||||
"Cmd"
|
||||
} else {
|
||||
"Ctrl"
|
||||
};
|
||||
|
||||
pub fn show(ctx: &egui::Context, cx: &EditorContext<'_>) -> Vec<Action> {
|
||||
let mut actions = Vec::new();
|
||||
|
||||
egui::TopBottomPanel::top("menu_bar")
|
||||
.exact_height(32.0)
|
||||
.frame(
|
||||
egui::Frame::new()
|
||||
.fill(theme::BG)
|
||||
.inner_margin(egui::Margin::symmetric(10, 4)),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("◊ Bevy Forge")
|
||||
.color(theme::ACCENT)
|
||||
.strong()
|
||||
.size(14.0),
|
||||
)
|
||||
.on_hover_text("Bevy Forge — visual scene editor for Bevy 0.20 BSN");
|
||||
ui.separator();
|
||||
|
||||
file_menu(ui, cx, &mut actions);
|
||||
edit_menu(ui, cx, &mut actions);
|
||||
view_menu(ui, cx, &mut actions);
|
||||
help_menu(ui, &mut actions);
|
||||
|
||||
project_badge(ui, cx);
|
||||
});
|
||||
});
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Menus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn file_menu(ui: &mut egui::Ui, cx: &EditorContext<'_>, actions: &mut Vec<Action>) {
|
||||
let project_open = cx.project.is_some();
|
||||
|
||||
ui.menu_button("File", |ui| {
|
||||
// A click inside the "New scene" submenu closes the submenu but not the
|
||||
// File menu (egui delegates close handling to the submenu), so remember
|
||||
// to close the parent as well.
|
||||
let mut close_file_menu = false;
|
||||
ui.menu_button("New scene", |ui| {
|
||||
if item(ui, "Empty scene", "")
|
||||
.on_hover_text("A blank scene with nothing in it")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::NewScene(ProjectTemplate::Empty));
|
||||
ui.close();
|
||||
close_file_menu = true;
|
||||
}
|
||||
if item(ui, "2D starter", "")
|
||||
.on_hover_text("Camera, light and a sprite to build on")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::NewScene(ProjectTemplate::TwoD));
|
||||
ui.close();
|
||||
close_file_menu = true;
|
||||
}
|
||||
if item(ui, "3D starter", "")
|
||||
.on_hover_text("Camera, sun, ground and primitive shapes")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::NewScene(ProjectTemplate::ThreeD));
|
||||
ui.close();
|
||||
close_file_menu = true;
|
||||
}
|
||||
});
|
||||
if close_file_menu {
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if item(ui, "Open project…", &shortcut(false, "O")).clicked() {
|
||||
actions.push(Action::OpenProjectDialog);
|
||||
ui.close();
|
||||
}
|
||||
if item_enabled(ui, project_open, "Open scene…", "")
|
||||
.on_disabled_hover_text("Open a project first")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::OpenSceneDialog);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let save = item(ui, "Save", &shortcut(false, "S"));
|
||||
let save = if project_open {
|
||||
save.on_hover_text("Save the current scene")
|
||||
} else {
|
||||
save.on_hover_text("No project open — this opens the Save as dialog")
|
||||
};
|
||||
if save.clicked() {
|
||||
actions.push(Action::Save);
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "Save as…", &shortcut(true, "S")).clicked() {
|
||||
actions.push(Action::OpenSaveAsDialog);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if item_enabled(ui, project_open, "Reload assets", "")
|
||||
.on_disabled_hover_text("Open a project first")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ReloadAssets);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if item(ui, "Export BSN…", &shortcut(false, "E"))
|
||||
.on_hover_text("Bevy 0.20 scene notation, ready to paste into a project")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::OpenExportDialog(ExportFormat::Bsn));
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "Export Rust…", &shortcut(true, "E"))
|
||||
.on_hover_text("Imperative Commands::spawn code")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::OpenExportDialog(ExportFormat::Rust));
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "Copy BSN", "")
|
||||
.on_hover_text("Generate BSN code and copy it to the clipboard")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::CopyExport(ExportFormat::Bsn));
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if item(ui, "Quit", "").clicked() {
|
||||
actions.push(Action::RequestQuit);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn edit_menu(ui: &mut egui::Ui, cx: &EditorContext<'_>, actions: &mut Vec<Action>) {
|
||||
ui.menu_button("Edit", |ui| {
|
||||
let undo_label = match cx.session.undo_label() {
|
||||
Some(label) => format!("Undo {label}"),
|
||||
None => "Undo".to_owned(),
|
||||
};
|
||||
if item_enabled(
|
||||
ui,
|
||||
cx.session.can_undo(),
|
||||
&undo_label,
|
||||
&shortcut(false, "Z"),
|
||||
)
|
||||
.on_disabled_hover_text("Nothing to undo yet")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::Undo);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
let redo_label = match cx.session.redo_label() {
|
||||
Some(label) => format!("Redo {label}"),
|
||||
None => "Redo".to_owned(),
|
||||
};
|
||||
if item_enabled(
|
||||
ui,
|
||||
cx.session.can_redo(),
|
||||
&redo_label,
|
||||
&shortcut(false, "Y"),
|
||||
)
|
||||
.on_disabled_hover_text("Nothing to redo")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::Redo);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let has_selection = !cx.ui.selection.is_empty();
|
||||
if item_enabled(ui, has_selection, "Duplicate", &shortcut(false, "D"))
|
||||
.on_disabled_hover_text("Select an entity first")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::DuplicateSelection);
|
||||
ui.close();
|
||||
}
|
||||
if item_enabled(ui, has_selection, "Delete", "Delete")
|
||||
.on_disabled_hover_text("Select an entity first")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::DeleteSelection);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn view_menu(ui: &mut egui::Ui, cx: &EditorContext<'_>, actions: &mut Vec<Action>) {
|
||||
ui.menu_button("View", |ui| {
|
||||
let mut mode = cx.ui.view_mode;
|
||||
let two_d = ui
|
||||
.radio_value(&mut mode, ViewMode::TwoD, "2D view")
|
||||
.on_hover_text("Switch to the 2D viewport (2)");
|
||||
let three_d = ui
|
||||
.radio_value(&mut mode, ViewMode::ThreeD, "3D view")
|
||||
.on_hover_text("Switch to the 3D viewport (3)");
|
||||
if (two_d.clicked() || three_d.clicked()) && mode != cx.ui.view_mode {
|
||||
actions.push(Action::SetViewMode(mode));
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let mut grid = cx.ui.show_grid;
|
||||
if ui
|
||||
.checkbox(&mut grid, "Grid")
|
||||
.on_hover_text("Show the grid (G)")
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::ToggleGrid);
|
||||
ui.close();
|
||||
}
|
||||
let mut snap = cx.ui.snap_to_grid;
|
||||
if ui
|
||||
.checkbox(&mut snap, "Snap to grid")
|
||||
.on_hover_text("Snap moves to whole grid steps")
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::ToggleSnap);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let mut assets = cx.ui.show_assets;
|
||||
if ui
|
||||
.checkbox(&mut assets, "Asset browser")
|
||||
.on_hover_text(format!(
|
||||
"Toggle the asset browser ({})",
|
||||
shortcut(false, "B")
|
||||
))
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::ToggleAssets);
|
||||
ui.close();
|
||||
}
|
||||
let mut left = cx.ui.show_left_panel;
|
||||
if ui
|
||||
.checkbox(&mut left, "Hierarchy panel")
|
||||
.on_hover_text(format!(
|
||||
"Toggle the hierarchy panel ({})",
|
||||
shortcut(true, "L")
|
||||
))
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::ToggleLeftPanel);
|
||||
ui.close();
|
||||
}
|
||||
let mut right = cx.ui.show_right_panel;
|
||||
if ui
|
||||
.checkbox(&mut right, "Inspector panel")
|
||||
.on_hover_text("Toggle the inspector panel")
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::ToggleRightPanel);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if item(ui, "Fit view", "F").clicked() {
|
||||
actions.push(Action::FitView);
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "Command palette…", &shortcut(false, "K")).clicked() {
|
||||
actions.push(Action::OpenCommandPalette);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn help_menu(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
||||
ui.menu_button("Help", |ui| {
|
||||
if item(ui, "Keyboard shortcuts", "F1").clicked() {
|
||||
actions.push(Action::ShowShortcuts);
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "About Bevy Forge", "").clicked() {
|
||||
actions.push(Action::ShowAbout);
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
ui.hyperlink_to("Bevy 0.20 documentation", "https://bevy.org")
|
||||
.on_hover_text("Open bevy.org in your browser");
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project / scene badge on the right
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn project_badge(ui: &mut egui::Ui, cx: &EditorContext<'_>) {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
widgets::help_marker(
|
||||
ui,
|
||||
"Ctrl+K opens the command palette · F1 lists every shortcut",
|
||||
);
|
||||
|
||||
if cx.session.is_dirty() {
|
||||
ui.label(
|
||||
egui::RichText::new("• unsaved")
|
||||
.color(theme::WARNING)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Unsaved changes — Ctrl+S saves the scene");
|
||||
}
|
||||
|
||||
let scene = &cx.session.doc().name;
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(shorten(scene, 36))
|
||||
.color(theme::TEXT)
|
||||
.size(12.0),
|
||||
)
|
||||
.truncate(),
|
||||
)
|
||||
.on_hover_text(format!("Scene: {scene}"));
|
||||
|
||||
ui.separator();
|
||||
|
||||
match cx.project {
|
||||
Some(project) => {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(shorten(&project.name, 24))
|
||||
.color(theme::MUTED)
|
||||
.size(11.5),
|
||||
)
|
||||
.truncate(),
|
||||
)
|
||||
.on_hover_text(format!("Project: {}", project.root.display()));
|
||||
}
|
||||
None => {
|
||||
ui.label(
|
||||
egui::RichText::new("No project")
|
||||
.color(theme::FAINT)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Open a Bevy project to save scenes, browse assets and export");
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Standard menu item with a right-aligned shortcut hint.
|
||||
fn item(ui: &mut egui::Ui, label: &str, shortcut_text: &str) -> egui::Response {
|
||||
let button = egui::Button::new(label);
|
||||
let button = if shortcut_text.is_empty() {
|
||||
button
|
||||
} else {
|
||||
button.shortcut_text(shortcut_text)
|
||||
};
|
||||
ui.add(button)
|
||||
}
|
||||
|
||||
/// Menu item that can be greyed out, with an explanation on hover.
|
||||
fn item_enabled(
|
||||
ui: &mut egui::Ui,
|
||||
enabled: bool,
|
||||
label: &str,
|
||||
shortcut_text: &str,
|
||||
) -> egui::Response {
|
||||
let button = egui::Button::new(label);
|
||||
let button = if shortcut_text.is_empty() {
|
||||
button
|
||||
} else {
|
||||
button.shortcut_text(shortcut_text)
|
||||
};
|
||||
ui.add_enabled(enabled, button)
|
||||
}
|
||||
|
||||
/// `Ctrl+Z`-style hint for the menu (with `Shift` when requested).
|
||||
fn shortcut(shift: bool, key: &str) -> String {
|
||||
if shift {
|
||||
format!("{COMMAND}+Shift+{key}")
|
||||
} else {
|
||||
format!("{COMMAND}+{key}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Elide the middle of a long scene/project name so both ends stay readable.
|
||||
fn shorten(text: &str, max_chars: usize) -> String {
|
||||
let count = text.chars().count();
|
||||
if count <= max_chars || max_chars < 4 {
|
||||
return text.to_owned();
|
||||
}
|
||||
let keep_left = (max_chars - 1) / 2;
|
||||
let keep_right = max_chars - 1 - keep_left;
|
||||
let left: String = text.chars().take(keep_left).collect();
|
||||
let right: String = text.chars().skip(count - keep_right).collect();
|
||||
format!("{left}…{right}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::{ExportState, UiState};
|
||||
use crate::texture_cache::TextureCache;
|
||||
use crate::toast::Toasts;
|
||||
use bevy_forge_core::{AssetIndex, SceneDoc, Session};
|
||||
|
||||
#[test]
|
||||
fn shortcut_hints_use_the_platform_command_key() {
|
||||
assert_eq!(shortcut(false, "S"), format!("{COMMAND}+S"));
|
||||
assert_eq!(shortcut(true, "S"), format!("{COMMAND}+Shift+S"));
|
||||
assert!(shortcut(false, "Z").ends_with('Z'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_names_are_shortened_in_the_middle() {
|
||||
assert_eq!(shorten("garden", 20), "garden");
|
||||
assert_eq!(shorten("abcdefghij", 6), "ab…hij");
|
||||
assert_eq!(
|
||||
shorten("Édition — jardin.forge.ron", 14).chars().count(),
|
||||
14
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_draws_headlessly_without_a_project() {
|
||||
let ctx = egui::Context::default();
|
||||
theme::apply(&ctx);
|
||||
|
||||
let mut session = Session::new(SceneDoc::new("Untitled"));
|
||||
let assets = AssetIndex::default();
|
||||
let mut textures = TextureCache::new();
|
||||
let mut ui_state = UiState::default();
|
||||
let mut toasts = Toasts::new();
|
||||
let mut export = ExportState::default();
|
||||
let mut actions = Vec::new();
|
||||
|
||||
let raw_input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(1440.0, 900.0),
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = ctx.run(raw_input, |ctx| {
|
||||
let cx = EditorContext {
|
||||
session: &mut session,
|
||||
project: None,
|
||||
assets: &assets,
|
||||
textures: &mut textures,
|
||||
ui: &mut ui_state,
|
||||
toasts: &mut toasts,
|
||||
export: &mut export,
|
||||
};
|
||||
actions = show(ctx, &cx);
|
||||
});
|
||||
|
||||
assert!(actions.is_empty(), "opening the menu bar emits no action");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
//! Command palette (Ctrl+K): fuzzy command search with keyboard navigation.
|
||||
//!
|
||||
//! The palette only emits [`Action`]s; `app.rs` owns the actual behaviour. Commands
|
||||
//! that cannot run in the current state (undo with no history, delete with no
|
||||
//! selection, …) stay visible but are rendered as disabled.
|
||||
|
||||
use bevy_forge_core::{ExportFormat, Project, ProjectTemplate, Session};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::state::{EditorContext, Selection, ViewMode};
|
||||
use crate::theme;
|
||||
use crate::widgets;
|
||||
|
||||
/// Maximum number of command rows visible before the list scrolls.
|
||||
const MAX_VISIBLE_ROWS: usize = 10;
|
||||
/// Height of a single command row, in points.
|
||||
const ROW_HEIGHT: f32 = 32.0;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PaletteState {
|
||||
pub open: bool,
|
||||
pub query: String,
|
||||
pub selected: usize,
|
||||
/// Set once focus has been requested for the current opening.
|
||||
pub focus_requested: bool,
|
||||
/// Scroll the selected row into view on the next list pass.
|
||||
pub scroll_to_selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Category {
|
||||
File,
|
||||
Edit,
|
||||
View,
|
||||
Help,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::File => "FILE",
|
||||
Self::Edit => "EDIT",
|
||||
Self::View => "VIEW",
|
||||
Self::Help => "HELP",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum CommandId {
|
||||
NewEmptyScene,
|
||||
New2dScene,
|
||||
New3dScene,
|
||||
OpenProject,
|
||||
OpenScene,
|
||||
Save,
|
||||
SaveAs,
|
||||
ExportBsn,
|
||||
ExportRust,
|
||||
CopyBsn,
|
||||
ToggleBothPanels,
|
||||
ToggleHierarchy,
|
||||
ToggleInspector,
|
||||
Undo,
|
||||
Redo,
|
||||
Duplicate,
|
||||
Delete,
|
||||
FitView,
|
||||
ToggleGrid,
|
||||
ToggleSnap,
|
||||
ToggleAssets,
|
||||
View2d,
|
||||
View3d,
|
||||
Shortcuts,
|
||||
About,
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct CommandSpec {
|
||||
id: CommandId,
|
||||
label: &'static str,
|
||||
category: Category,
|
||||
shortcut: Option<&'static str>,
|
||||
hint: &'static str,
|
||||
}
|
||||
|
||||
const COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec {
|
||||
id: CommandId::NewEmptyScene,
|
||||
label: "New empty scene",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Start a blank scene with no entities.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::New2dScene,
|
||||
label: "New 2D scene",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Create a 2D starter with a camera and sprites.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::New3dScene,
|
||||
label: "New 3D scene",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Create a 3D starter with a camera, light and primitives.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::OpenProject,
|
||||
label: "Open project…",
|
||||
category: Category::File,
|
||||
shortcut: Some("Ctrl+O"),
|
||||
hint: "Open a Bevy project folder (Cargo.toml with a bevy dependency).",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::OpenScene,
|
||||
label: "Open scene…",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Pick one of the .forge.ron scenes in the current project.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Save,
|
||||
label: "Save scene",
|
||||
category: Category::File,
|
||||
shortcut: Some("Ctrl+S"),
|
||||
hint: "Save the current scene into the project's scenes/ folder.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::SaveAs,
|
||||
label: "Save scene as…",
|
||||
category: Category::File,
|
||||
shortcut: Some("Ctrl+Shift+S"),
|
||||
hint: "Choose a path for the scene file.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ExportBsn,
|
||||
label: "Export BSN…",
|
||||
category: Category::File,
|
||||
shortcut: Some("Ctrl+E"),
|
||||
hint: "Generate Bevy 0.20 BSN Rust code and copy or save it.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ExportRust,
|
||||
label: "Export Rust…",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Generate imperative Commands::spawn code.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::CopyBsn,
|
||||
label: "Copy BSN to clipboard",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Copy the generated BSN snippet without opening the export dialog.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Quit,
|
||||
label: "Quit Bevy Forge",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Close the editor. Unsaved changes ask first.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Undo,
|
||||
label: "Undo",
|
||||
category: Category::Edit,
|
||||
shortcut: Some("Ctrl+Z"),
|
||||
hint: "Revert the last scene edit.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Redo,
|
||||
label: "Redo",
|
||||
category: Category::Edit,
|
||||
shortcut: Some("Ctrl+Shift+Z"),
|
||||
hint: "Reapply the last undone edit.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Duplicate,
|
||||
label: "Duplicate selection",
|
||||
category: Category::Edit,
|
||||
shortcut: Some("Ctrl+D"),
|
||||
hint: "Deep-copy the selected entities.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Delete,
|
||||
label: "Delete selection",
|
||||
category: Category::Edit,
|
||||
shortcut: Some("Del"),
|
||||
hint: "Remove the selected entities and their children.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ToggleBothPanels,
|
||||
label: "Toggle both side panels",
|
||||
category: Category::View,
|
||||
shortcut: None,
|
||||
hint: "Show or hide the hierarchy and inspector together.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ToggleHierarchy,
|
||||
label: "Toggle hierarchy panel",
|
||||
category: Category::View,
|
||||
shortcut: None,
|
||||
hint: "Show or hide the scene hierarchy on the left.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ToggleInspector,
|
||||
label: "Toggle inspector panel",
|
||||
category: Category::View,
|
||||
shortcut: None,
|
||||
hint: "Show or hide the inspector on the right.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::FitView,
|
||||
label: "Fit view",
|
||||
category: Category::View,
|
||||
shortcut: Some("F"),
|
||||
hint: "Frame the scene content in the active viewport.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ToggleGrid,
|
||||
label: "Toggle grid",
|
||||
category: Category::View,
|
||||
shortcut: Some("G"),
|
||||
hint: "Show or hide the viewport grid.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ToggleSnap,
|
||||
label: "Toggle snap to grid",
|
||||
category: Category::View,
|
||||
shortcut: None,
|
||||
hint: "Snap transforms to the grid while dragging.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ToggleAssets,
|
||||
label: "Toggle asset browser",
|
||||
category: Category::View,
|
||||
shortcut: None,
|
||||
hint: "Show or hide the asset browser panel.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::View2d,
|
||||
label: "Switch to 2D view",
|
||||
category: Category::View,
|
||||
shortcut: Some("2"),
|
||||
hint: "Use the orthographic 2D viewport.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::View3d,
|
||||
label: "Switch to 3D view",
|
||||
category: Category::View,
|
||||
shortcut: Some("3"),
|
||||
hint: "Use the 3D viewport with orbit controls.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Shortcuts,
|
||||
label: "Keyboard shortcuts",
|
||||
category: Category::Help,
|
||||
shortcut: Some("F1"),
|
||||
hint: "Show every keyboard and mouse shortcut.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::About,
|
||||
label: "About Bevy Forge",
|
||||
category: Category::Help,
|
||||
shortcut: None,
|
||||
hint: "Version and project information.",
|
||||
},
|
||||
];
|
||||
|
||||
/// Render the palette when open and return the commands chosen this frame.
|
||||
pub fn show(
|
||||
ctx: &egui::Context,
|
||||
cx: &mut EditorContext<'_>,
|
||||
state: &mut PaletteState,
|
||||
) -> Vec<Action> {
|
||||
if !state.open {
|
||||
// Never hold keyboard focus while closed.
|
||||
state.focus_requested = false;
|
||||
state.scroll_to_selected = false;
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut actions = Vec::new();
|
||||
let matches = filter_commands(&state.query);
|
||||
if matches.is_empty() {
|
||||
state.selected = 0;
|
||||
} else if state.selected >= matches.len() {
|
||||
state.selected = matches.len() - 1;
|
||||
}
|
||||
|
||||
let id = egui::Id::new("forge_command_palette");
|
||||
let area =
|
||||
egui::Modal::default_area(id).anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 96.0));
|
||||
let mut executed = false;
|
||||
|
||||
let modal = egui::Modal::new(id)
|
||||
.area(area)
|
||||
.frame(dialog_frame(ctx))
|
||||
.backdrop_color(egui::Color32::from_black_alpha(150))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(520.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("Command palette")
|
||||
.size(14.0)
|
||||
.strong()
|
||||
.color(theme::TEXT),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new("Bevy Forge")
|
||||
.size(10.5)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
widgets::kbd(ui, "Esc");
|
||||
ui.label(egui::RichText::new("close").size(10.5).color(theme::FAINT));
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
|
||||
let search = ui.add(
|
||||
egui::TextEdit::singleline(&mut state.query)
|
||||
.id_salt("forge_palette_query")
|
||||
.hint_text("Search commands…")
|
||||
.desired_width(f32::INFINITY)
|
||||
.font(egui::TextStyle::Body)
|
||||
.margin(egui::Margin::symmetric(8, 6)),
|
||||
);
|
||||
if !state.focus_requested {
|
||||
search.request_focus();
|
||||
state.focus_requested = true;
|
||||
}
|
||||
// Arrow keys and Enter are only handled while the palette owns the
|
||||
// keyboard, so the palette can never steal keys from another field.
|
||||
let owns_keyboard =
|
||||
search.has_focus() || search.lost_focus() || !ctx.wants_keyboard_input();
|
||||
if owns_keyboard {
|
||||
let down = ctx.input_mut(|input| {
|
||||
input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)
|
||||
});
|
||||
let up = ctx.input_mut(|input| {
|
||||
input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)
|
||||
});
|
||||
if down || up {
|
||||
state.selected = step_selection(state.selected, matches.len(), down);
|
||||
state.scroll_to_selected = true;
|
||||
}
|
||||
if ctx.input(|input| input.key_pressed(egui::Key::Enter)) {
|
||||
if let Some(&command_index) = matches.get(state.selected) {
|
||||
let command = &COMMANDS[command_index];
|
||||
if command_enabled(command.id, cx.session, cx.project, &cx.ui.selection) {
|
||||
actions.extend(command_actions(command.id));
|
||||
executed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.separator();
|
||||
|
||||
if matches.is_empty() {
|
||||
widgets::empty_state(
|
||||
ui,
|
||||
"No commands match",
|
||||
"Try “save”, “export”, “2D”, “undo” or “about”.",
|
||||
);
|
||||
} else {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(ROW_HEIGHT * MAX_VISIBLE_ROWS as f32)
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
// Rows carry their own height; avoid gaps so exactly
|
||||
// MAX_VISIBLE_ROWS rows fit in the scroll viewport.
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for (visible_index, &command_index) in matches.iter().enumerate() {
|
||||
let command = &COMMANDS[command_index];
|
||||
let enabled = command_enabled(
|
||||
command.id,
|
||||
cx.session,
|
||||
cx.project,
|
||||
&cx.ui.selection,
|
||||
);
|
||||
let selected = visible_index == state.selected;
|
||||
let response = command_row(ui, command, selected, enabled);
|
||||
if selected && state.scroll_to_selected {
|
||||
response.scroll_to_me(Some(egui::Align::Center));
|
||||
state.scroll_to_selected = false;
|
||||
}
|
||||
if response.clicked() && enabled {
|
||||
state.selected = visible_index;
|
||||
actions.extend(command_actions(command.id));
|
||||
executed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.add_space(2.0);
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
let hint = matches
|
||||
.get(state.selected)
|
||||
.map(|&index| COMMANDS[index].hint)
|
||||
.unwrap_or("Type to filter commands.");
|
||||
ui.label(egui::RichText::new(hint).size(10.5).color(theme::FAINT));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
widgets::kbd(ui, "Enter");
|
||||
ui.label(egui::RichText::new("run").size(10.5).color(theme::FAINT));
|
||||
widgets::kbd(ui, "Up/Down");
|
||||
ui.label(
|
||||
egui::RichText::new("navigate")
|
||||
.size(10.5)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if executed || modal.should_close() {
|
||||
state.open = false;
|
||||
state.focus_requested = false;
|
||||
state.scroll_to_selected = false;
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
fn dialog_frame(ctx: &egui::Context) -> egui::Frame {
|
||||
egui::Frame::popup(&ctx.style())
|
||||
.fill(theme::PANEL)
|
||||
.stroke(egui::Stroke::new(1.0_f32, theme::BORDER))
|
||||
.corner_radius(egui::CornerRadius::same(10))
|
||||
.inner_margin(egui::Margin::symmetric(16, 14))
|
||||
}
|
||||
|
||||
/// Move the selection by one row, wrapping around the ends.
|
||||
fn step_selection(selected: usize, len: usize, down: bool) -> usize {
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
if down {
|
||||
if selected + 1 >= len {
|
||||
0
|
||||
} else {
|
||||
selected + 1
|
||||
}
|
||||
} else if selected == 0 {
|
||||
len - 1
|
||||
} else {
|
||||
selected - 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a command can run in the current editor state.
|
||||
fn command_enabled(
|
||||
id: CommandId,
|
||||
session: &Session,
|
||||
project: Option<&Project>,
|
||||
selection: &Selection,
|
||||
) -> bool {
|
||||
match id {
|
||||
CommandId::Undo => session.can_undo(),
|
||||
CommandId::Redo => session.can_redo(),
|
||||
CommandId::Duplicate | CommandId::Delete => !selection.is_empty(),
|
||||
CommandId::OpenScene => project.is_some(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Actions produced by executing a command. Most commands map to one action;
|
||||
/// "Toggle both side panels" is the exception.
|
||||
fn command_actions(id: CommandId) -> Vec<Action> {
|
||||
match id {
|
||||
CommandId::NewEmptyScene => vec![Action::NewScene(ProjectTemplate::Empty)],
|
||||
CommandId::New2dScene => vec![Action::NewScene(ProjectTemplate::TwoD)],
|
||||
CommandId::New3dScene => vec![Action::NewScene(ProjectTemplate::ThreeD)],
|
||||
CommandId::OpenProject => vec![Action::OpenProjectDialog],
|
||||
CommandId::OpenScene => vec![Action::OpenSceneDialog],
|
||||
CommandId::Save => vec![Action::Save],
|
||||
CommandId::SaveAs => vec![Action::OpenSaveAsDialog],
|
||||
CommandId::ExportBsn => vec![Action::OpenExportDialog(ExportFormat::Bsn)],
|
||||
CommandId::ExportRust => vec![Action::OpenExportDialog(ExportFormat::Rust)],
|
||||
CommandId::CopyBsn => vec![Action::CopyExport(ExportFormat::Bsn)],
|
||||
CommandId::ToggleBothPanels => {
|
||||
vec![Action::ToggleLeftPanel, Action::ToggleRightPanel]
|
||||
}
|
||||
CommandId::ToggleHierarchy => vec![Action::ToggleLeftPanel],
|
||||
CommandId::ToggleInspector => vec![Action::ToggleRightPanel],
|
||||
CommandId::Undo => vec![Action::Undo],
|
||||
CommandId::Redo => vec![Action::Redo],
|
||||
CommandId::Duplicate => vec![Action::DuplicateSelection],
|
||||
CommandId::Delete => vec![Action::DeleteSelection],
|
||||
CommandId::FitView => vec![Action::FitView],
|
||||
CommandId::ToggleGrid => vec![Action::ToggleGrid],
|
||||
CommandId::ToggleSnap => vec![Action::ToggleSnap],
|
||||
CommandId::ToggleAssets => vec![Action::ToggleAssets],
|
||||
CommandId::View2d => vec![Action::SetViewMode(ViewMode::TwoD)],
|
||||
CommandId::View3d => vec![Action::SetViewMode(ViewMode::ThreeD)],
|
||||
CommandId::Shortcuts => vec![Action::ShowShortcuts],
|
||||
CommandId::About => vec![Action::ShowAbout],
|
||||
CommandId::Quit => vec![Action::RequestQuit],
|
||||
}
|
||||
}
|
||||
|
||||
/// Indices of commands matching `query`, best match first. An empty query keeps
|
||||
/// the curated command order.
|
||||
fn filter_commands(query: &str) -> Vec<usize> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return (0..COMMANDS.len()).collect();
|
||||
}
|
||||
let mut scored: Vec<(usize, i32)> = COMMANDS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, command)| {
|
||||
command_score(command.label, query).map(|score| (index, score))
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
scored.into_iter().map(|(index, _)| index).collect()
|
||||
}
|
||||
|
||||
/// Score a label against a query. Substring matches always beat subsequence
|
||||
/// matches; prefix and word-boundary matches get additional weight.
|
||||
fn command_score(label: &str, query: &str) -> Option<i32> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
let haystack = label.to_lowercase();
|
||||
let needle = query.to_lowercase();
|
||||
|
||||
if let Some(position) = haystack.find(&needle) {
|
||||
let before = haystack[..position].chars().next_back();
|
||||
let at_start = position == 0;
|
||||
let at_boundary = at_start || before.is_some_and(|c| !c.is_alphanumeric());
|
||||
let mut score = 600 - position.min(240) as i32;
|
||||
if at_boundary {
|
||||
score += 250;
|
||||
}
|
||||
if at_start {
|
||||
score += 150;
|
||||
}
|
||||
return Some(score);
|
||||
}
|
||||
|
||||
// Subsequence ("fuzzy") match.
|
||||
let mut haystack_chars = haystack.char_indices();
|
||||
let mut previous_end: Option<usize> = None;
|
||||
let mut score = 200_i32;
|
||||
for needle_char in needle.chars() {
|
||||
let mut found = None;
|
||||
for (index, haystack_char) in haystack_chars.by_ref() {
|
||||
if haystack_char == needle_char {
|
||||
found = Some(index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let index = found?;
|
||||
if let Some(previous) = previous_end {
|
||||
let gap = index.saturating_sub(previous + 1);
|
||||
score -= (gap.min(20) as i32) * 3;
|
||||
score += 2; // Reward contiguous runs.
|
||||
}
|
||||
previous_end = Some(index);
|
||||
}
|
||||
Some(score.max(1))
|
||||
}
|
||||
|
||||
fn command_row(
|
||||
ui: &mut egui::Ui,
|
||||
command: &CommandSpec,
|
||||
selected: bool,
|
||||
enabled: bool,
|
||||
) -> egui::Response {
|
||||
let width = ui.available_width();
|
||||
let (rect, response) = ui.allocate_exact_size(
|
||||
egui::vec2(width, ROW_HEIGHT),
|
||||
if enabled {
|
||||
egui::Sense::click()
|
||||
} else {
|
||||
egui::Sense::hover()
|
||||
},
|
||||
);
|
||||
let hovered = enabled && response.hovered();
|
||||
let background = if selected && enabled {
|
||||
theme::ACCENT_DIM
|
||||
} else if hovered {
|
||||
theme::ELEVATED
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
};
|
||||
let painter = ui.painter();
|
||||
let row_rect = rect.shrink2(egui::vec2(0.0, 1.0));
|
||||
if background != egui::Color32::TRANSPARENT {
|
||||
painter.rect_filled(row_rect, 5.0, background);
|
||||
}
|
||||
if selected && enabled {
|
||||
painter.rect_stroke(
|
||||
row_rect,
|
||||
5.0,
|
||||
egui::Stroke::new(1.0_f32, theme::ACCENT),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
|
||||
let text_color = if enabled { theme::TEXT } else { theme::FAINT };
|
||||
painter.text(
|
||||
egui::pos2(rect.left() + 10.0, rect.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
command.category.label(),
|
||||
egui::FontId::proportional(9.0),
|
||||
theme::FAINT,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(rect.left() + 66.0, rect.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
command.label,
|
||||
egui::FontId::proportional(13.5),
|
||||
text_color,
|
||||
);
|
||||
if let Some(shortcut) = command.shortcut {
|
||||
draw_keycap(
|
||||
painter,
|
||||
rect.right_center() - egui::vec2(10.0, 0.0),
|
||||
shortcut,
|
||||
enabled,
|
||||
);
|
||||
}
|
||||
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, command.label)
|
||||
});
|
||||
response.on_hover_text(command.hint)
|
||||
}
|
||||
|
||||
fn draw_keycap(painter: &egui::Painter, right_center: egui::Pos2, text: &str, enabled: bool) {
|
||||
let color = if enabled { theme::MUTED } else { theme::FAINT };
|
||||
let galley = painter.layout_no_wrap(text.to_owned(), egui::FontId::monospace(10.0), color);
|
||||
let padding = egui::vec2(6.0, 2.0);
|
||||
let size = galley.size() + padding * 2.0;
|
||||
let rect = egui::Rect::from_min_size(
|
||||
egui::pos2(right_center.x - size.x, right_center.y - size.y * 0.5),
|
||||
size,
|
||||
);
|
||||
painter.rect_filled(rect, 3.0, theme::BG);
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
3.0,
|
||||
egui::Stroke::new(1.0_f32, theme::BORDER),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
painter.galley(rect.min + padding, galley, color);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy_forge_core::SceneDoc;
|
||||
|
||||
fn label_of(index: usize) -> &'static str {
|
||||
COMMANDS[index].label
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_query_keeps_all_commands_in_order() {
|
||||
let all = filter_commands("");
|
||||
assert_eq!(all.len(), COMMANDS.len());
|
||||
assert_eq!(all.first().copied(), Some(0));
|
||||
assert!(all.windows(2).all(|pair| pair[0] < pair[1]));
|
||||
assert_eq!(filter_commands(" ").len(), COMMANDS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_match_ranks_first() {
|
||||
let matches = filter_commands("save");
|
||||
assert!(!matches.is_empty());
|
||||
assert_eq!(label_of(matches[0]), "Save scene");
|
||||
// "Save scene as…" is also a substring match and stays in the list.
|
||||
assert!(matches
|
||||
.iter()
|
||||
.any(|&index| label_of(index) == "Save scene as…"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substring_beats_fuzzy_match() {
|
||||
// "undo" is a prefix of "Undo"; "U…n…d…o" also fuzzy-matches several labels.
|
||||
let matches = filter_commands("undo");
|
||||
assert_eq!(label_of(matches[0]), "Undo");
|
||||
let scene_search = filter_commands("ne3");
|
||||
// "New 3D scene" must fuzzy-match while "New empty scene" must not.
|
||||
assert!(scene_search
|
||||
.iter()
|
||||
.any(|&index| label_of(index) == "New 3D scene"));
|
||||
assert!(!scene_search
|
||||
.iter()
|
||||
.any(|&index| label_of(index) == "New empty scene"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_subsequence_matches() {
|
||||
let matches = filter_commands("oprj");
|
||||
assert!(
|
||||
matches
|
||||
.iter()
|
||||
.any(|&index| label_of(index) == "Open project…"),
|
||||
"expected a fuzzy match for Open project…, got {:?}",
|
||||
matches.iter().map(|&i| label_of(i)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_query_returns_nothing() {
|
||||
assert!(filter_commands("zzzzzz").is_empty());
|
||||
assert!(command_score("Save scene", "zzzzzz").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_matches_score_above_mid_word_matches() {
|
||||
let boundary = command_score("Open scene", "scene").unwrap();
|
||||
let mid_word = command_score("Obscene thing", "scene").unwrap();
|
||||
assert!(boundary > mid_word);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_step_wraps() {
|
||||
assert_eq!(step_selection(0, 3, true), 1);
|
||||
assert_eq!(step_selection(2, 3, true), 0);
|
||||
assert_eq!(step_selection(0, 3, false), 2);
|
||||
assert_eq!(step_selection(1, 3, false), 0);
|
||||
assert_eq!(step_selection(0, 0, true), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_has_an_action_and_unique_id() {
|
||||
for (index, command) in COMMANDS.iter().enumerate() {
|
||||
assert!(!command.label.is_empty());
|
||||
assert!(!command_actions(command.id).is_empty());
|
||||
assert!(
|
||||
COMMANDS[..index].iter().all(|other| other.id != command.id),
|
||||
"duplicate command id {:?}",
|
||||
command.id
|
||||
);
|
||||
}
|
||||
assert!(COMMANDS.len() >= 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn availability_tracks_history_and_selection() {
|
||||
let mut session = Session::new(SceneDoc::new("Test"));
|
||||
let mut selection = Selection::default();
|
||||
assert!(!command_enabled(
|
||||
CommandId::Undo,
|
||||
&session,
|
||||
None,
|
||||
&selection
|
||||
));
|
||||
assert!(!command_enabled(
|
||||
CommandId::Delete,
|
||||
&session,
|
||||
None,
|
||||
&selection
|
||||
));
|
||||
session.edit("Add entity", |doc| {
|
||||
doc.add_entity("Cube", None);
|
||||
});
|
||||
selection.select_only(session.doc().roots[0]);
|
||||
assert!(command_enabled(CommandId::Undo, &session, None, &selection));
|
||||
assert!(command_enabled(
|
||||
CommandId::Delete,
|
||||
&session,
|
||||
None,
|
||||
&selection
|
||||
));
|
||||
assert!(command_enabled(
|
||||
CommandId::New2dScene,
|
||||
&session,
|
||||
None,
|
||||
&selection
|
||||
));
|
||||
assert!(!command_enabled(
|
||||
CommandId::OpenScene,
|
||||
&session,
|
||||
None,
|
||||
&selection
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Global keyboard shortcuts.
|
||||
//!
|
||||
//! [`handle`] runs once per frame before the panels are drawn, turns key presses
|
||||
//! into deferred [`Action`]s and consumes the matching events so nothing else
|
||||
//! reacts to the same press. Two rules keep this predictable:
|
||||
//!
|
||||
//! * A focused text field (inline rename, search boxes, the command palette,
|
||||
//! dialogs) owns the keyboard: no shortcut fires while
|
||||
//! [`egui::Context::wants_keyboard_input`] is true. This also leaves `Escape`
|
||||
//! to the widget that has focus.
|
||||
//! * Modifiers must match exactly. egui's `consume_key` deliberately ignores
|
||||
//! extra Shift/Alt modifiers, so `Ctrl+Shift+S` would otherwise count as
|
||||
//! `Ctrl+S`. The mapping below inspects key events and uses
|
||||
//! [`egui::Modifiers::matches_exact`] instead.
|
||||
//!
|
||||
//! Clearing the selection with `Escape` is a focus-local action owned by the
|
||||
//! viewport that has the canvas focused: the frozen [`Action`] enum has no
|
||||
//! clear-selection variant, and a global handler must not consume `Escape` from
|
||||
//! the widgets that do own it.
|
||||
|
||||
use bevy_forge_core::{ExportFormat, Session};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::state::ViewMode;
|
||||
|
||||
/// Human-readable shortcut reference shared with Help → Keyboard shortcuts.
|
||||
///
|
||||
/// `Ctrl` is `Cmd` on macOS: shortcuts are matched with
|
||||
/// [`egui::Modifiers::COMMAND`], which maps to the platform convention.
|
||||
pub const SHORTCUT_REFERENCE: &[(&str, &str)] = &[
|
||||
("Ctrl+S", "Save the current scene"),
|
||||
("Ctrl+Shift+S", "Save the scene as…"),
|
||||
("Ctrl+O", "Open a project…"),
|
||||
("Ctrl+K", "Command palette"),
|
||||
("F1", "Keyboard shortcut reference"),
|
||||
("Ctrl+Z", "Undo"),
|
||||
("Ctrl+Shift+Z / Ctrl+Y", "Redo"),
|
||||
("Ctrl+D", "Duplicate the selection"),
|
||||
("Delete", "Delete the selection"),
|
||||
("Ctrl+E", "Export BSN scene"),
|
||||
("Ctrl+Shift+E", "Export imperative Rust"),
|
||||
("F", "Fit the active viewport"),
|
||||
("G", "Toggle the grid"),
|
||||
("2 / 3", "Switch to the 2D / 3D viewport"),
|
||||
("Ctrl+B", "Toggle the asset browser"),
|
||||
("Ctrl+Shift+L", "Toggle the hierarchy panel"),
|
||||
("Esc", "Clear the selection when the viewport has focus"),
|
||||
];
|
||||
|
||||
/// Map one key press to the action it should emit.
|
||||
///
|
||||
/// Pure: no input state, no session and no UI. `modifiers` must match a binding
|
||||
/// exactly, so `Ctrl+Alt+S` or `Shift+2` do not trigger the plain bindings.
|
||||
/// Whether undo/redo are currently possible is checked by [`handle`], not here.
|
||||
pub fn action_for(key: egui::Key, modifiers: egui::Modifiers) -> Option<Action> {
|
||||
use egui::{Key, Modifiers};
|
||||
|
||||
// Keep the more specific binding first: `matches_exact` rejects extra
|
||||
// modifiers, so `Ctrl+Shift+Z` can never fall through to plain `Ctrl+Z`.
|
||||
match key {
|
||||
Key::S if modifiers.matches_exact(Modifiers::COMMAND | Modifiers::SHIFT) => {
|
||||
Some(Action::OpenSaveAsDialog)
|
||||
}
|
||||
Key::S if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::Save),
|
||||
Key::O if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::OpenProjectDialog),
|
||||
Key::K if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::OpenCommandPalette),
|
||||
Key::Z if modifiers.matches_exact(Modifiers::COMMAND | Modifiers::SHIFT) => {
|
||||
Some(Action::Redo)
|
||||
}
|
||||
Key::Z if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::Undo),
|
||||
Key::Y if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::Redo),
|
||||
Key::D if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::DuplicateSelection),
|
||||
Key::E if modifiers.matches_exact(Modifiers::COMMAND | Modifiers::SHIFT) => {
|
||||
Some(Action::OpenExportDialog(ExportFormat::Rust))
|
||||
}
|
||||
Key::E if modifiers.matches_exact(Modifiers::COMMAND) => {
|
||||
Some(Action::OpenExportDialog(ExportFormat::Bsn))
|
||||
}
|
||||
Key::B if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::ToggleAssets),
|
||||
Key::L if modifiers.matches_exact(Modifiers::COMMAND | Modifiers::SHIFT) => {
|
||||
Some(Action::ToggleLeftPanel)
|
||||
}
|
||||
|
||||
// Plain keys: no modifier at all (Shift+2 must not switch viewports).
|
||||
Key::F1 if modifiers.matches_exact(Modifiers::NONE) => Some(Action::ShowShortcuts),
|
||||
Key::F if modifiers.matches_exact(Modifiers::NONE) => Some(Action::FitView),
|
||||
Key::G if modifiers.matches_exact(Modifiers::NONE) => Some(Action::ToggleGrid),
|
||||
Key::Delete if modifiers.matches_exact(Modifiers::NONE) => Some(Action::DeleteSelection),
|
||||
Key::Num2 if modifiers.matches_exact(Modifiers::NONE) => {
|
||||
Some(Action::SetViewMode(ViewMode::TwoD))
|
||||
}
|
||||
Key::Num3 if modifiers.matches_exact(Modifiers::NONE) => {
|
||||
Some(Action::SetViewMode(ViewMode::ThreeD))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate keyboard input into at most one action per binding per frame.
|
||||
pub fn handle(ctx: &egui::Context, session: &Session) -> Vec<Action> {
|
||||
// Never steal keys from a focused text field (rename, search, palette,
|
||||
// dialogs): the widget gets its own Ctrl+Z/Delete/Escape handling.
|
||||
if ctx.wants_keyboard_input() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut actions = Vec::new();
|
||||
ctx.input_mut(|input| take_shortcut_events(input, session, &mut actions));
|
||||
actions
|
||||
}
|
||||
|
||||
/// Remove matching key events from `input` and record their actions.
|
||||
///
|
||||
/// Events are consumed even when the action is currently disabled (no undo
|
||||
/// history, no redo history) so a disabled shortcut never leaks into a panel.
|
||||
fn take_shortcut_events(
|
||||
input: &mut egui::InputState,
|
||||
session: &Session,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
input.events.retain(|event| {
|
||||
let egui::Event::Key {
|
||||
key,
|
||||
pressed: true,
|
||||
modifiers,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
let Some(action) = action_for(*key, *modifiers) else {
|
||||
return true;
|
||||
};
|
||||
let enabled = match action {
|
||||
Action::Undo => session.can_undo(),
|
||||
Action::Redo => session.can_redo(),
|
||||
_ => true,
|
||||
};
|
||||
// Several repeats in one frame still produce a single action.
|
||||
if enabled && !actions.contains(&action) {
|
||||
actions.push(action);
|
||||
}
|
||||
false
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy_forge_core::SceneDoc;
|
||||
use egui::{Key, Modifiers};
|
||||
|
||||
fn key_event(key: Key, modifiers: Modifiers) -> egui::Event {
|
||||
egui::Event::Key {
|
||||
key,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_shortcuts_map_to_actions() {
|
||||
let cmd = Modifiers::COMMAND;
|
||||
assert_eq!(action_for(Key::S, cmd), Some(Action::Save));
|
||||
assert_eq!(action_for(Key::O, cmd), Some(Action::OpenProjectDialog));
|
||||
assert_eq!(action_for(Key::K, cmd), Some(Action::OpenCommandPalette));
|
||||
assert_eq!(action_for(Key::D, cmd), Some(Action::DuplicateSelection));
|
||||
assert_eq!(action_for(Key::B, cmd), Some(Action::ToggleAssets));
|
||||
assert_eq!(
|
||||
action_for(Key::E, cmd),
|
||||
Some(Action::OpenExportDialog(ExportFormat::Bsn))
|
||||
);
|
||||
assert_eq!(
|
||||
action_for(Key::E, cmd | Modifiers::SHIFT),
|
||||
Some(Action::OpenExportDialog(ExportFormat::Rust))
|
||||
);
|
||||
assert_eq!(
|
||||
action_for(Key::L, cmd | Modifiers::SHIFT),
|
||||
Some(Action::ToggleLeftPanel)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_require_exact_modifiers() {
|
||||
assert_eq!(action_for(Key::Z, Modifiers::COMMAND), Some(Action::Undo));
|
||||
assert_eq!(
|
||||
action_for(Key::Z, Modifiers::COMMAND | Modifiers::SHIFT),
|
||||
Some(Action::Redo),
|
||||
"Ctrl+Shift+Z is redo, never undo"
|
||||
);
|
||||
assert_eq!(action_for(Key::Y, Modifiers::COMMAND), Some(Action::Redo));
|
||||
// Extra modifiers must not match a binding.
|
||||
assert_eq!(
|
||||
action_for(Key::Z, Modifiers::COMMAND | Modifiers::ALT),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
action_for(Key::S, Modifiers::COMMAND | Modifiers::ALT),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_shortcuts_map_to_view_and_selection_actions() {
|
||||
assert_eq!(
|
||||
action_for(Key::F1, Modifiers::NONE),
|
||||
Some(Action::ShowShortcuts)
|
||||
);
|
||||
assert_eq!(action_for(Key::F, Modifiers::NONE), Some(Action::FitView));
|
||||
assert_eq!(
|
||||
action_for(Key::G, Modifiers::NONE),
|
||||
Some(Action::ToggleGrid)
|
||||
);
|
||||
assert_eq!(
|
||||
action_for(Key::Delete, Modifiers::NONE),
|
||||
Some(Action::DeleteSelection)
|
||||
);
|
||||
assert_eq!(
|
||||
action_for(Key::Num2, Modifiers::NONE),
|
||||
Some(Action::SetViewMode(ViewMode::TwoD))
|
||||
);
|
||||
assert_eq!(
|
||||
action_for(Key::Num3, Modifiers::NONE),
|
||||
Some(Action::SetViewMode(ViewMode::ThreeD))
|
||||
);
|
||||
// Shift+2/Shift+3 and Ctrl+F are not plain bindings.
|
||||
assert_eq!(action_for(Key::Num2, Modifiers::SHIFT), None);
|
||||
assert_eq!(action_for(Key::Num3, Modifiers::SHIFT), None);
|
||||
assert_eq!(action_for(Key::F, Modifiers::COMMAND), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_undo_is_consumed_without_emitting_an_action() {
|
||||
let mut input = egui::InputState::default();
|
||||
input.events.push(key_event(Key::Z, Modifiers::COMMAND));
|
||||
let session = Session::new(SceneDoc::new("Empty"));
|
||||
let mut actions = Vec::new();
|
||||
take_shortcut_events(&mut input, &session, &mut actions);
|
||||
|
||||
assert!(actions.is_empty(), "nothing to undo yet");
|
||||
assert!(
|
||||
input.events.is_empty(),
|
||||
"the key press must still be consumed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_action_is_emitted_once_when_history_exists() {
|
||||
let mut session = Session::new(SceneDoc::new("Empty"));
|
||||
session.edit("Add entity", |doc| {
|
||||
doc.add_entity("Cube", None);
|
||||
});
|
||||
assert!(session.can_undo());
|
||||
|
||||
let mut input = egui::InputState::default();
|
||||
input.events.push(key_event(Key::Z, Modifiers::COMMAND));
|
||||
input.events.push(key_event(Key::Z, Modifiers::COMMAND));
|
||||
let mut actions = Vec::new();
|
||||
take_shortcut_events(&mut input, &session, &mut actions);
|
||||
|
||||
assert_eq!(actions, vec![Action::Undo], "repeat events are collapsed");
|
||||
assert!(input.events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redo_action_requires_redo_history() {
|
||||
let mut session = Session::new(SceneDoc::new("Empty"));
|
||||
session.edit("Add entity", |doc| {
|
||||
doc.add_entity("Cube", None);
|
||||
});
|
||||
// Without an undo the redo stack is empty.
|
||||
let mut input = egui::InputState::default();
|
||||
input.events.push(key_event(Key::Y, Modifiers::COMMAND));
|
||||
let mut actions = Vec::new();
|
||||
take_shortcut_events(&mut input, &session, &mut actions);
|
||||
assert!(actions.is_empty());
|
||||
|
||||
session.undo();
|
||||
assert!(session.can_redo());
|
||||
let mut input = egui::InputState::default();
|
||||
input.events.push(key_event(Key::Y, Modifiers::COMMAND));
|
||||
let mut actions = Vec::new();
|
||||
take_shortcut_events(&mut input, &session, &mut actions);
|
||||
assert_eq!(actions, vec![Action::Redo]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumed_events_do_not_leak_unknown_keys() {
|
||||
let input_session = Session::new(SceneDoc::new("Empty"));
|
||||
let mut input = egui::InputState::default();
|
||||
input.events.push(key_event(Key::A, Modifiers::COMMAND));
|
||||
let mut actions = Vec::new();
|
||||
take_shortcut_events(&mut input, &input_session, &mut actions);
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(input.events.len(), 1, "unbound keys stay available");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_field_focus_suppresses_every_shortcut() {
|
||||
// Reproduce an inline rename: a text field owns the keyboard and the user
|
||||
// presses Ctrl+S. No action may fire, and the key must not be consumed
|
||||
// from the widget.
|
||||
let ctx = egui::Context::default();
|
||||
let mut rename_buffer = String::from("Cube");
|
||||
let session = Session::new(SceneDoc::new("Typing"));
|
||||
let mut actions = vec![Action::DeleteSelection]; // poison: must be reset
|
||||
|
||||
let raw_input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(960.0, 640.0),
|
||||
)),
|
||||
events: vec![key_event(Key::S, Modifiers::COMMAND)],
|
||||
..Default::default()
|
||||
};
|
||||
let _ = ctx.run(raw_input, |ctx| {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
let response = ui.add(egui::TextEdit::singleline(&mut rename_buffer));
|
||||
response.request_focus();
|
||||
assert!(ctx.wants_keyboard_input(), "the rename field has focus");
|
||||
actions = handle(ctx, &session);
|
||||
});
|
||||
});
|
||||
|
||||
assert!(
|
||||
actions.is_empty(),
|
||||
"Ctrl+S must not fire while renaming, got {actions:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_dispatches_one_shortcut_action_per_press() {
|
||||
// End-to-end: raw key events -> shortcuts::handle -> Action -> ForgeApp.
|
||||
// One Ctrl+D must duplicate exactly once (no double-dispatch), Ctrl+Z must
|
||||
// undo it, and Delete must delete the selected entity.
|
||||
use crate::app::ForgeApp;
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let mut doc = SceneDoc::new("Shortcut pipeline");
|
||||
let id = doc.add_entity("Cube", None);
|
||||
let mut app = ForgeApp::new_for_test(&ctx, doc);
|
||||
app.ui.selection.select_only(id);
|
||||
|
||||
let frame = |events: Vec<egui::Event>| egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(1440.0, 900.0),
|
||||
)),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Warm-up frame: let the panels lay out and load fonts.
|
||||
let _ = ctx.run(frame(Vec::new()), |ctx| app.ui(ctx));
|
||||
assert_eq!(app.session.doc().entities.len(), 1);
|
||||
|
||||
let _ = ctx.run(frame(vec![key_event(Key::D, Modifiers::COMMAND)]), |ctx| {
|
||||
app.ui(ctx)
|
||||
});
|
||||
assert_eq!(
|
||||
app.session.doc().entities.len(),
|
||||
2,
|
||||
"Ctrl+D duplicates exactly once"
|
||||
);
|
||||
|
||||
let _ = ctx.run(frame(vec![key_event(Key::Z, Modifiers::COMMAND)]), |ctx| {
|
||||
app.ui(ctx)
|
||||
});
|
||||
assert_eq!(
|
||||
app.session.doc().entities.len(),
|
||||
1,
|
||||
"Ctrl+Z undoes the duplicate"
|
||||
);
|
||||
|
||||
app.ui.selection.select_only(id);
|
||||
let _ = ctx.run(
|
||||
frame(vec![key_event(Key::Delete, Modifiers::NONE)]),
|
||||
|ctx| app.ui(ctx),
|
||||
);
|
||||
assert!(
|
||||
app.session.doc().entity(id).is_none(),
|
||||
"Delete removes the selected entity"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! UI-wide state shared by panels, and the borrowed context passed to them.
|
||||
|
||||
use bevy_forge_core::{
|
||||
AssetIndex, AssetKind, Color, ExportFormat, Project, SceneDoc, Session, Vec2,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::texture_cache::TextureCache;
|
||||
use crate::toast::Toasts;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Screen {
|
||||
#[default]
|
||||
Welcome,
|
||||
Editor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ViewMode {
|
||||
#[default]
|
||||
TwoD,
|
||||
ThreeD,
|
||||
}
|
||||
|
||||
impl ViewMode {
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::TwoD => "2D",
|
||||
Self::ThreeD => "3D",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggled(self) -> Self {
|
||||
match self {
|
||||
Self::TwoD => Self::ThreeD,
|
||||
Self::ThreeD => Self::TwoD,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-selection with a primary entity for the inspector.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Selection {
|
||||
ids: BTreeSet<u64>,
|
||||
primary: Option<u64>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.ids.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.ids.len()
|
||||
}
|
||||
|
||||
pub fn contains(&self, id: u64) -> bool {
|
||||
self.ids.contains(&id)
|
||||
}
|
||||
|
||||
pub fn ids(&self) -> impl Iterator<Item = u64> + '_ {
|
||||
self.ids.iter().copied()
|
||||
}
|
||||
|
||||
pub fn as_set(&self) -> &BTreeSet<u64> {
|
||||
&self.ids
|
||||
}
|
||||
|
||||
pub fn primary(&self) -> Option<u64> {
|
||||
self.primary
|
||||
}
|
||||
|
||||
pub fn primary_or_single(&self) -> Option<u64> {
|
||||
self.primary.or_else(|| {
|
||||
if self.ids.len() == 1 {
|
||||
self.ids.iter().next().copied()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn select_only(&mut self, id: u64) {
|
||||
self.ids.clear();
|
||||
self.ids.insert(id);
|
||||
self.primary = Some(id);
|
||||
}
|
||||
|
||||
pub fn select_many(&mut self, ids: impl IntoIterator<Item = u64>, primary: Option<u64>) {
|
||||
self.ids = ids.into_iter().collect();
|
||||
self.primary = primary.or_else(|| self.ids.iter().next().copied());
|
||||
}
|
||||
|
||||
pub fn add(&mut self, id: u64) {
|
||||
self.ids.insert(id);
|
||||
self.primary = Some(id);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: u64) {
|
||||
self.ids.remove(&id);
|
||||
if self.primary == Some(id) {
|
||||
self.primary = self.ids.iter().next().copied();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self, id: u64) {
|
||||
if self.ids.contains(&id) {
|
||||
self.remove(id);
|
||||
} else {
|
||||
self.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retain_existing(&mut self, doc: &SceneDoc) {
|
||||
self.ids.retain(|id| doc.entity(*id).is_some());
|
||||
if self.primary.is_some_and(|id| !self.ids.contains(&id)) {
|
||||
self.primary = self.ids.iter().next().copied();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.ids.clear();
|
||||
self.primary = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UiState {
|
||||
pub selection: Selection,
|
||||
pub view_mode: ViewMode,
|
||||
pub show_assets: bool,
|
||||
pub show_grid: bool,
|
||||
pub snap_to_grid: bool,
|
||||
pub grid_size: f32,
|
||||
pub hierarchy_search: String,
|
||||
pub inspector_search: String,
|
||||
pub asset_search: String,
|
||||
pub asset_filter: Option<AssetKind>,
|
||||
pub cursor_world: Option<Vec2>,
|
||||
pub pointer_over_viewport: bool,
|
||||
pub viewport_rect: egui::Rect,
|
||||
pub status: String,
|
||||
pub error_banner: Option<String>,
|
||||
pub show_left_panel: bool,
|
||||
pub show_right_panel: bool,
|
||||
/// Consumed by the active viewport to frame scene content.
|
||||
pub fit_view_requested: bool,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selection: Selection::default(),
|
||||
view_mode: ViewMode::TwoD,
|
||||
show_assets: true,
|
||||
show_grid: true,
|
||||
snap_to_grid: false,
|
||||
grid_size: 1.0,
|
||||
hierarchy_search: String::new(),
|
||||
inspector_search: String::new(),
|
||||
asset_search: String::new(),
|
||||
asset_filter: None,
|
||||
cursor_world: None,
|
||||
pointer_over_viewport: false,
|
||||
viewport_rect: egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(0.0, 0.0)),
|
||||
status: "Ready".into(),
|
||||
error_banner: None,
|
||||
show_left_panel: true,
|
||||
show_right_panel: true,
|
||||
fit_view_requested: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag-and-drop payload for dragging an asset from the browser into a viewport.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AssetDragPayload {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// State of the export dialog and the last generated snippet.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExportState {
|
||||
pub dialog_open: bool,
|
||||
pub format: ExportFormat,
|
||||
pub function_name: String,
|
||||
pub path: String,
|
||||
pub code: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub copied: bool,
|
||||
}
|
||||
|
||||
impl Default for ExportState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dialog_open: false,
|
||||
format: ExportFormat::Bsn,
|
||||
function_name: "scene".into(),
|
||||
path: String::new(),
|
||||
code: None,
|
||||
error: None,
|
||||
copied: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExportState {
|
||||
pub fn regenerate(&mut self, doc: &SceneDoc) {
|
||||
let options = bevy_forge_core::CodegenOptions {
|
||||
function_name: self.function_name.clone(),
|
||||
include_header: true,
|
||||
};
|
||||
match self.format.generate(doc, &options) {
|
||||
Ok(code) => {
|
||||
self.code = Some(code);
|
||||
self.error = None;
|
||||
}
|
||||
Err(error) => {
|
||||
self.code = None;
|
||||
self.error = Some(error.to_string());
|
||||
}
|
||||
}
|
||||
self.copied = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Data every panel needs. Panels never touch `ForgeApp` directly, which keeps
|
||||
/// them independently testable and avoids borrow-checker acrobatics.
|
||||
pub struct EditorContext<'a> {
|
||||
pub session: &'a mut Session,
|
||||
pub project: Option<&'a Project>,
|
||||
pub assets: &'a AssetIndex,
|
||||
pub textures: &'a mut TextureCache,
|
||||
pub ui: &'a mut UiState,
|
||||
pub toasts: &'a mut Toasts,
|
||||
pub export: &'a mut ExportState,
|
||||
}
|
||||
|
||||
impl EditorContext<'_> {
|
||||
pub fn notify_info(&mut self, message: impl Into<String>) {
|
||||
self.toasts.info(message);
|
||||
}
|
||||
|
||||
pub fn notify_success(&mut self, message: impl Into<String>) {
|
||||
self.toasts.success(message);
|
||||
}
|
||||
|
||||
pub fn notify_error(&mut self, message: impl Into<String>) {
|
||||
self.toasts.error(message);
|
||||
}
|
||||
|
||||
/// Asset paths of a given kind, relative to the Bevy `assets/` directory.
|
||||
pub fn assets_of_kind(&self, kind: AssetKind) -> Vec<String> {
|
||||
self.assets
|
||||
.by_kind(kind)
|
||||
.into_iter()
|
||||
.map(|asset| asset.path.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn image_assets(&self) -> Vec<String> {
|
||||
self.assets_of_kind(AssetKind::Image)
|
||||
}
|
||||
|
||||
pub fn mesh_assets(&self) -> Vec<String> {
|
||||
self.assets_of_kind(AssetKind::Mesh)
|
||||
}
|
||||
|
||||
/// Choose a sensible default component palette color for a new entity.
|
||||
pub fn default_color(&self) -> Color {
|
||||
Color::srgba(0.55, 0.72, 1.0, 1.0)
|
||||
}
|
||||
|
||||
/// Path for saving/exporting, if a project is open.
|
||||
pub fn scenes_dir(&self) -> Option<PathBuf> {
|
||||
self.project.map(|project| project.scenes_dir())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! Bottom status bar: scene counts, selection, validation, view/save state and
|
||||
//! the cursor position.
|
||||
//!
|
||||
//! Everything is 11 px muted text on a 25 px strip; long paths are elided in the
|
||||
//! middle and shown in full in a tooltip. The panel itself never mutates state.
|
||||
|
||||
use bevy_forge_core::{validate_scene, SceneDoc, SceneStats, Severity, ValidationReport, Vec2};
|
||||
|
||||
use crate::state::{EditorContext, Selection};
|
||||
use crate::theme;
|
||||
|
||||
/// Height of the status strip in pixels.
|
||||
pub const STATUS_BAR_HEIGHT: f32 = 25.0;
|
||||
|
||||
pub fn show(ctx: &egui::Context, cx: &EditorContext<'_>) {
|
||||
egui::TopBottomPanel::bottom("status_bar")
|
||||
.exact_height(STATUS_BAR_HEIGHT)
|
||||
.frame(
|
||||
egui::Frame::new()
|
||||
.fill(theme::BG)
|
||||
.inner_margin(egui::Margin::symmetric(10, 0)),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
// `validate_scene` is O(entities) and runs once per frame. Editor
|
||||
// scenes are small (well under 1k entities) and this keeps the badge
|
||||
// honest without any cache invalidation bugs; memoize only if a
|
||||
// profile ever shows it matters.
|
||||
let doc = cx.session.doc();
|
||||
let report = validate_scene(doc, Some(cx.assets));
|
||||
let stats = doc.stats();
|
||||
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(counts_label(&stats))
|
||||
.color(theme::MUTED)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text(stats_tooltip(&stats));
|
||||
|
||||
ui.separator();
|
||||
|
||||
let selection_color = if cx.ui.selection.is_empty() {
|
||||
theme::FAINT
|
||||
} else {
|
||||
theme::SELECTION
|
||||
};
|
||||
let selection = ui.label(
|
||||
egui::RichText::new(selection_label(doc, &cx.ui.selection))
|
||||
.color(selection_color)
|
||||
.size(11.0),
|
||||
);
|
||||
if let Some(tooltip) = selection_tooltip(doc, &cx.ui.selection) {
|
||||
selection.on_hover_text(tooltip);
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let (validation, color) = validation_summary(&report);
|
||||
ui.label(egui::RichText::new(validation).color(color).size(11.0))
|
||||
.on_hover_text(validation_tooltip(doc, &report));
|
||||
|
||||
// The right cluster is laid out from the right edge inwards; the
|
||||
// status message is added last and therefore starts the cluster.
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
save_state(ui, cx);
|
||||
ui.separator();
|
||||
cursor_state(ui, cx.ui.cursor_world);
|
||||
ui.separator();
|
||||
snap_state(ui, cx.ui.snap_to_grid);
|
||||
ui.separator();
|
||||
grid_state(ui, cx.ui.show_grid, cx.ui.grid_size);
|
||||
ui.separator();
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} view", cx.ui.view_mode.label()))
|
||||
.color(theme::MUTED)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Viewport mode — press 2 or 3 to switch");
|
||||
ui.separator();
|
||||
project_state(ui, cx);
|
||||
ui.separator();
|
||||
|
||||
let status = status_message(&cx.ui.status);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(shorten(&status, 80))
|
||||
.color(theme::FAINT)
|
||||
.size(11.0),
|
||||
)
|
||||
.truncate(),
|
||||
)
|
||||
.on_hover_text(&status);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure formatting helpers (unit-tested below)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn plural(count: usize, singular: &str, plural: &str) -> String {
|
||||
if count == 1 {
|
||||
format!("{count} {singular}")
|
||||
} else {
|
||||
format!("{count} {plural}")
|
||||
}
|
||||
}
|
||||
|
||||
fn counts_label(stats: &SceneStats) -> String {
|
||||
format!(
|
||||
"{} · {} · {} · {} · {}",
|
||||
plural(stats.entities, "entity", "entities"),
|
||||
plural(stats.roots, "root", "roots"),
|
||||
plural(stats.sprites, "sprite", "sprites"),
|
||||
plural(stats.meshes, "mesh", "meshes"),
|
||||
plural(stats.lights, "light", "lights"),
|
||||
)
|
||||
}
|
||||
|
||||
fn stats_tooltip(stats: &SceneStats) -> String {
|
||||
format!(
|
||||
"Scene contents\n{} entities · {} roots\n{} sprites · {} meshes · {} lights\n{} cameras · {} custom components",
|
||||
stats.entities,
|
||||
stats.roots,
|
||||
stats.sprites,
|
||||
stats.meshes,
|
||||
stats.lights,
|
||||
stats.cameras,
|
||||
stats.custom_components,
|
||||
)
|
||||
}
|
||||
|
||||
fn selection_label(doc: &SceneDoc, selection: &Selection) -> String {
|
||||
match selection.len() {
|
||||
0 => "No selection".to_owned(),
|
||||
1 => match selection.ids().next().and_then(|id| doc.entity(id)) {
|
||||
// Keep the strip short even for very long entity names; the tooltip
|
||||
// below always shows the full name.
|
||||
Some(node) => format!("Selected {}", shorten(&node.name, 28)),
|
||||
None => "1 selected".to_owned(),
|
||||
},
|
||||
count => match selection
|
||||
.primary_or_single()
|
||||
.and_then(|id| doc.entity(id))
|
||||
.map(|node| node.name.as_str())
|
||||
{
|
||||
Some(primary) => format!("{count} selected · {}", shorten(primary, 24)),
|
||||
None => format!("{count} selected"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_tooltip(doc: &SceneDoc, selection: &Selection) -> Option<String> {
|
||||
if selection.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let names: Vec<&str> = selection
|
||||
.ids()
|
||||
.filter_map(|id| doc.entity(id))
|
||||
.map(|node| node.name.as_str())
|
||||
.collect();
|
||||
if names.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut text = format!("Selection ({} entities):", names.len());
|
||||
for name in names.iter().take(8) {
|
||||
text.push_str("\n• ");
|
||||
text.push_str(name);
|
||||
}
|
||||
if names.len() > 8 {
|
||||
text.push_str(&format!("\n… and {} more", names.len() - 8));
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn validation_summary(report: &ValidationReport) -> (String, egui::Color32) {
|
||||
let errors = report.errors();
|
||||
let warnings = report.warnings();
|
||||
if errors > 0 {
|
||||
// Errors dominate the color, but warnings stay visible in the text.
|
||||
let label = if warnings > 0 {
|
||||
format!(
|
||||
"{} · {}",
|
||||
plural(errors, "error", "errors"),
|
||||
plural(warnings, "warning", "warnings")
|
||||
)
|
||||
} else {
|
||||
plural(errors, "error", "errors")
|
||||
};
|
||||
(label, theme::ERROR)
|
||||
} else if warnings > 0 {
|
||||
(plural(warnings, "warning", "warnings"), theme::WARNING)
|
||||
} else {
|
||||
("Valid".to_owned(), theme::SUCCESS)
|
||||
}
|
||||
}
|
||||
|
||||
fn validation_tooltip(doc: &SceneDoc, report: &ValidationReport) -> String {
|
||||
let mut text = format!(
|
||||
"Validation\n{} · {} · {}",
|
||||
plural(report.errors(), "error", "errors"),
|
||||
plural(report.warnings(), "warning", "warnings"),
|
||||
plural(report.count(Severity::Info), "info", "info")
|
||||
);
|
||||
let issues = report.sorted();
|
||||
for issue in issues.iter().take(6) {
|
||||
text.push('\n');
|
||||
if let Some(entity) = issue.entity {
|
||||
let name = doc
|
||||
.entity(entity)
|
||||
.map(|node| node.name.as_str())
|
||||
.unwrap_or("missing entity");
|
||||
text.push_str(&format!(
|
||||
"{}: {name} — {}",
|
||||
issue.severity.label(),
|
||||
issue.message
|
||||
));
|
||||
} else {
|
||||
text.push_str(&format!("{}: {}", issue.severity.label(), issue.message));
|
||||
}
|
||||
}
|
||||
if issues.len() > 6 {
|
||||
text.push_str(&format!("\n… and {} more", issues.len() - 6));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn cursor_label(cursor: Option<Vec2>) -> String {
|
||||
match cursor {
|
||||
Some(pos) => format!("x {:.2} y {:.2}", pos.x, pos.y),
|
||||
None => "x — y —".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn grid_label(show_grid: bool, grid_size: f32) -> String {
|
||||
if show_grid {
|
||||
format!("Grid {}", trim_float(grid_size))
|
||||
} else {
|
||||
"Grid off".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn snap_label(snap_to_grid: bool) -> String {
|
||||
if snap_to_grid {
|
||||
"Snap on".to_owned()
|
||||
} else {
|
||||
"Snap off".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a small float without trailing zeros (`1`, `0.5`, `0.25`).
|
||||
fn trim_float(value: f32) -> String {
|
||||
let text = format!("{value:.2}");
|
||||
text.trim_end_matches('0').trim_end_matches('.').to_owned()
|
||||
}
|
||||
|
||||
fn status_message(status: &str) -> String {
|
||||
if status.trim().is_empty() {
|
||||
"Ready".to_owned()
|
||||
} else {
|
||||
status.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten `text` to at most `max_chars` characters, eliding the middle so both
|
||||
/// the operation ("Saved …") and the file name stay readable.
|
||||
fn shorten(text: &str, max_chars: usize) -> String {
|
||||
let count = text.chars().count();
|
||||
if count <= max_chars || max_chars < 4 {
|
||||
return text.to_owned();
|
||||
}
|
||||
let keep_left = (max_chars - 1) / 2;
|
||||
let keep_right = max_chars - 1 - keep_left;
|
||||
let left: String = text.chars().take(keep_left).collect();
|
||||
let right: String = text.chars().skip(count - keep_right).collect();
|
||||
format!("{left}…{right}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small drawing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn save_state(ui: &mut egui::Ui, cx: &EditorContext<'_>) {
|
||||
if cx.session.is_dirty() {
|
||||
ui.label(
|
||||
egui::RichText::new("• Unsaved changes")
|
||||
.color(theme::WARNING)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("The scene has unsaved changes — Ctrl+S saves it");
|
||||
} else {
|
||||
ui.label(
|
||||
egui::RichText::new("Saved")
|
||||
.color(theme::SUCCESS)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("All changes are saved");
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_state(ui: &mut egui::Ui, cursor: Option<Vec2>) {
|
||||
ui.label(
|
||||
egui::RichText::new(cursor_label(cursor))
|
||||
.color(theme::MUTED)
|
||||
.monospace()
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Cursor position in world units (2D) or on the ground plane (3D)");
|
||||
}
|
||||
|
||||
fn snap_state(ui: &mut egui::Ui, snap_to_grid: bool) {
|
||||
let color = if snap_to_grid {
|
||||
theme::ACCENT
|
||||
} else {
|
||||
theme::FAINT
|
||||
};
|
||||
ui.label(
|
||||
egui::RichText::new(snap_label(snap_to_grid))
|
||||
.color(color)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Grid snapping — toggle with the hierarchy toolbar or Edit menu");
|
||||
}
|
||||
|
||||
fn grid_state(ui: &mut egui::Ui, show_grid: bool, grid_size: f32) {
|
||||
let color = if show_grid {
|
||||
theme::MUTED
|
||||
} else {
|
||||
theme::FAINT
|
||||
};
|
||||
ui.label(
|
||||
egui::RichText::new(grid_label(show_grid, grid_size))
|
||||
.color(color)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Grid visibility and spacing — G toggles the grid");
|
||||
}
|
||||
|
||||
fn project_state(ui: &mut egui::Ui, cx: &EditorContext<'_>) {
|
||||
match cx.project {
|
||||
Some(project) => {
|
||||
ui.label(
|
||||
egui::RichText::new(shorten(&project.name, 28))
|
||||
.color(theme::MUTED)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text(format!("Project: {}", project.root.display()));
|
||||
}
|
||||
None => {
|
||||
ui.label(
|
||||
egui::RichText::new("No project")
|
||||
.color(theme::FAINT)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Open a Bevy project to save scenes and browse assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy_forge_core::Issue;
|
||||
|
||||
fn doc_with_two_entities() -> (SceneDoc, u64, u64) {
|
||||
let mut doc = SceneDoc::new("Status bar test");
|
||||
let parent = doc.add_entity("Player", None);
|
||||
let child = doc.add_entity("Hat", Some(parent));
|
||||
(doc, parent, child)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_are_pluralized() {
|
||||
let mut stats = SceneStats::default();
|
||||
stats.entities = 1;
|
||||
stats.roots = 2;
|
||||
stats.sprites = 0;
|
||||
stats.meshes = 3;
|
||||
stats.lights = 1;
|
||||
assert_eq!(
|
||||
counts_label(&stats),
|
||||
"1 entity · 2 roots · 0 sprites · 3 meshes · 1 light"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_summary_uses_entity_names() {
|
||||
let (doc, parent, child) = doc_with_two_entities();
|
||||
let mut selection = Selection::default();
|
||||
assert_eq!(selection_label(&doc, &selection), "No selection");
|
||||
assert!(selection_tooltip(&doc, &selection).is_none());
|
||||
|
||||
selection.select_only(parent);
|
||||
assert_eq!(selection_label(&doc, &selection), "Selected Player");
|
||||
|
||||
selection.select_many([parent, child], Some(child));
|
||||
assert_eq!(selection_label(&doc, &selection), "2 selected · Hat");
|
||||
let tooltip = selection_tooltip(&doc, &selection).expect("tooltip for selection");
|
||||
assert!(tooltip.contains("Player"));
|
||||
assert!(tooltip.contains("Hat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_selection_names_are_shortened_in_the_strip() {
|
||||
let mut doc = SceneDoc::new("Long names");
|
||||
let id = doc.add_entity("Extremely Long Player Character Name With Suffix", None);
|
||||
let mut selection = Selection::default();
|
||||
selection.select_only(id);
|
||||
let label = selection_label(&doc, &selection);
|
||||
assert!(
|
||||
label.len() < 60,
|
||||
"status strip label stays compact: {label}"
|
||||
);
|
||||
assert!(label.contains('…'), "the middle is elided: {label}");
|
||||
// The tooltip keeps the complete name.
|
||||
let tooltip = selection_tooltip(&doc, &selection).expect("tooltip");
|
||||
assert!(tooltip.contains("Extremely Long Player Character Name With Suffix"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_summary_prefers_the_worst_severity() {
|
||||
let mut report = ValidationReport::default();
|
||||
assert_eq!(validation_summary(&report).0, "Valid");
|
||||
|
||||
report.issues.push(Issue::warning("duplicate name"));
|
||||
let (label, color) = validation_summary(&report);
|
||||
assert_eq!(label, "1 warning");
|
||||
assert_eq!(color, theme::WARNING);
|
||||
|
||||
report.issues.push(Issue::error("sprite missing image"));
|
||||
let (label, color) = validation_summary(&report);
|
||||
assert_eq!(label, "1 error · 1 warning");
|
||||
assert_eq!(color, theme::ERROR);
|
||||
|
||||
let mut only_errors = ValidationReport::default();
|
||||
only_errors
|
||||
.issues
|
||||
.push(Issue::error("sprite missing image"));
|
||||
assert_eq!(validation_summary(&only_errors).0, "1 error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_tooltip_lists_issues_with_entity_names() {
|
||||
let (doc, parent, _) = doc_with_two_entities();
|
||||
let mut report = ValidationReport::default();
|
||||
report
|
||||
.issues
|
||||
.push(Issue::error("scale is zero").with_entity(parent));
|
||||
let tooltip = validation_tooltip(&doc, &report);
|
||||
assert!(tooltip.contains("1 error · 0 warnings · 0 info"));
|
||||
assert!(tooltip.contains("Player"));
|
||||
assert!(tooltip.contains("scale is zero"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorten_elides_the_middle_unicode_safely() {
|
||||
assert_eq!(shorten("short", 20), "short");
|
||||
assert_eq!(shorten("abcdefghij", 6), "ab…hij");
|
||||
assert_eq!(
|
||||
shorten("Sauvegardé — jardin.forge.ron", 14).chars().count(),
|
||||
14
|
||||
);
|
||||
let unicode = shorten("Édition — café.forge.ron", 12);
|
||||
assert!(unicode.starts_with("Édit"));
|
||||
assert!(unicode.contains('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_grid_and_snap_labels_are_explicit() {
|
||||
assert_eq!(cursor_label(None), "x — y —");
|
||||
assert_eq!(
|
||||
cursor_label(Some(Vec2::new(1.234, -5.0))),
|
||||
"x 1.23 y -5.00"
|
||||
);
|
||||
assert_eq!(grid_label(true, 1.0), "Grid 1");
|
||||
assert_eq!(grid_label(true, 0.5), "Grid 0.5");
|
||||
assert_eq!(grid_label(false, 1.0), "Grid off");
|
||||
assert_eq!(snap_label(true), "Snap on");
|
||||
assert_eq!(snap_label(false), "Snap off");
|
||||
assert_eq!(trim_float(0.25), "0.25");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! Lazily loaded image textures for the viewport and asset browser.
|
||||
//!
|
||||
//! Images are decoded on first use and cached as egui GPU textures. Failure
|
||||
//! results are cached too, so a broken asset is only read once and never blocks
|
||||
//! the UI thread on repeated attempts. The cache is deliberately small and
|
||||
//! unbounded for now: scene projects reference a handful of assets, and
|
||||
//! [`TextureCache::clear`] drops every handle when the project changes.
|
||||
//!
|
||||
//! Two safety guards keep large files from stalling the editor:
|
||||
//!
|
||||
//! * Images wider or taller than [`MAX_DECODE_DIMENSION`] are rejected by the
|
||||
//! decoder instead of being fully decoded into memory.
|
||||
//! * Decoded images larger than [`MAX_UPLOAD_DIMENSION`] are downscaled with
|
||||
//! [`image::DynamicImage::thumbnail`] before upload. The reported
|
||||
//! [`CachedTexture::size`] always stays the *source* pixel size so viewports
|
||||
//! can still lay out sprites at their native dimensions.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Source images wider or taller than this are rejected without decoding them
|
||||
/// into a full pixel buffer.
|
||||
const MAX_DECODE_DIMENSION: u32 = 4096;
|
||||
|
||||
/// GPU uploads are downscaled to at most this many pixels per side. The editor
|
||||
/// only shows textures at a few hundred screen pixels, so full-resolution
|
||||
/// uploads would waste VRAM.
|
||||
const MAX_UPLOAD_DIMENSION: u32 = 512;
|
||||
|
||||
pub struct CachedTexture {
|
||||
pub texture: egui::TextureHandle,
|
||||
/// Pixel size of the source image, before any upload downscale.
|
||||
pub size: [usize; 2],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TextureCache {
|
||||
entries: HashMap<PathBuf, CachedTexture>,
|
||||
failed: HashMap<PathBuf, String>,
|
||||
}
|
||||
|
||||
impl TextureCache {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Load an image once and cache the GPU texture.
|
||||
///
|
||||
/// Returns `None` for missing, oversized or unsupported files. A failure is
|
||||
/// cached permanently (until [`Self::clear`]) so the file is not re-read
|
||||
/// every frame. The returned reference borrows from the cache and must not
|
||||
/// outlive the next mutable use of it.
|
||||
pub fn get_or_load(&mut self, ctx: &egui::Context, path: &Path) -> Option<&CachedTexture> {
|
||||
// Decode and insert first, then return a borrow of the stored entry. All
|
||||
// mutable borrows must finish before the final lookup so the returned
|
||||
// reference can live as long as the caller needs it.
|
||||
let already_cached = self.entries.contains_key(path);
|
||||
let previously_failed = self.failed.contains_key(path);
|
||||
if !already_cached && !previously_failed {
|
||||
match decode_image(path) {
|
||||
Ok((image, source_size)) => {
|
||||
let texture =
|
||||
ctx.load_texture(texture_name(path), image, egui::TextureOptions::LINEAR);
|
||||
self.entries.insert(
|
||||
path.to_path_buf(),
|
||||
CachedTexture {
|
||||
texture,
|
||||
size: source_size,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
self.failed.insert(path.to_path_buf(), error);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.entries.get(path)
|
||||
}
|
||||
|
||||
pub fn texture(&self, path: &Path) -> Option<&egui::TextureHandle> {
|
||||
self.entries.get(path).map(|entry| &entry.texture)
|
||||
}
|
||||
|
||||
pub fn dimensions(&self, path: &Path) -> Option<[usize; 2]> {
|
||||
self.entries.get(path).map(|entry| entry.size)
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
// Dropping the handles frees the GPU textures.
|
||||
self.entries.clear();
|
||||
self.failed.clear();
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// The cached human-readable failure for `path`, if a load was attempted.
|
||||
pub fn error_for(&self, path: &Path) -> Option<&str> {
|
||||
self.failed.get(path).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable, path-derived debug name for the egui texture.
|
||||
fn texture_name(path: &Path) -> String {
|
||||
format!("bevy-forge-asset:{}", path.display())
|
||||
}
|
||||
|
||||
/// Decode `path` into an RGBA color image no larger than
|
||||
/// [`MAX_UPLOAD_DIMENSION`], returning the upload image and the source size.
|
||||
fn decode_image(path: &Path) -> Result<(egui::ColorImage, [usize; 2]), String> {
|
||||
let mut reader = image::ImageReader::open(path)
|
||||
.map_err(|error| format!("could not open `{}`: {error}", path.display()))?
|
||||
.with_guessed_format()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"could not detect the format of `{}`: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_DECODE_DIMENSION);
|
||||
limits.max_image_height = Some(MAX_DECODE_DIMENSION);
|
||||
reader.limits(limits);
|
||||
|
||||
let decoded = reader
|
||||
.decode()
|
||||
.map_err(|error| format!("could not decode `{}`: {error}", path.display()))?;
|
||||
let source_size = [decoded.width() as usize, decoded.height() as usize];
|
||||
|
||||
let upload =
|
||||
if decoded.width() > MAX_UPLOAD_DIMENSION || decoded.height() > MAX_UPLOAD_DIMENSION {
|
||||
decoded.thumbnail(MAX_UPLOAD_DIMENSION, MAX_UPLOAD_DIMENSION)
|
||||
} else {
|
||||
decoded
|
||||
};
|
||||
let rgba = upload.to_rgba8();
|
||||
let upload_size = [rgba.width() as usize, rgba.height() as usize];
|
||||
// `ColorImage::from_rgba_unmultiplied` asserts the buffer length, which is
|
||||
// guaranteed here because `to_rgba8` allocates exactly width * height * 4.
|
||||
let image = egui::ColorImage::from_rgba_unmultiplied(upload_size, rgba.as_raw());
|
||||
Ok((image, source_size))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use image::{Rgba, RgbaImage};
|
||||
|
||||
fn write_png(path: &Path, width: u32, height: u32, color: [u8; 4]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create test asset directory");
|
||||
}
|
||||
RgbaImage::from_pixel(width, height, Rgba(color))
|
||||
.save(path)
|
||||
.expect("write test PNG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_fails_once_and_stays_cached_until_clear() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("textures/missing.png");
|
||||
let ctx = egui::Context::default();
|
||||
let mut cache = TextureCache::new();
|
||||
|
||||
assert!(cache.get_or_load(&ctx, &path).is_none());
|
||||
let error = cache
|
||||
.error_for(&path)
|
||||
.expect("a failed read must store an error");
|
||||
assert!(
|
||||
error.contains("missing.png"),
|
||||
"error should name the file: {error}"
|
||||
);
|
||||
assert!(cache.is_empty());
|
||||
assert!(cache.texture(&path).is_none());
|
||||
assert!(cache.dimensions(&path).is_none());
|
||||
|
||||
// Failures are permanent: creating the file must not trigger a re-read.
|
||||
write_png(&path, 2, 2, [0, 0, 0, 255]);
|
||||
assert!(
|
||||
cache.get_or_load(&ctx, &path).is_none(),
|
||||
"failures are not retried until clear()"
|
||||
);
|
||||
assert_eq!(cache.len(), 0);
|
||||
|
||||
// ... and clear() drops the failure so a fixed asset can load.
|
||||
cache.clear();
|
||||
assert!(cache.error_for(&path).is_none());
|
||||
assert!(cache.get_or_load(&ctx, &path).is_some());
|
||||
assert_eq!(cache.dimensions(&path), Some([2, 2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_bytes_are_reported_without_panicking() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("not-an-image.png");
|
||||
std::fs::write(&path, b"this is definitely not a PNG").expect("write garbage");
|
||||
let ctx = egui::Context::default();
|
||||
let mut cache = TextureCache::new();
|
||||
|
||||
assert!(cache.get_or_load(&ctx, &path).is_none());
|
||||
let error = cache.error_for(&path).expect("error is cached");
|
||||
assert!(
|
||||
error.contains("not-an-image.png"),
|
||||
"error should name the file: {error}"
|
||||
);
|
||||
assert!(cache.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_png_loads_once_and_same_path_reuses_the_texture() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
// Paths with spaces and unicode must work just like plain ones.
|
||||
let path = temp.path().join("textures with space/héro.png");
|
||||
write_png(&path, 6, 4, [10, 20, 30, 255]);
|
||||
let ctx = egui::Context::default();
|
||||
let mut cache = TextureCache::new();
|
||||
|
||||
let (texture_id, size) = {
|
||||
let entry = cache
|
||||
.get_or_load(&ctx, &path)
|
||||
.expect("headless egui contexts support texture handles");
|
||||
(entry.texture.id(), entry.size)
|
||||
};
|
||||
assert_eq!(size, [6, 4], "reports source dimensions");
|
||||
assert_eq!(cache.len(), 1);
|
||||
assert!(cache.error_for(&path).is_none());
|
||||
|
||||
// Overwrite with a different image: the cached entry must win.
|
||||
write_png(&path, 2, 2, [255, 0, 0, 255]);
|
||||
let entry = cache
|
||||
.get_or_load(&ctx, &path)
|
||||
.expect("second call returns the cached entry");
|
||||
assert_eq!(entry.size, [6, 4]);
|
||||
assert_eq!(entry.texture.id(), texture_id, "same GPU texture handle");
|
||||
assert_eq!(cache.len(), 1, "no duplicate entry for the same path");
|
||||
assert_eq!(cache.dimensions(&path), Some([6, 4]));
|
||||
assert_eq!(cache.texture(&path).map(|t| t.id()), Some(texture_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_images_are_downscaled_for_upload_but_report_source_size() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("big.png");
|
||||
write_png(&path, 640, 320, [1, 2, 3, 255]);
|
||||
let ctx = egui::Context::default();
|
||||
let mut cache = TextureCache::new();
|
||||
|
||||
let entry = cache.get_or_load(&ctx, &path).expect("load big image");
|
||||
assert_eq!(entry.size, [640, 320], "source size is preserved");
|
||||
assert_eq!(
|
||||
entry.texture.size(),
|
||||
[512, 256],
|
||||
"upload is downscaled to the 512 px cap, preserving aspect ratio"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_images_fail_instead_of_being_decoded() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("huge.png");
|
||||
// 4097 px wide (height stays tiny) is just past the decode cap.
|
||||
write_png(&path, MAX_DECODE_DIMENSION + 1, 1, [0, 0, 0, 255]);
|
||||
let ctx = egui::Context::default();
|
||||
let mut cache = TextureCache::new();
|
||||
|
||||
assert!(cache.get_or_load(&ctx, &path).is_none());
|
||||
let error = cache.error_for(&path).expect("oversize error is cached");
|
||||
assert!(
|
||||
error.contains("huge.png"),
|
||||
"error should name the file: {error}"
|
||||
);
|
||||
assert!(cache.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_drops_entries_and_failures() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let good = temp.path().join("good.png");
|
||||
let bad = temp.path().join("bad.png");
|
||||
write_png(&good, 3, 3, [0, 0, 0, 255]);
|
||||
std::fs::write(&bad, b"garbage").expect("write garbage");
|
||||
let ctx = egui::Context::default();
|
||||
let mut cache = TextureCache::new();
|
||||
|
||||
assert!(cache.get_or_load(&ctx, &good).is_some());
|
||||
assert!(cache.get_or_load(&ctx, &bad).is_none());
|
||||
assert_eq!(cache.len(), 1);
|
||||
assert!(cache.error_for(&bad).is_some());
|
||||
|
||||
cache.clear();
|
||||
assert!(cache.is_empty());
|
||||
assert!(cache.error_for(&bad).is_none());
|
||||
assert!(cache.texture(&good).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Bevy Forge's visual identity: a calm, dark, slightly blue editor theme.
|
||||
|
||||
use egui::{Color32, CornerRadius, FontFamily, FontId, Stroke, TextStyle, Visuals};
|
||||
|
||||
pub const BG: Color32 = Color32::from_rgb(0x13, 0x15, 0x1c);
|
||||
pub const PANEL: Color32 = Color32::from_rgb(0x1a, 0x1d, 0x27);
|
||||
pub const PANEL_ALT: Color32 = Color32::from_rgb(0x20, 0x24, 0x30);
|
||||
pub const ELEVATED: Color32 = Color32::from_rgb(0x27, 0x2c, 0x3a);
|
||||
pub const BORDER: Color32 = Color32::from_rgb(0x33, 0x39, 0x49);
|
||||
pub const TEXT: Color32 = Color32::from_rgb(0xe8, 0xea, 0xf2);
|
||||
pub const MUTED: Color32 = Color32::from_rgb(0x94, 0x9c, 0xb2);
|
||||
pub const FAINT: Color32 = Color32::from_rgb(0x7f, 0x87, 0x9b);
|
||||
pub const ACCENT: Color32 = Color32::from_rgb(0x7c, 0x9c, 0xff);
|
||||
pub const ACCENT_DIM: Color32 = Color32::from_rgb(0x42, 0x54, 0x8c);
|
||||
pub const SELECTION: Color32 = Color32::from_rgb(0xff, 0xd1, 0x66);
|
||||
pub const ERROR: Color32 = Color32::from_rgb(0xff, 0x6b, 0x6b);
|
||||
pub const WARNING: Color32 = Color32::from_rgb(0xff, 0xc8, 0x57);
|
||||
pub const SUCCESS: Color32 = Color32::from_rgb(0x6e, 0xe7, 0xa0);
|
||||
pub const X_AXIS: Color32 = Color32::from_rgb(0xff, 0x6b, 0x7a);
|
||||
pub const Y_AXIS: Color32 = Color32::from_rgb(0x7e, 0xe7, 0x87);
|
||||
pub const Z_AXIS: Color32 = Color32::from_rgb(0x6c, 0xa8, 0xff);
|
||||
pub const GRID_MINOR: Color32 = Color32::from_rgb(0x22, 0x26, 0x31);
|
||||
pub const GRID_MAJOR: Color32 = Color32::from_rgb(0x2d, 0x33, 0x42);
|
||||
|
||||
pub fn apply(ctx: &egui::Context) {
|
||||
// Force the dark theme even when the OS prefers light; `set_visuals` only
|
||||
// affects the currently active theme.
|
||||
ctx.set_theme(egui::ThemePreference::Dark);
|
||||
let mut visuals = Visuals::dark();
|
||||
visuals.panel_fill = PANEL;
|
||||
visuals.window_fill = PANEL;
|
||||
visuals.extreme_bg_color = BG;
|
||||
visuals.faint_bg_color = PANEL_ALT;
|
||||
visuals.code_bg_color = BG;
|
||||
visuals.override_text_color = Some(TEXT);
|
||||
visuals.selection.bg_fill = ACCENT_DIM;
|
||||
visuals.selection.stroke = Stroke::new(1.0_f32, ACCENT);
|
||||
visuals.window_stroke = Stroke::new(1.0_f32, BORDER);
|
||||
visuals.widgets.noninteractive.bg_fill = PANEL;
|
||||
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, BORDER);
|
||||
visuals.widgets.inactive.bg_fill = PANEL_ALT;
|
||||
visuals.widgets.inactive.weak_bg_fill = PANEL_ALT;
|
||||
visuals.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, BORDER);
|
||||
visuals.widgets.inactive.corner_radius = CornerRadius::same(5);
|
||||
visuals.widgets.hovered.bg_fill = ELEVATED;
|
||||
visuals.widgets.hovered.weak_bg_fill = ELEVATED;
|
||||
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, ACCENT_DIM);
|
||||
visuals.widgets.hovered.corner_radius = CornerRadius::same(5);
|
||||
visuals.widgets.active.bg_fill = ACCENT_DIM;
|
||||
visuals.widgets.active.weak_bg_fill = ACCENT_DIM;
|
||||
visuals.widgets.active.bg_stroke = Stroke::new(1.0_f32, ACCENT);
|
||||
visuals.widgets.active.corner_radius = CornerRadius::same(5);
|
||||
visuals.widgets.open.bg_fill = ELEVATED;
|
||||
visuals.window_corner_radius = CornerRadius::same(8);
|
||||
visuals.menu_corner_radius = CornerRadius::same(6);
|
||||
ctx.set_visuals(visuals);
|
||||
|
||||
let mut style = (*ctx.style()).clone();
|
||||
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
|
||||
style.spacing.button_padding = egui::vec2(8.0, 4.0);
|
||||
style.spacing.indent = 16.0;
|
||||
style.spacing.interact_size.y = 24.0;
|
||||
style.spacing.scroll.bar_width = 8.0;
|
||||
style.text_styles.insert(
|
||||
TextStyle::Heading,
|
||||
FontId::new(19.0, FontFamily::Proportional),
|
||||
);
|
||||
style
|
||||
.text_styles
|
||||
.insert(TextStyle::Body, FontId::new(13.5, FontFamily::Proportional));
|
||||
style.text_styles.insert(
|
||||
TextStyle::Button,
|
||||
FontId::new(13.0, FontFamily::Proportional),
|
||||
);
|
||||
style.text_styles.insert(
|
||||
TextStyle::Small,
|
||||
FontId::new(11.5, FontFamily::Proportional),
|
||||
);
|
||||
style.text_styles.insert(
|
||||
TextStyle::Monospace,
|
||||
FontId::new(12.5, FontFamily::Monospace),
|
||||
);
|
||||
ctx.set_style(style);
|
||||
}
|
||||
|
||||
pub fn card_frame() -> egui::Frame {
|
||||
egui::Frame::new()
|
||||
.fill(PANEL_ALT)
|
||||
.stroke(Stroke::new(1.0_f32, BORDER))
|
||||
.corner_radius(CornerRadius::same(7))
|
||||
.inner_margin(egui::Margin::symmetric(10, 8))
|
||||
}
|
||||
|
||||
pub fn inset_frame() -> egui::Frame {
|
||||
egui::Frame::new()
|
||||
.fill(BG)
|
||||
.stroke(Stroke::new(1.0_f32, BORDER))
|
||||
.corner_radius(CornerRadius::same(5))
|
||||
.inner_margin(egui::Margin::symmetric(8, 6))
|
||||
}
|
||||
|
||||
pub fn subtle_button(text: impl Into<egui::WidgetText>) -> egui::Button<'static> {
|
||||
egui::Button::new(text).frame(false)
|
||||
}
|
||||
|
||||
/// Muted secondary text used for hints and counts.
|
||||
pub fn hint(text: impl Into<String>) -> egui::RichText {
|
||||
egui::RichText::new(text).color(MUTED).size(11.5)
|
||||
}
|
||||
|
||||
pub fn accent_text(text: impl Into<String>) -> egui::RichText {
|
||||
egui::RichText::new(text).color(ACCENT)
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Transient toast notifications.
|
||||
//!
|
||||
//! Toasts stack in the bottom-right corner, above the status bar. They expire on
|
||||
//! their own ([`LIFETIME_SECS`]), can be dismissed with a click and never capture
|
||||
//! pointer input outside their own small area. At most [`MAX_TOASTS`] are kept so
|
||||
//! a burst of errors cannot cover the editor.
|
||||
//!
|
||||
//! Ages are advanced from egui's frame delta rather than wall-clock time, which
|
||||
//! keeps the expiry logic pure and unit-testable via [`Toasts::advance`].
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::theme;
|
||||
|
||||
/// How long a toast stays visible before it expires, in seconds.
|
||||
pub const LIFETIME_SECS: f32 = 4.5;
|
||||
/// Maximum number of toasts kept on screen; the oldest is dropped first.
|
||||
pub const MAX_TOASTS: usize = 5;
|
||||
|
||||
/// Fade-in duration at the start of a toast's life.
|
||||
const FADE_IN_SECS: f32 = 0.15;
|
||||
/// Fade-out duration at the end of a toast's life.
|
||||
const FADE_OUT_SECS: f32 = 0.45;
|
||||
/// Text wrap width of a toast message.
|
||||
const MAX_TOAST_WIDTH: f32 = 340.0;
|
||||
/// One frame of animation never counts for more than this (avoids huge jumps
|
||||
/// after the app was suspended).
|
||||
const MAX_FRAME_DT: f32 = 0.25;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ToastLevel {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ToastLevel {
|
||||
/// Single-glyph marker drawn in a colored pill. Every glyph is covered by the
|
||||
/// bundled egui fonts (NotoEmoji for ✔, Ubuntu for the rest).
|
||||
pub const fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "i",
|
||||
Self::Success => "✔",
|
||||
Self::Warning => "!",
|
||||
Self::Error => "×",
|
||||
}
|
||||
}
|
||||
|
||||
/// Spoken name, used for tooltips and tests.
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "Info",
|
||||
Self::Success => "Success",
|
||||
Self::Warning => "Warning",
|
||||
Self::Error => "Error",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn color(self) -> egui::Color32 {
|
||||
match self {
|
||||
Self::Info => theme::ACCENT,
|
||||
Self::Success => theme::SUCCESS,
|
||||
Self::Warning => theme::WARNING,
|
||||
Self::Error => theme::ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Toast {
|
||||
/// Monotonic key for stable egui widget ids. Never reused, so removing one
|
||||
/// toast cannot move another toast's widget state.
|
||||
key: u64,
|
||||
level: ToastLevel,
|
||||
message: String,
|
||||
/// Seconds since the toast was pushed.
|
||||
age: f32,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
/// Visibility multiplier in `0.0..=1.0`, fading in and out at the edges.
|
||||
fn opacity(&self) -> f32 {
|
||||
let fade_in = (self.age / FADE_IN_SECS).clamp(0.0, 1.0);
|
||||
let fade_out = ((LIFETIME_SECS - self.age) / FADE_OUT_SECS).clamp(0.0, 1.0);
|
||||
fade_in.min(fade_out)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Toasts {
|
||||
entries: Vec<Toast>,
|
||||
next_key: u64,
|
||||
}
|
||||
|
||||
impl Toasts {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn push(&mut self, level: ToastLevel, message: impl Into<String>) {
|
||||
let message = message.into();
|
||||
if message.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
// A repeated message means the user did the same thing twice; refresh the
|
||||
// existing toast instead of stacking duplicates.
|
||||
if let Some(last) = self.entries.last_mut() {
|
||||
if last.level == level && last.message == message {
|
||||
last.age = 0.0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let key = self.next_key;
|
||||
self.next_key = self.next_key.wrapping_add(1);
|
||||
self.entries.push(Toast {
|
||||
key,
|
||||
level,
|
||||
message,
|
||||
age: 0.0,
|
||||
});
|
||||
while self.entries.len() > MAX_TOASTS {
|
||||
self.entries.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(&mut self, message: impl Into<String>) {
|
||||
self.push(ToastLevel::Info, message);
|
||||
}
|
||||
|
||||
pub fn success(&mut self, message: impl Into<String>) {
|
||||
self.push(ToastLevel::Success, message);
|
||||
}
|
||||
|
||||
pub fn warning(&mut self, message: impl Into<String>) {
|
||||
self.push(ToastLevel::Warning, message);
|
||||
}
|
||||
|
||||
pub fn error(&mut self, message: impl Into<String>) {
|
||||
self.push(ToastLevel::Error, message);
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
|
||||
/// Age all toasts by `dt` seconds and drop the expired ones.
|
||||
///
|
||||
/// Kept separate from [`Self::show`] so expiry is testable without an egui
|
||||
/// context.
|
||||
fn advance(&mut self, dt: f32) {
|
||||
let dt = dt.max(0.0);
|
||||
for entry in &mut self.entries {
|
||||
entry.age += dt;
|
||||
}
|
||||
self.entries.retain(|entry| entry.age < LIFETIME_SECS);
|
||||
}
|
||||
|
||||
/// Dismiss one toast by its stable key.
|
||||
fn dismiss(&mut self, key: u64) {
|
||||
self.entries.retain(|entry| entry.key != key);
|
||||
}
|
||||
|
||||
/// Draw the toast stack and expire old entries.
|
||||
pub fn show(&mut self, ctx: &egui::Context) {
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dt = ctx.input(|input| input.stable_dt).clamp(0.0, MAX_FRAME_DT);
|
||||
self.advance(dt);
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut dismissed = None;
|
||||
egui::Area::new(egui::Id::new("bevy_forge_toasts"))
|
||||
// Sit above the 25 px status bar with a small margin.
|
||||
.anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -34.0))
|
||||
.order(egui::Order::Foreground)
|
||||
.interactable(true)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_max_width(MAX_TOAST_WIDTH + 32.0);
|
||||
ui.vertical(|ui| {
|
||||
// Oldest first: the newest toast is closest to the status bar.
|
||||
for entry in &self.entries {
|
||||
ui.push_id(entry.key, |ui| {
|
||||
ui.set_opacity(entry.opacity());
|
||||
if toast_ui(ui, entry).clicked() {
|
||||
dismissed = Some(entry.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if let Some(key) = dismissed {
|
||||
self.dismiss(key);
|
||||
}
|
||||
|
||||
// Animate only while toasts are visible; an idle editor repaints on demand.
|
||||
ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a single toast. The returned response covers the whole card and is
|
||||
/// clickable, so a click anywhere on the toast dismisses it.
|
||||
fn toast_ui(ui: &mut egui::Ui, entry: &Toast) -> egui::Response {
|
||||
let color = entry.level.color();
|
||||
let frame = egui::Frame::popup(ui.style())
|
||||
.fill(theme::ELEVATED)
|
||||
.stroke(egui::Stroke::new(1.0_f32, color.gamma_multiply(0.65)))
|
||||
.corner_radius(egui::CornerRadius::same(7))
|
||||
.inner_margin(egui::Margin::symmetric(10, 7))
|
||||
.outer_margin(egui::Margin::symmetric(0, 2));
|
||||
|
||||
let inner = frame.show(ui, |ui| {
|
||||
ui.set_max_width(MAX_TOAST_WIDTH);
|
||||
ui.horizontal(|ui| {
|
||||
egui::Frame::new()
|
||||
.fill(color.gamma_multiply(0.16))
|
||||
.corner_radius(egui::CornerRadius::same(9))
|
||||
.inner_margin(egui::Margin::symmetric(5, 1))
|
||||
.show(ui, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(entry.level.icon())
|
||||
.color(color)
|
||||
.strong()
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text(entry.level.label());
|
||||
});
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(&entry.message)
|
||||
.color(theme::TEXT)
|
||||
.size(12.0),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
inner
|
||||
.response
|
||||
.interact(egui::Sense::click())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text(format!("{} — click to dismiss", entry.level.label()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn toasts_expire_after_their_lifetime() {
|
||||
let mut toasts = Toasts::new();
|
||||
toasts.info("Saved garden.forge.ron");
|
||||
toasts.success("Exported scene.rs");
|
||||
assert_eq!(toasts.len(), 2);
|
||||
|
||||
toasts.advance(LIFETIME_SECS - 0.1);
|
||||
assert_eq!(toasts.len(), 2, "still visible just before the deadline");
|
||||
|
||||
toasts.advance(0.2);
|
||||
assert!(toasts.is_empty(), "expired toasts are removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_most_five_toasts_are_kept() {
|
||||
let mut toasts = Toasts::new();
|
||||
for index in 0..MAX_TOASTS + 3 {
|
||||
toasts.warning(format!("warning {index}"));
|
||||
}
|
||||
assert_eq!(toasts.len(), MAX_TOASTS);
|
||||
assert!(toasts.entries.iter().all(|entry| entry.age == 0.0));
|
||||
// The oldest three were dropped.
|
||||
assert_eq!(toasts.entries[0].message, "warning 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_messages_refresh_instead_of_stacking() {
|
||||
let mut toasts = Toasts::new();
|
||||
toasts.error("Export failed");
|
||||
toasts.advance(2.0);
|
||||
assert!(toasts.entries[0].age > 0.0);
|
||||
|
||||
toasts.error("Export failed");
|
||||
assert_eq!(toasts.len(), 1);
|
||||
assert_eq!(toasts.entries[0].age, 0.0, "the toast timer restarts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_messages_are_ignored() {
|
||||
let mut toasts = Toasts::new();
|
||||
toasts.info(" ");
|
||||
assert!(toasts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissal_and_clear_work() {
|
||||
let mut toasts = Toasts::new();
|
||||
toasts.info("one");
|
||||
toasts.info("two");
|
||||
let key = toasts.entries[0].key;
|
||||
toasts.dismiss(key);
|
||||
assert_eq!(toasts.len(), 1);
|
||||
assert_eq!(toasts.entries[0].message, "two");
|
||||
|
||||
toasts.clear();
|
||||
assert!(toasts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_fades_in_and_out() {
|
||||
let mut entry = Toast {
|
||||
key: 0,
|
||||
level: ToastLevel::Info,
|
||||
message: "fade".into(),
|
||||
age: 0.0,
|
||||
};
|
||||
assert_eq!(entry.opacity(), 0.0, "starts transparent");
|
||||
entry.age = FADE_IN_SECS;
|
||||
assert_eq!(entry.opacity(), 1.0, "fully visible while live");
|
||||
entry.age = LIFETIME_SECS - FADE_OUT_SECS / 2.0;
|
||||
assert!(entry.opacity() < 1.0 && entry.opacity() > 0.0, "fades out");
|
||||
entry.age = LIFETIME_SECS;
|
||||
assert_eq!(entry.opacity(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_markers_are_distinct() {
|
||||
let levels = [
|
||||
ToastLevel::Info,
|
||||
ToastLevel::Success,
|
||||
ToastLevel::Warning,
|
||||
ToastLevel::Error,
|
||||
];
|
||||
for (index, level) in levels.iter().enumerate() {
|
||||
assert!(!level.icon().is_empty());
|
||||
assert!(!level.label().is_empty());
|
||||
assert_ne!(level.color(), levels[(index + 1) % levels.len()].color());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_is_headless_safe() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut toasts = Toasts::new();
|
||||
toasts.success("Rendered headlessly");
|
||||
let raw_input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(960.0, 640.0),
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = ctx.run(raw_input, |ctx| toasts.show(ctx));
|
||||
assert_eq!(toasts.len(), 1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,540 @@
|
||||
//! Welcome screen shown when no project is open: brand hero, project opener,
|
||||
//! recent projects, starter scenes and demo hints.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bevy_forge_core::{EditorConfig, ProjectTemplate};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::theme;
|
||||
use crate::widgets;
|
||||
|
||||
const FEATURES: &[(&str, &str)] = &[
|
||||
("2D & 3D viewports", "pan, zoom and gizmos"),
|
||||
("Hierarchy & inspector", "transforms, components, assets"),
|
||||
("Undo, redo & validation", "safe scene edits"),
|
||||
("Asset browser", "thumbnails and drag-to-place"),
|
||||
("BSN & Rust export", "paste-ready Bevy 0.20 code"),
|
||||
];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WelcomeState {
|
||||
pub project_path: String,
|
||||
pub error: Option<String>,
|
||||
/// Set once the path field has received initial focus.
|
||||
pub focus_requested: bool,
|
||||
}
|
||||
|
||||
pub fn show(ctx: &egui::Context, state: &mut WelcomeState, config: &EditorConfig) -> Vec<Action> {
|
||||
let mut actions = Vec::new();
|
||||
let margin = 56.0;
|
||||
egui::TopBottomPanel::bottom("welcome_footer")
|
||||
.exact_height(36.0)
|
||||
.frame(
|
||||
egui::Frame::new()
|
||||
.fill(theme::BG)
|
||||
.inner_margin(egui::Margin::symmetric(0, 4)),
|
||||
)
|
||||
.show(ctx, |ui| footer(ui, margin));
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame::new().fill(theme::BG))
|
||||
.show(ctx, |ui| {
|
||||
paint_backdrop(ui);
|
||||
let content_height = ui.available_height();
|
||||
let gap = 48.0;
|
||||
let inner_width = (ui.available_width() - 2.0 * margin - gap).max(520.0);
|
||||
let right_width = (inner_width * 0.43).clamp(360.0, 500.0);
|
||||
let left_width = inner_width - right_width;
|
||||
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(ui.available_width(), content_height),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.add_space(margin);
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(left_width, content_height),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui| {
|
||||
ui.add_space((content_height * 0.22).min(200.0));
|
||||
brand_column(ui);
|
||||
},
|
||||
);
|
||||
ui.add_space(gap);
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(right_width, content_height),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(content_height)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui.add_space((content_height * 0.27).min(240.0));
|
||||
project_card(ui, state, config, &mut actions);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
actions
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hero (left column)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn brand_column(ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
diamond_mark(ui, 15.0, theme::ACCENT);
|
||||
ui.label(
|
||||
egui::RichText::new("BEVY FORGE")
|
||||
.size(11.5)
|
||||
.strong()
|
||||
.color(theme::MUTED),
|
||||
);
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
egui::RichText::new("Bevy Forge")
|
||||
.size(52.0)
|
||||
.strong()
|
||||
.color(theme::TEXT),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new("Design Bevy scenes visually. Export real BSN code.")
|
||||
.size(18.0)
|
||||
.color(theme::MUTED),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
let (rule, _) = ui.allocate_exact_size(egui::vec2(72.0, 2.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rule, 1.0, theme::ACCENT);
|
||||
ui.add_space(16.0);
|
||||
|
||||
for (lead, detail) in FEATURES {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(egui::RichText::new("•").size(14.0).color(theme::ACCENT));
|
||||
ui.label(
|
||||
egui::RichText::new(*lead)
|
||||
.size(14.0)
|
||||
.strong()
|
||||
.color(theme::TEXT),
|
||||
);
|
||||
ui.label(egui::RichText::new(*detail).size(12.5).color(theme::MUTED));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
ui.add_space(18.0);
|
||||
ui.horizontal(|ui| {
|
||||
badge(ui, "ALPHA");
|
||||
badge(ui, concat!("v", env!("CARGO_PKG_VERSION")));
|
||||
badge(ui, "Bevy 0.20 · BSN");
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
egui::RichText::new("Open source · MIT OR Apache-2.0")
|
||||
.size(10.5)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
}
|
||||
|
||||
fn badge(ui: &mut egui::Ui, text: &str) {
|
||||
egui::Frame::new()
|
||||
.fill(theme::PANEL_ALT)
|
||||
.stroke(egui::Stroke::new(1.0_f32, theme::BORDER))
|
||||
.corner_radius(egui::CornerRadius::same(4))
|
||||
.inner_margin(egui::Margin::symmetric(7, 3))
|
||||
.show(ui, |ui| {
|
||||
ui.label(egui::RichText::new(text).size(10.0).color(theme::MUTED));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project card (right column)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn project_card(
|
||||
ui: &mut egui::Ui,
|
||||
state: &mut WelcomeState,
|
||||
config: &EditorConfig,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
egui::Frame::new()
|
||||
.fill(theme::PANEL_ALT)
|
||||
.stroke(egui::Stroke::new(1.0_f32, theme::BORDER))
|
||||
.corner_radius(egui::CornerRadius::same(12))
|
||||
.inner_margin(egui::Margin::symmetric(22, 20))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
|
||||
widgets::section_label(ui, "Open a project");
|
||||
ui.label(
|
||||
egui::RichText::new("Start from a Bevy project")
|
||||
.size(19.0)
|
||||
.strong()
|
||||
.color(theme::TEXT),
|
||||
);
|
||||
ui.add(
|
||||
egui::Label::new(theme::hint(
|
||||
"Scenes load from <project>/scenes and assets from <project>/assets.",
|
||||
))
|
||||
.wrap(),
|
||||
);
|
||||
ui.add_space(9.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let field_width = (ui.available_width() - 84.0).max(140.0);
|
||||
let response = ui.add_sized(
|
||||
[field_width, 28.0],
|
||||
egui::TextEdit::singleline(&mut state.project_path)
|
||||
.id_salt("welcome_project_path")
|
||||
.hint_text("/path/to/bevy-project")
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.margin(egui::Margin::symmetric(7, 5)),
|
||||
);
|
||||
if !state.focus_requested {
|
||||
response.request_focus();
|
||||
state.focus_requested = true;
|
||||
}
|
||||
if response.changed() {
|
||||
state.error = None;
|
||||
}
|
||||
let open_clicked = ui
|
||||
.add_sized([76.0, 28.0], widgets::primary_button("Open"))
|
||||
.clicked();
|
||||
let enter =
|
||||
response.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
|
||||
if open_clicked || enter {
|
||||
try_open(state, actions);
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(error) = &state.error {
|
||||
ui.add_space(3.0);
|
||||
error_panel(ui, error);
|
||||
}
|
||||
|
||||
let recents: Vec<&PathBuf> = config.recent_projects.iter().take(5).collect();
|
||||
if !recents.is_empty() {
|
||||
ui.add_space(10.0);
|
||||
ui.separator();
|
||||
widgets::section_label(ui, "Recent projects");
|
||||
for path in recents {
|
||||
if recent_row(ui, path) {
|
||||
actions.push(Action::OpenProject(path.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.separator();
|
||||
widgets::section_label(ui, "Start a new scene");
|
||||
let button_width = ((ui.available_width() - 8.0) / 2.0).max(120.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_sized(
|
||||
[button_width, 30.0],
|
||||
egui::Button::new(egui::RichText::new("2D starter").size(12.5)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::NewScene(ProjectTemplate::TwoD));
|
||||
}
|
||||
if ui
|
||||
.add_sized(
|
||||
[button_width, 30.0],
|
||||
egui::Button::new(egui::RichText::new("3D starter").size(12.5)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::NewScene(ProjectTemplate::ThreeD));
|
||||
}
|
||||
});
|
||||
ui.add(
|
||||
egui::Label::new(theme::hint(
|
||||
"New scenes open in the editor. Save them into a project with Ctrl+S.",
|
||||
))
|
||||
.wrap(),
|
||||
);
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.separator();
|
||||
widgets::section_label(ui, "Try the demo");
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.button("Open demo project")
|
||||
.on_hover_text("examples/demo-project — a Bevy 0.20 project with two scenes")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::OpenProject(demo_project_path()));
|
||||
}
|
||||
ui.label(theme::hint("garden (3D) + platformer (2D)"));
|
||||
});
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new("cargo run -p bevy_forge_editor -- examples/demo-project")
|
||||
.monospace()
|
||||
.size(9.5)
|
||||
.color(theme::FAINT),
|
||||
)
|
||||
.selectable(true)
|
||||
.truncate(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn try_open(state: &mut WelcomeState, actions: &mut Vec<Action>) {
|
||||
match crate::dialogs::validate_project_input(&state.project_path) {
|
||||
Ok(path) => {
|
||||
state.error = None;
|
||||
actions.push(Action::OpenProject(path));
|
||||
}
|
||||
Err(message) => state.error = Some(message),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_panel(ui: &mut egui::Ui, message: &str) {
|
||||
egui::Frame::new()
|
||||
.fill(egui::Color32::from_rgb(52, 24, 30))
|
||||
.stroke(egui::Stroke::new(1.0_f32, theme::ERROR))
|
||||
.corner_radius(egui::CornerRadius::same(5))
|
||||
.inner_margin(egui::Margin::symmetric(9, 6))
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(message).color(theme::ERROR).size(11.0))
|
||||
.wrap(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn recent_row(ui: &mut egui::Ui, path: &Path) -> bool {
|
||||
let name = folder_name(path);
|
||||
let full = path.display().to_string();
|
||||
let subtitle = compact_path(&full, 52);
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(egui::vec2(ui.available_width(), 36.0), egui::Sense::click());
|
||||
let hovered = response.hovered();
|
||||
let painter = ui.painter();
|
||||
let row = rect.shrink2(egui::vec2(0.0, 1.5));
|
||||
painter.rect_filled(
|
||||
row,
|
||||
6.0,
|
||||
if hovered {
|
||||
theme::ELEVATED
|
||||
} else {
|
||||
theme::PANEL
|
||||
},
|
||||
);
|
||||
painter.rect_stroke(
|
||||
row,
|
||||
6.0,
|
||||
egui::Stroke::new(1.0_f32, theme::BORDER),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(row.left() + 9.0, row.top() + 6.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
&name,
|
||||
egui::FontId::proportional(12.5),
|
||||
theme::TEXT,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(row.left() + 9.0, row.top() + 21.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
subtitle,
|
||||
egui::FontId::monospace(9.5),
|
||||
theme::FAINT,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(row.right() - 9.0, row.center().y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
"Open",
|
||||
egui::FontId::proportional(10.0),
|
||||
theme::ACCENT,
|
||||
);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
|
||||
let response = response.on_hover_text(full);
|
||||
response.clicked()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chrome
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Draw the Bevy Forge diamond mark. Painted instead of a glyph so it renders
|
||||
/// identically with every font configuration.
|
||||
fn diamond_mark(ui: &mut egui::Ui, size: f32, color: egui::Color32) {
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover());
|
||||
let center = rect.center();
|
||||
let radius = size * 0.5;
|
||||
ui.painter().add(egui::Shape::convex_polygon(
|
||||
vec![
|
||||
egui::pos2(center.x, center.y - radius),
|
||||
egui::pos2(center.x + radius, center.y),
|
||||
egui::pos2(center.x, center.y + radius),
|
||||
egui::pos2(center.x - radius, center.y),
|
||||
],
|
||||
color,
|
||||
egui::Stroke::NONE,
|
||||
));
|
||||
}
|
||||
|
||||
fn paint_backdrop(ui: &egui::Ui) {
|
||||
let rect = ui.max_rect();
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(rect, 0.0, theme::BG);
|
||||
painter.circle_filled(
|
||||
egui::pos2(
|
||||
rect.left() + rect.width() * 0.16,
|
||||
rect.top() + rect.height() * 0.22,
|
||||
),
|
||||
rect.width() * 0.24,
|
||||
egui::Color32::from_rgba_unmultiplied(124, 156, 255, 10),
|
||||
);
|
||||
painter.circle_filled(
|
||||
egui::pos2(
|
||||
rect.left() + rect.width() * 0.88,
|
||||
rect.top() + rect.height() * 0.82,
|
||||
),
|
||||
rect.width() * 0.18,
|
||||
egui::Color32::from_rgba_unmultiplied(66, 84, 140, 14),
|
||||
);
|
||||
painter.rect_filled(
|
||||
egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), 1.0)),
|
||||
0.0,
|
||||
theme::ACCENT_DIM,
|
||||
);
|
||||
}
|
||||
|
||||
fn footer(ui: &mut egui::Ui, margin: f32) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(margin);
|
||||
ui.label(
|
||||
egui::RichText::new("Bevy 0.20 · BSN")
|
||||
.size(11.0)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
ui.label(egui::RichText::new("·").size(11.0).color(theme::BORDER));
|
||||
ui.add_space(10.0);
|
||||
ui.label(
|
||||
egui::RichText::new("Made for the Bevy community")
|
||||
.size(11.0)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.add_space(margin);
|
||||
ui.label(
|
||||
egui::RichText::new("command palette")
|
||||
.size(11.0)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
widgets::kbd(ui, "Ctrl+K");
|
||||
ui.add_space(10.0);
|
||||
ui.label(
|
||||
egui::RichText::new("shortcuts")
|
||||
.size(11.0)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
widgets::kbd(ui, "F1");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers (pure; unit-tested)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The bundled demo project, resolved at compile time from the crate location.
|
||||
pub fn demo_project_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo-project")
|
||||
}
|
||||
|
||||
fn folder_name(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| path.display().to_string())
|
||||
}
|
||||
|
||||
/// Shorten a path to its tail, keeping complete trailing components so recent
|
||||
/// projects stay recognisable. Example: `…/bevy-forge/examples/demo-project`.
|
||||
fn compact_path(path: &str, max_chars: usize) -> String {
|
||||
let count = path.chars().count();
|
||||
if count <= max_chars {
|
||||
return path.to_string();
|
||||
}
|
||||
if max_chars < 8 {
|
||||
return path.to_string();
|
||||
}
|
||||
let keep = max_chars - 2;
|
||||
let tail: String = path.chars().skip(count - keep).collect();
|
||||
// Drop the first (likely partial) component and keep the last components.
|
||||
let tail = tail
|
||||
.find('/')
|
||||
.map(|index| &tail[index..])
|
||||
.unwrap_or(tail.as_str());
|
||||
format!("…{tail}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compacts_long_paths_to_their_tail() {
|
||||
let path = "/home/emil/Documents/deepseek-harness/default-workspace/bevy-forge/examples/demo-project";
|
||||
let short = compact_path(path, 40);
|
||||
assert!(short.chars().count() <= 40);
|
||||
assert!(short.starts_with("…/"));
|
||||
assert!(short.ends_with("examples/demo-project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_short_paths_unchanged() {
|
||||
assert_eq!(compact_path("/tmp/game", 40), "/tmp/game");
|
||||
assert_eq!(compact_path("/tmp/game", 4), "/tmp/game");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacting_handles_multibyte_characters() {
|
||||
let path = "/home/émil/jeux/bevy-forge/examples/demo-project";
|
||||
let short = compact_path(path, 20);
|
||||
assert!(short.chars().count() <= 20);
|
||||
assert!(short.starts_with("…/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_name_prefers_the_last_component() {
|
||||
assert_eq!(folder_name(Path::new("/tmp/games/demo")), "demo");
|
||||
assert_eq!(folder_name(Path::new("/")), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_project_path_points_at_the_bundled_example() {
|
||||
let path = demo_project_path();
|
||||
assert!(path.ends_with("examples/demo-project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_validation_errors_are_human_readable() {
|
||||
let mut state = WelcomeState::default();
|
||||
let mut actions = Vec::new();
|
||||
try_open(&mut state, &mut actions);
|
||||
assert!(actions.is_empty());
|
||||
assert!(state.error.as_deref().unwrap_or("").contains("path"));
|
||||
|
||||
state.project_path = " /tmp/game ".into();
|
||||
try_open(&mut state, &mut actions);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::OpenProject(PathBuf::from("/tmp/game"))]
|
||||
);
|
||||
assert!(state.error.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Small reusable widgets shared by the inspector, hierarchy and viewports.
|
||||
|
||||
use bevy_forge_core::{Color, Vec2 as FVec2, Vec3 as FVec3};
|
||||
use egui::{Response, RichText, Ui};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
pub fn color32(color: Color) -> egui::Color32 {
|
||||
egui::Color32::from_rgba_unmultiplied(
|
||||
(color.r.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(color.g.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(color.b.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(color.a.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn color_from_egui(color: egui::Color32) -> Color {
|
||||
Color::from_rgba8(color.r(), color.g(), color.b(), color.a())
|
||||
}
|
||||
|
||||
pub fn section_label(ui: &mut Ui, text: impl Into<String>) {
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
RichText::new(text.into().to_uppercase())
|
||||
.color(theme::MUTED)
|
||||
.size(10.5)
|
||||
.strong(),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn field_label(ui: &mut Ui, text: &str) -> Response {
|
||||
ui.label(RichText::new(text).color(theme::MUTED).size(12.0))
|
||||
}
|
||||
|
||||
pub fn help_marker(ui: &mut Ui, text: &str) {
|
||||
ui.label(RichText::new("?").color(theme::FAINT).size(11.0))
|
||||
.on_hover_text(text);
|
||||
}
|
||||
|
||||
pub fn vec2_editor(
|
||||
ui: &mut Ui,
|
||||
id_salt: impl std::hash::Hash,
|
||||
value: &mut FVec2,
|
||||
speed: f32,
|
||||
) -> bool {
|
||||
ui.push_id(id_salt, |ui| {
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("X").color(theme::X_AXIS).size(11.0));
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::DragValue::new(&mut value.x)
|
||||
.speed(speed)
|
||||
.fixed_decimals(3),
|
||||
)
|
||||
.changed();
|
||||
ui.label(RichText::new("Y").color(theme::Y_AXIS).size(11.0));
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::DragValue::new(&mut value.y)
|
||||
.speed(speed)
|
||||
.fixed_decimals(3),
|
||||
)
|
||||
.changed();
|
||||
});
|
||||
changed
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
pub fn vec3_editor(
|
||||
ui: &mut Ui,
|
||||
id_salt: impl std::hash::Hash,
|
||||
value: &mut FVec3,
|
||||
speed: f32,
|
||||
) -> bool {
|
||||
ui.push_id(id_salt, |ui| {
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("X").color(theme::X_AXIS).size(11.0));
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::DragValue::new(&mut value.x)
|
||||
.speed(speed)
|
||||
.fixed_decimals(3),
|
||||
)
|
||||
.changed();
|
||||
ui.label(RichText::new("Y").color(theme::Y_AXIS).size(11.0));
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::DragValue::new(&mut value.y)
|
||||
.speed(speed)
|
||||
.fixed_decimals(3),
|
||||
)
|
||||
.changed();
|
||||
ui.label(RichText::new("Z").color(theme::Z_AXIS).size(11.0));
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::DragValue::new(&mut value.z)
|
||||
.speed(speed)
|
||||
.fixed_decimals(3),
|
||||
)
|
||||
.changed();
|
||||
});
|
||||
changed
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
pub fn color_editor(ui: &mut Ui, id_salt: impl std::hash::Hash, value: &mut Color) -> bool {
|
||||
ui.push_id(id_salt, |ui| {
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
let mut color = color32(*value);
|
||||
if ui.color_edit_button_srgba(&mut color).changed() {
|
||||
*value = color_from_egui(color);
|
||||
changed = true;
|
||||
}
|
||||
let mut hex = value.to_hex();
|
||||
let response = ui.add(
|
||||
egui::TextEdit::singleline(&mut hex)
|
||||
.desired_width(78.0)
|
||||
.font(egui::TextStyle::Monospace),
|
||||
);
|
||||
if response.changed() {
|
||||
if let Some(parsed) = Color::from_hex(&hex) {
|
||||
*value = Color::srgba(parsed.r, parsed.g, parsed.b, value.a);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if value.a < 1.0 {
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::DragValue::new(&mut value.a)
|
||||
.speed(0.01)
|
||||
.range(0.0..=1.0)
|
||||
.fixed_decimals(2),
|
||||
)
|
||||
.changed();
|
||||
}
|
||||
});
|
||||
changed
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// Asset combo box. Returns true when the selection changed. `current` is
|
||||
/// `None` for "no asset"; the first entry clears the selection.
|
||||
pub fn asset_picker(
|
||||
ui: &mut Ui,
|
||||
id_salt: impl std::hash::Hash,
|
||||
current: &mut Option<String>,
|
||||
assets: &[String],
|
||||
placeholder: &str,
|
||||
) -> bool {
|
||||
ui.push_id(id_salt, |ui| {
|
||||
let mut changed = false;
|
||||
egui::ComboBox::from_id_salt(ui.id())
|
||||
.selected_text(current.as_deref().unwrap_or(placeholder).to_string())
|
||||
.width(ui.available_width().min(260.0))
|
||||
.show_ui(ui, |ui| {
|
||||
if ui
|
||||
.selectable_label(current.is_none(), format!("— {placeholder}"))
|
||||
.clicked()
|
||||
{
|
||||
*current = None;
|
||||
changed = true;
|
||||
}
|
||||
for asset in assets {
|
||||
let selected = current.as_deref() == Some(asset.as_str());
|
||||
if ui.selectable_label(selected, asset).clicked() {
|
||||
*current = Some(asset.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if assets.is_empty() {
|
||||
ui.label(theme::hint("No matching assets in this project"));
|
||||
}
|
||||
});
|
||||
changed
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
pub fn search_field(ui: &mut Ui, hint: &str, text: &mut String) -> Response {
|
||||
let response = ui.add(
|
||||
egui::TextEdit::singleline(text)
|
||||
.hint_text(hint)
|
||||
.desired_width(ui.available_width())
|
||||
.margin(egui::Margin::symmetric(6, 4)),
|
||||
);
|
||||
if !text.is_empty() && response.has_focus() {
|
||||
// Keep the field responsive; Escape clears via the global shortcut handler.
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
pub fn icon_button(ui: &mut Ui, icon: &str, tooltip: &str) -> Response {
|
||||
ui.add(egui::Button::new(RichText::new(icon).size(14.0)).frame(false))
|
||||
.on_hover_text(tooltip)
|
||||
}
|
||||
|
||||
pub fn toolbar_toggle(ui: &mut Ui, selected: bool, label: &str, tooltip: &str) -> Response {
|
||||
ui.selectable_label(selected, label).on_hover_text(tooltip)
|
||||
}
|
||||
|
||||
pub fn primary_button(text: &str) -> egui::Button<'static> {
|
||||
egui::Button::new(RichText::new(text).strong())
|
||||
.fill(theme::ACCENT_DIM)
|
||||
.stroke(egui::Stroke::new(1.0_f32, theme::ACCENT))
|
||||
}
|
||||
|
||||
pub fn danger_button(text: &str) -> egui::Button<'static> {
|
||||
egui::Button::new(RichText::new(text).color(theme::ERROR))
|
||||
.fill(theme::PANEL_ALT)
|
||||
.stroke(egui::Stroke::new(1.0_f32, theme::ERROR))
|
||||
}
|
||||
|
||||
pub fn kbd(ui: &mut Ui, keys: &str) {
|
||||
ui.label(
|
||||
RichText::new(keys)
|
||||
.color(theme::MUTED)
|
||||
.monospace()
|
||||
.size(11.0)
|
||||
.background_color(theme::BG),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn empty_state(ui: &mut Ui, title: &str, hint: &str) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(18.0);
|
||||
ui.label(RichText::new(title).color(theme::MUTED).size(13.0));
|
||||
ui.add_space(3.0);
|
||||
ui.label(RichText::new(hint).color(theme::FAINT).size(11.5));
|
||||
ui.add_space(18.0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
//! Headless end-to-end UI tests for Bevy Forge.
|
||||
//!
|
||||
//! These tests drive the real [`ForgeApp`] through `egui::Context::run` with no
|
||||
//! window, GPU, display or network. They cover:
|
||||
//!
|
||||
//! * render smoke at 1440x900 and 1000x640 in both 2D and 3D view modes,
|
||||
//! * every panel/drawer toggle plus the command palette and all dialogs,
|
||||
//! * app-level actions (undo/redo/duplicate/delete/view mode/grid),
|
||||
//! * `.forge.ron` save/load round-trips through a temporary Bevy project,
|
||||
//! * the unsaved-changes close guard and quit flags.
|
||||
//!
|
||||
//! Only the public editor API is used; panel internals are intentionally not
|
||||
//! inspected. The tests are deterministic and independent (each builds its own
|
||||
//! context/app) and finish in well under a second of CPU time.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Once;
|
||||
|
||||
use bevy_forge_core::{samples, ComponentData, SceneDoc, SpriteData, Transform, Vec3};
|
||||
use bevy_forge_editor::action::Action;
|
||||
use bevy_forge_editor::app::ForgeApp;
|
||||
use bevy_forge_editor::state::ViewMode;
|
||||
use bevy_forge_editor::Screen;
|
||||
use eframe::egui;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Point the editor config at a throwaway directory.
|
||||
///
|
||||
/// `ForgeApp::open_project` (and several toggle actions) persist the editor
|
||||
/// preferences through `EditorConfig::save`, which honours `XDG_CONFIG_HOME`.
|
||||
/// Keeping that inside a temp dir makes the tests hermetic and stops them from
|
||||
/// adding temporary projects to the developer's real recent-projects list.
|
||||
fn isolate_editor_config() {
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("bevy-forge-test-config-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("create test config dir");
|
||||
std::env::set_var("XDG_CONFIG_HOME", &dir);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Headless frame driver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct FrameStats {
|
||||
/// Number of top-level shapes emitted by egui this frame.
|
||||
shapes: usize,
|
||||
/// Number of tessellated, clipped primitives.
|
||||
clipped_primitives: usize,
|
||||
/// Number of tessellated meshes (text/rects/images become meshes).
|
||||
meshes: usize,
|
||||
/// Total vertex count across all tessellated meshes.
|
||||
vertices: usize,
|
||||
/// Area of the screen used by the panels this frame.
|
||||
used_rect: egui::Rect,
|
||||
}
|
||||
|
||||
impl FrameStats {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
shapes: 0,
|
||||
clipped_primitives: 0,
|
||||
meshes: 0,
|
||||
vertices: 0,
|
||||
used_rect: egui::Rect::NOTHING,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `ForgeApp` plus the headless egui context driving it.
|
||||
struct HeadlessEditor {
|
||||
ctx: egui::Context,
|
||||
app: ForgeApp,
|
||||
size: egui::Vec2,
|
||||
frame: u64,
|
||||
}
|
||||
|
||||
impl HeadlessEditor {
|
||||
fn new(size: egui::Vec2, doc: SceneDoc) -> Self {
|
||||
let ctx = egui::Context::default();
|
||||
let app = ForgeApp::new_for_test(&ctx, doc);
|
||||
Self {
|
||||
ctx,
|
||||
app,
|
||||
size,
|
||||
frame: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, action: Action) {
|
||||
let ctx = self.ctx.clone();
|
||||
self.app.dispatch_action(&ctx, action);
|
||||
}
|
||||
|
||||
fn frame(&mut self) -> FrameStats {
|
||||
self.frame_with_events(Vec::new())
|
||||
}
|
||||
|
||||
fn frame_with_events(&mut self, events: Vec<egui::Event>) -> FrameStats {
|
||||
self.frame += 1;
|
||||
let mut raw = egui::RawInput::default();
|
||||
raw.screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, self.size));
|
||||
raw.viewports
|
||||
.entry(egui::ViewportId::ROOT)
|
||||
.or_default()
|
||||
.native_pixels_per_point = Some(1.0);
|
||||
// Deterministic, monotonically increasing time keeps animations/toasts stable.
|
||||
raw.time = Some(self.frame as f64 / 60.0);
|
||||
raw.predicted_dt = 1.0 / 60.0;
|
||||
raw.events = events;
|
||||
|
||||
let ctx = self.ctx.clone();
|
||||
let output = ctx.run(raw, |ctx| self.app.ui(ctx));
|
||||
let shape_count = output.shapes.len();
|
||||
let clipped = ctx.tessellate(output.shapes, output.pixels_per_point);
|
||||
let mut meshes = 0usize;
|
||||
let mut vertices = 0usize;
|
||||
for primitive in &clipped {
|
||||
if let egui::epaint::Primitive::Mesh(mesh) = &primitive.primitive {
|
||||
meshes += 1;
|
||||
vertices += mesh.vertices.len();
|
||||
}
|
||||
}
|
||||
FrameStats {
|
||||
shapes: shape_count,
|
||||
clipped_primitives: clipped.len(),
|
||||
meshes,
|
||||
vertices,
|
||||
used_rect: ctx.used_rect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn frames(&mut self, count: usize) -> FrameStats {
|
||||
let mut last = FrameStats::empty();
|
||||
for _ in 0..count {
|
||||
last = self.frame();
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
/// Render one frame and assert it painted something tessellable.
|
||||
fn expect_frame(&mut self, context: &str) -> FrameStats {
|
||||
let stats = self.frame();
|
||||
assert!(
|
||||
stats.shapes > 0,
|
||||
"{context}: the frame produced no egui shapes"
|
||||
);
|
||||
assert!(
|
||||
stats.clipped_primitives > 0,
|
||||
"{context}: tessellation produced no clipped primitives"
|
||||
);
|
||||
assert!(
|
||||
stats.meshes > 0 && stats.vertices > 0,
|
||||
"{context}: tessellation produced no mesh vertices"
|
||||
);
|
||||
assert!(
|
||||
stats.used_rect.is_finite(),
|
||||
"{context}: non-finite used rect {:?}",
|
||||
stats.used_rect
|
||||
);
|
||||
stats
|
||||
}
|
||||
}
|
||||
|
||||
fn key_event(key: egui::Key) -> egui::Event {
|
||||
egui::Event::Key {
|
||||
key,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_rendered(app: &ForgeApp, stats: &FrameStats, label: &str, size: egui::Vec2) {
|
||||
assert!(
|
||||
stats.shapes > 0,
|
||||
"{label} at {size:?}: expected at least one painted egui shape"
|
||||
);
|
||||
assert!(
|
||||
stats.clipped_primitives > 0 && stats.meshes > 0 && stats.vertices > 0,
|
||||
"{label} at {size:?}: tessellation was empty (shapes={}, prims={}, meshes={}, verts={})",
|
||||
stats.shapes,
|
||||
stats.clipped_primitives,
|
||||
stats.meshes,
|
||||
stats.vertices
|
||||
);
|
||||
assert!(
|
||||
stats.used_rect.is_finite() && stats.used_rect.is_positive(),
|
||||
"{label} at {size:?}: used rect is not a finite, positive area: {:?}",
|
||||
stats.used_rect
|
||||
);
|
||||
|
||||
let viewport = app.ui.viewport_rect;
|
||||
assert!(
|
||||
viewport.is_finite(),
|
||||
"{label} at {size:?}: viewport rect is non-finite: {viewport:?}"
|
||||
);
|
||||
assert!(
|
||||
viewport.width() > 1.0 && viewport.height() > 1.0,
|
||||
"{label} at {size:?}: viewport rect has no area: {viewport:?}"
|
||||
);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
|
||||
assert!(
|
||||
viewport.intersects(screen),
|
||||
"{label} at {size:?}: viewport rect {viewport:?} does not intersect the {size:?} screen"
|
||||
);
|
||||
eprintln!(
|
||||
"render-smoke {label} {size:?}: shapes={} prims={} meshes={} verts={} used={:?} viewport={:?}",
|
||||
stats.shapes, stats.clipped_primitives, stats.meshes, stats.vertices,
|
||||
stats.used_rect, viewport
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Render smoke
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn render_smoke_both_sizes_and_view_modes() {
|
||||
isolate_editor_config();
|
||||
|
||||
for size in [egui::vec2(1440.0, 900.0), egui::vec2(1000.0, 640.0)] {
|
||||
// 2D demo: starts in 2D, renders, then switches to the 3D viewport.
|
||||
let mut editor = HeadlessEditor::new(size, samples::platformer_demo());
|
||||
assert_eq!(editor.app.screen, Screen::Editor);
|
||||
assert_eq!(editor.app.ui.view_mode, ViewMode::TwoD);
|
||||
|
||||
let stats = editor.frames(8);
|
||||
assert_rendered(&editor.app, &stats, "platformer demo (2D)", size);
|
||||
|
||||
editor.dispatch(Action::SetViewMode(ViewMode::ThreeD));
|
||||
assert_eq!(editor.app.ui.view_mode, ViewMode::ThreeD);
|
||||
let stats = editor.frames(6);
|
||||
assert_rendered(&editor.app, &stats, "platformer demo (3D)", size);
|
||||
|
||||
// 3D demo: the full garden scene rendered by the 3D viewport.
|
||||
let mut editor = HeadlessEditor::new(size, samples::garden_demo());
|
||||
editor.dispatch(Action::SetViewMode(ViewMode::ThreeD));
|
||||
assert_eq!(editor.app.ui.view_mode, ViewMode::ThreeD);
|
||||
let stats = editor.frames(8);
|
||||
assert_rendered(&editor.app, &stats, "garden demo (3D)", size);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Panels, palette and dialogs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn panel_toggles_palette_and_dialogs_render() {
|
||||
isolate_editor_config();
|
||||
|
||||
let mut editor = HeadlessEditor::new(egui::vec2(1280.0, 800.0), samples::platformer_demo());
|
||||
editor.frames(4);
|
||||
|
||||
// Assets drawer.
|
||||
assert!(
|
||||
editor.app.ui.show_assets,
|
||||
"assets drawer is visible by default"
|
||||
);
|
||||
editor.dispatch(Action::ToggleAssets);
|
||||
assert!(
|
||||
!editor.app.ui.show_assets,
|
||||
"ToggleAssets must hide the asset drawer"
|
||||
);
|
||||
editor.expect_frame("assets drawer hidden");
|
||||
editor.dispatch(Action::ToggleAssets);
|
||||
assert!(editor.app.ui.show_assets);
|
||||
editor.expect_frame("assets drawer visible");
|
||||
|
||||
// Left hierarchy panel.
|
||||
assert!(editor.app.ui.show_left_panel);
|
||||
editor.dispatch(Action::ToggleLeftPanel);
|
||||
assert!(!editor.app.ui.show_left_panel);
|
||||
editor.expect_frame("hierarchy panel hidden");
|
||||
editor.dispatch(Action::ToggleLeftPanel);
|
||||
assert!(editor.app.ui.show_left_panel);
|
||||
editor.expect_frame("hierarchy panel visible");
|
||||
|
||||
// Right inspector panel.
|
||||
assert!(editor.app.ui.show_right_panel);
|
||||
editor.dispatch(Action::ToggleRightPanel);
|
||||
assert!(!editor.app.ui.show_right_panel);
|
||||
editor.expect_frame("inspector panel hidden");
|
||||
editor.dispatch(Action::ToggleRightPanel);
|
||||
assert!(editor.app.ui.show_right_panel);
|
||||
editor.expect_frame("inspector panel visible");
|
||||
|
||||
// Grid and snapping flags (both render in the viewport).
|
||||
let grid = editor.app.ui.show_grid;
|
||||
editor.dispatch(Action::ToggleGrid);
|
||||
assert_eq!(
|
||||
editor.app.ui.show_grid, !grid,
|
||||
"ToggleGrid must flip the flag"
|
||||
);
|
||||
editor.expect_frame("grid off");
|
||||
editor.dispatch(Action::ToggleGrid);
|
||||
assert_eq!(editor.app.ui.show_grid, grid);
|
||||
editor.expect_frame("grid on");
|
||||
|
||||
let snap = editor.app.ui.snap_to_grid;
|
||||
editor.dispatch(Action::ToggleSnap);
|
||||
assert_eq!(
|
||||
editor.app.ui.snap_to_grid, !snap,
|
||||
"ToggleSnap must flip the flag"
|
||||
);
|
||||
editor.expect_frame("snap on");
|
||||
editor.dispatch(Action::ToggleSnap);
|
||||
assert_eq!(editor.app.ui.snap_to_grid, snap);
|
||||
|
||||
// Command palette: opened by action, closed by Escape.
|
||||
editor.dispatch(Action::OpenCommandPalette);
|
||||
assert!(editor.app.palette.open, "palette must open");
|
||||
editor.expect_frame("command palette open");
|
||||
let stats = editor.frame_with_events(vec![key_event(egui::Key::Escape)]);
|
||||
assert!(
|
||||
!editor.app.palette.open,
|
||||
"Escape must close the command palette"
|
||||
);
|
||||
assert!(stats.shapes > 0, "closing frame must still paint");
|
||||
editor.expect_frame("command palette closed");
|
||||
|
||||
// Save-as dialog.
|
||||
editor.dispatch(Action::OpenSaveAsDialog);
|
||||
assert!(editor.app.dialogs.save_as_open, "Save As must open");
|
||||
assert!(
|
||||
!editor.app.dialogs.save_as_path.is_empty(),
|
||||
"Save As pre-fills a path"
|
||||
);
|
||||
editor.expect_frame("save-as dialog open");
|
||||
editor.app.dialogs.save_as_open = false;
|
||||
editor.expect_frame("save-as dialog closed");
|
||||
|
||||
// Open-project dialog.
|
||||
editor.dispatch(Action::OpenProjectDialog);
|
||||
assert!(editor.app.dialogs.open_project_open);
|
||||
editor.expect_frame("open-project dialog open");
|
||||
editor.app.dialogs.open_project_open = false;
|
||||
editor.expect_frame("open-project dialog closed");
|
||||
|
||||
// Open-scene dialog.
|
||||
editor.dispatch(Action::OpenSceneDialog);
|
||||
assert!(editor.app.dialogs.open_scene_open);
|
||||
editor.expect_frame("open-scene dialog open");
|
||||
editor.app.dialogs.open_scene_open = false;
|
||||
editor.expect_frame("open-scene dialog closed");
|
||||
|
||||
// Export dialog (exercises code generation for the preview).
|
||||
editor.dispatch(Action::OpenExportDialog(bevy_forge_core::ExportFormat::Bsn));
|
||||
assert!(editor.app.export.dialog_open, "export dialog must open");
|
||||
assert!(
|
||||
editor.app.export.code.is_some() || editor.app.export.error.is_some(),
|
||||
"export dialog must have a preview or an error"
|
||||
);
|
||||
editor.expect_frame("export dialog open");
|
||||
editor.app.export.dialog_open = false;
|
||||
editor.expect_frame("export dialog closed");
|
||||
|
||||
// Shortcuts + about dialogs.
|
||||
editor.dispatch(Action::ShowShortcuts);
|
||||
assert!(editor.app.dialogs.shortcuts_open);
|
||||
editor.expect_frame("shortcuts dialog open");
|
||||
editor.app.dialogs.shortcuts_open = false;
|
||||
editor.expect_frame("shortcuts dialog closed");
|
||||
|
||||
editor.dispatch(Action::ShowAbout);
|
||||
assert!(editor.app.dialogs.about_open);
|
||||
editor.expect_frame("about dialog open");
|
||||
editor.app.dialogs.about_open = false;
|
||||
let stats = editor.expect_frame("all dialogs closed");
|
||||
|
||||
// Everything restored: the viewport is still laid out after all toggles.
|
||||
assert!(
|
||||
editor.app.ui.viewport_rect.is_positive(),
|
||||
"viewport rect must be laid out after all toggles: {:?}",
|
||||
editor.app.ui.viewport_rect
|
||||
);
|
||||
assert!(stats.shapes > 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn actions_undo_redo_duplicate_delete_and_flags() {
|
||||
isolate_editor_config();
|
||||
|
||||
let mut editor = HeadlessEditor::new(egui::vec2(1280.0, 800.0), samples::platformer_demo());
|
||||
editor.frames(2);
|
||||
|
||||
let base_entities = editor.app.session.doc().entities.len();
|
||||
assert!(base_entities > 0, "demo scene must have entities");
|
||||
|
||||
// -- Undo / redo after an edit -----------------------------------------
|
||||
let player = editor
|
||||
.app
|
||||
.session
|
||||
.doc()
|
||||
.find_by_name("Player")
|
||||
.expect("platformer demo contains a Player entity");
|
||||
editor.app.ui.selection.select_only(player);
|
||||
editor.app.session.edit("Rename Player", |doc| {
|
||||
doc.rename(player, "Hero");
|
||||
});
|
||||
assert!(editor.app.session.can_undo());
|
||||
assert!(editor.app.session.is_dirty());
|
||||
assert_eq!(editor.app.session.undo_label(), Some("Rename Player"));
|
||||
|
||||
editor.dispatch(Action::Undo);
|
||||
assert!(
|
||||
editor.app.session.doc().find_by_name("Player").is_some(),
|
||||
"Undo must restore the original name"
|
||||
);
|
||||
assert!(
|
||||
editor.app.ui.selection.contains(player),
|
||||
"selection survives undo when the entity still exists"
|
||||
);
|
||||
assert!(editor.app.session.can_redo());
|
||||
assert_eq!(editor.app.session.redo_label(), Some("Rename Player"));
|
||||
editor.expect_frame("after undo");
|
||||
|
||||
editor.dispatch(Action::Redo);
|
||||
assert!(
|
||||
editor.app.session.doc().find_by_name("Hero").is_some(),
|
||||
"Redo must reapply the renamed entity"
|
||||
);
|
||||
editor.expect_frame("after redo");
|
||||
|
||||
// -- View mode flags ----------------------------------------------------
|
||||
editor.dispatch(Action::SetViewMode(ViewMode::ThreeD));
|
||||
assert_eq!(editor.app.ui.view_mode, ViewMode::ThreeD);
|
||||
editor.expect_frame("3D view mode");
|
||||
assert!(
|
||||
editor.app.ui.viewport_rect.is_positive(),
|
||||
"3D viewport rect must be laid out: {:?}",
|
||||
editor.app.ui.viewport_rect
|
||||
);
|
||||
editor.dispatch(Action::SetViewMode(ViewMode::TwoD));
|
||||
assert_eq!(editor.app.ui.view_mode, ViewMode::TwoD);
|
||||
editor.expect_frame("2D view mode");
|
||||
|
||||
// -- Grid toggle --------------------------------------------------------
|
||||
let grid = editor.app.ui.show_grid;
|
||||
editor.dispatch(Action::ToggleGrid);
|
||||
assert_eq!(editor.app.ui.show_grid, !grid);
|
||||
editor.dispatch(Action::ToggleGrid);
|
||||
assert_eq!(editor.app.ui.show_grid, grid);
|
||||
|
||||
// -- Duplicate (deep copy of Player + child Hat) ------------------------
|
||||
assert_eq!(editor.app.ui.selection.len(), 1);
|
||||
editor.dispatch(Action::DuplicateSelection);
|
||||
assert_eq!(
|
||||
editor.app.session.doc().entities.len(),
|
||||
base_entities + 2,
|
||||
"duplicating Player must also duplicate its child Hat"
|
||||
);
|
||||
let after_duplicate = editor.app.session.doc().entities.len();
|
||||
let selection_after_duplicate = editor.app.ui.selection.len();
|
||||
assert_eq!(
|
||||
editor.app.ui.selection.len(),
|
||||
1,
|
||||
"the duplicated subtree root becomes the selection"
|
||||
);
|
||||
let duplicate = editor
|
||||
.app
|
||||
.ui
|
||||
.selection
|
||||
.primary()
|
||||
.expect("duplicate becomes primary");
|
||||
assert_ne!(duplicate, player);
|
||||
assert!(editor.app.session.doc().entity(duplicate).is_some());
|
||||
assert_eq!(editor.app.session.undo_label(), Some("Duplicate entities"));
|
||||
editor.expect_frame("after duplicate");
|
||||
|
||||
// -- Delete selection ---------------------------------------------------
|
||||
editor.dispatch(Action::DeleteSelection);
|
||||
assert_eq!(
|
||||
editor.app.session.doc().entities.len(),
|
||||
base_entities,
|
||||
"deleting the duplicate removes the copied subtree"
|
||||
);
|
||||
assert!(editor.app.ui.selection.is_empty());
|
||||
assert_eq!(editor.app.session.undo_label(), Some("Delete entities"));
|
||||
editor.expect_frame("after delete");
|
||||
let after_delete = editor.app.session.doc().entities.len();
|
||||
|
||||
editor.dispatch(Action::Undo);
|
||||
assert_eq!(
|
||||
editor.app.session.doc().entities.len(),
|
||||
base_entities + 2,
|
||||
"undo restores the deleted duplicate subtree"
|
||||
);
|
||||
let after_undo_delete = editor.app.session.doc().entities.len();
|
||||
editor.dispatch(Action::Redo);
|
||||
assert_eq!(
|
||||
editor.app.session.doc().entities.len(),
|
||||
base_entities,
|
||||
"redo deletes it again"
|
||||
);
|
||||
eprintln!(
|
||||
"actions: base_entities={base_entities}, after_duplicate={after_duplicate} \
|
||||
selection_after_duplicate={selection_after_duplicate}, after_delete={after_delete}, \
|
||||
after_undo_delete={after_undo_delete}, after_redo_delete={}",
|
||||
editor.app.session.doc().entities.len()
|
||||
);
|
||||
|
||||
// -- Palette command path ----------------------------------------------
|
||||
editor.dispatch(Action::OpenCommandPalette);
|
||||
assert!(editor.app.palette.open);
|
||||
editor.expect_frame("palette via action");
|
||||
editor.frame_with_events(vec![key_event(egui::Key::Escape)]);
|
||||
assert!(!editor.app.palette.open);
|
||||
|
||||
// -- Save without a project must ask for a path -------------------------
|
||||
editor.dispatch(Action::Save);
|
||||
assert!(
|
||||
editor.app.dialogs.save_as_open,
|
||||
"Save without a project must open the Save As dialog"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a minimal Bevy project on disk and return its root.
|
||||
fn write_temp_project(dir: &Path) -> PathBuf {
|
||||
std::fs::write(
|
||||
dir.join("Cargo.toml"),
|
||||
"[package]\n\
|
||||
name = \"forge-test\"\n\
|
||||
version = \"0.1.0\"\n\
|
||||
edition = \"2021\"\n\
|
||||
\n\
|
||||
[dependencies]\n\
|
||||
bevy = \"0.20.0\"\n",
|
||||
)
|
||||
.expect("write Cargo.toml");
|
||||
std::fs::create_dir_all(dir.join("assets")).expect("create assets dir");
|
||||
std::fs::create_dir_all(dir.join("scenes")).expect("create scenes dir");
|
||||
dir.to_path_buf()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_save_load_round_trip_and_corrupt_file() {
|
||||
isolate_editor_config();
|
||||
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let root = write_temp_project(temp.path());
|
||||
|
||||
// First session: open the project, author a scene, save it.
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = ForgeApp::new_for_test(&ctx, samples::empty_scene());
|
||||
assert!(
|
||||
app.open_project(&root),
|
||||
"open_project must accept a Bevy project"
|
||||
);
|
||||
assert_eq!(app.screen, Screen::Editor);
|
||||
let project = app.project.clone().expect("project is open");
|
||||
assert_eq!(project.name, "forge-test");
|
||||
assert_eq!(app.assets.len(), 0, "project assets dir is empty");
|
||||
|
||||
app.session.edit("Build round-trip scene", |doc| {
|
||||
doc.name = "Round Trip".into();
|
||||
let level = doc.add_entity("Level", None);
|
||||
let level_node = doc.entity_mut(level).expect("level exists");
|
||||
level_node.transform = Transform::from_xyz(1.5, -2.25, 3.75);
|
||||
|
||||
let crate_id = doc.add_entity("Crate", Some(level));
|
||||
let crate_node = doc.entity_mut(crate_id).expect("crate exists");
|
||||
crate_node.transform = Transform {
|
||||
translation: Vec3::new(0.5, 1.0, -0.5),
|
||||
rotation: Vec3::new(0.0, 45.0, 0.0),
|
||||
scale: Vec3::new(2.0, 2.0, 2.0),
|
||||
};
|
||||
doc.add_component(crate_id, ComponentData::Sprite(SpriteData::default()));
|
||||
let camera = doc.add_entity("Camera", None);
|
||||
doc.add_component(camera, ComponentData::Camera2d);
|
||||
});
|
||||
assert!(app.session.is_dirty());
|
||||
|
||||
let scene_path = project.scenes_dir().join("test.forge.ron");
|
||||
app.save_scene_to(&scene_path);
|
||||
assert!(
|
||||
scene_path.is_file(),
|
||||
"save_scene_to must write {}",
|
||||
scene_path.display()
|
||||
);
|
||||
assert!(!app.session.is_dirty(), "saving must clear the dirty flag");
|
||||
assert_eq!(
|
||||
app.current_scene_path.as_deref(),
|
||||
Some(scene_path.as_path()),
|
||||
"the saved path becomes the current scene"
|
||||
);
|
||||
assert_eq!(
|
||||
app.session.doc().check_integrity(),
|
||||
Vec::<String>::new(),
|
||||
"saved scene must be structurally valid"
|
||||
);
|
||||
eprintln!(
|
||||
"persistence: saved {} ({} entities, dirty={})",
|
||||
scene_path.display(),
|
||||
app.session.doc().entities.len(),
|
||||
app.session.is_dirty()
|
||||
);
|
||||
|
||||
let saved_text = std::fs::read_to_string(&scene_path).expect("read saved scene");
|
||||
assert!(
|
||||
saved_text.contains("Round Trip"),
|
||||
"scene file must contain the scene name"
|
||||
);
|
||||
let loaded = project.load_scene(&scene_path).expect("reload scene");
|
||||
assert_eq!(loaded.name, "Round Trip");
|
||||
assert_eq!(loaded.entities.len(), 3);
|
||||
|
||||
// Second session: reopen the project and the scene, compare the document.
|
||||
let ctx2 = egui::Context::default();
|
||||
let mut app2 = ForgeApp::new_for_test(&ctx2, samples::empty_scene());
|
||||
assert!(app2.open_project(&root), "reopen project");
|
||||
assert!(
|
||||
app2.current_scene_path.is_some(),
|
||||
"opening a project with a scene must auto-open it"
|
||||
);
|
||||
assert!(app2.open_scene(&scene_path), "explicitly reopen the scene");
|
||||
assert_eq!(app2.screen, Screen::Editor);
|
||||
assert!(!app2.session.is_dirty());
|
||||
|
||||
let doc2 = app2.session.doc();
|
||||
assert_eq!(doc2.name, "Round Trip");
|
||||
assert_eq!(doc2.entities.len(), 3);
|
||||
assert_eq!(doc2.check_integrity(), Vec::<String>::new());
|
||||
|
||||
let level = doc2.find_by_name("Level").expect("Level survives reload");
|
||||
let level_node = doc2.entity(level).expect("level node");
|
||||
assert_eq!(
|
||||
level_node.transform.translation,
|
||||
Vec3::new(1.5, -2.25, 3.75)
|
||||
);
|
||||
|
||||
let crate_id = doc2.find_by_name("Crate").expect("Crate survives reload");
|
||||
let crate_node = doc2.entity(crate_id).expect("crate node");
|
||||
assert_eq!(crate_node.parent, Some(level));
|
||||
assert_eq!(crate_node.transform.translation, Vec3::new(0.5, 1.0, -0.5));
|
||||
assert_eq!(crate_node.transform.rotation, Vec3::new(0.0, 45.0, 0.0));
|
||||
assert_eq!(crate_node.transform.scale, Vec3::new(2.0, 2.0, 2.0));
|
||||
assert!(matches!(
|
||||
crate_node.component("sprite"),
|
||||
Some(ComponentData::Sprite(_))
|
||||
));
|
||||
|
||||
// Corrupt scene files must fail gracefully and leave the session untouched.
|
||||
let corrupt = root.join("scenes").join("corrupt.forge.ron");
|
||||
std::fs::write(&corrupt, "this is not a SceneDoc {{{").expect("write corrupt scene");
|
||||
app2.toasts.clear();
|
||||
let before_name = app2.session.doc().name.clone();
|
||||
let before_entities = app2.session.doc().entities.len();
|
||||
assert!(
|
||||
!app2.open_scene(&corrupt),
|
||||
"opening a corrupt scene must report failure"
|
||||
);
|
||||
assert_eq!(
|
||||
app2.session.doc().name,
|
||||
before_name,
|
||||
"session must be unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
app2.session.doc().entities.len(),
|
||||
before_entities,
|
||||
"session entity count must be unchanged"
|
||||
);
|
||||
assert_eq!(app2.screen, Screen::Editor, "the editor stays open");
|
||||
assert!(
|
||||
!app2.toasts.is_empty(),
|
||||
"a load failure must surface an error toast"
|
||||
);
|
||||
|
||||
// Missing project / scene paths do not panic either.
|
||||
let mut bare = ForgeApp::new_for_test(&ctx2, samples::empty_scene());
|
||||
assert!(!bare.open_scene(&root.join("scenes/nope.forge.ron")));
|
||||
assert!(!bare.toasts.is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Close guard / quit flags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn close_guard_requests_confirmation_and_quit_flags() {
|
||||
isolate_editor_config();
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
// A clean editor quits immediately.
|
||||
let mut clean = ForgeApp::new_for_test(&ctx, samples::starter_2d());
|
||||
let mut frame = 0u64;
|
||||
run_one(&ctx, &mut clean, &mut frame);
|
||||
assert!(!clean.session.is_dirty());
|
||||
clean.dispatch_action(&ctx, Action::RequestQuit);
|
||||
assert!(clean.quit_now, "a clean editor quits without prompting");
|
||||
assert!(
|
||||
!clean.dialogs.confirm_quit_open,
|
||||
"no confirmation for a clean document"
|
||||
);
|
||||
|
||||
// A dirty document asks for confirmation instead of quitting.
|
||||
let mut dirty = ForgeApp::new_for_test(&ctx, samples::starter_2d());
|
||||
dirty.session.edit("Add entity", |doc| {
|
||||
doc.add_entity("Extra", None);
|
||||
});
|
||||
assert!(dirty.session.is_dirty());
|
||||
dirty.dispatch_action(&ctx, Action::RequestQuit);
|
||||
assert!(
|
||||
dirty.dialogs.confirm_quit_open,
|
||||
"RequestQuit on a dirty document must open the confirmation dialog"
|
||||
);
|
||||
assert!(!dirty.quit_now, "the app must not quit before confirmation");
|
||||
assert!(!dirty.allow_close, "the close guard must stay armed");
|
||||
|
||||
// The confirmation dialog renders without closing the context.
|
||||
let mut frame = 2u64;
|
||||
let stats = run_one(&ctx, &mut dirty, &mut frame);
|
||||
assert!(stats.shapes > 0, "the unsaved-changes dialog must paint");
|
||||
|
||||
// Confirming via QuitNow sets both flags; the test ends without a window.
|
||||
dirty.dispatch_action(&ctx, Action::QuitNow);
|
||||
assert!(dirty.allow_close, "QuitNow must allow the close");
|
||||
assert!(dirty.quit_now, "QuitNow must request the quit");
|
||||
let stats = run_one(&ctx, &mut dirty, &mut frame);
|
||||
assert!(
|
||||
stats.shapes > 0,
|
||||
"the app keeps painting until eframe closes"
|
||||
);
|
||||
}
|
||||
|
||||
/// One deterministic headless frame for a bare `ForgeApp` (close-guard test).
|
||||
fn run_one(ctx: &egui::Context, app: &mut ForgeApp, frame: &mut u64) -> FrameStats {
|
||||
let mut raw = egui::RawInput::default();
|
||||
raw.screen_rect = Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(1200.0, 760.0),
|
||||
));
|
||||
raw.viewports
|
||||
.entry(egui::ViewportId::ROOT)
|
||||
.or_default()
|
||||
.native_pixels_per_point = Some(1.0);
|
||||
*frame += 1;
|
||||
raw.time = Some(*frame as f64 / 60.0);
|
||||
raw.predicted_dt = 1.0 / 60.0;
|
||||
let output = ctx.run(raw, |ctx| app.ui(ctx));
|
||||
let shapes = output.shapes.len();
|
||||
let clipped = ctx.tessellate(output.shapes, output.pixels_per_point);
|
||||
let mut meshes = 0usize;
|
||||
let mut vertices = 0usize;
|
||||
for primitive in &clipped {
|
||||
if let egui::epaint::Primitive::Mesh(mesh) = &primitive.primitive {
|
||||
meshes += 1;
|
||||
vertices += mesh.vertices.len();
|
||||
}
|
||||
}
|
||||
FrameStats {
|
||||
shapes,
|
||||
clipped_primitives: clipped.len(),
|
||||
meshes,
|
||||
vertices,
|
||||
used_rect: ctx.used_rect(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user