diff --git a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs index dcdfc78..f438c45 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs @@ -11,6 +11,8 @@ public sealed class ChatConfig : ILightlessConfiguration public bool ShowRulesOverlayOnOpen { get; set; } = true; public bool ShowMessageTimestamps { get; set; } = true; public float ChatWindowOpacity { get; set; } = .97f; + public bool FadeWhenUnfocused { get; set; } = false; + public float UnfocusedWindowOpacity { get; set; } = 0.6f; public bool IsWindowPinned { get; set; } = false; public bool AutoOpenChatOnPluginLoad { get; set; } = false; public float ChatFontScale { get; set; } = 1.0f; diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index c16b380..9b4055b 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -49,7 +49,8 @@ public class LightlessConfig : ILightlessConfiguration public int DownloadSpeedLimitInBytes { get; set; } = 0; public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps; public bool PreferNotesOverNamesForVisible { get; set; } = false; - public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Default; + public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Alphabetical; + public OnlinePairSortMode OnlinePairSortMode { get; set; } = OnlinePairSortMode.Alphabetical; public float ProfileDelay { get; set; } = 1.5f; public bool ProfilePopoutRight { get; set; } = false; public bool ProfilesAllowNsfw { get; set; } = false; diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs index a7bd80c..5561bfe 100644 --- a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs @@ -1,36 +1,38 @@ -using LightlessSync.API.Data; + using LightlessSync.API.Data; -namespace LightlessSync.PlayerData.Pairs; + namespace LightlessSync.PlayerData.Pairs; -/// -/// orchestrates the lifecycle of a paired character -/// -public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject -{ - new string Ident { get; } - bool Initialized { get; } - bool IsVisible { get; } - bool ScheduledForDeletion { get; set; } - CharacterData? LastReceivedCharacterData { get; } - long LastAppliedDataBytes { get; } - new string? PlayerName { get; } - string PlayerNameHash { get; } - uint PlayerCharacterId { get; } - DateTime? LastDataReceivedAt { get; } - DateTime? LastApplyAttemptAt { get; } - DateTime? LastSuccessfulApplyAt { get; } - string? LastFailureReason { get; } - IReadOnlyList LastBlockingConditions { get; } - bool IsApplying { get; } - bool IsDownloading { get; } - int PendingDownloadCount { get; } - int ForbiddenDownloadCount { get; } + /// + /// orchestrates the lifecycle of a paired character + /// + public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject + { + new string Ident { get; } + bool Initialized { get; } + bool IsVisible { get; } + bool ScheduledForDeletion { get; set; } + CharacterData? LastReceivedCharacterData { get; } + long LastAppliedDataBytes { get; } + new string? PlayerName { get; } + string PlayerNameHash { get; } + uint PlayerCharacterId { get; } + DateTime? LastDataReceivedAt { get; } + DateTime? LastApplyAttemptAt { get; } + DateTime? LastSuccessfulApplyAt { get; } + string? LastFailureReason { get; } + IReadOnlyList LastBlockingConditions { get; } + bool IsApplying { get; } + bool IsDownloading { get; } + int PendingDownloadCount { get; } + int ForbiddenDownloadCount { get; } + DateTime? InvisibleSinceUtc { get; } + DateTime? VisibilityEvictionDueAtUtc { get; } void Initialize(); - void ApplyData(CharacterData data); - void ApplyLastReceivedData(bool forced = false); - bool FetchPerformanceMetricsFromCache(); - void LoadCachedCharacterData(CharacterData data); - void SetUploading(bool uploading); - void SetPaused(bool paused); -} + void ApplyData(CharacterData data); + void ApplyLastReceivedData(bool forced = false); + bool FetchPerformanceMetricsFromCache(); + void LoadCachedCharacterData(CharacterData data); + void SetUploading(bool uploading); + void SetPaused(bool paused); + } diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index 7d780dd..935b705 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -194,9 +194,13 @@ public class Pair { var handler = TryGetHandler(); if (handler is null) - { return PairDebugInfo.Empty; - } + + var now = DateTime.UtcNow; + var dueAt = handler.VisibilityEvictionDueAtUtc; + var remainingSeconds = dueAt.HasValue + ? Math.Max(0, (dueAt.Value - now).TotalSeconds) + : (double?)null; return new PairDebugInfo( true, @@ -206,6 +210,9 @@ public class Pair handler.LastDataReceivedAt, handler.LastApplyAttemptAt, handler.LastSuccessfulApplyAt, + handler.InvisibleSinceUtc, + handler.VisibilityEvictionDueAtUtc, + remainingSeconds, handler.LastFailureReason, handler.LastBlockingConditions, handler.IsApplying, diff --git a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs index 9074c82..31c3236 100644 --- a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs +++ b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs @@ -8,6 +8,9 @@ public sealed record PairDebugInfo( DateTime? LastDataReceivedAt, DateTime? LastApplyAttemptAt, DateTime? LastSuccessfulApplyAt, + DateTime? InvisibleSinceUtc, + DateTime? VisibilityEvictionDueAtUtc, + double? VisibilityEvictionRemainingSeconds, string? LastFailureReason, IReadOnlyList BlockingConditions, bool IsApplying, @@ -24,6 +27,9 @@ public sealed record PairDebugInfo( null, null, null, + null, + null, + null, Array.Empty(), false, false, diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 556dd84..706b0bc 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -70,7 +70,14 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private DateTime? _lastSuccessfulApplyAt; private string? _lastFailureReason; private IReadOnlyList _lastBlockingConditions = Array.Empty(); + private readonly object _visibilityGraceGate = new(); + private CancellationTokenSource? _visibilityGraceCts; + private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1); + private DateTime? _invisibleSinceUtc; + private DateTime? _visibilityEvictionDueAtUtc; + public DateTime? InvisibleSinceUtc => _invisibleSinceUtc; + public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc; public string Ident { get; } public bool Initialized { get; private set; } public bool ScheduledForDeletion { get; set; } @@ -80,24 +87,37 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa get => _isVisible; private set { - if (_isVisible != value) + if (_isVisible == value) return; + + _isVisible = value; + + if (!_isVisible) { - _isVisible = value; - if (!_isVisible) - { - DisableSync(); - ResetPenumbraCollection(reason: "VisibilityLost"); - } - else if (_charaHandler is not null && _charaHandler.Address != nint.Zero) - { - _ = EnsurePenumbraCollection(); - } - var user = GetPrimaryUserData(); - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), - EventSeverity.Informational, "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible")))); - Mediator.Publish(new RefreshUiMessage()); - Mediator.Publish(new VisibilityChange()); + DisableSync(); + + _invisibleSinceUtc = DateTime.UtcNow; + _visibilityEvictionDueAtUtc = _invisibleSinceUtc.Value.Add(VisibilityEvictionGrace); + + StartVisibilityGraceTask(); } + else + { + CancelVisibilityGraceTask(); + + _invisibleSinceUtc = null; + _visibilityEvictionDueAtUtc = null; + + ScheduledForDeletion = false; + + if (_charaHandler is not null && _charaHandler.Address != nint.Zero) + _ = EnsurePenumbraCollection(); + } + + var user = GetPrimaryUserData(); + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), + EventSeverity.Informational, "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible")))); + Mediator.Publish(new RefreshUiMessage()); + Mediator.Publish(new VisibilityChange()); } } @@ -918,6 +938,46 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } + private void CancelVisibilityGraceTask() + { + lock (_visibilityGraceGate) + { + _visibilityGraceCts?.CancelDispose(); + _visibilityGraceCts = null; + } + } + + private void StartVisibilityGraceTask() + { + CancellationToken token; + lock (_visibilityGraceGate) + { + _visibilityGraceCts = _visibilityGraceCts?.CancelRecreate() ?? new CancellationTokenSource(); + token = _visibilityGraceCts.Token; + } + + _visibilityGraceTask = Task.Run(async () => + { + try + { + await Task.Delay(VisibilityEvictionGrace, token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + if (IsVisible) return; + + ScheduledForDeletion = true; + ResetPenumbraCollection(reason: "VisibilityLostTimeout"); + } + catch (OperationCanceledException) + { + // operation cancelled, do nothing + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Visibility grace task failed for {handler}", GetLogIdentifier()); + } + }, CancellationToken.None); + } + protected override void Dispose(bool disposing) { base.Dispose(disposing); @@ -936,7 +996,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _downloadCancellationTokenSource = null; _downloadManager.Dispose(); _charaHandler?.Dispose(); + CancelVisibilityGraceTask(); _charaHandler = null; + _invisibleSinceUtc = null; + _visibilityEvictionDueAtUtc = null; if (!string.IsNullOrEmpty(name)) { @@ -1265,6 +1328,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } private Task? _pairDownloadTask; + private Task _visibilityGraceTask; private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken) diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs index f490804..ec05ee7 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs @@ -11,7 +11,9 @@ public sealed class PairHandlerRegistry : IDisposable { private readonly object _gate = new(); private readonly object _pendingGate = new(); + private readonly object _visibilityGate = new(); private readonly Dictionary _entriesByIdent = new(StringComparer.Ordinal); + private readonly Dictionary _pendingInvisibleEvictions = new(StringComparer.Ordinal); private readonly Dictionary _entriesByHandler = new(ReferenceEqualityComparer.Instance); private readonly IPairHandlerAdapterFactory _handlerFactory; @@ -144,6 +146,37 @@ public sealed class PairHandlerRegistry : IDisposable return PairOperationResult.Ok(registration.PairIdent); } + private PairOperationResult CancelAllInvisibleEvictions() + { + List snapshot; + lock (_visibilityGate) + { + snapshot = [.. _pendingInvisibleEvictions.Values]; + _pendingInvisibleEvictions.Clear(); + } + + List? errors = null; + + foreach (var cts in snapshot) + { + try { cts.Cancel(); } + catch (Exception ex) + { + (errors ??= new List()).Add($"Cancel: {ex.Message}"); + } + + try { cts.Dispose(); } + catch (Exception ex) + { + (errors ??= new List()).Add($"Dispose: {ex.Message}"); + } + } + + return errors is null + ? PairOperationResult.Ok() + : PairOperationResult.Fail($"CancelAllInvisibleEvictions had error(s): {string.Join(" | ", errors)}"); + } + public PairOperationResult ApplyCharacterData(PairRegistration registration, OnlineUserCharaDataDto dto) { if (registration.CharacterIdent is null) @@ -300,6 +333,7 @@ public sealed class PairHandlerRegistry : IDisposable lock (_gate) { handlers = _entriesByHandler.Keys.ToList(); + CancelAllInvisibleEvictions(); _entriesByIdent.Clear(); _entriesByHandler.Clear(); } @@ -332,6 +366,7 @@ public sealed class PairHandlerRegistry : IDisposable lock (_gate) { handlers = _entriesByHandler.Keys.ToList(); + CancelAllInvisibleEvictions(); _entriesByIdent.Clear(); _entriesByHandler.Clear(); } diff --git a/LightlessSync/Services/CommandManagerService.cs b/LightlessSync/Services/CommandManagerService.cs index d42a865..0014b3a 100644 --- a/LightlessSync/Services/CommandManagerService.cs +++ b/LightlessSync/Services/CommandManagerService.cs @@ -49,7 +49,7 @@ public sealed class CommandManagerService : IDisposable "\t /light analyze - Opens the Lightless Character Data Analysis window" + Environment.NewLine + "\t /light settings - Opens the Lightless Settings window" + Environment.NewLine + "\t /light finder - Opens the Lightfinder window" + Environment.NewLine + - "\t /light finder - Opens the Lightless Chat window" + "\t /light chat - Opens the Lightless Chat window" }); } diff --git a/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs b/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs index f360ba3..20d9a8f 100644 --- a/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs +++ b/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs @@ -126,11 +126,11 @@ public sealed class TextureMetadataHelper private const string TextureSegment = "/texture/"; private const string MaterialSegment = "/material/"; - private const uint NormalSamplerId = 0x0C5EC1F1u; - private const uint IndexSamplerId = 0x565F8FD8u; - private const uint SpecularSamplerId = 0x2B99E025u; - private const uint DiffuseSamplerId = 0x115306BEu; - private const uint MaskSamplerId = 0x8A4E82B6u; + private const uint NormalSamplerId = ShpkFile.NormalSamplerId; + private const uint IndexSamplerId = ShpkFile.IndexSamplerId; + private const uint SpecularSamplerId = ShpkFile.SpecularSamplerId; + private const uint DiffuseSamplerId = ShpkFile.DiffuseSamplerId; + private const uint MaskSamplerId = ShpkFile.MaskSamplerId; public TextureMetadataHelper(ILogger logger, IDataManager dataManager) { diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index cd758f5..b1195b4 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -843,12 +843,16 @@ public class CompactUi : WindowMediatorSubscriberBase //Filter of not grouped/foldered and offline pairs var allOnlineNotTaggedPairs = SortEntries(allEntries.Where(FilterNotTaggedUsers)); - var onlineNotTaggedPairs = SortEntries(filteredEntries.Where(e => FilterNotTaggedUsers(e) && FilterOnlineOrPausedSelf(e))); - - if (allOnlineNotTaggedPairs.Count > 0) - { + if (allOnlineNotTaggedPairs.Count > 0 && _configService.Current.ShowOfflineUsersSeparately) { + var filteredOnlineEntries = SortOnlineEntries(filteredEntries.Where(e => FilterNotTaggedUsers(e) && FilterOnlineOrPausedSelf(e))); drawFolders.Add(_drawEntityFactory.CreateTagFolder( - _configService.Current.ShowOfflineUsersSeparately ? TagHandler.CustomOnlineTag : TagHandler.CustomAllTag, + TagHandler.CustomOnlineTag, + filteredOnlineEntries, + allOnlineNotTaggedPairs)); + } else if (allOnlineNotTaggedPairs.Count > 0 && !_configService.Current.ShowOfflineUsersSeparately) { + var onlineNotTaggedPairs = SortEntries(filteredEntries.Where(FilterNotTaggedUsers)); + drawFolders.Add(_drawEntityFactory.CreateTagFolder( + TagHandler.CustomAllTag, onlineNotTaggedPairs, allOnlineNotTaggedPairs)); } @@ -885,7 +889,7 @@ public class CompactUi : WindowMediatorSubscriberBase } } - private bool PassesFilter(PairUiEntry entry, string filter) + private static bool PassesFilter(PairUiEntry entry, string filter) { if (string.IsNullOrEmpty(filter)) return true; @@ -946,6 +950,17 @@ public class CompactUi : WindowMediatorSubscriberBase }; } + private ImmutableList SortOnlineEntries(IEnumerable entries) + { + var entryList = entries.ToList(); + return _configService.Current.OnlinePairSortMode switch + { + OnlinePairSortMode.Alphabetical => [.. entryList.OrderBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)], + OnlinePairSortMode.PreferredDirectPairs => SortVisibleByPreferred(entryList), + _ => SortEntries(entryList), + }; + } + private ImmutableList SortVisibleByMetric(IEnumerable entries, Func selector) { return [.. entries diff --git a/LightlessSync/UI/Components/DrawFolderBase.cs b/LightlessSync/UI/Components/DrawFolderBase.cs index 40330c7..0532da9 100644 --- a/LightlessSync/UI/Components/DrawFolderBase.cs +++ b/LightlessSync/UI/Components/DrawFolderBase.cs @@ -4,8 +4,8 @@ using Dalamud.Interface.Utility.Raii; using LightlessSync.UI.Handlers; using LightlessSync.UI.Models; using System.Collections.Immutable; -using LightlessSync.UI; using LightlessSync.UI.Style; +using OtterGui.Text; namespace LightlessSync.UI.Components; @@ -113,9 +113,13 @@ public abstract class DrawFolderBase : IDrawFolder using var indent = ImRaii.PushIndent(_uiSharedService.GetIconSize(FontAwesomeIcon.EllipsisV).X + ImGui.GetStyle().ItemSpacing.X, false); if (DrawPairs.Any()) { - foreach (var item in DrawPairs) + using var clipper = ImUtf8.ListClipper(DrawPairs.Count, ImGui.GetFrameHeightWithSpacing()); + while (clipper.Step()) { - item.DrawPairedClient(); + for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) + { + DrawPairs[i].DrawPairedClient(); + } } } else diff --git a/LightlessSync/UI/Components/DrawFolderTag.cs b/LightlessSync/UI/Components/DrawFolderTag.cs index dcba0d4..b91617a 100644 --- a/LightlessSync/UI/Components/DrawFolderTag.cs +++ b/LightlessSync/UI/Components/DrawFolderTag.cs @@ -169,11 +169,16 @@ public class DrawFolderTag : DrawFolderBase protected override float DrawRightSide(float currentRightSideX) { - if (_id == TagHandler.CustomVisibleTag) + if (string.Equals(_id, TagHandler.CustomVisibleTag, StringComparison.Ordinal)) { return DrawVisibleFilter(currentRightSideX); } + if (string.Equals(_id, TagHandler.CustomOnlineTag, StringComparison.Ordinal)) + { + return DrawOnlineFilter(currentRightSideX); + } + if (!RenderPause) { return currentRightSideX; @@ -254,7 +259,7 @@ public class DrawFolderTag : DrawFolderBase foreach (VisiblePairSortMode mode in Enum.GetValues()) { var selected = _configService.Current.VisiblePairSortMode == mode; - if (ImGui.MenuItem(GetSortLabel(mode), string.Empty, selected)) + if (ImGui.MenuItem(GetSortVisibleLabel(mode), string.Empty, selected)) { if (!selected) { @@ -273,7 +278,49 @@ public class DrawFolderTag : DrawFolderBase return buttonStart - spacingX; } - private static string GetSortLabel(VisiblePairSortMode mode) => mode switch + private float DrawOnlineFilter(float currentRightSideX) + { + var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Filter); + var spacingX = ImGui.GetStyle().ItemSpacing.X; + var buttonStart = currentRightSideX - buttonSize.X; + + ImGui.SameLine(buttonStart); + if (_uiSharedService.IconButton(FontAwesomeIcon.Filter)) + { + SuppressNextRowToggle(); + ImGui.OpenPopup($"online-filter-{_id}"); + } + + UiSharedService.AttachToolTip("Adjust how online pairs are ordered."); + + if (ImGui.BeginPopup($"online-filter-{_id}")) + { + ImGui.TextUnformatted("Online Pair Ordering"); + ImGui.Separator(); + + foreach (OnlinePairSortMode mode in Enum.GetValues()) + { + var selected = _configService.Current.OnlinePairSortMode == mode; + if (ImGui.MenuItem(GetSortOnlineLabel(mode), string.Empty, selected)) + { + if (!selected) + { + _configService.Current.OnlinePairSortMode = mode; + _configService.Save(); + _mediator.Publish(new RefreshUiMessage()); + } + + ImGui.CloseCurrentPopup(); + } + } + + ImGui.EndPopup(); + } + + return buttonStart - spacingX; + } + + private static string GetSortVisibleLabel(VisiblePairSortMode mode) => mode switch { VisiblePairSortMode.Alphabetical => "Alphabetical", VisiblePairSortMode.VramUsage => "VRAM usage (descending)", @@ -282,4 +329,11 @@ public class DrawFolderTag : DrawFolderBase VisiblePairSortMode.PreferredDirectPairs => "Preferred permissions & Direct pairs", _ => "Default", }; + + private static string GetSortOnlineLabel(OnlinePairSortMode mode) => mode switch + { + OnlinePairSortMode.Alphabetical => "Alphabetical", + OnlinePairSortMode.PreferredDirectPairs => "Preferred permissions & Direct pairs", + _ => "Default", + }; } \ No newline at end of file diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index ca74bc9..22911cb 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -301,6 +301,14 @@ namespace LightlessSync.UI bool ShellFinderEnabled = _configService.Current.SyncshellFinderEnabled; bool isBroadcasting = _broadcastService.IsBroadcasting; + if (isBroadcasting) + { + var warningColor = UIColors.Get("LightlessYellow"); + _uiSharedService.DrawNoteLine("! ", warningColor, + new SeStringUtils.RichTextEntry("Syncshell Finder can only be changed while Lightfinder is disabled.", warningColor)); + ImGuiHelpers.ScaledDummy(0.2f); + } + if (isBroadcasting) ImGui.BeginDisabled(); diff --git a/LightlessSync/UI/Models/OnlinePairSortMode.cs b/LightlessSync/UI/Models/OnlinePairSortMode.cs new file mode 100644 index 0000000..ff85b9c --- /dev/null +++ b/LightlessSync/UI/Models/OnlinePairSortMode.cs @@ -0,0 +1,7 @@ +namespace LightlessSync.UI.Models; + +public enum OnlinePairSortMode +{ + Alphabetical = 0, + PreferredDirectPairs = 1, +} diff --git a/LightlessSync/UI/Models/VisiblePairSortMode.cs b/LightlessSync/UI/Models/VisiblePairSortMode.cs index fcb1d65..ec133b9 100644 --- a/LightlessSync/UI/Models/VisiblePairSortMode.cs +++ b/LightlessSync/UI/Models/VisiblePairSortMode.cs @@ -2,10 +2,9 @@ namespace LightlessSync.UI.Models; public enum VisiblePairSortMode { - Default = 0, - Alphabetical = 1, - VramUsage = 2, - EffectiveVramUsage = 3, - TriangleCount = 4, - PreferredDirectPairs = 5, + Alphabetical = 0, + VramUsage = 1, + EffectiveVramUsage = 2, + TriangleCount = 3, + PreferredDirectPairs = 4, } diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 4ce64ac..c30d5fa 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1463,7 +1463,10 @@ public class SettingsUi : WindowMediatorSubscriberBase DrawPairPropertyRow("Has Handler", FormatBool(debugInfo.HasHandler)); DrawPairPropertyRow("Handler Initialized", FormatBool(debugInfo.HandlerInitialized)); DrawPairPropertyRow("Handler Visible", FormatBool(debugInfo.HandlerVisible)); + DrawPairPropertyRow("Last Time person rendered in", FormatTimestamp(debugInfo.InvisibleSinceUtc)); + DrawPairPropertyRow("Handler Timer Temp Collection removal", FormatCountdown(debugInfo.VisibilityEvictionRemainingSeconds)); DrawPairPropertyRow("Handler Scheduled For Deletion", FormatBool(debugInfo.HandlerScheduledForDeletion)); + DrawPairPropertyRow("Note", pair.GetNote() ?? "(none)"); ImGui.EndTable(); } @@ -1698,6 +1701,19 @@ public class SettingsUi : WindowMediatorSubscriberBase return value is null ? "n/a" : value.Value.ToLocalTime().ToString("G", CultureInfo.CurrentCulture); } + private static string? FormatCountdown(double? remainingSeconds) + { + if (!remainingSeconds.HasValue) + return "No"; + + var secs = Math.Max(0, remainingSeconds.Value); + var t = TimeSpan.FromSeconds(secs); + + return t.TotalHours >= 1 + ? $"{(int)t.TotalHours:00}:{t.Minutes:00}:{t.Seconds:00}" + : $"{(int)t.TotalMinutes:00}:{t.Seconds:00}"; + } + private static string FormatBytes(long value) => value < 0 ? "n/a" : UiSharedService.ByteToString(value); private static string FormatCharacterId(uint id) => id == uint.MaxValue ? "n/a" : $"{id} (0x{id:X8})"; diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index 2f215a1..0586c06 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -350,9 +350,9 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase ? shell.Group.Alias : shell.Group.GID; + var style = ImGui.GetStyle(); float startX = ImGui.GetCursorPosX(); - float availWidth = ImGui.GetContentRegionAvail().X; - float rightTextW = ImGui.CalcTextSize(broadcasterName).X; + float availW = ImGui.GetContentRegionAvail().X; ImGui.BeginGroup(); @@ -364,13 +364,45 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); } - ImGui.SameLine(); - float rightX = startX + availWidth - rightTextW; - var pos = ImGui.GetCursorPos(); - ImGui.SetCursorPos(new Vector2(rightX, pos.Y + 3f * ImGuiHelpers.GlobalScale)); - ImGui.TextUnformatted(broadcasterName); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Broadcaster of the syncshell."); + float nameRightX = ImGui.GetItemRectMax().X; + + var regionMinScreen = ImGui.GetCursorScreenPos(); + float regionRightX = regionMinScreen.X + availW; + + float minBroadcasterX = nameRightX + style.ItemSpacing.X; + + float maxBroadcasterWidth = regionRightX - minBroadcasterX; + + string broadcasterToShow = broadcasterName; + + if (!string.IsNullOrEmpty(broadcasterName) && maxBroadcasterWidth > 0f) + { + float bcFullWidth = ImGui.CalcTextSize(broadcasterName).X; + string toolTip; + + if (bcFullWidth > maxBroadcasterWidth) + { + broadcasterToShow = TruncateTextToWidth(broadcasterName, maxBroadcasterWidth); + toolTip = broadcasterName + Environment.NewLine + Environment.NewLine + "Broadcaster of the syncshell."; + } + else + { + toolTip = "Broadcaster of the syncshell."; + } + + float bcWidth = ImGui.CalcTextSize(broadcasterToShow).X; + + float broadX = regionRightX - bcWidth; + + broadX = MathF.Max(broadX, minBroadcasterX); + + ImGui.SameLine(); + var curPos = ImGui.GetCursorPos(); + ImGui.SetCursorPos(new Vector2(broadX - regionMinScreen.X + startX, curPos.Y + 3f * ImGuiHelpers.GlobalScale)); + ImGui.TextUnformatted(broadcasterToShow); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(toolTip); + } ImGui.EndGroup(); @@ -590,6 +622,40 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase float widthUsed = cursorLocalX - baseLocal.X; return (widthUsed, rowHeight); } + private static string TruncateTextToWidth(string text, float maxWidth) + { + if (string.IsNullOrEmpty(text)) + return text; + + const string ellipsis = "..."; + float ellipsisWidth = ImGui.CalcTextSize(ellipsis).X; + + if (maxWidth <= ellipsisWidth) + return ellipsis; + + int low = 0; + int high = text.Length; + string best = ellipsis; + + while (low <= high) + { + int mid = (low + high) / 2; + string candidate = string.Concat(text.AsSpan(0, mid), ellipsis); + float width = ImGui.CalcTextSize(candidate).X; + + if (width <= maxWidth) + { + best = candidate; + low = mid + 1; + } + else + { + high = mid - 1; + } + } + + return best; + } private IDalamudTextureWrap? GetIconWrap(uint iconId) { diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index d45427c..396e63c 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -11,9 +11,11 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services; using LightlessSync.Services.Chat; +using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Services; +using LightlessSync.UI.Style; using LightlessSync.Utils; using LightlessSync.WebAPI; using LightlessSync.WebAPI.SignalR.Utils; @@ -29,10 +31,13 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private const string ReportPopupId = "Report Message##zone_chat_report_popup"; private const string ChannelDragPayloadId = "zone_chat_channel_drag"; private const float DefaultWindowOpacity = .97f; + private const float DefaultUnfocusedWindowOpacity = 0.6f; private const float MinWindowOpacity = 0.05f; private const float MaxWindowOpacity = 1f; private const float MinChatFontScale = 0.75f; private const float MaxChatFontScale = 1.5f; + private const float UnfocusedFadeOutSpeed = 0.22f; + private const float FocusFadeInSpeed = 2.0f; private const int ReportReasonMaxLength = 500; private const int ReportContextMaxLength = 1000; private const int MaxChannelNoteTabLength = 25; @@ -40,6 +45,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private readonly UiSharedService _uiSharedService; private readonly ZoneChatService _zoneChatService; private readonly PairUiService _pairUiService; + private readonly LightFinderService _lightFinderService; private readonly LightlessProfileManager _profileManager; private readonly ApiController _apiController; private readonly ChatConfigService _chatConfigService; @@ -49,16 +55,20 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private readonly Dictionary _draftMessages = new(StringComparer.Ordinal); private readonly ImGuiWindowFlags _unpinnedWindowFlags; private float _currentWindowOpacity = DefaultWindowOpacity; + private float _baseWindowOpacity = DefaultWindowOpacity; private bool _isWindowPinned; private bool _showRulesOverlay; private bool _refocusChatInput; private string? _refocusChatInputKey; + private bool _isWindowFocused = true; + private int _titleBarStylePopCount; private string? _selectedChannelKey; private bool _scrollToBottom = true; private float? _pendingChannelScroll; private float _channelScroll; private float _channelScrollMax; + private readonly SeluneBrush _seluneBrush = new(); private ChatChannelSnapshot? _reportTargetChannel; private ChatMessageEntry? _reportTargetMessage; private string _reportReason = string.Empty; @@ -79,6 +89,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase UiSharedService uiSharedService, ZoneChatService zoneChatService, PairUiService pairUiService, + LightFinderService lightFinderService, LightlessProfileManager profileManager, ChatConfigService chatConfigService, ServerConfigurationManager serverConfigurationManager, @@ -91,6 +102,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _uiSharedService = uiSharedService; _zoneChatService = zoneChatService; _pairUiService = pairUiService; + _lightFinderService = lightFinderService; _profileManager = profileManager; _chatConfigService = chatConfigService; _serverConfigurationManager = serverConfigurationManager; @@ -124,8 +136,25 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { RefreshWindowFlags(); base.PreDraw(); - _currentWindowOpacity = Math.Clamp(_chatConfigService.Current.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity); + var config = _chatConfigService.Current; + var baseOpacity = Math.Clamp(config.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity); + _baseWindowOpacity = baseOpacity; + + if (config.FadeWhenUnfocused) + { + var unfocusedOpacity = Math.Clamp(config.UnfocusedWindowOpacity, MinWindowOpacity, MaxWindowOpacity); + var targetOpacity = _isWindowFocused ? baseOpacity : Math.Min(baseOpacity, unfocusedOpacity); + var delta = ImGui.GetIO().DeltaTime; + var speed = _isWindowFocused ? FocusFadeInSpeed : UnfocusedFadeOutSpeed; + _currentWindowOpacity = MoveTowards(_currentWindowOpacity, targetOpacity, speed * delta); + } + else + { + _currentWindowOpacity = baseOpacity; + } + ImGui.SetNextWindowBgAlpha(_currentWindowOpacity); + PushTitleBarFadeColors(_currentWindowOpacity); } private void UpdateHideState() @@ -179,8 +208,36 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase protected override void DrawInternal() { + if (_titleBarStylePopCount > 0) + { + ImGui.PopStyleColor(_titleBarStylePopCount); + _titleBarStylePopCount = 0; + } + + var config = _chatConfigService.Current; + var isFocused = ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows); + var isHovered = ImGui.IsWindowHovered(ImGuiHoveredFlags.RootAndChildWindows); + if (config.FadeWhenUnfocused && isHovered && !isFocused) + { + ImGui.SetWindowFocus(); + } + + _isWindowFocused = config.FadeWhenUnfocused ? (isFocused || isHovered) : isFocused; + + var contentAlpha = 1f; + if (config.FadeWhenUnfocused) + { + var baseOpacity = MathF.Max(_baseWindowOpacity, 0.001f); + contentAlpha = Math.Clamp(_currentWindowOpacity / baseOpacity, 0f, 1f); + } + + using var alpha = ImRaii.PushStyle(ImGuiStyleVar.Alpha, contentAlpha); + var drawList = ImGui.GetWindowDrawList(); + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize); var childBgColor = ImGui.GetStyle().Colors[(int)ImGuiCol.ChildBg]; - childBgColor.W *= _currentWindowOpacity; + childBgColor.W *= _baseWindowOpacity; using var childBg = ImRaii.PushColor(ImGuiCol.ChildBg, childBgColor); DrawConnectionControls(); @@ -192,36 +249,58 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); ImGui.TextWrapped("No chat channels available."); ImGui.PopStyleColor(); - return; } - - EnsureSelectedChannel(channels); - CleanupDrafts(channels); - - DrawChannelButtons(channels); - - if (_selectedChannelKey is null) - return; - - var activeChannel = channels.FirstOrDefault(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal)); - if (activeChannel.Equals(default(ChatChannelSnapshot))) + else { - activeChannel = channels[0]; - _selectedChannelKey = activeChannel.Key; + EnsureSelectedChannel(channels); + CleanupDrafts(channels); + + DrawChannelButtons(channels); + + if (_selectedChannelKey is null) + { + selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); + return; + } + + var activeChannel = channels.FirstOrDefault(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal)); + if (activeChannel.Equals(default(ChatChannelSnapshot))) + { + activeChannel = channels[0]; + _selectedChannelKey = activeChannel.Key; + } + + _zoneChatService.SetActiveChannel(activeChannel.Key); + + DrawHeader(activeChannel); + ImGui.Separator(); + DrawMessageArea(activeChannel, _currentWindowOpacity); + ImGui.Separator(); + DrawInput(activeChannel); } - _zoneChatService.SetActiveChannel(activeChannel.Key); - - DrawHeader(activeChannel); - ImGui.Separator(); - DrawMessageArea(activeChannel, _currentWindowOpacity); - ImGui.Separator(); - DrawInput(activeChannel); - if (_showRulesOverlay) { DrawRulesOverlay(); } + + selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); + } + + private void PushTitleBarFadeColors(float opacity) + { + _titleBarStylePopCount = 0; + var alpha = Math.Clamp(opacity, 0f, 1f); + var colors = ImGui.GetStyle().Colors; + + var titleBg = colors[(int)ImGuiCol.TitleBg]; + var titleBgActive = colors[(int)ImGuiCol.TitleBgActive]; + var titleBgCollapsed = colors[(int)ImGuiCol.TitleBgCollapsed]; + + ImGui.PushStyleColor(ImGuiCol.TitleBg, new Vector4(titleBg.X, titleBg.Y, titleBg.Z, titleBg.W * alpha)); + ImGui.PushStyleColor(ImGuiCol.TitleBgActive, new Vector4(titleBgActive.X, titleBgActive.Y, titleBgActive.Z, titleBgActive.W * alpha)); + ImGui.PushStyleColor(ImGuiCol.TitleBgCollapsed, new Vector4(titleBgCollapsed.X, titleBgCollapsed.Y, titleBgCollapsed.Z, titleBgCollapsed.W * alpha)); + _titleBarStylePopCount = 3; } private void DrawHeader(ChatChannelSnapshot channel) @@ -1119,18 +1198,56 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var groupSize = ImGui.GetItemRectSize(); var minBlockX = cursorStart.X + groupSize.X + style.ItemSpacing.X; var availableAfterGroup = contentRightX - (cursorStart.X + groupSize.X); + var lightfinderButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.PersonCirclePlus).X; var settingsButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Cog).X; var pinIcon = _isWindowPinned ? FontAwesomeIcon.Lock : FontAwesomeIcon.Unlock; var pinButtonWidth = _uiSharedService.GetIconButtonSize(pinIcon).X; - var blockWidth = rulesButtonWidth + style.ItemSpacing.X + settingsButtonWidth + style.ItemSpacing.X + pinButtonWidth; + var blockWidth = lightfinderButtonWidth + style.ItemSpacing.X + rulesButtonWidth + style.ItemSpacing.X + settingsButtonWidth + style.ItemSpacing.X + pinButtonWidth; var desiredBlockX = availableAfterGroup > blockWidth + style.ItemSpacing.X ? contentRightX - blockWidth : minBlockX; desiredBlockX = Math.Max(cursorStart.X, desiredBlockX); - var rulesPos = new Vector2(desiredBlockX, cursorStart.Y); - var settingsPos = new Vector2(desiredBlockX + rulesButtonWidth + style.ItemSpacing.X, cursorStart.Y); + var lightfinderPos = new Vector2(desiredBlockX, cursorStart.Y); + var rulesPos = new Vector2(lightfinderPos.X + lightfinderButtonWidth + style.ItemSpacing.X, cursorStart.Y); + var settingsPos = new Vector2(rulesPos.X + rulesButtonWidth + style.ItemSpacing.X, cursorStart.Y); var pinPos = new Vector2(settingsPos.X + settingsButtonWidth + style.ItemSpacing.X, cursorStart.Y); + ImGui.SameLine(); + ImGui.SetCursorPos(lightfinderPos); + var lightfinderEnabled = _lightFinderService.IsBroadcasting; + var lightfinderColor = lightfinderEnabled ? UIColors.Get("LightlessGreen") : ImGuiColors.DalamudGrey3; + var lightfinderButtonSize = new Vector2(lightfinderButtonWidth, ImGui.GetFrameHeight()); + ImGui.InvisibleButton("zone_chat_lightfinder_button", lightfinderButtonSize); + var lightfinderMin = ImGui.GetItemRectMin(); + var lightfinderMax = ImGui.GetItemRectMax(); + var iconSize = _uiSharedService.GetIconSize(FontAwesomeIcon.PersonCirclePlus); + var iconPos = new Vector2( + lightfinderMin.X + (lightfinderButtonSize.X - iconSize.X) * 0.5f, + lightfinderMin.Y + (lightfinderButtonSize.Y - iconSize.Y) * 0.5f); + using (_uiSharedService.IconFont.Push()) + { + ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(lightfinderColor), FontAwesomeIcon.PersonCirclePlus.ToIconString()); + } + + if (ImGui.IsItemClicked()) + { + Mediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); + } + if (ImGui.IsItemHovered()) + { + var padding = new Vector2(8f * ImGuiHelpers.GlobalScale); + Selune.RegisterHighlight( + lightfinderMin - padding, + lightfinderMax + padding, + SeluneHighlightMode.Point, + exactSize: true, + clipToElement: true, + clipPadding: padding, + highlightColorOverride: lightfinderColor, + highlightAlphaOverride: 0.2f); + ImGui.SetTooltip("If Lightfinder is enabled, you will be able to see the character names of other Lightfinder users in the same zone when they send a message."); + } + ImGui.SameLine(); ImGui.SetCursorPos(rulesPos); if (ImGui.Button("Rules", new Vector2(rulesButtonWidth, 0f))) @@ -1376,9 +1493,55 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetTooltip("Adjust chat window transparency.\nRight-click to reset to default."); } + var fadeUnfocused = chatConfig.FadeWhenUnfocused; + if (ImGui.Checkbox("Fade window when unfocused", ref fadeUnfocused)) + { + chatConfig.FadeWhenUnfocused = fadeUnfocused; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("When enabled, the chat window fades after it loses focus.\nHovering the window restores focus."); + } + + ImGui.BeginDisabled(!fadeUnfocused); + var unfocusedOpacity = Math.Clamp(chatConfig.UnfocusedWindowOpacity, MinWindowOpacity, MaxWindowOpacity); + var unfocusedChanged = ImGui.SliderFloat("Unfocused transparency", ref unfocusedOpacity, MinWindowOpacity, MaxWindowOpacity, "%.2f"); + var resetUnfocused = ImGui.IsItemClicked(ImGuiMouseButton.Right); + if (resetUnfocused) + { + unfocusedOpacity = DefaultUnfocusedWindowOpacity; + unfocusedChanged = true; + } + if (unfocusedChanged) + { + chatConfig.UnfocusedWindowOpacity = unfocusedOpacity; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Target transparency while the chat window is unfocused.\nRight-click to reset to default."); + } + ImGui.EndDisabled(); + ImGui.EndPopup(); } + private static float MoveTowards(float current, float target, float maxDelta) + { + if (current < target) + { + return MathF.Min(current + maxDelta, target); + } + + if (current > target) + { + return MathF.Max(current - maxDelta, target); + } + + return target; + } + private void ToggleChatConnection(bool currentlyEnabled) { _ = Task.Run(async () =>