Skip to content

Commit bf5f8eb

Browse files
new Add Component Menu
1 parent 5010441 commit bf5f8eb

3 files changed

Lines changed: 362 additions & 292 deletions

File tree

Prowl.Editor/GUI/CustomEditors/GameObjectInspector.cs

Lines changed: 361 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
using System.Reflection;
55

66
using Prowl.Editor.Prefabs;
7-
using Prowl.Editor.GUI.Popups;
87
using Prowl.OrigamiUI;
98
using Prowl.PaperUI;
109
using Prowl.PaperUI.LayoutEngine;
@@ -1040,14 +1039,33 @@ private static void DrawAddComponentButton(Paper paper, Prowl.Scribe.FontFile fo
10401039
{
10411040
using (paper.Row("gi_add_comp_row").Height(28).ChildLeft(20).ChildRight(20).Enter())
10421041
{
1043-
paper.Box("gi_add_comp")
1042+
var trigger = paper.Box("gi_add_comp")
10441043
.Height(28).Rounded(4)
10451044
.BackgroundColor(EditorTheme.Ink100)
10461045
.Hovered.BackgroundColor(EditorTheme.Ink200).End()
1047-
.Text($"{EditorIcons.Plus} {Loc.Get("inspector.add_component")}", font)
1048-
.TextColor(EditorTheme.Ink500)
1049-
.FontSize(EditorTheme.FontSize).Alignment(TextAlignment.MiddleCenter)
1050-
.OnClick(go, (g, _) => AddComponentPopup.Open(g));
1046+
.OnClick(go, (g, _) => ToggleAddComponentPopup(g));
1047+
1048+
using (trigger.Enter())
1049+
{
1050+
var trigHandle = paper.CurrentParent;
1051+
1052+
paper.Box("gi_add_comp_lbl")
1053+
.Width(UnitValue.Stretch()).Height(28).IsNotInteractable()
1054+
.Text($"{EditorIcons.Plus} {Loc.Get("inspector.add_component")}", font)
1055+
.TextColor(EditorTheme.Ink500)
1056+
.FontSize(EditorTheme.FontSize).Alignment(TextAlignment.MiddleCenter);
1057+
1058+
if (_addComponentOpen && _addComponentTarget == go)
1059+
{
1060+
if (paper.IsKeyPressed(PaperKey.Escape))
1061+
CloseAddComponentPopup();
1062+
else
1063+
{
1064+
RenderAddComponentBackdrop(paper);
1065+
RenderAddComponentPopover(paper, trigHandle);
1066+
}
1067+
}
1068+
}
10511069
}
10521070
}
10531071

@@ -1255,4 +1273,341 @@ private static Rect GetParentRect(RectTransform rt)
12551273
return parentRt.ComputedRect;
12561274
return canvas?.RootRect ?? default;
12571275
}
1276+
1277+
// ================================================================
1278+
// Add Component Popup
1279+
// ================================================================
1280+
1281+
private struct ComponentEntry
1282+
{
1283+
public string Path; // Full path e.g. "Physics/Colliders/Box Collider"
1284+
public string Category; // e.g. "Physics/Colliders"
1285+
public string Name; // e.g. "Box Collider"
1286+
public string Icon;
1287+
public Type Type;
1288+
}
1289+
1290+
private static bool _addComponentOpen;
1291+
private static GameObject? _addComponentTarget;
1292+
private static string _addComponentSearch = "";
1293+
private static List<string> _addComponentNavStack = [];
1294+
private static List<ComponentEntry>? _cachedComponents;
1295+
1296+
// Matches Origami's DropdownBuilder default popover cap (Widgets/Dropdown.cs), so the Add
1297+
// Component popover scrolls the same way any other dropdown in the editor does.
1298+
private const float PopoverMaxListHeight = 320f;
1299+
1300+
/// <summary>
1301+
/// Drop the cached component list (which holds every MonoBehaviour <see cref="Type"/>,
1302+
/// including user ones) so the script AssemblyLoadContext can be collected.
1303+
/// </summary>
1304+
[Runtime.OnAssemblyUnload]
1305+
public static void ClearAddComponentCache() => _cachedComponents = null;
1306+
1307+
private static void ToggleAddComponentPopup(GameObject target)
1308+
{
1309+
if (_addComponentOpen && _addComponentTarget == target)
1310+
{
1311+
CloseAddComponentPopup();
1312+
return;
1313+
}
1314+
1315+
_addComponentTarget = target;
1316+
_addComponentSearch = "";
1317+
_addComponentNavStack = [];
1318+
_cachedComponents ??= GatherComponents();
1319+
_addComponentOpen = true;
1320+
}
1321+
1322+
private static void CloseAddComponentPopup()
1323+
{
1324+
_addComponentOpen = false;
1325+
_addComponentTarget = null;
1326+
}
1327+
1328+
// Fullscreen, invisible click-catcher so clicking anywhere outside the popover closes it —
1329+
// the same click-outside behaviour Origami's dropdowns use (see DropdownInternal.RenderBackdrop).
1330+
private static void RenderAddComponentBackdrop(Paper paper)
1331+
{
1332+
paper.Box("gi_acp_backdrop")
1333+
.PositionType(PositionType.SelfDirected)
1334+
.Position(-9999, -9999)
1335+
.Size(99999, 99999)
1336+
.Layer(Layer.Overlay)
1337+
.StopEventPropagation()
1338+
.OnClick(0, (_, _) => CloseAddComponentPopup());
1339+
}
1340+
1341+
// Popover anchored directly below the Add Component button, styled like Origami's dropdown
1342+
// popovers (Widgets/Dropdown.cs / DropdownTypes.cs): same background, border, shadow, rounding
1343+
// and row hover treatment, all sourced from EditorTheme so it stays in sync with the rest of
1344+
// the editor's theme.
1345+
private static void RenderAddComponentPopover(Paper paper, ElementHandle trigHandle)
1346+
{
1347+
var font = EditorTheme.DefaultFont;
1348+
if (font == null) return;
1349+
1350+
float triggerWidth = trigHandle.Data.LayoutRect.Size.X > 0 ? (float)trigHandle.Data.LayoutRect.Size.X : 280f;
1351+
float triggerHeight = trigHandle.Data.LayoutRect.Size.Y > 0 ? (float)trigHandle.Data.LayoutRect.Size.Y : 28f;
1352+
1353+
const float padX = 5f, padY = 5f, searchH = 28f, searchGap = 4f;
1354+
1355+
using (paper.Column("gi_acp_pop")
1356+
.PositionType(PositionType.SelfDirected)
1357+
.Position(0, triggerHeight + 4f)
1358+
.Width(triggerWidth)
1359+
.Height(UnitValue.Auto)
1360+
.BackgroundColor(EditorTheme.Popover)
1361+
.BorderColor(EditorTheme.BorderStrong).BorderWidth(1)
1362+
.DropShadow(0, 14, 40, -6, EditorTheme.Shadow)
1363+
.Rounded(EditorTheme.Roundness + 2f)
1364+
.Padding(padX, padX, padY, padY)
1365+
.ColBetween(searchGap)
1366+
.HookToParent()
1367+
.Layer(Layer.Topmost)
1368+
.ClampToScreen()
1369+
.StopEventPropagation()
1370+
.Enter())
1371+
{
1372+
using (paper.Row("gi_acp_search_row").Height(searchH).Enter())
1373+
{
1374+
Origami.SearchField(paper, "gi_acp_search", _addComponentSearch, v => _addComponentSearch = v, Loc.Get("popup.search_components")).Show();
1375+
}
1376+
1377+
var components = _cachedComponents ?? [];
1378+
1379+
Origami.ScrollView(paper, "gi_acp_scroll", triggerWidth - padX * 2, PopoverMaxListHeight)
1380+
.Padding(0)
1381+
.Body(() =>
1382+
{
1383+
if (!string.IsNullOrEmpty(_addComponentSearch))
1384+
DrawAddComponentSearchResults(paper, font, components);
1385+
else
1386+
DrawAddComponentBrowseLevel(paper, font, components);
1387+
});
1388+
}
1389+
}
1390+
1391+
// Flat, globally-filtered list shown while the search box has text (ignores current folder).
1392+
private static void DrawAddComponentSearchResults(Paper paper, Prowl.Scribe.FontFile font, List<ComponentEntry> components)
1393+
{
1394+
var filtered = components.Where(c =>
1395+
c.Name.Contains(_addComponentSearch, StringComparison.OrdinalIgnoreCase) ||
1396+
c.Path.Contains(_addComponentSearch, StringComparison.OrdinalIgnoreCase))
1397+
.ToList();
1398+
1399+
if (filtered.Count == 0)
1400+
{
1401+
paper.Box("acp_empty").Height(40)
1402+
.Text(Loc.Get("popup.no_components"), font)
1403+
.TextColor(EditorTheme.Ink300)
1404+
.FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleCenter);
1405+
return;
1406+
}
1407+
1408+
for (int i = 0; i < filtered.Count; i++)
1409+
DrawComponentItem(paper, font, $"acp_item_{i}", filtered[i]);
1410+
}
1411+
1412+
// Unity-style click-to-navigate browser: the current folder's subfolders and components,
1413+
// with a "Back" row when nested. Clicking a folder drills in; clicking Back steps back out.
1414+
private static void DrawAddComponentBrowseLevel(Paper paper, Prowl.Scribe.FontFile font, List<ComponentEntry> components)
1415+
{
1416+
string prefix = string.Join("/", _addComponentNavStack);
1417+
var (leaves, subfolders) = SplitComponentLevel(components, prefix);
1418+
1419+
if (_addComponentNavStack.Count > 0)
1420+
{
1421+
string currentName = _addComponentNavStack[^1];
1422+
using (paper.Row("acp_back")
1423+
.Height(EditorTheme.RowHeight)
1424+
.Hovered.BackgroundColor(EditorTheme.Hover).End()
1425+
.Rounded(6).ChildLeft(9).ChildRight(9).RowBetween(9)
1426+
.OnClick(0, (_, _) => _addComponentNavStack.RemoveAt(_addComponentNavStack.Count - 1))
1427+
.Enter())
1428+
{
1429+
paper.Box("acp_back_ico").Width(16).Height(EditorTheme.RowHeight)
1430+
.Text(EditorIcons.ChevronLeft, font).TextColor(EditorTheme.Ink400)
1431+
.FontSize(11f).Alignment(TextAlignment.MiddleCenter);
1432+
paper.Box("acp_back_name").Height(EditorTheme.RowHeight)
1433+
.Text(currentName, font).TextColor(EditorTheme.Ink500)
1434+
.FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleLeft);
1435+
}
1436+
1437+
paper.Box("acp_back_sep").Height(1).Margin(8, 3, 8, 3).BackgroundColor(EditorTheme.BorderSoft);
1438+
}
1439+
1440+
foreach (var folder in subfolders)
1441+
{
1442+
var captured = folder;
1443+
using (paper.Row($"acp_folder_{folder}")
1444+
.Height(EditorTheme.RowHeight)
1445+
.Hovered.BackgroundColor(EditorTheme.Hover).End()
1446+
.Rounded(6).ChildLeft(9).ChildRight(9).RowBetween(9)
1447+
.OnClick(0, (_, _) => _addComponentNavStack.Add(captured))
1448+
.Enter())
1449+
{
1450+
paper.Box($"acp_folder_{folder}_ico").Width(16).Height(EditorTheme.RowHeight)
1451+
.Text(EditorIcons.Folder, font).TextColor(EditorTheme.Ink400)
1452+
.FontSize(11f).Alignment(TextAlignment.MiddleCenter);
1453+
1454+
paper.Box($"acp_folder_{folder}_name")
1455+
.Width(UnitValue.Stretch()).Height(EditorTheme.RowHeight)
1456+
.Text(folder, font).TextColor(EditorTheme.Ink500)
1457+
.FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleLeft);
1458+
1459+
paper.Box($"acp_folder_{folder}_arw").Width(16).Height(EditorTheme.RowHeight)
1460+
.Text(EditorIcons.ChevronRight, font).TextColor(EditorTheme.Ink300)
1461+
.FontSize(11f).Alignment(TextAlignment.MiddleCenter);
1462+
}
1463+
}
1464+
1465+
if (subfolders.Count > 0 && leaves.Count > 0)
1466+
paper.Box("acp_level_sep").Height(1).Margin(8, 3, 8, 3).BackgroundColor(EditorTheme.BorderSoft);
1467+
1468+
foreach (var comp in leaves)
1469+
DrawComponentItem(paper, font, $"acp_item_{comp.Type.Name}", comp);
1470+
1471+
if (subfolders.Count == 0 && leaves.Count == 0)
1472+
{
1473+
paper.Box("acp_empty").Height(40)
1474+
.Text(Loc.Get("popup.no_components"), font)
1475+
.TextColor(EditorTheme.Ink300)
1476+
.FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleCenter);
1477+
}
1478+
}
1479+
1480+
// Splits components into this level's direct items (Category == prefix) and its immediate
1481+
// subfolder names (the next path segment past prefix), so browsing can drill in one segment
1482+
// at a time regardless of how deep the full category path goes.
1483+
private static (List<ComponentEntry> Leaves, List<string> Subfolders) SplitComponentLevel(List<ComponentEntry> components, string prefix)
1484+
{
1485+
var leaves = new List<ComponentEntry>();
1486+
var subfolders = new HashSet<string>(StringComparer.Ordinal);
1487+
1488+
foreach (var c in components)
1489+
{
1490+
if (c.Category == prefix)
1491+
{
1492+
leaves.Add(c);
1493+
continue;
1494+
}
1495+
1496+
if (prefix.Length > 0 && !c.Category.StartsWith(prefix + "/", StringComparison.Ordinal))
1497+
continue;
1498+
1499+
string rel = prefix.Length > 0 ? c.Category[(prefix.Length + 1)..] : c.Category;
1500+
int slash = rel.IndexOf('/');
1501+
subfolders.Add(slash < 0 ? rel : rel[..slash]);
1502+
}
1503+
1504+
var sortedSubfolders = subfolders.ToList();
1505+
sortedSubfolders.Sort(StringComparer.OrdinalIgnoreCase);
1506+
leaves.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
1507+
return (leaves, sortedSubfolders);
1508+
}
1509+
1510+
private static void DrawComponentItem(Paper paper, Prowl.Scribe.FontFile font, string id, ComponentEntry comp)
1511+
{
1512+
using (paper.Row(id)
1513+
.Height(EditorTheme.RowHeight)
1514+
.Hovered.BackgroundColor(EditorTheme.Hover).End()
1515+
.Rounded(6).ChildLeft(9).ChildRight(9).RowBetween(9)
1516+
.OnClick(comp.Type, (type, _) =>
1517+
{
1518+
if (_addComponentTarget != null)
1519+
{
1520+
AddComponentWithUndo(_addComponentTarget, type);
1521+
CloseAddComponentPopup();
1522+
}
1523+
})
1524+
.Enter())
1525+
{
1526+
paper.Box($"{id}_ico")
1527+
.Width(16).Height(EditorTheme.RowHeight)
1528+
.Text(comp.Icon, font).TextColor(EditorTheme.Ink400)
1529+
.FontSize(11f).Alignment(TextAlignment.MiddleCenter);
1530+
1531+
paper.Box($"{id}_name")
1532+
.Height(EditorTheme.RowHeight)
1533+
.Text(comp.Name, font).TextColor(EditorTheme.Ink500)
1534+
.FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleLeft);
1535+
}
1536+
}
1537+
1538+
/// <summary>
1539+
/// Adds a component of <paramref name="type"/> to <paramref name="go"/> and registers a matching
1540+
/// undo/redo step. Shared by the popup and the drag-a-script-onto-the-inspector path.
1541+
/// </summary>
1542+
public static MonoBehaviour? AddComponentWithUndo(GameObject go, Type type)
1543+
{
1544+
var addedComp = go.AddComponent(type);
1545+
if (addedComp != null)
1546+
{
1547+
var compId = addedComp.Identifier;
1548+
var goId = go.Identifier;
1549+
var serialized = Echo.Serializer.Serialize(addedComp.GetType(), addedComp);
1550+
var compType = addedComp.GetType();
1551+
Undo.RegisterAction("Add Component",
1552+
undo: () => { var g = Undo.FindGO(goId); if (g == null) return; var c = g.GetComponentByIdentifier(compId); if (c != null) g.RemoveComponent(c); },
1553+
redo: () => { var g = Undo.FindGO(goId); if (g == null) return; var c = Echo.Serializer.Deserialize(serialized, compType) as MonoBehaviour; if (c != null) { c.Identifier = compId; g.AddComponent(c); } });
1554+
}
1555+
return addedComp;
1556+
}
1557+
1558+
private static List<ComponentEntry> GatherComponents()
1559+
{
1560+
var result = new List<ComponentEntry>();
1561+
1562+
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
1563+
{
1564+
Type[] types;
1565+
try { types = assembly.GetTypes(); }
1566+
catch { continue; }
1567+
1568+
foreach (var type in types)
1569+
{
1570+
if (!typeof(MonoBehaviour).IsAssignableFrom(type) || type.IsAbstract) continue;
1571+
if (type == typeof(MonoBehaviour)) continue;
1572+
if (type.Name == "MissingMonobehaviour") continue;
1573+
1574+
var menuAttr = type.GetCustomAttribute<AddComponentMenuAttribute>();
1575+
string path = menuAttr?.Path ?? type.Name;
1576+
string icon = menuAttr?.Icon ?? "";
1577+
1578+
int lastSlash = path.LastIndexOf('/');
1579+
string category = lastSlash >= 0 ? path[..lastSlash] : "";
1580+
string name = lastSlash >= 0 ? path[(lastSlash + 1)..] : path;
1581+
1582+
// Default icons by category
1583+
if (string.IsNullOrEmpty(icon))
1584+
{
1585+
icon = category switch
1586+
{
1587+
var c when c.StartsWith("Rendering") => EditorIcons.Cube,
1588+
var c when c.StartsWith("Audio") => EditorIcons.VolumeHigh,
1589+
var c when c.StartsWith("Light") => EditorIcons.Sun,
1590+
var c when c.Contains("Collider") => EditorIcons.VectorSquare,
1591+
var c when c.Contains("Constraint") || c.Contains("Joint") => EditorIcons.Link,
1592+
var c when c.StartsWith("Physics") => EditorIcons.Atom,
1593+
var c when c.StartsWith("UI") => EditorIcons.Desktop,
1594+
var c when c.StartsWith("Effects") => EditorIcons.Burst,
1595+
var c when c.StartsWith("Terrain") => EditorIcons.Mountain,
1596+
_ => EditorIcons.PuzzlePiece
1597+
};
1598+
}
1599+
1600+
result.Add(new ComponentEntry
1601+
{
1602+
Path = path,
1603+
Category = category,
1604+
Name = name,
1605+
Icon = icon,
1606+
Type = type
1607+
});
1608+
}
1609+
}
1610+
1611+
return result;
1612+
}
12581613
}

Prowl.Editor/GUI/Panels/InspectorPanel.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ private static void DrawScriptComponentDropZone(Paper paper, Scribe.FontFile fon
201201
// Complete the drop: the drag has ended (released) while over this overlay.
202202
if (paper.IsParentHovered && !DragDrop.IsDragging)
203203
{
204-
Popups.AddComponentPopup.AddComponentWithUndo(go, componentType);
204+
GameObjectInspector.AddComponentWithUndo(go, componentType);
205205
DragDrop.EndDrag();
206206
}
207207
}

0 commit comments

Comments
 (0)