Merge pull request #1681 from Soreepeong/feature/inotificationmanager

Implement INotificationManager
This commit is contained in:
goat 2024-03-16 16:46:12 +01:00 committed by GitHub
commit dcec076ca7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2508 additions and 386 deletions

View file

@ -11,6 +11,7 @@ using Dalamud.Game.Gui;
using Dalamud.Game.Text; using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal; using Dalamud.Interface.Internal;
using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Internal.Windows; using Dalamud.Interface.Internal.Windows;

View file

@ -0,0 +1,9 @@
namespace Dalamud.Interface.ImGuiNotification.EventArgs;
/// <summary>Arguments for use with <see cref="IActiveNotification.Click"/>.</summary>
/// <remarks>Not to be implemented by plugins.</remarks>
public interface INotificationClickArgs
{
/// <summary>Gets the notification being clicked.</summary>
IActiveNotification Notification { get; }
}

View file

@ -0,0 +1,12 @@
namespace Dalamud.Interface.ImGuiNotification.EventArgs;
/// <summary>Arguments for use with <see cref="IActiveNotification.Dismiss"/>.</summary>
/// <remarks>Not to be implemented by plugins.</remarks>
public interface INotificationDismissArgs
{
/// <summary>Gets the notification being dismissed.</summary>
IActiveNotification Notification { get; }
/// <summary>Gets the dismiss reason.</summary>
NotificationDismissReason Reason { get; }
}

View file

@ -0,0 +1,19 @@
using System.Numerics;
namespace Dalamud.Interface.ImGuiNotification.EventArgs;
/// <summary>Arguments for use with <see cref="IActiveNotification.DrawActions"/>.</summary>
/// <remarks>Not to be implemented by plugins.</remarks>
public interface INotificationDrawArgs
{
/// <summary>Gets the notification being drawn.</summary>
IActiveNotification Notification { get; }
/// <summary>Gets the top left coordinates of the area being drawn.</summary>
Vector2 MinCoord { get; }
/// <summary>Gets the bottom right coordinates of the area being drawn.</summary>
/// <remarks>Note that <see cref="Vector2.Y"/> can be <see cref="float.MaxValue"/>, in which case there is no
/// vertical limits to the drawing region.</remarks>
Vector2 MaxCoord { get; }
}

View file

@ -0,0 +1,83 @@
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Interface.ImGuiNotification.EventArgs;
using Dalamud.Interface.Internal;
namespace Dalamud.Interface.ImGuiNotification;
/// <summary>Represents an active notification.</summary>
/// <remarks>Not to be implemented by plugins.</remarks>
public interface IActiveNotification : INotification
{
/// <summary>The counter for <see cref="Id"/> field.</summary>
private static long idCounter;
/// <summary>Invoked upon dismissing the notification.</summary>
/// <remarks>The event callback will not be called, if it gets dismissed after plugin unload.</remarks>
event Action<INotificationDismissArgs> Dismiss;
/// <summary>Invoked upon clicking on the notification.</summary>
/// <remarks>Note that this function may be called even after <see cref="Dismiss"/> has been invoked.</remarks>
event Action<INotificationClickArgs> Click;
/// <summary>Invoked upon drawing the action bar of the notification.</summary>
/// <remarks>Note that this function may be called even after <see cref="Dismiss"/> has been invoked.</remarks>
event Action<INotificationDrawArgs> DrawActions;
/// <summary>Gets the ID of this notification.</summary>
/// <remarks>This value does not change.</remarks>
long Id { get; }
/// <summary>Gets the time of creating this notification.</summary>
/// <remarks>This value does not change.</remarks>
DateTime CreatedAt { get; }
/// <summary>Gets the effective expiry time.</summary>
/// <remarks>Contains <see cref="DateTime.MaxValue"/> if the notification does not expire.</remarks>
/// <remarks>This value will change depending on property changes and user interactions.</remarks>
DateTime EffectiveExpiry { get; }
/// <summary>Gets the reason how this notification got dismissed. <c>null</c> if not dismissed.</summary>
/// <remarks>This includes when the hide animation is being played.</remarks>
NotificationDismissReason? DismissReason { get; }
/// <summary>Dismisses this notification.</summary>
/// <remarks>If the notification has already been dismissed, this function does nothing.</remarks>
void DismissNow();
/// <summary>Extends this notifiation.</summary>
/// <param name="extension">The extension time.</param>
/// <remarks>This does not override <see cref="INotification.HardExpiry"/>.</remarks>
void ExtendBy(TimeSpan extension);
/// <summary>Sets the icon from <see cref="IDalamudTextureWrap"/>, overriding the icon.</summary>
/// <param name="textureWrap">The new texture wrap to use, or null to clear and revert back to the icon specified
/// from <see cref="INotification.Icon"/>.</param>
/// <remarks>
/// <para>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.</para>
/// <para>If <see cref="DismissReason"/> is not <c>null</c>, then calling this function will simply dispose the
/// passed <paramref name="textureWrap"/> without actually updating the icon.</para>
/// </remarks>
void SetIconTexture(IDalamudTextureWrap? textureWrap);
/// <summary>Sets the icon from <see cref="IDalamudTextureWrap"/>, overriding the icon, once the given task
/// completes.</summary>
/// <param name="textureWrapTask">The task that will result in a new texture wrap to use, or null to clear and
/// revert back to the icon specified from <see cref="INotification.Icon"/>.</param>
/// <remarks>
/// <para>The texture resulted from the passed <see cref="Task{TResult}"/> 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 <see cref="IDalamudTextureWrap"/> yourself.</para>
/// <para>If the task fails for any reason, the exception will be silently ignored and the icon specified from
/// <see cref="INotification.Icon"/> will be used instead.</para>
/// <para>If <see cref="DismissReason"/> is not <c>null</c>, then calling this function will simply dispose the
/// result of the passed <paramref name="textureWrapTask"/> without actually updating the icon.</para>
/// </remarks>
void SetIconTexture(Task<IDalamudTextureWrap?>? textureWrapTask);
/// <summary>Generates a new value to use for <see cref="Id"/>.</summary>
/// <returns>The new value.</returns>
internal static long CreateNewId() => Interlocked.Increment(ref idCounter);
}

View file

@ -0,0 +1,76 @@
using System.Threading.Tasks;
using Dalamud.Interface.Internal;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Plugin.Services;
namespace Dalamud.Interface.ImGuiNotification;
/// <summary>Represents a notification.</summary>
/// <remarks>Not to be implemented by plugins.</remarks>
public interface INotification
{
/// <summary>Gets or sets the content body of the notification.</summary>
string Content { get; set; }
/// <summary>Gets or sets the title of the notification.</summary>
string? Title { get; set; }
/// <summary>Gets or sets the text to display when the notification is minimized.</summary>
string? MinimizedText { get; set; }
/// <summary>Gets or sets the type of the notification.</summary>
NotificationType Type { get; set; }
/// <summary>Gets or sets the icon source.</summary>
/// <remarks>Use <see cref="IActiveNotification.SetIconTexture(IDalamudTextureWrap?)"/> or
/// <see cref="IActiveNotification.SetIconTexture(Task{IDalamudTextureWrap?}?)"/> to use a texture, after calling
/// <see cref="INotificationManager.AddNotification"/>. Call either of those functions with <c>null</c> to revert
/// the effective icon back to this property.</remarks>
INotificationIcon? Icon { get; set; }
/// <summary>Gets or sets the hard expiry.</summary>
/// <remarks>
/// Setting this value will override <see cref="InitialDuration"/> and <see cref="ExtensionDurationSinceLastInterest"/>, in that
/// the notification will be dismissed when this expiry expires.<br />
/// Set to <see cref="DateTime.MaxValue"/> to make only <see cref="InitialDuration"/> take effect.<br />
/// If neither <see cref="HardExpiry"/> nor <see cref="InitialDuration"/> is not MaxValue, then the notification
/// will not expire after a set time. It must be explicitly dismissed by the user of via calling
/// <see cref="IActiveNotification.DismissNow"/>.<br />
/// Updating this value will reset the dismiss timer.
/// </remarks>
DateTime HardExpiry { get; set; }
/// <summary>Gets or sets the initial duration.</summary>
/// <remarks>Set to <see cref="TimeSpan.MaxValue"/> to make only <see cref="HardExpiry"/> take effect.</remarks>
/// <remarks>Updating this value will reset the dismiss timer, but the remaining duration will still be calculated
/// based on <see cref="IActiveNotification.CreatedAt"/>.</remarks>
TimeSpan InitialDuration { get; set; }
/// <summary>Gets or sets the new duration for this notification once the mouse cursor leaves the window and the
/// window is no longer focused.</summary>
/// <remarks>
/// If set to <see cref="TimeSpan.Zero"/> 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.<br />
/// Updating this value will reset the dismiss timer.
/// </remarks>
TimeSpan ExtensionDurationSinceLastInterest { get; set; }
/// <summary>Gets or sets a value indicating whether to show an indeterminate expiration animation if
/// <see cref="HardExpiry"/> is set to <see cref="DateTime.MaxValue"/>.</summary>
bool ShowIndeterminateIfNoExpiry { get; set; }
/// <summary>Gets or sets a value indicating whether to respect the current UI visibility state.</summary>
bool RespectUiHidden { get; set; }
/// <summary>Gets or sets a value indicating whether the notification has been minimized.</summary>
bool Minimized { get; set; }
/// <summary>Gets or sets a value indicating whether the user can dismiss the notification by themselves.</summary>
/// <remarks>Consider adding a cancel button to <see cref="IActiveNotification.DrawActions"/>.</remarks>
bool UserDismissable { get; set; }
/// <summary>Gets or sets the progress for the background progress bar of the notification.</summary>
/// <remarks>The progress should be in the range between 0 and 1.</remarks>
float Progress { get; set; }
}

