Compare commits

..

10 Commits

23 changed files with 644 additions and 144 deletions

View File

@@ -6,6 +6,7 @@ using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.IO;
namespace LightlessSync.FileCache;
@@ -21,6 +22,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private CancellationTokenSource _scanCancellationTokenSource = new();
private readonly CancellationTokenSource _periodicCalculationTokenSource = new();
public static readonly IImmutableList<string> AllowedFileExtensions = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk", ".kdb"];
private static readonly HashSet<string> AllowedFileExtensionSet = new(AllowedFileExtensions, StringComparer.OrdinalIgnoreCase);
public CacheMonitor(ILogger<CacheMonitor> logger, IpcManager ipcManager, LightlessConfigService configService,
FileCacheManager fileDbManager, LightlessMediator mediator, PerformanceCollectorService performanceCollector, DalamudUtilService dalamudUtil,
@@ -163,7 +165,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
{
Logger.LogTrace("Lightless FSW: FileChanged: {change} => {path}", e.ChangeType, e.FullPath);
if (!AllowedFileExtensions.Any(ext => e.FullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) return;
if (!HasAllowedExtension(e.FullPath)) return;
lock (_watcherChanges)
{
@@ -207,7 +209,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private void Fs_Changed(object sender, FileSystemEventArgs e)
{
if (Directory.Exists(e.FullPath)) return;
if (!AllowedFileExtensions.Any(ext => e.FullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) return;
if (!HasAllowedExtension(e.FullPath)) return;
if (e.ChangeType is not (WatcherChangeTypes.Changed or WatcherChangeTypes.Deleted or WatcherChangeTypes.Created))
return;
@@ -231,7 +233,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
{
foreach (var file in directoryFiles)
{
if (!AllowedFileExtensions.Any(ext => file.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) continue;
if (!HasAllowedExtension(file)) continue;
var oldPath = file.Replace(e.FullPath, e.OldFullPath, StringComparison.OrdinalIgnoreCase);
_watcherChanges.Remove(oldPath);
@@ -243,7 +245,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
}
else
{
if (!AllowedFileExtensions.Any(ext => e.FullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) return;
if (!HasAllowedExtension(e.FullPath)) return;
lock (_watcherChanges)
{
@@ -263,6 +265,17 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public FileSystemWatcher? PenumbraWatcher { get; private set; }
public FileSystemWatcher? LightlessWatcher { get; private set; }
private static bool HasAllowedExtension(string path)
{
if (string.IsNullOrEmpty(path))
{
return false;
}
var extension = Path.GetExtension(path);
return !string.IsNullOrEmpty(extension) && AllowedFileExtensionSet.Contains(extension);
}
private async Task LightlessWatcherExecution()
{
_lightlessFswCts = _lightlessFswCts.CancelRecreate();
@@ -606,7 +619,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
[
.. Directory.GetFiles(folder, "*.*", SearchOption.AllDirectories)
.AsParallel()
.Where(f => AllowedFileExtensions.Any(e => f.EndsWith(e, StringComparison.OrdinalIgnoreCase))
.Where(f => HasAllowedExtension(f)
&& !f.Contains(@"\bg\", StringComparison.OrdinalIgnoreCase)
&& !f.Contains(@"\bgcommon\", StringComparison.OrdinalIgnoreCase)
&& !f.Contains(@"\ui\", StringComparison.OrdinalIgnoreCase)),

View File

@@ -372,6 +372,9 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
{
if (descriptor.IsInGpose)
return;
if (!TryResolveObjectKind(descriptor, out var resolvedKind))
return;

View File

@@ -1,5 +1,4 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services;
@@ -13,7 +12,7 @@ public sealed class IpcCallerMoodles : IpcServiceBase
private static readonly IpcServiceDescriptor MoodlesDescriptor = new("Moodles", "Moodles", new Version(0, 0, 0, 0));
private readonly ICallGateSubscriber<int> _moodlesApiVersion;
private readonly ICallGateSubscriber<IPlayerCharacter, object> _moodlesOnChange;
private readonly ICallGateSubscriber<nint, object> _moodlesOnChange;
private readonly ICallGateSubscriber<nint, string> _moodlesGetStatus;
private readonly ICallGateSubscriber<nint, string, object> _moodlesSetStatus;
private readonly ICallGateSubscriber<nint, object> _moodlesRevertStatus;
@@ -29,7 +28,7 @@ public sealed class IpcCallerMoodles : IpcServiceBase
_lightlessMediator = lightlessMediator;
_moodlesApiVersion = pi.GetIpcSubscriber<int>("Moodles.Version");
_moodlesOnChange = pi.GetIpcSubscriber<IPlayerCharacter, object>("Moodles.StatusManagerModified");
_moodlesOnChange = pi.GetIpcSubscriber<nint, object>("Moodles.StatusManagerModified");
_moodlesGetStatus = pi.GetIpcSubscriber<nint, string>("Moodles.GetStatusManagerByPtrV2");
_moodlesSetStatus = pi.GetIpcSubscriber<nint, string, object>("Moodles.SetStatusManagerByPtrV2");
_moodlesRevertStatus = pi.GetIpcSubscriber<nint, object>("Moodles.ClearStatusManagerByPtrV2");
@@ -39,9 +38,9 @@ public sealed class IpcCallerMoodles : IpcServiceBase
CheckAPI();
}
private void OnMoodlesChange(IPlayerCharacter character)
private void OnMoodlesChange(nint address)
{
_lightlessMediator.Publish(new MoodlesMessage(character.Address));
_lightlessMediator.Publish(new MoodlesMessage(address));
}
protected override void Dispose(bool disposing)
@@ -107,7 +106,7 @@ public sealed class IpcCallerMoodles : IpcServiceBase
try
{
return _moodlesApiVersion.InvokeFunc() == 3
return _moodlesApiVersion.InvokeFunc() >= 4
? IpcConnectionState.Available
: IpcConnectionState.VersionMismatch;
}

View File

@@ -11,6 +11,8 @@ public sealed class ChatConfig : ILightlessConfiguration
public bool ShowRulesOverlayOnOpen { get; set; } = true;
public bool ShowMessageTimestamps { get; set; } = true;
public float ChatWindowOpacity { get; set; } = .97f;
public bool FadeWhenUnfocused { get; set; } = false;
public float UnfocusedWindowOpacity { get; set; } = 0.6f;
public bool IsWindowPinned { get; set; } = false;
public bool AutoOpenChatOnPluginLoad { get; set; } = false;
public float ChatFontScale { get; set; } = 1.0f;

View File

@@ -49,7 +49,8 @@ public class LightlessConfig : ILightlessConfiguration
public int DownloadSpeedLimitInBytes { get; set; } = 0;
public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps;
public bool PreferNotesOverNamesForVisible { get; set; } = false;
public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Default;
public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Alphabetical;
public OnlinePairSortMode OnlinePairSortMode { get; set; } = OnlinePairSortMode.Alphabetical;
public float ProfileDelay { get; set; } = 1.5f;
public bool ProfilePopoutRight { get; set; } = false;
public bool ProfilesAllowNsfw { get; set; } = false;

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<Authors></Authors>
<Company></Company>
<Version>1.42.0.72</Version>
<Version>2.0.0</Version>
<Description></Description>
<Copyright></Copyright>
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>

View File

@@ -119,6 +119,7 @@ public class PlayerDataFactory
CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new();
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
var logDebug = _logger.IsEnabled(LogLevel.Debug);
// wait until chara is not drawing and present so nothing spontaneously explodes
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct).ConfigureAwait(false);
@@ -132,11 +133,6 @@ public class PlayerDataFactory
ct.ThrowIfCancellationRequested();
Dictionary<string, List<ushort>>? boneIndices =
objectKind != ObjectKind.Player
? null
: await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false);
DateTime start = DateTime.UtcNow;
// penumbra call, it's currently broken
@@ -154,11 +150,21 @@ public class PlayerDataFactory
ct.ThrowIfCancellationRequested();
_logger.LogDebug("== Static Replacements ==");
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
if (logDebug)
{
_logger.LogDebug("=> {repl}", replacement);
ct.ThrowIfCancellationRequested();
_logger.LogDebug("== Static Replacements ==");
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
{
_logger.LogDebug("=> {repl}", replacement);
ct.ThrowIfCancellationRequested();
}
}
else
{
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement))
{
ct.ThrowIfCancellationRequested();
}
}
await _transientResourceManager.WaitForRecording(ct).ConfigureAwait(false);
@@ -190,11 +196,21 @@ public class PlayerDataFactory
var transientPaths = ManageSemiTransientData(objectKind);
var resolvedTransientPaths = await GetFileReplacementsFromPaths(transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
_logger.LogDebug("== Transient Replacements ==");
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)).OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
if (logDebug)
{
_logger.LogDebug("=> {repl}", replacement);
fragment.FileReplacements.Add(replacement);
_logger.LogDebug("== Transient Replacements ==");
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)).OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
{
_logger.LogDebug("=> {repl}", replacement);
fragment.FileReplacements.Add(replacement);
}
}
else
{
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)))
{
fragment.FileReplacements.Add(replacement);
}
}
// clean up all semi transient resources that don't have any file replacement (aka null resolve)
@@ -252,11 +268,26 @@ public class PlayerDataFactory
ct.ThrowIfCancellationRequested();
Dictionary<string, List<ushort>>? boneIndices = null;
var hasPapFiles = false;
if (objectKind == ObjectKind.Player)
{
hasPapFiles = fragment.FileReplacements.Any(f =>
!f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase));
if (hasPapFiles)
{
boneIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false);
}
}
if (objectKind == ObjectKind.Player)
{
try
{
await VerifyPlayerAnimationBones(boneIndices, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false);
if (hasPapFiles)
{
await VerifyPlayerAnimationBones(boneIndices, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException e)
{
@@ -278,12 +309,16 @@ public class PlayerDataFactory
{
if (boneIndices == null) return;
foreach (var kvp in boneIndices)
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value));
foreach (var kvp in boneIndices)
{
_logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value));
}
}
if (boneIndices.All(u => u.Value.Count == 0)) return;
var maxPlayerBoneIndex = boneIndices.SelectMany(kvp => kvp.Value).DefaultIfEmpty().Max();
if (maxPlayerBoneIndex <= 0) return;
int noValidationFailed = 0;
foreach (var file in fragment.FileReplacements.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList())
@@ -303,12 +338,13 @@ public class PlayerDataFactory
_logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count);
foreach (var boneCount in skeletonIndices.Select(k => k).ToList())
foreach (var boneCount in skeletonIndices)
{
if (boneCount.Value.Max() > boneIndices.SelectMany(b => b.Value).Max())
var maxAnimationIndex = boneCount.Value.DefaultIfEmpty().Max();
if (maxAnimationIndex > maxPlayerBoneIndex)
{
_logger.LogWarning("Found more bone indices on the animation {path} skeleton {skl} (max indice {idx}) than on any player related skeleton (max indice {idx2})",
file.ResolvedPath, boneCount.Key, boneCount.Value.Max(), boneIndices.SelectMany(b => b.Value).Max());
file.ResolvedPath, boneCount.Key, maxAnimationIndex, maxPlayerBoneIndex);
validationFailed = true;
break;
}

View File

@@ -1,36 +1,38 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data;
namespace LightlessSync.PlayerData.Pairs;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// orchestrates the lifecycle of a paired character
/// </summary>
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
{
new string Ident { get; }
bool Initialized { get; }
bool IsVisible { get; }
bool ScheduledForDeletion { get; set; }
CharacterData? LastReceivedCharacterData { get; }
long LastAppliedDataBytes { get; }
new string? PlayerName { get; }
string PlayerNameHash { get; }
uint PlayerCharacterId { get; }
DateTime? LastDataReceivedAt { get; }
DateTime? LastApplyAttemptAt { get; }
DateTime? LastSuccessfulApplyAt { get; }
string? LastFailureReason { get; }
IReadOnlyList<string> LastBlockingConditions { get; }
bool IsApplying { get; }
bool IsDownloading { get; }
int PendingDownloadCount { get; }
int ForbiddenDownloadCount { get; }
/// <summary>
/// orchestrates the lifecycle of a paired character
/// </summary>
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
{
new string Ident { get; }
bool Initialized { get; }
bool IsVisible { get; }
bool ScheduledForDeletion { get; set; }
CharacterData? LastReceivedCharacterData { get; }
long LastAppliedDataBytes { get; }
new string? PlayerName { get; }
string PlayerNameHash { get; }
uint PlayerCharacterId { get; }
DateTime? LastDataReceivedAt { get; }
DateTime? LastApplyAttemptAt { get; }
DateTime? LastSuccessfulApplyAt { get; }
string? LastFailureReason { get; }
IReadOnlyList<string> LastBlockingConditions { get; }
bool IsApplying { get; }
bool IsDownloading { get; }
int PendingDownloadCount { get; }
int ForbiddenDownloadCount { get; }
DateTime? InvisibleSinceUtc { get; }
DateTime? VisibilityEvictionDueAtUtc { get; }
void Initialize();
void ApplyData(CharacterData data);
void ApplyLastReceivedData(bool forced = false);
bool FetchPerformanceMetricsFromCache();
void LoadCachedCharacterData(CharacterData data);
void SetUploading(bool uploading);
void SetPaused(bool paused);
}
void ApplyData(CharacterData data);
void ApplyLastReceivedData(bool forced = false);
bool FetchPerformanceMetricsFromCache();
void LoadCachedCharacterData(CharacterData data);
void SetUploading(bool uploading);
void SetPaused(bool paused);
}

View File

@@ -194,9 +194,13 @@ public class Pair
{
var handler = TryGetHandler();
if (handler is null)
{
return PairDebugInfo.Empty;
}
var now = DateTime.UtcNow;
var dueAt = handler.VisibilityEvictionDueAtUtc;
var remainingSeconds = dueAt.HasValue
? Math.Max(0, (dueAt.Value - now).TotalSeconds)
: (double?)null;
return new PairDebugInfo(
true,
@@ -206,6 +210,9 @@ public class Pair
handler.LastDataReceivedAt,
handler.LastApplyAttemptAt,
handler.LastSuccessfulApplyAt,
handler.InvisibleSinceUtc,
handler.VisibilityEvictionDueAtUtc,
remainingSeconds,
handler.LastFailureReason,
handler.LastBlockingConditions,
handler.IsApplying,

View File

@@ -8,6 +8,9 @@ public sealed record PairDebugInfo(
DateTime? LastDataReceivedAt,
DateTime? LastApplyAttemptAt,
DateTime? LastSuccessfulApplyAt,
DateTime? InvisibleSinceUtc,
DateTime? VisibilityEvictionDueAtUtc,
double? VisibilityEvictionRemainingSeconds,
string? LastFailureReason,
IReadOnlyList<string> BlockingConditions,
bool IsApplying,
@@ -24,6 +27,9 @@ public sealed record PairDebugInfo(
null,
null,
null,
null,
null,
null,
Array.Empty<string>(),
false,
false,

View File

@@ -70,7 +70,14 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
private DateTime? _lastSuccessfulApplyAt;
private string? _lastFailureReason;
private IReadOnlyList<string> _lastBlockingConditions = Array.Empty<string>();
private readonly object _visibilityGraceGate = new();
private CancellationTokenSource? _visibilityGraceCts;
private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1);
private DateTime? _invisibleSinceUtc;
private DateTime? _visibilityEvictionDueAtUtc;
public DateTime? InvisibleSinceUtc => _invisibleSinceUtc;
public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc;
public string Ident { get; }
public bool Initialized { get; private set; }
public bool ScheduledForDeletion { get; set; }
@@ -80,24 +87,37 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
get => _isVisible;
private set
{
if (_isVisible != value)
if (_isVisible == value) return;
_isVisible = value;
if (!_isVisible)
{
_isVisible = value;
if (!_isVisible)
{
DisableSync();
ResetPenumbraCollection(reason: "VisibilityLost");
}
else if (_charaHandler is not null && _charaHandler.Address != nint.Zero)
{
_ = EnsurePenumbraCollection();
}
var user = GetPrimaryUserData();
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter),
EventSeverity.Informational, "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible"))));
Mediator.Publish(new RefreshUiMessage());
Mediator.Publish(new VisibilityChange());
DisableSync();
_invisibleSinceUtc = DateTime.UtcNow;
_visibilityEvictionDueAtUtc = _invisibleSinceUtc.Value.Add(VisibilityEvictionGrace);
StartVisibilityGraceTask();
}
else
{
CancelVisibilityGraceTask();
_invisibleSinceUtc = null;
_visibilityEvictionDueAtUtc = null;
ScheduledForDeletion = false;
if (_charaHandler is not null && _charaHandler.Address != nint.Zero)
_ = EnsurePenumbraCollection();
}
var user = GetPrimaryUserData();
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter),
EventSeverity.Informational, "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible"))));
Mediator.Publish(new RefreshUiMessage());
Mediator.Publish(new VisibilityChange());
}
}
@@ -918,6 +938,46 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
}
}
private void CancelVisibilityGraceTask()
{
lock (_visibilityGraceGate)
{
_visibilityGraceCts?.CancelDispose();
_visibilityGraceCts = null;
}
}
private void StartVisibilityGraceTask()
{
CancellationToken token;
lock (_visibilityGraceGate)
{
_visibilityGraceCts = _visibilityGraceCts?.CancelRecreate() ?? new CancellationTokenSource();
token = _visibilityGraceCts.Token;
}
_visibilityGraceTask = Task.Run(async () =>
{
try
{
await Task.Delay(VisibilityEvictionGrace, token).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
if (IsVisible) return;
ScheduledForDeletion = true;
ResetPenumbraCollection(reason: "VisibilityLostTimeout");
}
catch (OperationCanceledException)
{
// operation cancelled, do nothing
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Visibility grace task failed for {handler}", GetLogIdentifier());
}
}, CancellationToken.None);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
@@ -936,7 +996,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
_downloadCancellationTokenSource = null;
_downloadManager.Dispose();
_charaHandler?.Dispose();
CancelVisibilityGraceTask();
_charaHandler = null;
_invisibleSinceUtc = null;
_visibilityEvictionDueAtUtc = null;
if (!string.IsNullOrEmpty(name))
{
@@ -1265,6 +1328,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
}
private Task? _pairDownloadTask;
private Task _visibilityGraceTask;
private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData,
bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken)

