Skip to main content

pite_editor/app/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2mod 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    /// Previous frame's galley row tops (galley space) for the code editor:
78    /// pixel-snapped, so the gutter tiles exactly instead of drifting on an
79    /// assumed pitch. Empty until the first paint.
80    pub(super) code_row_tops: Vec<f32>,
81    /// `galley_pos.y - widget_top.y`: scroll-invariant content offset of the
82    /// galley origin, paired with `code_row_tops`.
83    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    /// Pointer was over the viewport last frame: game input goes to the
97    /// running scene only then, so editing text and shortcuts keep priority.
98    pub(super) viewport_hovered: bool,
99    pub(super) viewport_rect: Option<egui::Rect>,
100    /// Bound to the project's window size: the viewport must frame the scene the
101    /// way the game window does.
102    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            // Everything lives inside the MenuBar scope: MenuBar claims the
268            // full row width, so siblings after it in an outer horizontal
269            // row get zero space and vanish.
270            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                // Transport controls pinned to the right end, Godot-style:
334                // the same actions as the Run menu, always visible.
335                // Right-to-left places the first widget rightmost, so Stop
336                // comes first to keep the Play, Stop order.
337                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}