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}