# Popup System Design

**Package:** `com.jovian.popup-system`
**Date:** 2026-04-06

## Purpose

A lightweight, low-allocation popup/tooltip system for Unity UGUI. Supports hover tooltips, anchored popups, and follow-mouse modes with dynamic content built via a fluent API. Per-game-state instances with category-based isolation and priority dismissal.

## Architecture

```
Game Code / PopupTrigger MonoBehaviour
  │  popupSystem.Show(category, builder, anchor)
  │  popupSystem.Hide(category)
  ▼
IPopupSystem (per-game-state instance, injected via constructor DI)
  │  RegisterCategory(PopupCategory, priority)
  │  One PopupReference per category (lazy-created on first Show)
  │  Priority: higher priority category dismisses lower on show
  │  Tick()-driven delay timers and animations (no coroutines)
  ▼
PopupReference (MonoBehaviour — one instance per registered category)
  │  Canvas + CanvasGroup for transitions
  │  Content parent (VerticalLayoutGroup + ContentSizeFitter)
  │  Grow-only element cache (reuse, never destroy)
  │  Screen edge clamping
  │  Anchor-to-element or follow-mouse positioning
  ▼
IPopupAnimator (extensible interface for show/hide transitions)
  │  Default: FadePopupAnimator (CanvasGroup.alpha lerp)
  │  Custom implementations for scale, slide, etc.
  ▼
PopupContentBuilder (struct, fluent API)
    .AddHeader(text) .AddText(text) .AddStat(label, value)
    .AddImage(sprite) .AddSeparator()
    → activates pre-existing child elements in PopupReference
```

## Core Types

### PopupCategory (readonly struct)

Same pattern as LogChannel. Value type, string-keyed, zero-alloc comparisons.

```csharp
[Serializable]
public readonly struct PopupCategory : IEquatable<PopupCategory> {
    readonly string id;
    public string Id => id;
    public PopupCategory(string id) => this.id = id;

    public static readonly PopupCategory Character = new("Character");
    public static readonly PopupCategory Item = new("Item");
    public static readonly PopupCategory Skill = new("Skill");
    public static readonly PopupCategory General = new("General");
}
```

### PopupSettings (ScriptableObject)

Full configuration loaded via Addressables.

```
General:
  popupDelay              float     0.4s      Time before popup appears after hover
  fadeDuration            float     0.2s      Default animation duration
  defaultAnchorSide       enum      Below     Below/Above/Left/Right
  screenEdgePadding       float     10px      Minimum distance from screen edge
  maxPopupWidth           float     400px     Maximum content width
  sortingOrder            int       100       Canvas sorting order

Follow Mouse:
  followMouseOffset       Vector2   (15, -15) Offset from cursor in follow mode

Input:
  touchHoldDuration       float     0.6s      Press-and-hold duration for touch
  gamepadFocusTrigger     bool      true      Trigger on gamepad focus events

Priority:
  categoryPriorities      List<CategoryPriority>
    - category: PopupCategory
    - priority: int           Higher value = higher priority

Per-Category Overrides:
  categoryDelayOverrides  List<CategoryDelay>
    - category: PopupCategory
    - delay: float
```

### IPopupSystem (interface)

```csharp
public interface IPopupSystem {
    void RegisterCategory(PopupCategory category, int priority = 0);
    void Show(PopupCategory category, Action<PopupContentBuilder> buildContent,
              RectTransform anchor = null, AnchorSide? anchorSide = null);
    void ShowAtPosition(PopupCategory category, Action<PopupContentBuilder> buildContent,
                        Vector2 screenPosition);
    void Hide(PopupCategory category);
    void HideAll();
    void Tick(float deltaTime);
    void Dispose();
}
```

### PopupSystem (implementation)

- `Dictionary<PopupCategory, PopupReferenceState>` — O(1) lookup by category
- `PopupReferenceState` holds: PopupReference instance (null until first show), priority, delay timer, pending show data
- `Tick(deltaTime)` drives delay countdown and animation lerp — no coroutines
- On `Show()`:
  - If a higher-priority popup is already visible, queue or cancel
  - Start delay timer; on expiry, activate the view
  - PopupReference clears content (deactivates cached elements), runs builder callback, positions, animates in
- On `Hide()`:
  - Animate out, deactivate
- On `Dispose()`:
  - Destroy all PopupReference GameObjects

### PopupReference (MonoBehaviour)

Single prefab, instantiated once per category:

```
PopupReference (Canvas, CanvasGroup, RectMask2D)
├── Content (RectTransform, VerticalLayoutGroup, ContentSizeFitter)
│   ├── [cached] HeaderElement (TMP_Text, deactivated)
│   ├── [cached] TextElement x N (TMP_Text, deactivated)
│   ├── [cached] StatElement x N (TMP_Text label + TMP_Text value, deactivated)
│   ├── [cached] ImageElement x N (Image, deactivated)
│   └── [cached] SeparatorElement x N (Image, deactivated)
└── Background (Image, optional)
```

**Grow-only element cache:**
- Pre-creates a small set of each element type (e.g. 3 text, 2 stat, 1 image, 2 separator)
- `GetOrCreateElement<T>(type)` returns next available deactivated element, or Instantiates if none free
- New elements persist and are reused on next Show
- `ClearContent()` deactivates all elements and resets the reuse index — no Destroy calls

**Positioning:**
- `AnchorToElement(RectTransform target, AnchorSide side)` — positions relative to target element
- `FollowMouse(Vector2 offset)` — updates position each Tick to track cursor
- `ClampToScreen(float padding)` — adjusts position if popup overflows screen bounds, flips anchor side if needed

**Animation:**
- Calls `IPopupAnimator.Show(canvasGroup, duration, onComplete)` and `Hide(...)`
- Default `FadePopupAnimator` lerps `canvasGroup.alpha` 0→1 / 1→0 over duration
- Driven by float timer in Tick, not coroutines

