diff --git a/Questionable.Model/EExpansionVersion.cs b/Questionable.Model/EExpansionVersion.cs index 038d43ec..b5b1714c 100644 --- a/Questionable.Model/EExpansionVersion.cs +++ b/Questionable.Model/EExpansionVersion.cs @@ -24,4 +24,13 @@ public static class ExpansionData { EExpansionVersion.Endwalker, "6.x - Endwalker" }, { EExpansionVersion.Dawntrail, "7.x - Dawntrail" } }; + + public static string ToFriendlyString(this EExpansionVersion expansionVersion) + { + return expansionVersion switch + { + EExpansionVersion.ARealmReborn => "A Realm Reborn", + _ => expansionVersion.ToString(), + }; + } } diff --git a/Questionable/QuestionablePlugin.cs b/Questionable/QuestionablePlugin.cs index 77440ad6..8517d135 100644 --- a/Questionable/QuestionablePlugin.cs +++ b/Questionable/QuestionablePlugin.cs @@ -24,6 +24,7 @@ using Questionable.Functions; using Questionable.Validation; using Questionable.Validation.Validators; using Questionable.Windows; +using Questionable.Windows.JournalComponents; using Questionable.Windows.QuestComponents; using Action = Questionable.Controller.Steps.Interactions.Action; @@ -211,6 +212,9 @@ public sealed class QuestionablePlugin : IDalamudPlugin serviceCollection.AddSingleton<QuickAccessButtonsComponent>(); serviceCollection.AddSingleton<RemainingTasksComponent>(); + serviceCollection.AddSingleton<QuestJournalComponent>(); + serviceCollection.AddSingleton<GatheringJournalComponent>(); + serviceCollection.AddSingleton<QuestWindow>(); serviceCollection.AddSingleton<ConfigWindow>(); serviceCollection.AddSingleton<DebugOverlay>(); diff --git a/Questionable/Windows/JournalComponents/GatheringJournalComponent.cs b/Questionable/Windows/JournalComponents/GatheringJournalComponent.cs new file mode 100644 index 00000000..699a651d --- /dev/null +++ b/Questionable/Windows/JournalComponents/GatheringJournalComponent.cs @@ -0,0 +1,380 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using Dalamud.Utility; +using Dalamud.Utility.Signatures; +using ImGuiNET; +using LLib.GameData; +using Lumina.Excel.GeneratedSheets; +using Questionable.Controller; +using Questionable.Model; +using Questionable.Model.Gathering; + +namespace Questionable.Windows.JournalComponents; + +internal sealed class GatheringJournalComponent +{ + private readonly IDalamudPluginInterface _pluginInterface; + private readonly UiUtils _uiUtils; + private readonly GatheringPointRegistry _gatheringPointRegistry; + private readonly Dictionary<int, string> _gatheringItems; + private readonly List<ExpansionPoints> _gatheringPoints; + private readonly List<ushort> _gatheredItems = []; + + private delegate byte GetIsGatheringItemGatheredDelegate(ushort item); + + [Signature("48 89 5C 24 ?? 57 48 83 EC 20 8B D9 8B F9")] + private GetIsGatheringItemGatheredDelegate _getIsGatheringItemGathered = null!; + + internal bool IsGatheringItemGathered(uint item) => _getIsGatheringItemGathered((ushort)item) != 0; + + public GatheringJournalComponent(IDataManager dataManager, IDalamudPluginInterface pluginInterface, UiUtils uiUtils, + IGameInteropProvider gameInteropProvider, GatheringPointRegistry gatheringPointRegistry) + { + _pluginInterface = pluginInterface; + _uiUtils = uiUtils; + _gatheringPointRegistry = gatheringPointRegistry; + var routeToGatheringPoint = dataManager.GetExcelSheet<GatheringLeveRoute>()! + .Where(x => x.UnkData0[0].GatheringPoint != 0) + .SelectMany(x => x.UnkData0 + .Where(y => y.GatheringPoint != 0) + .Select(y => new + { + RouteId = x.RowId, + GatheringPointId = y.GatheringPoint + })) + .GroupBy(x => x.RouteId) + .ToDictionary(x => x.Key, x => x.Select(y => y.GatheringPointId).ToList()); + var gatheringLeveSheet = dataManager.GetExcelSheet<GatheringLeve>()!; + var territoryTypeSheet = dataManager.GetExcelSheet<TerritoryType>()!; + var gatheringPointToLeve = dataManager.GetExcelSheet<Leve>()! + .Where(x => x.RowId > 0) + .Select(x => + { + uint startZonePlaceName = x.PlaceNameStartZone.Row; + startZonePlaceName = startZonePlaceName switch + { + 27 => 28, // limsa + 39 => 52, // gridania + 51 => 40, // uldah + 62 => 2300, // ishgard + _ => startZonePlaceName + }; + + var territoryType = territoryTypeSheet.FirstOrDefault(y => startZonePlaceName == y.PlaceName.Row) + ?? throw new InvalidOperationException($"Unable to use {startZonePlaceName}"); + return new + { + LeveId = x.RowId, + LeveName = x.Name.ToString(), + TerritoryType = (ushort)territoryType.RowId, + TerritoryName = territoryType.Name.ToString(), + GatheringLeve = gatheringLeveSheet.GetRow((uint)x.DataId), + }; + }) + .Where(x => x.GatheringLeve != null) + .Select(x => new + { + x.LeveId, + x.LeveName, + x.TerritoryType, + x.TerritoryName, + GatheringPoints = x.GatheringLeve!.Route + .Where(y => y.Row != 0) + .SelectMany(y => routeToGatheringPoint[y.Row]), + }) + .SelectMany(x => x.GatheringPoints.Select(y => new + { + x.LeveId, + x.LeveName, + x.TerritoryType, + x.TerritoryName, + GatheringPointId = y + })) + .GroupBy(x => x.GatheringPointId) + .ToDictionary(x => x.Key, x => x.First()); + + var itemSheet = dataManager.GetExcelSheet<Item>()!; + + _gatheringItems = dataManager.GetExcelSheet<GatheringItem>()! + .Where(x => x.RowId != 0 && x.GatheringItemLevel.Row != 0) + .Select(x => new + { + GatheringItemId = (int)x.RowId, + Name = itemSheet.GetRow((uint)x.Item)?.Name.ToString() + }) + .Where(x => !string.IsNullOrEmpty(x.Name)) + .ToDictionary(x => x.GatheringItemId, x => x.Name!); + + _gatheringPoints = dataManager.GetExcelSheet<GatheringPoint>()! + .Where(x => x.GatheringPointBase.Row != 0) + .DistinctBy(x => x.GatheringPointBase.Row) + .Select(x => new + { + GatheringPointId = x.RowId, + Point = new DefaultGatheringPoint(new GatheringPointId((ushort)x.GatheringPointBase.Row), + x.GatheringPointBase.Value!.GatheringType.Row switch + { + 0 or 1 => EClassJob.Miner, + 2 or 3 => EClassJob.Botanist, + _ => EClassJob.Fisher + }, + x.GatheringPointBase.Value.GatheringLevel, + x.GatheringPointBase.Value.Item.Where(y => y != 0).Select(y => (ushort)y).ToList(), + (EExpansionVersion?)x.TerritoryType.Value?.ExVersion.Row ?? (EExpansionVersion)byte.MaxValue, + (ushort)x.TerritoryType.Row, + x.TerritoryType.Value?.PlaceName.Value?.Name.ToString(), + $"{x.GatheringPointBase.Row} - {x.PlaceName.Value?.Name}") + }) + .Where(x => x.Point.ClassJob != EClassJob.Fisher) + .Select(x => + { + if (gatheringPointToLeve.TryGetValue((int)x.GatheringPointId, out var leve)) + { + // it's a leve + return x.Point with + { + Expansion = EExpansionVersion.Shadowbringers, + TerritoryType = leve.TerritoryType, + TerritoryName = leve.TerritoryName, + PlaceName = leve.LeveName, + }; + } + else if (x.Point.TerritoryType == 1 && _gatheringPointRegistry.TryGetGatheringPoint(x.Point.Id, out GatheringRoot? gatheringRoot)) + { + // for some reason the game doesn't know where this gathering location is + var territoryType = territoryTypeSheet.GetRow(gatheringRoot.Steps.Last().TerritoryId)!; + return x.Point with + { + Expansion = (EExpansionVersion)territoryType.ExVersion.Row, + TerritoryType = (ushort)territoryType.RowId, + TerritoryName = territoryType.PlaceName.Value?.Name.ToString(), + }; + } + else + return x.Point; + }) + .Where(x => x.Expansion != (EExpansionVersion)byte.MaxValue) + .Where(x => x.GatheringItemIds.Count > 0) + .GroupBy(x => x.Expansion) + .Select(x => new ExpansionPoints(x.Key, x + .GroupBy(y => new + { + y.TerritoryType, + TerritoryName = $"{(!string.IsNullOrEmpty(y.TerritoryName) ? y.TerritoryName : "???")} ({y.TerritoryType})" + }) + .Select(y => new TerritoryPoints(y.Key.TerritoryType, y.Key.TerritoryName, y.ToList())) + .Where(y => y.Points.Count > 0) + .ToList())) + .OrderBy(x => x.Expansion) + .ToList(); + + gameInteropProvider.InitializeFromAttributes(this); + } + + public void DrawGatheringItems() + { + using var tab = ImRaii.TabItem("Gathering Points"); + if (!tab) + return; + + using var table = ImRaii.Table("GatheringPoints", 3, ImGuiTableFlags.NoSavedSettings); + if (!table) + return; + + ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.NoHide); + ImGui.TableSetupColumn("Supported", ImGuiTableColumnFlags.WidthFixed, 100 * ImGui.GetIO().FontGlobalScale); + ImGui.TableSetupColumn("Collected", ImGuiTableColumnFlags.WidthFixed, 100 * ImGui.GetIO().FontGlobalScale); + ImGui.TableHeadersRow(); + + foreach (var expansion in _gatheringPoints) + DrawExpansion(expansion); + } + + private void DrawExpansion(ExpansionPoints expansion) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + + bool open = ImGui.TreeNodeEx(expansion.Expansion.ToFriendlyString(), ImGuiTreeNodeFlags.SpanFullWidth); + + ImGui.TableNextColumn(); + DrawCount(expansion.CompletedPoints, expansion.TotalPoints); + ImGui.TableNextColumn(); + DrawCount(expansion.CompletedItems, expansion.TotalItems); + + if (open) + { + foreach (var territory in expansion.PointsByTerritories) + DrawTerritory(territory); + + ImGui.TreePop(); + } + } + + private void DrawTerritory(TerritoryPoints territory) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + + bool open = ImGui.TreeNodeEx(territory.ToFriendlyString(), ImGuiTreeNodeFlags.SpanFullWidth); + + ImGui.TableNextColumn(); + DrawCount(territory.CompletedPoints, territory.TotalPoints); + ImGui.TableNextColumn(); + DrawCount(territory.CompletedItems, territory.TotalItems); + + if (open) + { + foreach (var point in territory.Points) + DrawPoint(point); + + ImGui.TreePop(); + } + } + + private void DrawPoint(DefaultGatheringPoint point) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + + bool open = ImGui.TreeNodeEx($"{point.PlaceName} ({point.ClassJob} Lv. {point.Level})", + ImGuiTreeNodeFlags.SpanFullWidth); + + ImGui.TableNextColumn(); + float spacing; + // ReSharper disable once UnusedVariable + using (var font = _pluginInterface.UiBuilder.IconFontFixedWidthHandle.Push()) + { + spacing = ImGui.GetColumnWidth() / 2 - ImGui.CalcTextSize(FontAwesomeIcon.Check.ToIconString()).X; + } + + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + spacing); + _uiUtils.ChecklistItem(string.Empty, point.IsComplete); + + ImGui.TableNextColumn(); + DrawCount(point.CompletedItems, point.TotalItems); + + if (open) + { + foreach (var item in point.GatheringItemIds) + DrawItem(item); + + ImGui.TreePop(); + } + } + + private void DrawItem(ushort item) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TreeNodeEx(_gatheringItems.GetValueOrDefault(item, "???"), + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.NoTreePushOnOpen | ImGuiTreeNodeFlags.SpanFullWidth); + + ImGui.TableNextColumn(); + + ImGui.TableNextColumn(); + float spacing; + // ReSharper disable once UnusedVariable + using (var font = _pluginInterface.UiBuilder.IconFontFixedWidthHandle.Push()) + { + spacing = ImGui.GetColumnWidth() / 2 - ImGui.CalcTextSize(FontAwesomeIcon.Check.ToIconString()).X; + } + + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + spacing); + if (item < 10_000) + _uiUtils.ChecklistItem(string.Empty, _gatheredItems.Contains(item)); + else + _uiUtils.ChecklistItem(string.Empty, ImGuiColors.DalamudGrey, FontAwesomeIcon.Minus); + } + + private static void DrawCount(int count, int total) + { + string len = 999.ToString(CultureInfo.CurrentCulture); + ImGui.PushFont(UiBuilder.MonoFont); + + string text = + $"{count.ToString(CultureInfo.CurrentCulture).PadLeft(len.Length)} / {total.ToString(CultureInfo.CurrentCulture).PadLeft(len.Length)}"; + if (count == total) + ImGui.TextColored(ImGuiColors.ParsedGreen, text); + else + ImGui.TextUnformatted(text); + + ImGui.PopFont(); + } + + internal void RefreshCounts() + { + _gatheredItems.Clear(); + foreach (ushort key in _gatheringItems.Keys) + { + if (IsGatheringItemGathered(key)) + _gatheredItems.Add(key); + } + + foreach (var expansion in _gatheringPoints) + { + foreach (var territory in expansion.PointsByTerritories) + { + foreach (var point in territory.Points) + { + point.TotalItems = point.GatheringItemIds.Count(x => x < 10_000); + point.CompletedItems = point.GatheringItemIds.Count(_gatheredItems.Contains); + point.IsComplete = _gatheringPointRegistry.TryGetGatheringPoint(point.Id, out _); + } + + territory.TotalItems = territory.Points.Sum(x => x.TotalItems); + territory.CompletedItems = territory.Points.Sum(x => x.CompletedItems); + territory.CompletedPoints = territory.Points.Count(x => x.IsComplete); + } + + expansion.TotalItems = expansion.PointsByTerritories.Sum(x => x.TotalItems); + expansion.CompletedItems = expansion.PointsByTerritories.Sum(x => x.CompletedItems); + expansion.TotalPoints = expansion.PointsByTerritories.Sum(x => x.TotalPoints); + expansion.CompletedPoints = expansion.PointsByTerritories.Sum(x => x.CompletedPoints); + } + } + + private sealed record ExpansionPoints(EExpansionVersion Expansion, List<TerritoryPoints> PointsByTerritories) + { + public int TotalItems { get; set; } + public int TotalPoints { get; set; } + public int CompletedItems { get; set; } + public int CompletedPoints { get; set; } + } + + private sealed record TerritoryPoints( + ushort TerritoryType, + string TerritoryName, + List<DefaultGatheringPoint> Points) + { + public int TotalItems { get; set; } + public int TotalPoints => Points.Count; + public int CompletedItems { get; set; } + public int CompletedPoints { get; set; } + public string ToFriendlyString() => + !string.IsNullOrEmpty(TerritoryName) ? TerritoryName : $"??? ({TerritoryType})"; + } + + private sealed record DefaultGatheringPoint( + GatheringPointId Id, + EClassJob ClassJob, + byte Level, + List<ushort> GatheringItemIds, + EExpansionVersion Expansion, + ushort TerritoryType, + string? TerritoryName, + string? PlaceName) + { + public int TotalItems { get; set; } + public int CompletedItems { get; set; } + public bool IsComplete { get; set; } + } +} diff --git a/Questionable/Windows/JournalComponents/QuestJournalComponent.cs b/Questionable/Windows/JournalComponents/QuestJournalComponent.cs new file mode 100644 index 00000000..94ed2ea1 --- /dev/null +++ b/Questionable/Windows/JournalComponents/QuestJournalComponent.cs @@ -0,0 +1,351 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using ImGuiNET; +using Questionable.Controller; +using Questionable.Data; +using Questionable.Functions; +using Questionable.Model; +using Questionable.Windows.QuestComponents; + +namespace Questionable.Windows.JournalComponents; + +internal sealed class QuestJournalComponent +{ + private readonly Dictionary<JournalData.Genre, (int Available, int Completed)> _genreCounts = new(); + private readonly Dictionary<JournalData.Category, (int Available, int Completed)> _categoryCounts = new(); + private readonly Dictionary<JournalData.Section, (int Available, int Completed)> _sectionCounts = new(); + + private readonly JournalData _journalData; + private readonly QuestRegistry _questRegistry; + private readonly QuestFunctions _questFunctions; + private readonly UiUtils _uiUtils; + private readonly QuestTooltipComponent _questTooltipComponent; + private readonly IDalamudPluginInterface _pluginInterface; + private readonly ICommandManager _commandManager; + + private List<FilteredSection> _filteredSections = []; + private string _searchText = string.Empty; + + public QuestJournalComponent(JournalData journalData, QuestRegistry questRegistry, QuestFunctions questFunctions, + UiUtils uiUtils, QuestTooltipComponent questTooltipComponent, IDalamudPluginInterface pluginInterface, + ICommandManager commandManager) + { + _journalData = journalData; + _questRegistry = questRegistry; + _questFunctions = questFunctions; + _uiUtils = uiUtils; + _questTooltipComponent = questTooltipComponent; + _pluginInterface = pluginInterface; + _commandManager = commandManager; + } + + public void DrawQuests() + { + using var tab = ImRaii.TabItem("Quests"); + if (!tab) + return; + + if (ImGui.CollapsingHeader("Explanation", ImGuiTreeNodeFlags.DefaultOpen)) + { + ImGui.Text("The list below contains all quests that appear in your journal."); + ImGui.BulletText("'Supported' lists quests that Questionable can do for you"); + ImGui.BulletText("'Completed' lists quests your current character has completed."); + ImGui.BulletText( + "Not all quests can be completed even if they're listed as available, e.g. starting city quest chains."); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + } + + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + if (ImGui.InputTextWithHint(string.Empty, "Search quests and categories", ref _searchText, 256)) + UpdateFilter(); + + if (_filteredSections.Count > 0) + { + using var table = ImRaii.Table("Quests", 3, ImGuiTableFlags.NoSavedSettings); + if (!table) + return; + + ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.NoHide); + ImGui.TableSetupColumn("Supported", ImGuiTableColumnFlags.WidthFixed, 120 * ImGui.GetIO().FontGlobalScale); + ImGui.TableSetupColumn("Completed", ImGuiTableColumnFlags.WidthFixed, 120 * ImGui.GetIO().FontGlobalScale); + ImGui.TableHeadersRow(); + + foreach (var section in _filteredSections) + DrawSection(section); + } + else + ImGui.Text("No quest or category matches your search text."); + } + + private void DrawSection(FilteredSection filter) + { + if (filter.Section.QuestCount == 0) + return; + + (int supported, int completed) = _sectionCounts.GetValueOrDefault(filter.Section); + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + + bool open = ImGui.TreeNodeEx(filter.Section.Name, ImGuiTreeNodeFlags.SpanFullWidth); + + ImGui.TableNextColumn(); + DrawCount(supported, filter.Section.QuestCount); + ImGui.TableNextColumn(); + DrawCount(completed, filter.Section.QuestCount); + + if (open) + { + foreach (var category in filter.Categories) + DrawCategory(category); + + ImGui.TreePop(); + } + } + + private void DrawCategory(FilteredCategory filter) + { + if (filter.Category.QuestCount == 0) + return; + + (int supported, int completed) = _categoryCounts.GetValueOrDefault(filter.Category); + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + + bool open = ImGui.TreeNodeEx(filter.Category.Name, ImGuiTreeNodeFlags.SpanFullWidth); + + ImGui.TableNextColumn(); + DrawCount(supported, filter.Category.QuestCount); + ImGui.TableNextColumn(); + DrawCount(completed, filter.Category.QuestCount); + + if (open) + { + foreach (var genre in filter.Genres) + DrawGenre(genre); + + ImGui.TreePop(); + } + } + + private void DrawGenre(FilteredGenre filter) + { + if (filter.Genre.QuestCount == 0) + return; + + (int supported, int completed) = _genreCounts.GetValueOrDefault(filter.Genre); + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + + bool open = ImGui.TreeNodeEx(filter.Genre.Name, ImGuiTreeNodeFlags.SpanFullWidth); + + ImGui.TableNextColumn(); + DrawCount(supported, filter.Genre.QuestCount); + ImGui.TableNextColumn(); + DrawCount(completed, filter.Genre.QuestCount); + + if (open) + { + foreach (var quest in filter.Quests) + DrawQuest(quest); + + ImGui.TreePop(); + } + } + + private void DrawQuest(IQuestInfo questInfo) + { + _questRegistry.TryGetQuest(questInfo.QuestId, out var quest); + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TreeNodeEx(questInfo.Name, + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.NoTreePushOnOpen | ImGuiTreeNodeFlags.SpanFullWidth); + + + if (questInfo is QuestInfo && ImGui.IsItemClicked() && + _commandManager.Commands.TryGetValue("/questinfo", out var commandInfo)) + { + _commandManager.DispatchCommand("/questinfo", questInfo.QuestId.ToString() ?? string.Empty, commandInfo); + } + + if (ImGui.IsItemHovered()) + _questTooltipComponent.Draw(questInfo); + + ImGui.TableNextColumn(); + float spacing; + // ReSharper disable once UnusedVariable + using (var font = _pluginInterface.UiBuilder.IconFontFixedWidthHandle.Push()) + { + spacing = ImGui.GetColumnWidth() / 2 - ImGui.CalcTextSize(FontAwesomeIcon.Check.ToIconString()).X; + } + + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + spacing); + _uiUtils.ChecklistItem(string.Empty, quest is { Root.Disabled: false }); + + ImGui.TableNextColumn(); + var (color, icon, text) = _uiUtils.GetQuestStyle(questInfo.QuestId); + _uiUtils.ChecklistItem(text, color, icon); + } + + private static void DrawCount(int count, int total) + { + string len = 9999.ToString(CultureInfo.CurrentCulture); + ImGui.PushFont(UiBuilder.MonoFont); + + string text = + $"{count.ToString(CultureInfo.CurrentCulture).PadLeft(len.Length)} / {total.ToString(CultureInfo.CurrentCulture).PadLeft(len.Length)}"; + if (count == total) + ImGui.TextColored(ImGuiColors.ParsedGreen, text); + else + ImGui.TextUnformatted(text); + + ImGui.PopFont(); + } + + public void UpdateFilter() + { + Predicate<string> match; + if (string.IsNullOrWhiteSpace(_searchText)) + match = _ => true; + else + match = x => x.Contains(_searchText, StringComparison.CurrentCultureIgnoreCase); + + _filteredSections = _journalData.Sections + .Select(section => FilterSection(section, match)) + .Where(x => x != null) + .Cast<FilteredSection>() + .ToList(); + } + + private static FilteredSection? FilterSection(JournalData.Section section, Predicate<string> match) + { + if (match(section.Name)) + { + return new FilteredSection(section, + section.Categories + .Select(x => FilterCategory(x, _ => true)) + .Cast<FilteredCategory>() + .ToList()); + } + else + { + List<FilteredCategory> filteredCategories = section.Categories + .Select(category => FilterCategory(category, match)) + .Where(x => x != null) + .Cast<FilteredCategory>() + .ToList(); + if (filteredCategories.Count > 0) + return new FilteredSection(section, filteredCategories); + + return null; + } + } + + private static FilteredCategory? FilterCategory(JournalData.Category category, Predicate<string> match) + { + if (match(category.Name)) + { + return new FilteredCategory(category, + category.Genres + .Select(x => FilterGenre(x, _ => true)) + .Cast<FilteredGenre>() + .ToList()); + } + else + { + List<FilteredGenre> filteredGenres = category.Genres + .Select(genre => FilterGenre(genre, match)) + .Where(x => x != null) + .Cast<FilteredGenre>() + .ToList(); + if (filteredGenres.Count > 0) + return new FilteredCategory(category, filteredGenres); + + return null; + } + } + + private static FilteredGenre? FilterGenre(JournalData.Genre genre, Predicate<string> match) + { + if (match(genre.Name)) + return new FilteredGenre(genre, genre.Quests); + else + { + List<IQuestInfo> filteredQuests = genre.Quests + .Where(x => match(x.Name)) + .ToList(); + if (filteredQuests.Count > 0) + return new FilteredGenre(genre, filteredQuests); + } + + return null; + } + + internal void RefreshCounts() + { + _genreCounts.Clear(); + _categoryCounts.Clear(); + _sectionCounts.Clear(); + + foreach (var genre in _journalData.Genres) + { + int available = genre.Quests.Count(x => + _questRegistry.TryGetQuest(x.QuestId, out var quest) && !quest.Root.Disabled); + int completed = genre.Quests.Count(x => _questFunctions.IsQuestComplete(x.QuestId)); + _genreCounts[genre] = (available, completed); + } + + foreach (var category in _journalData.Categories) + { + var counts = _genreCounts + .Where(x => category.Genres.Contains(x.Key)) + .Select(x => x.Value) + .ToList(); + int available = counts.Sum(x => x.Available); + int completed = counts.Sum(x => x.Completed); + _categoryCounts[category] = (available, completed); + } + + foreach (var section in _journalData.Sections) + { + var counts = _categoryCounts + .Where(x => section.Categories.Contains(x.Key)) + .Select(x => x.Value) + .ToList(); + int available = counts.Sum(x => x.Available); + int completed = counts.Sum(x => x.Completed); + _sectionCounts[section] = (available, completed); + } + } + + internal void ClearCounts() + { + foreach (var genreCount in _genreCounts.ToList()) + _genreCounts[genreCount.Key] = (genreCount.Value.Available, 0); + + foreach (var categoryCount in _categoryCounts.ToList()) + _categoryCounts[categoryCount.Key] = (categoryCount.Value.Available, 0); + + foreach (var sectionCount in _sectionCounts.ToList()) + _sectionCounts[sectionCount.Key] = (sectionCount.Value.Available, 0); + } + + private sealed record FilteredSection(JournalData.Section Section, List<FilteredCategory> Categories); + + private sealed record FilteredCategory(JournalData.Category Category, List<FilteredGenre> Genres); + + private sealed record FilteredGenre(JournalData.Genre Genre, List<IQuestInfo> Quests); +} diff --git a/Questionable/Windows/JournalProgressWindow.cs b/Questionable/Windows/JournalProgressWindow.cs index bf00be39..db5758f2 100644 --- a/Questionable/Windows/JournalProgressWindow.cs +++ b/Questionable/Windows/JournalProgressWindow.cs @@ -14,49 +14,33 @@ using Questionable.Controller; using Questionable.Data; using Questionable.Functions; using Questionable.Model; +using Questionable.Windows.JournalComponents; using Questionable.Windows.QuestComponents; namespace Questionable.Windows; internal sealed class JournalProgressWindow : LWindow, IDisposable { - private readonly JournalData _journalData; + private readonly QuestJournalComponent _questJournalComponent; + private readonly GatheringJournalComponent _gatheringJournalComponent; private readonly QuestRegistry _questRegistry; - private readonly QuestFunctions _questFunctions; - private readonly UiUtils _uiUtils; - private readonly QuestTooltipComponent _questTooltipComponent; - private readonly IDalamudPluginInterface _pluginInterface; private readonly IClientState _clientState; - private readonly ICommandManager _commandManager; - private readonly Dictionary<JournalData.Genre, (int Available, int Completed)> _genreCounts = new(); - private readonly Dictionary<JournalData.Category, (int Available, int Completed)> _categoryCounts = new(); - private readonly Dictionary<JournalData.Section, (int Available, int Completed)> _sectionCounts = new(); - - private List<FilteredSection> _filteredSections = []; - private string _searchText = string.Empty; - - public JournalProgressWindow(JournalData journalData, + public JournalProgressWindow( + QuestJournalComponent questJournalComponent, + GatheringJournalComponent gatheringJournalComponent, QuestRegistry questRegistry, - QuestFunctions questFunctions, - UiUtils uiUtils, - QuestTooltipComponent questTooltipComponent, - IDalamudPluginInterface pluginInterface, - IClientState clientState, - ICommandManager commandManager) + IClientState clientState) : base("Journal Progress###QuestionableJournalProgress") { - _journalData = journalData; + _questJournalComponent = questJournalComponent; + _gatheringJournalComponent = gatheringJournalComponent; _questRegistry = questRegistry; - _questFunctions = questFunctions; - _uiUtils = uiUtils; - _questTooltipComponent = questTooltipComponent; - _pluginInterface = pluginInterface; _clientState = clientState; - _commandManager = commandManager; - _clientState.Login += RefreshCounts; - _clientState.Logout -= ClearCounts; + _clientState.Login += _questJournalComponent.RefreshCounts; + _clientState.Login += _gatheringJournalComponent.RefreshCounts; + _clientState.Logout -= _questJournalComponent.ClearCounts; _questRegistry.Reloaded += OnQuestsReloaded; SizeConstraints = new WindowSizeConstraints @@ -65,318 +49,34 @@ internal sealed class JournalProgressWindow : LWindow, IDisposable }; } - private void OnQuestsReloaded(object? sender, EventArgs e) => RefreshCounts(); + private void OnQuestsReloaded(object? sender, EventArgs e) + { + _questJournalComponent.RefreshCounts(); + _gatheringJournalComponent.RefreshCounts(); + } public override void OnOpen() { - UpdateFilter(); - RefreshCounts(); + _questJournalComponent.UpdateFilter(); + _questJournalComponent.RefreshCounts(); + _gatheringJournalComponent.RefreshCounts(); } public override void Draw() { - if (ImGui.CollapsingHeader("Explanation", ImGuiTreeNodeFlags.DefaultOpen)) - { - ImGui.Text("The list below contains all quests that appear in your journal."); - ImGui.BulletText("'Supported' lists quests that Questionable can do for you"); - ImGui.BulletText("'Completed' lists quests your current character has completed."); - ImGui.BulletText( - "Not all quests can be completed even if they're listed as available, e.g. starting city quest chains."); - - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - } - - ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); - if (ImGui.InputTextWithHint(string.Empty, "Search quests and categories", ref _searchText, 256)) - UpdateFilter(); - - if (_filteredSections.Count > 0) - { - using var table = ImRaii.Table("Quests", 3, ImGuiTableFlags.NoSavedSettings); - if (!table) - return; - - ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.NoHide); - ImGui.TableSetupColumn("Supported", ImGuiTableColumnFlags.WidthFixed, 120 * ImGui.GetIO().FontGlobalScale); - ImGui.TableSetupColumn("Completed", ImGuiTableColumnFlags.WidthFixed, 120 * ImGui.GetIO().FontGlobalScale); - ImGui.TableHeadersRow(); - - foreach (var section in _filteredSections) - { - DrawSection(section); - } - } - else - ImGui.Text("No quest or category matches your search text."); - } - - private void DrawSection(FilteredSection filter) - { - if (filter.Section.QuestCount == 0) + using var tabBar = ImRaii.TabBar("Journal"); + if (!tabBar) return; - (int supported, int completed) = _sectionCounts.GetValueOrDefault(filter.Section); - - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - - bool open = ImGui.TreeNodeEx(filter.Section.Name, ImGuiTreeNodeFlags.SpanFullWidth); - - ImGui.TableNextColumn(); - DrawCount(supported, filter.Section.QuestCount); - ImGui.TableNextColumn(); - DrawCount(completed, filter.Section.QuestCount); - - if (open) - { - foreach (var category in filter.Categories) - DrawCategory(category); - - ImGui.TreePop(); - } - } - - private void DrawCategory(FilteredCategory filter) - { - if (filter.Category.QuestCount == 0) - return; - - (int supported, int completed) = _categoryCounts.GetValueOrDefault(filter.Category); - - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - - bool open = ImGui.TreeNodeEx(filter.Category.Name, ImGuiTreeNodeFlags.SpanFullWidth); - - ImGui.TableNextColumn(); - DrawCount(supported, filter.Category.QuestCount); - ImGui.TableNextColumn(); - DrawCount(completed, filter.Category.QuestCount); - - if (open) - { - foreach (var genre in filter.Genres) - DrawGenre(genre); - - ImGui.TreePop(); - } - } - - private void DrawGenre(FilteredGenre filter) - { - if (filter.Genre.QuestCount == 0) - return; - - (int supported, int completed) = _genreCounts.GetValueOrDefault(filter.Genre); - - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - - bool open = ImGui.TreeNodeEx(filter.Genre.Name, ImGuiTreeNodeFlags.SpanFullWidth); - - ImGui.TableNextColumn(); - DrawCount(supported, filter.Genre.QuestCount); - ImGui.TableNextColumn(); - DrawCount(completed, filter.Genre.QuestCount); - - if (open) - { - foreach (var quest in filter.Quests) - DrawQuest(quest); - - ImGui.TreePop(); - } - } - - private void DrawQuest(IQuestInfo questInfo) - { - _questRegistry.TryGetQuest(questInfo.QuestId, out var quest); - - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.TreeNodeEx(questInfo.Name, - ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.NoTreePushOnOpen | ImGuiTreeNodeFlags.SpanFullWidth); - - - if (questInfo is QuestInfo && ImGui.IsItemClicked() && _commandManager.Commands.TryGetValue("/questinfo", out var commandInfo)) - { - _commandManager.DispatchCommand("/questinfo", questInfo.QuestId.ToString() ?? string.Empty, commandInfo); - } - - if (ImGui.IsItemHovered()) - _questTooltipComponent.Draw(questInfo); - - ImGui.TableNextColumn(); - float spacing; - // ReSharper disable once UnusedVariable - using (var font = _pluginInterface.UiBuilder.IconFontFixedWidthHandle.Push()) - { - spacing = ImGui.GetColumnWidth() / 2 - ImGui.CalcTextSize(FontAwesomeIcon.Check.ToIconString()).X; - } - - ImGui.SetCursorPosX(ImGui.GetCursorPosX() + spacing); - _uiUtils.ChecklistItem(string.Empty, quest is { Root.Disabled: false }); - - ImGui.TableNextColumn(); - var (color, icon, text) = _uiUtils.GetQuestStyle(questInfo.QuestId); - _uiUtils.ChecklistItem(text, color, icon); - } - - private static void DrawCount(int count, int total) - { - string len = 9999.ToString(CultureInfo.CurrentCulture); - ImGui.PushFont(UiBuilder.MonoFont); - - string text = - $"{count.ToString(CultureInfo.CurrentCulture).PadLeft(len.Length)} / {total.ToString(CultureInfo.CurrentCulture).PadLeft(len.Length)}"; - if (count == total) - ImGui.TextColored(ImGuiColors.ParsedGreen, text); - else - ImGui.TextUnformatted(text); - - ImGui.PopFont(); - } - - private void UpdateFilter() - { - Predicate<string> match; - if (string.IsNullOrWhiteSpace(_searchText)) - match = _ => true; - else - match = x => x.Contains(_searchText, StringComparison.CurrentCultureIgnoreCase); - - _filteredSections = _journalData.Sections - .Select(section => FilterSection(section, match)) - .Where(x => x != null) - .Cast<FilteredSection>() - .ToList(); - } - - private static FilteredSection? FilterSection(JournalData.Section section, Predicate<string> match) - { - if (match(section.Name)) - { - return new FilteredSection(section, - section.Categories - .Select(x => FilterCategory(x, _ => true)) - .Cast<FilteredCategory>() - .ToList()); - } - else - { - List<FilteredCategory> filteredCategories = section.Categories - .Select(category => FilterCategory(category, match)) - .Where(x => x != null) - .Cast<FilteredCategory>() - .ToList(); - if (filteredCategories.Count > 0) - return new FilteredSection(section, filteredCategories); - - return null; - } - } - - private static FilteredCategory? FilterCategory(JournalData.Category category, Predicate<string> match) - { - if (match(category.Name)) - { - return new FilteredCategory(category, - category.Genres - .Select(x => FilterGenre(x, _ => true)) - .Cast<FilteredGenre>() - .ToList()); - } - else - { - List<FilteredGenre> filteredGenres = category.Genres - .Select(genre => FilterGenre(genre, match)) - .Where(x => x != null) - .Cast<FilteredGenre>() - .ToList(); - if (filteredGenres.Count > 0) - return new FilteredCategory(category, filteredGenres); - - return null; - } - } - - private static FilteredGenre? FilterGenre(JournalData.Genre genre, Predicate<string> match) - { - if (match(genre.Name)) - return new FilteredGenre(genre, genre.Quests); - else - { - List<IQuestInfo> filteredQuests = genre.Quests - .Where(x => match(x.Name)) - .ToList(); - if (filteredQuests.Count > 0) - return new FilteredGenre(genre, filteredQuests); - } - - return null; - } - - private void RefreshCounts() - { - _genreCounts.Clear(); - _categoryCounts.Clear(); - _sectionCounts.Clear(); - - foreach (var genre in _journalData.Genres) - { - int available = genre.Quests.Count(x => - _questRegistry.TryGetQuest(x.QuestId, out var quest) && !quest.Root.Disabled); - int completed = genre.Quests.Count(x => _questFunctions.IsQuestComplete(x.QuestId)); - _genreCounts[genre] = (available, completed); - } - - foreach (var category in _journalData.Categories) - { - var counts = _genreCounts - .Where(x => category.Genres.Contains(x.Key)) - .Select(x => x.Value) - .ToList(); - int available = counts.Sum(x => x.Available); - int completed = counts.Sum(x => x.Completed); - _categoryCounts[category] = (available, completed); - } - - foreach (var section in _journalData.Sections) - { - var counts = _categoryCounts - .Where(x => section.Categories.Contains(x.Key)) - .Select(x => x.Value) - .ToList(); - int available = counts.Sum(x => x.Available); - int completed = counts.Sum(x => x.Completed); - _sectionCounts[section] = (available, completed); - } - } - - private void ClearCounts() - { - foreach (var genreCount in _genreCounts.ToList()) - _genreCounts[genreCount.Key] = (genreCount.Value.Available, 0); - - foreach (var categoryCount in _categoryCounts.ToList()) - _categoryCounts[categoryCount.Key] = (categoryCount.Value.Available, 0); - - foreach (var sectionCount in _sectionCounts.ToList()) - _sectionCounts[sectionCount.Key] = (sectionCount.Value.Available, 0); + _questJournalComponent.DrawQuests(); + _gatheringJournalComponent.DrawGatheringItems(); } public void Dispose() { _questRegistry.Reloaded -= OnQuestsReloaded; - _clientState.Logout -= ClearCounts; - _clientState.Login -= RefreshCounts; + _clientState.Logout -= _questJournalComponent.ClearCounts; + _clientState.Login -= _gatheringJournalComponent.RefreshCounts; + _clientState.Login -= _questJournalComponent.RefreshCounts; } - - private sealed record FilteredSection(JournalData.Section Section, List<FilteredCategory> Categories); - - private sealed record FilteredCategory(JournalData.Category Category, List<FilteredGenre> Genres); - - private sealed record FilteredGenre(JournalData.Genre Genre, List<IQuestInfo> Quests); }