using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.UI.Models; using Microsoft.Extensions.Logging; using System.Numerics; using Dalamud.Bindings.ImGui; namespace LightlessSync.UI; public class LightlessNotificationUI : WindowMediatorSubscriberBase { private readonly List _notifications = new(); private readonly object _notificationLock = new(); private readonly LightlessConfigService _configService; private const float NotificationWidth = 350f; private const float NotificationMinHeight = 60f; private const float NotificationMaxHeight = 200f; private const float NotificationSpacing = 8f; private const float AnimationSpeed = 10f; private const float EdgeXMargin = 0; private const float EdgeYMargin = 30f; private const float SlideDistance = 100f; public LightlessNotificationUI(ILogger logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService) : base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector) { _configService = configService; Flags = ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoBackground | ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.AlwaysAutoResize; var viewport = ImGui.GetMainViewport(); if (viewport.WorkSize.X > 0) { Position = new Vector2(viewport.WorkPos.X + viewport.WorkSize.X - NotificationWidth - EdgeXMargin, viewport.WorkPos.Y + EdgeYMargin); PositionCondition = ImGuiCond.Always; } Size = new Vector2(NotificationWidth, 100); SizeCondition = ImGuiCond.FirstUseEver; IsOpen = false; RespectCloseHotkey = false; DisableWindowSounds = true; Mediator.Subscribe(this, HandleNotificationMessage); Mediator.Subscribe(this, HandleNotificationDismissMessage); } private void HandleNotificationMessage(LightlessNotificationMessage message) { AddNotification(message.Notification); } private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) { RemoveNotification(message.NotificationId); } public void AddNotification(LightlessNotification notification) { lock (_notificationLock) { var existingNotification = _notifications.FirstOrDefault(n => n.Id == notification.Id); if (existingNotification != null) { // Update existing notification without restarting animation existingNotification.Message = notification.Message; existingNotification.Progress = notification.Progress; existingNotification.ShowProgress = notification.ShowProgress; existingNotification.Title = notification.Title; _logger.LogDebug("Updated existing notification: {Title}", notification.Title); } else { _notifications.Add(notification); _logger.LogDebug("Added new notification: {Title}", notification.Title); } if (!IsOpen) { IsOpen = true; } } } public void RemoveNotification(string id) { lock (_notificationLock) { var notification = _notifications.FirstOrDefault(n => n.Id == id); if (notification != null) { notification.IsAnimatingOut = true; notification.IsAnimatingIn = false; } } } protected override void DrawInternal() { lock (_notificationLock) { UpdateNotifications(); if (_notifications.Count == 0) { IsOpen = false; return; } var viewport = ImGui.GetMainViewport(); var windowPos = new Vector2( viewport.WorkPos.X + viewport.WorkSize.X - NotificationWidth - EdgeXMargin, viewport.WorkPos.Y + EdgeYMargin ); ImGui.SetWindowPos(windowPos); for (int i = 0; i < _notifications.Count; i++) { DrawNotification(_notifications[i], i); if (i < _notifications.Count - 1) { ImGui.Dummy(new Vector2(0, NotificationSpacing)); } } } } private void UpdateNotifications() { var deltaTime = ImGui.GetIO().DeltaTime; var maxNotifications = _configService.Current.MaxSimultaneousNotifications; while (_notifications.Count(n => !n.IsAnimatingOut) > maxNotifications) { var oldestNotification = _notifications .Where(n => !n.IsAnimatingOut) .OrderBy(n => n.CreatedAt) .FirstOrDefault(); if (oldestNotification != null) { oldestNotification.IsAnimatingOut = true; oldestNotification.IsAnimatingIn = false; } } for (int i = _notifications.Count - 1; i >= 0; i--) { var notification = _notifications[i]; if (notification.IsAnimatingIn && notification.AnimationProgress < 1f) { notification.AnimationProgress = Math.Min(1f, notification.AnimationProgress + deltaTime * AnimationSpeed); } else if (notification.IsAnimatingOut && notification.AnimationProgress > 0f) { notification.AnimationProgress = Math.Max(0f, notification.AnimationProgress - deltaTime * (AnimationSpeed * 0.7f)); } else if (!notification.IsAnimatingOut && !notification.IsDismissed) { notification.IsAnimatingIn = false; if (notification.IsExpired && !notification.IsAnimatingOut) { notification.IsAnimatingOut = true; notification.IsAnimatingIn = false; } } if (notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f) { _notifications.RemoveAt(i); } } } private void DrawNotification(LightlessNotification notification, int index) { var alpha = notification.AnimationProgress; if (_configService.Current.EnableNotificationAnimations && alpha <= 0f) return; var slideOffset = 0f; if (_configService.Current.EnableNotificationAnimations) { slideOffset = (1f - alpha) * SlideDistance; } var originalCursorPos = ImGui.GetCursorPos(); ImGui.SetCursorPosX(originalCursorPos.X + slideOffset); var notificationHeight = CalculateNotificationHeight(notification); using var child = ImRaii.Child($"notification_{notification.Id}", new Vector2(NotificationWidth - slideOffset, notificationHeight), false, ImGuiWindowFlags.NoScrollbar); if (child.Success) { DrawNotificationContent(notification, alpha); } } private void DrawNotificationContent(LightlessNotification notification, float alpha) { var drawList = ImGui.GetWindowDrawList(); var windowPos = ImGui.GetWindowPos(); var windowSize = ImGui.GetWindowSize(); var baseOpacity = _configService.Current.NotificationOpacity; var finalOpacity = _configService.Current.EnableNotificationAnimations ? baseOpacity * alpha : baseOpacity; var bgColor = new Vector4(30f/255f, 30f/255f, 30f/255f, finalOpacity); var accentColor = GetNotificationAccentColor(notification.Type); var progressBarColor = UIColors.Get("LightlessBlue"); var finalAccentAlpha = _configService.Current.EnableNotificationAnimations ? alpha : 1f; accentColor.W *= finalAccentAlpha; var shadowOffset = new Vector2(1f, 1f); var shadowAlpha = _configService.Current.EnableNotificationAnimations ? 0.4f * alpha : 0.4f; var shadowColor = new Vector4(0f, 0f, 0f, shadowAlpha); drawList.AddRectFilled( windowPos + shadowOffset, windowPos + windowSize + shadowOffset, ImGui.ColorConvertFloat4ToU32(shadowColor), 3f ); var isHovered = ImGui.IsWindowHovered(); if (isHovered) { bgColor = bgColor * 1.1f; bgColor.W = Math.Min(bgColor.W, 0.98f); } drawList.AddRectFilled( windowPos, windowPos + windowSize, ImGui.ColorConvertFloat4ToU32(bgColor), 3f ); var accentWidth = 3f; drawList.AddRectFilled( windowPos, windowPos + new Vector2(accentWidth, windowSize.Y), ImGui.ColorConvertFloat4ToU32(accentColor), 3f ); DrawDurationProgressBar(notification, alpha, windowPos, windowSize, progressBarColor, drawList); DrawNotificationText(notification, alpha); } private void DrawDurationProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, Vector4 progressBarColor, ImDrawListPtr drawList) { var elapsed = DateTime.UtcNow - notification.CreatedAt; var progress = Math.Min(1.0f, (float)(elapsed.TotalSeconds / notification.Duration.TotalSeconds)); var progressHeight = 2f; var progressY = windowPos.Y + windowSize.Y - progressHeight; var progressWidth = windowSize.X * progress; var bgProgressColor = new Vector4(progressBarColor.X * 0.3f, progressBarColor.Y * 0.3f, progressBarColor.Z * 0.3f, 0.5f * alpha); drawList.AddRectFilled( new Vector2(windowPos.X, progressY), new Vector2(windowPos.X + windowSize.X, progressY + progressHeight), ImGui.ColorConvertFloat4ToU32(bgProgressColor), 0f ); if (progress > 0) { var progressColor = progressBarColor; progressColor.W *= alpha; drawList.AddRectFilled( new Vector2(windowPos.X, progressY), new Vector2(windowPos.X + progressWidth, progressY + progressHeight), ImGui.ColorConvertFloat4ToU32(progressColor), 0f ); } } private void DrawNotificationText(LightlessNotification notification, float alpha) { var padding = new Vector2(10f, 6f); var contentPos = new Vector2(6f, 0f) + padding; var windowSize = ImGui.GetWindowSize(); var contentSize = windowSize - padding * 2 - new Vector2(6f, 0f); ImGui.SetCursorPos(contentPos); float titleHeight = 0f; using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, alpha))) { // Set text wrap position to prevent title overflow ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentSize.X); var titleStartY = ImGui.GetCursorPosY(); if (_configService.Current.ShowNotificationTimestamp) { var timestamp = notification.CreatedAt.ToLocalTime().ToString("HH:mm:ss"); ImGui.TextWrapped($"[{timestamp}] {notification.Title}"); } else { ImGui.TextWrapped(notification.Title); } titleHeight = ImGui.GetCursorPosY() - titleStartY; ImGui.PopTextWrapPos(); } if (!string.IsNullOrEmpty(notification.Message)) { ImGui.SetCursorPos(contentPos + new Vector2(0f, titleHeight + 4f)); ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentSize.X); using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, alpha))) { ImGui.TextWrapped(notification.Message); } ImGui.PopTextWrapPos(); } if (notification.ShowProgress) { ImGui.Spacing(); var progressColor = GetNotificationAccentColor(notification.Type); progressColor.W *= alpha; using (ImRaii.PushColor(ImGuiCol.PlotHistogram, progressColor)) { ImGui.ProgressBar(notification.Progress, new Vector2(contentSize.X, 2f), ""); } } if (notification.Actions.Count > 0) { ImGui.Spacing(); ImGui.SetCursorPosX(contentPos.X); DrawNotificationActions(notification, contentSize.X, alpha); } } private void DrawNotificationActions(LightlessNotification notification, float availableWidth, float alpha) { var buttonSpacing = 8f; var rightPadding = 10f; var usableWidth = availableWidth - rightPadding; var totalSpacing = (notification.Actions.Count - 1) * buttonSpacing; var buttonWidth = (usableWidth - totalSpacing) / notification.Actions.Count; _logger.LogDebug("Drawing {ActionCount} notification actions, buttonWidth: {ButtonWidth}, availableWidth: {AvailableWidth}", notification.Actions.Count, buttonWidth, availableWidth); var startCursorPos = ImGui.GetCursorPos(); for (int i = 0; i < notification.Actions.Count; i++) { var action = notification.Actions[i]; if (i > 0) { ImGui.SameLine(); var currentX = startCursorPos.X + i * (buttonWidth + buttonSpacing); ImGui.SetCursorPosX(currentX); } DrawActionButton(action, notification, alpha, buttonWidth); } } private void DrawActionButton(LightlessNotificationAction action, LightlessNotification notification, float alpha, float buttonWidth) { _logger.LogDebug("Drawing action button: {ActionId} - {ActionLabel}, width: {ButtonWidth}", action.Id, action.Label, buttonWidth); var buttonColor = action.Color; buttonColor.W *= alpha; var hoveredColor = buttonColor * 1.1f; hoveredColor.W = buttonColor.W; var activeColor = buttonColor * 0.9f; activeColor.W = buttonColor.W; using (ImRaii.PushColor(ImGuiCol.Button, buttonColor)) using (ImRaii.PushColor(ImGuiCol.ButtonHovered, hoveredColor)) using (ImRaii.PushColor(ImGuiCol.ButtonActive, activeColor)) using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, alpha))) { var buttonPressed = false; if (action.Icon != FontAwesomeIcon.None) { buttonPressed = DrawIconTextButton(action.Icon, action.Label, buttonWidth, alpha); } else { buttonPressed = ImGui.Button(action.Label, new Vector2(buttonWidth, 0)); } _logger.LogDebug("Button {ActionId} pressed: {ButtonPressed}", action.Id, buttonPressed); if (buttonPressed) { try { _logger.LogDebug("Executing action: {ActionId}", action.Id); action.OnClick(notification); _logger.LogDebug("Action executed successfully: {ActionId}", action.Id); } catch (Exception ex) { _logger.LogError(ex, "Error executing notification action: {ActionId}", action.Id); } } } } private bool DrawIconTextButton(FontAwesomeIcon icon, string text, float width, float alpha) { var drawList = ImGui.GetWindowDrawList(); var cursorPos = ImGui.GetCursorScreenPos(); var frameHeight = ImGui.GetFrameHeight(); Vector2 iconSize; using (ImRaii.PushFont(UiBuilder.IconFont)) { iconSize = ImGui.CalcTextSize(icon.ToIconString()); } var textSize = ImGui.CalcTextSize(text); var spacing = 3f * ImGuiHelpers.GlobalScale; var totalTextWidth = iconSize.X + spacing + textSize.X; var buttonPressed = ImGui.InvisibleButton($"btn_{icon}_{text}", new Vector2(width, frameHeight)); var buttonMin = ImGui.GetItemRectMin(); var buttonMax = ImGui.GetItemRectMax(); var buttonSize = buttonMax - buttonMin; var buttonColor = ImGui.GetColorU32(ImGuiCol.Button); if (ImGui.IsItemHovered()) buttonColor = ImGui.GetColorU32(ImGuiCol.ButtonHovered); if (ImGui.IsItemActive()) buttonColor = ImGui.GetColorU32(ImGuiCol.ButtonActive); drawList.AddRectFilled(buttonMin, buttonMax, buttonColor, 3f); var iconPos = buttonMin + new Vector2((buttonSize.X - totalTextWidth) / 2f, (buttonSize.Y - iconSize.Y) / 2f); var textPos = iconPos + new Vector2(iconSize.X + spacing, (iconSize.Y - textSize.Y) / 2f); var textColor = ImGui.GetColorU32(ImGuiCol.Text); // Draw icon using (ImRaii.PushFont(UiBuilder.IconFont)) { drawList.AddText(iconPos, textColor, icon.ToIconString()); } // Draw text drawList.AddText(textPos, textColor, text); return buttonPressed; } private float CalculateNotificationHeight(LightlessNotification notification) { var contentWidth = NotificationWidth - 35f; // Account for padding and accent bar var height = 20f; // Base height for padding var titleText = notification.Title; if (_configService.Current.ShowNotificationTimestamp) { var timestamp = notification.CreatedAt.ToLocalTime().ToString("HH:mm:ss"); titleText = $"[{timestamp}] {titleText}"; } var titleSize = ImGui.CalcTextSize(titleText, true, contentWidth); height += titleSize.Y + 4f; // Title height + spacing // Calculate message height if (!string.IsNullOrEmpty(notification.Message)) { var messageSize = ImGui.CalcTextSize(notification.Message, true, contentWidth); height += messageSize.Y + 4f; // Message height + spacing } // Add height for progress bar if (notification.ShowProgress) { height += 12f; } // Add height for action buttons if (notification.Actions.Count > 0) { height += 28f; } // Allow notifications to grow taller but cap at maximum height return Math.Clamp(height, NotificationMinHeight, NotificationMaxHeight); } private Vector4 GetNotificationAccentColor(NotificationType type) { return type switch { NotificationType.Info => UIColors.Get("LightlessPurple"), NotificationType.Warning => UIColors.Get("LightlessYellow"), NotificationType.Error => UIColors.Get("DimRed"), NotificationType.PairRequest => UIColors.Get("LightlessBlue"), NotificationType.Download => UIColors.Get("LightlessGreen"), _ => UIColors.Get("LightlessPurple") }; } }