Skip to main content

khora_editor/chrome/
title_bar.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//! Title bar — branded top strip with brand pill, native menus and Cmd+K
16//! search.
17//!
18//! v3 cleanup: only menus / actions that have actual handlers are exposed.
19//! - Menus: `File`, `Edit`, `Help` (each entry routes through
20//!   `EditorState::pending_menu_action` which is dispatched in
21//!   `EditorApp::process_menu_actions`).
22//! - Search pill: opens the Cmd+K command palette.
23//! - The `Object / View / Build / Window` menus, the right-side icon
24//!   buttons (branch / build / bell / share) and the "EF" avatar were
25//!   removed — they had no backing handler.
26
27use std::sync::{Arc, Mutex};
28
29use khora_sdk::editor_ui::*;
30
31use crate::widgets::{
32    brand::paint_brand_pill,
33    chrome::paint_search_pill,
34    paint::{paint_vertical_gradient, with_alpha},
35};
36
37const TITLE_BAR_HEIGHT: f32 = 44.0;
38const SEARCH_MIN_W: f32 = 180.0;
39const SEARCH_PREFERRED_W: f32 = 320.0;
40const SEARCH_HEIGHT: f32 = 28.0;
41/// Below this width we collapse the pill into a single search icon.
42const SEARCH_COLLAPSE_THRESHOLD: f32 = 120.0;
43const MENU_REGION_WIDTH: f32 = 240.0;
44
45/// Top-bar branded strip.
46pub struct TitleBarPanel {
47    state: Arc<Mutex<EditorState>>,
48    theme: UiTheme,
49}
50
51impl TitleBarPanel {
52    pub fn new(state: Arc<Mutex<EditorState>>, theme: UiTheme) -> Self {
53        Self { state, theme }
54    }
55
56    fn open_command_palette(&self) {
57        if let Ok(mut s) = self.state.lock() {
58            s.command_palette_open = true;
59        }
60    }
61}
62
63impl EditorPanel for TitleBarPanel {
64    fn id(&self) -> &str {
65        "khora.editor.title_bar"
66    }
67
68    fn title(&self) -> &str {
69        "Title Bar"
70    }
71
72    fn preferred_size(&self) -> Option<f32> {
73        Some(TITLE_BAR_HEIGHT)
74    }
75
76    fn ui(&mut self, ui: &mut dyn UiBuilder) {
77        let theme = self.theme.clone();
78        let project_name = self
79            .state
80            .lock()
81            .ok()
82            .and_then(|s| s.project_name.clone())
83            .unwrap_or_else(|| "untitled".to_owned());
84
85        let rect = ui.panel_rect();
86        let [x, y, w, h] = rect;
87
88        // ── Background gradient + bottom hairline ────
89        paint_vertical_gradient(ui, rect, theme.surface_elevated, theme.surface, 6);
90        ui.paint_line(
91            [x, y + h],
92            [x + w, y + h],
93            with_alpha(theme.separator, 0.55),
94            1.0,
95        );
96
97        // ── Brand pill ───────────────────────────────
98        let brand_x = x + 14.0;
99        let pill_right =
100            paint_brand_pill(ui, [brand_x, y], h, "KhoraEngine", &project_name, &theme);
101
102        // ── Native egui menus (File / Edit / Help) ───
103        let menus_x = pill_right + 12.0;
104        let menus_y = y + (h - 24.0) * 0.5;
105        let menu_region = [menus_x, menus_y, MENU_REGION_WIDTH, 24.0];
106
107        // Capture closures for dispatch tokens, then route via region_at →
108        // egui menu_button. The inner closures push dispatch tokens by
109        // calling self.dispatch.
110        let state_for_menus = self.state.clone();
111        let dispatch = move |action: &str| {
112            if let Ok(mut s) = state_for_menus.lock() {
113                s.pending_menu_action = Some(action.to_owned());
114            }
115        };
116
117        ui.region_at("titlebar-menus", menu_region, &mut |ui_inner| {
118            ui_inner.horizontal(&mut |ui_inner| {
119                ui_inner.menu_button("File", &mut |m| {
120                    if m.button("New Scene") {
121                        dispatch("new_scene");
122                        m.close_menu();
123                    }
124                    if m.button("Open…") {
125                        dispatch("open");
126                        m.close_menu();
127                    }
128                    m.separator();
129                    if m.button("Save") {
130                        dispatch("save");
131                        m.close_menu();
132                    }
133                    if m.button("Save As…") {
134                        dispatch("save_as");
135                        m.close_menu();
136                    }
137                    m.separator();
138                    if m.button("Quit") {
139                        dispatch("quit");
140                        m.close_menu();
141                    }
142                });
143                // Undo / Redo are absent on purpose: `CommandHistory` is never
144                // fed, so both entries were permanent no-ops. Offering an undo
145                // that silently does nothing is worse than not offering one —
146                // it invites destructive edits the user believes are reversible.
147                ui_inner.menu_button("Edit", &mut |m| {
148                    if m.button("Delete  Del") {
149                        dispatch("delete");
150                        m.close_menu();
151                    }
152                });
153                ui_inner.menu_button("Build", &mut |m| {
154                    if m.button("Build Game…") {
155                        dispatch("build_game");
156                        m.close_menu();
157                    }
158                });
159                ui_inner.menu_button("Help", &mut |m| {
160                    if m.button("Documentation") {
161                        dispatch("documentation");
162                        m.close_menu();
163                    }
164                    if m.button("About Khora Engine") {
165                        dispatch("about");
166                        m.close_menu();
167                    }
168                });
169            });
170        });
171
172        // ── Search pill (right-aligned with overflow guard) ──
173        let right_pad = 14.0;
174        let search_right = x + w - right_pad;
175        let search_left_min = menus_x + MENU_REGION_WIDTH + 12.0;
176        let available = (search_right - search_left_min).max(0.0);
177
178        if available >= SEARCH_MIN_W {
179            // Full pill
180            let search_w = SEARCH_PREFERRED_W.min(available);
181            let search_x = search_right - search_w;
182            let search_y = y + (h - SEARCH_HEIGHT) * 0.5;
183            let (search_int, _) = paint_search_pill(
184                ui,
185                [search_x, search_y],
186                search_w,
187                SEARCH_HEIGHT,
188                "Search commands, assets, entities…",
189                &theme,
190            );
191            if search_int.clicked {
192                self.open_command_palette();
193            }
194        } else if available >= SEARCH_COLLAPSE_THRESHOLD {
195            // Collapsed icon-only button (no pill, just hit area)
196            let icon_x = search_right - 28.0;
197            let icon_y = y + (h - 28.0) * 0.5;
198            let int = ui.interact_rect("titlebar-search-collapsed", [icon_x, icon_y, 28.0, 28.0]);
199            if int.hovered {
200                ui.paint_rect_filled([icon_x, icon_y], [28.0, 28.0], theme.surface_active, 6.0);
201            }
202            crate::widgets::paint::paint_icon(
203                ui,
204                [icon_x + 7.0, icon_y + 7.0],
205                Icon::Search,
206                14.0,
207                theme.text_dim,
208            );
209            if int.clicked {
210                self.open_command_palette();
211            }
212        }
213        // else: not enough room even for the icon — hide entirely.
214    }
215}