View file

@ -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;
/// <summary>Icon source for <see cref="INotification"/>.</summary>
/// <remarks>Plugins implementing this interface are left to their own on managing the resources contained by the
/// instance of their implementation of <see cref="INotificationIcon"/>. In other words, they should not expect to have
/// <see cref="IDisposable.Dispose"/> called if their implementation is an <see cref="IDisposable"/>. Dalamud will not
/// call <see cref="IDisposable.Dispose"/> on any instance of <see cref="INotificationIcon"/>. On plugin unloads, the
/// icon may be reverted back to the default, if the instance of <see cref="INotificationIcon"/> is not provided by
/// Dalamud.</remarks>
public interface INotificationIcon
{
/// <summary>Gets a new instance of <see cref="INotificationIcon"/> that will source the icon from an
/// <see cref="SeIconChar"/>.</summary>
/// <param name="iconChar">The icon character.</param>
/// <returns>A new instance of <see cref="INotificationIcon"/> that should be disposed after use.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static INotificationIcon From(SeIconChar iconChar) => new SeIconCharNotificationIcon(iconChar);
/// <summary>Gets a new instance of <see cref="INotificationIcon"/> that will source the icon from an
/// <see cref="FontAwesomeIcon"/>.</summary>
/// <param name="iconChar">The icon character.</param>
/// <returns>A new instance of <see cref="INotificationIcon"/> that should be disposed after use.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static INotificationIcon From(FontAwesomeIcon iconChar) => new FontAwesomeIconNotificationIcon(iconChar);
/// <summary>Gets a new instance of <see cref="INotificationIcon"/> that will source the icon from a texture
/// file shipped as a part of the game resources.</summary>
/// <param name="gamePath">The path to a texture file in the game virtual file system.</param>
/// <returns>A new instance of <see cref="INotificationIcon"/> that should be disposed after use.</returns>
/// <remarks>If any errors are thrown, the default icon will be displayed instead.</remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static INotificationIcon FromGame(string gamePath) => new GamePathNotificationIcon(gamePath);
/// <summary>Gets a new instance of <see cref="INotificationIcon"/> that will source the icon from an image
/// file from the file system.</summary>
/// <param name="filePath">The path to an image file in the file system.</param>
/// <returns>A new instance of <see cref="INotificationIcon"/> that should be disposed after use.</returns>
/// <remarks>If any errors are thrown, the default icon will be displayed instead.</remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static INotificationIcon FromFile(string filePath) => new FilePathNotificationIcon(filePath);
/// <summary>Draws the icon.</summary>
/// <param name="minCoord">The coordinates of the top left of the icon area.</param>
/// <param name="maxCoord">The coordinates of the bottom right of the icon area.</param>
/// <param name="color">The foreground color.</param>
/// <returns><c>true</c> if anything has been drawn.</returns>
bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color);
}

View file

@ -0,0 +1,87 @@
using System.Numerics;
using Dalamud.Interface.ImGuiNotification.EventArgs;
namespace Dalamud.Interface.ImGuiNotification.Internal;
/// <summary>Represents an active notification.</summary>
internal sealed partial class ActiveNotification : INotificationDismissArgs
{
/// <inheritdoc/>
public event Action<INotificationDismissArgs>? Dismiss;
/// <inheritdoc/>
IActiveNotification INotificationDismissArgs.Notification => this;
/// <inheritdoc/>
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");
}
}
}
/// <summary>Represents an active notification.</summary>
internal sealed partial class ActiveNotification : INotificationClickArgs
{
/// <inheritdoc/>
public event Action<INotificationClickArgs>? Click;
/// <inheritdoc/>
IActiveNotification INotificationClickArgs.Notification => this;
private void InvokeClick()
{
try
{
this.Click?.Invoke(this);
}
catch (Exception e)
{
this.LogEventInvokeError(e, $"{nameof(this.Click)} error");
}
}
}
/// <summary>Represents an active notification.</summary>
internal sealed partial class ActiveNotification : INotificationDrawArgs
{
private Vector2 drawActionArgMinCoord;
private Vector2 drawActionArgMaxCoord;
/// <inheritdoc/>
public event Action<INotificationDrawArgs>? DrawActions;
/// <inheritdoc/>
IActiveNotification INotificationDrawArgs.Notification => this;
/// <inheritdoc/>
Vector2 INotificationDrawArgs.MinCoord => this.drawActionArgMinCoord;
/// <inheritdoc/>
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");
}
}
}

View file

@ -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;
/// <summary>Represents an active notification.</summary>
internal sealed partial class ActiveNotification
{
/// <summary>Draws this notification.</summary>
/// <param name="width">The maximum width of the notification window.</param>
/// <param name="offsetY">The offset from the bottom.</param>
/// <returns>The height of the notification.</returns>
public float Draw(float 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;
}
/// <summary>Calculates the effective expiry, taking ImGui window state into account.</summary>
/// <param name="warrantsExtension">Notification will not dismiss while this paramter is <c>true</c>.</param>
/// <returns>The calculated effective expiry.</returns>
/// <remarks>Expected to be called BETWEEN <see cref="ImGui.Begin(string)"/> and <see cref="ImGui.End"/>.</remarks>
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<InterfaceManager>.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<NotificationManager>.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();
}
}

View file

