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}\".");
}
}