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; } /// Set job dropdown to player's current job if it's in the current job-type list; otherwise keep selection if valid or Unspecified. 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(); } // 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(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(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(); } }