Skip to main content

concinnity_dev/editor/
mod.rs

1// src/editor/mod.rs
2//
3// The `cn editor` run path. Like `cn debug`, the editor compiles world.jsonl in
4// memory: the session boots from the authored entries, not from the blobs a
5// build left under the state tree, so what opens is always what the world file
6// says. It overlays an injected editor HUD and persists edits by writing
7// world.jsonl. The blobs are refreshed only by an explicit build (`cn build`,
8// or the console's cook command). An optional debug port reuses the existing
9// debug server so `cn debug send` / `screenshot` can inspect a session.
10//
11// `cn editor -f <world>` opens that world. With no world named the session
12// opens an empty scene under the Worlds panel (`editor/worlds.rs`), which
13// lists the project's worlds and opens, creates, or deletes one.
14
15mod asset_list;
16mod asset_tree;
17mod axes;
18mod behavior;
19mod behavior_chart;
20mod behavior_panel;
21mod billboards;
22mod character_shape;
23mod character_shape_panel;
24mod console;
25mod console_panel;
26mod content_panel;
27mod create_menu;
28mod cursor;
29mod file_dialog;
30mod filter;
31mod form;
32mod form_panel;
33mod framing;
34mod gizmo;
35mod gltf_export;
36mod group_transform;
37mod health;
38mod health_panel;
39mod highlight;
40mod history;
41mod hook;
42mod hud;
43mod import_panel;
44mod inject;
45mod lighting;
46mod lighting_panel;
47mod list_panel;
48mod live;
49mod marquee;
50mod modal;
51pub(crate) mod notify;
52mod orbit;
53mod outlines;
54mod overrides;
55mod palette;
56mod palette_panel;
57mod panel;
58mod preview;
59mod registry;
60mod resize;
61mod select_related;
62mod selection;
63mod session_store;
64mod sim;
65mod snap;
66mod story;
67mod story_panel;
68mod template_panel;
69mod templates;
70mod theme;
71mod thumbs;
72mod toast_overlay;
73mod variables;
74mod variables_panel;
75mod view;
76mod view_menu;
77mod visibility;
78mod widget;
79mod widget_slider;
80mod world_files;
81mod worlds;
82
83use crate::app::state::App;
84use crate::debug_hook::DebugHook;
85use crate::ecs::World;
86use crate::world::WORLD_JSONL;
87use concinnity_engine::shutdown::ShutdownToken;
88use hook::EditorHook;
89
90// A minimal renderable world: a lone GraphicsConfig, which the cook pipeline
91// expands into a Window plus default shaders. Booted in memory when there is
92// nothing renderable to load (no world file, or an authored world with no
93// render marker), so the editor still opens a window over a black scene. Named
94// distinctively so it never collides with an authored asset, and it is never
95// added to the authored entry list, so it can never leak into the user's
96// world.jsonl on SAVE.
97const SEED_GRAPHICS_CONFIG: &str =
98    "{\"name\":\"editor_default_gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}";
99
100/// Editor entry point (`cn editor`). Compiles the authored world in memory,
101/// injects the editor HUD, and runs the world loop driven by the editor hook
102/// (plus the debug server when a port is given).
103pub fn run_editor(json_path: Option<&str>, debug_port: Option<u16>) -> std::io::Result<()> {
104    // Instead of the engine's plain `init_logging`: the same stderr formatter
105    // plus a layer mirroring this crate's events into the Console panel's log.
106    // The sink exists first so even boot-time errors reach the panel.
107    let console_sink = console::ConsoleSink::default();
108    console::install_tracing(console_sink.clone());
109
110    // Resolve the edit target -- the world.jsonl where readable names live and
111    // where SAVE writes -- and whether the session opens on the Worlds panel
112    // instead of a world.
113    let (world_path, pick_a_world) = resolve_edit_target(json_path);
114
115    // Hand the resolved path to the engine so the hot-reload watcher
116    // subscribes to this world.jsonl. The engine no longer discovers it;
117    // world.jsonl lookup is authoring I/O in concinnity-cook.
118    concinnity_engine::app::dev_flags::set_world_jsonl_path(Some(world_path.clone()));
119
120    // Parse the authored entry list up front so edits patch it directly. A
121    // session opening on the start screen edits nothing until a world is picked
122    // there, and it boots on nothing: the window comes up on the screen's own
123    // listing, and the project's most recent world is compiled behind it a few
124    // frames later (`hook/worlds_start.rs`). A world that takes seconds to
125    // compile is then waited out on a screen that is up and usable rather than
126    // in front of no window at all.
127    let (entries, previewing) = if pick_a_world {
128        (Vec::new(), start_screen_pick())
129    } else if std::path::Path::new(&world_path).exists() {
130        let content = std::fs::read_to_string(&world_path)?;
131        let entries = crate::world::parse_world_jsonl(&content)
132            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
133        (entries, None)
134    } else {
135        (Vec::new(), None)
136    };
137
138    // Bring up a renderable world by compiling those entries, seeding a render
139    // marker when they alone would not render.
140    let mut app = crate::project::app();
141    boot_world(&mut app, &entries)?;
142
143    // Inject the editor HUD elements before start (this also drops the world's
144    // DebugHud, whose F1 role the editor takes over); the editor's DebugHook
145    // tick drives them each frame.
146    inject::editor_hud(app.world_mut());
147
148    // Every editor session hot-reloads file-backed assets; with a debug port
149    // the DebugServer owns the reload driver (so the WS `reload-assets`
150    // command reaches its flag), without one the driver runs as its own hook.
151    // Either way the session holds exactly one driver, so a reload is never
152    // applied twice.
153    let mut editor_hook = EditorHook::new(world_path, entries).with_console_sink(console_sink);
154    if pick_a_world {
155        editor_hook = editor_hook.with_start_screen(previewing);
156    }
157    let hook: Box<dyn DebugHook> = match debug_port {
158        Some(port) => {
159            let server =
160                crate::debug::DebugServer::start(port)?.with_notifier(editor_hook.notifier());
161            MultiHook::boxed(vec![Box::new(editor_hook), Box::new(server)])
162        }
163        None => {
164            let reload = crate::debug::hot_reload::HotReloadDriver::new()
165                .with_notifier(editor_hook.notifier());
166            MultiHook::boxed(vec![Box::new(editor_hook), Box::new(reload)])
167        }
168    };
169
170    crate::run::start_app(app, Some(hook))
171}
172
173// Resolve the world the editor opens on, and whether it opens on the Worlds
174// panel rather than on that world. An explicit path is taken as-is (present or
175// not, so a brand-new file can be named) and loads straight away. With no path
176// the session opens an empty scene and the Worlds panel, which picks the world
177// to work on; the path stands in until it does, so a SAVE before any pick
178// still lands in the project's `worlds/`.
179fn resolve_edit_target(json_path: Option<&str>) -> (String, bool) {
180    match json_path {
181        Some(p) => (p.to_string(), false),
182        None => (unsaved_world_path(), true),
183    }
184}
185
186// The world the start screen preselects: the project's most recent one. Only
187// its path -- reading and compiling it is the screen's own work, done once it
188// has a window to show the result in. A project with no worlds preselects
189// nothing, which is what the screen's empty listing already says.
190fn start_screen_pick() -> Option<String> {
191    let world = world_files::newest(
192        crate::project::worlds_dir().as_deref(),
193        crate::project::content_root().as_deref(),
194    )?;
195    Some(world.path.to_string_lossy().into_owned())
196}
197
198// Where the editor puts a world nobody has saved yet.
199pub(crate) fn unsaved_world_path() -> String {
200    crate::project::worlds_dir()
201        .map(|dir| dir.join(WORLD_JSONL).to_string_lossy().into_owned())
202        .unwrap_or_else(|| WORLD_JSONL.to_string())
203}
204
205// Populate `app` with a renderable world for editing, compiled from the
206// authored entries in memory. Nothing under the build root is read: the blobs
207// there are refreshed only by an explicit build, so they may lag the world file
208// the editor is opening.
209fn boot_world(app: &mut App, entries: &[serde_json::Value]) -> std::io::Result<()> {
210    let jsonl = crate::world::write_world_jsonl(entries)
211        .map_err(|e| std::io::Error::other(e.to_string()))?;
212    let (world, _) = build_renderable(&jsonl)?;
213    app.load_world(world);
214    Ok(())
215}
216
217// Compile world.jsonl content into a ready-to-run world, plus the template
218// baselines its expansion merged authored patches over. Content that would not
219// render (an empty world, or authored entries with no render marker) is
220// recompiled with a seeded GraphicsConfig, so a session always opens a window.
221// Boot and every live-preview rebuild come through here, so what the editor
222// shows never depends on which of the two produced it.
223fn build_renderable(
224    jsonl: &str,
225) -> std::io::Result<(World, Vec<concinnity_cook::build_only::ShadowedAsset>)> {
226    match crate::authoring::build_world_and_shadows(jsonl) {
227        Ok(built) if concinnity_engine::ecs::renders(&built.0) => Ok(built),
228        _ => crate::authoring::build_world_and_shadows(&seeded_content(jsonl)),
229    }
230}
231
232// Guarantee a render marker: append the seed GraphicsConfig to the authored
233// content (only reached when the world does not otherwise render, so there is
234// no existing GraphicsConfig to collide with).
235fn seeded_content(base: &str) -> String {
236    if base.trim().is_empty() {
237        SEED_GRAPHICS_CONFIG.to_string()
238    } else {
239        format!("{base}\n{SEED_GRAPHICS_CONFIG}")
240    }
241}
242
243// Fan a single per-frame drive out to several hooks. Lets the editor run its own
244// hook and the debug server side by side without either owning the other.
245struct MultiHook {
246    hooks: Vec<Box<dyn DebugHook>>,
247}
248
249impl MultiHook {
250    fn boxed(hooks: Vec<Box<dyn DebugHook>>) -> Box<dyn DebugHook> {
251        Box::new(Self { hooks })
252    }
253}
254
255impl DebugHook for MultiHook {
256    fn tick(&mut self, world: &mut World) {
257        for hook in &mut self.hooks {
258            hook.tick(world);
259        }
260    }
261
262    fn apply_world_swap(&mut self, app: &mut crate::app::state::App) {
263        for hook in &mut self.hooks {
264            hook.apply_world_swap(app);
265        }
266    }
267
268    fn attach_shutdown(&mut self, shutdown: ShutdownToken) {
269        for hook in &mut self.hooks {
270            hook.attach_shutdown(shutdown.clone());
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    // An empty (or whitespace-only) world seeds to just the render marker, so an
280    // empty session still opens a window.
281    #[test]
282    fn seeded_content_of_empty_is_the_render_marker() {
283        assert_eq!(seeded_content(""), SEED_GRAPHICS_CONFIG);
284        assert_eq!(seeded_content("   \n"), SEED_GRAPHICS_CONFIG);
285    }
286
287    // Authored content keeps its entries and gains the render marker on its own
288    // line, so the combined string still parses as one asset per line.
289    #[test]
290    fn seeded_content_appends_marker_to_authored_content() {
291        let base = "{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}";
292        let seeded = seeded_content(base);
293        let parsed = crate::world::parse_world_jsonl(&seeded).unwrap();
294        assert_eq!(parsed.len(), 2, "authored entry plus the seed marker");
295        assert_eq!(parsed[0]["name"], "phys");
296        assert_eq!(parsed[1]["type"], "GraphicsConfig");
297    }
298
299    // The seed marker is itself a well-formed, renderable asset line.
300    #[test]
301    fn seed_marker_is_a_graphics_config() {
302        let parsed = crate::world::parse_world_jsonl(SEED_GRAPHICS_CONFIG).unwrap();
303        assert_eq!(parsed.len(), 1);
304        assert_eq!(parsed[0]["type"], "GraphicsConfig");
305    }
306
307    // A project whose build root is a `.concinnity/` of its own, as `cn` opens
308    // one, with the machine-wide cache so a boot compiles shaders once.
309    fn open_project(dir: &std::path::Path) -> std::path::PathBuf {
310        let build_root = dir.join(".concinnity");
311        crate::project::open(
312            concinnity_host::store::paths::StateTree::at(dir)
313                .with_build(&build_root)
314                .with_cache(concinnity_testing::shared_cache_dir(
315                    "concinnity-dev-tests-cache",
316                )),
317        );
318        build_root
319    }
320
321    fn entry(name: &str, ty: &str, args: serde_json::Value) -> serde_json::Value {
322        serde_json::json!({"name": name, "type": ty, "args": args})
323    }
324
325    // A renderable authored world, plus a label whose content identifies which
326    // compile a booted world came from.
327    fn renderable_entries(label: &str) -> Vec<serde_json::Value> {
328        vec![
329            entry("cam", "Camera3D", serde_json::json!({})),
330            entry("room", "Room", serde_json::json!({})),
331            entry("hint", "TextLabel", serde_json::json!({"content": label})),
332        ]
333    }
334
335    // The content of the booted world's only TextLabel.
336    fn booted_label(app: &App) -> String {
337        app.world()
338            .query::<crate::components::TextLabel>()
339            .next()
340            .expect("the authored label is in the booted world")
341            .content
342            .clone()
343    }
344
345    // Boot compiles the authored entries in memory: the world it brings up is
346    // the one the entry list describes, and no build output is read or written.
347    #[test]
348    fn boot_compiles_the_authored_entries_without_touching_the_build_root() {
349        let _guard = crate::test_support::lock();
350        let dir = concinnity_testing::TempTree::new();
351        let build_root = open_project(dir.path());
352
353        let mut app = crate::project::app();
354        boot_world(&mut app, &renderable_entries("authored")).expect("the world builds");
355
356        assert!(concinnity_engine::ecs::renders(app.world()));
357        assert_eq!(booted_label(&app), "authored");
358        assert!(
359            !build_root.join("data").exists() && !build_root.join("world-lock.json").exists(),
360            "boot writes no blobs and no lock"
361        );
362
363        crate::test_support::isolate_state_dir();
364    }
365
366    // Blobs a build left behind are ignored: the session shows what the entry
367    // list says even when the compiled output on disk says something else, and
368    // that output is left exactly as the build wrote it.
369    #[test]
370    fn boot_ignores_blobs_that_no_longer_match_the_entries() {
371        let _guard = crate::test_support::lock();
372        let dir = concinnity_testing::TempTree::new();
373        let build_root = open_project(dir.path());
374
375        // An explicit build, as `cn build` runs it, over the stale world.
376        let world_path = dir.path().join("worlds").join(WORLD_JSONL);
377        std::fs::create_dir_all(world_path.parent().unwrap()).unwrap();
378        std::fs::write(
379            &world_path,
380            crate::world::write_world_jsonl(&renderable_entries("stale")).unwrap(),
381        )
382        .unwrap();
383        crate::build_world_to_disk(world_path.to_str().unwrap()).expect("the build writes blobs");
384        let blob = concinnity_host::store::blob::primary_in(&build_root.join("data"));
385        let before = std::fs::read(&blob).expect("the build wrote a primary blob");
386
387        let mut app = crate::project::app();
388        boot_world(&mut app, &renderable_entries("edited")).expect("the world builds");
389
390        assert_eq!(
391            booted_label(&app),
392            "edited",
393            "the entries win over the blobs the last build left"
394        );
395        assert_eq!(
396            std::fs::read(&blob).unwrap(),
397            before,
398            "boot leaves the build output untouched"
399        );
400
401        crate::test_support::isolate_state_dir();
402    }
403
404    // Nothing renderable to compile still opens a window: an empty entry list
405    // boots the seeded render marker.
406    #[test]
407    fn boot_seeds_a_render_marker_for_an_empty_entry_list() {
408        let _guard = crate::test_support::lock();
409        crate::test_support::isolate_state_dir();
410
411        let mut app = crate::project::app();
412        boot_world(&mut app, &[]).expect("an empty world seeds");
413        assert!(concinnity_engine::ecs::renders(app.world()));
414    }
415
416    // An explicit path is taken verbatim and loads directly, panel closed --
417    // including one that does not exist yet, which boots as an empty world
418    // rather than erroring.
419    #[test]
420    fn resolve_edit_target_honors_an_explicit_path() {
421        let (path, pick) = resolve_edit_target(Some("/no/such/cn-editor-world.jsonl"));
422        assert_eq!(path, "/no/such/cn-editor-world.jsonl");
423        assert!(!pick, "a named world loads instead of the Worlds panel");
424    }
425
426    // With no world named, the session opens on the Worlds panel instead of
427    // guessing which of the project's worlds the user meant.
428    #[test]
429    fn resolve_edit_target_without_a_path_opens_the_worlds_panel() {
430        let _guard = crate::test_support::lock();
431        crate::test_support::isolate_state_dir();
432
433        let (path, pick) = resolve_edit_target(None);
434        assert!(pick, "no named world opens the Worlds panel");
435        assert_eq!(path, unsaved_world_path());
436    }
437
438    // With no world to discover, the editor opens an unsaved one in the
439    // project's `worlds/`, so the first save lands where a build looks.
440    #[test]
441    fn an_unsaved_world_is_named_inside_the_projects_worlds_directory() {
442        // Reading the session's project; opening one is what the guard covers.
443        let _guard = crate::test_support::lock();
444        crate::test_support::isolate_state_dir();
445
446        let worlds = crate::project::worlds_dir().expect("the harness opened a project");
447        assert_eq!(
448            unsaved_world_path(),
449            worlds.join(WORLD_JSONL).to_string_lossy()
450        );
451    }
452}