Files
TheriapolisV3/Theriapolis.Godot/Scenes/Widgets/PopoverLayer.cs
T
Christopher Wiebe 0ab4715aee M6.16: Unified hybrid species grid + codex-styled hover popover
StepSpecies hybrid mode now uses one grid combining sire-clade species
(under a "SIRE — <Clade>" eyebrow) and dam-clade species (under
"DAM — <Clade>"). Cards are click-to-select like the purebred path —
since clades are guaranteed disjoint by StepClade's parent-conflict
rule, the lineage is implicit from the species' clade and no per-card
toggles are needed.

Hover popover now picks up the codex theme: parchment Bg2 panel with a
gild border, rounded 14px corners, and soft drop shadow; H3 display
serif title, mono Eyebrow tag, CardBody description. Detriment popovers
swap to a 3px seal-red border via the panel_detriment stylebox override
(replaces the old red Modulate hack).

Theme propagation fix: CanvasLayer breaks Godot's Control theme
inheritance, so the popup was rendering on Godot defaults. _Ready
defers a lookup of the parent Control's theme and assigns it directly
to the popup so the codex parchment + Cormorant/CrimsonPro fonts
actually resolve.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 21:26:38 -07:00

166 lines
6.2 KiB
C#

using Godot;
namespace Theriapolis.GodotHost.Scenes.Widgets;
/// <summary>
/// Shared overlay layer that owns one reusable trait popover panel.
/// Per GODOT_PORTING_GUIDE.md §6.1 — TraitChip triggers ask
/// <see cref="Instance"/> to show the popover at their global rect; the
/// popover hides as soon as the trigger fires MouseExited.
///
/// The popover itself is MouseFilter=Ignore so it never intercepts
/// input — clicks pass through to the chip's parent (card selection),
/// scroll wheel events go to the underlying ScrollContainer, and the
/// chip's hover state stays accurate when the cursor moves onto the
/// popover area (the cursor is registered as "outside the chip", so
/// MouseExited fires and we hide). This lets adjacent chips fire
/// reliably even when the previous popover overlaps them spatially.
///
/// One PopoverLayer per scene; lives as a CanvasLayer child of
/// Wizard.tscn so popovers float above every step's content. Mirrors
/// <c>src/trait-hint.jsx</c>'s viewport-clamp / flip-above behaviour.
/// </summary>
public partial class PopoverLayer : CanvasLayer
{
public static PopoverLayer? Instance { get; private set; }
private const float ArrowOffsetPx = 6f;
private const int ViewportPadPx = 8;
private PanelContainer _popup = null!;
private Label _titleLabel = null!;
private Label _tagLabel = null!;
private Label _descLabel = null!;
public override void _EnterTree()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
public override void _Ready()
{
Layer = 100;
BuildPopover();
// Theme inheritance walks Control descendants only — CanvasLayer is
// a plain Node, so it breaks the propagation chain from the Wizard
// Control above. _Ready is bottom-up, so the parent Wizard hasn't
// assigned its codex theme yet — defer the lookup until parent's
// _Ready has run, then pull its theme onto the popup directly.
CallDeferred(MethodName.InheritParentTheme);
}
private void InheritParentTheme()
{
if (GetParent() is Control parentControl && parentControl.Theme is not null)
_popup.Theme = parentControl.Theme;
}
private void BuildPopover()
{
// Ignore so clicks/scroll/hover all pass through to whatever's
// beneath. The popover is purely a visual readout; the chip
// owns the lifecycle entirely. ThemeTypeVariation pulls the
// CodexPopover stylebox (parchment bg2 + gild border + rounded
// corners + soft shadow) defined in CodexTheme.
_popup = new PanelContainer
{
Visible = false,
MouseFilter = Control.MouseFilterEnum.Ignore,
ZIndex = 100,
ThemeTypeVariation = "CodexPopover",
};
AddChild(_popup);
var v = new VBoxContainer { CustomMinimumSize = new Vector2(240, 0) };
v.AddThemeConstantOverride("separation", 6);
_popup.AddChild(v);
var nameRow = new HBoxContainer();
nameRow.AddThemeConstantOverride("separation", 10);
v.AddChild(nameRow);
// Display-serif title at H3 size (20px) — pulls the trait name
// out of the body copy below.
_titleLabel = new Label { ThemeTypeVariation = "H3" };
nameRow.AddChild(_titleLabel);
// Mono uppercase tag label, vertically centred against the title.
_tagLabel = new Label
{
Visible = false,
ThemeTypeVariation = "Eyebrow",
VerticalAlignment = VerticalAlignment.Center,
SizeFlagsVertical = Control.SizeFlags.ShrinkCenter,
};
nameRow.AddChild(_tagLabel);
_descLabel = new Label
{
AutowrapMode = TextServer.AutowrapMode.WordSmart,
CustomMinimumSize = new Vector2(220, 0),
ThemeTypeVariation = "CardBody",
};
v.AddChild(_descLabel);
}
public void ShowFor(Control trigger, string title, string description, string tag, bool detriment)
{
_titleLabel.Text = title;
_descLabel.Text = description;
_tagLabel.Visible = !string.IsNullOrEmpty(tag) || detriment;
_tagLabel.Text = !string.IsNullOrEmpty(tag) ? tag.ToUpperInvariant()
: (detriment ? "DETRIMENT" : "");
// Detriment popover swaps to the seal-bordered stylebox via
// override; non-detriment clears the override so the default
// CodexPopover panel takes effect again.
if (detriment && _popup.HasThemeStylebox("panel_detriment", "CodexPopover"))
{
var box = _popup.GetThemeStylebox("panel_detriment", "CodexPopover");
_popup.AddThemeStyleboxOverride("panel", box);
}
else
{
_popup.RemoveThemeStyleboxOverride("panel");
}
_popup.Visible = true;
_popup.ResetSize();
Reposition(trigger);
}
/// <summary>Hide the popover. Was previously a 80ms-grace timer when
/// the popover stayed alive across chip→popover hover transitions, but
/// the popover is now non-interactive so there's no transition to
/// cover for — close immediately.</summary>
public void ScheduleClose() => _popup.Visible = false;
private void Reposition(Control trigger)
{
var trigRect = trigger.GetGlobalRect();
var popSize = _popup.GetCombinedMinimumSize();
var vp = trigger.GetViewportRect().Size;
// Default: under trigger, left-aligned with trigger.
float left = trigRect.Position.X;
float top = trigRect.End.Y + ArrowOffsetPx;
// Flip above if no room below and there's room above.
if (top + popSize.Y + ViewportPadPx > vp.Y &&
trigRect.Position.Y - ArrowOffsetPx - popSize.Y >= ViewportPadPx)
{
top = trigRect.Position.Y - ArrowOffsetPx - popSize.Y;
}
// Clamp horizontally + vertically to keep popover in-viewport.
left = Mathf.Clamp(left, ViewportPadPx, vp.X - popSize.X - ViewportPadPx);
top = Mathf.Clamp(top, ViewportPadPx, vp.Y - popSize.Y - ViewportPadPx);
_popup.Position = new Vector2(left, top);
}
}