View File

@@ -11,7 +11,9 @@ public sealed class PairHandlerRegistry : IDisposable
{
private readonly object _gate = new();
private readonly object _pendingGate = new();
private readonly object _visibilityGate = new();
private readonly Dictionary<string, PairHandlerEntry> _entriesByIdent = new(StringComparer.Ordinal);
private readonly Dictionary<string, CancellationTokenSource> _pendingInvisibleEvictions = new(StringComparer.Ordinal);
private readonly Dictionary<IPairHandlerAdapter, PairHandlerEntry> _entriesByHandler = new(ReferenceEqualityComparer.Instance);
private readonly IPairHandlerAdapterFactory _handlerFactory;
@@ -144,6 +146,37 @@ public sealed class PairHandlerRegistry : IDisposable
return PairOperationResult<PairUniqueIdentifier>.Ok(registration.PairIdent);
}
private PairOperationResult CancelAllInvisibleEvictions()
{
List<CancellationTokenSource> snapshot;
lock (_visibilityGate)
{
snapshot = [.. _pendingInvisibleEvictions.Values];
_pendingInvisibleEvictions.Clear();
}
List<string>? errors = null;
foreach (var cts in snapshot)
{
try { cts.Cancel(); }
catch (Exception ex)
{
(errors ??= new List<string>()).Add($"Cancel: {ex.Message}");
}
try { cts.Dispose(); }
catch (Exception ex)
{
(errors ??= new List<string>()).Add($"Dispose: {ex.Message}");
}
}
return errors is null
? PairOperationResult.Ok()
: PairOperationResult.Fail($"CancelAllInvisibleEvictions had error(s): {string.Join(" | ", errors)}");
}
public PairOperationResult ApplyCharacterData(PairRegistration registration, OnlineUserCharaDataDto dto)
{
if (registration.CharacterIdent is null)
@@ -300,6 +333,7 @@ public sealed class PairHandlerRegistry : IDisposable
lock (_gate)
{
handlers = _entriesByHandler.Keys.ToList();
CancelAllInvisibleEvictions();
_entriesByIdent.Clear();
_entriesByHandler.Clear();
}
@@ -332,6 +366,7 @@ public sealed class PairHandlerRegistry : IDisposable
lock (_gate)
{
handlers = _entriesByHandler.Keys.ToList();
CancelAllInvisibleEvictions();
_entriesByIdent.Clear();
_entriesByHandler.Clear();
}

View File

@@ -49,7 +49,7 @@ public sealed class CommandManagerService : IDisposable
"\t /light analyze - Opens the Lightless Character Data Analysis window" + Environment.NewLine +
"\t /light settings - Opens the Lightless Settings window" + Environment.NewLine +
"\t /light finder - Opens the Lightfinder window" + Environment.NewLine +
"\t /light finder - Opens the Lightless Chat window"
"\t /light chat - Opens the Lightless Chat window"
});
}

