Skip to main content

khora_editor/
cmd_palette.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Command palette — Cmd+K modal that runs editor actions.
16//!
17//! Phase J: atomic modal — backdrop + box are painted absolutely, all
18//! interactive controls live inside a single `region_at` so the layout
19//! stays in sync regardless of zoom or window size.
20
21use 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/// A single palette command.
31#[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
134/// Floating modal command palette.
135pub struct CommandPalettePanel {
136    state: Arc<Mutex<EditorState>>,
137    theme: UiTheme,
138    query: String,
139    active: usize,
140    /// Whether the palette was already open last frame, so focus is requested
141    /// exactly once per opening rather than every frame (which would trap it).
142    was_open: bool,
143    /// Points the result list is scrolled down by, tracking the active row.
144    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        // Bail out without painting if not open.
179        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            // Reset the one-shot focus latch so the next open takes focus
187            // again, and clear the query so the palette doesn't reopen showing
188            // the last search.
189            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        // ── Backdrop (full screen, semi-opaque) ───────
200        ui.paint_rect_filled([sx, sy], [sw, sh], with_alpha(theme.background, 0.65), 0.0);
201
202        // ── Modal box ────────────────────────────────
203        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        // Detect click on backdrop EXCLUDING the modal area, so clicking
209        // inside the modal doesn't close the palette (Bug C.5 in v3 plan).
210        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        // Brand glow border
225        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        // Internal soft glow
233        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        // (Backdrop / modal interaction handled above before painting the
242        // modal box — `modal_int.hovered` gates the close-on-outside.)
243
244        // ── Header (input row) ───────────────────────
245        let header_h = 56.0;
246        let pad = 18.0;
247        let input_y = modal_y + (header_h - 28.0) * 0.5;
248
249        // Diamond
250        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        // Esc kbd chip on right
259        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        // Hairline below header
263        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        // ── Filter pre-compute ────────────────────────
271        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        // ── Body region (input + list) ───────────────
289        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        // ── Keyboard navigation ──────────────────────
297        // Read before the field is drawn: the arrows must move the selection
298        // even while the query field holds focus, which is the whole point of
299        // a palette. `key_pressed` would refuse (it suppresses shortcuts while
300        // a field is focused), so the arrows go through the raw key state.
301        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        // Focus the field on the frame the palette opens, once. Requesting it
309        // every frame would trap focus; never requesting it — the old
310        // behaviour — meant the user had to click the box before typing, and
311        // meanwhile every keystroke fell through to the editor's shortcuts.
312        let take_focus = !self.was_open;
313        self.was_open = true;
314
315        // Snapshot before the field borrows it: the row loop below may move the
316        // selection on hover, but the scroll must follow where it is *now*.
317        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        // Input field
326        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                // pick currently-active item
342                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        // List — clipped to the modal body and scrolled to keep the active row
357        // in view.
358        //
359        // It used to run past the modal's bottom edge: the last rows painted
360        // over the footer and then straight onto the workspace behind, so the
361        // dialog looked like it had burst. The list is short today, but it is a
362        // list — its length is not a constant.
363        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        // Follow the keyboard selection rather than the wheel: the palette is
370        // driven from the keyboard, so the view must track the active row.
371        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            // Keep the active row inside the viewport, nudging only as much as
394            // needed so the list doesn't jump on every arrow press.
395            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            // Section header
408            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                // The highlighted row carries the gold selection bar, exactly
425                // like a selected entity or asset — one selection language
426                // across the whole editor. The icon sits bare: a filled plate
427                // per row turns the list into a grid of buttons.
428                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                // Label + desc
455                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        // ── Footer ───────────────────────────────────
493        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        // Apply effects
529        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}