using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Extensions; using OtterGui.Raii; using OtterTex; using Penumbra.Import.Textures; using Penumbra.Mods; using Penumbra.UI.Classes; namespace Penumbra.UI.AdvancedWindow; public partial class ModEditWindow { private readonly TextureManager _textures; private readonly Texture _left = new(); private readonly Texture _right = new(); private readonly CombinedTexture _center; private readonly TextureDrawer.PathSelectCombo _textureSelectCombo; private bool _overlayCollapsed = true; private bool _addMipMaps = true; private int _currentSaveAs; private static readonly (string, string)[] SaveAsStrings = { ("As Is", "Save the current texture with its own format without additional conversion or compression, if possible."), ("RGBA (Uncompressed)", "Save the current texture as an uncompressed BGRA bitmap.\nThis requires the most space but technically offers the best quality."), ("BC1 (Simple Compression for Opaque RGB)", "Save the current texture compressed via BC1/DXT1 compression.\nThis offers a 8:1 compression ratio and is quick with acceptable quality, but only supports RGB, without Alpha.\n\nCan be used for diffuse maps and equipment textures to save extra space."), ("BC3 (Simple Compression for RGBA)", "Save the current texture compressed via BC3/DXT5 compression.\nThis offers a 4:1 compression ratio and is quick with acceptable quality, and fully supports RGBA.\n\nGeneric format that can be used for most textures."), ("BC4 (Simple Compression for Opaque Grayscale)", "Save the current texture compressed via BC4 compression.\nThis offers a 8:1 compression ratio and has almost indistinguishable quality, but only supports Grayscale, without Alpha.\n\nCan be used for face paints and legacy marks."), ("BC5 (Simple Compression for Opaque RG)", "Save the current texture compressed via BC5 compression.\nThis offers a 4:1 compression ratio and has almost indistinguishable quality, but only supports RG, without B or Alpha.\n\nRecommended for index maps, unrecommended for normal maps."), ("BC7 (Complex Compression for RGBA)", "Save the current texture compressed via BC7 compression.\nThis offers a 4:1 compression ratio and has almost indistinguishable quality, but may take a while.\n\nGeneric format that can be used for most textures."), }; private void DrawInputChild(string label, Texture tex, Vector2 size, Vector2 imageSize) { using (var child = ImRaii.Child(label, size, true)) { if (!child) return; using var id = ImRaii.PushId(label); ImGuiUtil.DrawTextButton(label, new Vector2(-1, 0), ImGui.GetColorU32(ImGuiCol.FrameBg)); ImGui.NewLine(); using (var disabled = ImRaii.Disabled(!_center.SaveTask.IsCompleted)) { TextureDrawer.PathInputBox(_textures, tex, ref tex.TmpPath, "##input", "Import Image...", "Can import game paths as well as your own files.", Mod!.ModPath.FullName, _fileDialog, _config.DefaultModImportPath); if (_textureSelectCombo.Draw("##combo", "Select the textures included in this mod on your drive or the ones they replace from the game files.", tex.Path, Mod.ModPath.FullName.Length + 1, out var newPath) && newPath != tex.Path) tex.Load(_textures, newPath); if (tex == _left) _center.DrawMatrixInputLeft(size.X); else _center.DrawMatrixInputRight(size.X); } ImGui.NewLine(); using var child2 = ImRaii.Child("image"); if (child2) TextureDrawer.Draw(tex, imageSize); } if (_dragDropManager.CreateImGuiTarget("TextureDragDrop", out var files, out _) && GetFirstTexture(files, out var file)) tex.Load(_textures, file); } private void SaveAsCombo() { var (text, desc) = SaveAsStrings[_currentSaveAs]; ImGui.SetNextItemWidth(-ImGui.GetFrameHeight() - ImGui.GetStyle().ItemSpacing.X); using var combo = ImRaii.Combo("##format", text); ImGuiUtil.HoverTooltip(desc); if (!combo) return; foreach (var ((newText, newDesc), idx) in SaveAsStrings.WithIndex()) { if (ImGui.Selectable(newText, idx == _currentSaveAs)) _currentSaveAs = idx; ImGuiUtil.SelectableHelpMarker(newDesc); } } private void RedrawOnSaveBox() { var redraw = _config.Ephemeral.ForceRedrawOnFileChange; if (ImGui.Checkbox("Redraw on Save", ref redraw)) { _config.Ephemeral.ForceRedrawOnFileChange = redraw; _config.Ephemeral.Save(); } ImGuiUtil.HoverTooltip("Force a redraw of your player character whenever you save a file here."); } private void MipMapInput() { ImGui.Checkbox("##mipMaps", ref _addMipMaps); ImGuiUtil.HoverTooltip( "Add the appropriate number of MipMaps to the file."); } private bool _forceTextureStartPath = true; private void DrawOutputChild(Vector2 size, Vector2 imageSize) { using var child = ImRaii.Child("Output", size, true); if (!child) return; if (_center.IsLoaded) { RedrawOnSaveBox(); ImGui.SameLine(); SaveAsCombo(); ImGui.SameLine(); MipMapInput(); var canSaveInPlace = Path.IsPathRooted(_left.Path) && _left.Type is TextureType.Tex or TextureType.Dds or TextureType.Png; var isActive = _config.DeleteModModifier.IsActive(); var tt = isActive ? "This saves the texture in place. This is not revertible." : $"This saves the texture in place. This is not revertible. Hold {_config.DeleteModModifier} to save."; var buttonSize2 = new Vector2((ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemSpacing.X) / 2, 0); var buttonSize3 = new Vector2((ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemSpacing.X * 2) / 3, 0); if (ImGuiUtil.DrawDisabledButton("Save in place", buttonSize2, tt, !isActive || !canSaveInPlace || _center.IsLeftCopy && _currentSaveAs == (int)CombinedTexture.TextureSaveType.AsIs)) { _center.SaveAs(_left.Type, _textures, _left.Path, (CombinedTexture.TextureSaveType)_currentSaveAs, _addMipMaps); AddChangeTask(_left.Path); AddReloadTask(_left.Path, false); } ImGui.SameLine(); if (ImGui.Button("Save as TEX", buttonSize2)) OpenSaveAsDialog(".tex"); if (ImGui.Button("Export as TGA", buttonSize3)) OpenSaveAsDialog(".tga"); ImGui.SameLine(); if (ImGui.Button("Export as PNG", buttonSize3)) OpenSaveAsDialog(".png"); ImGui.SameLine(); if (ImGui.Button("Export as DDS", buttonSize3)) OpenSaveAsDialog(".dds"); ImGui.NewLine(); var canConvertInPlace = canSaveInPlace && _left.Type is TextureType.Tex && _center.IsLeftCopy; if (ImGuiUtil.DrawDisabledButton("Convert to BC7", buttonSize3, "This converts the texture to BC7 format in place. This is not revertible.", !canConvertInPlace || _left.Format is DXGIFormat.BC7Typeless or DXGIFormat.BC7UNorm or DXGIFormat.BC7UNormSRGB)) { _center.SaveAsTex(_textures, _left.Path, CombinedTexture.TextureSaveType.BC7, _left.MipMaps > 1); AddChangeTask(_left.Path); AddReloadTask(_left.Path, false); } ImGui.SameLine(); if (ImGuiUtil.DrawDisabledButton("Convert to BC3", buttonSize3, "This converts the texture to BC3 format in place. This is not revertible.", !canConvertInPlace || _left.Format is DXGIFormat.BC3Typeless or DXGIFormat.BC3UNorm or DXGIFormat.BC3UNormSRGB)) { _center.SaveAsTex(_textures, _left.Path, CombinedTexture.TextureSaveType.BC3, _left.MipMaps > 1); AddChangeTask(_left.Path); AddReloadTask(_left.Path, false); } ImGui.SameLine(); if (ImGuiUtil.DrawDisabledButton("Convert to RGBA", buttonSize3, "This converts the texture to RGBA format in place. This is not revertible.", !canConvertInPlace || _left.Format is DXGIFormat.B8G8R8A8UNorm or DXGIFormat.B8G8R8A8Typeless or DXGIFormat.B8G8R8A8UNormSRGB)) { _center.SaveAsTex(_textures, _left.Path, CombinedTexture.TextureSaveType.Bitmap, _left.MipMaps > 1); AddChangeTask(_left.Path); AddReloadTask(_left.Path, false); } } switch (_center.SaveTask.Status) { case TaskStatus.WaitingForActivation: case TaskStatus.WaitingToRun: case TaskStatus.Running: ImGuiUtil.DrawTextButton("Computing...", -Vector2.UnitX, Colors.PressEnterWarningBg); break; case TaskStatus.Canceled: case TaskStatus.Faulted: { ImGui.TextUnformatted("Could not save file:"); using var color = ImRaii.PushColor(ImGuiCol.Text, 0xFF0000FF); ImGuiUtil.TextWrapped(_center.SaveTask.Exception?.ToString() ?? "Unknown Error"); break; } default: ImGui.Dummy(new Vector2(1, ImGui.GetFrameHeight())); break; } ImGui.NewLine(); using var child2 = ImRaii.Child("image"); if (child2) _center.Draw(_textures, imageSize); } private void InvokeChange(Mod? mod, string path) { if (mod == null) return; if (!_editor.Files.Tex.FindFirst(r => string.Equals(r.File.FullName, path, StringComparison.OrdinalIgnoreCase), out var registry)) return; _communicator.ModFileChanged.Invoke(mod, registry); } private void OpenSaveAsDialog(string defaultExtension) { var fileName = Path.GetFileNameWithoutExtension(_left.Path.Length > 0 ? _left.Path : _right.Path); _fileDialog.OpenSavePicker("Save Texture as TEX, DDS, PNG or TGA...", "Textures{.png,.dds,.tex,.tga},.tex,.dds,.png,.tga", fileName, defaultExtension, (a, b) => { if (a) { _center.SaveAs(null, _textures, b, (CombinedTexture.TextureSaveType)_currentSaveAs, _addMipMaps); AddChangeTask(b); if (b == _left.Path) AddReloadTask(_left.Path, false); else if (b == _right.Path) AddReloadTask(_right.Path, true); } }, Mod!.ModPath.FullName, _forceTextureStartPath); _forceTextureStartPath = false; } private void AddChangeTask(string path) { _center.SaveTask.ContinueWith(t => { if (!t.IsCompletedSuccessfully) return; _framework.RunOnFrameworkThread(() => InvokeChange(Mod, path)); }, TaskScheduler.Default); } private void AddReloadTask(string path, bool right) { _center.SaveTask.ContinueWith(t => { if (!t.IsCompletedSuccessfully) return; var tex = right ? _right : _left; if (tex.Path != path) return; _framework.RunOnFrameworkThread(() => tex.Reload(_textures)); }, TaskScheduler.Default); } private Vector2 GetChildWidth() { var windowWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X - ImGui.GetTextLineHeight(); if (_overlayCollapsed) { var width = windowWidth - ImGui.GetStyle().FramePadding.X * 3; return new Vector2(width / 2, -1); } return new Vector2((windowWidth - ImGui.GetStyle().FramePadding.X * 5) / 3, -1); } private void DrawTextureTab() { using var tab = ImRaii.TabItem("Textures"); if (!tab) return; try { _dragDropManager.CreateImGuiSource("TextureDragDrop", m => m.Extensions.Any(e => ValidTextureExtensions.Contains(e.ToLowerInvariant())), m => { if (!GetFirstTexture(m.Files, out var file)) return false; ImGui.TextUnformatted($"Dragging texture for editing: {Path.GetFileName(file)}"); return true; }); var childWidth = GetChildWidth(); var imageSize = new Vector2(childWidth.X - ImGui.GetStyle().FramePadding.X * 2); DrawInputChild("Input Texture", _left, childWidth, imageSize); ImGui.SameLine(); DrawOutputChild(childWidth, imageSize); if (!_overlayCollapsed) { ImGui.SameLine(); DrawInputChild("Overlay Texture", _right, childWidth, imageSize); } ImGui.SameLine(); DrawOverlayCollapseButton(); } catch (Exception e) { Penumbra.Log.Error($"Unknown Error while drawing textures:\n{e}"); } } private void DrawOverlayCollapseButton() { var (label, tooltip) = _overlayCollapsed ? (">", "Show a third panel in which you can import an additional texture as an overlay for the primary texture.") : ("<", "Hide the overlay texture panel and clear the currently loaded overlay texture, if any."); if (ImGui.Button(label, new Vector2(ImGui.GetTextLineHeight(), ImGui.GetContentRegionAvail().Y))) _overlayCollapsed = !_overlayCollapsed; ImGuiUtil.HoverTooltip(tooltip); } private static bool GetFirstTexture(IEnumerable files, [NotNullWhen(true)] out string? file) { file = files.FirstOrDefault(f => ValidTextureExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())); return file != null; } private static readonly string[] ValidTextureExtensions = { ".png", ".dds", ".tex", ".tga", }; }