Add configurable "anchor position" for notifications

This commit is contained in:
goaaats 2025-05-07 00:00:32 +02:00
parent 20ef5fb919
commit 2b49170f6a
7 changed files with 401 additions and 16 deletions

View file

@ -3,12 +3,14 @@ using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using Dalamud.Game.Text; using Dalamud.Game.Text;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.FontIdentifier; using Dalamud.Interface.FontIdentifier;
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal; using Dalamud.Interface.Internal;
using Dalamud.Interface.Internal.ReShadeHandling; using Dalamud.Interface.Internal.ReShadeHandling;
using Dalamud.Interface.Style; using Dalamud.Interface.Style;
@ -501,6 +503,11 @@ internal sealed class DalamudConfiguration : IInternalDisposableService
/// </summary> /// </summary>
public bool SendUpdateNotificationToChat { get; set; } = false; public bool SendUpdateNotificationToChat { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating where notifications are anchored to on the screen.
/// </summary>
public Vector2 NotificationAnchorPosition { get; set; } = new(1f, 1f);
/// <summary> /// <summary>
/// Load a configuration from the provided path. /// Load a configuration from the provided path.
/// </summary> /// </summary>

View file

@ -15,8 +15,9 @@ internal sealed partial class ActiveNotification
/// <summary>Draws this notification.</summary> /// <summary>Draws this notification.</summary>
/// <param name="width">The maximum width of the notification window.</param> /// <param name="width">The maximum width of the notification window.</param>
/// <param name="offsetY">The offset from the bottom.</param> /// <param name="offsetY">The offset from the bottom.</param>
/// <param name="anchorPosition">Where notifications are anchored to on the screen.</param>
/// <returns>The height of the notification.</returns> /// <returns>The height of the notification.</returns>
public float Draw(float width, float offsetY) public float Draw(float width, float offsetY, Vector2 anchorPosition, NotificationSnapDirection snapDirection)
{ {
var opacity = var opacity =
Math.Clamp( Math.Clamp(
@ -35,7 +36,6 @@ internal sealed partial class ActiveNotification
(NotificationConstants.ScaledWindowPadding * 2); (NotificationConstants.ScaledWindowPadding * 2);
var viewport = ImGuiHelpers.MainViewport; var viewport = ImGuiHelpers.MainViewport;
var viewportPos = viewport.WorkPos;
var viewportSize = viewport.WorkSize; var viewportSize = viewport.WorkSize;
ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity); ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity);
@ -52,13 +52,78 @@ internal sealed partial class ActiveNotification
NotificationConstants.BackgroundOpacity)); 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(); ImGuiHelpers.ForceNextWindowMainViewport();
ImGui.SetNextWindowPos( ImGui.SetNextWindowPos(
(viewportPos + viewportSize) - topLeft,
new Vector2(NotificationConstants.ScaledViewportEdgeMargin) -
new Vector2(0, offsetY),
ImGuiCond.Always, ImGuiCond.Always,
Vector2.One); pivot);
ImGui.SetNextWindowSizeConstraints( ImGui.SetNextWindowSizeConstraints(
new(width, actionWindowHeight), new(width, actionWindowHeight),
new( new(
@ -142,7 +207,7 @@ internal sealed partial class ActiveNotification
ImGui.PopStyleColor(); ImGui.PopStyleColor();
ImGui.PopStyleVar(3); ImGui.PopStyleVar(3);
return windowSize.Y; return NotificationManager.ShouldScrollDownwards(anchorPosition) ? -windowSize.Y : windowSize.Y;
} }
/// <summary>Calculates the effective expiry, taking ImGui window state into account.</summary> /// <summary>Calculates the effective expiry, taking ImGui window state into account.</summary>

View file

@ -54,6 +54,11 @@ internal static class NotificationConstants
/// </summary> /// </summary>
public const float ProgressWaveLoopMaxColorTimeRatio = 0.7f; public const float ProgressWaveLoopMaxColorTimeRatio = 0.7f;
/// <summary>
/// The ratio of the screen at which the notification window will snap to the top or bottom of the screen.
/// </summary>
public const float NotificationTopBottomSnapMargin = 0.08f;
/// <summary>Default duration of the notification.</summary> /// <summary>Default duration of the notification.</summary>
public static readonly TimeSpan DefaultDuration = TimeSpan.FromSeconds(7); public static readonly TimeSpan DefaultDuration = TimeSpan.FromSeconds(7);

View file

@ -1,6 +1,8 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics;
using Dalamud.Configuration.Internal;
using Dalamud.Game.Gui; using Dalamud.Game.Gui;
using Dalamud.Interface.GameFonts; using Dalamud.Interface.GameFonts;
using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Interface.ManagedFontAtlas;
@ -22,9 +24,14 @@ internal class NotificationManager : INotificationManager, IInternalDisposableSe
[ServiceManager.ServiceDependency] [ServiceManager.ServiceDependency]
private readonly GameGui gameGui = Service<GameGui>.Get(); private readonly GameGui gameGui = Service<GameGui>.Get();
[ServiceManager.ServiceDependency]
private readonly DalamudConfiguration configuration = Service<DalamudConfiguration>.Get();
private readonly List<ActiveNotification> notifications = new(); private readonly List<ActiveNotification> notifications = new();
private readonly ConcurrentBag<ActiveNotification> pendingNotifications = new(); private readonly ConcurrentBag<ActiveNotification> pendingNotifications = new();
private NotificationPositionChooser? positionChooser;
[ServiceManager.ServiceConstructor] [ServiceManager.ServiceConstructor]
private NotificationManager(FontAtlasFactory fontAtlasFactory) private NotificationManager(FontAtlasFactory fontAtlasFactory)
{ {
@ -48,6 +55,48 @@ internal class NotificationManager : INotificationManager, IInternalDisposableSe
/// <summary>Gets the private atlas for use with notification windows.</summary> /// <summary>Gets the private atlas for use with notification windows.</summary>
private IFontAtlas PrivateAtlas { get; } private IFontAtlas PrivateAtlas { get; }
/// <summary>
/// Calculate the width to be used to draw notifications.
/// </summary>
/// <returns>The width.</returns>
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);
}
/// <summary>
/// Check if notifications should scroll downwards on the screen, based on the anchor position.
/// </summary>
/// <param name="anchorPosition">Where notifications are anchored to.</param>
/// <returns>A value indicating wether notifications should scroll downwards.</returns>
public static bool ShouldScrollDownwards(Vector2 anchorPosition)
{
return anchorPosition.Y < 0.5f;
}
/// <summary>
/// Choose the snap position for a notification based on the anchor position.
/// </summary>
/// <param name="anchorPosition">Where notifications are anchored to.</param>
/// <returns>The snap position.</returns>
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;
}
/// <inheritdoc/> /// <inheritdoc/>
public void DisposeService() public void DisposeService()
{ {
@ -98,25 +147,38 @@ internal class NotificationManager : INotificationManager, IInternalDisposableSe
/// <summary>Draw all currently queued notifications.</summary> /// <summary>Draw all currently queued notifications.</summary>
public void Draw() public void Draw()
{ {
var viewportSize = ImGuiHelpers.MainViewport.WorkSize;
var height = 0f; var height = 0f;
var uiHidden = this.gameGui.GameUiHidden; var uiHidden = this.gameGui.GameUiHidden;
while (this.pendingNotifications.TryTake(out var newNotification)) while (this.pendingNotifications.TryTake(out var newNotification))
this.notifications.Add(newNotification); this.notifications.Add(newNotification);
var width = ImGui.CalcTextSize(NotificationConstants.NotificationWidthMeasurementString).X; var width = CalculateNotificationWidth();
width += NotificationConstants.ScaledWindowPadding * 3;
width += NotificationConstants.ScaledIconSize;
width = Math.Min(width, viewportSize.X * NotificationConstants.MaxNotificationWindowWidthWrtMainViewportWidth);
this.notifications.RemoveAll(static x => x.UpdateOrDisposeInternal()); 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) foreach (var tn in this.notifications)
{ {
if (uiHidden && tn.RespectUiHidden) if (uiHidden && tn.RespectUiHidden)
continue; 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();
}
/// <summary>
/// Starts the position chooser for notifications. Will block the UI until the user makes a selection.
/// </summary>
public void StartPositionChooser()
{
this.positionChooser = new NotificationPositionChooser(this.configuration);
this.positionChooser.SelectionMade += () => { this.positionChooser = null; };
} }
} }

View file

@ -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;
/// <summary>
/// Class responsible for drawing UI that lets users choose the position of notifications.
/// </summary>
internal class NotificationPositionChooser
{
private readonly DalamudConfiguration configuration;
private readonly Vector2 previousAnchorPosition;
private Vector2 currentAnchorPosition;
/// <summary>
/// Initializes a new instance of the <see cref="NotificationPositionChooser"/> class.
/// </summary>
/// <param name="configuration">The configuration we are reading or writing from.</param>
public NotificationPositionChooser(DalamudConfiguration configuration)
{
this.configuration = configuration;
this.previousAnchorPosition = configuration.NotificationAnchorPosition;
}
/// <summary>
/// Gets or sets an action that is invoked when the user makes a selection.
/// </summary>
public event Action? SelectionMade;
/// <summary>
/// Draw the chooser UI.
/// </summary>
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);
}
}

View file

@ -0,0 +1,27 @@
namespace Dalamud.Interface.ImGuiNotification.Internal;
/// <summary>
/// Where notifications should snap to on the screen when they are shown.
/// </summary>
public enum NotificationSnapDirection
{
/// <summary>
/// Snap to the top of the screen.
/// </summary>
Top,
/// <summary>
/// Snap to the bottom of the screen.
/// </summary>
Bottom,
/// <summary>
/// Snap to the left of the screen.
/// </summary>
Left,
/// <summary>
/// Snap to the right of the screen.
/// </summary>
Right,
}

View file

@ -11,6 +11,7 @@ using Dalamud.Interface.Colors;
using Dalamud.Interface.FontIdentifier; using Dalamud.Interface.FontIdentifier;
using Dalamud.Interface.GameFonts; using Dalamud.Interface.GameFonts;
using Dalamud.Interface.ImGuiFontChooserDialog; using Dalamud.Interface.ImGuiFontChooserDialog;
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal.Windows.PluginInstaller; using Dalamud.Interface.Internal.Windows.PluginInstaller;
using Dalamud.Interface.Internal.Windows.Settings.Widgets; using Dalamud.Interface.Internal.Windows.Settings.Widgets;
using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.ManagedFontAtlas.Internals;
@ -38,7 +39,7 @@ public class SettingsTabLook : SettingsTab
private IFontSpec defaultFontSpec = null!; private IFontSpec defaultFontSpec = null!;
public override SettingsEntry[] Entries { get; } = public override SettingsEntry[] Entries { get; } =
{ [
new GapSettingsEntry(5, true), new GapSettingsEntry(5, true),
new ButtonSettingsEntry( new ButtonSettingsEntry(
@ -46,6 +47,11 @@ public class SettingsTabLook : SettingsTab
Loc.Localize("DalamudSettingsStyleEditorHint", "Modify the look & feel of Dalamud windows."), Loc.Localize("DalamudSettingsStyleEditorHint", "Modify the look & feel of Dalamud windows."),
() => Service<DalamudInterface>.Get().OpenStyleEditor()), () => Service<DalamudInterface>.Get().OpenStyleEditor()),
new ButtonSettingsEntry(
Loc.Localize("DalamudSettingsOpenNotificationEditor", "Modify Notification Position"),
Loc.Localize("DalamudSettingsNotificationEditorHint", "Choose where Dalamud notifications appear on the screen."),
() => Service<NotificationManager>.Get().StartPositionChooser()),
new SettingsEntry<bool>( new SettingsEntry<bool>(
Loc.Localize("DalamudSettingsUseDarkMode", "Use Windows immersive/dark mode"), 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."), 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.TextUnformatted("\uE020\uE021\uE022\uE023\uE024\uE025\uE026\uE027");
ImGui.PopStyleVar(1); ImGui.PopStyleVar(1);
}, },
}, }
}; ];
public override string Title => Loc.Localize("DalamudSettingsVisual", "Look & Feel"); public override string Title => Loc.Localize("DalamudSettingsVisual", "Look & Feel");