From dcde56e2675fe0ff1aff7e6406745d288c3fc46b Mon Sep 17 00:00:00 2001 From: yewnyx Date: Mon, 6 Jul 2026 14:32:37 +0900 Subject: [PATCH 1/4] feat(library): optional secondary action on shareable entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BasisShareableEntry gains ActionLabel/Action plus optional ActionConfirmTitle/Body, and the Instantiated tab renders it as a labeled button beside the remove button — with the same yes/no DialogBox flow the remove path uses when confirm text is present. Registry gains SetAction(id, ...) for flipping the presentation after invocation (e.g. Share -> Unshare), mirroring SetDetail. Generic on purpose: the registering package owns the semantics, the framework just presents it — same philosophy as the registry itself (add-on packages appear in the Library without the framework referencing them). First consumer is com.lattice.basis's window share/unshare toggle. --- .../BasisUI/Menus/Library/LibraryProvider.cs | 25 +++++++++++++++++++ .../ContentShare/BasisShareableRegistry.cs | 25 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs b/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs index 2f97004301..8ba8284e85 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs +++ b/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs @@ -2041,6 +2041,31 @@ private static void CreateShareableListEntry(BasisShareableEntry entry, RectTran itemTextInfo.Descriptor.SetHeight(50); itemTextInfo.Descriptor.SetWidth(400); + // Optional secondary action (registered by the entry's provider — + // e.g. a Share/Unshare toggle): a labeled button beside the trash, + // with an optional consent-style yes/no dialog in front of it. + if (!string.IsNullOrEmpty(entry.ActionLabel)) + { + PanelButton actionItem = PanelButton.CreateNew(ButtonStyles.AcceptButton, itemListPanel.TabButtonParent); + actionItem.Descriptor.SetTitle(entry.ActionLabel); + actionItem.SetSize(new Vector2(180, 80)); + actionItem.OnClicked += async () => + { + if (!string.IsNullOrEmpty(entry.ActionConfirmTitle)) + { + DialogBox confirmDialog = DialogBox.Create(panel, new Vector2(650, 180), + entry.ActionConfirmTitle, + entry.ActionConfirmBody ?? string.Empty, + AddressableAssets.Sprites.Information, + true + ); + LibraryProviderDialogRemove.BuildDialogButtons(confirmDialog); + if (!await confirmDialog.WaitAsync()) return; + } + entry.Action?.Invoke(); + }; + } + PanelButton removeItem = PanelButton.CreateNew(ButtonStyles.CancelButton, itemListPanel.TabButtonParent); removeItem.Descriptor.SetTitle(string.Empty); removeItem.SetIcon(AddressableAssets.Sprites.Trash); diff --git a/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisShareableRegistry.cs b/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisShareableRegistry.cs index d7b7ad8f91..d3feaf33b7 100644 --- a/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisShareableRegistry.cs +++ b/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisShareableRegistry.cs @@ -18,6 +18,16 @@ public sealed class BasisShareableEntry public string Title; public string SharerName; public Action Remove; + + /// Optional secondary action, rendered as a labeled button next to the + /// remove button (e.g. a "Share"/"Unshare" toggle). Null/empty label = no button. + /// The registering package owns the semantics; the Library UI just presents it. + public string ActionLabel; + public Action Action; + /// Non-null = the Library shows a yes/no dialog with this title/body + /// before invoking (for consent-style actions). + public string ActionConfirmTitle; + public string ActionConfirmBody; } /// @@ -55,5 +65,20 @@ public static void SetDetail(string id, string detail) } } + /// Update an entry's secondary action presentation (label + optional + /// confirm text) — e.g. flipping a Share button to Unshare after it's invoked. + /// The Action delegate itself stays as registered. + public static void SetAction(string id, string label, string confirmTitle = null, string confirmBody = null) + { + if (string.IsNullOrEmpty(id)) return; + if (Entries.TryGetValue(id, out BasisShareableEntry entry)) + { + entry.ActionLabel = label; + entry.ActionConfirmTitle = confirmTitle; + entry.ActionConfirmBody = confirmBody; + OnChanged?.Invoke(); + } + } + public static IReadOnlyCollection GetAll() => Entries.Values; } From f15fc4d542fe09fa5a6653af875fb92ba18b758e Mon Sep 17 00:00:00 2001 From: yewnyx Date: Mon, 6 Jul 2026 21:03:44 +0900 Subject: [PATCH 2/4] feat(library): SetSharerName + null-label clears the action button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SetSharerName updates "shared by …" after registration (a received share learns its sharer late); document that a null SetAction label removes the secondary button. Consumed by com.lattice.basis's received-window flow. --- .../ContentShare/BasisShareableRegistry.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisShareableRegistry.cs b/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisShareableRegistry.cs index d3feaf33b7..21676bfa9c 100644 --- a/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisShareableRegistry.cs +++ b/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisShareableRegistry.cs @@ -67,7 +67,8 @@ public static void SetDetail(string id, string detail) /// Update an entry's secondary action presentation (label + optional /// confirm text) — e.g. flipping a Share button to Unshare after it's invoked. - /// The Action delegate itself stays as registered. + /// A null/empty label removes the button. The Action delegate itself stays as + /// registered. public static void SetAction(string id, string label, string confirmTitle = null, string confirmBody = null) { if (string.IsNullOrEmpty(id)) return; @@ -80,5 +81,17 @@ public static void SetAction(string id, string label, string confirmTitle = null } } + /// Update who shared an entry (rendered as "shared by …") after + /// registration — e.g. once a received share's sharer is identified. + public static void SetSharerName(string id, string sharerName) + { + if (string.IsNullOrEmpty(id)) return; + if (Entries.TryGetValue(id, out BasisShareableEntry entry)) + { + entry.SharerName = sharerName; + OnChanged?.Invoke(); + } + } + public static IReadOnlyCollection GetAll() => Entries.Values; } From 2d9f1832b186e492fb4f97ee76c24a6fae2aca35 Mon Sep 17 00:00:00 2001 From: yewnyx Date: Sat, 11 Jul 2026 05:13:51 +0900 Subject: [PATCH 3/4] refactor(library): fold shareable entry actions into a list Replace the single optional secondary action + dedicated remove path with a List on BasisShareableEntry. Removal is now just a Destructive action, so the Library has no special-case remove button. The action style is a semantic Positive/Destructive enum, keeping the registry free of any BasisUI prefab/sprite references. Per PR #930 review feedback. --- .../BasisUI/Menus/Library/LibraryProvider.cs | 87 ++++++++++++------- .../ContentShare/BasisContentShareManager.cs | 10 ++- .../ContentShare/BasisShareableRegistry.cs | 53 +++++++---- .../BasisImagePickupManager.cs | 18 +++- 4 files changed, 116 insertions(+), 52 deletions(-) diff --git a/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs b/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs index 8ba8284e85..daaec29072 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs +++ b/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs @@ -2041,45 +2041,70 @@ private static void CreateShareableListEntry(BasisShareableEntry entry, RectTran itemTextInfo.Descriptor.SetHeight(50); itemTextInfo.Descriptor.SetWidth(400); - // Optional secondary action (registered by the entry's provider — - // e.g. a Share/Unshare toggle): a labeled button beside the trash, - // with an optional consent-style yes/no dialog in front of it. - if (!string.IsNullOrEmpty(entry.ActionLabel)) + // Buttons registered by the entry's provider, rendered in order — e.g. a + // Share/Unshare toggle followed by a remove button. Removal is just a + // Destructive action; the Library has no dedicated remove path. + if (entry.Actions != null) { - PanelButton actionItem = PanelButton.CreateNew(ButtonStyles.AcceptButton, itemListPanel.TabButtonParent); - actionItem.Descriptor.SetTitle(entry.ActionLabel); - actionItem.SetSize(new Vector2(180, 80)); - actionItem.OnClicked += async () => + foreach (BasisShareableAction action in entry.Actions) { - if (!string.IsNullOrEmpty(entry.ActionConfirmTitle)) - { - DialogBox confirmDialog = DialogBox.Create(panel, new Vector2(650, 180), - entry.ActionConfirmTitle, - entry.ActionConfirmBody ?? string.Empty, - AddressableAssets.Sprites.Information, - true - ); - LibraryProviderDialogRemove.BuildDialogButtons(confirmDialog); - if (!await confirmDialog.WaitAsync()) return; - } - entry.Action?.Invoke(); - }; + if (action == null || action.Invoke == null) continue; + CreateShareableActionButton(entry, itemListPanel, action); + } } + } - PanelButton removeItem = PanelButton.CreateNew(ButtonStyles.CancelButton, itemListPanel.TabButtonParent); - removeItem.Descriptor.SetTitle(string.Empty); - removeItem.SetIcon(AddressableAssets.Sprites.Trash); - removeItem.SetSize(new Vector2(80, 80)); - removeItem.Descriptor.IconImage.rectTransform.sizeDelta = new Vector2(-30, -30); - removeItem.Descriptor.SetTooltip(BasisLocalization.Get("library.instantiated.remove.tooltip")); - removeItem.OnClicked += async () => + private static void CreateShareableActionButton(BasisShareableEntry entry, PanelTabGroup itemListPanel, BasisShareableAction action) + { + bool destructive = action.Style == BasisShareableActionStyle.Destructive; + PanelButton actionItem = PanelButton.CreateNew(destructive ? ButtonStyles.CancelButton : ButtonStyles.AcceptButton, itemListPanel.TabButtonParent); + + if (destructive && string.IsNullOrEmpty(action.Label)) { - bool confirmed = await LibraryProviderDialogRemove.PromptUserForRemoval(panel, ShareableDisplayName(entry), ShareableKindLabel(entry.Kind)); - if (!confirmed) return; - entry.Remove?.Invoke(); + // Icon-only trash button (the standard remove affordance). + actionItem.Descriptor.SetTitle(string.Empty); + actionItem.SetIcon(AddressableAssets.Sprites.Trash); + actionItem.SetSize(new Vector2(80, 80)); + actionItem.Descriptor.IconImage.rectTransform.sizeDelta = new Vector2(-30, -30); + actionItem.Descriptor.SetTooltip(BasisLocalization.Get("library.instantiated.remove.tooltip")); + } + else + { + actionItem.Descriptor.SetTitle(action.Label ?? string.Empty); + actionItem.SetSize(new Vector2(180, 80)); + } + + actionItem.OnClicked += async () => + { + if (!await ConfirmShareableAction(entry, action)) return; + action.Invoke?.Invoke(); }; } + // Returns true if the action should proceed. Explicit confirm text wins; otherwise + // a Destructive action falls back to the Library's standard "remove {name}?" prompt. + private static async Task ConfirmShareableAction(BasisShareableEntry entry, BasisShareableAction action) + { + if (!string.IsNullOrEmpty(action.ConfirmTitle)) + { + DialogBox confirmDialog = DialogBox.Create(panel, new Vector2(650, 180), + action.ConfirmTitle, + action.ConfirmBody ?? string.Empty, + AddressableAssets.Sprites.Information, + true + ); + LibraryProviderDialogRemove.BuildDialogButtons(confirmDialog); + return await confirmDialog.WaitAsync(); + } + + if (action.Style == BasisShareableActionStyle.Destructive) + { + return await LibraryProviderDialogRemove.PromptUserForRemoval(panel, ShareableDisplayName(entry), ShareableKindLabel(entry.Kind)); + } + + return true; + } + private static string ShareableIcon(BasisShareableKind kind) { switch (kind) diff --git a/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisContentShareManager.cs b/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisContentShareManager.cs index 5084de237d..e8d054dbf2 100644 --- a/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisContentShareManager.cs +++ b/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisContentShareManager.cs @@ -7,6 +7,7 @@ using Basis.Scripts.TransformBinders.BoneControl; using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Threading.Tasks; using UnityEngine; using UnityEngine.AddressableAssets; @@ -299,7 +300,14 @@ private static void CreateSphere(ServerContentShareMessage serverMsg) Kind = ToShareableKind(msg.ContentType), Title = shareDetail, SharerName = serverMsg.SharerDisplayName, - Remove = () => RequestRemoveSphere(sphereId), + Actions = new List + { + new BasisShareableAction + { + Style = BasisShareableActionStyle.Destructive, + Invoke = () => RequestRemoveSphere(sphereId), + }, + }, }); } } diff --git a/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisShareableRegistry.cs b/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisShareableRegistry.cs index 21676bfa9c..0e28331e45 100644 --- a/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisShareableRegistry.cs +++ b/Basis/Packages/com.basis.framework/Networking/ContentShare/BasisShareableRegistry.cs @@ -11,23 +11,44 @@ public enum BasisShareableKind Other } +/// How the Library should present a . +/// Semantic (not a concrete UI style) so this framework type stays decoupled from +/// BasisUI prefabs/sprites — the Library maps these to buttons. +public enum BasisShareableActionStyle +{ + /// A normal/affirmative action (e.g. "Share"), rendered as an accept button. + Positive, + /// A removal action, rendered as the trash button. When no explicit + /// confirm text is supplied the Library shows its standard "remove {name}?" prompt. + Destructive, +} + +/// One button shown on a Library shareable entry. The registering package owns +/// the semantics (label + callback); the Library just presents it. +public sealed class BasisShareableAction +{ + /// Button text. Empty = icon-only (only meaningful for + /// , which uses the trash icon). + public string Label; + public BasisShareableActionStyle Style; + public Action Invoke; + /// Non-null = the Library shows a yes/no dialog with this title/body before + /// invoking (for consent-style actions). + public string ConfirmTitle; + public string ConfirmBody; +} + public sealed class BasisShareableEntry { public string Id; public BasisShareableKind Kind; public string Title; public string SharerName; - public Action Remove; - /// Optional secondary action, rendered as a labeled button next to the - /// remove button (e.g. a "Share"/"Unshare" toggle). Null/empty label = no button. - /// The registering package owns the semantics; the Library UI just presents it. - public string ActionLabel; - public Action Action; - /// Non-null = the Library shows a yes/no dialog with this title/body - /// before invoking (for consent-style actions). - public string ActionConfirmTitle; - public string ActionConfirmBody; + /// Buttons rendered on the entry (in order), e.g. a "Share"/"Unshare" toggle + /// followed by a remove button. Removal is just a + /// action here — the Library has no dedicated remove path. + public List Actions; } /// @@ -65,18 +86,14 @@ public static void SetDetail(string id, string detail) } } - /// Update an entry's secondary action presentation (label + optional - /// confirm text) — e.g. flipping a Share button to Unshare after it's invoked. - /// A null/empty label removes the button. The Action delegate itself stays as - /// registered. - public static void SetAction(string id, string label, string confirmTitle = null, string confirmBody = null) + /// Replace an entry's action buttons — e.g. flipping a "Share" toggle to + /// "Unshare" after it's invoked. Pass an empty list (or null) to leave no buttons. + public static void SetActions(string id, List actions) { if (string.IsNullOrEmpty(id)) return; if (Entries.TryGetValue(id, out BasisShareableEntry entry)) { - entry.ActionLabel = label; - entry.ActionConfirmTitle = confirmTitle; - entry.ActionConfirmBody = confirmBody; + entry.Actions = actions; OnChanged?.Invoke(); } } diff --git a/Basis/Packages/com.basis.imagepickup/BasisImagePickupManager.cs b/Basis/Packages/com.basis.imagepickup/BasisImagePickupManager.cs index 9fe54c1541..ef29b77319 100644 --- a/Basis/Packages/com.basis.imagepickup/BasisImagePickupManager.cs +++ b/Basis/Packages/com.basis.imagepickup/BasisImagePickupManager.cs @@ -127,7 +127,14 @@ public bool SpawnFromFile(string path) Kind = BasisShareableKind.Image, Title = $"{result.Width}x{result.Height}", SharerName = ownerName, - Remove = () => { if (Instance != null) Instance.RequestDespawn(id); }, + Actions = new List + { + new BasisShareableAction + { + Style = BasisShareableActionStyle.Destructive, + Invoke = () => { if (Instance != null) Instance.RequestDespawn(id); }, + }, + }, }); if (HasNetworkID) @@ -345,7 +352,14 @@ private void FinalizeTransfer(InboundTransfer transfer) Kind = BasisShareableKind.Image, Title = $"{transfer.Width}x{transfer.Height}", SharerName = transfer.OwnerName, - Remove = () => { if (Instance != null) Instance.RequestDespawn(imageId); }, + Actions = new List + { + new BasisShareableAction + { + Style = BasisShareableActionStyle.Destructive, + Invoke = () => { if (Instance != null) Instance.RequestDespawn(imageId); }, + }, + }, }); } From bcd6b5831d146ccc492dccfa10d4a1e1eb784a76 Mon Sep 17 00:00:00 2001 From: yewnyx Date: Sat, 11 Jul 2026 05:46:01 +0900 Subject: [PATCH 4/4] refactor(library): share one action-button builder across entry tabs Extract EntryActionButton + BuildEntryActionButton, which own the common list-entry button setup (style, icon inset, sizing, tooltip, hidden/disabled handling, async click). Route the shareable action button and the instantiated tab's select/teleport/lock/remove buttons through it, removing the duplicated inline setup. The registry's BasisShareableAction stays free of BasisUI references and maps onto the builder in the UI layer. --- .../BasisUI/Menus/Library/LibraryProvider.cs | 376 ++++++++++-------- 1 file changed, 203 insertions(+), 173 deletions(-) diff --git a/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs b/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs index daaec29072..3482adfa3e 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs +++ b/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs @@ -2057,28 +2057,21 @@ private static void CreateShareableListEntry(BasisShareableEntry entry, RectTran private static void CreateShareableActionButton(BasisShareableEntry entry, PanelTabGroup itemListPanel, BasisShareableAction action) { bool destructive = action.Style == BasisShareableActionStyle.Destructive; - PanelButton actionItem = PanelButton.CreateNew(destructive ? ButtonStyles.CancelButton : ButtonStyles.AcceptButton, itemListPanel.TabButtonParent); + // A destructive action with no label is the standard icon-only trash affordance. + bool trashButton = destructive && string.IsNullOrEmpty(action.Label); - if (destructive && string.IsNullOrEmpty(action.Label)) + BuildEntryActionButton(itemListPanel.TabButtonParent, new EntryActionButton { - // Icon-only trash button (the standard remove affordance). - actionItem.Descriptor.SetTitle(string.Empty); - actionItem.SetIcon(AddressableAssets.Sprites.Trash); - actionItem.SetSize(new Vector2(80, 80)); - actionItem.Descriptor.IconImage.rectTransform.sizeDelta = new Vector2(-30, -30); - actionItem.Descriptor.SetTooltip(BasisLocalization.Get("library.instantiated.remove.tooltip")); - } - else - { - actionItem.Descriptor.SetTitle(action.Label ?? string.Empty); - actionItem.SetSize(new Vector2(180, 80)); - } - - actionItem.OnClicked += async () => - { - if (!await ConfirmShareableAction(entry, action)) return; - action.Invoke?.Invoke(); - }; + Style = destructive ? ButtonStyles.CancelButton : ButtonStyles.AcceptButton, + Icon = trashButton ? AddressableAssets.Sprites.Trash : null, + Label = action.Label, + Tooltip = trashButton ? BasisLocalization.Get("library.instantiated.remove.tooltip") : null, + OnClick = async () => + { + if (!await ConfirmShareableAction(entry, action)) return; + action.Invoke?.Invoke(); + }, + }); } // Returns true if the action should proceed. Explicit confirm text wins; otherwise @@ -2105,6 +2098,53 @@ private static async Task ConfirmShareableAction(BasisShareableEntry entry return true; } + // One small action button on a Library list entry. Used by both the shareables tab + // and the instantiated-objects tab so every entry button shares the same sizing, + // icon inset, tooltip and hidden/disabled handling. Field defaults (all false/null) + // are the common case: visible, enabled, no tooltip. + private struct EntryActionButton + { + public string Style; // ButtonStyles.* prefab + public string Icon; // sprite key; ignored when Label is set + public string Label; // set => labeled 180x80 button; empty => icon-only 80x80 + public string Tooltip; + public bool Hidden; // hides the button (e.g. not applicable to this entry) + public bool Disabled; + public string DisabledReason; // surfaced when Disabled + public Func OnClick; + } + + private static PanelButton BuildEntryActionButton(Component parent, in EntryActionButton spec) + { + PanelButton button = PanelButton.CreateNew(spec.Style, parent); + + if (string.IsNullOrEmpty(spec.Label)) + { + button.Descriptor.SetTitle(string.Empty); + if (!string.IsNullOrEmpty(spec.Icon)) + { + button.SetIcon(spec.Icon); + // Inset the icon so its strokes stay clear of the bevel — matches PE Image Simple Square's pattern. + button.Descriptor.IconImage.rectTransform.sizeDelta = new Vector2(-30, -30); + } + button.SetSize(new Vector2(80, 80)); + } + else + { + button.Descriptor.SetTitle(spec.Label); + button.SetSize(new Vector2(180, 80)); + } + + if (!string.IsNullOrEmpty(spec.Tooltip)) button.Descriptor.SetTooltip(spec.Tooltip); + if (spec.Hidden) button.Descriptor.SetActive(false); + if (spec.Disabled) button.SetInteractable(false, spec.DisabledReason); + + Func onClick = spec.OnClick; + if (onClick != null) button.OnClicked += async () => await onClick(); + + return button; + } + private static string ShareableIcon(BasisShareableKind kind) { switch (kind) @@ -2277,69 +2317,63 @@ private static void CreateListEntry(BasisRuntimeSpawnRegistry.SpawnInstance item bool isScene = itemKey.SpawnMode == BasisRuntimeSpawnRegistry.SpawnMode.Scene; - PanelButton selectItem = PanelButton.CreateNew(ButtonStyles.AcceptButton, itemListPanel.TabButtonParent); - selectItem.Descriptor.SetTitle(string.Empty); - selectItem.SetIcon(AddressableAssets.Sprites.Select); - selectItem.SetSize(new Vector2(80, 80)); - // Inset the icon so its strokes stay clear of the bevel — matches PE Image Simple Square's pattern. - selectItem.Descriptor.IconImage.rectTransform.sizeDelta = new Vector2(-30, -30); - - selectItem.Descriptor.SetActive(!isScene); - selectItem.Descriptor.SetTooltip(BasisLocalization.Get("library.instantiated.select.tooltip")); - selectItem.OnClicked += async () => + BuildEntryActionButton(itemListPanel.TabButtonParent, new EntryActionButton { - if(hasSelected) + Style = ButtonStyles.AcceptButton, + Icon = AddressableAssets.Sprites.Select, + Tooltip = BasisLocalization.Get("library.instantiated.select.tooltip"), + Hidden = isScene, + OnClick = async () => { - PlacementManager.RemoveSelectionSpawnInstanceID(itemKey); - - await RefreshCurrentTab(); - } - else - { - // send the selection - PlacementManager.SetActiveSelection(itemKey); - - // close the menu - BasisMainMenu.Close(); - } - - }; - - PanelButton TeleportToItem = PanelButton.CreateNew(ButtonStyles.StandardButton, itemListPanel.TabButtonParent); - TeleportToItem.Descriptor.SetTitle(string.Empty); - TeleportToItem.SetIcon(AddressableAssets.Sprites.TeleportTo); - TeleportToItem.SetSize(new Vector2(80, 80)); - TeleportToItem.Descriptor.IconImage.rectTransform.sizeDelta = new Vector2(-30, -30); + if (hasSelected) + { + PlacementManager.RemoveSelectionSpawnInstanceID(itemKey); + await RefreshCurrentTab(); + } + else + { + // send the selection + PlacementManager.SetActiveSelection(itemKey); + // close the menu + BasisMainMenu.Close(); + } + }, + }); - TeleportToItem.Descriptor.SetActive(!isScene); - TeleportToItem.Descriptor.SetTooltip(BasisLocalization.Get("library.instantiated.teleport.tooltip")); - TeleportToItem.OnClicked += () => + BuildEntryActionButton(itemListPanel.TabButtonParent, new EntryActionButton { - - switch(itemKey.SpawnMode) + Style = ButtonStyles.StandardButton, + Icon = AddressableAssets.Sprites.TeleportTo, + Tooltip = BasisLocalization.Get("library.instantiated.teleport.tooltip"), + Hidden = isScene, + OnClick = () => { - case BasisRuntimeSpawnRegistry.SpawnMode.Avatar: - case BasisRuntimeSpawnRegistry.SpawnMode.GameObject: - - // find the object in the BasisRuntimeSpawnRegistry - if (BasisRuntimeSpawnRegistry.SpawnedGameobjects.TryGetValue(itemKey.LoadedNetID, out GameObject go) && go != null) - { - Vector3 offsetTarget = go.transform.position; + switch (itemKey.SpawnMode) + { + case BasisRuntimeSpawnRegistry.SpawnMode.Avatar: + case BasisRuntimeSpawnRegistry.SpawnMode.GameObject: - if(itemKey.bundleConnector != null) + // find the object in the BasisRuntimeSpawnRegistry + if (BasisRuntimeSpawnRegistry.SpawnedGameobjects.TryGetValue(itemKey.LoadedNetID, out GameObject go) && go != null) { - offsetTarget.y = offsetTarget.y + itemKey.bundleConnector.Bounds.max.y; - } + Vector3 offsetTarget = go.transform.position; - BasisLocalPlayer.Instance.Teleport( offsetTarget, Quaternion.identity, mode: BasisTeleportMode.WorldFeet ); - } + if (itemKey.bundleConnector != null) + { + offsetTarget.y = offsetTarget.y + itemKey.bundleConnector.Bounds.max.y; + } - break; - case BasisRuntimeSpawnRegistry.SpawnMode.Scene: - BasisDebug.LogWarning( "LibraryProvider.cs -> Teleport To Item button for scene is not implemented!" ); - break; - } - }; + BasisLocalPlayer.Instance.Teleport( offsetTarget, Quaternion.identity, mode: BasisTeleportMode.WorldFeet ); + } + + break; + case BasisRuntimeSpawnRegistry.SpawnMode.Scene: + BasisDebug.LogWarning( "LibraryProvider.cs -> Teleport To Item button for scene is not implemented!" ); + break; + } + return Task.CompletedTask; + }, + }); // Static / lock toggle — networked game objects only; applies for everyone (server-authoritative). // Cycles None -> Static (creator or moderator) -> Admin-locked (moderator only) -> None. @@ -2371,131 +2405,127 @@ private static void CreateListEntry(BasisRuntimeSpawnRegistry.SpawnInstance item break; } - PanelButton staticToggle = PanelButton.CreateNew(ButtonStyles.StandardButton, itemListPanel.TabButtonParent); - staticToggle.Descriptor.SetTitle(string.Empty); - staticToggle.SetIcon(lockLevel == 0 ? AddressableAssets.Sprites.Unlocked - : lockLevel == 1 ? AddressableAssets.Sprites.Locked - : AddressableAssets.Sprites.Admin); - staticToggle.SetSize(new Vector2(80, 80)); - staticToggle.Descriptor.IconImage.rectTransform.sizeDelta = new Vector2(-30, -30); - staticToggle.Descriptor.SetTooltip(BasisLocalization.Get( - lockLevel == 0 ? "library.instantiated.static.tooltip" - : lockLevel == 1 ? "library.instantiated.static.lockedTooltip" - : "library.instantiated.static.adminTooltip")); - // Disabled reason depends on why: an admin-locked item can only be changed by a moderator. string disabledReason = lockLevel == 2 ? BasisLocalization.Get("library.instantiated.static.adminOnly") : BasisLocalization.Get("library.instantiated.static.noPermission"); - staticToggle.SetInteractable(canToggle, canToggle ? null : disabledReason); - staticToggle.OnClicked += () => + BuildEntryActionButton(itemListPanel.TabButtonParent, new EntryActionButton { - // Mode 0 = GameObject. The server authorizes per tier and rebroadcasts; the row - // icon updates when that broadcast arrives (RegistryChangeType.Modified). - BasisNetworkSpawnItem.RequestSetStatic(itemKey.LoadedNetID, 0, nextStatic, nextAdmin); - }; + Style = ButtonStyles.StandardButton, + Icon = lockLevel == 0 ? AddressableAssets.Sprites.Unlocked + : lockLevel == 1 ? AddressableAssets.Sprites.Locked + : AddressableAssets.Sprites.Admin, + Tooltip = BasisLocalization.Get( + lockLevel == 0 ? "library.instantiated.static.tooltip" + : lockLevel == 1 ? "library.instantiated.static.lockedTooltip" + : "library.instantiated.static.adminTooltip"), + Disabled = !canToggle, + DisabledReason = disabledReason, + OnClick = () => + { + // Mode 0 = GameObject. The server authorizes per tier and rebroadcasts; the row + // icon updates when that broadcast arrives (RegistryChangeType.Modified). + BasisNetworkSpawnItem.RequestSetStatic(itemKey.LoadedNetID, 0, nextStatic, nextAdmin); + return Task.CompletedTask; + }, + }); } - PanelButton removeItem = PanelButton.CreateNew(ButtonStyles.CancelButton, itemListPanel.TabButtonParent); - removeItem.Descriptor.SetTitle(string.Empty); - removeItem.SetIcon(AddressableAssets.Sprites.Trash); - removeItem.SetSize(new Vector2(80, 80)); - removeItem.Descriptor.IconImage.rectTransform.sizeDelta = new Vector2(-30, -30); - removeItem.Descriptor.SetTooltip(BasisLocalization.Get("library.instantiated.remove.tooltip")); + // Network items can be protected; only an admin may remove those. Non-network items always removable. + bool canRemove = itemKey.SpawnMethod != BasisRuntimeSpawnRegistry.SpawnMethod.Network || !itemKey.isProtected || IsProtected; - // only apply this to items that are spawned on the network - if(itemKey.SpawnMethod == BasisRuntimeSpawnRegistry.SpawnMethod.Network) + BuildEntryActionButton(itemListPanel.TabButtonParent, new EntryActionButton { - // determine if we can actually remove this via admin - // if the item is embedded only allow an admin to interact - bool canRemove = !itemKey.isProtected || IsProtected; - removeItem.SetInteractable(canRemove, canRemove ? null : BasisLocalization.Get("library.disabled.protected")); - } - - removeItem.OnClicked += async () => - { - BasisDebug.Log($"CreateListEntry() -> requested removal of item = {itemKey.Url} of instanceID = {instanceID} of SpawnMethod = {itemKey.SpawnMethod} and SpawnMode = {itemKey.SpawnMode}"); + Style = ButtonStyles.CancelButton, + Icon = AddressableAssets.Sprites.Trash, + Tooltip = BasisLocalization.Get("library.instantiated.remove.tooltip"), + Disabled = !canRemove, + DisabledReason = canRemove ? null : BasisLocalization.Get("library.disabled.protected"), + OnClick = async () => + { + BasisDebug.Log($"CreateListEntry() -> requested removal of item = {itemKey.Url} of instanceID = {instanceID} of SpawnMethod = {itemKey.SpawnMethod} and SpawnMode = {itemKey.SpawnMode}"); - bool result = await LibraryProviderDialogRemove.PromptUserForRemoval(panel, title, itemKey.SpawnMode.ToString()); + bool result = await LibraryProviderDialogRemove.PromptUserForRemoval(panel, title, itemKey.SpawnMode.ToString()); - if (!result) // if the result is false - { - return; // guard key stop here - } + if (!result) // if the result is false + { + return; // guard key stop here + } - // clear up front; network removals fire OnRegistryChanged.Removed async after a round-trip - PlacementManager.RemoveSelectionSpawnInstanceID(itemKey); + // clear up front; network removals fire OnRegistryChanged.Removed async after a round-trip + PlacementManager.RemoveSelectionSpawnInstanceID(itemKey); - switch (itemKey.SpawnMethod) - { - case BasisRuntimeSpawnRegistry.SpawnMethod.Local: - case BasisRuntimeSpawnRegistry.SpawnMethod.Embedded: + switch (itemKey.SpawnMethod) + { + case BasisRuntimeSpawnRegistry.SpawnMethod.Local: + case BasisRuntimeSpawnRegistry.SpawnMethod.Embedded: - // If this content was set to load on boot, removing it here also stops it - // from coming back next launch. No-op when it was never a boot item. - _ = BasisPreloadContentStore.Remove(itemKey.Url); + // If this content was set to load on boot, removing it here also stops it + // from coming back next launch. No-op when it was never a boot item. + _ = BasisPreloadContentStore.Remove(itemKey.Url); - switch(itemKey.SpawnMode) - { - case BasisRuntimeSpawnRegistry.SpawnMode.Avatar: - case BasisRuntimeSpawnRegistry.SpawnMode.GameObject: + switch(itemKey.SpawnMode) + { + case BasisRuntimeSpawnRegistry.SpawnMode.Avatar: + case BasisRuntimeSpawnRegistry.SpawnMode.GameObject: - // if the item is local and embedded lets actually try get the gameobject first - if (BasisRuntimeSpawnRegistry.SpawnedGameobjects.TryGetValue(itemKey.LoadedNetID, out GameObject go) && go != null) - { - // if the gameobject is not null then lets remove its registery - bool success = await BasisRuntimeSpawnRegistry.RemoveByLoadedNetId(itemKey.LoadedNetID); - if (success) + // if the item is local and embedded lets actually try get the gameobject first + if (BasisRuntimeSpawnRegistry.SpawnedGameobjects.TryGetValue(itemKey.LoadedNetID, out GameObject go) && go != null) { - // we should delete the embedded item - GameObject.Destroy(go); - } - else - { - BasisDebug.LogError($"failed to remove item = {instanceID} that has itemKey.SpawnMethod = {itemKey.SpawnMethod} from basis BasisRuntimeSpawnRegistry"); - } + // if the gameobject is not null then lets remove its registery + bool success = await BasisRuntimeSpawnRegistry.RemoveByLoadedNetId(itemKey.LoadedNetID); + if (success) + { + // we should delete the embedded item + GameObject.Destroy(go); + } + else + { + BasisDebug.LogError($"failed to remove item = {instanceID} that has itemKey.SpawnMethod = {itemKey.SpawnMethod} from basis BasisRuntimeSpawnRegistry"); + } - } + } - break; + break; - case BasisRuntimeSpawnRegistry.SpawnMode.Scene: + case BasisRuntimeSpawnRegistry.SpawnMode.Scene: - if(BasisRuntimeSpawnRegistry.SpawnedScenes.TryGetValue(itemKey.LoadedNetID, out Scene scene) && scene.IsValid()) - { - bool success = await BasisRuntimeSpawnRegistry.RemoveByLoadedNetId(itemKey.LoadedNetID); - if(success) - { - BasisDebug.Log( $"successfully removed scene with LoadedNetID = {itemKey.LoadedNetID}" ); - } - else + if(BasisRuntimeSpawnRegistry.SpawnedScenes.TryGetValue(itemKey.LoadedNetID, out Scene scene) && scene.IsValid()) { - BasisDebug.LogError($"failed to remove scene with LoadedNetID = {instanceID}"); + bool success = await BasisRuntimeSpawnRegistry.RemoveByLoadedNetId(itemKey.LoadedNetID); + if(success) + { + BasisDebug.Log( $"successfully removed scene with LoadedNetID = {itemKey.LoadedNetID}" ); + } + else + { + BasisDebug.LogError($"failed to remove scene with LoadedNetID = {instanceID}"); + } } - } - - break; - } - break; - case BasisRuntimeSpawnRegistry.SpawnMethod.Network: - switch (itemKey.SpawnMode) - { - case BasisRuntimeSpawnRegistry.SpawnMode.GameObject: - BasisNetworkSpawnItem.RequestGameObjectUnLoad(itemKey.LoadedNetID); - break; - case BasisRuntimeSpawnRegistry.SpawnMode.Scene: - BasisNetworkSpawnItem.RequestSceneUnLoad(itemKey.LoadedNetID); - break; - default: - BasisDebug.LogWarning($"Missing Spawn Method! {itemKey.SpawnMode}"); break; - } - break; - } - await RefreshCurrentTab(); - }; + } + + break; + case BasisRuntimeSpawnRegistry.SpawnMethod.Network: + switch (itemKey.SpawnMode) + { + case BasisRuntimeSpawnRegistry.SpawnMode.GameObject: + BasisNetworkSpawnItem.RequestGameObjectUnLoad(itemKey.LoadedNetID); + break; + case BasisRuntimeSpawnRegistry.SpawnMode.Scene: + BasisNetworkSpawnItem.RequestSceneUnLoad(itemKey.LoadedNetID); + break; + default: + BasisDebug.LogWarning($"Missing Spawn Method! {itemKey.SpawnMode}"); + break; + } + break; + } + await RefreshCurrentTab(); + }, + }); } #endregion