Merge branch '1.12.4' into linux-improvements

This commit is contained in:
2025-11-07 05:29:34 +01:00
11 changed files with 298 additions and 129 deletions

View File

@@ -28,7 +28,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
private readonly CancellationTokenSource _cleanupCts = new(); private readonly CancellationTokenSource _cleanupCts = new();
private Task? _cleanupTask; private Task? _cleanupTask;
private int _checkEveryFrames = 20; private readonly int _checkEveryFrames = 20;
private int _frameCounter = 0; private int _frameCounter = 0;
private int _lookupsThisFrame = 0; private int _lookupsThisFrame = 0;
private const int MaxLookupsPerFrame = 30; private const int MaxLookupsPerFrame = 30;
@@ -221,6 +221,16 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid))); (excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)));
} }
public List<KeyValuePair<string, BroadcastEntry>> GetActiveBroadcasts(string? excludeHashedCid = null)
{
var now = DateTime.UtcNow;
var comparer = StringComparer.Ordinal;
return [.. _broadcastCache.Where(entry =>
entry.Value.IsBroadcasting &&
entry.Value.ExpiryTime > now &&
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)))];
}
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
base.Dispose(disposing); base.Dispose(disposing);

View File

@@ -13,7 +13,8 @@ namespace LightlessSync.Services;
public sealed class CommandManagerService : IDisposable public sealed class CommandManagerService : IDisposable
{ {
private const string _commandName = "/light"; private const string _longName = "/lightless";
private const string _shortName = "/light";
private readonly ApiController _apiController; private readonly ApiController _apiController;
private readonly ICommandManager _commandManager; private readonly ICommandManager _commandManager;
@@ -34,7 +35,11 @@ public sealed class CommandManagerService : IDisposable
_apiController = apiController; _apiController = apiController;
_mediator = mediator; _mediator = mediator;
_lightlessConfigService = lightlessConfigService; _lightlessConfigService = lightlessConfigService;
_commandManager.AddHandler(_commandName, new CommandInfo(OnCommand) _commandManager.AddHandler(_longName, new CommandInfo(OnCommand)
{
HelpMessage = $"\u2191;"
});
_commandManager.AddHandler(_shortName, new CommandInfo(OnCommand)
{ {
HelpMessage = "Opens the Lightless Sync UI" + Environment.NewLine + Environment.NewLine + HelpMessage = "Opens the Lightless Sync UI" + Environment.NewLine + Environment.NewLine +
"Additionally possible commands:" + Environment.NewLine + "Additionally possible commands:" + Environment.NewLine +
@@ -49,7 +54,8 @@ public sealed class CommandManagerService : IDisposable
public void Dispose() public void Dispose()
{ {
_commandManager.RemoveHandler(_commandName); _commandManager.RemoveHandler(_longName);
_commandManager.RemoveHandler(_shortName);
} }
private void OnCommand(string command, string args) private void OnCommand(string command, string args)

View File

@@ -98,7 +98,7 @@ internal class ContextMenuService : IHostedService
if (targetData == null || targetData.Address == nint.Zero) if (targetData == null || targetData.Address == nint.Zero)
return; return;
//Check if user is paired or is own. //Check if user is directly paired or is own.
if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId) if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId)
return; return;
@@ -116,7 +116,7 @@ internal class ContextMenuService : IHostedService
args.AddMenuItem(new MenuItem args.AddMenuItem(new MenuItem
{ {
Name = "Send Pair Request", Name = "Send Direct Pair Request",
PrefixChar = 'L', PrefixChar = 'L',
UseDefaultPrefix = false, UseDefaultPrefix = false,
PrefixColor = 708, PrefixColor = 708,
@@ -159,7 +159,7 @@ internal class ContextMenuService : IHostedService
} }
} }
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs() private HashSet<ulong> VisibleUserIds => [.. _pairManager.DirectPairs
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)]; .Select(u => (ulong)u.PlayerCharacterId)];

View File

@@ -1,5 +1,6 @@
using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.Text; using Dalamud.Game.Text;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.Framework; using FFXIVClientStructs.FFXIV.Client.System.Framework;
@@ -15,8 +16,9 @@ using LightlessSync.UtilsEnum.Enum;
// Created using https://github.com/PunishedPineapple/Distance as a reference, thank you! // Created using https://github.com/PunishedPineapple/Distance as a reference, thank you!
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization; using System.Globalization;
using System.Text;
namespace LightlessSync.Services; namespace LightlessSync.Services;
@@ -32,10 +34,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private readonly LightlessMediator _mediator; private readonly LightlessMediator _mediator;
public LightlessMediator Mediator => _mediator; public LightlessMediator Mediator => _mediator;
private bool mEnabled = false; private bool _mEnabled = false;
private bool _needsLabelRefresh = false; private bool _needsLabelRefresh = false;
private AddonNamePlate* mpNameplateAddon = null; private AddonNamePlate* _mpNameplateAddon = null;
private readonly AtkTextNode*[] mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects]; private readonly AtkTextNode*[] _mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects]; private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects]; private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects]; private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects];
@@ -44,10 +46,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber
internal const uint mNameplateNodeIDBase = 0x7D99D500; internal const uint mNameplateNodeIDBase = 0x7D99D500;
private const string DefaultLabelText = "LightFinder"; private const string DefaultLabelText = "LightFinder";
private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn; private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn;
private const int ContainerOffsetX = 50; private const int _containerOffsetX = 50;
private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon); private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon);
private volatile HashSet<string> _activeBroadcastingCids = []; private ImmutableHashSet<string> _activeBroadcastingCids = [];
public NameplateHandler(ILogger<NameplateHandler> logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairManager pairManager) public NameplateHandler(ILogger<NameplateHandler> logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairManager pairManager)
{ {
@@ -74,17 +76,17 @@ public unsafe class NameplateHandler : IMediatorSubscriber
DisableNameplate(); DisableNameplate();
DestroyNameplateNodes(); DestroyNameplateNodes();
_mediator.Unsubscribe<PriorityFrameworkUpdateMessage>(this); _mediator.Unsubscribe<PriorityFrameworkUpdateMessage>(this);
mpNameplateAddon = null; _mpNameplateAddon = null;
} }
internal void EnableNameplate() internal void EnableNameplate()
{ {
if (!mEnabled) if (!_mEnabled)
{ {
try try
{ {
_addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour); _addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour);
mEnabled = true; _mEnabled = true;
} }
catch (Exception e) catch (Exception e)
{ {
@@ -96,7 +98,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
internal void DisableNameplate() internal void DisableNameplate()
{ {
if (mEnabled) if (_mEnabled)
{ {
try try
{ {
@@ -107,24 +109,30 @@ public unsafe class NameplateHandler : IMediatorSubscriber
_logger.LogError($"Unknown error while unregistering nameplate listener:\n{e}"); _logger.LogError($"Unknown error while unregistering nameplate listener:\n{e}");
} }
mEnabled = false; _mEnabled = false;
HideAllNameplateNodes(); HideAllNameplateNodes();
} }
} }
private void NameplateDrawDetour(AddonEvent type, AddonArgs args) private void NameplateDrawDetour(AddonEvent type, AddonArgs args)
{ {
if (args.Addon.Address == nint.Zero)
{
_logger.LogWarning("Nameplate draw detour received a null addon address, skipping update.");
return;
}
var pNameplateAddon = (AddonNamePlate*)args.Addon.Address; var pNameplateAddon = (AddonNamePlate*)args.Addon.Address;
if (mpNameplateAddon != pNameplateAddon) if (_mpNameplateAddon != pNameplateAddon)
{ {
for (int i = 0; i < mTextNodes.Length; ++i) mTextNodes[i] = null; for (int i = 0; i < _mTextNodes.Length; ++i) _mTextNodes[i] = null;
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length); System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length); System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length); System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue); System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
mpNameplateAddon = pNameplateAddon; _mpNameplateAddon = pNameplateAddon;
if (mpNameplateAddon != null) CreateNameplateNodes(); if (_mpNameplateAddon != null) CreateNameplateNodes();
} }
UpdateNameplateNodes(); UpdateNameplateNodes();
@@ -138,7 +146,16 @@ public unsafe class NameplateHandler : IMediatorSubscriber
if (nameplateObject == null) if (nameplateObject == null)
continue; continue;
var rootNode = nameplateObject.Value.RootComponentNode;
if (rootNode == null || rootNode->Component == null)
continue;
var pNameplateResNode = nameplateObject.Value.NameContainer; var pNameplateResNode = nameplateObject.Value.NameContainer;
if (pNameplateResNode == null)
continue;
if (pNameplateResNode->ChildNode == null)
continue;
var pNewNode = AtkNodeHelpers.CreateOrphanTextNode(mNameplateNodeIDBase + (uint)i, TextFlags.Edge | TextFlags.Glare); var pNewNode = AtkNodeHelpers.CreateOrphanTextNode(mNameplateNodeIDBase + (uint)i, TextFlags.Edge | TextFlags.Glare);
if (pNewNode != null) if (pNewNode != null)
@@ -148,24 +165,43 @@ public unsafe class NameplateHandler : IMediatorSubscriber
pNewNode->AtkResNode.NextSiblingNode = pLastChild; pNewNode->AtkResNode.NextSiblingNode = pLastChild;
pNewNode->AtkResNode.ParentNode = pNameplateResNode; pNewNode->AtkResNode.ParentNode = pNameplateResNode;
pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode; pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode;
nameplateObject.Value.RootComponentNode->Component->UldManager.UpdateDrawNodeList(); rootNode->Component->UldManager.UpdateDrawNodeList();
pNewNode->AtkResNode.SetUseDepthBasedPriority(true); pNewNode->AtkResNode.SetUseDepthBasedPriority(true);
mTextNodes[i] = pNewNode; _mTextNodes[i] = pNewNode;
} }
} }
} }
private void DestroyNameplateNodes() private void DestroyNameplateNodes()
{ {
var pCurrentNameplateAddon = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate", 1).Address; var currentHandle = _gameGui.GetAddonByName("NamePlate", 1);
if (mpNameplateAddon == null || mpNameplateAddon != pCurrentNameplateAddon) if (currentHandle.Address == nint.Zero)
{
_logger.LogWarning("Unable to destroy nameplate nodes because the NamePlate addon is not available.");
return; return;
}
var pCurrentNameplateAddon = (AddonNamePlate*)currentHandle.Address;
if (_mpNameplateAddon == null)
return;
if (_mpNameplateAddon != pCurrentNameplateAddon)
{
_logger.LogWarning("Skipping nameplate node destroy due to addon address mismatch (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)pCurrentNameplateAddon);
return;
}
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i) for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
{ {
var pTextNode = mTextNodes[i]; var pTextNode = _mTextNodes[i];
var pNameplateNode = GetNameplateComponentNode(i); var pNameplateNode = GetNameplateComponentNode(i);
if (pTextNode != null && pNameplateNode != null) if (pTextNode != null && (pNameplateNode == null || pNameplateNode->Component == null))
{
_logger.LogDebug("Skipping destroy for nameplate {Index} because its component node is unavailable.", i);
continue;
}
if (pTextNode != null && pNameplateNode != null && pNameplateNode->Component != null)
{ {
try try
{ {
@@ -175,7 +211,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode; pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode;
pNameplateNode->Component->UldManager.UpdateDrawNodeList(); pNameplateNode->Component->UldManager.UpdateDrawNodeList();
pTextNode->AtkResNode.Destroy(true); pTextNode->AtkResNode.Destroy(true);
mTextNodes[i] = null; _mTextNodes[i] = null;
} }
catch (Exception e) catch (Exception e)
{ {
@@ -192,7 +228,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private void HideAllNameplateNodes() private void HideAllNameplateNodes()
{ {
for (int i = 0; i < mTextNodes.Length; ++i) for (int i = 0; i < _mTextNodes.Length; ++i)
{ {
HideNameplateTextNode(i); HideNameplateTextNode(i);
} }
@@ -200,22 +236,62 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private void UpdateNameplateNodes() private void UpdateNameplateNodes()
{ {
var framework = Framework.Instance(); var currentHandle = _gameGui.GetAddonByName("NamePlate");
var ui3DModule = framework->GetUIModule()->GetUI3DModule(); if (currentHandle.Address == nint.Zero)
{
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
return;
}
var currentAddon = (AddonNamePlate*)currentHandle.Address;
if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon)
{
if (_mpNameplateAddon != null)
_logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon);
return;
}
var framework = Framework.Instance();
if (framework == null)
{
_logger.LogDebug("Framework instance unavailable during nameplate update, skipping.");
return;
}
var uiModule = framework->GetUIModule();
if (uiModule == null)
{
_logger.LogDebug("UI module unavailable during nameplate update, skipping.");
return;
}
var ui3DModule = uiModule->GetUI3DModule();
if (ui3DModule == null) if (ui3DModule == null)
{
_logger.LogDebug("UI3D module unavailable during nameplate update, skipping.");
return;
}
var vec = ui3DModule->NamePlateObjectInfoPointers;
if (vec.IsEmpty)
return; return;
for (int i = 0; i < ui3DModule->NamePlateObjectInfoCount; ++i) var visibleUserIdsSnapshot = VisibleUserIds;
var safeCount = System.Math.Min(
ui3DModule->NamePlateObjectInfoCount,
vec.Length
);
for (int i = 0; i < safeCount; ++i)
{ {
if (ui3DModule->NamePlateObjectInfoPointers.IsEmpty) continue; var config = _configService.Current;
var objectInfoPtr = ui3DModule->NamePlateObjectInfoPointers[i]; var objectInfoPtr = vec[i];
if (objectInfoPtr == null)
if (objectInfoPtr == null) continue; continue;
var objectInfo = objectInfoPtr.Value; var objectInfo = objectInfoPtr.Value;
if (objectInfo == null || objectInfo->GameObject == null) if (objectInfo == null || objectInfo->GameObject == null)
continue; continue;
@@ -223,62 +299,68 @@ public unsafe class NameplateHandler : IMediatorSubscriber
if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects) if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects)
continue; continue;
var pNode = mTextNodes[nameplateIndex]; var pNode = _mTextNodes[nameplateIndex];
if (pNode == null) if (pNode == null)
continue; continue;
if (mpNameplateAddon == null) var gameObject = objectInfo->GameObject;
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
{
pNode->AtkResNode.ToggleVisibility(enable: false);
continue; continue;
}
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)objectInfo->GameObject); // CID gating
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
if (cid == null || !_activeBroadcastingCids.Contains(cid)) if (cid == null || !_activeBroadcastingCids.Contains(cid))
{ {
pNode->AtkResNode.ToggleVisibility(false); pNode->AtkResNode.ToggleVisibility(enable: false);
continue; continue;
} }
if (!_configService.Current.LightfinderLabelShowOwn && (objectInfo->GameObject->GetGameObjectId() == _clientState.LocalPlayer.GameObjectId)) var local = _clientState.LocalPlayer;
if (!config.LightfinderLabelShowOwn && local != null &&
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
{ {
pNode->AtkResNode.ToggleVisibility(false); pNode->AtkResNode.ToggleVisibility(enable: false);
continue; continue;
} }
if (!_configService.Current.LightfinderLabelShowPaired && VisibleUserIds.Any(u => u == objectInfo->GameObject->GetGameObjectId())) var hidePaired = !config.LightfinderLabelShowPaired;
var goId = (ulong)gameObject->GetGameObjectId();
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
{ {
pNode->AtkResNode.ToggleVisibility(false); pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
var nameplateObject = mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
nameplateObject.RootComponentNode->Component->UldManager.UpdateDrawNodeList();
var pNameplateIconNode = nameplateObject.MarkerIcon;
var pNameplateResNode = nameplateObject.NameContainer;
var pNameplateTextNode = nameplateObject.NameText;
bool IsVisible = pNameplateIconNode->AtkResNode.IsVisible() || (pNameplateResNode->IsVisible() && pNameplateTextNode->AtkResNode.IsVisible()) || _configService.Current.LightfinderLabelShowHidden;
pNode->AtkResNode.ToggleVisibility(IsVisible);
if (nameplateObject.RootComponentNode == null ||
nameplateObject.NameContainer == null ||
nameplateObject.NameText == null)
{
pNode->AtkResNode.ToggleVisibility(false);
continue; continue;
} }
var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
var root = nameplateObject.RootComponentNode;
var nameContainer = nameplateObject.NameContainer; var nameContainer = nameplateObject.NameContainer;
var nameText = nameplateObject.NameText; var nameText = nameplateObject.NameText;
var marker = nameplateObject.MarkerIcon;
if (nameContainer == null || nameText == null) if (root == null || root->Component == null || nameContainer == null || nameText == null)
{ {
pNode->AtkResNode.ToggleVisibility(false); _logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex);
pNode->AtkResNode.ToggleVisibility(enable: false);
continue; continue;
} }
root->Component->UldManager.UpdateDrawNodeList();
bool isVisible =
((marker != null) && marker->AtkResNode.IsVisible()) ||
(nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) ||
config.LightfinderLabelShowHidden;
pNode->AtkResNode.ToggleVisibility(isVisible);
if (!isVisible)
continue;
var labelColor = UIColors.Get("Lightfinder"); var labelColor = UIColors.Get("Lightfinder");
var edgeColor = UIColors.Get("LightfinderEdge"); var edgeColor = UIColors.Get("LightfinderEdge");
var config = _configService.Current;
var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f); var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f);
var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f; var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f;
@@ -437,7 +519,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
positionY += config.LightfinderLabelOffsetY; positionY += config.LightfinderLabelOffsetY;
alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8); alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8);
pNode->AtkResNode.SetUseDepthBasedPriority(true); pNode->AtkResNode.SetUseDepthBasedPriority(enable: true);
pNode->AtkResNode.Color.A = 255; pNode->AtkResNode.Color.A = 255;
@@ -545,7 +627,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
} }
private void HideNameplateTextNode(int i) private void HideNameplateTextNode(int i)
{ {
var pNode = mTextNodes[i]; var pNode = _mTextNodes[i];
if (pNode != null) if (pNode != null)
{ {
pNode->AtkResNode.ToggleVisibility(false); pNode->AtkResNode.ToggleVisibility(false);
@@ -555,10 +637,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private AddonNamePlate.NamePlateObject? GetNameplateObject(int i) private AddonNamePlate.NamePlateObject? GetNameplateObject(int i)
{ {
if (i < AddonNamePlate.NumNamePlateObjects && if (i < AddonNamePlate.NumNamePlateObjects &&
mpNameplateAddon != null && _mpNameplateAddon != null &&
mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null) _mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null)
{ {
return mpNameplateAddon->NamePlateObjectArray[i]; return _mpNameplateAddon->NamePlateObjectArray[i];
} }
else else
{ {
@@ -571,10 +653,12 @@ public unsafe class NameplateHandler : IMediatorSubscriber
var nameplateObject = GetNameplateObject(i); var nameplateObject = GetNameplateObject(i);
return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null; return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null;
} }
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs() private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs()
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)]; .Select(u => (ulong)u.PlayerCharacterId)];
public void FlagRefresh() public void FlagRefresh()
{ {
_needsLabelRefresh = true; _needsLabelRefresh = true;
@@ -591,18 +675,12 @@ public unsafe class NameplateHandler : IMediatorSubscriber
public void UpdateBroadcastingCids(IEnumerable<string> cids) public void UpdateBroadcastingCids(IEnumerable<string> cids)
{ {
var newSet = cids.ToHashSet(); var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet))
var changed = !_activeBroadcastingCids.SetEquals(newSet);
if (!changed)
return; return;
_activeBroadcastingCids.Clear(); _activeBroadcastingCids = newSet;
foreach (var cid in newSet) _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids));
_activeBroadcastingCids.Add(cid);
_logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(",", _activeBroadcastingCids));
FlagRefresh(); FlagRefresh();
} }

View File

@@ -1,7 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
@@ -14,10 +10,10 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
private readonly DalamudUtilService _dalamudUtil; private readonly DalamudUtilService _dalamudUtil;
private readonly PairManager _pairManager; private readonly PairManager _pairManager;
private readonly Lazy<WebAPI.ApiController> _apiController; private readonly Lazy<WebAPI.ApiController> _apiController;
private readonly object _syncRoot = new(); private readonly Lock _syncRoot = new();
private readonly List<PairRequestEntry> _requests = []; private readonly List<PairRequestEntry> _requests = [];
private static readonly TimeSpan Expiration = TimeSpan.FromMinutes(5); private static readonly TimeSpan _expiration = TimeSpan.FromMinutes(5);
public PairRequestService( public PairRequestService(
ILogger<PairRequestService> logger, ILogger<PairRequestService> logger,
@@ -189,7 +185,7 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
} }
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
return _requests.RemoveAll(r => now - r.ReceivedAt > Expiration) > 0; return _requests.RemoveAll(r => now - r.ReceivedAt > _expiration) > 0;
} }
public void AcceptPairRequest(string hashedCid, string displayName) public void AcceptPairRequest(string hashedCid, string displayName)

View File

@@ -170,7 +170,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
if (!_charaDataManager.BrioAvailable) if (!_charaDataManager.BrioAvailable)
{ {
ImGuiHelpers.ScaledDummy(3); ImGuiHelpers.ScaledDummy(3);
UiSharedService.DrawGroupedCenteredColorText("To utilize any features related to posing or spawning characters you require to have Brio installed.", ImGuiColors.DalamudRed); UiSharedService.DrawGroupedCenteredColorText("To utilize any features related to posing or spawning characters, you are required to have Brio installed.", ImGuiColors.DalamudRed);
UiSharedService.DistanceSeparator(); UiSharedService.DistanceSeparator();
} }

View File

@@ -1,4 +1,3 @@
using System;
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
@@ -16,12 +15,14 @@ using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Components; using LightlessSync.UI.Components;
using LightlessSync.UI.Handlers; using LightlessSync.UI.Handlers;
using LightlessSync.UI.Models;
using LightlessSync.Utils; using LightlessSync.Utils;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files;
using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.Files.Models;
using LightlessSync.WebAPI.SignalR.Utils; using LightlessSync.WebAPI.SignalR.Utils;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Globalization; using System.Globalization;
@@ -708,23 +709,23 @@ public class CompactUi : WindowMediatorSubscriberBase
} }
//Filter of not foldered syncshells //Filter of not foldered syncshells
var groupFolders = new List<IDrawFolder>(); var groupFolders = new List<GroupFolder>();
foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase)) foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase))
{ {
GetGroups(allPairs, filteredPairs, group, out ImmutableList<Pair> allGroupPairs, out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs); GetGroups(allPairs, filteredPairs, group, out ImmutableList<Pair> allGroupPairs, out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs);
if (FilterNotTaggedSyncshells(group)) if (FilterNotTaggedSyncshells(group))
{ {
groupFolders.Add(_drawEntityFactory.CreateDrawGroupFolder(group, filteredGroupPairs, allGroupPairs)); groupFolders.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder(group, filteredGroupPairs, allGroupPairs)));
} }
} }
//Filter of grouped up syncshells (All Syncshells Folder) //Filter of grouped up syncshells (All Syncshells Folder)
if (_configService.Current.GroupUpSyncshells) if (_configService.Current.GroupUpSyncshells)
drawFolders.Add(new DrawGroupedGroupFolder(groupFolders, _tagHandler, _uiSharedService, drawFolders.Add(new DrawGroupedGroupFolder(groupFolders, _tagHandler, _apiController, _uiSharedService,
_selectSyncshellForTagUi, _renameSyncshellTagUi, "")); _selectSyncshellForTagUi, _renameSyncshellTagUi, ""));
else else
drawFolders.AddRange(groupFolders); drawFolders.AddRange(groupFolders.Select(v => v.GroupDrawFolder));
//Filter of grouped/foldered pairs //Filter of grouped/foldered pairs
foreach (var tag in _tagHandler.GetAllPairTagsSorted()) foreach (var tag in _tagHandler.GetAllPairTagsSorted())
@@ -738,7 +739,7 @@ public class CompactUi : WindowMediatorSubscriberBase
//Filter of grouped/foldered syncshells //Filter of grouped/foldered syncshells
foreach (var syncshellTag in _tagHandler.GetAllSyncshellTagsSorted()) foreach (var syncshellTag in _tagHandler.GetAllSyncshellTagsSorted())
{ {
var syncshellFolderTags = new List<IDrawFolder>(); var syncshellFolderTags = new List<GroupFolder>();
foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase)) foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase))
{ {
if (_tagHandler.HasSyncshellTag(group.GID, syncshellTag)) if (_tagHandler.HasSyncshellTag(group.GID, syncshellTag))
@@ -747,11 +748,11 @@ public class CompactUi : WindowMediatorSubscriberBase
out ImmutableList<Pair> allGroupPairs, out ImmutableList<Pair> allGroupPairs,
out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs); out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs);
syncshellFolderTags.Add(_drawEntityFactory.CreateDrawGroupFolder($"tag_{group.GID}", group, filteredGroupPairs, allGroupPairs)); syncshellFolderTags.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder($"tag_{group.GID}", group, filteredGroupPairs, allGroupPairs)));
} }
} }
drawFolders.Add(new DrawGroupedGroupFolder(syncshellFolderTags, _tagHandler, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, syncshellTag)); drawFolders.Add(new DrawGroupedGroupFolder(syncshellFolderTags, _tagHandler, _apiController, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, syncshellTag));
} }
//Filter of not grouped/foldered and offline pairs //Filter of not grouped/foldered and offline pairs