@ -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;
/// <summary>Represents an active notification.</summary>
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;
/// <summary>Gets the time of starting to count the timer for the expiration.</summary>
private DateTime lastInterestTime;
/// <summary>Gets the extended expiration time from <see cref="ExtendBy"/>.</summary>
private DateTime extendedExpiry;
/// <summary>The icon texture to use if specified; otherwise, icon will be used from <see cref="Icon"/>.</summary>
private Task<IDalamudTextureWrap>? iconTextureWrap;
/// <summary>The plugin that initiated this notification.</summary>
private LocalPlugin? initiatorPlugin;
/// <summary>Whether <see cref="initiatorPlugin"/> has been unloaded.</summary>
private bool isInitiatorUnloaded;
/// <summary>The progress before for the progress bar animation with <see cref="progressEasing"/>.</summary>
private float progressBefore;
/// <summary>Used for calculating correct dismissal progressbar animation (left edge).</summary>
private float prevProgressL;
/// <summary>Used for calculating correct dismissal progressbar animation (right edge).</summary>
private float prevProgressR;
/// <summary>New progress value to be updated on next call to <see cref="UpdateOrDisposeInternal"/>.</summary>
private float? newProgress;
/// <summary>New minimized value to be updated on next call to <see cref="UpdateOrDisposeInternal"/>.</summary>
private bool? newMinimized;
/// <summary>Initializes a new instance of the <see cref="ActiveNotification"/> class.</summary>
/// <param name="underlyingNotification">The underlying notification.</param>
/// <param name="initiatorPlugin">The initiator plugin. Use <c>null</c> if originated by Dalamud.</param>
public ActiveNotification(Notification underlyingNotification, LocalPlugin? initiatorPlugin)
{
this.underlyingNotification = underlyingNotification with { };
this.initiatorPlugin = initiatorPlugin;
this.showEasing = new InCubic(NotificationConstants.ShowAnimationDuration);
this.hideEasing = new OutCubic(NotificationConstants.HideAnimationDuration);
this.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();
}
/// <inheritdoc/>
public long Id { get; } = IActiveNotification.CreateNewId();
/// <inheritdoc/>
public DateTime CreatedAt { get; }
/// <inheritdoc/>
public string Content
{
get => this.underlyingNotification.Content;
set => this.underlyingNotification.Content = value;
}
/// <inheritdoc/>
public string? Title
{
get => this.underlyingNotification.Title;
set => this.underlyingNotification.Title = value;
}
/// <inheritdoc/>
public bool RespectUiHidden
{
get => this.underlyingNotification.RespectUiHidden;
set => this.underlyingNotification.RespectUiHidden = value;
}
/// <inheritdoc/>
public string? MinimizedText
{
get => this.underlyingNotification.MinimizedText;
set => this.underlyingNotification.MinimizedText = value;
}
/// <inheritdoc/>
public NotificationType Type
{
get => this.underlyingNotification.Type;
set => this.underlyingNotification.Type = value;
}
/// <inheritdoc/>
public INotificationIcon? Icon
{
get => this.underlyingNotification.Icon;
set => this.underlyingNotification.Icon = value;
}
/// <inheritdoc/>
public DateTime HardExpiry
{
get => this.underlyingNotification.HardExpiry;
set
{
if (this.underlyingNotification.HardExpiry == value)
return;
this.underlyingNotification.HardExpiry = value;
this.lastInterestTime = DateTime.Now;
}
}
/// <inheritdoc/>
public TimeSpan InitialDuration
{
get => this.underlyingNotification.InitialDuration;
set
{
this.underlyingNotification.InitialDuration = value;
this.lastInterestTime = DateTime.Now;
}
}
/// <inheritdoc/>
public TimeSpan ExtensionDurationSinceLastInterest
{
get => this.underlyingNotification.ExtensionDurationSinceLastInterest;
set
{
this.underlyingNotification.ExtensionDurationSinceLastInterest = value;
this.lastInterestTime = DateTime.Now;
}
}
/// <inheritdoc/>
public DateTime EffectiveExpiry { get; private set; }
/// <inheritdoc/>
public NotificationDismissReason? DismissReason { get; private set; }
/// <inheritdoc/>
public bool ShowIndeterminateIfNoExpiry
{
get => this.underlyingNotification.ShowIndeterminateIfNoExpiry;
set => this.underlyingNotification.ShowIndeterminateIfNoExpiry = value;
}
/// <inheritdoc/>
public bool Minimized
{
get => this.newMinimized ?? this.underlyingNotification.Minimized;
set => this.newMinimized = value;
}
/// <inheritdoc/>
public bool UserDismissable
{
get => this.underlyingNotification.UserDismissable;
set => this.underlyingNotification.UserDismissable = value;
}
/// <inheritdoc/>
public float Progress
{
get => this.newProgress ?? this.underlyingNotification.Progress;
set => this.newProgress = value;
}
/// <summary>Gets the eased progress.</summary>
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));
}
}
/// <summary>Gets the string for the initiator field.</summary>
private string InitiatorString =>
this.initiatorPlugin is not { } plugin
? NotificationConstants.DefaultInitiator
: this.isInitiatorUnloaded
? NotificationConstants.UnloadedInitiatorNameFormat.Format(plugin.Name)
: plugin.Name;
/// <summary>Gets the effective text to display when minimized.</summary>
private string EffectiveMinimizedText => (this.MinimizedText ?? this.Content).ReplaceLineEndings(" ");
/// <inheritdoc/>
public void DismissNow() => this.DismissNow(NotificationDismissReason.Programmatical);
/// <summary>Dismisses this notification. Multiple calls will be ignored.</summary>
/// <param name="reason">The reason of dismissal.</param>
public void DismissNow(NotificationDismissReason reason)
{
if (this.DismissReason is not null)
return;
this.DismissReason = reason;
this.hideEasing.Start();
this.InvokeDismiss();
}
/// <inheritdoc/>
public void ExtendBy(TimeSpan extension)
{
var newExpiry = DateTime.Now + extension;
if (this.extendedExpiry < newExpiry)
this.extendedExpiry = newExpiry;
}
/// <inheritdoc/>
public void SetIconTexture(IDalamudTextureWrap? textureWrap)
{
this.SetIconTexture(textureWrap is null ? null : Task.FromResult(textureWrap));
}
/// <inheritdoc/>
public void SetIconTexture(Task<IDalamudTextureWrap?>? 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);
}
}
/// <summary>Removes non-Dalamud invocation targets from events.</summary>
/// <remarks>
/// 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.
/// </remarks>
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>(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;
}
}
/// <summary>Updates the state of this notification, and release the relevant resource if this notification is no
/// longer in use.</summary>
/// <returns><c>true</c> if the notification is over and relevant resources are released.</returns>
/// <remarks>Intended to be called from the main thread only.</remarks>
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;
}
/// <summary>Clears the resources associated with this instance of <see cref="ActiveNotification"/>.</summary>
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}");
}

View file

@ -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;
/// <summary>Constants for drawing notification windows.</summary>
internal static class NotificationConstants
{
// .............................[..]
// ..when.......................[XX]
// .. ..
// ..[i]..title title title title ..
// .. by this_plugin ..
// .. ..
// .. body body body body ..
// .. some more wrapped body ..
// .. ..
// .. action buttons ..
// .................................
/// <summary>The string to measure size of, to decide the width of notification windows.</summary>
/// <remarks>Probably not worth localizing.</remarks>
public const string NotificationWidthMeasurementString =
"The width of this text will decide the width\n" +
"of the notification window.";
/// <summary>The ratio of maximum notification window width w.r.t. main viewport width.</summary>
public const float MaxNotificationWindowWidthWrtMainViewportWidth = 2f / 3;
/// <summary>The size of the icon.</summary>
public const float IconSize = 32;
/// <summary>The background opacity of a notification window.</summary>
public const float BackgroundOpacity = 0.82f;
/// <summary>The duration of indeterminate progress bar loop in milliseconds.</summary>
public const float IndeterminateProgressbarLoopDuration = 2000f;
/// <summary>The duration of the progress wave animation in milliseconds.</summary>
public const float ProgressWaveLoopDuration = 2000f;
/// <summary>The time ratio of a progress wave loop where the animation is idle.</summary>
public const float ProgressWaveIdleTimeRatio = 0.5f;
/// <summary>The time ratio of a non-idle portion of the progress wave loop where the color is the most opaque.
/// </summary>
public const float ProgressWaveLoopMaxColorTimeRatio = 0.7f;
/// <summary>Default duration of the notification.</summary>
public static readonly TimeSpan DefaultDuration = TimeSpan.FromSeconds(3);
/// <summary>Duration of show animation.</summary>
public static readonly TimeSpan ShowAnimationDuration = TimeSpan.FromMilliseconds(300);
/// <summary>Duration of hide animation.</summary>
public static readonly TimeSpan HideAnimationDuration = TimeSpan.FromMilliseconds(300);
/// <summary>Duration of progress change animation.</summary>
public static readonly TimeSpan ProgressChangeAnimationDuration = TimeSpan.FromMilliseconds(200);
/// <summary>Duration of expando animation.</summary>
public static readonly TimeSpan ExpandoAnimationDuration = TimeSpan.FromMilliseconds(300);
/// <summary>Text color for the rectangular border when the notification is focused.</summary>
public static readonly Vector4 FocusBorderColor = new(0.4f, 0.4f, 0.4f, 1f);
/// <summary>Text color for the when.</summary>
public static readonly Vector4 WhenTextColor = new(0.8f, 0.8f, 0.8f, 1f);
/// <summary>Text color for the close button [X].</summary>
public static readonly Vector4 CloseTextColor = new(0.8f, 0.8f, 0.8f, 1f);
/// <summary>Text color for the title.</summary>
public static readonly Vector4 TitleTextColor = new(1f, 1f, 1f, 1f);
/// <summary>Text color for the name of the initiator.</summary>
public static readonly Vector4 BlameTextColor = new(0.8f, 0.8f, 0.8f, 1f);
/// <summary>Text color for the body.</summary>
public static readonly Vector4 BodyTextColor = new(0.9f, 0.9f, 0.9f, 1f);
/// <summary>Color for the background progress bar (determinate progress only).</summary>
public static readonly Vector4 BackgroundProgressColorMax = new(1f, 1f, 1f, 0.1f);
/// <summary>Color for the background progress bar (determinate progress only).</summary>
public static readonly Vector4 BackgroundProgressColorMin = new(1f, 1f, 1f, 0.05f);
/// <summary>Gets the scaled padding of the window (dot(.) in the above diagram).</summary>
public static float ScaledWindowPadding => MathF.Round(16 * ImGuiHelpers.GlobalScale);
/// <summary>Gets the distance from the right bottom border of the viewport
/// to the right bottom border of a notification window.
/// </summary>
public static float ScaledViewportEdgeMargin => MathF.Round(20 * ImGuiHelpers.GlobalScale);
/// <summary>Gets the scaled gap between two notification windows.</summary>
public static float ScaledWindowGap => MathF.Round(10 * ImGuiHelpers.GlobalScale);
/// <summary>Gets the scaled gap between components.</summary>
public static float ScaledComponentGap => MathF.Round(5 * ImGuiHelpers.GlobalScale);
/// <summary>Gets the scaled size of the icon.</summary>
public static float ScaledIconSize => MathF.Round(IconSize * ImGuiHelpers.GlobalScale);
/// <summary>Gets the height of the expiry progress bar.</summary>
public static float ScaledExpiryProgressBarHeight => MathF.Round(3 * ImGuiHelpers.GlobalScale);
/// <summary>Gets the thickness of the focus indicator rectangle.</summary>
public static float FocusIndicatorThickness => MathF.Round(3 * ImGuiHelpers.GlobalScale);
/// <summary>Gets the string to show in place of this_plugin if the notification is shown by Dalamud.</summary>
public static string DefaultInitiator => Loc.Localize("NotificationConstants.DefaultInitiator", "Dalamud");
/// <summary>Gets the string format of the initiator name field, if the initiator is unloaded.</summary>
public static string UnloadedInitiatorNameFormat =>
Loc.Localize("NotificationConstants.UnloadedInitiatorNameFormat", "{0} (unloaded)");
/// <summary>Gets the color corresponding to the notification type.</summary>
/// <param name="type">The notification type.</param>
/// <returns>The corresponding color.</returns>
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,
};
/// <summary>Gets the <see cref="FontAwesomeIcon"/> char value corresponding to the notification type.</summary>
/// <param name="type">The notification type.</param>
/// <returns>The corresponding char, or null.</returns>
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',
};
/// <summary>Gets the localized title string corresponding to the notification type.</summary>
/// <param name="type">The notification type.</param>
/// <returns>The corresponding title.</returns>
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,
};
}

