Implement INotificationManager

This commit is contained in:
Soreepeong 2024-02-25 05:31:13 +09:00
parent 8e5a84792e
commit 3ba395bd70
12 changed files with 1064 additions and 307 deletions

View file

@ -0,0 +1,109 @@
using System.Threading;
namespace Dalamud.Interface.ImGuiNotification;
/// <summary>
/// Represents an active notification.
/// </summary>
public interface IActiveNotification : INotification
{
/// <summary>
/// The counter for <see cref="Id"/> field.
/// </summary>
private static long idCounter;
/// <summary>
/// Invoked upon dismissing the notification.
/// </summary>
/// <remarks>
/// The event callback will not be called, if a user interacts with the notification after the plugin is unloaded.
/// </remarks>
event NotificationDismissedDelegate Dismiss;
/// <summary>
/// Invoked upon clicking on the notification.
/// </summary>
/// <remarks>
/// This event is not applicable when <see cref="INotification.Interactible"/> is set to <c>false</c>.
/// Note that this function may be called even after <see cref="Dismiss"/> has been invoked.
/// Refer to <see cref="IsDismissed"/>.
/// </remarks>
event Action<IActiveNotification> Click;
/// <summary>
/// Invoked when the mouse enters the notification window.
/// </summary>
/// <remarks>
/// This event is applicable regardless of <see cref="INotification.Interactible"/>.
/// Note that this function may be called even after <see cref="Dismiss"/> has been invoked.
/// Refer to <see cref="IsDismissed"/>.
/// </remarks>
event Action<IActiveNotification> MouseEnter;
/// <summary>
/// Invoked when the mouse leaves the notification window.
/// </summary>
/// <remarks>
/// This event is applicable regardless of <see cref="INotification.Interactible"/>.
/// Note that this function may be called even after <see cref="Dismiss"/> has been invoked.
/// Refer to <see cref="IsDismissed"/>.
/// </remarks>
event Action<IActiveNotification> MouseLeave;
/// <summary>
/// Invoked upon drawing the action bar of the notification.
/// </summary>
/// <remarks>
/// This event is applicable regardless of <see cref="INotification.Interactible"/>.
/// Note that this function may be called even after <see cref="Dismiss"/> has been invoked.
/// Refer to <see cref="IsDismissed"/>.
/// </remarks>
event Action<IActiveNotification> DrawActions;
/// <summary>
/// Gets the ID of this notification.
/// </summary>
long Id { get; }
/// <summary>
/// Gets a value indicating whether the mouse cursor is on the notification window.
/// </summary>
bool IsMouseHovered { get; }
/// <summary>
/// Gets a value indicating whether the notification has been dismissed.
/// This includes when the hide animation is being played.
/// </summary>
bool IsDismissed { get; }
/// <summary>
/// Clones this notification as a <see cref="Notification"/>.
/// </summary>
/// <returns>A new instance of <see cref="Notification"/>.</returns>
Notification CloneNotification();
/// <summary>
/// Dismisses this notification.
/// </summary>
void DismissNow();
/// <summary>
/// Updates the notification data.
/// </summary>
/// <remarks>
/// Call <see cref="UpdateIcon"/> to update the icon using the new <see cref="INotification.IconCreator"/>.
/// </remarks>
/// <param name="newNotification">The new notification entry.</param>
void Update(INotification newNotification);
/// <summary>
/// Loads the icon again using <see cref="INotification.IconCreator"/>.
/// </summary>
void UpdateIcon();
/// <summary>
/// Generates a new value to use for <see cref="Id"/>.
/// </summary>
/// <returns>The new value.</returns>
internal static long CreateNewId() => Interlocked.Increment(ref idCounter);
}

View file

