using System.Collections.Generic; using System.Linq; using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Configuration.Internal; using Dalamud.Console; using Dalamud.Game; using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; using Dalamud.Game.ClientState; using Dalamud.Game.Gui; using Dalamud.Game.Text; using Dalamud.Interface.Animation.EasingFunctions; using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using Dalamud.Plugin.Internal; using Dalamud.Plugin.Services; using Dalamud.Storage.Assets; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Component.GUI; using Lumina.Text.ReadOnly; using Serilog; namespace Dalamud.Interface.Internal.Windows; /// /// Class responsible for drawing the main plugin window. /// internal class TitleScreenMenuWindow : Window, IDisposable { private const float TargetFontSizePt = 18f; private const float TargetFontSizePx = TargetFontSizePt * 4 / 3; private readonly ClientState clientState; private readonly DalamudConfiguration configuration; private readonly GameGui gameGui; private readonly TitleScreenMenu titleScreenMenu; private readonly DisposeSafety.ScopedFinalizer scopedFinalizer = new(); private readonly IFontAtlas privateAtlas; private readonly Lazy myFontHandle; private readonly Lazy shadeTexture; private readonly AddonLifecycleEventListener versionStringListener; private readonly Dictionary shadeEasings = new(); private readonly Dictionary moveEasings = new(); private readonly Dictionary logoEasings = new(); private readonly IConsoleVariable showTsm; private InOutCubic? fadeOutEasing; private State state = State.Hide; private int lastLoadedPluginCount = -1; /// /// Initializes a new instance of the class. /// /// An instance of . /// An instance of . /// An instance of . /// An instance of . /// An instance of . /// An instance of . /// An instance of . /// An instance of . /// An instance of . public TitleScreenMenuWindow( ClientState clientState, DalamudConfiguration configuration, DalamudAssetManager dalamudAssetManager, FontAtlasFactory fontAtlasFactory, Framework framework, GameGui gameGui, TitleScreenMenu titleScreenMenu, ConsoleManager consoleManager, AddonLifecycle addonLifecycle) : base( "TitleScreenMenuOverlay", ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoBackground | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoNavFocus | ImGuiWindowFlags.NoDocking) { this.showTsm = consoleManager.AddVariable("dalamud.show_tsm", "Show the Title Screen Menu", true); this.clientState = clientState; this.configuration = configuration; this.gameGui = gameGui; this.titleScreenMenu = titleScreenMenu; this.IsOpen = true; this.DisableWindowSounds = true; this.ForceMainWindow = true; this.Position = new Vector2(0, 200); this.PositionCondition = ImGuiCond.Always; this.RespectCloseHotkey = false; this.shadeTexture = new(() => dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.TitleScreenMenuShade)); this.privateAtlas = fontAtlasFactory.CreateFontAtlas(this.WindowName, FontAtlasAutoRebuildMode.Async); this.scopedFinalizer.Add(this.privateAtlas); this.myFontHandle = new( () => this.scopedFinalizer.Add( this.privateAtlas.NewDelegateFontHandle( e => e.OnPreBuild( toolkit => toolkit.AddDalamudDefaultFont( TargetFontSizePx, titleScreenMenu.Entries.SelectMany(x => x.Name).ToGlyphRange()))))); titleScreenMenu.EntryListChange += this.TitleScreenMenuEntryListChange; this.scopedFinalizer.Add(() => titleScreenMenu.EntryListChange -= this.TitleScreenMenuEntryListChange); this.shadeTexture = new(() => dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.TitleScreenMenuShade)); framework.Update += this.FrameworkOnUpdate; this.scopedFinalizer.Add(() => framework.Update -= this.FrameworkOnUpdate); this.versionStringListener = new AddonLifecycleEventListener(AddonEvent.PreDraw, "_TitleRevision", this.OnVersionStringDraw); addonLifecycle.RegisterListener(this.versionStringListener); this.scopedFinalizer.Add(() => addonLifecycle.UnregisterListener(this.versionStringListener)); } private enum State { Hide, Show, FadeOut, } /// /// Gets or sets a value indicating whether drawing is allowed. /// public bool AllowDrawing { get; set; } = true; /// public void Dispose() => this.scopedFinalizer.Dispose(); /// public override void PreDraw() { ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(0, 0)); ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(0, 0)); if (this.state == State.Show) ImGui.SetNextWindowFocus(); base.PreDraw(); } /// public override void PostDraw() { ImGui.PopStyleVar(2); base.PostDraw(); } /// public override void Draw() { if (!this.AllowDrawing || !this.showTsm.Value) return; var scale = ImGui.GetIO().FontGlobalScale; var entries = this.titleScreenMenu.PluginEntries; var hovered = ImGui.IsWindowHovered( ImGuiHoveredFlags.RootAndChildWindows | ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); Service.Get().OverrideGameCursor = !hovered; switch (this.state) { case State.Show: { var i = 0; foreach (var entry in entries) { if (!entry.IsShowConditionSatisfied()) continue; if (entry.Texture.TryGetWrap(out var textureWrap, out var exception)) { if (textureWrap.Width != 64 && textureWrap.Height != 64) { Log.Error("Texture provided for ITitleScreenMenuEntry must be 64x64. Entry will be removed."); this.titleScreenMenu.RemoveEntry(entry); continue; } } if (exception != null) { Log.Error(exception, "An exception occurred while attempting to get the texture wrap for a ITitleScreenMenuEntry. Entry will be removed."); this.titleScreenMenu.RemoveEntry(entry); continue; } if (!this.moveEasings.TryGetValue(entry.Id, out var moveEasing)) { moveEasing = new InOutQuint(TimeSpan.FromMilliseconds(400)); this.moveEasings.Add(entry.Id, moveEasing); } if (!moveEasing.IsRunning && !moveEasing.IsDone) { moveEasing.Restart(); } if (moveEasing.IsDone) { moveEasing.Stop(); } moveEasing.Update(); var finalPos = (i + 1) * this.shadeTexture.Value.Height * scale; var pos = moveEasing.ValueClamped * finalPos; // FIXME(goat): Sometimes, easings can overshoot and bring things out of alignment. if (moveEasing.IsDone) { pos = finalPos; } this.DrawEntry(entry, moveEasing.IsRunning && i != 0, true, i == 0, true, moveEasing.IsDone); var cursor = ImGui.GetCursorPos(); cursor.Y = (float)pos; ImGui.SetCursorPos(cursor); i++; } // Don't check for hover if we're in the middle of an animation, as it will cause flickering. if (this.moveEasings.Any(x => !x.Value.IsDone)) break; if (!hovered) { this.state = State.FadeOut; } break; } case State.FadeOut: { this.fadeOutEasing ??= new InOutCubic(TimeSpan.FromMilliseconds(400)) { IsInverse = true, }; if (!this.fadeOutEasing.IsRunning && !this.fadeOutEasing.IsDone) { this.fadeOutEasing.Restart(); } if (this.fadeOutEasing.IsDone) { this.fadeOutEasing.Stop(); } this.fadeOutEasing.Update(); using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, (float)this.fadeOutEasing.ValueClamped)) { var i = 0; foreach (var entry in entries) { if (!entry.IsShowConditionSatisfied()) continue; if (entry.Texture.TryGetWrap(out var textureWrap, out var exception)) { if (textureWrap.Width != 64 && textureWrap.Height != 64) { Log.Error($"Texture provided for ITitleScreenMenuEntry {entry.Name} must be 64x64. Entry will be removed."); this.titleScreenMenu.RemoveEntry(entry); continue; } } if (exception != null) { Log.Error(exception, $"An exception occurred while attempting to get the texture wrap for ITitleScreenMenuEntry {entry.Name}. Entry will be removed."); this.titleScreenMenu.RemoveEntry(entry); continue; } var finalPos = (i + 1) * this.shadeTexture.Value.Height * scale; this.DrawEntry(entry, i != 0, true, i == 0, false, false); var cursor = ImGui.GetCursorPos(); cursor.Y = finalPos; ImGui.SetCursorPos(cursor); i++; } } var isHover = ImGui.IsWindowHovered(ImGuiHoveredFlags.RootAndChildWindows | ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); if (!isHover && this.fadeOutEasing!.IsDone) { this.state = State.Hide; this.fadeOutEasing = null; } else if (isHover) { this.state = State.Show; this.fadeOutEasing = null; } break; } case State.Hide: { if (entries.Count > 0 && this.DrawEntry(entries[0], true, false, true, true, false)) { this.state = State.Show; } this.moveEasings.Clear(); this.logoEasings.Clear(); this.shadeEasings.Clear(); break; } } } private bool DrawEntry( ITitleScreenMenuEntry entry, bool inhibitFadeout, bool showText, bool isFirst, bool overrideAlpha, bool interactable) { using var fontScopeDispose = this.myFontHandle.Value.Push(); var scale = ImGui.GetIO().FontGlobalScale; if (!this.shadeEasings.TryGetValue(entry.Id, out var shadeEasing)) { shadeEasing = new InOutCubic(TimeSpan.FromMilliseconds(350)); this.shadeEasings.Add(entry.Id, shadeEasing); } var initialCursor = ImGui.GetCursorPos(); using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, (float)shadeEasing.ValueClamped)) { var texture = this.shadeTexture.Value; ImGui.Image(texture.Handle, new Vector2(texture.Width, texture.Height) * scale); } var isHover = ImGui.IsItemHovered(); if (isHover && (!shadeEasing.IsRunning || (shadeEasing.IsDone && shadeEasing.IsInverse)) && !inhibitFadeout) { shadeEasing.IsInverse = false; shadeEasing.Restart(); } else if (!isHover && !shadeEasing.IsInverse && shadeEasing.IsRunning && !inhibitFadeout) { shadeEasing.IsInverse = true; shadeEasing.Restart(); } var isClick = ImGui.IsItemClicked(); if (isClick && interactable) { entry.Trigger(); } shadeEasing.Update(); if (!this.logoEasings.TryGetValue(entry.Id, out var logoEasing)) { logoEasing = new InOutCubic(TimeSpan.FromMilliseconds(350)); this.logoEasings.Add(entry.Id, logoEasing); } if (!logoEasing.IsRunning && !logoEasing.IsDone) { logoEasing.Restart(); } if (logoEasing.IsDone) { logoEasing.Stop(); } logoEasing.Update(); ImGui.SetCursorPos(initialCursor); ImGuiHelpers.ScaledDummy(5); ImGui.SameLine(); if (overrideAlpha) { ImGui.PushStyleVar(ImGuiStyleVar.Alpha, isFirst ? 1f : (float)logoEasing.ValueClamped); } else if (isFirst) { ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 1f); } // Wrap should always be valid at this point due to us checking the validity of the image each frame var dalamudTextureWrap = entry.Texture.GetWrapOrEmpty(); ImGui.Image(dalamudTextureWrap.Handle, new Vector2(TitleScreenMenu.TextureSize * scale)); if (overrideAlpha || isFirst) { ImGui.PopStyleVar(); } ImGui.SameLine(); ImGuiHelpers.ScaledDummy(10); ImGui.SameLine(); var textHeight = ImGui.GetTextLineHeightWithSpacing(); var cursor = ImGui.GetCursorPos(); cursor.Y += (dalamudTextureWrap.Height * scale / 2) - (textHeight / 2); if (overrideAlpha) { ImGui.PushStyleVar(ImGuiStyleVar.Alpha, showText ? (float)logoEasing.ValueClamped : 0f); } // Drop shadow ImGui.SetCursorPos(cursor); ImGuiHelpers.SeStringWrapped( ReadOnlySeString.FromText(entry.Name), new() { FontSize = TargetFontSizePx * ImGui.GetIO().FontGlobalScale, Edge = true, Shadow = true, }); if (overrideAlpha) { ImGui.PopStyleVar(); } initialCursor.Y += dalamudTextureWrap.Height * scale; ImGui.SetCursorPos(initialCursor); return isHover; } private void FrameworkOnUpdate(IFramework unused) { this.IsOpen = !this.clientState.IsLoggedIn; if (!this.configuration.ShowTsm) this.IsOpen = false; var charaSelect = this.gameGui.GetAddonByName("CharaSelect", 1); var charaMake = this.gameGui.GetAddonByName("CharaMake", 1); var titleDcWorldMap = this.gameGui.GetAddonByName("TitleDCWorldMap", 1); if (charaMake != IntPtr.Zero || charaSelect != IntPtr.Zero || titleDcWorldMap != IntPtr.Zero) this.IsOpen = false; } private unsafe void OnVersionStringDraw(AddonEvent ev, AddonArgs args) { if (ev is not (AddonEvent.PostDraw or AddonEvent.PreDraw)) return; var addon = args.Addon.Struct; var textNode = addon->GetTextNodeById(3); // look and feel init. should be harmless to set. textNode->TextFlags |= TextFlags.MultiLine; textNode->AlignmentType = AlignmentType.TopLeft; var containsDalamudVersionString = textNode->OriginalTextPointer.Value == textNode->NodeText.StringPtr.Value; if (!this.configuration.ShowTsm || !this.showTsm.Value) { if (containsDalamudVersionString) textNode->SetText(addon->AtkValues[1].String); this.lastLoadedPluginCount = -1; return; } var pm = Service.GetNullable(); var count = pm?.LoadedPluginCount ?? 0; // Avoid rebuilding the string every frame. if (containsDalamudVersionString && count == this.lastLoadedPluginCount) return; this.lastLoadedPluginCount = count; using var rssb = new RentedSeStringBuilder(); rssb.Builder .Append(new ReadOnlySeStringSpan(addon->AtkValues[1].String.Value)) .Append("\n\n") .PushEdgeColorType(701) .PushColorType(539) .Append(SeIconChar.BoxedLetterD.ToIconChar()) .PopColorType() .PopEdgeColorType() .Append($" Dalamud: {Versioning.GetScmVersion()}") .Append($" - {count} {(count != 1 ? "plugins" : "plugin")} loaded"); if (pm?.SafeMode is true) rssb.Builder.PushColorType(17).Append(" [SAFE MODE]").PopColorType(); textNode->SetText(rssb.Builder.GetViewAsSpan()); } private void TitleScreenMenuEntryListChange() => this.privateAtlas.BuildFontsAsync(); }