Skip to main content

khora_infra/ui/egui/
shell.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 [`EditorShell`] backed by egui native panels.
16//!
17//! Generic host: the shell knows nothing about Khora branding, menus, toolbars
18//! or status-bar contents. It just applies a theme, lays out the slots
19//! defined by [`PanelLocation`], and forwards each slot's `egui::Ui` to the
20//! application-supplied [`EditorPanel`]s.
21//!
22//! Slot layout (all registered panels are routed here):
23//! ```text
24//! ┌──────────────────────────────────────────────────────┐
25//! │  TopBar (stack, fixed height)                        │
26//! ├────────┬───────────────────────────┬─────────────────┤
27//! │ Spine  │                           │                 │
28//! │ (fixed │     Center                │   Right         │
29//! │ width) │                           │   (resizable)   │
30//! │        ├───────────────────────────┴─────────────────┤
31//! │   +    │     Bottom (resizable, tabbed)              │
32//! │  Left  ├──────────────────────────────────────────────┤
33//! │        │     StatusBar (stack, fixed height)         │
34//! └────────┴──────────────────────────────────────────────┘
35//! ```
36
37use super::theme::apply_theme;
38use super::ui_builder::EguiUiBuilder;
39use khora_core::ui::editor::panel::{EditorPanel, PanelLocation};
40use khora_core::ui::editor::shell::EditorShell;
41use khora_core::ui::editor::state::{EditorState, StatusBarData};
42use khora_core::ui::editor::viewport_texture::ViewportTextureHandle;
43use khora_core::ui::fonts::{FontHandle, FontPack, NamedFont};
44use khora_core::ui::UiTheme;
45use std::collections::HashMap;
46use std::sync::{Arc, Mutex};
47
48/// Installs each [`NamedFont`] into `definitions`, registering it as the
49/// primary face for `family` (or as a fallback at the front of the list).
50fn install_named(defs: &mut egui::FontDefinitions, family: egui::FontFamily, list: Vec<NamedFont>) {
51    for named in list.into_iter() {
52        let key = named.name.clone();
53        let bytes = match named.data {
54            FontHandle::Static(s) => egui::FontData::from_static(s),
55            FontHandle::Owned(v) => egui::FontData::from_owned(v),
56        };
57        defs.font_data
58            .insert(key.clone(), std::sync::Arc::new(bytes));
59        defs.families
60            .entry(family.clone())
61            .or_default()
62            .insert(0, key);
63    }
64}
65
66/// Appends `fallback`'s font keys after whatever was installed for the named
67/// family, so requesting `FontFamily::Name(name)` always resolves to glyphs
68/// even when no dedicated face was supplied for it.
69fn ensure_family_fallback(
70    defs: &mut egui::FontDefinitions,
71    name: &str,
72    fallback: &egui::FontFamily,
73) {
74    let fallback_keys = defs.families.get(fallback).cloned().unwrap_or_default();
75    let entry = defs
76        .families
77        .entry(egui::FontFamily::Name(name.into()))
78        .or_default();
79    for key in fallback_keys {
80        if !entry.contains(&key) {
81            entry.push(key);
82        }
83    }
84}
85
86/// Fixed-height/width slots — same on every screen.
87const DEFAULT_TOPBAR_HEIGHT: f32 = 32.0;
88const DEFAULT_SPINE_WIDTH: f32 = 56.0;
89const DEFAULT_STATUSBAR_HEIGHT: f32 = 24.0;
90
91/// Proportional defaults relative to screen size. The [`EditorPanel`]'s own
92/// `preferred_size` is treated as a fallback for very small screens — the
93/// proportional rule wins above the per-panel minimum so the dock looks the
94/// same on a 1080p laptop and a 4K display.
95const LEFT_FRAC: f32 = 0.16;
96const RIGHT_FRAC: f32 = 0.20;
97const BOTTOM_FRAC: f32 = 0.26;
98
99/// Hard minimums — below these widths panel content begins to collide. The
100/// resize handles still let the user shrink to these limits.
101const LEFT_MIN: f32 = 240.0;
102const LEFT_MAX_FRAC: f32 = 0.35;
103const RIGHT_MIN: f32 = 260.0;
104const RIGHT_MAX_FRAC: f32 = 0.40;
105const BOTTOM_MIN: f32 = 140.0;
106const BOTTOM_MAX_FRAC: f32 = 0.55;
107
108/// A floating panel keeps its z-order so the shell can paint them top-down.
109struct FloatingEntry {
110    z: i32,
111    panel: Box<dyn EditorPanel>,
112}
113
114/// Cached, one-shot proportional defaults — computed the first frame the
115/// shell sees a non-zero screen rect and never recomputed. egui persists
116/// the user's resize between frames; recomputing the default each frame
117/// would risk overriding it (and was the source of the "panels snap back"
118/// bug users reported earlier).
119#[derive(Default, Clone, Copy)]
120struct CachedDefaults {
121    left_w: Option<f32>,
122    right_w: Option<f32>,
123    bottom_h: Option<f32>,
124}
125
126/// Egui-backed editor shell using native `SidePanel`, `TopBottomPanel`, and
127/// `CentralPanel` for the dock layout.
128pub struct EguiEditorShell {
129    ctx: egui::Context,
130    top_panels: Vec<Box<dyn EditorPanel>>,
131    spine_panels: Vec<Box<dyn EditorPanel>>,
132    left_panels: Vec<Box<dyn EditorPanel>>,
133    right_panels: Vec<Box<dyn EditorPanel>>,
134    bottom_panels: Vec<Box<dyn EditorPanel>>,
135    status_panels: Vec<Box<dyn EditorPanel>>,
136    center_panels: Vec<Box<dyn EditorPanel>>,
137    floating_panels: Vec<FloatingEntry>,
138    theme: UiTheme,
139    theme_applied: bool,
140    active_bottom_tab: usize,
141    /// Maps abstract viewport handles to egui texture IDs.
142    viewport_textures: HashMap<ViewportTextureHandle, egui::TextureId>,
143    /// Status-bar snapshot. Kept here so shells that want to surface it to a
144    /// debug overlay can; the actual status-bar UI lives in editor-side panels.
145    status: StatusBarData,
146    editor_state: Option<Arc<Mutex<EditorState>>>,
147    defaults: CachedDefaults,
148}
149
150impl EguiEditorShell {
151    /// Creates a new shell using the given egui context (shared with `EguiOverlay`).
152    pub fn new(ctx: egui::Context, theme: UiTheme) -> Self {
153        Self {
154            ctx,
155            top_panels: Vec::new(),
156            spine_panels: Vec::new(),
157            left_panels: Vec::new(),
158            right_panels: Vec::new(),
159            bottom_panels: Vec::new(),
160            status_panels: Vec::new(),
161            center_panels: Vec::new(),
162            floating_panels: Vec::new(),
163            theme,
164            theme_applied: false,
165            active_bottom_tab: 0,
166            viewport_textures: HashMap::new(),
167            status: StatusBarData::default(),
168            editor_state: None,
169            defaults: CachedDefaults::default(),
170        }
171    }
172
173    /// Registers an abstract viewport handle → egui texture ID mapping.
174    ///
175    /// Called by the render system after `overlay.register_viewport_texture()`.
176    pub fn register_viewport_texture(
177        &mut self,
178        handle: ViewportTextureHandle,
179        egui_id: egui::TextureId,
180    ) {
181        self.viewport_textures.insert(handle, egui_id);
182    }
183
184    /// Returns the egui texture ID for a given viewport handle, if registered.
185    pub fn resolve_viewport_texture(
186        &self,
187        handle: ViewportTextureHandle,
188    ) -> Option<egui::TextureId> {
189        self.viewport_textures.get(&handle).copied()
190    }
191
192    /// Returns the most recent [`StatusBarData`] passed via [`set_status`].
193    /// Editor-side status-bar panels read this through their own state, but
194    /// debug shells / tests can introspect it here.
195    pub fn last_status(&self) -> &StatusBarData {
196        &self.status
197    }
198}
199
200impl EditorShell for EguiEditorShell {
201    fn register_panel(&mut self, location: PanelLocation, panel: Box<dyn EditorPanel>) {
202        log::info!(
203            "EditorShell: registered panel '{}' at {:?}",
204            panel.id(),
205            location
206        );
207        match location {
208            PanelLocation::TopBar => self.top_panels.push(panel),
209            PanelLocation::Spine => self.spine_panels.push(panel),
210            PanelLocation::Left => self.left_panels.push(panel),
211            PanelLocation::Right => self.right_panels.push(panel),
212            PanelLocation::Bottom => self.bottom_panels.push(panel),
213            PanelLocation::StatusBar => self.status_panels.push(panel),
214            PanelLocation::Center => self.center_panels.push(panel),
215            PanelLocation::Floating(z) => self.floating_panels.push(FloatingEntry { z, panel }),
216        }
217        // Keep floating panels sorted bottom-to-top so painting respects z-order.
218        if !self.floating_panels.is_empty() {
219            self.floating_panels.sort_by_key(|e| e.z);
220        }
221    }
222
223    fn remove_panel(&mut self, id: &str) -> bool {
224        let remove_from = |v: &mut Vec<Box<dyn EditorPanel>>| -> bool {
225            if let Some(pos) = v.iter().position(|p| p.id() == id) {
226                v.remove(pos);
227                true
228            } else {
229                false
230            }
231        };
232        if remove_from(&mut self.top_panels)
233            || remove_from(&mut self.spine_panels)
234            || remove_from(&mut self.left_panels)
235            || remove_from(&mut self.right_panels)
236            || remove_from(&mut self.bottom_panels)
237            || remove_from(&mut self.status_panels)
238            || remove_from(&mut self.center_panels)
239        {
240            return true;
241        }
242        if let Some(pos) = self.floating_panels.iter().position(|e| e.panel.id() == id) {
243            self.floating_panels.remove(pos);
244            return true;
245        }
246        false
247    }
248
249    fn set_theme(&mut self, theme: UiTheme) {
250        self.theme = theme;
251        self.theme_applied = false;
252    }
253
254    fn set_fonts(&mut self, fonts: FontPack) {
255        if fonts.is_empty() {
256            return;
257        }
258
259        let mut definitions = egui::FontDefinitions::default();
260        install_named(
261            &mut definitions,
262            egui::FontFamily::Proportional,
263            fonts.proportional,
264        );
265        install_named(
266            &mut definitions,
267            egui::FontFamily::Monospace,
268            fonts.monospace,
269        );
270        install_named(
271            &mut definitions,
272            egui::FontFamily::Name("display".into()),
273            fonts.display,
274        );
275        install_named(
276            &mut definitions,
277            egui::FontFamily::Name("icons".into()),
278            fonts.icons,
279        );
280        // Both named families must always resolve: epaint *panics* when text
281        // asks for a `FontFamily::Name` bound to no fonts. The proportional
282        // fallback means a missing Fraunces degrades to Geist and a missing
283        // icon font degrades to tofu — never to a crash.
284        ensure_family_fallback(&mut definitions, "display", &egui::FontFamily::Proportional);
285        ensure_family_fallback(&mut definitions, "icons", &egui::FontFamily::Proportional);
286        self.ctx.set_fonts(definitions);
287    }
288
289    fn set_status(&mut self, data: StatusBarData) {
290        self.status = data;
291    }
292
293    fn set_editor_state(&mut self, state: Arc<Mutex<EditorState>>) {
294        self.editor_state = Some(state);
295    }
296
297    fn show_frame(&mut self) {
298        // Apply theme once (or when changed).
299        if !self.theme_applied {
300            apply_theme(&self.ctx, &self.theme);
301            // Snap pixels_per_point to the nearest 0.5 multiple. Fractional
302            // DPI scales (e.g. 1.25) cause Geist glyphs to render at sub-pixel
303            // positions and look soft. Snapping keeps integer scale on most
304            // monitors and a clean half-pixel offset on the rest.
305            let raw = self
306                .ctx
307                .input(|i| i.viewport().native_pixels_per_point)
308                .unwrap_or(1.0);
309            let snapped = ((raw * 2.0).round() / 2.0).max(1.0);
310            self.ctx.set_pixels_per_point(snapped);
311            self.theme_applied = true;
312        }
313
314        // Cheap Arc clone — avoids borrow conflicts between the panel calls and
315        // `&mut self` field accesses.
316        let ctx = self.ctx.clone();
317        let vt = &self.viewport_textures;
318
319        // Panels nest inside a `Ui`; there is no top-level-against-a-`Context`
320        // form any more. `Context::run_ui` builds this root for apps that let
321        // egui drive the pass, but this overlay drives `begin_pass` /
322        // `end_pass` itself so the wgpu render can sit between them — so we
323        // build the same root here, exactly as `run_ui` does.
324        let mut root_ui = egui::Ui::new(
325            ctx.clone(),
326            egui::Id::new((ctx.viewport_id(), "__top_ui")),
327            egui::UiBuilder::new()
328                .layer_id(egui::LayerId::background())
329                // `content_rect`, not the deprecated `available_rect`: the
330                // former is the area safe to draw content in (it excludes OS
331                // notches and status bars), and it is what the proportional
332                // panel defaults below already measure against.
333                .max_rect(ctx.content_rect()),
334        );
335        let root_ui = &mut root_ui;
336
337        // No mode special-case here any more. The application hosts its
338        // working panels in a dock that keeps one layout per workspace, so
339        // switching modes swaps the whole arrangement — the shell used to
340        // reach into `EditorState::active_mode` to hide one slot, which put
341        // application knowledge inside a host that is meant to be generic.
342
343        // Compute proportional defaults the FIRST frame we see a real
344        // screen rect, then cache them. Recomputing each frame would risk
345        // overwriting the user's resize — egui persists `PanelState` and
346        // checks our `default_width` only when no state exists, but feeding
347        // it shifting values frame-to-frame is brittle and was reported as
348        // panels "snapping back" after a resize.
349        let screen = ctx.content_rect();
350        let screen_w = screen.width();
351        let screen_h = screen.height();
352        if self.defaults.left_w.is_none() && screen_w > 200.0 && screen_h > 200.0 {
353            self.defaults.left_w = Some((screen_w * LEFT_FRAC).max(LEFT_MIN));
354            self.defaults.right_w = Some((screen_w * RIGHT_FRAC).max(RIGHT_MIN));
355            self.defaults.bottom_h = Some((screen_h * BOTTOM_FRAC).max(BOTTOM_MIN));
356        }
357        let left_default = self.defaults.left_w.unwrap_or(LEFT_MIN);
358        let right_default = self.defaults.right_w.unwrap_or(RIGHT_MIN);
359        let bottom_default = self.defaults.bottom_h.unwrap_or(BOTTOM_MIN);
360        // Max widths stay proportional to the current screen so giant
361        // monitors aren't artificially capped.
362        let left_max = (screen_w * LEFT_MAX_FRAC).max(LEFT_MIN + 60.0);
363        let right_max = (screen_w * RIGHT_MAX_FRAC).max(RIGHT_MIN + 60.0);
364        let bottom_max = (screen_h * BOTTOM_MAX_FRAC).max(BOTTOM_MIN + 80.0);
365
366        // ── Top bar(s) ─────────────────────────────────
367        // Each TopBar panel becomes its own fixed-height TopBottomPanel,
368        // stacked in registration order from the top down.
369        for (idx, panel) in self.top_panels.iter_mut().enumerate() {
370            let height = panel.preferred_size().unwrap_or(DEFAULT_TOPBAR_HEIGHT);
371            let panel_id = format!("editor_topbar_{}_{}", idx, panel.id());
372            egui::Panel::top(panel_id)
373                .exact_size(height)
374                .resizable(false)
375                .show_inside(root_ui, |ui| {
376                    let mut builder = EguiUiBuilder::new(ui, vt);
377                    panel.ui(&mut builder);
378                });
379        }
380
381        // ── Status bar(s) ──────────────────────────────
382        // Stacked from the bottom up. Declared BEFORE the resizable Bottom so
383        // egui reserves vertical space for it correctly.
384        for (idx, panel) in self.status_panels.iter_mut().enumerate() {
385            let height = panel.preferred_size().unwrap_or(DEFAULT_STATUSBAR_HEIGHT);
386            let panel_id = format!("editor_statusbar_{}_{}", idx, panel.id());
387            egui::Panel::bottom(panel_id)
388                .exact_size(height)
389                .resizable(false)
390                .show_inside(root_ui, |ui| {
391                    let mut builder = EguiUiBuilder::new(ui, vt);
392                    panel.ui(&mut builder);
393                });
394        }
395
396        // ── Spine (fixed left strip) ──────────────────
397        // First spine panel wins (the layout assumes one spine). Additional
398        // spine panels are rendered as a vertical stack inside the same panel
399        // for now — easy to revisit if real apps need more.
400        if !self.spine_panels.is_empty() {
401            let width = self.spine_panels[0]
402                .preferred_size()
403                .unwrap_or(DEFAULT_SPINE_WIDTH);
404            egui::Panel::left("editor_spine")
405                .exact_size(width)
406                .resizable(false)
407                .show_inside(root_ui, |ui| {
408                    for panel in &mut self.spine_panels {
409                        let mut builder = EguiUiBuilder::new(ui, vt);
410                        panel.ui(&mut builder);
411                    }
412                });
413        }
414
415        // ── Bottom (resizable, tabbed) ─────────────────
416        if !self.bottom_panels.is_empty() {
417            let active_tab = &mut self.active_bottom_tab;
418            let panels = &mut self.bottom_panels;
419            // Panel-supplied preferred size is treated as an *additional*
420            // floor — useful for panels that need more than the global
421            // minimum. Proportional default still wins on big screens.
422            let panel_min = panels[0].preferred_size().unwrap_or(0.0);
423            let default_h = bottom_default.max(panel_min);
424
425            egui::Panel::bottom("editor_bottom")
426                .default_size(default_h)
427                .min_size(BOTTOM_MIN)
428                .max_size(bottom_max.max(default_h + 1.0))
429                .resizable(true)
430                .show_inside(root_ui, |ui| {
431                    // Force the inner UI to span the full panel rect —
432                    // otherwise our paint-only panels (no cursor allocation)
433                    // make `inner_response.response.rect` shrink to
434                    // `width_range.min`, and egui stores THAT in PanelState,
435                    // so the panel snaps back to the minimum on every drag
436                    // release.
437                    ui.set_min_size(ui.max_rect().size());
438                    if panels.len() > 1 {
439                        ui.horizontal(|ui| {
440                            ui.add_space(8.0);
441                            for (i, panel) in panels.iter().enumerate() {
442                                let active = *active_tab == i;
443                                if ui.selectable_label(active, panel.title()).clicked() {
444                                    *active_tab = i;
445                                }
446                                ui.add_space(2.0);
447                            }
448                        });
449                        ui.add(egui::Separator::default().spacing(2.0));
450                    }
451
452                    if let Some(panel) = panels.get_mut(*active_tab) {
453                        // Hand the panel ONLY the area below the tab bar
454                        // — without this, the panel's `panel_rect()` is
455                        // still the full bottom-panel rect, so its header
456                        // strip paints over the tab bar and the content
457                        // (logs / asset grid) ends up clipped or hidden.
458                        let remaining = ui.available_rect_before_wrap();
459                        let mut child = ui.new_child(
460                            egui::UiBuilder::new()
461                                .max_rect(remaining)
462                                .layout(egui::Layout::top_down(egui::Align::Min)),
463                        );
464                        child.set_min_size(remaining.size());
465                        let mut builder = EguiUiBuilder::new(&mut child, vt);
466                        panel.ui(&mut builder);
467                    }
468                });
469        }
470
471        // ── Left sidebar (resizable) ──────────────────
472        if !self.left_panels.is_empty() {
473            let panel_min = self.left_panels[0].preferred_size().unwrap_or(0.0);
474            let default_w = left_default.max(panel_min);
475            let panels = &mut self.left_panels;
476            egui::Panel::left("editor_left")
477                .default_size(default_w)
478                .min_size(LEFT_MIN)
479                .max_size(left_max.max(default_w + 1.0))
480                .resizable(true)
481                .show_inside(root_ui, |ui| {
482                    // See the bottom panel above for the rationale —
483                    // without `set_min_size` the resize drag is reverted on
484                    // mouse release because PanelState stores the painted
485                    // content rect (small) instead of the panel rect.
486                    ui.set_min_size(ui.max_rect().size());
487                    for panel in panels.iter_mut() {
488                        let mut builder = EguiUiBuilder::new(ui, vt);
489                        panel.ui(&mut builder);
490                    }
491                });
492        }
493
494        // ── Right sidebar (resizable) ─────────────────
495        if !self.right_panels.is_empty() {
496            let panel_min = self.right_panels[0].preferred_size().unwrap_or(0.0);
497            let default_w = right_default.max(panel_min);
498            let panels = &mut self.right_panels;
499            egui::Panel::right("editor_right")
500                .default_size(default_w)
501                .min_size(RIGHT_MIN)
502                .max_size(right_max.max(default_w + 1.0))
503                .resizable(true)
504                .show_inside(root_ui, |ui| {
505                    // Same rationale as the left panel above.
506                    ui.set_min_size(ui.max_rect().size());
507                    for panel in panels.iter_mut() {
508                        let mut builder = EguiUiBuilder::new(ui, vt);
509                        panel.ui(&mut builder);
510                    }
511                });
512        }
513
514        // ── Central area ──────────────────────────────
515        egui::CentralPanel::default().show_inside(root_ui, |ui| {
516            if self.center_panels.is_empty() {
517                ui.centered_and_justified(|ui| {
518                    ui.label("");
519                });
520            } else {
521                for panel in &mut self.center_panels {
522                    let mut builder = EguiUiBuilder::new(ui, vt);
523                    panel.ui(&mut builder);
524                }
525            }
526        });
527
528        // ── Floating overlays (z-ordered) ─────────────
529        // egui::Area lets us render free-floating UI on top of the rest. The
530        // panel is responsible for all its own positioning / sizing.
531        for entry in &mut self.floating_panels {
532            let area_id = egui::Id::new(("editor_floating", entry.panel.id()));
533            // `Area` is not a panel: it floats in its own layer above the
534            // layout rather than carving space out of a parent `Ui`, so it
535            // still takes the context directly.
536            egui::Area::new(area_id)
537                .order(egui::Order::Foreground)
538                .interactable(true)
539                .show(&ctx, |ui| {
540                    let mut builder = EguiUiBuilder::new(ui, vt);
541                    entry.panel.ui(&mut builder);
542                });
543        }
544    }
545}