Skip to main content

khora_infra/ui/egui/
ui_builder.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//! Concrete [`UiBuilder`] backed by `egui::Ui`.
16
17use khora_core::platform::input::KeyCode;
18use khora_core::ui::editor::ui_builder::{FontFamilyHint, InlineEditEvent, Interaction, TextAlign};
19use khora_core::ui::editor::viewport_texture::ViewportTextureHandle;
20use khora_core::ui::editor::UiBuilder;
21use std::collections::HashMap;
22
23use super::theme::AXIS_COLORS_KEY;
24
25/// Maps a backend-neutral [`FontFamilyHint`] to an egui [`FontId`](egui::FontId).
26///
27/// `Display` and `Icons` resolve to named families installed by `set_fonts`;
28/// both fall back to the proportional family (via egui's own family fallback)
29/// when their face wasn't provided.
30fn font_id_for(family: FontFamilyHint, size: f32) -> egui::FontId {
31    match family {
32        FontFamilyHint::Proportional => egui::FontId::proportional(size),
33        FontFamilyHint::Monospace => egui::FontId::monospace(size),
34        FontFamilyHint::Display => {
35            egui::FontId::new(size, egui::FontFamily::Name("display".into()))
36        }
37        FontFamilyHint::Icons => egui::FontId::new(size, egui::FontFamily::Name("icons".into())),
38    }
39}
40
41/// Wraps `&mut egui::Ui` to implement the abstract [`UiBuilder`] trait.
42pub struct EguiUiBuilder<'a> {
43    ui: &'a mut egui::Ui,
44    /// Shared reference to the viewport texture mapping.
45    viewport_textures: &'a HashMap<ViewportTextureHandle, egui::TextureId>,
46    /// The last widget response (for context menu / double-click queries).
47    last_response: Option<egui::Response>,
48    /// Clip rects saved by `push_clip_rect`, so nesting restores correctly.
49    clip_stack: Vec<egui::Rect>,
50}
51
52/// Maps the engine's [`KeyCode`] to egui's key enum.
53///
54/// Deliberately partial: only the keys the editor's UI actually binds are
55/// listed, so an unmapped key reports "not pressed" instead of silently
56/// matching the wrong one. Extend it as bindings are added.
57fn map_key(key: KeyCode) -> Option<egui::Key> {
58    Some(match key {
59        KeyCode::ArrowUp => egui::Key::ArrowUp,
60        KeyCode::ArrowDown => egui::Key::ArrowDown,
61        KeyCode::ArrowLeft => egui::Key::ArrowLeft,
62        KeyCode::ArrowRight => egui::Key::ArrowRight,
63        KeyCode::Enter => egui::Key::Enter,
64        KeyCode::Escape => egui::Key::Escape,
65        KeyCode::Tab => egui::Key::Tab,
66        KeyCode::Home => egui::Key::Home,
67        KeyCode::End => egui::Key::End,
68        KeyCode::Delete => egui::Key::Delete,
69        KeyCode::Backspace => egui::Key::Backspace,
70        KeyCode::F2 => egui::Key::F2,
71        _ => return None,
72    })
73}
74
75impl<'a> EguiUiBuilder<'a> {
76    /// Creates a new builder wrapping the given egui UI region.
77    pub fn new(
78        ui: &'a mut egui::Ui,
79        viewport_textures: &'a HashMap<ViewportTextureHandle, egui::TextureId>,
80    ) -> Self {
81        Self {
82            ui,
83            viewport_textures,
84            last_response: None,
85            clip_stack: Vec::new(),
86        }
87    }
88
89    /// A painter on the top foreground layer, clipped only to the whole screen.
90    /// Used for overlay affordances (drag ghosts) that must stay visible when
91    /// the cursor leaves the current panel's clip rect.
92    fn overlay_painter(&self) -> egui::Painter {
93        self.ui.ctx().layer_painter(egui::LayerId::new(
94            egui::Order::Foreground,
95            egui::Id::new("khora_overlay"),
96        ))
97    }
98}
99
100fn color_to_egui(c: [f32; 4]) -> egui::Color32 {
101    egui::Color32::from_rgba_unmultiplied(
102        (c[0] * 255.0) as u8,
103        (c[1] * 255.0) as u8,
104        (c[2] * 255.0) as u8,
105        (c[3] * 255.0) as u8,
106    )
107}
108
109impl UiBuilder for EguiUiBuilder<'_> {
110    // ── Text ───────────────────────────────────────────
111
112    fn heading(&mut self, text: &str) {
113        self.ui.heading(text);
114    }
115
116    fn label(&mut self, text: &str) {
117        self.ui.label(text);
118    }
119
120    fn colored_label(&mut self, color: [f32; 4], text: &str) {
121        self.ui.colored_label(color_to_egui(color), text);
122    }
123
124    fn small_label(&mut self, text: &str) {
125        self.ui.small(text);
126    }
127
128    fn monospace(&mut self, text: &str) {
129        self.ui.monospace(text);
130    }
131
132    // ── Interactive ────────────────────────────────────
133
134    fn button(&mut self, text: &str) -> bool {
135        let r = self.ui.button(text);
136        let clicked = r.clicked();
137        self.last_response = Some(r);
138        clicked
139    }
140
141    fn small_button(&mut self, text: &str) -> bool {
142        let r = self.ui.small_button(text);
143        let clicked = r.clicked();
144        self.last_response = Some(r);
145        clicked
146    }
147
148    fn selectable_label(&mut self, active: bool, text: &str) -> bool {
149        let r = self.ui.selectable_label(active, text);
150        let clicked = r.clicked();
151        self.last_response = Some(r);
152        clicked
153    }
154
155    fn selectable_label_double_clicked(&mut self, active: bool, text: &str) -> bool {
156        let r = self.ui.selectable_label(active, text);
157        let double_clicked = r.double_clicked();
158        self.last_response = Some(r);
159        double_clicked
160    }
161
162    fn checkbox(&mut self, checked: &mut bool, text: &str) -> bool {
163        let r = self.ui.checkbox(checked, text);
164        let changed = r.changed();
165        self.last_response = Some(r);
166        changed
167    }
168
169    fn drag_value_f32(&mut self, label: &str, value: &mut f32, speed: f32) -> bool {
170        let inner = self.ui.horizontal(|ui| {
171            ui.label(label);
172            ui.add(egui::DragValue::new(value).speed(speed))
173        });
174        let changed = inner.inner.changed();
175        self.last_response = Some(inner.inner);
176        changed
177    }
178
179    fn slider_f32(&mut self, label: &str, value: &mut f32, min: f32, max: f32) -> bool {
180        let r = self.ui.add(egui::Slider::new(value, min..=max).text(label));
181        let changed = r.changed();
182        self.last_response = Some(r);
183        changed
184    }
185
186    fn text_edit_singleline(&mut self, text: &mut String) -> bool {
187        let r = self.ui.text_edit_singleline(text);
188        let changed = r.changed();
189        // Storing the response is what makes `is_last_item_enter_pressed` and
190        // `is_last_item_escape_pressed` work at all: without it they inspect a
191        // `None` and report `false` forever, which is why Enter and Escape did
192        // nothing in the command palette.
193        self.last_response = Some(r);
194        changed
195    }
196
197    fn vec3_editor(&mut self, label: &str, value: &mut [f32; 3], speed: f32) -> bool {
198        // The axis colour rides on the *letter*, not on a filled badge behind
199        // it. Three saturated badges per row turn a transform-heavy inspector
200        // into a rainbow; a tinted letter says the same thing and lets the
201        // numbers stay the loudest part of the row.
202        //
203        // Colours come from the active theme (stashed by `apply_theme`), so
204        // the inspector's X/Y/Z always match the viewport gizmo's.
205        let axes = self
206            .ui
207            .ctx()
208            .data(|d| d.get_temp::<[egui::Color32; 3]>(egui::Id::new(AXIS_COLORS_KEY)))
209            .unwrap_or([
210                egui::Color32::from_rgb(246, 109, 103),
211                egui::Color32::from_rgb(114, 207, 142),
212                egui::Color32::from_rgb(115, 204, 234),
213            ]);
214
215        let axis_letter = |ui: &mut egui::Ui, ch: &str, color: egui::Color32| {
216            ui.label(
217                egui::RichText::new(ch)
218                    .color(color)
219                    .strong()
220                    .monospace()
221                    .size(10.0),
222            );
223        };
224
225        let inner = self.ui.horizontal(|ui| {
226            if !label.is_empty() {
227                ui.label(label);
228            }
229            let mut changed = false;
230            let mut last = None;
231            for (i, ch) in ["X", "Y", "Z"].iter().enumerate() {
232                axis_letter(ui, ch, axes[i]);
233                let r = ui.add(egui::DragValue::new(&mut value[i]).speed(speed));
234                changed |= r.changed();
235                last = Some(r);
236            }
237            (changed, last)
238        });
239        let (changed, last) = inner.inner;
240        // The Z field is the row's "last item" — a context menu or tooltip
241        // attached after the row lands on the field the user ended on.
242        self.last_response = last;
243        changed
244    }
245
246    fn color_edit(&mut self, label: &str, color: &mut [f32; 4]) -> bool {
247        let inner = self.ui.horizontal(|ui| {
248            ui.label(label);
249            ui.color_edit_button_rgba_unmultiplied(color)
250        });
251        let changed = inner.inner.changed();
252        self.last_response = Some(inner.inner);
253        changed
254    }
255
256    fn combo_box(
257        &mut self,
258        id_salt: &str,
259        label: &str,
260        current: &mut usize,
261        options: &[&str],
262    ) -> bool {
263        let selected_text = options.get(*current).copied().unwrap_or("");
264        let mut changed = false;
265        // Salted explicitly rather than by label: `from_label` derives the id
266        // from the label text, so two combo boxes labelled the same — which the
267        // inspector's generic enum walker produces for every switchable enum —
268        // shared one popup and one open state.
269        let out = egui::ComboBox::new(("khora_combo", id_salt), label)
270            .selected_text(selected_text)
271            .show_ui(self.ui, |ui| {
272                for (i, option) in options.iter().enumerate() {
273                    if ui.selectable_label(i == *current, *option).clicked() {
274                        *current = i;
275                        changed = true;
276                    }
277                }
278            });
279        self.last_response = Some(out.response);
280        changed
281    }
282
283    // ── Layout ─────────────────────────────────────────
284
285    fn horizontal(&mut self, f: &mut dyn FnMut(&mut dyn UiBuilder)) {
286        let vt = self.viewport_textures;
287        self.ui.horizontal(|ui| {
288            let mut nested = EguiUiBuilder::new(ui, vt);
289            f(&mut nested);
290        });
291    }
292
293    fn vertical(&mut self, f: &mut dyn FnMut(&mut dyn UiBuilder)) {
294        let vt = self.viewport_textures;
295        self.ui.vertical(|ui| {
296            let mut nested = EguiUiBuilder::new(ui, vt);
297            f(&mut nested);
298        });
299    }
300
301    fn collapsing(
302        &mut self,
303        header: &str,
304        default_open: bool,
305        f: &mut dyn FnMut(&mut dyn UiBuilder),
306    ) {
307        let vt = self.viewport_textures;
308        egui::CollapsingHeader::new(header)
309            .default_open(default_open)
310            .show(self.ui, |ui| {
311                let mut nested = EguiUiBuilder::new(ui, vt);
312                f(&mut nested);
313            });
314    }
315
316    fn indent(&mut self, id: &str, f: &mut dyn FnMut(&mut dyn UiBuilder)) {
317        let vt = self.viewport_textures;
318        self.ui.indent(id, |ui| {
319            let mut nested = EguiUiBuilder::new(ui, vt);
320            f(&mut nested);
321        });
322    }
323
324    fn scroll_area(&mut self, id: &str, f: &mut dyn FnMut(&mut dyn UiBuilder)) {
325        let vt = self.viewport_textures;
326        egui::ScrollArea::vertical()
327            .id_salt(id)
328            .show(self.ui, |ui| {
329                let mut nested = EguiUiBuilder::new(ui, vt);
330                f(&mut nested);
331            });
332    }
333
334    fn viewport_image(
335        &mut self,
336        handle: ViewportTextureHandle,
337        size: [f32; 2],
338    ) -> Option<[f32; 2]> {
339        let egui_id = self.viewport_textures.get(&handle)?;
340        // IMPORTANT: use `Sense::hover()` rather than click_and_drag here.
341        // egui-winit's `consumed = wants_pointer_input()` short-circuits
342        // every mouse press while the cursor is over a click-sensing
343        // widget — which previously meant clicks/drags on the 3D viewport
344        // were swallowed before the engine's input handler could orbit /
345        // pan the camera. Hover sense still drives `Response::hovered()`,
346        // which is all `viewport_hovered` needs.
347        let image = egui::Image::new(egui::load::SizedTexture::new(
348            *egui_id,
349            egui::vec2(size[0], size[1]),
350        ))
351        .sense(egui::Sense::hover());
352        let response = self.ui.add(image);
353        let min = response.rect.min;
354        self.last_response = Some(response);
355        Some([min.x, min.y])
356    }
357
358    // ── Decoration ─────────────────────────────────────
359
360    fn separator(&mut self) {
361        self.ui.separator();
362    }
363
364    fn spacing(&mut self, points: f32) {
365        self.ui.add_space(points);
366    }
367
368    // ── Interaction ────────────────────────────────────
369
370    fn is_last_item_double_clicked(&self) -> bool {
371        self.last_response
372            .as_ref()
373            .is_some_and(|r| r.double_clicked())
374    }
375
376    fn is_last_item_hovered(&self) -> bool {
377        self.last_response.as_ref().is_some_and(|r| r.hovered())
378    }
379
380    fn is_last_item_enter_pressed(&self) -> bool {
381        self.last_response
382            .as_ref()
383            .is_some_and(|r| r.lost_focus() && self.ui.input(|i| i.key_pressed(egui::Key::Enter)))
384    }
385
386    fn is_last_item_escape_pressed(&self) -> bool {
387        self.last_response
388            .as_ref()
389            .is_some_and(|_| self.ui.input(|i| i.key_pressed(egui::Key::Escape)))
390    }
391
392    fn context_menu_last(&mut self, f: &mut dyn FnMut(&mut dyn UiBuilder)) {
393        // Borrow rather than take — the egui `Response::context_menu` API
394        // takes `&self`, and removing the response from `last_response`
395        // would prevent any follow-up call (`tooltip_for_last`, etc.) from
396        // working in the same widget's lifecycle. Cloning is cheap (it's
397        // mostly Arc-internal in egui).
398        if let Some(response) = self.last_response.clone() {
399            let vt = self.viewport_textures;
400            response.context_menu(|ui| {
401                let mut nested = EguiUiBuilder::new(ui, vt);
402                f(&mut nested);
403            });
404        }
405    }
406
407    fn context_menu_panel(&mut self, f: &mut dyn FnMut(&mut dyn UiBuilder)) {
408        // Allocate remaining space at the bottom of the scroll area so the
409        // context-menu target does not overlap earlier interactive widgets
410        // (which would steal left-clicks from selectable_labels above).
411        let remaining = self.ui.available_size();
412        // Ensure the context-menu area covers at least some space so
413        // right-clicking on any empty part of the panel works.
414        let min_h = remaining.y.max(40.0);
415        let (id, rect) = self.ui.allocate_space(egui::vec2(remaining.x, min_h));
416        let response = self.ui.interact(rect, id, egui::Sense::click());
417        let vt = self.viewport_textures;
418        response.context_menu(|ui| {
419            let mut nested = EguiUiBuilder::new(ui, vt);
420            f(&mut nested);
421        });
422    }
423
424    fn close_menu(&mut self) {
425        self.ui.close();
426    }
427
428    fn menu_button(&mut self, label: &str, f: &mut dyn FnMut(&mut dyn UiBuilder)) {
429        let vt = self.viewport_textures;
430        self.ui.menu_button(label, |ui| {
431            let mut nested = EguiUiBuilder::new(ui, vt);
432            f(&mut nested);
433        });
434    }
435
436    fn paint_line(&mut self, from: [f32; 2], to: [f32; 2], color: [f32; 4], thickness: f32) {
437        self.ui.painter().line_segment(
438            [egui::pos2(from[0], from[1]), egui::pos2(to[0], to[1])],
439            egui::Stroke::new(thickness, color_to_egui(color)),
440        );
441    }
442
443    fn paint_rect_filled(&mut self, min: [f32; 2], size: [f32; 2], color: [f32; 4], rounding: f32) {
444        let rect =
445            egui::Rect::from_min_size(egui::pos2(min[0], min[1]), egui::vec2(size[0], size[1]));
446        let corner = egui::CornerRadius::same(rounding.clamp(0.0, 255.0) as u8);
447        self.ui
448            .painter()
449            .rect_filled(rect, corner, color_to_egui(color));
450    }
451
452    fn paint_text(&mut self, pos: [f32; 2], color: [f32; 4], text: &str) {
453        self.ui.painter().text(
454            egui::pos2(pos[0], pos[1]),
455            egui::Align2::LEFT_TOP,
456            text,
457            egui::FontId::proportional(12.0),
458            color_to_egui(color),
459        );
460    }
461
462    // ── Queries ────────────────────────────────────────
463
464    fn available_width(&self) -> f32 {
465        self.ui.available_width()
466    }
467
468    fn available_height(&self) -> f32 {
469        self.ui.available_height()
470    }
471
472    fn panel_rect(&self) -> [f32; 4] {
473        let r = self.ui.max_rect();
474        [r.min.x, r.min.y, r.width(), r.height()]
475    }
476
477    fn screen_rect(&self) -> [f32; 4] {
478        let r = self.ui.ctx().content_rect();
479        [r.min.x, r.min.y, r.width(), r.height()]
480    }
481
482    fn paint_rect_stroke(
483        &mut self,
484        min: [f32; 2],
485        size: [f32; 2],
486        color: [f32; 4],
487        rounding: f32,
488        thickness: f32,
489    ) {
490        let rect =
491            egui::Rect::from_min_size(egui::pos2(min[0], min[1]), egui::vec2(size[0], size[1]));
492        let corner = egui::CornerRadius::same(rounding.clamp(0.0, 255.0) as u8);
493        self.ui.painter().rect_stroke(
494            rect,
495            corner,
496            egui::Stroke::new(thickness, color_to_egui(color)),
497            egui::epaint::StrokeKind::Inside,
498        );
499    }
500
501    fn paint_circle_filled(&mut self, center: [f32; 2], radius: f32, color: [f32; 4]) {
502        self.ui.painter().circle_filled(
503            egui::pos2(center[0], center[1]),
504            radius,
505            color_to_egui(color),
506        );
507    }
508
509    fn paint_circle_stroke(
510        &mut self,
511        center: [f32; 2],
512        radius: f32,
513        color: [f32; 4],
514        thickness: f32,
515    ) {
516        self.ui.painter().circle_stroke(
517            egui::pos2(center[0], center[1]),
518            radius,
519            egui::Stroke::new(thickness, color_to_egui(color)),
520        );
521    }
522
523    fn paint_text_styled(
524        &mut self,
525        pos: [f32; 2],
526        text: &str,
527        size: f32,
528        color: [f32; 4],
529        family: FontFamilyHint,
530        align: TextAlign,
531    ) {
532        let egui_align = match align {
533            TextAlign::Left => egui::Align2::LEFT_TOP,
534            TextAlign::Center => egui::Align2::CENTER_TOP,
535            TextAlign::Right => egui::Align2::RIGHT_TOP,
536        };
537        let font_id = font_id_for(family, size);
538        self.ui.painter().text(
539            egui::pos2(pos[0], pos[1]),
540            egui_align,
541            text,
542            font_id,
543            color_to_egui(color),
544        );
545    }
546
547    fn paint_path_filled(&mut self, points: &[[f32; 2]], color: [f32; 4]) {
548        if points.len() < 3 {
549            return;
550        }
551        use egui::epaint::{PathShape, PathStroke};
552        let pts: Vec<egui::Pos2> = points.iter().map(|p| egui::pos2(p[0], p[1])).collect();
553        self.ui.painter().add(egui::Shape::Path(PathShape {
554            points: pts,
555            closed: true,
556            fill: color_to_egui(color),
557            stroke: PathStroke::NONE,
558        }));
559    }
560
561    fn interact_rect(&mut self, id_salt: &str, rect: [f32; 4]) -> Interaction {
562        let r =
563            egui::Rect::from_min_size(egui::pos2(rect[0], rect[1]), egui::vec2(rect[2], rect[3]));
564        let id = self.ui.id().with(("khora_hot", id_salt));
565        let response = self.ui.interact(r, id, egui::Sense::click_and_drag());
566        let interaction = Interaction {
567            hovered: response.hovered(),
568            clicked: response.clicked(),
569            pressed: response.is_pointer_button_down_on(),
570            double_clicked: response.double_clicked(),
571            focused: response.has_focus(),
572        };
573        self.last_response = Some(response);
574        interaction
575    }
576
577    fn key_pressed(&self, key: KeyCode) -> bool {
578        // A shortcut must not fire while a text field is taking input, or a
579        // panel's single-key bindings would eat the user's typing.
580        if self.ui.ctx().egui_wants_keyboard_input() {
581            return false;
582        }
583        let Some(egui_key) = map_key(key) else {
584            return false;
585        };
586        self.ui.input(|i| i.key_pressed(egui_key))
587    }
588
589    fn keyboard_captured(&self) -> bool {
590        self.ui.ctx().egui_wants_keyboard_input()
591    }
592
593    fn raw_key_pressed(&self, key: KeyCode) -> bool {
594        let Some(egui_key) = map_key(key) else {
595            return false;
596        };
597        self.ui.input(|i| i.key_pressed(egui_key))
598    }
599
600    fn focus_last_item(&mut self) {
601        if let Some(response) = self.last_response.as_ref() {
602            response.request_focus();
603        }
604    }
605
606    fn push_clip_rect(&mut self, rect: [f32; 4]) {
607        let r =
608            egui::Rect::from_min_size(egui::pos2(rect[0], rect[1]), egui::vec2(rect[2], rect[3]));
609        // Intersect rather than replace: a nested clip must never widen the
610        // region its parent already restricted.
611        self.clip_stack.push(self.ui.clip_rect());
612        let clipped = self.ui.clip_rect().intersect(r);
613        self.ui.set_clip_rect(clipped);
614    }
615
616    fn pop_clip_rect(&mut self) {
617        if let Some(previous) = self.clip_stack.pop() {
618            self.ui.set_clip_rect(previous);
619        }
620    }
621
622    fn scroll_delta_in(&self, rect: [f32; 4]) -> f32 {
623        let r =
624            egui::Rect::from_min_size(egui::pos2(rect[0], rect[1]), egui::vec2(rect[2], rect[3]));
625        let inside = self
626            .ui
627            .ctx()
628            .pointer_latest_pos()
629            .is_some_and(|p| r.contains(p));
630        if !inside {
631            return 0.0;
632        }
633        self.ui.input(|i| i.smooth_scroll_delta.y)
634    }
635
636    fn dnd_attach_drag_payload(&mut self, payload: u64) {
637        // Attach to the response left by the last `interact_rect` call.
638        // That response was created with `Sense::click_and_drag()`, so
639        // egui already detects drags — we just need to publish the payload.
640        if let Some(response) = self.last_response.as_ref() {
641            if response.dragged() {
642                response.dnd_set_drag_payload::<u64>(payload);
643            }
644        }
645    }
646
647    fn dnd_take_drop_payload(&mut self) -> Option<u64> {
648        self.last_response
649            .as_ref()
650            .and_then(|r| r.dnd_release_payload::<u64>())
651            .map(|payload| *payload)
652    }
653
654    fn pointer_position(&self) -> Option<[f32; 2]> {
655        self.ui.ctx().pointer_interact_pos().map(|p| [p.x, p.y])
656    }
657
658    fn is_last_item_dragged(&self) -> bool {
659        self.last_response
660            .as_ref()
661            .map(|r| r.dragged())
662            .unwrap_or(false)
663    }
664
665    fn is_drag_active(&self) -> bool {
666        self.ui.ctx().dragged_id().is_some()
667    }
668
669    fn inline_text_field(
670        &mut self,
671        rect: [f32; 4],
672        id_salt: &str,
673        text: &mut String,
674        request_focus: bool,
675    ) -> InlineEditEvent {
676        let r =
677            egui::Rect::from_min_size(egui::pos2(rect[0], rect[1]), egui::vec2(rect[2], rect[3]));
678        let mut child = self.ui.new_child(
679            egui::UiBuilder::new()
680                .max_rect(r)
681                .id_salt(("khora_inline", id_salt))
682                .layout(egui::Layout::top_down(egui::Align::Min)),
683        );
684        let field_id = egui::Id::new(("khora_inline_edit", id_salt));
685        let resp = child.add(
686            egui::TextEdit::singleline(text)
687                .id(field_id)
688                .desired_width(rect[2]),
689        );
690        if request_focus {
691            resp.request_focus();
692        }
693        if resp.lost_focus() {
694            // Escape cancels; Enter or a click elsewhere commits (commit-on-blur
695            // — the standard file-explorer rename behaviour, and it avoids a
696            // stuck field if the user clicks away).
697            let escaped = child.input(|i| i.key_pressed(egui::Key::Escape));
698            return if escaped {
699                InlineEditEvent::Cancelled
700            } else {
701                InlineEditEvent::Committed
702            };
703        }
704        if resp.changed() {
705            InlineEditEvent::Changed
706        } else {
707            InlineEditEvent::Idle
708        }
709    }
710
711    fn overlay_rect_filled(
712        &mut self,
713        min: [f32; 2],
714        size: [f32; 2],
715        color: [f32; 4],
716        rounding: f32,
717    ) {
718        let painter = self.overlay_painter();
719        let rect =
720            egui::Rect::from_min_size(egui::pos2(min[0], min[1]), egui::vec2(size[0], size[1]));
721        let corner = egui::CornerRadius::same(rounding.clamp(0.0, 255.0) as u8);
722        painter.rect_filled(rect, corner, color_to_egui(color));
723    }
724
725    fn overlay_rect_stroke(
726        &mut self,
727        min: [f32; 2],
728        size: [f32; 2],
729        color: [f32; 4],
730        rounding: f32,
731        thickness: f32,
732    ) {
733        let painter = self.overlay_painter();
734        let rect =
735            egui::Rect::from_min_size(egui::pos2(min[0], min[1]), egui::vec2(size[0], size[1]));
736        let corner = egui::CornerRadius::same(rounding.clamp(0.0, 255.0) as u8);
737        painter.rect_stroke(
738            rect,
739            corner,
740            egui::Stroke::new(thickness, color_to_egui(color)),
741            egui::epaint::StrokeKind::Inside,
742        );
743    }
744
745    fn overlay_text(
746        &mut self,
747        pos: [f32; 2],
748        text: &str,
749        size: f32,
750        color: [f32; 4],
751        family: FontFamilyHint,
752    ) {
753        let painter = self.overlay_painter();
754        let font_id = font_id_for(family, size);
755        painter.text(
756            egui::pos2(pos[0], pos[1]),
757            egui::Align2::LEFT_TOP,
758            text,
759            font_id,
760            color_to_egui(color),
761        );
762    }
763
764    fn tooltip_for_last(&mut self, text: &str) {
765        if let Some(response) = self.last_response.as_ref() {
766            response.clone().on_hover_text(text);
767        }
768    }
769
770    fn region_at(&mut self, id_salt: &str, rect: [f32; 4], f: &mut dyn FnMut(&mut dyn UiBuilder)) {
771        let r =
772            egui::Rect::from_min_size(egui::pos2(rect[0], rect[1]), egui::vec2(rect[2], rect[3]));
773        let vt = self.viewport_textures;
774        // Salted by name, not by position. Deriving the id from the rect's
775        // screen coordinates meant moving a panel by one pixel — a splitter
776        // drag, a window resize — renumbered every widget inside, so egui
777        // dropped focus and edit state mid-typing; and two regions landing on
778        // the same integer coordinate collided outright.
779        let id_salt = ("khora_region", id_salt);
780        let mut child = self.ui.new_child(
781            egui::UiBuilder::new()
782                .max_rect(r)
783                .id_salt(id_salt)
784                .layout(egui::Layout::top_down(egui::Align::Min)),
785        );
786        let mut nested = EguiUiBuilder::new(&mut child, vt);
787        f(&mut nested);
788    }
789
790    fn cursor_pos(&self) -> [f32; 2] {
791        let p = self.ui.next_widget_position();
792        [p.x, p.y]
793    }
794
795    fn allocate_size(&mut self, size: [f32; 2]) -> [f32; 4] {
796        let (rect, _) = self
797            .ui
798            .allocate_exact_size(egui::vec2(size[0], size[1]), egui::Sense::hover());
799        [rect.min.x, rect.min.y, rect.width(), rect.height()]
800    }
801
802    fn measure_text(&self, text: &str, size: f32, family: FontFamilyHint) -> [f32; 2] {
803        let font_id = font_id_for(family, size);
804        // Use the painter's helper to lay out text — handles fonts atlas
805        // mutability internally in egui 0.33.
806        let galley =
807            self.ui
808                .painter()
809                .layout_no_wrap(text.to_owned(), font_id, egui::Color32::WHITE);
810        let r = galley.rect;
811        [r.width(), r.height()]
812    }
813
814    // ── Inset panels (Phase 7) ─────────────────────────
815
816    fn top_inset_panel(&mut self, id: &str, height: f32, f: &mut dyn FnMut(&mut dyn UiBuilder)) {
817        let vt = self.viewport_textures;
818        egui::Panel::top(egui::Id::new(id.to_owned()))
819            .exact_size(height)
820            .resizable(false)
821            .frame(egui::Frame::new())
822            .show_inside(self.ui, |ui| {
823                let mut nested = EguiUiBuilder::new(ui, vt);
824                f(&mut nested);
825            });
826    }
827
828    fn bottom_inset_panel(&mut self, id: &str, height: f32, f: &mut dyn FnMut(&mut dyn UiBuilder)) {
829        let vt = self.viewport_textures;
830        egui::Panel::bottom(egui::Id::new(id.to_owned()))
831            .exact_size(height)
832            .resizable(false)
833            .frame(egui::Frame::new())
834            .show_inside(self.ui, |ui| {
835                let mut nested = EguiUiBuilder::new(ui, vt);
836                f(&mut nested);
837            });
838    }
839
840    fn left_inset_panel(&mut self, id: &str, width: f32, f: &mut dyn FnMut(&mut dyn UiBuilder)) {
841        let vt = self.viewport_textures;
842        egui::Panel::left(egui::Id::new(id.to_owned()))
843            .exact_size(width)
844            .resizable(false)
845            .frame(egui::Frame::new())
846            .show_inside(self.ui, |ui| {
847                let mut nested = EguiUiBuilder::new(ui, vt);
848                f(&mut nested);
849            });
850    }
851
852    fn right_inset_panel(&mut self, id: &str, width: f32, f: &mut dyn FnMut(&mut dyn UiBuilder)) {
853        let vt = self.viewport_textures;
854        egui::Panel::right(egui::Id::new(id.to_owned()))
855            .exact_size(width)
856            .resizable(false)
857            .frame(egui::Frame::new())
858            .show_inside(self.ui, |ui| {
859                let mut nested = EguiUiBuilder::new(ui, vt);
860                f(&mut nested);
861            });
862    }
863
864    fn central_inset(&mut self, f: &mut dyn FnMut(&mut dyn UiBuilder)) {
865        let vt = self.viewport_textures;
866        egui::CentralPanel::default()
867            .frame(egui::Frame::new())
868            .show_inside(self.ui, |ui| {
869                let mut nested = EguiUiBuilder::new(ui, vt);
870                f(&mut nested);
871            });
872    }
873
874    fn modal(&mut self, id: &str, size: [f32; 2], f: &mut dyn FnMut(&mut dyn UiBuilder)) {
875        self.show_modal_dialog(id, size, f);
876    }
877
878    fn frame_box(
879        &mut self,
880        margin: khora_core::ui::Margin,
881        fill: Option<khora_core::math::LinearRgba>,
882        stroke: khora_core::ui::Stroke,
883        radius: khora_core::ui::CornerRadius,
884        f: &mut dyn FnMut(&mut dyn UiBuilder),
885    ) {
886        let mut frame = egui::Frame::new()
887            .inner_margin(egui::Margin {
888                left: margin.left as i8,
889                right: margin.right as i8,
890                top: margin.top as i8,
891                bottom: margin.bottom as i8,
892            })
893            .corner_radius(egui::CornerRadius {
894                nw: radius.nw as u8,
895                ne: radius.ne as u8,
896                sw: radius.sw as u8,
897                se: radius.se as u8,
898            });
899        if let Some(fill_color) = fill {
900            frame = frame.fill(linear_to_color(fill_color));
901        }
902        if stroke.width > 0.0 {
903            frame = frame.stroke(egui::Stroke::new(
904                stroke.width,
905                linear_to_color(stroke.color),
906            ));
907        }
908        let vt = self.viewport_textures;
909        frame.show(self.ui, |ui| {
910            let mut nested = EguiUiBuilder::new(ui, vt);
911            f(&mut nested);
912        });
913    }
914}
915
916fn linear_to_color(c: khora_core::math::LinearRgba) -> egui::Color32 {
917    color_to_egui([c.r, c.g, c.b, c.a])
918}
919
920impl EguiUiBuilder<'_> {
921    /// Modal implementation lives outside the `UiBuilder` impl block so
922    /// `Self::ui` can be re-borrowed across the egui `Window::show`
923    /// call without colliding with the trait method's generics.
924    fn show_modal_dialog(
925        &mut self,
926        id: &str,
927        size: [f32; 2],
928        f: &mut dyn FnMut(&mut dyn UiBuilder),
929    ) {
930        let ctx = self.ui.ctx().clone();
931        let vt_clone = self.viewport_textures.clone();
932
933        // Backdrop: dim the whole screen with a foreground-layer
934        // painter. The egui Window itself sits in `Order::Foreground`
935        // so the backdrop must be just *under* it; we use
936        // `Order::Middle` so other content sinks below.
937        let screen = ctx.input(|i| i.viewport().inner_rect.unwrap_or(egui::Rect::ZERO));
938        let layer = egui::LayerId::new(
939            egui::Order::Middle,
940            egui::Id::new(format!("{}_backdrop", id)),
941        );
942        let painter = egui::Painter::new(ctx.clone(), layer, screen);
943        painter.rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140));
944
945        let window_id = egui::Id::new(format!("{}_modal", id));
946        egui::Window::new("")
947            .id(window_id)
948            .title_bar(false)
949            .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
950            .fixed_size([size[0], size[1]])
951            .resizable(false)
952            .collapsible(false)
953            .frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(0)))
954            .show(&ctx, |ui| {
955                let mut nested = EguiUiBuilder::new(ui, &vt_clone);
956                f(&mut nested);
957            });
958    }
959}