From 24d0c38f59cad54bb3222b17372ca2f3fb1c3322 Mon Sep 17 00:00:00 2001 From: azyges Date: Mon, 5 Jan 2026 22:43:50 +0900 Subject: [PATCH 1/2] animated emotes and fix clipper in chat window --- .../Configurations/ChatConfig.cs | 1 + .../Services/Chat/ChatEmoteService.cs | 579 ++++++++++++++++-- LightlessSync/UI/ZoneChatUi.cs | 520 ++++++++++++---- 3 files changed, 938 insertions(+), 162 deletions(-) diff --git a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs index 43090a2..5532d78 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs @@ -11,6 +11,7 @@ public sealed class ChatConfig : ILightlessConfiguration public bool ShowRulesOverlayOnOpen { get; set; } = true; public bool ShowMessageTimestamps { get; set; } = true; public bool ShowNotesInSyncshellChat { get; set; } = true; + public bool EnableAnimatedEmotes { get; set; } = true; public float ChatWindowOpacity { get; set; } = .97f; public bool FadeWhenUnfocused { get; set; } = false; public float UnfocusedWindowOpacity { get; set; } = 0.6f; diff --git a/LightlessSync/Services/Chat/ChatEmoteService.cs b/LightlessSync/Services/Chat/ChatEmoteService.cs index b733f2e..e0d402f 100644 --- a/LightlessSync/Services/Chat/ChatEmoteService.cs +++ b/LightlessSync/Services/Chat/ChatEmoteService.cs @@ -1,29 +1,41 @@ using Dalamud.Interface.Textures.TextureWraps; +using LightlessSync.LightlessConfiguration; using LightlessSync.UI; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; +using System.Diagnostics; using System.Text.Json; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Webp; +using SixLabors.ImageSharp.Metadata; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; namespace LightlessSync.Services.Chat; public sealed class ChatEmoteService : IDisposable { private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global"; + private const int DefaultFrameDelayMs = 100; + private const int MinFrameDelayMs = 20; private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly UiSharedService _uiSharedService; + private readonly ChatConfigService _chatConfigService; private readonly ConcurrentDictionary _emotes = new(StringComparer.Ordinal); private readonly SemaphoreSlim _downloadGate = new(3, 3); private readonly object _loadLock = new(); private Task? _loadTask; - public ChatEmoteService(ILogger logger, HttpClient httpClient, UiSharedService uiSharedService) + public ChatEmoteService(ILogger logger, HttpClient httpClient, UiSharedService uiSharedService, ChatConfigService chatConfigService) { _logger = logger; _httpClient = httpClient; _uiSharedService = uiSharedService; + _chatConfigService = chatConfigService; } public void EnsureGlobalEmotesLoaded() @@ -62,13 +74,17 @@ public sealed class ChatEmoteService : IDisposable return false; } - if (entry.Texture is not null) + var allowAnimation = _chatConfigService.Current.EnableAnimatedEmotes; + if (entry.TryGetTexture(allowAnimation, out texture)) { - texture = entry.Texture; + if (allowAnimation && entry.NeedsAnimationLoad && !entry.HasAttemptedAnimation) + { + entry.EnsureLoading(allowAnimation, QueueEmoteDownload, allowWhenStaticLoaded: true); + } return true; } - entry.EnsureLoading(QueueEmoteDownload); + entry.EnsureLoading(allowAnimation, QueueEmoteDownload); return true; } @@ -76,7 +92,7 @@ public sealed class ChatEmoteService : IDisposable { foreach (var entry in _emotes.Values) { - entry.Texture?.Dispose(); + entry.Dispose(); } _downloadGate.Dispose(); @@ -108,13 +124,13 @@ public sealed class ChatEmoteService : IDisposable continue; } - var url = TryBuildEmoteUrl(emoteElement); - if (string.IsNullOrWhiteSpace(url)) + var source = TryBuildEmoteSource(emoteElement); + if (source is null || (!source.Value.HasStatic && !source.Value.HasAnimation)) { continue; } - _emotes.TryAdd(name, new EmoteEntry(url)); + _emotes.TryAdd(name, new EmoteEntry(name, source.Value)); } } catch (Exception ex) @@ -123,7 +139,7 @@ public sealed class ChatEmoteService : IDisposable } } - private static string? TryBuildEmoteUrl(JsonElement emoteElement) + private static EmoteSource? TryBuildEmoteSource(JsonElement emoteElement) { if (!emoteElement.TryGetProperty("data", out var dataElement)) { @@ -156,29 +172,38 @@ public sealed class ChatEmoteService : IDisposable return null; } - var fileName = PickBestStaticFile(filesElement); - if (string.IsNullOrWhiteSpace(fileName)) + var files = ReadEmoteFiles(filesElement); + if (files.Count == 0) { return null; } - return baseUrl.TrimEnd('/') + "/" + fileName; + var animatedFile = PickBestAnimatedFile(files); + var animatedUrl = animatedFile is null ? null : BuildEmoteUrl(baseUrl, animatedFile.Value.Name); + + var staticName = animatedFile?.StaticName; + if (string.IsNullOrWhiteSpace(staticName)) + { + staticName = PickBestStaticFileName(files); + } + + var staticUrl = string.IsNullOrWhiteSpace(staticName) ? null : BuildEmoteUrl(baseUrl, staticName); + if (string.IsNullOrWhiteSpace(animatedUrl) && string.IsNullOrWhiteSpace(staticUrl)) + { + return null; + } + + return new EmoteSource(staticUrl, animatedUrl); } - private static string? PickBestStaticFile(JsonElement filesElement) - { - string? png1x = null; - string? webp1x = null; - string? pngFallback = null; - string? webpFallback = null; + private static string BuildEmoteUrl(string baseUrl, string fileName) + => baseUrl.TrimEnd('/') + "/" + fileName; + private static List ReadEmoteFiles(JsonElement filesElement) + { + var files = new List(); foreach (var file in filesElement.EnumerateArray()) { - if (file.TryGetProperty("static", out var staticElement) && staticElement.ValueKind == JsonValueKind.False) - { - continue; - } - if (!file.TryGetProperty("name", out var nameElement)) { continue; @@ -190,6 +215,88 @@ public sealed class ChatEmoteService : IDisposable continue; } + string? staticName = null; + if (file.TryGetProperty("static_name", out var staticNameElement) && staticNameElement.ValueKind == JsonValueKind.String) + { + staticName = staticNameElement.GetString(); + } + + var frameCount = 1; + if (file.TryGetProperty("frame_count", out var frameCountElement) && frameCountElement.ValueKind == JsonValueKind.Number) + { + frameCountElement.TryGetInt32(out frameCount); + frameCount = Math.Max(frameCount, 1); + } + + string? format = null; + if (file.TryGetProperty("format", out var formatElement) && formatElement.ValueKind == JsonValueKind.String) + { + format = formatElement.GetString(); + } + + files.Add(new EmoteFile(name, staticName, frameCount, format)); + } + + return files; + } + + private static EmoteFile? PickBestAnimatedFile(IReadOnlyList files) + { + EmoteFile? webp1x = null; + EmoteFile? gif1x = null; + EmoteFile? webpFallback = null; + EmoteFile? gifFallback = null; + + foreach (var file in files) + { + if (file.FrameCount <= 1 || !IsAnimatedFormatSupported(file)) + { + continue; + } + + if (file.Name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase)) + { + webp1x = file; + } + else if (file.Name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase)) + { + gif1x = file; + } + else if (file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null) + { + webpFallback = file; + } + else if (file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null) + { + gifFallback = file; + } + } + + return webp1x ?? gif1x ?? webpFallback ?? gifFallback; + } + + private static string? PickBestStaticFileName(IReadOnlyList files) + { + string? png1x = null; + string? webp1x = null; + string? gif1x = null; + string? pngFallback = null; + string? webpFallback = null; + string? gifFallback = null; + + foreach (var file in files) + { + if (file.FrameCount > 1) + { + continue; + } + + var name = file.StaticName ?? file.Name; + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + if (name.Equals("1x.png", StringComparison.OrdinalIgnoreCase)) { png1x = name; @@ -198,6 +305,10 @@ public sealed class ChatEmoteService : IDisposable { webp1x = name; } + else if (name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase)) + { + gif1x = name; + } else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null) { pngFallback = name; @@ -206,25 +317,80 @@ public sealed class ChatEmoteService : IDisposable { webpFallback = name; } + else if (name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null) + { + gifFallback = name; + } } - return png1x ?? webp1x ?? pngFallback ?? webpFallback; + return png1x ?? webp1x ?? gif1x ?? pngFallback ?? webpFallback ?? gifFallback; } - private void QueueEmoteDownload(EmoteEntry entry) + private static bool IsAnimatedFormatSupported(EmoteFile file) + { + if (!string.IsNullOrWhiteSpace(file.Format)) + { + return file.Format.Equals("WEBP", StringComparison.OrdinalIgnoreCase) + || file.Format.Equals("GIF", StringComparison.OrdinalIgnoreCase); + } + + return file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) + || file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase); + } + + private readonly record struct EmoteSource(string? StaticUrl, string? AnimatedUrl) + { + public bool HasStatic => !string.IsNullOrWhiteSpace(StaticUrl); + public bool HasAnimation => !string.IsNullOrWhiteSpace(AnimatedUrl); + } + + private readonly record struct EmoteFile(string Name, string? StaticName, int FrameCount, string? Format); + + private void QueueEmoteDownload(EmoteEntry entry, bool allowAnimation) { _ = Task.Run(async () => { await _downloadGate.WaitAsync().ConfigureAwait(false); try { - var data = await _httpClient.GetByteArrayAsync(entry.Url).ConfigureAwait(false); - var texture = _uiSharedService.LoadImage(data); - entry.SetTexture(texture); + if (allowAnimation) + { + if (entry.HasAnimatedSource) + { + entry.MarkAnimationAttempted(); + if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false)) + { + return; + } + } + + if (entry.HasStaticSource && !entry.HasStaticTexture && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false)) + { + return; + } + } + else + { + if (entry.HasStaticSource && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false)) + { + return; + } + + if (entry.HasAnimatedSource) + { + entry.MarkAnimationAttempted(); + if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false)) + { + return; + } + } + } + + entry.MarkFailed(); } catch (Exception ex) { - _logger.LogDebug(ex, "Failed to load 7TV emote {Url}", entry.Url); + _logger.LogDebug(ex, "Failed to load 7TV emote {Emote}", entry.Code); entry.MarkFailed(); } finally @@ -234,21 +400,334 @@ public sealed class ChatEmoteService : IDisposable }); } - private sealed class EmoteEntry + private async Task TryLoadAnimatedEmoteAsync(EmoteEntry entry) { - private int _loadingState; - - public EmoteEntry(string url) + if (string.IsNullOrWhiteSpace(entry.AnimatedUrl)) { - Url = url; + return false; } - public string Url { get; } - public IDalamudTextureWrap? Texture { get; private set; } - - public void EnsureLoading(Action queueDownload) + try { - if (Texture is not null) + var data = await _httpClient.GetByteArrayAsync(entry.AnimatedUrl).ConfigureAwait(false); + var isWebp = entry.AnimatedUrl.EndsWith(".webp", StringComparison.OrdinalIgnoreCase); + if (!TryDecodeAnimation(data, isWebp, out var animation)) + { + return false; + } + + entry.SetAnimation(animation); + return true; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to decode animated 7TV emote {Emote}", entry.Code); + return false; + } + } + + private async Task TryLoadStaticEmoteAsync(EmoteEntry entry) + { + if (string.IsNullOrWhiteSpace(entry.StaticUrl)) + { + return false; + } + + try + { + var data = await _httpClient.GetByteArrayAsync(entry.StaticUrl).ConfigureAwait(false); + var texture = _uiSharedService.LoadImage(data); + entry.SetStaticTexture(texture); + return true; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to decode static 7TV emote {Emote}", entry.Code); + return false; + } + } + + private bool TryDecodeAnimation(byte[] data, bool isWebp, out EmoteAnimation? animation) + { + animation = null; + List? frames = null; + + try + { + Image image; + if (isWebp) + { + using var stream = new MemoryStream(data); + image = WebpDecoder.Instance.Decode( + new WebpDecoderOptions { BackgroundColorHandling = BackgroundColorHandling.Ignore }, + stream); + } + else + { + image = Image.Load(data); + } + + using (image) + { + if (image.Frames.Count <= 1) + { + return false; + } + + using var composite = new Image(image.Width, image.Height, Color.Transparent); + Image? restoreCanvas = null; + GifDisposalMethod? pendingGifDisposal = null; + WebpDisposalMethod? pendingWebpDisposal = null; + + frames = new List(image.Frames.Count); + for (var i = 0; i < image.Frames.Count; i++) + { + var frameMetadata = image.Frames[i].Metadata; + var delayMs = GetFrameDelayMs(frameMetadata); + + ApplyDisposal(composite, ref restoreCanvas, pendingGifDisposal, pendingWebpDisposal); + + GifDisposalMethod? currentGifDisposal = null; + WebpDisposalMethod? currentWebpDisposal = null; + var blendMethod = WebpBlendMethod.Over; + + if (isWebp) + { + if (frameMetadata.TryGetWebpFrameMetadata(out var webpMetadata)) + { + currentWebpDisposal = webpMetadata.DisposalMethod; + blendMethod = webpMetadata.BlendMethod; + } + } + else if (frameMetadata.TryGetGifMetadata(out var gifMetadata)) + { + currentGifDisposal = gifMetadata.DisposalMethod; + } + + if (currentGifDisposal == GifDisposalMethod.RestoreToPrevious) + { + restoreCanvas?.Dispose(); + restoreCanvas = composite.Clone(); + } + + using var frameImage = image.Frames.CloneFrame(i); + var alphaMode = blendMethod == WebpBlendMethod.Source + ? PixelAlphaCompositionMode.Src + : PixelAlphaCompositionMode.SrcOver; + composite.Mutate(ctx => ctx.DrawImage(frameImage, PixelColorBlendingMode.Normal, alphaMode, 1f)); + + using var renderedFrame = composite.Clone(); + using var ms = new MemoryStream(); + renderedFrame.SaveAsPng(ms); + + var texture = _uiSharedService.LoadImage(ms.ToArray()); + frames.Add(new EmoteFrame(texture, delayMs)); + + pendingGifDisposal = currentGifDisposal; + pendingWebpDisposal = currentWebpDisposal; + } + + restoreCanvas?.Dispose(); + + animation = new EmoteAnimation(frames); + return true; + } + } + catch + { + if (frames is not null) + { + foreach (var frame in frames) + { + frame.Texture.Dispose(); + } + } + + return false; + } + } + + private static int GetFrameDelayMs(ImageFrameMetadata metadata) + { + if (metadata.TryGetGifMetadata(out var gifMetadata)) + { + var delayMs = (long)gifMetadata.FrameDelay * 10L; + return NormalizeFrameDelayMs(delayMs); + } + + if (metadata.TryGetWebpFrameMetadata(out var webpMetadata)) + { + return NormalizeFrameDelayMs(webpMetadata.FrameDelay); + } + + return DefaultFrameDelayMs; + } + + private static int NormalizeFrameDelayMs(long delayMs) + { + if (delayMs <= 0) + { + return DefaultFrameDelayMs; + } + + var clamped = delayMs > int.MaxValue ? int.MaxValue : (int)delayMs; + return Math.Max(clamped, MinFrameDelayMs); + } + + private static void ApplyDisposal( + Image composite, + ref Image? restoreCanvas, + GifDisposalMethod? gifDisposal, + WebpDisposalMethod? webpDisposal) + { + if (gifDisposal is not null) + { + switch (gifDisposal) + { + case GifDisposalMethod.RestoreToBackground: + composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent)); + break; + case GifDisposalMethod.RestoreToPrevious: + if (restoreCanvas is not null) + { + composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent)); + var restoreSnapshot = restoreCanvas; + composite.Mutate(ctx => ctx.DrawImage(restoreSnapshot, PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.Src, 1f)); + restoreCanvas.Dispose(); + restoreCanvas = null; + } + break; + } + } + else if (webpDisposal == WebpDisposalMethod.RestoreToBackground) + { + composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent)); + } + } + + private sealed class EmoteAnimation : IDisposable + { + private readonly EmoteFrame[] _frames; + private readonly int _durationMs; + private readonly long _startTimestamp; + + public EmoteAnimation(IReadOnlyList frames) + { + _frames = frames.ToArray(); + _durationMs = Math.Max(1, frames.Sum(frame => frame.DurationMs)); + _startTimestamp = Stopwatch.GetTimestamp(); + } + + public IDalamudTextureWrap? GetCurrentFrame() + { + if (_frames.Length == 0) + { + return null; + } + + if (_frames.Length == 1) + { + return _frames[0].Texture; + } + + var elapsedTicks = Stopwatch.GetTimestamp() - _startTimestamp; + var elapsedMs = (elapsedTicks * 1000L) / Stopwatch.Frequency; + var targetMs = (int)(elapsedMs % _durationMs); + var accumulated = 0; + + foreach (var frame in _frames) + { + accumulated += frame.DurationMs; + if (targetMs < accumulated) + { + return frame.Texture; + } + } + + return _frames[^1].Texture; + } + + public IDalamudTextureWrap? GetStaticFrame() + { + if (_frames.Length == 0) + { + return null; + } + + return _frames[0].Texture; + } + + public void Dispose() + { + foreach (var frame in _frames) + { + frame.Texture.Dispose(); + } + } + } + + private readonly record struct EmoteFrame(IDalamudTextureWrap Texture, int DurationMs); + + private sealed class EmoteEntry : IDisposable + { + private int _loadingState; + private int _animationAttempted; + private IDalamudTextureWrap? _staticTexture; + private EmoteAnimation? _animation; + + public EmoteEntry(string code, EmoteSource source) + { + Code = code; + StaticUrl = source.StaticUrl; + AnimatedUrl = source.AnimatedUrl; + } + + public string Code { get; } + public string? StaticUrl { get; } + public string? AnimatedUrl { get; } + public bool HasStaticSource => !string.IsNullOrWhiteSpace(StaticUrl); + public bool HasAnimatedSource => !string.IsNullOrWhiteSpace(AnimatedUrl); + public bool HasStaticTexture => _staticTexture is not null; + public bool HasAttemptedAnimation => Interlocked.CompareExchange(ref _animationAttempted, 0, 0) != 0; + public bool NeedsAnimationLoad => _animation is null && HasAnimatedSource; + + public void MarkAnimationAttempted() + { + Interlocked.Exchange(ref _animationAttempted, 1); + } + + public bool TryGetTexture(bool allowAnimation, out IDalamudTextureWrap? texture) + { + if (allowAnimation && _animation is not null) + { + texture = _animation.GetCurrentFrame(); + return true; + } + + if (_staticTexture is not null) + { + texture = _staticTexture; + return true; + } + + if (!allowAnimation && _animation is not null) + { + texture = _animation.GetStaticFrame(); + return true; + } + + texture = null; + return false; + } + + public void EnsureLoading(bool allowAnimation, Action queueDownload, bool allowWhenStaticLoaded = false) + { + if (_animation is not null) + { + return; + } + + if (!allowWhenStaticLoaded && _staticTexture is not null) { return; } @@ -258,12 +737,22 @@ public sealed class ChatEmoteService : IDisposable return; } - queueDownload(this); + queueDownload(this, allowAnimation); } - public void SetTexture(IDalamudTextureWrap texture) + public void SetAnimation(EmoteAnimation animation) { - Texture = texture; + _staticTexture?.Dispose(); + _staticTexture = null; + _animation?.Dispose(); + _animation = animation; + Interlocked.Exchange(ref _loadingState, 0); + } + + public void SetStaticTexture(IDalamudTextureWrap texture) + { + _staticTexture?.Dispose(); + _staticTexture = texture; Interlocked.Exchange(ref _loadingState, 0); } @@ -271,5 +760,11 @@ public sealed class ChatEmoteService : IDisposable { Interlocked.Exchange(ref _loadingState, 0); } + + public void Dispose() + { + _animation?.Dispose(); + _staticTexture?.Dispose(); + } } } diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index a03ceab..571b8ca 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -22,7 +22,6 @@ using LightlessSync.UI.Services; using LightlessSync.UI.Style; using LightlessSync.Utils; using Dalamud.Interface.Textures.TextureWraps; -using OtterGui.Text; using LightlessSync.WebAPI; using LightlessSync.WebAPI.SignalR.Utils; using Microsoft.Extensions.Logging; @@ -429,150 +428,182 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } else { - var itemHeight = ImGui.GetTextLineHeightWithSpacing(); - using var clipper = ImUtf8.ListClipper(channel.Messages.Count, itemHeight); - while (clipper.Step()) + var messageCount = channel.Messages.Count; + var contentMaxX = ImGui.GetWindowContentRegionMax().X; + var cursorStartX = ImGui.GetCursorPosX(); + var lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing(); + var prefix = new float[messageCount + 1]; + var totalHeight = 0f; + + for (var i = 0; i < messageCount; i++) { - for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) + var messageHeight = MeasureMessageHeight(channel, channel.Messages[i], showTimestamps, cursorStartX, contentMaxX, itemSpacing, ref pairSnapshot); + if (messageHeight <= 0f) { - var message = channel.Messages[i]; - ImGui.PushID(i); + messageHeight = lineHeightWithSpacing; + } - if (message.IsSystem) + totalHeight += messageHeight; + prefix[i + 1] = totalHeight; + } + + var scrollY = ImGui.GetScrollY(); + var windowHeight = ImGui.GetWindowHeight(); + var startIndex = Math.Max(0, UpperBound(prefix, scrollY) - 1); + var endIndex = Math.Min(messageCount, LowerBound(prefix, scrollY + windowHeight)); + startIndex = Math.Max(0, startIndex - 2); + endIndex = Math.Min(messageCount, endIndex + 2); + + if (startIndex > 0) + { + ImGui.Dummy(new Vector2(1f, prefix[startIndex])); + } + + for (var i = startIndex; i < endIndex; i++) + { + var message = channel.Messages[i]; + ImGui.PushID(i); + + if (message.IsSystem) + { + DrawSystemEntry(message); + ImGui.PopID(); + continue; + } + + if (message.Payload is not { } payload) + { + ImGui.PopID(); + continue; + } + + var timestampText = string.Empty; + if (showTimestamps) + { + timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] "; + } + var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite; + var showRoleIcons = false; + var isOwner = false; + var isModerator = false; + var isPinned = false; + + if (channel.Type == ChatChannelType.Group + && payload.Sender.Kind == ChatSenderKind.IdentifiedUser + && payload.Sender.User is not null) + { + pairSnapshot ??= _pairUiService.GetSnapshot(); + var groupId = channel.Descriptor.CustomKey; + if (!string.IsNullOrWhiteSpace(groupId) + && pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo)) { - DrawSystemEntry(message); - ImGui.PopID(); - continue; + var senderUid = payload.Sender.User.UID; + isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal); + if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info)) + { + isModerator = info.IsModerator(); + isPinned = info.IsPinned(); + } } - if (message.Payload is not { } payload) + showRoleIcons = isOwner || isModerator || isPinned; + } + + ImGui.BeginGroup(); + ImGui.PushStyleColor(ImGuiCol.Text, color); + if (showRoleIcons) + { + if (!string.IsNullOrEmpty(timestampText)) { - ImGui.PopID(); - continue; + ImGui.TextUnformatted(timestampText); + ImGui.SameLine(0f, 0f); } - var timestampText = string.Empty; - if (showTimestamps) + var hasIcon = false; + if (isModerator) { - timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] "; - } - var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite; - var showRoleIcons = false; - var isOwner = false; - var isModerator = false; - var isPinned = false; - - if (channel.Type == ChatChannelType.Group - && payload.Sender.Kind == ChatSenderKind.IdentifiedUser - && payload.Sender.User is not null) - { - pairSnapshot ??= _pairUiService.GetSnapshot(); - var groupId = channel.Descriptor.CustomKey; - if (!string.IsNullOrWhiteSpace(groupId) - && pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo)) - { - var senderUid = payload.Sender.User.UID; - isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal); - if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info)) - { - isModerator = info.IsModerator(); - isPinned = info.IsPinned(); - } - } - - showRoleIcons = isOwner || isModerator || isPinned; + _uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple")); + UiSharedService.AttachToolTip("Moderator"); + hasIcon = true; } - ImGui.BeginGroup(); - ImGui.PushStyleColor(ImGuiCol.Text, color); - if (showRoleIcons) + if (isOwner) { - if (!string.IsNullOrEmpty(timestampText)) - { - ImGui.TextUnformatted(timestampText); - ImGui.SameLine(0f, 0f); - } - - var hasIcon = false; - if (isModerator) - { - _uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple")); - UiSharedService.AttachToolTip("Moderator"); - hasIcon = true; - } - - if (isOwner) - { - if (hasIcon) - { - ImGui.SameLine(0f, itemSpacing); - } - - _uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow")); - UiSharedService.AttachToolTip("Owner"); - hasIcon = true; - } - - if (isPinned) - { - if (hasIcon) - { - ImGui.SameLine(0f, itemSpacing); - } - - _uiSharedService.IconText(FontAwesomeIcon.Thumbtack, UIColors.Get("LightlessBlue")); - UiSharedService.AttachToolTip("Pinned"); - hasIcon = true; - } - if (hasIcon) { ImGui.SameLine(0f, itemSpacing); } - var messageStartX = ImGui.GetCursorPosX(); - DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX); + _uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow")); + UiSharedService.AttachToolTip("Owner"); + hasIcon = true; } - else - { - var messageStartX = ImGui.GetCursorPosX(); - DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX); - } - ImGui.PopStyleColor(); - ImGui.EndGroup(); - ImGui.SetNextWindowSizeConstraints( - new Vector2(190f * ImGuiHelpers.GlobalScale, 0f), - new Vector2(float.MaxValue, float.MaxValue)); - if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}")) + if (isPinned) { - var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime(); - var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture); - ImGui.TextDisabled(contextTimestampText); - if (channel.Type == ChatChannelType.Group - && payload.Sender.Kind == ChatSenderKind.IdentifiedUser - && payload.Sender.User is not null) + if (hasIcon) { - var aliasOrUid = payload.Sender.User.AliasOrUID; - if (!string.IsNullOrWhiteSpace(aliasOrUid) - && !string.Equals(message.DisplayName, aliasOrUid, StringComparison.Ordinal)) - { - ImGui.TextDisabled(aliasOrUid); - } - } - ImGui.Separator(); - - var actionIndex = 0; - foreach (var action in GetContextMenuActions(channel, message)) - { - DrawContextMenuAction(action, actionIndex++); + ImGui.SameLine(0f, itemSpacing); } - ImGui.EndPopup(); + _uiSharedService.IconText(FontAwesomeIcon.Thumbtack, UIColors.Get("LightlessBlue")); + UiSharedService.AttachToolTip("Pinned"); + hasIcon = true; } - ImGui.PopID(); + if (hasIcon) + { + ImGui.SameLine(0f, itemSpacing); + } + + var messageStartX = ImGui.GetCursorPosX(); + DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX); } + else + { + var messageStartX = ImGui.GetCursorPosX(); + DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX); + } + ImGui.PopStyleColor(); + ImGui.EndGroup(); + + ImGui.SetNextWindowSizeConstraints( + new Vector2(190f * ImGuiHelpers.GlobalScale, 0f), + new Vector2(float.MaxValue, float.MaxValue)); + if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}")) + { + var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime(); + var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture); + ImGui.TextDisabled(contextTimestampText); + if (channel.Type == ChatChannelType.Group + && payload.Sender.Kind == ChatSenderKind.IdentifiedUser + && payload.Sender.User is not null) + { + var aliasOrUid = payload.Sender.User.AliasOrUID; + if (!string.IsNullOrWhiteSpace(aliasOrUid) + && !string.Equals(message.DisplayName, aliasOrUid, StringComparison.Ordinal)) + { + ImGui.TextDisabled(aliasOrUid); + } + } + ImGui.Separator(); + + var actionIndex = 0; + foreach (var action in GetContextMenuActions(channel, message)) + { + DrawContextMenuAction(action, actionIndex++); + } + + ImGui.EndPopup(); + } + + ImGui.PopID(); + } + + var remainingHeight = totalHeight - prefix[endIndex]; + if (remainingHeight > 0f) + { + ImGui.Dummy(new Vector2(1f, remainingHeight)); } } @@ -708,7 +739,20 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var clicked = false; if (texture is not null) { - clicked = ImGui.ImageButton(texture.Handle, new Vector2(emoteSize)); + var buttonSize = new Vector2(itemWidth, itemHeight); + clicked = ImGui.InvisibleButton("##emote_button", buttonSize); + var drawList = ImGui.GetWindowDrawList(); + var itemMin = ImGui.GetItemRectMin(); + var itemMax = ImGui.GetItemRectMax(); + var bgColor = ImGui.IsItemActive() + ? ImGui.GetColorU32(ImGuiCol.ButtonActive) + : ImGui.IsItemHovered() + ? ImGui.GetColorU32(ImGuiCol.ButtonHovered) + : ImGui.GetColorU32(ImGuiCol.Button); + drawList.AddRectFilled(itemMin, itemMax, bgColor, style.FrameRounding); + var imageMin = itemMin + style.FramePadding; + var imageMax = imageMin + new Vector2(emoteSize); + drawList.AddImage(texture.Handle, imageMin, imageMax); } else { @@ -878,7 +922,232 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private static bool IsEmoteChar(char value) { - return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!'; + return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!' || value == '(' || value == ')'; + } + + private float MeasureMessageHeight( + ChatChannelSnapshot channel, + ChatMessageEntry message, + bool showTimestamps, + float cursorStartX, + float contentMaxX, + float itemSpacing, + ref PairUiSnapshot? pairSnapshot) + { + if (message.IsSystem) + { + return MeasureSystemEntryHeight(message); + } + + if (message.Payload is not { } payload) + { + return 0f; + } + + var timestampText = string.Empty; + if (showTimestamps) + { + timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] "; + } + + var showRoleIcons = false; + var isOwner = false; + var isModerator = false; + var isPinned = false; + + if (channel.Type == ChatChannelType.Group + && payload.Sender.Kind == ChatSenderKind.IdentifiedUser + && payload.Sender.User is not null) + { + pairSnapshot ??= _pairUiService.GetSnapshot(); + var groupId = channel.Descriptor.CustomKey; + if (!string.IsNullOrWhiteSpace(groupId) + && pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo)) + { + var senderUid = payload.Sender.User.UID; + isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal); + if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info)) + { + isModerator = info.IsModerator(); + isPinned = info.IsPinned(); + } + } + + showRoleIcons = isOwner || isModerator || isPinned; + } + + var lineStartX = cursorStartX; + string prefix; + if (showRoleIcons) + { + lineStartX += MeasureRolePrefixWidth(timestampText, isOwner, isModerator, isPinned, itemSpacing); + prefix = $"{message.DisplayName}: "; + } + else + { + prefix = $"{timestampText}{message.DisplayName}: "; + } + + var lines = MeasureChatMessageLines(prefix, payload.Message, lineStartX, contentMaxX); + return Math.Max(1, lines) * ImGui.GetTextLineHeightWithSpacing(); + } + + private int MeasureChatMessageLines(string prefix, string message, float lineStartX, float contentMaxX) + { + var segments = BuildChatSegments(prefix, message); + if (segments.Count == 0) + { + return 1; + } + + var emoteWidth = ImGui.GetTextLineHeight(); + var availableWidth = Math.Max(1f, contentMaxX - lineStartX); + var remainingWidth = availableWidth; + var firstOnLine = true; + var lines = 1; + + foreach (var segment in segments) + { + if (segment.IsLineBreak) + { + lines++; + firstOnLine = true; + remainingWidth = availableWidth; + continue; + } + + if (segment.IsWhitespace && firstOnLine) + { + continue; + } + + var segmentWidth = segment.IsEmote ? emoteWidth : ImGui.CalcTextSize(segment.Text).X; + if (!firstOnLine) + { + if (segmentWidth > remainingWidth) + { + lines++; + firstOnLine = true; + remainingWidth = availableWidth; + if (segment.IsWhitespace) + { + continue; + } + } + } + + remainingWidth -= segmentWidth; + firstOnLine = false; + } + + return lines; + } + + private float MeasureRolePrefixWidth(string timestampText, bool isOwner, bool isModerator, bool isPinned, float itemSpacing) + { + var width = 0f; + + if (!string.IsNullOrEmpty(timestampText)) + { + width += ImGui.CalcTextSize(timestampText).X; + } + + var hasIcon = false; + if (isModerator) + { + width += MeasureIconWidth(FontAwesomeIcon.UserShield); + hasIcon = true; + } + + if (isOwner) + { + if (hasIcon) + { + width += itemSpacing; + } + + width += MeasureIconWidth(FontAwesomeIcon.Crown); + hasIcon = true; + } + + if (isPinned) + { + if (hasIcon) + { + width += itemSpacing; + } + + width += MeasureIconWidth(FontAwesomeIcon.Thumbtack); + hasIcon = true; + } + + if (hasIcon) + { + width += itemSpacing; + } + + return width; + } + + private float MeasureIconWidth(FontAwesomeIcon icon) + { + using var font = _uiSharedService.IconFont.Push(); + return ImGui.CalcTextSize(icon.ToIconString()).X; + } + + private float MeasureSystemEntryHeight(ChatMessageEntry entry) + { + _ = entry; + var spacing = ImGui.GetStyle().ItemSpacing.Y; + var lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing(); + var separatorHeight = Math.Max(1f, ImGuiHelpers.GlobalScale); + + var height = spacing; + height += lineHeightWithSpacing; + height += spacing * 0.35f; + height += separatorHeight; + height += spacing; + return height; + } + + private static int LowerBound(float[] values, float target) + { + var low = 0; + var high = values.Length; + while (low < high) + { + var mid = (low + high) / 2; + if (values[mid] < target) + { + low = mid + 1; + } + else + { + high = mid; + } + } + + return low; + } + + private static int UpperBound(float[] values, float target) + { + var low = 0; + var high = values.Length; + while (low < high) + { + var mid = (low + high) / 2; + if (values[mid] <= target) + { + low = mid + 1; + } + else + { + high = mid; + } + } + + return low; } private void DrawEmoteTooltip(string name, IDalamudTextureWrap? texture) @@ -2092,6 +2361,17 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetTooltip("When enabled, your notes replace user names in syncshell chat."); } + var enableAnimatedEmotes = chatConfig.EnableAnimatedEmotes; + if (ImGui.Checkbox("Enable animated emotes", ref enableAnimatedEmotes)) + { + chatConfig.EnableAnimatedEmotes = enableAnimatedEmotes; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("When disabled, emotes render as static images."); + } + ImGui.Separator(); ImGui.TextUnformatted("Chat Visibility"); From 17dd8a307b6fd8e46f117ed1f9c4531fbf8a9f62 Mon Sep 17 00:00:00 2001 From: azyges Date: Tue, 6 Jan 2026 00:42:44 +0900 Subject: [PATCH 2/2] fix access violation --- .../Services/LightFinder/LightFinderScannerService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs index b13ff44..0a737c7 100644 --- a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs @@ -78,12 +78,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase var now = DateTime.UtcNow; - foreach (var address in _actorTracker.PlayerAddresses) + foreach (var descriptor in _actorTracker.PlayerDescriptors) { - if (address == nint.Zero) + if (string.IsNullOrEmpty(descriptor.HashedContentId)) continue; - var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address); + var cid = descriptor.HashedContentId; var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now; if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < _maxQueueSize)