1use std::sync::{Arc, Mutex};
22
23use khora_sdk::editor_ui::*;
24use khora_sdk::KeyCode;
25
26use crate::widgets::brand::paint_diamond_filled;
27use crate::widgets::chrome::paint_kbd_chip;
28use crate::widgets::paint::{paint_icon, paint_text_size, with_alpha};
29
30#[derive(Debug, Clone)]
32struct Command {
33 label: &'static str,
34 description: &'static str,
35 action: &'static str,
36 icon: Icon,
37}
38
39#[derive(Debug, Clone)]
40struct Section {
41 title: &'static str,
42 items: &'static [Command],
43}
44
45const QUICK_ACTIONS: &[Command] = &[
46 Command {
47 label: "Save Scene",
48 description: "Save the current scene file",
49 action: "save",
50 icon: Icon::Cube,
51 },
52 Command {
53 label: "Play",
54 description: "Run scene in editor",
55 action: "play",
56 icon: Icon::Play,
57 },
58 Command {
59 label: "Pause",
60 description: "Pause the running simulation",
61 action: "pause",
62 icon: Icon::Pause,
63 },
64 Command {
65 label: "Stop",
66 description: "Stop simulation, restore scene",
67 action: "stop",
68 icon: Icon::Stop,
69 },
70];
71
72const CREATE_ACTIONS: &[Command] = &[
73 Command {
74 label: "New Scene",
75 description: "Discard current scene and start fresh",
76 action: "new_scene",
77 icon: Icon::Plus,
78 },
79 Command {
80 label: "New Empty Entity",
81 description: "Spawn an empty entity in the scene",
82 action: "spawn_empty",
83 icon: Icon::Plus,
84 },
85 Command {
86 label: "Save Scene As…",
87 description: "Save under a new filename",
88 action: "save_as",
89 icon: Icon::Cube,
90 },
91];
92
93const NAVIGATE_ACTIONS: &[Command] = &[
94 Command {
95 label: "Open Scene…",
96 description: "Open a .kscene file",
97 action: "open",
98 icon: Icon::Folder,
99 },
100 Command {
101 label: "Documentation",
102 description: "Open Khora Engine docs",
103 action: "documentation",
104 icon: Icon::Code,
105 },
106];
107
108const VIEW_ACTIONS: &[Command] = &[Command {
109 label: "Toggle Wireframe",
110 description: "Show scene meshes as edge lines (debug)",
111 action: "toggle_wireframe",
112 icon: Icon::Cube,
113}];
114
115const SECTIONS: &[Section] = &[
116 Section {
117 title: "Quick Actions",
118 items: QUICK_ACTIONS,
119 },
120 Section {
121 title: "Create",
122 items: CREATE_ACTIONS,
123 },
124 Section {
125 title: "Navigate",
126 items: NAVIGATE_ACTIONS,
127 },
128 Section {
129 title: "View",
130 items: VIEW_ACTIONS,
131 },
132];
133
134pub struct CommandPalettePanel {
136 state: Arc<Mutex<EditorState>>,
137 theme: UiTheme,
138 query: String,
139 active: usize,
140 was_open: bool,
143 list_scroll: f32,
145}
146
147impl CommandPalettePanel {
148 pub fn new(state: Arc<Mutex<EditorState>>, theme: UiTheme) -> Self {
149 Self {
150 state,
151 theme,
152 query: String::new(),
153 active: 0,
154 was_open: false,
155 list_scroll: 0.0,
156 }
157 }
158}
159
160fn matches(cmd: &Command, query_lower: &str) -> bool {
161 if query_lower.is_empty() {
162 return true;
163 }
164 cmd.label.to_lowercase().contains(query_lower)
165 || cmd.description.to_lowercase().contains(query_lower)
166}
167
168impl EditorPanel for CommandPalettePanel {
169 fn id(&self) -> &str {
170 "khora.editor.command_palette"
171 }
172
173 fn title(&self) -> &str {
174 "Command Palette"
175 }
176
177 fn ui(&mut self, ui: &mut dyn UiBuilder) {
178 let is_open = self
180 .state
181 .lock()
182 .ok()
183 .map(|s| s.command_palette_open)
184 .unwrap_or(false);
185 if !is_open {
186 self.was_open = false;
190 self.list_scroll = 0.0;
191 self.query.clear();
192 self.active = 0;
193 return;
194 }
195
196 let theme = self.theme.clone();
197 let [sx, sy, sw, sh] = ui.screen_rect();
198
199 ui.paint_rect_filled([sx, sy], [sw, sh], with_alpha(theme.background, 0.65), 0.0);
201
202 let modal_w = 640.0_f32.min(sw - 64.0);
204 let modal_x = sx + (sw - modal_w) * 0.5;
205 let modal_y = sy + sh * 0.14;
206 let modal_h = 480.0_f32.min(sh - modal_y - 32.0);
207
208 let backdrop_int = ui.interact_rect("cmdk-backdrop", [sx, sy, sw, sh]);
211 let modal_int = ui.interact_rect("cmdk-modal-eat", [modal_x, modal_y, modal_w, modal_h]);
212 if backdrop_int.clicked && !modal_int.hovered {
213 if let Ok(mut s) = self.state.lock() {
214 s.command_palette_open = false;
215 }
216 }
217
218 ui.paint_rect_filled(
219 [modal_x, modal_y],
220 [modal_w, modal_h],
221 theme.surface_elevated,
222 theme.radius_xl,
223 );
224 ui.paint_rect_stroke(
226 [modal_x, modal_y],
227 [modal_w, modal_h],
228 with_alpha(theme.primary, 0.25),
229 theme.radius_xl,
230 1.0,
231 );
232 ui.paint_rect_stroke(
234 [modal_x + 1.0, modal_y + 1.0],
235 [modal_w - 2.0, modal_h - 2.0],
236 with_alpha(theme.primary, 0.05),
237 theme.radius_xl - 1.0,
238 1.0,
239 );
240
241 let header_h = 56.0;
246 let pad = 18.0;
247 let input_y = modal_y + (header_h - 28.0) * 0.5;
248
249 paint_diamond_filled(
251 ui,
252 modal_x + pad + 9.0,
253 modal_y + header_h * 0.5,
254 7.0,
255 theme.primary,
256 );
257
258 let esc_x = modal_x + modal_w - pad - 30.0;
260 paint_kbd_chip(ui, [esc_x, input_y + 6.0], "esc", &theme);
261
262 ui.paint_line(
264 [modal_x + pad - 6.0, modal_y + header_h],
265 [modal_x + modal_w - pad + 6.0, modal_y + header_h],
266 with_alpha(theme.separator, 0.55),
267 1.0,
268 );
269
270 let q_lower = self.query.to_lowercase();
272 let mut visible: Vec<(&Section, Vec<&Command>)> = Vec::new();
273 for section in SECTIONS {
274 let items: Vec<&Command> = section
275 .items
276 .iter()
277 .filter(|c| matches(c, &q_lower))
278 .collect();
279 if !items.is_empty() {
280 visible.push((section, items));
281 }
282 }
283 let total: usize = visible.iter().map(|(_, items)| items.len()).sum();
284 if self.active >= total.max(1) {
285 self.active = 0;
286 }
287
288 let body_rect = [
290 modal_x,
291 modal_y + header_h + 4.0,
292 modal_w,
293 modal_h - header_h - 56.0,
294 ];
295
296 if ui.raw_key_pressed(KeyCode::ArrowDown) && total > 0 {
302 self.active = (self.active + 1) % total;
303 }
304 if ui.raw_key_pressed(KeyCode::ArrowUp) && total > 0 {
305 self.active = (self.active + total - 1) % total;
306 }
307
308 let take_focus = !self.was_open;
313 self.was_open = true;
314
315 let active_now = self.active;
318 let list_scroll_prev = self.list_scroll;
319 let query_ref = &mut self.query;
320 let active_ref = &mut self.active;
321 let mut to_dispatch: Option<&'static str> = None;
322 let mut close_after = false;
323 let theme_for_closure = theme.clone();
324
325 let input_rect = [
327 modal_x + pad + 28.0,
328 input_y,
329 modal_w - pad * 2.0 - 80.0,
330 28.0,
331 ];
332 ui.region_at("cmd-palette-input", input_rect, &mut |ui_inner| {
333 ui_inner.text_edit_singleline(query_ref);
334 if take_focus {
335 ui_inner.focus_last_item();
336 }
337 if ui_inner.is_last_item_escape_pressed() {
338 close_after = true;
339 }
340 if ui_inner.is_last_item_enter_pressed() {
341 let mut idx = 0usize;
343 'outer: for (_, items) in &visible {
344 for cmd in items {
345 if idx == *active_ref {
346 to_dispatch = Some(cmd.action);
347 close_after = true;
348 break 'outer;
349 }
350 idx += 1;
351 }
352 }
353 }
354 });
355
356 let row_pitch = 36.0 + 2.0;
364 let content_h = visible
365 .iter()
366 .map(|(_, items)| 18.0 + items.len() as f32 * row_pitch)
367 .sum::<f32>()
368 + 8.0;
369 let active_top = {
372 let mut y = 8.0;
373 let mut seen = 0usize;
374 for (_, items) in &visible {
375 y += 18.0;
376 for _ in items.iter() {
377 if seen == active_now {
378 break;
379 }
380 seen += 1;
381 y += row_pitch;
382 }
383 if seen == active_now {
384 break;
385 }
386 }
387 y
388 };
389 let view_h = body_rect[3];
390 let scroll = if content_h <= view_h {
391 0.0
392 } else {
393 let want_min = (active_top + row_pitch - view_h).max(0.0);
396 let want_max = active_top;
397 list_scroll_prev
398 .clamp(want_min, want_max)
399 .min(content_h - view_h)
400 };
401 self.list_scroll = scroll;
402
403 ui.push_clip_rect(body_rect);
404 let mut idx = 0usize;
405 let mut row_y = body_rect[1] + 8.0 - scroll;
406 for (section, items) in &visible {
407 ui.paint_text_styled(
409 [modal_x + pad, row_y],
410 section.title,
411 10.0,
412 theme.text_muted,
413 FontFamilyHint::Proportional,
414 TextAlign::Left,
415 );
416 row_y += 18.0;
417
418 for cmd in items {
419 let active = idx == *active_ref;
420 let row_x = modal_x + pad - 4.0;
421 let row_w = modal_w - pad * 2.0 + 8.0;
422 let row_h = 36.0;
423
424 if active {
429 ui.paint_rect_filled(
430 [row_x, row_y],
431 [row_w, row_h],
432 theme.surface_active,
433 theme.radius_md,
434 );
435 khora_tool_ui::widgets::paint::selection_bar(
436 ui,
437 [row_x, row_y, row_w, row_h],
438 theme.accent_c,
439 );
440 }
441
442 paint_icon(
443 ui,
444 [row_x + 15.0, row_y + 11.0],
445 cmd.icon,
446 14.0,
447 if active {
448 theme.accent_c
449 } else {
450 theme.primary_dim
451 },
452 );
453
454 paint_text_size(ui, [row_x + 44.0, row_y + 8.0], cmd.label, 13.0, theme.text);
456 ui.paint_text_styled(
457 [row_x + row_w - 8.0, row_y + 11.0],
458 cmd.description,
459 11.0,
460 theme.text_muted,
461 FontFamilyHint::Proportional,
462 TextAlign::Right,
463 );
464
465 let interaction =
466 ui.interact_rect(&format!("cmdk-row-{}", idx), [row_x, row_y, row_w, row_h]);
467 if interaction.hovered {
468 *active_ref = idx;
469 }
470 if interaction.clicked {
471 to_dispatch = Some(cmd.action);
472 close_after = true;
473 }
474
475 row_y += row_h + 1.0;
476 idx += 1;
477 }
478 row_y += 6.0;
479 }
480 if visible.is_empty() {
481 ui.paint_text_styled(
482 [modal_x + modal_w * 0.5, row_y + 30.0],
483 "No commands match.",
484 12.0,
485 theme.text_muted,
486 FontFamilyHint::Proportional,
487 TextAlign::Center,
488 );
489 }
490 ui.pop_clip_rect();
491
492 let footer_y = modal_y + modal_h - 40.0;
494 ui.paint_line(
495 [modal_x, footer_y],
496 [modal_x + modal_w, footer_y],
497 with_alpha(theme.separator, 0.55),
498 1.0,
499 );
500 let mut fx = modal_x + pad;
501 for (chip, text) in [("↑↓", " Navigate"), ("↵", " Select"), ("esc", " Close")] {
502 let after = paint_kbd_chip(ui, [fx, footer_y + 14.0], chip, &theme);
503 paint_text_size(
504 ui,
505 [after + 4.0, footer_y + 14.0],
506 text,
507 10.5,
508 theme.text_muted,
509 );
510 fx = after + 64.0;
511 }
512 let engine_version = self
513 .state
514 .lock()
515 .ok()
516 .and_then(|s| s.project_engine_version.clone())
517 .unwrap_or_else(|| "dev".to_owned());
518 let version_label = format!("khora · v{}", engine_version);
519 ui.paint_text_styled(
520 [modal_x + modal_w - pad, footer_y + 14.0],
521 &version_label,
522 10.0,
523 theme.text_muted,
524 FontFamilyHint::Monospace,
525 TextAlign::Right,
526 );
527
528 let _ = theme_for_closure;
530 if let Some(action) = to_dispatch {
531 if let Ok(mut s) = self.state.lock() {
532 s.pending_menu_action = Some(action.to_owned());
533 }
534 }
535 if close_after {
536 if let Ok(mut s) = self.state.lock() {
537 s.command_palette_open = false;
538 }
539 self.query.clear();
540 self.active = 0;
541 }
542 }
543}