diff --git a/Dalamud/Game/ChatHandlers.cs b/Dalamud/Game/ChatHandlers.cs
index 836fb5ec8..5dd6ed3ba 100644
--- a/Dalamud/Game/ChatHandlers.cs
+++ b/Dalamud/Game/ChatHandlers.cs
@@ -11,6 +11,7 @@ using Dalamud.Game.Gui;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
+using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Internal.Windows;
diff --git a/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationClickArgs.cs b/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationClickArgs.cs
new file mode 100644
index 000000000..b85a96004
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationClickArgs.cs
@@ -0,0 +1,9 @@
+namespace Dalamud.Interface.ImGuiNotification.EventArgs;
+
+/// Arguments for use with .
+/// Not to be implemented by plugins.
+public interface INotificationClickArgs
+{
+ /// Gets the notification being clicked.
+ IActiveNotification Notification { get; }
+}
diff --git a/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationDismissArgs.cs b/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationDismissArgs.cs
new file mode 100644
index 000000000..7f664efa1
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationDismissArgs.cs
@@ -0,0 +1,12 @@
+namespace Dalamud.Interface.ImGuiNotification.EventArgs;
+
+/// Arguments for use with .
+/// Not to be implemented by plugins.
+public interface INotificationDismissArgs
+{
+ /// Gets the notification being dismissed.
+ IActiveNotification Notification { get; }
+
+ /// Gets the dismiss reason.
+ NotificationDismissReason Reason { get; }
+}
diff --git a/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationDrawArgs.cs b/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationDrawArgs.cs
new file mode 100644
index 000000000..221f769e0
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationDrawArgs.cs
@@ -0,0 +1,19 @@
+using System.Numerics;
+
+namespace Dalamud.Interface.ImGuiNotification.EventArgs;
+
+/// Arguments for use with .
+/// Not to be implemented by plugins.
+public interface INotificationDrawArgs
+{
+ /// Gets the notification being drawn.
+ IActiveNotification Notification { get; }
+
+ /// Gets the top left coordinates of the area being drawn.
+ Vector2 MinCoord { get; }
+
+ /// Gets the bottom right coordinates of the area being drawn.
+ /// Note that can be , in which case there is no
+ /// vertical limits to the drawing region.
+ Vector2 MaxCoord { get; }
+}
diff --git a/Dalamud/Interface/ImGuiNotification/IActiveNotification.cs b/Dalamud/Interface/ImGuiNotification/IActiveNotification.cs
new file mode 100644
index 000000000..e677471b4
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/IActiveNotification.cs
@@ -0,0 +1,83 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+using Dalamud.Interface.ImGuiNotification.EventArgs;
+using Dalamud.Interface.Internal;
+
+namespace Dalamud.Interface.ImGuiNotification;
+
+/// Represents an active notification.
+/// Not to be implemented by plugins.
+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 it gets dismissed after plugin unload.
+ event Action Dismiss;
+
+ /// Invoked upon clicking on the notification.
+ /// Note that this function may be called even after has been invoked.
+ event Action Click;
+
+ /// Invoked upon drawing the action bar of the notification.
+ /// Note that this function may be called even after has been invoked.
+ event Action DrawActions;
+
+ /// Gets the ID of this notification.
+ /// This value does not change.
+ long Id { get; }
+
+ /// Gets the time of creating this notification.
+ /// This value does not change.
+ DateTime CreatedAt { get; }
+
+ /// Gets the effective expiry time.
+ /// Contains if the notification does not expire.
+ /// This value will change depending on property changes and user interactions.
+ DateTime EffectiveExpiry { get; }
+
+ /// Gets the reason how this notification got dismissed. null if not dismissed.
+ /// This includes when the hide animation is being played.
+ NotificationDismissReason? DismissReason { get; }
+
+ /// Dismisses this notification.
+ /// If the notification has already been dismissed, this function does nothing.
+ void DismissNow();
+
+ /// Extends this notifiation.
+ /// The extension time.
+ /// This does not override .
+ void ExtendBy(TimeSpan extension);
+
+ /// Sets the icon from , overriding the icon.
+ /// The new texture wrap to use, or null to clear and revert back to the icon specified
+ /// from .
+ ///
+ /// The texture passed will be disposed when the notification is dismissed or a new different texture is set
+ /// via another call to this function. You do not have to dispose it yourself.
+ /// If is not null, then calling this function will simply dispose the
+ /// passed without actually updating the icon.
+ ///
+ void SetIconTexture(IDalamudTextureWrap? textureWrap);
+
+ /// Sets the icon from , overriding the icon, once the given task
+ /// completes.
+ /// The task that will result in a new texture wrap to use, or null to clear and
+ /// revert back to the icon specified from .
+ ///
+ /// The texture resulted from the passed will be disposed when the notification
+ /// is dismissed or a new different texture is set via another call to this function. You do not have to dispose the
+ /// resulted instance of yourself.
+ /// If the task fails for any reason, the exception will be silently ignored and the icon specified from
+ /// will be used instead.
+ /// If is not null, then calling this function will simply dispose the
+ /// result of the passed without actually updating the icon.
+ ///
+ void SetIconTexture(Task? textureWrapTask);
+
+ /// 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..f9a043c0b
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/INotification.cs
@@ -0,0 +1,76 @@
+using System.Threading.Tasks;
+
+using Dalamud.Interface.Internal;
+using Dalamud.Interface.Internal.Notifications;
+using Dalamud.Plugin.Services;
+
+namespace Dalamud.Interface.ImGuiNotification;
+
+/// Represents a notification.
+/// Not to be implemented by plugins.
+public interface INotification
+{
+ /// Gets or sets the content body of the notification.
+ string Content { get; set; }
+
+ /// Gets or sets the title of the notification.
+ string? Title { get; set; }
+
+ /// Gets or sets the text to display when the notification is minimized.
+ string? MinimizedText { get; set; }
+
+ /// Gets or sets the type of the notification.
+ NotificationType Type { get; set; }
+
+ /// Gets or sets the icon source.
+ /// Use or
+ /// to use a texture, after calling
+ /// . Call either of those functions with null to revert
+ /// the effective icon back to this property.
+ INotificationIcon? Icon { get; set; }
+
+ /// Gets or sets the hard expiry.
+ ///
+ /// Setting this value will override and , in that
+ /// the notification will be dismissed when this expiry expires.
+ /// Set to to make only take effect.
+ /// If neither nor is not MaxValue, then the notification
+ /// will not expire after a set time. It must be explicitly dismissed by the user of via calling
+ /// .
+ /// Updating this value will reset the dismiss timer.
+ ///
+ DateTime HardExpiry { get; set; }
+
+ /// Gets or sets the initial duration.
+ /// Set to to make only take effect.
+ /// Updating this value will reset the dismiss timer, but the remaining duration will still be calculated
+ /// based on .
+ TimeSpan InitialDuration { get; set; }
+
+ /// Gets or sets the new duration for this notification once the mouse cursor leaves the window and the
+ /// window is no longer focused.
+ ///
+ /// If set to or less, then this feature is turned off, and hovering the mouse on the
+ /// notification or focusing on it will not make the notification stay.
+ /// Updating this value will reset the dismiss timer.
+ ///
+ TimeSpan ExtensionDurationSinceLastInterest { get; set; }
+
+ /// Gets or sets a value indicating whether to show an indeterminate expiration animation if
+ /// is set to .
+ bool ShowIndeterminateIfNoExpiry { get; set; }
+
+ /// Gets or sets a value indicating whether to respect the current UI visibility state.
+ bool RespectUiHidden { get; set; }
+
+ /// Gets or sets a value indicating whether the notification has been minimized.
+ bool Minimized { get; set; }
+
+ /// Gets or sets a value indicating whether the user can dismiss the notification by themselves.
+ /// Consider adding a cancel button to .
+ bool UserDismissable { get; set; }
+
+ /// Gets or sets the progress for the background progress bar of the notification.
+ /// The progress should be in the range between 0 and 1.
+ float Progress { get; set; }
+}
diff --git a/Dalamud/Interface/ImGuiNotification/INotificationIcon.cs b/Dalamud/Interface/ImGuiNotification/INotificationIcon.cs
new file mode 100644
index 000000000..94c746b4f
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/INotificationIcon.cs
@@ -0,0 +1,54 @@
+using System.Numerics;
+using System.Runtime.CompilerServices;
+
+using Dalamud.Game.Text;
+using Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon;
+
+namespace Dalamud.Interface.ImGuiNotification;
+
+/// Icon source for .
+/// Plugins implementing this interface are left to their own on managing the resources contained by the
+/// instance of their implementation of . In other words, they should not expect to have
+/// called if their implementation is an . Dalamud will not
+/// call on any instance of . On plugin unloads, the
+/// icon may be reverted back to the default, if the instance of is not provided by
+/// Dalamud.
+public interface INotificationIcon
+{
+ /// Gets a new instance of that will source the icon from an
+ /// .
+ /// The icon character.
+ /// A new instance of that should be disposed after use.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static INotificationIcon From(SeIconChar iconChar) => new SeIconCharNotificationIcon(iconChar);
+
+ /// Gets a new instance of that will source the icon from an
+ /// .
+ /// The icon character.
+ /// A new instance of that should be disposed after use.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static INotificationIcon From(FontAwesomeIcon iconChar) => new FontAwesomeIconNotificationIcon(iconChar);
+
+ /// Gets a new instance of that will source the icon from a texture
+ /// file shipped as a part of the game resources.
+ /// The path to a texture file in the game virtual file system.
+ /// A new instance of that should be disposed after use.
+ /// If any errors are thrown, the default icon will be displayed instead.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static INotificationIcon FromGame(string gamePath) => new GamePathNotificationIcon(gamePath);
+
+ /// Gets a new instance of that will source the icon from an image
+ /// file from the file system.
+ /// The path to an image file in the file system.
+ /// A new instance of that should be disposed after use.
+ /// If any errors are thrown, the default icon will be displayed instead.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static INotificationIcon FromFile(string filePath) => new FilePathNotificationIcon(filePath);
+
+ /// Draws the icon.
+ /// The coordinates of the top left of the icon area.
+ /// The coordinates of the bottom right of the icon area.
+ /// The foreground color.
+ /// true if anything has been drawn.
+ bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color);
+}
diff --git a/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.EventArgs.cs b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.EventArgs.cs
new file mode 100644
index 000000000..428d9103f
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.EventArgs.cs
@@ -0,0 +1,87 @@
+using System.Numerics;
+
+using Dalamud.Interface.ImGuiNotification.EventArgs;
+
+namespace Dalamud.Interface.ImGuiNotification.Internal;
+
+/// Represents an active notification.
+internal sealed partial class ActiveNotification : INotificationDismissArgs
+{
+ ///
+ public event Action? Dismiss;
+
+ ///
+ IActiveNotification INotificationDismissArgs.Notification => this;
+
+ ///
+ NotificationDismissReason INotificationDismissArgs.Reason =>
+ this.DismissReason
+ ?? throw new InvalidOperationException("DismissReason must be set before using INotificationDismissArgs");
+
+ private void InvokeDismiss()
+ {
+ try
+ {
+ this.Dismiss?.Invoke(this);
+ }
+ catch (Exception e)
+ {
+ this.LogEventInvokeError(e, $"{nameof(this.Dismiss)} error");
+ }
+ }
+}
+
+/// Represents an active notification.
+internal sealed partial class ActiveNotification : INotificationClickArgs
+{
+ ///
+ public event Action? Click;
+
+ ///
+ IActiveNotification INotificationClickArgs.Notification => this;
+
+ private void InvokeClick()
+ {
+ try
+ {
+ this.Click?.Invoke(this);
+ }
+ catch (Exception e)
+ {
+ this.LogEventInvokeError(e, $"{nameof(this.Click)} error");
+ }
+ }
+}
+
+/// Represents an active notification.
+internal sealed partial class ActiveNotification : INotificationDrawArgs
+{
+ private Vector2 drawActionArgMinCoord;
+ private Vector2 drawActionArgMaxCoord;
+
+ ///
+ public event Action? DrawActions;
+
+ ///
+ IActiveNotification INotificationDrawArgs.Notification => this;
+
+ ///
+ Vector2 INotificationDrawArgs.MinCoord => this.drawActionArgMinCoord;
+
+ ///
+ Vector2 INotificationDrawArgs.MaxCoord => this.drawActionArgMaxCoord;
+
+ private void InvokeDrawActions(Vector2 minCoord, Vector2 maxCoord)
+ {
+ this.drawActionArgMinCoord = minCoord;
+ this.drawActionArgMaxCoord = maxCoord;
+ try
+ {
+ this.DrawActions?.Invoke(this);
+ }
+ catch (Exception e)
+ {
+ this.LogEventInvokeError(e, $"{nameof(this.DrawActions)} error; event registration cancelled");
+ }
+ }
+}
diff --git a/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs
new file mode 100644
index 000000000..d4a08ff69
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs
@@ -0,0 +1,500 @@
+using System.Numerics;
+
+using Dalamud.Interface.Internal;
+using Dalamud.Interface.Utility;
+using Dalamud.Utility;
+
+using ImGuiNET;
+
+namespace Dalamud.Interface.ImGuiNotification.Internal;
+
+/// Represents an active notification.
+internal sealed partial class ActiveNotification
+{
+ /// Draws this notification.
+ /// The maximum width of the notification window.
+ /// The offset from the bottom.
+ /// The height of the notification.
+ public float Draw(float width, float offsetY)
+ {
+ 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 actionWindowHeight =
+ // Content
+ ImGui.GetTextLineHeight() +
+ // Top and bottom padding
+ (NotificationConstants.ScaledWindowPadding * 2);
+
+ var viewport = ImGuiHelpers.MainViewport;
+ var viewportPos = viewport.WorkPos;
+ var viewportSize = viewport.WorkSize;
+
+ ImGui.PushID(this.Id.GetHashCode());
+ ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity);
+ ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 0f);
+ ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(NotificationConstants.ScaledWindowPadding));
+ unsafe
+ {
+ ImGui.PushStyleColor(
+ ImGuiCol.WindowBg,
+ *ImGui.GetStyleColorVec4(ImGuiCol.WindowBg) * new Vector4(
+ 1f,
+ 1f,
+ 1f,
+ NotificationConstants.BackgroundOpacity));
+ }
+
+ ImGuiHelpers.ForceNextWindowMainViewport();
+ ImGui.SetNextWindowPos(
+ (viewportPos + viewportSize) -
+ new Vector2(NotificationConstants.ScaledViewportEdgeMargin) -
+ new Vector2(0, offsetY),
+ ImGuiCond.Always,
+ Vector2.One);
+ ImGui.SetNextWindowSizeConstraints(
+ new(width, actionWindowHeight),
+ new(
+ width,
+ !this.underlyingNotification.Minimized || this.expandoEasing.IsRunning
+ ? float.MaxValue
+ : actionWindowHeight));
+ ImGui.Begin(
+ $"##NotifyMainWindow{this.Id}",
+ ImGuiWindowFlags.AlwaysAutoResize |
+ ImGuiWindowFlags.NoDecoration |
+ ImGuiWindowFlags.NoNav |
+ ImGuiWindowFlags.NoMove |
+ ImGuiWindowFlags.NoFocusOnAppearing |
+ ImGuiWindowFlags.NoDocking);
+
+ var isFocused = ImGui.IsWindowFocused();
+ var isHovered = ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem);
+ var isTakingKeyboardInput = isFocused && ImGui.GetIO().WantTextInput;
+ var warrantsExtension =
+ this.ExtensionDurationSinceLastInterest > TimeSpan.Zero
+ && (isHovered || isTakingKeyboardInput);
+
+ this.EffectiveExpiry = this.CalculateEffectiveExpiry(ref warrantsExtension);
+
+ if (!isTakingKeyboardInput && !isHovered && isFocused)
+ {
+ ImGui.SetWindowFocus(null);
+ isFocused = false;
+ }
+
+ if (DateTime.Now > this.EffectiveExpiry)
+ this.DismissNow(NotificationDismissReason.Timeout);
+
+ if (this.ExtensionDurationSinceLastInterest > TimeSpan.Zero && warrantsExtension)
+ this.lastInterestTime = DateTime.Now;
+
+ this.DrawWindowBackgroundProgressBar();
+ this.DrawTopBar(width, actionWindowHeight, isHovered);
+ if (!this.underlyingNotification.Minimized && !this.expandoEasing.IsRunning)
+ {
+ this.DrawContentAndActions(width, actionWindowHeight);
+ }
+ else if (this.expandoEasing.IsRunning)
+ {
+ if (this.underlyingNotification.Minimized)
+ ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity * (1f - (float)this.expandoEasing.Value));
+ else
+ ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity * (float)this.expandoEasing.Value);
+ this.DrawContentAndActions(width, actionWindowHeight);
+ ImGui.PopStyleVar();
+ }
+
+ if (isFocused)
+ this.DrawFocusIndicator();
+ this.DrawExpiryBar(this.EffectiveExpiry, warrantsExtension);
+
+ if (ImGui.IsWindowHovered())
+ {
+ if (this.Click is null)
+ {
+ if (this.UserDismissable && ImGui.IsMouseClicked(ImGuiMouseButton.Left))
+ this.DismissNow(NotificationDismissReason.Manual);
+ }
+ else
+ {
+ if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)
+ || ImGui.IsMouseClicked(ImGuiMouseButton.Right)
+ || ImGui.IsMouseClicked(ImGuiMouseButton.Middle))
+ this.InvokeClick();
+ }
+ }
+
+ var windowSize = ImGui.GetWindowSize();
+ ImGui.End();
+
+ ImGui.PopStyleColor();
+ ImGui.PopStyleVar(3);
+ ImGui.PopID();
+
+ return windowSize.Y;
+ }
+
+ /// Calculates the effective expiry, taking ImGui window state into account.
+ /// Notification will not dismiss while this paramter is true.
+ /// The calculated effective expiry.
+ /// Expected to be called BETWEEN and .
+ private DateTime CalculateEffectiveExpiry(ref bool warrantsExtension)
+ {
+ DateTime expiry;
+ var initialDuration = this.InitialDuration;
+ var expiryInitial =
+ initialDuration == TimeSpan.MaxValue
+ ? DateTime.MaxValue
+ : this.CreatedAt + initialDuration;
+
+ var extendDuration = this.ExtensionDurationSinceLastInterest;
+ if (warrantsExtension)
+ {
+ expiry = DateTime.MaxValue;
+ }
+ else
+ {
+ var expiryExtend =
+ extendDuration == TimeSpan.MaxValue
+ ? DateTime.MaxValue
+ : this.lastInterestTime + extendDuration;
+
+ expiry = expiryInitial > expiryExtend ? expiryInitial : expiryExtend;
+ if (expiry < this.extendedExpiry)
+ expiry = this.extendedExpiry;
+ }
+
+ var he = this.HardExpiry;
+ if (he < expiry)
+ {
+ expiry = he;
+ warrantsExtension = false;
+ }
+
+ return expiry;
+ }
+
+ private void DrawWindowBackgroundProgressBar()
+ {
+ var elapsed = (float)(((DateTime.Now - this.CreatedAt).TotalMilliseconds %
+ NotificationConstants.ProgressWaveLoopDuration) /
+ NotificationConstants.ProgressWaveLoopDuration);
+ elapsed /= NotificationConstants.ProgressWaveIdleTimeRatio;
+
+ var colorElapsed =
+ elapsed < NotificationConstants.ProgressWaveLoopMaxColorTimeRatio
+ ? elapsed / NotificationConstants.ProgressWaveLoopMaxColorTimeRatio
+ : ((NotificationConstants.ProgressWaveLoopMaxColorTimeRatio * 2) - elapsed) /
+ NotificationConstants.ProgressWaveLoopMaxColorTimeRatio;
+
+ elapsed = Math.Clamp(elapsed, 0f, 1f);
+ colorElapsed = Math.Clamp(colorElapsed, 0f, 1f);
+ colorElapsed = MathF.Sin(colorElapsed * (MathF.PI / 2f));
+
+ var progress = Math.Clamp(this.ProgressEased, 0f, 1f);
+ if (progress >= 1f)
+ elapsed = colorElapsed = 0f;
+
+ var windowPos = ImGui.GetWindowPos();
+ var windowSize = ImGui.GetWindowSize();
+ var rb = windowPos + windowSize;
+ var midp = windowPos + windowSize with { X = windowSize.X * progress * elapsed };
+ var rp = windowPos + windowSize with { X = windowSize.X * progress };
+
+ ImGui.PushClipRect(windowPos, rb, false);
+ ImGui.GetWindowDrawList().AddRectFilled(
+ windowPos,
+ midp,
+ ImGui.GetColorU32(
+ Vector4.Lerp(
+ NotificationConstants.BackgroundProgressColorMin,
+ NotificationConstants.BackgroundProgressColorMax,
+ colorElapsed)));
+ ImGui.GetWindowDrawList().AddRectFilled(
+ midp with { Y = 0 },
+ rp,
+ ImGui.GetColorU32(NotificationConstants.BackgroundProgressColorMin));
+ ImGui.PopClipRect();
+ }
+
+ private void DrawFocusIndicator()
+ {
+ var windowPos = ImGui.GetWindowPos();
+ var windowSize = ImGui.GetWindowSize();
+ ImGui.PushClipRect(windowPos, windowPos + windowSize, false);
+ ImGui.GetWindowDrawList().AddRect(
+ windowPos,
+ windowPos + windowSize,
+ ImGui.GetColorU32(NotificationConstants.FocusBorderColor * new Vector4(1f, 1f, 1f, ImGui.GetStyle().Alpha)),
+ 0f,
+ ImDrawFlags.None,
+ NotificationConstants.FocusIndicatorThickness);
+ ImGui.PopClipRect();
+ }
+
+ private void DrawTopBar(float width, float height, bool drawActionButtons)
+ {
+ var windowPos = ImGui.GetWindowPos();
+ var windowSize = ImGui.GetWindowSize();
+
+ var rtOffset = new Vector2(width, 0);
+ using (Service.Get().IconFontHandle?.Push())
+ {
+ ImGui.PushClipRect(windowPos, windowPos + windowSize with { Y = height }, false);
+ if (this.UserDismissable)
+ {
+ if (this.DrawIconButton(FontAwesomeIcon.Times, rtOffset, height, drawActionButtons))
+ this.DismissNow(NotificationDismissReason.Manual);
+ rtOffset.X -= height;
+ }
+
+ if (this.underlyingNotification.Minimized)
+ {
+ if (this.DrawIconButton(FontAwesomeIcon.ChevronDown, rtOffset, height, drawActionButtons))
+ this.Minimized = false;
+ }
+ else
+ {
+ if (this.DrawIconButton(FontAwesomeIcon.ChevronUp, rtOffset, height, drawActionButtons))
+ this.Minimized = true;
+ }
+
+ rtOffset.X -= height;
+ ImGui.PopClipRect();
+ }
+
+ float relativeOpacity;
+ if (this.expandoEasing.IsRunning)
+ {
+ relativeOpacity =
+ this.underlyingNotification.Minimized
+ ? 1f - (float)this.expandoEasing.Value
+ : (float)this.expandoEasing.Value;
+ }
+ else
+ {
+ relativeOpacity = this.underlyingNotification.Minimized ? 0f : 1f;
+ }
+
+ if (drawActionButtons)
+ ImGui.PushClipRect(windowPos, windowPos + rtOffset with { Y = height }, false);
+ else
+ ImGui.PushClipRect(windowPos, windowPos + windowSize with { Y = height }, false);
+
+ if (relativeOpacity > 0)
+ {
+ ImGui.PushStyleVar(ImGuiStyleVar.Alpha, ImGui.GetStyle().Alpha * relativeOpacity);
+ ImGui.SetCursorPos(new(NotificationConstants.ScaledWindowPadding));
+ ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.WhenTextColor);
+ ImGui.TextUnformatted(
+ ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem)
+ ? this.CreatedAt.LocAbsolute()
+ : this.CreatedAt.LocRelativePastLong());
+ ImGui.PopStyleColor();
+ ImGui.PopStyleVar();
+ }
+
+ if (relativeOpacity < 1)
+ {
+ rtOffset = new(width - NotificationConstants.ScaledWindowPadding, 0);
+ ImGui.PushStyleVar(ImGuiStyleVar.Alpha, ImGui.GetStyle().Alpha * (1f - relativeOpacity));
+
+ var ltOffset = new Vector2(NotificationConstants.ScaledWindowPadding);
+ this.DrawIcon(ltOffset, new(height - (2 * NotificationConstants.ScaledWindowPadding)));
+
+ ltOffset.X = height;
+
+ var agoText = this.CreatedAt.LocRelativePastShort();
+ var agoSize = ImGui.CalcTextSize(agoText);
+ rtOffset.X -= agoSize.X;
+ ImGui.SetCursorPos(rtOffset with { Y = NotificationConstants.ScaledWindowPadding });
+ ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.WhenTextColor);
+ ImGui.TextUnformatted(agoText);
+ ImGui.PopStyleColor();
+
+ rtOffset.X -= NotificationConstants.ScaledWindowPadding;
+
+ ImGui.PushClipRect(
+ windowPos + ltOffset with { Y = 0 },
+ windowPos + rtOffset with { Y = height },
+ true);
+ ImGui.SetCursorPos(ltOffset with { Y = NotificationConstants.ScaledWindowPadding });
+ ImGui.TextUnformatted(this.EffectiveMinimizedText);
+ ImGui.PopClipRect();
+
+ ImGui.PopStyleVar();
+ }
+
+ ImGui.PopClipRect();
+ }
+
+ private bool DrawIconButton(FontAwesomeIcon icon, Vector2 rt, float size, bool drawActionButtons)
+ {
+ ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, Vector2.Zero);
+ if (!drawActionButtons)
+ ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0f);
+ ImGui.PushStyleColor(ImGuiCol.Button, 0);
+ ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.CloseTextColor);
+
+ ImGui.SetCursorPos(rt - new Vector2(size, 0));
+ var r = ImGui.Button(icon.ToIconString(), new(size));
+
+ ImGui.PopStyleColor(2);
+ if (!drawActionButtons)
+ ImGui.PopStyleVar();
+ ImGui.PopStyleVar();
+ return r;
+ }
+
+ private void DrawContentAndActions(float width, float actionWindowHeight)
+ {
+ var textColumnX = (NotificationConstants.ScaledWindowPadding * 2) + NotificationConstants.ScaledIconSize;
+ var textColumnWidth = width - textColumnX - NotificationConstants.ScaledWindowPadding;
+ var textColumnOffset = new Vector2(textColumnX, actionWindowHeight);
+
+ this.DrawIcon(
+ new(NotificationConstants.ScaledWindowPadding, actionWindowHeight),
+ new(NotificationConstants.ScaledIconSize));
+
+ textColumnOffset.Y += this.DrawTitle(textColumnOffset, textColumnWidth);
+ textColumnOffset.Y += NotificationConstants.ScaledComponentGap;
+
+ this.DrawContentBody(textColumnOffset, textColumnWidth);
+
+ if (this.DrawActions is null)
+ return;
+
+ var userActionOffset = new Vector2(
+ NotificationConstants.ScaledWindowPadding,
+ ImGui.GetCursorPosY() + NotificationConstants.ScaledComponentGap);
+ ImGui.SetCursorPos(userActionOffset);
+ this.InvokeDrawActions(
+ userActionOffset,
+ new(width - NotificationConstants.ScaledWindowPadding, float.MaxValue));
+ }
+
+ private void DrawIcon(Vector2 minCoord, Vector2 size)
+ {
+ var maxCoord = minCoord + size;
+ var iconColor = this.Type.ToColor();
+
+ if (NotificationUtilities.DrawIconFrom(minCoord, maxCoord, this.iconTextureWrap))
+ return;
+
+ if (this.Icon?.DrawIcon(minCoord, maxCoord, iconColor) is true)
+ return;
+
+ if (NotificationUtilities.DrawIconFrom(
+ minCoord,
+ maxCoord,
+ this.Type.ToChar(),
+ Service.Get().IconFontAwesomeFontHandle,
+ iconColor))
+ return;
+
+ if (NotificationUtilities.DrawIconFrom(minCoord, maxCoord, this.initiatorPlugin))
+ return;
+
+ NotificationUtilities.DrawIconFromDalamudLogo(minCoord, maxCoord);
+ }
+
+ private float DrawTitle(Vector2 minCoord, float width)
+ {
+ ImGui.PushTextWrapPos(minCoord.X + width);
+
+ ImGui.SetCursorPos(minCoord);
+ if ((this.Title ?? this.Type.ToTitle()) is { } title)
+ {
+ ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.TitleTextColor);
+ ImGui.TextUnformatted(title);
+ ImGui.PopStyleColor();
+ }
+
+ ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.BlameTextColor);
+ ImGui.SetCursorPos(minCoord with { Y = ImGui.GetCursorPosY() });
+ ImGui.TextUnformatted(this.InitiatorString);
+ ImGui.PopStyleColor();
+
+ ImGui.PopTextWrapPos();
+ return ImGui.GetCursorPosY() - minCoord.Y;
+ }
+
+ private void DrawContentBody(Vector2 minCoord, float width)
+ {
+ ImGui.SetCursorPos(minCoord);
+ ImGui.PushTextWrapPos(minCoord.X + width);
+ ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.BodyTextColor);
+ ImGui.TextUnformatted(this.Content);
+ ImGui.PopStyleColor();
+ ImGui.PopTextWrapPos();
+ }
+
+ private void DrawExpiryBar(DateTime effectiveExpiry, bool warrantsExtension)
+ {
+ float barL, barR;
+ if (this.DismissReason is not null)
+ {
+ var v = this.hideEasing.IsDone ? 0f : 1f - (float)this.hideEasing.Value;
+ var midpoint = (this.prevProgressL + this.prevProgressR) / 2f;
+ var length = (this.prevProgressR - this.prevProgressL) / 2f;
+ barL = midpoint - (length * v);
+ barR = midpoint + (length * v);
+ }
+ else if (warrantsExtension)
+ {
+ barL = 0f;
+ barR = 1f;
+ this.prevProgressL = barL;
+ this.prevProgressR = barR;
+ }
+ else if (effectiveExpiry == DateTime.MaxValue)
+ {
+ if (this.ShowIndeterminateIfNoExpiry)
+ {
+ var elapsed = (float)(((DateTime.Now - this.CreatedAt).TotalMilliseconds %
+ NotificationConstants.IndeterminateProgressbarLoopDuration) /
+ NotificationConstants.IndeterminateProgressbarLoopDuration);
+ barL = Math.Max(elapsed - (1f / 3), 0f) / (2f / 3);
+ barR = Math.Min(elapsed, 2f / 3) / (2f / 3);
+ barL = MathF.Pow(barL, 3);
+ barR = 1f - MathF.Pow(1f - barR, 3);
+ this.prevProgressL = barL;
+ this.prevProgressR = barR;
+ }
+ else
+ {
+ this.prevProgressL = barL = 0f;
+ this.prevProgressR = barR = 1f;
+ }
+ }
+ else
+ {
+ barL = 1f - (float)((effectiveExpiry - DateTime.Now).TotalMilliseconds /
+ (effectiveExpiry - this.lastInterestTime).TotalMilliseconds);
+ barR = 1f;
+ this.prevProgressL = barL;
+ this.prevProgressR = barR;
+ }
+
+ barR = Math.Clamp(barR, 0f, 1f);
+
+ var windowPos = ImGui.GetWindowPos();
+ var windowSize = ImGui.GetWindowSize();
+ ImGui.PushClipRect(windowPos, windowPos + windowSize, false);
+ ImGui.GetWindowDrawList().AddRectFilled(
+ windowPos + new Vector2(
+ windowSize.X * barL,
+ windowSize.Y - NotificationConstants.ScaledExpiryProgressBarHeight),
+ windowPos + windowSize with { X = windowSize.X * barR },
+ ImGui.GetColorU32(this.Type.ToColor()));
+ ImGui.PopClipRect();
+ }
+}
diff --git a/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.cs b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.cs
new file mode 100644
index 000000000..3bc7c3837
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.cs
@@ -0,0 +1,370 @@
+using System.Runtime.Loader;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Dalamud.Interface.Animation;
+using Dalamud.Interface.Animation.EasingFunctions;
+using Dalamud.Interface.Internal;
+using Dalamud.Interface.Internal.Notifications;
+using Dalamud.Plugin.Internal.Types;
+using Dalamud.Utility;
+
+using Serilog;
+
+namespace Dalamud.Interface.ImGuiNotification.Internal;
+
+/// Represents an active notification.
+internal sealed partial class ActiveNotification : IActiveNotification
+{
+ private readonly Notification underlyingNotification;
+
+ private readonly Easing showEasing;
+ private readonly Easing hideEasing;
+ private readonly Easing progressEasing;
+ private readonly Easing expandoEasing;
+
+ /// Gets the time of starting to count the timer for the expiration.
+ private DateTime lastInterestTime;
+
+ /// Gets the extended expiration time from .
+ private DateTime extendedExpiry;
+
+ /// The icon texture to use if specified; otherwise, icon will be used from .
+ private Task? iconTextureWrap;
+
+ /// The plugin that initiated this notification.
+ private LocalPlugin? initiatorPlugin;
+
+ /// Whether has been unloaded.
+ private bool isInitiatorUnloaded;
+
+ /// The progress before for the progress bar animation with .
+ private float progressBefore;
+
+ /// Used for calculating correct dismissal progressbar animation (left edge).
+ private float prevProgressL;
+
+ /// Used for calculating correct dismissal progressbar animation (right edge).
+ private float prevProgressR;
+
+ /// New progress value to be updated on next call to .
+ private float? newProgress;
+
+ /// New minimized value to be updated on next call to .
+ private bool? newMinimized;
+
+ /// 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.progressEasing = new InOutCubic(NotificationConstants.ProgressChangeAnimationDuration);
+ this.expandoEasing = new InOutCubic(NotificationConstants.ExpandoAnimationDuration);
+ this.CreatedAt = this.lastInterestTime = this.extendedExpiry = DateTime.Now;
+
+ this.showEasing.Start();
+ this.progressEasing.Start();
+ }
+
+ ///
+ public long Id { get; } = IActiveNotification.CreateNewId();
+
+ ///
+ public DateTime CreatedAt { get; }
+
+ ///
+ public string Content
+ {
+ get => this.underlyingNotification.Content;
+ set => this.underlyingNotification.Content = value;
+ }
+
+ ///
+ public string? Title
+ {
+ get => this.underlyingNotification.Title;
+ set => this.underlyingNotification.Title = value;
+ }
+
+ ///
+ public bool RespectUiHidden
+ {
+ get => this.underlyingNotification.RespectUiHidden;
+ set => this.underlyingNotification.RespectUiHidden = value;
+ }
+
+ ///
+ public string? MinimizedText
+ {
+ get => this.underlyingNotification.MinimizedText;
+ set => this.underlyingNotification.MinimizedText = value;
+ }
+
+ ///
+ public NotificationType Type
+ {
+ get => this.underlyingNotification.Type;
+ set => this.underlyingNotification.Type = value;
+ }
+
+ ///
+ public INotificationIcon? Icon
+ {
+ get => this.underlyingNotification.Icon;
+ set => this.underlyingNotification.Icon = value;
+ }
+
+ ///
+ public DateTime HardExpiry
+ {
+ get => this.underlyingNotification.HardExpiry;
+ set
+ {
+ if (this.underlyingNotification.HardExpiry == value)
+ return;
+ this.underlyingNotification.HardExpiry = value;
+ this.lastInterestTime = DateTime.Now;
+ }
+ }
+
+ ///
+ public TimeSpan InitialDuration
+ {
+ get => this.underlyingNotification.InitialDuration;
+ set
+ {
+ this.underlyingNotification.InitialDuration = value;
+ this.lastInterestTime = DateTime.Now;
+ }
+ }
+
+ ///
+ public TimeSpan ExtensionDurationSinceLastInterest
+ {
+ get => this.underlyingNotification.ExtensionDurationSinceLastInterest;
+ set
+ {
+ this.underlyingNotification.ExtensionDurationSinceLastInterest = value;
+ this.lastInterestTime = DateTime.Now;
+ }
+ }
+
+ ///
+ public DateTime EffectiveExpiry { get; private set; }
+
+ ///
+ public NotificationDismissReason? DismissReason { get; private set; }
+
+ ///
+ public bool ShowIndeterminateIfNoExpiry
+ {
+ get => this.underlyingNotification.ShowIndeterminateIfNoExpiry;
+ set => this.underlyingNotification.ShowIndeterminateIfNoExpiry = value;
+ }
+
+ ///
+ public bool Minimized
+ {
+ get => this.newMinimized ?? this.underlyingNotification.Minimized;
+ set => this.newMinimized = value;
+ }
+
+ ///
+ public bool UserDismissable
+ {
+ get => this.underlyingNotification.UserDismissable;
+ set => this.underlyingNotification.UserDismissable = value;
+ }
+
+ ///
+ public float Progress
+ {
+ get => this.newProgress ?? this.underlyingNotification.Progress;
+ set => this.newProgress = value;
+ }
+
+ /// Gets the eased progress.
+ private float ProgressEased
+ {
+ get
+ {
+ var underlyingProgress = this.underlyingNotification.Progress;
+ if (Math.Abs(underlyingProgress - this.progressBefore) < 0.000001f || this.progressEasing.IsDone)
+ return underlyingProgress;
+
+ var state = Math.Clamp((float)this.progressEasing.Value, 0f, 1f);
+ return this.progressBefore + (state * (underlyingProgress - this.progressBefore));
+ }
+ }
+
+ /// Gets the string for the initiator field.
+ private string InitiatorString =>
+ this.initiatorPlugin is not { } plugin
+ ? NotificationConstants.DefaultInitiator
+ : this.isInitiatorUnloaded
+ ? NotificationConstants.UnloadedInitiatorNameFormat.Format(plugin.Name)
+ : plugin.Name;
+
+ /// Gets the effective text to display when minimized.
+ private string EffectiveMinimizedText => (this.MinimizedText ?? this.Content).ReplaceLineEndings(" ");
+
+ ///
+ 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.DismissReason is not null)
+ return;
+
+ this.DismissReason = reason;
+ this.hideEasing.Start();
+ this.InvokeDismiss();
+ }
+
+ ///
+ public void ExtendBy(TimeSpan extension)
+ {
+ var newExpiry = DateTime.Now + extension;
+ if (this.extendedExpiry < newExpiry)
+ this.extendedExpiry = newExpiry;
+ }
+
+ ///
+ public void SetIconTexture(IDalamudTextureWrap? textureWrap)
+ {
+ this.SetIconTexture(textureWrap is null ? null : Task.FromResult(textureWrap));
+ }
+
+ ///
+ public void SetIconTexture(Task? textureWrapTask)
+ {
+ if (this.DismissReason is not null)
+ {
+ textureWrapTask?.ToContentDisposedTask(true);
+ return;
+ }
+
+ // After replacing, if the old texture is not the old texture, then dispose the old texture.
+ if (Interlocked.Exchange(ref this.iconTextureWrap, textureWrapTask) is { } wrapTaskToDispose &&
+ wrapTaskToDispose != textureWrapTask)
+ {
+ wrapTaskToDispose.ToContentDisposedTask(true);
+ }
+ }
+
+ /// Removes non-Dalamud invocation targets from events.
+ ///
+ /// This is done to prevent references of plugins being unloaded from outliving the plugin itself.
+ /// Anything that can contain plugin-provided types and functions count, which effectively means that events and
+ /// interface/object-typed fields need to be scrubbed.
+ /// As a notification can be marked as non-user-dismissable, in which case after removing event handlers there will
+ /// be no way to remove the notification, we force the notification to become user-dismissable, and reset the expiry
+ /// to the default duration on unload.
+ ///
+ internal void RemoveNonDalamudInvocations()
+ {
+ var dalamudContext = AssemblyLoadContext.GetLoadContext(typeof(NotificationManager).Assembly);
+ this.Dismiss = RemoveNonDalamudInvocationsCore(this.Dismiss);
+ this.Click = RemoveNonDalamudInvocationsCore(this.Click);
+ this.DrawActions = RemoveNonDalamudInvocationsCore(this.DrawActions);
+
+ if (this.Icon is { } previousIcon && !IsOwnedByDalamud(previousIcon.GetType()))
+ this.Icon = null;
+
+ this.isInitiatorUnloaded = true;
+ this.UserDismissable = true;
+ this.ExtensionDurationSinceLastInterest = NotificationConstants.DefaultDuration;
+
+ var newMaxExpiry = DateTime.Now + NotificationConstants.DefaultDuration;
+ if (this.EffectiveExpiry > newMaxExpiry)
+ this.HardExpiry = newMaxExpiry;
+
+ return;
+
+ bool IsOwnedByDalamud(Type t) => AssemblyLoadContext.GetLoadContext(t.Assembly) == dalamudContext;
+
+ T? RemoveNonDalamudInvocationsCore(T? @delegate) where T : Delegate
+ {
+ if (@delegate is null)
+ return null;
+
+ foreach (var il in @delegate.GetInvocationList())
+ {
+ if (il.Target is { } target && !IsOwnedByDalamud(target.GetType()))
+ @delegate = (T)Delegate.Remove(@delegate, il);
+ }
+
+ return @delegate;
+ }
+ }
+
+ /// Updates the state of this notification, and release the relevant resource if this notification is no
+ /// longer in use.
+ /// true if the notification is over and relevant resources are released.
+ /// Intended to be called from the main thread only.
+ internal bool UpdateOrDisposeInternal()
+ {
+ this.showEasing.Update();
+ this.hideEasing.Update();
+ this.progressEasing.Update();
+ if (this.expandoEasing.IsRunning)
+ {
+ this.expandoEasing.Update();
+ if (this.expandoEasing.IsDone)
+ this.expandoEasing.Stop();
+ }
+
+ if (this.newProgress is { } newProgressValue)
+ {
+ if (Math.Abs(this.underlyingNotification.Progress - newProgressValue) > float.Epsilon)
+ {
+ this.progressBefore = this.ProgressEased;
+ this.underlyingNotification.Progress = newProgressValue;
+ this.progressEasing.Restart();
+ this.progressEasing.Update();
+ }
+
+ this.newProgress = null;
+ }
+
+ if (this.newMinimized is { } newMinimizedValue)
+ {
+ if (this.underlyingNotification.Minimized != newMinimizedValue)
+ {
+ this.underlyingNotification.Minimized = newMinimizedValue;
+ this.expandoEasing.Restart();
+ this.expandoEasing.Update();
+ }
+
+ this.newMinimized = null;
+ }
+
+ if (!this.hideEasing.IsRunning || !this.hideEasing.IsDone)
+ return false;
+
+ this.DisposeInternal();
+ return true;
+ }
+
+ /// Clears the resources associated with this instance of .
+ internal void DisposeInternal()
+ {
+ if (Interlocked.Exchange(ref this.iconTextureWrap, null) is { } wrapTaskToDispose)
+ wrapTaskToDispose.ToContentDisposedTask(true);
+ this.Dismiss = null;
+ this.Click = null;
+ this.DrawActions = null;
+ this.initiatorPlugin = null;
+ }
+
+ private void LogEventInvokeError(Exception exception, string message) =>
+ Log.Error(
+ exception,
+ $"[{nameof(ActiveNotification)}:{this.initiatorPlugin?.Name ?? NotificationConstants.DefaultInitiator}] {message}");
+}
diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs
new file mode 100644
index 000000000..de212160c
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs
@@ -0,0 +1,161 @@
+using System.Numerics;
+
+using CheapLoc;
+
+using Dalamud.Interface.Colors;
+using Dalamud.Interface.Internal.Notifications;
+using Dalamud.Interface.Utility;
+
+namespace Dalamud.Interface.ImGuiNotification.Internal;
+
+/// Constants for drawing notification windows.
+internal static class NotificationConstants
+{
+ // .............................[..]
+ // ..when.......................[XX]
+ // .. ..
+ // ..[i]..title title title title ..
+ // .. by this_plugin ..
+ // .. ..
+ // .. body body body body ..
+ // .. some more wrapped body ..
+ // .. ..
+ // .. action buttons ..
+ // .................................
+
+ /// The string to measure size of, to decide the width of notification windows.
+ /// Probably not worth localizing.
+ public const string NotificationWidthMeasurementString =
+ "The width of this text will decide the width\n" +
+ "of the notification window.";
+
+ /// The ratio of maximum notification window width w.r.t. main viewport width.
+ public const float MaxNotificationWindowWidthWrtMainViewportWidth = 2f / 3;
+
+ /// The size of the icon.
+ public const float IconSize = 32;
+
+ /// The background opacity of a notification window.
+ public const float BackgroundOpacity = 0.82f;
+
+ /// The duration of indeterminate progress bar loop in milliseconds.
+ public const float IndeterminateProgressbarLoopDuration = 2000f;
+
+ /// The duration of the progress wave animation in milliseconds.
+ public const float ProgressWaveLoopDuration = 2000f;
+
+ /// The time ratio of a progress wave loop where the animation is idle.
+ public const float ProgressWaveIdleTimeRatio = 0.5f;
+
+ /// The time ratio of a non-idle portion of the progress wave loop where the color is the most opaque.
+ ///
+ public const float ProgressWaveLoopMaxColorTimeRatio = 0.7f;
+
+ /// Default duration of the notification.
+ public static readonly TimeSpan DefaultDuration = TimeSpan.FromSeconds(3);
+
+ /// Duration of show animation.
+ public static readonly TimeSpan ShowAnimationDuration = TimeSpan.FromMilliseconds(300);
+
+ /// Duration of hide animation.
+ public static readonly TimeSpan HideAnimationDuration = TimeSpan.FromMilliseconds(300);
+
+ /// Duration of progress change animation.
+ public static readonly TimeSpan ProgressChangeAnimationDuration = TimeSpan.FromMilliseconds(200);
+
+ /// Duration of expando animation.
+ public static readonly TimeSpan ExpandoAnimationDuration = TimeSpan.FromMilliseconds(300);
+
+ /// Text color for the rectangular border when the notification is focused.
+ public static readonly Vector4 FocusBorderColor = new(0.4f, 0.4f, 0.4f, 1f);
+
+ /// Text color for the when.
+ public static readonly Vector4 WhenTextColor = new(0.8f, 0.8f, 0.8f, 1f);
+
+ /// 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);
+
+ /// Color for the background progress bar (determinate progress only).
+ public static readonly Vector4 BackgroundProgressColorMax = new(1f, 1f, 1f, 0.1f);
+
+ /// Color for the background progress bar (determinate progress only).
+ public static readonly Vector4 BackgroundProgressColorMin = new(1f, 1f, 1f, 0.05f);
+
+ /// 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 height of the expiry progress bar.
+ public static float ScaledExpiryProgressBarHeight => MathF.Round(3 * ImGuiHelpers.GlobalScale);
+
+ /// Gets the thickness of the focus indicator rectangle.
+ public static float FocusIndicatorThickness => MathF.Round(3 * ImGuiHelpers.GlobalScale);
+
+ /// Gets the string to show in place of this_plugin if the notification is shown by Dalamud.
+ public static string DefaultInitiator => Loc.Localize("NotificationConstants.DefaultInitiator", "Dalamud");
+
+ /// Gets the string format of the initiator name field, if the initiator is unloaded.
+ public static string UnloadedInitiatorNameFormat =>
+ Loc.Localize("NotificationConstants.UnloadedInitiatorNameFormat", "{0} (unloaded)");
+
+ /// Gets the color corresponding to the notification type.
+ /// The notification type.
+ /// The corresponding color.
+ public static Vector4 ToColor(this NotificationType type) => 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 char value corresponding to the notification type.
+ /// The notification type.
+ /// The corresponding char, or null.
+ public static char ToChar(this NotificationType type) => type switch
+ {
+ NotificationType.None => '\0',
+ NotificationType.Success => FontAwesomeIcon.CheckCircle.ToIconChar(),
+ NotificationType.Warning => FontAwesomeIcon.ExclamationCircle.ToIconChar(),
+ NotificationType.Error => FontAwesomeIcon.TimesCircle.ToIconChar(),
+ NotificationType.Info => FontAwesomeIcon.InfoCircle.ToIconChar(),
+ _ => '\0',
+ };
+
+ /// Gets the localized title string corresponding to the notification type.
+ /// The notification type.
+ /// The corresponding title.
+ public static string? ToTitle(this NotificationType type) => type switch
+ {
+ NotificationType.None => null,
+ NotificationType.Success => Loc.Localize("NotificationConstants.Title.Success", "Success"),
+ NotificationType.Warning => Loc.Localize("NotificationConstants.Title.Warning", "Warning"),
+ NotificationType.Error => Loc.Localize("NotificationConstants.Title.Error", "Error"),
+ NotificationType.Info => Loc.Localize("NotificationConstants.Title.Info", "Info"),
+ _ => null,
+ };
+}
diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/FilePathNotificationIcon.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/FilePathNotificationIcon.cs
new file mode 100644
index 000000000..3aa712160
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/FilePathNotificationIcon.cs
@@ -0,0 +1,34 @@
+using System.IO;
+using System.Numerics;
+
+using Dalamud.Interface.Internal;
+
+namespace Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon;
+
+/// Represents the use of a texture from a file as the icon of a notification.
+/// If there was no texture loaded for any reason, the plugin icon will be displayed instead.
+internal class FilePathNotificationIcon : INotificationIcon
+{
+ private readonly FileInfo fileInfo;
+
+ /// Initializes a new instance of the class.
+ /// The path to a .tex file inside the game resources.
+ public FilePathNotificationIcon(string filePath) => this.fileInfo = new(filePath);
+
+ ///
+ public bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color) =>
+ NotificationUtilities.DrawIconFrom(
+ minCoord,
+ maxCoord,
+ Service.Get().GetTextureFromFile(this.fileInfo));
+
+ ///
+ public override bool Equals(object? obj) =>
+ obj is FilePathNotificationIcon r && r.fileInfo.FullName == this.fileInfo.FullName;
+
+ ///
+ public override int GetHashCode() => HashCode.Combine(this.GetType().GetHashCode(), this.fileInfo.FullName);
+
+ ///
+ public override string ToString() => $"{nameof(FilePathNotificationIcon)}({this.fileInfo.FullName})";
+}
diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/FontAwesomeIconNotificationIcon.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/FontAwesomeIconNotificationIcon.cs
new file mode 100644
index 000000000..0acfdee4c
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/FontAwesomeIconNotificationIcon.cs
@@ -0,0 +1,31 @@
+using System.Numerics;
+
+namespace Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon;
+
+/// Represents the use of as the icon of a notification.
+internal class FontAwesomeIconNotificationIcon : INotificationIcon
+{
+ private readonly char iconChar;
+
+ /// Initializes a new instance of the class.
+ /// The character.
+ public FontAwesomeIconNotificationIcon(FontAwesomeIcon iconChar) => this.iconChar = (char)iconChar;
+
+ ///
+ public bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color) =>
+ NotificationUtilities.DrawIconFrom(
+ minCoord,
+ maxCoord,
+ this.iconChar,
+ Service.Get().IconFontAwesomeFontHandle,
+ color);
+
+ ///
+ public override bool Equals(object? obj) => obj is FontAwesomeIconNotificationIcon r && r.iconChar == this.iconChar;
+
+ ///
+ public override int GetHashCode() => HashCode.Combine(this.GetType().GetHashCode(), this.iconChar);
+
+ ///
+ public override string ToString() => $"{nameof(FontAwesomeIconNotificationIcon)}({this.iconChar})";
+}
diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/GamePathNotificationIcon.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/GamePathNotificationIcon.cs
new file mode 100644
index 000000000..e0699e1b6
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/GamePathNotificationIcon.cs
@@ -0,0 +1,34 @@
+using System.Numerics;
+
+using Dalamud.Interface.Internal;
+using Dalamud.Plugin.Services;
+
+namespace Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon;
+
+/// Represents the use of a game-shipped texture as the icon of a notification.
+/// If there was no texture loaded for any reason, the plugin icon will be displayed instead.
+internal class GamePathNotificationIcon : INotificationIcon
+{
+ private readonly string gamePath;
+
+ /// Initializes a new instance of the class.
+ /// The path to a .tex file inside the game resources.
+ /// Use to get the game path from icon IDs.
+ public GamePathNotificationIcon(string gamePath) => this.gamePath = gamePath;
+
+ ///
+ public bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color) =>
+ NotificationUtilities.DrawIconFrom(
+ minCoord,
+ maxCoord,
+ Service.Get().GetTextureFromGame(this.gamePath));
+
+ ///
+ public override bool Equals(object? obj) => obj is GamePathNotificationIcon r && r.gamePath == this.gamePath;
+
+ ///
+ public override int GetHashCode() => HashCode.Combine(this.GetType().GetHashCode(), this.gamePath);
+
+ ///
+ public override string ToString() => $"{nameof(GamePathNotificationIcon)}({this.gamePath})";
+}
diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/SeIconCharNotificationIcon.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/SeIconCharNotificationIcon.cs
new file mode 100644
index 000000000..3bbd8dd81
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/SeIconCharNotificationIcon.cs
@@ -0,0 +1,33 @@
+using System.Numerics;
+
+using Dalamud.Game.Text;
+
+namespace Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon;
+
+/// Represents the use of as the icon of a notification.
+internal class SeIconCharNotificationIcon : INotificationIcon
+{
+ private readonly SeIconChar iconChar;
+
+ /// Initializes a new instance of the class.
+ /// The character.
+ public SeIconCharNotificationIcon(SeIconChar c) => this.iconChar = c;
+
+ ///
+ public bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color) =>
+ NotificationUtilities.DrawIconFrom(
+ minCoord,
+ maxCoord,
+ (char)this.iconChar,
+ Service.Get().IconAxisFontHandle,
+ color);
+
+ ///
+ public override bool Equals(object? obj) => obj is SeIconCharNotificationIcon r && r.iconChar == this.iconChar;
+
+ ///
+ public override int GetHashCode() => HashCode.Combine(this.GetType().GetHashCode(), this.iconChar);
+
+ ///
+ public override string ToString() => $"{nameof(SeIconCharNotificationIcon)}({this.iconChar})";
+}
diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationManager.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationManager.cs
new file mode 100644
index 000000000..272407615
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationManager.cs
@@ -0,0 +1,165 @@
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+
+using Dalamud.Game.Gui;
+using Dalamud.Interface.GameFonts;
+using Dalamud.Interface.Internal.Notifications;
+using Dalamud.Interface.ManagedFontAtlas;
+using Dalamud.Interface.ManagedFontAtlas.Internals;
+using Dalamud.Interface.Utility;
+using Dalamud.IoC;
+using Dalamud.IoC.Internal;
+using Dalamud.Plugin.Internal.Types;
+using Dalamud.Plugin.Services;
+
+using ImGuiNET;
+
+namespace Dalamud.Interface.ImGuiNotification.Internal;
+
+/// Class handling notifications/toasts in ImGui.
+[InterfaceVersion("1.0")]
+[ServiceManager.EarlyLoadedService]
+internal class NotificationManager : INotificationManager, IServiceType, IDisposable
+{
+ [ServiceManager.ServiceDependency]
+ private readonly GameGui gameGui = Service.Get();
+
+ private readonly List notifications = new();
+ private readonly ConcurrentBag pendingNotifications = new();
+
+ [ServiceManager.ServiceConstructor]
+ 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; }
+
+ /// Gets the private atlas for use with notification windows.
+ private IFontAtlas PrivateAtlas { get; }
+
+ ///
+ public void Dispose()
+ {
+ this.PrivateAtlas.Dispose();
+ foreach (var n in this.pendingNotifications)
+ n.DisposeInternal();
+ foreach (var n in this.notifications)
+ n.DisposeInternal();
+ 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 added notification.
+ public IActiveNotification AddNotification(Notification notification, LocalPlugin plugin)
+ {
+ var an = new ActiveNotification(notification, plugin);
+ this.pendingNotifications.Add(an);
+ return an;
+ }
+
+ /// Add a notification to the notification queue.
+ /// The content of the notification.
+ /// The title of the notification.
+ /// The type of the notification.
+ 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.WorkSize;
+ var height = 0f;
+ var uiHidden = this.gameGui.GameUiHidden;
+
+ while (this.pendingNotifications.TryTake(out var newNotification))
+ this.notifications.Add(newNotification);
+
+ var width = ImGui.CalcTextSize(NotificationConstants.NotificationWidthMeasurementString).X;
+ width += NotificationConstants.ScaledWindowPadding * 3;
+ width += NotificationConstants.ScaledIconSize;
+ width = Math.Min(width, viewportSize.X * NotificationConstants.MaxNotificationWindowWidthWrtMainViewportWidth);
+
+ this.notifications.RemoveAll(static x => x.UpdateOrDisposeInternal());
+ foreach (var tn in this.notifications)
+ {
+ if (uiHidden && tn.RespectUiHidden)
+ continue;
+ height += tn.Draw(width, height) + NotificationConstants.ScaledWindowGap;
+ }
+ }
+}
+
+/// 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();
+
+ [ServiceManager.ServiceDependency]
+ private readonly NotificationManager notificationManagerService = Service.Get();
+
+ [ServiceManager.ServiceConstructor]
+ private NotificationManagerPluginScoped(LocalPlugin localPlugin) =>
+ this.localPlugin = localPlugin;
+
+ ///
+ public IActiveNotification AddNotification(Notification notification)
+ {
+ var an = this.notificationManagerService.AddNotification(notification, this.localPlugin);
+ _ = this.notifications.TryAdd(an, 0);
+ an.Dismiss += a => this.notifications.TryRemove(a.Notification, out _);
+ return an;
+ }
+
+ ///
+ public void Dispose()
+ {
+ while (!this.notifications.IsEmpty)
+ {
+ foreach (var n in this.notifications.Keys)
+ {
+ this.notifications.TryRemove(n, out _);
+ ((ActiveNotification)n).RemoveNonDalamudInvocations();
+ }
+ }
+ }
+}
diff --git a/Dalamud/Interface/ImGuiNotification/Notification.cs b/Dalamud/Interface/ImGuiNotification/Notification.cs
new file mode 100644
index 000000000..5175985c7
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/Notification.cs
@@ -0,0 +1,52 @@
+using Dalamud.Interface.ImGuiNotification.Internal;
+using Dalamud.Interface.Internal.Notifications;
+
+namespace Dalamud.Interface.ImGuiNotification;
+
+/// Represents a blueprint for a notification.
+public sealed record Notification : INotification
+{
+ ///
+ /// Gets the default value for and .
+ ///
+ public static TimeSpan DefaultDuration => NotificationConstants.DefaultDuration;
+
+ ///
+ public string Content { get; set; } = string.Empty;
+
+ ///
+ public string? Title { get; set; }
+
+ ///
+ public string? MinimizedText { get; set; }
+
+ ///
+ public NotificationType Type { get; set; } = NotificationType.None;
+
+ ///
+ public INotificationIcon? Icon { get; set; }
+
+ ///
+ public DateTime HardExpiry { get; set; } = DateTime.MaxValue;
+
+ ///
+ public TimeSpan InitialDuration { get; set; } = DefaultDuration;
+
+ ///
+ public TimeSpan ExtensionDurationSinceLastInterest { get; set; } = DefaultDuration;
+
+ ///
+ public bool ShowIndeterminateIfNoExpiry { get; set; } = true;
+
+ ///
+ public bool RespectUiHidden { get; set; } = true;
+
+ ///
+ public bool Minimized { get; set; } = true;
+
+ ///
+ public bool UserDismissable { get; set; } = true;
+
+ ///
+ public float Progress { get; set; } = 1f;
+}
diff --git a/Dalamud/Interface/ImGuiNotification/NotificationDismissReason.cs b/Dalamud/Interface/ImGuiNotification/NotificationDismissReason.cs
new file mode 100644
index 000000000..2c9d6d2a4
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/NotificationDismissReason.cs
@@ -0,0 +1,16 @@
+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/NotificationUtilities.cs b/Dalamud/Interface/ImGuiNotification/NotificationUtilities.cs
new file mode 100644
index 000000000..631263f95
--- /dev/null
+++ b/Dalamud/Interface/ImGuiNotification/NotificationUtilities.cs
@@ -0,0 +1,149 @@
+using System.IO;
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+
+using Dalamud.Game.Text;
+using Dalamud.Interface.Internal;
+using Dalamud.Interface.Internal.Windows;
+using Dalamud.Interface.ManagedFontAtlas;
+using Dalamud.Interface.Utility;
+using Dalamud.Plugin.Internal.Types;
+using Dalamud.Storage.Assets;
+
+using ImGuiNET;
+
+namespace Dalamud.Interface.ImGuiNotification;
+
+/// Utilities for implementing stuff under .
+public static class NotificationUtilities
+{
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static INotificationIcon ToNotificationIcon(this SeIconChar iconChar) =>
+ INotificationIcon.From(iconChar);
+
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static INotificationIcon ToNotificationIcon(this FontAwesomeIcon iconChar) =>
+ INotificationIcon.From(iconChar);
+
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static INotificationIcon ToNotificationIcon(this FileInfo fileInfo) =>
+ INotificationIcon.FromFile(fileInfo.FullName);
+
+ /// Draws an icon from an and a .
+ /// The coordinates of the top left of the icon area.
+ /// The coordinates of the bottom right of the icon area.
+ /// The icon character.
+ /// The font handle to use.
+ /// The foreground color.
+ /// true if anything has been drawn.
+ internal static unsafe bool DrawIconFrom(
+ Vector2 minCoord,
+ Vector2 maxCoord,
+ char c,
+ IFontHandle fontHandle,
+ Vector4 color)
+ {
+ if (c is '\0' or char.MaxValue)
+ return false;
+
+ var smallerDim = Math.Max(maxCoord.Y - minCoord.Y, maxCoord.X - minCoord.X);
+ using (fontHandle.Push())
+ {
+ var font = ImGui.GetFont();
+ var glyphPtr = (ImGuiHelpers.ImFontGlyphReal*)font.FindGlyphNoFallback(c).NativePtr;
+ if (glyphPtr is null)
+ return false;
+
+ ref readonly var glyph = ref *glyphPtr;
+ var size = glyph.XY1 - glyph.XY0;
+ var smallerSizeDim = Math.Min(size.X, size.Y);
+ var scale = smallerSizeDim > smallerDim ? smallerDim / smallerSizeDim : 1f;
+ size *= scale;
+ var pos = ((minCoord + maxCoord) - size) / 2;
+ pos += ImGui.GetWindowPos();
+ ImGui.GetWindowDrawList().AddImage(
+ font.ContainerAtlas.Textures[glyph.TextureIndex].TexID,
+ pos,
+ pos + size,
+ glyph.UV0,
+ glyph.UV1,
+ ImGui.GetColorU32(color with { W = color.W * ImGui.GetStyle().Alpha }));
+ }
+
+ return true;
+ }
+
+ /// Draws an icon from an instance of .
+ /// The coordinates of the top left of the icon area.
+ /// The coordinates of the bottom right of the icon area.
+ /// The texture.
+ /// true if anything has been drawn.
+ internal static bool DrawIconFrom(Vector2 minCoord, Vector2 maxCoord, IDalamudTextureWrap? texture)
+ {
+ if (texture is null)
+ return false;
+ try
+ {
+ var handle = texture.ImGuiHandle;
+ var size = texture.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;
+ ImGui.SetCursorPos(((minCoord + maxCoord) - size) / 2);
+ ImGui.Image(handle, size);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ /// Draws an icon from an instance of that results in an
+ /// .
+ /// The coordinates of the top left of the icon area.
+ /// The coordinates of the bottom right of the icon area.
+ /// The task that results in a texture.
+ /// true if anything has been drawn.
+ /// Exceptions from the task will be treated as if no texture is provided.
+ internal static bool DrawIconFrom(Vector2 minCoord, Vector2 maxCoord, Task? textureTask) =>
+ textureTask?.IsCompletedSuccessfully is true && DrawIconFrom(minCoord, maxCoord, textureTask.Result);
+
+ /// Draws an icon from an instance of .
+ /// The coordinates of the top left of the icon area.
+ /// The coordinates of the bottom right of the icon area.
+ /// The plugin. Dalamud icon will be drawn if null is given.
+ /// true if anything has been drawn.
+ internal static bool DrawIconFrom(Vector2 minCoord, Vector2 maxCoord, LocalPlugin? plugin)
+ {
+ var dam = Service.Get();
+ if (plugin is null)
+ return false;
+
+ if (!Service.Get().TryGetIcon(
+ plugin,
+ plugin.Manifest,
+ plugin.IsThirdParty,
+ out var texture) || texture is null)
+ {
+ texture = dam.GetDalamudTextureWrap(DalamudAsset.DefaultIcon);
+ }
+
+ return DrawIconFrom(minCoord, maxCoord, texture);
+ }
+
+ /// Draws the Dalamud logo as an icon.
+ /// The coordinates of the top left of the icon area.
+ /// The coordinates of the bottom right of the icon area.
+ internal static void DrawIconFromDalamudLogo(Vector2 minCoord, Vector2 maxCoord)
+ {
+ var dam = Service.Get();
+ var texture = dam.GetDalamudTextureWrap(DalamudAsset.LogoSmall);
+ DrawIconFrom(minCoord, maxCoord, texture);
+ }
+}
diff --git a/Dalamud/Interface/Internal/InterfaceManager.cs b/Dalamud/Interface/Internal/InterfaceManager.cs
index 126097ed3..48ad653d2 100644
--- a/Dalamud/Interface/Internal/InterfaceManager.cs
+++ b/Dalamud/Interface/Internal/InterfaceManager.cs
@@ -14,6 +14,7 @@ using Dalamud.Game.ClientState.Keys;
using Dalamud.Game.Internal.DXGI;
using Dalamud.Hooking;
using Dalamud.Hooking.WndProcHook;
+using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.ManagedAsserts;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.ManagedFontAtlas;
@@ -917,7 +918,7 @@ internal class InterfaceManager : IDisposable, IServiceType
if (this.IsDispatchingEvents)
{
this.Draw?.Invoke();
- Service.Get().Draw();
+ Service.GetNullable()?.Draw();
}
ImGuiManagedAsserts.ReportProblems("Dalamud Core", snap);
diff --git a/Dalamud/Interface/Internal/Notifications/NotificationManager.cs b/Dalamud/Interface/Internal/Notifications/NotificationManager.cs
deleted file mode 100644
index 67ad3ee8f..000000000
--- a/Dalamud/Interface/Internal/Notifications/NotificationManager.cs
+++ /dev/null
@@ -1,318 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Numerics;
-
-using Dalamud.Interface.Colors;
-using Dalamud.Interface.Utility;
-using Dalamud.Utility;
-using ImGuiNET;
-
-namespace Dalamud.Interface.Internal.Notifications;
-
-///
-/// Class handling notifications/toasts in ImGui.
-/// Ported from https://github.com/patrickcjk/imgui-notify.
-///
-[ServiceManager.EarlyLoadedService]
-internal class NotificationManager : IServiceType
-{
- ///
- /// 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();
-
- [ServiceManager.ServiceConstructor]
- private NotificationManager()
- {
- }
-
- ///
- /// Add a notification to the notification queue.
- ///
- /// 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,
- });
- }
-
- ///
- /// Draw all currently queued notifications.
- ///
- public void Draw()
- {
- var viewportSize = ImGuiHelpers.MainViewport.Size;
- var height = 0f;
-
- for (var i = 0; i < this.notifications.Count; i++)
- {
- var tn = this.notifications.ElementAt(i);
-
- if (tn.GetPhase() == Notification.Phase.Expired)
- {
- this.notifications.RemoveAt(i);
- continue;
- }
-
- var opacity = tn.GetFadePercent();
-
- var iconColor = tn.Color;
- iconColor.W = opacity;
-
- var windowName = $"##NOTIFY{i}";
-
- 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);
-
- 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();
- }
- }
-
- ///
- /// Container class for notifications.
- ///
- internal class Notification
- {
- ///
- /// Possible notification phases.
- ///
- internal enum Phase
- {
- ///
- /// 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)
- {
- return (float)elapsed / NotifyFadeInOutTime * NotifyOpacity;
- }
- 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/Notifications/NotificationType.cs b/Dalamud/Interface/Internal/Notifications/NotificationType.cs
index 1885ec809..5fffbe9af 100644
--- a/Dalamud/Interface/Internal/Notifications/NotificationType.cs
+++ b/Dalamud/Interface/Internal/Notifications/NotificationType.cs
@@ -1,32 +1,23 @@
-namespace Dalamud.Interface.Internal.Notifications;
+using Dalamud.Utility;
-///
-/// Possible notification types.
-///
+namespace Dalamud.Interface.Internal.Notifications;
+
+/// Possible notification types.
+[Api10ToDo(Api10ToDoAttribute.MoveNamespace, nameof(ImGuiNotification.Internal))]
public enum NotificationType
{
- ///
- /// No special type.
- ///
+ /// No special type.
None,
- ///
- /// Type indicating success.
- ///
+ /// Type indicating success.
Success,
- ///
- /// Type indicating a warning.
- ///
+ /// Type indicating a warning.
Warning,
- ///
- /// Type indicating an error.
- ///
+ /// Type indicating an error.
Error,
- ///
- /// Type indicating generic information.
- ///
+ /// Type indicating generic information.
Info,
}
diff --git a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs
index 0c9c90d0d..b0ca9c2aa 100644
--- a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs
+++ b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs
@@ -12,6 +12,8 @@ using Dalamud.Game;
using Dalamud.Game.Command;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Components;
+using Dalamud.Interface.ImGuiNotification;
+using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
@@ -76,6 +78,8 @@ internal class ConsoleWindow : Window, IDisposable
private int historyPos;
private int copyStart = -1;
+ private IActiveNotification? prevCopyNotification;
+
/// Initializes a new instance of the class.
/// An instance of .
public ConsoleWindow(DalamudConfiguration configuration)
@@ -436,10 +440,14 @@ internal class ConsoleWindow : Window, IDisposable
return;
ImGui.SetClipboardText(sb.ToString());
- Service.Get().AddNotification(
- $"{n:n0} line(s) copied.",
- this.WindowName,
- NotificationType.Success);
+ this.prevCopyNotification?.DismissNow();
+ this.prevCopyNotification = Service.Get().AddNotification(
+ new()
+ {
+ Title = this.WindowName,
+ Content = $"{n:n0} line(s) copied.",
+ Type = NotificationType.Success,
+ });
}
private void DrawOptionsToolbar()
diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/DataShareWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/DataShareWidget.cs
index 92f340a7b..346255dfe 100644
--- a/Dalamud/Interface/Internal/Windows/Data/Widgets/DataShareWidget.cs
+++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/DataShareWidget.cs
@@ -5,6 +5,7 @@ using System.Numerics;
using System.Reflection;
using System.Text;
+using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs
index 2c7ceb95b..086b0c1ad 100644
--- a/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs
+++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs
@@ -1,5 +1,14 @@
-using Dalamud.Interface.Internal.Notifications;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Dalamud.Game.Text;
+using Dalamud.Interface.ImGuiNotification;
+using Dalamud.Interface.ImGuiNotification.Internal;
+using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Windowing;
+using Dalamud.Storage.Assets;
+using Dalamud.Utility;
+
using ImGuiNET;
namespace Dalamud.Interface.Internal.Windows.Data.Widgets;
@@ -9,11 +18,13 @@ namespace Dalamud.Interface.Internal.Windows.Data.Widgets;
///
internal class ImGuiWidget : IDataWindowWidget
{
+ private NotificationTemplate notificationTemplate;
+
///
public string[]? CommandShortcuts { get; init; } = { "imgui" };
-
+
///
- public string DisplayName { get; init; } = "ImGui";
+ public string DisplayName { get; init; } = "ImGui";
///
public bool Ready { get; set; }
@@ -22,6 +33,7 @@ internal class ImGuiWidget : IDataWindowWidget
public void Load()
{
this.Ready = true;
+ this.notificationTemplate.Reset();
}
///
@@ -38,38 +50,374 @@ internal class ImGuiWidget : IDataWindowWidget
ImGui.Separator();
- ImGui.TextUnformatted($"WindowSystem.TimeSinceLastAnyFocus: {WindowSystem.TimeSinceLastAnyFocus.TotalMilliseconds:0}ms");
+ ImGui.TextUnformatted(
+ $"WindowSystem.TimeSinceLastAnyFocus: {WindowSystem.TimeSinceLastAnyFocus.TotalMilliseconds:0}ms");
ImGui.Separator();
- if (ImGui.Button("Add random notification"))
+ ImGui.Checkbox("##manualContent", ref this.notificationTemplate.ManualContent);
+ ImGui.SameLine();
+ ImGui.InputText("Content##content", ref this.notificationTemplate.Content, 255);
+
+ ImGui.Checkbox("##manualTitle", ref this.notificationTemplate.ManualTitle);
+ ImGui.SameLine();
+ ImGui.InputText("Title##title", ref this.notificationTemplate.Title, 255);
+
+ ImGui.Checkbox("##manualMinimizedText", ref this.notificationTemplate.ManualMinimizedText);
+ ImGui.SameLine();
+ ImGui.InputText("MinimizedText##minimizedText", ref this.notificationTemplate.MinimizedText, 255);
+
+ ImGui.Checkbox("##manualType", ref this.notificationTemplate.ManualType);
+ ImGui.SameLine();
+ ImGui.Combo(
+ "Type##type",
+ ref this.notificationTemplate.TypeInt,
+ NotificationTemplate.TypeTitles,
+ NotificationTemplate.TypeTitles.Length);
+
+ ImGui.Combo(
+ "Icon##iconCombo",
+ ref this.notificationTemplate.IconInt,
+ NotificationTemplate.IconTitles,
+ NotificationTemplate.IconTitles.Length);
+ switch (this.notificationTemplate.IconInt)
{
- var rand = new Random();
+ case 1 or 2:
+ ImGui.InputText(
+ "Icon Text##iconText",
+ ref this.notificationTemplate.IconText,
+ 255);
+ break;
+ case 5 or 6:
+ ImGui.Combo(
+ "Asset##iconAssetCombo",
+ ref this.notificationTemplate.IconAssetInt,
+ NotificationTemplate.AssetSources,
+ NotificationTemplate.AssetSources.Length);
+ break;
+ case 3 or 7:
+ ImGui.InputText(
+ "Game Path##iconText",
+ ref this.notificationTemplate.IconText,
+ 255);
+ break;
+ case 4 or 8:
+ ImGui.InputText(
+ "File Path##iconText",
+ ref this.notificationTemplate.IconText,
+ 255);
+ break;
+ }
- var title = rand.Next(0, 5) switch
+ ImGui.Combo(
+ "Initial Duration",
+ ref this.notificationTemplate.InitialDurationInt,
+ NotificationTemplate.InitialDurationTitles,
+ NotificationTemplate.InitialDurationTitles.Length);
+
+ ImGui.Combo(
+ "Extension Duration",
+ ref this.notificationTemplate.HoverExtendDurationInt,
+ NotificationTemplate.HoverExtendDurationTitles,
+ NotificationTemplate.HoverExtendDurationTitles.Length);
+
+ ImGui.Combo(
+ "Progress",
+ ref this.notificationTemplate.ProgressMode,
+ NotificationTemplate.ProgressModeTitles,
+ NotificationTemplate.ProgressModeTitles.Length);
+
+ ImGui.Checkbox("Respect UI Hidden", ref this.notificationTemplate.RespectUiHidden);
+
+ ImGui.Checkbox("Minimized", ref this.notificationTemplate.Minimized);
+
+ ImGui.Checkbox("Show Indeterminate If No Expiry", ref this.notificationTemplate.ShowIndeterminateIfNoExpiry);
+
+ ImGui.Checkbox("User Dismissable", ref this.notificationTemplate.UserDismissable);
+
+ ImGui.Checkbox(
+ "Action Bar (always on if not user dismissable for the example)",
+ ref this.notificationTemplate.ActionBar);
+
+ if (ImGui.Button("Add notification"))
+ {
+ var 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.";
+
+ NewRandom(out var title, out var type, out var progress);
+ if (this.notificationTemplate.ManualTitle)
+ title = this.notificationTemplate.Title;
+ if (this.notificationTemplate.ManualContent)
+ text = this.notificationTemplate.Content;
+ if (this.notificationTemplate.ManualType)
+ type = (NotificationType)this.notificationTemplate.TypeInt;
+
+ var n = notifications.AddNotification(
+ new()
+ {
+ Content = text,
+ Title = title,
+ MinimizedText = this.notificationTemplate.ManualMinimizedText
+ ? this.notificationTemplate.MinimizedText
+ : null,
+ Type = type,
+ ShowIndeterminateIfNoExpiry = this.notificationTemplate.ShowIndeterminateIfNoExpiry,
+ RespectUiHidden = this.notificationTemplate.RespectUiHidden,
+ Minimized = this.notificationTemplate.Minimized,
+ UserDismissable = this.notificationTemplate.UserDismissable,
+ InitialDuration =
+ this.notificationTemplate.InitialDurationInt == 0
+ ? TimeSpan.MaxValue
+ : NotificationTemplate.Durations[this.notificationTemplate.InitialDurationInt],
+ ExtensionDurationSinceLastInterest =
+ this.notificationTemplate.HoverExtendDurationInt == 0
+ ? TimeSpan.Zero
+ : NotificationTemplate.Durations[this.notificationTemplate.HoverExtendDurationInt],
+ Progress = this.notificationTemplate.ProgressMode switch
+ {
+ 0 => 1f,
+ 1 => progress,
+ 2 => 0f,
+ 3 => 0f,
+ 4 => -1f,
+ _ => 0.5f,
+ },
+ Icon = this.notificationTemplate.IconInt switch
+ {
+ 1 => INotificationIcon.From(
+ (SeIconChar)(this.notificationTemplate.IconText.Length == 0
+ ? 0
+ : this.notificationTemplate.IconText[0])),
+ 2 => INotificationIcon.From(
+ (FontAwesomeIcon)(this.notificationTemplate.IconText.Length == 0
+ ? 0
+ : this.notificationTemplate.IconText[0])),
+ 3 => INotificationIcon.FromGame(this.notificationTemplate.IconText),
+ 4 => INotificationIcon.FromFile(this.notificationTemplate.IconText),
+ _ => null,
+ },
+ });
+
+ var dam = Service.Get();
+ var tm = Service.Get();
+ switch (this.notificationTemplate.IconInt)
{
- 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,
- };
+ case 5:
+ n.SetIconTexture(
+ dam.GetDalamudTextureWrap(
+ Enum.Parse(
+ NotificationTemplate.AssetSources[this.notificationTemplate.IconAssetInt])));
+ break;
+ case 6:
+ n.SetIconTexture(
+ dam.GetDalamudTextureWrapAsync(
+ Enum.Parse(
+ NotificationTemplate.AssetSources[this.notificationTemplate.IconAssetInt])));
+ break;
+ case 7:
+ n.SetIconTexture(tm.GetTextureFromGame(this.notificationTemplate.IconText));
+ break;
+ case 8:
+ n.SetIconTexture(tm.GetTextureFromFile(new(this.notificationTemplate.IconText)));
+ break;
+ }
- var type = rand.Next(0, 4) switch
+ switch (this.notificationTemplate.ProgressMode)
{
- 0 => NotificationType.Error,
- 1 => NotificationType.Warning,
- 2 => NotificationType.Info,
- 3 => NotificationType.Success,
- 4 => NotificationType.None,
- _ => NotificationType.None,
- };
+ case 2:
+ Task.Run(
+ async () =>
+ {
+ for (var i = 0; i <= 10 && !n.DismissReason.HasValue; i++)
+ {
+ await Task.Delay(500);
+ n.Progress = i / 10f;
+ }
+ });
+ break;
+ case 3:
+ Task.Run(
+ async () =>
+ {
+ for (var i = 0; i <= 10 && !n.DismissReason.HasValue; i++)
+ {
+ await Task.Delay(500);
+ n.Progress = i / 10f;
+ }
- 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.";
+ n.ExtendBy(NotificationConstants.DefaultDuration);
+ n.InitialDuration = NotificationConstants.DefaultDuration;
+ });
+ break;
+ }
- notifications.AddNotification(text, title, type);
+ if (this.notificationTemplate.ActionBar || !this.notificationTemplate.UserDismissable)
+ {
+ var nclick = 0;
+ var testString = "input";
+
+ n.Click += _ => nclick++;
+ n.DrawActions += an =>
+ {
+ ImGui.AlignTextToFramePadding();
+ ImGui.TextUnformatted($"{nclick}");
+
+ ImGui.SameLine();
+ if (ImGui.Button("Update"))
+ {
+ NewRandom(out title, out type, out progress);
+ an.Notification.Title = title;
+ an.Notification.Type = type;
+ an.Notification.Progress = progress;
+ }
+
+ ImGui.SameLine();
+ if (ImGui.Button("Dismiss"))
+ an.Notification.DismissNow();
+
+ ImGui.SameLine();
+ ImGui.SetNextItemWidth(an.MaxCoord.X - ImGui.GetCursorPosX());
+ ImGui.InputText("##input", ref testString, 255);
+ };
+ }
+ }
+ }
+
+ private static void NewRandom(out string? title, out NotificationType type, out float progress)
+ {
+ var rand = new Random();
+
+ title = rand.Next(0, 7) 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, 5) switch
+ {
+ 0 => NotificationType.Error,
+ 1 => NotificationType.Warning,
+ 2 => NotificationType.Info,
+ 3 => NotificationType.Success,
+ 4 => NotificationType.None,
+ _ => NotificationType.None,
+ };
+
+ if (rand.Next() % 2 == 0)
+ progress = -1;
+ else
+ progress = rand.NextSingle();
+ }
+
+ private struct NotificationTemplate
+ {
+ public static readonly string[] IconTitles =
+ {
+ "None (use Type)",
+ "SeIconChar",
+ "FontAwesomeIcon",
+ "GamePath",
+ "FilePath",
+ "TextureWrap from DalamudAssets",
+ "TextureWrap from DalamudAssets(Async)",
+ "TextureWrap from GamePath",
+ "TextureWrap from FilePath",
+ };
+
+ public static readonly string[] AssetSources =
+ Enum.GetValues()
+ .Where(x => x.GetAttribute()?.Purpose is DalamudAssetPurpose.TextureFromPng)
+ .Select(Enum.GetName)
+ .ToArray();
+
+ public static readonly string[] ProgressModeTitles =
+ {
+ "Default",
+ "Random",
+ "Increasing",
+ "Increasing & Auto Dismiss",
+ "Indeterminate",
+ };
+
+ public static readonly string[] TypeTitles =
+ {
+ nameof(NotificationType.None),
+ nameof(NotificationType.Success),
+ nameof(NotificationType.Warning),
+ nameof(NotificationType.Error),
+ nameof(NotificationType.Info),
+ };
+
+ public static readonly string[] InitialDurationTitles =
+ {
+ "Infinite",
+ "1 seconds",
+ "3 seconds (default)",
+ "10 seconds",
+ };
+
+ public static readonly string[] HoverExtendDurationTitles =
+ {
+ "Disable",
+ "1 seconds",
+ "3 seconds (default)",
+ "10 seconds",
+ };
+
+ public static readonly TimeSpan[] Durations =
+ {
+ TimeSpan.Zero,
+ TimeSpan.FromSeconds(1),
+ NotificationConstants.DefaultDuration,
+ TimeSpan.FromSeconds(10),
+ };
+
+ public bool ManualContent;
+ public string Content;
+ public bool ManualTitle;
+ public string Title;
+ public bool ManualMinimizedText;
+ public string MinimizedText;
+ public int IconInt;
+ public string IconText;
+ public int IconAssetInt;
+ public bool ManualType;
+ public int TypeInt;
+ public int InitialDurationInt;
+ public int HoverExtendDurationInt;
+ public bool ShowIndeterminateIfNoExpiry;
+ public bool RespectUiHidden;
+ public bool Minimized;
+ public bool UserDismissable;
+ public bool ActionBar;
+ public int ProgressMode;
+
+ public void Reset()
+ {
+ this.ManualContent = false;
+ this.Content = string.Empty;
+ this.ManualTitle = false;
+ this.Title = string.Empty;
+ this.ManualMinimizedText = false;
+ this.MinimizedText = string.Empty;
+ this.IconInt = 0;
+ this.IconText = "ui/icon/000000/000004_hr1.tex";
+ this.IconAssetInt = 0;
+ this.ManualType = false;
+ this.TypeInt = (int)NotificationType.None;
+ this.InitialDurationInt = 2;
+ this.HoverExtendDurationInt = 2;
+ this.ShowIndeterminateIfNoExpiry = true;
+ this.Minimized = true;
+ this.UserDismissable = true;
+ this.ActionBar = true;
+ this.ProgressMode = 0;
+ this.RespectUiHidden = true;
}
}
}
diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs
index 95c227662..210290f17 100644
--- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs
+++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs
@@ -15,6 +15,7 @@ using Dalamud.Game.Command;
using Dalamud.Interface.Animation.EasingFunctions;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Components;
+using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs
index eafea9d16..857002771 100644
--- a/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs
+++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs
@@ -7,6 +7,7 @@ using CheapLoc;
using Dalamud.Configuration.Internal;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Components;
+using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
diff --git a/Dalamud/Interface/Internal/Windows/PluginStatWindow.cs b/Dalamud/Interface/Internal/Windows/PluginStatWindow.cs
index a1d93bb8c..bfa30cafd 100644
--- a/Dalamud/Interface/Internal/Windows/PluginStatWindow.cs
+++ b/Dalamud/Interface/Internal/Windows/PluginStatWindow.cs
@@ -7,6 +7,7 @@ using System.Reflection;
using Dalamud.Game;
using Dalamud.Hooking.Internal;
using Dalamud.Interface.Components;
+using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Windowing;
using Dalamud.Plugin.Internal;
diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs
index 89d968158..15e2803da 100644
--- a/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs
+++ b/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs
@@ -24,7 +24,6 @@ internal abstract class FontHandle : IFontHandle
private static readonly ConditionalWeakTable NonMainThreadFontAccessWarning = new();
private static long nextNonMainThreadFontAccessWarningCheck;
- private readonly InterfaceManager interfaceManager;
private readonly List pushedFonts = new(8);
private IFontHandleManager? manager;
@@ -36,7 +35,6 @@ internal abstract class FontHandle : IFontHandle
/// An instance of .
protected FontHandle(IFontHandleManager manager)
{
- this.interfaceManager = Service.Get();
this.manager = manager;
}
@@ -58,7 +56,11 @@ internal abstract class FontHandle : IFontHandle
/// Gets the associated .
///
/// When the object has already been disposed.
- protected IFontHandleManager Manager => this.manager ?? throw new ObjectDisposedException(this.GetType().Name);
+ protected IFontHandleManager Manager =>
+ this.manager
+ ?? throw new ObjectDisposedException(
+ this.GetType().Name,
+ "Did you write `using (fontHandle)` instead of `using (fontHandle.Push())`?");
///
public void Dispose()
@@ -122,7 +124,7 @@ internal abstract class FontHandle : IFontHandle
}
}
- this.interfaceManager.EnqueueDeferredDispose(locked);
+ Service.Get().EnqueueDeferredDispose(locked);
return locked.ImFont;
}
@@ -201,7 +203,7 @@ internal abstract class FontHandle : IFontHandle
ThreadSafety.AssertMainThread();
// Warn if the client is not properly managing the pushed font stack.
- var cumulativePresentCalls = this.interfaceManager.CumulativePresentCalls;
+ var cumulativePresentCalls = Service.Get().CumulativePresentCalls;
if (this.lastCumulativePresentCalls != cumulativePresentCalls)
{
this.lastCumulativePresentCalls = cumulativePresentCalls;
@@ -218,7 +220,7 @@ internal abstract class FontHandle : IFontHandle
if (this.TryLock(out _) is { } locked)
{
font = locked.ImFont;
- this.interfaceManager.EnqueueDeferredDispose(locked);
+ Service.Get().EnqueueDeferredDispose(locked);
}
var rented = SimplePushedFont.Rent(this.pushedFonts, font);
diff --git a/Dalamud/Interface/UiBuilder.cs b/Dalamud/Interface/UiBuilder.cs
index d260868a0..2053d9354 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,15 @@ using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.Gui;
using Dalamud.Interface.FontIdentifier;
using Dalamud.Interface.GameFonts;
+using Dalamud.Interface.ImGuiNotification;
+using Dalamud.Interface.ImGuiNotification.Internal;
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 +33,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 +58,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 +564,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,
+ InitialDuration = TimeSpan.FromMilliseconds(msDelay),
+ },
+ this.localPlugin);
+ _ = this.notifications.TryAdd(an, 0);
+ an.Dismiss += a => this.notifications.TryRemove(a.Notification, 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/Localization.cs b/Dalamud/Localization.cs
index b180f113a..a9b0cf93d 100644
--- a/Dalamud/Localization.cs
+++ b/Dalamud/Localization.cs
@@ -36,6 +36,7 @@ public class Localization : IServiceType
/// Use embedded loc resource files.
public Localization(string locResourceDirectory, string locResourcePrefix = "", bool useEmbedded = false)
{
+ this.DalamudLanguageCultureInfo = CultureInfo.InvariantCulture;
this.locResourceDirectory = locResourceDirectory;
this.locResourcePrefix = locResourcePrefix;
this.useEmbedded = useEmbedded;
@@ -61,7 +62,24 @@ public class Localization : IServiceType
///
/// Event that occurs when the language is changed.
///
- public event LocalizationChangedDelegate LocalizationChanged;
+ public event LocalizationChangedDelegate? LocalizationChanged;
+
+ ///
+ /// Gets an instance of that corresponds to the language configured from Dalamud Settings.
+ ///
+ public CultureInfo DalamudLanguageCultureInfo { get; private set; }
+
+ ///
+ /// Gets an instance of that corresponds to .
+ ///
+ /// The language code which should be in .
+ /// The corresponding instance of .
+ public static CultureInfo GetCultureInfoFromLangCode(string langCode) =>
+ CultureInfo.GetCultureInfo(langCode switch
+ {
+ "tw" => "zh-tw",
+ _ => langCode,
+ });
///
/// Search the set-up localization data for the provided assembly for the given string key and return it.
@@ -108,6 +126,7 @@ public class Localization : IServiceType
///
public void SetupWithFallbacks()
{
+ this.DalamudLanguageCultureInfo = CultureInfo.InvariantCulture;
this.LocalizationChanged?.Invoke(FallbackLangCode);
Loc.SetupWithFallbacks(this.assembly);
}
@@ -124,6 +143,7 @@ public class Localization : IServiceType
return;
}
+ this.DalamudLanguageCultureInfo = GetCultureInfoFromLangCode(langCode);
this.LocalizationChanged?.Invoke(langCode);
try
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/Internal/Types/LocalDevPlugin.cs b/Dalamud/Plugin/Internal/Types/LocalDevPlugin.cs
index 580d5c161..1f9f503e0 100644
--- a/Dalamud/Plugin/Internal/Types/LocalDevPlugin.cs
+++ b/Dalamud/Plugin/Internal/Types/LocalDevPlugin.cs
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using Dalamud.Configuration.Internal;
+using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Logging.Internal;
using Dalamud.Plugin.Internal.Types.Manifest;
diff --git a/Dalamud/Plugin/Services/INotificationManager.cs b/Dalamud/Plugin/Services/INotificationManager.cs
new file mode 100644
index 000000000..7d9ccd0b0
--- /dev/null
+++ b/Dalamud/Plugin/Services/INotificationManager.cs
@@ -0,0 +1,12 @@
+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);
+}
diff --git a/Dalamud/Utility/Api10ToDoAttribute.cs b/Dalamud/Utility/Api10ToDoAttribute.cs
index f397f8f0c..a13aaead5 100644
--- a/Dalamud/Utility/Api10ToDoAttribute.cs
+++ b/Dalamud/Utility/Api10ToDoAttribute.cs
@@ -11,9 +11,19 @@ internal sealed class Api10ToDoAttribute : Attribute
///
public const string DeleteCompatBehavior = "Delete. This is for making API 9 plugins work.";
+ ///
+ /// Marks that this should be moved to an another namespace.
+ ///
+ public const string MoveNamespace = "Move to another namespace.";
+
///
/// Initializes a new instance of the class.
///
/// The explanation.
- public Api10ToDoAttribute(string what) => _ = what;
+ /// The explanation 2.
+ public Api10ToDoAttribute(string what, string what2 = "")
+ {
+ _ = what;
+ _ = what2;
+ }
}
diff --git a/Dalamud/Utility/DateTimeSpanExtensions.cs b/Dalamud/Utility/DateTimeSpanExtensions.cs
new file mode 100644
index 000000000..8422a4a26
--- /dev/null
+++ b/Dalamud/Utility/DateTimeSpanExtensions.cs
@@ -0,0 +1,125 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+
+using CheapLoc;
+
+using Dalamud.Logging.Internal;
+
+namespace Dalamud.Utility;
+
+///
+/// Utility functions for and .
+///
+public static class DateTimeSpanExtensions
+{
+ private static readonly ModuleLog Log = new(nameof(DateTimeSpanExtensions));
+
+ private static ParsedRelativeFormatStrings? relativeFormatStringLong;
+
+ private static ParsedRelativeFormatStrings? relativeFormatStringShort;
+
+ /// Formats an instance of as a localized absolute time.
+ /// When.
+ /// The formatted string.
+ /// The string will be formatted according to Square Enix Account region settings, if Dalamud default
+ /// language is English.
+ public static unsafe string LocAbsolute(this DateTime when)
+ {
+ var culture = Service.GetNullable()?.DalamudLanguageCultureInfo ?? CultureInfo.InvariantCulture;
+ if (!Equals(culture, CultureInfo.InvariantCulture))
+ return when.ToString("G", culture);
+
+ var framework = FFXIVClientStructs.FFXIV.Client.System.Framework.Framework.Instance();
+ var region = 0;
+ if (framework is not null)
+ region = framework->Region;
+ return region switch
+ {
+ 1 => when.ToString("MM/dd/yyyy HH:mm:ss"), // na
+ 2 => when.ToString("dd-mm-yyyy HH:mm:ss"), // eu
+ _ => when.ToString("yyyy-MM-dd HH:mm:ss"), // jp(0), cn(3), kr(4), and other possible errorneous cases
+ };
+ }
+
+ /// Formats an instance of as a localized relative time.
+ /// When.
+ /// The formatted string.
+ public static string LocRelativePastLong(this DateTime when)
+ {
+ var loc = Loc.Localize(
+ "DateTimeSpanExtensions.RelativeFormatStringsLong",
+ "172800,{0:%d} days ago\n86400,yesterday\n7200,{0:%h} hours ago\n3600,an hour ago\n120,{0:%m} minutes ago\n60,a minute ago\n2,{0:%s} seconds ago\n1,a second ago\n-Infinity,just now");
+ Debug.Assert(loc != null, "loc != null");
+
+ if (relativeFormatStringLong?.FormatStringLoc != loc)
+ relativeFormatStringLong ??= new(loc);
+
+ return relativeFormatStringLong.Format(DateTime.Now - when);
+ }
+
+ /// Formats an instance of as a localized relative time.
+ /// When.
+ /// The formatted string.
+ public static string LocRelativePastShort(this DateTime when)
+ {
+ var loc = Loc.Localize(
+ "DateTimeSpanExtensions.RelativeFormatStringsShort",
+ "86400,{0:%d}d\n3600,{0:%h}h\n60,{0:%m}m\n1,{0:%s}s\n-Infinity,now");
+ Debug.Assert(loc != null, "loc != null");
+
+ if (relativeFormatStringShort?.FormatStringLoc != loc)
+ relativeFormatStringShort = new(loc);
+
+ return relativeFormatStringShort.Format(DateTime.Now - when);
+ }
+
+ private sealed class ParsedRelativeFormatStrings
+ {
+ private readonly List<(float MinSeconds, string FormatString)> formatStrings = new();
+
+ public ParsedRelativeFormatStrings(string value)
+ {
+ this.FormatStringLoc = value;
+ foreach (var line in value.Split("\n"))
+ {
+ var sep = line.IndexOf(',');
+ if (sep < 0)
+ {
+ Log.Error("A line without comma has been found: {line}", line);
+ continue;
+ }
+
+ if (!float.TryParse(
+ line.AsSpan(0, sep),
+ NumberStyles.Float,
+ CultureInfo.InvariantCulture,
+ out var seconds))
+ {
+ Log.Error("Could not parse the duration: {line}", line);
+ continue;
+ }
+
+ this.formatStrings.Add((seconds, line[(sep + 1)..]));
+ }
+
+ this.formatStrings.Sort((a, b) => b.MinSeconds.CompareTo(a.MinSeconds));
+ }
+
+ public string FormatStringLoc { get; }
+
+ /// Formats an instance of as a localized string.
+ /// The duration.
+ /// The formatted string.
+ public string Format(TimeSpan ts)
+ {
+ foreach (var (minSeconds, formatString) in this.formatStrings)
+ {
+ if (ts.TotalSeconds >= minSeconds)
+ return string.Format(formatString, ts);
+ }
+
+ return this.formatStrings[^1].FormatString.Format(ts);
+ }
+ }
+}