@ -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;
/// <summary>
/// Represents a notification.
/// </summary>
public interface INotification
{
/// <summary>
/// Gets the content body of the notification.
/// </summary>
string Content { get; }
/// <summary>
/// Gets the title of the notification.
/// </summary>
string? Title { get; }
/// <summary>
/// Gets the type of the notification.
/// </summary>
NotificationType Type { get; }
/// <summary>
/// Gets the icon creator function for the notification.<br />
/// Currently <see cref="IDalamudTextureWrap"/>, <see cref="SeIconChar"/>, and <see cref="FontAwesomeIcon"/> types
/// are accepted.
/// </summary>
/// <remarks>
/// The icon created by the task returned will be owned by Dalamud,
/// i.e. it will be <see cref="IDisposable.Dispose"/>d automatically as needed.<br />
/// If <c>null</c> is supplied for this property or <see cref="Task.IsCompletedSuccessfully"/> of the returned task
/// is <c>false</c>, then the corresponding icon with <see cref="Type"/> will be used.<br />
/// Use <see cref="Task.FromResult{TResult}"/> if you have an instance of <see cref="IDalamudTextureWrap"/> that you
/// can transfer ownership to Dalamud and is available for use right away.
/// </remarks>
Func<Task<object>>? IconCreator { get; }
/// <summary>
/// Gets the expiry.
/// </summary>
DateTime Expiry { get; }
/// <summary>
/// Gets a value indicating whether this notification may be interacted.
/// </summary>
/// <remarks>
/// Set this value to <c>true</c> if you want to respond to user inputs from
/// <see cref="IActiveNotification.DrawActions"/>.
/// Note that the close buttons for notifications are always provided and interactible.
/// </remarks>
bool Interactible { get; }
/// <summary>
/// Gets a value indicating whether clicking on the notification window counts as dismissing the notification.
/// </summary>
/// <remarks>
/// This property has no effect if <see cref="Interactible"/> is <c>false</c>.
/// </remarks>
bool ClickIsDismiss { get; }
/// <summary>
/// Gets the new duration for this notification if mouse cursor is on the notification window.
/// If set to <see cref="TimeSpan.Zero"/> or less, then this feature is turned off.
/// </summary>
/// <remarks>
/// This property is applicable regardless of <see cref="Interactible"/>.
/// </remarks>
TimeSpan HoverExtendDuration { get; }
}

View file

@ -0,0 +1,35 @@
using System.Threading.Tasks;
using Dalamud.Interface.Internal.Notifications;
namespace Dalamud.Interface.ImGuiNotification;
/// <summary>
/// Represents a blueprint for a notification.
/// </summary>
public sealed record Notification : INotification
{
/// <inheritdoc/>
public string Content { get; set; } = string.Empty;
/// <inheritdoc/>
public string? Title { get; set; }
/// <inheritdoc/>
public NotificationType Type { get; set; } = NotificationType.None;
/// <inheritdoc/>
public Func<Task<object>>? IconCreator { get; set; }
/// <inheritdoc/>
public DateTime Expiry { get; set; } = DateTime.Now + NotificationConstants.DefaultDisplayDuration;
/// <inheritdoc/>
public bool Interactible { get; set; }
/// <inheritdoc/>
public bool ClickIsDismiss { get; set; } = true;
/// <inheritdoc/>
public TimeSpan HoverExtendDuration { get; set; } = NotificationConstants.DefaultHoverExtendDuration;
}

View file

@ -0,0 +1,22 @@
namespace Dalamud.Interface.ImGuiNotification;
/// <summary>
/// Specifies the reason of dismissal for a notification.
/// </summary>
public enum NotificationDismissReason
{
/// <summary>
/// The notification is dismissed because the expiry specified from <see cref="INotification.Expiry"/> is met.
/// </summary>
Timeout = 1,
/// <summary>
/// The notification is dismissed because the user clicked on the close button on a notification window.
/// </summary>
Manual = 2,
/// <summary>
/// The notification is dismissed from calling <see cref="IActiveNotification.DismissNow"/>.
/// </summary>
Programmatical = 3,
}

View file

