use concinnity_core::components::FrameInput;
use concinnity_core::ecs::{
CursorShape, DesiredCursor, FlyCam, HiddenAssets, HudLayers, MenuOverride, ViewOverrides,
World, WorldLines,
};
use concinnity_engine::app::runtime::Runtime;
use concinnity_engine::live_edit::PendingBackend;
use concinnity_engine::shutdown::ShutdownToken;
use super::asset_handle::AssetHandle;
use super::behavior;
use super::entry_list::{EntryId, EntryList};
use super::history::History;
use super::hud;
use super::live;
use super::notify;
use super::outlines;
use super::overrides;
use super::panels::asset_list::ListRow;
use super::panels::asset_tree::TreeRow;
use super::panels::console::ConsoleSink;
use super::panels::form::FormField;
use super::panels::health::HealthState;
use super::panels::lighting;
use super::panels::registry::{self, PanelKey, PerPanel};
use super::selection::Selection;
use super::session_store;
use super::sim;
use super::view_menu;
use super::viewport::cursor;
use super::viewport::framing;
use super::viewport::gizmo;
use super::viewport::resize;
use super::viewport::snap;
use super::widget;
use super::worlds;
use crate::debug::hot_reload::WorldPathHandle;
use crate::editor::text_area::clipboard::InternalClipboard;
use crate::frame_hook::FrameHook;
use edit::assets_state::AssetsState;
use edit::behavior_state::BehaviorState;
use edit::console_state::ConsoleState;
use edit::content_state::ContentState;
use edit::import_state::ImportState;
use edit::lighting_state::LightingState;
use edit::map_state::MapState;
use edit::palette_state::PaletteState;
use edit::shaders_state::ShadersState;
use edit::shape_state::ShapeState;
use edit::story_state::StoryState;
use edit::templates_state::TemplatesState;
use edit::variables_state::VariablesState;
use edit::worlds_state::WorldsState;
use form_state::{FormState, FormTarget, FormTemplate};
const TOP_BAR_LAYER: i32 = 1_000;
#[derive(Debug, Clone, Copy)]
struct Drag {
key: PanelKey,
grab: [f32; 2],
}
pub(crate) struct EditorHook {
world_path: String,
world_path_handle: WorldPathHandle,
entries: EntryList,
history: History,
baseline: EntryList,
saved: EntryList,
dirty: bool,
sim: sim::SimControl,
hud_visible: bool,
axes_visible: bool,
rebuild_preview: bool,
rebuild_required: bool,
world_entries: EntryList,
world_shadows: Option<live::ShadowBaselines>,
templates: TemplatesState,
lighting: LightingState,
shape: ShapeState,
story: StoryState,
shaders: ShadersState,
text_clipboard: InternalClipboard,
clock: std::time::Instant,
import: ImportState,
console: ConsoleState,
console_sink: ConsoleSink,
console_build_running: std::sync::Arc<std::sync::atomic::AtomicBool>,
notifier: notify::Notifier,
toasts_hidden: bool,
behavior: BehaviorState,
map: MapState,
variables: VariablesState,
trace_seen: u64,
live_vars: Vec<(String, String, String)>,
live_locals: Vec<(String, String, String)>,
ctrl_held: bool,
hidden_assets: std::collections::BTreeSet<AssetHandle>,
locked_assets: std::collections::BTreeSet<AssetHandle>,
isolate: Option<std::collections::BTreeSet<AssetHandle>>,
shift_held: bool,
viewport: [f32; 2],
top_inset: f32,
assets: AssetsState,
content: ContentState,
create_menu: Option<drive::create_menu::CreateMenu>,
modal: Option<crate::editor::modal::Dialog>,
worlds: WorldsState,
untitled: bool,
start_mode: bool,
start_preview: Option<String>,
start_drawn: u32,
cinematic: Option<worlds::cinematic::Cinematic>,
cinematic_ticking: bool,
cinematic_restore: Option<framing::CameraPose>,
palette: PaletteState,
health: HealthState,
display_menu_open: bool,
view_mode: view_menu::ViewMode,
show_flags: view_menu::ShowFlags,
published_view: ViewOverrides,
show_billboards: bool,
extent_show: outlines::CategorySet,
shutdown: Option<ShutdownToken>,
form: FormState,
template_index: Option<overrides::TemplateIndex>,
selection: Selection,
pick_last: Option<pick::PickLast>,
marquee: Option<drag::marquee::MarqueeDrag>,
orbit: Option<drive::orbit::OrbitDrag>,
gizmo_mode: gizmo::GizmoMode,
gizmo_drag: Option<drag::gizmo::GizmoDrag>,
snap: snap::SnapSettings,
align_to_surface: bool,
fly: bool,
glide: Option<drive::glide::CameraGlide>,
bookmarks: [Option<framing::CameraPose>; session_store::BOOKMARK_SLOTS],
open: PerPanel<bool>,
positions: PerPanel<Option<[f32; 2]>>,
sizes: PerPanel<Option<[f32; 2]>>,
drag: Option<Drag>,
resize: Option<resize::Resize>,
panel_order: Vec<PanelKey>,
rebuild_op: Option<notify::OpHandle>,
rebuild_countdown: u8,
last_rebuild_secs: f32,
}
struct PanelData {
rows: Vec<TreeRow>,
hidden: std::collections::BTreeSet<String>,
locked: std::collections::BTreeSet<String>,
row_menu: Option<String>,
picker_options: Option<Vec<crate::editor::panels::assets_panel::PickerOption>>,
form_title: String,
form_overrides: Option<FormOverridesData>,
form_extras: Vec<crate::editor::panels::form_extras::ExtraRow>,
form_blocked: Option<String>,
}
struct FormOverridesData {
marks: Vec<overrides::FieldOrigin>,
count: usize,
field_menu: Option<(usize, Vec<String>)>,
entity_menu: Option<Vec<String>>,
}
#[derive(Default)]
struct TemplateDetailData {
title: String,
description: String,
rows: Vec<ListRow>,
}
struct LightingData {
rows: Vec<lighting::Row>,
fields: Vec<Option<FormField>>,
}
fn initial_open() -> PerPanel<bool> {
let mut open = PerPanel::splat(false);
open[PanelKey::Preview] = true;
open
}
fn scroll_step(cur: usize, delta: f32, max: usize) -> usize {
if delta > 0.0 {
(cur + 1).min(max)
} else {
cur.saturating_sub(1)
}
}
fn visible_slot(j: usize, scroll: usize, window: usize) -> Option<usize> {
(j >= scroll && j < scroll + window).then(|| j - scroll)
}
fn short_status(e: &str) -> String {
let line = e.lines().next().unwrap_or(e);
let clipped: String = line.chars().take(44).collect();
if clipped.len() < line.len() {
format!("{clipped}...")
} else {
clipped
}
}
fn declared_id(e: &serde_json::Value) -> Option<&str> {
concinnity_cook::authoring::world::entry_id(e)
}
fn entry_type(e: &serde_json::Value) -> Option<&str> {
e.get("type").and_then(|v| v.as_str())
}
fn names_of_types(entries: &[serde_json::Value], types: &[&str]) -> Vec<String> {
entries
.iter()
.filter(|e| types.is_empty() || entry_type(e).is_some_and(|t| types.contains(&t)))
.filter_map(|e| declared_id(e).map(String::from))
.collect()
}
mod drag;
mod drive;
mod edit;
mod behavior_asset;
mod behavior_keys;
mod bookmarks;
mod browse;
mod camera_pose;
mod cook_worker;
mod drop_floor;
mod duplicate;
mod editing;
mod edits;
mod fly;
mod form_extras;
mod form_state;
mod handles;
mod hide;
mod layout;
pub(super) mod panels;
mod pick;
mod routing;
mod sim_control;
mod worlds_start;
#[cfg(test)]
mod tests;
impl EditorHook {
pub(crate) fn new(world_path: String, entries: impl Into<EntryList>) -> Self {
let bookmarks = session_store::default_path()
.and_then(|path| {
session_store::load(&path)
.worlds
.get(&session_store::world_key(&world_path))
.map(|w| w.bookmarks)
})
.unwrap_or_default();
let entries = entries.into();
Self {
world_path_handle: WorldPathHandle::new(world_path.as_str()),
world_path,
history: History::default(),
baseline: entries.clone(),
saved: entries.clone(),
world_entries: entries.clone(),
entries,
dirty: false,
sim: sim::SimControl::default(),
hud_visible: true,
axes_visible: true,
rebuild_preview: false,
rebuild_required: false,
world_shadows: None,
templates: TemplatesState::default(),
lighting: LightingState::default(),
shape: ShapeState::default(),
story: StoryState::default(),
shaders: ShadersState::default(),
text_clipboard: InternalClipboard::default(),
clock: std::time::Instant::now(),
import: ImportState::default(),
console: ConsoleState::default(),
console_sink: ConsoleSink::default(),
console_build_running: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
notifier: notify::Notifier::default(),
toasts_hidden: true,
behavior: BehaviorState::default(),
map: MapState::default(),
variables: VariablesState::default(),
trace_seen: 0,
live_vars: Vec::new(),
live_locals: Vec::new(),
ctrl_held: false,
hidden_assets: std::collections::BTreeSet::new(),
locked_assets: std::collections::BTreeSet::new(),
isolate: None,
shift_held: false,
viewport: [0.0, 0.0],
top_inset: 0.0,
assets: AssetsState::default(),
content: ContentState::default(),
create_menu: None,
modal: None,
worlds: WorldsState::default(),
untitled: false,
start_mode: false,
start_preview: None,
start_drawn: 0,
cinematic: None,
cinematic_ticking: false,
cinematic_restore: None,
palette: PaletteState::default(),
health: HealthState::new(),
display_menu_open: false,
view_mode: view_menu::ViewMode::default(),
show_flags: view_menu::ShowFlags::default(),
published_view: ViewOverrides::default(),
show_billboards: true,
extent_show: outlines::CategorySet::default(),
shutdown: None,
form: FormState::default(),
template_index: None,
selection: Selection::default(),
pick_last: None,
marquee: None,
orbit: None,
gizmo_mode: gizmo::GizmoMode::default(),
gizmo_drag: None,
snap: snap::SnapSettings::default(),
align_to_surface: false,
fly: false,
glide: None,
bookmarks,
open: initial_open(),
positions: PerPanel::splat(None),
sizes: PerPanel::splat(None),
drag: None,
resize: None,
panel_order: PanelKey::ALL.to_vec(),
rebuild_op: None,
rebuild_countdown: 0,
last_rebuild_secs: 0.0,
}
}
}
impl EditorHook {
pub(crate) fn with_console_sink(mut self, sink: ConsoleSink) -> Self {
self.console_sink = sink;
self
}
pub(crate) fn with_start_screen(mut self, picked: Option<String>) -> Self {
self.start_mode = true;
self.open[PanelKey::Worlds] = true;
self.worlds.selected = picked.clone();
self.start_preview = picked;
self.refresh_worlds();
self
}
pub(crate) fn notifier(&self) -> notify::Notifier {
self.notifier.clone()
}
pub(crate) fn world_path_handle(&self) -> WorldPathHandle {
self.world_path_handle.clone()
}
fn sample_behavior_filter(&mut self, world: &World) {
let typed = match self.behavior.picking {
true => widget::field_text(world, behavior::panel::FILTER_INPUT),
false => String::new(),
};
if typed != self.behavior.filter {
self.behavior.pick = 0;
self.behavior.pick_scroll = 0;
self.behavior.filter = typed;
}
}
fn frontmost_open_panel(&self) -> Option<PanelKey> {
self.panel_order
.iter()
.rev()
.copied()
.find(|&k| self.panel_shown(k))
}
fn text_focus_active(&self) -> bool {
self.non_console_text_focus() || self.console.focus
}
fn non_console_text_focus(&self) -> bool {
self.assets.search_focus
|| self.content.search_focus
|| self.assets.picker_open
|| self.form.selected_type.is_some()
|| self.lighting.focus.is_some()
|| self.story_typing()
|| self.shader_typing()
|| self.import.focus
|| self.behavior.focus
|| self.behavior.name_focus
|| self.behavior.picking
|| self.variables.name_focus
|| self.variables.value_focus
|| self.prompting()
|| self.open[PanelKey::Palette]
}
}
impl FrameHook for EditorHook {
fn attach_shutdown(&mut self, shutdown: ShutdownToken) {
self.shutdown = Some(shutdown);
}
fn tick(&mut self, world: &mut World) {
self.drive_app_menu(world);
self.refresh_tree_if_needed();
self.drive_shader_source();
self.shaders
.retry_missing(self.clock.elapsed().as_secs_f64());
if self.open[PanelKey::Shaders] {
self.shader_rows();
}
self.health.sample(world);
self.seed_billboard_transforms(world);
self.sample_behavior_filter(world);
self.sample_palette_query(world);
self.sample_shape_rows(world);
let input = world.query::<FrameInput>().last().cloned();
if let Some(input) = &input {
self.shift_held = input.shift;
self.ctrl_held = input.ctrl;
self.viewport = input.viewport;
self.top_inset = input.top_inset;
if input.typed_any() {
match self.frontmost_open_panel() {
Some(PanelKey::Edit) => self.form.touched = true,
Some(PanelKey::Lighting) if self.lighting.focus.is_some() => {
self.lighting.touched = true;
}
_ => {}
}
}
if input.escape {
self.sim.pause();
self.fly = false;
self.glide = None;
self.orbit = None;
self.create_menu = None;
self.display_menu_open = false;
self.close_palette();
}
if self.glide.is_some() {
self.drive_glide(input, world);
} else {
self.drive_fly(input, world);
}
if input.hud_toggle && !self.start_mode {
self.hud_visible = !self.hud_visible;
}
let vp = input.viewport;
if self.hud_visible && vp[0] > 0.0 {
if self.start_mode {
self.drive_start_input(input, vp, world);
} else {
self.drive_session_input(input, vp, world);
}
}
}
self.drive_start_preview();
self.drive_cinematic(world);
self.drive_sim(world);
self.drive_trace(world);
world.insert_resource(FlyCam(self.fly && !self.sim.playing()));
world.insert_resource(HiddenAssets(self.effective_hidden_ids()));
let view = view_menu::resolve_view(
self.published_view,
world.resource::<ViewOverrides>().copied(),
ViewOverrides {
mode: self.view_mode,
show: self.show_flags,
},
);
self.view_mode = view.mode;
self.show_flags = view.show;
self.published_view = view;
world.insert_resource(view);
hud::apply_layout(world, self.hud_state());
let vp = input.as_ref().map(|i| i.viewport).unwrap_or([0.0, 0.0]);
let mouse = input
.as_ref()
.map(|i| [i.mouse_x, i.mouse_y])
.unwrap_or([0.0, 0.0]);
let shown = self.hud_visible && vp[0] > 0.0;
let mut lines = world
.remove_resource::<WorldLines>()
.map(|l| l.0)
.unwrap_or_default();
lines.clear();
if self.show_flags.contains(view_menu::ShowFlags::LINES) && !self.start_mode {
self.push_axis_lines(world, &mut lines);
self.push_extent_lines(world, vp, &mut lines);
}
world.insert_resource(WorldLines(lines));
let owns_cursor = vp[0] > 0.0 && !self.sim.playing() && !self.fly;
cursor::set_visible(world, owns_cursor);
let shape = if !owns_cursor || !shown {
CursorShape::Default
} else if let Some(r) = &self.resize {
resize::cursor_shape(r.edges)
} else {
self.hover_cursor(vp, mouse)
};
world.insert_resource(DesiredCursor(shape));
if shown {
self.publish_layers(world);
} else {
world.insert_resource(HudLayers::default());
}
for p in registry::all() {
if shown && self.panel_shown(p.key()) {
let o = self.origin(p.key(), vp);
p.draw(self, world, o, mouse);
} else {
p.hide(world);
}
}
let overlays = shown && !self.start_mode;
self.drive_cinematic_draw(world, vp, shown);
self.drive_loading_draw(world, shown);
if shown && self.start_mode {
self.start_drawn = self.start_drawn.saturating_add(1);
}
self.drive_billboards(world, vp, overlays && self.show_billboards);
self.drive_highlight(world, vp, overlays);
self.drive_gizmo_draw(world, vp, overlays);
self.drive_marquee_draw(world, overlays);
self.drive_create_menu_draw(world, overlays, mouse);
self.drive_display_menu_draw(world, vp, overlays, mouse);
self.drive_toasts(world, vp, shown, mouse);
self.drive_modal_draw(world, vp, shown, mouse);
}
fn apply_world_swap(&mut self, runtime: &mut Runtime) {
if !self.refresh_preview(runtime.world_mut()) {
return;
}
if !self.start_mode
&& self.last_rebuild_secs > SLOW_REBUILD_SECS
&& self.rebuild_op.is_none()
{
self.rebuild_op = Some(self.notifier.begin_op("Rebuilding preview"));
self.rebuild_countdown = 2;
}
if self.rebuild_countdown > 0 {
self.rebuild_countdown -= 1;
return;
}
self.rebuild_preview = false;
self.rebuild_required = false;
let started = std::time::Instant::now();
self.swap_preview_world(runtime);
self.last_rebuild_secs = started.elapsed().as_secs_f32();
if let Some(op) = self.rebuild_op.take() {
op.finish();
}
}
}
const SLOW_REBUILD_SECS: f32 = 0.15;
impl EditorHook {
fn refresh_preview(&mut self, world: &mut World) -> bool {
if !self.rebuild_preview {
return false;
}
if self.try_live_apply(world) {
self.rebuild_preview = false;
return false;
}
self.sim.on_edit();
true
}
fn try_live_apply(&mut self, world: &mut World) -> bool {
if self.rebuild_required {
return false;
}
if !live::same_assets(&self.world_entries, &self.entries) || !self.seed_world_shadows() {
return false;
}
let shadows = self.world_shadows.as_ref().expect("seeded above");
let Some(changes) = live::args_changes(&self.world_entries, &self.entries, shadows) else {
return false;
};
let Some(plan) = live::plan(world, &self.entries, &changes) else {
return false;
};
tracing::debug!("editor: {} edit(s) applied live, no rebuild", plan.len());
live::commit(world, plan);
self.world_entries = self.entries.clone();
true
}
fn seed_world_shadows(&mut self) -> bool {
if self.world_shadows.is_some() {
return true;
}
let Ok(loaded) = Self::cook_entries(&self.world_entries) else {
return false;
};
self.world_shadows = Some(
loaded
.shadowed
.into_iter()
.map(|s| (s.name, s.args))
.collect(),
);
true
}
fn swap_preview_world(&mut self, runtime: &mut Runtime) {
let (world, shadows) = match self.build_preview_world() {
Ok(built) => built,
Err(e) => {
tracing::error!("editor: live preview rebuild failed, keeping current world: {e}");
match self.start_mode {
true => self.preview_failed(&e.to_string()),
false => self.notifier.error_with(
&format!("Preview rebuild failed: {e}"),
notify::Action::OpenConsole,
),
}
return;
}
};
self.world_entries = self.entries.clone();
self.world_shadows = Some(shadows);
let mut staged = Runtime::new();
staged.load_world(world);
let fields = Self::field_snapshot(runtime.world());
super::inject::editor_hud(staged.world_mut());
Self::restore_fields(staged.world_mut(), &fields);
staged
.world_mut()
.insert_resource(MenuOverride(Some(!self.sim.playing())));
let Some(backend) = concinnity_engine::live_edit::take_render_backend(runtime.world_mut())
else {
return;
};
staged.world_mut().insert_resource(PendingBackend(backend));
let new_world = std::mem::replace(staged.world_mut(), World::new());
runtime.load_world(new_world);
if let Err(e) = runtime.start() {
tracing::error!("editor: live preview start failed: {e:?}");
self.notifier.error_with(
&format!("Preview start failed: {e:?}"),
notify::Action::OpenConsole,
);
}
}
}