Skip to main content

khora_editor/widgets/inspector/
add_component.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//! "+ Add Component" menu — bucketed by `SemanticDomain` (live World).
10//!
11//! Walks the inventory of `ComponentRegistration` to enumerate every
12//! component the engine knows about, filters out inherent-on-vessel
13//! components plus those already on the entity, and groups what's left
14//! by domain tag.
15
16use khora_sdk::editor_ui::{EditorState, InspectedEntity, UiBuilder};
17use khora_sdk::prelude::ecs::EntityId;
18
19use super::display::category_label_for_tag;
20
21/// Components the editor renders somewhere other than as an inspector card,
22/// so offering them in "+ Add Component" would be redundant.
23///
24/// This is a **presentation** concern, not an authorship one: `Name` is very
25/// much the author's data, it simply lives in the inspector header. Whether a
26/// component may be authored at all is answered by
27/// [`ComponentProvenance`](khora_sdk::khora_data::ecs::ComponentProvenance),
28/// which is declared on the component itself — so engine-written types are
29/// excluded by construction, including ones defined outside this workspace.
30pub const SURFACED_ELSEWHERE: &[&str] = &["Name"];
31
32/// `SemanticDomain::Ui` as the small integer tag the editor carries in
33/// [`ComponentJson::domain`] (see `ops::domain_tag`).
34///
35/// The `Ui*` family drives the in-world Taffy UI, which is authored in a
36/// canvas, not on a 3D scene entity — dropping a `UiNode` on a mesh yields a
37/// component nothing lays out. Scene mode therefore hides the whole domain.
38/// This is a **workspace** filter, not an authorship one: the day a Canvas
39/// workspace ships, it shows this domain and hides the others rather than
40/// removing the rule.
41const UI_DOMAIN_TAG: u8 = 4;
42
43/// Whether the inspector should treat `type_name` as the author's own data —
44/// offering it in "+ Add Component" and rendering it as an editable card.
45///
46/// The single source of truth for both surfaces, so the menu and the card list
47/// cannot drift apart. Returns `false` for anything the engine writes
48/// (`GlobalTransform`, `Children`, `PhysicsDebugData`…), for tool-written
49/// components nobody adds by hand (`Parent`, `Prefab`), for the ones rendered
50/// elsewhere, and for unregistered types.
51pub fn is_author_facing(type_name: &str) -> bool {
52    if SURFACED_ELSEWHERE.contains(&type_name) {
53        return false;
54    }
55    khora_sdk::khora_data::scene::provenance_of(type_name).is_some_and(|p| p.is_hand_authorable())
56}
57
58/// Render the "+ Add Component" menu button. Selecting a component
59/// queues `EditorState::pending_add_component` for the next frame.
60pub fn render_add_component(
61    ui: &mut dyn UiBuilder,
62    entity: EntityId,
63    inspected: &InspectedEntity,
64    state: &mut EditorState,
65) {
66    let mut buckets: std::collections::BTreeMap<u8, Vec<String>> =
67        std::collections::BTreeMap::new();
68    let mut other: Vec<String> = Vec::new();
69
70    let already_present: std::collections::HashSet<&str> = inspected
71        .components_json
72        .iter()
73        .map(|c| c.type_name.as_str())
74        .collect();
75
76    for reg in inventory::iter::<khora_sdk::ComponentRegistration> {
77        if !is_author_facing(reg.type_name) {
78            continue;
79        }
80        if already_present.contains(reg.type_name) {
81            continue;
82        }
83        let domain_tag = state.component_domain_registry.get(reg.type_name).copied();
84        if domain_tag == Some(UI_DOMAIN_TAG) {
85            continue;
86        }
87
88        if let Some(tag) = domain_tag {
89            buckets
90                .entry(tag)
91                .or_default()
92                .push(reg.type_name.to_string());
93        } else {
94            other.push(reg.type_name.to_string());
95        }
96    }
97    let pending: std::cell::Cell<Option<String>> = std::cell::Cell::new(None);
98    ui.menu_button("+ Add Component", &mut |ui_m| {
99        for (tag, items) in &buckets {
100            if items.is_empty() {
101                continue;
102            }
103            let label = category_label_for_tag(Some(*tag)).to_string();
104            ui_m.menu_button(&label, &mut |ui_s| {
105                for n in items {
106                    if ui_s.button(n) {
107                        pending.set(Some(n.clone()));
108                        ui_s.close_menu();
109                    }
110                }
111            });
112        }
113        if !other.is_empty() {
114            ui_m.menu_button("Other", &mut |ui_s| {
115                for n in &other {
116                    if ui_s.button(n) {
117                        pending.set(Some(n.clone()));
118                        ui_s.close_menu();
119                    }
120                }
121            });
122        }
123    });
124    if let Some(name) = pending.into_inner() {
125        state.pending_add_component = Some((entity, name));
126    }
127}