diff --git a/LightlessAPI b/LightlessAPI index 6c542c0..44fbe10 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 6c542c0ccca0327896ef895f9de02a76869ea311 +Subproject commit 44fbe1045872fcae4df45e43625a9ff1a79bc2ef diff --git a/LightlessSync/FileCache/FileCacheManager.cs b/LightlessSync/FileCache/FileCacheManager.cs index 94dd658..ed57656 100644 --- a/LightlessSync/FileCache/FileCacheManager.cs +++ b/LightlessSync/FileCache/FileCacheManager.cs @@ -16,6 +16,8 @@ public sealed class FileCacheManager : IHostedService public const string CachePrefix = "{cache}"; public const string CsvSplit = "|"; public const string PenumbraPrefix = "{penumbra}"; + private const int FileCacheVersion = 1; + private const string FileCacheVersionHeaderPrefix = "#lightless-file-cache-version:"; private readonly LightlessConfigService _configService; private readonly LightlessMediator _lightlessMediator; private readonly string _csvPath; @@ -54,6 +56,62 @@ public sealed class FileCacheManager : IHostedService return NormalizeSeparators(prefixedPath).ToLowerInvariant(); } + private static bool TryBuildPrefixedPath(string path, string? baseDirectory, string prefix, out string prefixedPath, out int matchedLength) + { + prefixedPath = string.Empty; + matchedLength = 0; + + if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(baseDirectory)) + { + return false; + } + + var normalizedPath = NormalizeSeparators(path).ToLowerInvariant(); + var normalizedBase = NormalizeSeparators(baseDirectory).TrimEnd('\\').ToLowerInvariant(); + + if (!normalizedPath.StartsWith(normalizedBase, StringComparison.Ordinal)) + { + return false; + } + + if (normalizedPath.Length > normalizedBase.Length) + { + if (normalizedPath[normalizedBase.Length] != '\\') + { + return false; + } + + prefixedPath = prefix + normalizedPath.Substring(normalizedBase.Length); + } + else + { + prefixedPath = prefix; + } + + prefixedPath = prefixedPath.Replace("\\\\", "\\", StringComparison.Ordinal); + matchedLength = normalizedBase.Length; + return true; + } + + private static string BuildVersionHeader() => $"{FileCacheVersionHeaderPrefix}{FileCacheVersion}"; + + private static bool TryParseVersionHeader(string? line, out int version) + { + version = 0; + if (string.IsNullOrWhiteSpace(line)) + { + return false; + } + + if (!line.StartsWith(FileCacheVersionHeaderPrefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var versionSpan = line.AsSpan(FileCacheVersionHeaderPrefix.Length); + return int.TryParse(versionSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out version); + } + private string NormalizeToPrefixedPath(string path) { if (string.IsNullOrEmpty(path)) return string.Empty; @@ -66,27 +124,25 @@ public sealed class FileCacheManager : IHostedService return NormalizePrefixedPathKey(normalized); } - var penumbraDir = _ipcManager.Penumbra.ModDirectory; - if (!string.IsNullOrEmpty(penumbraDir)) + string? chosenPrefixed = null; + var chosenLength = -1; + + if (TryBuildPrefixedPath(normalized, _ipcManager.Penumbra.ModDirectory, PenumbraPrefix, out var penumbraPrefixed, out var penumbraMatch)) { - var normalizedPenumbra = NormalizeSeparators(penumbraDir); - var replacement = normalizedPenumbra.EndsWith("\\", StringComparison.Ordinal) - ? PenumbraPrefix + "\\" - : PenumbraPrefix; - normalized = normalized.Replace(normalizedPenumbra, replacement, StringComparison.OrdinalIgnoreCase); + chosenPrefixed = penumbraPrefixed; + chosenLength = penumbraMatch; } - var cacheFolder = _configService.Current.CacheFolder; - if (!string.IsNullOrEmpty(cacheFolder)) + if (TryBuildPrefixedPath(normalized, _configService.Current.CacheFolder, CachePrefix, out var cachePrefixed, out var cacheMatch)) { - var normalizedCache = NormalizeSeparators(cacheFolder); - var replacement = normalizedCache.EndsWith("\\", StringComparison.Ordinal) - ? CachePrefix + "\\" - : CachePrefix; - normalized = normalized.Replace(normalizedCache, replacement, StringComparison.OrdinalIgnoreCase); + if (cacheMatch > chosenLength) + { + chosenPrefixed = cachePrefixed; + chosenLength = cacheMatch; + } } - return NormalizePrefixedPathKey(normalized); + return NormalizePrefixedPathKey(chosenPrefixed ?? normalized); } public FileCacheEntity? CreateCacheEntry(string path) @@ -94,7 +150,9 @@ public sealed class FileCacheManager : IHostedService FileInfo fi = new(path); if (!fi.Exists) return null; _logger.LogTrace("Creating cache entry for {path}", path); - return CreateFileEntity(_configService.Current.CacheFolder.ToLowerInvariant(), CachePrefix, fi); + var cacheFolder = _configService.Current.CacheFolder; + if (string.IsNullOrEmpty(cacheFolder)) return null; + return CreateFileEntity(cacheFolder, CachePrefix, fi); } public FileCacheEntity? CreateFileEntry(string path) @@ -102,14 +160,18 @@ public sealed class FileCacheManager : IHostedService FileInfo fi = new(path); if (!fi.Exists) return null; _logger.LogTrace("Creating file entry for {path}", path); - return CreateFileEntity(_ipcManager.Penumbra.ModDirectory!.ToLowerInvariant(), PenumbraPrefix, fi); + var modDirectory = _ipcManager.Penumbra.ModDirectory; + if (string.IsNullOrEmpty(modDirectory)) return null; + return CreateFileEntity(modDirectory, PenumbraPrefix, fi); } private FileCacheEntity? CreateFileEntity(string directory, string prefix, FileInfo fi) { - var fullName = fi.FullName.ToLowerInvariant(); - if (!fullName.Contains(directory, StringComparison.Ordinal)) return null; - string prefixedPath = fullName.Replace(directory, prefix + "\\", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal); + if (!TryBuildPrefixedPath(fi.FullName, directory, prefix, out var prefixedPath, out _)) + { + return null; + } + return CreateFileCacheEntity(fi, prefixedPath); } @@ -367,6 +429,7 @@ public sealed class FileCacheManager : IHostedService lock (_fileWriteLock) { StringBuilder sb = new(); + sb.AppendLine(BuildVersionHeader()); foreach (var entry in _fileCaches.Values.SelectMany(k => k.Values).OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase)) { sb.AppendLine(entry.CsvEntry); @@ -389,6 +452,53 @@ public sealed class FileCacheManager : IHostedService } } + private void EnsureCsvHeaderLocked() + { + if (!File.Exists(_csvPath)) + { + return; + } + + string[] existingLines = File.ReadAllLines(_csvPath); + if (existingLines.Length > 0 && TryParseVersionHeader(existingLines[0], out var existingVersion) && existingVersion == FileCacheVersion) + { + return; + } + + StringBuilder rebuilt = new(); + rebuilt.AppendLine(BuildVersionHeader()); + foreach (var line in existingLines) + { + if (TryParseVersionHeader(line, out _)) + { + continue; + } + + if (!string.IsNullOrEmpty(line)) + { + rebuilt.AppendLine(line); + } + } + + File.WriteAllText(_csvPath, rebuilt.ToString()); + } + + private void BackupUnsupportedCache(string suffix) + { + var sanitizedSuffix = string.IsNullOrWhiteSpace(suffix) ? "unsupported" : $"{suffix}.unsupported"; + var backupPath = _csvPath + "." + sanitizedSuffix; + + try + { + File.Move(_csvPath, backupPath, overwrite: true); + _logger.LogWarning("Backed up unsupported file cache to {path}", backupPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to back up unsupported file cache to {path}", backupPath); + } + } + internal FileCacheEntity MigrateFileHashToExtension(FileCacheEntity fileCache, string ext) { try @@ -427,7 +537,15 @@ public sealed class FileCacheManager : IHostedService AddHashedFile(entity); lock (_fileWriteLock) { - File.AppendAllLines(_csvPath, new[] { entity.CsvEntry }); + if (!File.Exists(_csvPath)) + { + File.WriteAllLines(_csvPath, new[] { BuildVersionHeader(), entity.CsvEntry }); + } + else + { + EnsureCsvHeaderLocked(); + File.AppendAllLines(_csvPath, new[] { entity.CsvEntry }); + } } var result = GetFileCacheByPath(fileInfo.FullName); _logger.LogTrace("Creating cache entity for {name} success: {success}", fileInfo.FullName, (result != null)); @@ -546,49 +664,111 @@ public sealed class FileCacheManager : IHostedService _logger.LogWarning("Could not load entries from {path}, continuing with empty file cache", _csvPath); } - _logger.LogInformation("Found {amount} files in {path}", entries.Length, _csvPath); + bool rewriteRequired = false; + bool parseEntries = entries.Length > 0; + int startIndex = 0; - Dictionary processedFiles = new(StringComparer.OrdinalIgnoreCase); - foreach (var entry in entries) + if (entries.Length > 0) { - var splittedEntry = entry.Split(CsvSplit, StringSplitOptions.None); - try + var headerLine = entries[0]; + var hasHeader = !string.IsNullOrEmpty(headerLine) && + headerLine.StartsWith(FileCacheVersionHeaderPrefix, StringComparison.OrdinalIgnoreCase); + + if (hasHeader) { - var hash = splittedEntry[0]; - if (hash.Length != 40) throw new InvalidOperationException("Expected Hash length of 40, received " + hash.Length); - var path = splittedEntry[1]; - var time = splittedEntry[2]; - - if (processedFiles.ContainsKey(path)) + if (!TryParseVersionHeader(headerLine, out var parsedVersion)) { - _logger.LogWarning("Already processed {file}, ignoring", path); - continue; + _logger.LogWarning("Failed to parse file cache version header \"{header}\". Backing up existing cache.", headerLine); + BackupUnsupportedCache("invalid-version"); + parseEntries = false; + rewriteRequired = true; + entries = Array.Empty(); } - - processedFiles.Add(path, value: true); - - long size = -1; - long compressed = -1; - if (splittedEntry.Length > 3) + else if (parsedVersion != FileCacheVersion) { - if (long.TryParse(splittedEntry[3], CultureInfo.InvariantCulture, out long result)) - { - size = result; - } - if (long.TryParse(splittedEntry[4], CultureInfo.InvariantCulture, out long resultCompressed)) - { - compressed = resultCompressed; - } + _logger.LogWarning("Unsupported file cache version {version} detected (expected {expected}). Backing up existing cache.", parsedVersion, FileCacheVersion); + BackupUnsupportedCache($"v{parsedVersion}"); + parseEntries = false; + rewriteRequired = true; + entries = Array.Empty(); + } + else + { + startIndex = 1; } - AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed))); } - catch (Exception ex) + else if (entries.Length > 0) { - _logger.LogWarning(ex, "Failed to initialize entry {entry}, ignoring", entry); + _logger.LogInformation("File cache missing version header, scheduling rewrite."); + rewriteRequired = true; } } - if (processedFiles.Count != entries.Length) + var totalEntries = Math.Max(0, entries.Length - startIndex); + Dictionary processedFiles = new(StringComparer.OrdinalIgnoreCase); + + if (parseEntries && totalEntries > 0) + { + _logger.LogInformation("Found {amount} files in {path}", totalEntries, _csvPath); + + for (var index = startIndex; index < entries.Length; index++) + { + var entry = entries[index]; + if (string.IsNullOrWhiteSpace(entry)) + { + continue; + } + + var splittedEntry = entry.Split(CsvSplit, StringSplitOptions.None); + try + { + var hash = splittedEntry[0]; + if (hash.Length != 40) + throw new InvalidOperationException("Expected Hash length of 40, received " + hash.Length); + var path = splittedEntry[1]; + var time = splittedEntry[2]; + + if (processedFiles.ContainsKey(path)) + { + _logger.LogWarning("Already processed {file}, ignoring", path); + continue; + } + + processedFiles.Add(path, value: true); + + long size = -1; + long compressed = -1; + if (splittedEntry.Length > 3) + { + if (long.TryParse(splittedEntry[3], CultureInfo.InvariantCulture, out long result)) + { + size = result; + } + if (splittedEntry.Length > 4 && + long.TryParse(splittedEntry[4], CultureInfo.InvariantCulture, out long resultCompressed)) + { + compressed = resultCompressed; + } + } + AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed))); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to initialize entry {entry}, ignoring", entry); + } + } + + if (processedFiles.Count != totalEntries) + { + rewriteRequired = true; + } + } + else if (!parseEntries && entries.Length > 0) + { + _logger.LogInformation("Skipping existing file cache entries due to incompatible version."); + } + + if (rewriteRequired) { WriteOutFullCsv(); } diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightfinderDtrDisplayMode.cs b/LightlessSync/LightlessConfiguration/Configurations/LightfinderDtrDisplayMode.cs new file mode 100644 index 0000000..5c7633c --- /dev/null +++ b/LightlessSync/LightlessConfiguration/Configurations/LightfinderDtrDisplayMode.cs @@ -0,0 +1,7 @@ +namespace LightlessSync.LightlessConfiguration.Configurations; + +public enum LightfinderDtrDisplayMode +{ + NearbyBroadcasts = 0, + PendingPairRequests = 1, +} diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index c20d631..4d82529 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -22,6 +22,13 @@ public class LightlessConfig : ILightlessConfiguration public DtrEntry.Colors DtrColorsDefault { get; set; } = default; public DtrEntry.Colors DtrColorsNotConnected { get; set; } = new(Glow: 0x0428FFu); public DtrEntry.Colors DtrColorsPairsInRange { get; set; } = new(Glow: 0xFFBA47u); + public bool ShowLightfinderInDtr { get; set; } = false; + public bool UseLightfinderColorsInDtr { get; set; } = true; + public DtrEntry.Colors DtrColorsLightfinderEnabled { get; set; } = new(Foreground: 0xB590FFu, Glow: 0x4F406Eu); + public DtrEntry.Colors DtrColorsLightfinderDisabled { get; set; } = new(Foreground: 0xD44444u, Glow: 0x642222u); + public DtrEntry.Colors DtrColorsLightfinderCooldown { get; set; } = new(Foreground: 0xFFE97Au, Glow: 0x766C3Au); + public DtrEntry.Colors DtrColorsLightfinderUnavailable { get; set; } = new(Foreground: 0x000000u, Glow: 0x000000u); + public LightfinderDtrDisplayMode LightfinderDtrDisplayMode { get; set; } = LightfinderDtrDisplayMode.PendingPairRequests; public bool UseLightlessRedesign { get; set; } = true; public bool EnableRightClickMenus { get; set; } = true; public NotificationLocation ErrorNotification { get; set; } = NotificationLocation.Both; @@ -56,6 +63,7 @@ public class LightlessConfig : ILightlessConfiguration public bool ShowOnlineNotificationsOnlyForNamedPairs { get; set; } = false; public bool ShowTransferBars { get; set; } = true; public bool ShowTransferWindow { get; set; } = false; + public bool UseNotificationsForDownloads { get; set; } = true; public bool ShowUploading { get; set; } = true; public bool ShowUploadingBigText { get; set; } = true; public bool ShowVisibleUsersSeparately { get; set; } = true; @@ -69,17 +77,62 @@ public class LightlessConfig : ILightlessConfiguration public bool AutoPopulateEmptyNotesFromCharaName { get; set; } = false; public int Version { get; set; } = 1; public NotificationLocation WarningNotification { get; set; } = NotificationLocation.Both; + + // Lightless Notification Configuration + public bool UseLightlessNotifications { get; set; } = true; + public bool ShowNotificationProgress { get; set; } = true; + public NotificationLocation LightlessInfoNotification { get; set; } = NotificationLocation.LightlessUi; + public NotificationLocation LightlessWarningNotification { get; set; } = NotificationLocation.LightlessUi; + public NotificationLocation LightlessErrorNotification { get; set; } = NotificationLocation.ChatAndLightlessUi; + public NotificationLocation LightlessPairRequestNotification { get; set; } = NotificationLocation.LightlessUi; + public NotificationLocation LightlessDownloadNotification { get; set; } = NotificationLocation.TextOverlay; + + // Basic Settings + public float NotificationOpacity { get; set; } = 0.95f; + public int MaxSimultaneousNotifications { get; set; } = 5; + public bool AutoDismissOnAction { get; set; } = true; + public bool DismissNotificationOnClick { get; set; } = false; + public bool ShowNotificationTimestamp { get; set; } = false; + + // Position & Layout + public int NotificationOffsetY { get; set; } = 50; + public int NotificationOffsetX { get; set; } = 0; + public float NotificationWidth { get; set; } = 350f; + public float NotificationSpacing { get; set; } = 8f; + + // Animation & Effects + public float NotificationAnimationSpeed { get; set; } = 10f; + public float NotificationAccentBarWidth { get; set; } = 3f; + + // Duration per Type + public int InfoNotificationDurationSeconds { get; set; } = 10; + public int WarningNotificationDurationSeconds { get; set; } = 15; + public int ErrorNotificationDurationSeconds { get; set; } = 20; + public int PairRequestDurationSeconds { get; set; } = 180; + public int DownloadNotificationDurationSeconds { get; set; } = 300; + public uint CustomInfoSoundId { get; set; } = 2; // Se2 + public uint CustomWarningSoundId { get; set; } = 16; // Se15 + public uint CustomErrorSoundId { get; set; } = 16; // Se15 + public uint PairRequestSoundId { get; set; } = 5; // Se5 + public uint DownloadSoundId { get; set; } = 15; // Se14 + public bool DisableInfoSound { get; set; } = true; + public bool DisableWarningSound { get; set; } = true; + public bool DisableErrorSound { get; set; } = true; + public bool DisablePairRequestSound { get; set; } = true; + public bool DisableDownloadSound { get; set; } = true; public bool UseFocusTarget { get; set; } = false; public bool overrideFriendColor { get; set; } = false; public bool overridePartyColor { get; set; } = false; public bool overrideFcTagColor { get; set; } = false; public bool useColoredUIDs { get; set; } = true; public bool BroadcastEnabled { get; set; } = false; + public bool LightfinderAutoEnableOnConnect { get; set; } = false; public short LightfinderLabelOffsetX { get; set; } = 0; public short LightfinderLabelOffsetY { get; set; } = 0; public bool LightfinderLabelUseIcon { get; set; } = false; public bool LightfinderLabelShowOwn { get; set; } = true; public bool LightfinderLabelShowPaired { get; set; } = true; + public bool LightfinderLabelShowHidden { get; set; } = false; public string LightfinderLabelIconGlyph { get; set; } = SeIconCharExtensions.ToIconString(SeIconChar.Hyadelyn); public float LightfinderLabelScale { get; set; } = 1.0f; public bool LightfinderAutoAlign { get; set; } = true; diff --git a/LightlessSync/LightlessConfiguration/Configurations/UiStyleOverride.cs b/LightlessSync/LightlessConfiguration/Configurations/UiStyleOverride.cs new file mode 100644 index 0000000..3ea43eb --- /dev/null +++ b/LightlessSync/LightlessConfiguration/Configurations/UiStyleOverride.cs @@ -0,0 +1,21 @@ +using System; +using System.Numerics; + +namespace LightlessSync.LightlessConfiguration.Configurations; + +[Serializable] +public class UiStyleOverride +{ + public uint? Color { get; set; } + public float? Float { get; set; } + public Vector2Config? Vector2 { get; set; } + + public bool IsEmpty => Color is null && Float is null && Vector2 is null; +} + +[Serializable] +public record struct Vector2Config(float X, float Y) +{ + public static implicit operator Vector2(Vector2Config value) => new(value.X, value.Y); + public static implicit operator Vector2Config(Vector2 value) => new(value.X, value.Y); +} diff --git a/LightlessSync/LightlessConfiguration/Configurations/UiThemeConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/UiThemeConfig.cs new file mode 100644 index 0000000..aa0b219 --- /dev/null +++ b/LightlessSync/LightlessConfiguration/Configurations/UiThemeConfig.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; + +namespace LightlessSync.LightlessConfiguration.Configurations; + +[Serializable] +public class UiThemeConfig : ILightlessConfiguration +{ + public Dictionary StyleOverrides { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public int Version { get; set; } = 1; +} diff --git a/LightlessSync/LightlessConfiguration/Models/NotificationLocation.cs b/LightlessSync/LightlessConfiguration/Models/NotificationLocation.cs index a33e727..2815986 100644 --- a/LightlessSync/LightlessConfiguration/Models/NotificationLocation.cs +++ b/LightlessSync/LightlessConfiguration/Models/NotificationLocation.cs @@ -1,16 +1,21 @@ -namespace LightlessSync.LightlessConfiguration.Models; +namespace LightlessSync.LightlessConfiguration.Models; public enum NotificationLocation { Nowhere, Chat, Toast, - Both + Both, + LightlessUi, + ChatAndLightlessUi, + TextOverlay, } public enum NotificationType { Info, Warning, - Error + Error, + PairRequest, + Download } \ No newline at end of file diff --git a/LightlessSync/LightlessConfiguration/UiThemeConfigService.cs b/LightlessSync/LightlessConfiguration/UiThemeConfigService.cs new file mode 100644 index 0000000..21a5051 --- /dev/null +++ b/LightlessSync/LightlessConfiguration/UiThemeConfigService.cs @@ -0,0 +1,14 @@ +using LightlessSync.LightlessConfiguration.Configurations; + +namespace LightlessSync.LightlessConfiguration; + +public class UiThemeConfigService : ConfigurationServiceBase +{ + public const string ConfigName = "ui-theme.json"; + + public UiThemeConfigService(string configDir) : base(configDir) + { + } + + public override string ConfigurationName => ConfigName; +} diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index a9b387d..4bab148 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -3,7 +3,7 @@ - 1.12.1 + 1.12.2 https://github.com/Light-Public-Syncshells/LightlessClient diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index c1648ca..bac9e29 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -114,6 +114,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); + collection.AddSingleton(s => new Lazy(() => s.GetRequiredService())); collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); @@ -142,8 +143,18 @@ public sealed class Plugin : IDalamudPlugin clientState, objectTable, framework, gameGui, condition, gameData, targetManager, gameConfig, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); - collection.AddSingleton((s) => new DtrEntry(s.GetRequiredService>(), dtrBar, s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); + collection.AddSingleton((s) => new DtrEntry( + s.GetRequiredService>(), + dtrBar, + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService())); collection.AddSingleton(s => new PairManager(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), contextMenu, s.GetRequiredService())); collection.AddSingleton(); @@ -172,9 +183,14 @@ public sealed class Plugin : IDalamudPlugin s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); - collection.AddSingleton((s) => new NotificationService(s.GetRequiredService>(), - s.GetRequiredService(), s.GetRequiredService(), - notificationManager, chatGui, s.GetRequiredService())); + collection.AddSingleton((s) => new NotificationService( + s.GetRequiredService>(), + s.GetRequiredService(), + s.GetRequiredService(), + notificationManager, + chatGui, + s.GetRequiredService(), + s.GetRequiredService())); collection.AddSingleton((s) => { var httpClient = new HttpClient(); @@ -182,10 +198,12 @@ public sealed class Plugin : IDalamudPlugin httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LightlessSync", ver!.Major + "." + ver!.Minor + "." + ver!.Build)); return httpClient; }); + collection.AddSingleton((s) => new UiThemeConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => { var cfg = new LightlessConfigService(pluginInterface.ConfigDirectory.FullName); - LightlessSync.UI.Style.MainStyle.Init(cfg); + var theme = s.GetRequiredService(); + LightlessSync.UI.Style.MainStyle.Init(cfg, theme); return cfg; }); collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName)); @@ -197,6 +215,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton((s) => new PlayerPerformanceConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new CharaDataConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton>(s => s.GetRequiredService()); + collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); @@ -235,6 +254,12 @@ public sealed class Plugin : IDalamudPlugin collection.AddScoped((s) => new BroadcastUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped((s) => new SyncshellFinderUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped(); + collection.AddScoped((s) => + new LightlessNotificationUI( + s.GetRequiredService>(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService())); collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); @@ -242,7 +267,9 @@ public sealed class Plugin : IDalamudPlugin collection.AddScoped((s) => new UiService(s.GetRequiredService>(), pluginInterface.UiBuilder, s.GetRequiredService(), s.GetRequiredService(), s.GetServices(), s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService())); + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService())); collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); @@ -279,4 +306,4 @@ public sealed class Plugin : IDalamudPlugin _host.StopAsync().GetAwaiter().GetResult(); _host.Dispose(); } -} \ No newline at end of file +} diff --git a/LightlessSync/Services/BroadcastScanningService.cs b/LightlessSync/Services/BroadcastScanningService.cs index 4619a5e..79fe984 100644 --- a/LightlessSync/Services/BroadcastScanningService.cs +++ b/LightlessSync/Services/BroadcastScanningService.cs @@ -211,6 +211,16 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos UpdateSyncshellBroadcasts(); } + public int CountActiveBroadcasts(string? excludeHashedCid = null) + { + var now = DateTime.UtcNow; + var comparer = StringComparer.Ordinal; + return _broadcastCache.Count(entry => + entry.Value.IsBroadcasting && + entry.Value.ExpiryTime > now && + (excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid))); + } + protected override void Dispose(bool disposing) { base.Dispose(disposing); diff --git a/LightlessSync/Services/BroadcastService.cs b/LightlessSync/Services/BroadcastService.cs index bf9eb05..0cdd3c0 100644 --- a/LightlessSync/Services/BroadcastService.cs +++ b/LightlessSync/Services/BroadcastService.cs @@ -7,6 +7,7 @@ using LightlessSync.WebAPI; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using System.Threading; namespace LightlessSync.Services; public class BroadcastService : IHostedService, IMediatorSubscriber @@ -16,9 +17,11 @@ public class BroadcastService : IHostedService, IMediatorSubscriber private readonly LightlessMediator _mediator; private readonly LightlessConfigService _config; private readonly DalamudUtilService _dalamudUtil; + private CancellationTokenSource? _lightfinderCancelTokens; + private Action? _connectedHandler; public LightlessMediator Mediator => _mediator; - public bool IsLightFinderAvailable { get; private set; } = true; + public bool IsLightFinderAvailable { get; private set; } = false; public bool IsBroadcasting => _config.Current.BroadcastEnabled; private bool _syncedOnStartup = false; @@ -57,24 +60,125 @@ public class BroadcastService : IHostedService, IMediatorSubscriber await action().ConfigureAwait(false); } - public async Task StartAsync(CancellationToken cancellationToken) + private async Task GetLocalHashedCidAsync(string context) + { + try + { + var cid = await _dalamudUtil.GetCIDAsync().ConfigureAwait(false); + return cid.ToString().GetHash256(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to resolve CID for {Context}", context); + return null; + } + } + + private void ApplyBroadcastDisabled(bool forcePublish = false) + { + bool wasEnabled = _config.Current.BroadcastEnabled; + bool hadExpiry = _config.Current.BroadcastTtl != DateTime.MinValue; + bool hadRemaining = _remainingTtl.HasValue; + + _config.Current.BroadcastEnabled = false; + _config.Current.BroadcastTtl = DateTime.MinValue; + + if (wasEnabled || hadExpiry) + _config.Save(); + + _remainingTtl = null; + _waitingForTtlFetch = false; + _syncedOnStartup = false; + + if (forcePublish || wasEnabled || hadRemaining) + _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); + } + + private bool TryApplyBroadcastEnabled(TimeSpan? ttl, string context) + { + if (ttl is not { } validTtl || validTtl <= TimeSpan.Zero) + { + _logger.LogWarning("Lightfinder enable skipped ({Context}): invalid TTL ({TTL})", context, ttl); + return false; + } + + bool wasEnabled = _config.Current.BroadcastEnabled; + TimeSpan? previousRemaining = _remainingTtl; + DateTime previousExpiry = _config.Current.BroadcastTtl; + + var newExpiry = DateTime.UtcNow + validTtl; + + _config.Current.BroadcastEnabled = true; + _config.Current.BroadcastTtl = newExpiry; + + if (!wasEnabled || previousExpiry != newExpiry) + _config.Save(); + + _remainingTtl = validTtl; + _waitingForTtlFetch = false; + + if (!wasEnabled || previousRemaining != validTtl) + _mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl)); + + _logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl); + return true; + } + + private void HandleLightfinderUnavailable(string message, Exception? ex = null) + { + if (ex != null) + _logger.LogWarning(ex, message); + else + _logger.LogWarning(message); + + IsLightFinderAvailable = false; + ApplyBroadcastDisabled(forcePublish: true); + } + + private void OnDisconnected() + { + IsLightFinderAvailable = false; + ApplyBroadcastDisabled(forcePublish: true); + _logger.LogDebug("Cleared Lightfinder state due to disconnect."); + } + + public Task StartAsync(CancellationToken cancellationToken) { _mediator.Subscribe(this, OnEnableBroadcast); _mediator.Subscribe(this, OnBroadcastStatusChanged); _mediator.Subscribe(this, OnTick); + _mediator.Subscribe(this, _ => OnDisconnected()); - _apiController.OnConnected += () => _ = CheckLightfinderSupportAsync(cancellationToken); - //_ = CheckLightfinderSupportAsync(cancellationToken); + IsLightFinderAvailable = false; + + _lightfinderCancelTokens?.Cancel(); + _lightfinderCancelTokens?.Dispose(); + _lightfinderCancelTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _connectedHandler = () => _ = CheckLightfinderSupportAsync(_lightfinderCancelTokens.Token); + _apiController.OnConnected += _connectedHandler; + + if (_apiController.IsConnected) + _ = CheckLightfinderSupportAsync(_lightfinderCancelTokens.Token); + + return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { + _lightfinderCancelTokens?.Cancel(); + _lightfinderCancelTokens?.Dispose(); + _lightfinderCancelTokens = null; + + if (_connectedHandler is not null) + { + _apiController.OnConnected -= _connectedHandler; + _connectedHandler = null; + } + _mediator.UnsubscribeAll(this); - _apiController.OnConnected -= () => _ = CheckLightfinderSupportAsync(cancellationToken); return Task.CompletedTask; } - // need to rework this, this is cooked private async Task CheckLightfinderSupportAsync(CancellationToken cancellationToken) { try @@ -85,25 +189,54 @@ public class BroadcastService : IHostedService, IMediatorSubscriber if (cancellationToken.IsCancellationRequested) return; - var dummy = "0".PadLeft(64, '0'); + var hashedCid = await GetLocalHashedCidAsync("Lightfinder state check").ConfigureAwait(false); + if (string.IsNullOrEmpty(hashedCid)) + return; - await _apiController.IsUserBroadcasting(dummy).ConfigureAwait(false); - await _apiController.SetBroadcastStatus(dummy, true, null).ConfigureAwait(false); - await _apiController.GetBroadcastTtl(dummy).ConfigureAwait(false); - await _apiController.AreUsersBroadcasting([dummy]).ConfigureAwait(false); + BroadcastStatusInfoDto? status = null; + try + { + status = await _apiController.IsUserBroadcasting(hashedCid).ConfigureAwait(false); + } + catch (HubException ex) when (ex.Message.Contains("Method does not exist", StringComparison.OrdinalIgnoreCase)) + { + HandleLightfinderUnavailable("Lightfinder unavailable on server (required method missing).", ex); + } + + if (!IsLightFinderAvailable) + _logger.LogInformation("Lightfinder is available."); IsLightFinderAvailable = true; - _logger.LogInformation("Lightfinder is available."); - } - catch (HubException ex) when (ex.Message.Contains("Method does not exist")) - { - _logger.LogWarning("Lightfinder unavailable: required method missing."); - IsLightFinderAvailable = false; - _config.Current.BroadcastEnabled = false; - _config.Current.BroadcastTtl = DateTime.MinValue; - _config.Save(); - _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); + bool isBroadcasting = status?.IsBroadcasting == true; + TimeSpan? ttl = status?.TTL; + + if (isBroadcasting) + { + if (ttl is not { } remaining || remaining <= TimeSpan.Zero) + ttl = await GetBroadcastTtlAsync(hashedCid).ConfigureAwait(false); + + if (TryApplyBroadcastEnabled(ttl, "server handshake")) + { + _syncedOnStartup = true; + } + else + { + isBroadcasting = false; + } + } + + if (!isBroadcasting) + { + ApplyBroadcastDisabled(forcePublish: true); + _logger.LogInformation("Lightfinder is available but no active broadcast was found."); + } + + if (_config.Current.LightfinderAutoEnableOnConnect && !isBroadcasting) + { + _logger.LogInformation("Auto-enabling Lightfinder broadcast after reconnect."); + _mediator.Publish(new EnableBroadcastMessage(hashedCid, true)); + } } catch (OperationCanceledException) { @@ -111,14 +244,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber } catch (Exception ex) { - _logger.LogWarning(ex, "Lightfinder check failed."); - IsLightFinderAvailable = false; - - _config.Current.BroadcastEnabled = false; - _config.Current.BroadcastTtl = DateTime.MinValue; - _config.Save(); - - _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); + HandleLightfinderUnavailable("Lightfinder check failed.", ex); } } @@ -139,46 +265,38 @@ public class BroadcastService : IHostedService, IMediatorSubscriber }; } - await _apiController.SetBroadcastStatus(msg.HashedCid, msg.Enabled, groupDto).ConfigureAwait(false); + await _apiController.SetBroadcastStatus(msg.Enabled, groupDto).ConfigureAwait(false); _logger.LogDebug("Broadcast {Status} for {Cid}", msg.Enabled ? "enabled" : "disabled", msg.HashedCid); if (!msg.Enabled) { - _config.Current.BroadcastEnabled = false; - _config.Current.BroadcastTtl = DateTime.MinValue; - _config.Save(); - - _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); + ApplyBroadcastDisabled(forcePublish: true); Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}"))); return; } _waitingForTtlFetch = true; - TimeSpan? ttl = await GetBroadcastTtlAsync(msg.HashedCid).ConfigureAwait(false); - - if (ttl is { } remaining && remaining > TimeSpan.Zero) + try { - _config.Current.BroadcastTtl = DateTime.UtcNow + remaining; - _config.Current.BroadcastEnabled = true; - _config.Save(); + TimeSpan? ttl = await GetBroadcastTtlAsync(msg.HashedCid).ConfigureAwait(false); - _logger.LogDebug("Fetched TTL from server: {TTL}", remaining); - _mediator.Publish(new BroadcastStatusChangedMessage(true, remaining)); - Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}"))); + if (TryApplyBroadcastEnabled(ttl, "client request")) + { + _logger.LogDebug("Fetched TTL from server: {TTL}", ttl); + Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}"))); + } + else + { + ApplyBroadcastDisabled(forcePublish: true); + _logger.LogWarning("No valid TTL returned after enabling broadcast. Disabling."); + } } - else + finally { - _logger.LogWarning("No valid TTL returned after enabling broadcast. Disabling."); - _config.Current.BroadcastEnabled = false; - _config.Current.BroadcastTtl = DateTime.MinValue; - _config.Save(); - - _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); + _waitingForTtlFetch = false; } - - _waitingForTtlFetch = false; } catch (Exception ex) { @@ -219,17 +337,24 @@ public class BroadcastService : IHostedService, IMediatorSubscriber return result; } - public async Task GetBroadcastTtlAsync(string cid) + public async Task GetBroadcastTtlAsync(string? cidForLog = null) { TimeSpan? ttl = null; await RequireConnectionAsync(nameof(GetBroadcastTtlAsync), async () => { try { - ttl = await _apiController.GetBroadcastTtl(cid).ConfigureAwait(false); + ttl = await _apiController.GetBroadcastTtl().ConfigureAwait(false); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to fetch broadcast TTL for {cid}", cid); + if (cidForLog is { Length: > 0 }) + { + _logger.LogWarning(ex, "Failed to fetch broadcast TTL for {Cid}", cidForLog); + } + else + { + _logger.LogWarning(ex, "Failed to fetch broadcast TTL"); + } } }).ConfigureAwait(false); return ttl; @@ -281,7 +406,12 @@ public class BroadcastService : IHostedService, IMediatorSubscriber return; } - var hashedCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256(); + var hashedCid = await GetLocalHashedCidAsync(nameof(ToggleBroadcast)).ConfigureAwait(false); + if (string.IsNullOrEmpty(hashedCid)) + { + _logger.LogWarning("ToggleBroadcast - unable to resolve CID."); + return; + } try { @@ -321,31 +451,31 @@ public class BroadcastService : IHostedService, IMediatorSubscriber await RequireConnectionAsync(nameof(OnTick), async () => { if (!_syncedOnStartup && _config.Current.BroadcastEnabled) { - _syncedOnStartup = true; try { - string hashedCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256(); - TimeSpan? ttl = await GetBroadcastTtlAsync(hashedCid).ConfigureAwait(false); - if (ttl is { } - remaining && remaining > TimeSpan.Zero) + var hashedCid = await GetLocalHashedCidAsync("startup TTL refresh").ConfigureAwait(false); + if (string.IsNullOrEmpty(hashedCid)) { - _config.Current.BroadcastTtl = DateTime.UtcNow + remaining; - _config.Current.BroadcastEnabled = true; - _config.Save(); - _logger.LogDebug("Refreshed broadcast TTL from server on first OnTick: {TTL}", remaining); + _logger.LogDebug("Skipping TTL refresh; hashed CID unavailable."); + return; + } + + TimeSpan? ttl = await GetBroadcastTtlAsync(hashedCid).ConfigureAwait(false); + if (TryApplyBroadcastEnabled(ttl, "startup TTL refresh")) + { + _syncedOnStartup = true; + _logger.LogDebug("Refreshed broadcast TTL from server on first OnTick: {TTL}", ttl); } else { _logger.LogWarning("No valid TTL found on OnTick. Disabling broadcast state."); - _config.Current.BroadcastEnabled = false; - _config.Current.BroadcastTtl = DateTime.MinValue; - _config.Save(); - _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); + ApplyBroadcastDisabled(forcePublish: true); } } catch (Exception ex) { _logger.LogError(ex, "Failed to refresh TTL in OnTick"); + _syncedOnStartup = false; } } if (_config.Current.BroadcastEnabled) @@ -362,10 +492,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber if (_remainingTtl == null) { _logger.LogDebug("Broadcast TTL expired. Disabling broadcast locally."); - _config.Current.BroadcastEnabled = false; - _config.Current.BroadcastTtl = DateTime.MinValue; - _config.Save(); - _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); + ApplyBroadcastDisabled(forcePublish: true); } } else diff --git a/LightlessSync/Services/CommandManagerService.cs b/LightlessSync/Services/CommandManagerService.cs index b2993d3..7aedc7b 100644 --- a/LightlessSync/Services/CommandManagerService.cs +++ b/LightlessSync/Services/CommandManagerService.cs @@ -43,7 +43,7 @@ public sealed class CommandManagerService : IDisposable "\t /light gpose - Opens the Lightless Character Data Hub window" + Environment.NewLine + "\t /light analyze - Opens the Lightless Character Data Analysis window" + Environment.NewLine + "\t /light settings - Opens the Lightless Settings window" + Environment.NewLine + - "\t /light lightfinder - Opens the Lightfinder window" + "\t /light finder - Opens the Lightfinder window" }); } @@ -123,7 +123,7 @@ public sealed class CommandManagerService : IDisposable { _mediator.Publish(new UiToggleMessage(typeof(SettingsUi))); } - else if (string.Equals(splitArgs[0], "lightfinder", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(splitArgs[0], "finder", StringComparison.OrdinalIgnoreCase)) { _mediator.Publish(new UiToggleMessage(typeof(BroadcastUI))); } diff --git a/LightlessSync/Services/ContextMenuService.cs b/LightlessSync/Services/ContextMenuService.cs index ad00514..97bfc17 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -147,7 +147,7 @@ internal class ContextMenuService : IHostedService var receiverCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address); _logger.LogInformation("Sending pair request: sender {SenderCid}, receiver {ReceiverCid}", senderCid, receiverCid); - await _apiController.TryPairWithContentId(receiverCid, senderCid).ConfigureAwait(false); + await _apiController.TryPairWithContentId(receiverCid).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(receiverCid)) { _pairRequestService.RemoveRequest(receiverCid); diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index 963bcbf..1524dbd 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -17,6 +17,7 @@ namespace LightlessSync.Services.Mediator; public record SwitchToIntroUiMessage : MessageBase; public record SwitchToMainUiMessage : MessageBase; public record OpenSettingsUiMessage : MessageBase; +public record OpenLightfinderSettingsMessage : MessageBase; public record DalamudLoginMessage : MessageBase; public record DalamudLogoutMessage : MessageBase; public record PriorityFrameworkUpdateMessage : SameThreadMessage; @@ -53,6 +54,8 @@ public record NotificationMessage public record CreateCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage; public record ClearCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage; public record CharacterDataCreatedMessage(CharacterData CharacterData) : SameThreadMessage; +public record LightlessNotificationMessage(LightlessSync.UI.Models.LightlessNotification Notification) : MessageBase; +public record LightlessNotificationDismissMessage(string NotificationId) : MessageBase; public record CharacterDataAnalyzedMessage : MessageBase; public record PenumbraStartRedrawMessage(IntPtr Address) : MessageBase; public record PenumbraEndRedrawMessage(IntPtr Address) : MessageBase; diff --git a/LightlessSync/Services/Mediator/WindowMediatorSubscriberBase.cs b/LightlessSync/Services/Mediator/WindowMediatorSubscriberBase.cs index 8d7aceb..3cecf83 100644 --- a/LightlessSync/Services/Mediator/WindowMediatorSubscriberBase.cs +++ b/LightlessSync/Services/Mediator/WindowMediatorSubscriberBase.cs @@ -1,5 +1,4 @@ -using Dalamud.Interface.Windowing; -using LightlessSync.UI.Style; +using Dalamud.Interface.Windowing; using Microsoft.Extensions.Logging; namespace LightlessSync.Services.Mediator; @@ -34,18 +33,6 @@ public abstract class WindowMediatorSubscriberBase : Window, IMediatorSubscriber GC.SuppressFinalize(this); } - public override void PreDraw() - { - base.PreDraw(); - MainStyle.PushStyle(); // internally checks ShouldUseTheme - } - - public override void PostDraw() - { - MainStyle.PopStyle(); // always attempts to pop if pushed - base.PostDraw(); - } - public override void Draw() { _performanceCollectorService.LogPerformance(this, $"Draw", DrawInternal); diff --git a/LightlessSync/Services/NameplateHandler.cs b/LightlessSync/Services/NameplateHandler.cs index 74edabc..dc761bb 100644 --- a/LightlessSync/Services/NameplateHandler.cs +++ b/LightlessSync/Services/NameplateHandler.cs @@ -221,6 +221,9 @@ public unsafe class NameplateHandler : IMediatorSubscriber if (pNode == null) continue; + if (mpNameplateAddon == null) + continue; + var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)objectInfo->GameObject); if (cid == null || !_activeBroadcastingCids.Contains(cid)) @@ -247,9 +250,17 @@ public unsafe class NameplateHandler : IMediatorSubscriber var pNameplateIconNode = nameplateObject.MarkerIcon; var pNameplateResNode = nameplateObject.NameContainer; var pNameplateTextNode = nameplateObject.NameText; - bool IsVisible = pNameplateIconNode->AtkResNode.IsVisible() || (pNameplateResNode->IsVisible() && pNameplateTextNode->AtkResNode.IsVisible()); + bool IsVisible = pNameplateIconNode->AtkResNode.IsVisible() || (pNameplateResNode->IsVisible() && pNameplateTextNode->AtkResNode.IsVisible()) || _configService.Current.LightfinderLabelShowHidden; pNode->AtkResNode.ToggleVisibility(IsVisible); + if (nameplateObject.RootComponentNode == null || + nameplateObject.NameContainer == null || + nameplateObject.NameText == null) + { + pNode->AtkResNode.ToggleVisibility(false); + continue; + } + var nameContainer = nameplateObject.NameContainer; var nameText = nameplateObject.NameText; @@ -259,8 +270,8 @@ public unsafe class NameplateHandler : IMediatorSubscriber continue; } - var labelColor = UIColors.Get("LightlessPurple"); - var edgeColor = UIColors.Get("FullBlack"); + var labelColor = UIColors.Get("Lightfinder"); + var edgeColor = UIColors.Get("LightfinderEdge"); var config = _configService.Current; var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f); @@ -360,33 +371,35 @@ public unsafe class NameplateHandler : IMediatorSubscriber } int positionX; + + if (!config.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal))) + labelContent = DefaultLabelText; + + pNode->FontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed; + + pNode->SetText(labelContent); + + if (!config.LightfinderLabelUseIcon) + { + pNode->TextFlags &= ~TextFlags.AutoAdjustNodeSize; + pNode->AtkResNode.Width = 0; + nodeWidth = (int)pNode->AtkResNode.GetWidth(); + if (nodeWidth <= 0) + nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); + pNode->AtkResNode.Width = (ushort)nodeWidth; + } + else + { + pNode->TextFlags |= TextFlags.AutoAdjustNodeSize; + pNode->AtkResNode.Width = 0; + nodeWidth = pNode->AtkResNode.GetWidth(); + } + + if (config.LightfinderAutoAlign && nameContainer != null && hasValidOffset) { var nameplateWidth = (int)nameContainer->Width; - if (!config.LightfinderLabelUseIcon) - { - pNode->TextFlags &= ~TextFlags.AutoAdjustNodeSize; - pNode->AtkResNode.Width = 0; - pNode->SetText(labelContent); - - nodeWidth = (int)pNode->AtkResNode.GetWidth(); - if (nodeWidth <= 0) - nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); - - if (nodeWidth > nameplateWidth) - nodeWidth = nameplateWidth; - - pNode->AtkResNode.Width = (ushort)nodeWidth; - } - else - { - pNode->TextFlags |= TextFlags.AutoAdjustNodeSize; - pNode->AtkResNode.Width = 0; - pNode->SetText(labelContent); - nodeWidth = (int)pNode->AtkResNode.GetWidth(); - } - int leftPos = nameplateWidth / 8; int rightPos = nameplateWidth - nodeWidth - (nameplateWidth / 8); int centrePos = (nameplateWidth - nodeWidth) / 2; @@ -414,7 +427,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber positionX = 58 + config.LightfinderLabelOffsetX; alignment = AlignmentType.Bottom; } - + positionY += config.LightfinderLabelOffsetY; alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8); diff --git a/LightlessSync/Services/NotificationService.cs b/LightlessSync/Services/NotificationService.cs index 4ee361b..3f3fdfb 100644 --- a/LightlessSync/Services/NotificationService.cs +++ b/LightlessSync/Services/NotificationService.cs @@ -1,62 +1,495 @@ -using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Interface; using Dalamud.Interface.ImGuiNotification; using Dalamud.Plugin.Services; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services.Mediator; +using LightlessSync.UI; +using LightlessSync.UI.Models; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using FFXIVClientStructs.FFXIV.Client.UI; using NotificationType = LightlessSync.LightlessConfiguration.Models.NotificationType; namespace LightlessSync.Services; - public class NotificationService : DisposableMediatorSubscriberBase, IHostedService { + private readonly ILogger _logger; + private readonly LightlessConfigService _configService; private readonly DalamudUtilService _dalamudUtilService; private readonly INotificationManager _notificationManager; private readonly IChatGui _chatGui; - private readonly LightlessConfigService _configurationService; + private readonly PairRequestService _pairRequestService; + private readonly HashSet _shownPairRequestNotifications = new(); - public NotificationService(ILogger logger, LightlessMediator mediator, + public NotificationService( + ILogger logger, + LightlessConfigService configService, DalamudUtilService dalamudUtilService, INotificationManager notificationManager, - IChatGui chatGui, LightlessConfigService configurationService) : base(logger, mediator) + IChatGui chatGui, + LightlessMediator mediator, + PairRequestService pairRequestService) : base(logger, mediator) { + _logger = logger; + _configService = configService; _dalamudUtilService = dalamudUtilService; _notificationManager = notificationManager; _chatGui = chatGui; - _configurationService = configurationService; + _pairRequestService = pairRequestService; } public Task StartAsync(CancellationToken cancellationToken) { - Mediator.Subscribe(this, ShowNotification); + Mediator.Subscribe(this, HandleNotificationMessage); + Mediator.Subscribe(this, HandlePairRequestsUpdated); return Task.CompletedTask; } - public Task StopAsync(CancellationToken cancellationToken) + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void ShowNotification(string title, string message, NotificationType type = NotificationType.Info, + TimeSpan? duration = null, List? actions = null, uint? soundEffectId = null) { - return Task.CompletedTask; + var notification = CreateNotification(title, message, type, duration, actions, soundEffectId); + + if (_configService.Current.AutoDismissOnAction && notification.Actions.Any()) + { + WrapActionsWithAutoDismiss(notification); + } + + if (notification.SoundEffectId.HasValue) + { + PlayNotificationSound(notification.SoundEffectId.Value); + } + + Mediator.Publish(new LightlessNotificationMessage(notification)); } - private void PrintErrorChat(string? message) + private LightlessNotification CreateNotification(string title, string message, NotificationType type, + TimeSpan? duration, List? actions, uint? soundEffectId) { - SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Error: " + message); - _chatGui.PrintError(se.BuiltString); + return new LightlessNotification + { + Title = title, + Message = message, + Type = type, + Duration = duration ?? GetDefaultDurationForType(type), + Actions = actions ?? new List(), + SoundEffectId = GetSoundEffectId(type, soundEffectId), + ShowProgress = _configService.Current.ShowNotificationProgress, + CreatedAt = DateTime.UtcNow + }; } - private void PrintInfoChat(string? message) + private void WrapActionsWithAutoDismiss(LightlessNotification notification) { - SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Info: ").AddItalics(message ?? string.Empty); - _chatGui.Print(se.BuiltString); + foreach (var action in notification.Actions) + { + var originalOnClick = action.OnClick; + action.OnClick = (n) => + { + originalOnClick(n); + if (_configService.Current.AutoDismissOnAction) + { + DismissNotification(n); + } + }; + } } - private void PrintWarnChat(string? message) + private void DismissNotification(LightlessNotification notification) { - SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ").AddUiForeground("Warning: " + (message ?? string.Empty), 31).AddUiForegroundOff(); - _chatGui.Print(se.BuiltString); + notification.IsDismissed = true; + notification.IsAnimatingOut = true; } + public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline) + { + var notification = new LightlessNotification + { + Id = $"pair_request_{senderId}", + Title = "Pair Request Received", + Message = $"{senderName} wants to directly pair with you.", + Type = NotificationType.PairRequest, + Duration = TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds), + SoundEffectId = GetPairRequestSoundId(), + Actions = CreatePairRequestActions(onAccept, onDecline) + }; + + if (notification.SoundEffectId.HasValue) + { + PlayNotificationSound(notification.SoundEffectId.Value); + } + + Mediator.Publish(new LightlessNotificationMessage(notification)); + } + + private uint? GetPairRequestSoundId() => + !_configService.Current.DisablePairRequestSound ? _configService.Current.PairRequestSoundId : null; + + private List CreatePairRequestActions(Action onAccept, Action onDecline) + { + return new List + { + new() + { + Id = "accept", + Label = "Accept", + Icon = FontAwesomeIcon.Check, + Color = UIColors.Get("LightlessGreen"), + IsPrimary = true, + OnClick = (n) => + { + _logger.LogInformation("Pair request accepted"); + onAccept(); + DismissNotification(n); + } + }, + new() + { + Id = "decline", + Label = "Decline", + Icon = FontAwesomeIcon.Times, + Color = UIColors.Get("DimRed"), + IsDestructive = true, + OnClick = (n) => + { + _logger.LogInformation("Pair request declined"); + onDecline(); + DismissNotification(n); + } + } + }; + } + + public void ShowDownloadCompleteNotification(string fileName, int fileCount, Action? onOpenFolder = null) + { + var notification = new LightlessNotification + { + Title = "Download Complete", + Message = FormatDownloadCompleteMessage(fileName, fileCount), + Type = NotificationType.Info, + Duration = TimeSpan.FromSeconds(8), + Actions = CreateDownloadCompleteActions(onOpenFolder), + SoundEffectId = NotificationSounds.DownloadComplete + }; + + if (notification.SoundEffectId.HasValue) + { + PlayNotificationSound(notification.SoundEffectId.Value); + } + + Mediator.Publish(new LightlessNotificationMessage(notification)); + } + + private string FormatDownloadCompleteMessage(string fileName, int fileCount) => + fileCount > 1 + ? $"Downloaded {fileCount} files successfully." + : $"Downloaded {fileName} successfully."; + + private List CreateDownloadCompleteActions(Action? onOpenFolder) + { + var actions = new List(); + + if (onOpenFolder != null) + { + actions.Add(new LightlessNotificationAction + { + Id = "open_folder", + Label = "Open Folder", + Icon = FontAwesomeIcon.FolderOpen, + Color = UIColors.Get("LightlessBlue"), + OnClick = (n) => + { + onOpenFolder(); + DismissNotification(n); + } + }); + } + + return actions; + } + + public void ShowErrorNotification(string title, string message, Exception? exception = null, Action? onRetry = null, + Action? onViewLog = null) + { + var notification = new LightlessNotification + { + Title = title, + Message = FormatErrorMessage(message, exception), + Type = NotificationType.Error, + Duration = TimeSpan.FromSeconds(15), + Actions = CreateErrorActions(onRetry, onViewLog), + SoundEffectId = NotificationSounds.Error + }; + + if (notification.SoundEffectId.HasValue) + { + PlayNotificationSound(notification.SoundEffectId.Value); + } + + Mediator.Publish(new LightlessNotificationMessage(notification)); + } + + private string FormatErrorMessage(string message, Exception? exception) => + exception != null ? $"{message}\n\nError: {exception.Message}" : message; + + private List CreateErrorActions(Action? onRetry, Action? onViewLog) + { + var actions = new List(); + + if (onRetry != null) + { + actions.Add(new LightlessNotificationAction + { + Id = "retry", + Label = "Retry", + Icon = FontAwesomeIcon.Redo, + Color = UIColors.Get("LightlessBlue"), + OnClick = (n) => + { + onRetry(); + DismissNotification(n); + } + }); + } + + if (onViewLog != null) + { + actions.Add(new LightlessNotificationAction + { + Id = "view_log", + Label = "View Log", + Icon = FontAwesomeIcon.FileAlt, + Color = UIColors.Get("LightlessYellow"), + OnClick = (n) => onViewLog() + }); + } + + return actions; + } + + public void ShowPairDownloadNotification(List<(string playerName, float progress, string status)> downloadStatus, + int queueWaiting = 0) + { + var userDownloads = downloadStatus.Where(x => x.playerName != "Pair Queue").ToList(); + var totalProgress = userDownloads.Count > 0 ? userDownloads.Average(x => x.progress) : 0f; + var message = BuildPairDownloadMessage(userDownloads, queueWaiting); + + var notification = new LightlessNotification + { + Id = "pair_download_progress", + Title = "Downloading Pair Data", + Message = message, + Type = NotificationType.Download, + Duration = TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds), + ShowProgress = true, + Progress = totalProgress + }; + + Mediator.Publish(new LightlessNotificationMessage(notification)); + + if (AreAllDownloadsCompleted(userDownloads)) + { + DismissPairDownloadNotification(); + } + } + + private string BuildPairDownloadMessage(List<(string playerName, float progress, string status)> userDownloads, + int queueWaiting) + { + var messageParts = new List(); + + if (queueWaiting > 0) + { + messageParts.Add($"Queue: {queueWaiting} waiting"); + } + + if (userDownloads.Count > 0) + { + var completedCount = userDownloads.Count(x => x.progress >= 1.0f); + messageParts.Add($"Progress: {completedCount}/{userDownloads.Count} completed"); + } + + var activeDownloadLines = BuildActiveDownloadLines(userDownloads); + if (!string.IsNullOrEmpty(activeDownloadLines)) + { + messageParts.Add(activeDownloadLines); + } + + return string.Join("\n", messageParts); + } + + private string BuildActiveDownloadLines(List<(string playerName, float progress, string status)> userDownloads) + { + var activeDownloads = userDownloads + .Where(x => x.progress < 1.0f) + .Take(_configService.Current.MaxConcurrentPairApplications); + + if (!activeDownloads.Any()) return string.Empty; + + return string.Join("\n", activeDownloads.Select(x => $"• {x.playerName}: {FormatDownloadStatus(x)}")); + } + + private string FormatDownloadStatus((string playerName, float progress, string status) download) => + download.status switch + { + "downloading" => $"{download.progress:P0}", + "decompressing" => "decompressing", + "queued" => "queued", + "waiting" => "waiting for slot", + _ => download.status + }; + + private bool AreAllDownloadsCompleted(List<(string playerName, float progress, string status)> userDownloads) => + userDownloads.Any() && userDownloads.All(x => x.progress >= 1.0f); + + public void DismissPairDownloadNotification() => + Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); + + private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch + { + NotificationType.Info => TimeSpan.FromSeconds(_configService.Current.InfoNotificationDurationSeconds), + NotificationType.Warning => TimeSpan.FromSeconds(_configService.Current.WarningNotificationDurationSeconds), + NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds), + NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds), + NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds), + _ => TimeSpan.FromSeconds(10) + }; + + private uint? GetSoundEffectId(NotificationType type, uint? overrideSoundId) + { + if (overrideSoundId.HasValue) return overrideSoundId; + if (IsSoundDisabledForType(type)) return null; + return GetConfiguredSoundForType(type); + } + + private bool IsSoundDisabledForType(NotificationType type) => type switch + { + NotificationType.Info => _configService.Current.DisableInfoSound, + NotificationType.Warning => _configService.Current.DisableWarningSound, + NotificationType.Error => _configService.Current.DisableErrorSound, + NotificationType.Download => _configService.Current.DisableDownloadSound, + _ => false + }; + + private uint GetConfiguredSoundForType(NotificationType type) => type switch + { + NotificationType.Info => _configService.Current.CustomInfoSoundId, + NotificationType.Warning => _configService.Current.CustomWarningSoundId, + NotificationType.Error => _configService.Current.CustomErrorSoundId, + NotificationType.Download => _configService.Current.DownloadSoundId, + _ => NotificationSounds.GetDefaultSound(type) + }; + + private void PlayNotificationSound(uint soundEffectId) + { + try + { + UIGlobals.PlayChatSoundEffect(soundEffectId); + _logger.LogDebug("Played notification sound effect {SoundId} via ChatGui", soundEffectId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to play notification sound effect {SoundId}", soundEffectId); + } + } + + private void HandleNotificationMessage(NotificationMessage msg) + { + _logger.LogInformation("{msg}", msg.ToString()); + if (!_dalamudUtilService.IsLoggedIn) return; + + var location = GetNotificationLocation(msg.Type); + ShowNotificationLocationBased(msg, location); + } + + private NotificationLocation GetNotificationLocation(NotificationType type) => + _configService.Current.UseLightlessNotifications + ? GetLightlessNotificationLocation(type) + : GetClassicNotificationLocation(type); + + private NotificationLocation GetLightlessNotificationLocation(NotificationType type) => type switch + { + NotificationType.Info => _configService.Current.LightlessInfoNotification, + NotificationType.Warning => _configService.Current.LightlessWarningNotification, + NotificationType.Error => _configService.Current.LightlessErrorNotification, + NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification, + NotificationType.Download => _configService.Current.LightlessDownloadNotification, + _ => NotificationLocation.LightlessUi + }; + + private NotificationLocation GetClassicNotificationLocation(NotificationType type) => type switch + { + NotificationType.Info => _configService.Current.InfoNotification, + NotificationType.Warning => _configService.Current.WarningNotification, + NotificationType.Error => _configService.Current.ErrorNotification, + NotificationType.PairRequest => NotificationLocation.Toast, + NotificationType.Download => NotificationLocation.Toast, + _ => NotificationLocation.Nowhere + }; + + private void ShowNotificationLocationBased(NotificationMessage msg, NotificationLocation location) + { + switch (location) + { + case NotificationLocation.Toast: + ShowToast(msg); + break; + + case NotificationLocation.Chat: + ShowChat(msg); + break; + + case NotificationLocation.Both: + ShowToast(msg); + ShowChat(msg); + break; + + case NotificationLocation.LightlessUi: + ShowLightlessNotification(msg); + break; + + case NotificationLocation.ChatAndLightlessUi: + ShowChat(msg); + ShowLightlessNotification(msg); + break; + + case NotificationLocation.Nowhere: + break; + } + } + + private void ShowLightlessNotification(NotificationMessage msg) + { + var duration = msg.TimeShownOnScreen ?? GetDefaultDurationForType(msg.Type); + ShowNotification(msg.Title ?? "Lightless Sync", msg.Message ?? string.Empty, msg.Type, duration, null, null); + } + + private void ShowToast(NotificationMessage msg) + { + var dalamudType = ConvertToDalamudNotificationType(msg.Type); + + _notificationManager.AddNotification(new Notification() + { + Content = msg.Message ?? string.Empty, + Title = msg.Title, + Type = dalamudType, + Minimized = false, + InitialDuration = msg.TimeShownOnScreen ?? TimeSpan.FromSeconds(3) + }); + } + + private Dalamud.Interface.ImGuiNotification.NotificationType + ConvertToDalamudNotificationType(NotificationType type) => type switch + { + NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error, + NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning, + _ => Dalamud.Interface.ImGuiNotification.NotificationType.Info + }; + private void ShowChat(NotificationMessage msg) { switch (msg.Type) @@ -75,67 +508,54 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ } } - private void ShowNotification(NotificationMessage msg) + private void PrintErrorChat(string? message) { - Logger.LogInformation("{msg}", msg.ToString()); + SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Error: " + message); + _chatGui.PrintError(se.BuiltString); + } - if (!_dalamudUtilService.IsLoggedIn) return; + private void PrintInfoChat(string? message) + { + SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Info: ") + .AddItalics(message ?? string.Empty); + _chatGui.Print(se.BuiltString); + } - switch (msg.Type) + private void PrintWarnChat(string? message) + { + SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ") + .AddUiForeground("Warning: " + (message ?? string.Empty), 31).AddUiForegroundOff(); + _chatGui.Print(se.BuiltString); + } + + private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _) + { + var activeRequests = _pairRequestService.GetActiveRequests(); + var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet(); + + // Dismiss notifications for requests that are no longer active + var notificationsToRemove = _shownPairRequestNotifications + .Where(hashedCid => !activeRequestIds.Contains(hashedCid)) + .ToList(); + + foreach (var hashedCid in notificationsToRemove) { - case NotificationType.Info: - ShowNotificationLocationBased(msg, _configurationService.Current.InfoNotification); - break; + var notificationId = $"pair_request_{hashedCid}"; + Mediator.Publish(new LightlessNotificationDismissMessage(notificationId)); + _shownPairRequestNotifications.Remove(hashedCid); + } - case NotificationType.Warning: - ShowNotificationLocationBased(msg, _configurationService.Current.WarningNotification); - break; - - case NotificationType.Error: - ShowNotificationLocationBased(msg, _configurationService.Current.ErrorNotification); - break; + // Show/update notifications for all active requests + foreach (var request in activeRequests) + { + _shownPairRequestNotifications.Add(request.HashedCid); + ShowPairRequestNotification( + request.DisplayName, + request.HashedCid, + () => _pairRequestService.AcceptPairRequest(request.HashedCid, request.DisplayName), + () => _pairRequestService.DeclinePairRequest(request.HashedCid) + ); } } - - private void ShowNotificationLocationBased(NotificationMessage msg, NotificationLocation location) - { - switch (location) - { - case NotificationLocation.Toast: - ShowToast(msg); - break; - - case NotificationLocation.Chat: - ShowChat(msg); - break; - - case NotificationLocation.Both: - ShowToast(msg); - ShowChat(msg); - break; - - case NotificationLocation.Nowhere: - break; - } - } - - private void ShowToast(NotificationMessage msg) - { - Dalamud.Interface.ImGuiNotification.NotificationType dalamudType = msg.Type switch - { - NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error, - NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning, - NotificationType.Info => Dalamud.Interface.ImGuiNotification.NotificationType.Info, - _ => Dalamud.Interface.ImGuiNotification.NotificationType.Info - }; - - _notificationManager.AddNotification(new Notification() - { - Content = msg.Message ?? string.Empty, - Title = msg.Title, - Type = dalamudType, - Minimized = false, - InitialDuration = msg.TimeShownOnScreen ?? TimeSpan.FromSeconds(3) - }); - } -} \ No newline at end of file +} + \ No newline at end of file diff --git a/LightlessSync/Services/PairRequestService.cs b/LightlessSync/Services/PairRequestService.cs index 998ea42..92294e2 100644 --- a/LightlessSync/Services/PairRequestService.cs +++ b/LightlessSync/Services/PairRequestService.cs @@ -1,6 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; @@ -11,16 +13,18 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase { private readonly DalamudUtilService _dalamudUtil; private readonly PairManager _pairManager; + private readonly Lazy _apiController; private readonly object _syncRoot = new(); private readonly List _requests = []; private static readonly TimeSpan Expiration = TimeSpan.FromMinutes(5); - public PairRequestService(ILogger logger, LightlessMediator mediator, DalamudUtilService dalamudUtil, PairManager pairManager) + public PairRequestService(ILogger logger, LightlessMediator mediator, DalamudUtilService dalamudUtil, PairManager pairManager, Lazy apiController) : base(logger, mediator) { _dalamudUtil = dalamudUtil; _pairManager = pairManager; + _apiController = apiController; Mediator.Subscribe(this, _ => { @@ -183,6 +187,40 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase return _requests.RemoveAll(r => now - r.ReceivedAt > Expiration) > 0; } + public void AcceptPairRequest(string hashedCid, string displayName) + { + _ = Task.Run(async () => + { + try + { + await _apiController.Value.TryPairWithContentId(hashedCid).ConfigureAwait(false); + RemoveRequest(hashedCid); + + var displayText = string.IsNullOrEmpty(displayName) ? hashedCid : displayName; + Mediator.Publish(new NotificationMessage( + "Pair request accepted", + $"Sent a pair request back to {displayText}.", + NotificationType.Info, + TimeSpan.FromSeconds(3))); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to accept pair request for {HashedCid}", hashedCid); + Mediator.Publish(new NotificationMessage( + "Failed to Accept Pair Request", + ex.Message, + NotificationType.Error, + TimeSpan.FromSeconds(5))); + } + }); + } + + public void DeclinePairRequest(string hashedCid) + { + RemoveRequest(hashedCid); + Logger.LogDebug("Declined pair request from {HashedCid}", hashedCid); + } + private record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt); public readonly record struct PairRequestDisplay(string HashedCid, string DisplayName, string Message, DateTime ReceivedAt); diff --git a/LightlessSync/Services/ServerConfiguration/ServerConfigurationManager.cs b/LightlessSync/Services/ServerConfiguration/ServerConfigurationManager.cs index 25d9796..388ac87 100644 --- a/LightlessSync/Services/ServerConfiguration/ServerConfigurationManager.cs +++ b/LightlessSync/Services/ServerConfiguration/ServerConfigurationManager.cs @@ -607,8 +607,9 @@ public class ServerConfigurationManager { var baseUri = serverUri.Replace("wss://", "https://").Replace("ws://", "http://"); var oauthCheckUri = LightlessAuth.GetUIDsFullPath(new Uri(baseUri)); - _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - var response = await _httpClient.GetAsync(oauthCheckUri).ConfigureAwait(false); + using var request = new HttpRequestMessage(HttpMethod.Get, oauthCheckUri); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + using var response = await _httpClient.SendAsync(request).ConfigureAwait(false); var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); return await JsonSerializer.DeserializeAsync>(responseStream).ConfigureAwait(false) ?? []; } diff --git a/LightlessSync/Services/UiService.cs b/LightlessSync/Services/UiService.cs index 4071b61..3740114 100644 --- a/LightlessSync/Services/UiService.cs +++ b/LightlessSync/Services/UiService.cs @@ -1,9 +1,10 @@ -using Dalamud.Interface; +using Dalamud.Interface; using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.Windowing; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; using LightlessSync.UI; +using LightlessSync.UI.Style; using Microsoft.Extensions.Logging; namespace LightlessSync.Services; @@ -22,7 +23,8 @@ public sealed class UiService : DisposableMediatorSubscriberBase LightlessConfigService lightlessConfigService, WindowSystem windowSystem, IEnumerable windows, UiFactory uiFactory, FileDialogManager fileDialogManager, - LightlessMediator lightlessMediator) : base(logger, lightlessMediator) + LightlessMediator lightlessMediator, + NotificationService notificationService) : base(logger, lightlessMediator) { _logger = logger; _logger.LogTrace("Creating {type}", GetType().Name); @@ -119,7 +121,15 @@ public sealed class UiService : DisposableMediatorSubscriberBase private void Draw() { - _windowSystem.Draw(); - _fileDialogManager.Draw(); + MainStyle.PushStyle(); + try + { + _windowSystem.Draw(); + _fileDialogManager.Draw(); + } + finally + { + MainStyle.PopStyle(); + } } } \ No newline at end of file diff --git a/LightlessSync/UI/BroadcastUI.cs b/LightlessSync/UI/BroadcastUI.cs index 5749a9c..c760a45 100644 --- a/LightlessSync/UI/BroadcastUI.cs +++ b/LightlessSync/UI/BroadcastUI.cs @@ -1,4 +1,5 @@ using Dalamud.Bindings.ImGui; +using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Utility; @@ -209,7 +210,7 @@ namespace LightlessSync.UI else { ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); - ImGui.Text("The Lightfinder’s light wanes, but not in vain."); // cringe.. + ImGui.Text("The Lightfinder’s light wanes, but not in vain."); // cringe.. ImGui.PopStyleColor(); } } @@ -252,12 +253,27 @@ namespace LightlessSync.UI _broadcastService.ToggleBroadcast(); } + var toggleButtonHeight = ImGui.GetItemRectSize().Y; + if (isOnCooldown || !_broadcastService.IsLightFinderAvailable) ImGui.EndDisabled(); ImGui.PopStyleColor(); ImGui.PopStyleVar(); + ImGui.SameLine(); + if (_uiSharedService.IconButton(FontAwesomeIcon.Cog, toggleButtonHeight)) + { + Mediator.Publish(new OpenLightfinderSettingsMessage()); + } + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.TextUnformatted("Open Lightfinder settings."); + ImGui.EndTooltip(); + } + ImGui.EndTabItem(); } diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index b02594f..c264681 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -1,4 +1,4 @@ -using System; +using System; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility; @@ -87,7 +87,7 @@ public class CompactUi : WindowMediatorSubscriberBase IpcManager ipcManager, BroadcastService broadcastService, CharacterAnalyzer characterAnalyzer, - PlayerPerformanceConfigService playerPerformanceConfig, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService) + PlayerPerformanceConfigService playerPerformanceConfig, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService) { _uiSharedService = uiShared; _configService = configService; @@ -105,7 +105,7 @@ public class CompactUi : WindowMediatorSubscriberBase _renamePairTagUi = renameTagUi; _ipcManager = ipcManager; _broadcastService = broadcastService; - _tabMenu = new TopTabMenu(Mediator, _apiController, _pairManager, _uiSharedService, pairRequestService, dalamudUtilService); + _tabMenu = new TopTabMenu(Mediator, _apiController, _pairManager, _uiSharedService, pairRequestService, dalamudUtilService, lightlessNotificationService); AllowPinning = true; AllowClickthrough = false; diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 2e9f366..301f177 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -1,7 +1,7 @@ -using System; using Dalamud.Bindings.ImGui; using Dalamud.Interface.Colors; using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.Mediator; @@ -22,9 +22,12 @@ public class DownloadUi : WindowMediatorSubscriberBase private readonly UiSharedService _uiShared; private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly ConcurrentDictionary _uploadingPlayers = new(); + private readonly NotificationService _notificationService; + private bool _notificationDismissed = true; public DownloadUi(ILogger logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService, - PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared, PerformanceCollectorService performanceCollectorService) + PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared, + PerformanceCollectorService performanceCollectorService, NotificationService notificationService) : base(logger, mediator, "Lightless Sync Downloads", performanceCollectorService) { _dalamudUtilService = dalamudUtilService; @@ -32,6 +35,7 @@ public class DownloadUi : WindowMediatorSubscriberBase _pairProcessingLimiter = pairProcessingLimiter; _fileTransferManager = fileTransferManager; _uiShared = uiShared; + _notificationService = notificationService; SizeConstraints = new WindowSizeConstraints() { @@ -56,7 +60,14 @@ public class DownloadUi : WindowMediatorSubscriberBase IsOpen = true; Mediator.Subscribe(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus); - Mediator.Subscribe(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _)); + Mediator.Subscribe(this, (msg) => + { + _currentDownloads.TryRemove(msg.DownloadId, out _); + if (!_currentDownloads.Any()) + { + _notificationService.DismissPairDownloadNotification(); + } + }); Mediator.Subscribe(this, (_) => IsOpen = false); Mediator.Subscribe(this, (_) => IsOpen = true); Mediator.Subscribe(this, (msg) => @@ -77,19 +88,7 @@ public class DownloadUi : WindowMediatorSubscriberBase if (_configService.Current.ShowTransferWindow) { var limiterSnapshot = _pairProcessingLimiter.GetSnapshot(); - if (limiterSnapshot.IsEnabled) - { - var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey; - var queueText = $"Pair queue {limiterSnapshot.InFlight}/{limiterSnapshot.Limit}"; - queueText += limiterSnapshot.Waiting > 0 ? $" ({limiterSnapshot.Waiting} waiting, {limiterSnapshot.Remaining} free)" : $" ({limiterSnapshot.Remaining} free)"; - UiSharedService.DrawOutlinedFont(queueText, queueColor, new Vector4(0, 0, 0, 255), 1); - ImGui.NewLine(); - } - else - { - UiSharedService.DrawOutlinedFont("Pair apply limiter disabled", ImGuiColors.DalamudGrey, new Vector4(0, 0, 0, 255), 1); - ImGui.NewLine(); - } + try { if (_fileTransferManager.IsUploading) @@ -122,28 +121,64 @@ public class DownloadUi : WindowMediatorSubscriberBase try { - foreach (var item in _currentDownloads.ToList()) + // Check if download notifications are enabled (not set to TextOverlay) + var useNotifications = _configService.Current.UseLightlessNotifications + ? _configService.Current.LightlessDownloadNotification != NotificationLocation.TextOverlay + : _configService.Current.UseNotificationsForDownloads; + + if (useNotifications) { - var dlSlot = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForSlot); - var dlQueue = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForQueue); - var dlProg = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Downloading); - var dlDecomp = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Decompressing); - var totalFiles = item.Value.Sum(c => c.Value.TotalFiles); - var transferredFiles = item.Value.Sum(c => c.Value.TransferredFiles); - var totalBytes = item.Value.Sum(c => c.Value.TotalBytes); - var transferredBytes = item.Value.Sum(c => c.Value.TransferredBytes); + // Use notification system + if (_currentDownloads.Any()) + { + UpdateDownloadNotification(limiterSnapshot); + _notificationDismissed = false; + } + else if (!_notificationDismissed) + { + _notificationService.DismissPairDownloadNotification(); + _notificationDismissed = true; + } + } + else + { + if (limiterSnapshot.IsEnabled) + { + var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey; + var queueText = $"Pair queue {limiterSnapshot.InFlight}/{limiterSnapshot.Limit}"; + queueText += limiterSnapshot.Waiting > 0 ? $" ({limiterSnapshot.Waiting} waiting, {limiterSnapshot.Remaining} free)" : $" ({limiterSnapshot.Remaining} free)"; + UiSharedService.DrawOutlinedFont(queueText, queueColor, new Vector4(0, 0, 0, 255), 1); + ImGui.NewLine(); + } + else + { + UiSharedService.DrawOutlinedFont("Pair apply limiter disabled", ImGuiColors.DalamudGrey, new Vector4(0, 0, 0, 255), 1); + ImGui.NewLine(); + } + + foreach (var item in _currentDownloads.ToList()) + { + var dlSlot = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForSlot); + var dlQueue = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForQueue); + var dlProg = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Downloading); + var dlDecomp = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Decompressing); + var totalFiles = item.Value.Sum(c => c.Value.TotalFiles); + var transferredFiles = item.Value.Sum(c => c.Value.TransferredFiles); + var totalBytes = item.Value.Sum(c => c.Value.TotalBytes); + var transferredBytes = item.Value.Sum(c => c.Value.TransferredBytes); - UiSharedService.DrawOutlinedFont($"â–¼", ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1); - ImGui.SameLine(); - var xDistance = ImGui.GetCursorPosX(); - UiSharedService.DrawOutlinedFont( - $"{item.Key.Name} [W:{dlSlot}/Q:{dlQueue}/P:{dlProg}/D:{dlDecomp}]", - ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1); - ImGui.NewLine(); - ImGui.SameLine(xDistance); - UiSharedService.DrawOutlinedFont( - $"{transferredFiles}/{totalFiles} ({UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)})", - ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1); + UiSharedService.DrawOutlinedFont($"â–¼", ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1); + ImGui.SameLine(); + var xDistance = ImGui.GetCursorPosX(); + UiSharedService.DrawOutlinedFont( + $"{item.Key.Name} [W:{dlSlot}/Q:{dlQueue}/P:{dlProg}/D:{dlDecomp}]", + ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1); + ImGui.NewLine(); + ImGui.SameLine(xDistance); + UiSharedService.DrawOutlinedFont( + $"{transferredFiles}/{totalFiles} ({UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)})", + ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1); + } } } catch @@ -262,4 +297,40 @@ public class DownloadUi : WindowMediatorSubscriberBase MaximumSize = new Vector2(300, maxHeight), }; } + + private void UpdateDownloadNotification(PairProcessingLimiterSnapshot limiterSnapshot) + { + var downloadStatus = new List<(string playerName, float progress, string status)>(); + + foreach (var item in _currentDownloads.ToList()) + { + var dlSlot = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForSlot); + var dlQueue = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForQueue); + var dlProg = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Downloading); + var dlDecomp = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Decompressing); + var totalFiles = item.Value.Sum(c => c.Value.TotalFiles); + var transferredFiles = item.Value.Sum(c => c.Value.TransferredFiles); + var totalBytes = item.Value.Sum(c => c.Value.TotalBytes); + var transferredBytes = item.Value.Sum(c => c.Value.TransferredBytes); + + var progress = totalBytes > 0 ? (float)transferredBytes / totalBytes : 0f; + + string status; + if (dlDecomp > 0) status = "decompressing"; + else if (dlProg > 0) status = "downloading"; + else if (dlQueue > 0) status = "queued"; + else if (dlSlot > 0) status = "waiting"; + else status = "completed"; + + downloadStatus.Add((item.Key.Name, progress, status)); + } + + // Pass queue waiting count separately, show notification if there are downloads or queue items + var queueWaiting = limiterSnapshot.IsEnabled ? limiterSnapshot.Waiting : 0; + if (downloadStatus.Any() || queueWaiting > 0) + { + _notificationService.ShowPairDownloadNotification(downloadStatus, queueWaiting); + } + } + } \ No newline at end of file diff --git a/LightlessSync/UI/DtrEntry.cs b/LightlessSync/UI/DtrEntry.cs index 0335136..89c7389 100644 --- a/LightlessSync/UI/DtrEntry.cs +++ b/LightlessSync/UI/DtrEntry.cs @@ -1,56 +1,92 @@ -using Dalamud.Game.Gui.Dtr; +using Dalamud.Game.Gui.Dtr; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Plugin.Services; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.PlayerData.Pairs; +using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using LightlessSync.WebAPI; using LightlessSync.WebAPI.SignalR.Utils; +using LightlessSync.Utils; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System.Runtime.InteropServices; +using System.Text; namespace LightlessSync.UI; public sealed class DtrEntry : IDisposable, IHostedService { + private static readonly TimeSpan _localHashedCidCacheDuration = TimeSpan.FromMinutes(2); + private static readonly TimeSpan _localHashedCidErrorCooldown = TimeSpan.FromMinutes(1); + private readonly ApiController _apiController; private readonly ServerConfigurationManager _serverManager; private readonly CancellationTokenSource _cancellationTokenSource = new(); private readonly ConfigurationServiceBase _configService; private readonly IDtrBar _dtrBar; - private readonly Lazy _entry; + private readonly Lazy _statusEntry; + private readonly Lazy _lightfinderEntry; private readonly ILogger _logger; + private readonly BroadcastService _broadcastService; + private readonly BroadcastScannerService _broadcastScannerService; private readonly LightlessMediator _lightlessMediator; private readonly PairManager _pairManager; + private readonly PairRequestService _pairRequestService; + private readonly DalamudUtilService _dalamudUtilService; private Task? _runTask; - private string? _text; - private string? _tooltip; - private Colors _colors; + private string? _statusText; + private string? _statusTooltip; + private Colors _statusColors; + private string? _lightfinderText; + private string? _lightfinderTooltip; + private Colors _lightfinderColors; + private string? _localHashedCid; + private DateTime _localHashedCidFetchedAt = DateTime.MinValue; + private DateTime _localHashedCidNextErrorLog = DateTime.MinValue; + private DateTime _pairRequestNextErrorLog = DateTime.MinValue; - public DtrEntry(ILogger logger, IDtrBar dtrBar, ConfigurationServiceBase configService, LightlessMediator lightlessMediator, PairManager pairManager, ApiController apiController, ServerConfigurationManager serverManager) + public DtrEntry( + ILogger logger, + IDtrBar dtrBar, + ConfigurationServiceBase configService, + LightlessMediator lightlessMediator, + PairManager pairManager, + PairRequestService pairRequestService, + ApiController apiController, + ServerConfigurationManager serverManager, + BroadcastService broadcastService, + BroadcastScannerService broadcastScannerService, + DalamudUtilService dalamudUtilService) { _logger = logger; _dtrBar = dtrBar; - _entry = new(CreateEntry); + _statusEntry = new(CreateStatusEntry); + _lightfinderEntry = new(CreateLightfinderEntry); _configService = configService; _lightlessMediator = lightlessMediator; _pairManager = pairManager; + _pairRequestService = pairRequestService; _apiController = apiController; _serverManager = serverManager; + _broadcastService = broadcastService; + _broadcastScannerService = broadcastScannerService; + _dalamudUtilService = dalamudUtilService; } public void Dispose() { - if (_entry.IsValueCreated) + if (_statusEntry.IsValueCreated) { _logger.LogDebug("Disposing DtrEntry"); Clear(); - _entry.Value.Remove(); + _statusEntry.Value.Remove(); } + if (_lightfinderEntry.IsValueCreated) + _lightfinderEntry.Value.Remove(); } public Task StartAsync(CancellationToken cancellationToken) @@ -70,7 +106,7 @@ public sealed class DtrEntry : IDisposable, IHostedService } catch (OperationCanceledException) { - // ignore cancelled + } finally { @@ -80,33 +116,66 @@ public sealed class DtrEntry : IDisposable, IHostedService private void Clear() { - if (!_entry.IsValueCreated) return; - _logger.LogInformation("Clearing entry"); - _text = null; - _tooltip = null; - _colors = default; - - _entry.Value.Shown = false; + HideStatusEntry(); + HideLightfinderEntry(); } - private IDtrBarEntry CreateEntry() + private void HideStatusEntry() { - _logger.LogTrace("Creating new DtrBar entry"); + if (_statusEntry.IsValueCreated && _statusEntry.Value.Shown) + { + _logger.LogInformation("Hiding status entry"); + _statusEntry.Value.Shown = false; + } + + _statusText = null; + _statusTooltip = null; + _statusColors = default; + } + + private void HideLightfinderEntry() + { + if (_lightfinderEntry.IsValueCreated && _lightfinderEntry.Value.Shown) + { + _logger.LogInformation("Hiding Lightfinder entry"); + _lightfinderEntry.Value.Shown = false; + } + + _lightfinderText = null; + _lightfinderTooltip = null; + _lightfinderColors = default; + } + + private IDtrBarEntry CreateStatusEntry() + { + _logger.LogTrace("Creating status DtrBar entry"); var entry = _dtrBar.Get("Lightless Sync"); - entry.OnClick = interactionEvent => OnClickEvent(interactionEvent); + entry.OnClick = interactionEvent => OnStatusEntryClick(interactionEvent); return entry; } - - private void OnClickEvent(DtrInteractionEvent interactionEvent) + + private IDtrBarEntry CreateLightfinderEntry() { - if (interactionEvent.ClickType.Equals(MouseClickType.Left) && !interactionEvent.ModifierKeys.Equals(ClickModifierKeys.Shift)) + _logger.LogTrace("Creating Lightfinder DtrBar entry"); + var entry = _dtrBar.Get("Lightfinder"); + entry.OnClick = interactionEvent => OnLightfinderEntryClick(interactionEvent); + return entry; + } + + private void OnStatusEntryClick(DtrInteractionEvent interactionEvent) + { + if (interactionEvent.ClickType.Equals(MouseClickType.Left)) { - _lightlessMediator.Publish(new UiToggleMessage(typeof(CompactUi))); - } - else if (interactionEvent.ClickType.Equals(MouseClickType.Left) && interactionEvent.ModifierKeys.Equals(ClickModifierKeys.Shift)) - { - _lightlessMediator.Publish(new UiToggleMessage(typeof(SettingsUi))); + if (interactionEvent.ModifierKeys.HasFlag(ClickModifierKeys.Shift)) + { + _lightlessMediator.Publish(new UiToggleMessage(typeof(SettingsUi))); + } + else + { + _lightlessMediator.Publish(new UiToggleMessage(typeof(CompactUi))); + } + return; } if (interactionEvent.ClickType.Equals(MouseClickType.Right)) @@ -131,6 +200,17 @@ public sealed class DtrEntry : IDisposable, IHostedService } } + private void OnLightfinderEntryClick(DtrInteractionEvent interactionEvent) + { + if (!_configService.Current.ShowLightfinderInDtr) + return; + + if (interactionEvent.ClickType.Equals(MouseClickType.Left)) + { + _broadcastService.ToggleBroadcast(); + } + } + private async Task RunAsync() { while (!_cancellationTokenSource.IsCancellationRequested) @@ -143,96 +223,278 @@ public sealed class DtrEntry : IDisposable, IHostedService private void Update() { - if (!_configService.Current.EnableDtrEntry || !_configService.Current.HasValidSetup()) - { - if (_entry.IsValueCreated && _entry.Value.Shown) - { - _logger.LogInformation("Disabling entry"); + var config = _configService.Current; - Clear(); - } + if (!config.HasValidSetup()) + { + HideStatusEntry(); + HideLightfinderEntry(); return; } - if (!_entry.Value.Shown) - { - _logger.LogInformation("Showing entry"); - _entry.Value.Shown = true; - } + if (config.EnableDtrEntry) + UpdateStatusEntry(config); + else + HideStatusEntry(); + if (config.ShowLightfinderInDtr) + UpdateLightfinderEntry(config); + else + HideLightfinderEntry(); + } + + private void UpdateStatusEntry(LightlessConfig config) + { string text; string tooltip; Colors colors; + if (_apiController.IsConnected) { var pairCount = _pairManager.GetVisibleUserCount(); text = $"\uE044 {pairCount}"; if (pairCount > 0) { - IEnumerable visiblePairs; - if (_configService.Current.ShowUidInDtrTooltip) - { - visiblePairs = _pairManager.GetOnlineUserPairs() - .Where(x => x.IsVisible) - .Select(x => string.Format("{0} ({1})", _configService.Current.PreferNoteInDtrTooltip ? x.GetNote() ?? x.PlayerName : x.PlayerName, x.UserData.AliasOrUID)); - } - else - { - visiblePairs = _pairManager.GetOnlineUserPairs() - .Where(x => x.IsVisible) - .Select(x => string.Format("{0}", _configService.Current.PreferNoteInDtrTooltip ? x.GetNote() ?? x.PlayerName : x.PlayerName)); - } + var preferNote = config.PreferNoteInDtrTooltip; + var showUid = config.ShowUidInDtrTooltip; + + var visiblePairsQuery = _pairManager.GetOnlineUserPairs() + .Where(x => x.IsVisible); + + IEnumerable visiblePairs = showUid + ? visiblePairsQuery.Select(x => string.Format("{0} ({1})", preferNote ? x.GetNote() ?? x.PlayerName : x.PlayerName, x.UserData.AliasOrUID)) + : visiblePairsQuery.Select(x => string.Format("{0}", preferNote ? x.GetNote() ?? x.PlayerName : x.PlayerName)); tooltip = $"Lightless Sync: Connected{Environment.NewLine}----------{Environment.NewLine}{string.Join(Environment.NewLine, visiblePairs)}"; - colors = _configService.Current.DtrColorsPairsInRange; + colors = config.DtrColorsPairsInRange; } else { tooltip = "Lightless Sync: Connected"; - colors = _configService.Current.DtrColorsDefault; + colors = config.DtrColorsDefault; } } else { text = "\uE044 \uE04C"; tooltip = "Lightless Sync: Not Connected"; - colors = _configService.Current.DtrColorsNotConnected; + colors = config.DtrColorsNotConnected; } - if (!_configService.Current.UseColorsInDtr) + if (!config.UseColorsInDtr) colors = default; - if (!string.Equals(text, _text, StringComparison.Ordinal) || !string.Equals(tooltip, _tooltip, StringComparison.Ordinal) || colors != _colors) + var statusEntry = _statusEntry.Value; + if (!statusEntry.Shown) { - _text = text; - _tooltip = tooltip; - _colors = colors; - _entry.Value.Text = BuildColoredSeString(text, colors); - _entry.Value.Tooltip = tooltip; + _logger.LogInformation("Showing status entry"); + statusEntry.Shown = true; } + + bool statusNeedsUpdate = + !string.Equals(text, _statusText, StringComparison.Ordinal) || + !string.Equals(tooltip, _statusTooltip, StringComparison.Ordinal) || + colors != _statusColors; + + if (statusNeedsUpdate) + { + statusEntry.Text = BuildColoredSeString(text, colors); + statusEntry.Tooltip = tooltip; + _statusText = text; + _statusTooltip = tooltip; + _statusColors = colors; + } + } + + private void UpdateLightfinderEntry(LightlessConfig config) + { + var lightfinderEntry = _lightfinderEntry.Value; + if (!lightfinderEntry.Shown) + { + _logger.LogInformation("Showing Lightfinder entry"); + lightfinderEntry.Shown = true; + } + + var indicator = BuildLightfinderIndicator(); + var lightfinderText = indicator.Text ?? string.Empty; + var lightfinderColors = config.UseLightfinderColorsInDtr ? indicator.Colors : default; + var lightfinderTooltip = BuildLightfinderTooltip(indicator.Tooltip); + + bool lightfinderNeedsUpdate = + !string.Equals(lightfinderText, _lightfinderText, StringComparison.Ordinal) || + !string.Equals(lightfinderTooltip, _lightfinderTooltip, StringComparison.Ordinal) || + lightfinderColors != _lightfinderColors; + + if (lightfinderNeedsUpdate) + { + lightfinderEntry.Text = BuildColoredSeString(lightfinderText, lightfinderColors); + lightfinderEntry.Tooltip = lightfinderTooltip; + _lightfinderText = lightfinderText; + _lightfinderTooltip = lightfinderTooltip; + _lightfinderColors = lightfinderColors; + } + } + + private string? GetLocalHashedCid() + { + var now = DateTime.UtcNow; + if (_localHashedCid is not null && now - _localHashedCidFetchedAt < _localHashedCidCacheDuration) + return _localHashedCid; + + try + { + var cid = _dalamudUtilService.GetCIDAsync().GetAwaiter().GetResult(); + var hashedCid = cid.ToString().GetHash256(); + _localHashedCid = hashedCid; + _localHashedCidFetchedAt = now; + return hashedCid; + } + catch (Exception ex) + { + if (now >= _localHashedCidNextErrorLog) + { + _logger.LogDebug(ex, "Failed to refresh local hashed CID for Lightfinder DTR entry."); + _localHashedCidNextErrorLog = now + _localHashedCidErrorCooldown; + } + + _localHashedCid = null; + _localHashedCidFetchedAt = now; + return null; + } + } + + private int GetNearbyBroadcastCount() + { + var localHashedCid = GetLocalHashedCid(); + return _broadcastScannerService.CountActiveBroadcasts( + string.IsNullOrEmpty(localHashedCid) ? null : localHashedCid); + } + + private int GetPendingPairRequestCount() + { + try + { + return _pairRequestService.GetActiveRequests().Count; + } + catch (Exception ex) + { + var now = DateTime.UtcNow; + if (now >= _pairRequestNextErrorLog) + { + _logger.LogDebug(ex, "Failed to retrieve pair request count for Lightfinder DTR entry."); + _pairRequestNextErrorLog = now + _localHashedCidErrorCooldown; + } + + return 0; + } + } + + private (string Text, Colors Colors, string Tooltip) BuildLightfinderIndicator() + { + var config = _configService.Current; + const string icon = "\uE048"; + if (!_broadcastService.IsLightFinderAvailable) + { + return ($"{icon} --", SwapColorChannels(config.DtrColorsLightfinderUnavailable), "Lightfinder - Unavailable on this server."); + } + + if (_broadcastService.IsBroadcasting) + { + var tooltipBuilder = new StringBuilder("Lightfinder - Enabled"); + + switch (config.LightfinderDtrDisplayMode) + { + case LightfinderDtrDisplayMode.PendingPairRequests: + { + var requestCount = GetPendingPairRequestCount(); + tooltipBuilder.AppendLine(); + tooltipBuilder.Append("Pending pair requests: ").Append(requestCount); + return ($"{icon} Requests {requestCount}", SwapColorChannels(config.DtrColorsLightfinderEnabled), tooltipBuilder.ToString()); + } + default: + { + var broadcastCount = GetNearbyBroadcastCount(); + tooltipBuilder.AppendLine(); + tooltipBuilder.Append("Nearby Lightfinder users: ").Append(broadcastCount); + return ($"{icon} {broadcastCount}", SwapColorChannels(config.DtrColorsLightfinderEnabled), tooltipBuilder.ToString()); + } + } + } + + var tooltip = new StringBuilder("Lightfinder - Disabled"); + var colors = SwapColorChannels(config.DtrColorsLightfinderDisabled); + if (_broadcastService.RemainingCooldown is { } cooldown && cooldown > TimeSpan.Zero) + { + tooltip.AppendLine(); + tooltip.Append("Cooldown: ").Append(Math.Ceiling(cooldown.TotalSeconds)).Append("s"); + colors = SwapColorChannels(config.DtrColorsLightfinderCooldown); + } + + return ($"{icon} OFF", colors, tooltip.ToString()); + } + + private static string BuildLightfinderTooltip(string baseTooltip) + { + var builder = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(baseTooltip)) + builder.Append(baseTooltip.TrimEnd()); + else + builder.Append("Lightfinder status unavailable."); + + return builder.ToString().TrimEnd(); + } + + private static void AppendColoredSegment(SeStringBuilder builder, string? text, Colors colors) + { + if (string.IsNullOrEmpty(text)) + return; + + if (colors.Foreground != default) + builder.Add(BuildColorStartPayload(_colorTypeForeground, colors.Foreground)); + if (colors.Glow != default) + builder.Add(BuildColorStartPayload(_colorTypeGlow, colors.Glow)); + + builder.AddText(text); + + if (colors.Glow != default) + builder.Add(BuildColorEndPayload(_colorTypeGlow)); + if (colors.Foreground != default) + builder.Add(BuildColorEndPayload(_colorTypeForeground)); } #region Colored SeString private const byte _colorTypeForeground = 0x13; private const byte _colorTypeGlow = 0x14; + private static Colors SwapColorChannels(Colors colors) + => new(SwapColorComponent(colors.Foreground), SwapColorComponent(colors.Glow)); + + private static uint SwapColorComponent(uint color) + { + if (color == 0) + return 0; + + return ((color & 0xFFu) << 16) | (color & 0xFF00u) | ((color >> 16) & 0xFFu); + } + private static SeString BuildColoredSeString(string text, Colors colors) { var ssb = new SeStringBuilder(); - if (colors.Foreground != default) - ssb.Add(BuildColorStartPayload(_colorTypeForeground, colors.Foreground)); - if (colors.Glow != default) - ssb.Add(BuildColorStartPayload(_colorTypeGlow, colors.Glow)); - ssb.AddText(text); - if (colors.Glow != default) - ssb.Add(BuildColorEndPayload(_colorTypeGlow)); - if (colors.Foreground != default) - ssb.Add(BuildColorEndPayload(_colorTypeForeground)); + AppendColoredSegment(ssb, text, colors); return ssb.Build(); } private static RawPayload BuildColorStartPayload(byte colorType, uint color) - => new(unchecked([0x02, colorType, 0x05, 0xF6, byte.Max((byte)color, 0x01), byte.Max((byte)(color >> 8), 0x01), byte.Max((byte)(color >> 16), 0x01), 0x03])); + => new(unchecked([ + 0x02, + colorType, + 0x05, + 0xF6, + byte.Max((byte)color, (byte)0x01), + byte.Max((byte)(color >> 8), (byte)0x01), + byte.Max((byte)(color >> 16), (byte)0x01), + 0x03 + ])); private static RawPayload BuildColorEndPayload(byte colorType) => new([0x02, colorType, 0x02, 0xEC, 0x03]); diff --git a/LightlessSync/UI/LightlessNotificationUI.cs b/LightlessSync/UI/LightlessNotificationUI.cs new file mode 100644 index 0000000..c0d5b01 --- /dev/null +++ b/LightlessSync/UI/LightlessNotificationUI.cs @@ -0,0 +1,596 @@ +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 readonly List _notifications = new(); + private readonly object _notificationLock = new(); + private readonly LightlessConfigService _configService; + + 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); + } + 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) + { + 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); + } + } + } + + private void StartOutAnimation(LightlessNotification notification) + { + 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(); + Position = CalculateWindowPosition(viewport); + DrawAllNotifications(); + } + + ImGui.PopStyleVar(); + } + + private Vector2 CalculateWindowPosition(ImGuiViewportPtr viewport) + { + var x = viewport.WorkPos.X + viewport.WorkSize.X - + _configService.Current.NotificationWidth - + _configService.Current.NotificationOffsetX - + WindowPaddingOffset; + var y = viewport.WorkPos.Y + _configService.Current.NotificationOffsetY; + return new Vector2(x, y); + } + + private void DrawAllNotifications() + { + 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)); + } + } + } + + 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) + { + for (int i = _notifications.Count - 1; i >= 0; i--) + { + var notification = _notifications[i]; + UpdateNotificationAnimation(notification, deltaTime); + + if (ShouldRemoveNotification(notification)) + { + _notifications.RemoveAt(i); + } + } + } + + 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 bool ShouldRemoveNotification(LightlessNotification notification) => + notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f; + + private void DrawNotification(LightlessNotification notification, int index) + { + var alpha = notification.AnimationProgress; + if (alpha <= 0f) return; + + var slideOffset = (1f - alpha) * SlideAnimationDistance; + var originalCursorPos = ImGui.GetCursorPos(); + ImGui.SetCursorPosX(originalCursorPos.X + slideOffset); + + var notificationHeight = CalculateNotificationHeight(notification); + var notificationWidth = _configService.Current.NotificationWidth - slideOffset; + + 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) + { + drawList.AddRectFilled( + windowPos, + windowPos + new Vector2(accentWidth, windowSize.Y), + 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 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); + + var titleHeight = DrawTitle(notification, contentSize.X, alpha); + DrawMessage(notification, contentPos, contentSize.X, titleHeight, alpha); + + if (notification.Actions.Count > 0) + { + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + ImGui.GetStyle().ItemSpacing.Y); + ImGui.SetCursorPosX(contentPos.X); + DrawNotificationActions(notification, contentSize.X, alpha); + } + } + + private float DrawTitle(LightlessNotification notification, float contentWidth, float alpha) + { + using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, alpha))) + { + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentWidth); + var titleStartY = ImGui.GetCursorPosY(); + + var titleText = _configService.Current.ShowNotificationTimestamp + ? $"[{notification.CreatedAt.ToLocalTime():HH:mm:ss}] {notification.Title}" + : notification.Title; + + ImGui.TextWrapped(titleText); + var titleHeight = ImGui.GetCursorPosY() - titleStartY; + ImGui.PopTextWrapPos(); + return titleHeight; + } + } + + private void DrawMessage(LightlessNotification notification, Vector2 contentPos, float contentWidth, float titleHeight, float alpha) + { + if (string.IsNullOrEmpty(notification.Message)) return; + + ImGui.SetCursorPos(contentPos + new Vector2(0f, titleHeight + 4f)); + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + contentWidth); + using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, alpha))) + { + ImGui.TextWrapped(notification.Message); + } + ImGui.PopTextWrapPos(); + } + + 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; + 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"), + _ => UIColors.Get("LightlessPurple") + }; + } +} \ No newline at end of file diff --git a/LightlessSync/UI/Models/LightlessNotification.cs b/LightlessSync/UI/Models/LightlessNotification.cs new file mode 100644 index 0000000..3c6edea --- /dev/null +++ b/LightlessSync/UI/Models/LightlessNotification.cs @@ -0,0 +1,32 @@ +using Dalamud.Interface; +using LightlessSync.LightlessConfiguration.Models; +using System.Numerics; +namespace LightlessSync.UI.Models; +public class LightlessNotification +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public NotificationType Type { get; set; } = NotificationType.Info; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public TimeSpan Duration { get; set; } = TimeSpan.FromSeconds(5); + public bool IsExpired => DateTime.UtcNow - CreatedAt > Duration; + public bool IsDismissed { get; set; } = false; + public List Actions { get; set; } = new(); + public bool ShowProgress { get; set; } = false; + public float Progress { get; set; } = 0f; + public float AnimationProgress { get; set; } = 0f; + public bool IsAnimatingIn { get; set; } = true; + public bool IsAnimatingOut { get; set; } = false; + public uint? SoundEffectId { get; set; } = null; +} +public class LightlessNotificationAction +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Label { get; set; } = string.Empty; + public FontAwesomeIcon Icon { get; set; } = FontAwesomeIcon.None; + public Vector4 Color { get; set; } = Vector4.One; + public Action OnClick { get; set; } = _ => { }; + public bool IsPrimary { get; set; } = false; + public bool IsDestructive { get; set; } = false; +} \ No newline at end of file diff --git a/LightlessSync/UI/Models/NotificationSounds.cs b/LightlessSync/UI/Models/NotificationSounds.cs new file mode 100644 index 0000000..fc74256 --- /dev/null +++ b/LightlessSync/UI/Models/NotificationSounds.cs @@ -0,0 +1,72 @@ +using LightlessSync.LightlessConfiguration.Models; + +namespace LightlessSync.UI.Models; + +/// +/// Common FFXIV sound effect IDs for notifications. +/// These correspond to the same sound IDs used in macros (1–16). +/// +public static class NotificationSounds +{ + // ───────────────────────────────────────────── + // Base IDs (1–16) + // https://ffxiv.consolegameswiki.com/wiki/Macros#Sound_Effects + // ───────────────────────────────────────────── + public const uint Se1 = 1; // Soft chime + public const uint Se2 = 2; // Higher chime + public const uint Se3 = 3; // Bell tone + public const uint Se4 = 4; // Harp tone + public const uint Se5 = 5; // Mechanical click + public const uint Se6 = 6; // Drum / percussion + public const uint Se7 = 7; // Metallic chime + public const uint Se8 = 8; // Wooden tone + public const uint Se9 = 9; // Wind / flute tone + public const uint Se10 = 11; // Magical sparkle (ID 10 is skipped in game) + public const uint Se11 = 12; // Metallic ring + public const uint Se12 = 13; // Deep thud + public const uint Se13 = 14; // "Tell received" ping + public const uint Se14 = 15; // Success fanfare + public const uint Se15 = 16; // System warning + // Note: Se16 doesn't exist - Se15 is the last available sound + + /// + /// General notification sound () + /// + public const uint Info = Se2; + + /// + /// Warning/alert sound () + /// + public const uint Warning = Se15; + + /// + /// Error sound ( - System warning, used for errors) + /// + public const uint Error = Se15; + + /// + /// Success sound () + /// + public const uint Success = Se14; + + /// + /// Pair request sound (, same as tell notification) + /// + public const uint PairRequest = Se13; + + /// + /// Download complete sound (, a clean sparkle tone) + /// + public const uint DownloadComplete = Se10; + + /// + /// Get default sound for notification type + /// + public static uint GetDefaultSound(NotificationType type) => type switch + { + NotificationType.Info => Info, + NotificationType.Warning => Warning, + NotificationType.Error => Error, + _ => Info + }; +} diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 84e4e94..6c2fd6e 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -18,6 +18,7 @@ using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; +using LightlessSync.UI.Style; using LightlessSync.Utils; using LightlessSync.UtilsEnum.Enum; using LightlessSync.WebAPI; @@ -44,6 +45,7 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly ApiController _apiController; private readonly CacheMonitor _cacheMonitor; private readonly LightlessConfigService _configService; + private readonly UiThemeConfigService _themeConfigService; private readonly ConcurrentDictionary> _currentDownloads = new(); private readonly DalamudUtilService _dalamudUtilService; private readonly HttpClient _httpClient; @@ -61,6 +63,7 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress; private readonly NameplateService _nameplateService; private readonly NameplateHandler _nameplateHandler; + private readonly NotificationService _lightlessNotificationService; private (int, int, FileCacheEntity) _currentProgress; private bool _deleteAccountPopupModalShown = false; private bool _deleteFilesPopupModalShown = false; @@ -73,17 +76,16 @@ public class SettingsUi : WindowMediatorSubscriberBase private string _lightfinderIconInput = string.Empty; private bool _lightfinderIconInputInitialized = false; private int _lightfinderIconPresetIndex = -1; + private bool _selectGeneralTabOnNextDraw = false; + private bool _openLightfinderSectionOnNextDraw = false; + private static readonly LightlessConfig DefaultConfig = new(); + private static readonly (string Label, SeIconChar Icon)[] LightfinderIconPresets = new[] { - ("Link Marker", SeIconChar.LinkMarker), - ("Hyadelyn", SeIconChar.Hyadelyn), - ("Gil", SeIconChar.Gil), - ("Quest Sync", SeIconChar.QuestSync), - ("Glamoured", SeIconChar.Glamoured), - ("Glamoured (Dyed)", SeIconChar.GlamouredDyed), - ("Auto-Translate Open", SeIconChar.AutoTranslateOpen), - ("Auto-Translate Close", SeIconChar.AutoTranslateClose), - ("Boxed Star", SeIconChar.BoxedStar), + ("Link Marker", SeIconChar.LinkMarker), ("Hyadelyn", SeIconChar.Hyadelyn), ("Gil", SeIconChar.Gil), + ("Quest Sync", SeIconChar.QuestSync), ("Glamoured", SeIconChar.Glamoured), + ("Glamoured (Dyed)", SeIconChar.GlamouredDyed), ("Auto-Translate Open", SeIconChar.AutoTranslateOpen), + ("Auto-Translate Close", SeIconChar.AutoTranslateClose), ("Boxed Star", SeIconChar.BoxedStar), ("Boxed Plus", SeIconChar.BoxedPlus) }; @@ -92,7 +94,7 @@ public class SettingsUi : WindowMediatorSubscriberBase private bool _wasOpen = false; public SettingsUi(ILogger logger, - UiSharedService uiShared, LightlessConfigService configService, + UiSharedService uiShared, LightlessConfigService configService, UiThemeConfigService themeConfigService, PairManager pairManager, ServerConfigurationManager serverConfigurationManager, PlayerPerformanceConfigService playerPerformanceConfigService, @@ -105,9 +107,12 @@ public class SettingsUi : WindowMediatorSubscriberBase IpcManager ipcManager, CacheMonitor cacheMonitor, DalamudUtilService dalamudUtilService, HttpClient httpClient, NameplateService nameplateService, - NameplateHandler nameplateHandler) : base(logger, mediator, "Lightless Sync Settings", performanceCollector) + NameplateHandler nameplateHandler, + NotificationService lightlessNotificationService) : base(logger, mediator, "Lightless Sync Settings", + performanceCollector) { _configService = configService; + _themeConfigService = themeConfigService; _pairManager = pairManager; _serverConfigurationManager = serverConfigurationManager; _playerPerformanceConfigService = playerPerformanceConfigService; @@ -125,22 +130,29 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared = uiShared; _nameplateService = nameplateService; _nameplateHandler = nameplateHandler; + _lightlessNotificationService = lightlessNotificationService; AllowClickthrough = false; AllowPinning = true; _validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v); SizeConstraints = new WindowSizeConstraints() { - MinimumSize = new Vector2(800, 400), - MaximumSize = new Vector2(800, 2000), + MinimumSize = new Vector2(800, 400), MaximumSize = new Vector2(800, 2000), }; Mediator.Subscribe(this, (_) => Toggle()); + Mediator.Subscribe(this, (_) => + { + IsOpen = true; + _selectGeneralTabOnNextDraw = true; + _openLightfinderSectionOnNextDraw = true; + }); Mediator.Subscribe(this, (_) => IsOpen = false); Mediator.Subscribe(this, (_) => UiSharedService_GposeStart()); Mediator.Subscribe(this, (_) => UiSharedService_GposeEnd()); Mediator.Subscribe(this, (msg) => LastCreatedCharacterData = msg.CharacterData); - Mediator.Subscribe(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus); + Mediator.Subscribe(this, + (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus); Mediator.Subscribe(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _)); _nameplateService = nameplateService; } @@ -174,43 +186,369 @@ public class SettingsUi : WindowMediatorSubscriberBase DrawSettingsContent(); } - private static bool InputDtrColors(string label, ref DtrEntry.Colors colors) - { - using var id = ImRaii.PushId(label); - var innerSpacing = ImGui.GetStyle().ItemInnerSpacing.X; - var foregroundColor = ConvertColor(colors.Foreground); - var glowColor = ConvertColor(colors.Glow); + private static Vector3 PackedColorToVector3(uint color) + => new( + (color & 0xFF) / 255f, + ((color >> 8) & 0xFF) / 255f, + ((color >> 16) & 0xFF) / 255f); - var ret = ImGui.ColorEdit3("###foreground", ref foregroundColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.NoLabel | ImGuiColorEditFlags.Uint8); + private static uint Vector3ToPackedColor(Vector3 color) + { + static byte ToByte(float channel) + { + var scaled = MathF.Round(Math.Clamp(channel, 0f, 1f) * 255.0f); + return (byte)Math.Clamp((int)scaled, 0, 255); + } + + var r = ToByte(color.X); + var g = ToByte(color.Y); + var b = ToByte(color.Z); + return (uint)(r | (g << 8) | (b << 16)); + } + + private static bool DrawDtrColorEditors(ref DtrEntry.Colors colors) + { + var innerSpacing = ImGui.GetStyle().ItemInnerSpacing.X; + var foregroundColor = PackedColorToVector3(colors.Foreground); + var glowColor = PackedColorToVector3(colors.Glow); + + const ImGuiColorEditFlags colorFlags = ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.NoLabel; + var changed = ImGui.ColorEdit3("###foreground", ref foregroundColor, colorFlags); if (ImGui.IsItemHovered()) ImGui.SetTooltip("Foreground Color - Set to pure black (#000000) to use the default color"); ImGui.SameLine(0.0f, innerSpacing); - ret |= ImGui.ColorEdit3("###glow", ref glowColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.NoLabel | ImGuiColorEditFlags.Uint8); + changed |= ImGui.ColorEdit3("###glow", ref glowColor, colorFlags); if (ImGui.IsItemHovered()) ImGui.SetTooltip("Glow Color - Set to pure black (#000000) to use the default color"); + if (changed) + colors = new(Vector3ToPackedColor(foregroundColor), Vector3ToPackedColor(glowColor)); + + return changed; + } + + private void DrawDtrColorRow(string id, string label, string description, ref DtrEntry.Colors colors, DtrEntry.Colors defaultDisplay, Action applyConfig) + { + ImGui.TableNextRow(); + + ImGui.TableSetColumnIndex(0); + using (ImRaii.PushId(id)) + { + var edited = DrawDtrColorEditors(ref colors); + ImGui.SameLine(0.0f, ImGui.GetStyle().ItemInnerSpacing.X); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(label); + + if (edited) + { + applyConfig(colors); + _configService.Save(); + } + } + + ImGui.TableSetColumnIndex(1); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(description); + + ImGui.TableSetColumnIndex(2); + using var resetId = ImRaii.PushId($"reset-{id}"); + var availableWidth = ImGui.GetContentRegionAvail().X; + var isDefault = colors == defaultDisplay; + + using (ImRaii.Disabled(isDefault)) + { + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button(FontAwesomeIcon.Undo.ToIconString(), new Vector2(availableWidth, 0))) + { + colors = defaultDisplay; + applyConfig(defaultDisplay); + _configService.Save(); + } + } + } + + UiSharedService.AttachToolTip(isDefault ? "Colors already match the default value." : "Reset these colors to their default values."); + } + + private static bool InputDtrColors(string label, ref DtrEntry.Colors colors) + { + using var id = ImRaii.PushId(label); + var innerSpacing = ImGui.GetStyle().ItemInnerSpacing.X; + var ret = DrawDtrColorEditors(ref colors); + ImGui.SameLine(0.0f, innerSpacing); ImGui.TextUnformatted(label); - if (ret) - colors = new(ConvertBackColor(foregroundColor), ConvertBackColor(glowColor)); - return ret; + } - static Vector3 ConvertColor(uint color) - => unchecked(new((byte)color / 255.0f, (byte)(color >> 8) / 255.0f, (byte)(color >> 16) / 255.0f)); + private static DtrEntry.Colors SwapColorChannels(DtrEntry.Colors colors) + => new(SwapColorChannels(colors.Foreground), SwapColorChannels(colors.Glow)); - static uint ConvertBackColor(Vector3 color) - => byte.CreateSaturating(color.X * 255.0f) | ((uint)byte.CreateSaturating(color.Y * 255.0f) << 8) | ((uint)byte.CreateSaturating(color.Z * 255.0f) << 16); + private static uint SwapColorChannels(uint color) + { + if (color == 0) + return 0; + + return ((color & 0xFFu) << 16) | (color & 0xFF00u) | ((color >> 16) & 0xFFu); + } + + private static Vector4 PackedThemeColorToVector4(uint packed) + => new( + (packed & 0xFF) / 255f, + ((packed >> 8) & 0xFF) / 255f, + ((packed >> 16) & 0xFF) / 255f, + ((packed >> 24) & 0xFF) / 255f); + + private static uint ThemeVector4ToPackedColor(Vector4 color) + { + static byte ToByte(float channel) + { + var scaled = MathF.Round(Math.Clamp(channel, 0f, 1f) * 255.0f); + return (byte)Math.Clamp((int)scaled, 0, 255); + } + + var r = ToByte(color.X); + var g = ToByte(color.Y); + var b = ToByte(color.Z); + var a = ToByte(color.W); + return (uint)(r | (g << 8) | (b << 16) | (a << 24)); + } + + private void UpdateStyleOverride(string key, Action updater) + { + var overrides = _themeConfigService.Current.StyleOverrides; + + if (!overrides.TryGetValue(key, out var entry)) + entry = new UiStyleOverride(); + + updater(entry); + + if (entry.IsEmpty) + overrides.Remove(key); + else + overrides[key] = entry; + + _themeConfigService.Save(); + } + + private void DrawThemeOverridesSection() + { + ImGui.TextUnformatted("Lightless Theme Overrides"); + _uiShared.DrawHelpText("Adjust the Lightless redesign theme. Overrides only apply when the redesign is enabled."); + + if (!_configService.Current.UseLightlessRedesign) + UiSharedService.ColorTextWrapped("The Lightless redesign is currently disabled. Enable it to see these changes take effect.", UIColors.Get("DimRed")); + + const ImGuiTableFlags flags = ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp; + if (!ImGui.BeginTable("##ThemeOverridesTable", 3, flags)) + return; + + ImGui.TableSetupColumn("Element", ImGuiTableColumnFlags.WidthFixed, 325f); + ImGui.TableSetupColumn("Value", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Reset", ImGuiTableColumnFlags.WidthFixed, 70f); + ImGui.TableHeadersRow(); + + DrawThemeCategoryRow("Colors"); + foreach (var option in MainStyle.ColorOptions) + DrawThemeColorRow(option); + + DrawThemeCategoryRow("Spacing & Padding"); + foreach (var option in MainStyle.Vector2Options) + DrawThemeVectorRow(option); + + DrawThemeCategoryRow("Rounding & Sizes"); + foreach (var option in MainStyle.FloatOptions) + DrawThemeFloatRow(option); + + ImGui.EndTable(); + } + + private static void DrawThemeCategoryRow(string label) + { + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.TextColored(UIColors.Get("LightlessPurple"), label); + ImGui.TableSetColumnIndex(1); + ImGui.TableSetColumnIndex(2); + } + + private void DrawThemeColorRow(MainStyle.StyleColorOption option) + { + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.TextUnformatted(option.Label); + bool showTooltip = ImGui.IsItemHovered(); + + var tooltip = string.Empty; + if (!string.IsNullOrEmpty(option.Description)) + tooltip = option.Description; + + var overrides = _themeConfigService.Current.StyleOverrides; + overrides.TryGetValue(option.Key, out var existing); + + if (!string.IsNullOrEmpty(option.UiColorKey)) + { + if (!string.IsNullOrEmpty(tooltip)) + tooltip += "\n"; + tooltip += $"Default uses UIColors[\"{option.UiColorKey}\"]"; + + ImGui.SameLine(); + ImGui.TextDisabled($"(UIColors.{option.UiColorKey})"); + if (ImGui.IsItemHovered()) + showTooltip = true; + } + + ImGui.TableSetColumnIndex(2); + if (DrawStyleResetButton(option.Key, existing?.Color is not null)) + { + UpdateStyleOverride(option.Key, entry => + { + entry.Color = null; + entry.Float = null; + entry.Vector2 = null; + }); + + existing = null; + } + + if (showTooltip && !string.IsNullOrEmpty(tooltip)) + ImGui.SetTooltip(tooltip); + + var defaultColor = MainStyle.NormalizeColorVector(option.DefaultValue()); + var current = existing?.Color is { } packed ? PackedThemeColorToVector4(packed) : defaultColor; + var edit = current; + + ImGui.TableSetColumnIndex(1); + if (ImGui.ColorEdit4($"##theme-color-{option.Key}", ref edit, ImGuiColorEditFlags.AlphaPreviewHalf)) + { + UpdateStyleOverride(option.Key, entry => + { + entry.Color = ThemeVector4ToPackedColor(edit); + entry.Float = null; + entry.Vector2 = null; + }); + } + } + + private void DrawThemeVectorRow(MainStyle.StyleVector2Option option) + { + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.TextUnformatted(option.Label); + if (!string.IsNullOrEmpty(option.Description) && ImGui.IsItemHovered()) + ImGui.SetTooltip(option.Description); + + var overrides = _themeConfigService.Current.StyleOverrides; + overrides.TryGetValue(option.Key, out var existing); + + ImGui.TableSetColumnIndex(2); + if (DrawStyleResetButton(option.Key, existing?.Vector2 is not null)) + { + UpdateStyleOverride(option.Key, entry => + { + entry.Vector2 = null; + entry.Color = null; + entry.Float = null; + }); + existing = null; + } + + var defaultValue = option.DefaultValue(); + var current = existing?.Vector2 is { } vectorOverride ? (Vector2)vectorOverride : defaultValue; + var edit = current; + + ImGui.TableSetColumnIndex(1); + if (ImGui.DragFloat2($"##theme-vector-{option.Key}", ref edit, option.Speed)) + { + if (option.Min is { } min) + edit = Vector2.Max(edit, min); + if (option.Max is { } max) + edit = Vector2.Min(edit, max); + + UpdateStyleOverride(option.Key, entry => + { + entry.Vector2 = new Vector2Config(edit.X, edit.Y); + entry.Color = null; + entry.Float = null; + }); + } + } + + private void DrawThemeFloatRow(MainStyle.StyleFloatOption option) + { + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.TextUnformatted(option.Label); + if (!string.IsNullOrEmpty(option.Description) && ImGui.IsItemHovered()) + ImGui.SetTooltip(option.Description); + + var overrides = _themeConfigService.Current.StyleOverrides; + overrides.TryGetValue(option.Key, out var existing); + + ImGui.TableSetColumnIndex(2); + if (DrawStyleResetButton(option.Key, existing?.Float is not null)) + { + UpdateStyleOverride(option.Key, entry => + { + entry.Float = null; + entry.Color = null; + entry.Vector2 = null; + }); + existing = null; + } + + var current = existing?.Float ?? option.DefaultValue; + var edit = current; + + var min = option.Min ?? float.MinValue; + var max = option.Max ?? float.MaxValue; + + ImGui.TableSetColumnIndex(1); + if (ImGui.DragFloat($"##theme-float-{option.Key}", ref edit, option.Speed, min, max, "%.2f")) + { + if (option.Min.HasValue) + edit = MathF.Max(option.Min.Value, edit); + if (option.Max.HasValue) + edit = MathF.Min(option.Max.Value, edit); + + UpdateStyleOverride(option.Key, entry => + { + entry.Float = edit; + entry.Color = null; + entry.Vector2 = null; + }); + } + } + + private bool DrawStyleResetButton(string key, bool hasOverride, string? tooltipOverride = null) + { + using var id = ImRaii.PushId($"reset-{key}"); + using var disabled = ImRaii.Disabled(!hasOverride); + var availableWidth = ImGui.GetContentRegionAvail().X; + bool pressed = false; + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button(FontAwesomeIcon.Undo.ToIconString(), new Vector2(availableWidth, 0))) + pressed = true; + } + + var tooltip = tooltipOverride ?? (hasOverride + ? "Reset this style override to its default value." + : "Value already matches the default."); + UiSharedService.AttachToolTip(tooltip); + return pressed; } private void DrawBlockedTransfers() { _lastTab = "BlockedTransfers"; - UiSharedService.ColorTextWrapped("Files that you attempted to upload or download that were forbidden to be transferred by their creators will appear here. " + - "If you see file paths from your drive here, then those files were not allowed to be uploaded. If you see hashes, those files were not allowed to be downloaded. " + - "Ask your paired friend to send you the mod in question through other means, acquire the mod yourself or pester the mod creator to allow it to be sent over Lightless.", + UiSharedService.ColorTextWrapped( + "Files that you attempted to upload or download that were forbidden to be transferred by their creators will appear here. " + + "If you see file paths from your drive here, then those files were not allowed to be uploaded. If you see hashes, those files were not allowed to be downloaded. " + + "Ask your paired friend to send you the mod in question through other means, acquire the mod yourself or pester the mod creator to allow it to be sent over Lightless.", ImGuiColors.DalamudGrey); if (ImGui.BeginTable("TransfersTable", 2, ImGuiTableFlags.SizingStretchProp)) @@ -232,9 +570,11 @@ public class SettingsUi : WindowMediatorSubscriberBase { ImGui.TextUnformatted(item.Hash); } + ImGui.TableNextColumn(); ImGui.TextUnformatted(item.ForbiddenBy); } + ImGui.EndTable(); } } @@ -262,6 +602,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); Mediator.Publish(new DownloadLimitChangedMessage()); } + ImGui.SameLine(); ImGui.SetNextItemWidth(100 * ImGuiHelpers.GlobalScale); _uiShared.DrawCombo("###speed", [DownloadSpeeds.Bps, DownloadSpeeds.KBps, DownloadSpeeds.MBps], @@ -287,6 +628,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); Mediator.Publish(new DownloadLimitChangedMessage()); } + _uiShared.DrawHelpText("Controls how many download slots can be active at once."); if (ImGui.SliderInt("Maximum Parallel Uploads", ref maxParallelUploads, 1, 8)) @@ -294,6 +636,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.ParallelUploads = maxParallelUploads; _configService.Save(); } + _uiShared.DrawHelpText("Controls how many uploads can run at once."); _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); @@ -304,7 +647,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); Mediator.Publish(new PairProcessingLimitChangedMessage()); } - _uiShared.DrawHelpText("When enabled we stagger pair downloads to avoid large network and game lag caused by attempting to download everyone at once."); + + _uiShared.DrawHelpText( + "When enabled we stagger pair downloads to avoid large network and game lag caused by attempting to download everyone at once."); var limiterDisabledScope = !limitPairApplications; if (limiterDisabledScope) @@ -318,6 +663,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); Mediator.Publish(new PairProcessingLimitChangedMessage()); } + _uiShared.DrawHelpText("How many pair downloads/applications can run simultaneously when the limit is on."); if (limiterDisabledScope) @@ -330,7 +676,9 @@ public class SettingsUi : WindowMediatorSubscriberBase { var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey; var queueText = $"Pair queue {limiterSnapshot.InFlight}/{limiterSnapshot.Limit}"; - queueText += limiterSnapshot.Waiting > 0 ? $" ({limiterSnapshot.Waiting} waiting, {limiterSnapshot.Remaining} free)" : $" ({limiterSnapshot.Remaining} free)"; + queueText += limiterSnapshot.Waiting > 0 + ? $" ({limiterSnapshot.Waiting} waiting, {limiterSnapshot.Remaining} free)" + : $" ({limiterSnapshot.Remaining} free)"; ImGui.TextColored(queueColor, queueText); } else @@ -345,19 +693,27 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.UseAlternativeFileUpload = useAlternativeUpload; _configService.Save(); } - _uiShared.DrawHelpText("This will attempt to upload files in one go instead of a stream. Typically not necessary to enable. Use if you have upload issues."); + + _uiShared.DrawHelpText( + "This will attempt to upload files in one go instead of a stream. Typically not necessary to enable. Use if you have upload issues."); ImGui.Separator(); _uiShared.UnderlinedBigText("Transfer UI", UIColors.Get("LightlessBlue")); ImGuiHelpers.ScaledDummy(5); + _uiShared.DrawHelpText( + "Download progress notification settings have been moved to the 'Enhanced Notifications' tab for better organization."); + ImGuiHelpers.ScaledDummy(5); + bool showTransferWindow = _configService.Current.ShowTransferWindow; if (ImGui.Checkbox("Show separate transfer window", ref showTransferWindow)) { _configService.Current.ShowTransferWindow = showTransferWindow; _configService.Save(); } - _uiShared.DrawHelpText($"The download window will show the current progress of outstanding downloads.{Environment.NewLine}{Environment.NewLine}" + + + _uiShared.DrawHelpText( + $"The download window will show the current progress of outstanding downloads.{Environment.NewLine}{Environment.NewLine}" + $"What do W/Q/P/D stand for?{Environment.NewLine}W = Waiting for Slot (see Maximum Parallel Downloads){Environment.NewLine}" + $"Q = Queued on Server, waiting for queue ready signal{Environment.NewLine}" + $"P = Processing download (aka downloading){Environment.NewLine}" + @@ -369,6 +725,7 @@ public class SettingsUi : WindowMediatorSubscriberBase { _uiShared.EditTrackerPosition = editTransferWindowPosition; } + ImGui.Unindent(); if (!_configService.Current.ShowTransferWindow) ImGui.EndDisabled(); @@ -378,7 +735,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.ShowTransferBars = showTransferBars; _configService.Save(); } - _uiShared.DrawHelpText("This will render a progress bar during the download at the feet of the player you are downloading from."); + + _uiShared.DrawHelpText( + "This will render a progress bar during the download at the feet of the player you are downloading from."); if (!showTransferBars) ImGui.BeginDisabled(); ImGui.Indent(); @@ -388,6 +747,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.TransferBarsShowText = transferBarShowText; _configService.Save(); } + _uiShared.DrawHelpText("Shows download text (amount of MiB downloaded) in the transfer bars"); int transferBarWidth = _configService.Current.TransferBarsWidth; if (ImGui.SliderInt("Transfer Bar Width", ref transferBarWidth, 10, 500)) @@ -395,21 +755,27 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.TransferBarsWidth = transferBarWidth; _configService.Save(); } - _uiShared.DrawHelpText("Width of the displayed transfer bars (will never be less wide than the displayed text)"); + + _uiShared.DrawHelpText( + "Width of the displayed transfer bars (will never be less wide than the displayed text)"); int transferBarHeight = _configService.Current.TransferBarsHeight; if (ImGui.SliderInt("Transfer Bar Height", ref transferBarHeight, 2, 50)) { _configService.Current.TransferBarsHeight = transferBarHeight; _configService.Save(); } - _uiShared.DrawHelpText("Height of the displayed transfer bars (will never be less tall than the displayed text)"); + + _uiShared.DrawHelpText( + "Height of the displayed transfer bars (will never be less tall than the displayed text)"); bool showUploading = _configService.Current.ShowUploading; if (ImGui.Checkbox("Show 'Uploading' text below players that are currently uploading", ref showUploading)) { _configService.Current.ShowUploading = showUploading; _configService.Save(); } - _uiShared.DrawHelpText("This will render an 'Uploading' text at the feet of the player that is in progress of uploading data."); + + _uiShared.DrawHelpText( + "This will render an 'Uploading' text at the feet of the player that is in progress of uploading data."); ImGui.Unindent(); if (!showUploading) ImGui.BeginDisabled(); @@ -420,6 +786,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.ShowUploadingBigText = showUploadingBigText; _configService.Save(); } + _uiShared.DrawHelpText("This will render an 'Uploading' text in a larger font."); ImGui.Unindent(); @@ -435,30 +802,41 @@ public class SettingsUi : WindowMediatorSubscriberBase using var tree = ImRaii.TreeNode("Speed Test to Servers"); if (tree) { - if (_downloadServersTask == null || ((_downloadServersTask?.IsCompleted ?? false) && (!_downloadServersTask?.IsCompletedSuccessfully ?? false))) + if (_downloadServersTask == null || ((_downloadServersTask?.IsCompleted ?? false) && + (!_downloadServersTask?.IsCompletedSuccessfully ?? false))) { if (_uiShared.IconTextButton(FontAwesomeIcon.GroupArrowsRotate, "Update Download Server List")) { _downloadServersTask = GetDownloadServerList(); } } - if (_downloadServersTask != null && _downloadServersTask.IsCompleted && !_downloadServersTask.IsCompletedSuccessfully) + + if (_downloadServersTask != null && _downloadServersTask.IsCompleted && + !_downloadServersTask.IsCompletedSuccessfully) { - UiSharedService.ColorTextWrapped("Failed to get download servers from service, see /xllog for more information", ImGuiColors.DalamudRed); + UiSharedService.ColorTextWrapped( + "Failed to get download servers from service, see /xllog for more information", + ImGuiColors.DalamudRed); } - if (_downloadServersTask != null && _downloadServersTask.IsCompleted && _downloadServersTask.IsCompletedSuccessfully) + + if (_downloadServersTask != null && _downloadServersTask.IsCompleted && + _downloadServersTask.IsCompletedSuccessfully) { if (_speedTestTask == null || _speedTestTask.IsCompleted) { if (_uiShared.IconTextButton(FontAwesomeIcon.ArrowRight, "Start Speedtest")) { - _speedTestTask = RunSpeedTest(_downloadServersTask.Result!, _speedTestCts?.Token ?? CancellationToken.None); + _speedTestTask = RunSpeedTest(_downloadServersTask.Result!, + _speedTestCts?.Token ?? CancellationToken.None); } } else if (!_speedTestTask.IsCompleted) { - UiSharedService.ColorTextWrapped("Running Speedtest to File Servers...", UIColors.Get("LightlessYellow")); - UiSharedService.ColorTextWrapped("Please be patient, depending on usage and load this can take a while.", UIColors.Get("LightlessYellow")); + UiSharedService.ColorTextWrapped("Running Speedtest to File Servers...", + UIColors.Get("LightlessYellow")); + UiSharedService.ColorTextWrapped( + "Please be patient, depending on usage and load this can take a while.", + UIColors.Get("LightlessYellow")); if (_uiShared.IconTextButton(FontAwesomeIcon.Ban, "Cancel speedtest")) { _speedTestCts?.Cancel(); @@ -466,6 +844,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _speedTestCts = new(); } } + if (_speedTestTask != null && _speedTestTask.IsCompleted) { if (_speedTestTask.Result != null && _speedTestTask.Result.Count != 0) @@ -477,11 +856,13 @@ public class SettingsUi : WindowMediatorSubscriberBase } else { - UiSharedService.ColorTextWrapped("Speedtest completed with no results", UIColors.Get("LightlessYellow")); + UiSharedService.ColorTextWrapped("Speedtest completed with no results", + UIColors.Get("LightlessYellow")); } } } } + ImGuiHelpers.ScaledDummy(10); } @@ -516,6 +897,7 @@ public class SettingsUi : WindowMediatorSubscriberBase { ImGui.TextUnformatted(transfer.Hash); } + ImGui.TableNextColumn(); ImGui.TextUnformatted(UiSharedService.ByteToString(transfer.Transferred)); ImGui.TableNextColumn(); @@ -524,6 +906,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.EndTable(); } + ImGui.Separator(); ImGui.TextUnformatted("Downloads"); if (ImGui.BeginTable("DownloadsTable", 4)) @@ -539,7 +922,8 @@ public class SettingsUi : WindowMediatorSubscriberBase var userName = transfer.Key.Name; foreach (var entry in transfer.Value) { - var color = UiSharedService.UploadColor((entry.Value.TransferredBytes, entry.Value.TotalBytes)); + var color = UiSharedService.UploadColor((entry.Value.TransferredBytes, + entry.Value.TotalBytes)); ImGui.TableNextColumn(); ImGui.TextUnformatted(userName); ImGui.TableNextColumn(); @@ -548,7 +932,8 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.TableNextColumn(); ImGui.TextUnformatted(entry.Value.TransferredFiles + "/" + entry.Value.TotalFiles); ImGui.TableNextColumn(); - ImGui.TextUnformatted(UiSharedService.ByteToString(entry.Value.TransferredBytes) + "/" + UiSharedService.ByteToString(entry.Value.TotalBytes)); + ImGui.TextUnformatted(UiSharedService.ByteToString(entry.Value.TransferredBytes) + "/" + + UiSharedService.ByteToString(entry.Value.TotalBytes)); ImGui.TableNextColumn(); col.Dispose(); ImGui.TableNextRow(); @@ -584,7 +969,9 @@ public class SettingsUi : WindowMediatorSubscriberBase Stopwatch? st = null; try { - result = await _fileTransferOrchestrator.SendRequestAsync(HttpMethod.Get, new Uri(new Uri(server), "speedtest/run"), token, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); + result = await _fileTransferOrchestrator.SendRequestAsync(HttpMethod.Get, + new Uri(new Uri(server), "speedtest/run"), token, HttpCompletionOption.ResponseHeadersRead) + .ConfigureAwait(false); result.EnsureSuccessStatusCode(); using CancellationTokenSource speedtestTimeCts = new(); speedtestTimeCts.CancelAfter(TimeSpan.FromSeconds(10)); @@ -607,8 +994,10 @@ public class SettingsUi : WindowMediatorSubscriberBase { _logger.LogWarning("Speedtest to {server} cancelled", server); } + st.Stop(); - _logger.LogInformation("Downloaded {bytes} from {server} in {time}", UiSharedService.ByteToString(readBytes), server, st.Elapsed); + _logger.LogInformation("Downloaded {bytes} from {server} in {time}", + UiSharedService.ByteToString(readBytes), server, st.Elapsed); var bps = (long)((readBytes) / st.Elapsed.TotalSeconds); speedTestResults.Add($"{server}: ~{UiSharedService.ByteToString(bps)}/s"); } @@ -634,6 +1023,7 @@ public class SettingsUi : WindowMediatorSubscriberBase st?.Stop(); } } + return speedTestResults; } @@ -641,9 +1031,13 @@ public class SettingsUi : WindowMediatorSubscriberBase { try { - var result = await _fileTransferOrchestrator.SendRequestAsync(HttpMethod.Get, new Uri(_fileTransferOrchestrator.FilesCdnUri!, "files/downloadServers"), CancellationToken.None).ConfigureAwait(false); + var result = await _fileTransferOrchestrator.SendRequestAsync(HttpMethod.Get, + new Uri(_fileTransferOrchestrator.FilesCdnUri!, "files/downloadServers"), CancellationToken.None) + .ConfigureAwait(false); result.EnsureSuccessStatusCode(); - return await JsonSerializer.DeserializeAsync>(await result.Content.ReadAsStreamAsync().ConfigureAwait(false)).ConfigureAwait(false); + return await JsonSerializer + .DeserializeAsync>(await result.Content.ReadAsStreamAsync().ConfigureAwait(false)) + .ConfigureAwait(false); } catch (Exception ex) { @@ -661,7 +1055,9 @@ public class SettingsUi : WindowMediatorSubscriberBase #if DEBUG if (LastCreatedCharacterData != null && ImGui.TreeNode("Last created character data")) { - foreach (var l in JsonSerializer.Serialize(LastCreatedCharacterData, new JsonSerializerOptions() { WriteIndented = true }).Split('\n')) + foreach (var l in JsonSerializer + .Serialize(LastCreatedCharacterData, new JsonSerializerOptions() { WriteIndented = true }) + .Split('\n')) { ImGui.TextUnformatted($"{l}"); } @@ -673,13 +1069,15 @@ public class SettingsUi : WindowMediatorSubscriberBase { if (LastCreatedCharacterData != null) { - ImGui.SetClipboardText(JsonSerializer.Serialize(LastCreatedCharacterData, new JsonSerializerOptions() { WriteIndented = true })); + ImGui.SetClipboardText(JsonSerializer.Serialize(LastCreatedCharacterData, + new JsonSerializerOptions() { WriteIndented = true })); } else { ImGui.SetClipboardText("ERROR: No created character data, cannot copy."); } } + UiSharedService.AttachToolTip("Use this when reporting mods being rejected from the server."); _uiShared.DrawCombo("Log Level", Enum.GetValues(), (l) => l.ToString(), (l) => @@ -694,7 +1092,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.LogPerformance = logPerformance; _configService.Save(); } - _uiShared.DrawHelpText("Enabling this can incur a (slight) performance impact. Enabling this for extended periods of time is not recommended."); + + _uiShared.DrawHelpText( + "Enabling this can incur a (slight) performance impact. Enabling this for extended periods of time is not recommended."); using (ImRaii.Disabled(!logPerformance)) { @@ -702,6 +1102,7 @@ public class SettingsUi : WindowMediatorSubscriberBase { _performanceCollector.PrintPerformanceStats(); } + ImGui.SameLine(); if (_uiShared.IconTextButton(FontAwesomeIcon.StickyNote, "Print Performance Stats (last 60s) to /xllog")) { @@ -715,7 +1116,10 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.DebugStopWhining = stopWhining; _configService.Save(); } - _uiShared.DrawHelpText("Having modified game files will still mark your logs with UNSUPPORTED and you will not receive support, message shown or not." + UiSharedService.TooltipSeparator + + _uiShared.DrawHelpText( + "Having modified game files will still mark your logs with UNSUPPORTED and you will not receive support, message shown or not." + + UiSharedService.TooltipSeparator + "Keeping LOD enabled can lead to more crashes. Use at your own risk."); _uiShared.ColoredSeparator(UIColors.Get("LightlessYellow"), 2f); @@ -728,12 +1132,14 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.UnderlinedBigText("Storage", UIColors.Get("LightlessBlue")); ImGuiHelpers.ScaledDummy(5); - UiSharedService.TextWrapped("Lightless stores downloaded files from paired people permanently. This is to improve loading performance and requiring less downloads. " + + UiSharedService.TextWrapped( + "Lightless stores downloaded files from paired people permanently. This is to improve loading performance and requiring less downloads. " + "The storage governs itself by clearing data beyond the set storage size. Please set the storage size accordingly. It is not necessary to manually clear the storage."); _uiShared.DrawFileScanState(); ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("Monitoring Penumbra Folder: " + (_cacheMonitor.PenumbraWatcher?.Path ?? "Not monitoring")); + ImGui.TextUnformatted( + "Monitoring Penumbra Folder: " + (_cacheMonitor.PenumbraWatcher?.Path ?? "Not monitoring")); if (string.IsNullOrEmpty(_cacheMonitor.PenumbraWatcher?.Path)) { ImGui.SameLine(); @@ -745,7 +1151,8 @@ public class SettingsUi : WindowMediatorSubscriberBase } ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("Monitoring Lightless Storage Folder: " + (_cacheMonitor.LightlessWatcher?.Path ?? "Not monitoring")); + ImGui.TextUnformatted("Monitoring Lightless Storage Folder: " + + (_cacheMonitor.LightlessWatcher?.Path ?? "Not monitoring")); if (string.IsNullOrEmpty(_cacheMonitor.LightlessWatcher?.Path)) { ImGui.SameLine(); @@ -755,6 +1162,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _cacheMonitor.StartLightlessWatcher(_configService.Current.CacheFolder); } } + if (_cacheMonitor.LightlessWatcher == null || _cacheMonitor.PenumbraWatcher == null) { if (_uiShared.IconTextButton(FontAwesomeIcon.Play, "Resume Monitoring")) @@ -763,9 +1171,11 @@ public class SettingsUi : WindowMediatorSubscriberBase _cacheMonitor.StartPenumbraWatcher(_ipcManager.Penumbra.ModDirectory); _cacheMonitor.InvokeScan(); } + UiSharedService.AttachToolTip("Attempts to resume monitoring for both Penumbra and Lightless Storage. " - + "Resuming the monitoring will also force a full scan to run." + Environment.NewLine - + "If the button remains present after clicking it, consult /xllog for errors"); + + "Resuming the monitoring will also force a full scan to run." + + Environment.NewLine + + "If the button remains present after clicking it, consult /xllog for errors"); } else { @@ -776,32 +1186,42 @@ public class SettingsUi : WindowMediatorSubscriberBase _cacheMonitor.StopMonitoring(); } } + UiSharedService.AttachToolTip("Stops the monitoring for both Penumbra and Lightless Storage. " - + "Do not stop the monitoring, unless you plan to move the Penumbra and Lightless Storage folders, to ensure correct functionality of Lightless." + Environment.NewLine - + "If you stop the monitoring to move folders around, resume it after you are finished moving the files." - + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); + + "Do not stop the monitoring, unless you plan to move the Penumbra and Lightless Storage folders, to ensure correct functionality of Lightless." + + Environment.NewLine + + "If you stop the monitoring to move folders around, resume it after you are finished moving the files." + + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); } _uiShared.DrawCacheDirectorySetting(); ImGui.AlignTextToFramePadding(); if (_cacheMonitor.FileCacheSize >= 0) - ImGui.TextUnformatted($"Currently utilized local storage: {UiSharedService.ByteToString(_cacheMonitor.FileCacheSize)}"); + ImGui.TextUnformatted( + $"Currently utilized local storage: {UiSharedService.ByteToString(_cacheMonitor.FileCacheSize)}"); else ImGui.TextUnformatted($"Currently utilized local storage: Calculating..."); - ImGui.TextUnformatted($"Remaining space free on drive: {UiSharedService.ByteToString(_cacheMonitor.FileCacheDriveFree)}"); + ImGui.TextUnformatted( + $"Remaining space free on drive: {UiSharedService.ByteToString(_cacheMonitor.FileCacheDriveFree)}"); bool useFileCompactor = _configService.Current.UseCompactor; bool isLinux = _dalamudUtilService.IsWine; if (!useFileCompactor && !isLinux) { - UiSharedService.ColorTextWrapped("Hint: To free up space when using Lightless consider enabling the File Compactor", UIColors.Get("LightlessYellow")); + UiSharedService.ColorTextWrapped( + "Hint: To free up space when using Lightless consider enabling the File Compactor", + UIColors.Get("LightlessYellow")); } + if (isLinux || !_cacheMonitor.StorageisNTFS) ImGui.BeginDisabled(); if (ImGui.Checkbox("Use file compactor", ref useFileCompactor)) { _configService.Current.UseCompactor = useFileCompactor; _configService.Save(); } - _uiShared.DrawHelpText("The file compactor can massively reduce your saved files. It might incur a minor penalty on loading files on a slow CPU." + Environment.NewLine + + _uiShared.DrawHelpText( + "The file compactor can massively reduce your saved files. It might incur a minor penalty on loading files on a slow CPU." + + Environment.NewLine + "It is recommended to leave it enabled to save on space."); ImGui.SameLine(); if (!_fileCompactor.MassCompactRunning) @@ -814,8 +1234,10 @@ public class SettingsUi : WindowMediatorSubscriberBase _cacheMonitor.RecalculateFileCacheSize(CancellationToken.None); }); } - UiSharedService.AttachToolTip("This will run compression on all files in your current Lightless Storage." + Environment.NewLine - + "You do not need to run this manually if you keep the file compactor enabled."); + + UiSharedService.AttachToolTip("This will run compression on all files in your current Lightless Storage." + + Environment.NewLine + + "You do not need to run this manually if you keep the file compactor enabled."); ImGui.SameLine(); if (_uiShared.IconTextButton(FontAwesomeIcon.File, "Decompact all files in storage")) { @@ -825,26 +1247,32 @@ public class SettingsUi : WindowMediatorSubscriberBase _cacheMonitor.RecalculateFileCacheSize(CancellationToken.None); }); } - UiSharedService.AttachToolTip("This will run decompression on all files in your current Lightless Storage."); + + UiSharedService.AttachToolTip( + "This will run decompression on all files in your current Lightless Storage."); } else { - UiSharedService.ColorText($"File compactor currently running ({_fileCompactor.Progress})", UIColors.Get("LightlessYellow")); + UiSharedService.ColorText($"File compactor currently running ({_fileCompactor.Progress})", + UIColors.Get("LightlessYellow")); } + if (isLinux || !_cacheMonitor.StorageisNTFS) { ImGui.EndDisabled(); ImGui.TextUnformatted("The file compactor is only available on Windows and NTFS drives."); } + ImGuiHelpers.ScaledDummy(new Vector2(10, 10)); ImGui.Separator(); if (_uiShared.MediumTreeNode("Storage Validation", UIColors.Get("LightlessYellow"))) { - UiSharedService.TextWrapped("File Storage validation can make sure that all files in your local Lightless Storage are valid. " + - "Run the validation before you clear the Storage for no reason. " + Environment.NewLine + - "This operation, depending on how many files you have in your storage, can take a while and will be CPU and drive intensive."); + UiSharedService.TextWrapped( + "File Storage validation can make sure that all files in your local Lightless Storage are valid. " + + "Run the validation before you clear the Storage for no reason. " + Environment.NewLine + + "This operation, depending on how many files you have in your storage, can take a while and will be CPU and drive intensive."); using (ImRaii.Disabled(_validationTask != null && !_validationTask.IsCompleted)) { if (_uiShared.IconTextButton(FontAwesomeIcon.Check, "Start File Storage Validation")) @@ -853,9 +1281,11 @@ public class SettingsUi : WindowMediatorSubscriberBase _validationCts?.Dispose(); _validationCts = new(); var token = _validationCts.Token; - _validationTask = Task.Run(() => _fileCacheManager.ValidateLocalIntegrity(_validationProgress, token)); + _validationTask = Task.Run(() => + _fileCacheManager.ValidateLocalIntegrity(_validationProgress, token)); } } + if (_validationTask != null && !_validationTask.IsCompleted) { ImGui.SameLine(); @@ -871,12 +1301,14 @@ public class SettingsUi : WindowMediatorSubscriberBase { if (_validationTask.IsCompleted) { - UiSharedService.TextWrapped($"The storage validation has completed and removed {_validationTask.Result.Count} invalid files from storage."); + UiSharedService.TextWrapped( + $"The storage validation has completed and removed {_validationTask.Result.Count} invalid files from storage."); } else { - UiSharedService.TextWrapped($"Storage validation is running: {_currentProgress.Item1}/{_currentProgress.Item2}"); + UiSharedService.TextWrapped( + $"Storage validation is running: {_currentProgress.Item1}/{_currentProgress.Item2}"); UiSharedService.TextWrapped($"Current item: {_currentProgress.Item3.ResolvedFilepath}"); } } @@ -894,12 +1326,15 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Indent(); ImGui.Checkbox("##readClearCache", ref _readClearCache); ImGui.SameLine(); - UiSharedService.TextWrapped("I understand that: " + Environment.NewLine + "- By clearing the local storage I put the file servers of my connected service under extra strain by having to redownload all data." - + Environment.NewLine + "- This is not a step to try to fix sync issues." - + Environment.NewLine + "- This can make the situation of not getting other players data worse in situations of heavy file server load."); + UiSharedService.TextWrapped("I understand that: " + Environment.NewLine + + "- By clearing the local storage I put the file servers of my connected service under extra strain by having to redownload all data." + + Environment.NewLine + "- This is not a step to try to fix sync issues." + + Environment.NewLine + + "- This can make the situation of not getting other players data worse in situations of heavy file server load."); if (!_readClearCache) ImGui.BeginDisabled(); - if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Clear local storage") && UiSharedService.CtrlPressed() && _readClearCache) + if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Clear local storage") && + UiSharedService.CtrlPressed() && _readClearCache) { _ = Task.Run(() => { @@ -916,9 +1351,14 @@ public class SettingsUi : WindowMediatorSubscriberBase } }); } - UiSharedService.AttachToolTip("You normally do not need to do this. THIS IS NOT SOMETHING YOU SHOULD BE DOING TO TRY TO FIX SYNC ISSUES." + Environment.NewLine - + "This will solely remove all downloaded data from all players and will require you to re-download everything again." + Environment.NewLine - + "Lightless storage is self-clearing and will not surpass the limit you have set it to." + Environment.NewLine + + UiSharedService.AttachToolTip( + "You normally do not need to do this. THIS IS NOT SOMETHING YOU SHOULD BE DOING TO TRY TO FIX SYNC ISSUES." + + Environment.NewLine + + "This will solely remove all downloaded data from all players and will require you to re-download everything again." + + Environment.NewLine + + "Lightless storage is self-clearing and will not surpass the limit you have set it to." + + Environment.NewLine + "If you still think you need to do this hold CTRL while pressing the button."); if (!_readClearCache) ImGui.EndDisabled(); @@ -948,8 +1388,11 @@ public class SettingsUi : WindowMediatorSubscriberBase { if (_uiShared.IconTextButton(FontAwesomeIcon.StickyNote, "Export all your user notes to clipboard")) { - ImGui.SetClipboardText(UiSharedService.GetNotes(_pairManager.DirectPairs.UnionBy(_pairManager.GroupPairs.SelectMany(p => p.Value), p => p.UserData, UserDataComparer.Instance).ToList())); + ImGui.SetClipboardText(UiSharedService.GetNotes(_pairManager.DirectPairs + .UnionBy(_pairManager.GroupPairs.SelectMany(p => p.Value), p => p.UserData, + UserDataComparer.Instance).ToList())); } + if (_uiShared.IconTextButton(FontAwesomeIcon.FileImport, "Import notes from clipboard")) { _notesSuccessfullyApplied = null; @@ -959,14 +1402,17 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.SameLine(); ImGui.Checkbox("Overwrite existing notes", ref _overwriteExistingLabels); - _uiShared.DrawHelpText("If this option is selected all already existing notes for UIDs will be overwritten by the imported notes."); + _uiShared.DrawHelpText( + "If this option is selected all already existing notes for UIDs will be overwritten by the imported notes."); if (_notesSuccessfullyApplied.HasValue && _notesSuccessfullyApplied.Value) { UiSharedService.ColorTextWrapped("User Notes successfully imported", UIColors.Get("LightlessBlue")); } else if (_notesSuccessfullyApplied.HasValue && !_notesSuccessfullyApplied.Value) { - UiSharedService.ColorTextWrapped("Attempt to import notes from clipboard failed. Check formatting and try again", ImGuiColors.DalamudRed); + UiSharedService.ColorTextWrapped( + "Attempt to import notes from clipboard failed. Check formatting and try again", + ImGuiColors.DalamudRed); } _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); @@ -983,7 +1429,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.OpenPopupOnAdd = openPopupOnAddition; _configService.Save(); } - _uiShared.DrawHelpText("This will open a popup that allows you to set the notes for a user after successfully adding them to your individual pairs."); + + _uiShared.DrawHelpText( + "This will open a popup that allows you to set the notes for a user after successfully adding them to your individual pairs."); var autoPopulateNotes = _configService.Current.AutoPopulateEmptyNotesFromCharaName; if (ImGui.Checkbox("Automatically populate notes using player names", ref autoPopulateNotes)) @@ -991,7 +1439,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.AutoPopulateEmptyNotesFromCharaName = autoPopulateNotes; _configService.Save(); } - _uiShared.DrawHelpText("This will automatically populate user notes using the first encountered player name if the note was not set prior"); + + _uiShared.DrawHelpText( + "This will automatically populate user notes using the first encountered player name if the note was not set prior"); _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); @@ -1032,6 +1482,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.EnableRightClickMenus = enableRightClickMenu; _configService.Save(); } + _uiShared.DrawHelpText("This will add all Lightless related right click menu entries in the game UI."); if (ImGui.Checkbox("Display status and visible pair count in Server Info Bar", ref enableDtrEntry)) @@ -1039,7 +1490,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.EnableDtrEntry = enableDtrEntry; _configService.Save(); } - _uiShared.DrawHelpText("This will add Lightless connection status and visible pair count in the Server Info Bar.\nYou can further configure this through your Dalamud Settings."); + + _uiShared.DrawHelpText( + "This will add Lightless connection status and visible pair count in the Server Info Bar.\nYou can further configure this through your Dalamud Settings."); using (ImRaii.Disabled(!enableDtrEntry)) { @@ -1064,12 +1517,193 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Separator(); + var forceOpenLightfinder = _openLightfinderSectionOnNextDraw; + if (_openLightfinderSectionOnNextDraw) + { + ImGui.SetNextItemOpen(true, ImGuiCond.Always); + } + if (_uiShared.MediumTreeNode("Lightfinder", UIColors.Get("LightlessPurple"))) { + if (forceOpenLightfinder) + { + ImGui.SetScrollHereY(); + } + + _openLightfinderSectionOnNextDraw = false; + + bool autoEnable = _configService.Current.LightfinderAutoEnableOnConnect; var autoAlign = _configService.Current.LightfinderAutoAlign; var offsetX = (int)_configService.Current.LightfinderLabelOffsetX; var offsetY = (int)_configService.Current.LightfinderLabelOffsetY; var labelScale = _configService.Current.LightfinderLabelScale; + bool showLightfinderInDtr = _configService.Current.ShowLightfinderInDtr; + var dtrLightfinderEnabled = SwapColorChannels(_configService.Current.DtrColorsLightfinderEnabled); + var dtrLightfinderDisabled = SwapColorChannels(_configService.Current.DtrColorsLightfinderDisabled); + var dtrLightfinderCooldown = SwapColorChannels(_configService.Current.DtrColorsLightfinderCooldown); + var dtrLightfinderUnavailable = SwapColorChannels(_configService.Current.DtrColorsLightfinderUnavailable); + + ImGui.TextUnformatted("Connection"); + if (ImGui.Checkbox("Auto-enable Lightfinder on server connection", ref autoEnable)) + { + _configService.Current.LightfinderAutoEnableOnConnect = autoEnable; + _configService.Save(); + } + _uiShared.DrawHelpText("When enabled, Lightfinder will automatically turn on after reconnecting to the Lightless server."); + + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + + ImGui.TextUnformatted("Lightfinder Nameplate Colors"); + if (ImGui.BeginTable("##LightfinderColorTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) + { + ImGui.TableSetupColumn("Color", ImGuiTableColumnFlags.WidthFixed); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Reset", ImGuiTableColumnFlags.WidthFixed, 40f); + ImGui.TableHeadersRow(); + + var lightfinderColors = new (string Key, string Label, string Description)[] + { + ("Lightfinder", "Nameplate Text", "Color used for Lightfinder nameplate text."), + ("LightfinderEdge", "Nameplate Outline", "Outline color applied around Lightfinder nameplate text.") + }; + + foreach (var (key, label, description) in lightfinderColors) + { + ImGui.TableNextRow(); + + ImGui.TableSetColumnIndex(0); + var colorValue = UIColors.Get(key); + if (ImGui.ColorEdit4($"##color_{key}", ref colorValue, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf)) + { + UIColors.Set(key, colorValue); + } + ImGui.SameLine(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(label); + + ImGui.TableSetColumnIndex(1); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(description); + + ImGui.TableSetColumnIndex(2); + using var resetId = ImRaii.PushId($"Reset_{key}"); + var availableWidth = ImGui.GetContentRegionAvail().X; + var isCustom = UIColors.IsCustom(key); + using (ImRaii.Disabled(!isCustom)) + { + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button(FontAwesomeIcon.Undo.ToIconString(), new Vector2(availableWidth, 0))) + { + UIColors.Reset(key); + } + } + } + UiSharedService.AttachToolTip(isCustom ? "Reset this color to default" : "Color is already at default value"); + } + + ImGui.EndTable(); + } + + ImGui.Spacing(); + + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + + ImGui.TextUnformatted("Lightfinder Info Bar"); + if (ImGui.Checkbox("Show Lightfinder status in Server info bar", ref showLightfinderInDtr)) + { + _configService.Current.ShowLightfinderInDtr = showLightfinderInDtr; + _configService.Save(); + } + _uiShared.DrawHelpText("Adds a Lightfinder status to the Server info bar. Left click toggles Lightfinder when visible."); + + var lightfinderDisplayMode = _configService.Current.LightfinderDtrDisplayMode; + var lightfinderDisplayLabel = lightfinderDisplayMode switch + { + LightfinderDtrDisplayMode.PendingPairRequests => "Pending pair requests", + _ => "Nearby Lightfinder users", + }; + + ImGui.BeginDisabled(!showLightfinderInDtr); + if (ImGui.BeginCombo("Info display", lightfinderDisplayLabel)) + { + foreach (var option in Enum.GetValues()) + { + var optionLabel = option switch + { + LightfinderDtrDisplayMode.PendingPairRequests => "Pending pair requests", + _ => "Nearby Lightfinder users", + }; + + var selected = option == lightfinderDisplayMode; + if (ImGui.Selectable(optionLabel, selected)) + { + _configService.Current.LightfinderDtrDisplayMode = option; + _configService.Save(); + } + + if (selected) + ImGui.SetItemDefaultFocus(); + } + + ImGui.EndCombo(); + } + ImGui.EndDisabled(); + _uiShared.DrawHelpText("Choose what the Lightfinder info bar displays while Lightfinder is active."); + + bool useLightfinderColors = _configService.Current.UseLightfinderColorsInDtr; + if (ImGui.Checkbox("Color-code the Lightfinder info bar according to status", ref useLightfinderColors)) + { + _configService.Current.UseLightfinderColorsInDtr = useLightfinderColors; + _configService.Save(); + } + + ImGui.BeginDisabled(!showLightfinderInDtr || !useLightfinderColors); + const ImGuiTableFlags lightfinderInfoTableFlags = ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit; + if (ImGui.BeginTable("##LightfinderInfoBarColorTable", 3, lightfinderInfoTableFlags)) + { + ImGui.TableSetupColumn("Status", ImGuiTableColumnFlags.WidthFixed, 220f); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Reset", ImGuiTableColumnFlags.WidthFixed, 40f); + ImGui.TableHeadersRow(); + + DrawDtrColorRow( + "enabled", + "Enabled", + "Displayed when Lightfinder is active.", + ref dtrLightfinderEnabled, + SwapColorChannels(DefaultConfig.DtrColorsLightfinderEnabled), + value => _configService.Current.DtrColorsLightfinderEnabled = SwapColorChannels(value)); + + DrawDtrColorRow( + "disabled", + "Disabled", + "Shown when Lightfinder is turned off.", + ref dtrLightfinderDisabled, + SwapColorChannels(DefaultConfig.DtrColorsLightfinderDisabled), + value => _configService.Current.DtrColorsLightfinderDisabled = SwapColorChannels(value)); + + DrawDtrColorRow( + "cooldown", + "Cooldown", + "Displayed while Lightfinder is on cooldown.", + ref dtrLightfinderCooldown, + SwapColorChannels(DefaultConfig.DtrColorsLightfinderCooldown), + value => _configService.Current.DtrColorsLightfinderCooldown = SwapColorChannels(value)); + + DrawDtrColorRow( + "unavailable", + "Unavailable", + "Used when Lightfinder is not available on the current server.", + ref dtrLightfinderUnavailable, + SwapColorChannels(DefaultConfig.DtrColorsLightfinderUnavailable), + value => _configService.Current.DtrColorsLightfinderUnavailable = SwapColorChannels(value)); + + ImGui.EndTable(); + } + ImGui.EndDisabled(); + + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); ImGui.TextUnformatted("Alignment"); ImGui.BeginDisabled(autoAlign); @@ -1081,6 +1715,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) { _configService.Current.LightfinderLabelOffsetX = 0; @@ -1089,10 +1724,12 @@ public class SettingsUi : WindowMediatorSubscriberBase _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } + if (ImGui.IsItemHovered()) ImGui.SetTooltip("Right click to reset to default."); ImGui.EndDisabled(); - _uiShared.DrawHelpText("Moves the Lightfinder label horizontally on player nameplates.\nUnavailable when automatic alignment is enabled."); + _uiShared.DrawHelpText( + "Moves the Lightfinder label horizontally on player nameplates.\nUnavailable when automatic alignment is enabled."); if (ImGui.SliderInt("Label Offset Y", ref offsetY, -200, 200)) @@ -1103,6 +1740,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) { _configService.Current.LightfinderLabelOffsetY = 0; @@ -1111,6 +1749,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } + if (ImGui.IsItemHovered()) ImGui.SetTooltip("Right click to reset to default."); _uiShared.DrawHelpText("Moves the Lightfinder label vertically on player nameplates."); @@ -1123,6 +1762,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) { _configService.Current.LightfinderLabelScale = 1.0f; @@ -1131,6 +1771,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } + if (ImGui.IsItemHovered()) ImGui.SetTooltip("Right click to reset to default."); _uiShared.DrawHelpText("Adjusts the Lightfinder label size for both text and icon modes."); @@ -1145,7 +1786,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } - _uiShared.DrawHelpText("Automatically position the label relative to the in-game nameplate. Turn off to rely entirely on manual offsets."); + + _uiShared.DrawHelpText( + "Automatically position the label relative to the in-game nameplate. Turn off to rely entirely on manual offsets."); if (autoAlign) { @@ -1197,6 +1840,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } + _uiShared.DrawHelpText("Toggles your own Lightfinder label."); var showPaired = _configService.Current.LightfinderLabelShowPaired; @@ -1208,8 +1852,20 @@ public class SettingsUi : WindowMediatorSubscriberBase _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } + _uiShared.DrawHelpText("Toggles paired player(s) Lightfinder label."); + var showHidden = _configService.Current.LightfinderLabelShowHidden; + if (ImGui.Checkbox("Show Lightfinder label when no nameplate(s) is visible", ref showHidden)) + { + _configService.Current.LightfinderLabelShowHidden = showHidden; + _configService.Save(); + _nameplateHandler.ClearNameplateCaches(); + _nameplateHandler.FlagRefresh(); + _nameplateService.RequestRedraw(); + } + _uiShared.DrawHelpText("Toggles Lightfinder label when no nameplate(s) is visible."); + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); ImGui.TextUnformatted("Label"); @@ -1232,6 +1888,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _lightfinderIconPresetIndex = -1; } } + _uiShared.DrawHelpText("Switch between the Lightfinder text label and an icon on nameplates."); if (useIcon) @@ -1254,7 +1911,8 @@ public class SettingsUi : WindowMediatorSubscriberBase var selected = i == _lightfinderIconPresetIndex; if (ImGui.Selectable(preview, selected)) { - ApplyLightfinderIcon(optionGlyph, i); + _lightfinderIconInput = NameplateHandler.ToIconEditorString(optionGlyph); + _lightfinderIconPresetIndex = i; } } @@ -1305,7 +1963,8 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.SameLine(); ImGui.AlignTextToFramePadding(); ImGui.Text($"Preview: {previewGlyph}"); - _uiShared.DrawHelpText("Enter a hex code (e.g. E0BB), pick a preset, or paste an icon character directly."); + _uiShared.DrawHelpText( + "Enter a hex code (e.g. E0BB), pick a preset, or paste an icon character directly."); } else { @@ -1329,17 +1988,14 @@ public class SettingsUi : WindowMediatorSubscriberBase ("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"), ("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"), ("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"), - ("LightlessGreen", "Success Green", "Join buttons and success messages"), - ("LightlessYellow", "Warning Yellow", "Warning colors"), ("LightlessYellow2", "Warning Yellow (Alt)", "Warning colors"), - ("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"), - ("DimRed", "Error Red", "Error and offline colors") }; - if (ImGui.BeginTable("##ColorTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) + if (ImGui.BeginTable("##ColorTable", 3, + ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) { ImGui.TableSetupColumn("Color", ImGuiTableColumnFlags.WidthFixed); ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); @@ -1349,30 +2005,32 @@ public class SettingsUi : WindowMediatorSubscriberBase foreach (var (colorKey, displayName, description) in colorNames) { ImGui.TableNextRow(); - + // color column ImGui.TableSetColumnIndex(0); var currentColor = UIColors.Get(colorKey); var colorToEdit = currentColor; - if (ImGui.ColorEdit4($"##color_{colorKey}", ref colorToEdit, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf)) + if (ImGui.ColorEdit4($"##color_{colorKey}", ref colorToEdit, + ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf)) { UIColors.Set(colorKey, colorToEdit); } + ImGui.SameLine(); ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted(displayName); - + // description column ImGui.TableSetColumnIndex(1); ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted(description); - + // actions column ImGui.TableSetColumnIndex(2); using var resetId = ImRaii.PushId($"Reset_{colorKey}"); var availableWidth = ImGui.GetContentRegionAvail().X; var isCustom = UIColors.IsCustom(colorKey); - + using (ImRaii.Disabled(!isCustom)) { using (ImRaii.PushFont(UiBuilder.IconFont)) @@ -1383,9 +2041,12 @@ public class SettingsUi : WindowMediatorSubscriberBase } } } - UiSharedService.AttachToolTip(isCustom ? "Reset this color to default" : "Color is already at default value"); + + UiSharedService.AttachToolTip(isCustom + ? "Reset this color to default" + : "Color is already at default value"); } - + ImGui.EndTable(); } @@ -1394,6 +2055,7 @@ public class SettingsUi : WindowMediatorSubscriberBase { UIColors.ResetAll(); } + _uiShared.DrawHelpText("This will reset all theme colors to their default values"); ImGui.Spacing(); @@ -1407,31 +2069,46 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.UseColorsInDtr = useColorsInDtr; _configService.Save(); } - _uiShared.DrawHelpText("This will color the Server Info Bar entry based on connection status and visible pairs."); - using (ImRaii.Disabled(!useColorsInDtr)) + _uiShared.DrawHelpText( + "This will color the Server Info Bar entry based on connection status and visible pairs."); + + ImGui.BeginDisabled(!useColorsInDtr); + const ImGuiTableFlags serverInfoTableFlags = ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit; + if (ImGui.BeginTable("##ServerInfoBarColorTable", 3, serverInfoTableFlags)) { - using var indent = ImRaii.PushIndent(); - if (InputDtrColors("Default", ref dtrColorsDefault)) - { - _configService.Current.DtrColorsDefault = dtrColorsDefault; - _configService.Save(); - } + ImGui.TableSetupColumn("Status", ImGuiTableColumnFlags.WidthFixed, 220f); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Reset", ImGuiTableColumnFlags.WidthFixed, 40f); + ImGui.TableHeadersRow(); - ImGui.SameLine(); - if (InputDtrColors("Not Connected", ref dtrColorsNotConnected)) - { - _configService.Current.DtrColorsNotConnected = dtrColorsNotConnected; - _configService.Save(); - } + DrawDtrColorRow( + "server-default", + "Default", + "Displayed when connected without any special status.", + ref dtrColorsDefault, + DefaultConfig.DtrColorsDefault, + value => _configService.Current.DtrColorsDefault = value); - ImGui.SameLine(); - if (InputDtrColors("Pairs in Range", ref dtrColorsPairsInRange)) - { - _configService.Current.DtrColorsPairsInRange = dtrColorsPairsInRange; - _configService.Save(); - } + DrawDtrColorRow( + "server-not-connected", + "Not Connected", + "Shown while disconnected from the Lightless server.", + ref dtrColorsNotConnected, + DefaultConfig.DtrColorsNotConnected, + value => _configService.Current.DtrColorsNotConnected = value); + + DrawDtrColorRow( + "server-pairs", + "Pairs in Range", + "Used when nearby paired players are detected.", + ref dtrColorsPairsInRange, + DefaultConfig.DtrColorsPairsInRange, + value => _configService.Current.DtrColorsPairsInRange = value); + + ImGui.EndTable(); } + ImGui.EndDisabled(); ImGui.Spacing(); @@ -1451,6 +2128,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); _nameplateService.RequestRedraw(); } + _uiShared.DrawHelpText("This will override the nameplate colors for visible paired players in-game."); using (ImRaii.Disabled(!nameColorsEnabled)) @@ -1462,18 +2140,21 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); _nameplateService.RequestRedraw(); } + if (ImGui.Checkbox("Override friend color", ref isFriendOverride)) { _configService.Current.overrideFriendColor = isFriendOverride; _configService.Save(); _nameplateService.RequestRedraw(); } + if (ImGui.Checkbox("Override party color", ref isPartyOverride)) { _configService.Current.overridePartyColor = isPartyOverride; _configService.Save(); _nameplateService.RequestRedraw(); } + if (ImGui.Checkbox("Override FC tag color", ref isFcTagOverride)) { _configService.Current.overrideFcTagColor = isFcTagOverride; @@ -1501,8 +2182,11 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.useColoredUIDs = usePairColoredUIDs; _configService.Save(); } + _uiShared.DrawHelpText("This changes the vanity colored UID's in pair list."); + DrawThemeOverridesSection(); + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -1517,7 +2201,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); Mediator.Publish(new RefreshUiMessage()); } - _uiShared.DrawHelpText("This will show all currently visible users in a special 'Visible' group in the main UI."); + + _uiShared.DrawHelpText( + "This will show all currently visible users in a special 'Visible' group in the main UI."); using (ImRaii.Disabled(!showVisibleSeparate)) { @@ -1536,7 +2222,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); Mediator.Publish(new RefreshUiMessage()); } - _uiShared.DrawHelpText("This will show all currently offline users in a special 'Offline' group in the main UI."); + + _uiShared.DrawHelpText( + "This will show all currently offline users in a special 'Offline' group in the main UI."); using (ImRaii.Disabled(!showOfflineSeparate)) { @@ -1555,7 +2243,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); Mediator.Publish(new RefreshUiMessage()); } - _uiShared.DrawHelpText("This will group up all Syncshells in a special 'All Syncshells' folder in the main UI."); + + _uiShared.DrawHelpText( + "This will group up all Syncshells in a special 'All Syncshells' folder in the main UI."); if (ImGui.Checkbox("Show grouped syncshells in main screen/all syncshells", ref groupedSyncshells)) { @@ -1563,6 +2253,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); Mediator.Publish(new RefreshUiMessage()); } + _uiShared.DrawHelpText("This will show grouped syncshells in main screen or group 'All Syncshells'."); if (ImGui.Checkbox("Show player name for visible players", ref showNameInsteadOfNotes)) @@ -1571,7 +2262,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); Mediator.Publish(new RefreshUiMessage()); } - _uiShared.DrawHelpText("This will show the character name instead of custom set note when a character is visible"); + + _uiShared.DrawHelpText( + "This will show the character name instead of custom set note when a character is visible"); ImGui.Indent(); if (!_configService.Current.ShowCharacterNameInsteadOfNotesForVisible) ImGui.BeginDisabled(); @@ -1581,6 +2274,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); Mediator.Publish(new RefreshUiMessage()); } + _uiShared.DrawHelpText("If you set a note for a player it will be shown instead of the player name"); if (!_configService.Current.ShowCharacterNameInsteadOfNotesForVisible) ImGui.EndDisabled(); ImGui.Unindent(); @@ -1590,6 +2284,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.UseFocusTarget = useFocusTarget; _configService.Save(); } + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -1603,6 +2298,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.ProfilesShow = showProfiles; _configService.Save(); } + _uiShared.DrawHelpText("This will show the configured user profile after a set delay"); ImGui.Indent(); if (!showProfiles) ImGui.BeginDisabled(); @@ -1612,12 +2308,14 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); Mediator.Publish(new CompactUiChange(Vector2.Zero, Vector2.Zero)); } + _uiShared.DrawHelpText("Will show profiles on the right side of the main UI"); if (ImGui.SliderFloat("Hover Delay", ref profileDelay, 1, 10)) { _configService.Current.ProfileDelay = profileDelay; _configService.Save(); } + _uiShared.DrawHelpText("Delay until the profile should be displayed"); if (!showProfiles) ImGui.EndDisabled(); ImGui.Unindent(); @@ -1627,104 +2325,23 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Current.ProfilesAllowNsfw = showNsfwProfiles; _configService.Save(); } + _uiShared.DrawHelpText("Will show profiles that have the NSFW tag enabled"); _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } - - ImGui.Separator(); - ImGui.Dummy(new Vector2(10)); - _uiShared.BigText("Notifications"); - - var disableOptionalPluginWarnings = _configService.Current.DisableOptionalPluginWarnings; - var onlineNotifs = _configService.Current.ShowOnlineNotifications; - var onlineNotifsPairsOnly = _configService.Current.ShowOnlineNotificationsOnlyForIndividualPairs; - var onlineNotifsNamedOnly = _configService.Current.ShowOnlineNotificationsOnlyForNamedPairs; - - if (_uiShared.MediumTreeNode("Display", UIColors.Get("LightlessPurple"))) - { - _uiShared.DrawCombo("Info Notification Display##settingsUi", (NotificationLocation[])Enum.GetValues(typeof(NotificationLocation)), (i) => i.ToString(), - (i) => - { - _configService.Current.InfoNotification = i; - _configService.Save(); - }, _configService.Current.InfoNotification); - _uiShared.DrawHelpText("The location where \"Info\" notifications will display." - + Environment.NewLine + "'Nowhere' will not show any Info notifications" - + Environment.NewLine + "'Chat' will print Info notifications in chat" - + Environment.NewLine + "'Toast' will show Warning toast notifications in the bottom right corner" - + Environment.NewLine + "'Both' will show chat as well as the toast notification"); - - _uiShared.DrawCombo("Warning Notification Display##settingsUi", (NotificationLocation[])Enum.GetValues(typeof(NotificationLocation)), (i) => i.ToString(), - (i) => - { - _configService.Current.WarningNotification = i; - _configService.Save(); - }, _configService.Current.WarningNotification); - _uiShared.DrawHelpText("The location where \"Warning\" notifications will display." - + Environment.NewLine + "'Nowhere' will not show any Warning notifications" - + Environment.NewLine + "'Chat' will print Warning notifications in chat" - + Environment.NewLine + "'Toast' will show Warning toast notifications in the bottom right corner" - + Environment.NewLine + "'Both' will show chat as well as the toast notification"); - - _uiShared.DrawCombo("Error Notification Display##settingsUi", (NotificationLocation[])Enum.GetValues(typeof(NotificationLocation)), (i) => i.ToString(), - (i) => - { - _configService.Current.ErrorNotification = i; - _configService.Save(); - }, _configService.Current.ErrorNotification); - _uiShared.DrawHelpText("The location where \"Error\" notifications will display." - + Environment.NewLine + "'Nowhere' will not show any Error notifications" - + Environment.NewLine + "'Chat' will print Error notifications in chat" - + Environment.NewLine + "'Toast' will show Error toast notifications in the bottom right corner" - + Environment.NewLine + "'Both' will show chat as well as the toast notification"); - - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); - ImGui.TreePop(); - } - + ImGui.Separator(); - if (_uiShared.MediumTreeNode("Toggles", UIColors.Get("LightlessPurple"))) - { - if (ImGui.Checkbox("Disable optional plugin warnings", ref disableOptionalPluginWarnings)) - { - _configService.Current.DisableOptionalPluginWarnings = disableOptionalPluginWarnings; - _configService.Save(); - } - _uiShared.DrawHelpText("Enabling this will not show any \"Warning\" labeled messages for missing optional plugins."); - if (ImGui.Checkbox("Enable online notifications", ref onlineNotifs)) - { - _configService.Current.ShowOnlineNotifications = onlineNotifs; - _configService.Save(); - } - _uiShared.DrawHelpText("Enabling this will show a small notification (type: Info) in the bottom right corner when pairs go online."); - - using var disabled = ImRaii.Disabled(!onlineNotifs); - if (ImGui.Checkbox("Notify only for individual pairs", ref onlineNotifsPairsOnly)) - { - _configService.Current.ShowOnlineNotificationsOnlyForIndividualPairs = onlineNotifsPairsOnly; - _configService.Save(); - } - _uiShared.DrawHelpText("Enabling this will only show online notifications (type: Info) for individual pairs."); - if (ImGui.Checkbox("Notify only for named pairs", ref onlineNotifsNamedOnly)) - { - _configService.Current.ShowOnlineNotificationsOnlyForNamedPairs = onlineNotifsNamedOnly; - _configService.Save(); - } - _uiShared.DrawHelpText("Enabling this will only show online notifications (type: Info) for pairs where you have set an individual note."); - - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); - ImGui.TreePop(); - } } private void DrawPerformance() { _uiShared.UnderlinedBigText("Performance Settings", UIColors.Get("LightlessBlue")); ImGui.Dummy(new Vector2(10)); - UiSharedService.TextWrapped("The configuration options here are to give you more informed warnings and automation when it comes to other performance-intensive synced players."); + UiSharedService.TextWrapped( + "The configuration options here are to give you more informed warnings and automation when it comes to other performance-intensive synced players."); bool showPerformanceIndicator = _playerPerformanceConfigService.Current.ShowPerformanceIndicator; @@ -1735,14 +2352,20 @@ public class SettingsUi : WindowMediatorSubscriberBase _playerPerformanceConfigService.Current.ShowPerformanceIndicator = showPerformanceIndicator; _playerPerformanceConfigService.Save(); } - _uiShared.DrawHelpText("Will show a performance indicator when players exceed defined thresholds in Lightless UI." + Environment.NewLine + "Will use warning thresholds."); + + _uiShared.DrawHelpText( + "Will show a performance indicator when players exceed defined thresholds in Lightless UI." + + Environment.NewLine + "Will use warning thresholds."); bool warnOnExceedingThresholds = _playerPerformanceConfigService.Current.WarnOnExceedingThresholds; - if (ImGui.Checkbox("Warn on loading in players exceeding performance thresholds", ref warnOnExceedingThresholds)) + if (ImGui.Checkbox("Warn on loading in players exceeding performance thresholds", + ref warnOnExceedingThresholds)) { _playerPerformanceConfigService.Current.WarnOnExceedingThresholds = warnOnExceedingThresholds; _playerPerformanceConfigService.Save(); } - _uiShared.DrawHelpText("Lightless will print a warning in chat once per session of meeting those people. Will not warn on players with preferred permissions."); + + _uiShared.DrawHelpText( + "Lightless will print a warning in chat once per session of meeting those people. Will not warn on players with preferred permissions."); using (ImRaii.Disabled(!warnOnExceedingThresholds && !showPerformanceIndicator)) { using var indent = ImRaii.PushIndent(); @@ -1752,8 +2375,11 @@ public class SettingsUi : WindowMediatorSubscriberBase _playerPerformanceConfigService.Current.WarnOnPreferredPermissionsExceedingThresholds = warnOnPref; _playerPerformanceConfigService.Save(); } - _uiShared.DrawHelpText("Lightless will also print warnings and show performance indicator for players where you enabled preferred permissions. If warning in general is disabled, this will not produce any warnings."); + + _uiShared.DrawHelpText( + "Lightless will also print warnings and show performance indicator for players where you enabled preferred permissions. If warning in general is disabled, this will not produce any warnings."); } + using (ImRaii.Disabled(!showPerformanceIndicator && !warnOnExceedingThresholds)) { var vram = _playerPerformanceConfigService.Current.VRAMSizeWarningThresholdMiB; @@ -1764,9 +2390,12 @@ public class SettingsUi : WindowMediatorSubscriberBase _playerPerformanceConfigService.Current.VRAMSizeWarningThresholdMiB = vram; _playerPerformanceConfigService.Save(); } + ImGui.SameLine(); ImGui.Text("(MiB)"); - _uiShared.DrawHelpText("Limit in MiB of approximate VRAM usage to trigger warning or performance indicator on UI." + UiSharedService.TooltipSeparator + _uiShared.DrawHelpText( + "Limit in MiB of approximate VRAM usage to trigger warning or performance indicator on UI." + + UiSharedService.TooltipSeparator + "Default: 375 MiB"); ImGui.SetNextItemWidth(100 * ImGuiHelpers.GlobalScale); if (ImGui.InputInt("Warning Triangle threshold", ref tris)) @@ -1774,9 +2403,12 @@ public class SettingsUi : WindowMediatorSubscriberBase _playerPerformanceConfigService.Current.TrisWarningThresholdThousands = tris; _playerPerformanceConfigService.Save(); } + ImGui.SameLine(); ImGui.Text("(thousand triangles)"); - _uiShared.DrawHelpText("Limit in approximate used triangles from mods to trigger warning or performance indicator on UI." + UiSharedService.TooltipSeparator + _uiShared.DrawHelpText( + "Limit in approximate used triangles from mods to trigger warning or performance indicator on UI." + + UiSharedService.TooltipSeparator + "Default: 165 thousand"); } @@ -1787,7 +2419,8 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Separator(); bool autoPause = _playerPerformanceConfigService.Current.AutoPausePlayersExceedingThresholds; - bool autoPauseEveryone = _playerPerformanceConfigService.Current.AutoPausePlayersWithPreferredPermissionsExceedingThresholds; + bool autoPauseEveryone = _playerPerformanceConfigService.Current + .AutoPausePlayersWithPreferredPermissionsExceedingThresholds; bool autoPauseInDuty = _playerPerformanceConfigService.Current.PauseInInstanceDuty; bool autoPauseInCombat = _playerPerformanceConfigService.Current.PauseInCombat; bool autoPauseWhilePerforming = _playerPerformanceConfigService.Current.PauseWhilePerforming; @@ -1799,40 +2432,59 @@ public class SettingsUi : WindowMediatorSubscriberBase _playerPerformanceConfigService.Current.PauseInCombat = autoPauseInCombat; _playerPerformanceConfigService.Save(); } - _uiShared.DrawHelpText("AUTO-ENABLED: Your risk of crashing during a fight increases when this is disabled. For example: VFX mods Loading mid fight can cause a crash." + Environment.NewLine + + _uiShared.DrawHelpText( + "AUTO-ENABLED: Your risk of crashing during a fight increases when this is disabled. For example: VFX mods Loading mid fight can cause a crash." + + Environment.NewLine + UiSharedService.TooltipSeparator + "WARNING: DISABLE AT YOUR OWN RISK."); if (ImGui.Checkbox("Auto pause sync while in Perfomance as Bard", ref autoPauseWhilePerforming)) { _playerPerformanceConfigService.Current.PauseWhilePerforming = autoPauseWhilePerforming; _playerPerformanceConfigService.Save(); } - _uiShared.DrawHelpText("AUTO-ENABLED: Your risk of crashing during a performance increases when this is disabled. For example: Some mods can crash you mid performance" + Environment.NewLine + + _uiShared.DrawHelpText( + "AUTO-ENABLED: Your risk of crashing during a performance increases when this is disabled. For example: Some mods can crash you mid performance" + + Environment.NewLine + UiSharedService.TooltipSeparator + "WARNING: DISABLE AT YOUR OWN RISK."); if (ImGui.Checkbox("Auto pause sync while in instances and duties", ref autoPauseInDuty)) { _playerPerformanceConfigService.Current.PauseInInstanceDuty = autoPauseInDuty; _playerPerformanceConfigService.Save(); } - _uiShared.DrawHelpText("When enabled, it will automatically pause all players while you are in an instance, such as a dungeon or raid." + Environment.NewLine - + UiSharedService.TooltipSeparator + "Warning: You may have to leave the dungeon to resync with people again"); + + _uiShared.DrawHelpText( + "When enabled, it will automatically pause all players while you are in an instance, such as a dungeon or raid." + + Environment.NewLine + + UiSharedService.TooltipSeparator + + "Warning: You may have to leave the dungeon to resync with people again"); if (ImGui.Checkbox("Automatically pause players exceeding thresholds", ref autoPause)) { _playerPerformanceConfigService.Current.AutoPausePlayersExceedingThresholds = autoPause; _playerPerformanceConfigService.Save(); } - _uiShared.DrawHelpText("When enabled, it will automatically pause all players without preferred permissions that exceed the thresholds defined below." + Environment.NewLine + + _uiShared.DrawHelpText( + "When enabled, it will automatically pause all players without preferred permissions that exceed the thresholds defined below." + + Environment.NewLine + "Will print a warning in chat when a player got paused automatically." - + UiSharedService.TooltipSeparator + "Warning: this will not automatically unpause those people again, you will have to do this manually."); + + UiSharedService.TooltipSeparator + + "Warning: this will not automatically unpause those people again, you will have to do this manually."); using (ImRaii.Disabled(!autoPause)) { using var indent = ImRaii.PushIndent(); - if (ImGui.Checkbox("Automatically pause also players with preferred permissions", ref autoPauseEveryone)) + if (ImGui.Checkbox("Automatically pause also players with preferred permissions", + ref autoPauseEveryone)) { - _playerPerformanceConfigService.Current.AutoPausePlayersWithPreferredPermissionsExceedingThresholds = autoPauseEveryone; + _playerPerformanceConfigService.Current + .AutoPausePlayersWithPreferredPermissionsExceedingThresholds = autoPauseEveryone; _playerPerformanceConfigService.Save(); } - _uiShared.DrawHelpText("When enabled, will automatically pause all players regardless of preferred permissions that exceed thresholds defined below." + UiSharedService.TooltipSeparator + + + _uiShared.DrawHelpText( + "When enabled, will automatically pause all players regardless of preferred permissions that exceed thresholds defined below." + + UiSharedService.TooltipSeparator + "Warning: this will not automatically unpause those people again, you will have to do this manually."); var vramAuto = _playerPerformanceConfigService.Current.VRAMSizeAutoPauseThresholdMiB; var trisAuto = _playerPerformanceConfigService.Current.TrisAutoPauseThresholdThousands; @@ -1842,9 +2494,12 @@ public class SettingsUi : WindowMediatorSubscriberBase _playerPerformanceConfigService.Current.VRAMSizeAutoPauseThresholdMiB = vramAuto; _playerPerformanceConfigService.Save(); } + ImGui.SameLine(); ImGui.Text("(MiB)"); - _uiShared.DrawHelpText("When a loading in player and their VRAM usage exceeds this amount, automatically pauses the synced player." + UiSharedService.TooltipSeparator + _uiShared.DrawHelpText( + "When a loading in player and their VRAM usage exceeds this amount, automatically pauses the synced player." + + UiSharedService.TooltipSeparator + "Default: 550 MiB"); ImGui.SetNextItemWidth(100 * ImGuiHelpers.GlobalScale); if (ImGui.InputInt("Auto Pause Triangle threshold", ref trisAuto)) @@ -1852,9 +2507,12 @@ public class SettingsUi : WindowMediatorSubscriberBase _playerPerformanceConfigService.Current.TrisAutoPauseThresholdThousands = trisAuto; _playerPerformanceConfigService.Save(); } + ImGui.SameLine(); ImGui.Text("(thousand triangles)"); - _uiShared.DrawHelpText("When a loading in player and their triangle count exceeds this amount, automatically pauses the synced player." + UiSharedService.TooltipSeparator + _uiShared.DrawHelpText( + "When a loading in player and their triangle count exceeds this amount, automatically pauses the synced player." + + UiSharedService.TooltipSeparator + "Default: 250 thousand"); } @@ -1868,7 +2526,8 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.UnderlinedBigText("Whitelisted UIDs", UIColors.Get("LightlessBlue")); ImGuiHelpers.ScaledDummy(5); - UiSharedService.TextWrapped("The entries in the list below will be ignored for all warnings and auto pause operations."); + UiSharedService.TextWrapped( + "The entries in the list below will be ignored for all warnings and auto pause operations."); ImGui.Dummy(new Vector2(10)); ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); ImGui.InputText("##ignoreuid", ref _uidToAddForIgnore, 20); @@ -1877,14 +2536,17 @@ public class SettingsUi : WindowMediatorSubscriberBase { if (_uiShared.IconTextButton(FontAwesomeIcon.Plus, "Add UID/Vanity ID to whitelist")) { - if (!_playerPerformanceConfigService.Current.UIDsToIgnore.Contains(_uidToAddForIgnore, StringComparer.Ordinal)) + if (!_playerPerformanceConfigService.Current.UIDsToIgnore.Contains(_uidToAddForIgnore, + StringComparer.Ordinal)) { _playerPerformanceConfigService.Current.UIDsToIgnore.Add(_uidToAddForIgnore); _playerPerformanceConfigService.Save(); } + _uidToAddForIgnore = string.Empty; } } + _uiShared.DrawHelpText("Hint: UIDs are case sensitive."); var playerList = _playerPerformanceConfigService.Current.UIDsToIgnore; ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); @@ -1902,6 +2564,7 @@ public class SettingsUi : WindowMediatorSubscriberBase } } } + using (ImRaii.Disabled(_selectedEntry == -1)) { if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Delete selected UID")) @@ -1932,7 +2595,8 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText("Completely deletes all your uploaded files on the service."); - if (ImGui.BeginPopupModal("Delete all your files?", ref _deleteFilesPopupModalShown, UiSharedService.PopupWindowFlags)) + if (ImGui.BeginPopupModal("Delete all your files?", ref _deleteFilesPopupModalShown, + UiSharedService.PopupWindowFlags)) { UiSharedService.TextWrapped( "All your own uploaded files on the service will be deleted.\nThis operation cannot be undone."); @@ -1941,7 +2605,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Spacing(); var buttonSize = (ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X - - ImGui.GetStyle().ItemSpacing.X) / 2; + ImGui.GetStyle().ItemSpacing.X) / 2; if (ImGui.Button("Delete everything", new Vector2(buttonSize, 0))) { @@ -1959,6 +2623,7 @@ public class SettingsUi : WindowMediatorSubscriberBase UiSharedService.SetScaledWindowSize(325); ImGui.EndPopup(); } + ImGui.SameLine(); if (ImGui.Button("Delete account")) { @@ -1968,7 +2633,8 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText("Completely deletes your account and all uploaded files to the service."); - if (ImGui.BeginPopupModal("Delete your account?", ref _deleteAccountPopupModalShown, UiSharedService.PopupWindowFlags)) + if (ImGui.BeginPopupModal("Delete your account?", ref _deleteAccountPopupModalShown, + UiSharedService.PopupWindowFlags)) { UiSharedService.TextWrapped( "Your account and all associated files and data on the service will be deleted."); @@ -2015,14 +2681,18 @@ public class SettingsUi : WindowMediatorSubscriberBase { _serverConfigurationManager.SendCensusData = sendCensus; } - _uiShared.DrawHelpText("This will allow sending census data to the currently connected service." + UiSharedService.TooltipSeparator - + "Census data contains:" + Environment.NewLine - + "- Current World" + Environment.NewLine - + "- Current Gender" + Environment.NewLine - + "- Current Race" + Environment.NewLine - + "- Current Clan (this is not your Free Company, this is e.g. Keeper or Seeker for Miqo'te)" + UiSharedService.TooltipSeparator - + "The census data is only saved temporarily and will be removed from the server on disconnect. It is stored temporarily associated with your UID while you are connected." + UiSharedService.TooltipSeparator - + "If you do not wish to participate in the statistical census, untick this box and reconnect to the server."); + + _uiShared.DrawHelpText("This will allow sending census data to the currently connected service." + + UiSharedService.TooltipSeparator + + "Census data contains:" + Environment.NewLine + + "- Current World" + Environment.NewLine + + "- Current Gender" + Environment.NewLine + + "- Current Race" + Environment.NewLine + + "- Current Clan (this is not your Free Company, this is e.g. Keeper or Seeker for Miqo'te)" + + UiSharedService.TooltipSeparator + + "The census data is only saved temporarily and will be removed from the server on disconnect. It is stored temporarily associated with your UID while you are connected." + + UiSharedService.TooltipSeparator + + "If you do not wish to participate in the statistical census, untick this box and reconnect to the server."); ImGuiHelpers.ScaledDummy(new Vector2(10, 10)); var idx = _uiShared.DrawServiceSelection(); @@ -2039,7 +2709,9 @@ public class SettingsUi : WindowMediatorSubscriberBase var selectedServer = _serverConfigurationManager.GetServerByIndex(idx); if (selectedServer == _serverConfigurationManager.CurrentServer) { - UiSharedService.ColorTextWrapped("For any changes to be applied to the current service you need to reconnect to the service.", UIColors.Get("LightlessYellow")); + UiSharedService.ColorTextWrapped( + "For any changes to be applied to the current service you need to reconnect to the service.", + UIColors.Get("LightlessYellow")); } bool useOauth = selectedServer.UseOAuth2; @@ -2050,30 +2722,41 @@ public class SettingsUi : WindowMediatorSubscriberBase { if (selectedServer.SecretKeys.Any() || useOauth) { - UiSharedService.ColorTextWrapped("Characters listed here will automatically connect to the selected Lightless service with the settings as provided below." + - " Make sure to enter the character names correctly or use the 'Add current character' button at the bottom.", UIColors.Get("LightlessYellow")); + UiSharedService.ColorTextWrapped( + "Characters listed here will automatically connect to the selected Lightless service with the settings as provided below." + + " Make sure to enter the character names correctly or use the 'Add current character' button at the bottom.", + UIColors.Get("LightlessYellow")); int i = 0; _uiShared.DrawUpdateOAuthUIDsButton(selectedServer); if (selectedServer.UseOAuth2 && !string.IsNullOrEmpty(selectedServer.OAuthToken)) { - bool hasSetSecretKeysButNoUid = selectedServer.Authentications.Exists(u => u.SecretKeyIdx != -1 && string.IsNullOrEmpty(u.UID)); + bool hasSetSecretKeysButNoUid = + selectedServer.Authentications.Exists(u => + u.SecretKeyIdx != -1 && string.IsNullOrEmpty(u.UID)); if (hasSetSecretKeysButNoUid) { ImGui.Dummy(new(5f, 5f)); - UiSharedService.TextWrapped("Some entries have been detected that have previously been assigned secret keys but not UIDs. " + + UiSharedService.TextWrapped( + "Some entries have been detected that have previously been assigned secret keys but not UIDs. " + "Press this button below to attempt to convert those entries."); - using (ImRaii.Disabled(_secretKeysConversionTask != null && !_secretKeysConversionTask.IsCompleted)) + using (ImRaii.Disabled(_secretKeysConversionTask != null && + !_secretKeysConversionTask.IsCompleted)) { - if (_uiShared.IconTextButton(FontAwesomeIcon.ArrowsLeftRight, "Try to Convert Secret Keys to UIDs")) + if (_uiShared.IconTextButton(FontAwesomeIcon.ArrowsLeftRight, + "Try to Convert Secret Keys to UIDs")) { - _secretKeysConversionTask = ConvertSecretKeysToUIDs(selectedServer, _secretKeysConversionCts.Token); + _secretKeysConversionTask = + ConvertSecretKeysToUIDs(selectedServer, _secretKeysConversionCts.Token); } } + if (_secretKeysConversionTask != null && !_secretKeysConversionTask.IsCompleted) { - UiSharedService.ColorTextWrapped("Converting Secret Keys to UIDs", UIColors.Get("LightlessYellow")); + UiSharedService.ColorTextWrapped("Converting Secret Keys to UIDs", + UIColors.Get("LightlessYellow")); } + if (_secretKeysConversionTask != null && _secretKeysConversionTask.IsCompletedSuccessfully) { Vector4? textColor = null; @@ -2081,10 +2764,12 @@ public class SettingsUi : WindowMediatorSubscriberBase { textColor = UIColors.Get("LightlessYellow"); } + if (!_secretKeysConversionTask.Result.Success) { textColor = ImGuiColors.DalamudRed; } + string text = $"Conversion has completed: {_secretKeysConversionTask.Result.Result}"; if (textColor == null) { @@ -2094,49 +2779,70 @@ public class SettingsUi : WindowMediatorSubscriberBase { UiSharedService.ColorTextWrapped(text, textColor!.Value); } - if (!_secretKeysConversionTask.Result.Success || _secretKeysConversionTask.Result.PartialSuccess) + + if (!_secretKeysConversionTask.Result.Success || + _secretKeysConversionTask.Result.PartialSuccess) { - UiSharedService.TextWrapped("In case of conversion failures, please set the UIDs for the failed conversions manually."); + UiSharedService.TextWrapped( + "In case of conversion failures, please set the UIDs for the failed conversions manually."); } } } } + ImGui.Separator(); string youName = _dalamudUtilService.GetPlayerName(); uint youWorld = _dalamudUtilService.GetHomeWorldId(); ulong youCid = _dalamudUtilService.GetCID(); - if (!selectedServer.Authentications.Exists(a => string.Equals(a.CharacterName, youName, StringComparison.Ordinal) && a.WorldId == youWorld)) + if (!selectedServer.Authentications.Exists(a => + string.Equals(a.CharacterName, youName, StringComparison.Ordinal) && a.WorldId == youWorld)) { _uiShared.BigText("Your Character is not Configured", ImGuiColors.DalamudRed); - UiSharedService.ColorTextWrapped("You have currently no character configured that corresponds to your current name and world.", ImGuiColors.DalamudRed); + UiSharedService.ColorTextWrapped( + "You have currently no character configured that corresponds to your current name and world.", + ImGuiColors.DalamudRed); var authWithCid = selectedServer.Authentications.Find(f => f.LastSeenCID == youCid); if (authWithCid != null) { ImGuiHelpers.ScaledDummy(5); - UiSharedService.ColorText("A potential rename/world change from this character was detected:", UIColors.Get("LightlessYellow")); + UiSharedService.ColorText( + "A potential rename/world change from this character was detected:", + UIColors.Get("LightlessYellow")); using (ImRaii.PushIndent(10f)) - UiSharedService.ColorText("Entry: " + authWithCid.CharacterName + " - " + _dalamudUtilService.WorldData.Value[(ushort)authWithCid.WorldId], UIColors.Get("LightlessBlue")); - UiSharedService.ColorText("Press the button below to adjust that entry to your current character:", UIColors.Get("LightlessYellow")); + UiSharedService.ColorText( + "Entry: " + authWithCid.CharacterName + " - " + + _dalamudUtilService.WorldData.Value[(ushort)authWithCid.WorldId], + UIColors.Get("LightlessBlue")); + UiSharedService.ColorText( + "Press the button below to adjust that entry to your current character:", + UIColors.Get("LightlessYellow")); using (ImRaii.PushIndent(10f)) - UiSharedService.ColorText("Current: " + youName + " - " + _dalamudUtilService.WorldData.Value[(ushort)youWorld], UIColors.Get("LightlessBlue")); + UiSharedService.ColorText( + "Current: " + youName + " - " + + _dalamudUtilService.WorldData.Value[(ushort)youWorld], + UIColors.Get("LightlessBlue")); ImGuiHelpers.ScaledDummy(5); - if (_uiShared.IconTextButton(FontAwesomeIcon.ArrowRight, "Update Entry to Current Character")) + if (_uiShared.IconTextButton(FontAwesomeIcon.ArrowRight, + "Update Entry to Current Character")) { authWithCid.CharacterName = youName; authWithCid.WorldId = youWorld; _serverConfigurationManager.Save(); } } + ImGuiHelpers.ScaledDummy(5); ImGui.Separator(); ImGuiHelpers.ScaledDummy(5); } + foreach (var item in selectedServer.Authentications.ToList()) { using var charaId = ImRaii.PushId("selectedChara" + i); var worldIdx = (ushort)item.WorldId; - var data = _uiShared.WorldData.OrderBy(u => u.Value, StringComparer.Ordinal).ToDictionary(k => k.Key, k => k.Value); + var data = _uiShared.WorldData.OrderBy(u => u.Value, StringComparer.Ordinal) + .ToDictionary(k => k.Key, k => k.Value); if (!data.TryGetValue(worldIdx, out string? worldPreview)) { worldPreview = data.First().Value; @@ -2160,24 +2866,31 @@ public class SettingsUi : WindowMediatorSubscriberBase { thisIsYou = true; } + bool misManaged = false; - if (selectedServer.UseOAuth2 && !string.IsNullOrEmpty(selectedServer.OAuthToken) && string.IsNullOrEmpty(item.UID)) + if (selectedServer.UseOAuth2 && !string.IsNullOrEmpty(selectedServer.OAuthToken) && + string.IsNullOrEmpty(item.UID)) { misManaged = true; } + if (!selectedServer.UseOAuth2 && item.SecretKeyIdx == -1) { misManaged = true; } + Vector4 color = UIColors.Get("LightlessBlue"); string text = thisIsYou ? "Your Current Character" : string.Empty; if (misManaged) { - text += " [MISMANAGED (" + (selectedServer.UseOAuth2 ? "No UID Set" : "No Secret Key Set") + ")]"; + text += " [MISMANAGED (" + (selectedServer.UseOAuth2 ? "No UID Set" : "No Secret Key Set") + + ")]"; color = ImGuiColors.DalamudRed; } - if (selectedServer.Authentications.Where(e => e != item).Any(e => string.Equals(e.CharacterName, item.CharacterName, StringComparison.Ordinal) - && e.WorldId == item.WorldId)) + + if (selectedServer.Authentications.Where(e => e != item).Any(e => + string.Equals(e.CharacterName, item.CharacterName, StringComparison.Ordinal) + && e.WorldId == item.WorldId)) { text += " [DUPLICATE]"; color = ImGuiColors.DalamudRed; @@ -2204,11 +2917,16 @@ public class SettingsUi : WindowMediatorSubscriberBase item.WorldId = w.Key; _serverConfigurationManager.Save(); } - }, EqualityComparer>.Default.Equals(data.FirstOrDefault(f => f.Key == worldIdx), default) ? data.First() : data.First(f => f.Key == worldIdx)); + }, + EqualityComparer>.Default.Equals( + data.FirstOrDefault(f => f.Key == worldIdx), default) + ? data.First() + : data.First(f => f.Key == worldIdx)); if (!useOauth) { - _uiShared.DrawCombo("Secret Key###" + item.CharacterName + i, keys, (w) => w.Value.FriendlyName, + _uiShared.DrawCombo("Secret Key###" + item.CharacterName + i, keys, + (w) => w.Value.FriendlyName, (w) => { if (w.Key != item.SecretKeyIdx) @@ -2216,20 +2934,28 @@ public class SettingsUi : WindowMediatorSubscriberBase item.SecretKeyIdx = w.Key; _serverConfigurationManager.Save(); } - }, EqualityComparer>.Default.Equals(keys.FirstOrDefault(f => f.Key == item.SecretKeyIdx), default) ? keys.First() : keys.First(f => f.Key == item.SecretKeyIdx)); + }, + EqualityComparer>.Default.Equals( + keys.FirstOrDefault(f => f.Key == item.SecretKeyIdx), default) + ? keys.First() + : keys.First(f => f.Key == item.SecretKeyIdx)); } else { _uiShared.DrawUIDComboForAuthentication(i, item, selectedServer.ServerUri, _logger); } + bool isAutoLogin = item.AutoLogin; if (ImGui.Checkbox("Automatically login to Lightless", ref isAutoLogin)) { item.AutoLogin = isAutoLogin; _serverConfigurationManager.Save(); } - _uiShared.DrawHelpText("When enabled and logging into this character in XIV, Lightless will automatically connect to the current service."); - if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Delete Character") && UiSharedService.CtrlPressed()) + + _uiShared.DrawHelpText( + "When enabled and logging into this character in XIV, Lightless will automatically connect to the current service."); + if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Delete Character") && + UiSharedService.CtrlPressed()) _serverConfigurationManager.RemoveCharacterFromServer(idx, item); UiSharedService.AttachToolTip("Hold CTRL to delete this entry."); @@ -2245,13 +2971,15 @@ public class SettingsUi : WindowMediatorSubscriberBase if (selectedServer.Authentications.Any()) ImGui.Separator(); - if (!selectedServer.Authentications.Exists(c => string.Equals(c.CharacterName, youName, StringComparison.Ordinal) - && c.WorldId == youWorld)) + if (!selectedServer.Authentications.Exists(c => + string.Equals(c.CharacterName, youName, StringComparison.Ordinal) + && c.WorldId == youWorld)) { if (_uiShared.IconTextButton(FontAwesomeIcon.User, "Add current character")) { _serverConfigurationManager.AddCurrentCharacterToServer(idx); } + ImGui.SameLine(); } @@ -2262,7 +2990,8 @@ public class SettingsUi : WindowMediatorSubscriberBase } else { - UiSharedService.ColorTextWrapped("You need to add a Secret Key first before adding Characters.", UIColors.Get("LightlessYellow")); + UiSharedService.ColorTextWrapped("You need to add a Secret Key first before adding Characters.", + UIColors.Get("LightlessYellow")); } ImGui.EndTabItem(); @@ -2279,24 +3008,29 @@ public class SettingsUi : WindowMediatorSubscriberBase item.Value.FriendlyName = friendlyName; _serverConfigurationManager.Save(); } + var key = item.Value.Key; if (ImGui.InputText("Secret Key", ref key, 64)) { item.Value.Key = key; _serverConfigurationManager.Save(); } + if (!selectedServer.Authentications.Exists(p => p.SecretKeyIdx == item.Key)) { - if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Delete Secret Key") && UiSharedService.CtrlPressed()) + if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Delete Secret Key") && + UiSharedService.CtrlPressed()) { selectedServer.SecretKeys.Remove(item.Key); _serverConfigurationManager.Save(); } + UiSharedService.AttachToolTip("Hold CTRL to delete this secret key entry"); } else { - UiSharedService.ColorTextWrapped("This key is in use and cannot be deleted", UIColors.Get("LightlessYellow")); + UiSharedService.ColorTextWrapped("This key is in use and cannot be deleted", + UIColors.Get("LightlessYellow")); } if (item.Key != selectedServer.SecretKeys.Keys.LastOrDefault()) @@ -2306,10 +3040,9 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Separator(); if (_uiShared.IconTextButton(FontAwesomeIcon.Plus, "Add new Secret Key")) { - selectedServer.SecretKeys.Add(selectedServer.SecretKeys.Any() ? selectedServer.SecretKeys.Max(p => p.Key) + 1 : 0, new SecretKey() - { - FriendlyName = "New Secret Key", - }); + selectedServer.SecretKeys.Add( + selectedServer.SecretKeys.Any() ? selectedServer.SecretKeys.Max(p => p.Key) + 1 : 0, + new SecretKey() { FriendlyName = "New Secret Key", }); _serverConfigurationManager.Save(); } @@ -2327,6 +3060,7 @@ public class SettingsUi : WindowMediatorSubscriberBase { selectedServer.ServerUri = serverUri; } + if (isMain) { _uiShared.DrawHelpText("You cannot edit the URI of the main service."); @@ -2337,6 +3071,7 @@ public class SettingsUi : WindowMediatorSubscriberBase selectedServer.ServerName = serverName; _serverConfigurationManager.Save(); } + if (isMain) { _uiShared.DrawHelpText("You cannot edit the name of the main service."); @@ -2344,12 +3079,16 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.SetNextItemWidth(200); var serverTransport = _serverConfigurationManager.GetTransport(); - _uiShared.DrawCombo("Server Transport Type", Enum.GetValues().Where(t => t != HttpTransportType.None), + _uiShared.DrawCombo("Server Transport Type", + Enum.GetValues().Where(t => t != HttpTransportType.None), (v) => v.ToString(), onSelected: (t) => _serverConfigurationManager.SetTransportType(t), serverTransport); - _uiShared.DrawHelpText("You normally do not need to change this, if you don't know what this is or what it's for, keep it to WebSockets." + Environment.NewLine - + "If you run into connection issues with e.g. VPNs, try ServerSentEvents first before trying out LongPolling." + UiSharedService.TooltipSeparator + _uiShared.DrawHelpText( + "You normally do not need to change this, if you don't know what this is or what it's for, keep it to WebSockets." + + Environment.NewLine + + "If you run into connection issues with e.g. VPNs, try ServerSentEvents first before trying out LongPolling." + + UiSharedService.TooltipSeparator + "Note: if the server does not support a specific Transport Type it will fall through to the next automatically: WebSockets > ServerSentEvents > LongPolling"); if (_dalamudUtilService.IsWine) @@ -2360,7 +3099,9 @@ public class SettingsUi : WindowMediatorSubscriberBase selectedServer.ForceWebSockets = forceWebSockets; _serverConfigurationManager.Save(); } - _uiShared.DrawHelpText("On wine, Lightless will automatically fall back to ServerSentEvents/LongPolling, even if WebSockets is selected. " + + _uiShared.DrawHelpText( + "On wine, Lightless will automatically fall back to ServerSentEvents/LongPolling, even if WebSockets is selected. " + "WebSockets are known to crash XIV entirely on wine 8.5 shipped with Dalamud. " + "Only enable this if you are not running wine 8.5." + Environment.NewLine + "Note: If the issue gets resolved at some point this option will be removed."); @@ -2373,20 +3114,26 @@ public class SettingsUi : WindowMediatorSubscriberBase selectedServer.UseOAuth2 = useOauth; _serverConfigurationManager.Save(); } - _uiShared.DrawHelpText("Use Discord OAuth2 Authentication to identify with this server instead of secret keys"); + + _uiShared.DrawHelpText( + "Use Discord OAuth2 Authentication to identify with this server instead of secret keys"); if (useOauth) { _uiShared.DrawOAuth(selectedServer); if (string.IsNullOrEmpty(_serverConfigurationManager.GetDiscordUserFromToken(selectedServer))) { ImGuiHelpers.ScaledDummy(10f); - UiSharedService.ColorTextWrapped("You have enabled OAuth2 but it is not linked. Press the buttons Check, then Authenticate to link properly.", ImGuiColors.DalamudRed); + UiSharedService.ColorTextWrapped( + "You have enabled OAuth2 but it is not linked. Press the buttons Check, then Authenticate to link properly.", + ImGuiColors.DalamudRed); } + if (!string.IsNullOrEmpty(_serverConfigurationManager.GetDiscordUserFromToken(selectedServer)) && selectedServer.Authentications.TrueForAll(u => string.IsNullOrEmpty(u.UID))) { ImGuiHelpers.ScaledDummy(10f); - UiSharedService.ColorTextWrapped("You have enabled OAuth2 but no characters configured. Set the correct UIDs for your characters in \"Character Management\".", + UiSharedService.ColorTextWrapped( + "You have enabled OAuth2 but no characters configured. Set the correct UIDs for your characters in \"Character Management\".", ImGuiColors.DalamudRed); } } @@ -2394,10 +3141,12 @@ public class SettingsUi : WindowMediatorSubscriberBase if (!isMain && selectedServer != _serverConfigurationManager.CurrentServer) { ImGui.Separator(); - if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Delete Service") && UiSharedService.CtrlPressed()) + if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Delete Service") && + UiSharedService.CtrlPressed()) { _serverConfigurationManager.DeleteServer(selectedServer); } + _uiShared.DrawHelpText("Hold CTRL to delete this service"); } @@ -2409,26 +3158,35 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.BigText("Default Permission Settings"); if (selectedServer == _serverConfigurationManager.CurrentServer && _apiController.IsConnected) { - UiSharedService.TextWrapped("Note: The default permissions settings here are not applied retroactively to existing pairs or joined Syncshells."); - UiSharedService.TextWrapped("Note: The default permissions settings here are sent and stored on the connected service."); + UiSharedService.TextWrapped( + "Note: The default permissions settings here are not applied retroactively to existing pairs or joined Syncshells."); + UiSharedService.TextWrapped( + "Note: The default permissions settings here are sent and stored on the connected service."); ImGuiHelpers.ScaledDummy(5f); var perms = _apiController.DefaultPermissions!; bool individualIsSticky = perms.IndividualIsSticky; bool disableIndividualSounds = perms.DisableIndividualSounds; bool disableIndividualAnimations = perms.DisableIndividualAnimations; bool disableIndividualVFX = perms.DisableIndividualVFX; - if (ImGui.Checkbox("Individually set permissions become preferred permissions", ref individualIsSticky)) + if (ImGui.Checkbox("Individually set permissions become preferred permissions", + ref individualIsSticky)) { perms.IndividualIsSticky = individualIsSticky; _ = _apiController.UserUpdateDefaultPermissions(perms); } - _uiShared.DrawHelpText("The preferred attribute means that the permissions to that user will never change through any of your permission changes to Syncshells " + + + _uiShared.DrawHelpText( + "The preferred attribute means that the permissions to that user will never change through any of your permission changes to Syncshells " + "(i.e. if you have paused one specific user in a Syncshell and they become preferred permissions, then pause and unpause the same Syncshell, the user will remain paused - " + - "if a user does not have preferred permissions, it will follow the permissions of the Syncshell and be unpaused)." + Environment.NewLine + Environment.NewLine + + "if a user does not have preferred permissions, it will follow the permissions of the Syncshell and be unpaused)." + + Environment.NewLine + Environment.NewLine + "This setting means:" + Environment.NewLine + - " - All new individual pairs get their permissions defaulted to preferred permissions." + Environment.NewLine + - " - All individually set permissions for any pair will also automatically become preferred permissions. This includes pairs in Syncshells." + Environment.NewLine + Environment.NewLine + - "It is possible to remove or set the preferred permission state for any pair at any time." + Environment.NewLine + Environment.NewLine + + " - All new individual pairs get their permissions defaulted to preferred permissions." + + Environment.NewLine + + " - All individually set permissions for any pair will also automatically become preferred permissions. This includes pairs in Syncshells." + + Environment.NewLine + Environment.NewLine + + "It is possible to remove or set the preferred permission state for any pair at any time." + + Environment.NewLine + Environment.NewLine + "If unsure, leave this setting off."); ImGuiHelpers.ScaledDummy(3f); @@ -2437,18 +3195,21 @@ public class SettingsUi : WindowMediatorSubscriberBase perms.DisableIndividualSounds = disableIndividualSounds; _ = _apiController.UserUpdateDefaultPermissions(perms); } + _uiShared.DrawHelpText("This setting will disable sound sync for all new individual pairs."); if (ImGui.Checkbox("Disable individual pair animations", ref disableIndividualAnimations)) { perms.DisableIndividualAnimations = disableIndividualAnimations; _ = _apiController.UserUpdateDefaultPermissions(perms); } + _uiShared.DrawHelpText("This setting will disable animation sync for all new individual pairs."); if (ImGui.Checkbox("Disable individual pair VFX", ref disableIndividualVFX)) { perms.DisableIndividualVFX = disableIndividualVFX; _ = _apiController.UserUpdateDefaultPermissions(perms); } + _uiShared.DrawHelpText("This setting will disable VFX sync for all new individual pairs."); ImGuiHelpers.ScaledDummy(5f); bool disableGroundSounds = perms.DisableGroupSounds; @@ -2459,28 +3220,36 @@ public class SettingsUi : WindowMediatorSubscriberBase perms.DisableGroupSounds = disableGroundSounds; _ = _apiController.UserUpdateDefaultPermissions(perms); } - _uiShared.DrawHelpText("This setting will disable sound sync for all non-sticky pairs in newly joined syncshells."); + + _uiShared.DrawHelpText( + "This setting will disable sound sync for all non-sticky pairs in newly joined syncshells."); if (ImGui.Checkbox("Disable Syncshell pair animations", ref disableGroupAnimations)) { perms.DisableGroupAnimations = disableGroupAnimations; _ = _apiController.UserUpdateDefaultPermissions(perms); } - _uiShared.DrawHelpText("This setting will disable animation sync for all non-sticky pairs in newly joined syncshells."); + + _uiShared.DrawHelpText( + "This setting will disable animation sync for all non-sticky pairs in newly joined syncshells."); if (ImGui.Checkbox("Disable Syncshell pair VFX", ref disableGroupVFX)) { perms.DisableGroupVFX = disableGroupVFX; _ = _apiController.UserUpdateDefaultPermissions(perms); } - _uiShared.DrawHelpText("This setting will disable VFX sync for all non-sticky pairs in newly joined syncshells."); + + _uiShared.DrawHelpText( + "This setting will disable VFX sync for all non-sticky pairs in newly joined syncshells."); } else { UiSharedService.ColorTextWrapped("Default Permission Settings unavailable for this service. " + - "You need to connect to this service to change the default permissions since they are stored on the service.", UIColors.Get("LightlessYellow")); + "You need to connect to this service to change the default permissions since they are stored on the service.", + UIColors.Get("LightlessYellow")); } ImGui.EndTabItem(); } + ImGui.EndTabBar(); } @@ -2491,10 +3260,13 @@ public class SettingsUi : WindowMediatorSubscriberBase private Task<(bool Success, bool PartialSuccess, string Result)>? _secretKeysConversionTask = null; private CancellationTokenSource _secretKeysConversionCts = new CancellationTokenSource(); - private async Task<(bool Success, bool partialSuccess, string Result)> ConvertSecretKeysToUIDs(ServerStorage serverStorage, CancellationToken token) + private async Task<(bool Success, bool partialSuccess, string Result)> ConvertSecretKeysToUIDs( + ServerStorage serverStorage, CancellationToken token) { - List failedConversions = serverStorage.Authentications.Where(u => u.SecretKeyIdx == -1 && string.IsNullOrEmpty(u.UID)).ToList(); - List conversionsToAttempt = serverStorage.Authentications.Where(u => u.SecretKeyIdx != -1 && string.IsNullOrEmpty(u.UID)).ToList(); + List failedConversions = serverStorage.Authentications + .Where(u => u.SecretKeyIdx == -1 && string.IsNullOrEmpty(u.UID)).ToList(); + List conversionsToAttempt = serverStorage.Authentications + .Where(u => u.SecretKeyIdx != -1 && string.IsNullOrEmpty(u.UID)).ToList(); List successfulConversions = []; Dictionary> secretKeyMapping = new(StringComparer.Ordinal); foreach (var authEntry in conversionsToAttempt) @@ -2515,7 +3287,9 @@ public class SettingsUi : WindowMediatorSubscriberBase if (secretKeyMapping.Count == 0) { - return (false, false, $"Failed to convert {failedConversions.Count} entries: " + string.Join(", ", failedConversions.Select(k => k.CharacterName))); + return (false, false, + $"Failed to convert {failedConversions.Count} entries: " + + string.Join(", ", failedConversions.Select(k => k.CharacterName))); } var baseUri = serverStorage.ServerUri.Replace("wss://", "https://").Replace("ws://", "http://"); @@ -2526,8 +3300,10 @@ public class SettingsUi : WindowMediatorSubscriberBase requestMessage.Content = requestContent; using var response = await _httpClient.SendAsync(requestMessage, token).ConfigureAwait(false); - Dictionary? secretKeyUidMapping = await JsonSerializer.DeserializeAsync> - (await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false), cancellationToken: token).ConfigureAwait(false); + Dictionary? secretKeyUidMapping = await JsonSerializer + .DeserializeAsync> + (await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false), cancellationToken: token) + .ConfigureAwait(false); if (secretKeyUidMapping == null) { return (false, false, $"Failed to parse the server response. Failed to convert all entries."); @@ -2565,7 +3341,8 @@ public class SettingsUi : WindowMediatorSubscriberBase private static string GetLightfinderPresetGlyph(int index) { - return NameplateHandler.NormalizeIconGlyph(SeIconCharExtensions.ToIconString(LightfinderIconPresets[index].Icon)); + return NameplateHandler.NormalizeIconGlyph( + SeIconCharExtensions.ToIconString(LightfinderIconPresets[index].Icon)); } private void RefreshLightfinderIconState() @@ -2606,7 +3383,8 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.SameLine(); ImGui.TextUnformatted("("); ImGui.SameLine(); - ImGui.TextColored(UIColors.Get("LightlessBlue"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture)); + ImGui.TextColored(UIColors.Get("LightlessBlue"), + _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture)); ImGui.SameLine(); ImGui.TextUnformatted("Users Online"); ImGui.SameLine(); @@ -2618,13 +3396,21 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.SameLine(); if (ImGui.Button("Lightless Sync Discord")) { - Util.OpenLink("https://discord.gg/mpNdkrTRjW"); + Util.OpenLink("https://discord.gg/Lightless"); } + ImGui.Separator(); if (ImGui.BeginTabBar("mainTabBar")) { - if (ImGui.BeginTabItem("General")) + var generalTabFlags = ImGuiTabItemFlags.None; + if (_selectGeneralTabOnNextDraw) { + generalTabFlags |= ImGuiTabItemFlags.SetSelected; + } + + if (ImGui.BeginTabItem("General", generalTabFlags)) + { + _selectGeneralTabOnNextDraw = false; DrawGeneral(); ImGui.EndTabItem(); } @@ -2653,6 +3439,12 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.EndTabItem(); } + if (ImGui.BeginTabItem("Notifications")) + { + DrawNotificationSettings(); + ImGui.EndTabItem(); + } + if (ImGui.BeginTabItem("Debug")) { DrawDebug(); @@ -2673,4 +3465,731 @@ public class SettingsUi : WindowMediatorSubscriberBase _wasOpen = IsOpen; IsOpen = false; } + + private void DrawNotificationSettings() + { + _lastTab = "Notifications"; + _uiShared.UnderlinedBigText("Notification System", UIColors.Get("LightlessBlue")); + ImGuiHelpers.ScaledDummy(5); + + bool useLightlessNotifications = _configService.Current.UseLightlessNotifications; + if (ImGui.Checkbox("Use Lightless Notifications", ref useLightlessNotifications)) + { + _configService.Current.UseLightlessNotifications = useLightlessNotifications; + _configService.Save(); + } + + _uiShared.DrawHelpText( + "Enable modern notifications with interactive buttons, animations, and progress tracking. Disable for classic Dalamud toast/chat notifications."); + + ImGuiHelpers.ScaledDummy(5); + ImGui.Separator(); + + if (_uiShared.MediumTreeNode("Notification Locations", UIColors.Get("LightlessPurple"))) + { + UiSharedService.ColorTextWrapped("Choose where each notification type appears.", ImGuiColors.DalamudGrey); + ImGuiHelpers.ScaledDummy(5); + + if (useLightlessNotifications) + { + // Lightless notification locations + ImGui.Indent(); + + var lightlessLocations = GetLightlessNotificationLocations(); + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Info Notifications:"); + ImGui.SameLine(); + ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); + _uiShared.DrawCombo("###enhanced_info", lightlessLocations, GetNotificationLocationLabel, (location) => + { + _configService.Current.LightlessInfoNotification = location; + _configService.Save(); + }, _configService.Current.LightlessInfoNotification); + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Warning Notifications:"); + ImGui.SameLine(); + ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); + _uiShared.DrawCombo("###enhanced_warning", lightlessLocations, GetNotificationLocationLabel, + (location) => + { + _configService.Current.LightlessWarningNotification = location; + _configService.Save(); + }, _configService.Current.LightlessWarningNotification); + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Error Notifications:"); + ImGui.SameLine(); + ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); + _uiShared.DrawCombo("###enhanced_error", lightlessLocations, GetNotificationLocationLabel, (location) => + { + _configService.Current.LightlessErrorNotification = location; + _configService.Save(); + }, _configService.Current.LightlessErrorNotification); + + ImGuiHelpers.ScaledDummy(3); + _uiShared.DrawHelpText("Special notification types:"); + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Pair Request Notifications:"); + ImGui.SameLine(); + ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); + _uiShared.DrawCombo("###enhanced_pairrequest", lightlessLocations, GetNotificationLocationLabel, + (location) => + { + _configService.Current.LightlessPairRequestNotification = location; + _configService.Save(); + }, _configService.Current.LightlessPairRequestNotification); + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Download Progress Notifications:"); + ImGui.SameLine(); + ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); + var downloadLocations = GetDownloadNotificationLocations(); + _uiShared.DrawCombo("###enhanced_download", downloadLocations, GetNotificationLocationLabel, + (location) => + { + _configService.Current.LightlessDownloadNotification = location; + _configService.Save(); + }, _configService.Current.LightlessDownloadNotification); + + + ImGui.Unindent(); + } + else + { + // Classic notifications when lightless notifs is disabled + var classicLocations = GetClassicNotificationLocations(); + ImGui.Indent(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Info Notifications:"); + ImGui.SameLine(); + ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); + _uiShared.DrawCombo("###info", classicLocations, GetNotificationLocationLabel, (location) => + { + _configService.Current.InfoNotification = location; + _configService.Save(); + }, _configService.Current.InfoNotification); + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Warning Notifications:"); + ImGui.SameLine(); + ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); + _uiShared.DrawCombo("###warning", classicLocations, GetNotificationLocationLabel, (location) => + { + _configService.Current.WarningNotification = location; + _configService.Save(); + }, _configService.Current.WarningNotification); + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Error Notifications:"); + ImGui.SameLine(); + ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); + _uiShared.DrawCombo("###error", classicLocations, GetNotificationLocationLabel, (location) => + { + _configService.Current.ErrorNotification = location; + _configService.Save(); + }, _configService.Current.ErrorNotification); + + ImGui.Unindent(); + } + + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + } + + ImGui.Separator(); + if (useLightlessNotifications) + { + if (_uiShared.MediumTreeNode("Test Notifications", UIColors.Get("LightlessPurple"))) + { + ImGui.Indent(); + + // Test notification buttons + if (_uiShared.IconTextButton(FontAwesomeIcon.Bell, "Test Info")) + { + Mediator.Publish(new NotificationMessage("Test Info", + "This is a test info notification to let you know Chocola is cute :3", NotificationType.Info)); + } + + ImGui.SameLine(); + if (_uiShared.IconTextButton(FontAwesomeIcon.ExclamationTriangle, "Test Warning")) + { + Mediator.Publish(new NotificationMessage("Test Warning", "This is a test warning notification!", + NotificationType.Warning)); + } + + ImGui.SameLine(); + if (_uiShared.IconTextButton(FontAwesomeIcon.ExclamationCircle, "Test Error")) + { + Mediator.Publish(new NotificationMessage("Test Error", "This is a test error notification!", + NotificationType.Error)); + } + + ImGuiHelpers.ScaledDummy(3); + if (_uiShared.IconTextButton(FontAwesomeIcon.UserPlus, "Test Pair Request")) + { + _lightlessNotificationService.ShowPairRequestNotification( + "Test User", + "test-uid-123", + () => + { + Mediator.Publish(new NotificationMessage("Accepted", "You accepted the test pair request.", + NotificationType.Info)); + }, + () => + { + Mediator.Publish(new NotificationMessage("Declined", "You declined the test pair request.", + NotificationType.Info)); + } + ); + } + + ImGui.SameLine(); + if (_uiShared.IconTextButton(FontAwesomeIcon.Download, "Test Download Progress")) + { + _lightlessNotificationService.ShowPairDownloadNotification( + new List<(string playerName, float progress, string status)> + { + ("Player One", 0.35f, "downloading"), + ("Player Two", 0.75f, "downloading"), + ("Player Three", 1.0f, "downloading") + }, + queueWaiting: 2 + ); + } + + _uiShared.DrawHelpText("Preview how notifications will appear with your current settings."); + + ImGui.Unindent(); + + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + } + + ImGui.Separator(); + if (_uiShared.MediumTreeNode("Basic Settings", UIColors.Get("LightlessPurple"))) + { + int maxNotifications = _configService.Current.MaxSimultaneousNotifications; + if (ImGui.SliderInt("Max Simultaneous Notifications", ref maxNotifications, 1, 10)) + { + _configService.Current.MaxSimultaneousNotifications = Math.Clamp(maxNotifications, 1, 10); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.MaxSimultaneousNotifications = 5; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (5)."); + _uiShared.DrawHelpText("Maximum number of notifications that can be shown at once."); + + bool showTimestamp = _configService.Current.ShowNotificationTimestamp; + if (ImGui.Checkbox("Show Timestamps", ref showTimestamp)) + { + _configService.Current.ShowNotificationTimestamp = showTimestamp; + _configService.Save(); + } + + _uiShared.DrawHelpText("Display the time when each notification was created."); + + bool dismissOnClick = _configService.Current.DismissNotificationOnClick; + if (ImGui.Checkbox("Dismiss on Click", ref dismissOnClick)) + { + _configService.Current.DismissNotificationOnClick = dismissOnClick; + _configService.Save(); + } + + _uiShared.DrawHelpText("Click anywhere on a notification to dismiss it. Notifications with action buttons (like pair requests) are excluded."); + + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + } + + if (useLightlessNotifications) + { + ImGui.Separator(); + if (_uiShared.MediumTreeNode("Appearance & Animation", UIColors.Get("LightlessPurple"))) + { + + float opacity = _configService.Current.NotificationOpacity; + if (ImGui.SliderFloat("Notification Opacity", ref opacity, 0.1f, 1.0f, "%.2f")) + { + _configService.Current.NotificationOpacity = Math.Clamp(opacity, 0.1f, 1.0f); + _configService.Save(); + } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.NotificationOpacity = 0.95f; + _configService.Save(); + } + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (0.95)."); + + _uiShared.DrawHelpText("Transparency level of notification windows."); + + ImGui.Spacing(); + ImGui.TextUnformatted("Size & Layout"); + + float notifWidth = _configService.Current.NotificationWidth; + if (ImGui.SliderFloat("Notification Width", ref notifWidth, 250f, 600f, "%.0f")) + { + _configService.Current.NotificationWidth = Math.Clamp(notifWidth, 250f, 600f); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.NotificationWidth = 350f; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (350)."); + _uiShared.DrawHelpText("Width of notification windows."); + + float notifSpacing = _configService.Current.NotificationSpacing; + if (ImGui.SliderFloat("Notification Spacing", ref notifSpacing, 0f, 30f, "%.0f")) + { + _configService.Current.NotificationSpacing = Math.Clamp(notifSpacing, 0f, 30f); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.NotificationSpacing = 8f; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (8)."); + _uiShared.DrawHelpText("Gap between stacked notifications."); + + ImGui.Spacing(); + ImGui.TextUnformatted("Position"); + + int offsetY = _configService.Current.NotificationOffsetY; + if (ImGui.SliderInt("Vertical Offset", ref offsetY, 0, 500)) + { + _configService.Current.NotificationOffsetY = Math.Clamp(offsetY, 0, 500); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.NotificationOffsetY = 50; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (50)."); + _uiShared.DrawHelpText("Move notifications down from the top-right corner."); + + int offsetX = _configService.Current.NotificationOffsetX; + if (ImGui.SliderInt("Horizontal Offset", ref offsetX, 0, 500)) + { + _configService.Current.NotificationOffsetX = Math.Clamp(offsetX, 0, 500); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.NotificationOffsetX = 0; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (0)."); + _uiShared.DrawHelpText("Move notifications left from the right edge."); + + ImGui.Spacing(); + ImGui.TextUnformatted("Animation Settings"); + + float animSpeed = _configService.Current.NotificationAnimationSpeed; + if (ImGui.SliderFloat("Animation Speed", ref animSpeed, 1f, 30f, "%.1f")) + { + _configService.Current.NotificationAnimationSpeed = Math.Clamp(animSpeed, 1f, 30f); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.NotificationAnimationSpeed = 10f; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (10)."); + _uiShared.DrawHelpText("How fast notifications slide in/out. Higher = faster."); + + ImGui.Spacing(); + ImGui.TextUnformatted("Visual Effects"); + + float accentWidth = _configService.Current.NotificationAccentBarWidth; + if (ImGui.SliderFloat("Accent Bar Width", ref accentWidth, 0f, 10f, "%.1f")) + { + _configService.Current.NotificationAccentBarWidth = Math.Clamp(accentWidth, 0f, 10f); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.NotificationAccentBarWidth = 3f; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (3)."); + _uiShared.DrawHelpText("Width of the colored accent bar on the left side."); + + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + } + } + + ImGui.Separator(); + if (_uiShared.MediumTreeNode("Duration Settings", UIColors.Get("LightlessPurple"))) + { + UiSharedService.ColorTextWrapped("Configure how long each notification type stays visible.", ImGuiColors.DalamudGrey); + ImGuiHelpers.ScaledDummy(5); + + int infoDuration = _configService.Current.InfoNotificationDurationSeconds; + if (ImGui.SliderInt("Info Duration (seconds)", ref infoDuration, 3, 60)) + { + _configService.Current.InfoNotificationDurationSeconds = Math.Clamp(infoDuration, 3, 60); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.InfoNotificationDurationSeconds = 10; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (10)."); + + int warningDuration = _configService.Current.WarningNotificationDurationSeconds; + if (ImGui.SliderInt("Warning Duration (seconds)", ref warningDuration, 3, 60)) + { + _configService.Current.WarningNotificationDurationSeconds = Math.Clamp(warningDuration, 3, 60); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.WarningNotificationDurationSeconds = 15; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (15)."); + + int errorDuration = _configService.Current.ErrorNotificationDurationSeconds; + if (ImGui.SliderInt("Error Duration (seconds)", ref errorDuration, 3, 120)) + { + _configService.Current.ErrorNotificationDurationSeconds = Math.Clamp(errorDuration, 3, 120); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.ErrorNotificationDurationSeconds = 20; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (20)."); + + int pairRequestDuration = _configService.Current.PairRequestDurationSeconds; + if (ImGui.SliderInt("Pair Request Duration (seconds)", ref pairRequestDuration, 30, 600)) + { + _configService.Current.PairRequestDurationSeconds = Math.Clamp(pairRequestDuration, 30, 600); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.PairRequestDurationSeconds = 180; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (180)."); + + int downloadDuration = _configService.Current.DownloadNotificationDurationSeconds; + if (ImGui.SliderInt("Download Duration (seconds)", ref downloadDuration, 60, 600)) + { + _configService.Current.DownloadNotificationDurationSeconds = Math.Clamp(downloadDuration, 60, 600); + _configService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _configService.Current.DownloadNotificationDurationSeconds = 300; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Right click to reset to default (300)."); + + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + } + + ImGui.Separator(); + if (_uiShared.MediumTreeNode("Sound Settings", UIColors.Get("LightlessPurple"))) + { + ImGui.TextUnformatted("Notification Sounds"); + ImGuiHelpers.ScaledDummy(5); + + DrawSoundTable(); + + _uiShared.DrawHelpText( + "Configure which sounds play for each notification type. Use the play button to preview sounds."); + + _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + } + + ImGui.Separator(); + _uiShared.UnderlinedBigText("Specific Notification Types", UIColors.Get("LightlessBlue")); + ImGuiHelpers.ScaledDummy(5); + + UiSharedService.ColorTextWrapped("Configure specific types of notifications and their behavior.", + ImGuiColors.DalamudGrey); + ImGuiHelpers.ScaledDummy(3); + + // Online Notifications Section + if (_uiShared.MediumTreeNode("Online Status Notifications", UIColors.Get("LightlessGreen"))) + { + var onlineNotifs = _configService.Current.ShowOnlineNotifications; + var onlineNotifsPairsOnly = _configService.Current.ShowOnlineNotificationsOnlyForIndividualPairs; + var onlineNotifsNamedOnly = _configService.Current.ShowOnlineNotificationsOnlyForNamedPairs; + + if (ImGui.Checkbox("Enable online notifications", ref onlineNotifs)) + { + _configService.Current.ShowOnlineNotifications = onlineNotifs; + _configService.Save(); + } + + _uiShared.DrawHelpText( + "Show notifications when pairs come online. These will use the Info notification location settings above."); + + using var disabled = ImRaii.Disabled(!onlineNotifs); + ImGui.Indent(); + if (ImGui.Checkbox("Notify only for individual pairs", ref onlineNotifsPairsOnly)) + { + _configService.Current.ShowOnlineNotificationsOnlyForIndividualPairs = onlineNotifsPairsOnly; + _configService.Save(); + } + + _uiShared.DrawHelpText("Only show online notifications for individual pairs (not syncshell members)."); + + if (ImGui.Checkbox("Notify only for named pairs", ref onlineNotifsNamedOnly)) + { + _configService.Current.ShowOnlineNotificationsOnlyForNamedPairs = onlineNotifsNamedOnly; + _configService.Save(); + } + + _uiShared.DrawHelpText( + "Only show online notifications for pairs where you have set an individual note."); + ImGui.Unindent(); + + _uiShared.ColoredSeparator(UIColors.Get("LightlessGreen"), 1.5f); + ImGui.TreePop(); + } + + if (_uiShared.MediumTreeNode("System Notifications", UIColors.Get("LightlessYellow"))) + { + var disableOptionalPluginWarnings = _configService.Current.DisableOptionalPluginWarnings; + if (ImGui.Checkbox("Disable optional plugin warnings", ref disableOptionalPluginWarnings)) + { + _configService.Current.DisableOptionalPluginWarnings = disableOptionalPluginWarnings; + _configService.Save(); + } + + _uiShared.DrawHelpText("Disable warning notifications for missing optional plugins."); + + _uiShared.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f); + ImGui.TreePop(); + } + + ImGui.Separator(); + // Location descriptions removed - information is now inline with each setting + } + } + + private NotificationLocation[] GetLightlessNotificationLocations() + { + return new[] + { + NotificationLocation.LightlessUi, NotificationLocation.ChatAndLightlessUi, NotificationLocation.Nowhere + }; + } + + private NotificationLocation[] GetDownloadNotificationLocations() + { + return new[] + { + NotificationLocation.LightlessUi, NotificationLocation.ChatAndLightlessUi, + NotificationLocation.TextOverlay, NotificationLocation.Nowhere + }; + } + + private NotificationLocation[] GetClassicNotificationLocations() + { + return new[] + { + NotificationLocation.Toast, NotificationLocation.Chat, NotificationLocation.Both, + NotificationLocation.Nowhere + }; + } + + private string GetNotificationLocationLabel(NotificationLocation location) + { + return location switch + { + NotificationLocation.Nowhere => "Nowhere", + NotificationLocation.Chat => "Chat", + NotificationLocation.Toast => "Toast", + NotificationLocation.Both => "Toast + Chat", + NotificationLocation.LightlessUi => "Lightless Notifications", + NotificationLocation.ChatAndLightlessUi => "Chat + Lightless Notifications", + NotificationLocation.TextOverlay => "Text Overlay", + _ => location.ToString() + }; + } + + private void DrawSoundTable() + { + var soundEffects = new[] + { + (1u, "Se1 - Soft chime"), (2u, "Se2 - Higher chime"), (3u, "Se3 - Bell tone"), (4u, "Se4 - Harp tone"), + (5u, "Se5 - Mechanical click"), (6u, "Se6 - Drum/percussion"), (7u, "Se7 - Metallic chime"), + (8u, "Se8 - Wooden tone"), (9u, "Se9 - Wind/flute tone"), (11u, "Se10 - Magical sparkle"), + (12u, "Se11 - Metallic ring"), (13u, "Se12 - Deep thud"), (14u, "Se13 - Tell received ping"), + (15u, "Se14 - Success fanfare"), (16u, "Se15 - System warning") + }; + + if (ImGui.BeginTable("##SoundTable", 3, + ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) + { + ImGui.TableSetupColumn("Type", ImGuiTableColumnFlags.WidthFixed, 120 * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("Sound", ImGuiTableColumnFlags.WidthStretch, 280 * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, 120 * ImGuiHelpers.GlobalScale); + ImGui.TableHeadersRow(); + + var soundTypes = new[] + { + ("Info", 0, _configService.Current.CustomInfoSoundId, _configService.Current.DisableInfoSound, 2u), + ("Warning", 1, _configService.Current.CustomWarningSoundId, _configService.Current.DisableWarningSound, 16u), + ("Error", 2, _configService.Current.CustomErrorSoundId, _configService.Current.DisableErrorSound, 16u), + ("Pair Request", 3, _configService.Current.PairRequestSoundId, _configService.Current.DisablePairRequestSound, 5u), + ("Download", 4, _configService.Current.DownloadSoundId, _configService.Current.DisableDownloadSound, 15u) + }; + + foreach (var (typeName, typeIndex, currentSoundId, isDisabled, defaultSoundId) in soundTypes) + { + ImGui.TableNextRow(); + + // Type column + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(typeName); + + // Sound picker column + ImGui.TableSetColumnIndex(1); + using (ImRaii.Disabled(isDisabled)) + { + var currentIndex = Array.FindIndex(soundEffects, s => s.Item1 == currentSoundId); + if (currentIndex == -1) currentIndex = 1; + + ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); + if (ImGui.Combo($"##sound_{typeIndex}", ref currentIndex, + soundEffects.Select(s => s.Item2).ToArray(), soundEffects.Length)) + { + var newSoundId = soundEffects[currentIndex].Item1; + switch (typeIndex) + { + case 0: _configService.Current.CustomInfoSoundId = newSoundId; break; + case 1: _configService.Current.CustomWarningSoundId = newSoundId; break; + case 2: _configService.Current.CustomErrorSoundId = newSoundId; break; + case 3: _configService.Current.PairRequestSoundId = newSoundId; break; + case 4: _configService.Current.DownloadSoundId = newSoundId; break; + } + + _configService.Save(); + } + } + + // Actions column + ImGui.TableSetColumnIndex(2); + var availableWidth = ImGui.GetContentRegionAvail().X; + var buttonWidth = (availableWidth - ImGui.GetStyle().ItemSpacing.X * 2) / 3; + + // Play button + using var playId = ImRaii.PushId($"Play_{typeIndex}"); + using (ImRaii.Disabled(isDisabled)) + { + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button(FontAwesomeIcon.Play.ToIconString(), new Vector2(buttonWidth, 0))) + { + try + { + FFXIVClientStructs.FFXIV.Client.UI.UIGlobals.PlayChatSoundEffect(currentSoundId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to play test sound"); + } + } + } + } + UiSharedService.AttachToolTip("Test this sound"); + + // Disable toggle button + ImGui.SameLine(); + using var disableId = ImRaii.PushId($"Disable_{typeIndex}"); + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + var icon = isDisabled ? FontAwesomeIcon.VolumeOff : FontAwesomeIcon.VolumeUp; + var color = isDisabled ? UIColors.Get("DimRed") : UIColors.Get("LightlessGreen"); + + ImGui.PushStyleColor(ImGuiCol.Button, color); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, color * new Vector4(1.2f, 1.2f, 1.2f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, color * new Vector4(0.8f, 0.8f, 0.8f, 1f)); + + if (ImGui.Button(icon.ToIconString(), new Vector2(buttonWidth, 0))) + { + bool newDisabled = !isDisabled; + switch (typeIndex) + { + case 0: _configService.Current.DisableInfoSound = newDisabled; break; + case 1: _configService.Current.DisableWarningSound = newDisabled; break; + case 2: _configService.Current.DisableErrorSound = newDisabled; break; + case 3: _configService.Current.DisablePairRequestSound = newDisabled; break; + case 4: _configService.Current.DisableDownloadSound = newDisabled; break; + } + _configService.Save(); + } + + ImGui.PopStyleColor(3); + } + UiSharedService.AttachToolTip(isDisabled ? "Sound is disabled - click to enable" : "Sound is enabled - click to disable"); + + // Reset button + ImGui.SameLine(); + using var resetId = ImRaii.PushId($"Reset_{typeIndex}"); + bool isDefault = currentSoundId == defaultSoundId; + + using (ImRaii.Disabled(isDefault)) + { + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button(FontAwesomeIcon.Undo.ToIconString(), new Vector2(buttonWidth, 0))) + { + switch (typeIndex) + { + case 0: _configService.Current.CustomInfoSoundId = defaultSoundId; break; + case 1: _configService.Current.CustomWarningSoundId = defaultSoundId; break; + case 2: _configService.Current.CustomErrorSoundId = defaultSoundId; break; + case 3: _configService.Current.PairRequestSoundId = defaultSoundId; break; + case 4: _configService.Current.DownloadSoundId = defaultSoundId; break; + } + _configService.Save(); + } + } + } + UiSharedService.AttachToolTip(isDefault ? "Sound is already at default value" : "Reset to default sound"); + } + + ImGui.EndTable(); + } + } } + + diff --git a/LightlessSync/UI/Style/MainStyle.cs b/LightlessSync/UI/Style/MainStyle.cs index d40ed2e..d3d8b68 100644 --- a/LightlessSync/UI/Style/MainStyle.cs +++ b/LightlessSync/UI/Style/MainStyle.cs @@ -1,169 +1,231 @@ -// inspiration: brio because it's style is fucking amazing +// inspiration: brio because it's style is fucking amazing using Dalamud.Bindings.ImGui; using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Configurations; +using System; +using System.Collections.Generic; using System.Numerics; -namespace LightlessSync.UI.Style +namespace LightlessSync.UI.Style; + +internal static class MainStyle { - internal static class MainStyle + public readonly record struct StyleColorOption(string Key, string Label, Func DefaultValue, ImGuiCol Target, string? Description = null, string? UiColorKey = null); + public readonly record struct StyleFloatOption(string Key, string Label, float DefaultValue, ImGuiStyleVar Target, float? Min = null, float? Max = null, float Speed = 0.25f, string? Description = null); + public readonly record struct StyleVector2Option(string Key, string Label, Func DefaultValue, ImGuiStyleVar Target, Vector2? Min = null, Vector2? Max = null, float Speed = 0.25f, string? Description = null); + + private static LightlessConfigService? _config; + private static UiThemeConfigService? _themeConfig; + public static void Init(LightlessConfigService config, UiThemeConfigService themeConfig) { - private static LightlessConfigService? _config; - public static void Init(LightlessConfigService config) => _config = config; - public static bool ShouldUseTheme => _config?.Current.UseLightlessRedesign ?? false; - - private static bool _hasPushed; - private static int _pushedColorCount; - private static int _pushedStyleVarCount; - - public static void PushStyle() - { - if (_hasPushed) - PopStyle(); - - if (!ShouldUseTheme) - { - _hasPushed = false; - return; - } - - _hasPushed = true; - _pushedColorCount = 0; - _pushedStyleVarCount = 0; - - Push(ImGuiCol.Text, new Vector4(255, 255, 255, 255)); - Push(ImGuiCol.TextDisabled, new Vector4(128, 128, 128, 255)); - - Push(ImGuiCol.WindowBg, new Vector4(23, 23, 23, 248)); - Push(ImGuiCol.ChildBg, new Vector4(23, 23, 23, 66)); - Push(ImGuiCol.PopupBg, new Vector4(23, 23, 23, 248)); - - Push(ImGuiCol.Border, new Vector4(65, 65, 65, 255)); - Push(ImGuiCol.BorderShadow, new Vector4(0, 0, 0, 150)); - - Push(ImGuiCol.FrameBg, new Vector4(40, 40, 40, 255)); - Push(ImGuiCol.FrameBgHovered, new Vector4(50, 50, 50, 255)); - Push(ImGuiCol.FrameBgActive, new Vector4(30, 30, 30, 255)); - - Push(ImGuiCol.TitleBg, new Vector4(24, 24, 24, 232)); - Push(ImGuiCol.TitleBgActive, new Vector4(30, 30, 30, 255)); - Push(ImGuiCol.TitleBgCollapsed, new Vector4(27, 27, 27, 255)); - - Push(ImGuiCol.MenuBarBg, new Vector4(36, 36, 36, 255)); - Push(ImGuiCol.ScrollbarBg, new Vector4(0, 0, 0, 0)); - Push(ImGuiCol.ScrollbarGrab, new Vector4(62, 62, 62, 255)); - Push(ImGuiCol.ScrollbarGrabHovered, new Vector4(70, 70, 70, 255)); - Push(ImGuiCol.ScrollbarGrabActive, new Vector4(70, 70, 70, 255)); - - Push(ImGuiCol.CheckMark, UIColors.Get("LightlessPurple")); - - Push(ImGuiCol.SliderGrab, new Vector4(101, 101, 101, 255)); - Push(ImGuiCol.SliderGrabActive, new Vector4(123, 123, 123, 255)); - - Push(ImGuiCol.Button, UIColors.Get("ButtonDefault")); - Push(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple")); - Push(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurpleActive")); - - Push(ImGuiCol.Header, new Vector4(0, 0, 0, 60)); - Push(ImGuiCol.HeaderHovered, new Vector4(0, 0, 0, 90)); - Push(ImGuiCol.HeaderActive, new Vector4(0, 0, 0, 120)); - - Push(ImGuiCol.Separator, new Vector4(75, 75, 75, 121)); - Push(ImGuiCol.SeparatorHovered, UIColors.Get("LightlessPurple")); - Push(ImGuiCol.SeparatorActive, UIColors.Get("LightlessPurpleActive")); - - Push(ImGuiCol.ResizeGrip, new Vector4(0, 0, 0, 0)); - Push(ImGuiCol.ResizeGripHovered, new Vector4(0, 0, 0, 0)); - Push(ImGuiCol.ResizeGripActive, UIColors.Get("LightlessPurpleActive")); - - Push(ImGuiCol.Tab, new Vector4(40, 40, 40, 255)); - Push(ImGuiCol.TabHovered, UIColors.Get("LightlessPurple")); - Push(ImGuiCol.TabActive, UIColors.Get("LightlessPurpleActive")); - Push(ImGuiCol.TabUnfocused, new Vector4(40, 40, 40, 255)); - Push(ImGuiCol.TabUnfocusedActive, UIColors.Get("LightlessPurpleActive")); - - Push(ImGuiCol.DockingPreview, UIColors.Get("LightlessPurpleActive")); - Push(ImGuiCol.DockingEmptyBg, new Vector4(50, 50, 50, 255)); - - Push(ImGuiCol.PlotLines, new Vector4(150, 150, 150, 255)); - - Push(ImGuiCol.TableHeaderBg, new Vector4(48, 48, 48, 255)); - Push(ImGuiCol.TableBorderStrong, new Vector4(79, 79, 89, 255)); - Push(ImGuiCol.TableBorderLight, new Vector4(59, 59, 64, 255)); - Push(ImGuiCol.TableRowBg, new Vector4(0, 0, 0, 0)); - Push(ImGuiCol.TableRowBgAlt, new Vector4(255, 255, 255, 15)); - - Push(ImGuiCol.TextSelectedBg, new Vector4(98, 75, 224, 255)); - Push(ImGuiCol.DragDropTarget, new Vector4(98, 75, 224, 255)); - - Push(ImGuiCol.NavHighlight, new Vector4(98, 75, 224, 179)); - Push(ImGuiCol.NavWindowingDimBg, new Vector4(204, 204, 204, 51)); - Push(ImGuiCol.NavWindowingHighlight, new Vector4(204, 204, 204, 89)); - - PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(6, 6)); - PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(4, 3)); - PushStyleVar(ImGuiStyleVar.CellPadding, new Vector2(4, 4)); - PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(4, 4)); - PushStyleVar(ImGuiStyleVar.ItemInnerSpacing, new Vector2(4, 4)); - - PushStyleVar(ImGuiStyleVar.IndentSpacing, 21.0f); - PushStyleVar(ImGuiStyleVar.ScrollbarSize, 10.0f); - PushStyleVar(ImGuiStyleVar.GrabMinSize, 20.0f); - - PushStyleVar(ImGuiStyleVar.WindowBorderSize, 1.5f); - PushStyleVar(ImGuiStyleVar.ChildBorderSize, 1.5f); - PushStyleVar(ImGuiStyleVar.PopupBorderSize, 1.5f); - PushStyleVar(ImGuiStyleVar.FrameBorderSize, 0f); - - PushStyleVar(ImGuiStyleVar.WindowRounding, 7f); - PushStyleVar(ImGuiStyleVar.ChildRounding, 4f); - PushStyleVar(ImGuiStyleVar.FrameRounding, 4f); - PushStyleVar(ImGuiStyleVar.PopupRounding, 4f); - PushStyleVar(ImGuiStyleVar.ScrollbarRounding, 4f); - PushStyleVar(ImGuiStyleVar.GrabRounding, 4f); - PushStyleVar(ImGuiStyleVar.TabRounding, 4f); - } - - public static void PopStyle() - { - if (!_hasPushed) - return; - - if (_pushedStyleVarCount > 0) - ImGui.PopStyleVar(_pushedStyleVarCount); - if (_pushedColorCount > 0) - ImGui.PopStyleColor(_pushedColorCount); - - _hasPushed = false; - _pushedColorCount = 0; - _pushedStyleVarCount = 0; - } - - private static void Push(ImGuiCol col, Vector4 rgba) - { - if (rgba.X > 1f || rgba.Y > 1f || rgba.Z > 1f || rgba.W > 1f) - rgba /= 255f; - - ImGui.PushStyleColor(col, rgba); - _pushedColorCount++; - } - - private static void Push(ImGuiCol col, uint packedRgba) - { - ImGui.PushStyleColor(col, packedRgba); - _pushedColorCount++; - } - - private static void PushStyleVar(ImGuiStyleVar var, float value) - { - ImGui.PushStyleVar(var, value); - _pushedStyleVarCount++; - } - - private static void PushStyleVar(ImGuiStyleVar var, Vector2 value) - { - ImGui.PushStyleVar(var, value); - _pushedStyleVarCount++; - } + _config = config; + _themeConfig = themeConfig; } + public static bool ShouldUseTheme => _config?.Current.UseLightlessRedesign ?? false; + + private static bool _hasPushed; + private static int _pushedColorCount; + private static int _pushedStyleVarCount; + + private static readonly StyleColorOption[] _colorOptions = + [ + new("color.text", "Text", () => Rgba(255, 255, 255, 255), ImGuiCol.Text), + new("color.textDisabled", "Text (Disabled)", () => Rgba(128, 128, 128, 255), ImGuiCol.TextDisabled), + new("color.windowBg", "Window Background", () => Rgba(23, 23, 23, 248), ImGuiCol.WindowBg), + new("color.childBg", "Child Background", () => Rgba(23, 23, 23, 66), ImGuiCol.ChildBg), + new("color.popupBg", "Popup Background", () => Rgba(23, 23, 23, 248), ImGuiCol.PopupBg), + new("color.border", "Border", () => Rgba(65, 65, 65, 255), ImGuiCol.Border), + new("color.borderShadow", "Border Shadow", () => Rgba(0, 0, 0, 150), ImGuiCol.BorderShadow), + new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg), + new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 255), ImGuiCol.FrameBgHovered), + new("color.frameBgActive", "Frame Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.FrameBgActive), + new("color.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg), + new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive), + new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(27, 27, 27, 255), ImGuiCol.TitleBgCollapsed), + new("color.menuBarBg", "Menu Bar Background", () => Rgba(36, 36, 36, 255), ImGuiCol.MenuBarBg), + new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg), + new("color.scrollbarGrab", "Scrollbar Grab", () => Rgba(62, 62, 62, 255), ImGuiCol.ScrollbarGrab), + new("color.scrollbarGrabHovered", "Scrollbar Grab (Hover)", () => Rgba(70, 70, 70, 255), ImGuiCol.ScrollbarGrabHovered), + new("color.scrollbarGrabActive", "Scrollbar Grab (Active)", () => Rgba(70, 70, 70, 255), ImGuiCol.ScrollbarGrabActive), + new("color.checkMark", "Check Mark", () => UIColors.Get("LightlessPurple"), ImGuiCol.CheckMark, UiColorKey: "LightlessPurple"), + new("color.sliderGrab", "Slider Grab", () => Rgba(101, 101, 101, 255), ImGuiCol.SliderGrab), + new("color.sliderGrabActive", "Slider Grab (Active)", () => Rgba(123, 123, 123, 255), ImGuiCol.SliderGrabActive), + new("color.button", "Button", () => UIColors.Get("ButtonDefault"), ImGuiCol.Button, UiColorKey: "ButtonDefault"), + new("color.buttonHovered", "Button (Hover)", () => UIColors.Get("LightlessPurple"), ImGuiCol.ButtonHovered, UiColorKey: "LightlessPurple"), + new("color.buttonActive", "Button (Active)", () => UIColors.Get("LightlessPurpleActive"), ImGuiCol.ButtonActive, UiColorKey: "LightlessPurpleActive"), + new("color.header", "Header", () => Rgba(0, 0, 0, 60), ImGuiCol.Header), + new("color.headerHovered", "Header (Hover)", () => Rgba(0, 0, 0, 90), ImGuiCol.HeaderHovered), + new("color.headerActive", "Header (Active)", () => Rgba(0, 0, 0, 120), ImGuiCol.HeaderActive), + new("color.separator", "Separator", () => Rgba(75, 75, 75, 121), ImGuiCol.Separator), + new("color.separatorHovered", "Separator (Hover)", () => UIColors.Get("LightlessPurple"), ImGuiCol.SeparatorHovered, UiColorKey: "LightlessPurple"), + new("color.separatorActive", "Separator (Active)", () => UIColors.Get("LightlessPurpleActive"), ImGuiCol.SeparatorActive, UiColorKey: "LightlessPurpleActive"), + new("color.resizeGrip", "Resize Grip", () => Rgba(0, 0, 0, 0), ImGuiCol.ResizeGrip), + new("color.resizeGripHovered", "Resize Grip (Hover)", () => Rgba(0, 0, 0, 0), ImGuiCol.ResizeGripHovered), + new("color.resizeGripActive", "Resize Grip (Active)", () => UIColors.Get("LightlessPurpleActive"), ImGuiCol.ResizeGripActive, UiColorKey: "LightlessPurpleActive"), + new("color.tab", "Tab", () => Rgba(40, 40, 40, 255), ImGuiCol.Tab), + new("color.tabHovered", "Tab (Hover)", () => UIColors.Get("LightlessPurple"), ImGuiCol.TabHovered, UiColorKey: "LightlessPurple"), + new("color.tabActive", "Tab (Active)", () => UIColors.Get("LightlessPurpleActive"), ImGuiCol.TabActive, UiColorKey: "LightlessPurpleActive"), + new("color.tabUnfocused", "Tab (Unfocused)", () => Rgba(40, 40, 40, 255), ImGuiCol.TabUnfocused), + new("color.tabUnfocusedActive", "Tab (Unfocused Active)", () => UIColors.Get("LightlessPurpleActive"), ImGuiCol.TabUnfocusedActive, UiColorKey: "LightlessPurpleActive"), + new("color.dockingPreview", "Docking Preview", () => UIColors.Get("LightlessPurpleActive"), ImGuiCol.DockingPreview, UiColorKey: "LightlessPurpleActive"), + new("color.dockingEmptyBg", "Docking Empty Background", () => Rgba(50, 50, 50, 255), ImGuiCol.DockingEmptyBg), + new("color.plotLines", "Plot Lines", () => Rgba(150, 150, 150, 255), ImGuiCol.PlotLines), + new("color.tableHeaderBg", "Table Header Background", () => Rgba(48, 48, 48, 255), ImGuiCol.TableHeaderBg), + new("color.tableBorderStrong", "Table Border Strong", () => Rgba(79, 79, 89, 255), ImGuiCol.TableBorderStrong), + new("color.tableBorderLight", "Table Border Light", () => Rgba(59, 59, 64, 255), ImGuiCol.TableBorderLight), + new("color.tableRowBg", "Table Row Background", () => Rgba(0, 0, 0, 0), ImGuiCol.TableRowBg), + new("color.tableRowBgAlt", "Table Row Background (Alt)", () => Rgba(255, 255, 255, 15), ImGuiCol.TableRowBgAlt), + new("color.textSelectedBg", "Text Selection Background", () => Rgba(173, 138, 245, 255), ImGuiCol.TextSelectedBg), + new("color.dragDropTarget", "Drag & Drop Target", () => Rgba(173, 138, 245, 255), ImGuiCol.DragDropTarget), + new("color.navHighlight", "Navigation Highlight", () => Rgba(173, 138, 245, 179), ImGuiCol.NavHighlight), + new("color.navWindowingDimBg", "Navigation Window Dim", () => Rgba(204, 204, 204, 51), ImGuiCol.NavWindowingDimBg), + new("color.navWindowingHighlight", "Navigation Window Highlight", () => Rgba(204, 204, 204, 89), ImGuiCol.NavWindowingHighlight) + ]; + + private static readonly StyleVector2Option[] _vector2Options = + [ + new("vector.windowPadding", "Window Padding", () => new Vector2(6f, 6f), ImGuiStyleVar.WindowPadding), + new("vector.framePadding", "Frame Padding", () => new Vector2(4f, 3f), ImGuiStyleVar.FramePadding), + new("vector.cellPadding", "Cell Padding", () => new Vector2(4f, 4f), ImGuiStyleVar.CellPadding), + new("vector.itemSpacing", "Item Spacing", () => new Vector2(4f, 4f), ImGuiStyleVar.ItemSpacing), + new("vector.itemInnerSpacing", "Item Inner Spacing", () => new Vector2(4f, 4f), ImGuiStyleVar.ItemInnerSpacing) + ]; + + private static readonly StyleFloatOption[] _floatOptions = + [ + new("float.indentSpacing", "Indent Spacing", 21f, ImGuiStyleVar.IndentSpacing, 0f, 100f, 0.5f), + new("float.scrollbarSize", "Scrollbar Size", 10f, ImGuiStyleVar.ScrollbarSize, 4f, 30f, 0.5f), + new("float.grabMinSize", "Grab Minimum Size", 20f, ImGuiStyleVar.GrabMinSize, 1f, 80f, 0.5f), + new("float.windowBorderSize", "Window Border Size", 1.5f, ImGuiStyleVar.WindowBorderSize, 0f, 5f, 0.1f), + new("float.childBorderSize", "Child Border Size", 1.5f, ImGuiStyleVar.ChildBorderSize, 0f, 5f, 0.1f), + new("float.popupBorderSize", "Popup Border Size", 1.5f, ImGuiStyleVar.PopupBorderSize, 0f, 5f, 0.1f), + new("float.frameBorderSize", "Frame Border Size", 0f, ImGuiStyleVar.FrameBorderSize, 0f, 5f, 0.1f), + new("float.windowRounding", "Window Rounding", 7f, ImGuiStyleVar.WindowRounding, 0f, 20f, 0.2f), + new("float.childRounding", "Child Rounding", 4f, ImGuiStyleVar.ChildRounding, 0f, 20f, 0.2f), + new("float.frameRounding", "Frame Rounding", 4f, ImGuiStyleVar.FrameRounding, 0f, 20f, 0.2f), + new("float.popupRounding", "Popup Rounding", 4f, ImGuiStyleVar.PopupRounding, 0f, 20f, 0.2f), + new("float.scrollbarRounding", "Scrollbar Rounding", 4f, ImGuiStyleVar.ScrollbarRounding, 0f, 20f, 0.2f), + new("float.grabRounding", "Grab Rounding", 4f, ImGuiStyleVar.GrabRounding, 0f, 20f, 0.2f), + new("float.tabRounding", "Tab Rounding", 4f, ImGuiStyleVar.TabRounding, 0f, 20f, 0.2f) + ]; + + public static IReadOnlyList ColorOptions => _colorOptions; + public static IReadOnlyList FloatOptions => _floatOptions; + public static IReadOnlyList Vector2Options => _vector2Options; + + public static void PushStyle() + { + if (_hasPushed) + PopStyle(); + + if (!ShouldUseTheme) + { + _hasPushed = false; + return; + } + + _hasPushed = true; + _pushedColorCount = 0; + _pushedStyleVarCount = 0; + + foreach (var option in _colorOptions) + Push(option.Target, ResolveColor(option)); + + foreach (var option in _vector2Options) + PushStyleVar(option.Target, ResolveVector(option)); + + foreach (var option in _floatOptions) + PushStyleVar(option.Target, ResolveFloat(option)); + } + + public static void PopStyle() + { + if (!_hasPushed) + return; + + if (_pushedStyleVarCount > 0) + ImGui.PopStyleVar(_pushedStyleVarCount); + if (_pushedColorCount > 0) + ImGui.PopStyleColor(_pushedColorCount); + + _hasPushed = false; + _pushedColorCount = 0; + _pushedStyleVarCount = 0; + } + + private static Vector4 ResolveColor(StyleColorOption option) + { + var defaultValue = NormalizeColorVector(option.DefaultValue()); + if (_themeConfig?.Current.StyleOverrides.TryGetValue(option.Key, out var overrideValue) == true && overrideValue.Color is { } packed) + return PackedColorToVector4(packed); + + return defaultValue; + } + + private static Vector2 ResolveVector(StyleVector2Option option) + { + var value = option.DefaultValue(); + if (_themeConfig?.Current.StyleOverrides.TryGetValue(option.Key, out var overrideValue) == true && overrideValue.Vector2 is { } vectorOverride) + { + value = vectorOverride; + } + + if (option.Min is { } min) + value = Vector2.Max(value, min); + if (option.Max is { } max) + value = Vector2.Min(value, max); + return value; + } + + private static float ResolveFloat(StyleFloatOption option) + { + var value = option.DefaultValue; + if (_themeConfig?.Current.StyleOverrides.TryGetValue(option.Key, out var overrideValue) == true && overrideValue.Float is { } floatOverride) + { + value = floatOverride; + } + + if (option.Min.HasValue) + value = MathF.Max(option.Min.Value, value); + if (option.Max.HasValue) + value = MathF.Min(option.Max.Value, value); + return value; + } + + private static void Push(ImGuiCol col, Vector4 rgba) + { + rgba = NormalizeColorVector(rgba); + ImGui.PushStyleColor(col, rgba); + _pushedColorCount++; + } + + private static void PushStyleVar(ImGuiStyleVar var, float value) + { + ImGui.PushStyleVar(var, value); + _pushedStyleVarCount++; + } + + private static void PushStyleVar(ImGuiStyleVar var, Vector2 value) + { + ImGui.PushStyleVar(var, value); + _pushedStyleVarCount++; + } + + private static Vector4 Rgba(byte r, byte g, byte b, byte a = 255) + => new Vector4(r / 255f, g / 255f, b / 255f, a / 255f); + + internal static Vector4 NormalizeColorVector(Vector4 rgba) + { + if (rgba.X > 1f || rgba.Y > 1f || rgba.Z > 1f || rgba.W > 1f) + rgba /= 255f; + return rgba; + } + + internal static Vector4 PackedColorToVector4(uint color) + => new( + (color & 0xFF) / 255f, + ((color >> 8) & 0xFF) / 255f, + ((color >> 16) & 0xFF) / 255f, + ((color >> 24) & 0xFF) / 255f); } diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index 8a9e6c9..2a6a236 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -1,4 +1,4 @@ -using Dalamud.Bindings.ImGui; +using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; @@ -11,12 +11,8 @@ using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Utils; using LightlessSync.WebAPI; -using Serilog; -using System; -using System.Collections.Generic; using System.Numerics; -using System.Reflection.Emit; -using System.Threading.Tasks; + namespace LightlessSync.UI; @@ -33,13 +29,14 @@ public class TopTabMenu private bool _pairRequestsExpanded; // useless for now private int _lastRequestCount; private readonly UiSharedService _uiSharedService; + private readonly NotificationService _lightlessNotificationService; private string _filter = string.Empty; private int _globalControlCountdown = 0; private float _pairRequestsHeight = 150f; private string _pairToAdd = string.Empty; private SelectedTab _selectedTab = SelectedTab.None; - public TopTabMenu(LightlessMediator lightlessMediator, ApiController apiController, PairManager pairManager, UiSharedService uiSharedService, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService) + public TopTabMenu(LightlessMediator lightlessMediator, ApiController apiController, PairManager pairManager, UiSharedService uiSharedService, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService) { _lightlessMediator = lightlessMediator; _apiController = apiController; @@ -47,6 +44,7 @@ public class TopTabMenu _pairRequestService = pairRequestService; _dalamudUtilService = dalamudUtilService; _uiSharedService = uiSharedService; + _lightlessNotificationService = lightlessNotificationService; } private enum SelectedTab @@ -199,16 +197,79 @@ public class TopTabMenu if (TabSelection != SelectedTab.None) ImGuiHelpers.ScaledDummy(3f); #if DEBUG - if (ImGui.Button("Add Test Pair Request")) + if (ImGui.Button("Test Pair Request")) + { + _lightlessNotificationService.ShowPairRequestNotification( + "Debug User", + "debug-user-id", + onAccept: () => + { + _lightlessMediator.Publish(new NotificationMessage( + "Pair Accepted", + "Debug pair request was accepted!", + NotificationType.Info, + TimeSpan.FromSeconds(3))); + }, + onDecline: () => + { + _lightlessMediator.Publish(new NotificationMessage( + "Pair Declined", + "Debug pair request was declined.", + NotificationType.Warning, + TimeSpan.FromSeconds(3))); + } + ); + } + + ImGui.SameLine(); + if (ImGui.Button("Test Info")) { - var fakeCid = Guid.NewGuid().ToString("N"); - var display = _pairRequestService.RegisterIncomingRequest(fakeCid, "Debug pair request"); _lightlessMediator.Publish(new NotificationMessage( - "Pair request received (debug)", - display.Message, + "Information", + "This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps. This is a test ifno notification with some longer text to see how it wraps.", NotificationType.Info, TimeSpan.FromSeconds(5))); } + + ImGui.SameLine(); + if (ImGui.Button("Test Warning")) + { + _lightlessMediator.Publish(new NotificationMessage( + "Warning", + "This is a test warning notification.", + NotificationType.Warning, + TimeSpan.FromSeconds(7))); + } + + ImGui.SameLine(); + if (ImGui.Button("Test Error")) + { + _lightlessMediator.Publish(new NotificationMessage( + "Error", + "This is a test error notification erp police", + NotificationType.Error, + TimeSpan.FromSeconds(10))); + } + + if (ImGui.Button("Test Download Progress")) + { + var downloadStatus = new List<(string playerName, float progress, string status)> + { + ("Mauwmauw Nekochan", 0.85f, "downloading"), + ("Raelynn Kitsune", 0.34f, "downloading"), + ("Jaina Elraeth", 0.67f, "downloading"), + ("Vaelstra Bloodthorn", 0.19f, "downloading"), + ("Lydia Hera Moondrop", 0.86f, "downloading"), + ("C'liina Star", 1.0f, "completed") + }; + + _lightlessNotificationService.ShowPairDownloadNotification(downloadStatus); + } + ImGui.SameLine(); + if (ImGui.Button("Dismiss Download")) + { + _lightlessNotificationService.DismissPairDownloadNotification(); + } #endif DrawIncomingPairRequests(availableWidth); @@ -401,7 +462,7 @@ public class TopTabMenu try { var myCidHash = (await _dalamudUtilService.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256(); - await _apiController.TryPairWithContentId(request.HashedCid, myCidHash).ConfigureAwait(false); + await _apiController.TryPairWithContentId(request.HashedCid).ConfigureAwait(false); _pairRequestService.RemoveRequest(request.HashedCid); var display = string.IsNullOrEmpty(request.DisplayName) ? request.HashedCid : request.DisplayName; @@ -850,4 +911,4 @@ public class TopTabMenu ImGui.EndPopup(); } } -} +} \ No newline at end of file diff --git a/LightlessSync/UI/UIColors.cs b/LightlessSync/UI/UIColors.cs index 3bd288f..993573d 100644 --- a/LightlessSync/UI/UIColors.cs +++ b/LightlessSync/UI/UIColors.cs @@ -26,6 +26,9 @@ namespace LightlessSync.UI { "LightlessAdminGlow", "#b09343" }, { "LightlessModeratorText", "#94ffda" }, { "LightlessModeratorGlow", "#599c84" }, + + { "Lightfinder", "#ad8af5" }, + { "LightfinderEdge", "#000000" }, }; private static LightlessConfigService? _configService; diff --git a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs index d268dd8..bbac3a6 100644 --- a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs +++ b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs @@ -35,16 +35,16 @@ public partial class ApiController await _lightlessHub!.SendAsync(nameof(UserAddPair), user).ConfigureAwait(false); } - public async Task TryPairWithContentId(string otherCid, string myCid) + public async Task TryPairWithContentId(string otherCid) { if (!IsConnected) return; - await _lightlessHub!.SendAsync(nameof(TryPairWithContentId), otherCid, myCid).ConfigureAwait(false); + await _lightlessHub!.SendAsync(nameof(TryPairWithContentId), otherCid).ConfigureAwait(false); } - public async Task SetBroadcastStatus(string hashedCid, bool enabled, GroupBroadcastRequestDto? groupDto = null) + public async Task SetBroadcastStatus(bool enabled, GroupBroadcastRequestDto? groupDto = null) { CheckConnection(); - await _lightlessHub!.InvokeAsync(nameof(SetBroadcastStatus), hashedCid, enabled, groupDto).ConfigureAwait(false); + await _lightlessHub!.InvokeAsync(nameof(SetBroadcastStatus), enabled, groupDto).ConfigureAwait(false); } public async Task IsUserBroadcasting(string hashedCid) @@ -59,10 +59,10 @@ public partial class ApiController return await _lightlessHub!.InvokeAsync(nameof(AreUsersBroadcasting), hashedCids).ConfigureAwait(false); } - public async Task GetBroadcastTtl(string hashedCid) + public async Task GetBroadcastTtl() { CheckConnection(); - return await _lightlessHub!.InvokeAsync(nameof(GetBroadcastTtl), hashedCid).ConfigureAwait(false); + return await _lightlessHub!.InvokeAsync(nameof(GetBroadcastTtl)).ConfigureAwait(false); } public async Task UserDelete() diff --git a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs index 263c87a..090539b 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs @@ -1,4 +1,4 @@ -using LightlessSync.API.Data; +using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Dto; using LightlessSync.API.Dto.CharaData; @@ -6,6 +6,7 @@ using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services.Mediator; +using LightlessSync.Utils; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Logging; @@ -104,25 +105,27 @@ public partial class ApiController return Task.CompletedTask; } - public Task Client_ReceiveBroadcastPairRequest(UserPairNotificationDto dto) { if (dto == null) return Task.CompletedTask; var request = _pairRequestService.RegisterIncomingRequest(dto.myHashedCid, dto.message ?? string.Empty); + var senderName = string.IsNullOrEmpty(request.DisplayName) ? "Unknown User" : request.DisplayName; - Mediator.Publish(new NotificationMessage( - "Pair request received", - request.Message, - NotificationType.Info, - TimeSpan.FromSeconds(5))); + _lightlessNotificationService.ShowPairRequestNotification( + senderName, + request.HashedCid, + onAccept: () => _pairRequestService.AcceptPairRequest(request.HashedCid, senderName), + onDecline: () => _pairRequestService.DeclinePairRequest(request.HashedCid)); return Task.CompletedTask; } + public Task Client_UpdateSystemInfo(SystemInfoDto systemInfo) { SystemInfoDto = systemInfo; + //Mediator.Publish(new UpdateSystemInfoMessage(systemInfo)); return Task.CompletedTask; } diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index 90be67f..b1170be 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -1,4 +1,4 @@ -using Dalamud.Utility; +using Dalamud.Utility; using LightlessSync.API.Data; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto; @@ -32,6 +32,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL private readonly ServerConfigurationManager _serverManager; private readonly TokenProvider _tokenProvider; private readonly LightlessConfigService _lightlessConfigService; + private readonly NotificationService _lightlessNotificationService; private CancellationTokenSource _connectionCancellationTokenSource; private ConnectionDto? _connectionDto; private bool _doNotNotifyOnNextInfo = false; @@ -44,7 +45,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL public ApiController(ILogger logger, HubFactory hubFactory, DalamudUtilService dalamudUtil, PairManager pairManager, PairRequestService pairRequestService, ServerConfigurationManager serverManager, LightlessMediator mediator, - TokenProvider tokenProvider, LightlessConfigService lightlessConfigService) : base(logger, mediator) + TokenProvider tokenProvider, LightlessConfigService lightlessConfigService, NotificationService lightlessNotificationService) : base(logger, mediator) { _hubFactory = hubFactory; _dalamudUtil = dalamudUtil; @@ -53,6 +54,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL _serverManager = serverManager; _tokenProvider = tokenProvider; _lightlessConfigService = lightlessConfigService; + _lightlessNotificationService = lightlessNotificationService; _connectionCancellationTokenSource = new CancellationTokenSource(); Mediator.Subscribe(this, (_) => DalamudUtilOnLogIn()); diff --git a/PenumbraAPI b/PenumbraAPI index dd14131..648b6fc 160000 --- a/PenumbraAPI +++ b/PenumbraAPI @@ -1 +1 @@ -Subproject commit dd14131793e5ae47cc8e9232f46469216017b5aa +Subproject commit 648b6fc2ce600a95ab2b2ced27e1639af2b04502