use std::collections::HashSet;
use egui::{
Button, CollapsingHeader, Color32, DragValue, Key, RichText, ScrollArea, Sense, Stroke, TextEdit, Ui, vec2,
};
use glam::Vec2;
use ponko_ecs::{ComponentId, Entity, World};
use serde_json::Value;
use crate::editor::Editor;
use crate::reflect::short_name;
use crate::{Ctx, Rect, Sprite, Transform2D, WorldReflect};
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) const ENV_INSPECTOR: &str = "CRABBY_INSPECTOR";
pub(crate) const TOGGLE_CODE: winit::keyboard::KeyCode = winit::keyboard::KeyCode::Backquote;
pub(crate) fn enabled(game_default: bool) -> (bool, bool) {
#[cfg(target_arch = "wasm32")]
let setting = crate::web::query_param("inspector").map(|v| if v.is_empty() { "1".into() } else { v });
#[cfg(not(target_arch = "wasm32"))]
let setting = std::env::var(ENV_INSPECTOR).ok();
match setting.as_deref() {
Some("1") => (true, false),
Some("open") => (true, true),
Some("0") => (false, false),
_ => (game_default, false),
}
}
const HISTORY: usize = 240;
const PICK_RADIUS: f32 = 24.0;
const ROW_HEIGHT: f32 = 18.0;
const DUPLICATE_OFFSET: Vec2 = Vec2::new(16.0, 16.0);
const PREFAB_FOLDER: &str = "prefabs";
const DEFAULT_SCENE: &str = "levels/level.json";
pub(crate) struct Overlay {
pub primitives: Vec<egui::ClippedPrimitive>,
pub textures: egui::TexturesDelta,
pub pixels_per_point: f32,
}
impl Overlay {
pub fn merge_older(&mut self, mut older: Overlay) {
let mut textures = std::mem::take(&mut older.textures);
textures.append(std::mem::take(&mut self.textures));
self.textures = textures;
}
}
impl Drop for Overlay {
fn drop(&mut self) {
self.textures.clear();
}
}
#[derive(Default)]
pub(crate) struct Panels {
pub selected: Option<Entity>,
filter: Option<ComponentId>,
pub picking: bool,
pub pick_at: Option<Vec2>,
error: Option<String>,
frame_ms: Vec<f32>,
frame_at: usize,
rows: Vec<(usize, usize)>,
reflected: HashSet<String>,
pub editor: Editor,
pub editing: bool,
drag: Option<Drag>,
placing: Option<String>,
prefabs: Option<Vec<String>>,
scene_path: String,
prefab_path: String,
status: Option<String>,
}
struct Drag {
e: Entity,
before: Value,
grab: Vec2,
moved: bool,
}
impl Panels {
pub fn show(&mut self, ui: &mut Ui, ctx: &mut Ctx) {
self.editing = ctx.is_paused();
self.record_frame(ctx.dt() * 1000.0);
self.reflected.clear();
self.reflected
.extend(ctx.world.reflected_components().into_iter().map(str::to_owned));
if let Some(at) = self.pick_at.take() {
self.picking = false;
if let Some(e) = pick(ctx, at) {
self.selected = Some(e);
}
}
if self.selected.is_some_and(|e| !ctx.world.is_alive(e)) {
self.selected = None;
}
egui::Panel::right("crabby-inspector")
.default_size(360.0)
.resizable(true)
.show(ui, |ui| {
ScrollArea::vertical().id_salt("inspector-scroll").show(ui, |ui| {
self.sim_controls(ui, ctx);
self.frame_graph(ui);
if let Some(error) = &self.error {
ui.colored_label(Color32::LIGHT_RED, error);
}
CollapsingHeader::new("Edit")
.default_open(true)
.show(ui, |ui| self.edit_tools(ui, ctx));
if let Some(e) = self.selected {
ui.separator();
self.entity(ui, ctx, e);
ui.separator();
}
CollapsingHeader::new("Entities")
.default_open(true)
.show(ui, |ui| self.entity_list(ui, ctx));
if !ctx.world.reflected_resources().is_empty() {
CollapsingHeader::new("Resources")
.default_open(true)
.show(ui, |ui| self.resources(ui, ctx));
}
CollapsingHeader::new("Systems")
.default_open(true)
.show(ui, |ui| systems(ui, ctx));
});
});
if self.editing {
self.world_input(ui, ctx);
self.shortcuts(ui, ctx);
} else if let Some(drag) = self.drag.take() {
self.end_drag(drag);
}
self.highlight(ui, ctx);
}
fn report<T>(&mut self, result: Result<T, String>) -> Option<T> {
match result {
Ok(v) => {
self.error = None;
Some(v)
}
Err(e) => {
self.error = Some(e);
None
}
}
}
fn world_input(&mut self, ui: &Ui, ctx: &mut Ctx) {
let (pos, pressed, down, pan, scroll, shift) = ui.input(|i| {
let p = &i.pointer;
let pan = if p.secondary_down() || p.middle_down() {
p.delta()
} else {
egui::Vec2::ZERO
};
let pos = p.latest_pos().map(|p| Vec2::new(p.x, p.y));
(
pos,
p.primary_pressed(),
p.primary_down(),
pan,
i.smooth_scroll_delta.y,
i.modifiers.shift,
)
});
let Some(pos) = pos else { return };
if !ui.ctx().is_pointer_over_egui() {
if pan != egui::Vec2::ZERO {
ctx.camera.position -= Vec2::new(pan.x, pan.y) / ctx.camera.zoom;
}
if scroll != 0.0 {
ctx.camera.zoom_at(pos, 1.1f32.powf(scroll / 50.0));
}
if pressed {
self.click(ctx, pos, shift);
}
}
if let Some(drag) = &mut self.drag {
if down && ctx.world.is_alive(drag.e) {
let to = ctx.camera.screen_to_world(pos) + drag.grab;
if let Some(t) = ctx.world.get_mut::<Transform2D>(drag.e)
&& t.position != to
{
t.position = to;
drag.moved = true;
}
} else if let Some(drag) = self.drag.take() {
self.end_drag(drag);
}
}
}
fn click(&mut self, ctx: &mut Ctx, at: Vec2, keep_placing: bool) {
let world_at = ctx.camera.screen_to_world(at);
if let Some(path) = self.placing.clone() {
let placed = self.editor.spawn_prefab(ctx, &path, world_at);
if let Some(e) = self.report(placed) {
self.selected = Some(e);
}
if !keep_placing {
self.placing = None;
}
return;
}
self.selected = pick(ctx, at);
if let Some(e) = self.selected
&& let Some(t) = ctx.world.get::<Transform2D>(e)
{
let grab = t.position - world_at;
if let Ok(before) = ctx.world.get_json(e, "Transform2D") {
self.drag = Some(Drag {
e,
before,
grab,
moved: false,
});
}
}
}
fn end_drag(&mut self, drag: Drag) {
if drag.moved {
self.editor.moved(drag.e, drag.before);
}
}
fn shortcuts(&mut self, ui: &Ui, ctx: &mut Ctx) {
if ui.ctx().egui_wants_keyboard_input() {
return;
}
let (delete, duplicate, undo, escape) = ui.input(|i| {
(
i.key_pressed(Key::Delete) || i.key_pressed(Key::Backspace),
i.modifiers.command && i.key_pressed(Key::D),
i.modifiers.command && i.key_pressed(Key::Z),
i.key_pressed(Key::Escape),
)
});
if escape {
self.placing = None;
}
if undo {
self.undo(ctx);
}
let Some(e) = self.selected else { return };
if delete {
let deleted = self.editor.delete(ctx, e);
self.report(deleted);
self.selected = None;
} else if duplicate {
let copy = self.editor.duplicate(ctx, e, DUPLICATE_OFFSET);
if let Some(copy) = self.report(copy) {
self.selected = Some(copy);
}
}
}
fn undo(&mut self, ctx: &mut Ctx) {
let undone = self.editor.undo(ctx);
self.report(undone);
}
fn edit_tools(&mut self, ui: &mut Ui, ctx: &mut Ctx) {
ui.horizontal(|ui| {
if ui.add_enabled(self.editor.can_undo(), Button::new("Undo")).clicked() {
self.undo(ctx);
}
if ui
.button("New entity")
.on_hover_text("An entity with only a transform, mid-screen")
.clicked()
{
let at = ctx.camera.center();
self.selected = Some(self.editor.spawn_empty(ctx, at));
}
});
if !self.editing {
ui.label(RichText::new("Pause to edit in the world").weak());
} else {
ui.label(
RichText::new("Click selects, drag moves, right-drag pans, wheel zooms. Del, Ctrl+D, Ctrl+Z.").weak(),
);
}
ui.horizontal(|ui| {
ui.label("Scene");
ui.add(
TextEdit::singleline(&mut self.scene_path)
.hint_text(DEFAULT_SCENE)
.desired_width(150.0),
);
let path = if self.scene_path.is_empty() {
DEFAULT_SCENE
} else {
&self.scene_path
}
.to_owned();
if ui.button("Save").clicked() {
let saved = ctx.save_scene(&path).map(|()| format!("saved {path}"));
self.status = self.report(saved);
}
if ui
.button("Load")
.on_hover_text("Replaces the world; clears undo")
.clicked()
{
let loaded = ctx.load_scene(&path).map(|()| format!("loaded {path}"));
if let Some(done) = self.report(loaded) {
self.status = Some(done);
self.editor = Editor::default();
self.selected = None;
}
}
});
ui.horizontal(|ui| {
ui.label("Prefab");
let hint = format!("{PREFAB_FOLDER}/thing.json");
ui.add(
TextEdit::singleline(&mut self.prefab_path)
.hint_text(hint)
.desired_width(150.0),
);
let save = ui.add_enabled(
self.selected.is_some() && !self.prefab_path.is_empty(),
Button::new("Save"),
);
if let Some(e) = self.selected
&& save.on_hover_text("Saves the selected entity as this prefab").clicked()
{
let path = self.prefab_path.clone();
let saved = ctx.save_prefab(e, &path).map(|()| format!("saved {path}"));
self.status = self.report(saved);
self.prefabs = None;
}
});
let prefabs = self
.prefabs
.get_or_insert_with(|| crate::assets::list(PREFAB_FOLDER, "json"))
.clone();
ui.horizontal_wrapped(|ui| {
for path in &prefabs {
let name = path.rsplit('/').next().unwrap_or(path).trim_end_matches(".json");
let armed = self.placing.as_deref() == Some(path.as_str());
let tip = "Click, then click in the world to place it (hold Shift to place more)";
if ui.selectable_label(armed, name).on_hover_text(tip).clicked() {
self.placing = if armed { None } else { Some(path.clone()) };
}
}
if ui
.small_button("⟳")
.on_hover_text(format!("List assets/{PREFAB_FOLDER}/ again"))
.clicked()
{
self.prefabs = None;
}
});
if let Some(status) = &self.status {
ui.label(RichText::new(status).weak());
}
}
fn record_frame(&mut self, ms: f32) {
if self.frame_ms.len() < HISTORY {
self.frame_ms.push(ms);
} else {
self.frame_ms[self.frame_at] = ms;
}
self.frame_at = (self.frame_at + 1) % HISTORY;
}
fn sim_controls(&mut self, ui: &mut Ui, ctx: &mut Ctx) {
ui.horizontal(|ui| {
if ctx.is_paused() {
let tip = if self.editor.playing() {
"Resume"
} else {
"Run the game; Stop puts the world back as it is now"
};
if ui.button("Play").on_hover_text(tip).clicked() {
self.editor.play(ctx);
}
} else if ui.button("Pause").clicked() {
ctx.pause();
}
if self.editor.playing() {
let stop = ui
.button("Stop")
.on_hover_text("Pause and put the world back as it was at Play");
if stop.clicked() {
let stopped = self.editor.stop(ctx);
self.report(stopped);
self.drag = None;
}
if ui
.button("Keep")
.on_hover_text("Keep the world as it is: Stop won't go back")
.clicked()
{
self.editor.keep();
}
}
if ui.button("Step").on_hover_text("Run one tick (pauses)").clicked() {
ctx.pause();
ctx.step();
}
let mut speed = ctx.sim_speed();
let drag = DragValue::new(&mut speed).range(0.0..=16.0).speed(0.02).suffix("×");
if ui.add(drag).on_hover_text("Simulation speed").changed() {
ctx.set_sim_speed(speed);
}
});
ui.label(format!(
"tick {} {:.0} fps {} entities",
ctx.sim_tick(),
ctx.fps(),
ctx.world.len()
));
}
fn frame_graph(&self, ui: &mut Ui) {
let width = ui.available_width();
let (rect, response) = ui.allocate_exact_size(vec2(width, 40.0), Sense::hover());
let painter = ui.painter();
painter.rect_filled(rect, 2.0, Color32::from_black_alpha(80));
let target = 1000.0 / 60.0;
let top = self
.frame_ms
.iter()
.copied()
.fold(target * 2.0, f32::max)
.min(target * 4.0);
let bar = rect.width() / HISTORY as f32;
let n = self.frame_ms.len();
for i in 0..n {
let ms = if n < HISTORY {
self.frame_ms[i]
} else {
self.frame_ms[(self.frame_at + i) % HISTORY]
};
let h = rect.height() * (ms / top).min(1.0);
let x = rect.left() + i as f32 * bar;
let color = if ms > target * 1.5 {
Color32::from_rgb(230, 90, 70)
} else {
Color32::from_rgb(90, 180, 120)
};
painter.rect_filled(
egui::Rect::from_min_max(egui::pos2(x, rect.bottom() - h), egui::pos2(x + bar, rect.bottom())),
0.0,
color,
);
}
let y = rect.bottom() - rect.height() * target / top;
painter.hline(rect.x_range(), y, Stroke::new(1.0, Color32::from_white_alpha(60)));
if n > 0 {
let last = self.frame_ms[(self.frame_at + HISTORY - 1) % HISTORY];
response.on_hover_text(format!("frame time; last {last:.2} ms, line at 60 fps"));
}
}
fn resources(&mut self, ui: &mut Ui, ctx: &mut Ctx) {
let names: Vec<String> = ctx.world.reflected_resources().into_iter().map(str::to_owned).collect();
for name in names {
let Ok(mut value) = ctx.world.resource_json(&name) else {
ui.label(RichText::new(format!("{name} (not inserted)")).weak());
continue;
};
CollapsingHeader::new(&name)
.id_salt(("resource", &name))
.show(ui, |ui| {
if edit_value(ui, &mut value) {
self.error = ctx.world.set_resource_json(&name, value).err().map(|e| e.to_string());
}
});
}
}
fn entity_list(&mut self, ui: &mut Ui, ctx: &mut Ctx) {
let world = &ctx.world;
ui.horizontal(|ui| {
ui.label("With");
let label = self
.filter
.map_or("anything".to_owned(), |id| component_name(world, id));
egui::ComboBox::from_id_salt("inspector-filter")
.selected_text(label)
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.filter, None, "anything");
for (i, info) in world.components().iter().enumerate() {
let Some(id) = world.component_at(i) else { continue };
ui.selectable_value(&mut self.filter, Some(id), short_name(info.name()));
}
});
let pick = ui
.selectable_label(self.picking, "Pick")
.on_hover_text("Click a unit in the world to select it");
if pick.clicked() {
self.picking = !self.picking;
}
});
let total = list_rows(world, self.filter, &mut self.rows);
ui.label(RichText::new(format!("{total} matching")).weak());
let mut clicked = None;
ScrollArea::vertical()
.id_salt("inspector-entities")
.max_height(220.0)
.auto_shrink([false, true])
.show_rows(ui, ROW_HEIGHT, total, |ui, range| {
for row in range {
let Some(e) = entity_at(world, &self.rows, row) else {
continue;
};
let text = format!("{e:?} {}", components_label(world, e));
let selected = self.selected == Some(e);
if ui.selectable_label(selected, text).clicked() {
clicked = Some(e);
}
}
});
if clicked.is_some() {
self.selected = clicked;
}
}
fn entity(&mut self, ui: &mut Ui, ctx: &mut Ctx, e: Entity) {
let position = ctx.world.get::<Transform2D>(e).map(|t| t.position);
ui.horizontal(|ui| {
ui.heading(format!("{e:?}"));
if let Some(at) = position
&& ui.button("Look at").clicked()
{
ctx.camera.look_at(at);
}
if ui.button("Duplicate").on_hover_text("Ctrl+D").clicked() {
let copy = self.editor.duplicate(ctx, e, DUPLICATE_OFFSET);
if let Some(copy) = self.report(copy) {
self.selected = Some(copy);
}
}
if ui.button("Despawn").on_hover_text("Del").clicked() {
let deleted = self.editor.delete(ctx, e);
self.report(deleted);
self.selected = None;
}
});
if self.selected.is_none() {
return;
}
let ids: Vec<ComponentId> = ctx.world.components_of(e).map(<[_]>::to_vec).unwrap_or_default();
let has: HashSet<String> = ids.iter().map(|&id| component_name(&ctx.world, id)).collect();
for id in ids {
let name = component_name(&ctx.world, id);
if !self.reflected.contains(&name) {
ui.label(RichText::new(format!("{name} (not reflected)")).weak())
.on_hover_text("world.reflect::<T>() makes it readable and editable here");
continue;
}
let Ok(mut value) = ctx.world.get_json(e, &name) else {
continue;
};
let mut remove = false;
CollapsingHeader::new(&name)
.id_salt(("component", &name))
.default_open(true)
.show(ui, |ui| {
if edit_value(ui, &mut value) {
let set = self.editor.set(ctx, e, &name, value);
self.report(set);
}
remove = ui.small_button("Remove").clicked();
});
if remove {
let removed = self.editor.remove(ctx, e, &name);
self.report(removed);
}
}
let mut addable: Vec<&String> = self.reflected.iter().filter(|n| !has.contains(*n)).collect();
addable.sort();
let mut add = None;
egui::ComboBox::from_id_salt("inspector-add")
.selected_text("Add component")
.show_ui(ui, |ui| {
for name in addable {
if ui.selectable_label(false, name).clicked() {
add = Some(name.clone());
}
}
});
if let Some(name) = add {
let added = self.editor.add(ctx, e, &name);
self.report(added);
}
}
fn highlight(&self, ui: &Ui, ctx: &Ctx) {
let Some(e) = self.selected else { return };
let Some(t) = ctx.world.get::<Transform2D>(e) else {
return;
};
let at = ctx.camera.world_to_screen(t.position);
let painter = ui.ctx().layer_painter(egui::LayerId::new(
egui::Order::Background,
egui::Id::unique("inspector-highlight"),
));
let center = egui::pos2(at.x, at.y);
painter.circle_stroke(center, 14.0, Stroke::new(2.0, Color32::from_rgb(255, 200, 40)));
painter.circle_stroke(center, 16.0, Stroke::new(1.0, Color32::from_black_alpha(160)));
}
}
fn component_name(world: &World, id: ComponentId) -> String {
short_name(world.component_info(id).name())
}
fn components_label(world: &World, e: Entity) -> String {
let Some(ids) = world.components_of(e) else {
return String::new();
};
let mut out = String::new();
for (i, &id) in ids.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(&component_name(world, id));
}
out
}
fn list_rows(world: &World, filter: Option<ComponentId>, rows: &mut Vec<(usize, usize)>) -> usize {
rows.clear();
let mut total = 0;
for (i, arch) in world.archetypes().iter().enumerate() {
if arch.is_empty() || filter.is_some_and(|id| !arch.has(id)) {
continue;
}
rows.push((i, total));
total += arch.len();
}
total
}
fn entity_at(world: &World, rows: &[(usize, usize)], row: usize) -> Option<Entity> {
let k = rows.partition_point(|&(_, start)| start <= row).checked_sub(1)?;
let (arch, start) = rows[k];
world.archetypes()[arch].entities().get(row - start).copied()
}
fn pick(ctx: &mut Ctx, at: Vec2) -> Option<Entity> {
let camera = ctx.camera;
let world_at = camera.screen_to_world(at);
let mut top: Option<(f32, Entity)> = None;
for (e, t, s) in ctx.world.query::<(Entity, &Transform2D, &Sprite)>().iter() {
let size = s.region.map_or(s.texture.size(), |r| r.size()) * t.scale.abs();
let min = t.position - size * s.pivot;
let rect = Rect { min, max: min + size };
if rect.contains(world_at) && top.is_none_or(|(z, _)| s.z >= z) {
top = Some((s.z, e));
}
}
if let Some((_, e)) = top {
return Some(e);
}
let mut best: Option<(f32, Entity)> = None;
for (e, t) in ctx.world.query::<(Entity, &Transform2D)>().iter() {
let d = camera.world_to_screen(t.position).distance_squared(at);
if d <= PICK_RADIUS * PICK_RADIUS && best.is_none_or(|(b, _)| d < b) {
best = Some((d, e));
}
}
best.map(|(_, e)| e)
}
fn edit_value(ui: &mut Ui, value: &mut Value) -> bool {
match value {
Value::Null => {
ui.label(RichText::new("none").weak());
false
}
Value::Bool(b) => ui.checkbox(b, "").changed(),
Value::Number(n) => edit_number(ui, n),
Value::String(s) => {
let id = ui.next_auto_id();
let mut draft = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| s.clone());
let response = ui.text_edit_singleline(&mut draft);
if response.has_focus() {
ui.data_mut(|d| d.insert_temp(id, draft));
false
} else {
ui.data_mut(|d| d.remove::<String>(id));
let changed = response.lost_focus() && draft != *s;
if changed {
*s = draft;
}
changed
}
}
Value::Array(items) if items.len() <= 4 && items.iter().all(Value::is_number) => {
let mut changed = false;
ui.horizontal(|ui| {
for item in items.iter_mut() {
if let Value::Number(n) = item {
changed |= edit_number(ui, n);
}
}
});
changed
}
Value::Array(items) => {
let mut changed = false;
CollapsingHeader::new(format!("[{}]", items.len()))
.id_salt(ui.next_auto_id())
.show(ui, |ui| {
for (i, item) in items.iter_mut().enumerate() {
ui.push_id(i, |ui| {
ui.horizontal(|ui| {
ui.label(i.to_string());
changed |= edit_value(ui, item);
});
});
}
});
changed
}
Value::Object(fields) => {
let mut changed = false;
egui::Grid::new(ui.next_auto_id())
.num_columns(2)
.striped(true)
.show(ui, |ui| {
for (key, field) in fields.iter_mut() {
ui.label(key);
ui.push_id(key, |ui| changed |= edit_value(ui, field));
ui.end_row();
}
});
changed
}
}
}
fn edit_number(ui: &mut Ui, n: &mut serde_json::Number) -> bool {
if let Some(mut v) = n.as_u64() {
let changed = ui.add(DragValue::new(&mut v)).changed();
*n = v.into();
changed
} else if let Some(mut v) = n.as_i64() {
let changed = ui.add(DragValue::new(&mut v)).changed();
*n = v.into();
changed
} else {
let mut v = n.as_f64().unwrap_or(0.0);
let speed = (v.abs() * 0.01).clamp(0.001, 10.0);
let changed = ui.add(DragValue::new(&mut v).speed(speed)).changed();
if let Some(f) = serde_json::Number::from_f64(v) {
*n = f;
}
changed
}
}
fn systems(ui: &mut Ui, ctx: &Ctx) {
let timings = ctx.system_timings();
if timings.is_empty() {
ui.label(RichText::new("no systems").weak());
return;
}
let top = timings.iter().map(|t| t.avg_ms).fold(0.001, f32::max);
egui::Grid::new("inspector-systems").num_columns(3).show(ui, |ui| {
for t in timings {
let name = t.name.rsplit("::").next().unwrap_or(t.name);
ui.label(if t.sim {
format!("{name} (sim)")
} else {
name.to_owned()
})
.on_hover_text(t.name);
ui.label(format!("{:.3} ms", t.avg_ms));
let (rect, _) = ui.allocate_exact_size(vec2(60.0, 8.0), Sense::hover());
let mut bar = rect;
bar.set_width(rect.width() * t.avg_ms / top);
ui.painter().rect_filled(bar, 1.0, Color32::from_rgb(242, 140, 40));
ui.end_row();
}
});
}
pub(crate) struct Inspector {
pub open: bool,
egui: egui::Context,
input: egui_winit::State,
pub panels: Panels,
}
impl Inspector {
pub fn new(window: &winit::window::Window, max_texture_side: usize) -> Self {
let egui = egui::Context::default();
let input = egui_winit::State::new(
egui.clone(),
egui::ViewportId::ROOT,
window,
Some(window.scale_factor() as f32),
None,
Some(max_texture_side),
);
Self {
open: false,
egui,
input,
panels: Panels::default(),
}
}
pub fn on_event(&mut self, window: &winit::window::Window, event: &winit::event::WindowEvent) -> bool {
use winit::event::{ElementState, MouseButton, WindowEvent};
if !self.open {
return false;
}
let consumed = self.input.on_window_event(window, event).consumed;
if self.panels.picking
&& !self.egui.is_pointer_over_egui()
&& let WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
..
} = event
{
let at = self.egui.input(|i| i.pointer.latest_pos());
self.panels.pick_at = at.map(|p| Vec2::new(p.x, p.y));
return true;
}
let input = matches!(
event,
WindowEvent::KeyboardInput { .. } | WindowEvent::MouseInput { .. } | WindowEvent::MouseWheel { .. }
);
if self.panels.editing && input {
let mouse = !matches!(event, WindowEvent::KeyboardInput { .. });
return consumed || mouse || self.is_editor_key(event);
}
consumed && input
}
fn is_editor_key(&self, event: &winit::event::WindowEvent) -> bool {
use winit::keyboard::{KeyCode, PhysicalKey};
let winit::event::WindowEvent::KeyboardInput { event: key, .. } = event else {
return false;
};
let PhysicalKey::Code(code) = key.physical_key else {
return false;
};
let command = self.egui.input(|i| i.modifiers.command);
match code {
KeyCode::Delete | KeyCode::Backspace | KeyCode::Escape => true,
KeyCode::KeyD | KeyCode::KeyZ => command,
_ => false,
}
}
pub fn frame(&mut self, window: &winit::window::Window, ctx: &mut Ctx) {
ctx.ui.overlay_has_pointer = false;
if !self.open {
return;
}
let raw = self.input.take_egui_input(window);
let panels = &mut self.panels;
let output = self.egui.run_ui(raw, |ui| panels.show(ui, ctx));
ctx.ui.overlay_has_pointer = self.egui.is_pointer_over_egui();
self.input.handle_platform_output(window, output.platform_output);
let primitives = self.egui.tessellate(output.shapes, output.pixels_per_point);
if let Some(renderer) = ctx.renderer.as_mut() {
renderer.set_overlay(Overlay {
primitives,
textures: output.textures_delta,
pixels_per_point: output.pixels_per_point,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Serialize, Deserialize)]
struct Health {
current: f32,
max: u32,
}
struct Secret;
fn run(panels: &mut Panels, ctx: &mut Ctx) -> egui::FullOutput {
let egui = egui::Context::default();
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1280.0, 720.0))),
..Default::default()
};
let mut out = egui.run_ui(input, |ui| panels.show(ui, ctx));
out.textures_delta.clear();
out
}
fn game() -> (Ctx, Entity) {
let mut ctx = Ctx::new(1.0 / 60.0, Vec2::new(1280.0, 720.0));
ctx.world.reflect::<Health>();
ctx.world.reflect::<Transform2D>();
let e = ctx.world.spawn((
Transform2D::at(Vec2::new(10.0, 0.0)),
Health { current: 3.0, max: 10 },
Secret,
));
for i in 0..100 {
ctx.world.spawn((Transform2D::at(Vec2::new(i as f32 * 100.0, 500.0)),));
}
(ctx, e)
}
fn frame(egui: &egui::Context, panels: &mut Panels, ctx: &mut Ctx, mut events: Vec<egui::Event>) {
let command = events
.iter()
.any(|e| matches!(e, egui::Event::Key { modifiers, .. } if modifiers.command));
let held = if command {
egui::Modifiers::COMMAND
} else {
Default::default()
};
events.insert(0, egui::Event::ModifiersChanged(held));
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1280.0, 720.0))),
events,
..Default::default()
};
let mut out = egui.run_ui(input, |ui| panels.show(ui, ctx));
out.textures_delta.clear();
}
fn button(x: f32, y: f32, pressed: bool) -> egui::Event {
egui::Event::PointerButton {
pos: egui::pos2(x, y),
button: egui::PointerButton::Primary,
pressed,
modifiers: Default::default(),
}
}
fn key(key: Key, command: bool) -> egui::Event {
egui::Event::Key {
key,
physical_key: None,
pressed: true,
repeat: false,
modifiers: if command {
egui::Modifiers::COMMAND
} else {
Default::default()
},
}
}
fn edit_scene() -> (egui::Context, Panels, Ctx, Entity) {
let (mut ctx, _) = game();
ctx.pause();
let e = ctx.world.spawn((
Transform2D::at(Vec2::new(300.0, 300.0)),
Health { current: 1.0, max: 1 },
));
let egui = egui::Context::default();
let mut panels = Panels::default();
frame(&egui, &mut panels, &mut ctx, vec![]); (egui, panels, ctx, e)
}
#[test]
fn paused_a_click_selects_a_drag_moves_and_undo_puts_it_back() {
let (egui, mut panels, mut ctx, e) = edit_scene();
let at = |ctx: &Ctx| ctx.world.get::<Transform2D>(e).unwrap().position;
frame(
&egui,
&mut panels,
&mut ctx,
vec![
egui::Event::PointerMoved(egui::pos2(305.0, 302.0)),
button(305.0, 302.0, true),
],
);
assert_eq!(panels.selected, Some(e));
frame(
&egui,
&mut panels,
&mut ctx,
vec![egui::Event::PointerMoved(egui::pos2(355.0, 322.0))],
);
assert_eq!(
at(&ctx),
Vec2::new(350.0, 320.0),
"moved by the drag, not snapped to the cursor"
);
frame(&egui, &mut panels, &mut ctx, vec![button(355.0, 322.0, false)]);
assert!(panels.editor.can_undo());
frame(&egui, &mut panels, &mut ctx, vec![key(Key::Z, true)]);
assert_eq!(at(&ctx), Vec2::new(300.0, 300.0));
}
#[test]
fn paused_keys_duplicate_and_delete_the_selection() {
let (egui, mut panels, mut ctx, e) = edit_scene();
panels.selected = Some(e);
let before = ctx.world.len();
frame(&egui, &mut panels, &mut ctx, vec![key(Key::D, true)]);
let copy = panels.selected.unwrap();
assert_ne!(copy, e);
assert_eq!(
ctx.world.get::<Transform2D>(copy).unwrap().position,
Vec2::new(316.0, 316.0)
);
frame(&egui, &mut panels, &mut ctx, vec![key(Key::Delete, false)]);
assert!(!ctx.world.is_alive(copy) && panels.selected.is_none());
assert_eq!(ctx.world.len(), before);
}
#[test]
fn running_the_world_clicks_go_to_the_game() {
let (egui, mut panels, mut ctx, e) = edit_scene();
ctx.resume();
frame(
&egui,
&mut panels,
&mut ctx,
vec![
egui::Event::PointerMoved(egui::pos2(300.0, 300.0)),
button(300.0, 300.0, true),
],
);
assert_eq!(panels.selected, None);
assert!(!panels.editing);
let _ = e;
}
#[test]
fn play_then_stop_from_the_panels_restores_the_world() {
let (egui, mut panels, mut ctx, e) = edit_scene();
panels.editor.play(&mut ctx);
ctx.world.despawn(e);
frame(&egui, &mut panels, &mut ctx, vec![]);
panels.editor.stop(&mut ctx).unwrap();
assert!(
ctx.world.get::<Health>(e).is_some(),
"back, with the same id (the world was reloaded)"
);
}
#[test]
fn panels_draw_a_selected_entity_with_reflected_and_hidden_components() {
let (mut ctx, e) = game();
let mut panels = Panels {
selected: Some(e),
..Default::default()
};
let out = run(&mut panels, &mut ctx);
assert!(!out.shapes.is_empty());
assert_eq!(panels.selected, Some(e));
assert!(panels.error.is_none());
}
#[test]
fn a_despawned_selection_is_dropped() {
let (mut ctx, e) = game();
let mut panels = Panels {
selected: Some(e),
..Default::default()
};
ctx.world.despawn(e);
let _ = run(&mut panels, &mut ctx);
assert_eq!(panels.selected, None);
}
#[test]
fn rows_cover_every_matching_entity_once() {
let (ctx, e) = game();
let mut rows = Vec::new();
let total = list_rows(&ctx.world, None, &mut rows);
assert_eq!(total, 101);
let listed: HashSet<Entity> = (0..total).filter_map(|r| entity_at(&ctx.world, &rows, r)).collect();
assert_eq!(listed.len(), 101);
assert!(listed.contains(&e));
assert_eq!(entity_at(&ctx.world, &rows, total), None);
let health = ctx.world.component_by_name(std::any::type_name::<Health>()).unwrap();
assert_eq!(list_rows(&ctx.world, Some(health), &mut rows), 1);
assert_eq!(entity_at(&ctx.world, &rows, 0), Some(e));
}
#[test]
fn a_pick_selects_the_nearest_entity_under_the_click() {
let (mut ctx, e) = game();
let at = ctx.camera.world_to_screen(Vec2::new(12.0, 3.0));
let mut panels = Panels {
picking: true,
pick_at: Some(at),
..Default::default()
};
let _ = run(&mut panels, &mut ctx);
assert_eq!(panels.selected, Some(e));
assert!(!panels.picking, "picking ends after one click");
let far = ctx.camera.world_to_screen(Vec2::new(55.0, -300.0));
assert_eq!(pick(&mut ctx, far), None, "nothing within reach");
}
#[test]
fn number_edits_keep_their_kind() {
let egui = egui::Context::default();
let mut value = json!({ "current": 2.5, "max": 7, "offset": -3, "name": "crab", "on": true });
egui.run_ui(egui::RawInput::default(), |ui| {
assert!(!edit_value(ui, &mut value), "nothing changes without input");
})
.drop_without_applying_deltas();
assert_eq!(
value,
json!({ "current": 2.5, "max": 7, "offset": -3, "name": "crab", "on": true })
);
assert!(value["max"].is_u64() && value["offset"].is_i64() && value["current"].is_f64());
}
#[test]
fn frame_history_wraps_around() {
let mut panels = Panels::default();
for i in 0..HISTORY + 10 {
panels.record_frame(i as f32);
}
assert_eq!(panels.frame_ms.len(), HISTORY);
assert_eq!(panels.frame_at, 10);
assert_eq!(panels.frame_ms[9], (HISTORY + 9) as f32);
}
}