Skip to main content

khora_editor/
workbench.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//! Workbench — the dockable area between the spine and the status bar.
16//!
17//! One [`EditorPanel`] that hosts all the others. It owns a
18//! [`DockTree`] per [`EditorMode`], lays the tree out over its own rect, and
19//! hands each leaf its share through [`UiBuilder::region_at`] — so the panels
20//! inside see a `panel_rect()` of exactly their pane and need no notion of the
21//! dock at all.
22//!
23//! ## Why here and not in the shell
24//!
25//! The dock's chrome is painted with `khora-tool-ui`, which carries Khora's
26//! brand. `khora-infra` — where the egui shell lives — must not depend on it,
27//! or every game built on the engine would inherit the vendor's look. Hosting
28//! the dock as a Center panel keeps the branded widgets in the editor where
29//! they belong, and leaves `EditorShell` generic.
30//!
31//! It also puts the per-mode layouts where mode knowledge already lives. The
32//! shell used to reach into `EditorState::active_mode` to hide the right
33//! sidebar in Control Plane mode; with a tree per mode, switching modes swaps
34//! the whole layout and that special case disappears.
35
36use std::collections::HashMap;
37use std::sync::{Arc, Mutex};
38
39use khora_sdk::editor_ui::{EditorMode, EditorPanel, EditorState, UiBuilder, UiTheme};
40use khora_sdk::khora_core::ui::editor::dock::{zone_at, DockTree, DropZone};
41use khora_tool_ui::widgets::{
42    dock_drag_ghost, dock_drop_overlay, dock_splitter, dock_tab_strip, TAB_STRIP_H,
43};
44
45/// Hosts the dockable panels for every workspace.
46pub struct WorkbenchPanel {
47    state: Arc<Mutex<EditorState>>,
48    theme: UiTheme,
49    /// Panels by id. Owned here rather than by the shell, because the dock —
50    /// not the registration order — decides where each one goes.
51    panels: HashMap<String, Box<dyn EditorPanel>>,
52    /// One layout per workspace. Switching modes swaps the tree wholesale.
53    trees: HashMap<EditorMode, DockTree>,
54    /// Panel id currently being dragged by its tab, for the whole gesture.
55    ///
56    /// Held as an id rather than an index: the layout is rebuilt every frame,
57    /// so an index would address a different pane the moment anything moved.
58    dragging: Option<String>,
59    /// Where the in-flight drag would land if released now.
60    pending_drop: Option<(String, DropZone)>,
61}
62
63impl WorkbenchPanel {
64    /// Creates an empty workbench.
65    pub fn new(state: Arc<Mutex<EditorState>>, theme: UiTheme) -> Self {
66        Self {
67            state,
68            theme,
69            panels: HashMap::new(),
70            trees: HashMap::new(),
71            dragging: None,
72            pending_drop: None,
73        }
74    }
75
76    /// Adds a panel to the pool the layouts can place.
77    ///
78    /// A panel that no mode's tree names is simply never shown — the pool and
79    /// the layouts are deliberately independent, so a mode can drop a panel
80    /// without unregistering it.
81    pub fn add_panel(&mut self, panel: Box<dyn EditorPanel>) {
82        self.panels.insert(panel.id().to_owned(), panel);
83    }
84
85    /// Sets the layout for one workspace.
86    pub fn set_layout(&mut self, mode: EditorMode, tree: DockTree) {
87        self.trees.insert(mode, tree);
88    }
89
90    fn active_mode(&self) -> EditorMode {
91        self.state
92            .lock()
93            .ok()
94            .map(|s| s.active_mode)
95            .unwrap_or_default()
96    }
97}
98
99impl EditorPanel for WorkbenchPanel {
100    fn id(&self) -> &str {
101        "khora.editor.workbench"
102    }
103
104    fn title(&self) -> &str {
105        "Workbench"
106    }
107
108    fn ui(&mut self, ui: &mut dyn UiBuilder) {
109        let mode = self.active_mode();
110        let area = ui.panel_rect();
111        let theme = self.theme.clone();
112
113        let Some(layout) = self.trees.get(&mode).map(|t| t.layout(area)) else {
114            return;
115        };
116
117        let pointer = ui.pointer_position();
118        let drag_active = ui.is_drag_active();
119
120        // ── Panes ─────────────────────────────────────
121        let mut activate: Option<(String, String)> = None;
122        let mut drag_start: Option<String> = None;
123
124        for (gi, group) in layout.groups.iter().enumerate() {
125            let salt = format!("wb-g{gi}");
126            let event = dock_tab_strip(ui, &theme, group, &salt);
127
128            if let Some(i) = event.activated {
129                if let Some(p) = group.panels.get(i) {
130                    activate = Some((p.clone(), p.clone()));
131                }
132            }
133            if let Some(i) = event.drag_started {
134                if let Some(p) = group.panels.get(i) {
135                    drag_start = Some(p.clone());
136                }
137            }
138
139            let [gx, gy, gw, gh] = group.rect;
140            let content = [gx, gy + TAB_STRIP_H, gw, (gh - TAB_STRIP_H).max(0.0)];
141            if content[2] <= 1.0 || content[3] <= 1.0 {
142                continue;
143            }
144            let Some(active_id) = group.active_panel().map(|s| s.to_owned()) else {
145                continue;
146            };
147            if let Some(panel) = self.panels.get_mut(&active_id) {
148                ui.region_at(&active_id, content, &mut |inner| panel.ui(inner));
149            }
150        }
151
152        // ── Dividers ──────────────────────────────────
153        for (si, splitter) in layout.splitters.iter().enumerate() {
154            if let Some(ratio) = dock_splitter(ui, &theme, splitter, &format!("wb-sp{si}")) {
155                if let Some(tree) = self.trees.get_mut(&mode) {
156                    tree.set_ratio(splitter.id, ratio);
157                }
158            }
159        }
160
161        // ── Drag feedback and commit ──────────────────
162        if let Some(id) = drag_start {
163            self.dragging = Some(id);
164        }
165
166        if let Some(dragged) = self.dragging.clone() {
167            if drag_active {
168                // Recompute the target every frame from the live layout: the
169                // pane under the cursor is only meaningful for the frame it
170                // was measured in.
171                self.pending_drop = pointer.and_then(|p| {
172                    layout.groups.iter().find_map(|g| {
173                        let zone = zone_at(g.rect, p)?;
174                        let anchor = g.active_panel()?.to_owned();
175                        Some((anchor, zone))
176                    })
177                });
178                if let Some((anchor, zone)) = &self.pending_drop {
179                    if let Some(g) = layout
180                        .groups
181                        .iter()
182                        .find(|g| g.active_panel() == Some(anchor.as_str()))
183                    {
184                        dock_drop_overlay(ui, &theme, g.rect, *zone);
185                    }
186                }
187                if let Some(p) = pointer {
188                    dock_drag_ghost(ui, &theme, &dragged, p);
189                }
190            } else {
191                // The pointer came up: commit wherever the last frame pointed.
192                if let Some((anchor, zone)) = self.pending_drop.take() {
193                    if anchor != dragged {
194                        if let Some(tree) = self.trees.get_mut(&mode) {
195                            tree.insert(dragged.clone(), Some(&anchor), zone);
196                        }
197                    }
198                }
199                self.dragging = None;
200                self.pending_drop = None;
201            }
202        }
203
204        if let Some((panel, _)) = activate {
205            if let Some(tree) = self.trees.get_mut(&mode) {
206                tree.activate(&panel);
207            }
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use khora_sdk::khora_core::ui::editor::dock::DropZone;
216
217    /// A panel the pool holds but no layout names must simply not be shown —
218    /// the two are independent so a mode can drop a panel without the app
219    /// having to unregister it.
220    #[test]
221    fn layout_selects_from_the_pool_rather_than_the_pool_driving_the_layout() {
222        let state = Arc::new(Mutex::new(EditorState::default()));
223        let mut wb = WorkbenchPanel::new(state, khora_tool_ui::khora_dark());
224
225        let mut tree = DockTree::single("khora.editor.viewport");
226        tree.insert(
227            "khora.editor.console",
228            Some("khora.editor.viewport"),
229            DropZone::Bottom,
230        );
231        wb.set_layout(EditorMode::Scene, tree);
232
233        let shown = wb.trees[&EditorMode::Scene].panels();
234        assert_eq!(shown.len(), 2);
235        assert!(shown.iter().any(|p| p == "khora.editor.console"));
236        assert!(
237            !shown.iter().any(|p| p == "khora.editor.control_plane"),
238            "a panel no tree names stays hidden"
239        );
240    }
241
242    /// Each workspace keeps its own layout, so switching modes swaps the whole
243    /// arrangement instead of hiding panels one by one.
244    #[test]
245    fn each_mode_owns_its_layout() {
246        let state = Arc::new(Mutex::new(EditorState::default()));
247        let mut wb = WorkbenchPanel::new(state, khora_tool_ui::khora_dark());
248        wb.set_layout(EditorMode::Scene, DockTree::single("khora.editor.viewport"));
249        wb.set_layout(
250            EditorMode::ControlPlane,
251            DockTree::single("khora.editor.control_plane"),
252        );
253
254        assert_eq!(
255            wb.trees[&EditorMode::Scene].panels(),
256            vec!["khora.editor.viewport"]
257        );
258        assert_eq!(
259            wb.trees[&EditorMode::ControlPlane].panels(),
260            vec!["khora.editor.control_plane"]
261        );
262    }
263}