diff --git a/Dalamud/Interface/Windowing/IWindow.cs b/Dalamud/Interface/Windowing/IWindow.cs new file mode 100644 index 000000000..1376f406f --- /dev/null +++ b/Dalamud/Interface/Windowing/IWindow.cs @@ -0,0 +1,216 @@ +using System.Collections.Generic; +using System.Numerics; + +using Dalamud.Bindings.ImGui; + +namespace Dalamud.Interface.Windowing; + +/// +/// Represents a ImGui window for use with the built-in . +/// +public interface IWindow +{ + /// + /// Gets or sets the namespace of the window. + /// + string? Namespace { get; set; } + + /// + /// Gets or sets the name of the window. + /// If you have multiple windows with the same name, you will need to + /// append an unique ID to it by specifying it after "###" behind the window title. + /// + string WindowName { get; set; } + + /// + /// Gets or sets a value indicating whether the window is focused. + /// + bool IsFocused { get; set; } + + /// + /// Gets or sets a value indicating whether this window is to be closed with a hotkey, like Escape, and keep game addons open in turn if it is closed. + /// + bool RespectCloseHotkey { get; set; } + + /// + /// Gets or sets a value indicating whether this window should not generate sound effects when opening and closing. + /// + bool DisableWindowSounds { get; set; } + + /// + /// Gets or sets a value representing the sound effect id to be played when the window is opened. + /// + uint OnOpenSfxId { get; set; } + + /// + /// Gets or sets a value representing the sound effect id to be played when the window is closed. + /// + uint OnCloseSfxId { get; set; } + + /// + /// Gets or sets a value indicating whether this window should not fade in and out, regardless of the users' + /// preference. + /// + bool DisableFadeInFadeOut { get; set; } + + /// + /// Gets or sets the position of this window. + /// + Vector2? Position { get; set; } + + /// + /// Gets or sets the condition that defines when the position of this window is set. + /// + ImGuiCond PositionCondition { get; set; } + + /// + /// Gets or sets the size of the window. The size provided will be scaled by the global scale. + /// + Vector2? Size { get; set; } + + /// + /// Gets or sets the condition that defines when the size of this window is set. + /// + ImGuiCond SizeCondition { get; set; } + + /// + /// Gets or sets the size constraints of the window. The size constraints provided will be scaled by the global scale. + /// + WindowSizeConstraints? SizeConstraints { get; set; } + + /// + /// Gets or sets a value indicating whether this window is collapsed. + /// + bool? Collapsed { get; set; } + + /// + /// Gets or sets the condition that defines when the collapsed state of this window is set. + /// + ImGuiCond CollapsedCondition { get; set; } + + /// + /// Gets or sets the window flags. + /// + ImGuiWindowFlags Flags { get; set; } + + /// + /// Gets or sets a value indicating whether this ImGui window will be forced to stay inside the main game window. + /// + bool ForceMainWindow { get; set; } + + /// + /// Gets or sets this window's background alpha value. + /// + float? BgAlpha { get; set; } + + /// + /// Gets or sets a value indicating whether this ImGui window should display a close button in the title bar. + /// + bool ShowCloseButton { get; set; } + + /// + /// Gets or sets a value indicating whether this window should offer to be pinned via the window's titlebar context menu. + /// + bool AllowPinning { get; set; } + + /// + /// Gets or sets a value indicating whether this window should offer to be made click-through via the window's titlebar context menu. + /// + bool AllowClickthrough { get; set; } + + /// + /// Gets a value indicating whether this window is pinned. + /// + public bool IsPinned { get; set; } + + /// + /// Gets a value indicating whether this window is click-through. + /// + public bool IsClickthrough { get; set; } + + /// + /// Gets or sets a list of available title bar buttons. + /// + /// If or are set to true, and this features is not + /// disabled globally by the user, an internal title bar button to manage these is added when drawing, but it will + /// not appear in this collection. If you wish to remove this button, set both of these values to false. + /// + List TitleBarButtons { get; set; } + + /// + /// Gets or sets a value indicating whether this window will stay open. + /// + bool IsOpen { get; set; } + + /// + /// Gets or sets a value indicating whether this window will request focus from the window system next frame. + /// + public bool RequestFocus { get; set; } + + /// + /// Toggle window is open state. + /// + void Toggle(); + + /// + /// Bring this window to the front. + /// + void BringToFront(); + + /// + /// Code to always be executed before the open-state of the window is checked. + /// + void PreOpenCheck(); + + /// + /// Additional conditions for the window to be drawn, regardless of its open-state. + /// + /// + /// True if the window should be drawn, false otherwise. + /// + /// + /// Not being drawn due to failing this condition will not change focus or trigger OnClose. + /// This is checked before PreDraw, but after Update. + /// + bool DrawConditions(); + + /// + /// Code to be executed before conditionals are applied and the window is drawn. + /// + void PreDraw(); + + /// + /// Code to be executed after the window is drawn. + /// + void PostDraw(); + + /// + /// Code to be executed every time the window renders. + /// + /// + /// In this method, implement your drawing code. + /// You do NOT need to ImGui.Begin your window. + /// + void Draw(); + + /// + /// Code to be executed when the window is opened. + /// + void OnOpen(); + + /// + /// Code to be executed when the window is closed. + /// + void OnClose(); + + /// + /// Code to be executed when the window is safe to be disposed or removed from the window system. + /// Doing so in may result in animations not playing correctly. + /// + void OnSafeToRemove(); + + /// + /// Code to be executed every frame, even when the window is collapsed. + /// + void Update(); +} diff --git a/Dalamud/Interface/Windowing/IWindowSystem.cs b/Dalamud/Interface/Windowing/IWindowSystem.cs new file mode 100644 index 000000000..500f3d96b --- /dev/null +++ b/Dalamud/Interface/Windowing/IWindowSystem.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; + +namespace Dalamud.Interface.Windowing; + +/// +/// Class running a WindowSystem using implementations to simplify ImGui windowing. +/// +public interface IWindowSystem +{ + /// + /// Gets a read-only list of all s in this . + /// + IReadOnlyList Windows { get; } + + /// + /// Gets a value indicating whether any window in this has focus and is + /// not marked to be excluded from consideration. + /// + bool HasAnyFocus { get; } + + /// + /// Gets or sets the name/ID-space of this . + /// + string? Namespace { get; set; } + + /// + /// Add a window to this . + /// The window system doesn't own your window, it just renders it + /// You need to store a reference to it to use it later. + /// + /// The window to add. + void AddWindow(IWindow window); + + /// + /// Remove a window from this . + /// Will not dispose your window, if it is disposable. + /// + /// The window to remove. + void RemoveWindow(IWindow window); + + /// + /// Remove all windows from this . + /// Will not dispose your windows, if they are disposable. + /// + void RemoveAllWindows(); + + /// + /// Draw all registered windows using ImGui. + /// + void Draw(); +} diff --git a/Dalamud/Interface/Windowing/TitleBarButton.cs b/Dalamud/Interface/Windowing/TitleBarButton.cs new file mode 100644 index 000000000..5fa1eab11 --- /dev/null +++ b/Dalamud/Interface/Windowing/TitleBarButton.cs @@ -0,0 +1,45 @@ +using System.Numerics; + +using Dalamud.Bindings.ImGui; + +namespace Dalamud.Interface.Windowing; + +/// +/// Structure describing a title bar button. +/// +public class TitleBarButton +{ + /// + /// Gets or sets the icon of the button. + /// + public FontAwesomeIcon Icon { get; set; } + + /// + /// Gets or sets a vector by which the position of the icon within the button shall be offset. + /// Automatically scaled by the global font scale for you. + /// + public Vector2 IconOffset { get; set; } + + /// + /// Gets or sets an action that is called when a tooltip shall be drawn. + /// May be null if no tooltip shall be drawn. + /// + public Action? ShowTooltip { get; set; } + + /// + /// Gets or sets an action that is called when the button is clicked. + /// + public Action Click { get; set; } + + /// + /// Gets or sets the priority the button shall be shown in. + /// Lower = closer to ImGui default buttons. + /// + public int Priority { get; set; } + + /// + /// Gets or sets a value indicating whether the button shall be clickable + /// when the respective window is set to clickthrough. + /// + public bool AvailableClickthrough { get; set; } +} diff --git a/Dalamud/Interface/Windowing/Window.cs b/Dalamud/Interface/Windowing/Window.cs index f12e87099..fc2ab959f 100644 --- a/Dalamud/Interface/Windowing/Window.cs +++ b/Dalamud/Interface/Windowing/Window.cs @@ -1,63 +1,13 @@ using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Numerics; -using System.Runtime.InteropServices; -using System.Threading.Tasks; -using CheapLoc; using Dalamud.Bindings.ImGui; -using Dalamud.Game.ClientState.Keys; -using Dalamud.Interface.Colors; -using Dalamud.Interface.Components; -using Dalamud.Interface.Internal; -using Dalamud.Interface.Textures.Internal; -using Dalamud.Interface.Textures.TextureWraps; -using Dalamud.Interface.Utility; -using Dalamud.Interface.Utility.Internal; -using Dalamud.Interface.Utility.Raii; -using Dalamud.Interface.Windowing.Persistence; -using Dalamud.Logging.Internal; -using Dalamud.Utility; - -using FFXIVClientStructs.FFXIV.Client.UI; namespace Dalamud.Interface.Windowing; -/// -/// Base class you can use to implement an ImGui window for use with the built-in . -/// -public abstract class Window +/// +public abstract class Window : IWindow { - private const float FadeInOutTime = 0.072f; - - private static readonly ModuleLog Log = new("WindowSystem"); - - private static bool wasEscPressedLastFrame = false; - - private bool internalLastIsOpen = false; - private bool internalIsOpen = false; - private bool internalIsPinned = false; - private bool internalIsClickthrough = false; - private bool didPushInternalAlpha = false; - private float? internalAlpha = null; - private bool nextFrameBringToFront = false; - - private bool hasInitializedFromPreset = false; - private PresetModel.PresetWindow? presetWindow; - private bool presetDirty = false; - - private bool pushedFadeInAlpha = false; - private float fadeInTimer = 0f; - private float fadeOutTimer = 0f; - private IDrawListTextureWrap? fadeOutTexture = null; - private Vector2 fadeOutSize = Vector2.Zero; - private Vector2 fadeOutOrigin = Vector2.Zero; - - private bool hasError = false; - private Exception? lastError; - /// /// Initializes a new instance of the class. /// @@ -86,936 +36,142 @@ public abstract class Window { } - /// - /// Flags to control window behavior. - /// - [Flags] - internal enum WindowDrawFlags - { - /// - /// Nothing. - /// - None = 0, - - /// - /// Enable window opening/closing sound effects. - /// - UseSoundEffects = 1 << 0, - - /// - /// Hook into the game's focus management. - /// - UseFocusManagement = 1 << 1, - - /// - /// Enable the built-in "additional options" menu on the title bar. - /// - UseAdditionalOptions = 1 << 2, - - /// - /// Do not draw non-critical animations. - /// - IsReducedMotion = 1 << 3, - } - - /// - /// Gets or sets the namespace of the window. - /// + /// public string? Namespace { get; set; } - /// - /// Gets or sets the name of the window. - /// If you have multiple windows with the same name, you will need to - /// append an unique ID to it by specifying it after "###" behind the window title. - /// + /// public string WindowName { get; set; } - /// - /// Gets a value indicating whether the window is focused. - /// - public bool IsFocused { get; private set; } + /// + public bool IsFocused { get; set; } - /// - /// Gets or sets a value indicating whether this window is to be closed with a hotkey, like Escape, and keep game addons open in turn if it is closed. - /// + /// public bool RespectCloseHotkey { get; set; } = true; - /// - /// Gets or sets a value indicating whether this window should not generate sound effects when opening and closing. - /// + /// public bool DisableWindowSounds { get; set; } = false; - /// - /// Gets or sets a value representing the sound effect id to be played when the window is opened. - /// + /// public uint OnOpenSfxId { get; set; } = 23u; - /// - /// Gets or sets a value representing the sound effect id to be played when the window is closed. - /// + /// public uint OnCloseSfxId { get; set; } = 24u; - /// - /// Gets or sets a value indicating whether this window should not fade in and out, regardless of the users' - /// preference. - /// + /// public bool DisableFadeInFadeOut { get; set; } = false; - /// - /// Gets or sets the position of this window. - /// + /// public Vector2? Position { get; set; } - /// - /// Gets or sets the condition that defines when the position of this window is set. - /// + /// public ImGuiCond PositionCondition { get; set; } - /// - /// Gets or sets the size of the window. The size provided will be scaled by the global scale. - /// + /// public Vector2? Size { get; set; } - /// - /// Gets or sets the condition that defines when the size of this window is set. - /// + /// public ImGuiCond SizeCondition { get; set; } - /// - /// Gets or sets the size constraints of the window. The size constraints provided will be scaled by the global scale. - /// + /// public WindowSizeConstraints? SizeConstraints { get; set; } - /// - /// Gets or sets a value indicating whether this window is collapsed. - /// + /// public bool? Collapsed { get; set; } - /// - /// Gets or sets the condition that defines when the collapsed state of this window is set. - /// + /// public ImGuiCond CollapsedCondition { get; set; } - /// - /// Gets or sets the window flags. - /// + /// public ImGuiWindowFlags Flags { get; set; } - /// - /// Gets or sets a value indicating whether this ImGui window will be forced to stay inside the main game window. - /// + /// public bool ForceMainWindow { get; set; } - /// - /// Gets or sets this window's background alpha value. - /// + /// public float? BgAlpha { get; set; } - /// - /// Gets or sets a value indicating whether this ImGui window should display a close button in the title bar. - /// + /// public bool ShowCloseButton { get; set; } = true; - /// - /// Gets or sets a value indicating whether this window should offer to be pinned via the window's titlebar context menu. - /// + /// public bool AllowPinning { get; set; } = true; - /// - /// Gets or sets a value indicating whether this window should offer to be made click-through via the window's titlebar context menu. - /// + /// public bool AllowClickthrough { get; set; } = true; - /// - /// Gets a value indicating whether this window is pinned. - /// - public bool IsPinned => this.internalIsPinned; + /// + public bool IsPinned { get; set; } - /// - /// Gets a value indicating whether this window is click-through. - /// - public bool IsClickthrough => this.internalIsClickthrough; + /// + public bool IsClickthrough { get; set; } - /// - /// Gets or sets a list of available title bar buttons. - /// - /// If or are set to true, and this features is not - /// disabled globally by the user, an internal title bar button to manage these is added when drawing, but it will - /// not appear in this collection. If you wish to remove this button, set both of these values to false. - /// - public List TitleBarButtons { get; set; } = new(); + /// + public List TitleBarButtons { get; set; } = []; - /// - /// Gets or sets a value indicating whether this window will stay open. - /// - public bool IsOpen - { - get => this.internalIsOpen; - set => this.internalIsOpen = value; - } + /// + public bool IsOpen { get; set; } - private bool CanShowCloseButton => this.ShowCloseButton && !this.internalIsClickthrough; + /// + public bool RequestFocus { get; set; } - /// - /// Toggle window is open state. - /// + /// public void Toggle() { this.IsOpen ^= true; } - /// - /// Bring this window to the front. - /// + /// public void BringToFront() { if (!this.IsOpen) + { return; + } - this.nextFrameBringToFront = true; + this.RequestFocus = true; } - /// - /// Code to always be executed before the open-state of the window is checked. - /// + /// public virtual void PreOpenCheck() { } - /// - /// Additional conditions for the window to be drawn, regardless of its open-state. - /// - /// - /// True if the window should be drawn, false otherwise. - /// - /// - /// Not being drawn due to failing this condition will not change focus or trigger OnClose. - /// This is checked before PreDraw, but after Update. - /// + /// public virtual bool DrawConditions() { return true; } - /// - /// Code to be executed before conditionals are applied and the window is drawn. - /// + /// public virtual void PreDraw() { - if (this.internalAlpha.HasValue) - { - ImGui.PushStyleVar(ImGuiStyleVar.Alpha, this.internalAlpha.Value); - this.didPushInternalAlpha = true; - } } - /// - /// Code to be executed after the window is drawn. - /// + /// public virtual void PostDraw() { - if (this.didPushInternalAlpha) - { - ImGui.PopStyleVar(); - this.didPushInternalAlpha = false; - } } - /// - /// Code to be executed every time the window renders. - /// - /// - /// In this method, implement your drawing code. - /// You do NOT need to ImGui.Begin your window. - /// + /// public abstract void Draw(); - /// - /// Code to be executed when the window is opened. - /// + /// public virtual void OnOpen() { } - /// - /// Code to be executed when the window is closed. - /// + /// public virtual void OnClose() { } - /// - /// Code to be executed when the window is safe to be disposed or removed from the window system. - /// Doing so in may result in animations not playing correctly. - /// + /// public virtual void OnSafeToRemove() { } - /// - /// Code to be executed every frame, even when the window is collapsed. - /// + /// public virtual void Update() { } - - /// - /// Draw the window via ImGui. - /// - /// Flags controlling window behavior. - /// Handler for window persistence data. - internal void DrawInternal(WindowDrawFlags internalDrawFlags, WindowSystemPersistence? persistence) - { - this.PreOpenCheck(); - var doFades = !internalDrawFlags.HasFlag(WindowDrawFlags.IsReducedMotion) && !this.DisableFadeInFadeOut; - - if (!this.IsOpen) - { - if (this.internalIsOpen != this.internalLastIsOpen) - { - this.internalLastIsOpen = this.internalIsOpen; - this.OnClose(); - - this.IsFocused = false; - - if (internalDrawFlags.HasFlag(WindowDrawFlags.UseSoundEffects) && !this.DisableWindowSounds) - UIGlobals.PlaySoundEffect(this.OnCloseSfxId); - } - - if (this.fadeOutTexture != null) - { - this.fadeOutTimer -= ImGui.GetIO().DeltaTime; - if (this.fadeOutTimer <= 0f) - { - this.fadeOutTexture.Dispose(); - this.fadeOutTexture = null; - this.OnSafeToRemove(); - } - else - { - this.DrawFakeFadeOutWindow(); - } - } - - this.fadeInTimer = doFades ? 0f : FadeInOutTime; - return; - } - - this.fadeInTimer += ImGui.GetIO().DeltaTime; - if (this.fadeInTimer > FadeInOutTime) - this.fadeInTimer = FadeInOutTime; - - this.Update(); - if (!this.DrawConditions()) - return; - - var hasNamespace = !string.IsNullOrEmpty(this.Namespace); - - if (hasNamespace) - ImGui.PushID(this.Namespace); - - this.PreHandlePreset(persistence); - - if (this.internalLastIsOpen != this.internalIsOpen && this.internalIsOpen) - { - this.internalLastIsOpen = this.internalIsOpen; - this.OnOpen(); - - if (internalDrawFlags.HasFlag(WindowDrawFlags.UseSoundEffects) && !this.DisableWindowSounds) - UIGlobals.PlaySoundEffect(this.OnOpenSfxId); - } - - this.PreDraw(); - this.ApplyConditionals(); - - if (this.ForceMainWindow) - ImGuiHelpers.ForceNextWindowMainViewport(); - - var wasFocused = this.IsFocused; - if (wasFocused) - { - var style = ImGui.GetStyle(); - var focusedHeaderColor = style.Colors[(int)ImGuiCol.TitleBgActive]; - ImGui.PushStyleColor(ImGuiCol.TitleBgCollapsed, focusedHeaderColor); - } - - if (this.nextFrameBringToFront) - { - ImGui.SetNextWindowFocus(); - this.nextFrameBringToFront = false; - } - - var flags = this.Flags; - - if (this.internalIsPinned || this.internalIsClickthrough) - flags |= ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoResize; - - if (this.internalIsClickthrough) - flags |= ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoScrollWithMouse | ImGuiWindowFlags.NoMouseInputs; - - if (this.CanShowCloseButton ? ImGui.Begin(this.WindowName, ref this.internalIsOpen, flags) : ImGui.Begin(this.WindowName, flags)) - { - var context = ImGui.GetCurrentContext(); - if (!context.IsNull) - { - ImGuiP.GetCurrentWindow().InheritNoInputs = this.internalIsClickthrough; - } - - // Not supported yet on non-main viewports - if ((this.internalIsPinned || this.internalIsClickthrough || this.internalAlpha.HasValue) && - ImGui.GetWindowViewport().ID != ImGui.GetMainViewport().ID) - { - this.internalAlpha = null; - this.internalIsPinned = false; - this.internalIsClickthrough = false; - this.presetDirty = true; - } - - if (this.hasError) - { - this.DrawErrorMessage(); - } - else - { - // Draw the actual window contents - try - { - this.Draw(); - } - catch (Exception ex) - { - Log.Error(ex, "Error during Draw(): {WindowName}", this.WindowName); - - this.hasError = true; - this.lastError = ex; - } - } - } - - const string additionsPopupName = "WindowSystemContextActions"; - var flagsApplicableForTitleBarIcons = !flags.HasFlag(ImGuiWindowFlags.NoDecoration) && - !flags.HasFlag(ImGuiWindowFlags.NoTitleBar); - var showAdditions = (this.AllowPinning || this.AllowClickthrough) && - internalDrawFlags.HasFlag(WindowDrawFlags.UseAdditionalOptions) && - flagsApplicableForTitleBarIcons; - var printWindow = false; - if (showAdditions) - { - ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 1f); - - if (ImGui.BeginPopup(additionsPopupName, ImGuiWindowFlags.NoMove)) - { - var isAvailable = ImGuiHelpers.CheckIsWindowOnMainViewport(); - - if (!isAvailable) - ImGui.BeginDisabled(); - - if (this.internalIsClickthrough) - ImGui.BeginDisabled(); - - if (this.AllowPinning) - { - var showAsPinned = this.internalIsPinned || this.internalIsClickthrough; - if (ImGui.Checkbox(Loc.Localize("WindowSystemContextActionPin", "Pin Window"), ref showAsPinned)) - { - this.internalIsPinned = showAsPinned; - this.presetDirty = true; - } - - ImGuiComponents.HelpMarker( - Loc.Localize("WindowSystemContextActionPinHint", "Pinned windows will not move or resize when you click and drag them, nor will they close when escape is pressed.")); - } - - if (this.internalIsClickthrough) - ImGui.EndDisabled(); - - if (this.AllowClickthrough) - { - if (ImGui.Checkbox( - Loc.Localize("WindowSystemContextActionClickthrough", "Make clickthrough"), - ref this.internalIsClickthrough)) - { - this.presetDirty = true; - } - - ImGuiComponents.HelpMarker( - Loc.Localize("WindowSystemContextActionClickthroughHint", "Clickthrough windows will not receive mouse input, move or resize. They are completely inert.")); - } - - var alpha = (this.internalAlpha ?? ImGui.GetStyle().Alpha) * 100f; - if (ImGui.SliderFloat(Loc.Localize("WindowSystemContextActionAlpha", "Opacity"), ref alpha, 20f, - 100f)) - { - this.internalAlpha = Math.Clamp(alpha / 100f, 0.2f, 1f); - this.presetDirty = true; - } - - ImGui.SameLine(); - if (ImGui.Button(Loc.Localize("WindowSystemContextActionReset", "Reset"))) - { - this.internalAlpha = null; - this.presetDirty = true; - } - - if (isAvailable) - { - ImGui.TextColored(ImGuiColors.DalamudGrey, - Loc.Localize("WindowSystemContextActionClickthroughDisclaimer", - "Open this menu again by clicking the three dashes to disable clickthrough.")); - } - else - { - ImGui.TextColored(ImGuiColors.DalamudGrey, - Loc.Localize("WindowSystemContextActionViewportDisclaimer", - "These features are only available if this window is inside the game window.")); - } - - if (!isAvailable) - ImGui.EndDisabled(); - - if (ImGui.Button(Loc.Localize("WindowSystemContextActionPrintWindow", "Print window"))) - printWindow = true; - - ImGui.EndPopup(); - } - - ImGui.PopStyleVar(); - } - - unsafe - { - var window = ImGuiP.GetCurrentWindow(); - - ImRect outRect; - ImGuiP.TitleBarRect(&outRect, window); - - var additionsButton = new TitleBarButton - { - Icon = FontAwesomeIcon.Bars, - IconOffset = new Vector2(2.5f, 1), - Click = _ => - { - this.internalIsClickthrough = false; - this.presetDirty = false; - ImGui.OpenPopup(additionsPopupName); - }, - Priority = int.MinValue, - AvailableClickthrough = true, - }; - - if (flagsApplicableForTitleBarIcons) - { - this.DrawTitleBarButtons(window, flags, outRect, - showAdditions - ? this.TitleBarButtons.Append(additionsButton) - : this.TitleBarButtons); - } - } - - if (wasFocused) - { - ImGui.PopStyleColor(); - } - - this.IsFocused = ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows); - - if (internalDrawFlags.HasFlag(WindowDrawFlags.UseFocusManagement) && !this.internalIsPinned) - { - var escapeDown = Service.Get()[VirtualKey.ESCAPE]; - if (escapeDown && this.IsFocused && !wasEscPressedLastFrame && this.RespectCloseHotkey) - { - this.IsOpen = false; - wasEscPressedLastFrame = true; - } - else if (!escapeDown && wasEscPressedLastFrame) - { - wasEscPressedLastFrame = false; - } - } - - this.fadeOutSize = ImGui.GetWindowSize(); - this.fadeOutOrigin = ImGui.GetWindowPos(); - var isCollapsed = ImGui.IsWindowCollapsed(); - var isDocked = ImGui.IsWindowDocked(); - - ImGui.End(); - - if (this.pushedFadeInAlpha) - { - ImGui.PopStyleVar(); - this.pushedFadeInAlpha = false; - } - - // TODO: No fade-out if the window is collapsed. We could do this if we knew the "FullSize" of the window - // from the internal ImGuiWindow, but I don't want to mess with that here for now. We can do this a lot - // easier with the new bindings. - // TODO: No fade-out if docking is enabled and the window is docked, since this makes them "unsnap". - // Ideally we should get rid of this "fake window" thing and just insert a new drawlist at the correct spot. - if (!this.internalIsOpen && this.fadeOutTexture == null && doFades && !isCollapsed && !isDocked) - { - this.fadeOutTexture = Service.Get().CreateDrawListTexture( - "WindowFadeOutTexture"); - this.fadeOutTexture.ResizeAndDrawWindow(this.WindowName, Vector2.One); - this.fadeOutTimer = FadeInOutTime; - } - - if (printWindow) - { - var tex = Service.Get().CreateDrawListTexture( - Loc.Localize("WindowSystemContextActionPrintWindow", "Print window")); - tex.ResizeAndDrawWindow(this.WindowName, Vector2.One); - _ = Service.Get().ShowTextureSaveMenuAsync( - this.WindowName, - this.WindowName, - Task.FromResult(tex)); - } - - this.PostDraw(); - - this.PostHandlePreset(persistence); - - if (hasNamespace) - ImGui.PopID(); - } - - private unsafe void ApplyConditionals() - { - if (this.Position.HasValue) - { - var pos = this.Position.Value; - - if (this.ForceMainWindow) - pos += ImGuiHelpers.MainViewport.Pos; - - ImGui.SetNextWindowPos(pos, this.PositionCondition); - } - - if (this.Size.HasValue) - { - ImGui.SetNextWindowSize(this.Size.Value * ImGuiHelpers.GlobalScale, this.SizeCondition); - } - - if (this.Collapsed.HasValue) - { - ImGui.SetNextWindowCollapsed(this.Collapsed.Value, this.CollapsedCondition); - } - - if (this.SizeConstraints.HasValue) - { - ImGui.SetNextWindowSizeConstraints(this.SizeConstraints.Value.MinimumSize * ImGuiHelpers.GlobalScale, this.SizeConstraints.Value.MaximumSize * ImGuiHelpers.GlobalScale); - } - - var maxBgAlpha = this.internalAlpha ?? this.BgAlpha; - var fadeInAlpha = this.fadeInTimer / FadeInOutTime; - if (fadeInAlpha < 1f) - { - maxBgAlpha = maxBgAlpha.HasValue ? - Math.Clamp(maxBgAlpha.Value * fadeInAlpha, 0f, 1f) : - (*ImGui.GetStyleColorVec4(ImGuiCol.WindowBg)).W * fadeInAlpha; - ImGui.PushStyleVar(ImGuiStyleVar.Alpha, ImGui.GetStyle().Alpha * fadeInAlpha); - this.pushedFadeInAlpha = true; - } - - if (maxBgAlpha.HasValue) - { - ImGui.SetNextWindowBgAlpha(maxBgAlpha.Value); - } - } - - private void PreHandlePreset(WindowSystemPersistence? persistence) - { - if (persistence == null || this.hasInitializedFromPreset) - return; - - var id = ImGui.GetID(this.WindowName); - this.presetWindow = persistence.GetWindow(id); - - this.hasInitializedFromPreset = true; - - // Fresh preset - don't apply anything - if (this.presetWindow == null) - { - this.presetWindow = new PresetModel.PresetWindow(); - this.presetDirty = true; - return; - } - - this.internalIsPinned = this.presetWindow.IsPinned; - this.internalIsClickthrough = this.presetWindow.IsClickThrough; - this.internalAlpha = this.presetWindow.Alpha; - } - - private void PostHandlePreset(WindowSystemPersistence? persistence) - { - if (persistence == null) - return; - - Debug.Assert(this.presetWindow != null, "this.presetWindow != null"); - - if (this.presetDirty) - { - this.presetWindow.IsPinned = this.internalIsPinned; - this.presetWindow.IsClickThrough = this.internalIsClickthrough; - this.presetWindow.Alpha = this.internalAlpha; - - var id = ImGui.GetID(this.WindowName); - persistence.SaveWindow(id, this.presetWindow!); - this.presetDirty = false; - - Log.Verbose("Saved preset for {WindowName}", this.WindowName); - } - } - - private unsafe void DrawTitleBarButtons(ImGuiWindowPtr window, ImGuiWindowFlags flags, ImRect titleBarRect, IEnumerable buttons) - { - ImGui.PushClipRect(ImGui.GetWindowPos(), ImGui.GetWindowPos() + ImGui.GetWindowSize(), false); - - var style = ImGui.GetStyle(); - var fontSize = ImGui.GetFontSize(); - var drawList = ImGui.GetWindowDrawList(); - - var padR = 0f; - var buttonSize = ImGui.GetFontSize(); - - var numNativeButtons = 0; - if (this.CanShowCloseButton) - numNativeButtons++; - - if (!flags.HasFlag(ImGuiWindowFlags.NoCollapse) && style.WindowMenuButtonPosition == ImGuiDir.Right) - numNativeButtons++; - - // If there are no native buttons, pad from the right to make some space - if (numNativeButtons == 0) - padR += style.FramePadding.X; - - // Pad to the left, to get out of the way of the native buttons - padR += numNativeButtons * (buttonSize + style.ItemInnerSpacing.X); - - Vector2 GetCenter(ImRect rect) => new((rect.Min.X + rect.Max.X) * 0.5f, (rect.Min.Y + rect.Max.Y) * 0.5f); - - var numButtons = 0; - bool DrawButton(TitleBarButton button, Vector2 pos) - { - var id = ImGui.GetID($"###CustomTbButton{numButtons}"); - numButtons++; - - var max = pos + new Vector2(fontSize, fontSize); - ImRect bb = new(pos, max); - var isClipped = !ImGuiP.ItemAdd(bb, id, null, 0); - bool hovered, held; - var pressed = false; - - if (this.internalIsClickthrough) - { - hovered = false; - held = false; - - // ButtonBehavior does not function if the window is clickthrough, so we have to do it ourselves - if (ImGui.IsMouseHoveringRect(pos, max)) - { - hovered = true; - - // We can't use ImGui native functions here, because they don't work with clickthrough - if ((Windows.Win32.PInvoke.GetKeyState((int)VirtualKey.LBUTTON) & 0x8000) != 0) - { - held = true; - pressed = true; - } - } - } - else - { - pressed = ImGuiP.ButtonBehavior(bb, id, &hovered, &held, ImGuiButtonFlags.None); - } - - if (isClipped) - return pressed; - - // Render - var bgCol = ImGui.GetColorU32((held && hovered) ? ImGuiCol.ButtonActive : hovered ? ImGuiCol.ButtonHovered : ImGuiCol.Button); - var textCol = ImGui.GetColorU32(ImGuiCol.Text); - if (hovered || held) - drawList.AddCircleFilled(GetCenter(bb) + new Vector2(0.0f, -0.5f), (fontSize * 0.5f) + 1.0f, bgCol); - - var offset = button.IconOffset * ImGuiHelpers.GlobalScale; - drawList.AddText(InterfaceManager.IconFont, (float)(fontSize * 0.8), new Vector2(bb.Min.X + offset.X, bb.Min.Y + offset.Y), textCol, button.Icon.ToIconString()); - - if (hovered) - button.ShowTooltip?.Invoke(); - - // Switch to moving the window after mouse is moved beyond the initial drag threshold - if (ImGui.IsItemActive() && ImGui.IsMouseDragging(ImGuiMouseButton.Left) && !this.internalIsClickthrough) - ImGuiP.StartMouseMovingWindow(window); - - return pressed; - } - - foreach (var button in buttons.OrderBy(x => x.Priority)) - { - if (this.internalIsClickthrough && !button.AvailableClickthrough) - return; - - Vector2 position = new(titleBarRect.Max.X - padR - buttonSize, titleBarRect.Min.Y + style.FramePadding.Y); - padR += buttonSize + style.ItemInnerSpacing.X; - - if (DrawButton(button, position)) - button.Click?.Invoke(ImGuiMouseButton.Left); - } - - ImGui.PopClipRect(); - } - - private void DrawFakeFadeOutWindow() - { - // Draw a fake window to fade out, so that the fade out texture stays in the right place in the - // focus order - ImGui.SetNextWindowPos(this.fadeOutOrigin); - ImGui.SetNextWindowSize(this.fadeOutSize); - - using var style = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, Vector2.Zero); - style.Push(ImGuiStyleVar.WindowBorderSize, 0); - style.Push(ImGuiStyleVar.FrameBorderSize, 0); - - const ImGuiWindowFlags flags = ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.NoNav | - ImGuiWindowFlags.NoScrollWithMouse | ImGuiWindowFlags.NoMouseInputs | - ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoBackground; - if (ImGui.Begin(this.WindowName, flags)) - { - var dl = ImGui.GetWindowDrawList(); - dl.AddImage( - this.fadeOutTexture!.Handle, - this.fadeOutOrigin, - this.fadeOutOrigin + this.fadeOutSize, - Vector2.Zero, - Vector2.One, - ImGui.ColorConvertFloat4ToU32(new(1f, 1f, 1f, Math.Clamp(this.fadeOutTimer / FadeInOutTime, 0f, 1f)))); - } - - ImGui.End(); - } - - private void DrawErrorMessage() - { - // TODO: Once window systems are services, offer to reload the plugin - ImGui.TextColoredWrapped(ImGuiColors.DalamudRed,Loc.Localize("WindowSystemErrorOccurred", "An error occurred while rendering this window. Please contact the developer for details.")); - - ImGuiHelpers.ScaledDummy(5); - - if (ImGui.Button(Loc.Localize("WindowSystemErrorRecoverButton", "Attempt to retry"))) - { - this.hasError = false; - this.lastError = null; - } - - ImGui.SameLine(); - - if (ImGui.Button(Loc.Localize("WindowSystemErrorClose", "Close Window"))) - { - this.IsOpen = false; - this.hasError = false; - this.lastError = null; - } - - ImGuiHelpers.ScaledDummy(10); - - if (this.lastError != null) - { - using var child = ImRaii.Child("##ErrorDetails", new Vector2(0, 200 * ImGuiHelpers.GlobalScale), true); - using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey)) - { - ImGui.TextWrapped(Loc.Localize("WindowSystemErrorDetails", "Error Details:")); - ImGui.Separator(); - ImGui.TextWrapped(this.lastError.ToString()); - } - - var childWindowSize = ImGui.GetWindowSize(); - var copyText = Loc.Localize("WindowSystemErrorCopy", "Copy"); - var buttonWidth = ImGuiComponents.GetIconButtonWithTextWidth(FontAwesomeIcon.Copy, copyText); - ImGui.SetCursorPos(new Vector2(childWindowSize.X - buttonWidth - ImGui.GetStyle().FramePadding.X, - ImGui.GetStyle().FramePadding.Y)); - if (ImGuiComponents.IconButtonWithText(FontAwesomeIcon.Copy, copyText)) - { - ImGui.SetClipboardText(this.lastError.ToString()); - } - } - } - - /// - /// Structure detailing the size constraints of a window. - /// - public struct WindowSizeConstraints - { - private Vector2 internalMaxSize = new(float.MaxValue); - - /// - /// Initializes a new instance of the struct. - /// - public WindowSizeConstraints() - { - } - - /// - /// Gets or sets the minimum size of the window. - /// - public Vector2 MinimumSize { get; set; } = new(0); - - /// - /// Gets or sets the maximum size of the window. - /// - public Vector2 MaximumSize - { - get => this.GetSafeMaxSize(); - set => this.internalMaxSize = value; - } - - private Vector2 GetSafeMaxSize() - { - var currentMin = this.MinimumSize; - - if (this.internalMaxSize.X < currentMin.X || this.internalMaxSize.Y < currentMin.Y) - return new Vector2(float.MaxValue); - - return this.internalMaxSize; - } - } - - /// - /// Structure describing a title bar button. - /// - public class TitleBarButton - { - /// - /// Gets or sets the icon of the button. - /// - public FontAwesomeIcon Icon { get; set; } - - /// - /// Gets or sets a vector by which the position of the icon within the button shall be offset. - /// Automatically scaled by the global font scale for you. - /// - public Vector2 IconOffset { get; set; } - - /// - /// Gets or sets an action that is called when a tooltip shall be drawn. - /// May be null if no tooltip shall be drawn. - /// - public Action? ShowTooltip { get; set; } - - /// - /// Gets or sets an action that is called when the button is clicked. - /// - public Action Click { get; set; } - - /// - /// Gets or sets the priority the button shall be shown in. - /// Lower = closer to ImGui default buttons. - /// - public int Priority { get; set; } - - /// - /// Gets or sets a value indicating whether the button shall be clickable - /// when the respective window is set to clickthrough. - /// - public bool AvailableClickthrough { get; set; } - } } diff --git a/Dalamud/Interface/Windowing/WindowHost.cs b/Dalamud/Interface/Windowing/WindowHost.cs new file mode 100644 index 000000000..1fcf11379 --- /dev/null +++ b/Dalamud/Interface/Windowing/WindowHost.cs @@ -0,0 +1,721 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; + +using CheapLoc; +using Dalamud.Bindings.ImGui; +using Dalamud.Game.ClientState.Keys; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Components; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Textures.Internal; +using Dalamud.Interface.Textures.TextureWraps; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Internal; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Windowing.Persistence; +using Dalamud.Logging.Internal; + +using FFXIVClientStructs.FFXIV.Client.UI; + +namespace Dalamud.Interface.Windowing; + +/// +/// Base class you can use to implement an ImGui window for use with the built-in . +/// +public class WindowHost +{ + private const float FadeInOutTime = 0.072f; + + private static readonly ModuleLog Log = new("WindowSystem"); + + private static bool wasEscPressedLastFrame = false; + + private bool internalLastIsOpen = false; + private bool didPushInternalAlpha = false; + private float? internalAlpha = null; + + private bool hasInitializedFromPreset = false; + private PresetModel.PresetWindow? presetWindow; + private bool presetDirty = false; + + private bool pushedFadeInAlpha = false; + private float fadeInTimer = 0f; + private float fadeOutTimer = 0f; + private IDrawListTextureWrap? fadeOutTexture = null; + private Vector2 fadeOutSize = Vector2.Zero; + private Vector2 fadeOutOrigin = Vector2.Zero; + + private bool hasError = false; + private Exception? lastError; + + /// + /// Initializes a new instance of the class. + /// + /// A plugin provided window. + internal WindowHost(IWindow window) + { + this.Window = window; + } + + /// + /// Flags to control window behavior. + /// + [Flags] + internal enum WindowDrawFlags + { + /// + /// Nothing. + /// + None = 0, + + /// + /// Enable window opening/closing sound effects. + /// + UseSoundEffects = 1 << 0, + + /// + /// Hook into the game's focus management. + /// + UseFocusManagement = 1 << 1, + + /// + /// Enable the built-in "additional options" menu on the title bar. + /// + UseAdditionalOptions = 1 << 2, + + /// + /// Do not draw non-critical animations. + /// + IsReducedMotion = 1 << 3, + } + + /// + /// Gets or sets the backing window provided by the plugin. + /// + public IWindow Window { get; set; } + + private bool CanShowCloseButton => this.Window.ShowCloseButton && !this.Window.IsClickthrough; + + /// + /// Draw the window via ImGui. + /// + /// Flags controlling window behavior. + /// Handler for window persistence data. + internal void DrawInternal(WindowDrawFlags internalDrawFlags, WindowSystemPersistence? persistence) + { + this.Window.PreOpenCheck(); + var doFades = !internalDrawFlags.HasFlag(WindowDrawFlags.IsReducedMotion) && !this.Window.DisableFadeInFadeOut; + + if (!this.Window.IsOpen) + { + if (this.Window.IsOpen != this.internalLastIsOpen) + { + this.internalLastIsOpen = this.Window.IsOpen; + this.Window.OnClose(); + + this.Window.IsFocused = false; + + if (internalDrawFlags.HasFlag(WindowDrawFlags.UseSoundEffects) && !this.Window.DisableWindowSounds) + UIGlobals.PlaySoundEffect(this.Window.OnCloseSfxId); + } + + if (this.fadeOutTexture != null) + { + this.fadeOutTimer -= ImGui.GetIO().DeltaTime; + if (this.fadeOutTimer <= 0f) + { + this.fadeOutTexture.Dispose(); + this.fadeOutTexture = null; + this.Window.OnSafeToRemove(); + } + else + { + this.DrawFakeFadeOutWindow(); + } + } + + this.fadeInTimer = doFades ? 0f : FadeInOutTime; + return; + } + + this.fadeInTimer += ImGui.GetIO().DeltaTime; + if (this.fadeInTimer > FadeInOutTime) + this.fadeInTimer = FadeInOutTime; + + this.Window.Update(); + if (!this.Window.DrawConditions()) + return; + + var hasNamespace = !string.IsNullOrEmpty(this.Window.Namespace); + + if (hasNamespace) + ImGui.PushID(this.Window.Namespace); + + this.PreHandlePreset(persistence); + + if (this.internalLastIsOpen != this.Window.IsOpen && this.Window.IsOpen) + { + this.internalLastIsOpen = this.Window.IsOpen; + this.Window.OnOpen(); + + if (internalDrawFlags.HasFlag(WindowDrawFlags.UseSoundEffects) && !this.Window.DisableWindowSounds) + UIGlobals.PlaySoundEffect(this.Window.OnOpenSfxId); + } + + // TODO: We may have to allow for windows to configure if they should fade + if (this.internalAlpha.HasValue) + { + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, this.internalAlpha.Value); + this.didPushInternalAlpha = true; + } + + this.Window.PreDraw(); + this.ApplyConditionals(); + + if (this.Window.ForceMainWindow) + ImGuiHelpers.ForceNextWindowMainViewport(); + + var wasFocused = this.Window.IsFocused; + if (wasFocused) + { + var style = ImGui.GetStyle(); + var focusedHeaderColor = style.Colors[(int)ImGuiCol.TitleBgActive]; + ImGui.PushStyleColor(ImGuiCol.TitleBgCollapsed, focusedHeaderColor); + } + + if (this.Window.RequestFocus) + { + ImGui.SetNextWindowFocus(); + this.Window.RequestFocus = false; + } + + var flags = this.Window.Flags; + + if (this.Window.IsPinned || this.Window.IsClickthrough) + flags |= ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoResize; + + if (this.Window.IsClickthrough) + flags |= ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoScrollWithMouse | ImGuiWindowFlags.NoMouseInputs; + + var isWindowOpen = this.Window.IsOpen; + + if (this.CanShowCloseButton ? ImGui.Begin(this.Window.WindowName, ref isWindowOpen, flags) : ImGui.Begin(this.Window.WindowName, flags)) + { + if (this.Window.IsOpen != isWindowOpen) + { + this.Window.IsOpen = isWindowOpen; + } + + var context = ImGui.GetCurrentContext(); + if (!context.IsNull) + { + ImGuiP.GetCurrentWindow().InheritNoInputs = this.Window.IsClickthrough; + } + + // Not supported yet on non-main viewports + if ((this.Window.IsPinned || this.Window.IsClickthrough || this.internalAlpha.HasValue) && + ImGui.GetWindowViewport().ID != ImGui.GetMainViewport().ID) + { + this.internalAlpha = null; + this.Window.IsPinned = false; + this.Window.IsClickthrough = false; + this.presetDirty = true; + } + + // Draw the actual window contents + if (this.hasError) + { + this.DrawErrorMessage(); + } + else + { + // Draw the actual window contents + try + { + this.Window.Draw(); + } + catch (Exception ex) + { + Log.Error(ex, "Error during Draw(): {WindowName}", this.Window.WindowName); + + this.hasError = true; + this.lastError = ex; + } + } + } + + const string additionsPopupName = "WindowSystemContextActions"; + var flagsApplicableForTitleBarIcons = !flags.HasFlag(ImGuiWindowFlags.NoDecoration) && + !flags.HasFlag(ImGuiWindowFlags.NoTitleBar); + var showAdditions = (this.Window.AllowPinning || this.Window.AllowClickthrough) && + internalDrawFlags.HasFlag(WindowDrawFlags.UseAdditionalOptions) && + flagsApplicableForTitleBarIcons; + var printWindow = false; + if (showAdditions) + { + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 1f); + + if (ImGui.BeginPopup(additionsPopupName, ImGuiWindowFlags.NoMove)) + { + var isAvailable = ImGuiHelpers.CheckIsWindowOnMainViewport(); + + if (!isAvailable) + ImGui.BeginDisabled(); + + if (this.Window.IsClickthrough) + ImGui.BeginDisabled(); + + if (this.Window.AllowPinning) + { + var showAsPinned = this.Window.IsPinned || this.Window.IsClickthrough; + if (ImGui.Checkbox(Loc.Localize("WindowSystemContextActionPin", "Pin Window"), ref showAsPinned)) + { + this.Window.IsPinned = showAsPinned; + this.presetDirty = true; + } + + ImGuiComponents.HelpMarker( + Loc.Localize("WindowSystemContextActionPinHint", "Pinned windows will not move or resize when you click and drag them, nor will they close when escape is pressed.")); + } + + if (this.Window.IsClickthrough) + ImGui.EndDisabled(); + + if (this.Window.AllowClickthrough) + { + var isClickthrough = this.Window.IsClickthrough; + if (ImGui.Checkbox( + Loc.Localize("WindowSystemContextActionClickthrough", "Make clickthrough"), + ref isClickthrough)) + { + this.Window.IsClickthrough = isClickthrough; + this.presetDirty = true; + } + + ImGuiComponents.HelpMarker( + Loc.Localize("WindowSystemContextActionClickthroughHint", "Clickthrough windows will not receive mouse input, move or resize. They are completely inert.")); + } + + var alpha = (this.internalAlpha ?? ImGui.GetStyle().Alpha) * 100f; + if (ImGui.SliderFloat(Loc.Localize("WindowSystemContextActionAlpha", "Opacity"), ref alpha, 20f, + 100f)) + { + this.internalAlpha = Math.Clamp(alpha / 100f, 0.2f, 1f); + this.presetDirty = true; + } + + ImGui.SameLine(); + if (ImGui.Button(Loc.Localize("WindowSystemContextActionReset", "Reset"))) + { + this.internalAlpha = null; + this.presetDirty = true; + } + + if (isAvailable) + { + ImGui.TextColored(ImGuiColors.DalamudGrey, + Loc.Localize("WindowSystemContextActionClickthroughDisclaimer", + "Open this menu again by clicking the three dashes to disable clickthrough.")); + } + else + { + ImGui.TextColored(ImGuiColors.DalamudGrey, + Loc.Localize("WindowSystemContextActionViewportDisclaimer", + "These features are only available if this window is inside the game window.")); + } + + if (!isAvailable) + ImGui.EndDisabled(); + + if (ImGui.Button(Loc.Localize("WindowSystemContextActionPrintWindow", "Print window"))) + printWindow = true; + + ImGui.EndPopup(); + } + + ImGui.PopStyleVar(); + } + + unsafe + { + var window = ImGuiP.GetCurrentWindow(); + + ImRect outRect; + ImGuiP.TitleBarRect(&outRect, window); + + var additionsButton = new TitleBarButton + { + Icon = FontAwesomeIcon.Bars, + IconOffset = new Vector2(2.5f, 1), + Click = _ => + { + this.Window.IsClickthrough = false; + this.presetDirty = false; + ImGui.OpenPopup(additionsPopupName); + }, + Priority = int.MinValue, + AvailableClickthrough = true, + }; + + if (flagsApplicableForTitleBarIcons) + { + this.DrawTitleBarButtons(window, flags, outRect, + showAdditions + ? this.Window.TitleBarButtons.Append(additionsButton) + : this.Window.TitleBarButtons); + } + } + + if (wasFocused) + { + ImGui.PopStyleColor(); + } + + this.Window.IsFocused = ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows); + + if (internalDrawFlags.HasFlag(WindowDrawFlags.UseFocusManagement) && !this.Window.IsPinned) + { + var escapeDown = Service.Get()[VirtualKey.ESCAPE]; + if (escapeDown && this.Window.IsFocused && !wasEscPressedLastFrame && this.Window.RespectCloseHotkey) + { + this.Window.IsOpen = false; + wasEscPressedLastFrame = true; + } + else if (!escapeDown && wasEscPressedLastFrame) + { + wasEscPressedLastFrame = false; + } + } + + this.fadeOutSize = ImGui.GetWindowSize(); + this.fadeOutOrigin = ImGui.GetWindowPos(); + var isCollapsed = ImGui.IsWindowCollapsed(); + var isDocked = ImGui.IsWindowDocked(); + + ImGui.End(); + + if (this.pushedFadeInAlpha) + { + ImGui.PopStyleVar(); + this.pushedFadeInAlpha = false; + } + + // TODO: No fade-out if the window is collapsed. We could do this if we knew the "FullSize" of the window + // from the internal ImGuiWindow, but I don't want to mess with that here for now. We can do this a lot + // easier with the new bindings. + // TODO: No fade-out if docking is enabled and the window is docked, since this makes them "unsnap". + // Ideally we should get rid of this "fake window" thing and just insert a new drawlist at the correct spot. + if (!this.Window.IsOpen && this.fadeOutTexture == null && doFades && !isCollapsed && !isDocked) + { + this.fadeOutTexture = Service.Get().CreateDrawListTexture( + "WindowFadeOutTexture"); + Log.Verbose("Attempting to fade out {WindowName}", this.Window.WindowName); + this.fadeOutTexture.ResizeAndDrawWindow(this.Window.WindowName, Vector2.One); + this.fadeOutTimer = FadeInOutTime; + } + + if (printWindow) + { + var tex = Service.Get().CreateDrawListTexture( + Loc.Localize("WindowSystemContextActionPrintWindow", "Print window")); + tex.ResizeAndDrawWindow(this.Window.WindowName, Vector2.One); + _ = Service.Get().ShowTextureSaveMenuAsync( + this.Window.WindowName, + this.Window.WindowName, + Task.FromResult(tex)); + } + + if (this.didPushInternalAlpha) + { + ImGui.PopStyleVar(); + this.didPushInternalAlpha = false; + } + + this.Window.PostDraw(); + + this.PostHandlePreset(persistence); + + if (hasNamespace) + ImGui.PopID(); + } + + private unsafe void ApplyConditionals() + { + if (this.Window.Position.HasValue) + { + var pos = this.Window.Position.Value; + + if (this.Window.ForceMainWindow) + pos += ImGuiHelpers.MainViewport.Pos; + + ImGui.SetNextWindowPos(pos, this.Window.PositionCondition); + } + + if (this.Window.Size.HasValue) + { + ImGui.SetNextWindowSize(this.Window.Size.Value * ImGuiHelpers.GlobalScale, this.Window.SizeCondition); + } + + if (this.Window.Collapsed.HasValue) + { + ImGui.SetNextWindowCollapsed(this.Window.Collapsed.Value, this.Window.CollapsedCondition); + } + + if (this.Window.SizeConstraints.HasValue) + { + var (min, max) = this.GetValidatedConstraints(this.Window.SizeConstraints.Value); + ImGui.SetNextWindowSizeConstraints( + min * ImGuiHelpers.GlobalScale, + max * ImGuiHelpers.GlobalScale); + } + + var maxBgAlpha = this.internalAlpha ?? this.Window.BgAlpha; + var fadeInAlpha = this.fadeInTimer / FadeInOutTime; + if (fadeInAlpha < 1f) + { + maxBgAlpha = maxBgAlpha.HasValue ? + Math.Clamp(maxBgAlpha.Value * fadeInAlpha, 0f, 1f) : + (*ImGui.GetStyleColorVec4(ImGuiCol.WindowBg)).W * fadeInAlpha; + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, ImGui.GetStyle().Alpha * fadeInAlpha); + this.pushedFadeInAlpha = true; + } + + if (maxBgAlpha.HasValue) + { + ImGui.SetNextWindowBgAlpha(maxBgAlpha.Value); + } + } + + private (Vector2 Min, Vector2 Max) GetValidatedConstraints(WindowSizeConstraints constraints) + { + var min = constraints.MinimumSize; + var max = constraints.MaximumSize; + + // If max < min, treat as "no constraint" (float.MaxValue) + if (max.X < min.X || max.Y < min.Y) + max = new Vector2(float.MaxValue); + + return (min, max); + } + + private void PreHandlePreset(WindowSystemPersistence? persistence) + { + if (persistence == null || this.hasInitializedFromPreset) + return; + + var id = ImGui.GetID(this.Window.WindowName); + this.presetWindow = persistence.GetWindow(id); + + this.hasInitializedFromPreset = true; + + // Fresh preset - don't apply anything + if (this.presetWindow == null) + { + this.presetWindow = new PresetModel.PresetWindow(); + this.presetDirty = true; + return; + } + + this.Window.IsPinned = this.presetWindow.IsPinned; + this.Window.IsClickthrough = this.presetWindow.IsClickThrough; + this.internalAlpha = this.presetWindow.Alpha; + } + + private void PostHandlePreset(WindowSystemPersistence? persistence) + { + if (persistence == null) + return; + + Debug.Assert(this.presetWindow != null, "this.presetWindow != null"); + + if (this.presetDirty) + { + this.presetWindow.IsPinned = this.Window.IsPinned; + this.presetWindow.IsClickThrough = this.Window.IsClickthrough; + this.presetWindow.Alpha = this.internalAlpha; + + var id = ImGui.GetID(this.Window.WindowName); + persistence.SaveWindow(id, this.presetWindow!); + this.presetDirty = false; + + Log.Verbose("Saved preset for {WindowName}", this.Window.WindowName); + } + } + + private unsafe void DrawTitleBarButtons(ImGuiWindowPtr window, ImGuiWindowFlags flags, ImRect titleBarRect, IEnumerable buttons) + { + ImGui.PushClipRect(ImGui.GetWindowPos(), ImGui.GetWindowPos() + ImGui.GetWindowSize(), false); + + var style = ImGui.GetStyle(); + var fontSize = ImGui.GetFontSize(); + var drawList = ImGui.GetWindowDrawList(); + + var padR = 0f; + var buttonSize = ImGui.GetFontSize(); + + var numNativeButtons = 0; + if (this.CanShowCloseButton) + numNativeButtons++; + + if (!flags.HasFlag(ImGuiWindowFlags.NoCollapse) && style.WindowMenuButtonPosition == ImGuiDir.Right) + numNativeButtons++; + + // If there are no native buttons, pad from the right to make some space + if (numNativeButtons == 0) + padR += style.FramePadding.X; + + // Pad to the left, to get out of the way of the native buttons + padR += numNativeButtons * (buttonSize + style.ItemInnerSpacing.X); + + Vector2 GetCenter(ImRect rect) => new((rect.Min.X + rect.Max.X) * 0.5f, (rect.Min.Y + rect.Max.Y) * 0.5f); + + var numButtons = 0; + bool DrawButton(TitleBarButton button, Vector2 pos) + { + var id = ImGui.GetID($"###CustomTbButton{numButtons}"); + numButtons++; + + var max = pos + new Vector2(fontSize, fontSize); + ImRect bb = new(pos, max); + var isClipped = !ImGuiP.ItemAdd(bb, id, null, 0); + bool hovered, held; + var pressed = false; + + if (this.Window.IsClickthrough) + { + hovered = false; + held = false; + + // ButtonBehavior does not function if the window is clickthrough, so we have to do it ourselves + if (ImGui.IsMouseHoveringRect(pos, max)) + { + hovered = true; + + // We can't use ImGui native functions here, because they don't work with clickthrough + if ((global::Windows.Win32.PInvoke.GetKeyState((int)VirtualKey.LBUTTON) & 0x8000) != 0) + { + held = true; + pressed = true; + } + } + } + else + { + pressed = ImGuiP.ButtonBehavior(bb, id, &hovered, &held, ImGuiButtonFlags.None); + } + + if (isClipped) + return pressed; + + // Render + var bgCol = ImGui.GetColorU32((held && hovered) ? ImGuiCol.ButtonActive : hovered ? ImGuiCol.ButtonHovered : ImGuiCol.Button); + var textCol = ImGui.GetColorU32(ImGuiCol.Text); + if (hovered || held) + drawList.AddCircleFilled(GetCenter(bb) + new Vector2(0.0f, -0.5f), (fontSize * 0.5f) + 1.0f, bgCol); + + var offset = button.IconOffset * ImGuiHelpers.GlobalScale; + drawList.AddText(InterfaceManager.IconFont, (float)(fontSize * 0.8), new Vector2(bb.Min.X + offset.X, bb.Min.Y + offset.Y), textCol, button.Icon.ToIconString()); + + if (hovered) + button.ShowTooltip?.Invoke(); + + // Switch to moving the window after mouse is moved beyond the initial drag threshold + if (ImGui.IsItemActive() && ImGui.IsMouseDragging(ImGuiMouseButton.Left) && !this.Window.IsClickthrough) + ImGuiP.StartMouseMovingWindow(window); + + return pressed; + } + + foreach (var button in buttons.OrderBy(x => x.Priority)) + { + if (this.Window.IsClickthrough && !button.AvailableClickthrough) + return; + + Vector2 position = new(titleBarRect.Max.X - padR - buttonSize, titleBarRect.Min.Y + style.FramePadding.Y); + padR += buttonSize + style.ItemInnerSpacing.X; + + if (DrawButton(button, position)) + button.Click?.Invoke(ImGuiMouseButton.Left); + } + + ImGui.PopClipRect(); + } + + private void DrawFakeFadeOutWindow() + { + // Draw a fake window to fade out, so that the fade out texture stays in the right place in the + // focus order + ImGui.SetNextWindowPos(this.fadeOutOrigin); + ImGui.SetNextWindowSize(this.fadeOutSize); + + using var style = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, Vector2.Zero); + style.Push(ImGuiStyleVar.WindowBorderSize, 0); + style.Push(ImGuiStyleVar.FrameBorderSize, 0); + + const ImGuiWindowFlags flags = ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.NoNav | + ImGuiWindowFlags.NoScrollWithMouse | ImGuiWindowFlags.NoMouseInputs | + ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoBackground; + if (ImGui.Begin(this.Window.WindowName, flags)) + { + var dl = ImGui.GetWindowDrawList(); + dl.AddImage( + this.fadeOutTexture!.Handle, + this.fadeOutOrigin, + this.fadeOutOrigin + this.fadeOutSize, + Vector2.Zero, + Vector2.One, + ImGui.ColorConvertFloat4ToU32(new(1f, 1f, 1f, Math.Clamp(this.fadeOutTimer / FadeInOutTime, 0f, 1f)))); + } + + ImGui.End(); + } + + private void DrawErrorMessage() + { + // TODO: Once window systems are services, offer to reload the plugin + ImGui.TextColoredWrapped(ImGuiColors.DalamudRed,Loc.Localize("WindowSystemErrorOccurred", "An error occurred while rendering this window. Please contact the developer for details.")); + + ImGuiHelpers.ScaledDummy(5); + + if (ImGui.Button(Loc.Localize("WindowSystemErrorRecoverButton", "Attempt to retry"))) + { + this.hasError = false; + this.lastError = null; + } + + ImGui.SameLine(); + + if (ImGui.Button(Loc.Localize("WindowSystemErrorClose", "Close Window"))) + { + this.Window.IsOpen = false; + this.hasError = false; + this.lastError = null; + } + + ImGuiHelpers.ScaledDummy(10); + + if (this.lastError != null) + { + using var child = ImRaii.Child("##ErrorDetails", new Vector2(0, 200 * ImGuiHelpers.GlobalScale), true); + using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey)) + { + ImGui.TextWrapped(Loc.Localize("WindowSystemErrorDetails", "Error Details:")); + ImGui.Separator(); + ImGui.TextWrapped(this.lastError.ToString()); + } + + var childWindowSize = ImGui.GetWindowSize(); + var copyText = Loc.Localize("WindowSystemErrorCopy", "Copy"); + var buttonWidth = ImGuiComponents.GetIconButtonWithTextWidth(FontAwesomeIcon.Copy, copyText); + ImGui.SetCursorPos(new Vector2(childWindowSize.X - buttonWidth - ImGui.GetStyle().FramePadding.X, + ImGui.GetStyle().FramePadding.Y)); + if (ImGuiComponents.IconButtonWithText(FontAwesomeIcon.Copy, copyText)) + { + ImGui.SetClipboardText(this.lastError.ToString()); + } + } + } +} diff --git a/Dalamud/Interface/Windowing/WindowSizeConstraints.cs b/Dalamud/Interface/Windowing/WindowSizeConstraints.cs new file mode 100644 index 000000000..2389ed95e --- /dev/null +++ b/Dalamud/Interface/Windowing/WindowSizeConstraints.cs @@ -0,0 +1,26 @@ +using System.Numerics; + +namespace Dalamud.Interface.Windowing; + +/// +/// Structure detailing the size constraints of a window. +/// +public struct WindowSizeConstraints +{ + /// + /// Initializes a new instance of the struct. + /// + public WindowSizeConstraints() + { + } + + /// + /// Gets or sets the minimum size of the window. + /// + public Vector2 MinimumSize { get; set; } + + /// + /// Gets or sets the maximum size of the window. + /// + public Vector2 MaximumSize { get; set; } +} diff --git a/Dalamud/Interface/Windowing/WindowSystem.cs b/Dalamud/Interface/Windowing/WindowSystem.cs index d6e9649bb..9676a9939 100644 --- a/Dalamud/Interface/Windowing/WindowSystem.cs +++ b/Dalamud/Interface/Windowing/WindowSystem.cs @@ -8,14 +8,12 @@ using Serilog; namespace Dalamud.Interface.Windowing; -/// -/// Class running a WindowSystem using implementations to simplify ImGui windowing. -/// -public class WindowSystem +/// +public class WindowSystem : IWindowSystem { private static DateTimeOffset lastAnyFocus; - private readonly List windows = new(); + private readonly List windows = new(); private string lastFocusedWindowName = string.Empty; @@ -29,7 +27,7 @@ public class WindowSystem } /// - /// Gets a value indicating whether any contains any + /// Gets a value indicating whether any contains any /// that has focus and is not marked to be excluded from consideration. /// public static bool HasAnyWindowSystemFocus { get; internal set; } = false; @@ -44,20 +42,13 @@ public class WindowSystem /// public static TimeSpan TimeSinceLastAnyFocus => DateTimeOffset.Now - lastAnyFocus; - /// - /// Gets a read-only list of all s in this . - /// - public IReadOnlyList Windows => this.windows; + /// + public IReadOnlyList Windows => this.windows.Select(c => c.Window).ToList(); - /// - /// Gets a value indicating whether any window in this has focus and is - /// not marked to be excluded from consideration. - /// + /// public bool HasAnyFocus { get; private set; } - /// - /// Gets or sets the name/ID-space of this . - /// + /// public string? Namespace { get; set; } /// @@ -66,42 +57,28 @@ public class WindowSystem /// internal static bool ShouldInhibitAtkCloseEvents { get; set; } - /// - /// Add a window to this . - /// The window system doesn't own your window, it just renders it - /// You need to store a reference to it to use it later. - /// - /// The window to add. - public void AddWindow(Window window) + /// + public void AddWindow(IWindow window) { - if (this.windows.Any(x => x.WindowName == window.WindowName)) + if (this.windows.Any(x => x.Window.WindowName == window.WindowName)) throw new ArgumentException("A window with this name/ID already exists."); - this.windows.Add(window); + this.windows.Add(new WindowHost(window)); } - /// - /// Remove a window from this . - /// Will not dispose your window, if it is disposable. - /// - /// The window to remove. - public void RemoveWindow(Window window) + /// + public void RemoveWindow(IWindow window) { - if (!this.windows.Contains(window)) + if (this.windows.All(c => c.Window != window)) throw new ArgumentException("This window is not registered on this WindowSystem."); - this.windows.Remove(window); + this.windows.RemoveAll(c => c.Window == window); } - /// - /// Remove all windows from this . - /// Will not dispose your windows, if they are disposable. - /// + /// public void RemoveAllWindows() => this.windows.Clear(); - /// - /// Draw all registered windows using ImGui. - /// + /// public void Draw() { var hasNamespace = !string.IsNullOrEmpty(this.Namespace); @@ -113,19 +90,19 @@ public class WindowSystem var config = Service.GetNullable(); var persistence = Service.GetNullable(); - var flags = Window.WindowDrawFlags.None; + var flags = WindowHost.WindowDrawFlags.None; if (config?.EnablePluginUISoundEffects ?? false) - flags |= Window.WindowDrawFlags.UseSoundEffects; + flags |= WindowHost.WindowDrawFlags.UseSoundEffects; if (config?.EnablePluginUiAdditionalOptions ?? false) - flags |= Window.WindowDrawFlags.UseAdditionalOptions; + flags |= WindowHost.WindowDrawFlags.UseAdditionalOptions; if (config?.IsFocusManagementEnabled ?? false) - flags |= Window.WindowDrawFlags.UseFocusManagement; + flags |= WindowHost.WindowDrawFlags.UseFocusManagement; if (config?.ReduceMotions ?? false) - flags |= Window.WindowDrawFlags.IsReducedMotion; + flags |= WindowHost.WindowDrawFlags.IsReducedMotion; // Shallow clone the list of windows so that we can edit it without modifying it while the loop is iterating foreach (var window in this.windows.ToArray()) @@ -136,15 +113,15 @@ public class WindowSystem window.DrawInternal(flags, persistence); } - var focusedWindow = this.windows.FirstOrDefault(window => window.IsFocused); + var focusedWindow = this.windows.FirstOrDefault(window => window.Window.IsFocused); this.HasAnyFocus = focusedWindow != default; if (this.HasAnyFocus) { - if (this.lastFocusedWindowName != focusedWindow.WindowName) + if (this.lastFocusedWindowName != focusedWindow.Window.WindowName) { - Log.Verbose($"WindowSystem \"{this.Namespace}\" Window \"{focusedWindow.WindowName}\" has focus now"); - this.lastFocusedWindowName = focusedWindow.WindowName; + Log.Verbose($"WindowSystem \"{this.Namespace}\" Window \"{focusedWindow.Window.WindowName}\" has focus now"); + this.lastFocusedWindowName = focusedWindow.Window.WindowName; } HasAnyWindowSystemFocus = true; @@ -161,10 +138,10 @@ public class WindowSystem } } - ShouldInhibitAtkCloseEvents |= this.windows.Any(w => w.IsFocused && - w.RespectCloseHotkey && - !w.IsPinned && - !w.IsClickthrough); + ShouldInhibitAtkCloseEvents |= this.windows.Any(w => w.Window.IsFocused && + w.Window.RespectCloseHotkey && + !w.Window.IsPinned && + !w.Window.IsClickthrough); if (hasNamespace) ImGui.PopID();