View File

@@ -126,11 +126,11 @@ public sealed class TextureMetadataHelper
private const string TextureSegment = "/texture/";
private const string MaterialSegment = "/material/";
private const uint NormalSamplerId = 0x0C5EC1F1u;
private const uint IndexSamplerId = 0x565F8FD8u;
private const uint SpecularSamplerId = 0x2B99E025u;
private const uint DiffuseSamplerId = 0x115306BEu;
private const uint MaskSamplerId = 0x8A4E82B6u;
private const uint NormalSamplerId = ShpkFile.NormalSamplerId;
private const uint IndexSamplerId = ShpkFile.IndexSamplerId;
private const uint SpecularSamplerId = ShpkFile.SpecularSamplerId;
private const uint DiffuseSamplerId = ShpkFile.DiffuseSamplerId;
private const uint MaskSamplerId = ShpkFile.MaskSamplerId;
public TextureMetadataHelper(ILogger<TextureMetadataHelper> logger, IDataManager dataManager)
{

View File

@@ -843,12 +843,16 @@ public class CompactUi : WindowMediatorSubscriberBase
//Filter of not grouped/foldered and offline pairs
var allOnlineNotTaggedPairs = SortEntries(allEntries.Where(FilterNotTaggedUsers));
var onlineNotTaggedPairs = SortEntries(filteredEntries.Where(e => FilterNotTaggedUsers(e) && FilterOnlineOrPausedSelf(e)));
if (allOnlineNotTaggedPairs.Count > 0)
{
if (allOnlineNotTaggedPairs.Count > 0 && _configService.Current.ShowOfflineUsersSeparately) {
var filteredOnlineEntries = SortOnlineEntries(filteredEntries.Where(e => FilterNotTaggedUsers(e) && FilterOnlineOrPausedSelf(e)));
drawFolders.Add(_drawEntityFactory.CreateTagFolder(
_configService.Current.ShowOfflineUsersSeparately ? TagHandler.CustomOnlineTag : TagHandler.CustomAllTag,
TagHandler.CustomOnlineTag,
filteredOnlineEntries,
allOnlineNotTaggedPairs));
} else if (allOnlineNotTaggedPairs.Count > 0 && !_configService.Current.ShowOfflineUsersSeparately) {
var onlineNotTaggedPairs = SortEntries(filteredEntries.Where(FilterNotTaggedUsers));
drawFolders.Add(_drawEntityFactory.CreateTagFolder(
TagHandler.CustomAllTag,
onlineNotTaggedPairs,
allOnlineNotTaggedPairs));
}
@@ -885,7 +889,7 @@ public class CompactUi : WindowMediatorSubscriberBase
}
}
private bool PassesFilter(PairUiEntry entry, string filter)
private static bool PassesFilter(PairUiEntry entry, string filter)
{
if (string.IsNullOrEmpty(filter)) return true;
@@ -946,6 +950,17 @@ public class CompactUi : WindowMediatorSubscriberBase
};
}
private ImmutableList<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

