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}