From 2b49170f6ab811ecee0fef152f2eb00c04259c17 Mon Sep 17 00:00:00 2001 From: goaaats Date: Wed, 7 May 2025 00:00:32 +0200 Subject: [PATCH] Add configurable "anchor position" for notifications --- .../Internal/DalamudConfiguration.cs | 7 + .../Internal/ActiveNotification.ImGui.cs | 79 ++++++- .../Internal/NotificationConstants.cs | 5 + .../Internal/NotificationManager.cs | 74 +++++- .../Internal/NotificationPositionChooser.cs | 213 ++++++++++++++++++ .../Internal/NotificationSnapDirection.cs | 27 +++ .../Windows/Settings/Tabs/SettingsTabLook.cs | 12 +- 7 files changed, 401 insertions(+), 16 deletions(-) create mode 100644 Dalamud/Interface/ImGuiNotification/Internal/NotificationPositionChooser.cs create mode 100644 Dalamud/Interface/ImGuiNotification/Internal/NotificationSnapDirection.cs diff --git a/Dalamud/Configuration/Internal/DalamudConfiguration.cs b/Dalamud/Configuration/Internal/DalamudConfiguration.cs index 2766ba681..6816b166f 100644 --- a/Dalamud/Configuration/Internal/DalamudConfiguration.cs +++ b/Dalamud/Configuration/Internal/DalamudConfiguration.cs @@ -3,12 +3,14 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; +using System.Numerics; using System.Runtime.InteropServices; using System.Threading.Tasks; using Dalamud.Game.Text; using Dalamud.Interface; using Dalamud.Interface.FontIdentifier; +using Dalamud.Interface.ImGuiNotification.Internal; using Dalamud.Interface.Internal; using Dalamud.Interface.Internal.ReShadeHandling; using Dalamud.Interface.Style; @@ -501,6 +503,11 @@ internal sealed class DalamudConfiguration : IInternalDisposableService /// public bool SendUpdateNotificationToChat { get; set; } = false; + /// + /// Gets or sets a value indicating where notifications are anchored to on the screen. + /// + public Vector2 NotificationAnchorPosition { get; set; } = new(1f, 1f); + /// /// Load a configuration from the provided path. /// diff --git a/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs index c672dd3b3..ce70ab180 100644 --- a/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs +++ b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs @@ -15,8 +15,9 @@ internal sealed partial class ActiveNotification /// Draws this notification. /// The maximum width of the notification window. /// The offset from the bottom. + /// Where notifications are anchored to on the screen. /// The height of the notification. - public float Draw(float width, float offsetY) + public float Draw(float width, float offsetY, Vector2 anchorPosition, NotificationSnapDirection snapDirection) { var opacity = Math.Clamp( @@ -35,7 +36,6 @@ internal sealed partial class ActiveNotification (NotificationConstants.ScaledWindowPadding * 2); var viewport = ImGuiHelpers.MainViewport; - var viewportPos = viewport.WorkPos; var viewportSize = viewport.WorkSize; ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity); @@ -52,13 +52,78 @@ internal sealed partial class ActiveNotification NotificationConstants.BackgroundOpacity)); } + Vector2 topLeft; + Vector2 pivot; + if (snapDirection is NotificationSnapDirection.Top or NotificationSnapDirection.Bottom) + { + // Top or bottom + var xPos = (viewportSize.X - width) * anchorPosition.X; + xPos = Math.Max(NotificationConstants.ScaledViewportEdgeMargin, Math.Min(viewportSize.X - width - NotificationConstants.ScaledViewportEdgeMargin, xPos)); + + if (snapDirection == NotificationSnapDirection.Top) + { + // Top + var yPos = NotificationConstants.ScaledViewportEdgeMargin - offsetY; + topLeft = new Vector2(xPos, yPos); + pivot = new(0, 0); + } + else + { + // Bottom + var yPos = viewportSize.Y - offsetY - NotificationConstants.ScaledViewportEdgeMargin; + topLeft = new Vector2(xPos, yPos); + pivot = new(0, 1); + } + } + else + { + // Left or Right + var yPos = (viewportSize.Y * anchorPosition.Y) - offsetY; + yPos = Math.Max( + NotificationConstants.ScaledViewportEdgeMargin, + Math.Min(viewportSize.Y - offsetY - NotificationConstants.ScaledViewportEdgeMargin, yPos)); + + if (snapDirection == NotificationSnapDirection.Left) + { + // Left + var xPos = NotificationConstants.ScaledViewportEdgeMargin; + + if (anchorPosition.Y > 0.5f) + { + // Bottom + topLeft = new Vector2(xPos, yPos); + pivot = new(0, 1); + } + else + { + // Top + topLeft = new Vector2(xPos, yPos); + pivot = new(0, 0); + } + } + else + { + // Right + var xPos = viewportSize.X - width - NotificationConstants.ScaledViewportEdgeMargin; + + if (anchorPosition.Y > 0.5f) + { + topLeft = new Vector2(xPos, yPos); + pivot = new(0, 1); + } + else + { + topLeft = new Vector2(xPos, yPos); + pivot = new(0, 0); + } + } + } + ImGuiHelpers.ForceNextWindowMainViewport(); ImGui.SetNextWindowPos( - (viewportPos + viewportSize) - - new Vector2(NotificationConstants.ScaledViewportEdgeMargin) - - new Vector2(0, offsetY), + topLeft, ImGuiCond.Always, - Vector2.One); + pivot); ImGui.SetNextWindowSizeConstraints( new(width, actionWindowHeight), new( @@ -142,7 +207,7 @@ internal sealed partial class ActiveNotification ImGui.PopStyleColor(); ImGui.PopStyleVar(3); - return windowSize.Y; + return NotificationManager.ShouldScrollDownwards(anchorPosition) ? -windowSize.Y : windowSize.Y; } /// Calculates the effective expiry, taking ImGui window state into account. diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs index 8b7ce7bfa..b79855a6b 100644 --- a/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs +++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs @@ -54,6 +54,11 @@ internal static class NotificationConstants /// public const float ProgressWaveLoopMaxColorTimeRatio = 0.7f; + /// + /// The ratio of the screen at which the notification window will snap to the top or bottom of the screen. + /// + public const float NotificationTopBottomSnapMargin = 0.08f; + /// Default duration of the notification. public static readonly TimeSpan DefaultDuration = TimeSpan.FromSeconds(7); diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationManager.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationManager.cs index 4157d1356..b8759fc2a 100644 --- a/Dalamud/Interface/ImGuiNotification/Internal/NotificationManager.cs +++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationManager.cs @@ -1,6 +1,8 @@ using System.Collections.Concurrent; using System.Collections.Generic; +using System.Numerics; +using Dalamud.Configuration.Internal; using Dalamud.Game.Gui; using Dalamud.Interface.GameFonts; using Dalamud.Interface.ManagedFontAtlas; @@ -22,9 +24,14 @@ internal class NotificationManager : INotificationManager, IInternalDisposableSe [ServiceManager.ServiceDependency] private readonly GameGui gameGui = Service.Get(); + [ServiceManager.ServiceDependency] + private readonly DalamudConfiguration configuration = Service.Get(); + private readonly List notifications = new(); private readonly ConcurrentBag pendingNotifications = new(); + private NotificationPositionChooser? positionChooser; + [ServiceManager.ServiceConstructor] private NotificationManager(FontAtlasFactory fontAtlasFactory) { @@ -48,6 +55,48 @@ internal class NotificationManager : INotificationManager, IInternalDisposableSe /// Gets the private atlas for use with notification windows. private IFontAtlas PrivateAtlas { get; } + /// + /// Calculate the width to be used to draw notifications. + /// + /// The width. + public static float CalculateNotificationWidth() + { + var viewportSize = ImGuiHelpers.MainViewport.WorkSize; + var width = ImGui.CalcTextSize(NotificationConstants.NotificationWidthMeasurementString).X; + width += NotificationConstants.ScaledWindowPadding * 3; + width += NotificationConstants.ScaledIconSize; + return Math.Min(width, viewportSize.X * NotificationConstants.MaxNotificationWindowWidthWrtMainViewportWidth); + } + + /// + /// Check if notifications should scroll downwards on the screen, based on the anchor position. + /// + /// Where notifications are anchored to. + /// A value indicating wether notifications should scroll downwards. + public static bool ShouldScrollDownwards(Vector2 anchorPosition) + { + return anchorPosition.Y < 0.5f; + } + + /// + /// Choose the snap position for a notification based on the anchor position. + /// + /// Where notifications are anchored to. + /// The snap position. + public static NotificationSnapDirection ChooseSnapDirection(Vector2 anchorPosition) + { + if (anchorPosition.Y <= NotificationConstants.NotificationTopBottomSnapMargin) + return NotificationSnapDirection.Top; + + if (anchorPosition.Y >= 1f - NotificationConstants.NotificationTopBottomSnapMargin) + return NotificationSnapDirection.Bottom; + + if (anchorPosition.X <= 0.5f) + return NotificationSnapDirection.Left; + + return NotificationSnapDirection.Right; + } + /// public void DisposeService() { @@ -98,25 +147,38 @@ internal class NotificationManager : INotificationManager, IInternalDisposableSe /// Draw all currently queued notifications. public void Draw() { - var viewportSize = ImGuiHelpers.MainViewport.WorkSize; var height = 0f; var uiHidden = this.gameGui.GameUiHidden; while (this.pendingNotifications.TryTake(out var newNotification)) this.notifications.Add(newNotification); - var width = ImGui.CalcTextSize(NotificationConstants.NotificationWidthMeasurementString).X; - width += NotificationConstants.ScaledWindowPadding * 3; - width += NotificationConstants.ScaledIconSize; - width = Math.Min(width, viewportSize.X * NotificationConstants.MaxNotificationWindowWidthWrtMainViewportWidth); + var width = CalculateNotificationWidth(); this.notifications.RemoveAll(static x => x.UpdateOrDisposeInternal()); + + var scrollsDownwards = ShouldScrollDownwards(this.configuration.NotificationAnchorPosition); + var snapDirection = ChooseSnapDirection(this.configuration.NotificationAnchorPosition); + foreach (var tn in this.notifications) { if (uiHidden && tn.RespectUiHidden) continue; - height += tn.Draw(width, height) + NotificationConstants.ScaledWindowGap; + + height += tn.Draw(width, height, this.configuration.NotificationAnchorPosition, snapDirection); + height += scrollsDownwards ? -NotificationConstants.ScaledWindowGap : NotificationConstants.ScaledWindowGap; } + + this.positionChooser?.Draw(); + } + + /// + /// Starts the position chooser for notifications. Will block the UI until the user makes a selection. + /// + public void StartPositionChooser() + { + this.positionChooser = new NotificationPositionChooser(this.configuration); + this.positionChooser.SelectionMade += () => { this.positionChooser = null; }; } } diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationPositionChooser.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationPositionChooser.cs new file mode 100644 index 000000000..6ad42ad80 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationPositionChooser.cs @@ -0,0 +1,213 @@ +using System.Numerics; + +using Dalamud.Configuration.Internal; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; + +using ImGuiNET; + +namespace Dalamud.Interface.ImGuiNotification.Internal; + +/// +/// Class responsible for drawing UI that lets users choose the position of notifications. +/// +internal class NotificationPositionChooser +{ + private readonly DalamudConfiguration configuration; + private readonly Vector2 previousAnchorPosition; + + private Vector2 currentAnchorPosition; + + /// + /// Initializes a new instance of the class. + /// + /// The configuration we are reading or writing from. + public NotificationPositionChooser(DalamudConfiguration configuration) + { + this.configuration = configuration; + this.previousAnchorPosition = configuration.NotificationAnchorPosition; + } + + /// + /// Gets or sets an action that is invoked when the user makes a selection. + /// + public event Action? SelectionMade; + + /// + /// Draw the chooser UI. + /// + public void Draw() + { + using var style1 = ImRaii.PushStyle(ImGuiStyleVar.WindowRounding, 0f); + using var style2 = ImRaii.PushStyle(ImGuiStyleVar.WindowBorderSize, 0f); + using var color = ImRaii.PushColor(ImGuiCol.WindowBg, new Vector4(0, 0, 0, 0)); + + ImGui.SetNextWindowFocus(); + ImGui.SetNextWindowPos(ImGuiHelpers.MainViewport.Pos); + ImGui.SetNextWindowSize(ImGuiHelpers.MainViewport.Size); + ImGuiHelpers.ForceNextWindowMainViewport(); + + ImGui.SetNextWindowBgAlpha(0.6f); + + ImGui.Begin( + "###NotificationPositionChooser", + ImGuiWindowFlags.NoDocking | ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoNav); + + var mousePosUnit = ImGui.GetMousePos() / ImGuiHelpers.MainViewport.Size; + + // Store the offset as a Vector2 + this.currentAnchorPosition = mousePosUnit; + + DrawPreview(this.previousAnchorPosition, 0.3f); + DrawPreview(this.currentAnchorPosition, 1f); + + if (ImGui.IsMouseClicked(ImGuiMouseButton.Right)) + { + this.SelectionMade?.Invoke(); + } + else if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + this.configuration.NotificationAnchorPosition = this.currentAnchorPosition; + this.configuration.QueueSave(); + + this.SelectionMade?.Invoke(); + } + + // In the middle of the screen, draw some instructions + string[] instructions = ["Drag to move the notifications to where you would like them to appear.", + "Click to select the position.", + "Right-click to close without making changes."]; + + var dl = ImGui.GetWindowDrawList(); + for (var i = 0; i < instructions.Length; i++) + { + var instruction = instructions[i]; + var instructionSize = ImGui.CalcTextSize(instruction); + var instructionPos = new Vector2( + ImGuiHelpers.MainViewport.Size.X / 2 - instructionSize.X / 2, + ImGuiHelpers.MainViewport.Size.Y / 2 - instructionSize.Y / 2 + i * instructionSize.Y); + dl.AddText(instructionPos, 0xFFFFFFFF, instruction); + } + + ImGui.End(); + } + + private static void DrawPreview(Vector2 anchorPosition, float borderAlpha) + { + var dl = ImGui.GetWindowDrawList(); + var width = NotificationManager.CalculateNotificationWidth(); + var height = 100f * ImGuiHelpers.GlobalScale; + var smallBoxHeight = height * 0.4f; + var edgeMargin = NotificationConstants.ScaledViewportEdgeMargin; + var spacing = 10f * ImGuiHelpers.GlobalScale; + + var viewportSize = ImGuiHelpers.MainViewport.Size; + var borderColor = ImGui.ColorConvertFloat4ToU32(new(1f, 1f, 1f, borderAlpha)); + var borderThickness = 4.0f * ImGuiHelpers.GlobalScale; + var borderRounding = 4.0f * ImGuiHelpers.GlobalScale; + var backgroundColor = new Vector4(0, 0, 0, 0.5f); // Semi-transparent black + + // Calculate positions based on the snap position + Vector2 topLeft, bottomRight, smallTopLeft, smallBottomRight; + + var snapPos = NotificationManager.ChooseSnapDirection(anchorPosition); + if (snapPos is NotificationSnapDirection.Top or NotificationSnapDirection.Bottom) + { + // Calculate X position - same logic for top and bottom + var xPos = (viewportSize.X - width) * anchorPosition.X; + xPos = Math.Max(edgeMargin, Math.Min(viewportSize.X - width - edgeMargin, xPos)); + + if (snapPos == NotificationSnapDirection.Top) + { + // For top position: big box at top, small box below it + var yPos = edgeMargin; + topLeft = new Vector2(xPos, yPos); + bottomRight = new Vector2(xPos + width, yPos + height); + + smallTopLeft = new Vector2(xPos, yPos + height + spacing); + smallBottomRight = new Vector2(xPos + width, yPos + height + spacing + smallBoxHeight); + } + else + { + // For bottom position: big box at bottom, small box above it + var yPos = viewportSize.Y - height - edgeMargin; + topLeft = new Vector2(xPos, yPos); + bottomRight = new Vector2(xPos + width, yPos + height); + + smallTopLeft = new Vector2(xPos, yPos - smallBoxHeight - spacing); + smallBottomRight = new Vector2(xPos + width, yPos - spacing); + } + } + else + { + // For left and right positions, boxes are still stacked vertically (one above the other) + // Only the horizontal position changes + + // Calculate Y position based on unit offset - used for both left and right positions + var yPos = (viewportSize.Y - height) * anchorPosition.Y; + yPos = Math.Max(edgeMargin, Math.Min(viewportSize.Y - height - edgeMargin, yPos)); + + if (snapPos == NotificationSnapDirection.Left) + { + // For left position: boxes are at the left edge of the screen + var xPos = edgeMargin; + + if (anchorPosition.Y > 0.5f) + { + // Small box on top + smallTopLeft = new Vector2(xPos, yPos - smallBoxHeight - spacing); + smallBottomRight = new Vector2(xPos + width, yPos - spacing); + + // Big box below + topLeft = new Vector2(xPos, yPos); + bottomRight = new Vector2(xPos + width, yPos + height); + } + else + { + // Big box on top + topLeft = new Vector2(xPos, yPos); + bottomRight = new Vector2(xPos + width, yPos + height); + + // Small box below + smallTopLeft = new Vector2(xPos, yPos + height + spacing); + smallBottomRight = new Vector2(xPos + width, yPos + height + spacing + smallBoxHeight); + } + } + else + { + // For right position: boxes are at the right edge of the screen + var xPos = viewportSize.X - width - edgeMargin; + + if (anchorPosition.Y > 0.5f) + { + // Small box on top + smallTopLeft = new Vector2(xPos, yPos - smallBoxHeight - spacing); + smallBottomRight = new Vector2(xPos + width, yPos - spacing); + + // Big box below + topLeft = new Vector2(xPos, yPos); + bottomRight = new Vector2(xPos + width, yPos + height); + } + else + { + // Big box on top + topLeft = new Vector2(xPos, yPos); + bottomRight = new Vector2(xPos + width, yPos + height); + + // Small box below + smallTopLeft = new Vector2(xPos, yPos + height + spacing); + smallBottomRight = new Vector2(xPos + width, yPos + height + spacing + smallBoxHeight); + } + } + } + + // Draw the big box + dl.AddRectFilled(topLeft, bottomRight, ImGui.ColorConvertFloat4ToU32(backgroundColor), borderRounding, ImDrawFlags.RoundCornersAll); + dl.AddRect(topLeft, bottomRight, borderColor, borderRounding, ImDrawFlags.RoundCornersAll, borderThickness); + + // Draw the small box + dl.AddRectFilled(smallTopLeft, smallBottomRight, ImGui.ColorConvertFloat4ToU32(backgroundColor), borderRounding, ImDrawFlags.RoundCornersAll); + dl.AddRect(smallTopLeft, smallBottomRight, borderColor, borderRounding, ImDrawFlags.RoundCornersAll, borderThickness); + } +} diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationSnapDirection.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationSnapDirection.cs new file mode 100644 index 000000000..1666e7a8c --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationSnapDirection.cs @@ -0,0 +1,27 @@ +namespace Dalamud.Interface.ImGuiNotification.Internal; + +/// +/// Where notifications should snap to on the screen when they are shown. +/// +public enum NotificationSnapDirection +{ + /// + /// Snap to the top of the screen. + /// + Top, + + /// + /// Snap to the bottom of the screen. + /// + Bottom, + + /// + /// Snap to the left of the screen. + /// + Left, + + /// + /// Snap to the right of the screen. + /// + Right, +} diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs index 7f75dbf29..112fa3a3f 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs @@ -11,6 +11,7 @@ using Dalamud.Interface.Colors; using Dalamud.Interface.FontIdentifier; using Dalamud.Interface.GameFonts; using Dalamud.Interface.ImGuiFontChooserDialog; +using Dalamud.Interface.ImGuiNotification.Internal; using Dalamud.Interface.Internal.Windows.PluginInstaller; using Dalamud.Interface.Internal.Windows.Settings.Widgets; using Dalamud.Interface.ManagedFontAtlas.Internals; @@ -38,7 +39,7 @@ public class SettingsTabLook : SettingsTab private IFontSpec defaultFontSpec = null!; public override SettingsEntry[] Entries { get; } = - { + [ new GapSettingsEntry(5, true), new ButtonSettingsEntry( @@ -46,6 +47,11 @@ public class SettingsTabLook : SettingsTab Loc.Localize("DalamudSettingsStyleEditorHint", "Modify the look & feel of Dalamud windows."), () => Service.Get().OpenStyleEditor()), + new ButtonSettingsEntry( + Loc.Localize("DalamudSettingsOpenNotificationEditor", "Modify Notification Position"), + Loc.Localize("DalamudSettingsNotificationEditorHint", "Choose where Dalamud notifications appear on the screen."), + () => Service.Get().StartPositionChooser()), + new SettingsEntry( Loc.Localize("DalamudSettingsUseDarkMode", "Use Windows immersive/dark mode"), Loc.Localize("DalamudSettingsUseDarkModeHint", "This will cause the FFXIV window title bar to follow your preferred Windows color settings, and switch to dark mode if enabled."), @@ -167,8 +173,8 @@ public class SettingsTabLook : SettingsTab ImGui.TextUnformatted("\uE020\uE021\uE022\uE023\uE024\uE025\uE026\uE027"); ImGui.PopStyleVar(1); }, - }, - }; + } + ]; public override string Title => Loc.Localize("DalamudSettingsVisual", "Look & Feel");