diff --git a/LightlessSync/UI/DataAnalysisUi.cs b/LightlessSync/UI/DataAnalysisUi.cs index a3061a7..de6f697 100644 --- a/LightlessSync/UI/DataAnalysisUi.cs +++ b/LightlessSync/UI/DataAnalysisUi.cs @@ -34,6 +34,8 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private const float TextureDetailSplitterWidth = 12f; private const float TextureDetailSplitterCollapsedWidth = 18f; private const float SelectedFilePanelLogicalHeight = 90f; + private const float TextureHoverPreviewDelaySeconds = 1.75f; + private const float TextureHoverPreviewSize = 350f; private static readonly Vector4 SelectedTextureRowTextColor = new(0f, 0f, 0f, 1f); private readonly CharacterAnalyzer _characterAnalyzer; @@ -77,6 +79,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private string _selectedJobEntry = string.Empty; private string _filterGamePath = string.Empty; private string _filterFilePath = string.Empty; + private string _textureHoverKey = string.Empty; private int _conversionCurrentFileProgress = 0; private int _conversionTotalJobs; @@ -87,6 +90,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private bool _textureRowsDirty = true; private bool _textureDetailCollapsed = false; private bool _conversionFailed; + private double _textureHoverStartTime = 0; +#if DEBUG + private bool _debugCompressionModalOpen = false; + private TextureConversionProgress? _debugConversionProgress; +#endif private bool _showAlreadyAddedTransients = false; private bool _acknowledgeReview = false; @@ -135,21 +143,30 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private void HandleConversionModal() { - if (_conversionTask == null) + bool hasConversion = _conversionTask != null; +#if DEBUG + bool showDebug = _debugCompressionModalOpen && !hasConversion; +#else + const bool showDebug = false; +#endif + if (!hasConversion && !showDebug) { return; } - if (_conversionTask.IsCompleted) + if (hasConversion && _conversionTask!.IsCompleted) { ResetConversionModalState(); - return; + if (!showDebug) + { + return; + } } _showModal = true; - if (ImGui.BeginPopupModal("Texture Compression in Progress", ImGuiWindowFlags.AlwaysAutoResize)) + if (ImGui.BeginPopupModal("Texture Compression in Progress", UiSharedService.PopupWindowFlags)) { - DrawConversionModalContent(); + DrawConversionModalContent(showDebug); ImGui.EndPopup(); } else @@ -164,31 +181,190 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } } - private void DrawConversionModalContent() + private void DrawConversionModalContent(bool isDebugPreview) { - var progress = _lastConversionProgress; + var scale = ImGuiHelpers.GlobalScale; + TextureConversionProgress? progress; +#if DEBUG + progress = isDebugPreview ? _debugConversionProgress : _lastConversionProgress; +#else + progress = _lastConversionProgress; +#endif var total = progress?.Total ?? Math.Max(_conversionTotalJobs, 1); var completed = progress != null - ? Math.Min(progress.Completed + 1, total) - : _conversionCurrentFileProgress; - var currentLabel = !string.IsNullOrEmpty(_conversionCurrentFileName) - ? _conversionCurrentFileName - : "Preparing..."; + ? Math.Clamp(progress.Completed + 1, 0, total) + : Math.Clamp(_conversionCurrentFileProgress, 0, total); + var percent = total > 0 ? Math.Clamp(completed / (float)total, 0f, 1f) : 0f; - ImGui.TextUnformatted($"Compressing textures ({completed}/{total})"); - UiSharedService.TextWrapped("Current file: " + currentLabel); + var job = progress?.CurrentJob; + var inputPath = job?.InputFile ?? string.Empty; + var targetLabel = job != null ? job.TargetType.ToString() : "Unknown"; + var currentLabel = !string.IsNullOrEmpty(inputPath) + ? Path.GetFileName(inputPath) + : !string.IsNullOrEmpty(_conversionCurrentFileName) ? _conversionCurrentFileName : "Preparing..."; + var mapKind = !string.IsNullOrEmpty(inputPath) + ? _textureMetadataHelper.DetermineMapKind(inputPath) + : TextureMapKind.Unknown; - if (_conversionFailed) + var accent = UIColors.Get("LightlessPurple"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.18f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.4f); + var headerHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 2.6f, 46f * scale); + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(12f * scale, 2f * scale))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder))) + using (var header = ImRaii.Child("compressionHeader", new Vector2(-1f, headerHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) { - UiSharedService.ColorText("Conversion encountered errors. Please review the log for details.", ImGuiColors.DalamudRed); + if (header) + { + if (ImGui.BeginTable("compressionHeaderTable", 2, + ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.NoHostExtendX)) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + DrawCompressionTitle(accent, scale); + + var statusText = isDebugPreview ? "Preview mode" : "Working..."; + var statusColor = isDebugPreview ? UIColors.Get("LightlessYellow") : ImGuiColors.DalamudGrey; + UiSharedService.ColorText(statusText, statusColor); + + ImGui.TableNextColumn(); + var progressText = $"{completed}/{total}"; + var percentText = $"{percent * 100f:0}%"; + var summaryText = $"{progressText} ({percentText})"; + var summaryWidth = ImGui.CalcTextSize(summaryText).X; + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + MathF.Max(0f, ImGui.GetColumnWidth() - summaryWidth)); + UiSharedService.ColorText(summaryText, ImGuiColors.DalamudGrey); + + ImGui.EndTable(); + } + } } - if (_uiSharedService.IconTextButton(FontAwesomeIcon.StopCircle, "Cancel conversion")) + ImGuiHelpers.ScaledDummy(6); + + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 4f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, new Vector2(0f, 4f * scale))) + using (ImRaii.PushColor(ImGuiCol.FrameBg, UiSharedService.Color(new Vector4(0.15f, 0.15f, 0.18f, 1f)))) + using (ImRaii.PushColor(ImGuiCol.PlotHistogram, UiSharedService.Color(accent))) { - _conversionCancellationTokenSource.Cancel(); + ImGui.ProgressBar(percent, new Vector2(-1f, 0f), $"{percent * 100f:0}%"); } - UiSharedService.SetScaledWindowSize(520); + ImGuiHelpers.ScaledDummy(6); + + var infoAccent = UIColors.Get("LightlessBlue"); + var infoBg = new Vector4(infoAccent.X, infoAccent.Y, infoAccent.Z, 0.12f); + var infoBorder = new Vector4(infoAccent.X, infoAccent.Y, infoAccent.Z, 0.32f); + const int detailRows = 3; + var detailHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * (detailRows + 1.2f), 72f * scale); + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 5f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(10f * scale, 6f * scale))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(infoBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(infoBorder))) + using (var details = ImRaii.Child("compressionDetail", new Vector2(-1f, detailHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (details) + { + if (ImGui.BeginTable("compressionDetailTable", 2, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX)) + { + DrawDetailRow("Current file", currentLabel, inputPath); + DrawDetailRow("Target format", targetLabel, null); + DrawDetailRow("Map type", mapKind.ToString(), null); + ImGui.EndTable(); + } + } + } + + if (_conversionFailed && !isDebugPreview) + { + ImGuiHelpers.ScaledDummy(4); + _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, ImGuiColors.DalamudRed); + ImGui.SameLine(0f, 6f * scale); + UiSharedService.TextWrapped("Conversion encountered errors. Please review the log for details.", color: ImGuiColors.DalamudRed); + } + + ImGuiHelpers.ScaledDummy(6); + if (!isDebugPreview) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.StopCircle, "Cancel conversion")) + { + _conversionCancellationTokenSource.Cancel(); + } + } + else + { +#if DEBUG + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Times, "Close preview")) + { + CloseDebugCompressionModal(); + } +#endif + } + + UiSharedService.SetScaledWindowSize(600); + + void DrawDetailRow(string label, string value, string? tooltip) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey)) + { + ImGui.TextUnformatted(label); + } + ImGui.TableNextColumn(); + ImGui.TextUnformatted(value); + if (!string.IsNullOrEmpty(tooltip)) + { + UiSharedService.AttachToolTip(tooltip); + } + } + + void DrawCompressionTitle(Vector4 iconColor, float localScale) + { + const string title = "Texture Compression"; + var spacing = 6f * localScale; + + var iconText = FontAwesomeIcon.CompressArrowsAlt.ToIconString(); + Vector2 iconSize; + using (_uiSharedService.IconFont.Push()) + { + iconSize = ImGui.CalcTextSize(iconText); + } + + Vector2 titleSize; + using (_uiSharedService.MediumFont.Push()) + { + titleSize = ImGui.CalcTextSize(title); + } + + var lineHeight = MathF.Max(iconSize.Y, titleSize.Y); + var iconOffsetY = (lineHeight - iconSize.Y) / 2f; + var textOffsetY = (lineHeight - titleSize.Y) / 2f; + + var start = ImGui.GetCursorScreenPos(); + var drawList = ImGui.GetWindowDrawList(); + + using (_uiSharedService.IconFont.Push()) + { + drawList.AddText(new Vector2(start.X, start.Y + iconOffsetY), UiSharedService.Color(iconColor), iconText); + } + + using (_uiSharedService.MediumFont.Push()) + { + var textPos = new Vector2(start.X + iconSize.X + spacing, start.Y + textOffsetY); + drawList.AddText(textPos, ImGui.GetColorU32(ImGuiCol.Text), title); + } + + ImGui.Dummy(new Vector2(iconSize.X + spacing + titleSize.X, lineHeight)); + } } private void ResetConversionModalState() @@ -202,6 +378,41 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _conversionTotalJobs = 0; } +#if DEBUG + private void OpenCompressionDebugModal() + { + if (_conversionTask != null && !_conversionTask.IsCompleted) + { + return; + } + + _debugCompressionModalOpen = true; + _debugConversionProgress = new TextureConversionProgress( + Completed: 3, + Total: 10, + CurrentJob: new TextureConversionJob( + @"C:\Lightless\Mods\Textures\example_diffuse.tex", + @"C:\Lightless\Mods\Textures\example_diffuse_bc7.tex", + Penumbra.Api.Enums.TextureType.Bc7Tex)); + _showModal = true; + _modalOpen = false; + } + + private void ResetDebugCompressionModalState() + { + _debugCompressionModalOpen = false; + _debugConversionProgress = null; + } + + private void CloseDebugCompressionModal() + { + ResetDebugCompressionModalState(); + _showModal = false; + _modalOpen = false; + ImGui.CloseCurrentPopup(); + } +#endif + private void RefreshAnalysisCache() { if (!_hasUpdate) @@ -757,6 +968,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase ResetTextureFilters(); InvalidateTextureRows(); _conversionFailed = false; +#if DEBUG + ResetDebugCompressionModalState(); +#endif } protected override void Dispose(bool disposing) @@ -1955,6 +2169,17 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase { InvalidateTextureRows(); } +#if DEBUG + ImGui.SameLine(); + using (ImRaii.Disabled(conversionRunning || !UiSharedService.CtrlPressed())) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Cog, "Preview popup (debug)", 200f * scale)) + { + OpenCompressionDebugModal(); + } + } + UiSharedService.AttachToolTip("Hold CTRL to open the compression popup preview."); +#endif TextureRow? lastSelected = null; using (var table = ImRaii.Table("textureDataTable", 9, @@ -2335,11 +2560,30 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase { if (_texturePreviews.TryGetValue(key, out var state)) { + var loadTask = state.LoadTask; + if (loadTask is { IsCompleted: false }) + { + _ = loadTask.ContinueWith(_ => + { + state.Texture?.Dispose(); + }, TaskScheduler.Default); + } + state.Texture?.Dispose(); _texturePreviews.Remove(key); } } + private void ClearHoverPreview(TextureRow row) + { + if (string.Equals(_selectedTextureKey, row.Key, StringComparison.Ordinal)) + { + return; + } + + ResetPreview(row.Key); + } + private TextureResolutionInfo? GetTextureResolution(TextureRow row) { if (_textureResolutionCache.TryGetValue(row.Key, out var cached)) @@ -2440,7 +2684,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase UiSharedService.AttachToolTip("Already stored in a compressed format; additional compression is disabled."); } - DrawSelectableColumn(isSelected, () => + var nameHovered = DrawSelectableColumn(isSelected, () => { var selectableLabel = $"{row.DisplayName}##texName{index}"; if (ImGui.Selectable(selectableLabel, isSelected)) @@ -2448,20 +2692,20 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _selectedTextureKey = isSelected ? string.Empty : key; } - return () => UiSharedService.AttachToolTip($"{row.PrimaryFilePath}{UiSharedService.TooltipSeparator}{string.Join(Environment.NewLine, row.GamePaths)}"); + return null; }); - DrawSelectableColumn(isSelected, () => + _ = DrawSelectableColumn(isSelected, () => { ImGui.TextUnformatted(row.Slot); return null; }); - DrawSelectableColumn(isSelected, () => + _ = DrawSelectableColumn(isSelected, () => { ImGui.TextUnformatted(row.MapKind.ToString()); return null; }); - DrawSelectableColumn(isSelected, () => + _ = DrawSelectableColumn(isSelected, () => { Action? tooltipAction = null; ImGui.TextUnformatted(row.Format); @@ -2475,7 +2719,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase return tooltipAction; }); - DrawSelectableColumn(isSelected, () => + _ = DrawSelectableColumn(isSelected, () => { if (row.SuggestedTarget.HasValue) { @@ -2537,19 +2781,21 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase UiSharedService.AttachToolTip("This texture is already compressed and cannot be processed again."); } - DrawSelectableColumn(isSelected, () => + _ = DrawSelectableColumn(isSelected, () => { ImGui.TextUnformatted(UiSharedService.ByteToString(row.OriginalSize)); return null; }); - DrawSelectableColumn(isSelected, () => + _ = DrawSelectableColumn(isSelected, () => { ImGui.TextUnformatted(UiSharedService.ByteToString(row.CompressedSize)); return null; }); + + DrawTextureRowHoverTooltip(row, nameHovered); } - private static void DrawSelectableColumn(bool isSelected, Func draw) + private static bool DrawSelectableColumn(bool isSelected, Func draw) { ImGui.TableNextColumn(); if (isSelected) @@ -2558,6 +2804,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } var after = draw(); + var hovered = ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled | ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); if (isSelected) { @@ -2565,6 +2812,127 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } after?.Invoke(); + return hovered; + } + + private void DrawTextureRowHoverTooltip(TextureRow row, bool isHovered) + { + if (!isHovered) + { + if (string.Equals(_textureHoverKey, row.Key, StringComparison.Ordinal)) + { + _textureHoverKey = string.Empty; + _textureHoverStartTime = 0; + ClearHoverPreview(row); + } + return; + } + + var now = ImGui.GetTime(); + if (!string.Equals(_textureHoverKey, row.Key, StringComparison.Ordinal)) + { + _textureHoverKey = row.Key; + _textureHoverStartTime = now; + } + + var elapsed = now - _textureHoverStartTime; + if (elapsed < TextureHoverPreviewDelaySeconds) + { + var progress = (float)Math.Clamp(elapsed / TextureHoverPreviewDelaySeconds, 0f, 1f); + DrawTextureRowTextTooltip(row, progress); + return; + } + + DrawTextureRowPreviewTooltip(row); + } + + private void DrawTextureRowTextTooltip(TextureRow row, float progress) + { + ImGui.BeginTooltip(); + ImGui.SetWindowFontScale(1f); + DrawTextureRowTooltipBody(row); + ImGuiHelpers.ScaledDummy(4); + DrawTextureHoverProgressBar(progress, GetTooltipContentWidth()); + ImGui.EndTooltip(); + } + + private void DrawTextureRowPreviewTooltip(TextureRow row) + { + ImGui.BeginTooltip(); + ImGui.SetWindowFontScale(1f); + + DrawTextureRowTooltipBody(row); + ImGuiHelpers.ScaledDummy(4); + + var previewSize = new Vector2(TextureHoverPreviewSize * ImGuiHelpers.GlobalScale); + var (previewTexture, previewLoading, previewError) = GetTexturePreview(row); + if (previewTexture != null) + { + ImGui.Image(previewTexture.Handle, previewSize); + } + else + { + using (ImRaii.Child("textureHoverPreview", previewSize, true)) + { + UiSharedService.TextWrapped(previewLoading ? "Loading preview..." : previewError ?? "Preview unavailable."); + } + } + ImGui.EndTooltip(); + } + + private static void DrawTextureRowTooltipBody(TextureRow row) + { + var text = row.GamePaths.Count > 0 + ? $"{row.PrimaryFilePath}{UiSharedService.TooltipSeparator}{string.Join(Environment.NewLine, row.GamePaths)}" + : row.PrimaryFilePath; + + var wrapWidth = GetTextureHoverTooltipWidth(); + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + wrapWidth); + if (text.Contains(UiSharedService.TooltipSeparator, StringComparison.Ordinal)) + { + var splitText = text.Split(UiSharedService.TooltipSeparator, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < splitText.Length; i++) + { + ImGui.TextUnformatted(splitText[i]); + if (i != splitText.Length - 1) + { + ImGui.Separator(); + } + } + } + else + { + ImGui.TextUnformatted(text); + } + ImGui.PopTextWrapPos(); + } + + private static void DrawTextureHoverProgressBar(float progress, float width) + { + var scale = ImGuiHelpers.GlobalScale; + var barHeight = 4f * scale; + var barWidth = width > 0f ? width : -1f; + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 3f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, Vector2.Zero)) + using (ImRaii.PushColor(ImGuiCol.PlotHistogram, UiSharedService.Color(UIColors.Get("LightlessPurple")))) + { + ImGui.ProgressBar(progress, new Vector2(barWidth, barHeight), string.Empty); + } + } + + private static float GetTextureHoverTooltipWidth() + => ImGui.GetFontSize() * 35f; + + private static float GetTooltipContentWidth() + { + var min = ImGui.GetWindowContentRegionMin(); + var max = ImGui.GetWindowContentRegionMax(); + var width = max.X - min.X; + if (width <= 0f) + { + width = ImGui.GetContentRegionAvail().X; + } + return width; } private static void ApplyTextureRowBackground(TextureRow row, bool isSelected)