View File

@@ -1,7 +1,11 @@
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
using LightlessSync.UI.Handlers; using LightlessSync.UI.Handlers;
using LightlessSync.UI.Models;
using LightlessSync.WebAPI;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Numerics; using System.Numerics;
@@ -10,19 +14,20 @@ namespace LightlessSync.UI.Components;
public class DrawGroupedGroupFolder : IDrawFolder public class DrawGroupedGroupFolder : IDrawFolder
{ {
private readonly string _tag; private readonly string _tag;
private readonly IEnumerable<IDrawFolder> _groups; private readonly IEnumerable<GroupFolder> _groups;
private readonly TagHandler _tagHandler; private readonly TagHandler _tagHandler;
private readonly UiSharedService _uiSharedService; private readonly UiSharedService _uiSharedService;
private readonly ApiController _apiController;
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
private readonly RenameSyncshellTagUi _renameSyncshellTagUi; private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
private bool _wasHovered = false; private bool _wasHovered = false;
private float _menuWidth; private float _menuWidth;
public IImmutableList<DrawUserPair> DrawPairs => throw new NotSupportedException(); public IImmutableList<DrawUserPair> DrawPairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList();
public int OnlinePairs => _groups.SelectMany(g => g.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count(); public int OnlinePairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count();
public int TotalPairs => _groups.Sum(g => g.TotalPairs); public int TotalPairs => _groups.Sum(g => g.GroupDrawFolder.TotalPairs);
public DrawGroupedGroupFolder(IEnumerable<IDrawFolder> groups, TagHandler tagHandler, UiSharedService uiSharedService, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, string tag) public DrawGroupedGroupFolder(IEnumerable<GroupFolder> groups, TagHandler tagHandler, ApiController apiController, UiSharedService uiSharedService, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, string tag)
{ {
_groups = groups; _groups = groups;
_tagHandler = tagHandler; _tagHandler = tagHandler;
@@ -30,6 +35,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
_selectSyncshellForTagUi = selectSyncshellForTagUi; _selectSyncshellForTagUi = selectSyncshellForTagUi;
_renameSyncshellTagUi = renameSyncshellTagUi; _renameSyncshellTagUi = renameSyncshellTagUi;
_tag = tag; _tag = tag;
_apiController = apiController;
} }
public void Draw() public void Draw()
@@ -42,7 +48,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
using var id = ImRaii.PushId(_id); using var id = ImRaii.PushId(_id);
var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered); var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered);
using (ImRaii.Child("folder__" + _id, new System.Numerics.Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight()))) using (ImRaii.Child("folder__" + _id, new Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight())))
{ {
ImGui.Dummy(new Vector2(0f, ImGui.GetFrameHeight())); ImGui.Dummy(new Vector2(0f, ImGui.GetFrameHeight()));
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(0f, 0f))) using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(0f, 0f)))
@@ -83,11 +89,16 @@ public class DrawGroupedGroupFolder : IDrawFolder
{ {
ImGui.TextUnformatted(_tag); ImGui.TextUnformatted(_tag);
ImGui.SameLine();
DrawPauseButton();
ImGui.SameLine(); ImGui.SameLine();
DrawMenu(); DrawMenu();
} else } else
{ {
ImGui.TextUnformatted("All Syncshells"); ImGui.TextUnformatted("All Syncshells");
ImGui.SameLine();
DrawPauseButton();
} }
} }
color.Dispose(); color.Dispose();
@@ -100,11 +111,49 @@ public class DrawGroupedGroupFolder : IDrawFolder
using var indent = ImRaii.PushIndent(20f); using var indent = ImRaii.PushIndent(20f);
foreach (var entry in _groups) foreach (var entry in _groups)
{ {
entry.Draw(); entry.GroupDrawFolder.Draw();
} }
} }
} }
protected void DrawPauseButton()
{
if (DrawPairs.Count > 0)
{
var isPaused = _groups.Select(g => g.GroupFullInfo).All(g => g.GroupUserPermissions.IsPaused());
FontAwesomeIcon pauseIcon = isPaused ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause;
var pauseButtonSize = _uiSharedService.GetIconButtonSize(pauseIcon);
var windowEndX = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth();
if (_tag != "")
{
var spacingX = ImGui.GetStyle().ItemSpacing.X;
var menuButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV);
ImGui.SameLine(windowEndX - pauseButtonSize.X - menuButtonSize.X - spacingX);
}
else
{
ImGui.SameLine(windowEndX - pauseButtonSize.X);
}
if (_uiSharedService.IconButton(pauseIcon))
{
ChangePauseStateGroups();
}
}
}
protected void ChangePauseStateGroups()
{
foreach(var group in _groups)
{
var perm = group.GroupFullInfo.GroupUserPermissions;
perm.SetPaused(!perm.IsPaused());
_ = _apiController.GroupChangeIndividualPermissionState(new GroupPairUserPermissionDto(group.GroupFullInfo.Group, new(_apiController.UID), perm));
}
}
protected void DrawMenu() protected void DrawMenu()
{ {
var barButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV); var barButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV);