@ -0,0 +1,10 @@
namespace Dalamud.Interface.ImGuiNotification;
/// <summary>
/// Delegate representing the dismissal of an active notification.
/// </summary>
/// <param name="notification">The notification being dismissed.</param>
/// <param name="dismissReason">The reason of dismissal.</param>
public delegate void NotificationDismissedDelegate(
IActiveNotification notification,
NotificationDismissReason dismissReason);

View file

@ -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;
/// <summary>
/// Represents an active notification.
/// </summary>
internal sealed class ActiveNotification : IActiveNotification, IDisposable
{
private readonly Easing showEasing;
private readonly Easing hideEasing;
private Notification underlyingNotification;
/// <summary>
/// Initializes a new instance of the <see cref="ActiveNotification"/> class.
/// </summary>
/// <param name="underlyingNotification">The underlying notification.</param>
/// <param name="initiatorPlugin">The initiator plugin. Use <c>null</c> if originated by Dalamud.</param>
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();
}
/// <inheritdoc/>
public event NotificationDismissedDelegate? Dismiss;
/// <inheritdoc/>
public event Action<IActiveNotification>? Click;
/// <inheritdoc/>
public event Action<IActiveNotification>? DrawActions;
/// <inheritdoc/>
public event Action<IActiveNotification>? MouseEnter;
/// <inheritdoc/>
public event Action<IActiveNotification>? MouseLeave;
/// <inheritdoc/>
public long Id { get; } = IActiveNotification.CreateNewId();
/// <summary>
/// Gets the tick of creating this notification.
/// </summary>
public long CreatedAt { get; } = Environment.TickCount64;
/// <inheritdoc/>
public string Content => this.underlyingNotification.Content;
/// <inheritdoc/>
public string? Title => this.underlyingNotification.Title;
/// <inheritdoc/>
public NotificationType Type => this.underlyingNotification.Type;
/// <inheritdoc/>
public Func<Task<object>>? IconCreator => this.underlyingNotification.IconCreator;
/// <inheritdoc/>
public DateTime Expiry => this.underlyingNotification.Expiry;
/// <inheritdoc/>
public bool Interactible => this.underlyingNotification.Interactible;
/// <inheritdoc/>
public bool ClickIsDismiss => this.underlyingNotification.ClickIsDismiss;
/// <inheritdoc/>
public TimeSpan HoverExtendDuration => this.underlyingNotification.HoverExtendDuration;
/// <inheritdoc/>
public bool IsMouseHovered { get; private set; }
/// <inheritdoc/>
public bool IsDismissed => this.hideEasing.IsRunning;
/// <summary>
/// Gets or sets the plugin that initiated this notification.
/// </summary>
public LocalPlugin? InitiatorPlugin { get; set; }
/// <summary>
/// Gets or sets the icon of this notification.
/// </summary>
public Task<object>? IconTask { get; set; }
/// <summary>
/// Gets the default color of the notification.
/// </summary>
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,
};
/// <summary>
/// Gets the default icon of the notification.
/// </summary>
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,
};
/// <summary>
/// Gets the default title of the notification.
/// </summary>
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,
};
/// <inheritdoc/>
public void Dispose()
{
this.ClearIconTask();
this.underlyingNotification.IconCreator = null;
this.Dismiss = null;
this.Click = null;
this.DrawActions = null;
this.InitiatorPlugin = null;
}
/// <inheritdoc/>
public Notification CloneNotification() => this.underlyingNotification with { };
/// <inheritdoc/>
public void DismissNow() => this.DismissNow(NotificationDismissReason.Programmatical);
/// <summary>
/// Dismisses this notification. Multiple calls will be ignored.
/// </summary>
/// <param name="reason">The reason of dismissal.</param>
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}");
}
}
/// <summary>
/// Updates animations.
/// </summary>
/// <returns><c>true</c> if the notification is over.</returns>
public bool UpdateAnimations()
{
this.showEasing.Update();
this.hideEasing.Update();
return this.hideEasing.IsRunning && this.hideEasing.IsDone;
}
/// <summary>
/// Draws this notification.
/// </summary>
/// <param name="maxWidth">The maximum width of the notification window.</param>
/// <param name="offsetY">The offset from the bottom.</param>
/// <returns>The height of the notification.</returns>
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<NotificationManager>.Get();
var interfaceManager = Service<InterfaceManager>.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;
}
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
public void UpdateIcon()
{
this.ClearIconTask();
this.IconTask = this.IconCreator?.Invoke();
}
/// <summary>
/// Removes non-Dalamud invocation targets from events.
/// </summary>
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>(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
}
}
}
}