### PopupContentBuilder (struct)

```csharp
public struct PopupContentBuilder {
    readonly PopupReference view;

    public PopupContentBuilder AddHeader(string text);
    public PopupContentBuilder AddText(string text);
    public PopupContentBuilder AddText(string text, string hexColor);
    public PopupContentBuilder AddStat(string label, int value);
    public PopupContentBuilder AddStat(string label, string value);
    public PopupContentBuilder AddImage(Sprite sprite, float height = 64f);
    public PopupContentBuilder AddSeparator();
}
```

Each method activates a cached element from PopupReference, sets its data, calls `SetAsLastSibling()` for ordering. Returns `this` for chaining. No allocations.

### PopupTrigger (MonoBehaviour)

Attach to any UI element. Handles hover detection automatically.

```csharp
public class PopupTrigger : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler {
    [SerializeField] PopupCategory category;
    [SerializeField] AnchorSide anchorSide;
    [SerializeField] PopupPositionMode positionMode; // AnchorToElement or FollowMouse

    IPopupSystem popupSystem;
    Action<PopupContentBuilder> contentCallback;

    public void Initialize(IPopupSystem popupSystem, Action<PopupContentBuilder> contentCallback);

    // IPointerEnterHandler — calls popupSystem.Show(category, contentCallback, rectTransform)
    // IPointerExitHandler — calls popupSystem.Hide(category)
}
```

- Category, anchor side, position mode configurable in Inspector
- `Initialize()` called from code to inject the popup system and content builder callback
- No per-frame cost when not hovered
- The `contentCallback` is set once and reused — no allocation per hover

### IPopupAnimator (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; }
}
```

Default implementation: `FadePopupAnimator` — lerps alpha. Custom implementations can do scale, slide, bounce, etc.

### PopupCategoryJsonConverter

Newtonsoft converter for serializing PopupCategory as string id (same pattern as LogChannelJsonConverter).

## Enums

```csharp
public enum AnchorSide { Below, Above, Left, Right }
public enum PopupPositionMode { AnchorToElement, FollowMouse }
```

## Optimization Summary

| Concern | Approach |
|---------|----------|
| Hot path (Show/Hide) | Struct category, dict lookup, no alloc |
| Content elements | Grow-only cache, activate/deactivate, never Destroy |
| Timers | Float countdown in Tick(), no coroutines |
| Builder | Struct, operates directly on cached elements |
| Per-frame | Zero alloc when idle; follow-mouse only reads Input position |
| Trigger | IPointerEnter/Exit interfaces, no delegates allocated per hover |

## Package Structure

```
Packages/com.jovian.popup-system/
├── package.json
├── README.md
├── Runtime/
│   ├── Jovian.PopupSystem.asmdef
│   ├── PopupCategory.cs
│   ├── PopupCategoryJsonConverter.cs
│   ├── PopupSettings.cs
│   ├── IPopupSystem.cs
│   ├── PopupSystem.cs
│   ├── IPopupAnimator.cs
│   ├── FadePopupAnimator.cs
│   ├── PopupContentBuilder.cs
│   └── UI/
│       ├── PopupReference.cs
│       └── PopupTrigger.cs
├── Editor/
│   ├── Jovian.PopupSystem.Editor.asmdef
│   └── PopupSettingsProvider.cs
└── Samples~/
    └── README.md
```

### Dependencies

- `com.unity.textmeshpro` (TMP_Text for content elements)
- `com.unity.inputsystem` (for pointer position in follow-mouse mode)
- `com.unity.nuget.newtonsoft-json` (for PopupCategory serialization)

## Integration Example

```csharp
// In GameModeGameState or similar:
var popupSettings = Addressables.LoadAssetAsync<PopupSettings>("PopupSettings").WaitForCompletion();
var popupViewPrefab = Addressables.LoadAssetAsync<PopupReference>("PopupReferencePrefab").WaitForCompletion();
var popupSystem = new PopupSystem(popupSettings, popupViewPrefab);
popupSystem.RegisterCategory(PopupCategory.Character, priority: 10);
popupSystem.RegisterCategory(PopupCategory.Item, priority: 5);

// Pass popupSystem to views/play modes via constructor DI

// In PartyGuiView — attach PopupTrigger to each slot:
var trigger = slot.GetComponent<PopupTrigger>();
trigger.Initialize(popupSystem, builder => {
    builder.AddHeader(member.Name)
           .AddSeparator()
           .AddStat("Health", member.Stats.GetValue(StatType.Health))
           .AddStat("Mana", member.Stats.GetValue(StatType.Mana))
           .AddStat("Level", member.Stats.GetValue(StatType.Level))
           .AddText($"{member.Race} {member.Class}");
});

// In Tick:
popupSystem.Tick(Time.deltaTime);

// On state exit:
popupSystem.Dispose();
```

## Prefab Setup

### PopupReference prefab

1. Root: Canvas (Screen Space Overlay, sorting order from settings), CanvasGroup (alpha=0)
2. Child "Content": RectTransform, VerticalLayoutGroup (Child Force Expand Width: true, Height: false, Spacing: 4, Padding: 8), ContentSizeFitter (Vertical Fit: Preferred Size, Horizontal Fit: Preferred Size up to maxPopupWidth)
3. Background Image behind Content
4. Optional: RectMask2D on root

Pre-create cached elements as children of Content (all deactivated):
- 2x Header (TMP_Text, bold, larger font)
- 4x Text (TMP_Text, normal)
- 4x Stat (horizontal layout: TMP_Text label + TMP_Text value)
- 2x Image (Image component)
- 3x Separator (Image, 1px height, horizontal stretch)
