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 { 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) { ShowTooltip(text, ImGui.GetMousePos(), title, id, name, iconId); } public void ShowTooltip(string text, Vector2 position, string? title = null, uint id = 0, string name = "", uint? iconId = null) { 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})"; } if (_config.ShowStatusIDs) { _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 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; } }