Skip to main content

khora_editor/panels/
properties.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//! Properties Inspector panel — composition layer over `widgets::inspector`.
16//!
17//! Owns the panel chrome (panel header, sub-tab segment control,
18//! inspector entity header) and dispatches the active sub-tab to the
19//! tab implementations registered in `widgets::inspector::tabs`. The
20//! actual JSON walker, per-shape renderers, card frame, and add-component
21//! menu live under `widgets/inspector/` so they can be reused by other
22//! panels and stay independently testable.
23
24use std::sync::{Arc, Mutex};
25
26use khora_sdk::editor_ui::*;
27
28use crate::widgets::chrome::paint_panel_header;
29use crate::widgets::inspector::asset_pane::{paint_asset_header, render_asset_pane};
30use crate::widgets::inspector::display::{pick_icon, pick_type_tag};
31use crate::widgets::inspector::header::paint_inspector_header;
32use crate::widgets::inspector::tabs::{DebugTab, InspectorTab, InspectorTabContext, PropertiesTab};
33
34const HEADER_HEIGHT: f32 = 34.0;
35const INSPECTOR_HEADER_HEIGHT: f32 = 64.0;
36const SUBTAB_HEIGHT: f32 = 28.0;
37
38pub struct PropertiesPanel {
39    state: Arc<Mutex<EditorState>>,
40    command_history: Arc<Mutex<CommandHistory>>,
41    theme: UiTheme,
42    tabs: Vec<Box<dyn InspectorTab>>,
43    active_tab: usize,
44}
45
46impl PropertiesPanel {
47    pub fn new(
48        state: Arc<Mutex<EditorState>>,
49        history: Arc<Mutex<CommandHistory>>,
50        theme: UiTheme,
51    ) -> Self {
52        let tabs: Vec<Box<dyn InspectorTab>> = vec![Box::new(PropertiesTab), Box::new(DebugTab)];
53        Self {
54            state,
55            command_history: history,
56            theme,
57            tabs,
58            active_tab: 0,
59        }
60    }
61}
62
63impl EditorPanel for PropertiesPanel {
64    fn id(&self) -> &str {
65        "khora.editor.properties"
66    }
67    fn title(&self) -> &str {
68        "Inspector"
69    }
70
71    fn preferred_size(&self) -> Option<f32> {
72        Some(320.0)
73    }
74
75    fn ui(&mut self, ui: &mut dyn UiBuilder) {
76        let theme = self.theme.clone();
77        let panel_rect = ui.panel_rect();
78        let [px, py, pw, _] = panel_rect;
79
80        // ── Panel header strip ────────────────────────
81        paint_panel_header(ui, panel_rect, HEADER_HEIGHT, &theme);
82
83        // No title chip: the dock tab above already names this panel.
84        //
85        // The `More` and `Lock` icons are gone too. Both painted a hover
86        // highlight and discarded the click: `More` had no menu behind it, and
87        // "lock the inspector to this entity" is a real feature nobody had
88        // written. They advertised capability the panel does not have.
89
90        // ── Snapshot ─────────────────────────────────
91        // The inspector is context-aware: when an asset is selected in
92        // the browser, switch to asset-metadata mode; otherwise show
93        // entity components (or empty state).
94        let (entity_data, asset_path, project_folder) = {
95            let s = match self.state.lock() {
96                Ok(s) => s,
97                Err(_) => return,
98            };
99            let entity_data = s.single_selected().and_then(|e| {
100                s.inspected
101                    .clone()
102                    .filter(|i| i.entity == e)
103                    .map(|i| (e, i))
104            });
105            (
106                entity_data,
107                s.inspected_asset_path.clone(),
108                s.project_folder.clone().unwrap_or_default(),
109            )
110        };
111        let asset_mode = entity_data.is_none() && asset_path.is_some();
112
113        // ── Inspector header ─────────────────────────
114        let header_origin = [px, py + HEADER_HEIGHT];
115        let after_header = if asset_mode {
116            paint_asset_header(ui, header_origin, pw, asset_path.as_ref().unwrap(), &theme)
117        } else {
118            match &entity_data {
119                Some((entity, inspected)) => {
120                    let icon = pick_icon(inspected);
121                    let id_label = format!("id 0x{:04X}", entity.index);
122                    let type_tag = pick_type_tag(inspected);
123                    paint_inspector_header(
124                        ui,
125                        header_origin,
126                        pw,
127                        icon,
128                        &inspected.name,
129                        type_tag,
130                        "Active",
131                        theme.success,
132                        Some(&id_label),
133                        &theme,
134                    )
135                }
136                None => {
137                    ui.paint_rect_filled(
138                        header_origin,
139                        [pw, INSPECTOR_HEADER_HEIGHT],
140                        theme.surface,
141                        0.0,
142                    );
143                    // Nothing selected: teach what the panel is for rather
144                    // than reporting a void.
145                    let w = (pw - 32.0).max(0.0);
146                    if w > 0.0 {
147                        khora_tool_ui::widgets::empty_state(
148                            ui,
149                            &theme,
150                            [px + 16.0, py + HEADER_HEIGHT + 24.0, w, 120.0],
151                            Icon::Crosshair,
152                            "No selection",
153                            "Select an entity to inspect it.",
154                        );
155                    }
156                    py + HEADER_HEIGHT + INSPECTOR_HEADER_HEIGHT + 96.0
157                }
158            }
159        };
160
161        // ── Asset mode body — short-circuits the tab rendering ──
162        if asset_mode {
163            if let Some(rel) = asset_path.as_ref() {
164                let body_y = after_header + 12.0;
165                let body_h = (panel_rect[1] + panel_rect[3] - body_y - 6.0).max(0.0);
166                let body_rect = [px + 6.0, body_y, pw - 12.0, body_h];
167                render_asset_pane(ui, body_rect, rel, &project_folder, &theme);
168            }
169            return;
170        }
171
172        // ── Sub-tabs: Properties | Debug ─────────────
173        // Two views of the *same* entity, so a segmented control — not tabs,
174        // which would imply navigating somewhere else.
175        let subtab_y = after_header + 8.0;
176        let labels: Vec<&str> = self.tabs.iter().map(|t| t.label()).collect();
177        if let Some(hit) = khora_tool_ui::widgets::segmented_tabs(
178            ui,
179            &theme,
180            [px + 6.0, subtab_y, pw - 12.0, SUBTAB_HEIGHT],
181            "p-sub",
182            &labels,
183            self.active_tab,
184        ) {
185            self.active_tab = hit;
186        }
187
188        // ── Body — dispatched to the active tab ──────
189        let body_y = subtab_y + SUBTAB_HEIGHT + 10.0;
190        let body_h = (panel_rect[1] + panel_rect[3] - body_y - 6.0).max(0.0);
191        let body_rect = [px + 6.0, body_y, pw - 12.0, body_h];
192
193        let (entity, inspected) = match entity_data {
194            Some(e) => e,
195            None => return,
196        };
197
198        let mut ctx = InspectorTabContext {
199            state: &self.state,
200            history: &self.command_history,
201            theme: &theme,
202            entity,
203            inspected: &inspected,
204        };
205        if let Some(tab) = self.tabs.get_mut(self.active_tab) {
206            tab.render(ui, body_rect, &mut ctx);
207        }
208    }
209}