View file

@ -0,0 +1,74 @@
using System.Numerics;
using Dalamud.Interface.Utility;
namespace Dalamud.Interface.Internal.Notifications;
/// <summary>
/// Constants for drawing notification windows.
/// </summary>
internal static class NotificationConstants
{
// ..............................[X]
// ..[i]..title title title title ..
// .. by this_plugin ..
// .. ..
// .. body body body body ..
// .. some more wrapped body ..
// .. ..
// .. action buttons ..
// .................................
/// <summary>The string to show in place of this_plugin if the notification is shown by Dalamud.</summary>
public const string DefaultInitiator = "Dalamud";
/// <summary>The size of the icon.</summary>
public const float IconSize = 32;
/// <summary>The background opacity of a notification window.</summary>
public const float BackgroundOpacity = 0.82f;
/// <summary>Duration of show animation.</summary>
public static readonly TimeSpan ShowAnimationDuration = TimeSpan.FromMilliseconds(300);
/// <summary>Default duration of the notification.</summary>
public static readonly TimeSpan DefaultDisplayDuration = TimeSpan.FromSeconds(3);
/// <summary>Default duration of the notification.</summary>
public static readonly TimeSpan DefaultHoverExtendDuration = TimeSpan.FromSeconds(3);
/// <summary>Duration of hide animation.</summary>
public static readonly TimeSpan HideAnimationDuration = TimeSpan.FromMilliseconds(300);
/// <summary>Text color for the close button [X].</summary>
public static readonly Vector4 CloseTextColor = new(0.8f, 0.8f, 0.8f, 1f);
/// <summary>Text color for the title.</summary>
public static readonly Vector4 TitleTextColor = new(1f, 1f, 1f, 1f);
/// <summary>Text color for the name of the initiator.</summary>
public static readonly Vector4 BlameTextColor = new(0.8f, 0.8f, 0.8f, 1f);
/// <summary>Text color for the body.</summary>
public static readonly Vector4 BodyTextColor = new(0.9f, 0.9f, 0.9f, 1f);
/// <summary>Gets the scaled padding of the window (dot(.) in the above diagram).</summary>
public static float ScaledWindowPadding => MathF.Round(16 * ImGuiHelpers.GlobalScale);
/// <summary>Gets the distance from the right bottom border of the viewport
/// to the right bottom border of a notification window.
/// </summary>
public static float ScaledViewportEdgeMargin => MathF.Round(20 * ImGuiHelpers.GlobalScale);
/// <summary>Gets the scaled gap between two notification windows.</summary>
public static float ScaledWindowGap => MathF.Round(10 * ImGuiHelpers.GlobalScale);
/// <summary>Gets the scaled gap between components.</summary>
public static float ScaledComponentGap => MathF.Round(5 * ImGuiHelpers.GlobalScale);
/// <summary>Gets the scaled size of the icon.</summary>
public static float ScaledIconSize => MathF.Round(IconSize * ImGuiHelpers.GlobalScale);
/// <summary>Gets the scaled size of the close button.</summary>
public static float ScaledCloseButtonMinSize => MathF.Round(16 * ImGuiHelpers.GlobalScale);
}

View file

