Files
LightlessClient/LightlessSync/UI/CompactUI.cs
2026-01-05 14:24:07 +01:00

1160 lines
50 KiB
C#

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 PlayerPerformanceConfigService _playerPerformanceConfig;
private readonly PairUiService _pairUiService;
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<GameObjectHandler, Dictionary<string, FileDownloadStatus>> _currentDownloads = new();
private List<IDrawFolder> _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<CompactUi> 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<PairFocusCharacterMessage>(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<SwitchToMainUiMessage>(this, (_) => IsOpen = true);
Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) => IsOpen = false);
Mediator.Subscribe<CutsceneStartMessage>(this, (_) => UiSharedService_GposeStart());
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => UiSharedService_GposeEnd());
Mediator.Subscribe<DownloadStartedMessage>(this, msg =>
{
_currentDownloads[msg.DownloadId] = new Dictionary<string, FileDownloadStatus>(msg.DownloadStatus, StringComparer.Ordinal);
});
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _));
Mediator.Subscribe<RefreshUiMessage>(this, (msg) => _drawFolders = [.. DrawFolders]);
_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<IDrawFolder> DrawFolders
{
get
{
var drawFolders = new List<IDrawFolder>();
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<GroupFolder>();
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<GroupFolder>();
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<PairUiEntry> SortEntries(IEnumerable<PairUiEntry> entries)
{
return [.. entries
.OrderByDescending(e => e.IsVisible)
.ThenByDescending(e => e.IsOnline)
.ThenBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)];
}
private ImmutableList<PairUiEntry> SortVisibleEntries(IEnumerable<PairUiEntry> 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.EffectiveTriangleCount => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateEffectiveTris),
VisiblePairSortMode.Alphabetical => [.. entryList.OrderBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)],
VisiblePairSortMode.PreferredDirectPairs => SortVisibleByPreferred(entryList),
_ => SortEntries(entryList),
};
}
private ImmutableList<PairUiEntry> SortOnlineEntries(IEnumerable<PairUiEntry> 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<PairUiEntry> SortVisibleByMetric(IEnumerable<PairUiEntry> entries, Func<PairUiEntry, long> selector)
{
return [.. entries
.OrderByDescending(entry => selector(entry) >= 0)
.ThenByDescending(selector)
.ThenByDescending(entry => entry.IsOnline)
.ThenBy(entry => AlphabeticalSortKey(entry), StringComparer.OrdinalIgnoreCase)];
}
private ImmutableList<PairUiEntry> SortVisibleByPreferred(IEnumerable<PairUiEntry> 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<PairUiEntry> SortGroupEntries(IEnumerable<PairUiEntry> 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<PairUiEntry> ResolveGroupEntries(
IReadOnlyDictionary<string, PairUiEntry> entryLookup,
IReadOnlyDictionary<string, Syncshell> syncshells,
GroupFullInfoDto group,
bool applyFilters)
{
if (!syncshells.TryGetValue(group.Group.GID, out var shell))
{
return ImmutableList<PairUiEntry>.Empty;
}
var entries = shell.Users.Keys
.Select(id => entryLookup.TryGetValue(id, out var entry) ? entry : null)
.Where(entry => entry is not null)
.Cast<PairUiEntry>();
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
}