View File

@@ -4,8 +4,8 @@ using Dalamud.Interface.Utility.Raii;
using LightlessSync.UI.Handlers;
using LightlessSync.UI.Models;
using System.Collections.Immutable;
using LightlessSync.UI;
using LightlessSync.UI.Style;
using OtterGui.Text;
namespace LightlessSync.UI.Components;
@@ -113,9 +113,13 @@ public abstract class DrawFolderBase : IDrawFolder
using var indent = ImRaii.PushIndent(_uiSharedService.GetIconSize(FontAwesomeIcon.EllipsisV).X + ImGui.GetStyle().ItemSpacing.X, false);
if (DrawPairs.Any())
{
foreach (var item in DrawPairs)
using var clipper = ImUtf8.ListClipper(DrawPairs.Count, ImGui.GetFrameHeightWithSpacing());
while (clipper.Step())
{
item.DrawPairedClient();
for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++)
{
DrawPairs[i].DrawPairedClient();
}
}
}
else

View File

@@ -169,11 +169,16 @@ public class DrawFolderTag : DrawFolderBase
protected override float DrawRightSide(float currentRightSideX)
{
if (_id == TagHandler.CustomVisibleTag)
if (string.Equals(_id, TagHandler.CustomVisibleTag, StringComparison.Ordinal))
{
return DrawVisibleFilter(currentRightSideX);
}
if (string.Equals(_id, TagHandler.CustomOnlineTag, StringComparison.Ordinal))
{
return DrawOnlineFilter(currentRightSideX);
}
if (!RenderPause)
{
return currentRightSideX;
@@ -254,7 +259,7 @@ public class DrawFolderTag : DrawFolderBase
foreach (VisiblePairSortMode mode in Enum.GetValues<VisiblePairSortMode>())
{
var selected = _configService.Current.VisiblePairSortMode == mode;
if (ImGui.MenuItem(GetSortLabel(mode), string.Empty, selected))
if (ImGui.MenuItem(GetSortVisibleLabel(mode), string.Empty, selected))
{
if (!selected)
{
@@ -273,7 +278,49 @@ public class DrawFolderTag : DrawFolderBase
return buttonStart - spacingX;
}
private static string GetSortLabel(VisiblePairSortMode mode) => mode switch
private float DrawOnlineFilter(float currentRightSideX)
{
var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Filter);
var spacingX = ImGui.GetStyle().ItemSpacing.X;
var buttonStart = currentRightSideX - buttonSize.X;
ImGui.SameLine(buttonStart);
if (_uiSharedService.IconButton(FontAwesomeIcon.Filter))
{
SuppressNextRowToggle();
ImGui.OpenPopup($"online-filter-{_id}");
}
UiSharedService.AttachToolTip("Adjust how online pairs are ordered.");
if (ImGui.BeginPopup($"online-filter-{_id}"))
{
ImGui.TextUnformatted("Online Pair Ordering");
ImGui.Separator();
foreach (OnlinePairSortMode mode in Enum.GetValues<OnlinePairSortMode>())
{
var selected = _configService.Current.OnlinePairSortMode == mode;
if (ImGui.MenuItem(GetSortOnlineLabel(mode), string.Empty, selected))
{
if (!selected)
{
_configService.Current.OnlinePairSortMode = mode;
_configService.Save();
_mediator.Publish(new RefreshUiMessage());
}
ImGui.CloseCurrentPopup();
}
}
ImGui.EndPopup();
}
return buttonStart - spacingX;
}
private static string GetSortVisibleLabel(VisiblePairSortMode mode) => mode switch
{
VisiblePairSortMode.Alphabetical => "Alphabetical",
VisiblePairSortMode.VramUsage => "VRAM usage (descending)",
@@ -282,4 +329,11 @@ public class DrawFolderTag : DrawFolderBase
VisiblePairSortMode.PreferredDirectPairs => "Preferred permissions & Direct pairs",
_ => "Default",
};
private static string GetSortOnlineLabel(OnlinePairSortMode mode) => mode switch
{
OnlinePairSortMode.Alphabetical => "Alphabetical",
OnlinePairSortMode.PreferredDirectPairs => "Preferred permissions & Direct pairs",
_ => "Default",
};
}

View File

@@ -301,6 +301,14 @@ namespace LightlessSync.UI
bool ShellFinderEnabled = _configService.Current.SyncshellFinderEnabled;
bool isBroadcasting = _broadcastService.IsBroadcasting;
if (isBroadcasting)
{
var warningColor = UIColors.Get("LightlessYellow");
_uiSharedService.DrawNoteLine("! ", warningColor,
new SeStringUtils.RichTextEntry("Syncshell Finder can only be changed while Lightfinder is disabled.", warningColor));
ImGuiHelpers.ScaledDummy(0.2f);
}
if (isBroadcasting)
ImGui.BeginDisabled();

View File

@@ -0,0 +1,7 @@
namespace LightlessSync.UI.Models;
public enum OnlinePairSortMode
{
Alphabetical = 0,
PreferredDirectPairs = 1,
}

View File

@@ -2,10 +2,9 @@ namespace LightlessSync.UI.Models;
public enum VisiblePairSortMode
{
Default = 0,
Alphabetical = 1,
VramUsage = 2,
EffectiveVramUsage = 3,
TriangleCount = 4,
PreferredDirectPairs = 5,
Alphabetical = 0,
VramUsage = 1,
EffectiveVramUsage = 2,
TriangleCount = 3,
PreferredDirectPairs = 4,
}

View File

@@ -1463,7 +1463,10 @@ public class SettingsUi : WindowMediatorSubscriberBase
DrawPairPropertyRow("Has Handler", FormatBool(debugInfo.HasHandler));
DrawPairPropertyRow("Handler Initialized", FormatBool(debugInfo.HandlerInitialized));
DrawPairPropertyRow("Handler Visible", FormatBool(debugInfo.HandlerVisible));
DrawPairPropertyRow("Last Time person rendered in", FormatTimestamp(debugInfo.InvisibleSinceUtc));
DrawPairPropertyRow("Handler Timer Temp Collection removal", FormatCountdown(debugInfo.VisibilityEvictionRemainingSeconds));
DrawPairPropertyRow("Handler Scheduled For Deletion", FormatBool(debugInfo.HandlerScheduledForDeletion));
DrawPairPropertyRow("Note", pair.GetNote() ?? "(none)");
ImGui.EndTable();
}
@@ -1698,6 +1701,19 @@ public class SettingsUi : WindowMediatorSubscriberBase
return value is null ? "n/a" : value.Value.ToLocalTime().ToString("G", CultureInfo.CurrentCulture);
}
private static string? FormatCountdown(double? remainingSeconds)
{
if (!remainingSeconds.HasValue)
return "No";
var secs = Math.Max(0, remainingSeconds.Value);
var t = TimeSpan.FromSeconds(secs);
return t.TotalHours >= 1
? $"{(int)t.TotalHours:00}:{t.Minutes:00}:{t.Seconds:00}"
: $"{(int)t.TotalMinutes:00}:{t.Seconds:00}";
}
private static string FormatBytes(long value) => value < 0 ? "n/a" : UiSharedService.ByteToString(value);
private static string FormatCharacterId(uint id) => id == uint.MaxValue ? "n/a" : $"{id} (0x{id:X8})";

