khora_editor/
workbench.rs1use 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
45pub struct WorkbenchPanel {
47 state: Arc<Mutex<EditorState>>,
48 theme: UiTheme,
49 panels: HashMap<String, Box<dyn EditorPanel>>,
52 trees: HashMap<EditorMode, DockTree>,
54 dragging: Option<String>,
59 pending_drop: Option<(String, DropZone)>,
61}
62
63impl WorkbenchPanel {
64 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 pub fn add_panel(&mut self, panel: Box<dyn EditorPanel>) {
82 self.panels.insert(panel.id().to_owned(), panel);
83 }
84
85 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 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 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 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 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 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 #[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 #[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}