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 const float _notificationMinHeight = 60f; private const float _notificationMaxHeight = 250f; private const float _windowPaddingOffset = 6f; private const float _slideAnimationDistance = 100f; private const float _outAnimationSpeedMultiplier = 0.7f; private const float _contentPaddingX = 10f; private const float _contentPaddingY = 6f; private const float _titleMessageSpacing = 4f; private const float _actionButtonSpacing = 8f; private readonly List _notifications = new(); private readonly object _notificationLock = new(); private readonly LightlessConfigService _configService; private readonly Dictionary _notificationYOffsets = new(); private readonly Dictionary _notificationTargetYOffsets = new(); 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; SizeCondition = ImGuiCond.FirstUseEver; IsOpen = false; RespectCloseHotkey = false; DisableWindowSounds = true; Mediator.Subscribe(this, HandleNotificationMessage); Mediator.Subscribe(this, HandleNotificationDismissMessage); Mediator.Subscribe(this, HandleClearAllNotifications); } private void HandleNotificationMessage(LightlessNotificationMessage message) => AddNotification(message.Notification); private void HandleNotificationDismissMessage(LightlessNotificationDismissMessage message) => RemoveNotification(message.NotificationId); private void HandleClearAllNotifications(ClearAllNotificationsMessage message) => ClearAllNotifications(); public void AddNotification(LightlessNotification notification) { lock (_notificationLock) { var existingNotification = _notifications.FirstOrDefault(n => n.Id == notification.Id); if (existingNotification != null) { UpdateExistingNotification(existingNotification, notification); } else { _notifications.Add(notification); _logger.LogDebug("Added new notification: {Title}", notification.Title); } if (!IsOpen) IsOpen = true; } } private void UpdateExistingNotification(LightlessNotification existing, LightlessNotification updated) { existing.Message = updated.Message; existing.Progress = updated.Progress; existing.ShowProgress = updated.ShowProgress; existing.Title = updated.Title; _logger.LogDebug("Updated existing notification: {Title}", updated.Title); } public void RemoveNotification(string id) { lock (_notificationLock) { var notification = _notifications.FirstOrDefault(n => n.Id == id); if (notification != null) { StartOutAnimation(notification); } } } public void ClearAllNotifications() { lock (_notificationLock) { foreach (var notification in _notifications) { StartOutAnimation(notification); } } } private void StartOutAnimation(LightlessNotification notification) { notification.IsAnimatingOut = true; notification.IsAnimatingIn = false; } private bool ShouldRemoveNotification(LightlessNotification notification) => notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f; 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(); // Window auto-resizes based on content (AlwaysAutoResize flag) Position = CalculateWindowPosition(viewport); PositionCondition = ImGuiCond.Always; DrawAllNotifications(); } ImGui.PopStyleVar(); } private Vector2 CalculateWindowPosition(ImGuiViewportPtr viewport) { var corner = _configService.Current.NotificationCorner; var offsetX = _configService.Current.NotificationOffsetX; var width = _configService.Current.NotificationWidth; float posX = corner == NotificationCorner.Left ? viewport.WorkPos.X + offsetX - _windowPaddingOffset : viewport.WorkPos.X + viewport.WorkSize.X - width - offsetX - _windowPaddingOffset; return new Vector2(posX, viewport.WorkPos.Y); } private void DrawAllNotifications() { var offsetY = _configService.Current.NotificationOffsetY; var startY = ImGui.GetCursorPosY() + offsetY; for (int i = 0; i < _notifications.Count; i++) { var notification = _notifications[i]; if (_notificationYOffsets.TryGetValue(notification.Id, out var yOffset)) { ImGui.SetCursorPosY(startY + yOffset); } DrawNotification(notification, i); } } private void UpdateNotifications() { var deltaTime = ImGui.GetIO().DeltaTime; EnforceMaxNotificationLimit(); UpdateAnimationsAndRemoveExpired(deltaTime); } private void EnforceMaxNotificationLimit() { 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) { StartOutAnimation(oldestNotification); } } } private void UpdateAnimationsAndRemoveExpired(float deltaTime) { UpdateTargetYPositions(); for (int i = _notifications.Count - 1; i >= 0; i--) { var notification = _notifications[i]; UpdateNotificationAnimation(notification, deltaTime); UpdateNotificationYOffset(notification, deltaTime); if (ShouldRemoveNotification(notification)) { _notifications.RemoveAt(i); _notificationYOffsets.Remove(notification.Id); _notificationTargetYOffsets.Remove(notification.Id); } } } private void UpdateTargetYPositions() { float currentY = 0f; for (int i = 0; i < _notifications.Count; i++) { var notification = _notifications[i]; if (!_notificationTargetYOffsets.ContainsKey(notification.Id)) { _notificationTargetYOffsets[notification.Id] = currentY; _notificationYOffsets[notification.Id] = currentY; } else { _notificationTargetYOffsets[notification.Id] = currentY; } currentY += CalculateNotificationHeight(notification) + _configService.Current.NotificationSpacing; } } private void UpdateNotificationYOffset(LightlessNotification notification, float deltaTime) { if (!_notificationYOffsets.ContainsKey(notification.Id) || !_notificationTargetYOffsets.ContainsKey(notification.Id)) return; var current = _notificationYOffsets[notification.Id]; var target = _notificationTargetYOffsets[notification.Id]; var diff = target - current; if (Math.Abs(diff) < 0.5f) { _notificationYOffsets[notification.Id] = target; } else { var speed = _configService.Current.NotificationSlideSpeed; _notificationYOffsets[notification.Id] = current + (diff * deltaTime * speed); } } private void UpdateNotificationAnimation(LightlessNotification notification, float deltaTime) { 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 * _outAnimationSpeedMultiplier); } else if (!notification.IsAnimatingOut && !notification.IsDismissed) { notification.IsAnimatingIn = false; if (notification.IsExpired) { StartOutAnimation(notification); } } } private Vector2 CalculateSlideOffset(float alpha) { var distance = (1f - alpha) * _slideAnimationDistance; var corner = _configService.Current.NotificationCorner; return corner == NotificationCorner.Left ? new Vector2(-distance, 0) : new Vector2(distance, 0); } private void DrawNotification(LightlessNotification notification, int index) { var alpha = notification.AnimationProgress; if (alpha <= 0f) return; var slideOffset = CalculateSlideOffset(alpha); var originalCursorPos = ImGui.GetCursorPos(); ImGui.SetCursorPos(originalCursorPos + slideOffset); var notificationHeight = CalculateNotificationHeight(notification); var notificationWidth = _configService.Current.NotificationWidth; ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); using var child = ImRaii.Child($"notification_{notification.Id}", new Vector2(notificationWidth, 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 bgColor = CalculateBackgroundColor(alpha, ImGui.IsWindowHovered()); var accentColor = GetNotificationAccentColor(notification.Type); accentColor.W *= alpha; DrawShadow(drawList, windowPos, windowSize, alpha); HandleClickToDismiss(notification); DrawBackground(drawList, windowPos, windowSize, bgColor); DrawAccentBar(drawList, windowPos, windowSize, accentColor); DrawDurationProgressBar(notification, alpha, windowPos, windowSize, drawList); DrawNotificationText(notification, alpha); } private Vector4 CalculateBackgroundColor(float alpha, bool isHovered) { var baseOpacity = _configService.Current.NotificationOpacity; var finalOpacity = baseOpacity * alpha; var bgColor = new Vector4(30f/255f, 30f/255f, 30f/255f, finalOpacity); if (isHovered) { bgColor *= 1.1f; bgColor.W = Math.Min(bgColor.W, 0.98f); } return bgColor; } private void DrawShadow(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, float alpha) { var shadowOffset = new Vector2(1f, 1f); var shadowColor = new Vector4(0f, 0f, 0f, 0.4f * alpha); drawList.AddRectFilled( windowPos + shadowOffset, windowPos + windowSize + shadowOffset, ImGui.ColorConvertFloat4ToU32(shadowColor), 3f ); } private void HandleClickToDismiss(LightlessNotification notification) { if (ImGui.IsWindowHovered() && _configService.Current.DismissNotificationOnClick && !notification.Actions.Any() && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) { notification.IsDismissed = true; StartOutAnimation(notification); } } private void DrawBackground(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 bgColor) { drawList.AddRectFilled( windowPos, windowPos + windowSize, ImGui.ColorConvertFloat4ToU32(bgColor), 3f ); } private void DrawAccentBar(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 accentColor) { var accentWidth = _configService.Current.NotificationAccentBarWidth; if (accentWidth <= 0f) return; var corner = _configService.Current.NotificationCorner; Vector2 accentStart, accentEnd; if (corner == NotificationCorner.Left) { accentStart = windowPos + new Vector2(windowSize.X - accentWidth, 0); accentEnd = windowPos + windowSize; } else { accentStart = windowPos; accentEnd = windowPos + new Vector2(accentWidth, windowSize.Y); } drawList.AddRectFilled( accentStart, accentEnd, ImGui.ColorConvertFloat4ToU32(accentColor), 3f ); } private void DrawDurationProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList) { var progress = CalculateProgress(notification); var progressBarColor = UIColors.Get("LightlessBlue"); var progressHeight = 2f; var progressY = windowPos.Y + windowSize.Y - progressHeight; var progressWidth = windowSize.X * progress; DrawProgressBackground(drawList, windowPos, windowSize, progressY, progressHeight, progressBarColor, alpha); if (progress > 0) { DrawProgressForeground(drawList, windowPos, progressY, progressHeight, progressWidth, progressBarColor, alpha); } } private float CalculateProgress(LightlessNotification notification) { if (notification.Type == NotificationType.Download && notification.ShowProgress) { return Math.Clamp(notification.Progress, 0f, 1f); } var elapsed = DateTime.UtcNow - notification.CreatedAt; return Math.Min(1.0f, (float)(elapsed.TotalSeconds / notification.Duration.TotalSeconds)); } private void DrawProgressBackground(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, float progressY, float progressHeight, Vector4 progressBarColor, float alpha) { 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 ); } private void DrawProgressForeground(ImDrawListPtr drawList, Vector2 windowPos, float progressY, float progressHeight, float progressWidth, Vector4 progressBarColor, float alpha) { 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 contentPos = new Vector2(_contentPaddingX, _contentPaddingY); var windowSize = ImGui.GetWindowSize(); var contentWidth = CalculateContentWidth(windowSize.X); ImGui.SetCursorPos(contentPos); var titleHeight = DrawTitle(notification, contentWidth, alpha); DrawMessage(notification, contentPos, contentWidth, titleHeight, alpha); if (HasActions(notification)) { PositionActionsAtBottom(windowSize.Y); DrawNotificationActions(notification, contentWidth, alpha); } } private float CalculateContentWidth(float windowWidth) => windowWidth - (_contentPaddingX * 2); private bool HasActions(LightlessNotification notification) => notification.Actions.Count > 0; private void PositionActionsAtBottom(float windowHeight) { var actionHeight = ImGui.GetFrameHeight(); var bottomY = windowHeight - _contentPaddingY - actionHeight; ImGui.SetCursorPosY(bottomY); ImGui.SetCursorPosX(_contentPaddingX); } private float DrawTitle(LightlessNotification notification, float contentWidth, float alpha) { var titleColor = new Vector4(1f, 1f, 1f, alpha); var titleText = FormatTitleText(notification); using (ImRaii.PushColor(ImGuiCol.Text, titleColor)) { return DrawWrappedText(titleText, contentWidth); } } private string FormatTitleText(LightlessNotification notification) { if (!_configService.Current.ShowNotificationTimestamp) return notification.Title; var timestamp = notification.CreatedAt.ToLocalTime().ToString("HH:mm:ss"); return $"[{timestamp}] {notification.Title}"; } private float DrawWrappedText(string text, float wrapWidth) { ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + wrapWidth); var startY = ImGui.GetCursorPosY(); ImGui.TextWrapped(text); var height = ImGui.GetCursorPosY() - startY; ImGui.PopTextWrapPos(); return height; } private void DrawMessage(LightlessNotification notification, Vector2 contentPos, float contentWidth, float titleHeight, float alpha) { if (string.IsNullOrEmpty(notification.Message)) return; var messagePos = contentPos + new Vector2(0f, titleHeight + _titleMessageSpacing); var messageColor = new Vector4(0.9f, 0.9f, 0.9f, alpha); ImGui.SetCursorPos(messagePos); using (ImRaii.PushColor(ImGuiCol.Text, messageColor)) { DrawWrappedText(notification.Message, contentWidth); } } private void DrawNotificationActions(LightlessNotification notification, float availableWidth, float alpha) { var buttonWidth = CalculateActionButtonWidth(notification.Actions.Count, availableWidth); _logger.LogDebug("Drawing {ActionCount} notification actions, buttonWidth: {ButtonWidth}, availableWidth: {AvailableWidth}", notification.Actions.Count, buttonWidth, availableWidth); var startX = ImGui.GetCursorPosX(); for (int i = 0; i < notification.Actions.Count; i++) { if (i > 0) { ImGui.SameLine(); PositionActionButton(i, startX, buttonWidth); } DrawActionButton(notification.Actions[i], notification, alpha, buttonWidth); } } private float CalculateActionButtonWidth(int actionCount, float availableWidth) { var totalSpacing = (actionCount - 1) * _actionButtonSpacing; return (availableWidth - totalSpacing) / actionCount; } private void PositionActionButton(int index, float startX, float buttonWidth) { var xPosition = startX + index * (buttonWidth + _actionButtonSpacing); ImGui.SetCursorPosX(xPosition); } 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 = CalculateContentWidth(_configService.Current.NotificationWidth); var height = 12f; height += CalculateTitleHeight(notification, contentWidth); height += CalculateMessageHeight(notification, contentWidth); if (notification.ShowProgress) { height += 12f; } if (notification.Actions.Count > 0) { height += ImGui.GetStyle().ItemSpacing.Y; height += ImGui.GetFrameHeight(); height += 12f; } return Math.Clamp(height, _notificationMinHeight, _notificationMaxHeight); } private float CalculateTitleHeight(LightlessNotification notification, float contentWidth) { var titleText = _configService.Current.ShowNotificationTimestamp ? $"[{notification.CreatedAt.ToLocalTime():HH:mm:ss}] {notification.Title}" : notification.Title; return ImGui.CalcTextSize(titleText, true, contentWidth).Y; } private float CalculateMessageHeight(LightlessNotification notification, float contentWidth) { if (string.IsNullOrEmpty(notification.Message)) return 0f; var messageHeight = ImGui.CalcTextSize(notification.Message, true, contentWidth).Y; return 4f + messageHeight; } 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"), NotificationType.Performance => UIColors.Get("LightlessOrange"), _ => UIColors.Get("LightlessPurple") }; } }