View File

@@ -350,9 +350,9 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
? shell.Group.Alias
: shell.Group.GID;
var style = ImGui.GetStyle();
float startX = ImGui.GetCursorPosX();
float availWidth = ImGui.GetContentRegionAvail().X;
float rightTextW = ImGui.CalcTextSize(broadcasterName).X;
float availW = ImGui.GetContentRegionAvail().X;
ImGui.BeginGroup();
@@ -364,13 +364,45 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group));
}
ImGui.SameLine();
float rightX = startX + availWidth - rightTextW;
var pos = ImGui.GetCursorPos();
ImGui.SetCursorPos(new Vector2(rightX, pos.Y + 3f * ImGuiHelpers.GlobalScale));
ImGui.TextUnformatted(broadcasterName);
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Broadcaster of the syncshell.");
float nameRightX = ImGui.GetItemRectMax().X;
var regionMinScreen = ImGui.GetCursorScreenPos();
float regionRightX = regionMinScreen.X + availW;
float minBroadcasterX = nameRightX + style.ItemSpacing.X;
float maxBroadcasterWidth = regionRightX - minBroadcasterX;
string broadcasterToShow = broadcasterName;
if (!string.IsNullOrEmpty(broadcasterName) && maxBroadcasterWidth > 0f)
{
float bcFullWidth = ImGui.CalcTextSize(broadcasterName).X;
string toolTip;
if (bcFullWidth > maxBroadcasterWidth)
{
broadcasterToShow = TruncateTextToWidth(broadcasterName, maxBroadcasterWidth);
toolTip = broadcasterName + Environment.NewLine + Environment.NewLine + "Broadcaster of the syncshell.";
}
else
{
toolTip = "Broadcaster of the syncshell.";
}
float bcWidth = ImGui.CalcTextSize(broadcasterToShow).X;
float broadX = regionRightX - bcWidth;
broadX = MathF.Max(broadX, minBroadcasterX);
ImGui.SameLine();
var curPos = ImGui.GetCursorPos();
ImGui.SetCursorPos(new Vector2(broadX - regionMinScreen.X + startX, curPos.Y + 3f * ImGuiHelpers.GlobalScale));
ImGui.TextUnformatted(broadcasterToShow);
if (ImGui.IsItemHovered())
ImGui.SetTooltip(toolTip);
}
ImGui.EndGroup();
@@ -590,6 +622,40 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
float widthUsed = cursorLocalX - baseLocal.X;
return (widthUsed, rowHeight);
}
private static string TruncateTextToWidth(string text, float maxWidth)
{
if (string.IsNullOrEmpty(text))
return text;
const string ellipsis = "...";
float ellipsisWidth = ImGui.CalcTextSize(ellipsis).X;
if (maxWidth <= ellipsisWidth)
return ellipsis;
int low = 0;
int high = text.Length;
string best = ellipsis;
while (low <= high)
{
int mid = (low + high) / 2;
string candidate = string.Concat(text.AsSpan(0, mid), ellipsis);
float width = ImGui.CalcTextSize(candidate).X;
if (width <= maxWidth)
{
best = candidate;
low = mid + 1;
}
else
{
high = mid - 1;
}
}
return best;
}
private IDalamudTextureWrap? GetIconWrap(uint iconId)
{

View File

@@ -11,9 +11,11 @@ using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Services;
using LightlessSync.Services.Chat;
using LightlessSync.Services.LightFinder;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Services;
using LightlessSync.UI.Style;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using LightlessSync.WebAPI.SignalR.Utils;
@@ -29,10 +31,13 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
private const string ReportPopupId = "Report Message##zone_chat_report_popup";
private const string ChannelDragPayloadId = "zone_chat_channel_drag";
private const float DefaultWindowOpacity = .97f;
private const float DefaultUnfocusedWindowOpacity = 0.6f;
private const float MinWindowOpacity = 0.05f;
private const float MaxWindowOpacity = 1f;
private const float MinChatFontScale = 0.75f;
private const float MaxChatFontScale = 1.5f;
private const float UnfocusedFadeOutSpeed = 0.22f;
private const float FocusFadeInSpeed = 2.0f;
private const int ReportReasonMaxLength = 500;
private const int ReportContextMaxLength = 1000;
private const int MaxChannelNoteTabLength = 25;
@@ -40,6 +45,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
private readonly UiSharedService _uiSharedService;
private readonly ZoneChatService _zoneChatService;
private readonly PairUiService _pairUiService;
private readonly LightFinderService _lightFinderService;
private readonly LightlessProfileManager _profileManager;
private readonly ApiController _apiController;
private readonly ChatConfigService _chatConfigService;
@@ -49,16 +55,20 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
private readonly Dictionary<string, string> _draftMessages = new(StringComparer.Ordinal);
private readonly ImGuiWindowFlags _unpinnedWindowFlags;
private float _currentWindowOpacity = DefaultWindowOpacity;
private float _baseWindowOpacity = DefaultWindowOpacity;
private bool _isWindowPinned;
private bool _showRulesOverlay;
private bool _refocusChatInput;
private string? _refocusChatInputKey;
private bool _isWindowFocused = true;
private int _titleBarStylePopCount;
private string? _selectedChannelKey;
private bool _scrollToBottom = true;
private float? _pendingChannelScroll;
private float _channelScroll;
private float _channelScrollMax;
private readonly SeluneBrush _seluneBrush = new();
private ChatChannelSnapshot? _reportTargetChannel;
private ChatMessageEntry? _reportTargetMessage;
private string _reportReason = string.Empty;
@@ -79,6 +89,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
UiSharedService uiSharedService,
ZoneChatService zoneChatService,
PairUiService pairUiService,
LightFinderService lightFinderService,
LightlessProfileManager profileManager,
ChatConfigService chatConfigService,
ServerConfigurationManager serverConfigurationManager,
@@ -91,6 +102,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
_uiSharedService = uiSharedService;
_zoneChatService = zoneChatService;
_pairUiService = pairUiService;
_lightFinderService = lightFinderService;
_profileManager = profileManager;
_chatConfigService = chatConfigService;
_serverConfigurationManager = serverConfigurationManager;
@@ -124,8 +136,25 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
{
RefreshWindowFlags();
base.PreDraw();
_currentWindowOpacity = Math.Clamp(_chatConfigService.Current.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity);
var config = _chatConfigService.Current;
var baseOpacity = Math.Clamp(config.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity);
_baseWindowOpacity = baseOpacity;
if (config.FadeWhenUnfocused)
{
var unfocusedOpacity = Math.Clamp(config.UnfocusedWindowOpacity, MinWindowOpacity, MaxWindowOpacity);
var targetOpacity = _isWindowFocused ? baseOpacity : Math.Min(baseOpacity, unfocusedOpacity);
var delta = ImGui.GetIO().DeltaTime;
var speed = _isWindowFocused ? FocusFadeInSpeed : UnfocusedFadeOutSpeed;
_currentWindowOpacity = MoveTowards(_currentWindowOpacity, targetOpacity, speed * delta);
}
else
{
_currentWindowOpacity = baseOpacity;
}
ImGui.SetNextWindowBgAlpha(_currentWindowOpacity);
PushTitleBarFadeColors(_currentWindowOpacity);
}
private void UpdateHideState()
@@ -179,8 +208,36 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
protected override void DrawInternal()
{
if (_titleBarStylePopCount > 0)
{
ImGui.PopStyleColor(_titleBarStylePopCount);
_titleBarStylePopCount = 0;
}
var config = _chatConfigService.Current;
var isFocused = ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows);
var isHovered = ImGui.IsWindowHovered(ImGuiHoveredFlags.RootAndChildWindows);
if (config.FadeWhenUnfocused && isHovered && !isFocused)
{
ImGui.SetWindowFocus();
}
_isWindowFocused = config.FadeWhenUnfocused ? (isFocused || isHovered) : isFocused;
var contentAlpha = 1f;
if (config.FadeWhenUnfocused)
{
var baseOpacity = MathF.Max(_baseWindowOpacity, 0.001f);
contentAlpha = Math.Clamp(_currentWindowOpacity / baseOpacity, 0f, 1f);
}
using var alpha = ImRaii.PushStyle(ImGuiStyleVar.Alpha, contentAlpha);
var drawList = ImGui.GetWindowDrawList();
var windowPos = ImGui.GetWindowPos();
var windowSize = ImGui.GetWindowSize();
using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize);
var childBgColor = ImGui.GetStyle().Colors[(int)ImGuiCol.ChildBg];
childBgColor.W *= _currentWindowOpacity;
childBgColor.W *= _baseWindowOpacity;
using var childBg = ImRaii.PushColor(ImGuiCol.ChildBg, childBgColor);
DrawConnectionControls();
@@ -192,36 +249,58 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextWrapped("No chat channels available.");
ImGui.PopStyleColor();
return;
}
EnsureSelectedChannel(channels);
CleanupDrafts(channels);
DrawChannelButtons(channels);
if (_selectedChannelKey is null)
return;
var activeChannel = channels.FirstOrDefault(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal));
if (activeChannel.Equals(default(ChatChannelSnapshot)))
else
{
activeChannel = channels[0];
_selectedChannelKey = activeChannel.Key;
EnsureSelectedChannel(channels);
CleanupDrafts(channels);
DrawChannelButtons(channels);
if (_selectedChannelKey is null)
{
selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
return;
}
var activeChannel = channels.FirstOrDefault(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal));
if (activeChannel.Equals(default(ChatChannelSnapshot)))
{
activeChannel = channels[0];
_selectedChannelKey = activeChannel.Key;
}
_zoneChatService.SetActiveChannel(activeChannel.Key);
DrawHeader(activeChannel);
ImGui.Separator();
DrawMessageArea(activeChannel, _currentWindowOpacity);
ImGui.Separator();
DrawInput(activeChannel);
}
_zoneChatService.SetActiveChannel(activeChannel.Key);
DrawHeader(activeChannel);
ImGui.Separator();
DrawMessageArea(activeChannel, _currentWindowOpacity);
ImGui.Separator();
DrawInput(activeChannel);
if (_showRulesOverlay)
{
DrawRulesOverlay();
}
selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
}
private void PushTitleBarFadeColors(float opacity)
{
_titleBarStylePopCount = 0;
var alpha = Math.Clamp(opacity, 0f, 1f);
var colors = ImGui.GetStyle().Colors;
var titleBg = colors[(int)ImGuiCol.TitleBg];
var titleBgActive = colors[(int)ImGuiCol.TitleBgActive];
var titleBgCollapsed = colors[(int)ImGuiCol.TitleBgCollapsed];
ImGui.PushStyleColor(ImGuiCol.TitleBg, new Vector4(titleBg.X, titleBg.Y, titleBg.Z, titleBg.W * alpha));
ImGui.PushStyleColor(ImGuiCol.TitleBgActive, new Vector4(titleBgActive.X, titleBgActive.Y, titleBgActive.Z, titleBgActive.W * alpha));
ImGui.PushStyleColor(ImGuiCol.TitleBgCollapsed, new Vector4(titleBgCollapsed.X, titleBgCollapsed.Y, titleBgCollapsed.Z, titleBgCollapsed.W * alpha));
_titleBarStylePopCount = 3;
}
private void DrawHeader(ChatChannelSnapshot channel)
@@ -1119,18 +1198,56 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
var groupSize = ImGui.GetItemRectSize();
var minBlockX = cursorStart.X + groupSize.X + style.ItemSpacing.X;
var availableAfterGroup = contentRightX - (cursorStart.X + groupSize.X);
var lightfinderButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.PersonCirclePlus).X;
var settingsButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Cog).X;
var pinIcon = _isWindowPinned ? FontAwesomeIcon.Lock : FontAwesomeIcon.Unlock;
var pinButtonWidth = _uiSharedService.GetIconButtonSize(pinIcon).X;
var blockWidth = rulesButtonWidth + style.ItemSpacing.X + settingsButtonWidth + style.ItemSpacing.X + pinButtonWidth;
var blockWidth = lightfinderButtonWidth + style.ItemSpacing.X + rulesButtonWidth + style.ItemSpacing.X + settingsButtonWidth + style.ItemSpacing.X + pinButtonWidth;
var desiredBlockX = availableAfterGroup > blockWidth + style.ItemSpacing.X
? contentRightX - blockWidth
: minBlockX;
desiredBlockX = Math.Max(cursorStart.X, desiredBlockX);
var rulesPos = new Vector2(desiredBlockX, cursorStart.Y);
var settingsPos = new Vector2(desiredBlockX + rulesButtonWidth + style.ItemSpacing.X, cursorStart.Y);
var lightfinderPos = new Vector2(desiredBlockX, cursorStart.Y);
var rulesPos = new Vector2(lightfinderPos.X + lightfinderButtonWidth + style.ItemSpacing.X, cursorStart.Y);
var settingsPos = new Vector2(rulesPos.X + rulesButtonWidth + style.ItemSpacing.X, cursorStart.Y);
var pinPos = new Vector2(settingsPos.X + settingsButtonWidth + style.ItemSpacing.X, cursorStart.Y);
ImGui.SameLine();
ImGui.SetCursorPos(lightfinderPos);
var lightfinderEnabled = _lightFinderService.IsBroadcasting;
var lightfinderColor = lightfinderEnabled ? UIColors.Get("LightlessGreen") : ImGuiColors.DalamudGrey3;
var lightfinderButtonSize = new Vector2(lightfinderButtonWidth, ImGui.GetFrameHeight());
ImGui.InvisibleButton("zone_chat_lightfinder_button", lightfinderButtonSize);
var lightfinderMin = ImGui.GetItemRectMin();
var lightfinderMax = ImGui.GetItemRectMax();
var iconSize = _uiSharedService.GetIconSize(FontAwesomeIcon.PersonCirclePlus);
var iconPos = new Vector2(
lightfinderMin.X + (lightfinderButtonSize.X - iconSize.X) * 0.5f,
lightfinderMin.Y + (lightfinderButtonSize.Y - iconSize.Y) * 0.5f);
using (_uiSharedService.IconFont.Push())
{
ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(lightfinderColor), FontAwesomeIcon.PersonCirclePlus.ToIconString());
}
if (ImGui.IsItemClicked())
{
Mediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
}
if (ImGui.IsItemHovered())
{
var padding = new Vector2(8f * ImGuiHelpers.GlobalScale);
Selune.RegisterHighlight(
lightfinderMin - padding,
lightfinderMax + padding,
SeluneHighlightMode.Point,
exactSize: true,
clipToElement: true,
clipPadding: padding,
highlightColorOverride: lightfinderColor,
highlightAlphaOverride: 0.2f);
ImGui.SetTooltip("If Lightfinder is enabled, you will be able to see the character names of other Lightfinder users in the same zone when they send a message.");
}
ImGui.SameLine();
ImGui.SetCursorPos(rulesPos);
if (ImGui.Button("Rules", new Vector2(rulesButtonWidth, 0f)))
@@ -1376,9 +1493,55 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
ImGui.SetTooltip("Adjust chat window transparency.\nRight-click to reset to default.");
}
var fadeUnfocused = chatConfig.FadeWhenUnfocused;
if (ImGui.Checkbox("Fade window when unfocused", ref fadeUnfocused))
{
chatConfig.FadeWhenUnfocused = fadeUnfocused;
_chatConfigService.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("When enabled, the chat window fades after it loses focus.\nHovering the window restores focus.");
}
ImGui.BeginDisabled(!fadeUnfocused);
var unfocusedOpacity = Math.Clamp(chatConfig.UnfocusedWindowOpacity, MinWindowOpacity, MaxWindowOpacity);
var unfocusedChanged = ImGui.SliderFloat("Unfocused transparency", ref unfocusedOpacity, MinWindowOpacity, MaxWindowOpacity, "%.2f");
var resetUnfocused = ImGui.IsItemClicked(ImGuiMouseButton.Right);
if (resetUnfocused)
{
unfocusedOpacity = DefaultUnfocusedWindowOpacity;
unfocusedChanged = true;
}
if (unfocusedChanged)
{
chatConfig.UnfocusedWindowOpacity = unfocusedOpacity;
_chatConfigService.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Target transparency while the chat window is unfocused.\nRight-click to reset to default.");
}
ImGui.EndDisabled();
ImGui.EndPopup();
}
private static float MoveTowards(float current, float target, float maxDelta)
{
if (current < target)
{
return MathF.Min(current + maxDelta, target);
}
if (current > target)
{
return MathF.Max(current - maxDelta, target);
}
return target;
}
private void ToggleChatConnection(bool currentlyEnabled)
{
_ = Task.Run(async () =>