diff --git a/Dalamud/Interface/ImGuiNotification/IActiveNotification.cs b/Dalamud/Interface/ImGuiNotification/IActiveNotification.cs index 3e8aef196..d1aa1d95b 100644 --- a/Dalamud/Interface/ImGuiNotification/IActiveNotification.cs +++ b/Dalamud/Interface/ImGuiNotification/IActiveNotification.cs @@ -1,28 +1,24 @@ using System.Threading; +using System.Threading.Tasks; + +using Dalamud.Game.Text; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Internal.Notifications; namespace Dalamud.Interface.ImGuiNotification; -/// -/// Represents an active notification. -/// +/// Represents an active notification. public interface IActiveNotification : INotification { - /// - /// The counter for field. - /// + /// The counter for field. private static long idCounter; - /// - /// Invoked upon dismissing the notification. - /// - /// - /// The event callback will not be called, if a user interacts with the notification after the plugin is unloaded. - /// + /// Invoked upon dismissing the notification. + /// The event callback will not be called, + /// if a user interacts with the notification after the plugin is unloaded. event NotificationDismissedDelegate Dismiss; - /// - /// Invoked upon clicking on the notification. - /// + /// Invoked upon clicking on the notification. /// /// This event is not applicable when is set to false. /// Note that this function may be called even after has been invoked. @@ -30,9 +26,7 @@ public interface IActiveNotification : INotification /// event Action Click; - /// - /// Invoked when the mouse enters the notification window. - /// + /// Invoked when the mouse enters the notification window. /// /// This event is applicable regardless of . /// Note that this function may be called even after has been invoked. @@ -40,9 +34,7 @@ public interface IActiveNotification : INotification /// event Action MouseEnter; - /// - /// Invoked when the mouse leaves the notification window. - /// + /// Invoked when the mouse leaves the notification window. /// /// This event is applicable regardless of . /// Note that this function may be called even after has been invoked. @@ -50,9 +42,7 @@ public interface IActiveNotification : INotification /// event Action MouseLeave; - /// - /// Invoked upon drawing the action bar of the notification. - /// + /// Invoked upon drawing the action bar of the notification. /// /// This event is applicable regardless of . /// Note that this function may be called even after has been invoked. @@ -60,50 +50,60 @@ public interface IActiveNotification : INotification /// event Action DrawActions; - /// - /// Gets the ID of this notification. - /// + /// + new string Content { get; set; } + + /// + new string? Title { get; set; } + + /// + new NotificationType Type { get; set; } + + /// + new Func>? IconCreator { get; set; } + + /// + new DateTime Expiry { get; set; } + + /// + new bool Interactible { get; set; } + + /// + new TimeSpan HoverExtendDuration { get; set; } + + /// + new float Progress { get; set; } + + /// Gets the ID of this notification. long Id { get; } - /// - /// Gets a value indicating whether the mouse cursor is on the notification window. - /// + /// Gets a value indicating whether the mouse cursor is on the notification window. bool IsMouseHovered { get; } - /// - /// Gets a value indicating whether the notification has been dismissed. - /// This includes when the hide animation is being played. - /// + /// Gets a value indicating whether the notification has been dismissed. + /// This includes when the hide animation is being played. bool IsDismissed { get; } - /// - /// Clones this notification as a . - /// + /// Clones this notification as a . /// A new instance of . Notification CloneNotification(); - /// - /// Dismisses this notification. - /// + /// Dismisses this notification. void DismissNow(); - /// - /// Updates the notification data. - /// + /// Updates the notification data. /// /// Call to update the icon using the new . + /// If is true, then this function is a no-op. /// /// The new notification entry. void Update(INotification newNotification); - /// - /// Loads the icon again using . - /// + /// Loads the icon again using . + /// If is true, then this function is a no-op. void UpdateIcon(); - /// - /// Generates a new value to use for . - /// + /// Generates a new value to use for . /// The new value. internal static long CreateNewId() => Interlocked.Increment(ref idCounter); } diff --git a/Dalamud/Interface/ImGuiNotification/INotification.cs b/Dalamud/Interface/ImGuiNotification/INotification.cs index f5f66725c..cbd8ad633 100644 --- a/Dalamud/Interface/ImGuiNotification/INotification.cs +++ b/Dalamud/Interface/ImGuiNotification/INotification.cs @@ -6,31 +6,21 @@ using Dalamud.Interface.Internal.Notifications; namespace Dalamud.Interface.ImGuiNotification; -/// -/// Represents a notification. -/// +/// Represents a notification. public interface INotification { - /// - /// Gets the content body of the notification. - /// + /// Gets the content body of the notification. string Content { get; } - /// - /// Gets the title of the notification. - /// + /// Gets the title of the notification. string? Title { get; } - /// - /// Gets the type of the notification. - /// + /// Gets the type of the notification. NotificationType Type { get; } - /// - /// Gets the icon creator function for the notification.
+ /// Gets the icon creator function for the notification.
/// Currently , , and types - /// are accepted. - ///
+ /// are accepted.
/// /// The icon created by the task returned will be owned by Dalamud, /// i.e. it will be d automatically as needed.
@@ -41,35 +31,30 @@ public interface INotification ///
Func>? IconCreator { get; } - /// - /// Gets the expiry. - /// + /// Gets the expiry. + /// Set to to make the notification not have an expiry time + /// (sticky, indeterminate, permanent, or persistent). DateTime Expiry { get; } - /// - /// Gets a value indicating whether this notification may be interacted. - /// + /// Gets a value indicating whether this notification may be interacted. /// /// Set this value to true if you want to respond to user inputs from /// . /// Note that the close buttons for notifications are always provided and interactible. + /// If set to true, then clicking on the notification itself will be interpreted as user-initiated dismissal, + /// unless is set. /// bool Interactible { get; } - - /// - /// Gets a value indicating whether clicking on the notification window counts as dismissing the notification. - /// - /// - /// This property has no effect if is false. - /// - bool ClickIsDismiss { get; } - /// - /// Gets the new duration for this notification if mouse cursor is on the notification window. - /// If set to or less, then this feature is turned off. - /// + /// Gets the new duration for this notification if mouse cursor is on the notification window. /// + /// If set to or less, then this feature is turned off. /// This property is applicable regardless of . /// TimeSpan HoverExtendDuration { get; } + + /// Gets the progress for the progress bar of the notification. + /// The progress should either be in the range between 0 and 1 or be a negative value. + /// Specifying a negative value will show an indeterminate progress bar. + float Progress { get; } } diff --git a/Dalamud/Interface/ImGuiNotification/Notification.cs b/Dalamud/Interface/ImGuiNotification/Notification.cs index fb2caa4f6..ccfb250c3 100644 --- a/Dalamud/Interface/ImGuiNotification/Notification.cs +++ b/Dalamud/Interface/ImGuiNotification/Notification.cs @@ -28,8 +28,8 @@ public sealed record Notification : INotification public bool Interactible { get; set; } /// - public bool ClickIsDismiss { get; set; } = true; + public TimeSpan HoverExtendDuration { get; set; } = NotificationConstants.DefaultHoverExtendDuration; /// - public TimeSpan HoverExtendDuration { get; set; } = NotificationConstants.DefaultHoverExtendDuration; + public float Progress { get; set; } = 1f; } diff --git a/Dalamud/Interface/Internal/Notifications/ActiveNotification.cs b/Dalamud/Interface/Internal/Notifications/ActiveNotification.cs index 5c343288e..c1fecdd3b 100644 --- a/Dalamud/Interface/Internal/Notifications/ActiveNotification.cs +++ b/Dalamud/Interface/Internal/Notifications/ActiveNotification.cs @@ -20,19 +20,25 @@ using Serilog; namespace Dalamud.Interface.Internal.Notifications; -/// -/// Represents an active notification. -/// +/// Represents an active notification. internal sealed class ActiveNotification : IActiveNotification, IDisposable { + private readonly Notification underlyingNotification; + private readonly Easing showEasing; private readonly Easing hideEasing; + private readonly Easing progressEasing; - private Notification underlyingNotification; + /// The progress before for the progress bar animation with . + private float progressBefore; - /// - /// Initializes a new instance of the class. - /// + /// Used for calculating correct dismissal progressbar animation (left edge). + private float prevProgressL; + + /// Used for calculating correct dismissal progressbar animation (right edge). + private float prevProgressR; + + /// Initializes a new instance of the class. /// The underlying notification. /// The initiator plugin. Use null if originated by Dalamud. public ActiveNotification(Notification underlyingNotification, LocalPlugin? initiatorPlugin) @@ -41,8 +47,10 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable this.InitiatorPlugin = initiatorPlugin; this.showEasing = new InCubic(NotificationConstants.ShowAnimationDuration); this.hideEasing = new OutCubic(NotificationConstants.HideAnimationDuration); + this.progressEasing = new InOutCubic(NotificationConstants.ProgressAnimationDuration); this.showEasing.Start(); + this.progressEasing.Start(); this.UpdateIcon(); } @@ -64,39 +72,111 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable /// public long Id { get; } = IActiveNotification.CreateNewId(); - /// - /// Gets the time of creating this notification. - /// + /// Gets the time of creating this notification. public DateTime CreatedAt { get; } = DateTime.Now; - /// - /// Gets the time of starting to count the timer for the expiration. - /// + /// Gets the time of starting to count the timer for the expiration. public DateTime ExpiryRelativeToTime { get; private set; } = DateTime.Now; - /// - public string Content => this.underlyingNotification.Content; + /// + public string Content + { + get => this.underlyingNotification.Content; + set + { + if (this.IsDismissed) + return; + this.underlyingNotification.Content = value; + } + } - /// - public string? Title => this.underlyingNotification.Title; + /// + public string? Title + { + get => this.underlyingNotification.Title; + set + { + if (this.IsDismissed) + return; + this.underlyingNotification.Title = value; + } + } - /// - public NotificationType Type => this.underlyingNotification.Type; + /// + public NotificationType Type + { + get => this.underlyingNotification.Type; + set + { + if (this.IsDismissed) + return; + this.underlyingNotification.Type = value; + } + } - /// - public Func>? IconCreator => this.underlyingNotification.IconCreator; + /// + public Func>? IconCreator + { + get => this.underlyingNotification.IconCreator; + set + { + if (this.IsDismissed) + return; + this.underlyingNotification.IconCreator = value; + } + } - /// - public DateTime Expiry => this.underlyingNotification.Expiry; + /// + public DateTime Expiry + { + get => this.underlyingNotification.Expiry; + set + { + if (this.underlyingNotification.Expiry == value || this.IsDismissed) + return; + this.underlyingNotification.Expiry = value; + this.ExpiryRelativeToTime = DateTime.Now; + } + } - /// - public bool Interactible => this.underlyingNotification.Interactible; + /// + public bool Interactible + { + get => this.underlyingNotification.Interactible; + set + { + if (this.IsDismissed) + return; + this.underlyingNotification.Interactible = value; + } + } - /// - public bool ClickIsDismiss => this.underlyingNotification.ClickIsDismiss; + /// + public TimeSpan HoverExtendDuration + { + get => this.underlyingNotification.HoverExtendDuration; + set + { + if (this.IsDismissed) + return; + this.underlyingNotification.HoverExtendDuration = value; + } + } - /// - public TimeSpan HoverExtendDuration => this.underlyingNotification.HoverExtendDuration; + /// + public float Progress + { + get => this.underlyingNotification.Progress; + set + { + if (this.IsDismissed) + return; + + this.progressBefore = this.ProgressEased; + this.underlyingNotification.Progress = value; + this.progressEasing.Restart(); + } + } /// public bool IsMouseHovered { get; private set; } @@ -104,19 +184,32 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable /// public bool IsDismissed => this.hideEasing.IsRunning; - /// - /// Gets or sets the plugin that initiated this notification. - /// + /// Gets a value indicating whether has been unloaded. + public bool IsInitiatorUnloaded { get; private set; } + + /// Gets or sets the plugin that initiated this notification. public LocalPlugin? InitiatorPlugin { get; set; } - /// - /// Gets or sets the icon of this notification. - /// + /// Gets or sets the icon of this notification. public Task? IconTask { get; set; } - /// - /// Gets the default color of the notification. - /// + /// Gets the eased progress. + private float ProgressEased + { + get + { + if (this.Progress < 0) + return 0f; + + if (Math.Abs(this.Progress - this.progressBefore) < 0.000001f || this.progressEasing.IsDone) + return this.Progress; + + var state = Math.Clamp((float)this.progressEasing.Value, 0f, 1f); + return this.progressBefore + (state * (this.Progress - this.progressBefore)); + } + } + + /// Gets the default color of the notification. private Vector4 DefaultIconColor => this.Type switch { NotificationType.None => ImGuiColors.DalamudWhite, @@ -127,9 +220,7 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable _ => ImGuiColors.DalamudWhite, }; - /// - /// Gets the default icon of the notification. - /// + /// Gets the default icon of the notification. private string? DefaultIconString => this.Type switch { NotificationType.None => null, @@ -140,9 +231,7 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable _ => null, }; - /// - /// Gets the default title of the notification. - /// + /// Gets the default title of the notification. private string? DefaultTitle => this.Type switch { NotificationType.None => null, @@ -153,6 +242,14 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable _ => null, }; + /// Gets the string for the initiator field. + private string InitiatorString => + this.InitiatorPlugin is not { } initiatorPlugin + ? NotificationConstants.DefaultInitiator + : this.IsInitiatorUnloaded + ? NotificationConstants.UnloadedInitiatorNameFormat.Format(initiatorPlugin.Name) + : initiatorPlugin.Name; + /// public void Dispose() { @@ -170,9 +267,7 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable /// public void DismissNow() => this.DismissNow(NotificationDismissReason.Programmatical); - /// - /// Dismisses this notification. Multiple calls will be ignored. - /// + /// Dismisses this notification. Multiple calls will be ignored. /// The reason of dismissal. public void DismissNow(NotificationDismissReason reason) { @@ -192,20 +287,17 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable } } - /// - /// Updates animations. - /// + /// Updates animations. /// true if the notification is over. public bool UpdateAnimations() { this.showEasing.Update(); this.hideEasing.Update(); + this.progressEasing.Update(); return this.hideEasing.IsRunning && this.hideEasing.IsDone; } - /// - /// Draws this notification. - /// + /// Draws this notification. /// The maximum width of the notification window. /// The offset from the bottom. /// The height of the notification. @@ -230,13 +322,29 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable var notificationManager = Service.Get(); var interfaceManager = Service.Get(); - var unboundedWidth = NotificationConstants.ScaledWindowPadding * 3; + var unboundedWidth = ImGui.CalcTextSize(this.Content).X; + float closeButtonHorizontalSpaceReservation; + using (interfaceManager.IconFontHandle?.Push()) + { + closeButtonHorizontalSpaceReservation = ImGui.CalcTextSize(FontAwesomeIcon.Times.ToIconString()).X; + closeButtonHorizontalSpaceReservation += NotificationConstants.ScaledWindowPadding; + } + + unboundedWidth = Math.Max( + unboundedWidth, + ImGui.CalcTextSize(this.Title ?? this.DefaultTitle ?? string.Empty).X); + unboundedWidth = Math.Max( + unboundedWidth, + ImGui.CalcTextSize(this.InitiatorString).X); + unboundedWidth = Math.Max( + unboundedWidth, + ImGui.CalcTextSize(this.CreatedAt.FormatAbsoluteDateTime()).X + closeButtonHorizontalSpaceReservation); + unboundedWidth = Math.Max( + unboundedWidth, + ImGui.CalcTextSize(this.CreatedAt.FormatRelativeDateTime()).X + closeButtonHorizontalSpaceReservation); + + unboundedWidth += NotificationConstants.ScaledWindowPadding * 3; unboundedWidth += NotificationConstants.ScaledIconSize; - unboundedWidth += Math.Max( - Math.Max( - ImGui.CalcTextSize(this.Title ?? this.DefaultTitle ?? string.Empty).X, - ImGui.CalcTextSize(this.InitiatorPlugin?.Name ?? NotificationConstants.DefaultInitiator).X), - ImGui.CalcTextSize(this.Content).X); var width = Math.Min(maxWidth, unboundedWidth); @@ -244,16 +352,7 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable var viewportPos = viewport.WorkPos; var viewportSize = viewport.WorkSize; - ImGuiHelpers.ForceNextWindowMainViewport(); - ImGui.SetNextWindowPos( - (viewportPos + viewportSize) - - new Vector2(NotificationConstants.ScaledViewportEdgeMargin) - - new Vector2(0, offsetY), - ImGuiCond.Always, - Vector2.One); - ImGui.SetNextWindowSizeConstraints(new(width, 0), new(width, float.MaxValue)); ImGui.PushID(this.Id.GetHashCode()); - ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(NotificationConstants.ScaledWindowPadding)); ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity); ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 0f); unsafe @@ -267,67 +366,88 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable NotificationConstants.BackgroundOpacity)); } + ImGuiHelpers.ForceNextWindowMainViewport(); + ImGui.SetNextWindowPos( + (viewportPos + viewportSize) - + new Vector2(NotificationConstants.ScaledViewportEdgeMargin) - + new Vector2(0, offsetY), + ImGuiCond.Always, + Vector2.One); + ImGui.SetNextWindowSizeConstraints(new(width, 0), new(width, float.MaxValue)); + ImGui.PushStyleVar( + ImGuiStyleVar.WindowPadding, + new Vector2(NotificationConstants.ScaledWindowPadding, 0)); ImGui.Begin( - $"##NotifyWindow{this.Id}", + $"##NotifyMainWindow{this.Id}", ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoDecoration | - (this.Interactible ? ImGuiWindowFlags.None : ImGuiWindowFlags.NoInputs) | + (this.Interactible + ? ImGuiWindowFlags.None + : ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.NoBringToFrontOnFocus) | ImGuiWindowFlags.NoNav | - ImGuiWindowFlags.NoBringToFrontOnFocus | - ImGuiWindowFlags.NoFocusOnAppearing); - - var basePos = ImGui.GetCursorPos(); - this.DrawIcon( - notificationManager, - basePos, - basePos + new Vector2(NotificationConstants.ScaledIconSize)); - basePos.X += NotificationConstants.ScaledIconSize + NotificationConstants.ScaledWindowPadding; - width -= NotificationConstants.ScaledIconSize + (NotificationConstants.ScaledWindowPadding * 2); - this.DrawTitle(basePos, basePos + new Vector2(width, 0)); - basePos.Y = ImGui.GetCursorPosY(); - this.DrawContentBody(basePos, basePos + new Vector2(width, 0)); - if (ImGui.IsWindowHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) - { - this.Click?.InvokeSafely(this); - if (this.ClickIsDismiss) - this.DismissNow(NotificationDismissReason.Manual); - } + ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoFocusOnAppearing | + ImGuiWindowFlags.NoDocking); + this.DrawNotificationMainWindowContent(notificationManager, width); var windowPos = ImGui.GetWindowPos(); var windowSize = ImGui.GetWindowSize(); - - float expiryRatio; - if (this.IsDismissed) - { - expiryRatio = 0f; - } - else if (this.Expiry == DateTime.MaxValue || (this.HoverExtendDuration > TimeSpan.Zero && this.IsMouseHovered)) - { - expiryRatio = 1f; - } - else - { - expiryRatio = (float)((this.Expiry - DateTime.Now).TotalMilliseconds / - (this.Expiry - this.ExpiryRelativeToTime).TotalMilliseconds); - } - - expiryRatio = Math.Clamp(expiryRatio, 0f, 1f); - ImGui.PushClipRect(windowPos, windowPos + windowSize, false); - ImGui.GetWindowDrawList().AddRectFilled( - windowPos + new Vector2(0, windowSize.Y - NotificationConstants.ScaledExpiryProgressBarHeight), - windowPos + windowSize with { X = windowSize.X * expiryRatio }, - ImGui.GetColorU32(this.DefaultIconColor)); - ImGui.PopClipRect(); + var hovered = ImGui.IsWindowHovered(); ImGui.End(); + ImGui.PopStyleVar(); - if (!this.IsDismissed) - this.DrawCloseButton(interfaceManager, windowPos); + offsetY += windowSize.Y; + + var actionWindowHeight = + // Content + ImGui.GetTextLineHeight() + + // Top and bottom padding + (NotificationConstants.ScaledWindowPadding * 2); + 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, actionWindowHeight)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); + ImGui.Begin( + $"##NotifyActionWindow{this.Id}", + ImGuiWindowFlags.NoDecoration | + ImGuiWindowFlags.NoNav | + ImGuiWindowFlags.NoFocusOnAppearing | + ImGuiWindowFlags.NoDocking); + + this.DrawNotificationActionWindowContent(interfaceManager, width); + windowSize.Y += actionWindowHeight; + windowPos.Y -= actionWindowHeight; + hovered |= ImGui.IsWindowHovered(); + + ImGui.End(); + ImGui.PopStyleVar(); ImGui.PopStyleColor(); - ImGui.PopStyleVar(3); + ImGui.PopStyleVar(2); ImGui.PopID(); + if (hovered) + { + if (this.Click is null) + { + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + this.DismissNow(NotificationDismissReason.Manual); + } + else + { + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left) + || ImGui.IsMouseClicked(ImGuiMouseButton.Right) + || ImGui.IsMouseClicked(ImGuiMouseButton.Middle)) + this.Click.InvokeSafely(this); + } + } + if (windowPos.X <= ImGui.GetIO().MousePos.X && windowPos.Y <= ImGui.GetIO().MousePos.Y && ImGui.GetIO().MousePos.X < windowPos.X + windowSize.X @@ -361,31 +481,28 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable /// public void Update(INotification newNotification) { - this.underlyingNotification.Content = newNotification.Content; - this.underlyingNotification.Title = newNotification.Title; - this.underlyingNotification.Type = newNotification.Type; - this.underlyingNotification.IconCreator = newNotification.IconCreator; - if (this.underlyingNotification.Expiry != newNotification.Expiry) - { - this.underlyingNotification.Expiry = newNotification.Expiry; - this.ExpiryRelativeToTime = DateTime.Now; - } - - this.underlyingNotification.Interactible = newNotification.Interactible; - this.underlyingNotification.ClickIsDismiss = newNotification.ClickIsDismiss; - this.underlyingNotification.HoverExtendDuration = newNotification.HoverExtendDuration; + if (this.IsDismissed) + return; + this.Content = newNotification.Content; + this.Title = newNotification.Title; + this.Type = newNotification.Type; + this.IconCreator = newNotification.IconCreator; + this.Expiry = newNotification.Expiry; + this.Interactible = newNotification.Interactible; + this.HoverExtendDuration = newNotification.HoverExtendDuration; + this.Progress = newNotification.Progress; } /// public void UpdateIcon() { + if (this.IsDismissed) + return; this.ClearIconTask(); this.IconTask = this.IconCreator?.Invoke(); } - /// - /// Removes non-Dalamud invocation targets from events. - /// + /// Removes non-Dalamud invocation targets from events. public void RemoveNonDalamudInvocations() { var dalamudContext = AssemblyLoadContext.GetLoadContext(typeof(NotificationManager).Assembly); @@ -395,6 +512,17 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable this.MouseEnter = RemoveNonDalamudInvocationsCore(this.MouseEnter); this.MouseLeave = RemoveNonDalamudInvocationsCore(this.MouseLeave); + this.underlyingNotification.Interactible = false; + this.IsInitiatorUnloaded = true; + + var now = DateTime.Now; + var newMaxExpiry = now + NotificationConstants.DefaultDisplayDuration; + if (this.underlyingNotification.Expiry > newMaxExpiry) + { + this.underlyingNotification.Expiry = newMaxExpiry; + this.ExpiryRelativeToTime = now; + } + return; T? RemoveNonDalamudInvocationsCore(T? @delegate) where T : Delegate @@ -426,6 +554,84 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable this.IconTask = null; } + private void DrawNotificationMainWindowContent(NotificationManager notificationManager, float width) + { + var basePos = ImGui.GetCursorPos(); + this.DrawIcon( + notificationManager, + basePos, + basePos + new Vector2(NotificationConstants.ScaledIconSize)); + basePos.X += NotificationConstants.ScaledIconSize + NotificationConstants.ScaledWindowPadding; + width -= NotificationConstants.ScaledIconSize + (NotificationConstants.ScaledWindowPadding * 2); + this.DrawTitle(basePos, basePos + new Vector2(width, 0)); + basePos.Y = ImGui.GetCursorPosY(); + this.DrawContentBody(basePos, basePos + new Vector2(width, 0)); + + // Intention was to have left, right, and bottom have the window padding and top have the component gap, + // but as ImGui only allows horz/vert padding, we add the extra bottom padding. + // Top padding is zero, as the action window will add the padding. + ImGui.Dummy(new(NotificationConstants.ScaledWindowPadding)); + + float progressL, progressR; + if (this.IsDismissed) + { + 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; + progressL = midpoint - (length * v); + progressR = midpoint + (length * v); + } + else if (this.Expiry == DateTime.MaxValue) + { + if (this.Progress >= 0) + { + progressL = 0f; + progressR = this.ProgressEased; + } + else + { + var elapsed = (float)(((DateTime.Now - this.CreatedAt).TotalMilliseconds % + NotificationConstants.IndeterminateProgressbarLoopDuration) / + NotificationConstants.IndeterminateProgressbarLoopDuration); + progressL = Math.Max(elapsed - (1f / 3), 0f) / (2f / 3); + progressR = Math.Min(elapsed, 2f / 3) / (2f / 3); + progressL = MathF.Pow(progressL, 3); + progressR = 1f - MathF.Pow(1f - progressR, 3); + } + + this.prevProgressL = progressL; + this.prevProgressR = progressR; + } + else if (this.HoverExtendDuration > TimeSpan.Zero && this.IsMouseHovered) + { + progressL = 0f; + progressR = 1f; + this.prevProgressL = progressL; + this.prevProgressR = progressR; + } + else + { + progressL = 1f - (float)((this.Expiry - DateTime.Now).TotalMilliseconds / + (this.Expiry - this.ExpiryRelativeToTime).TotalMilliseconds); + progressR = 1f; + this.prevProgressL = progressL; + this.prevProgressR = progressR; + } + + progressR = Math.Clamp(progressR, 0f, 1f); + + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + ImGui.PushClipRect(windowPos, windowPos + windowSize, false); + ImGui.GetWindowDrawList().AddRectFilled( + windowPos + new Vector2( + windowSize.X * progressL, + windowSize.Y - NotificationConstants.ScaledExpiryProgressBarHeight), + windowPos + windowSize with { X = windowSize.X * progressR }, + ImGui.GetColorU32(this.DefaultIconColor)); + ImGui.PopClipRect(); + } + private void DrawIcon(NotificationManager notificationManager, Vector2 minCoord, Vector2 maxCoord) { string? iconString = null; @@ -486,8 +692,14 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable ImGui.SetCursorPos(pos); ImGui.Image(iconTexture.ImGuiHandle, size); } - else if (fontHandle is not null) + else { + // Just making it extremely sure + // ReSharper disable once ConditionIsAlwaysTrueOrFalse + if (fontHandle is null || iconString is null) + // ReSharper disable once HeuristicUnreachableCode + return; + using (fontHandle.Push()) { var size = ImGui.CalcTextSize(iconString); @@ -514,47 +726,13 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.BlameTextColor); ImGui.SetCursorPos(minCoord with { Y = ImGui.GetCursorPosY() }); - ImGui.TextUnformatted(this.InitiatorPlugin?.Name ?? NotificationConstants.DefaultInitiator); + ImGui.TextUnformatted(this.InitiatorString); ImGui.PopStyleColor(); ImGui.PopTextWrapPos(); ImGui.SetCursorPosY(ImGui.GetCursorPosY() + NotificationConstants.ScaledComponentGap); } - private void DrawCloseButton(InterfaceManager interfaceManager, Vector2 screenCoord) - { - using (interfaceManager.IconFontHandle?.Push()) - { - var str = FontAwesomeIcon.Times.ToIconString(); - var size = NotificationConstants.ScaledCloseButtonMinSize; - var textSize = ImGui.CalcTextSize(str); - size = Math.Max(size, Math.Max(textSize.X, textSize.Y)); - ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, Vector2.Zero); - ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 0f); - ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); - ImGui.PushStyleColor(ImGuiCol.Button, 0); - ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.CloseTextColor); - - // ImGuiHelpers.ForceNextWindowMainViewport(); - ImGui.SetNextWindowPos(screenCoord, ImGuiCond.Always, new(1, 0)); - ImGui.SetNextWindowSizeConstraints(new(size), new(size)); - ImGui.Begin( - $"##CloseButtonWindow{this.Id}", - ImGuiWindowFlags.AlwaysAutoResize | - ImGuiWindowFlags.NoDecoration | - ImGuiWindowFlags.NoNav | - ImGuiWindowFlags.NoBringToFrontOnFocus | - ImGuiWindowFlags.NoFocusOnAppearing); - - if (ImGui.Button(str, new(size))) - this.DismissNow(); - - ImGui.End(); - ImGui.PopStyleColor(2); - ImGui.PopStyleVar(3); - } - } - private void DrawContentBody(Vector2 minCoord, Vector2 maxCoord) { ImGui.SetCursorPos(minCoord); @@ -576,4 +754,44 @@ internal sealed class ActiveNotification : IActiveNotification, IDisposable } } } + + private void DrawNotificationActionWindowContent(InterfaceManager interfaceManager, float width) + { + ImGui.SetCursorPos(new(NotificationConstants.ScaledWindowPadding)); + ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.WhenTextColor); + ImGui.TextUnformatted( + this.IsMouseHovered + ? this.CreatedAt.FormatAbsoluteDateTime() + : this.CreatedAt.FormatRelativeDateTime()); + ImGui.PopStyleColor(); + + this.DrawCloseButton( + interfaceManager, + new(width - NotificationConstants.ScaledWindowPadding, NotificationConstants.ScaledWindowPadding), + NotificationConstants.ScaledWindowPadding); + } + + private void DrawCloseButton(InterfaceManager interfaceManager, Vector2 rt, float pad) + { + using (interfaceManager.IconFontHandle?.Push()) + { + var str = FontAwesomeIcon.Times.ToIconString(); + var textSize = ImGui.CalcTextSize(str); + var size = Math.Max(textSize.X, textSize.Y); + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, Vector2.Zero); + if (!this.IsMouseHovered) + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0f); + ImGui.PushStyleColor(ImGuiCol.Button, 0); + ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.CloseTextColor); + + ImGui.SetCursorPos(rt - new Vector2(size, 0) - new Vector2(pad)); + if (ImGui.Button(str, new(size + (pad * 2)))) + this.DismissNow(); + + ImGui.PopStyleColor(2); + if (!this.IsMouseHovered) + ImGui.PopStyleVar(); + ImGui.PopStyleVar(); + } + } } diff --git a/Dalamud/Interface/Internal/Notifications/NotificationConstants.cs b/Dalamud/Interface/Internal/Notifications/NotificationConstants.cs index bf71cd87e..3592c2a00 100644 --- a/Dalamud/Interface/Internal/Notifications/NotificationConstants.cs +++ b/Dalamud/Interface/Internal/Notifications/NotificationConstants.cs @@ -9,7 +9,9 @@ namespace Dalamud.Interface.Internal.Notifications; /// internal static class NotificationConstants { - // ..............................[X] + // .............................[..] + // ..when.......................[XX] + // .. .. // ..[i]..title title title title .. // .. by this_plugin .. // .. .. @@ -28,6 +30,9 @@ internal static class NotificationConstants /// The background opacity of a notification window. public const float BackgroundOpacity = 0.82f; + /// The duration of indeterminate progress bar loop in milliseconds. + public const float IndeterminateProgressbarLoopDuration = 2000f; + /// Duration of show animation. public static readonly TimeSpan ShowAnimationDuration = TimeSpan.FromMilliseconds(300); @@ -40,6 +45,12 @@ internal static class NotificationConstants /// Duration of hide animation. public static readonly TimeSpan HideAnimationDuration = TimeSpan.FromMilliseconds(300); + /// Duration of hide animation. + public static readonly TimeSpan ProgressAnimationDuration = TimeSpan.FromMilliseconds(200); + + /// Text color for the when. + public static readonly Vector4 WhenTextColor = new(0.8f, 0.8f, 0.8f, 1f); + /// Text color for the close button [X]. public static readonly Vector4 CloseTextColor = new(0.8f, 0.8f, 0.8f, 1f); @@ -52,6 +63,21 @@ internal static class NotificationConstants /// Text color for the body. public static readonly Vector4 BodyTextColor = new(0.9f, 0.9f, 0.9f, 1f); + /// Gets the relative time format strings. + private static readonly (TimeSpan MinSpan, string? FormatString)[] RelativeFormatStrings = + { + (TimeSpan.FromDays(7), null), + (TimeSpan.FromDays(2), "{0:%d} days ago"), + (TimeSpan.FromDays(1), "yesterday"), + (TimeSpan.FromHours(2), "{0:%h} hours ago"), + (TimeSpan.FromHours(1), "an hour ago"), + (TimeSpan.FromMinutes(2), "{0:%m} minutes ago"), + (TimeSpan.FromMinutes(1), "a minute ago"), + (TimeSpan.FromSeconds(2), "{0:%s} seconds ago"), + (TimeSpan.FromSeconds(1), "a second ago"), + (TimeSpan.MinValue, "just now"), + }; + /// Gets the scaled padding of the window (dot(.) in the above diagram). public static float ScaledWindowPadding => MathF.Round(16 * ImGuiHelpers.GlobalScale); @@ -69,9 +95,36 @@ internal static class NotificationConstants /// Gets the scaled size of the icon. public static float ScaledIconSize => MathF.Round(IconSize * ImGuiHelpers.GlobalScale); - /// Gets the scaled size of the close button. - public static float ScaledCloseButtonMinSize => MathF.Round(16 * ImGuiHelpers.GlobalScale); - /// Gets the height of the expiry progress bar. public static float ScaledExpiryProgressBarHeight => MathF.Round(2 * ImGuiHelpers.GlobalScale); + + /// Gets the string format of the initiator name field, if the initiator is unloaded. + public static string UnloadedInitiatorNameFormat => "{0} (unloaded)"; + + /// + /// Formats an instance of as a relative time. + /// + /// When. + /// The formatted string. + public static string FormatRelativeDateTime(this DateTime when) + { + var ts = DateTime.Now - when; + foreach (var (minSpan, formatString) in RelativeFormatStrings) + { + if (ts < minSpan) + continue; + if (formatString is null) + break; + return string.Format(formatString, ts); + } + + return when.FormatAbsoluteDateTime(); + } + + /// + /// Formats an instance of as an absolute time. + /// + /// When. + /// The formatted string. + public static string FormatAbsoluteDateTime(this DateTime when) => $"{when:G}"; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs index 2eee81ee2..060498ba7 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs @@ -1,5 +1,8 @@ -using Dalamud.Interface.Internal.Notifications; +using System.Threading.Tasks; + +using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Windowing; + using ImGuiNET; namespace Dalamud.Interface.Internal.Windows.Data.Widgets; @@ -9,11 +12,13 @@ namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// internal class ImGuiWidget : IDataWindowWidget { + private NotificationTemplate notificationTemplate; + /// public string[]? CommandShortcuts { get; init; } = { "imgui" }; - + /// - public string DisplayName { get; init; } = "ImGui"; + public string DisplayName { get; init; } = "ImGui"; /// public bool Ready { get; set; } @@ -22,6 +27,7 @@ internal class ImGuiWidget : IDataWindowWidget public void Load() { this.Ready = true; + this.notificationTemplate.Reset(); } /// @@ -38,51 +44,134 @@ internal class ImGuiWidget : IDataWindowWidget ImGui.Separator(); - ImGui.TextUnformatted($"WindowSystem.TimeSinceLastAnyFocus: {WindowSystem.TimeSinceLastAnyFocus.TotalMilliseconds:0}ms"); + ImGui.TextUnformatted( + $"WindowSystem.TimeSinceLastAnyFocus: {WindowSystem.TimeSinceLastAnyFocus.TotalMilliseconds:0}ms"); ImGui.Separator(); - if (ImGui.Button("Add random notification")) - { - 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."; + 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("##manualType", ref this.notificationTemplate.ManualType); + ImGui.SameLine(); + ImGui.Combo( + "Type##type", + ref this.notificationTemplate.TypeInt, + NotificationTemplate.TypeTitles, + NotificationTemplate.TypeTitles.Length); + + ImGui.Combo( + "Duration", + ref this.notificationTemplate.DurationInt, + NotificationTemplate.DurationTitles, + NotificationTemplate.DurationTitles.Length); + + ImGui.Combo( + "Progress", + ref this.notificationTemplate.ProgressMode, + NotificationTemplate.ProgressModeTitles, + NotificationTemplate.ProgressModeTitles.Length); + + ImGui.Checkbox("Interactible", ref this.notificationTemplate.Interactible); + + ImGui.Checkbox("Action Bar", 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 duration = NotificationTemplate.Durations[this.notificationTemplate.DurationInt]; - NewRandom(out var title, out var type); var n = notifications.AddNotification( new() { Content = text, Title = title, Type = type, - Interactible = true, - ClickIsDismiss = false, - Expiry = DateTime.MaxValue, + Interactible = this.notificationTemplate.Interactible, + Expiry = duration == TimeSpan.MaxValue ? DateTime.MaxValue : DateTime.Now + duration, + Progress = this.notificationTemplate.ProgressMode switch + { + 0 => 1f, + 1 => progress, + 2 => 0f, + 3 => 0f, + 4 => -1f, + _ => 0.5f, + }, }); - - var nclick = 0; - n.Click += _ => nclick++; - n.DrawActions += an => + switch (this.notificationTemplate.ProgressMode) { - if (ImGui.Button("Update in place")) - { - NewRandom(out title, out type); - an.Update(an.CloneNotification() with { Title = title, Type = type }); - } + case 2: + Task.Run( + async () => + { + for (var i = 0; i <= 10 && !n.IsDismissed; i++) + { + await Task.Delay(500); + n.Progress = i / 10f; + } + }); + break; + case 3: + Task.Run( + async () => + { + for (var i = 0; i <= 10 && !n.IsDismissed; i++) + { + await Task.Delay(500); + n.Progress = i / 10f; + } - if (an.IsMouseHovered) + n.Expiry = DateTime.Now + NotificationConstants.DefaultDisplayDuration; + }); + break; + } + + if (this.notificationTemplate.ActionBar) + { + var nclick = 0; + n.Click += _ => nclick++; + n.DrawActions += an => { + if (ImGui.Button("Update in place")) + { + NewRandom(out title, out type, out progress); + an.Title = title; + an.Type = type; + an.Progress = progress; + } + + if (an.IsMouseHovered) + { + ImGui.SameLine(); + if (ImGui.Button("Dismiss")) + an.DismissNow(); + } + + ImGui.AlignTextToFramePadding(); ImGui.SameLine(); - if (ImGui.Button("Dismiss")) - an.DismissNow(); - } - - ImGui.AlignTextToFramePadding(); - ImGui.SameLine(); - ImGui.TextUnformatted($"Clicked {nclick} time(s)"); - }; + ImGui.TextUnformatted($"Clicked {nclick} time(s)"); + }; + } } } - private static void NewRandom(out string? title, out NotificationType type) + private static void NewRandom(out string? title, out NotificationType type, out float progress) { var rand = new Random(); @@ -106,5 +195,72 @@ internal class ImGuiWidget : IDataWindowWidget 4 => NotificationType.None, _ => NotificationType.None, }; + + if (rand.Next() % 2 == 0) + progress = -1; + else + progress = rand.NextSingle(); + } + + private struct NotificationTemplate + { + 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[] DurationTitles = + { + "Infinite", + "1 seconds", + "3 seconds (default)", + "10 seconds", + }; + + public static readonly TimeSpan[] Durations = + { + TimeSpan.MaxValue, + TimeSpan.FromSeconds(1), + NotificationConstants.DefaultDisplayDuration, + TimeSpan.FromSeconds(10), + }; + + public bool ManualContent; + public string Content; + public bool ManualTitle; + public string Title; + public bool ManualType; + public int TypeInt; + public int DurationInt; + public bool Interactible; + public bool ActionBar; + public int ProgressMode; + + public void Reset() + { + this.ManualContent = false; + this.Content = string.Empty; + this.ManualTitle = false; + this.Title = string.Empty; + this.ManualType = false; + this.TypeInt = (int)NotificationType.None; + this.DurationInt = 2; + this.Interactible = true; + this.ActionBar = true; + this.ProgressMode = 0; + } } }