M6.3: Trait popovers — shared PopoverLayer + TraitChip triggers

Per GODOT_PORTING_GUIDE.md §6 (and §12 build order — popovers before
the easy card-grid steps because traits/skills/bonuses surface them
everywhere). One reusable popover panel; lightweight chip triggers.

Scenes/Widgets/PopoverLayer.cs:
  CanvasLayer added once as a child of Wizard.tscn. Owns one
  PanelContainer + close Timer; static Instance for chip-side access.
  ShowFor(trigger, ...) populates and positions the popover at the
  trigger's global rect with viewport clamp + flip-above logic
  (mirrors src/trait-hint.jsx). 80 ms grace period when moving from
  trigger to popover so the popover stays open across the gap.
  Detriment popovers get a red Modulate as a placeholder for the
  seal-coloured StyleBox the theming pass will install.

Scenes/Widgets/TraitChip.cs:
  Lightweight PanelContainer + Label trigger. On MouseEntered asks
  PopoverLayer.Instance to show; on MouseExited schedules close.
  Pill styling deferred to theming (default Godot panel for now;
  TraitChip / TraitChipDetriment styleboxes will land alongside
  the parchment Theme pass).

Wizard.tscn:
  PopoverLayer added as a top-level CanvasLayer child so popovers
  float above every step's content regardless of where the trigger
  is in the tree.

Steps/StepClade.cs:
  Replaces the placeholder "{n} traits, {m} detriments" line with an
  HFlowContainer of TraitChip per trait + per detriment. Hover any
  chip → popover shows name + description (+ DETRIMENT tag for the
  detriment chips).

  Also: cards switched from Button to PanelContainer for content-
  driven height. Button isn't a Container, so its intrinsic min
  size didn't aggregate from the inner vbox — at higher trait
  counts the chips overflowed into the cards below. PanelContainer
  is a Container, so the card grows with its content. GuiInput
  handles the click-to-select; selected state shown via Modulate
  tint until the proper StyleBox swap lands in theming.

