using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Interface;
using Dalamud.Interface.Utility;
using HSUI.Config;
using HSUI.Config.Attributes;
using HSUI.Interface.GeneralElements;
using Dalamud.Bindings.ImGui;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using System;
using System.Numerics;
using System.Text;
namespace HSUI.Helpers
{
/// When showing an ID in the tooltip title, use Action for action IDs or Status for status effect IDs.
public enum TooltipIdKind
{
None = 0,
Action = 1,
Status = 2,
}
public class TooltipsHelper : IDisposable
{
#region Singleton
private TooltipsHelper()
{
}
public static void Initialize() { Instance = new TooltipsHelper(); }
public static TooltipsHelper Instance { get; private set; } = null!;
~TooltipsHelper()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
Instance = null!;
}
#endregion
private float MaxWidth => Math.Max(160, 340 * ImGuiHelpers.GlobalScale * _config.TooltipScale);
private float Margin => Math.Max(4, 5 * ImGuiHelpers.GlobalScale * _config.TooltipScale);
private TooltipsConfig _config => ConfigurationManager.Instance.GetConfigObject();
private string? _currentTooltipText = null;
private Vector2 _textSize;
private string? _currentTooltipTitle = null;
private Vector2 _titleSize;
private string? _previousRawText = null;
private uint? _currentIconId = null;
private Vector2 _position;
private Vector2 _size;
private bool _dataIsValid = false;
private const float IconSize = 24f;
public void ShowTooltipOnCursor(string text, string? title = null, uint id = 0, string name = "", uint? iconId = null, TooltipIdKind idKind = TooltipIdKind.None)
{
ShowTooltip(text, ImGui.GetMousePos(), title, id, name, iconId, idKind);
}
public void ShowTooltip(string text, Vector2 position, string? title = null, uint id = 0, string name = "", uint? iconId = null, TooltipIdKind idKind = TooltipIdKind.None)
{
if (text == null)
{
if (_config.DebugTooltips)
Plugin.Logger.Information("[HSUI Tooltip DBG] ShowTooltip skipped: text is null");
return;
}
// remove styling tags from text
if (_previousRawText != text)
{
_currentTooltipText = text;
_previousRawText = text;
}
_currentIconId = iconId;
// calcualte title size
_titleSize = Vector2.Zero;
if (title != null)
{
_currentTooltipTitle = title;
if (_config.ShowSourceName && name.Length > 0)
{
_currentTooltipTitle += $" ({name})";
}
bool showId = id != 0 && (
(idKind == TooltipIdKind.Action && _config.ShowActionIDs) ||
(idKind == TooltipIdKind.Status && _config.ShowStatusIDs) ||
(idKind == TooltipIdKind.None && _config.ShowStatusIDs));
if (showId)
{
_currentTooltipTitle += " (ID: " + id + ")";
}
string fontId = _config.FontID ?? FontsConfig.DefaultSmallFontKey;
using (FontsManager.Instance.PushFont(fontId))
{
_titleSize = ImGui.CalcTextSize(_currentTooltipTitle, false, MaxWidth);
_titleSize.Y += Margin;
}
}
// calculate text size
string fontIdForText = _config.FontID ?? FontsConfig.DefaultSmallFontKey;
using (FontsManager.Instance.PushFont(fontIdForText))
{
_textSize = ImGui.CalcTextSize(_currentTooltipText, false, MaxWidth);
}
float contentWidth = Math.Max(_titleSize.X, _textSize.X);
float contentHeight = _titleSize.Y + _textSize.Y;
float iconSizeScaled = IconSize * ImGuiHelpers.GlobalScale * _config.TooltipScale;
float iconGap = _currentIconId.HasValue && _currentIconId.Value > 0 ? iconSizeScaled + Margin : 0;
_size = new Vector2(
contentWidth + iconGap + Margin * 2,
Math.Max(contentHeight, _currentIconId.HasValue && _currentIconId.Value > 0 ? iconSizeScaled : 0) + Margin * 2);
// position tooltip using the given coordinates as bottom center
position.X = position.X - _size.X / 2f;
position.Y = position.Y - _size.Y;
// correct tooltips off screen
_position = ConstrainPosition(position, _size);
_dataIsValid = true;
if (_config.DebugTooltips)
Plugin.Logger.Information($"[HSUI Tooltip DBG] ShowTooltip: title='{title}' textLen={text?.Length ?? 0} textPreview='{(text != null && text.Length > 80 ? text[..80] + "..." : text ?? "")}' pos=({_position.X:F0},{_position.Y:F0})");
}
private WorldObjectTooltipConfig? _worldTooltipConfig;
///
/// Shows a tooltip for the game object the mouse is hovering over in the 3D world.
/// Call this each frame before Draw().
///
public void ShowWorldObjectTooltip()
{
try
{
_worldTooltipConfig ??= ConfigurationManager.Instance.GetConfigObject();
if (_worldTooltipConfig == null || !_worldTooltipConfig.Enabled)
return;
}
catch (Exception ex)
{
// Config not ready yet or not found - silently skip
if (_config.DebugTooltips)
Plugin.Logger.Warning($"[HSUI Tooltip] WorldObjectTooltipConfig not available: {ex.Message}");
return;
}
IGameObject? mouseOverTarget = Plugin.TargetManager.MouseOverTarget;
if (mouseOverTarget == null)
return;
// Don't show tooltip for ourselves
if (mouseOverTarget.GameObjectId == Plugin.ObjectTable.LocalPlayer?.GameObjectId)
return;
string name = mouseOverTarget.Name.ToString();
if (string.IsNullOrEmpty(name))
name = "Unknown";
var sb = new StringBuilder();
// Free Company tag for players
if (_worldTooltipConfig.ShowTitle && mouseOverTarget is IPlayerCharacter player)
{
string fcTag = player.CompanyTag.ToString();
if (!string.IsNullOrEmpty(fcTag))
sb.AppendLine($"«{fcTag}»");
}
// Level
if (_worldTooltipConfig.ShowLevel && mouseOverTarget is ICharacter charLevel)
{
byte level = charLevel.Level;
if (level > 0)
sb.AppendLine($"Level {level}");
}
// Job (for players)
if (_worldTooltipConfig.ShowJob && mouseOverTarget is IPlayerCharacter playerJob)
{
uint jobId = playerJob.ClassJob.RowId;
if (jobId > 0 && JobsHelper.JobNames.TryGetValue(jobId, out string? jobName))
sb.AppendLine($"Job: {jobName}");
}
// HP
if (_worldTooltipConfig.ShowHP && mouseOverTarget is ICharacter charHp)
{
uint currentHp = charHp.CurrentHp;
uint maxHp = charHp.MaxHp;
if (maxHp > 0)
{
float pct = (float)currentHp / maxHp * 100f;
sb.AppendLine($"HP: {currentHp:N0} / {maxHp:N0} ({pct:F0}%)");
}
}
// Distance
if (_worldTooltipConfig.ShowDistance)
{
var localPlayer = Plugin.ObjectTable.LocalPlayer;
if (localPlayer != null)
{
float dist = Vector3.Distance(localPlayer.Position, mouseOverTarget.Position);
sb.AppendLine($"Distance: {dist:F1}y");
}
}
// Object ID (debug)
if (_worldTooltipConfig.ShowObjectId)
{
sb.AppendLine($"ID: {mouseOverTarget.GameObjectId}");
}
string body = sb.ToString().TrimEnd('\r', '\n');
if (string.IsNullOrEmpty(body))
body = "(No info)";
uint? iconId = _worldTooltipConfig.ShowIcon ? GetWorldObjectIconId(mouseOverTarget) : null;
if (_worldTooltipConfig.DetachFromCursor)
{
ShowTooltip(body, _worldTooltipConfig.Position, name, 0, "", iconId);
}
else
{
ShowTooltipOnCursor(body, name, 0, "", iconId);
}
}
private static unsafe uint? GetWorldObjectIconId(IGameObject gameObject)
{
if (gameObject == null || gameObject.Address == IntPtr.Zero)
return null;
var obj = (GameObject*)gameObject.Address;
if (obj == null)
return null;
uint iconId = obj->NamePlateIconId;
return iconId > 0 ? iconId : null;
}
public void RemoveTooltip()
{
_dataIsValid = false;
}
public void Draw()
{
if (!_dataIsValid)
return;
if (ConfigurationManager.Instance.ShowingModalWindow)
{
if (_config.DebugTooltips)
Plugin.Logger.Information("[HSUI Tooltip DBG] Draw SKIPPED: ShowingModalWindow=true");
return;
}
// bg
ImGuiWindowFlags windowFlags =
ImGuiWindowFlags.NoTitleBar
| ImGuiWindowFlags.NoMove
| ImGuiWindowFlags.NoDecoration
| ImGuiWindowFlags.NoBackground
| ImGuiWindowFlags.NoInputs
| ImGuiWindowFlags.NoSavedSettings
| ImGuiWindowFlags.NoFocusOnAppearing;
// imgui clips the left and right borders inside windows for some reason
// we make the window bigger so the actual drawable size is the expected one
var windowMargin = new Vector2(4, 0);
var windowPos = _position - windowMargin;
ImGui.SetNextWindowPos(windowPos, ImGuiCond.Always);
ImGui.SetNextWindowSize(_size + windowMargin * 2);
ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 0);
ImGui.Begin("DelvUI_tooltip", windowFlags);
var drawList = ImGui.GetWindowDrawList();
drawList.AddRectFilled(_position, _position + _size, _config.BackgroundColor.Base);
if (_config.BorderConfig.Enabled)
{
drawList.AddRect(_position, _position + _size, _config.BorderConfig.Color.Base, 0, ImDrawFlags.None, _config.BorderConfig.Thickness);
}
// no idea why i have to do this
float globalScaleCorrection = -15 + 15 * ImGuiHelpers.GlobalScale;
string fontId = _config.FontID ?? FontsConfig.DefaultSmallFontKey;
float iconSizeScaled = IconSize * ImGuiHelpers.GlobalScale * _config.TooltipScale;
float iconGap = (_currentIconId.HasValue && _currentIconId.Value > 0) ? iconSizeScaled + Margin : 0;
float wrapWidth = (_currentIconId.HasValue && _currentIconId.Value > 0)
? _size.X - iconGap - Margin * 2
: Math.Max(_titleSize.X, _textSize.X) + Margin;
float textBlockX = windowMargin.X + Margin + iconGap;
if (_currentIconId.HasValue && _currentIconId.Value > 0)
{
var iconPos = _position + new Vector2(Margin, Margin);
DrawHelper.DrawIcon(_currentIconId.Value, iconPos, new Vector2(iconSizeScaled, iconSizeScaled), false, drawList);
}
if (_currentTooltipTitle != null)
{
// title
Vector2 cursorPos;
using (FontsManager.Instance.PushFont(fontId))
{
cursorPos = new Vector2(textBlockX, Margin);
ImGui.SetCursorPos(cursorPos);
ImGui.PushTextWrapPos(cursorPos.X + wrapWidth + globalScaleCorrection);
ImGui.TextColored(_config.TitleColor.Vector, _currentTooltipTitle);
ImGui.PopTextWrapPos();
}
// text
using (FontsManager.Instance.PushFont(fontId))
{
cursorPos = new Vector2(textBlockX, Margin + _titleSize.Y);
ImGui.SetCursorPos(cursorPos);
ImGui.PushTextWrapPos(cursorPos.X + wrapWidth + globalScaleCorrection);
ImGui.TextColored(_config.TextColor.Vector, _currentTooltipText);
ImGui.PopTextWrapPos();
}
}
else
{
// text
using (FontsManager.Instance.PushFont(fontId))
{
var cursorPos = new Vector2(textBlockX, Margin);
var textWidth = _size.X - iconGap - Margin * 2;
ImGui.SetCursorPos(cursorPos);
ImGui.PushTextWrapPos(cursorPos.X + textWidth + globalScaleCorrection);
ImGui.TextColored(_config.TextColor.Vector, _currentTooltipText);
ImGui.PopTextWrapPos();
}
}
ImGui.End();
ImGui.PopStyleVar();
if (_config.DebugTooltips)
Plugin.Logger.Information($"[HSUI Tooltip DBG] Draw rendered tooltip at ({_position.X:F0},{_position.Y:F0})");
RemoveTooltip();
}
private Vector2 ConstrainPosition(Vector2 position, Vector2 size)
{
var screenSize = ImGui.GetWindowViewport().Size;
if (position.X < 0)
{
position.X = Margin;
}
else if (position.X + size.X > screenSize.X)
{
position.X = screenSize.X - size.X - Margin;
}
if (position.Y < 0)
{
position.Y = Margin;
}
return position;
}
}
[Section("Misc")]
[SubSection("Tooltips", 0)]
public class TooltipsConfig : PluginConfigObject
{
public new static TooltipsConfig DefaultConfig() { return new TooltipsConfig(); }
[Checkbox("Debug Tooltips")]
[Order(3)]
public bool DebugTooltips = false;
[Font]
[Order(4)]
public string? FontID = null;
[DragFloat("Tooltip Scale", min = 0.5f, max = 2f, help = "Scale the tooltip window size. Useful for large resolutions.")]
[Order(6)]
public float TooltipScale = 1f;
[Checkbox("Show Status Effects IDs")]
[Order(7)]
public bool ShowStatusIDs = false;
[Checkbox("Show Action ID", help = "Show action ID in hotbar and action tooltips.")]
[Order(8)]
public bool ShowActionIDs = false;
[Checkbox("Show Source Name")]
[Order(10)]
public bool ShowSourceName = false;
[ColorEdit4("Background Color")]
[Order(15)]
public PluginConfigColor BackgroundColor = new PluginConfigColor(new(19f / 255f, 19f / 255f, 19f / 255f, 190f / 250f));
[ColorEdit4("Title Color")]
[Order(20)]
public PluginConfigColor TitleColor = new PluginConfigColor(new(255f / 255f, 210f / 255f, 31f / 255f, 100f / 100f));
[ColorEdit4("Text Color")]
[Order(35)]
public PluginConfigColor TextColor = new PluginConfigColor(new(255f / 255f, 255f / 255f, 255f / 255f, 100f / 100f));
[NestedConfig("Border", 40, separator = false, spacing = true, collapsingHeader = false)]
public TooltipBorderConfig BorderConfig = new();
}
[Exportable(false)]
public class TooltipBorderConfig : PluginConfigObject
{
[ColorEdit4("Color")]
[Order(5)]
public PluginConfigColor Color = new(new Vector4(10f / 255f, 10f / 255f, 10f / 255f, 160f / 255f));
[DragInt("Thickness", min = 1, max = 100)]
[Order(10)]
public int Thickness = 4;
public TooltipBorderConfig()
{
}
public TooltipBorderConfig(PluginConfigColor color, int thickness)
{
Color = color;
Thickness = thickness;
}
}
[Section("Misc")]
[SubSection("World Tooltip", 0)]
public class WorldObjectTooltipConfig : PluginConfigObject
{
public new static WorldObjectTooltipConfig DefaultConfig() { return new WorldObjectTooltipConfig(); }
[Checkbox("Detach from Cursor", spacing = true)]
[Order(1)]
public bool DetachFromCursor = false;
[DragFloat2("Position (when detached)", min = -4000f, max = 4000f)]
[Order(2, collapseWith = nameof(DetachFromCursor))]
public Vector2 Position = new Vector2(100, 100);
[Checkbox("Show Icon (quest/NPC icons)", spacing = true)]
[Order(3)]
public bool ShowIcon = true;
[Checkbox("Shift+Click action in Actions & Traits → insert \"You should check out X\" in chat", help = "When chat is focused, inserts directly. Otherwise copies to clipboard.")]
[Order(4)]
public bool ActionChatLinkEnabled = true;
[Checkbox("Show Level", spacing = true)]
[Order(6)]
public bool ShowLevel = true;
[Checkbox("Show HP")]
[Order(10)]
public bool ShowHP = true;
[Checkbox("Show Job (Players)")]
[Order(15)]
public bool ShowJob = true;
[Checkbox("Show FC Tag (Players)")]
[Order(20)]
public bool ShowTitle = false;
[Checkbox("Show Distance")]
[Order(25)]
public bool ShowDistance = true;
[Checkbox("Show Object ID")]
[Order(30)]
public bool ShowObjectId = false;
}
}