diff --git a/Helpers/TooltipsHelper.cs b/Helpers/TooltipsHelper.cs index c5992e2..9838dec 100644 --- a/Helpers/TooltipsHelper.cs +++ b/Helpers/TooltipsHelper.cs @@ -8,11 +8,27 @@ using HSUI.Interface.GeneralElements; using Dalamud.Bindings.ImGui; using FFXIVClientStructs.FFXIV.Client.Game.Object; using System; +using System.Collections.Generic; +using System.Linq; using System.Numerics; using System.Text; +using System.Text.RegularExpressions; namespace HSUI.Helpers { + /// Colored segment for game-style tooltip formatting. + public readonly struct TooltipSegment + { + public readonly string Text; + public readonly Vector4 Color; + + public TooltipSegment(string text, Vector4 color) + { + Text = text ?? ""; + Color = color; + } + } + /// When showing an ID in the tooltip title, use Action for action IDs or Status for status effect IDs. public enum TooltipIdKind { @@ -54,7 +70,7 @@ namespace HSUI.Helpers } #endregion - private float MaxWidth => Math.Max(160, 340 * ImGuiHelpers.GlobalScale * _config.TooltipScale); + private float MaxWidth => Math.Max(160, _config.TooltipMaxWidth * ImGuiHelpers.GlobalScale * _config.TooltipScale); private float Margin => Math.Max(4, 5 * ImGuiHelpers.GlobalScale * _config.TooltipScale); private TooltipsConfig _config => ConfigurationManager.Instance.GetConfigObject(); @@ -65,6 +81,7 @@ namespace HSUI.Helpers private Vector2 _titleSize; private string? _previousRawText = null; private uint? _currentIconId = null; + private List? _formattedSegments = null; private Vector2 _position; private Vector2 _size; @@ -73,12 +90,12 @@ namespace HSUI.Helpers 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) + public void ShowTooltipOnCursor(string text, string? title = null, uint id = 0, string name = "", uint? iconId = null, TooltipIdKind idKind = TooltipIdKind.None, List? formattedText = null) { - ShowTooltip(text, ImGui.GetMousePos(), title, id, name, iconId, idKind); + ShowTooltip(text, ImGui.GetMousePos(), title, id, name, iconId, idKind, formattedText); } - public void ShowTooltip(string text, Vector2 position, string? title = null, uint id = 0, string name = "", uint? iconId = null, TooltipIdKind idKind = TooltipIdKind.None) + public void ShowTooltip(string text, Vector2 position, string? title = null, uint id = 0, string name = "", uint? iconId = null, TooltipIdKind idKind = TooltipIdKind.None, List? formattedText = null) { if (text == null) { @@ -95,6 +112,7 @@ namespace HSUI.Helpers } _currentIconId = iconId; + _formattedSegments = formattedText; // calcualte title size _titleSize = Vector2.Zero; @@ -124,20 +142,42 @@ namespace HSUI.Helpers } } - // calculate text size + // calculate text size (build combined text from segments for sizing) string fontIdForText = _config.FontID ?? FontsConfig.DefaultSmallFontKey; using (FontsManager.Instance.PushFont(fontIdForText)) { - _textSize = ImGui.CalcTextSize(_currentTooltipText, false, MaxWidth); + float lineHeight = ImGui.GetTextLineHeight(); + if (_formattedSegments != null && _formattedSegments.Count > 0) + { + var sb = new StringBuilder(); + int newlineCount = 0; + foreach (var seg in _formattedSegments) + { + sb.Append(seg.Text); + if (seg.Text == "\n") newlineCount++; + } + _textSize = ImGui.CalcTextSize(sb.ToString(), false, MaxWidth); + // Account for Dummy spacing between sections (each \n adds compactSpacing) + float compactSpacing = Math.Max(2, lineHeight * _config.TooltipLineSpacing); + _textSize.Y += newlineCount * compactSpacing; + } + else + { + _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 fontScale = Math.Max(0.7f, _config.TooltipFontScale); + float contentWidth = Math.Max(_titleSize.X, _textSize.X) * fontScale; + float contentHeight = (_titleSize.Y + _textSize.Y) * fontScale; + float iconSizeScaled = IconSize * ImGuiHelpers.GlobalScale * _config.TooltipScale * _config.TooltipIconScale; float iconGap = _currentIconId.HasValue && _currentIconId.Value > 0 ? iconSizeScaled + Margin : 0; + // Add buffer to prevent clipping (font/scale changes, rounding, padding) + float heightBuffer = _config.TooltipHeightBuffer; + float minContentHeight = contentHeight + heightBuffer; _size = new Vector2( contentWidth + iconGap + Margin * 2, - Math.Max(contentHeight, _currentIconId.HasValue && _currentIconId.Value > 0 ? iconSizeScaled : 0) + Margin * 2); + Math.Max(minContentHeight, _currentIconId.HasValue && _currentIconId.Value > 0 ? iconSizeScaled + Margin * 2 : 0) + Margin * 2); // position tooltip using the given coordinates as bottom center position.X = position.X - _size.X / 2f; @@ -270,6 +310,41 @@ namespace HSUI.Helpers public void RemoveTooltip() { _dataIsValid = false; + _formattedSegments = null; + } + + private void DrawTooltipBody() + { + if (_formattedSegments != null && _formattedSegments.Count > 0) + { + bool afterNewline = true; + float lineHeight = ImGui.GetTextLineHeight(); + float compactSpacing = Math.Max(2, lineHeight * _config.TooltipLineSpacing); + + foreach (var seg in _formattedSegments) + { + if (seg.Text == "\n") + { + ImGui.Dummy(new Vector2(0, compactSpacing)); + afterNewline = true; + } + else if (!string.IsNullOrEmpty(seg.Text)) + { + if (!afterNewline) + ImGui.SameLine(0, 0); + string text = afterNewline ? seg.Text.TrimStart() : seg.Text; + if (!string.IsNullOrEmpty(text)) + { + ImGui.TextColored(seg.Color, text); + afterNewline = false; + } + } + } + } + else + { + ImGui.TextColored(_config.TextColor.Vector, _currentTooltipText); + } } public void Draw() @@ -303,6 +378,7 @@ namespace HSUI.Helpers ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 0); ImGui.Begin("DelvUI_tooltip", windowFlags); + ImGui.SetWindowFontScale(_config.TooltipFontScale); var drawList = ImGui.GetWindowDrawList(); drawList.AddRectFilled(_position, _position + _size, _config.BackgroundColor.Base); @@ -316,7 +392,7 @@ namespace HSUI.Helpers float globalScaleCorrection = -15 + 15 * ImGuiHelpers.GlobalScale; string fontId = _config.FontID ?? FontsConfig.DefaultSmallFontKey; - float iconSizeScaled = IconSize * ImGuiHelpers.GlobalScale * _config.TooltipScale; + float iconSizeScaled = IconSize * ImGuiHelpers.GlobalScale * _config.TooltipScale * _config.TooltipIconScale; float iconGap = (_currentIconId.HasValue && _currentIconId.Value > 0) ? iconSizeScaled + Margin : 0; float wrapWidth = (_currentIconId.HasValue && _currentIconId.Value > 0) ? _size.X - iconGap - Margin * 2 @@ -342,19 +418,19 @@ namespace HSUI.Helpers ImGui.PopTextWrapPos(); } - // text + // text (formatted or plain) 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); + DrawTooltipBody(); ImGui.PopTextWrapPos(); } } else { - // text + // text (formatted or plain) using (FontsManager.Instance.PushFont(fontId)) { var cursorPos = new Vector2(textBlockX, Margin); @@ -362,11 +438,12 @@ namespace HSUI.Helpers ImGui.SetCursorPos(cursorPos); ImGui.PushTextWrapPos(cursorPos.X + textWidth + globalScaleCorrection); - ImGui.TextColored(_config.TextColor.Vector, _currentTooltipText); + DrawTooltipBody(); ImGui.PopTextWrapPos(); } } + ImGui.SetWindowFontScale(1f); ImGui.End(); ImGui.PopStyleVar(); @@ -375,6 +452,211 @@ namespace HSUI.Helpers RemoveTooltip(); } + /// + /// Parses action/ability description text into colored segments for game-style tooltips. + /// Adds newlines before section headers and colors: section labels (Additional Effect, Duration) green, + /// status names (e.g. Blood of the Dragon) yellow, secondary labels grey. + /// + public static List FormatActionTooltip(string description, Vector4 textColor, Vector4 sectionLabelColor, Vector4 statusNameColor) + { + var result = new List(); + if (string.IsNullOrEmpty(description)) + return result; + + // Pattern: section labels to color (Additional Effect, Duration, X Effect, Maximum Charges, Blood Gauge Cost, Combo, roles) + var sectionRegex = new Regex(@"\b(Additional Effect:|Duration:|Maximum Charges:|Blood Gauge Cost:|Combo Action:|Combo Potency:|Combo Bonus:|Tank:|Melee:|Ranged:|Caster:|Healer:|[A-Za-z][A-Za-z0-9\s]+ Effect:)", RegexOptions.None); + // Pattern: "Grants X X Effect:" - captures status name X + var grantsRegex = new Regex(@"Grants\s+(.+?)\s+\1\s+Effect:", RegexOptions.Singleline); + + int lastEnd = 0; + foreach (Match m in sectionRegex.Matches(description)) + { + string chunk = description.Substring(lastEnd, m.Index - lastEnd).TrimEnd(); + if (!string.IsNullOrEmpty(chunk)) + { + // Check for "Grants StatusName" in this chunk to color status name yellow + var grantsMatch = grantsRegex.Match(chunk); + if (grantsMatch.Success) + { + string statusName = grantsMatch.Groups[1].Value.Trim(); + string grantsPrefix = "Grants " + statusName; + int idx = chunk.IndexOf(grantsPrefix, StringComparison.Ordinal); + if (idx >= 0) + { + if (idx > 0) + result.Add(new TooltipSegment(chunk.Substring(0, idx), textColor)); + result.Add(new TooltipSegment("Grants ", textColor)); + result.Add(new TooltipSegment(statusName + " ", statusNameColor)); + int after = idx + grantsPrefix.Length; + if (after < chunk.Length) + result.Add(new TooltipSegment(chunk.Substring(after), textColor)); + } + else + result.Add(new TooltipSegment(chunk, textColor)); + } + else + result.Add(new TooltipSegment(chunk, textColor)); + } + + result.Add(new TooltipSegment("\n", textColor)); + result.Add(new TooltipSegment(m.Value + " ", sectionLabelColor)); + // Add newline so description wraps below label like stock game (not floating beside it) + if (m.Value.IndexOf("Effect:", StringComparison.OrdinalIgnoreCase) >= 0) + result.Add(new TooltipSegment("\n", textColor)); + lastEnd = m.Index + m.Length; + } + if (lastEnd < description.Length) + { + string tail = description.Substring(lastEnd).TrimStart(); + if (!string.IsNullOrEmpty(tail)) + { + string valuePart = ""; + string notePart = ""; + bool hasValue = false; + + int newlineIdx = tail.IndexOf('\n'); + if (newlineIdx >= 0) + { + // "2\nCannot be executed..." - value on first line, note on next + valuePart = tail.Substring(0, newlineIdx).TrimEnd(); + notePart = tail.Substring(newlineIdx + 1).TrimStart(); + hasValue = Regex.IsMatch(valuePart, @"^\d+[ms]?$"); + } + else + { + // "2 Cannot be executed while bound." - value + space + note (no newline) + var valueNoteMatch = Regex.Match(tail, @"^(\d+[ms]?)\s+([A-Z].+)$", RegexOptions.Singleline); + if (valueNoteMatch.Success) + { + valuePart = valueNoteMatch.Groups[1].Value; + notePart = valueNoteMatch.Groups[2].Value; + hasValue = true; + } + else if (Regex.IsMatch(tail, @"^\d+[ms]?$")) + { + // Plain value only: "50" (Blood Gauge Cost), "2" (charges) - stay on same line as label + valuePart = tail; + hasValue = true; + } + } + + // Add valuePart when present - section content (e.g. "Restore party HP..." for Healer, "2" for charges) + if (!string.IsNullOrEmpty(valuePart)) + { + result.Add(new TooltipSegment(valuePart, textColor)); + } + if (!string.IsNullOrEmpty(notePart)) + { + result.Add(new TooltipSegment("\n", textColor)); + result.Add(new TooltipSegment(notePart, textColor)); + } + else if (!hasValue) + { + // Standalone note: "(Duration can be extended...)", "Cannot be executed...", footnotes + result.Add(new TooltipSegment("\n", textColor)); + result.Add(new TooltipSegment(tail, textColor)); + } + } + } + + return result; + } + + /// + /// Builds formatted segments from a full action tooltip body (stats + description). + /// Used when UseGameStyleFormatting is enabled. + /// Stats are placed first, then exactly one newline, then description below. + /// + public static List? BuildFormattedActionTooltipBody(string body, TooltipsConfig config) + { + if (string.IsNullOrEmpty(body) || !config.UseGameStyleFormatting) + return null; + + var result = new List(); + var textColor = config.TextColor.Vector; + var sectionColor = config.SectionLabelColor.Vector; + var statusColor = config.StatusNameColor.Vector; + var secondaryColor = config.SecondaryLabelColor.Vector; + + var blocks = body.Split(new[] { "\n\n" }, StringSplitOptions.None); + int descStart = 0; + bool hasStats = false; + + // Stats block: may contain "Potency: X" and "Cast: ... | Recast: ..." on separate lines + for (int i = 0; i < blocks.Length; i++) + { + string block = blocks[i].Trim(); + var lines = block.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); + bool blockIsStats = false; + foreach (string line in lines) + { + string part = line.Trim(); + if (part.StartsWith("Potency:", StringComparison.OrdinalIgnoreCase)) + { + if (hasStats) result.Add(new TooltipSegment("\n", textColor)); + result.Add(new TooltipSegment("Potency:", secondaryColor)); + result.Add(new TooltipSegment(" " + part.Substring(8).TrimStart(), textColor)); + hasStats = true; + blockIsStats = true; + } + else if (part.Contains("Cast:") || part.Contains("Recast:")) + { + if (hasStats) result.Add(new TooltipSegment("\n", textColor)); + var statsSegs = FormatActionStats(part, textColor, secondaryColor); + result.AddRange(statsSegs); + hasStats = true; + blockIsStats = true; + } + } + if (!blockIsStats) + { + descStart = i; + break; + } + descStart = i + 1; + } + + // Description: exactly one newline after stats, then description with compact section spacing + string desc = string.Join("\n", blocks.Skip(descStart)).Trim(); + if (!string.IsNullOrEmpty(desc)) + { + if (hasStats) result.Add(new TooltipSegment("\n", textColor)); + var descSegs = FormatActionTooltip(desc, textColor, sectionColor, statusColor); + result.AddRange(descSegs); + } + + return result.Count > 0 ? result : null; + } + + /// + /// Formats action stats (Cast, Recast, Range, Radius) as colored segments. + /// + public static List FormatActionStats(string statsLine, Vector4 textColor, Vector4 secondaryLabelColor) + { + var result = new List(); + if (string.IsNullOrEmpty(statsLine)) + return result; + + // statsLine format: "Cast: Instant | Recast: 60.0s | Range: 15y | Radius: 15y" + var parts = statsLine.Split(new[] { " | " }, StringSplitOptions.None); + for (int i = 0; i < parts.Length; i++) + { + if (i > 0) + result.Add(new TooltipSegment(" | ", textColor)); + int colonIdx = parts[i].IndexOf(':'); + if (colonIdx >= 0) + { + result.Add(new TooltipSegment(parts[i].Substring(0, colonIdx + 1), secondaryLabelColor)); + result.Add(new TooltipSegment(parts[i].Substring(colonIdx + 1), textColor)); + } + else + { + result.Add(new TooltipSegment(parts[i], textColor)); + } + } + return result; + } + private Vector2 ConstrainPosition(Vector2 position, Vector2 size) { var screenSize = ImGui.GetWindowViewport().Size; @@ -412,21 +694,57 @@ namespace HSUI.Helpers public string? FontID = null; [DragFloat("Tooltip Scale", min = 0.5f, max = 2f, help = "Scale the tooltip window size. Useful for large resolutions.")] - [Order(6)] + [Order(5)] public float TooltipScale = 1f; - [Checkbox("Show Status Effects IDs")] + [DragFloat("Tooltip Max Width", min = 160f, max = 600f, help = "Maximum width of the tooltip box in pixels.")] + [Order(6)] + public float TooltipMaxWidth = 340f; + + [DragFloat("Line Spacing", min = 0.2f, max = 1.5f, help = "Spacing between lines and sections (1 = normal, lower = compact).")] [Order(7)] + public float TooltipLineSpacing = 0.6f; + + [DragFloat("Font Scale", min = 0.7f, max = 1.5f, help = "Scale factor for tooltip font size.")] + [Order(8)] + public float TooltipFontScale = 1f; + + [DragFloat("Icon Scale", min = 0.5f, max = 2f, help = "Scale factor for the ability/status icon in tooltips.")] + [Order(9)] + public float TooltipIconScale = 1f; + + [DragFloat("Height Buffer", min = 0f, max = 80f, help = "Extra space at bottom of tooltip to prevent clipping. Adjust if text is cut off.")] + [Order(10)] + public float TooltipHeightBuffer = 28f; + + [Checkbox("Show Status Effects IDs")] + [Order(11)] public bool ShowStatusIDs = false; [Checkbox("Show Action ID", help = "Show action ID in hotbar and action tooltips.")] - [Order(8)] + [Order(12)] public bool ShowActionIDs = false; [Checkbox("Show Source Name")] - [Order(10)] + [Order(13)] public bool ShowSourceName = false; + [Checkbox("Game-style formatting", help = "Color section labels (Additional Effect, Duration) green, status names yellow, and add new lines between sections like the base game tooltips.")] + [Order(14)] + public bool UseGameStyleFormatting = true; + + [ColorEdit4("Section Label Color", help = "Color for labels like 'Additional Effect:', 'Duration:'.")] + [Order(15)] + public PluginConfigColor SectionLabelColor = new PluginConfigColor(new(0f, 255f / 255f, 0f, 1f)); + + [ColorEdit4("Status Name Color", help = "Color for buff/status effect names like 'Blood of the Dragon'.")] + [Order(16)] + public PluginConfigColor StatusNameColor = new PluginConfigColor(new(255f / 255f, 255f / 255f, 0f, 1f)); + + [ColorEdit4("Secondary Label Color", help = "Color for Cast, Recast, Range, Radius labels.")] + [Order(17)] + public PluginConfigColor SecondaryLabelColor = new PluginConfigColor(new(204f / 255f, 204f / 255f, 204f / 255f, 1f)); + [ColorEdit4("Background Color")] [Order(15)] public PluginConfigColor BackgroundColor = new PluginConfigColor(new(19f / 255f, 19f / 255f, 19f / 255f, 190f / 250f)); diff --git a/Interface/GeneralElements/ActionBarsHud.cs b/Interface/GeneralElements/ActionBarsHud.cs index c56ebe7..cdcf837 100644 --- a/Interface/GeneralElements/ActionBarsHud.cs +++ b/Interface/GeneralElements/ActionBarsHud.cs @@ -370,7 +370,9 @@ namespace HSUI.Interface.GeneralElements if (!string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(text)) { string body = string.IsNullOrEmpty(text) ? title : text; - TooltipsHelper.Instance.ShowTooltipOnCursor(body, title, slot.ActionId, "", slot.IconId > 0 ? slot.IconId : null, Helpers.TooltipIdKind.Action); + var tooltipConfig = ConfigurationManager.Instance?.GetConfigObject(); + var formatted = tooltipConfig != null ? TooltipsHelper.BuildFormattedActionTooltipBody(body, tooltipConfig) : null; + TooltipsHelper.Instance.ShowTooltipOnCursor(body, title, slot.ActionId, "", slot.IconId > 0 ? slot.IconId : null, Helpers.TooltipIdKind.Action, formatted); if (IsTooltipDebugEnabled()) Plugin.Logger.Information($"[HSUI Tooltip DBG] ActionBar tooltip (main overlay): slot={i} title='{title}'"); } @@ -429,7 +431,9 @@ namespace HSUI.Interface.GeneralElements if (!string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(text)) { string body = string.IsNullOrEmpty(text) ? title : text; - TooltipsHelper.Instance.ShowTooltipOnCursor(body, title, slot.ActionId, "", slot.IconId > 0 ? slot.IconId : null, Helpers.TooltipIdKind.Action); + var tooltipConfig = ConfigurationManager.Instance?.GetConfigObject(); + var formatted = tooltipConfig != null ? TooltipsHelper.BuildFormattedActionTooltipBody(body, tooltipConfig) : null; + TooltipsHelper.Instance.ShowTooltipOnCursor(body, title, slot.ActionId, "", slot.IconId > 0 ? slot.IconId : null, Helpers.TooltipIdKind.Action, formatted); if (IsTooltipDebugEnabled()) Plugin.Logger.Information($"[HSUI Tooltip DBG] ActionBar tooltip (overlay): slot={i} title='{title}'"); }