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");