using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; using LightlessSync.Interop.Ipc; using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Components; using LightlessSync.UI.Handlers; using LightlessSync.UI.Models; using LightlessSync.UI.Services; using LightlessSync.UI.Style; using LightlessSync.Utils; using LightlessSync.WebAPI; using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.SignalR.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Collections.Immutable; using System.Globalization; using System.Numerics; using System.Reflection; using System.Runtime.InteropServices; namespace LightlessSync.UI; public class CompactUi : WindowMediatorSubscriberBase { #region Constants private const float ConnectButtonHighlightThickness = 14f; #endregion #region Services private readonly ApiController _apiController; private readonly CharacterAnalyzer _characterAnalyzer; private readonly DalamudUtilService _dalamudUtilService; private readonly DrawEntityFactory _drawEntityFactory; private readonly FileUploadManager _fileTransferManager; private readonly IpcManager _ipcManager; private readonly LightFinderService _broadcastService; private readonly LightlessConfigService _configService; private readonly LightlessMediator _lightlessMediator; private readonly PairLedger _pairLedger; private readonly PairUiService _pairUiService; private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly ServerConfigurationManager _serverManager; private readonly TagHandler _tagHandler; private readonly UiSharedService _uiSharedService; #endregion #region UI Components private readonly AnimatedHeader _animatedHeader = new(); private readonly RenamePairTagUi _renamePairTagUi; private readonly RenameSyncshellTagUi _renameSyncshellTagUi; private readonly SelectPairForTagUi _selectPairsForGroupUi; private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; private readonly SelectTagForPairUi _selectTagForPairUi; private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi; private readonly SeluneBrush _seluneBrush = new(); private readonly TopTabMenu _tabMenu; #endregion #region State private readonly ConcurrentDictionary> _currentDownloads = new(); private List _drawFolders; private Pair? _focusedPair; private Pair? _lastAddedUser; private string _lastAddedUserComment = string.Empty; private Vector2 _lastPosition = Vector2.One; private Vector2 _lastSize = Vector2.One; private int _pendingFocusFrame = -1; private Pair? _pendingFocusPair; private bool _showModalForUserAddition; private float _transferPartHeight; private bool _wasOpen; private float _windowContentWidth; #endregion #region Constructor public CompactUi( ILogger logger, UiSharedService uiShared, LightlessConfigService configService, ApiController apiController, PairUiService pairUiService, ServerConfigurationManager serverManager, LightlessMediator mediator, FileUploadManager fileTransferManager, TagHandler tagHandler, DrawEntityFactory drawEntityFactory, SelectTagForPairUi selectTagForPairUi, SelectPairForTagUi selectPairForTagUi, RenamePairTagUi renameTagUi, SelectTagForSyncshellUi selectTagForSyncshellUi, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, PerformanceCollectorService performanceCollectorService, IpcManager ipcManager, LightFinderService broadcastService, CharacterAnalyzer characterAnalyzer, PlayerPerformanceConfigService playerPerformanceConfig, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService, PairLedger pairLedger, LightFinderScannerService lightFinderScannerService) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService) { _uiSharedService = uiShared; _configService = configService; _apiController = apiController; _pairUiService = pairUiService; _serverManager = serverManager; _fileTransferManager = fileTransferManager; _tagHandler = tagHandler; _drawEntityFactory = drawEntityFactory; _selectTagForPairUi = selectTagForPairUi; _selectTagForSyncshellUi = selectTagForSyncshellUi; _selectSyncshellForTagUi = selectSyncshellForTagUi; _renameSyncshellTagUi = renameSyncshellTagUi; _selectPairsForGroupUi = selectPairForTagUi; _renamePairTagUi = renameTagUi; _ipcManager = ipcManager; _broadcastService = broadcastService; _pairLedger = pairLedger; _dalamudUtilService = dalamudUtilService; _tabMenu = new TopTabMenu(Mediator, _apiController, _uiSharedService, pairRequestService, dalamudUtilService, lightlessNotificationService, broadcastService, lightFinderScannerService); Mediator.Subscribe(this, msg => RegisterFocusCharacter(msg.Pair)); WindowBuilder.For(this) .AllowPinning(true) .AllowClickthrough(false) .SetSizeConstraints(new Vector2(375, 400), new Vector2(375, 2000)) .AddFlags(ImGuiWindowFlags.NoDocking) .AddTitleBarButton(FontAwesomeIcon.Cog, "Open Lightless Settings", () => Mediator.Publish(new UiToggleMessage(typeof(SettingsUi)))) .AddTitleBarButton(FontAwesomeIcon.Book, "Open Lightless Event Viewer", () => Mediator.Publish(new UiToggleMessage(typeof(EventViewerUI)))) .Apply(); _drawFolders = [.. DrawFolders]; _animatedHeader.Height = 120f; _animatedHeader.EnableBottomGradient = true; _animatedHeader.GradientHeight = 250f; _animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects; #if DEBUG string dev = "Dev Build"; var ver = Assembly.GetExecutingAssembly().GetName().Version!; WindowName = $"Lightless Sync {dev} ({ver.Major}.{ver.Minor}.{ver.Build})###LightlessSyncMainUI"; Toggle(); #else var ver = Assembly.GetExecutingAssembly().GetName().Version; WindowName = "Lightless Sync " + ver.Major + "." + ver.Minor + "." + ver.Build + "###LightlessSyncMainUI"; #endif Mediator.Subscribe(this, (_) => IsOpen = true); Mediator.Subscribe(this, (_) => IsOpen = false); Mediator.Subscribe(this, (_) => UiSharedService_GposeStart()); Mediator.Subscribe(this, (_) => UiSharedService_GposeEnd()); Mediator.Subscribe(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus); Mediator.Subscribe(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _)); Mediator.Subscribe(this, (msg) => _drawFolders = DrawFolders.ToList()); _characterAnalyzer = characterAnalyzer; _playerPerformanceConfig = playerPerformanceConfig; _lightlessMediator = mediator; } #endregion #region Lifecycle public override void OnClose() { ForceReleaseFocus(); _animatedHeader.ClearParticles(); base.OnClose(); } protected override void DrawInternal() { var drawList = ImGui.GetWindowDrawList(); var windowPos = ImGui.GetWindowPos(); var windowSize = ImGui.GetWindowSize(); using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize); _windowContentWidth = UiSharedService.GetWindowContentRegionWidth(); // Draw animated header background (just the gradient/particles, content drawn by existing methods) var startCursorY = ImGui.GetCursorPosY(); _animatedHeader.Draw(_windowContentWidth, (_, _) => { }); // Reset cursor to draw content on top of the header background ImGui.SetCursorPosY(startCursorY); if (!_apiController.IsCurrentVersion) { var ver = _apiController.CurrentClientVersion; var unsupported = "UNSUPPORTED VERSION"; using (_uiSharedService.UidFont.Push()) { var uidTextSize = ImGui.CalcTextSize(unsupported); ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMax().X + ImGui.GetWindowContentRegionMin().X) / 2 - uidTextSize.X / 2); ImGui.AlignTextToFramePadding(); ImGui.TextColored(UIColors.Get("DimRed"), unsupported); } UiSharedService.ColorTextWrapped($"Your Lightless Sync installation is out of date, the current version is {ver.Major}.{ver.Minor}.{ver.Build}. " + $"It is highly recommended to keep Lightless Sync up to date. Open /xlplugins and update the plugin.", UIColors.Get("DimRed")); } if (!_ipcManager.Initialized) { var unsupported = "MISSING ESSENTIAL PLUGINS"; using (_uiSharedService.UidFont.Push()) { var uidTextSize = ImGui.CalcTextSize(unsupported); ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMax().X + ImGui.GetWindowContentRegionMin().X) / 2 - uidTextSize.X / 2); ImGui.AlignTextToFramePadding(); ImGui.TextColored(UIColors.Get("DimRed"), unsupported); } var penumAvailable = _ipcManager.Penumbra.APIAvailable; var glamAvailable = _ipcManager.Glamourer.APIAvailable; UiSharedService.ColorTextWrapped($"One or more Plugins essential for Lightless operation are unavailable. Enable or update following plugins:", UIColors.Get("DimRed")); using var indent = ImRaii.PushIndent(10f); if (!penumAvailable) { UiSharedService.TextWrapped("Penumbra"); _uiSharedService.BooleanToColoredIcon(penumAvailable, true); } if (!glamAvailable) { UiSharedService.TextWrapped("Glamourer"); _uiSharedService.BooleanToColoredIcon(glamAvailable, true); } ImGui.Separator(); } using (ImRaii.PushId("header")) DrawUIDHeader(); selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); var style = ImGui.GetStyle(); var contentMinY = windowPos.Y + ImGui.GetWindowContentRegionMin().Y; var gradientInset = 4f * ImGuiHelpers.GlobalScale; var gradientTop = MathF.Max(contentMinY, ImGui.GetCursorScreenPos().Y - style.ItemSpacing.Y + gradientInset); if (_apiController.ServerState is ServerState.Connected) { var pairSnapshot = _pairUiService.GetSnapshot(); using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot); using (ImRaii.PushId("pairlist")) DrawPairs(); var transfersTop = ImGui.GetCursorScreenPos().Y; var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset); selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime); float pairlistEnd = ImGui.GetCursorPosY(); using (ImRaii.PushId("transfers")) DrawTransfers(); _transferPartHeight = ImGui.GetCursorPosY() - pairlistEnd - ImGui.GetTextLineHeight(); using (ImRaii.PushId("group-pair-popup")) _selectPairsForGroupUi.Draw(pairSnapshot.DirectPairs); using (ImRaii.PushId("group-syncshell-popup")) _selectSyncshellForTagUi.Draw(pairSnapshot.Groups); using (ImRaii.PushId("group-pair-edit")) _renamePairTagUi.Draw(); using (ImRaii.PushId("group-syncshell-edit")) _renameSyncshellTagUi.Draw(); using (ImRaii.PushId("grouping-pair-popup")) _selectTagForPairUi.Draw(); using (ImRaii.PushId("grouping-syncshell-popup")) _selectTagForSyncshellUi.Draw(); } else { selune.Animate(ImGui.GetIO().DeltaTime); } ProcessFocusTracker(); var lastAddedPair = _pairUiService.GetLastAddedPair(); if (_configService.Current.OpenPopupOnAdd && lastAddedPair is not null) { _lastAddedUser = lastAddedPair; _pairUiService.ClearLastAddedPair(); ImGui.OpenPopup("Set Notes for New User"); _showModalForUserAddition = true; _lastAddedUserComment = string.Empty; } if (ImGui.BeginPopupModal("Set Notes for New User", ref _showModalForUserAddition, UiSharedService.PopupWindowFlags)) { if (_lastAddedUser == null) { _showModalForUserAddition = false; } else { UiSharedService.TextWrapped($"You have successfully added {_lastAddedUser.UserData.AliasOrUID}. Set a local note for the user in the field below:"); ImGui.InputTextWithHint("##noteforuser", $"Note for {_lastAddedUser.UserData.AliasOrUID}", ref _lastAddedUserComment, 100); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Note")) { _serverManager.SetNoteForUid(_lastAddedUser.UserData.UID, _lastAddedUserComment); _lastAddedUser = null; _lastAddedUserComment = string.Empty; _showModalForUserAddition = false; } } UiSharedService.SetScaledWindowSize(275); ImGui.EndPopup(); } var pos = ImGui.GetWindowPos(); var size = ImGui.GetWindowSize(); if (_lastSize != size || _lastPosition != pos) { _lastSize = size; _lastPosition = pos; Mediator.Publish(new CompactUiChange(_lastSize, _lastPosition)); } } #endregion #region Content Drawing private void DrawPairs() { float ySize = Math.Abs(_transferPartHeight) < 0.0001f ? 1 : (ImGui.GetWindowContentRegionMax().Y - ImGui.GetWindowContentRegionMin().Y + ImGui.GetTextLineHeight() - ImGui.GetStyle().WindowPadding.Y - ImGui.GetStyle().WindowBorderSize) - _transferPartHeight - ImGui.GetCursorPosY(); if (ImGui.BeginChild("list", new Vector2(_windowContentWidth, ySize), border: false)) { foreach (var item in _drawFolders) { item.Draw(); } } ImGui.EndChild(); } private void DrawTransfers() { var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot(); ImGui.AlignTextToFramePadding(); _uiSharedService.IconText(FontAwesomeIcon.Upload); ImGui.SameLine(35 * ImGuiHelpers.GlobalScale); if (currentUploads.Count > 0) { int totalUploads = currentUploads.Count; int doneUploads = 0; long totalUploaded = 0; long totalToUpload = 0; foreach (var upload in currentUploads) { if (upload.IsTransferred) { doneUploads++; } totalUploaded += upload.Transferred; totalToUpload += upload.Total; } int activeUploads = totalUploads - doneUploads; var uploadSlotLimit = Math.Clamp(_configService.Current.ParallelUploads, 1, 8); ImGui.TextUnformatted($"{doneUploads}/{totalUploads} (slots {activeUploads}/{uploadSlotLimit})"); var uploadText = $"({UiSharedService.ByteToString(totalUploaded)}/{UiSharedService.ByteToString(totalToUpload)})"; var textSize = ImGui.CalcTextSize(uploadText); ImGui.SameLine(_windowContentWidth - textSize.X); ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted(uploadText); } else { ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted("No uploads in progress"); } var downloadSummary = GetDownloadSummary(); ImGui.AlignTextToFramePadding(); _uiSharedService.IconText(FontAwesomeIcon.Download); ImGui.SameLine(35 * ImGuiHelpers.GlobalScale); if (downloadSummary.HasDownloads) { var totalDownloads = downloadSummary.TotalFiles; var doneDownloads = downloadSummary.TransferredFiles; var totalDownloaded = downloadSummary.TransferredBytes; var totalToDownload = downloadSummary.TotalBytes; ImGui.TextUnformatted($"{doneDownloads}/{totalDownloads}"); var downloadText = $"({UiSharedService.ByteToString(totalDownloaded)}/{UiSharedService.ByteToString(totalToDownload)})"; var textSize = ImGui.CalcTextSize(downloadText); ImGui.SameLine(_windowContentWidth - textSize.X); ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted(downloadText); } else { ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted("No downloads in progress"); } } private DownloadSummary GetDownloadSummary() { long totalBytes = 0; long transferredBytes = 0; int totalFiles = 0; int transferredFiles = 0; foreach (var kvp in _currentDownloads.ToArray()) { if (kvp.Value is not { Count: > 0 } statuses) { continue; } foreach (var status in statuses.Values) { totalBytes += status.TotalBytes; transferredBytes += status.TransferredBytes; totalFiles += status.TotalFiles; transferredFiles += status.TransferredFiles; } } return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes); } #endregion #region Header Drawing private void DrawUIDHeader() { var uidText = _apiController.ServerState.GetUidText(_apiController.DisplayName); var uidColor = _apiController.ServerState.GetUidColor(); Vector4? vanityTextColor = null; Vector4? vanityGlowColor = null; bool useVanityColors = false; if (_configService.Current.useColoredUIDs && _apiController.HasVanity) { if (!string.IsNullOrWhiteSpace(_apiController.TextColorHex)) { vanityTextColor = UIColors.HexToRgba(_apiController.TextColorHex); } if (!string.IsNullOrWhiteSpace(_apiController.TextGlowColorHex)) { vanityGlowColor = UIColors.HexToRgba(_apiController.TextGlowColorHex); } useVanityColors = vanityTextColor is not null || vanityGlowColor is not null; } //Getting information of character and triangles threshold to show overlimit status in UID bar. var analysisSummary = _characterAnalyzer.LatestSummary; Vector2 uidTextSize, iconSize; using (_uiSharedService.UidFont.Push()) uidTextSize = ImGui.CalcTextSize(uidText); using (_uiSharedService.IconFont.Push()) iconSize = ImGui.CalcTextSize(FontAwesomeIcon.PersonCirclePlus.ToIconString()); float uidStartX = 25f; float cursorY = ImGui.GetCursorPosY(); ImGui.SetCursorPosY(cursorY); ImGui.SetCursorPosX(uidStartX); bool headerItemClicked; using (_uiSharedService.UidFont.Push()) { if (useVanityColors) { var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor); var cursorPos = ImGui.GetCursorScreenPos(); var targetFontSize = ImGui.GetFontSize(); var font = ImGui.GetFont(); SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header"); } else { ImGui.TextColored(uidColor, uidText); } } // Get the actual rendered text rect for proper icon alignment var uidTextRect = ImGui.GetItemRectMax() - ImGui.GetItemRectMin(); var uidTextRectMin = ImGui.GetItemRectMin(); var uidTextHovered = ImGui.IsItemHovered(); headerItemClicked = ImGui.IsItemClicked(); // Track position for icons next to UID text // Use uidTextSize.Y (actual font height) for vertical centering, not hitbox height float nextIconX = uidTextRectMin.X + uidTextRect.X + 10f; float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f; float textVerticalOffset = (uidTextRect.Y - uidTextSize.Y) * 0.5f; var buttonSize = new Vector2(iconSize.X, uidTextSize.Y); if (_configService.Current.BroadcastEnabled && _apiController.IsConnected) { ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset)); ImGui.InvisibleButton("BroadcastIcon", buttonSize); var iconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset); using (_uiSharedService.IconFont.Push()) ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.Wifi.ToIconString()); nextIconX = ImGui.GetItemRectMax().X + 6f; if (ImGui.IsItemHovered()) { var padding = new Vector2(10f * ImGuiHelpers.GlobalScale); Selune.RegisterHighlight( ImGui.GetItemRectMin() - padding, ImGui.GetItemRectMax() + padding, SeluneHighlightMode.Point, exactSize: true, clipToElement: true, clipPadding: padding, highlightColorOverride: UIColors.Get("LightlessGreen"), highlightAlphaOverride: 0.2f); ImGui.BeginTooltip(); ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32f); ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("PairBlue")); ImGui.Text("Lightfinder"); ImGui.PopStyleColor(); ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); ImGui.TextWrapped("Use Lightfinder when you're okay with being visible to other users and understand that you are responsible for your own experience."); ImGui.PopStyleColor(); ImGuiHelpers.ScaledDummy(0.2f); UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); if (_configService.Current.BroadcastEnabled) { ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessGreen")); ImGui.Text("The Lightfinder calls, and somewhere, a soul may answer."); // cringe.. ImGui.PopStyleColor(); var ttl = _broadcastService.RemainingTtl; if (ttl is { } remaining && remaining > TimeSpan.Zero) { ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")); ImGui.Text($"Still shining, for {remaining:hh\\:mm\\:ss}"); ImGui.PopStyleColor(); } else { ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); ImGui.Text("The Lightfinder's light wanes, but not in vain."); // cringe.. ImGui.PopStyleColor(); } } else { ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); ImGui.Text("The Lightfinder rests, waiting to shine again."); // cringe.. ImGui.PopStyleColor(); } var cooldown = _broadcastService.RemainingCooldown; if (cooldown is { } cd) { ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); ImGui.Text($"The Lightfinder gathers its strength... ({Math.Ceiling(cd.TotalSeconds)}s)"); ImGui.PopStyleColor(); } ImGui.PopTextWrapPos(); ImGui.EndTooltip(); } if (ImGui.IsItemClicked()) _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); } // Warning threshold icon (next to lightfinder or UID text) if (_apiController.ServerState is ServerState.Connected && analysisSummary.HasData) { var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries); if (objectSummary.HasEntries) { var actualVramUsage = objectSummary.TexOriginalBytes; var actualTriCount = objectSummary.TotalTriangles; var isOverVRAMUsage = _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024 < actualVramUsage; var isOverTriHold = actualTriCount > (_playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000); if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds) { ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset)); ImGui.InvisibleButton("WarningThresholdIcon", buttonSize); var warningIconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset); using (_uiSharedService.IconFont.Push()) ImGui.GetWindowDrawList().AddText(warningIconPos, ImGui.GetColorU32(UIColors.Get("LightlessYellow")), FontAwesomeIcon.ExclamationTriangle.ToIconString()); if (ImGui.IsItemHovered()) { string warningMessage = ""; if (isOverTriHold) { warningMessage += $"You exceed your own triangles threshold by " + $"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles."; warningMessage += Environment.NewLine; } if (isOverVRAMUsage) { warningMessage += $"You exceed your own VRAM threshold by " + $"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}."; } UiSharedService.AttachToolTip(warningMessage); } if (ImGui.IsItemClicked()) { _lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi))); } } } } if (uidTextHovered) { var padding = new Vector2(35f * ImGuiHelpers.GlobalScale); Selune.RegisterHighlight( uidTextRectMin - padding, uidTextRectMin + uidTextRect + padding, SeluneHighlightMode.Point, exactSize: true, clipToElement: true, clipPadding: padding, highlightColorOverride: vanityGlowColor, highlightAlphaOverride: 0.05f); ImGui.SetTooltip("Click to copy"); } if (headerItemClicked) { ImGui.SetClipboardText(uidText); } // Connect/Disconnect button next to big UID (use screen pos to avoid affecting layout) DrawConnectButton(uidTextRectMin.Y + textVerticalOffset, uidTextSize.Y); // Add spacing below the big UID ImGuiHelpers.ScaledDummy(5f); if (_apiController.ServerState is ServerState.Connected) { if (headerItemClicked) { ImGui.SetClipboardText(_apiController.DisplayName); } // Only show smaller UID line if DisplayName differs from UID (custom vanity name) bool hasCustomName = !string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.OrdinalIgnoreCase); if (hasCustomName) { ImGui.SetCursorPosX(uidStartX); if (useVanityColors) { var seString = SeStringUtils.BuildFormattedPlayerName(_apiController.UID, vanityTextColor, vanityGlowColor); var cursorPos = ImGui.GetCursorScreenPos(); var targetFontSize = ImGui.GetFontSize(); var font = ImGui.GetFont(); SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize, font, "uid-footer"); } else { ImGui.TextColored(uidColor, _apiController.UID); } if (ImGui.IsItemHovered()) { var padding = new Vector2(30f * ImGuiHelpers.GlobalScale); Selune.RegisterHighlight( ImGui.GetItemRectMin() - padding, ImGui.GetItemRectMax() + padding, SeluneHighlightMode.Point, exactSize: true, clipToElement: true, clipPadding: padding, highlightColorOverride: vanityGlowColor, highlightAlphaOverride: 0.05f); } bool uidFooterClicked = ImGui.IsItemClicked(); UiSharedService.AttachToolTip("Click to copy"); if (uidFooterClicked) { ImGui.SetClipboardText(_apiController.UID); } // Users Online on same line as smaller UID (with separator) ImGui.SameLine(); ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted("|"); ImGui.SameLine(); ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture)); ImGui.SameLine(); ImGui.TextUnformatted("Users Online"); } else { // No custom name - just show Users Online aligned to uidStartX ImGui.SetCursorPosX(uidStartX); ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture)); ImGui.SameLine(); ImGui.TextUnformatted("Users Online"); } } else { ImGui.SetCursorPosX(uidStartX); UiSharedService.ColorTextWrapped(_apiController.ServerState.GetServerError(_apiController.AuthFailureMessage), uidColor); } } private void DrawConnectButton(float screenY, float textHeight) { var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link); bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting; var color = UiSharedService.GetBoolColor(!isConnectingOrConnected); var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link; // Position on right side, vertically centered with text if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting)) { var windowPos = ImGui.GetWindowPos(); var screenX = windowPos.X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X - 13f; var yOffset = (textHeight - buttonSize.Y) * 0.5f; ImGui.SetCursorScreenPos(new Vector2(screenX, screenY + yOffset)); using (ImRaii.PushColor(ImGuiCol.Text, color)) using (ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new(0, 0, 0, 0)))) { if (_uiSharedService.IconButton(connectedIcon, buttonSize.Y)) { if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause) { _serverManager.CurrentServer.FullPause = true; _serverManager.Save(); } else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause) { _serverManager.CurrentServer.FullPause = false; _serverManager.Save(); } _ = _apiController.CreateConnectionsAsync(); } } if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) { Selune.RegisterHighlight( ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, borderOnly: true, borderThicknessOverride: ConnectButtonHighlightThickness, exactSize: true, clipToElement: true, roundingOverride: ImGui.GetStyle().FrameRounding); } UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName); } } #endregion #region Folder Building private IEnumerable DrawFolders { get { var drawFolders = new List(); var filter = _tabMenu.Filter; var allEntries = _drawEntityFactory.GetAllEntries().ToList(); var filteredEntries = string.IsNullOrEmpty(filter) ? allEntries : allEntries.Where(e => PassesFilter(e, filter)).ToList(); var syncshells = _pairLedger.GetAllSyncshells(); var groupInfos = syncshells.Values .Select(s => s.GroupFullInfo) .OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase) .ToList(); var entryLookup = allEntries.ToDictionary(e => e.DisplayEntry.Ident.UserId, StringComparer.Ordinal); var filteredEntryLookup = filteredEntries.ToDictionary(e => e.DisplayEntry.Ident.UserId, StringComparer.Ordinal); //Filter of online/visible pairs if (_configService.Current.ShowVisibleUsersSeparately) { var allVisiblePairs = SortVisibleEntries(allEntries.Where(FilterVisibleUsers)); if (allVisiblePairs.Count > 0) { var filteredVisiblePairs = SortVisibleEntries(filteredEntries.Where(FilterVisibleUsers)); drawFolders.Add(_drawEntityFactory.CreateTagFolder(TagHandler.CustomVisibleTag, filteredVisiblePairs, allVisiblePairs)); } } //Filter of not foldered syncshells var groupFolders = new List(); foreach (var group in groupInfos) { if (!FilterNotTaggedSyncshells(group)) { continue; } var allGroupEntries = ResolveGroupEntries(entryLookup, syncshells, group, applyFilters: false); var filteredGroupEntries = ResolveGroupEntries(filteredEntryLookup, syncshells, group, applyFilters: true); // Always create the folder so empty syncshells remain visible in the UI. var drawGroupFolder = _drawEntityFactory.CreateGroupFolder(group.Group.GID, group, filteredGroupEntries, allGroupEntries); groupFolders.Add(new GroupFolder(group, drawGroupFolder)); } //Filter of grouped up syncshells (All Syncshells Folder) if (_configService.Current.GroupUpSyncshells) drawFolders.Add(new DrawGroupedGroupFolder(groupFolders, _tagHandler, _apiController, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, "")); else drawFolders.AddRange(groupFolders.Select(v => v.GroupDrawFolder)); //Filter of grouped/foldered pairs foreach (var tag in _tagHandler.GetAllPairTagsSorted()) { var allTagPairs = SortEntries(allEntries.Where(e => FilterTagUsers(e, tag))); if (allTagPairs.Count > 0) { var filteredTagPairs = SortEntries(filteredEntries.Where(e => FilterTagUsers(e, tag) && FilterOnlineOrPausedSelf(e))); drawFolders.Add(_drawEntityFactory.CreateTagFolder(tag, filteredTagPairs, allTagPairs)); } } //Filter of grouped/foldered syncshells foreach (var syncshellTag in _tagHandler.GetAllSyncshellTagsSorted()) { var syncshellFolderTags = new List(); foreach (var group in groupInfos) { if (!_tagHandler.HasSyncshellTag(group.Group.GID, syncshellTag)) { continue; } var allGroupEntries = ResolveGroupEntries(entryLookup, syncshells, group, applyFilters: false); var filteredGroupEntries = ResolveGroupEntries(filteredEntryLookup, syncshells, group, applyFilters: true); // Keep tagged syncshells rendered regardless of whether membership info has loaded. var taggedGroupFolder = _drawEntityFactory.CreateGroupFolder($"tag_{group.Group.GID}", group, filteredGroupEntries, allGroupEntries); syncshellFolderTags.Add(new GroupFolder(group, taggedGroupFolder)); } drawFolders.Add(new DrawGroupedGroupFolder(syncshellFolderTags, _tagHandler, _apiController, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, syncshellTag)); } //Filter of not grouped/foldered and offline pairs var allOnlineNotTaggedPairs = SortEntries(allEntries.Where(FilterNotTaggedUsers)); if (allOnlineNotTaggedPairs.Count > 0 && _configService.Current.ShowOfflineUsersSeparately) { var filteredOnlineEntries = SortOnlineEntries(filteredEntries.Where(e => FilterNotTaggedUsers(e) && FilterOnlineOrPausedSelf(e))); drawFolders.Add(_drawEntityFactory.CreateTagFolder( 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)); } if (_configService.Current.ShowOfflineUsersSeparately) { var allOfflinePairs = SortEntries(allEntries.Where(FilterOfflineUsers)); if (allOfflinePairs.Count > 0) { var filteredOfflinePairs = SortEntries(filteredEntries.Where(FilterOfflineUsers)); drawFolders.Add(_drawEntityFactory.CreateTagFolder(TagHandler.CustomOfflineTag, filteredOfflinePairs, allOfflinePairs)); } if (_configService.Current.ShowSyncshellOfflineUsersSeparately) { var allOfflineSyncshellUsers = SortEntries(allEntries.Where(FilterOfflineSyncshellUsers)); if (allOfflineSyncshellUsers.Count > 0) { var filteredOfflineSyncshellUsers = SortEntries(filteredEntries.Where(FilterOfflineSyncshellUsers)); drawFolders.Add(_drawEntityFactory.CreateTagFolder(TagHandler.CustomOfflineSyncshellTag, filteredOfflineSyncshellUsers, allOfflineSyncshellUsers)); } } } //Unpaired var unpairedAllEntries = SortEntries(allEntries.Where(e => e.IsOneSided)); if (unpairedAllEntries.Count > 0) { var unpairedFiltered = SortEntries(filteredEntries.Where(e => e.IsOneSided)); drawFolders.Add(_drawEntityFactory.CreateTagFolder(TagHandler.CustomUnpairedTag, unpairedFiltered, unpairedAllEntries)); } return drawFolders; } } #endregion #region Filtering & Sorting private static bool PassesFilter(PairUiEntry entry, string filter) { if (string.IsNullOrEmpty(filter)) return true; return entry.AliasOrUid.Contains(filter, StringComparison.OrdinalIgnoreCase) || (!string.IsNullOrEmpty(entry.Note) && entry.Note.Contains(filter, StringComparison.OrdinalIgnoreCase)) || (!string.IsNullOrEmpty(entry.DisplayName) && entry.DisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase)); } private string AlphabeticalSortKey(PairUiEntry entry) { if (_configService.Current.ShowCharacterNameInsteadOfNotesForVisible && !string.IsNullOrEmpty(entry.DisplayName)) { return _configService.Current.PreferNotesOverNamesForVisible ? (entry.Note ?? string.Empty) : entry.DisplayName; } return !string.IsNullOrEmpty(entry.Note) ? entry.Note : entry.AliasOrUid; } private bool FilterOnlineOrPausedSelf(PairUiEntry entry) => entry.IsOnline || (!entry.IsOnline && !_configService.Current.ShowOfflineUsersSeparately) || entry.SelfPermissions.IsPaused(); private bool FilterVisibleUsers(PairUiEntry entry) => entry.IsVisible && entry.IsOnline && (_configService.Current.ShowSyncshellUsersInVisible || entry.IsDirectlyPaired); private bool FilterTagUsers(PairUiEntry entry, string tag) => entry.IsDirectlyPaired && !entry.IsOneSided && _tagHandler.HasPairTag(entry.DisplayEntry.Ident.UserId, tag); private bool FilterNotTaggedUsers(PairUiEntry entry) => entry.IsDirectlyPaired && !entry.IsOneSided && !_tagHandler.HasAnyPairTag(entry.DisplayEntry.Ident.UserId); private bool FilterNotTaggedSyncshells(GroupFullInfoDto group) => !_tagHandler.HasAnySyncshellTag(group.GID) || _configService.Current.ShowGroupedSyncshellsInAll; private bool FilterOfflineUsers(PairUiEntry entry) { var groups = entry.DisplayEntry.Groups; var includeDirect = _configService.Current.ShowSyncshellOfflineUsersSeparately ? entry.IsDirectlyPaired : true; var includeGroup = !entry.IsOneSided || groups.Count != 0; return includeDirect && includeGroup && !entry.IsOnline && !entry.SelfPermissions.IsPaused(); } private static bool FilterOfflineSyncshellUsers(PairUiEntry entry) => !entry.IsDirectlyPaired && !entry.IsOnline && !entry.SelfPermissions.IsPaused(); private ImmutableList SortEntries(IEnumerable entries) { return [.. entries .OrderByDescending(e => e.IsVisible) .ThenByDescending(e => e.IsOnline) .ThenBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)]; } private ImmutableList SortVisibleEntries(IEnumerable entries) { var entryList = entries.ToList(); return _configService.Current.VisiblePairSortMode switch { VisiblePairSortMode.VramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateVramBytes), VisiblePairSortMode.EffectiveVramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateEffectiveVramBytes), VisiblePairSortMode.TriangleCount => SortVisibleByMetric(entryList, e => e.LastAppliedDataTris), VisiblePairSortMode.Alphabetical => [.. entryList.OrderBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)], VisiblePairSortMode.PreferredDirectPairs => SortVisibleByPreferred(entryList), _ => SortEntries(entryList), }; } 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 .OrderByDescending(entry => selector(entry) >= 0) .ThenByDescending(selector) .ThenByDescending(entry => entry.IsOnline) .ThenBy(entry => AlphabeticalSortKey(entry), StringComparer.OrdinalIgnoreCase)]; } private ImmutableList SortVisibleByPreferred(IEnumerable entries) { return [.. entries .OrderByDescending(entry => entry.IsDirectlyPaired && entry.SelfPermissions.IsSticky()) .ThenByDescending(entry => entry.IsDirectlyPaired) .ThenByDescending(entry => entry.IsOnline) .ThenBy(entry => AlphabeticalSortKey(entry), StringComparer.OrdinalIgnoreCase)]; } private ImmutableList SortGroupEntries(IEnumerable entries, GroupFullInfoDto group) { return [.. entries .OrderByDescending(e => e.IsOnline) .ThenBy(e => GroupSortWeight(e, group)) .ThenBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)]; } private int GroupSortWeight(PairUiEntry entry, GroupFullInfoDto group) { if (string.Equals(entry.DisplayEntry.Ident.UserId, group.OwnerUID, StringComparison.Ordinal)) { return 0; } if (group.GroupPairUserInfos.TryGetValue(entry.DisplayEntry.Ident.UserId, out var info)) { if (info.IsModerator()) return 1; if (info.IsPinned()) return 2; } return entry.IsVisible ? 3 : 4; } private ImmutableList ResolveGroupEntries( IReadOnlyDictionary entryLookup, IReadOnlyDictionary syncshells, GroupFullInfoDto group, bool applyFilters) { if (!syncshells.TryGetValue(group.Group.GID, out var shell)) { return ImmutableList.Empty; } var entries = shell.Users.Keys .Select(id => entryLookup.TryGetValue(id, out var entry) ? entry : null) .Where(entry => entry is not null) .Cast(); if (applyFilters && _configService.Current.ShowOfflineUsersSeparately) { entries = entries.Where(entry => !FilterOfflineUsers(entry)); } if (applyFilters && _configService.Current.ShowSyncshellOfflineUsersSeparately) { entries = entries.Where(entry => !FilterOfflineSyncshellUsers(entry)); } return SortGroupEntries(entries, group); } #endregion #region GPose Handlers private void UiSharedService_GposeEnd() => IsOpen = _wasOpen; private void UiSharedService_GposeStart() { _wasOpen = IsOpen; IsOpen = false; } #endregion #region Focus Tracking private void RegisterFocusCharacter(Pair pair) { _pendingFocusPair = pair; _pendingFocusFrame = ImGui.GetFrameCount(); } private void ProcessFocusTracker() { var frame = ImGui.GetFrameCount(); Pair? character = _pendingFocusFrame == frame ? _pendingFocusPair : null; if (!ReferenceEquals(character, _focusedPair)) { if (character is null) { _dalamudUtilService.ReleaseVisiblePairFocus(); } else { _dalamudUtilService.FocusVisiblePair(character); } _focusedPair = character; } if (_pendingFocusFrame == frame) { _pendingFocusPair = null; _pendingFocusFrame = -1; } } private void ForceReleaseFocus() { if (_focusedPair is null) { _pendingFocusPair = null; _pendingFocusFrame = -1; return; } _dalamudUtilService.ReleaseVisiblePairFocus(); _focusedPair = null; _pendingFocusPair = null; _pendingFocusFrame = -1; } #endregion #region Helper Types [StructLayout(LayoutKind.Auto)] private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes) { public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0; } #endregion }