using Godot; namespace Theriapolis.GodotHost.UI.Widgets; /// /// Hoverable text trigger that shows a floating popover with name + optional /// tag + description. Mirrors src/trait-hint.jsx from the React /// prototype — viewport-clamp horizontally and vertically, flip above/below /// based on available space, 80ms grace period when moving from trigger to /// popover so the popover stays open across the gap. /// /// Used by future TraitName, SkillChip, BonusPill widgets — the React /// version layers className differences on top of this same primitive. /// public partial class CodexPopover : Control { [Export] public string TriggerText { get; set; } = "trait"; [Export] public string Title { get; set; } = ""; [Export] public string Tag { get; set; } = ""; [Export] public string Description { get; set; } = ""; [Export] public bool Detriment { get; set; } private const float GracePeriodSec = 0.08f; // ~80 ms — matches trait-hint.jsx private const float ArrowOffset = 6f; private const int ViewportPad = 8; private Label _trigger = null!; private PanelContainer? _popover; private Timer _closeTimer = null!; private bool _hovering; public override void _Ready() { _trigger = new Label { Text = string.IsNullOrEmpty(Title) ? TriggerText : Title, ThemeTypeVariation = "Label", MouseFilter = MouseFilterEnum.Stop, }; _trigger.MouseEntered += OnTriggerEntered; _trigger.MouseExited += OnTriggerExited; AddChild(_trigger); _closeTimer = new Timer { OneShot = true, WaitTime = GracePeriodSec }; _closeTimer.Timeout += HidePopover; AddChild(_closeTimer); CustomMinimumSize = _trigger.GetMinimumSize(); } public override void _Notification(int what) { // Make sure the popover is freed if the trigger is removed. if (what == NotificationExitTree && _popover is not null) { _popover.QueueFree(); _popover = null; } } private void OnTriggerEntered() { _hovering = true; _closeTimer.Stop(); ShowPopover(); } private void OnTriggerExited() { _hovering = false; _closeTimer.Start(); } private void OnPopoverEntered() { _closeTimer.Stop(); } private void OnPopoverExited() { _closeTimer.Start(); } private void ShowPopover() { if (_popover is not null && IsInstanceValid(_popover)) { Reposition(); return; } _popover = BuildPopoverPanel(); _popover.MouseEntered += OnPopoverEntered; _popover.MouseExited += OnPopoverExited; var canvas = new CanvasLayer { Layer = 100 }; canvas.Name = "CodexPopoverCanvas"; canvas.AddChild(_popover); // Attach the canvas layer to the scene's root viewport so the popover // floats above every other UI element regardless of where the trigger // lives in the tree. GetTree().Root.AddChild(canvas); // One frame later, the panel has its laid-out size; reposition with // viewport clamp + flip-above logic. CallDeferred(MethodName.Reposition); } private void HidePopover() { if (_popover is null) return; // Free the canvas layer parent (which owns the popover). _popover.GetParent()?.QueueFree(); _popover = null; } private PanelContainer BuildPopoverPanel() { var panel = new PanelContainer { ThemeTypeVariation = "CodexPopover", MouseFilter = MouseFilterEnum.Pass, ZIndex = 100, // Detriment popovers swap to the seal-coloured stylebox. // Theme stylebox names live under "panel" for the default and // "panel_detriment" for the variant; we set whichever via override. }; if (Detriment && panel.HasThemeStylebox("panel_detriment", "CodexPopover")) { var box = panel.GetThemeStylebox("panel_detriment", "CodexPopover"); panel.AddThemeStyleboxOverride("panel", box); } var vbox = new VBoxContainer { CustomMinimumSize = new Vector2(220, 0) }; panel.AddChild(vbox); var nameRow = new HBoxContainer(); nameRow.AddChild(new Label { Text = string.IsNullOrEmpty(Title) ? TriggerText : Title, ThemeTypeVariation = "H3", }); if (!string.IsNullOrEmpty(Tag)) { var tagPill = new Label { Text = Tag.ToUpperInvariant(), ThemeTypeVariation = "ValidationOk", }; nameRow.AddChild(tagPill); } if (Detriment) { var detPill = new Label { Text = "DETRIMENT", ThemeTypeVariation = "ValidationOk", }; nameRow.AddChild(detPill); } vbox.AddChild(nameRow); if (!string.IsNullOrEmpty(Description)) { var desc = new Label { Text = Description, AutowrapMode = TextServer.AutowrapMode.WordSmart, ThemeTypeVariation = "CardBody", }; vbox.AddChild(desc); } return panel; } private void Reposition() { if (_popover is null || !IsInstanceValid(_popover)) return; var viewport = GetViewport().GetVisibleRect(); var trig = _trigger.GetGlobalRect(); var size = _popover.GetCombinedMinimumSize(); float left = trig.Position.X; float top = trig.Position.Y + trig.Size.Y + ArrowOffset; bool flippedAbove = false; if (top + size.Y + ViewportPad > viewport.Size.Y && trig.Position.Y - ArrowOffset - size.Y >= ViewportPad) { top = trig.Position.Y - ArrowOffset - size.Y; flippedAbove = true; } if (left + size.X + ViewportPad > viewport.Size.X) left = viewport.Size.X - size.X - ViewportPad; if (left < ViewportPad) left = ViewportPad; if (top + size.Y + ViewportPad > viewport.Size.Y) top = viewport.Size.Y - size.Y - ViewportPad; if (top < ViewportPad) top = ViewportPad; _popover.Position = new Vector2(left, top); _popover.Size = size; // flippedAbove can drive a future arrow image; we don't render an // arrow in M5 — the React version's CSS pseudo-element doesn't // map cleanly onto Godot's StyleBox. Border + shadow is sufficient. _ = flippedAbove; } public override void _Process(double delta) { // If the trigger is still mounted but the popover got orphaned for any // reason, drop our reference so a fresh hover re-creates it. if (_popover is not null && !IsInstanceValid(_popover)) _popover = null; } }