Closes M6.3. Per guide §12, next is M6.4 — easy card-grid steps
(Species / Calling / Subclass / History) variations on the StepClade
pattern, then StepSkills, then StepReview.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-02 20:57:02 -07:00
parent 4d3db17a89
commit ba3ebe7ff3
4 changed files with 244 additions and 48 deletions
+53 -47
View File
@@ -1,5 +1,6 @@
using Godot;
using Theriapolis.Core.Data;
using Theriapolis.GodotHost.Scenes.Widgets;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes.Steps;
@@ -69,55 +70,41 @@ public partial class StepClade : VBoxContainer, IStep
{
bool selected = _draft.CladeId == clade.Id;
var btn = new Button
// PanelContainer (a Container subclass) so the card height is
// driven by its inner VBoxContainer's content. Switching from
// Button avoids the issue where Button's intrinsic min size
// doesn't aggregate from non-Button children, causing chips to
// overflow into the cards below at high trait counts.
var card = new PanelContainer
{
Text = "",
Flat = false,
ToggleMode = true,
ButtonPressed = selected,
FocusMode = Control.FocusModeEnum.None,
// 200 wide so 3 cards + separators (≈ 632) + page margins +
// Aside fit inside ≥ 1024-px viewports (the smaller-screen
// floor we want to support). Height held at 200 so the inner
// labels render at readable size without a content-driven
// height collapse (Button isn't a Container, so child vbox
// height doesn't bubble up to the button's intrinsic min size).
CustomMinimumSize = new Vector2(200, 200),
ClipText = false,
Alignment = HorizontalAlignment.Left,
CustomMinimumSize = new Vector2(200, 0),
MouseFilter = MouseFilterEnum.Stop,
};
btn.Pressed += () =>
if (selected) card.Modulate = new Color(1f, 0.95f, 0.85f); // gild tint placeholder until theming
card.GuiInput += (InputEvent e) =>
{
// Default species for the new clade — match React app.jsx:
// when clade changes, species defaults to first species in clade.
string speciesId = "";
foreach (var s in CodexContent.SpeciesOfClade(clade.Id))
if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)
{
speciesId = s.Id;
break;
// Default species for the new clade — match React app.jsx:
// when clade changes, species defaults to first species in clade.
string speciesId = "";
foreach (var s in CodexContent.SpeciesOfClade(clade.Id))
{
speciesId = s.Id;
break;
}
_draft.Patch(new Godot.Collections.Dictionary
{
{ "clade_id", clade.Id },
{ "species_id", speciesId },
});
}
_draft.Patch(new Godot.Collections.Dictionary
{
{ "clade_id", clade.Id },
{ "species_id", speciesId },
});
};
// Label content stacked inside the button via an anchored VBoxContainer
// (Button isn't a Container, so we anchor the vbox to fill the button's
// rect and let the children flow within it).
var box = new VBoxContainer
{
MouseFilter = MouseFilterEnum.Ignore,
};
box.AnchorRight = 1f;
box.AnchorBottom = 1f;
box.OffsetLeft = 12;
box.OffsetTop = 12;
box.OffsetRight = -12;
box.OffsetBottom = -12;
var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass };
box.AddThemeConstantOverride("separation", 6);
btn.AddChild(box);
card.AddChild(box);
box.AddChild(new Label { Text = clade.Name, MouseFilter = MouseFilterEnum.Ignore });
box.AddChild(new Label
@@ -139,15 +126,34 @@ public partial class StepClade : VBoxContainer, IStep
});
}
if (clade.Traits.Length > 0)
if (clade.Traits.Length > 0 || clade.Detriments.Length > 0)
{
box.AddChild(new Label
var chips = new HFlowContainer { MouseFilter = MouseFilterEnum.Pass };
chips.AddThemeConstantOverride("h_separation", 6);
chips.AddThemeConstantOverride("v_separation", 4);
box.AddChild(chips);
foreach (var t in clade.Traits)
{
Text = $"{clade.Traits.Length} traits, {clade.Detriments.Length} detriments",
MouseFilter = MouseFilterEnum.Ignore,
});
var chip = new TraitChip
{
TraitName = t.Name,
Description = t.Description,
};
chips.AddChild(chip);
}
foreach (var d in clade.Detriments)
{
var chip = new TraitChip
{
TraitName = d.Name,
Description = d.Description,
Detriment = true,
};
chips.AddChild(chip);
}
}
return btn;
return card;
}
}
@@ -0,0 +1,130 @@
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 stays open while either the trigger or the popover itself is
/// hovered (80 ms grace via close timer).
///
/// 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 GracePeriodSec = 0.08f;
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!;
private Timer _closeTimer = null!;
public override void _EnterTree()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
public override void _Ready()
{
Layer = 100;
BuildPopover();
}
private void BuildPopover()
{
_popup = new PanelContainer
{
Visible = false,
MouseFilter = Control.MouseFilterEnum.Pass,
ZIndex = 100,
};
_popup.MouseEntered += CancelClose;
_popup.MouseExited += ScheduleClose;
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", 8);
v.AddChild(nameRow);
_titleLabel = new Label();
nameRow.AddChild(_titleLabel);
_tagLabel = new Label { Visible = false };
nameRow.AddChild(_tagLabel);
_descLabel = new Label
{
AutowrapMode = TextServer.AutowrapMode.WordSmart,
CustomMinimumSize = new Vector2(220, 0),
};
v.AddChild(_descLabel);
_closeTimer = new Timer { OneShot = true, WaitTime = GracePeriodSec };
_closeTimer.Timeout += HidePopover;
AddChild(_closeTimer);
}
public void ShowFor(Control trigger, string title, string description, string tag, bool detriment)
{
CancelClose();
_titleLabel.Text = title;
_descLabel.Text = description;
_tagLabel.Visible = !string.IsNullOrEmpty(tag) || detriment;
_tagLabel.Text = !string.IsNullOrEmpty(tag) ? tag.ToUpperInvariant()
: (detriment ? "DETRIMENT" : "");
// M6.3 default-theme tint: detriment popover gets a red modulate so
// it reads visually distinct from a regular trait. The proper
// codex StyleBox swap lands in the theming pass.
_popup.Modulate = detriment ? new Color(1f, 0.78f, 0.78f) : Colors.White;
_popup.Visible = true;
_popup.ResetSize();
Reposition(trigger);
}
public void ScheduleClose() => _closeTimer.Start();
public void CancelClose() => _closeTimer.Stop();
private void HidePopover() => _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);
}
}
@@ -0,0 +1,56 @@
using Godot;
namespace Theriapolis.GodotHost.Scenes.Widgets;
/// <summary>
/// Small chip widget. The hover trigger for a trait/skill/detriment.
/// Per GODOT_PORTING_GUIDE.md §6.2: on mouse-enter, asks the shared
/// <see cref="PopoverLayer"/> to show the popover at this chip's global
/// rect; on exit, schedules close (popover stays alive if the cursor
/// then enters the popover itself).
///
/// Lightweight — every clade trait list, every skill row, every bonus
/// pill spawns several of these. The actual popover panel is a single
/// reused instance owned by PopoverLayer, not one per chip.
/// </summary>
public partial class TraitChip : PanelContainer
{
[Export] public string TraitName { get; set; } = "";
[Export] public string Description { get; set; } = "";
[Export] public string Tag { get; set; } = "";
[Export] public bool Detriment { get; set; }
private Label _label = null!;
public override void _Ready()
{
MouseFilter = MouseFilterEnum.Stop;
_label = new Label
{
Text = TraitName,
MouseFilter = MouseFilterEnum.Ignore,
};
AddChild(_label);
MouseEntered += OnHoverEntered;
MouseExited += OnHoverExited;
}
public void SetTrait(string name, string description, string tag = "", bool detriment = false)
{
TraitName = name;
Description = description;
Tag = tag;
Detriment = detriment;
if (_label is not null) _label.Text = name;
}
private void OnHoverEntered()
{
PopoverLayer.Instance?.ShowFor(this, TraitName, Description, Tag, Detriment);
}
private void OnHoverExited()
{
PopoverLayer.Instance?.ScheduleClose();
}
}
+5 -1
View File
@@ -1,8 +1,9 @@
[gd_scene load_steps=4 format=3 uid="uid://wizard6m6v1"]
[gd_scene load_steps=5 format=3 uid="uid://wizard6m6v1"]
[ext_resource type="Script" path="res://Scenes/Wizard.cs" id="1_wizard"]
[ext_resource type="Script" path="res://UI/Widgets/CodexStepper.cs" id="2_stepper"]
[ext_resource type="PackedScene" path="res://Scenes/Aside.tscn" id="3_aside"]
[ext_resource type="Script" path="res://Scenes/Widgets/PopoverLayer.cs" id="4_popover"]
[node name="Wizard" type="Control"]
anchors_preset = 15
@@ -85,3 +86,6 @@ text = "1 / 8"
[node name="NextButton" type="Button" parent="Wrap/Layout/NavBar"]
unique_name_in_owner = true
text = "Next "
[node name="PopoverLayer" type="CanvasLayer" parent="."]
script = ExtResource("4_popover")