# Jovian Popup System A lightweight, low-allocation popup and tooltip system for Unity with category-based isolation, a fluent content builder, and extensible animations. ## Requirements - Unity 2022.3 or later - TextMeshPro 3.0.6+ - Input System 1.18.0+ - Newtonsoft JSON 3.2.1+ Install via the Unity Package Manager by adding the package from its local path or from a git URL. ## Quick Start ### 1. Create a PopupSettings asset In the Unity Editor, go to **Assets > Create > Jovian > Popup System > Popup Settings**. Place the asset somewhere accessible (e.g. `Assets/Settings/PopupSettings.asset`). You can also configure settings via **Project Settings > Jovian > Popup System**. ### 2. Build a PopupReference prefab See the [Prefab Setup](#prefab-setup) section below for step-by-step instructions. ### 3. Create and use PopupSystem ```csharp using Jovian.PopupSystem; using Jovian.PopupSystem.UI; // Create the system. Pass the scene Canvas as canvasRoot to auto-scan all PopupTrigger components. var popup = new PopupSystem(settings, viewPrefab, canvasRoot); // Register categories you intend to use. popup.RegisterCategory(PopupCategory.Item); popup.RegisterCategory(PopupCategory.Character); // Option A: Set content on auto-scanned triggers by name. var trigger = popup.GetTrigger("ItemSlot"); trigger?.SetContent(builder => { builder .AddHeader("Health Potion") .AddSeparator() .AddText("Restores a moderate amount of health.") .AddStat("Heal Amount", 50); }); // Option B: Show a popup directly from code. popup.Show(PopupCategory.Item, builder => { builder.AddHeader("Iron Sword").AddStat("Damage", 12); }, anchorRect, AnchorSide.Right); // Tick each frame (typically in Update or a game-state loop). popup.Tick(Time.deltaTime); // Clean up when the game state is torn down. popup.Dispose(); ``` ## PopupCategory `PopupCategory` is a readonly struct that acts as a channel for isolating popups. Each category gets its own view instance and can have independent priority and delay settings. ### Built-in categories ```csharp PopupCategory.Character // Character tooltips PopupCategory.Item // Item tooltips PopupCategory.Skill // Skill tooltips PopupCategory.General // General-purpose tooltips ``` ### Custom categories Create any number of additional categories: ```csharp var lootCategory = new PopupCategory("Loot"); popup.RegisterCategory(lootCategory, priority: 5); ``` Categories are compared by their string ID using ordinal comparison. ## PopupSettings `PopupSettings` is a ScriptableObject that holds all configuration. Create one via **Assets > Create > Jovian > Popup System > Popup Settings**. | Field | Type | Default | Description | |---|---|---|---| | `popupDelay` | float | 0.4 | Seconds before the popup appears after a show request. | | `fadeDuration` | float | 0.2 | Duration of the fade-in and fade-out animation. | | `defaultAnchorSide` | AnchorSide | Below | Default side to anchor the popup relative to the target element. | | `screenEdgePadding` | float | 10 | Minimum pixel distance from screen edges. | | `maxPopupWidth` | float | 400 | Maximum width of the popup in pixels. | | `sortingOrder` | int | 100 | Sorting order applied to the popup Canvas. | | `followMouseOffset` | Vector2 | (15, -15) | Pixel offset from the cursor in follow-mouse mode. | | `touchHoldDuration` | float | 0.6 | Seconds a touch must be held before triggering a popup. | | `gamepadFocusTrigger` | bool | true | Whether gamepad focus triggers popups. | | `categoryPriorities` | List | empty | Per-category priority overrides. Higher priority popups dismiss lower ones. | | `categoryDelayOverrides` | List | empty | Per-category delay overrides. Overrides `popupDelay` for specific categories. | ## PopupContentBuilder `PopupContentBuilder` is a struct with a fluent API for composing popup content. You receive it in the build callback passed to `Show` or `ShowAtPosition`. ### Available methods ```csharp builder.AddHeader("Fireball"); // Bold header text builder.AddText("Deals fire damage to all enemies."); // Body text builder.AddText("Rare", "FFD700"); // Colored text (hex, with or without #) builder.AddStat("Damage", 120); // Label + integer value row builder.AddStat("Range", "15m"); // Label + string value row builder.AddImage(iconSprite, 64f); // Sprite with optional height builder.AddSeparator(); // Horizontal divider line ``` ### Full example ```csharp popup.Show(PopupCategory.Skill, builder => { builder .AddHeader("Fireball") .AddText("Hurls a ball of fire that explodes on impact.") .AddSeparator() .AddStat("Damage", 120) .AddStat("Mana Cost", 35) .AddStat("Range", "15m") .AddSeparator() .AddText("Requires: Level 5", "FF6666"); }, targetRect); ``` All elements are drawn from a grow-only pool inside `PopupReference`. No allocations occur once the pool is warmed. ## PopupTrigger `PopupTrigger` is a MonoBehaviour you attach to UI elements to get hover-based popup behavior automatically. It implements `IPointerEnterHandler` and `IPointerExitHandler`. ### Inspector fields | Field | Type | Description | |---|---|---| | `category` | PopupCategory | Which category channel to use. | | `anchorSide` | AnchorSide | Which side of this element to anchor the popup. | | `positionMode` | PopupPositionMode | `AnchorToElement` or `FollowMouse`. | ### Approach 1: Auto-scanned triggers (recommended for static UI) When you pass a `canvasRoot` to the `PopupSystem` constructor, it auto-scans all `PopupTrigger` components and binds them. You only need to set content: ```csharp var popup = new PopupSystem(settings, viewPrefab, canvasRoot); popup.RegisterCategory(PopupCategory.Character); // Find a trigger by its GameObject name and set content var trigger = popup.GetTrigger("HeroPortrait"); trigger?.SetContent(builder => { builder.AddHeader("Kael").AddStat("Health", 55); }); // Or set content on all triggers of a category foreach(var t in popup.GetTriggers(PopupCategory.Item)) { t.SetContent(builder => builder.AddHeader("Item")); } ``` ### Approach 2: Dynamic triggers (for runtime-created UI) For UI elements instantiated at runtime (inventory slots, party portraits), use `InitializeTriggersInChildren` after instantiation: ```csharp // After instantiating slot prefabs under a container: popup.InitializeTriggersInChildren(slotsContainer, trigger => { var slotData = trigger.GetComponentInParent(); trigger.SetContent(builder => { builder.AddHeader(slotData.itemName); }); }); ``` This scans, binds, and registers all triggers under the parent, then calls the callback on each. ### Approach 3: Full manual initialization For complete control, bypass auto-scan and initialize triggers directly: ```csharp var trigger = button.GetComponent(); trigger.Initialize(popupSystem, builder => { builder.AddHeader("Attack").AddStat("Damage", 25); }); // Or with a category override: trigger.Initialize(popupSystem, PopupCategory.Skill, builder => { builder.AddHeader("Fireball"); }); ``` ### Updating content To change content on an already-initialized trigger without re-binding: ```csharp trigger.SetContent(builder => { builder.AddHeader("Attack").AddStat("Damage", newDamageValue); }); // UpdateContent is an alias for SetContent trigger.UpdateContent(builder => { ... }); ``` ## Code-Only Triggers You do not need `PopupTrigger` to show popups. Call `IPopupSystem` methods directly: ### Anchor to a RectTransform ```csharp popupSystem.Show(PopupCategory.Item, builder => { builder.AddHeader("Iron Sword"); }, inventorySlotRect, AnchorSide.Right); ``` ### Follow the mouse cursor Pass no anchor argument: ```csharp popupSystem.Show(PopupCategory.General, builder => { builder.AddText("Click to interact"); }); ``` ### Show at a fixed screen position ```csharp popupSystem.ShowAtPosition(PopupCategory.General, builder => { builder.AddText("Tutorial tip"); }, new Vector2(Screen.width * 0.5f, Screen.height * 0.5f)); ``` ### Hide ```csharp popupSystem.Hide(PopupCategory.Item); // Hide a specific category popupSystem.HideAll(); // Hide all categories ``` ## Priority System When a popup is shown, any visible popup from a lower-priority category is automatically dismissed. Priority is configured per category via `PopupSettings.categoryPriorities` or at registration time: ```csharp popup.RegisterCategory(PopupCategory.Character, priority: 10); popup.RegisterCategory(PopupCategory.Item, priority: 5); popup.RegisterCategory(PopupCategory.General, priority: 1); ``` If the player hovers a character portrait (priority 10) while an item tooltip (priority 5) is visible, the item tooltip is dismissed. Popups of equal or higher priority are not affected. Settings-based priorities (from `PopupSettings.categoryPriorities`) take precedence over the `priority` argument in `RegisterCategory`. ## Lifecycle Integration The popup system is designed to be created per game state, not as a global singleton. Each game state owns its own `IPopupSystem` instance: ```csharp // In your game state initialization: var guiCanvas = guiReferences.GetComponentInParent().transform; var popupSystem = new PopupSystem(popupSettings, popupViewPrefab, guiCanvas); popupSystem.RegisterCategory(PopupCategory.Character, priority: 10); popupSystem.RegisterCategory(PopupCategory.Item, priority: 5); // All PopupTrigger components under guiCanvas are auto-scanned and bound. // Pass popupSystem to views/play modes via constructor DI. // In your game state's Tick/Update: popupSystem.Tick(Time.deltaTime); // When the game state exits: popupSystem.Dispose(); // destroys all popup view GameObjects ``` Each category lazily creates its own `PopupReference` instance on first `Show` call. On `Dispose`, all views are destroyed. This ensures no leaked GameObjects when transitioning between game states. ## IPopupAnimator The popup system uses `IPopupAnimator` for show/hide transitions. The default implementation is `FadePopupAnimator`, which lerps the `CanvasGroup.alpha` over the configured `fadeDuration`. ### Interface ```csharp public interface IPopupAnimator { void Show(CanvasGroup canvasGroup, float duration, Action onComplete); void Hide(CanvasGroup canvasGroup, float duration, Action onComplete); void Tick(float deltaTime); bool IsAnimating { get; } } ``` ### Custom animator example ```csharp public sealed class ScalePopupAnimator : IPopupAnimator { private CanvasGroup target; private float duration; private float elapsed; private float startScale; private float endScale; private Action onComplete; public bool IsAnimating => target != null; public void Show(CanvasGroup canvasGroup, float duration, Action onComplete) { target = canvasGroup; this.duration = Mathf.Max(duration, 0.001f); elapsed = 0f; startScale = 0f; endScale = 1f; this.onComplete = onComplete; canvasGroup.transform.localScale = Vector3.zero; canvasGroup.alpha = 1f; } public void Hide(CanvasGroup canvasGroup, float duration, Action onComplete) { target = canvasGroup; this.duration = Mathf.Max(duration, 0.001f); elapsed = 0f; startScale = 1f; endScale = 0f; this.onComplete = onComplete; } public void Tick(float deltaTime) { if(target == null) { return; } elapsed += deltaTime; var t = Mathf.Clamp01(elapsed / duration); var scale = Mathf.Lerp(startScale, endScale, t); target.transform.localScale = Vector3.one * scale; if(t >= 1f) { var callback = onComplete; target = null; onComplete = null; callback?.Invoke(); } } } ``` Pass a factory function to the constructor so each category gets its own animator instance: ```csharp var popup = new PopupSystem(settings, viewPrefab, () => new ScalePopupAnimator()); ``` ## Prefab Setup Build the `PopupReference` prefab with the following hierarchy. Reference prefabs are included in the `Samples~/Prefabs` folder. ``` PopupReferencePrefab Canvas, CanvasGroup, CanvasScaler, PopupReference Background Image (popup background), ContentSizeFitter (V=Preferred) Content VerticalLayoutGroup (Control Child Size Width, Child Force Expand Width) ``` ### Step 1: Root GameObject 1. Create a new GameObject named `PopupReferencePrefab` 2. Add a `Canvas` component. Set **Render Mode** to **Screen Space - Overlay** 3. Add a `CanvasScaler` with **Scale With Screen Size**, reference resolution matching your project (e.g. 1920x1080). This is needed for prefab editing; at runtime the nested Canvas inherits the parent's scaler 4. Add a `CanvasGroup` component 5. Add a `PopupReference` component (from `Jovian.PopupSystem.UI`) 6. Set anchors to a single point (e.g. Min 0,1 Max 0,1), Pivot (0,1) for top-left origin 7. Do **not** add a `ContentSizeFitter` to the root ### Step 2: Background 1. Create a child GameObject named `Background` 2. Add an `Image` component. Set the image sprite/color to your desired popup background 3. Add a `ContentSizeFitter` with **Horizontal Fit = Unconstrained** and **Vertical Fit = Preferred Size** 4. Stretch the RectTransform to fill the root (anchors 0,0 to 1,1, offsets 0) or use point anchors with the ContentSizeFitter driving the size ### Step 3: Content container 1. Create a child of `Background` named `Content` 2. Stretch the RectTransform to fill the Background (anchors 0,0 to 1,1, offsets for padding) 3. Add a `VerticalLayoutGroup`: - **Control Child Size**: Width checked, Height unchecked - **Child Force Expand**: Width checked, Height unchecked - **Spacing**: 2 (or to taste) - **Padding**: as needed 4. Do **not** add a `ContentSizeFitter` to Content. The VerticalLayoutGroup reports its preferred size to the parent Background's ContentSizeFitter ### Step 4: Element prefabs Create these as separate prefabs. Do not add `ContentSizeFitter` to any element prefab: | Prefab | Component | Notes | |---|---|---| | PopupHeader | `TMP_Text` | Bold, larger font size. Add `LayoutElement` if you need minimum height | | PopupText | `TMP_Text` | Regular body text, rich text enabled | | PopupStat | `RectTransform` with `HorizontalLayoutGroup` | Must have exactly two `TMP_Text` children: label (left) and value (right) | | PopupIcon | `Image` | For icons or artwork. Add `LayoutElement` with preferred height | | PopupSeparator | `Image` | Thin horizontal line. Add `LayoutElement` with preferred height = 1 or 2 | ### Step 5: Wire references On the `PopupReference` component, assign: - **Content**: the Content RectTransform - **Canvas Group**: the root CanvasGroup - **Background**: the Background RectTransform - **Header Prefab**: your PopupHeader prefab - **Text Prefab**: your PopupText prefab - **Stat Prefab**: your PopupStat prefab - **Image Prefab**: your PopupIcon prefab - **Separator Prefab**: your PopupSeparator prefab Save as a prefab. The `maxPopupWidth` from `PopupSettings` is applied at runtime to constrain the popup's horizontal size. At runtime, when parented under a scene Canvas, the popup's own Canvas becomes a nested override Canvas inheriting the parent's scaling. ## Optimization Notes The popup system is designed for minimal runtime allocation: - `PopupCategory` is a readonly struct with value semantics -- zero heap allocation on pass or comparison - `PopupContentBuilder` is a struct that operates directly on cached UI elements -- no intermediate data structures - Content elements use a **grow-only cache**: elements are activated/deactivated, never instantiated or destroyed after warmup. `GetStat` caches TMP_Text references in a struct to avoid `GetComponentsInChildren` on reuse - Delay timers and animation are driven by `float` fields in `Tick()` -- no coroutines, no `WaitForSeconds` - Screen rect calculation uses a static `Vector3[4]` buffer -- no per-frame array allocation - Layout rebuilds only occur when content changes (dirty flag), not on every position update - Each registered category gets its own `IPopupAnimator` instance, preventing state corruption during concurrent show/hide animations ## Samples The `Samples~` folder contains ready-to-use reference material: - **Prefabs**: PopupReferencePrefab and all element prefabs (header, text, stat, icon, separator) with correct component setup - **Settings**: Pre-configured PopupSettings asset with sensible defaults - **Scripts**: Three example scripts demonstrating different integration patterns: - `PopupSystemExample` -- auto-scanned triggers with `GetTrigger` and `GetTriggers` - `DynamicTriggersExample` -- runtime-instantiated UI with `InitializeTriggersInChildren` - `CodeOnlyPopupExample` -- showing popups from code (anchored, fixed position, follow mouse) Import via Unity Package Manager: select the package, expand Samples, click Import. ## API Reference ### Core Types | Type | Namespace | Description | |---|---|---| | `IPopupSystem` | `Jovian.PopupSystem` | Main interface for showing/hiding popups. | | `PopupSystem` | `Jovian.PopupSystem` | Concrete implementation. Constructor: `(PopupSettings, PopupReference, Transform canvasParent, Func)`. Auto-scans triggers under canvasParent. Methods: `ScanTriggers`, `GetTrigger`, `GetTriggers`, `InitializeTriggersInChildren`. | | `PopupSettings` | `Jovian.PopupSystem` | ScriptableObject with all configuration fields. | | `PopupCategory` | `Jovian.PopupSystem` | Readonly struct identifying a popup channel. | | `PopupContentBuilder` | `Jovian.PopupSystem` | Fluent struct for building popup content in callbacks. | ### UI Types | Type | Namespace | Description | |---|---|---| | `PopupReference` | `Jovian.PopupSystem.UI` | MonoBehaviour managing popup layout, pooling, and positioning. | | `PopupTrigger` | `Jovian.PopupSystem.UI` | MonoBehaviour for hover-based popup triggers on UI elements. | ### Animation Types | Type | Namespace | Description | |---|---|---| | `IPopupAnimator` | `Jovian.PopupSystem` | Interface for custom show/hide animations. | | `FadePopupAnimator` | `Jovian.PopupSystem` | Default fade animation via CanvasGroup alpha. | ### Enums | Type | Values | |---|---| | `AnchorSide` | `Below`, `Above`, `Left`, `Right` | | `PopupPositionMode` | `AnchorToElement`, `FollowMouse` | ## License See the LICENSE file in the package root.