1mod assets;
3mod code;
4mod inspector;
5mod scene;
6mod settings;
7#[cfg(test)]
8mod tests;
9mod tree;
10mod viewport;
11
12use std::collections::HashSet;
13use std::path::{Path, PathBuf};
14use std::sync::mpsc::Receiver;
15use std::time::Instant;
16
17use anyhow::Result;
18use eframe::egui;
19use pite_core::NodeId;
20use pite_render::OffscreenRenderer;
21use pite_runtime::GameSession;
22
23use crate::icons::{self, Icon};
24use crate::lsp::{self, LspClient, LspOutcome};
25use crate::ops;
26
27use settings::SettingsDraft;
28
29#[derive(Clone, Copy, PartialEq)]
30pub(crate) enum Tab {
31 Viewport,
32 Code,
33}
34
35pub fn launch(scene: &Path) -> Result<()> {
36 let app = EditorApp::new(scene)?;
37 let options = eframe::NativeOptions {
38 viewport: egui::ViewportBuilder::default()
39 .with_inner_size([1280.0, 800.0])
40 .with_maximized(true)
41 .with_icon(
42 eframe::icon_data::from_png_bytes(pite_runtime::DEFAULT_ICON_PNG)
43 .expect("default window icon must be a valid PNG"),
44 ),
45 ..Default::default()
46 };
47 eframe::run_native(
48 "Pite Editor",
49 options,
50 Box::new(|cc| {
51 crate::theme::apply(&cc.egui_ctx);
52 Ok(Box::new(app) as Box<dyn eframe::App>)
53 }),
54 )
55 .map_err(|e| anyhow::anyhow!("editor failed: {e}"))?;
56 Ok(())
57}
58
59pub struct EditorApp {
60 pub(super) session: GameSession,
61 pub(super) scene_path: PathBuf,
62 pub(super) project_dir: Option<PathBuf>,
63 pub(super) selected: Option<String>,
64 pub(super) console: Vec<String>,
65 pub(super) seen_errors: HashSet<String>,
66 pub(super) code_file: Option<PathBuf>,
67 pub(super) code_text: String,
68 pub(super) lsp: Option<LspClient>,
69 pub(super) lsp_starter: Option<Receiver<LspOutcome>>,
70 pub(super) lsp_status: String,
71 pub(super) lsp_hint: String,
72 pub(super) lsp_completions: Vec<lsp::CompletionItem>,
73 pub(super) lsp_completion_open: bool,
74 pub(super) lsp_completion_offset: usize,
75 pub(super) lsp_completion_pending: Vec<(i64, usize)>,
76 pub(super) lsp_completion_selected: usize,
77 pub(super) code_row_tops: Vec<f32>,
81 pub(super) code_galley_dy: f32,
84 pub(super) playing: bool,
85 pub(super) last_frame: Instant,
86 pub(super) add_open: bool,
87 pub(super) add_type: String,
88 pub(super) add_name: String,
89 pub(super) settings: Option<SettingsDraft>,
90 pub(super) open_nodes: HashSet<String>,
91 pub(super) open_seen: HashSet<String>,
92 pub(super) viewport: Option<OffscreenRenderer>,
93 pub(super) viewport_failed: bool,
94 pub(super) viewport_tex: Option<egui::TextureHandle>,
95 pub(super) viewport_dirty: bool,
96 pub(super) viewport_hovered: bool,
99 pub(super) viewport_rect: Option<egui::Rect>,
100 pub(super) view_size: (u32, u32),
103 pub(super) grip_hover: Option<String>,
104 pub(super) tab: Tab,
105 pub(super) recent: Vec<PathBuf>,
106 pub(super) about_open: bool,
107}
108
109impl EditorApp {
110 pub fn new(scene: &Path) -> Result<Self> {
111 let mut session = GameSession::open(scene, false)?;
112 session.set_pause_on_error(true);
113 let project_dir = pite_project::find_project_root(scene);
114 let view_size = project_dir
115 .as_deref()
116 .and_then(|dir| pite_project::load_manifest(dir).ok())
117 .map_or_else(
118 || {
119 let d = pite_project::ProjectMeta::default();
120 (d.window_width, d.window_height)
121 },
122 |m| (m.project.window_width, m.project.window_height),
123 );
124 Ok(Self {
125 session,
126 scene_path: scene.to_path_buf(),
127 project_dir,
128 selected: None,
129 console: vec!["Pite editor ready.".to_string()],
130 seen_errors: HashSet::new(),
131 code_file: None,
132 code_text: String::new(),
133 lsp: None,
134 lsp_starter: None,
135 lsp_status: "lsp: off".to_string(),
136 lsp_hint: String::new(),
137 lsp_completions: Vec::new(),
138 lsp_completion_open: false,
139 lsp_completion_offset: 0,
140 lsp_completion_pending: Vec::new(),
141 lsp_completion_selected: 0,
142 code_row_tops: Vec::new(),
143 code_galley_dy: 0.0,
144 playing: false,
145 last_frame: Instant::now(),
146 add_open: false,
147 add_type: "Node2D".to_string(),
148 add_name: "NewNode".to_string(),
149 settings: None,
150 open_nodes: HashSet::new(),
151 open_seen: HashSet::new(),
152 viewport: None,
153 viewport_failed: false,
154 viewport_tex: None,
155 viewport_hovered: false,
156 viewport_rect: None,
157 view_size,
158 viewport_dirty: true,
159 grip_hover: None,
160 tab: Tab::Viewport,
161 recent: Self::load_recent(),
162 about_open: false,
163 })
164 }
165
166 pub(super) fn log(&mut self, line: impl Into<String>) {
167 self.console.push(line.into());
168 if self.console.len() > 500 {
169 self.console.drain(..self.console.len() - 500);
170 }
171 }
172
173 pub(super) fn drain_script_errors(&mut self) {
174 if let Some(err) = self.session.take_last_error() {
175 self.playing = false;
176 let log_msg = if !err.traceback.is_empty() {
177 format!("{}\n{}", err.message, err.traceback)
178 } else {
179 err.message.clone()
180 };
181 self.log(log_msg);
182 if let Some(file) = &err.file {
183 let p = Path::new(file);
184 if p.exists() {
185 self.open_asset(p);
186 }
187 }
188 }
189 for err in self.session.errors() {
190 if self.seen_errors.insert(err.clone()) {
191 self.log(err);
192 }
193 }
194 }
195
196 pub(super) fn is_open(&mut self, id: &str) -> bool {
197 if !self.open_seen.contains(id) {
198 self.open_seen.insert(id.to_string());
199 self.open_nodes.insert(id.to_string());
200 return true;
201 }
202 self.open_nodes.contains(id)
203 }
204
205 pub(super) fn debug_info(&mut self, ctx: &egui::Context) {
206 let lines = [
207 format!("pite {}", env!("CARGO_PKG_VERSION")),
208 format!("scene {}", self.scene_path.display()),
209 format!(
210 "project {}",
211 self.project_dir
212 .as_deref()
213 .map(|p| p.display().to_string())
214 .unwrap_or_else(|| "-".to_string())
215 ),
216 format!("lsp {}", self.lsp_status),
217 ];
218 for line in &lines {
219 self.log(line.clone());
220 }
221 ctx.copy_text(lines.join("\n"));
222 self.log("debug info copied to clipboard.".to_string());
223 }
224}
225
226impl eframe::App for EditorApp {
227 fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
228 let save_shortcut = ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S));
229 if save_shortcut {
230 self.save_scene();
231 }
232 let cmd = |key: egui::Key| {
233 ctx.input(|i| i.modifiers.command && !i.modifiers.shift && i.key_pressed(key))
234 };
235 let cmd_shift = |key: egui::Key| {
236 ctx.input(|i| i.modifiers.command && i.modifiers.shift && i.key_pressed(key))
237 };
238 if cmd(egui::Key::N) {
239 self.new_scene_via_dialog();
240 }
241 if cmd(egui::Key::O) {
242 self.open_scene_via_dialog();
243 }
244 if cmd_shift(egui::Key::S) {
245 self.save_scene_as_via_dialog();
246 }
247 if cmd(egui::Key::Q) {
248 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
249 }
250 if self.playing {
251 let delta = self.last_frame.elapsed().as_secs_f64().min(0.1);
252 self.last_frame = Instant::now();
253 self.pump_game_input(ctx);
254 self.session.frame(delta);
255 self.drain_script_errors();
256 ctx.request_repaint();
257 } else if self.session.poll_watch() {
258 self.viewport_dirty = true;
259 ctx.request_repaint();
260 }
261 }
262
263 fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
264 let ctx = ui.ctx().clone();
265
266 egui::Panel::top("menu").show(ui, |ui| {
267 egui::MenuBar::new().ui(ui, |ui| {
271 ui.menu_button("Scene", |ui| {
272 if ui.button("New Scene Ctrl+N").clicked() {
273 self.new_scene_via_dialog();
274 ui.close();
275 }
276 if ui.button("Open Scene… Ctrl+O").clicked() {
277 self.open_scene_via_dialog();
278 ui.close();
279 }
280 if self.recent.is_empty() {
281 ui.add_enabled(false, egui::Button::new("Open Recent"));
282 } else {
283 ui.menu_button("Open Recent", |ui| {
284 let recent = self.recent.clone();
285 for path in &recent {
286 if ui.button(path.display().to_string()).clicked() {
287 let path = path.clone();
288 self.open_scene_file(&path);
289 ui.close();
290 }
291 }
292 });
293 }
294 ui.separator();
295 if ui.button("Save Scene Ctrl+S").clicked() {
296 self.save_scene();
297 ui.close();
298 }
299 if ui.button("Save Scene As… Ctrl+Shift+S").clicked() {
300 self.save_scene_as_via_dialog();
301 ui.close();
302 }
303 ui.separator();
304 if ui.button("Quit Ctrl+Q").clicked() {
305 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
306 }
307 });
308 ui.menu_button("Project", |ui| {
309 if ui.button("Project Settings…").clicked() {
310 self.open_settings();
311 ui.close();
312 }
313 });
314 ui.menu_button("Help", |ui| {
315 if ui.button("Repository").clicked() {
316 ctx.open_url(egui::OpenUrl::new_tab("https://github.com/piteengine/pite"));
317 }
318 if ui.button("Issues").clicked() {
319 ctx.open_url(egui::OpenUrl::new_tab(
320 "https://github.com/piteengine/pite/issues",
321 ));
322 }
323 ui.separator();
324 if ui.button("About Pite").clicked() {
325 self.about_open = true;
326 ui.close();
327 }
328 if ui.button("Copy Debug Info").clicked() {
329 self.debug_info(ui.ctx());
330 ui.close();
331 }
332 });
333 let (play_icon, play_tip) = if self.playing {
338 (Icon::Pause, "Pause the scene")
339 } else {
340 (Icon::Play, "Run the scene")
341 };
342 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
343 if icons::icon_button(ui, Icon::Stop, "Stop and reset the scene").clicked() {
344 self.stop();
345 }
346 if icons::icon_button(ui, play_icon, play_tip).clicked() {
347 self.toggle_play();
348 }
349 });
350 });
351 });
352
353 if self.about_open {
354 egui::Window::new("About Pite")
355 .open(&mut self.about_open)
356 .show(ui, |ui| {
357 ui.label(format!("Pite game engine {}", env!("CARGO_PKG_VERSION")));
358 ui.label("Scene-tree 2D engine: Rust core, embedded Python, egui editor.");
359 ui.horizontal(|ui| {
360 if ui.button("Repository").clicked() {
361 ui.ctx().open_url(egui::OpenUrl::new_tab(
362 "https://github.com/piteengine/pite",
363 ));
364 }
365 if ui.button("Issues").clicked() {
366 ui.ctx().open_url(egui::OpenUrl::new_tab(
367 "https://github.com/piteengine/pite/issues",
368 ));
369 }
370 });
371 });
372 }
373
374 egui::Panel::left("left")
375 .default_size(240.0)
376 .min_size(240.0)
377 .max_size(360.0)
378 .resizable(true)
379 .show(ui, |ui| {
380 ui.horizontal(|ui| {
381 ui.heading("Scene");
382 if icons::icon_button(ui, Icon::Add, "Add a child node under the selection")
383 .clicked()
384 {
385 self.add_open = true;
386 }
387 });
388 if self.add_open {
389 self.show_add_child(ui);
390 }
391 let tree_h = (ui.available_height() * 0.52).max(120.0);
392 egui::ScrollArea::vertical()
393 .id_salt("tree")
394 .max_height(tree_h)
395 .show(ui, |ui| {
396 let root = self.session.host().with_tree(|t| t.root().cloned());
397 if let Some(root) = root {
398 self.show_node(ui, &root);
399 }
400 let avail = ui.available_size();
401 if avail.y > 8.0 {
402 let (rect, resp) = ui.allocate_exact_size(
403 egui::vec2(avail.x.max(1.0), avail.y),
404 egui::Sense::hover(),
405 );
406 if egui::DragAndDrop::has_any_payload(ui.ctx())
407 && resp.contains_pointer()
408 {
409 ui.painter().rect_stroke(
410 rect,
411 3.0,
412 egui::Stroke::new(1.5_f32, crate::theme::FAINT),
413 egui::StrokeKind::Middle,
414 );
415 }
416 if let Some(dragged) = resp.dnd_release_payload::<String>() {
417 if let Some(root) =
418 self.session.host().with_tree(|t| t.root().cloned())
419 {
420 let dragged_id = NodeId::from(dragged.as_str());
421 let result = self.session.host().with_tree_mut(|t| {
422 let len = t.children_of(&root).len();
423 ops::place_node(t, &dragged_id, &root, len)
424 });
425 match result {
426 Ok(()) => {
427 self.viewport_dirty = true;
428 self.log(format!("moved {dragged_id}."));
429 }
430 Err(e) => self.log(format!("move failed: {e:#}")),
431 }
432 }
433 }
434 }
435 });
436 ui.separator();
437 ui.heading("Assets");
438 let rels = self.asset_rel_paths();
439 let refs: Vec<&str> = rels.iter().map(String::as_str).collect();
440 let root = self
441 .project_dir
442 .clone()
443 .unwrap_or_else(|| PathBuf::from("."));
444 egui::ScrollArea::new([true, true])
445 .id_salt("assets")
446 .auto_shrink([false, false])
447 .show(ui, |ui| {
448 self.show_asset_dir(ui, &root, "", &refs);
449 });
450 });
451
452 egui::Panel::right("inspector")
453 .default_size(240.0)
454 .min_size(240.0)
455 .max_size(360.0)
456 .resizable(true)
457 .show(ui, |ui| {
458 egui::ScrollArea::vertical()
459 .id_salt("inspector")
460 .auto_shrink([false, false])
461 .show(ui, |ui| {
462 ui.heading("Inspector");
463 match self.selected.clone() {
464 Some(id) => self.show_inspector(ui, &id),
465 None => {
466 ui.label("Select a node.");
467 }
468 }
469 })
470 });
471
472 egui::Panel::bottom("console")
473 .default_size(120.0)
474 .min_size(120.0)
475 .max_size(240.0)
476 .resizable(true)
477 .show(ui, |ui| {
478 ui.heading("Console");
479 egui::ScrollArea::vertical()
480 .id_salt("console")
481 .auto_shrink([false, false])
482 .stick_to_bottom(true)
483 .show(ui, |ui| {
484 for line in &self.console {
485 ui.monospace(line);
486 }
487 });
488 });
489
490 egui::CentralPanel::default().show(ui, |ui| {
491 ui.horizontal(|ui| {
492 ui.selectable_value(&mut self.tab, Tab::Viewport, "Viewport");
493 ui.selectable_value(&mut self.tab, Tab::Code, "Code");
494 });
495 ui.separator();
496 ui.horizontal(|ui| match self.tab {
497 Tab::Viewport => {
498 ui.label(self.scene_path.to_string_lossy().as_ref());
499 if icons::icon_button(ui, Icon::Save, "Save scene (Ctrl+S)").clicked() {
500 self.save_scene();
501 }
502 }
503 Tab::Code => {
504 if let Some(file) = self.code_file.clone() {
505 ui.label(file.to_string_lossy().as_ref());
506 if icons::icon_button(ui, Icon::Save, "Save script (Ctrl+S)").clicked() {
507 self.save_code();
508 }
509 } else {
510 ui.label("Open a .py file from Assets.");
511 }
512 }
513 });
514 ui.separator();
515 match self.tab {
516 Tab::Viewport => self.show_viewport(ui),
517 Tab::Code => self.show_code(ui),
518 }
519 });
520
521 self.show_settings(&ctx);
522 }
523}