diff --git a/Dalamud/Interface/ImGuiNotification/IActiveNotification.cs b/Dalamud/Interface/ImGuiNotification/IActiveNotification.cs new file mode 100644 index 000000000..3e8aef196 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/IActiveNotification.cs @@ -0,0 +1,109 @@ +using System.Threading; + +namespace Dalamud.Interface.ImGuiNotification; + +/// +/// Represents an active notification. +/// +public interface IActiveNotification : INotification +{ + /// + /// The counter for field. + /// + private static long idCounter; + + /// + /// Invoked upon dismissing the notification. + /// + /// + /// The event callback will not be called, if a user interacts with the notification after the plugin is unloaded. + /// + event NotificationDismissedDelegate Dismiss; + + /// + /// Invoked upon clicking on the notification. + /// + /// + /// This event is not applicable when is set to false. + /// Note that this function may be called even after has been invoked. + /// Refer to . + /// + event Action Click; + + /// + /// Invoked when the mouse enters the notification window. + /// + /// + /// This event is applicable regardless of . + /// Note that this function may be called even after has been invoked. + /// Refer to . + /// + event Action MouseEnter; + + /// + /// Invoked when the mouse leaves the notification window. + /// + /// + /// This event is applicable regardless of . + /// Note that this function may be called even after has been invoked. + /// Refer to . + /// + event Action MouseLeave; + + /// + /// Invoked upon drawing the action bar of the notification. + /// + /// + /// This event is applicable regardless of . + /// Note that this function may be called even after has been invoked. + /// Refer to . + /// + event Action DrawActions; + + /// + /// Gets the ID of this notification. + /// + long Id { get; } + + /// + /// Gets a value indicating whether the mouse cursor is on the notification window. + /// + bool IsMouseHovered { get; } + + /// + /// Gets a value indicating whether the notification has been dismissed. + /// This includes when the hide animation is being played. + /// + bool IsDismissed { get; } + + /// + /// Clones this notification as a . + /// + /// A new instance of . + Notification CloneNotification(); + + /// + /// Dismisses this notification. + /// + void DismissNow(); + + /// + /// Updates the notification data. + /// + /// + /// Call to update the icon using the new . + /// + /// The new notification entry. + void Update(INotification newNotification); + + /// + /// Loads the icon again using . + /// + void UpdateIcon(); + + /// + /// Generates a new value to use for . + /// + /// The new value. + internal static long CreateNewId() => Interlocked.Increment(ref idCounter); +} diff --git a/Dalamud/Interface/ImGuiNotification/INotification.cs b/Dalamud/Interface/ImGuiNotification/INotification.cs new file mode 100644 index 000000000..f5f66725c --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/INotification.cs @@ -0,0 +1,75 @@ +using System.Threading.Tasks; + +using Dalamud.Game.Text; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Internal.Notifications; + +namespace Dalamud.Interface.ImGuiNotification; + +/// +/// Represents a notification. +/// +public interface INotification +{ + /// + /// Gets the content body of the notification. + /// + string Content { get; } + + /// + /// Gets the title of the notification. + /// + string? Title { get; } + + /// + /// Gets the type of the notification. + /// + NotificationType Type { get; } + + /// + /// Gets the icon creator function for the notification.
+ /// Currently , , and types + /// are accepted. + ///
+ /// + /// The icon created by the task returned will be owned by Dalamud, + /// i.e. it will be d automatically as needed.
+ /// If null is supplied for this property or of the returned task + /// is false, then the corresponding icon with will be used.
+ /// Use if you have an instance of that you + /// can transfer ownership to Dalamud and is available for use right away. + ///
+ Func>? IconCreator { get; } + + /// + /// Gets the expiry. + /// + DateTime Expiry { get; } + + /// + /// Gets a value indicating whether this notification may be interacted. + /// + /// + /// Set this value to true if you want to respond to user inputs from + /// . + /// Note that the close buttons for notifications are always provided and interactible. + /// + bool Interactible { get; } + + /// + /// Gets a value indicating whether clicking on the notification window counts as dismissing the notification. + /// + /// + /// This property has no effect if is false. + /// + bool ClickIsDismiss { get; } + + /// + /// Gets the new duration for this notification if mouse cursor is on the notification window. + /// If set to or less, then this feature is turned off. + /// + /// + /// This property is applicable regardless of . + /// + TimeSpan HoverExtendDuration { get; } +} diff --git a/Dalamud/Interface/ImGuiNotification/Notification.cs b/Dalamud/Interface/ImGuiNotification/Notification.cs new file mode 100644 index 000000000..fb2caa4f6 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/Notification.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; + +using Dalamud.Interface.Internal.Notifications; + +namespace Dalamud.Interface.ImGuiNotification; + +/// +/// Represents a blueprint for a notification. +/// +public sealed record Notification : INotification +{ + /// + public string Content { get; set; } = string.Empty; + + /// + public string? Title { get; set; } + + /// + public NotificationType Type { get; set; } = NotificationType.None; + + /// + public Func>? IconCreator { get; set; } + + /// + public DateTime Expiry { get; set; } = DateTime.Now + NotificationConstants.DefaultDisplayDuration; + + /// + public bool Interactible { get; set; } + + /// + public bool ClickIsDismiss { get; set; } = true; + + /// + public TimeSpan HoverExtendDuration { get; set; } = NotificationConstants.DefaultHoverExtendDuration; +} diff --git a/Dalamud/Interface/ImGuiNotification/NotificationDismissReason.cs b/Dalamud/Interface/ImGuiNotification/NotificationDismissReason.cs new file mode 100644 index 000000000..6e2fa338e --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/NotificationDismissReason.cs @@ -0,0 +1,22 @@ +namespace Dalamud.Interface.ImGuiNotification; + +/// +/// Specifies the reason of dismissal for a notification. +/// +public enum NotificationDismissReason +{ + /// + /// The notification is dismissed because the expiry specified from is met. + /// + Timeout = 1, + + /// + /// The notification is dismissed because the user clicked on the close button on a notification window. + /// + Manual = 2, + + /// + /// The notification is dismissed from calling . + /// + Programmatical = 3, +} diff --git a/Dalamud/Interface/ImGuiNotification/NotificationDismissedDelegate.cs b/Dalamud/Interface/ImGuiNotification/NotificationDismissedDelegate.cs new file mode 100644 index 000000000..5e899c32c --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/NotificationDismissedDelegate.cs @@ -0,0 +1,10 @@ +namespace Dalamud.Interface.ImGuiNotification; + +/// +/// Delegate representing the dismissal of an active notification. +/// +/// The notification being dismissed. +/// The reason of dismissal. +public delegate void NotificationDismissedDelegate( + IActiveNotification notification, + NotificationDismissReason dismissReason); diff --git a/Dalamud/Interface/Internal/Notifications/ActiveNotification.cs b/Dalamud/Interface/Internal/Notifications/ActiveNotification.cs new file mode 100644 index 000000000..182714157 --- /dev/null +++ b/Dalamud/Interface/Internal/Notifications/ActiveNotification.cs @@ -0,0 +1,508 @@ +using System.Numerics; +using System.Runtime.Loader; +using System.Threading.Tasks; + +using Dalamud.Game.Text; +using Dalamud.Interface.Animation; +using Dalamud.Interface.Animation.EasingFunctions; +using Dalamud.Interface.Colors; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.Utility; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Utility; + +using ImGuiNET; + +using Serilog; + +namespace Dalamud.Interface.Internal.Notifications; + +/// +/// Represents an active notification. +/// +internal sealed class ActiveNotification : IActiveNotification, IDisposable +{ + private readonly Easing showEasing; + private readonly Easing hideEasing; + + private Notification underlyingNotification; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying notification. + /// The initiator plugin. Use null if originated by Dalamud. + public ActiveNotification(Notification underlyingNotification, LocalPlugin? initiatorPlugin) + { + this.underlyingNotification = underlyingNotification with { }; + this.InitiatorPlugin = initiatorPlugin; + this.showEasing = new InCubic(NotificationConstants.ShowAnimationDuration); + this.hideEasing = new OutCubic(NotificationConstants.HideAnimationDuration); + + this.showEasing.Start(); + } + + /// + public event NotificationDismissedDelegate? Dismiss; + + /// + public event Action? Click; + + /// + public event Action? DrawActions; + + /// + public event Action? MouseEnter; + + /// + public event Action? MouseLeave; + + /// + public long Id { get; } = IActiveNotification.CreateNewId(); + + /// + /// Gets the tick of creating this notification. + /// + public long CreatedAt { get; } = Environment.TickCount64; + + /// + public string Content => this.underlyingNotification.Content; + + /// + public string? Title => this.underlyingNotification.Title; + + /// + public NotificationType Type => this.underlyingNotification.Type; + + /// + public Func>? IconCreator => this.underlyingNotification.IconCreator; + + /// + public DateTime Expiry => this.underlyingNotification.Expiry; + + /// + public bool Interactible => this.underlyingNotification.Interactible; + + /// + public bool ClickIsDismiss => this.underlyingNotification.ClickIsDismiss; + + /// + public TimeSpan HoverExtendDuration => this.underlyingNotification.HoverExtendDuration; + + /// + public bool IsMouseHovered { get; private set; } + + /// + public bool IsDismissed => this.hideEasing.IsRunning; + + /// + /// Gets or sets the plugin that initiated this notification. + /// + public LocalPlugin? InitiatorPlugin { get; set; } + + /// + /// Gets or sets the icon of this notification. + /// + public Task? IconTask { get; set; } + + /// + /// Gets the default color of the notification. + /// + private Vector4 DefaultIconColor => this.Type switch + { + NotificationType.None => ImGuiColors.DalamudWhite, + NotificationType.Success => ImGuiColors.HealerGreen, + NotificationType.Warning => ImGuiColors.DalamudOrange, + NotificationType.Error => ImGuiColors.DalamudRed, + NotificationType.Info => ImGuiColors.TankBlue, + _ => ImGuiColors.DalamudWhite, + }; + + /// + /// Gets the default icon of the notification. + /// + private string? DefaultIconString => this.Type switch + { + NotificationType.None => null, + NotificationType.Success => FontAwesomeIcon.CheckCircle.ToIconString(), + NotificationType.Warning => FontAwesomeIcon.ExclamationCircle.ToIconString(), + NotificationType.Error => FontAwesomeIcon.TimesCircle.ToIconString(), + NotificationType.Info => FontAwesomeIcon.InfoCircle.ToIconString(), + _ => null, + }; + + /// + /// Gets the default title of the notification. + /// + private string? DefaultTitle => this.Type switch + { + NotificationType.None => null, + NotificationType.Success => NotificationType.Success.ToString(), + NotificationType.Warning => NotificationType.Warning.ToString(), + NotificationType.Error => NotificationType.Error.ToString(), + NotificationType.Info => NotificationType.Info.ToString(), + _ => null, + }; + + /// + public void Dispose() + { + this.ClearIconTask(); + this.underlyingNotification.IconCreator = null; + this.Dismiss = null; + this.Click = null; + this.DrawActions = null; + this.InitiatorPlugin = null; + } + + /// + public Notification CloneNotification() => this.underlyingNotification with { }; + + /// + public void DismissNow() => this.DismissNow(NotificationDismissReason.Programmatical); + + /// + /// Dismisses this notification. Multiple calls will be ignored. + /// + /// The reason of dismissal. + public void DismissNow(NotificationDismissReason reason) + { + if (this.hideEasing.IsRunning) + return; + + this.hideEasing.Start(); + try + { + this.Dismiss?.Invoke(this, reason); + } + catch (Exception e) + { + Log.Error( + e, + $"{nameof(this.Dismiss)} error; notification is owned by {this.InitiatorPlugin?.Name ?? NotificationConstants.DefaultInitiator}"); + } + } + + /// + /// Updates animations. + /// + /// true if the notification is over. + public bool UpdateAnimations() + { + this.showEasing.Update(); + this.hideEasing.Update(); + return this.hideEasing.IsRunning && this.hideEasing.IsDone; + } + + /// + /// Draws this notification. + /// + /// The maximum width of the notification window. + /// The offset from the bottom. + /// The height of the notification. + public float Draw(float maxWidth, float offsetY) + { + if (!this.IsDismissed + && DateTime.Now > this.Expiry + && (this.HoverExtendDuration <= TimeSpan.Zero || !this.IsMouseHovered)) + { + this.DismissNow(NotificationDismissReason.Timeout); + } + + var opacity = + Math.Clamp( + (float)(this.hideEasing.IsRunning + ? (this.hideEasing.IsDone ? 0 : 1f - this.hideEasing.Value) + : (this.showEasing.IsDone ? 1 : this.showEasing.Value)), + 0f, + 1f); + if (opacity <= 0) + return 0; + + var notificationManager = Service.Get(); + var interfaceManager = Service.Get(); + var unboundedWidth = NotificationConstants.ScaledWindowPadding * 3; + unboundedWidth += NotificationConstants.ScaledIconSize; + unboundedWidth += Math.Max( + ImGui.CalcTextSize(this.Title ?? this.DefaultTitle ?? string.Empty).X, + ImGui.CalcTextSize(this.Content).X); + + var width = Math.Min(maxWidth, unboundedWidth); + + var viewport = ImGuiHelpers.MainViewport; + var viewportPos = viewport.WorkPos; + var viewportSize = viewport.WorkSize; + + ImGuiHelpers.ForceNextWindowMainViewport(); + ImGui.SetNextWindowPos( + (viewportPos + viewportSize) - + new Vector2(NotificationConstants.ScaledViewportEdgeMargin) - + new Vector2(0, offsetY), + ImGuiCond.Always, + Vector2.One); + ImGui.SetNextWindowSizeConstraints(new(width, 0), new(width, float.MaxValue)); + ImGui.PushID(this.Id.GetHashCode()); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(NotificationConstants.ScaledWindowPadding)); + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity); + unsafe + { + ImGui.PushStyleColor( + ImGuiCol.WindowBg, + *ImGui.GetStyleColorVec4(ImGuiCol.WindowBg) * new Vector4( + 1f, + 1f, + 1f, + NotificationConstants.BackgroundOpacity)); + } + + ImGui.Begin( + $"##NotifyWindow{this.Id}", + ImGuiWindowFlags.AlwaysAutoResize | + ImGuiWindowFlags.NoDecoration | + (this.Interactible ? ImGuiWindowFlags.None : ImGuiWindowFlags.NoInputs) | + ImGuiWindowFlags.NoNav | + ImGuiWindowFlags.NoBringToFrontOnFocus | + ImGuiWindowFlags.NoFocusOnAppearing); + + var basePos = ImGui.GetCursorPos(); + this.DrawIcon( + notificationManager, + basePos, + basePos + new Vector2(NotificationConstants.ScaledIconSize)); + basePos.X += NotificationConstants.ScaledIconSize + NotificationConstants.ScaledWindowPadding; + width -= NotificationConstants.ScaledIconSize + (NotificationConstants.ScaledWindowPadding * 2); + this.DrawTitle(basePos, basePos + new Vector2(width, 0)); + basePos.Y = ImGui.GetCursorPosY(); + this.DrawContentBody(basePos, basePos + new Vector2(width, 0)); + if (ImGui.IsWindowHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + this.Click?.InvokeSafely(this); + if (this.ClickIsDismiss) + this.DismissNow(NotificationDismissReason.Manual); + } + + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + + ImGui.End(); + + if (!this.IsDismissed) + this.DrawCloseButton(interfaceManager, windowPos); + + ImGui.PopStyleColor(); + ImGui.PopStyleVar(2); + ImGui.PopID(); + + if (windowPos.X <= ImGui.GetIO().MousePos.X + && windowPos.Y <= ImGui.GetIO().MousePos.Y + && ImGui.GetIO().MousePos.X < windowPos.X + windowSize.X + && ImGui.GetIO().MousePos.Y < windowPos.Y + windowSize.Y) + { + if (!this.IsMouseHovered) + { + this.IsMouseHovered = true; + this.MouseEnter.InvokeSafely(this); + } + } + else if (this.IsMouseHovered) + { + if (this.HoverExtendDuration > TimeSpan.Zero) + { + var newExpiry = DateTime.Now + this.HoverExtendDuration; + if (newExpiry > this.Expiry) + this.underlyingNotification.Expiry = newExpiry; + } + + this.IsMouseHovered = false; + this.MouseLeave.InvokeSafely(this); + } + + return windowSize.Y; + } + + /// + public void Update(INotification newNotification) + { + this.underlyingNotification.Content = newNotification.Content; + this.underlyingNotification.Title = newNotification.Title; + this.underlyingNotification.Type = newNotification.Type; + this.underlyingNotification.IconCreator = newNotification.IconCreator; + this.underlyingNotification.Expiry = newNotification.Expiry; + } + + /// + public void UpdateIcon() + { + this.ClearIconTask(); + this.IconTask = this.IconCreator?.Invoke(); + } + + /// + /// Removes non-Dalamud invocation targets from events. + /// + public void RemoveNonDalamudInvocations() + { + var dalamudContext = AssemblyLoadContext.GetLoadContext(typeof(NotificationManager).Assembly); + this.Dismiss = RemoveNonDalamudInvocationsCore(this.Dismiss); + this.Click = RemoveNonDalamudInvocationsCore(this.Click); + this.DrawActions = RemoveNonDalamudInvocationsCore(this.DrawActions); + this.MouseEnter = RemoveNonDalamudInvocationsCore(this.MouseEnter); + this.MouseLeave = RemoveNonDalamudInvocationsCore(this.MouseLeave); + + return; + + T? RemoveNonDalamudInvocationsCore(T? @delegate) where T : Delegate + { + if (@delegate is null) + return null; + + foreach (var il in @delegate.GetInvocationList()) + { + if (il.Target is { } target && + AssemblyLoadContext.GetLoadContext(target.GetType().Assembly) != dalamudContext) + { + @delegate = (T)Delegate.Remove(@delegate, il); + } + } + + return @delegate; + } + } + + private void ClearIconTask() + { + _ = this.IconTask?.ContinueWith( + r => + { + if (r.IsCompletedSuccessfully && r.Result is IDisposable d) + d.Dispose(); + }); + this.IconTask = null; + } + + private void DrawIcon(NotificationManager notificationManager, Vector2 minCoord, Vector2 maxCoord) + { + string? iconString; + IFontHandle? fontHandle; + switch (this.IconTask?.IsCompletedSuccessfully is true ? this.IconTask.Result : null) + { + case IDalamudTextureWrap wrap: + { + var size = wrap.Size; + if (size.X > maxCoord.X - minCoord.X) + size *= (maxCoord.X - minCoord.X) / size.X; + if (size.Y > maxCoord.Y - minCoord.Y) + size *= (maxCoord.Y - minCoord.Y) / size.Y; + var pos = ((minCoord + maxCoord) - size) / 2; + ImGui.SetCursorPos(pos); + ImGui.Image(wrap.ImGuiHandle, size); + return; + } + + case SeIconChar icon: + iconString = string.Empty + (char)icon; + fontHandle = notificationManager.IconAxisFontHandle; + break; + case FontAwesomeIcon icon: + iconString = icon.ToIconString(); + fontHandle = notificationManager.IconFontAwesomeFontHandle; + break; + default: + iconString = this.DefaultIconString; + fontHandle = notificationManager.IconFontAwesomeFontHandle; + break; + } + + if (string.IsNullOrWhiteSpace(iconString)) + return; + + using (fontHandle.Push()) + { + var size = ImGui.CalcTextSize(iconString); + var pos = ((minCoord + maxCoord) - size) / 2; + ImGui.SetCursorPos(pos); + ImGui.PushStyleColor(ImGuiCol.Text, this.DefaultIconColor); + ImGui.TextUnformatted(iconString); + ImGui.PopStyleColor(); + } + } + + private void DrawTitle(Vector2 minCoord, Vector2 maxCoord) + { + ImGui.PushTextWrapPos(maxCoord.X); + + if ((this.Title ?? this.DefaultTitle) is { } title) + { + ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.TitleTextColor); + ImGui.SetCursorPos(minCoord); + ImGui.TextUnformatted(title); + ImGui.PopStyleColor(); + } + + ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.BlameTextColor); + ImGui.SetCursorPos(minCoord with { Y = ImGui.GetCursorPosY() }); + ImGui.TextUnformatted(this.InitiatorPlugin?.Name ?? NotificationConstants.DefaultInitiator); + ImGui.PopStyleColor(); + + ImGui.PopTextWrapPos(); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + NotificationConstants.ScaledComponentGap); + } + + private void DrawCloseButton(InterfaceManager interfaceManager, Vector2 screenCoord) + { + using (interfaceManager.IconFontHandle?.Push()) + { + var str = FontAwesomeIcon.Times.ToIconString(); + var size = NotificationConstants.ScaledCloseButtonMinSize; + var textSize = ImGui.CalcTextSize(str); + size = Math.Max(size, Math.Max(textSize.X, textSize.Y)); + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, Vector2.Zero); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 0f); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 0f); + ImGui.PushStyleColor(ImGuiCol.Button, 0); + ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.CloseTextColor); + + // ImGuiHelpers.ForceNextWindowMainViewport(); + ImGui.SetNextWindowPos(screenCoord, ImGuiCond.Always, new(1, 0)); + ImGui.SetNextWindowSizeConstraints(new(size), new(size)); + ImGui.Begin( + $"##CloseButtonWindow{this.Id}", + ImGuiWindowFlags.AlwaysAutoResize | + ImGuiWindowFlags.NoDecoration | + ImGuiWindowFlags.NoNav | + ImGuiWindowFlags.NoBringToFrontOnFocus | + ImGuiWindowFlags.NoFocusOnAppearing); + + if (ImGui.Button(str, new(size))) + this.DismissNow(); + + ImGui.End(); + ImGui.PopStyleColor(2); + ImGui.PopStyleVar(4); + } + } + + private void DrawContentBody(Vector2 minCoord, Vector2 maxCoord) + { + ImGui.SetCursorPos(minCoord); + ImGui.PushTextWrapPos(maxCoord.X); + ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.BodyTextColor); + ImGui.TextUnformatted(this.Content); + ImGui.PopStyleColor(); + ImGui.PopTextWrapPos(); + if (this.DrawActions is not null) + { + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + NotificationConstants.ScaledComponentGap); + try + { + this.DrawActions.Invoke(this); + } + catch + { + // ignore + } + } + } +} diff --git a/Dalamud/Interface/Internal/Notifications/NotificationConstants.cs b/Dalamud/Interface/Internal/Notifications/NotificationConstants.cs new file mode 100644 index 000000000..44b1fa832 --- /dev/null +++ b/Dalamud/Interface/Internal/Notifications/NotificationConstants.cs @@ -0,0 +1,74 @@ +using System.Numerics; + +using Dalamud.Interface.Utility; + +namespace Dalamud.Interface.Internal.Notifications; + +/// +/// Constants for drawing notification windows. +/// +internal static class NotificationConstants +{ + // ..............................[X] + // ..[i]..title title title title .. + // .. by this_plugin .. + // .. .. + // .. body body body body .. + // .. some more wrapped body .. + // .. .. + // .. action buttons .. + // ................................. + + /// The string to show in place of this_plugin if the notification is shown by Dalamud. + public const string DefaultInitiator = "Dalamud"; + + /// The size of the icon. + public const float IconSize = 32; + + /// The background opacity of a notification window. + public const float BackgroundOpacity = 0.82f; + + /// Duration of show animation. + public static readonly TimeSpan ShowAnimationDuration = TimeSpan.FromMilliseconds(300); + + /// Default duration of the notification. + public static readonly TimeSpan DefaultDisplayDuration = TimeSpan.FromSeconds(3); + + /// Default duration of the notification. + public static readonly TimeSpan DefaultHoverExtendDuration = TimeSpan.FromSeconds(3); + + /// Duration of hide animation. + public static readonly TimeSpan HideAnimationDuration = TimeSpan.FromMilliseconds(300); + + /// Text color for the close button [X]. + public static readonly Vector4 CloseTextColor = new(0.8f, 0.8f, 0.8f, 1f); + + /// Text color for the title. + public static readonly Vector4 TitleTextColor = new(1f, 1f, 1f, 1f); + + /// Text color for the name of the initiator. + public static readonly Vector4 BlameTextColor = new(0.8f, 0.8f, 0.8f, 1f); + + /// Text color for the body. + public static readonly Vector4 BodyTextColor = new(0.9f, 0.9f, 0.9f, 1f); + + /// Gets the scaled padding of the window (dot(.) in the above diagram). + public static float ScaledWindowPadding => MathF.Round(16 * ImGuiHelpers.GlobalScale); + + /// Gets the distance from the right bottom border of the viewport + /// to the right bottom border of a notification window. + /// + public static float ScaledViewportEdgeMargin => MathF.Round(20 * ImGuiHelpers.GlobalScale); + + /// Gets the scaled gap between two notification windows. + public static float ScaledWindowGap => MathF.Round(10 * ImGuiHelpers.GlobalScale); + + /// Gets the scaled gap between components. + public static float ScaledComponentGap => MathF.Round(5 * ImGuiHelpers.GlobalScale); + + /// Gets the scaled size of the icon. + public static float ScaledIconSize => MathF.Round(IconSize * ImGuiHelpers.GlobalScale); + + /// Gets the scaled size of the close button. + public static float ScaledCloseButtonMinSize => MathF.Round(16 * ImGuiHelpers.GlobalScale); +} diff --git a/Dalamud/Interface/Internal/Notifications/NotificationManager.cs b/Dalamud/Interface/Internal/Notifications/NotificationManager.cs index 67ad3ee8f..fd92c30df 100644 --- a/Dalamud/Interface/Internal/Notifications/NotificationManager.cs +++ b/Dalamud/Interface/Internal/Notifications/NotificationManager.cs @@ -1,12 +1,15 @@ -using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using Dalamud.Interface.Colors; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Utility; -using Dalamud.Utility; -using ImGuiNET; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Plugin.Services; namespace Dalamud.Interface.Internal.Notifications; @@ -14,51 +17,66 @@ namespace Dalamud.Interface.Internal.Notifications; /// Class handling notifications/toasts in ImGui. /// Ported from https://github.com/patrickcjk/imgui-notify. /// +[InterfaceVersion("1.0")] [ServiceManager.EarlyLoadedService] -internal class NotificationManager : IServiceType +internal class NotificationManager : INotificationManager, IServiceType, IDisposable { - /// - /// Value indicating the bottom-left X padding. - /// - internal const float NotifyPaddingX = 20.0f; - - /// - /// Value indicating the bottom-left Y padding. - /// - internal const float NotifyPaddingY = 20.0f; - - /// - /// Value indicating the Y padding between each message. - /// - internal const float NotifyPaddingMessageY = 10.0f; - - /// - /// Value indicating the fade-in and out duration. - /// - internal const int NotifyFadeInOutTime = 500; - - /// - /// Value indicating the default time until the notification is dismissed. - /// - internal const int NotifyDefaultDismiss = 3000; - - /// - /// Value indicating the maximum opacity. - /// - internal const float NotifyOpacity = 0.82f; - - /// - /// Value indicating default window flags for the notifications. - /// - internal const ImGuiWindowFlags NotifyToastFlags = - ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoInputs | - ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoBringToFrontOnFocus | ImGuiWindowFlags.NoFocusOnAppearing; - - private readonly List notifications = new(); + private readonly List notifications = new(); + private readonly ConcurrentBag pendingNotifications = new(); [ServiceManager.ServiceConstructor] - private NotificationManager() + private NotificationManager(FontAtlasFactory fontAtlasFactory) { + this.PrivateAtlas = fontAtlasFactory.CreateFontAtlas( + nameof(NotificationManager), + FontAtlasAutoRebuildMode.Async); + this.IconAxisFontHandle = + this.PrivateAtlas.NewGameFontHandle(new(GameFontFamily.Axis, NotificationConstants.IconSize)); + this.IconFontAwesomeFontHandle = + this.PrivateAtlas.NewDelegateFontHandle( + e => e.OnPreBuild( + tk => tk.AddFontAwesomeIconFont(new() { SizePx = NotificationConstants.IconSize }))); + } + + /// Gets the handle to AXIS fonts, sized for use as an icon. + public IFontHandle IconAxisFontHandle { get; } + + /// Gets the handle to FontAwesome fonts, sized for use as an icon. + public IFontHandle IconFontAwesomeFontHandle { get; } + + private IFontAtlas PrivateAtlas { get; } + + /// + public void Dispose() + { + this.PrivateAtlas.Dispose(); + foreach (var n in this.pendingNotifications) + n.Dispose(); + foreach (var n in this.notifications) + n.Dispose(); + this.pendingNotifications.Clear(); + this.notifications.Clear(); + } + + /// + public IActiveNotification AddNotification(Notification notification) + { + var an = new ActiveNotification(notification, null); + this.pendingNotifications.Add(an); + return an; + } + + /// + /// Adds a notification originating from a plugin. + /// + /// The notification. + /// The source plugin. + /// The new notification. + public IActiveNotification AddNotification(Notification notification, LocalPlugin plugin) + { + var an = new ActiveNotification(notification, plugin); + this.pendingNotifications.Add(an); + return an; } /// @@ -67,252 +85,77 @@ internal class NotificationManager : IServiceType /// The content of the notification. /// The title of the notification. /// The type of the notification. - /// The time the notification should be displayed for. - public void AddNotification(string content, string? title = null, NotificationType type = NotificationType.None, uint msDelay = NotifyDefaultDismiss) - { - this.notifications.Add(new Notification - { - Content = content, - Title = title, - NotificationType = type, - DurationMs = msDelay, - }); - } + public void AddNotification( + string content, + string? title = null, + NotificationType type = NotificationType.None) => + this.AddNotification( + new() + { + Content = content, + Title = title, + Type = type, + }); /// /// Draw all currently queued notifications. /// public void Draw() { - var viewportSize = ImGuiHelpers.MainViewport.Size; + var viewportSize = ImGuiHelpers.MainViewport.WorkSize; var height = 0f; - for (var i = 0; i < this.notifications.Count; i++) - { - var tn = this.notifications.ElementAt(i); + while (this.pendingNotifications.TryTake(out var newNotification)) + this.notifications.Add(newNotification); - if (tn.GetPhase() == Notification.Phase.Expired) - { - this.notifications.RemoveAt(i); - continue; - } + var maxWidth = Math.Max(320 * ImGuiHelpers.GlobalScale, viewportSize.X / 3); - var opacity = tn.GetFadePercent(); + this.notifications.RemoveAll(x => x.UpdateAnimations()); + foreach (var tn in this.notifications) + height += tn.Draw(maxWidth, height) + NotificationConstants.ScaledWindowGap; + } +} - var iconColor = tn.Color; - iconColor.W = opacity; +/// +/// Plugin-scoped version of a service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class NotificationManagerPluginScoped : INotificationManager, IServiceType, IDisposable +{ + private readonly LocalPlugin localPlugin; + private readonly ConcurrentDictionary notifications = new(); - var windowName = $"##NOTIFY{i}"; + [ServiceManager.ServiceDependency] + private readonly NotificationManager notificationManagerService = Service.Get(); - ImGuiHelpers.ForceNextWindowMainViewport(); - ImGui.SetNextWindowBgAlpha(opacity); - ImGui.SetNextWindowPos(ImGuiHelpers.MainViewport.Pos + new Vector2(viewportSize.X - NotifyPaddingX, viewportSize.Y - NotifyPaddingY - height), ImGuiCond.Always, Vector2.One); - ImGui.Begin(windowName, NotifyToastFlags); + [ServiceManager.ServiceConstructor] + private NotificationManagerPluginScoped(LocalPlugin localPlugin) => + this.localPlugin = localPlugin; - ImGui.PushTextWrapPos(viewportSize.X / 3.0f); - - var wasTitleRendered = false; - - if (!tn.Icon.IsNullOrEmpty()) - { - wasTitleRendered = true; - ImGui.PushFont(InterfaceManager.IconFont); - ImGui.TextColored(iconColor, tn.Icon); - ImGui.PopFont(); - } - - var textColor = ImGuiColors.DalamudWhite; - textColor.W = opacity; - - ImGui.PushStyleColor(ImGuiCol.Text, textColor); - - if (!tn.Title.IsNullOrEmpty()) - { - if (!tn.Icon.IsNullOrEmpty()) - { - ImGui.SameLine(); - } - - ImGui.TextUnformatted(tn.Title); - wasTitleRendered = true; - } - else if (!tn.DefaultTitle.IsNullOrEmpty()) - { - if (!tn.Icon.IsNullOrEmpty()) - { - ImGui.SameLine(); - } - - ImGui.TextUnformatted(tn.DefaultTitle); - wasTitleRendered = true; - } - - if (wasTitleRendered && !tn.Content.IsNullOrEmpty()) - { - ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 5.0f); - } - - if (!tn.Content.IsNullOrEmpty()) - { - if (wasTitleRendered) - { - ImGui.Separator(); - } - - ImGui.TextUnformatted(tn.Content); - } - - ImGui.PopStyleColor(); - - ImGui.PopTextWrapPos(); - - height += ImGui.GetWindowHeight() + NotifyPaddingMessageY; - - ImGui.End(); - } + /// + public IActiveNotification AddNotification(Notification notification) + { + var an = this.notificationManagerService.AddNotification(notification, this.localPlugin); + _ = this.notifications.TryAdd(an, 0); + an.Dismiss += (a, unused) => this.notifications.TryRemove(an, out _); + return an; } - /// - /// Container class for notifications. - /// - internal class Notification + /// + public void Dispose() { - /// - /// Possible notification phases. - /// - internal enum Phase + while (!this.notifications.IsEmpty) { - /// - /// Phase indicating fade-in. - /// - FadeIn, - - /// - /// Phase indicating waiting until fade-out. - /// - Wait, - - /// - /// Phase indicating fade-out. - /// - FadeOut, - - /// - /// Phase indicating that the notification has expired. - /// - Expired, - } - - /// - /// Gets the type of the notification. - /// - internal NotificationType NotificationType { get; init; } - - /// - /// Gets the title of the notification. - /// - internal string? Title { get; init; } - - /// - /// Gets the content of the notification. - /// - internal string Content { get; init; } - - /// - /// Gets the duration of the notification in milliseconds. - /// - internal uint DurationMs { get; init; } - - /// - /// Gets the creation time of the notification. - /// - internal DateTime CreationTime { get; init; } = DateTime.Now; - - /// - /// Gets the default color of the notification. - /// - /// Thrown when is set to an out-of-range value. - internal Vector4 Color => this.NotificationType switch - { - NotificationType.None => ImGuiColors.DalamudWhite, - NotificationType.Success => ImGuiColors.HealerGreen, - NotificationType.Warning => ImGuiColors.DalamudOrange, - NotificationType.Error => ImGuiColors.DalamudRed, - NotificationType.Info => ImGuiColors.TankBlue, - _ => throw new ArgumentOutOfRangeException(), - }; - - /// - /// Gets the icon of the notification. - /// - /// Thrown when is set to an out-of-range value. - internal string? Icon => this.NotificationType switch - { - NotificationType.None => null, - NotificationType.Success => FontAwesomeIcon.CheckCircle.ToIconString(), - NotificationType.Warning => FontAwesomeIcon.ExclamationCircle.ToIconString(), - NotificationType.Error => FontAwesomeIcon.TimesCircle.ToIconString(), - NotificationType.Info => FontAwesomeIcon.InfoCircle.ToIconString(), - _ => throw new ArgumentOutOfRangeException(), - }; - - /// - /// Gets the default title of the notification. - /// - /// Thrown when is set to an out-of-range value. - internal string? DefaultTitle => this.NotificationType switch - { - NotificationType.None => null, - NotificationType.Success => NotificationType.Success.ToString(), - NotificationType.Warning => NotificationType.Warning.ToString(), - NotificationType.Error => NotificationType.Error.ToString(), - NotificationType.Info => NotificationType.Info.ToString(), - _ => throw new ArgumentOutOfRangeException(), - }; - - /// - /// Gets the elapsed time since creating the notification. - /// - internal TimeSpan ElapsedTime => DateTime.Now - this.CreationTime; - - /// - /// Gets the phase of the notification. - /// - /// The phase of the notification. - internal Phase GetPhase() - { - var elapsed = (int)this.ElapsedTime.TotalMilliseconds; - - if (elapsed > NotifyFadeInOutTime + this.DurationMs + NotifyFadeInOutTime) - return Phase.Expired; - else if (elapsed > NotifyFadeInOutTime + this.DurationMs) - return Phase.FadeOut; - else if (elapsed > NotifyFadeInOutTime) - return Phase.Wait; - else - return Phase.FadeIn; - } - - /// - /// Gets the opacity of the notification. - /// - /// The opacity, in a range from 0 to 1. - internal float GetFadePercent() - { - var phase = this.GetPhase(); - var elapsed = this.ElapsedTime.TotalMilliseconds; - - if (phase == Phase.FadeIn) + foreach (var n in this.notifications.Keys) { - return (float)elapsed / NotifyFadeInOutTime * NotifyOpacity; + this.notifications.TryRemove(n, out _); + ((ActiveNotification)n).RemoveNonDalamudInvocations(); } - else if (phase == Phase.FadeOut) - { - return (1.0f - (((float)elapsed - NotifyFadeInOutTime - this.DurationMs) / - NotifyFadeInOutTime)) * NotifyOpacity; - } - - return 1.0f * NotifyOpacity; } } } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs index 2c7ceb95b..ebf3157fa 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs @@ -44,32 +44,66 @@ internal class ImGuiWidget : IDataWindowWidget if (ImGui.Button("Add random notification")) { - var rand = new Random(); - - var title = rand.Next(0, 5) switch - { - 0 => "This is a toast", - 1 => "Truly, a toast", - 2 => "I am testing this toast", - 3 => "I hope this looks right", - 4 => "Good stuff", - 5 => "Nice", - _ => null, - }; - - var type = rand.Next(0, 4) switch - { - 0 => NotificationType.Error, - 1 => NotificationType.Warning, - 2 => NotificationType.Info, - 3 => NotificationType.Success, - 4 => NotificationType.None, - _ => NotificationType.None, - }; - const string text = "Bla bla bla bla bla bla bla bla bla bla bla.\nBla bla bla bla bla bla bla bla bla bla bla bla bla bla."; - notifications.AddNotification(text, title, type); + NewRandom(out var title, out var type); + var n = notifications.AddNotification( + new() + { + Content = text, + Title = title, + Type = type, + Interactible = true, + ClickIsDismiss = false, + }); + + var nclick = 0; + n.Click += _ => nclick++; + n.DrawActions += an => + { + if (ImGui.Button("Update in place")) + { + NewRandom(out title, out type); + an.Update(an.CloneNotification() with { Title = title, Type = type }); + } + + if (an.IsMouseHovered) + { + ImGui.SameLine(); + if (ImGui.Button("Dismiss")) + an.DismissNow(); + } + + ImGui.AlignTextToFramePadding(); + ImGui.SameLine(); + ImGui.TextUnformatted($"Clicked {nclick} time(s)"); + }; } } + + private static void NewRandom(out string? title, out NotificationType type) + { + var rand = new Random(); + + title = rand.Next(0, 5) switch + { + 0 => "This is a toast", + 1 => "Truly, a toast", + 2 => "I am testing this toast", + 3 => "I hope this looks right", + 4 => "Good stuff", + 5 => "Nice", + _ => null, + }; + + type = rand.Next(0, 4) switch + { + 0 => NotificationType.Error, + 1 => NotificationType.Warning, + 2 => NotificationType.Info, + 3 => NotificationType.Success, + 4 => NotificationType.None, + _ => NotificationType.None, + }; + } } diff --git a/Dalamud/Interface/UiBuilder.cs b/Dalamud/Interface/UiBuilder.cs index d260868a0..6da6ebc4a 100644 --- a/Dalamud/Interface/UiBuilder.cs +++ b/Dalamud/Interface/UiBuilder.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; @@ -9,12 +10,14 @@ using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.Gui; using Dalamud.Interface.FontIdentifier; using Dalamud.Interface.GameFonts; +using Dalamud.Interface.ImGuiNotification; using Dalamud.Interface.Internal; using Dalamud.Interface.Internal.ManagedAsserts; using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Plugin.Internal.Types; +using Dalamud.Plugin.Services; using Dalamud.Utility; using ImGuiNET; using ImGuiScene; @@ -29,11 +32,13 @@ namespace Dalamud.Interface; /// public sealed class UiBuilder : IDisposable { + private readonly LocalPlugin localPlugin; private readonly Stopwatch stopwatch; private readonly HitchDetector hitchDetector; private readonly string namespaceName; private readonly InterfaceManager interfaceManager = Service.Get(); private readonly Framework framework = Service.Get(); + private readonly ConcurrentDictionary notifications = new(); [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); @@ -52,8 +57,10 @@ public sealed class UiBuilder : IDisposable /// You do not have to call this manually. /// /// The plugin namespace. - internal UiBuilder(string namespaceName) + /// The relevant local plugin. + internal UiBuilder(string namespaceName, LocalPlugin localPlugin) { + this.localPlugin = localPlugin; try { this.stopwatch = new Stopwatch(); @@ -556,22 +563,46 @@ public sealed class UiBuilder : IDisposable /// The title of the notification. /// The type of the notification. /// The time the notification should be displayed for. - public void AddNotification( - string content, string? title = null, NotificationType type = NotificationType.None, uint msDelay = 3000) + [Obsolete($"Use {nameof(INotificationManager)}.", false)] + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public async void AddNotification( + string content, + string? title = null, + NotificationType type = NotificationType.None, + uint msDelay = 3000) { - Service - .GetAsync() - .ContinueWith(task => + var nm = await Service.GetAsync(); + var an = nm.AddNotification( + new() { - if (task.IsCompletedSuccessfully) - task.Result.AddNotification(content, title, type, msDelay); - }); + Content = content, + Title = title, + Type = type, + Expiry = DateTime.Now + TimeSpan.FromMilliseconds(msDelay), + }, + this.localPlugin); + _ = this.notifications.TryAdd(an, 0); + an.Dismiss += (a, unused) => this.notifications.TryRemove(an, out _); } /// /// Unregister the UiBuilder. Do not call this in plugin code. /// - void IDisposable.Dispose() => this.scopedFinalizer.Dispose(); + void IDisposable.Dispose() + { + this.scopedFinalizer.Dispose(); + + // Taken from NotificationManagerPluginScoped. + // TODO: remove on API 10. + while (!this.notifications.IsEmpty) + { + foreach (var n in this.notifications.Keys) + { + this.notifications.TryRemove(n, out _); + ((ActiveNotification)n).RemoveNonDalamudInvocations(); + } + } + } /// /// Open the registered configuration UI, if it exists. diff --git a/Dalamud/Plugin/DalamudPluginInterface.cs b/Dalamud/Plugin/DalamudPluginInterface.cs index 82f19aa49..5e103ecbe 100644 --- a/Dalamud/Plugin/DalamudPluginInterface.cs +++ b/Dalamud/Plugin/DalamudPluginInterface.cs @@ -52,7 +52,7 @@ public sealed class DalamudPluginInterface : IDisposable var dataManager = Service.Get(); var localization = Service.Get(); - this.UiBuilder = new UiBuilder(plugin.Name); + this.UiBuilder = new UiBuilder(plugin.Name, plugin); this.configs = Service.Get().PluginConfigs; this.Reason = reason; diff --git a/Dalamud/Plugin/Services/INotificationManager.cs b/Dalamud/Plugin/Services/INotificationManager.cs new file mode 100644 index 000000000..1d31ddd35 --- /dev/null +++ b/Dalamud/Plugin/Services/INotificationManager.cs @@ -0,0 +1,16 @@ +using Dalamud.Interface.ImGuiNotification; + +namespace Dalamud.Plugin.Services; + +/// +/// Manager for notifications provided by Dalamud using ImGui. +/// +public interface INotificationManager +{ + /// + /// Adds a notification. + /// + /// The new notification. + /// The added notification. + IActiveNotification AddNotification(Notification notification); +}