View File

@@ -1,5 +1,4 @@
 using System.Collections.Immutable;
using System.Collections.Immutable;
namespace LightlessSync.UI.Components; namespace LightlessSync.UI.Components;

View File

@@ -2,19 +2,22 @@ using Dalamud.Game.Gui.Dtr;
using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using Dalamud.Utility;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.LightlessConfiguration.Configurations;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Utils;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using LightlessSync.WebAPI.SignalR.Utils; using LightlessSync.WebAPI.SignalR.Utils;
using LightlessSync.Utils;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
using static LightlessSync.Services.PairRequestService;
namespace LightlessSync.UI; namespace LightlessSync.UI;
@@ -106,7 +109,7 @@ public sealed class DtrEntry : IDisposable, IHostedService
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
_logger.LogInformation("Lightfinder operation was canceled.");
} }
finally finally
{ {
@@ -363,29 +366,46 @@ public sealed class DtrEntry : IDisposable, IHostedService
} }
} }
private int GetNearbyBroadcastCount() private List<string> GetNearbyBroadcasts()
{
var localHashedCid = GetLocalHashedCid();
return _broadcastScannerService.CountActiveBroadcasts(
string.IsNullOrEmpty(localHashedCid) ? null : localHashedCid);
}
private int GetPendingPairRequestCount()
{ {
try try
{ {
return _pairRequestService.GetActiveRequests().Count; var localHashedCid = GetLocalHashedCid();
return [.. _broadcastScannerService
.GetActiveBroadcasts(string.IsNullOrEmpty(localHashedCid) ? null : localHashedCid)
.Select(b => _dalamudUtilService.FindPlayerByNameHash(b.Key).Name)];
} }
catch (Exception ex) catch (Exception ex)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
if (now >= _pairRequestNextErrorLog)
{
_logger.LogDebug(ex, "Failed to retrieve nearby broadcasts for Lightfinder DTR entry.");
_pairRequestNextErrorLog = now + _localHashedCidErrorCooldown;
}
return [];
}
}
private IReadOnlyList<PairRequestDisplay> GetPendingPairRequest()
{
try
{
return _pairRequestService.GetActiveRequests();
}
catch (Exception ex)
{
var now = DateTime.UtcNow;
if (now >= _pairRequestNextErrorLog) if (now >= _pairRequestNextErrorLog)
{ {
_logger.LogDebug(ex, "Failed to retrieve pair request count for Lightfinder DTR entry."); _logger.LogDebug(ex, "Failed to retrieve pair request count for Lightfinder DTR entry.");
_pairRequestNextErrorLog = now + _localHashedCidErrorCooldown; _pairRequestNextErrorLog = now + _localHashedCidErrorCooldown;
} }
return 0; return [];
} }
} }
@@ -400,23 +420,15 @@ public sealed class DtrEntry : IDisposable, IHostedService
if (_broadcastService.IsBroadcasting) if (_broadcastService.IsBroadcasting)
{ {
var tooltipBuilder = new StringBuilder("Lightfinder - Enabled");
switch (config.LightfinderDtrDisplayMode) switch (config.LightfinderDtrDisplayMode)
{ {
case LightfinderDtrDisplayMode.PendingPairRequests: case LightfinderDtrDisplayMode.PendingPairRequests:
{ {
var requestCount = GetPendingPairRequestCount(); return FormatTooltip("Pending pair requests", GetPendingPairRequest().Select(x => x.DisplayName), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled));
tooltipBuilder.AppendLine();
tooltipBuilder.Append("Pending pair requests: ").Append(requestCount);
return ($"{icon} Requests {requestCount}", SwapColorChannels(config.DtrColorsLightfinderEnabled), tooltipBuilder.ToString());
} }
default: default:
{ {
var broadcastCount = GetNearbyBroadcastCount(); return FormatTooltip("Nearby Lightfinder users", GetNearbyBroadcasts(), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled));
tooltipBuilder.AppendLine();
tooltipBuilder.Append("Nearby Lightfinder users: ").Append(broadcastCount);
return ($"{icon} {broadcastCount}", SwapColorChannels(config.DtrColorsLightfinderEnabled), tooltipBuilder.ToString());
} }
} }
} }
@@ -433,6 +445,18 @@ public sealed class DtrEntry : IDisposable, IHostedService
return ($"{icon} OFF", colors, tooltip.ToString()); return ($"{icon} OFF", colors, tooltip.ToString());
} }
private (string, Colors, string) FormatTooltip(string title, IEnumerable<string> names, string icon, Colors color)
{
var list = names.Where(x => !string.IsNullOrEmpty(x)).ToList();
var tooltip = new StringBuilder()
.Append($"Lightfinder - Enabled{Environment.NewLine}")
.Append($"{title}: {list.Count}{Environment.NewLine}")
.AppendJoin(Environment.NewLine, list)
.ToString();
return ($"{icon} {list.Count}", color, tooltip);
}
private static string BuildLightfinderTooltip(string baseTooltip) private static string BuildLightfinderTooltip(string baseTooltip)
{ {
var builder = new StringBuilder(); var builder = new StringBuilder();

View File

@@ -0,0 +1,6 @@
using LightlessSync.API.Dto.Group;
using LightlessSync.UI.Components;
namespace LightlessSync.UI.Models;
public record GroupFolder(GroupFullInfoDto GroupFullInfo, IDrawFolder GroupDrawFolder);