View file

@ -0,0 +1,34 @@
using System.IO;
using System.Numerics;
using Dalamud.Interface.Internal;
namespace Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon;
/// <summary>Represents the use of a texture from a file as the icon of a notification.</summary>
/// <remarks>If there was no texture loaded for any reason, the plugin icon will be displayed instead.</remarks>
internal class FilePathNotificationIcon : INotificationIcon
{
private readonly FileInfo fileInfo;
/// <summary>Initializes a new instance of the <see cref="FilePathNotificationIcon"/> class.</summary>
/// <param name="filePath">The path to a .tex file inside the game resources.</param>
public FilePathNotificationIcon(string filePath) => this.fileInfo = new(filePath);
/// <inheritdoc/>
public bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color) =>
NotificationUtilities.DrawIconFrom(
minCoord,
maxCoord,
Service<TextureManager>.Get().GetTextureFromFile(this.fileInfo));
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is FilePathNotificationIcon r && r.fileInfo.FullName == this.fileInfo.FullName;
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(this.GetType().GetHashCode(), this.fileInfo.FullName);
/// <inheritdoc/>
public override string ToString() => $"{nameof(FilePathNotificationIcon)}({this.fileInfo.FullName})";
}

View file

@ -0,0 +1,31 @@
using System.Numerics;
namespace Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon;
/// <summary>Represents the use of <see cref="FontAwesomeIcon"/> as the icon of a notification.</summary>
internal class FontAwesomeIconNotificationIcon : INotificationIcon
{
private readonly char iconChar;
/// <summary>Initializes a new instance of the <see cref="FontAwesomeIconNotificationIcon"/> class.</summary>
/// <param name="iconChar">The character.</param>
public FontAwesomeIconNotificationIcon(FontAwesomeIcon iconChar) => this.iconChar = (char)iconChar;
/// <inheritdoc/>
public bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color) =>
NotificationUtilities.DrawIconFrom(
minCoord,
maxCoord,
this.iconChar,
Service<NotificationManager>.Get().IconFontAwesomeFontHandle,
color);
/// <inheritdoc/>
public override bool Equals(object? obj) => obj is FontAwesomeIconNotificationIcon r && r.iconChar == this.iconChar;
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(this.GetType().GetHashCode(), this.iconChar);
/// <inheritdoc/>
public override string ToString() => $"{nameof(FontAwesomeIconNotificationIcon)}({this.iconChar})";
}

View file

@ -0,0 +1,34 @@
using System.Numerics;
using Dalamud.Interface.Internal;
using Dalamud.Plugin.Services;
namespace Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon;
/// <summary>Represents the use of a game-shipped texture as the icon of a notification.</summary>
/// <remarks>If there was no texture loaded for any reason, the plugin icon will be displayed instead.</remarks>
internal class GamePathNotificationIcon : INotificationIcon
{
private readonly string gamePath;
/// <summary>Initializes a new instance of the <see cref="GamePathNotificationIcon"/> class.</summary>
/// <param name="gamePath">The path to a .tex file inside the game resources.</param>
/// <remarks>Use <see cref="ITextureProvider.GetIconPath"/> to get the game path from icon IDs.</remarks>
public GamePathNotificationIcon(string gamePath) => this.gamePath = gamePath;
/// <inheritdoc/>
public bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color) =>
NotificationUtilities.DrawIconFrom(
minCoord,
maxCoord,
Service<TextureManager>.Get().GetTextureFromGame(this.gamePath));
/// <inheritdoc/>
public override bool Equals(object? obj) => obj is GamePathNotificationIcon r && r.gamePath == this.gamePath;
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(this.GetType().GetHashCode(), this.gamePath);
/// <inheritdoc/>
public override string ToString() => $"{nameof(GamePathNotificationIcon)}({this.gamePath})";
}

View file

@ -0,0 +1,33 @@
using System.Numerics;
using Dalamud.Game.Text;
namespace Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon;
/// <summary>Represents the use of <see cref="SeIconChar"/> as the icon of a notification.</summary>
internal class SeIconCharNotificationIcon : INotificationIcon
{
private readonly SeIconChar iconChar;
/// <summary>Initializes a new instance of the <see cref="SeIconCharNotificationIcon"/> class.</summary>
/// <param name="c">The character.</param>
public SeIconCharNotificationIcon(SeIconChar c) => this.iconChar = c;
/// <inheritdoc/>
public bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color) =>
NotificationUtilities.DrawIconFrom(
minCoord,
maxCoord,
(char)this.iconChar,
Service<NotificationManager>.Get().IconAxisFontHandle,
color);
/// <inheritdoc/>
public override bool Equals(object? obj) => obj is SeIconCharNotificationIcon r && r.iconChar == this.iconChar;
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(this.GetType().GetHashCode(), this.iconChar);
/// <inheritdoc/>
public override string ToString() => $"{nameof(SeIconCharNotificationIcon)}({this.iconChar})";
}

View file

