|
4 | 4 | using System.Reflection; |
5 | 5 |
|
6 | 6 | using Prowl.Editor.Prefabs; |
7 | | -using Prowl.Editor.GUI.Popups; |
8 | 7 | using Prowl.OrigamiUI; |
9 | 8 | using Prowl.PaperUI; |
10 | 9 | using Prowl.PaperUI.LayoutEngine; |
@@ -1040,14 +1039,33 @@ private static void DrawAddComponentButton(Paper paper, Prowl.Scribe.FontFile fo |
1040 | 1039 | { |
1041 | 1040 | using (paper.Row("gi_add_comp_row").Height(28).ChildLeft(20).ChildRight(20).Enter()) |
1042 | 1041 | { |
1043 | | - paper.Box("gi_add_comp") |
| 1042 | + var trigger = paper.Box("gi_add_comp") |
1044 | 1043 | .Height(28).Rounded(4) |
1045 | 1044 | .BackgroundColor(EditorTheme.Ink100) |
1046 | 1045 | .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 | + } |
1051 | 1069 | } |
1052 | 1070 | } |
1053 | 1071 |
|
@@ -1255,4 +1273,341 @@ private static Rect GetParentRect(RectTransform rt) |
1255 | 1273 | return parentRt.ComputedRect; |
1256 | 1274 | return canvas?.RootRect ?? default; |
1257 | 1275 | } |
| 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 | + } |
1258 | 1613 | } |
0 commit comments