using System.Collections.Generic; using Cthangover.Core.Events; using Cthangover.Core.Scenes; using Cthangover.Core.UI.Event; using Cthangover.Core.UI.Theming; using Godot; using Cthangover.Mods.Interface.Theming; using Cthangover.Core.UI.Messages; namespace Cthangover.Mods.Interface.Messages { /// /// Manages the visual notification list in the top-left corner of the scene. /// Displays up to MaxVisibleSlots notification banners simultaneously. /// Pending items wait in and are pulled /// into visible slots as they become free. Each visible notification has a /// 10-second lifetime; when it expires, it fades out and the remaining /// notifications shift upward with a smooth animation. /// public partial class NotificationPanel : Control, IOnUpdateEvent { private const int MaxVisibleSlots = 4; private const float SlotHeight = 40f; private const float SlotSpacing = 6f; private const float DisplayDuration = 10f; private const float FadeInDuration = 0.3f; private const float FadeOutDuration = 0.5f; private readonly List _slots = new(); private readonly List _dyingIndices = new(); /// Update priority 1 — runs after the highest-priority listeners. public int Priority => 1; /// Subscribes to notification requests; text is queued in , not shown immediately. public override void _EnterTree() { ModEventBus.Subscribe(OnNotificationRequested); } /// Registers with the event controller for per-frame slot updates. public override void _Ready() { MouseFilter = MouseFilterEnum.Ignore; if (SceneContextNode.Instance != null) { var eventController = SceneContextNode.FindNode("EventController"); eventController?.AddUpdateEventListener(this); } } /// Unsubscribes from notification requests and detaches from the update controller. public override void _ExitTree() { ModEventBus.Unsubscribe(OnNotificationRequested); if (SceneContextNode.Instance != null) { var eventController = SceneContextNode.FindNode("EventController"); eventController?.RemoveUpdateEventListener(this); } } /// /// Per-frame update: expires old slots, collects dead ones, /// pulls new notifications from the buffer, and repositions /// all visible slots with a smooth slide animation. /// public void OnUpdate() { var currentTime = (float)Godot.Time.GetTicksMsec() / 1000f; for (int i = _slots.Count - 1; i >= 0; i--) { var slot = _slots[i]; var elapsed = currentTime - slot.DisplayedAt; if (elapsed >= DisplayDuration && !slot.IsFadingOut) { FadeOutSlot(i); } } _dyingIndices.Clear(); for (int i = 0; i < _slots.Count; i++) { if (_slots[i].IsDead) _dyingIndices.Add(i); } for (int j = _dyingIndices.Count - 1; j >= 0; j--) { var idx = _dyingIndices[j]; _slots[idx].Container.QueueFree(); _slots.RemoveAt(idx); } while (_slots.Count < MaxVisibleSlots && NotificationBuffer.TryDequeue(out var text)) { CreateSlot(text, currentTime); } RepositionSlots(); } private void CreateSlot(string text, float time) { var container = new Panel { SizeFlagsHorizontal = SizeFlags.ShrinkBegin, MouseFilter = MouseFilterEnum.Ignore, }; var style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.ToastBg, UiTokens.Line); style.ContentMarginLeft = UiTokens.Space4; style.ContentMarginRight = UiTokens.Space4; style.ContentMarginTop = UiTokens.Space2; style.ContentMarginBottom = UiTokens.Space2; container.AddThemeStyleboxOverride("panel", style); var label = new Label { Text = text, Modulate = UiTokens.Alpha(Colors.White, 0f), HorizontalAlignment = HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Center, }; label.AddThemeFontSizeOverride("font_size", UiTokens.FontLead); label.AddThemeColorOverride("font_color", UiTokens.Ink); container.AddChild(label); AddChild(container); var slot = new NotificationSlot { Container = container, Text = text, DisplayedAt = time, }; _slots.Add(slot); var tween = CreateTween(); tween.TweenProperty(label, "modulate", Colors.White, FadeInDuration); } private void FadeOutSlot(int index) { var slot = _slots[index]; slot.IsFadingOut = true; var label = slot.Container.GetChildOrNull