using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Utility; using LightlessSync.LightlessConfiguration; using LightlessSync.Services; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; using System.Numerics; using System.Reflection; using System.Text; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; using Dalamud.Interface; namespace LightlessSync.UI; // Inspiration taken from Brio and Character Select+ (goats) public class UpdateNotesUi : WindowMediatorSubscriberBase { private readonly UiSharedService _uiShared; private ChangelogFile _changelog = new(); private bool _scrollToTop; private int _selectedTab; private struct Particle { public Vector2 Position; public Vector2 Velocity; public float Life; public float MaxLife; public float Size; public ParticleType Type; public List? Trail; public float Twinkle; public float Depth; public float Hue; } private enum ParticleType { TwinklingStar, ShootingStar } private readonly List _particles = []; private float _particleSpawnTimer; private readonly Random _random = new(); private const float HeaderHeight = 150f; private const float ParticleSpawnInterval = 0.2f; private const int MaxParticles = 50; private const int MaxTrailLength = 50; private const float EdgeFadeDistance = 30f; private const float ExtendedParticleHeight = 40f; public UpdateNotesUi(ILogger logger, LightlessMediator mediator, UiSharedService uiShared, LightlessConfigService configService, PerformanceCollectorService performanceCollectorService) : base(logger, mediator, "Lightless Sync — Update Notes", performanceCollectorService) { _uiShared = uiShared; AllowClickthrough = false; AllowPinning = false; RespectCloseHotkey = true; ShowCloseButton = true; Flags = ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoTitleBar; SizeConstraints = new WindowSizeConstraints() { MinimumSize = new Vector2(800, 700), MaximumSize = new Vector2(800, 700), }; LoadEmbeddedResources(); } public override void OnOpen() { _scrollToTop = true; } protected override void DrawInternal() { if (_uiShared.IsInGpose) return; DrawHeader(); ImGuiHelpers.ScaledDummy(6); DrawChangelog(); DrawCloseButton(); } private void DrawHeader() { var windowPos = ImGui.GetWindowPos(); var windowPadding = ImGui.GetStyle().WindowPadding; var headerWidth = (800f * ImGuiHelpers.GlobalScale) - (windowPadding.X * 2); var headerStart = windowPos + new Vector2(windowPadding.X, windowPadding.Y); var headerEnd = headerStart + new Vector2(headerWidth, HeaderHeight); var headerSize = new Vector2(headerWidth, HeaderHeight); var extendedParticleSize = new Vector2(headerWidth, HeaderHeight + ExtendedParticleHeight); DrawGradientBackground(headerStart, headerEnd); DrawHeaderText(headerStart); DrawHeaderButtons(headerStart, headerWidth); DrawBottomGradient(headerStart, headerEnd, headerWidth); ImGui.SetCursorPosY(windowPadding.Y + HeaderHeight + 5); ImGui.SetCursorPosX(20); using (ImRaii.PushFont(UiBuilder.IconFont)) { ImGui.TextColored(UIColors.Get("LightlessGreen"), FontAwesomeIcon.Star.ToIconString()); } ImGui.SameLine(); ImGui.TextColored(UIColors.Get("LightlessGreen"), "What's New"); if (!string.IsNullOrEmpty(_changelog.Tagline)) { ImGui.SameLine(); ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 10); ImGui.TextColored(new Vector4(0.75f, 0.75f, 0.85f, 1.0f), _changelog.Tagline); if (!string.IsNullOrEmpty(_changelog.Subline)) { ImGui.SameLine(); ImGui.TextColored(new Vector4(0.65f, 0.65f, 0.75f, 1.0f), $" – {_changelog.Subline}"); } } ImGuiHelpers.ScaledDummy(3); DrawParticleEffects(headerStart, extendedParticleSize); } private void DrawGradientBackground(Vector2 headerStart, Vector2 headerEnd) { var drawList = ImGui.GetWindowDrawList(); var darkPurple = new Vector4(0.08f, 0.05f, 0.15f, 1.0f); var deepPurple = new Vector4(0.12f, 0.08f, 0.20f, 1.0f); drawList.AddRectFilledMultiColor( headerStart, headerEnd, ImGui.GetColorU32(darkPurple), ImGui.GetColorU32(darkPurple), ImGui.GetColorU32(deepPurple), ImGui.GetColorU32(deepPurple) ); var random = new Random(42); for (int i = 0; i < 50; i++) { var starPos = headerStart + new Vector2( (float)random.NextDouble() * (headerEnd.X - headerStart.X), (float)random.NextDouble() * (headerEnd.Y - headerStart.Y) ); var brightness = 0.3f + (float)random.NextDouble() * 0.4f; drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(new Vector4(1f, 1f, 1f, brightness))); } } private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width) { var drawList = ImGui.GetWindowDrawList(); var gradientHeight = 60f; for (int i = 0; i < gradientHeight; i++) { var progress = i / gradientHeight; var smoothProgress = progress * progress; var r = 0.12f + (0.0f - 0.12f) * smoothProgress; var g = 0.08f + (0.0f - 0.08f) * smoothProgress; var b = 0.20f + (0.0f - 0.20f) * smoothProgress; var alpha = 1f - smoothProgress; var gradientColor = new Vector4(r, g, b, alpha); drawList.AddLine( new Vector2(headerStart.X, headerEnd.Y + i), new Vector2(headerStart.X + width, headerEnd.Y + i), ImGui.GetColorU32(gradientColor), 1f ); } } private void DrawHeaderText(Vector2 headerStart) { var textX = 20f; var textY = 30f; ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY)); using (_uiShared.UidFont.Push()) { ImGui.TextColored(new Vector4(0.95f, 0.95f, 0.95f, 1.0f), "Lightless Sync"); } ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY + 45f)); ImGui.TextColored(UIColors.Get("LightlessBlue"), "Update Notes"); } private void DrawHeaderButtons(Vector2 headerStart, float headerWidth) { var buttonSize = _uiShared.GetIconButtonSize(FontAwesomeIcon.Globe); var spacing = 8f * ImGuiHelpers.GlobalScale; var rightPadding = 15f * ImGuiHelpers.GlobalScale; var topPadding = 15f * ImGuiHelpers.GlobalScale; var buttonY = headerStart.Y + topPadding; var gitButtonX = headerStart.X + headerWidth - rightPadding - buttonSize.X; var discordButtonX = gitButtonX - buttonSize.X - spacing; ImGui.SetCursorScreenPos(new Vector2(discordButtonX, buttonY)); using (ImRaii.PushColor(ImGuiCol.Button, new Vector4(0, 0, 0, 0))) using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple") with { W = 0.3f })) using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurpleActive") with { W = 0.5f })) { if (_uiShared.IconButton(FontAwesomeIcon.Comments)) { Util.OpenLink("https://discord.gg/dsbjcXMnhA"); } if (ImGui.IsItemHovered()) { ImGui.SetTooltip("Join our Discord"); } ImGui.SetCursorScreenPos(new Vector2(gitButtonX, buttonY)); if (_uiShared.IconButton(FontAwesomeIcon.Code)) { Util.OpenLink("https://git.lightless-sync.org/Lightless-Sync"); } if (ImGui.IsItemHovered()) { ImGui.SetTooltip("View on Git"); } } } private void DrawParticleEffects(Vector2 bannerStart, Vector2 bannerSize) { var deltaTime = ImGui.GetIO().DeltaTime; _particleSpawnTimer += deltaTime; if (_particleSpawnTimer > ParticleSpawnInterval && _particles.Count < MaxParticles) { SpawnParticle(bannerSize); _particleSpawnTimer = 0f; } if (_random.NextDouble() < 0.003) { SpawnShootingStar(bannerSize); } var drawList = ImGui.GetWindowDrawList(); for (int i = _particles.Count - 1; i >= 0; i--) { var particle = _particles[i]; var screenPos = bannerStart + particle.Position; if (particle.Type == ParticleType.ShootingStar && particle.Trail != null) { particle.Trail.Insert(0, particle.Position); if (particle.Trail.Count > MaxTrailLength) particle.Trail.RemoveAt(particle.Trail.Count - 1); } if (particle.Type == ParticleType.TwinklingStar) { particle.Twinkle += 0.005f * particle.Depth; } particle.Position += particle.Velocity * deltaTime; particle.Life -= deltaTime; var isOutOfBounds = particle.Position.X < -50 || particle.Position.X > bannerSize.X + 50 || particle.Position.Y < -50 || particle.Position.Y > bannerSize.Y + 50; if (particle.Life <= 0 || (particle.Type != ParticleType.TwinklingStar && isOutOfBounds)) { _particles.RemoveAt(i); continue; } if (particle.Type == ParticleType.TwinklingStar) { if (particle.Position.X < 0 || particle.Position.X > bannerSize.X) particle.Velocity = particle.Velocity with { X = -particle.Velocity.X }; if (particle.Position.Y < 0 || particle.Position.Y > bannerSize.Y) particle.Velocity = particle.Velocity with { Y = -particle.Velocity.Y }; } var fadeIn = Math.Min(1f, (particle.MaxLife - particle.Life) / 20f); var fadeOut = Math.Min(1f, particle.Life / 20f); var lifeFade = Math.Min(fadeIn, fadeOut); var edgeFadeX = Math.Min( Math.Min(1f, (particle.Position.X + EdgeFadeDistance) / EdgeFadeDistance), Math.Min(1f, (bannerSize.X - particle.Position.X + EdgeFadeDistance) / EdgeFadeDistance) ); var edgeFadeY = Math.Min( Math.Min(1f, (particle.Position.Y + EdgeFadeDistance) / EdgeFadeDistance), Math.Min(1f, (bannerSize.Y - particle.Position.Y + EdgeFadeDistance) / EdgeFadeDistance) ); var edgeFade = Math.Min(edgeFadeX, edgeFadeY); var baseAlpha = lifeFade * edgeFade; var finalAlpha = particle.Type == ParticleType.TwinklingStar ? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle)) : baseAlpha; if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1) { var cyanColor = new Vector4(0.4f, 0.8f, 1.0f, 1.0f); for (int t = 1; t < particle.Trail.Count; t++) { var trailProgress = (float)t / particle.Trail.Count; var trailAlpha = Math.Min(1f, (1f - trailProgress) * finalAlpha * 1.8f); var trailWidth = (1f - trailProgress) * 3f + 1f; var glowAlpha = trailAlpha * 0.4f; drawList.AddLine( bannerStart + particle.Trail[t - 1], bannerStart + particle.Trail[t], ImGui.GetColorU32(cyanColor with { W = glowAlpha }), trailWidth + 4f ); drawList.AddLine( bannerStart + particle.Trail[t - 1], bannerStart + particle.Trail[t], ImGui.GetColorU32(cyanColor with { W = trailAlpha }), trailWidth ); } } else if (particle.Type == ParticleType.TwinklingStar) { DrawTwinklingStar(drawList, screenPos, particle.Size, particle.Hue, finalAlpha, particle.Depth); } _particles[i] = particle; } } private void DrawTwinklingStar(ImDrawListPtr drawList, Vector2 position, float size, float hue, float alpha, float depth) { var color = HslToRgb(hue, 1.0f, 0.85f); color.W = alpha; drawList.AddCircleFilled(position, size, ImGui.GetColorU32(color)); var glowColor = color with { W = alpha * 0.3f }; drawList.AddCircleFilled(position, size * (1.2f + depth * 0.3f), ImGui.GetColorU32(glowColor)); } private static Vector4 HslToRgb(float h, float s, float l) { h = h / 360f; float c = (1 - MathF.Abs(2 * l - 1)) * s; float x = c * (1 - MathF.Abs((h * 6) % 2 - 1)); float m = l - c / 2; float r, g, b; if (h < 1f / 6f) { r = c; g = x; b = 0; } else if (h < 2f / 6f) { r = x; g = c; b = 0; } else if (h < 3f / 6f) { r = 0; g = c; b = x; } else if (h < 4f / 6f) { r = 0; g = x; b = c; } else if (h < 5f / 6f) { r = x; g = 0; b = c; } else { r = c; g = 0; b = x; } return new Vector4(r + m, g + m, b + m, 1.0f); } private void SpawnParticle(Vector2 bannerSize) { var position = new Vector2( (float)_random.NextDouble() * bannerSize.X, (float)_random.NextDouble() * bannerSize.Y ); var depthLayers = new[] { 0.5f, 1.0f, 1.5f }; var depth = depthLayers[_random.Next(depthLayers.Length)]; var velocity = new Vector2( ((float)_random.NextDouble() - 0.5f) * 0.05f * depth, ((float)_random.NextDouble() - 0.5f) * 0.05f * depth ); var isBlue = _random.NextDouble() < 0.5; var hue = isBlue ? 220f + (float)_random.NextDouble() * 30f : 270f + (float)_random.NextDouble() * 40f; var size = (0.5f + (float)_random.NextDouble() * 2f) * depth; var maxLife = 120f + (float)_random.NextDouble() * 60f; _particles.Add(new Particle { Position = position, Velocity = velocity, Life = maxLife, MaxLife = maxLife, Size = size, Type = ParticleType.TwinklingStar, Trail = null, Twinkle = (float)_random.NextDouble() * MathF.PI * 2, Depth = depth, Hue = hue }); } private void SpawnShootingStar(Vector2 bannerSize) { var maxLife = 80f + (float)_random.NextDouble() * 40f; var startX = bannerSize.X * (0.3f + (float)_random.NextDouble() * 0.6f); var startY = -10f; _particles.Add(new Particle { Position = new Vector2(startX, startY), Velocity = new Vector2( -50f - (float)_random.NextDouble() * 40f, 30f + (float)_random.NextDouble() * 40f ), Life = maxLife, MaxLife = maxLife, Size = 2.5f, Type = ParticleType.ShootingStar, Trail = new List(), Twinkle = 0, Depth = 1.0f, Hue = 270f }); } private void DrawCloseButton() { ImGuiHelpers.ScaledDummy(5); var closeWidth = 200f * ImGuiHelpers.GlobalScale; var closeHeight = 35f * ImGuiHelpers.GlobalScale; ImGui.SetCursorPosX((ImGui.GetWindowSize().X - closeWidth) / 2); using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 8f)) using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessPurple"))) using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurpleActive"))) using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("ButtonDefault"))) { if (ImGui.Button("Got it!", new Vector2(closeWidth, closeHeight))) { IsOpen = false; } } } private void DrawChangelog() { using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f)) using (var child = ImRaii.Child("###ll_changelog", new Vector2(0, ImGui.GetContentRegionAvail().Y - 60), false, ImGuiWindowFlags.AlwaysVerticalScrollbar)) { if (!child) return; if (_scrollToTop) { _scrollToTop = false; ImGui.SetScrollHereY(0); } ImGui.PushTextWrapPos(); foreach (var entry in _changelog.Changelog) DrawChangelogEntry(entry); ImGui.PopTextWrapPos(); ImGui.Spacing(); } } private void DrawChangelogEntry(ChangelogEntry entry) { var currentColor = entry.IsCurrent == true ? UIColors.Get("LightlessGreen") : new Vector4(0.95f, 0.95f, 1.0f, 1.0f); bool isOpen; var flags = entry.IsCurrent == true ? ImGuiTreeNodeFlags.DefaultOpen : ImGuiTreeNodeFlags.None; using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 4f)) using (ImRaii.PushColor(ImGuiCol.Header, UIColors.Get("ButtonDefault"))) using (ImRaii.PushColor(ImGuiCol.HeaderHovered, UIColors.Get("LightlessPurple"))) using (ImRaii.PushColor(ImGuiCol.HeaderActive, UIColors.Get("LightlessPurpleActive"))) using (ImRaii.PushColor(ImGuiCol.Text, currentColor)) { isOpen = ImGui.CollapsingHeader($" {entry.Name} — {entry.Date} ", flags); } ImGui.SameLine(); ImGui.TextColored(new Vector4(0.85f, 0.85f, 0.95f, 1.0f), $" — {entry.Tagline}"); if (!isOpen) return; ImGuiHelpers.ScaledDummy(8); if (!string.IsNullOrEmpty(entry.Message)) { ImGui.TextWrapped(entry.Message); ImGuiHelpers.ScaledDummy(8); return; } if (entry.Versions != null) { foreach (var version in entry.Versions) { DrawFeatureSection(version.Number, UIColors.Get("LightlessGreen")); foreach (var item in version.Items) { ImGui.BulletText(item); } ImGuiHelpers.ScaledDummy(5); } } ImGuiHelpers.ScaledDummy(8); } private static void DrawFeatureSection(string title, Vector4 accentColor) { var drawList = ImGui.GetWindowDrawList(); var startPos = ImGui.GetCursorScreenPos(); var availableWidth = ImGui.GetContentRegionAvail().X; var backgroundMin = startPos + new Vector2(-8, -4); var backgroundMax = startPos + new Vector2(availableWidth + 8, 28); var bgColor = new Vector4(0.12f, 0.12f, 0.15f, 0.7f); drawList.AddRectFilled(backgroundMin, backgroundMax, ImGui.GetColorU32(bgColor), 6f); drawList.AddRectFilled( backgroundMin, backgroundMin + new Vector2(4, backgroundMax.Y - backgroundMin.Y), ImGui.GetColorU32(accentColor), 3f ); var glowColor = accentColor with { W = 0.15f }; drawList.AddRect( backgroundMin, backgroundMax, ImGui.GetColorU32(glowColor), 6f, ImDrawFlags.None, 1.5f ); ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 8); ImGui.Spacing(); ImGui.TextColored(accentColor, title); ImGui.Spacing(); } private void LoadEmbeddedResources() { try { var assembly = Assembly.GetExecutingAssembly(); using var changelogStream = assembly.GetManifestResourceStream("LightlessSync.UI.Changelog.changelog.yaml"); if (changelogStream != null) { using var reader = new StreamReader(changelogStream, Encoding.UTF8, true, 128); var yaml = reader.ReadToEnd(); var deserializer = new DeserializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .IgnoreUnmatchedProperties() .Build(); _changelog = deserializer.Deserialize(yaml) ?? new(); } } catch { // Ignore - window will gracefully render with defaults } } private sealed record ChangelogFile { public string Tagline { get; init; } = string.Empty; public string Subline { get; init; } = string.Empty; public List Changelog { get; init; } = new(); } private sealed record ChangelogEntry { public string Name { get; init; } = string.Empty; public string Date { get; init; } = string.Empty; public string Tagline { get; init; } = string.Empty; public bool? IsCurrent { get; init; } public string? Message { get; init; } public List? Versions { get; init; } } private sealed record ChangelogVersion { public string Number { get; init; } = string.Empty; public List Items { get; init; } = new(); } }