using Dalamud.Bindings.ImGui; using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using System.Numerics; namespace PlayerNotes.Windows; /// /// The main plugin window: a Friends tab and a Blacklist tab with identical layouts. /// Left side is a scrollable player list (with add-by-name and filter), the right side /// shows notes, tags, and actions for the selected player. Export/import move both pools /// plus tag definitions to/from a JSON file. /// public class MainWindow : Window { private const float ListColumnWidth = 280f; private readonly Plugin plugin; /// The currently selected entry, tagged with the pool it was picked in. private (Pool Pool, PlayerEntry Entry)? selection; // Notes editor state: the text is only re-seeded when the selection changes so that // ImGui's text cursor does not jump around while typing. private PlayerEntry? notesTarget; private string notesText = ""; private string filterText = ""; private string addNameText = ""; // Two-step confirmation for removal so notes are never deleted by accident. private bool confirmRemoveArmed; private PlayerEntry? confirmRemoveTarget; private readonly FileDialogManager fileDialog = new(); public MainWindow(Plugin plugin) : base("Player Notes") { this.plugin = plugin; Size = new Vector2(900, 560); } public override void Draw() { DrawToolbar(); using (ImRaii.TabBar("##pooltabs")) { if (ImGui.BeginTabItem("Friends")) { DrawPool(Pool.Friends); ImGui.EndTabItem(); } if (ImGui.BeginTabItem("Blacklist")) { DrawPool(Pool.Blacklist); ImGui.EndTabItem(); } } // Draws the open dialog (if any) and fires its callback when finished. fileDialog.Draw(); } private void DrawToolbar() { if (ImGui.Button("Export...")) { fileDialog.SaveFileDialog( "Export Player Notes", "*.json|JSON files", "playernotes", ".json", OnExportFinished); } ImGui.SameLine(); if (ImGui.Button("Import...")) { fileDialog.OpenFileDialog( "Import Player Notes", "*.json|JSON files", OnImportFinished); } ImGui.SameLine(); if (ImGui.Button("Reload from disk")) { plugin.Storage.Load(); ResetSelectionState(); } if (ImGui.IsItemHovered()) { using (ImRaii.Tooltip()) ImGui.Text($"Re-reads the data file after you edit it by hand:\n{plugin.Storage.FilePath}"); } ImGui.Separator(); } private void DrawPool(Pool pool) { using (ImRaii.Table("##poollayout", 2)) { ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, ListColumnWidth); // ---- Left column: the player list ---- ImGui.TableNextColumn(); DrawListPanel(pool); // ---- Right column: notes / tags for the selection ---- ImGui.TableNextColumn(); ImGui.SetCursorPosX(ImGui.GetCursorPos().X + 12f); // breathing room between list and editor DrawDetailPanel(pool); } } private void DrawListPanel(Pool pool) { var entries = plugin.Storage.Data.GetPool(pool); // Add a player by typing their name (no content ID available, so matching is by name). // "Name [World]" disambiguates same-name players across worlds; Enter submits too. ImGui.SetNextItemWidth(-90f); var submitted = ImGui.InputText("Add by name...", ref addNameText, 64) && ImGui.IsItemDeactivatedAfterEdit(); ImGui.SameLine(); if (submitted || ImGui.Button("Add")) { var (name, world) = ParseNameAndWorld(addNameText); if (plugin.AddToPool(pool, name, null, world)) addNameText = ""; // clear on success so the field visibly "did something" } ImGui.Separator(); ImGui.SetNextItemWidth(-1f); ImGui.InputTextWithHint("##filter", "Filter...", ref filterText, 128); using (ImRaii.Child("##entrylist", new Vector2(0, -ImGui.GetFrameHeightWithSpacing()))) { if (entries.Count == 0) { ImGui.TextDisabled("No players in this list yet.\nRight-click a player name in the chat window to add them."); return; } var filter = filterText.Trim(); for (var i = 0; i < entries.Count; i++) { var entry = entries[i]; if (filter.Length > 0 && !entry.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)) continue; var isSelected = selection is not null && ReferenceEquals(selection.Value.Entry, entry) && selection.Value.Pool == pool; // The world suffix disambiguates same-name players; the index-based ID keeps // duplicates selectable (name-only IDs would collide). var label = string.IsNullOrEmpty(entry.World) ? entry.Name : $"{entry.Name} [{entry.World}]"; if (ImGui.Selectable($"{label}###{pool}{i}", isSelected)) Select(pool, entry); if (ImGui.IsItemHovered() && !isSelected) { var tags = entry.Tags.Count > 0 ? string.Join(", ", entry.Tags) : "no tags"; using (ImRaii.Tooltip()) ImGui.Text($"Tags: {tags}"); } } } } private void DrawDetailPanel(Pool pool) { if (selection is not { } sel || sel.Pool != pool) { ImGui.TextDisabled("Select a player from the list to view or edit their notes."); return; } var entry = sel.Entry; ImGui.Text($"Name: {entry.Name}"); if (!string.IsNullOrEmpty(entry.World)) ImGui.TextDisabled($"World: {entry.World}"); if (entry.ContentId is { } contentId && contentId != 0) ImGui.TextDisabled($"Content ID: {contentId}"); ImGui.Separator(); // ---- Tags available for this pool (defined in PlayerNotes.json) ---- var availableTags = plugin.Storage.Data.Tags.ForPool(pool); if (availableTags.Count > 0) { // Measure on a fresh line, then lay the checkboxes out two per row to save space. var halfWidth = ImGui.GetContentRegionAvail().X / 2f; ImGui.Text("Tags:"); for (var i = 0; i < availableTags.Count; i++) { if (i > 0 && i % 2 == 1) ImGui.SameLine(halfWidth); var tag = availableTags[i]; var isOn = entry.Tags.Any(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase)); using (ImRaii.PushId(tag)) { if (ImGui.Checkbox(tag, ref isOn)) { if (isOn && !entry.Tags.Any(t => t.Equals(tag, StringComparison.OrdinalIgnoreCase))) entry.Tags.Add(tag); else if (!isOn) entry.Tags.RemoveAll(t => t.Equals(tag, StringComparison.OrdinalIgnoreCase)); plugin.Storage.Save(); } } } ImGui.Separator(); } // ---- Notes editor ---- ImGui.Text("Notes:"); if (!ReferenceEquals(notesTarget, entry)) { notesTarget = entry; notesText = entry.Notes; } // This binding has no Multiline input flag: a sized InputTextMultiline is the multiline box. // maxLength sizes the internal UTF-8 buffer in bytes; 0 makes it zero-length and swallows // all input, so give it generous room (4 bytes/char covers CJK text). var noteCapacity = Math.Max(8192, notesText.Length * 4 + 64); if (ImGui.InputTextMultiline("##noteedit", ref notesText, noteCapacity, new Vector2(-1f, 180f))) { entry.Notes = notesText; plugin.Storage.Save(); } // ---- Actions ---- var otherPoolName = pool == Pool.Friends ? "Blacklist" : "Friends"; if (ImGui.Button($"Move to {otherPoolName} list")) MoveEntry(pool, entry); ImGui.SameLine(); using (ImRaii.PushColor(ImGuiCol.Button, new Vector4(0.65f, 0.25f, 0.25f, 1f))) { if (ImGui.Button("Remove from list")) { if (confirmRemoveArmed && ReferenceEquals(confirmRemoveTarget, entry)) RemoveEntry(pool, entry); else { confirmRemoveArmed = true; confirmRemoveTarget = entry; } } } if (confirmRemoveArmed && ReferenceEquals(confirmRemoveTarget, entry)) ImGui.TextColored(0xFFFF7373u, "Click \"Remove from list\" again to confirm."); } /// Parses "Name" or "Name [World]" from the add-by-name field. private static (string Name, string World) ParseNameAndWorld(string input) { var s = input.Trim(); if (s.Length >= 2 && s[s.Length - 1] == ']') { var open = s.LastIndexOf('['); if (open > 0) { var name = s[..open].Trim(); var world = s[(open + 1)..(s.Length - 1)].Trim(); if (name.Length > 0) return (name, world); } } return (s, ""); } private void Select(Pool pool, PlayerEntry entry) { selection = (pool, entry); DisarmConfirm(); } /// Disarms the remove confirmation whenever the selection changes. private void DisarmConfirm() { confirmRemoveArmed = false; confirmRemoveTarget = null; } private void ResetSelectionState() { selection = null; notesTarget = null; filterText = ""; DisarmConfirm(); } private void MoveEntry(Pool pool, PlayerEntry entry) { var data = plugin.Storage.Data; var otherPool = pool == Pool.Friends ? Pool.Blacklist : Pool.Friends; data.GetPool(pool).Remove(entry); // Drop tags that do not exist in the destination pool's tag set. var validTags = data.Tags.ForPool(otherPool); entry.Tags.RemoveAll(t => !validTags.Any(v => v.Equals(t, StringComparison.OrdinalIgnoreCase))); data.GetPool(otherPool).Add(entry); plugin.Storage.Save(); selection = (otherPool, entry); DisarmConfirm(); } private void RemoveEntry(Pool pool, PlayerEntry entry) { plugin.Storage.Data.GetPool(pool).Remove(entry); plugin.Storage.Save(); if (selection is not null && ReferenceEquals(selection.Value.Entry, entry)) ResetSelectionState(); } // ---- Export / import ---- private void OnExportFinished(bool accepted, string path) { if (!accepted || string.IsNullOrEmpty(path)) return; try { ExportTo(path); } catch (Exception ex) { Plugin.Log.Error(ex, "Player notes export failed."); Plugin.ChatGui.PrintError($"[PlayerNotes] Failed: {ex.Message}"); } } private void OnImportFinished(bool accepted, string path) { if (!accepted || string.IsNullOrEmpty(path)) return; try { ImportFrom(path); } catch (Exception ex) { Plugin.Log.Error(ex, "Player notes import failed."); Plugin.ChatGui.PrintError($"[PlayerNotes] Failed: {ex.Message}"); } } private void ExportTo(string path) { File.WriteAllText(path, Storage.Serialize(plugin.Storage.Data)); var data = plugin.Storage.Data; Plugin.ChatGui.Print( $"[PlayerNotes] Exported {data.Friends.Count} friends and {data.Blacklist.Count} blacklist entries to \"{path}\"."); } private void ImportFrom(string path) { var imported = Storage.Parse(File.ReadAllText(path)); plugin.Storage.Replace(imported); ResetSelectionState(); Plugin.ChatGui.Print( $"[PlayerNotes] Imported {imported.Friends.Count} friends and {imported.Blacklist.Count} blacklist entries from \"{path}\"."); } }