v1.0.8.23: Main Menu bar; Duty List transparent background

Made-with: Cursor
This commit is contained in:
2026-03-04 01:38:48 -05:00
parent 9f9d5e1781
commit 293f83dc36
15 changed files with 744 additions and 20 deletions
+5 -4
View File
@@ -334,7 +334,7 @@ namespace HSUI.Helpers
DrawGradientFilledRect(cursorPos, new Vector2(Math.Max(1, barSize.X * shield), h), color, drawList);
}
public static void DrawInWindow(string name, Vector2 pos, Vector2 size, bool needsInput, Action<ImDrawListPtr> drawAction)
public static void DrawInWindow(string name, Vector2 pos, Vector2 size, bool needsInput, Action<ImDrawListPtr> drawAction, bool allowInputInClipRect = false)
{
const ImGuiWindowFlags windowFlags = ImGuiWindowFlags.NoTitleBar |
ImGuiWindowFlags.NoScrollbar |
@@ -344,7 +344,7 @@ namespace HSUI.Helpers
bool inputs = InputsHelper.Instance?.IsProxyEnabled == true ? false : needsInput;
DrawInWindow(name, pos, size, inputs, false, windowFlags, drawAction);
DrawInWindow(name, pos, size, inputs, false, windowFlags, drawAction, allowInputInClipRect);
}
public static void DrawInWindow(
@@ -354,7 +354,8 @@ namespace HSUI.Helpers
bool needsInput,
bool needsWindow,
ImGuiWindowFlags windowFlags,
Action<ImDrawListPtr> drawAction)
Action<ImDrawListPtr> drawAction,
bool allowInputInClipRect = false)
{
if (!ClipRectsHelper.Instance.Enabled || ClipRectsHelper.Instance.Mode == WindowClippingMode.Performance)
@@ -408,7 +409,7 @@ namespace HSUI.Helpers
if (ClipRectsHelper.Instance.Mode == WindowClippingMode.Hide) { return; }
ImGuiWindowFlags flags = windowFlags;
if (needsInput && clipRect.Value.Contains(ImGui.GetMousePos()))
if (needsInput && !allowInputInClipRect && clipRect.Value.Contains(ImGui.GetMousePos()))
{
flags |= ImGuiWindowFlags.NoInputs;
}
+434
View File
@@ -0,0 +1,434 @@
using AtkValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType;
using FFXIVClientStructs.FFXIV.Client.System.Framework;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Lumina.Excel;
using Lumina.Excel.Sheets;
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace HSUI.Helpers
{
public readonly struct MainMenuEntry
{
public uint CommandId { get; }
public uint IconId { get; }
public string Name { get; }
public MainMenuEntry(uint commandId, uint iconId, string name)
{
CommandId = commandId;
IconId = iconId;
Name = name ?? "";
}
}
public static class MainMenuHelper
{
/// <summary>Names of the 7 top-level Main Menu categories. Clicking these opens the context menu, not a direct action.</summary>
private static readonly string[] MainMenuCategoryNames =
{
"Character", "Duty", "Logs", "Travel", "Party", "Social", "System"
};
/// <summary>Fixed icon IDs for categories (from sheet). Used for display; command row is still resolved by name.</summary>
private static readonly IReadOnlyDictionary<string, uint> FixedCategoryIconIds = new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
{
{ "Duty", 5 },
{ "Logs", 21 },
{ "Travel", 7 },
{ "Social", 20 }
};
/// <summary>Categories that must use the category header row (smallest RowId in MainCommandCategory) so the context menu matches the game bar.</summary>
private static readonly HashSet<string> UseCategoryHeaderForMenu = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Logs",
"Travel"
};
/// <summary>Submenu item names that uniquely identify the Logs category (from default game context menu).</summary>
private static readonly string[] LogsCategoryAnchorNames = { "Hunting Log", "Sightseeing Log", "Crafting Log", "Gathering Log", "Fishing Log", "Fish Guide", "Orchestrion List", "Challenge Log" };
/// <summary>Submenu item names that uniquely identify the Travel category (from default game context menu).</summary>
private static readonly string[] TravelCategoryAnchorNames = { "Aether Currents", "Mount Speed", "Shared FATE", "Map", "Teleport", "Return" };
/// <summary>Get the 7 top-level Main Menu categories (Character, Duty, Logs, Travel, Party, Social, System) that are enabled. Clicking these opens the context menu.</summary>
public static List<MainMenuEntry> GetEnabledMainCommands()
{
var list = new List<MainMenuEntry>();
try
{
var sheet = Plugin.DataManager.GetExcelSheet<MainCommand>();
if (sheet == null) return list;
return GetEnabledMainCommandsUnsafe(list, sheet);
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI] MainMenuHelper.GetEnabledMainCommands: {ex.Message}");
}
return list;
}
/// <summary>True if the sheet row name matches the main menu category (exact or flexible).</summary>
private static bool MatchesCategoryName(string rowName, string categoryName)
{
if (string.IsNullOrEmpty(rowName)) return false;
string r = rowName.Trim();
if (string.Equals(r, categoryName, StringComparison.OrdinalIgnoreCase))
return true;
if (string.Equals(categoryName, "Duty", StringComparison.OrdinalIgnoreCase))
return r.IndexOf("Duty", StringComparison.OrdinalIgnoreCase) >= 0;
if (string.Equals(categoryName, "Travel", StringComparison.OrdinalIgnoreCase))
return r.IndexOf("Travel", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Teleport", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Return", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Aetheryte", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Port", StringComparison.OrdinalIgnoreCase) >= 0;
if (string.Equals(categoryName, "Party", StringComparison.OrdinalIgnoreCase))
return r.IndexOf("Party", StringComparison.OrdinalIgnoreCase) >= 0;
if (string.Equals(categoryName, "Logs", StringComparison.OrdinalIgnoreCase))
return r.IndexOf("Log", StringComparison.OrdinalIgnoreCase) >= 0
|| string.Equals(r, "Journal", StringComparison.OrdinalIgnoreCase)
|| r.IndexOf("Quest", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Completion", StringComparison.OrdinalIgnoreCase) >= 0;
if (string.Equals(categoryName, "Social", StringComparison.OrdinalIgnoreCase))
return r.IndexOf("Social", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Friend", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Contact", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Linkshell", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Free Company", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Fellowship", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Group", StringComparison.OrdinalIgnoreCase) >= 0;
if (string.Equals(categoryName, "System", StringComparison.OrdinalIgnoreCase))
return r.IndexOf("System", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Config", StringComparison.OrdinalIgnoreCase) >= 0;
return false;
}
/// <summary>Get the category header row ID (smallest RowId in the same MainCommandCategory). For Logs/Travel we identify the category by submenu item names from the default game menu.</summary>
private static unsafe uint GetCategoryHeaderCommandId(string categoryName, Lumina.Excel.ExcelSheet<MainCommand> sheet, AgentHUD* agentHud)
{
uint categoryGroupId = 0;
string[]? anchorNames = null;
if (string.Equals(categoryName, "Logs", StringComparison.OrdinalIgnoreCase))
anchorNames = LogsCategoryAnchorNames;
else if (string.Equals(categoryName, "Travel", StringComparison.OrdinalIgnoreCase))
anchorNames = TravelCategoryAnchorNames;
if (anchorNames != null)
{
// Find category by a row that matches one of the known submenu names (e.g. "Hunting Log", "Aether Currents").
foreach (var row in sheet)
{
uint id = row.RowId;
if (id == 0) continue;
if (!agentHud->IsMainCommandEnabled(id)) continue;
string name = "";
try { name = row.Name.ToString(); }
catch { }
name = name?.Trim() ?? "";
foreach (string anchor in anchorNames)
{
if (name.IndexOf(anchor, StringComparison.OrdinalIgnoreCase) >= 0)
{
try { categoryGroupId = row.MainCommandCategory.RowId; break; }
catch { }
break;
}
}
if (categoryGroupId != 0) break;
}
}
if (categoryGroupId == 0)
{
// Fallback: exact "Logs"/"Travel" or flexible category name match
bool exactOnly = string.Equals(categoryName, "Logs", StringComparison.OrdinalIgnoreCase)
|| string.Equals(categoryName, "Travel", StringComparison.OrdinalIgnoreCase);
foreach (var row in sheet)
{
uint id = row.RowId;
if (id == 0) continue;
string name = "";
try { name = row.Name.ToString(); }
catch { }
if (exactOnly && !string.Equals(name.Trim(), categoryName, StringComparison.OrdinalIgnoreCase))
continue;
if (!exactOnly && !MatchesCategoryName(name, categoryName))
continue;
if (!agentHud->IsMainCommandEnabled(id))
continue;
try { categoryGroupId = row.MainCommandCategory.RowId; break; }
catch { }
}
}
if (categoryGroupId == 0)
{
foreach (var row in sheet)
{
uint id = row.RowId;
if (id == 0) continue;
string name = "";
try { name = row.Name.ToString(); }
catch { }
if (!MatchesCategoryName(name, categoryName) || !agentHud->IsMainCommandEnabled(id))
continue;
try { categoryGroupId = row.MainCommandCategory.RowId; break; }
catch { }
}
}
if (categoryGroupId == 0) return 0;
uint headerId = 0;
foreach (var row in sheet)
{
if (row.RowId == 0) continue;
try { if (row.MainCommandCategory.RowId != categoryGroupId) continue; }
catch { continue; }
if (!agentHud->IsMainCommandEnabled(row.RowId)) continue;
if (headerId == 0 || row.RowId < headerId)
headerId = row.RowId;
}
return headerId;
}
private static unsafe List<MainMenuEntry> GetEnabledMainCommandsUnsafe(List<MainMenuEntry> list, Lumina.Excel.ExcelSheet<MainCommand> sheet)
{
var agentHud = AgentHUD.Instance();
if (agentHud == null) return list;
foreach (string categoryName in MainMenuCategoryNames)
{
// Logs and Travel: use category header row so context menu matches game bar.
if (UseCategoryHeaderForMenu.Contains(categoryName))
{
uint headerId = GetCategoryHeaderCommandId(categoryName, sheet, agentHud);
if (headerId != 0)
{
uint iconId = 0;
if (FixedCategoryIconIds.TryGetValue(categoryName, out uint fixedIconId))
iconId = fixedIconId;
if (iconId == 0)
iconId = GetFallbackIconIdForCategory(categoryName);
list.Add(new MainMenuEntry(headerId, iconId, categoryName));
continue;
}
}
foreach (var row in sheet)
{
uint id = row.RowId;
if (id == 0) continue;
string name = "";
try { name = row.Name.ToString(); }
catch { }
if (!MatchesCategoryName(name, categoryName))
continue;
if (!agentHud->IsMainCommandEnabled(id))
continue;
// Icon: use fixed icon ID for Duty/Logs/Travel/Social (from sheet); others use sheet → game → fallback.
uint iconId = 0;
if (FixedCategoryIconIds.TryGetValue(categoryName, out uint fixedIconId))
iconId = fixedIconId;
if (iconId == 0)
{
try { iconId = (uint)row.Icon; }
catch { }
}
if (iconId == 0)
iconId = GetIconIdForMainCommand(id);
if (iconId == 0)
iconId = GetFallbackIconIdForCategory(categoryName);
list.Add(new MainMenuEntry(id, iconId, categoryName));
break;
}
}
return list;
}
/// <summary>Resolve icon for a Main Command. Uses RaptureHotbarModule when sheet row.Icon is 0.</summary>
public static unsafe uint GetIconIdForMainCommand(uint commandId)
{
try
{
var module = RaptureHotbarModule.Instance();
if (module != null && module->ModuleReady)
{
var bar = module->StandardHotbars[0];
var slot = bar.GetHotbarSlot(0);
if (slot != null)
{
int gameIcon = slot->GetIconIdForSlot(RaptureHotbarModule.HotbarSlotType.MainCommand, commandId);
if (gameIcon > 0) return (uint)gameIcon;
}
}
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI] MainMenuHelper.GetIconIdForMainCommand({commandId}): {ex.Message}");
}
return 0;
}
/// <summary>Fallback icon when game and sheet both return 0. Duty = 61002 (exclamation) to match default bar.</summary>
private static uint GetFallbackIconIdForCategory(string categoryName)
{
return (categoryName?.ToLowerInvariant()) switch
{
"character" => 61001,
"duty" => 61002,
"logs" => 61003,
"travel" => 61004,
"party" => 61005,
"social" => 61006,
"system" => 61007,
_ => 0
};
}
/// <summary>Execute a main command by ID (e.g. Character, Social, Collection).</summary>
public static unsafe void ExecuteMainCommand(uint commandId)
{
try
{
var uiModule = Framework.Instance()->GetUIModule();
if (uiModule == null) return;
uiModule->ExecuteMainCommand(commandId);
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI] MainMenuHelper.ExecuteMainCommand({commandId}): {ex.Message}");
}
}
/// <summary>Open the context submenu for a category, or execute directly if no submenu. When sub-commands exist, the HUD shows a custom context menu; otherwise executes the command.</summary>
public static unsafe void OpenSubmenuOrExecute(uint categoryCommandId)
{
var subIds = GetSubCommandIds(categoryCommandId);
if (subIds != null && subIds.Count > 0)
return; // Caller should use GetSubCommandEntries and show custom menu; we don't call OpenSystemMenu (crashes)
ExecuteMainCommand(categoryCommandId);
}
/// <summary>Get sub-command entries (id + display name) for the category. Same options as the default main menu bar context menu. Returns empty if category has no sub-commands.</summary>
public static List<(uint CommandId, string Name)> GetSubCommandEntries(uint categoryCommandId)
{
var list = new List<(uint, string)>();
try
{
var subIds = GetSubCommandIds(categoryCommandId);
if (subIds == null || subIds.Count == 0) return list;
var sheet = Plugin.DataManager.GetExcelSheet<MainCommand>();
if (sheet == null) return list;
foreach (uint id in subIds)
{
string name = "";
if (sheet.TryGetRow(id, out var row))
{
try { name = row.Name.ToString(); }
catch { }
}
if (string.IsNullOrEmpty(name)) name = $"#{id}";
list.Add((id, name));
}
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI] MainMenuHelper.GetSubCommandEntries({categoryCommandId}): {ex.Message}");
}
return list;
}
/// <summary>Get MainCommand IDs that belong to the same category as the given command (for submenu).</summary>
private static unsafe List<uint> GetSubCommandIds(uint categoryCommandId)
{
var list = new List<uint>();
try
{
var sheet = Plugin.DataManager.GetExcelSheet<MainCommand>();
if (sheet == null) return list;
var agentHud = AgentHUD.Instance();
if (agentHud == null) return list;
if (!sheet.TryGetRow(categoryCommandId, out var categoryRow)) return list;
uint categoryGroupId;
try
{
categoryGroupId = categoryRow.MainCommandCategory.RowId;
}
catch { return list; }
foreach (var row in sheet)
{
if (row.RowId == 0) continue;
try
{
if (row.MainCommandCategory.RowId != categoryGroupId) continue;
}
catch { continue; }
if (!agentHud->IsMainCommandEnabled(row.RowId)) continue;
list.Add(row.RowId);
}
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI] MainMenuHelper.GetSubCommandIds({categoryCommandId}): {ex.Message}");
}
return list;
}
/// <summary>Open the game's system menu with the given MainCommand IDs (context submenu).</summary>
private static unsafe void OpenSystemMenuWithCommands(List<uint> commandIds)
{
if (commandIds == null || commandIds.Count == 0) return;
if (commandIds.Count > 17) return; // game limit
var agentHud = AgentHUD.Instance();
if (agentHud == null) return;
int count = commandIds.Count;
int allocSize = 5 + 17 + 18; // atkValueArgs layout: [4]=size, [5..21]=commands, [23..]=optional name strings
var ptr = (AtkValue*)Marshal.AllocHGlobal(allocSize * sizeof(AtkValue));
try
{
// Zero entire buffer so the game never reads garbage as string pointers (prevents crash in Utf8String.SetString)
for (int i = 0; i < allocSize; i++)
{
ptr[i].Type = AtkValueType.Null;
ptr[i].Int = 0;
}
ptr[4].ChangeType(AtkValueType.UInt);
ptr[4].UInt = (uint)count;
for (int i = 0; i < count; i++)
{
ptr[5 + i].ChangeType(AtkValueType.Int);
ptr[5 + i].Int = (int)commandIds[i];
}
agentHud->OpenSystemMenu(ptr, (uint)count);
}
finally
{
Marshal.FreeHGlobal(new IntPtr(ptr));
}
}
}
}
+30 -9
View File
@@ -88,8 +88,17 @@ namespace HSUI.Helpers
private bool _dataIsValid = false;
/// <summary>Set when an HUD element (e.g. Main Menu) has the mouse over it this frame. World object tooltip will not show.</summary>
private bool _mouseOverHudElement = false;
private const float IconSize = 24f;
/// <summary>Call when the mouse is over an interactive HUD element so world object tooltip is not shown.</summary>
public void NotifyMouseOverHudElement()
{
_mouseOverHudElement = true;
}
public void ShowTooltipOnCursor(string text, string? title = null, uint id = 0, string name = "", uint? iconId = null, TooltipIdKind idKind = TooltipIdKind.None, List<TooltipSegment>? formattedText = null)
{
ShowTooltip(text, ImGui.GetMousePos(), title, id, name, iconId, idKind, formattedText);
@@ -114,7 +123,13 @@ namespace HSUI.Helpers
_currentIconId = iconId;
_formattedSegments = formattedText;
// calcualte title size
// When showing a simple tooltip with no title (e.g. HUD), clear previous title/body so we don't mix with last frame's world tooltip
if (title == null)
{
_currentTooltipTitle = null;
}
// calculate title size
_titleSize = Vector2.Zero;
if (title != null)
{
@@ -199,19 +214,17 @@ namespace HSUI.Helpers
/// </summary>
public void ShowWorldObjectTooltip()
{
// Don't overwrite if an HUD element already set a tooltip this frame (e.g. Main Menu icon)
if (_dataIsValid)
return;
// Don't show world tooltip when mouse is over an interactive HUD element (e.g. Main Menu bar)
if (_mouseOverHudElement)
return;
try
{
_worldTooltipConfig ??= ConfigurationManager.Instance.GetConfigObject<WorldObjectTooltipConfig>();
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)
@@ -294,6 +307,13 @@ namespace HSUI.Helpers
{
ShowTooltipOnCursor(body, name, 0, "", iconId);
}
}
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}");
}
}
private static unsafe uint? GetWorldObjectIconId(IGameObject gameObject)
@@ -310,6 +330,7 @@ namespace HSUI.Helpers
public void RemoveTooltip()
{
_dataIsValid = false;
_mouseOverHudElement = false;
_formattedSegments = null;
}