Files
ConfigurableCombo/ConfigWindow.cs
T
jorg 7c174efa70 feat: Per-combo reset options and preserve-position toggle
- Per-combo reset when: Game timer (default), Never, On target change,
  After X seconds. Never = cycle sequence without time/target reset.
  After seconds uses configurable 0.5–300s window.

- Per-combo 'Preserve position when using other actions' (default on):
  when on, using another combo or any ability never resets this combo's
  step; it only resets per its own 'Reset when' setting. When off
  (original), using an action not in this combo resets it to step 1.

- Optional ITargetManager via constructor for On target change; plugin
  loads if service unavailable (On target change no-op when null).

Made-with: Cursor
2026-03-02 10:50:52 -06:00

451 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Game;
using Dalamud.Interface.Textures;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Lumina.Excel.Sheets;
using Action = Lumina.Excel.Sheets.Action;
namespace ConfigurableCombo;
public class ConfigWindow : Window
{
private const float ActionIconSize = 24f;
private uint _selectedJobId;
private JobListKind _jobListKind = JobListKind.Combat;
private string _newComboName = "New Combo";
private UserComboDefinition? _editingCombo;
private int _editingActionIndex = -1;
private string _editingActionIdInput = "";
private UserComboDefinition? _addFromJobTargetCombo;
private string _jobActionFilter = "";
private bool _windowWasOpen;
public ConfigWindow() : base("ConfigurableCombo")
{
Size = new Vector2(500, 450);
SizeCondition = ImGuiCond.FirstUseEver;
}
/// <summary>Set job dropdown to player's current job if it's in the current job-type list; otherwise keep selection if valid or Unspecified.</summary>
private void SyncJobDropdownToPlayerOrSelection()
{
var list = JobActionsHelper.GetJobList(_jobListKind);
var playerJobId = Service.ClientState.LocalPlayer?.ClassJob.RowId ?? 0u;
if (playerJobId != 0 && list.Any(x => x.JobId == playerJobId))
_selectedJobId = playerJobId;
else if (list.All(x => x.JobId != _selectedJobId))
_selectedJobId = 0;
}
public override void Draw()
{
if (!Service.Configuration.EnablePlugin)
{
ImGui.TextColored(new Vector4(1, 0.4f, 0.4f, 1), "Plugin is disabled. Enable below to use combo replacement.");
}
bool enablePlugin = Service.Configuration.EnablePlugin;
if (ImGui.Checkbox("Enable plugin", ref enablePlugin))
{
Service.Configuration.EnablePlugin = enablePlugin;
Service.Configuration.Save();
}
ImGui.SameLine();
ImGui.TextColored(new Vector4(0.6f, 0.7f, 0.8f, 1f), "Tip: Use /ccombo to open this window anytime.");
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
// When window just opened, select the player's current job so their combos are shown by default
if (IsOpen && !_windowWasOpen)
{
_windowWasOpen = true;
var player = Service.ClientState.LocalPlayer;
var currentJobId = player?.ClassJob.RowId ?? 0u;
if (currentJobId != 0)
{
var combatList = JobActionsHelper.GetJobList(JobListKind.Combat);
var craftList = JobActionsHelper.GetJobList(JobListKind.CraftingGathering);
if (combatList.Any(x => x.JobId == currentJobId))
{
_jobListKind = JobListKind.Combat;
_selectedJobId = currentJobId;
}
else if (craftList.Any(x => x.JobId == currentJobId))
{
_jobListKind = JobListKind.CraftingGathering;
_selectedJobId = currentJobId;
}
}
}
else if (!IsOpen)
{
_windowWasOpen = false;
}
// Job type toggle: Combat vs Crafting & Gathering
ImGui.Text("Job type:");
ImGui.SameLine();
bool showCombatJobs = _jobListKind == JobListKind.Combat;
if (ImGui.Checkbox("Combat (DoW/DoM)", ref showCombatJobs))
{
_jobListKind = showCombatJobs ? JobListKind.Combat : JobListKind.CraftingGathering;
SyncJobDropdownToPlayerOrSelection();
}
ImGui.SameLine();
bool showCraftGather = _jobListKind == JobListKind.CraftingGathering;
if (ImGui.Checkbox("Crafting & Gathering (DoH/DoL)", ref showCraftGather))
{
_jobListKind = showCraftGather ? JobListKind.CraftingGathering : JobListKind.Combat;
SyncJobDropdownToPlayerOrSelection();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Switch between battle jobs and crafters/gatherers. Combos are per job.");
ImGui.Text("Job:");
ImGui.SameLine();
var jobList = JobActionsHelper.GetJobList(_jobListKind);
var currentJobIndex = jobList.FindIndex(x => x.JobId == _selectedJobId);
if (currentJobIndex < 0) currentJobIndex = 0;
ImGui.SetNextItemWidth(180);
if (ImGui.BeginCombo("##job", jobList[currentJobIndex].Name))
{
for (int j = 0; j < jobList.Count; j++)
{
var (jobId, name) = jobList[j];
if (ImGui.Selectable(name, _selectedJobId == jobId))
{
_selectedJobId = jobId;
}
}
ImGui.EndCombo();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Combos below are for this job. New combos will be saved to this job.");
ImGui.Spacing();
ImGui.Text("Define action sequences. Put the first action on your hotbar; each press advances the sequence.");
ImGui.TextColored(new Vector4(0.7f, 0.8f, 1f, 1f),
"Sequence resets to step 1 after the last step or after any other action.");
ImGui.Spacing();
ImGui.TextWrapped("Example (NIN 1-2 with 3a/3b on separate keys): Add a combo with actions 2240, 2242 (Spinning Edge, Gust Slash). Put 2240 on one hotbar slot. Use that slot for 1->2; put Aeolian Edge (2255) and Armor Crush (3563) on other keybinds.");
ImGui.Spacing();
// Add new combo (for selected job)
ImGui.SetNextItemWidth(180);
ImGui.InputTextWithHint("##name", "Combo name", ref _newComboName, 64);
ImGui.SameLine();
if (ImGui.Button("Add combo"))
{
Service.Configuration.UserCombos.Add(new UserComboDefinition { Name = _newComboName, JobId = _selectedJobId });
Service.Configuration.Save();
_newComboName = "New Combo";
}
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
// List combos for selected job only
for (int i = 0; i < Service.Configuration.UserCombos.Count; i++)
{
var combo = Service.Configuration.UserCombos[i];
if (combo.JobId != _selectedJobId) continue;
DrawCombo(combo, i);
}
}
private void DrawCombo(UserComboDefinition combo, int index)
{
using (var id = ImRaii.PushId(index))
{
bool enabled = combo.Enabled;
if (ImGui.Checkbox("##enabled", ref enabled))
{
combo.Enabled = enabled;
Service.Configuration.Save();
}
ImGui.SameLine();
bool open = ImGui.TreeNodeEx($"{combo.Name} ({combo.ActionIds.Count} actions)", ImGuiTreeNodeFlags.DefaultOpen);
ImGui.SameLine(ImGui.GetContentRegionAvail().X - 80);
if (ImGui.SmallButton("Delete"))
{
Service.Configuration.UserCombos.RemoveAt(index);
Service.Configuration.Save();
return;
}
if (open)
{
ImGui.Indent();
// Name
string name = combo.Name;
if (ImGui.InputText("Name", ref name, 64))
{
combo.Name = name;
Service.Configuration.Save();
}
// Reset behavior (per-combo)
ImGui.Text("Reset combo when:");
ImGui.SameLine();
var resetMode = combo.ResetMode;
ImGui.SetNextItemWidth(180);
if (ImGui.BeginCombo("##resetMode", resetMode.ToString().Replace("_", " ")))
{
foreach (ComboResetMode mode in Enum.GetValues(typeof(ComboResetMode)))
{
if (ImGui.Selectable(mode.ToString().Replace("_", " "), resetMode == mode))
{
combo.ResetMode = mode;
Service.Configuration.Save();
}
}
ImGui.EndCombo();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Game timer: reset when game combo timer expires (~15s).\nNever: always run through sequence (cycle after last step).\nOn target change: reset when you change target.\nAfter seconds: reset after the time below with no combo use.");
if (combo.ResetMode == ComboResetMode.AfterSeconds)
{
ImGui.SameLine();
float sec = combo.ResetAfterSeconds;
ImGui.SetNextItemWidth(80);
if (ImGui.InputFloat("##resetSec", ref sec, 0.5f, 1f, "%.1fs", ImGuiInputTextFlags.CharsDecimal))
{
combo.ResetAfterSeconds = Math.Clamp(sec, 0.5f, 300f);
Service.Configuration.Save();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Seconds of no combo use before reset (0.5300).");
}
bool preservePosition = combo.PreservePositionWhenUsingOtherActions;
if (ImGui.Checkbox("Preserve position when using other actions", ref preservePosition))
{
combo.PreservePositionWhenUsingOtherActions = preservePosition;
Service.Configuration.Save();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("On (default): Using another combo or any ability never resets this combo's step; it only resets per \"Reset when\" above.\nOff (original): Using an action not in this combo resets this combo to step 1.");
// Action list
ImGui.Text("Action sequence (first = hotbar slot; order: 1, 2, 3a/3b on other keys...)");
ImGui.TextColored(new Vector4(0.9f, 0.85f, 0.4f, 1f), "First action must be the combo starter (e.g. Spinning Edge for NIN), or the icon/state won't advance.");
for (int a = 0; a < combo.ActionIds.Count; a++)
{
uint aid = combo.ActionIds[a];
string label = GetActionName(aid);
if (ImGui.SmallButton($"{label} (##{a})"))
{
_editingCombo = combo;
_editingActionIndex = a;
_editingActionIdInput = aid.ToString();
}
ImGui.SameLine();
if (ImGui.SmallButton($"X##del{a}"))
{
combo.ActionIds.RemoveAt(a);
Service.Configuration.Save();
break;
}
}
if (_editingCombo == combo && _editingActionIndex >= 0)
{
ImGui.SetNextItemWidth(120);
if (ImGui.InputText("Action ID", ref _editingActionIdInput, 16, ImGuiInputTextFlags.CharsDecimal))
{ }
ImGui.SameLine();
if (ImGui.Button("OK"))
{
if (uint.TryParse(_editingActionIdInput, out uint newId))
{
if (_editingActionIndex < combo.ActionIds.Count)
combo.ActionIds[_editingActionIndex] = newId;
else
combo.ActionIds.Add(newId);
Service.Configuration.Save();
}
_editingCombo = null;
_editingActionIndex = -1;
}
ImGui.SameLine();
if (ImGui.Button("Cancel"))
{
_editingCombo = null;
_editingActionIndex = -1;
}
}
ImGui.Spacing();
ImGui.Text("Add action:");
ImGui.SameLine();
if (ImGui.Button("Action browser..."))
{
_addFromJobTargetCombo = combo;
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Browse Job-Related, PvP, or Pet/Other actions. Click an action to add to sequence.");
ImGui.SameLine();
if (ImGui.Button("Add by ID"))
{
_editingCombo = combo;
_editingActionIndex = combo.ActionIds.Count;
_editingActionIdInput = "";
}
if (_addFromJobTargetCombo == combo)
{
DrawActionBrowser(combo);
}
ImGui.Unindent();
ImGui.TreePop();
}
}
}
private void DrawActionBrowser(UserComboDefinition targetCombo)
{
ImGui.Spacing();
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 5f))
using (ImRaii.Child("ActionBrowser", new Vector2(-1, 340), true))
{
ImGui.TextColored(new Vector4(0.7f, 0.9f, 1f, 1f), "Action browser — select a category, then click an action to add.");
ImGui.SameLine();
if (ImGui.SmallButton("Close"))
{
_addFromJobTargetCombo = null;
return;
}
ImGui.SetNextItemWidth(220f);
ImGui.InputTextWithHint("##filter", "Filter by name or ID...", ref _jobActionFilter, 64);
ImGui.Spacing();
var filter = _jobActionFilter.AsSpan().Trim().ToString();
bool hasFilter = !string.IsNullOrEmpty(filter);
if (ImGui.BeginTabBar("ActionBrowserTabs"))
{
if (ImGui.BeginTabItem("Job-Related"))
{
var actions = JobActionsHelper.GetActionsForJob(_selectedJobId);
DrawActionTable(actions, targetCombo, filter, hasFilter);
ImGui.EndTabItem();
}
if (ImGui.BeginTabItem("PvP Actions"))
{
var actions = JobActionsHelper.GetPvPActions(_selectedJobId);
DrawActionTable(actions, targetCombo, filter, hasFilter);
ImGui.EndTabItem();
}
if (ImGui.BeginTabItem("Role Actions"))
{
var actions = JobActionsHelper.GetRoleActions(_selectedJobId);
DrawActionTable(actions, targetCombo, filter, hasFilter);
ImGui.EndTabItem();
}
if (ImGui.BeginTabItem("Consumable Items"))
{
var actions = JobActionsHelper.GetConsumableActions();
DrawActionTable(actions, targetCombo, filter, hasFilter);
ImGui.EndTabItem();
}
ImGui.EndTabBar();
}
}
}
private void DrawActionTable(
List<(uint ActionId, string Name, uint IconId)> actions,
UserComboDefinition targetCombo,
string filter,
bool hasFilter)
{
if (actions.Count == 0)
{
ImGui.TextDisabled("No actions in this category.");
return;
}
if (ImGui.BeginTable("ActionBrowserTable", 3, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingStretchProp, new Vector2(-1, -1)))
{
ImGui.TableSetupColumn("Icon", ImGuiTableColumnFlags.WidthFixed, ActionIconSize + 8);
ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableSetupColumn("ID", ImGuiTableColumnFlags.WidthFixed, 48);
ImGui.TableHeadersRow();
foreach (var (actionId, name, iconId) in actions)
{
if (hasFilter)
{
if (name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0 &&
actionId.ToString().IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
continue;
}
ImGui.TableNextRow();
ImGui.TableNextColumn();
if (iconId > 0)
{
try
{
var tex = Service.TextureProvider.GetFromGameIcon(new GameIconLookup(iconId));
var wrap = tex.GetWrapOrEmpty();
if (wrap != null)
ImGui.Image(wrap.Handle, new Vector2(ActionIconSize, ActionIconSize));
}
catch { }
}
ImGui.TableNextColumn();
if (ImGui.Selectable($"{name}##{actionId}", false))
{
targetCombo.ActionIds.Add(actionId);
Service.Configuration.Save();
_addFromJobTargetCombo = null;
ImGui.EndTable();
return;
}
ImGui.TableNextColumn();
ImGui.TextUnformatted(actionId.ToString());
}
ImGui.EndTable();
}
}
private static string GetActionName(uint actionId)
{
if (actionId == 0) return "0";
try
{
var sheet = Service.DataManager.GetExcelSheet<Action>(ClientLanguage.English);
var row = sheet?.GetRow(actionId);
if (row != null)
{
var nameStr = (row as dynamic)?.Name?.ToString() ?? (row as dynamic)?.Singular?.ToString();
if (!string.IsNullOrEmpty(nameStr))
return $"{nameStr} ({actionId})";
}
// Fallback: may be an item ID (e.g. from Consumable Items tab)
var itemSheet = Service.DataManager.GetExcelSheet<Lumina.Excel.Sheets.Item>(ClientLanguage.English);
var itemRow = itemSheet?.GetRow(actionId);
if (itemRow != null)
{
var itemName = (itemRow as dynamic)?.Name?.ToString() ?? (itemRow as dynamic)?.Singular?.ToString();
if (!string.IsNullOrEmpty(itemName))
return $"{itemName} [Item] ({actionId})";
}
}
catch { }
return actionId.ToString();
}
}