@ -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;
/// <summary>Class handling notifications/toasts in ImGui.</summary>
[InterfaceVersion("1.0")]
[ServiceManager.EarlyLoadedService]
internal class NotificationManager : INotificationManager, IServiceType, IDisposable
{
[ServiceManager.ServiceDependency]
private readonly GameGui gameGui = Service<GameGui>.Get();
private readonly List<ActiveNotification> notifications = new();
private readonly ConcurrentBag<ActiveNotification> 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 })));
}
/// <summary>Gets the handle to AXIS fonts, sized for use as an icon.</summary>
public IFontHandle IconAxisFontHandle { get; }
/// <summary>Gets the handle to FontAwesome fonts, sized for use as an icon.</summary>
public IFontHandle IconFontAwesomeFontHandle { get; }
/// <summary>Gets the private atlas for use with notification windows.</summary>
private IFontAtlas PrivateAtlas { get; }
/// <inheritdoc/>
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();
}
/// <inheritdoc/>
public IActiveNotification AddNotification(Notification notification)
{
var an = new ActiveNotification(notification, null);
this.pendingNotifications.Add(an);
return an;
}
/// <summary>Adds a notification originating from a plugin.</summary>
/// <param name="notification">The notification.</param>
/// <param name="plugin">The source plugin.</param>
/// <returns>The added notification.</returns>
public IActiveNotification AddNotification(Notification notification, LocalPlugin plugin)
{
var an = new ActiveNotification(notification, plugin);
this.pendingNotifications.Add(an);
return an;
}
/// <summary>Add a notification to the notification queue.</summary>
/// <param name="content">The content of the notification.</param>
/// <param name="title">The title of the notification.</param>
/// <param name="type">The type of the notification.</param>
public void AddNotification(
string content,
string? title = null,
NotificationType type = NotificationType.None) =>
this.AddNotification(
new()
{
Content = content,
Title = title,
Type = type,
});
/// <summary>Draw all currently queued notifications.</summary>
public void Draw()
{
var viewportSize = ImGuiHelpers.MainViewport.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;
}
}
}
/// <summary>Plugin-scoped version of a <see cref="NotificationManager"/> service.</summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.ScopedService]
#pragma warning disable SA1015
[ResolveVia<INotificationManager>]
#pragma warning restore SA1015
internal class NotificationManagerPluginScoped : INotificationManager, IServiceType, IDisposable
{
private readonly LocalPlugin localPlugin;
private readonly ConcurrentDictionary<IActiveNotification, int> notifications = new();
[ServiceManager.ServiceDependency]
private readonly NotificationManager notificationManagerService = Service<NotificationManager>.Get();
[ServiceManager.ServiceConstructor]
private NotificationManagerPluginScoped(LocalPlugin localPlugin) =>
this.localPlugin = localPlugin;
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
public void Dispose()
{
while (!this.notifications.IsEmpty)
{
foreach (var n in this.notifications.Keys)
{
this.notifications.TryRemove(n, out _);
((ActiveNotification)n).RemoveNonDalamudInvocations();
}
}
}
}

View file

@ -0,0 +1,52 @@
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Notifications;
namespace Dalamud.Interface.ImGuiNotification;
/// <summary>Represents a blueprint for a notification.</summary>
public sealed record Notification : INotification
{
/// <summary>
/// Gets the default value for <see cref="InitialDuration"/> and <see cref="ExtensionDurationSinceLastInterest"/>.
/// </summary>
public static TimeSpan DefaultDuration => NotificationConstants.DefaultDuration;
/// <inheritdoc/>
public string Content { get; set; } = string.Empty;
/// <inheritdoc/>
public string? Title { get; set; }
/// <inheritdoc/>
public string? MinimizedText { get; set; }
/// <inheritdoc/>
public NotificationType Type { get; set; } = NotificationType.None;
/// <inheritdoc/>
public INotificationIcon? Icon { get; set; }
/// <inheritdoc/>
public DateTime HardExpiry { get; set; } = DateTime.MaxValue;
/// <inheritdoc/>
public TimeSpan InitialDuration { get; set; } = DefaultDuration;
/// <inheritdoc/>
public TimeSpan ExtensionDurationSinceLastInterest { get; set; } = DefaultDuration;
/// <inheritdoc/>
public bool ShowIndeterminateIfNoExpiry { get; set; } = true;
/// <inheritdoc/>
public bool RespectUiHidden { get; set; } = true;
/// <inheritdoc/>
public bool Minimized { get; set; } = true;
/// <inheritdoc/>
public bool UserDismissable { get; set; } = true;
/// <inheritdoc/>
public float Progress { get; set; } = 1f;
}

View file

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

View file

@ -0,0 +1,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;
/// <summary>Utilities for implementing stuff under <see cref="ImGuiNotification"/>.</summary>
public static class NotificationUtilities
{
/// <inheritdoc cref="INotificationIcon.From(SeIconChar)"/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static INotificationIcon ToNotificationIcon(this SeIconChar iconChar) =>
INotificationIcon.From(iconChar);
/// <inheritdoc cref="INotificationIcon.From(FontAwesomeIcon)"/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static INotificationIcon ToNotificationIcon(this FontAwesomeIcon iconChar) =>
INotificationIcon.From(iconChar);
/// <inheritdoc cref="INotificationIcon.FromFile(string)"/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static INotificationIcon ToNotificationIcon(this FileInfo fileInfo) =>
INotificationIcon.FromFile(fileInfo.FullName);
/// <summary>Draws an icon from an <see cref="IFontHandle"/> and a <see cref="char"/>.</summary>
/// <param name="minCoord">The coordinates of the top left of the icon area.</param>
/// <param name="maxCoord">The coordinates of the bottom right of the icon area.</param>
/// <param name="c">The icon character.</param>
/// <param name="fontHandle">The font handle to use.</param>
/// <param name="color">The foreground color.</param>
/// <returns><c>true</c> if anything has been drawn.</returns>
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;
}
/// <summary>Draws an icon from an instance of <see cref="IDalamudTextureWrap"/>.</summary>
/// <param name="minCoord">The coordinates of the top left of the icon area.</param>
/// <param name="maxCoord">The coordinates of the bottom right of the icon area.</param>
/// <param name="texture">The texture.</param>
/// <returns><c>true</c> if anything has been drawn.</returns>
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;
}
}
/// <summary>Draws an icon from an instance of <see cref="Task{TResult}"/> that results in an
/// <see cref="IDalamudTextureWrap"/>.</summary>
/// <param name="minCoord">The coordinates of the top left of the icon area.</param>
/// <param name="maxCoord">The coordinates of the bottom right of the icon area.</param>
/// <param name="textureTask">The task that results in a texture.</param>
/// <returns><c>true</c> if anything has been drawn.</returns>
/// <remarks>Exceptions from the task will be treated as if no texture is provided.</remarks>
internal static bool DrawIconFrom(Vector2 minCoord, Vector2 maxCoord, Task<IDalamudTextureWrap?>? textureTask) =>
textureTask?.IsCompletedSuccessfully is true && DrawIconFrom(minCoord, maxCoord, textureTask.Result);
/// <summary>Draws an icon from an instance of <see cref="LocalPlugin"/>.</summary>
/// <param name="minCoord">The coordinates of the top left of the icon area.</param>
/// <param name="maxCoord">The coordinates of the bottom right of the icon area.</param>
/// <param name="plugin">The plugin. Dalamud icon will be drawn if <c>null</c> is given.</param>
/// <returns><c>true</c> if anything has been drawn.</returns>
internal static bool DrawIconFrom(Vector2 minCoord, Vector2 maxCoord, LocalPlugin? plugin)
{
var dam = Service<DalamudAssetManager>.Get();
if (plugin is null)
return false;
if (!Service<PluginImageCache>.Get().TryGetIcon(
plugin,
plugin.Manifest,
plugin.IsThirdParty,
out var texture) || texture is null)
{
texture = dam.GetDalamudTextureWrap(DalamudAsset.DefaultIcon);
}
return DrawIconFrom(minCoord, maxCoord, texture);
}
/// <summary>Draws the Dalamud logo as an icon.</summary>
/// <param name="minCoord">The coordinates of the top left of the icon area.</param>
/// <param name="maxCoord">The coordinates of the bottom right of the icon area.</param>
internal static void DrawIconFromDalamudLogo(Vector2 minCoord, Vector2 maxCoord)
{
var dam = Service<DalamudAssetManager>.Get();
var texture = dam.GetDalamudTextureWrap(DalamudAsset.LogoSmall);
DrawIconFrom(minCoord, maxCoord, texture);
}
}

View file

@ -14,6 +14,7 @@ using Dalamud.Game.ClientState.Keys;
using Dalamud.Game.Internal.DXGI; using Dalamud.Game.Internal.DXGI;
using Dalamud.Hooking; using Dalamud.Hooking;
using Dalamud.Hooking.WndProcHook; using Dalamud.Hooking.WndProcHook;
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.ManagedAsserts; using Dalamud.Interface.Internal.ManagedAsserts;
using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Interface.ManagedFontAtlas;
@ -917,7 +918,7 @@ internal class InterfaceManager : IDisposable, IServiceType
if (this.IsDispatchingEvents) if (this.IsDispatchingEvents)
{ {
this.Draw?.Invoke(); this.Draw?.Invoke();
Service<NotificationManager>.Get().Draw(); Service<NotificationManager>.GetNullable()?.Draw();
} }
ImGuiManagedAsserts.ReportProblems("Dalamud Core", snap); ImGuiManagedAsserts.ReportProblems("Dalamud Core", snap);

View file