@ -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.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.EarlyLoadedService]
internal class NotificationManager : IServiceType
internal class NotificationManager : INotificationManager, IServiceType, IDisposable
{
/// <summary>
/// Value indicating the bottom-left X padding.
/// </summary>
internal const float NotifyPaddingX = 20.0f;
/// <summary>
/// Value indicating the bottom-left Y padding.
/// </summary>
internal const float NotifyPaddingY = 20.0f;
/// <summary>
/// Value indicating the Y padding between each message.
/// </summary>
internal const float NotifyPaddingMessageY = 10.0f;
/// <summary>
/// Value indicating the fade-in and out duration.
/// </summary>
internal const int NotifyFadeInOutTime = 500;
/// <summary>
/// Value indicating the default time until the notification is dismissed.
/// </summary>
internal const int NotifyDefaultDismiss = 3000;
/// <summary>
/// Value indicating the maximum opacity.
/// </summary>
internal const float NotifyOpacity = 0.82f;
/// <summary>
/// Value indicating default window flags for the notifications.
/// </summary>
internal const ImGuiWindowFlags NotifyToastFlags =
ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoInputs |
ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoBringToFrontOnFocus | ImGuiWindowFlags.NoFocusOnAppearing;
private readonly List<Notification> notifications = new();
private readonly List<ActiveNotification> notifications = new();
private readonly ConcurrentBag<ActiveNotification> 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 })));
}
/// <summary>Gets the handle to AXIS fonts, sized for use as an icon.</summary>
public IFontHandle IconAxisFontHandle { get; }
/// <summary>Gets the handle to FontAwesome fonts, sized for use as an icon.</summary>
public IFontHandle IconFontAwesomeFontHandle { get; }
private IFontAtlas PrivateAtlas { get; }
/// <inheritdoc/>
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();
}
/// <inheritdoc/>
public IActiveNotification AddNotification(Notification notification)
{
var an = new ActiveNotification(notification, null);
this.pendingNotifications.Add(an);
return an;
}
/// <summary>
/// Adds a notification originating from a plugin.
/// </summary>
/// <param name="notification">The notification.</param>
/// <param name="plugin">The source plugin.</param>
/// <returns>The new notification.</returns>
public IActiveNotification AddNotification(Notification notification, LocalPlugin plugin)
{
var an = new ActiveNotification(notification, plugin);
this.pendingNotifications.Add(an);
return an;
}
/// <summary>
@ -67,252 +85,77 @@ internal class NotificationManager : IServiceType
/// <param name="content">The content of the notification.</param>
/// <param name="title">The title of the notification.</param>
/// <param name="type">The type of the notification.</param>
/// <param name="msDelay">The time the notification should be displayed for.</param>
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,
});
/// <summary>
/// Draw all currently queued notifications.
/// </summary>
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;
/// <summary>
/// Plugin-scoped version of a <see cref="NotificationManager"/> service.
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.ScopedService]
#pragma warning disable SA1015
[ResolveVia<INotificationManager>]
#pragma warning restore SA1015
internal class NotificationManagerPluginScoped : INotificationManager, IServiceType, IDisposable
{
private readonly LocalPlugin localPlugin;
private readonly ConcurrentDictionary<IActiveNotification, int> notifications = new();
var windowName = $"##NOTIFY{i}";
[ServiceManager.ServiceDependency]
private readonly NotificationManager notificationManagerService = Service<NotificationManager>.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();
}
/// <inheritdoc/>
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;
}
/// <summary>
/// Container class for notifications.
/// </summary>
internal class Notification
/// <inheritdoc/>
public void Dispose()
{
/// <summary>
/// Possible notification phases.
/// </summary>
internal enum Phase
while (!this.notifications.IsEmpty)
{
/// <summary>
/// Phase indicating fade-in.
/// </summary>
FadeIn,
/// <summary>
/// Phase indicating waiting until fade-out.
/// </summary>
Wait,
/// <summary>
/// Phase indicating fade-out.
/// </summary>
FadeOut,
/// <summary>
/// Phase indicating that the notification has expired.
/// </summary>
Expired,
}
/// <summary>
/// Gets the type of the notification.
/// </summary>
internal NotificationType NotificationType { get; init; }
/// <summary>
/// Gets the title of the notification.
/// </summary>
internal string? Title { get; init; }
/// <summary>
/// Gets the content of the notification.
/// </summary>
internal string Content { get; init; }
/// <summary>
/// Gets the duration of the notification in milliseconds.
/// </summary>
internal uint DurationMs { get; init; }
/// <summary>
/// Gets the creation time of the notification.
/// </summary>
internal DateTime CreationTime { get; init; } = DateTime.Now;
/// <summary>
/// Gets the default color of the notification.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <see cref="NotificationType"/> is set to an out-of-range value.</exception>
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(),
};
/// <summary>
/// Gets the icon of the notification.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <see cref="NotificationType"/> is set to an out-of-range value.</exception>
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(),
};
/// <summary>
/// Gets the default title of the notification.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <see cref="NotificationType"/> is set to an out-of-range value.</exception>
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(),
};
/// <summary>
/// Gets the elapsed time since creating the notification.
/// </summary>
internal TimeSpan ElapsedTime => DateTime.Now - this.CreationTime;
/// <summary>
/// Gets the phase of the notification.
/// </summary>
/// <returns>The phase of the notification.</returns>
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;
}
/// <summary>
/// Gets the opacity of the notification.
/// </summary>
/// <returns>The opacity, in a range from 0 to 1.</returns>
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;
}
}
}

