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 NotificationMinHeight = 60f; private const float NotificationMaxHeight = 250f; 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; PositionCondition = ImGuiCond.Always; Size = new Vector2(_configService.Current.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() { ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); lock (_notificationLock) { UpdateNotifications(); if (_notifications.Count == 0) { ImGui.PopStyleVar(); IsOpen = false; return; } var viewport = ImGui.GetMainViewport(); // Always position at top (choco doesnt know how to handle top positions how fitting) var baseX = viewport.WorkPos.X + viewport.WorkSize.X - _configService.Current.NotificationWidth - _configService.Current.NotificationOffsetX - 6f ; var baseY = viewport.WorkPos.Y; // Apply Y offset var finalY = baseY + _configService.Current.NotificationOffsetY; // Update position Position = new Vector2(baseX, finalY); for (int i = 0; i < _notifications.Count; i++) { DrawNotification(_notifications[i], i); if (i < _notifications.Count - 1) { ImGui.Dummy(new Vector2(0, _configService.Current.NotificationSpacing)); } } } ImGui.PopStyleVar(); } 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 * _configService.Current.NotificationAnimationSpeed); } else if (notification.IsAnimatingOut && notification.AnimationProgress > 0f) { notification.AnimationProgress = Math.Max(0f, notification.AnimationProgress - deltaTime * (_configService.Current.NotificationAnimationSpeed * 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 (alpha <= 0f) return; var slideOffset = (1f - alpha) * 100f; // Fixed slide distance var originalCursorPos = ImGui.GetCursorPos(); ImGui.SetCursorPosX(originalCursorPos.X + slideOffset); var notificationHeight = CalculateNotificationHeight(notification); ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); using var child = ImRaii.Child($"notification_{notification.Id}", new Vector2(_configService.Current.NotificationWidth - slideOffset, notificationHeight), false, ImGuiWindowFlags.NoScrollbar); if (child.Success) { DrawNotificationContent(notification, alpha); } ImGui.PopStyleVar(); } 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 = baseOpacity * alpha; var bgColor = new Vector4(30f/255f, 30f/255f, 30f/255f, finalOpacity); var accentColor = GetNotificationAccentColor(notification.Type); var progressBarColor = UIColors.Get("LightlessBlue"); accentColor.W *= alpha; // Draw shadow with fixed intensity var shadowOffset = new Vector2(1f, 1f); var shadowAlpha = 0.4f * alpha; 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); // Handle click-to-dismiss for notifications without actions if (_configService.Current.DismissNotificationOnClick && !notification.Actions.Any() && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) { notification.IsDismissed = true; notification.IsAnimatingOut = true; } } drawList.AddRectFilled( windowPos, windowPos + windowSize, ImGui.ColorConvertFloat4ToU32(bgColor), 3f ); // Draw accent bar on left side of the notif (only if width > 0) var accentWidth = _configService.Current.NotificationAccentBarWidth; if (accentWidth > 0f) { 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) { // For download notifications, use download progress instead of duration float progress; if (notification.Type == NotificationType.Download && notification.ShowProgress) { progress = Math.Clamp(notification.Progress, 0f, 1f); } else { var elapsed = DateTime.UtcNow - notification.CreatedAt; 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(padding.X, padding.Y); var windowSize = ImGui.GetWindowSize(); var contentSize = new Vector2(windowSize.X - padding.X, windowSize.Y - padding.Y * 2); 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.Actions.Count > 0) { var spacingHeight = ImGui.GetStyle().ItemSpacing.Y; ImGui.SetCursorPosY(ImGui.GetCursorPosY() + spacingHeight); 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 = _configService.Current.NotificationWidth - 35f; // Account for padding and accent bar var height = 12f; // Base height for padding (top + bottom) 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; // Title height // Calculate message height if (!string.IsNullOrEmpty(notification.Message)) { height += 4f; // Spacing between title and message var messageSize = ImGui.CalcTextSize(notification.Message, true, contentWidth); // This is cringe height += messageSize.Y; // Message height } // Add height for progress bar if (notification.ShowProgress) { height += 12f; } // Add height for action buttons if (notification.Actions.Count > 0) { height += ImGui.GetStyle().ItemSpacing.Y; // Spacing before buttons height += ImGui.GetFrameHeight(); // Button height height += 12f; // Bottom padding for buttons } // 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") }; } }