@ -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;
/// <summary>
/// Class handling notifications/toasts in ImGui.
/// Ported from https://github.com/patrickcjk/imgui-notify.
/// </summary>
[ServiceManager.EarlyLoadedService]
internal class NotificationManager : IServiceType
{
/// <summary>
/// Value indicating the bottom-left X padding.
/// </summary>
internal const float NotifyPaddingX = 20.0f;
/// <summary>
/// Value indicating the bottom-left Y padding.
/// </summary>
internal const float NotifyPaddingY = 20.0f;
/// <summary>
/// Value indicating the Y padding between each message.
/// </summary>
internal const float NotifyPaddingMessageY = 10.0f;
/// <summary>
/// Value indicating the fade-in and out duration.
/// </summary>
internal const int NotifyFadeInOutTime = 500;
/// <summary>
/// Value indicating the default time until the notification is dismissed.
/// </summary>
internal const int NotifyDefaultDismiss = 3000;
/// <summary>
/// Value indicating the maximum opacity.
/// </summary>
internal const float NotifyOpacity = 0.82f;
/// <summary>
/// Value indicating default window flags for the notifications.
/// </summary>
internal const ImGuiWindowFlags NotifyToastFlags =
ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoInputs |
ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoBringToFrontOnFocus | ImGuiWindowFlags.NoFocusOnAppearing;
private readonly List<Notification> notifications = new();
[ServiceManager.ServiceConstructor]
private NotificationManager()
{
}
/// <summary>
/// Add a notification to the notification queue.
/// </summary>
/// <param name="content">The content of the notification.</param>
/// <param name="title">The title of the notification.</param>
/// <param name="type">The type of the notification.</param>
/// <param name="msDelay">The time the notification should be displayed for.</param>
public void AddNotification(string content, string? title = null, NotificationType type = NotificationType.None, uint msDelay = NotifyDefaultDismiss)
{
this.notifications.Add(new Notification
{
Content = content,
Title = title,
NotificationType = type,
DurationMs = msDelay,
});
}
/// <summary>
/// Draw all currently queued notifications.
/// </summary>
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();
}
}
/// <summary>
/// Container class for notifications.
/// </summary>
internal class Notification
{
/// <summary>
/// Possible notification phases.
/// </summary>
internal enum Phase
{
/// <summary>
/// Phase indicating fade-in.
/// </summary>
FadeIn,
/// <summary>
/// Phase indicating waiting until fade-out.
/// </summary>
Wait,
/// <summary>
/// Phase indicating fade-out.
/// </summary>
FadeOut,
/// <summary>
/// Phase indicating that the notification has expired.
/// </summary>
Expired,
}
/// <summary>
/// Gets the type of the notification.
/// </summary>
internal NotificationType NotificationType { get; init; }
/// <summary>
/// Gets the title of the notification.
/// </summary>
internal string? Title { get; init; }
/// <summary>
/// Gets the content of the notification.
/// </summary>
internal string Content { get; init; }
/// <summary>
/// Gets the duration of the notification in milliseconds.
/// </summary>
internal uint DurationMs { get; init; }
/// <summary>
/// Gets the creation time of the notification.
/// </summary>
internal DateTime CreationTime { get; init; } = DateTime.Now;
/// <summary>
/// Gets the default color of the notification.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <see cref="NotificationType"/> is set to an out-of-range value.</exception>
internal Vector4 Color => this.NotificationType switch
{
NotificationType.None => ImGuiColors.DalamudWhite,
NotificationType.Success => ImGuiColors.HealerGreen,
NotificationType.Warning => ImGuiColors.DalamudOrange,
NotificationType.Error => ImGuiColors.DalamudRed,
NotificationType.Info => ImGuiColors.TankBlue,
_ => throw new ArgumentOutOfRangeException(),
};
/// <summary>
/// Gets the icon of the notification.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <see cref="NotificationType"/> is set to an out-of-range value.</exception>
internal string? Icon => this.NotificationType switch
{
NotificationType.None => null,
NotificationType.Success => FontAwesomeIcon.CheckCircle.ToIconString(),
NotificationType.Warning => FontAwesomeIcon.ExclamationCircle.ToIconString(),
NotificationType.Error => FontAwesomeIcon.TimesCircle.ToIconString(),
NotificationType.Info => FontAwesomeIcon.InfoCircle.ToIconString(),
_ => throw new ArgumentOutOfRangeException(),
};
/// <summary>
/// Gets the default title of the notification.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <see cref="NotificationType"/> is set to an out-of-range value.</exception>
internal string? DefaultTitle => this.NotificationType switch
{
NotificationType.None => null,
NotificationType.Success => NotificationType.Success.ToString(),
NotificationType.Warning => NotificationType.Warning.ToString(),
NotificationType.Error => NotificationType.Error.ToString(),
NotificationType.Info => NotificationType.Info.ToString(),
_ => throw new ArgumentOutOfRangeException(),
};
/// <summary>
/// Gets the elapsed time since creating the notification.
/// </summary>
internal TimeSpan ElapsedTime => DateTime.Now - this.CreationTime;
/// <summary>
/// Gets the phase of the notification.
/// </summary>
/// <returns>The phase of the notification.</returns>
internal Phase GetPhase()
{
var elapsed = (int)this.ElapsedTime.TotalMilliseconds;
if (elapsed > NotifyFadeInOutTime + this.DurationMs + NotifyFadeInOutTime)
return Phase.Expired;
else if (elapsed > NotifyFadeInOutTime + this.DurationMs)
return Phase.FadeOut;
else if (elapsed > NotifyFadeInOutTime)
return Phase.Wait;
else
return Phase.FadeIn;
}
/// <summary>
/// Gets the opacity of the notification.
/// </summary>
/// <returns>The opacity, in a range from 0 to 1.</returns>
internal float GetFadePercent()
{
var phase = this.GetPhase();
var elapsed = this.ElapsedTime.TotalMilliseconds;
if (phase == Phase.FadeIn)
{
return (float)elapsed / NotifyFadeInOutTime * NotifyOpacity;
}
else if (phase == Phase.FadeOut)
{
return (1.0f - (((float)elapsed - NotifyFadeInOutTime - this.DurationMs) /
NotifyFadeInOutTime)) * NotifyOpacity;
}
return 1.0f * NotifyOpacity;
}
}
}

View file

@ -1,32 +1,23 @@
namespace Dalamud.Interface.Internal.Notifications; using Dalamud.Utility;
/// <summary> namespace Dalamud.Interface.Internal.Notifications;
/// Possible notification types.
/// </summary> /// <summary>Possible notification types.</summary>
[Api10ToDo(Api10ToDoAttribute.MoveNamespace, nameof(ImGuiNotification.Internal))]
public enum NotificationType public enum NotificationType
{ {
/// <summary> /// <summary>No special type.</summary>
/// No special type.
/// </summary>
None, None,
/// <summary> /// <summary>Type indicating success.</summary>
/// Type indicating success.
/// </summary>
Success, Success,
/// <summary> /// <summary>Type indicating a warning.</summary>
/// Type indicating a warning.
/// </summary>
Warning, Warning,
/// <summary> /// <summary>Type indicating an error.</summary>
/// Type indicating an error.
/// </summary>
Error, Error,
/// <summary> /// <summary>Type indicating generic information.</summary>
/// Type indicating generic information.
/// </summary>
Info, Info,
} }

View file

@ -12,6 +12,8 @@ using Dalamud.Game;
using Dalamud.Game.Command; using Dalamud.Game.Command;
using Dalamud.Interface.Colors; using Dalamud.Interface.Colors;
using Dalamud.Interface.Components; using Dalamud.Interface.Components;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;
@ -76,6 +78,8 @@ internal class ConsoleWindow : Window, IDisposable
private int historyPos; private int historyPos;
private int copyStart = -1; private int copyStart = -1;
private IActiveNotification? prevCopyNotification;
/// <summary>Initializes a new instance of the <see cref="ConsoleWindow"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="ConsoleWindow"/> class.</summary>
/// <param name="configuration">An instance of <see cref="DalamudConfiguration"/>.</param> /// <param name="configuration">An instance of <see cref="DalamudConfiguration"/>.</param>
public ConsoleWindow(DalamudConfiguration configuration) public ConsoleWindow(DalamudConfiguration configuration)
@ -436,10 +440,14 @@ internal class ConsoleWindow : Window, IDisposable
return; return;
ImGui.SetClipboardText(sb.ToString()); ImGui.SetClipboardText(sb.ToString());
Service<NotificationManager>.Get().AddNotification( this.prevCopyNotification?.DismissNow();
$"{n:n0} line(s) copied.", this.prevCopyNotification = Service<NotificationManager>.Get().AddNotification(
this.WindowName, new()
NotificationType.Success); {
Title = this.WindowName,
Content = $"{n:n0} line(s) copied.",
Type = NotificationType.Success,
});
} }
private void DrawOptionsToolbar() private void DrawOptionsToolbar()