View file

@ -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,
};
}
}

View file

@ -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;
/// </summary>
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<InterfaceManager>.Get();
private readonly Framework framework = Service<Framework>.Get();
private readonly ConcurrentDictionary<IActiveNotification, int> notifications = new();
[ServiceManager.ServiceDependency]
private readonly DalamudConfiguration configuration = Service<DalamudConfiguration>.Get();
@ -52,8 +57,10 @@ public sealed class UiBuilder : IDisposable
/// You do not have to call this manually.
/// </summary>
/// <param name="namespaceName">The plugin namespace.</param>
internal UiBuilder(string namespaceName)
/// <param name="localPlugin">The relevant local plugin.</param>
internal UiBuilder(string namespaceName, LocalPlugin localPlugin)
{
this.localPlugin = localPlugin;
try
{
this.stopwatch = new Stopwatch();
@ -556,22 +563,46 @@ public sealed class UiBuilder : IDisposable
/// <param name="title">The title of the notification.</param>
/// <param name="type">The type of the notification.</param>
/// <param name="msDelay">The time the notification should be displayed for.</param>
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<NotificationManager>
.GetAsync()
.ContinueWith(task =>
var nm = await Service<NotificationManager>.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 _);
}
/// <summary>
/// Unregister the UiBuilder. Do not call this in plugin code.
/// </summary>
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();
}
}
}
/// <summary>
/// Open the registered configuration UI, if it exists.

View file

@ -52,7 +52,7 @@ public sealed class DalamudPluginInterface : IDisposable
var dataManager = Service<DataManager>.Get();
var localization = Service<Localization>.Get();
this.UiBuilder = new UiBuilder(plugin.Name);
this.UiBuilder = new UiBuilder(plugin.Name, plugin);
this.configs = Service<PluginManager>.Get().PluginConfigs;
this.Reason = reason;

View file

@ -0,0 +1,16 @@
using Dalamud.Interface.ImGuiNotification;
namespace Dalamud.Plugin.Services;
/// <summary>
/// Manager for notifications provided by Dalamud using ImGui.
/// </summary>
public interface INotificationManager
{
/// <summary>
/// Adds a notification.
/// </summary>
/// <param name="notification">The new notification.</param>
/// <returns>The added notification.</returns>
IActiveNotification AddNotification(Notification notification);
}