View file

@ -5,6 +5,7 @@ using System.Numerics;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;

View file

@ -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.Interface.Windowing;
using Dalamud.Storage.Assets;
using Dalamud.Utility;
using ImGuiNET; using ImGuiNET;
namespace Dalamud.Interface.Internal.Windows.Data.Widgets; namespace Dalamud.Interface.Internal.Windows.Data.Widgets;
@ -9,6 +18,8 @@ namespace Dalamud.Interface.Internal.Windows.Data.Widgets;
/// </summary> /// </summary>
internal class ImGuiWidget : IDataWindowWidget internal class ImGuiWidget : IDataWindowWidget
{ {
private NotificationTemplate notificationTemplate;
/// <inheritdoc/> /// <inheritdoc/>
public string[]? CommandShortcuts { get; init; } = { "imgui" }; public string[]? CommandShortcuts { get; init; } = { "imgui" };
@ -22,6 +33,7 @@ internal class ImGuiWidget : IDataWindowWidget
public void Load() public void Load()
{ {
this.Ready = true; this.Ready = true;
this.notificationTemplate.Reset();
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -38,38 +50,374 @@ internal class ImGuiWidget : IDataWindowWidget
ImGui.Separator(); ImGui.Separator();
ImGui.TextUnformatted($"WindowSystem.TimeSinceLastAnyFocus: {WindowSystem.TimeSinceLastAnyFocus.TotalMilliseconds:0}ms"); ImGui.TextUnformatted(
$"WindowSystem.TimeSinceLastAnyFocus: {WindowSystem.TimeSinceLastAnyFocus.TotalMilliseconds:0}ms");
ImGui.Separator(); 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<DalamudAssetManager>.Get();
var tm = Service<TextureManager>.Get();
switch (this.notificationTemplate.IconInt)
{ {
0 => "This is a toast", case 5:
1 => "Truly, a toast", n.SetIconTexture(
2 => "I am testing this toast", dam.GetDalamudTextureWrap(
3 => "I hope this looks right", Enum.Parse<DalamudAsset>(
4 => "Good stuff", NotificationTemplate.AssetSources[this.notificationTemplate.IconAssetInt])));
5 => "Nice", break;
_ => null, case 6:
}; n.SetIconTexture(
dam.GetDalamudTextureWrapAsync(
Enum.Parse<DalamudAsset>(
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, case 2:
1 => NotificationType.Warning, Task.Run(
2 => NotificationType.Info, async () =>
3 => NotificationType.Success, {
4 => NotificationType.None, for (var i = 0; i <= 10 && !n.DismissReason.HasValue; i++)
_ => NotificationType.None, {
}; 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<DalamudAsset>()
.Where(x => x.GetAttribute<DalamudAssetAttribute>()?.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;
} }
} }
} }

View file

@ -15,6 +15,7 @@ using Dalamud.Game.Command;
using Dalamud.Interface.Animation.EasingFunctions; using Dalamud.Interface.Animation.EasingFunctions;
using Dalamud.Interface.Colors; using Dalamud.Interface.Colors;
using Dalamud.Interface.Components; using Dalamud.Interface.Components;
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;

View file

@ -7,6 +7,7 @@ using CheapLoc;
using Dalamud.Configuration.Internal; using Dalamud.Configuration.Internal;
using Dalamud.Interface.Colors; using Dalamud.Interface.Colors;
using Dalamud.Interface.Components; using Dalamud.Interface.Components;
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;

View file

@ -7,6 +7,7 @@ using System.Reflection;
using Dalamud.Game; using Dalamud.Game;
using Dalamud.Hooking.Internal; using Dalamud.Hooking.Internal;
using Dalamud.Interface.Components; using Dalamud.Interface.Components;
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Windowing; using Dalamud.Interface.Windowing;
using Dalamud.Plugin.Internal; using Dalamud.Plugin.Internal;

View file

@ -24,7 +24,6 @@ internal abstract class FontHandle : IFontHandle
private static readonly ConditionalWeakTable<LocalPlugin, object> NonMainThreadFontAccessWarning = new(); private static readonly ConditionalWeakTable<LocalPlugin, object> NonMainThreadFontAccessWarning = new();
private static long nextNonMainThreadFontAccessWarningCheck; private static long nextNonMainThreadFontAccessWarningCheck;
private readonly InterfaceManager interfaceManager;
private readonly List<IDisposable> pushedFonts = new(8); private readonly List<IDisposable> pushedFonts = new(8);
private IFontHandleManager? manager; private IFontHandleManager? manager;
@ -36,7 +35,6 @@ internal abstract class FontHandle : IFontHandle
/// <param name="manager">An instance of <see cref="IFontHandleManager"/>.</param> /// <param name="manager">An instance of <see cref="IFontHandleManager"/>.</param>
protected FontHandle(IFontHandleManager manager) protected FontHandle(IFontHandleManager manager)
{ {
this.interfaceManager = Service<InterfaceManager>.Get();
this.manager = manager; this.manager = manager;
} }
@ -58,7 +56,11 @@ internal abstract class FontHandle : IFontHandle
/// Gets the associated <see cref="IFontHandleManager"/>. /// Gets the associated <see cref="IFontHandleManager"/>.
/// </summary> /// </summary>
/// <exception cref="ObjectDisposedException">When the object has already been disposed.</exception> /// <exception cref="ObjectDisposedException">When the object has already been disposed.</exception>
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())`?");
/// <inheritdoc/> /// <inheritdoc/>
public void Dispose() public void Dispose()
@ -122,7 +124,7 @@ internal abstract class FontHandle : IFontHandle
} }
} }
this.interfaceManager.EnqueueDeferredDispose(locked); Service<InterfaceManager>.Get().EnqueueDeferredDispose(locked);
return locked.ImFont; return locked.ImFont;
} }
@ -201,7 +203,7 @@ internal abstract class FontHandle : IFontHandle
ThreadSafety.AssertMainThread(); ThreadSafety.AssertMainThread();
// Warn if the client is not properly managing the pushed font stack. // Warn if the client is not properly managing the pushed font stack.
var cumulativePresentCalls = this.interfaceManager.CumulativePresentCalls; var cumulativePresentCalls = Service<InterfaceManager>.Get().CumulativePresentCalls;
if (this.lastCumulativePresentCalls != cumulativePresentCalls) if (this.lastCumulativePresentCalls != cumulativePresentCalls)
{ {
this.lastCumulativePresentCalls = cumulativePresentCalls; this.lastCumulativePresentCalls = cumulativePresentCalls;
@ -218,7 +220,7 @@ internal abstract class FontHandle : IFontHandle
if (this.TryLock(out _) is { } locked) if (this.TryLock(out _) is { } locked)
{ {
font = locked.ImFont; font = locked.ImFont;
this.interfaceManager.EnqueueDeferredDispose(locked); Service<InterfaceManager>.Get().EnqueueDeferredDispose(locked);
} }
var rented = SimplePushedFont.Rent(this.pushedFonts, font); var rented = SimplePushedFont.Rent(this.pushedFonts, font);

View file

@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -9,12 +10,15 @@ using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.Gui; using Dalamud.Game.Gui;
using Dalamud.Interface.FontIdentifier; using Dalamud.Interface.FontIdentifier;
using Dalamud.Interface.GameFonts; using Dalamud.Interface.GameFonts;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal; using Dalamud.Interface.Internal;
using Dalamud.Interface.Internal.ManagedAsserts; using Dalamud.Interface.Internal.ManagedAsserts;
using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Interface.ManagedFontAtlas;
using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.ManagedFontAtlas.Internals;
using Dalamud.Plugin.Internal.Types; using Dalamud.Plugin.Internal.Types;
using Dalamud.Plugin.Services;
using Dalamud.Utility; using Dalamud.Utility;
using ImGuiNET; using ImGuiNET;
using ImGuiScene; using ImGuiScene;
@ -29,11 +33,13 @@ namespace Dalamud.Interface;
/// </summary> /// </summary>
public sealed class UiBuilder : IDisposable public sealed class UiBuilder : IDisposable
{ {
private readonly LocalPlugin localPlugin;
private readonly Stopwatch stopwatch; private readonly Stopwatch stopwatch;
private readonly HitchDetector hitchDetector; private readonly HitchDetector hitchDetector;
private readonly string namespaceName; private readonly string namespaceName;
private readonly InterfaceManager interfaceManager = Service<InterfaceManager>.Get(); private readonly InterfaceManager interfaceManager = Service<InterfaceManager>.Get();
private readonly Framework framework = Service<Framework>.Get(); private readonly Framework framework = Service<Framework>.Get();
private readonly ConcurrentDictionary<IActiveNotification, int> notifications = new();
[ServiceManager.ServiceDependency] [ServiceManager.ServiceDependency]
private readonly DalamudConfiguration configuration = Service<DalamudConfiguration>.Get(); private readonly DalamudConfiguration configuration = Service<DalamudConfiguration>.Get();
@ -52,8 +58,10 @@ public sealed class UiBuilder : IDisposable
/// You do not have to call this manually. /// You do not have to call this manually.
/// </summary> /// </summary>
/// <param name="namespaceName">The plugin namespace.</param> /// <param name="namespaceName">The plugin namespace.</param>
internal UiBuilder(string namespaceName) /// <param name="localPlugin">The relevant local plugin.</param>
internal UiBuilder(string namespaceName, LocalPlugin localPlugin)
{ {
this.localPlugin = localPlugin;
try try
{ {
this.stopwatch = new Stopwatch(); this.stopwatch = new Stopwatch();
@ -556,22 +564,46 @@ public sealed class UiBuilder : IDisposable
/// <param name="title">The title of the notification.</param> /// <param name="title">The title of the notification.</param>
/// <param name="type">The type of the notification.</param> /// <param name="type">The type of the notification.</param>
/// <param name="msDelay">The time the notification should be displayed for.</param> /// <param name="msDelay">The time the notification should be displayed for.</param>
public void AddNotification( [Obsolete($"Use {nameof(INotificationManager)}.", false)]
string content, string? title = null, NotificationType type = NotificationType.None, uint msDelay = 3000) [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)]
public async void AddNotification(
string content,
string? title = null,
NotificationType type = NotificationType.None,
uint msDelay = 3000)
{ {
Service<NotificationManager> var nm = await Service<NotificationManager>.GetAsync();
.GetAsync() var an = nm.AddNotification(
.ContinueWith(task => new()
{ {
if (task.IsCompletedSuccessfully) Content = content,
task.Result.AddNotification(content, title, type, msDelay); Title = title,
}); Type = type,
InitialDuration = TimeSpan.FromMilliseconds(msDelay),
},
this.localPlugin);
_ = this.notifications.TryAdd(an, 0);
an.Dismiss += a => this.notifications.TryRemove(a.Notification, out _);
} }
/// <summary> /// <summary>
/// Unregister the UiBuilder. Do not call this in plugin code. /// Unregister the UiBuilder. Do not call this in plugin code.
/// </summary> /// </summary>
void IDisposable.Dispose() => this.scopedFinalizer.Dispose(); void IDisposable.Dispose()
{
this.scopedFinalizer.Dispose();
// Taken from NotificationManagerPluginScoped.
// TODO: remove on API 10.
while (!this.notifications.IsEmpty)
{
foreach (var n in this.notifications.Keys)
{
this.notifications.TryRemove(n, out _);
((ActiveNotification)n).RemoveNonDalamudInvocations();
}
}
}
/// <summary> /// <summary>
/// Open the registered configuration UI, if it exists. /// Open the registered configuration UI, if it exists.

View file

@ -36,6 +36,7 @@ public class Localization : IServiceType
/// <param name="useEmbedded">Use embedded loc resource files.</param> /// <param name="useEmbedded">Use embedded loc resource files.</param>
public Localization(string locResourceDirectory, string locResourcePrefix = "", bool useEmbedded = false) public Localization(string locResourceDirectory, string locResourcePrefix = "", bool useEmbedded = false)
{ {
this.DalamudLanguageCultureInfo = CultureInfo.InvariantCulture;
this.locResourceDirectory = locResourceDirectory; this.locResourceDirectory = locResourceDirectory;
this.locResourcePrefix = locResourcePrefix; this.locResourcePrefix = locResourcePrefix;
this.useEmbedded = useEmbedded; this.useEmbedded = useEmbedded;
@ -61,7 +62,24 @@ public class Localization : IServiceType
/// <summary> /// <summary>
/// Event that occurs when the language is changed. /// Event that occurs when the language is changed.
/// </summary> /// </summary>
public event LocalizationChangedDelegate LocalizationChanged; public event LocalizationChangedDelegate? LocalizationChanged;
/// <summary>
/// Gets an instance of <see cref="CultureInfo"/> that corresponds to the language configured from Dalamud Settings.
/// </summary>
public CultureInfo DalamudLanguageCultureInfo { get; private set; }
/// <summary>
/// Gets an instance of <see cref="CultureInfo"/> that corresponds to <paramref name="langCode"/>.
/// </summary>
/// <param name="langCode">The language code which should be in <see cref="ApplicableLangCodes"/>.</param>
/// <returns>The corresponding instance of <see cref="CultureInfo"/>.</returns>
public static CultureInfo GetCultureInfoFromLangCode(string langCode) =>
CultureInfo.GetCultureInfo(langCode switch
{
"tw" => "zh-tw",
_ => langCode,
});
/// <summary> /// <summary>
/// Search the set-up localization data for the provided assembly for the given string key and return it. /// 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
/// </summary> /// </summary>
public void SetupWithFallbacks() public void SetupWithFallbacks()
{ {
this.DalamudLanguageCultureInfo = CultureInfo.InvariantCulture;
this.LocalizationChanged?.Invoke(FallbackLangCode); this.LocalizationChanged?.Invoke(FallbackLangCode);
Loc.SetupWithFallbacks(this.assembly); Loc.SetupWithFallbacks(this.assembly);
} }
@ -124,6 +143,7 @@ public class Localization : IServiceType
return; return;
} }
this.DalamudLanguageCultureInfo = GetCultureInfoFromLangCode(langCode);
this.LocalizationChanged?.Invoke(langCode); this.LocalizationChanged?.Invoke(langCode);
try try

View file

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

View file

@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Dalamud.Configuration.Internal; using Dalamud.Configuration.Internal;
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Internal.Notifications;
using Dalamud.Logging.Internal; using Dalamud.Logging.Internal;
using Dalamud.Plugin.Internal.Types.Manifest; using Dalamud.Plugin.Internal.Types.Manifest;

View file

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

View file

@ -11,9 +11,19 @@ internal sealed class Api10ToDoAttribute : Attribute
/// </summary> /// </summary>
public const string DeleteCompatBehavior = "Delete. This is for making API 9 plugins work."; public const string DeleteCompatBehavior = "Delete. This is for making API 9 plugins work.";
/// <summary>
/// Marks that this should be moved to an another namespace.
/// </summary>
public const string MoveNamespace = "Move to another namespace.";
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="Api10ToDoAttribute"/> class. /// Initializes a new instance of the <see cref="Api10ToDoAttribute"/> class.
/// </summary> /// </summary>
/// <param name="what">The explanation.</param> /// <param name="what">The explanation.</param>
public Api10ToDoAttribute(string what) => _ = what; /// <param name="what2">The explanation 2.</param>
public Api10ToDoAttribute(string what, string what2 = "")
{
_ = what;
_ = what2;
}
} }

View file

@ -0,0 +1,125 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using CheapLoc;
using Dalamud.Logging.Internal;
namespace Dalamud.Utility;
/// <summary>
/// Utility functions for <see cref="DateTime"/> and <see cref="TimeSpan"/>.
/// </summary>
public static class DateTimeSpanExtensions
{
private static readonly ModuleLog Log = new(nameof(DateTimeSpanExtensions));
private static ParsedRelativeFormatStrings? relativeFormatStringLong;
private static ParsedRelativeFormatStrings? relativeFormatStringShort;
/// <summary>Formats an instance of <see cref="DateTime"/> as a localized absolute time.</summary>
/// <param name="when">When.</param>
/// <returns>The formatted string.</returns>
/// <remarks>The string will be formatted according to Square Enix Account region settings, if Dalamud default
/// language is English.</remarks>
public static unsafe string LocAbsolute(this DateTime when)
{
var culture = Service<Localization>.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
};
}
/// <summary>Formats an instance of <see cref="DateTime"/> as a localized relative time.</summary>
/// <param name="when">When.</param>
/// <returns>The formatted string.</returns>
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);
}
/// <summary>Formats an instance of <see cref="DateTime"/> as a localized relative time.</summary>
/// <param name="when">When.</param>
/// <returns>The formatted string.</returns>
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; }
/// <summary>Formats an instance of <see cref="TimeSpan"/> as a localized string.</summary>
/// <param name="ts">The duration.</param>
/// <returns>The formatted string.</returns>
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);
}
}
}