Add stop conditions
authorLiza Carvelli <liza@carvel.li>
Wed, 13 Aug 2025 10:08:47 +0000 (12:08 +0200)
committerLiza Carvelli <liza@carvel.li>
Wed, 13 Aug 2025 10:11:17 +0000 (12:11 +0200)
Questionable/Configuration.cs
Questionable/Controller/QuestController.cs
Questionable/QuestionablePlugin.cs
Questionable/Windows/ConfigComponents/StopConditionComponent.cs [new file with mode: 0644]
Questionable/Windows/ConfigWindow.cs
Questionable/Windows/PriorityWindow.cs
Questionable/Windows/QuestComponents/ActiveQuestComponent.cs
Questionable/Windows/Utils/QuestSelector.cs [new file with mode: 0644]

index 1aae915c556d78450f27cc74016c4ed67d003706..f3230589a63d3c4bf0526335e1698df7e601dc16 100644 (file)
@@ -1,10 +1,13 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
 using System.Diagnostics.CodeAnalysis;
 using Dalamud.Configuration;
 using Dalamud.Game.Text;
 using FFXIVClientStructs.FFXIV.Client.UI.Agent;
 using LLib.GameData;
 using LLib.ImGui;
+using Newtonsoft.Json;
+using Questionable.Model.Questing;
 
 namespace Questionable;
 
@@ -15,6 +18,7 @@ internal sealed class Configuration : IPluginConfiguration
     public int Version { get; set; } = 1;
     public int PluginSetupCompleteVersion { get; set; }
     public GeneralConfiguration General { get; } = new();
+    public StopConfiguration Stop { get; } = new();
     public DutyConfiguration Duties { get; } = new();
     public SinglePlayerDutyConfiguration SinglePlayerDuties { get; } = new();
     public NotificationConfiguration Notifications { get; } = new();
@@ -39,6 +43,14 @@ internal sealed class Configuration : IPluginConfiguration
         public bool ConfigureTextAdvance { get; set; } = true;
     }
 
+    internal sealed class StopConfiguration
+    {
+        public bool Enabled { get; set; }
+
+        [JsonProperty(ItemConverterType = typeof(ElementIdNConverter))]
+        public List<ElementId> QuestsToStopAfter { get; set; } = [];
+    }
+
     internal sealed class DutyConfiguration
     {
         public bool RunInstancedContentWithAutoDuty { get; set; }
@@ -85,4 +97,19 @@ internal sealed class Configuration : IPluginConfiguration
         WrathCombo,
         RotationSolverReborn,
     }
+
+    public sealed class ElementIdNConverter : JsonConverter<ElementId>
+    {
+        public override void WriteJson(JsonWriter writer, ElementId? value, JsonSerializer serializer)
+        {
+            writer.WriteValue(value?.ToString());
+        }
+
+        public override ElementId? ReadJson(JsonReader reader, Type objectType, ElementId? existingValue,
+            bool hasExistingValue, JsonSerializer serializer)
+        {
+            string? value = reader.Value?.ToString();
+            return value != null ? ElementId.FromString(value) : null;
+        }
+    }
 }
index 6387f3b6a1d538af8a9567a9004752372e1ef9b2..13cfba176b7ab0786642e64cacfc74b9b7fd5db0 100644 (file)
@@ -365,7 +365,18 @@ internal sealed class QuestController : MiniTaskController<QuestController>
                 }
                 else if (_startedQuest == null || _startedQuest.Quest.Id != currentQuestId)
                 {
-                    if (_questRegistry.TryGetQuest(currentQuestId, out var quest))
+                    if (_configuration.Stop.Enabled &&
+                        _startedQuest != null &&
+                        _configuration.Stop.QuestsToStopAfter.Contains(_startedQuest.Quest.Id) &&
+                        _questFunctions.IsQuestComplete(_startedQuest.Quest.Id))
+                    {
+                        var questId = _startedQuest.Quest.Id;
+                        _logger.LogInformation("Reached stopping point (quest: {QuestId})", questId);
+                        _chatGui.Print($"Completed quest '{_startedQuest.Quest.Info.Name}', which is configured as a stopping point.", CommandHandler.MessageTag, CommandHandler.TagColor);
+                        _startedQuest = null;
+                        Stop($"Stopping point [{questId}] reached");
+                    }
+                    else if (_questRegistry.TryGetQuest(currentQuestId, out var quest))
                     {
                         _logger.LogInformation("New quest: {QuestName}", quest.Info.Name);
                         _startedQuest = new QuestProgress(quest, currentSequence);
index 3b96efd115c565976d3a780b48b276d834196bf1..d130b51397cfa4add41d1b5b7ca758883a8af037 100644 (file)
@@ -30,6 +30,7 @@ using Questionable.Windows;
 using Questionable.Windows.ConfigComponents;
 using Questionable.Windows.JournalComponents;
 using Questionable.Windows.QuestComponents;
+using Questionable.Windows.Utils;
 using Action = Questionable.Controller.Steps.Interactions.Action;
 
 namespace Questionable;
@@ -279,6 +280,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
     private static void AddWindows(ServiceCollection serviceCollection)
     {
         serviceCollection.AddSingleton<UiUtils>();
+        serviceCollection.AddTransient<QuestSelector>();
 
         serviceCollection.AddSingleton<ActiveQuestComponent>();
         serviceCollection.AddSingleton<ARealmRebornComponent>();
@@ -307,6 +309,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceCollection.AddSingleton<PluginConfigComponent>();
         serviceCollection.AddSingleton<DutyConfigComponent>();
         serviceCollection.AddSingleton<SinglePlayerDutyConfigComponent>();
+        serviceCollection.AddSingleton<StopConditionComponent>();
         serviceCollection.AddSingleton<NotificationConfigComponent>();
         serviceCollection.AddSingleton<DebugConfigComponent>();
     }
diff --git a/Questionable/Windows/ConfigComponents/StopConditionComponent.cs b/Questionable/Windows/ConfigComponents/StopConditionComponent.cs
new file mode 100644 (file)
index 0000000..9b07bd3
--- /dev/null
@@ -0,0 +1,119 @@
+using System.Collections.Generic;
+using System.Linq;
+using Dalamud.Bindings.ImGui;
+using Dalamud.Interface;
+using Dalamud.Interface.Components;
+using Dalamud.Interface.Utility.Raii;
+using Dalamud.Plugin;
+using Questionable.Controller;
+using Questionable.Functions;
+using Questionable.Model;
+using Questionable.Model.Questing;
+using Questionable.Windows.QuestComponents;
+using Questionable.Windows.Utils;
+
+namespace Questionable.Windows.ConfigComponents;
+
+internal sealed class StopConditionComponent : ConfigComponent
+{
+    private readonly IDalamudPluginInterface _pluginInterface;
+    private readonly QuestSelector _questSelector;
+    private readonly QuestRegistry _questRegistry;
+    private readonly QuestTooltipComponent _questTooltipComponent;
+    private readonly UiUtils _uiUtils;
+
+    public StopConditionComponent(
+        IDalamudPluginInterface pluginInterface,
+        QuestSelector questSelector,
+        QuestFunctions questFunctions,
+        QuestRegistry questRegistry,
+        QuestTooltipComponent questTooltipComponent,
+        UiUtils uiUtils,
+        Configuration configuration)
+        : base(pluginInterface, configuration)
+    {
+        _pluginInterface = pluginInterface;
+        _questSelector = questSelector;
+        _questRegistry = questRegistry;
+        _questTooltipComponent = questTooltipComponent;
+        _uiUtils = uiUtils;
+
+        _questSelector.SuggestionPredicate = quest => configuration.Stop.QuestsToStopAfter.All(x => x != quest.Id);
+        _questSelector.DefaultPredicate = quest => quest.Info.IsMainScenarioQuest && questFunctions.IsQuestAccepted(quest.Id);
+        _questSelector.QuestSelected = quest =>
+        {
+            configuration.Stop.QuestsToStopAfter.Add(quest.Id);
+            Save();
+        };
+    }
+
+    public override void DrawTab()
+    {
+        using var tab = ImRaii.TabItem("Stop###StopConditionns");
+        if (!tab)
+            return;
+
+        bool enabled = Configuration.Stop.Enabled;
+        if (ImGui.Checkbox("Stop Questionable when completing any of the quests selected below", ref enabled))
+        {
+            Configuration.Stop.Enabled = enabled;
+            Save();
+        }
+
+        ImGui.Separator();
+
+        using (ImRaii.Disabled(!enabled))
+        {
+            ImGui.Text("Quests to stop after:");
+
+            _questSelector.DrawSelection();
+
+            List<ElementId> questsToStopAfter = Configuration.Stop.QuestsToStopAfter;
+            Quest? itemToRemove = null;
+            for (int i = 0; i < questsToStopAfter.Count; i++)
+            {
+                ElementId questId = questsToStopAfter[i];
+
+                if (!_questRegistry.TryGetQuest(questId, out Quest? quest))
+                    continue;
+
+                using (ImRaii.PushId($"Quest{questId}"))
+                {
+                    var style = _uiUtils.GetQuestStyle(questId);
+                    bool hovered;
+                    using (var _ = _pluginInterface.UiBuilder.IconFontFixedWidthHandle.Push())
+                    {
+                        ImGui.AlignTextToFramePadding();
+                        ImGui.TextColored(style.Color, style.Icon.ToIconString());
+                        hovered = ImGui.IsItemHovered();
+                    }
+
+                    ImGui.SameLine();
+                    ImGui.AlignTextToFramePadding();
+                    ImGui.Text(quest.Info.Name);
+                    hovered |= ImGui.IsItemHovered();
+
+                    if (hovered)
+                        _questTooltipComponent.Draw(quest.Info);
+
+                    using (ImRaii.PushFont(UiBuilder.IconFont))
+                    {
+                        ImGui.SameLine(ImGui.GetContentRegionAvail().X +
+                                       ImGui.GetStyle().WindowPadding.X -
+                                       ImGui.CalcTextSize(FontAwesomeIcon.Times.ToIconString()).X -
+                                       ImGui.GetStyle().FramePadding.X * 2);
+                    }
+
+                    if (ImGuiComponents.IconButton($"##Remove{i}", FontAwesomeIcon.Times))
+                        itemToRemove = quest;
+                }
+            }
+
+            if (itemToRemove != null)
+            {
+                Configuration.Stop.QuestsToStopAfter.Remove(itemToRemove.Id);
+                Save();
+            }
+        }
+    }
+}
index 994eae283296316855051e38787454231c6edf14..96aa1308eb321d89336fdb69688ee29f5b435796 100644 (file)
@@ -13,6 +13,7 @@ internal sealed class ConfigWindow : LWindow, IPersistableWindowConfig
     private readonly PluginConfigComponent _pluginConfigComponent;
     private readonly DutyConfigComponent _dutyConfigComponent;
     private readonly SinglePlayerDutyConfigComponent _singlePlayerDutyConfigComponent;
+    private readonly StopConditionComponent _stopConditionComponent;
     private readonly NotificationConfigComponent _notificationConfigComponent;
     private readonly DebugConfigComponent _debugConfigComponent;
     private readonly Configuration _configuration;
@@ -23,6 +24,7 @@ internal sealed class ConfigWindow : LWindow, IPersistableWindowConfig
         PluginConfigComponent pluginConfigComponent,
         DutyConfigComponent dutyConfigComponent,
         SinglePlayerDutyConfigComponent singlePlayerDutyConfigComponent,
+        StopConditionComponent stopConditionComponent,
         NotificationConfigComponent notificationConfigComponent,
         DebugConfigComponent debugConfigComponent,
         Configuration configuration)
@@ -33,6 +35,7 @@ internal sealed class ConfigWindow : LWindow, IPersistableWindowConfig
         _pluginConfigComponent = pluginConfigComponent;
         _dutyConfigComponent = dutyConfigComponent;
         _singlePlayerDutyConfigComponent = singlePlayerDutyConfigComponent;
+        _stopConditionComponent = stopConditionComponent;
         _notificationConfigComponent = notificationConfigComponent;
         _debugConfigComponent = debugConfigComponent;
         _configuration = configuration;
@@ -50,6 +53,7 @@ internal sealed class ConfigWindow : LWindow, IPersistableWindowConfig
         _pluginConfigComponent.DrawTab();
         _dutyConfigComponent.DrawTab();
         _singlePlayerDutyConfigComponent.DrawTab();
+        _stopConditionComponent.DrawTab();
         _notificationConfigComponent.DrawTab();
         _debugConfigComponent.DrawTab();
     }
index cd933d79167acf202cad90abfc61dd4b7c1658a3..6b14a6775dcea492234eeab7df8dfaf4ad1f861f 100644 (file)
@@ -7,6 +7,7 @@ using Dalamud.Bindings.ImGui;
 using Dalamud.Interface;
 using Dalamud.Interface.Colors;
 using Dalamud.Interface.Components;
+using Dalamud.Interface.Utility.Raii;
 using Dalamud.Plugin;
 using Dalamud.Plugin.Services;
 using LLib.ImGui;
@@ -15,6 +16,7 @@ using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
 using Questionable.Windows.QuestComponents;
+using Questionable.Windows.Utils;
 
 namespace Questionable.Windows;
 
@@ -24,29 +26,35 @@ internal sealed class PriorityWindow : LWindow
     private const char ClipboardSeparator = ';';
 
     private readonly QuestController _questController;
-    private readonly QuestRegistry _questRegistry;
     private readonly QuestFunctions _questFunctions;
+    private readonly QuestSelector _questSelector;
     private readonly QuestTooltipComponent _questTooltipComponent;
     private readonly UiUtils _uiUtils;
     private readonly IChatGui _chatGui;
     private readonly IDalamudPluginInterface _pluginInterface;
 
-    private string _searchString = string.Empty;
     private ElementId? _draggedItem;
 
-    public PriorityWindow(QuestController questController, QuestRegistry questRegistry, QuestFunctions questFunctions,
+    public PriorityWindow(QuestController questController, QuestFunctions questFunctions, QuestSelector questSelector,
         QuestTooltipComponent questTooltipComponent, UiUtils uiUtils, IChatGui chatGui,
         IDalamudPluginInterface pluginInterface)
         : base("Quest Priority###QuestionableQuestPriority")
     {
         _questController = questController;
-        _questRegistry = questRegistry;
         _questFunctions = questFunctions;
+        _questSelector = questSelector;
         _questTooltipComponent = questTooltipComponent;
         _uiUtils = uiUtils;
         _chatGui = chatGui;
         _pluginInterface = pluginInterface;
 
+        _questSelector.SuggestionPredicate = quest =>
+            !quest.Info.IsMainScenarioQuest &&
+            !questFunctions.IsQuestUnobtainable(quest.Id) &&
+            questController.ManualPriorityQuests.All(x => x.Id != quest.Id);
+        _questSelector.DefaultPredicate = quest => questFunctions.IsQuestAccepted(quest.Id);
+        _questSelector.QuestSelected = quest => _questController.ManualPriorityQuests.Add(quest);
+
         Size = new Vector2(400, 400);
         SizeCondition = ImGuiCond.Once;
         SizeConstraints = new WindowSizeConstraints
@@ -59,7 +67,7 @@ internal sealed class PriorityWindow : LWindow
     public override void DrawContent()
     {
         ImGui.Text("Quests to do first:");
-        DrawQuestFilter();
+        _questSelector.DrawSelection();
         DrawQuestList();
 
         List<ElementId> clipboardItems = ParseClipboardItems();
@@ -92,60 +100,6 @@ internal sealed class PriorityWindow : LWindow
             "If you don't have any active MSQ quest, it will always try to pick up the next quest in the MSQ first.");
     }
 
-    private void DrawQuestFilter()
-    {
-        ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X);
-        if (ImGui.BeginCombo($"##QuestSelection", "Add Quest...", ImGuiComboFlags.HeightLarge))
-        {
-            ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X);
-            bool addFirst = ImGui.InputTextWithHint("", "Filter...", ref _searchString, 256,
-                ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.EnterReturnsTrue);
-
-            IEnumerable<Quest> foundQuests;
-            if (!string.IsNullOrEmpty(_searchString))
-            {
-                bool DefaultPredicate(Quest x) => x.Info.Name.Contains(_searchString, StringComparison.CurrentCultureIgnoreCase);
-
-                Func<Quest, bool> searchPredicate;
-                if (ElementId.TryFromString(_searchString, out ElementId? elementId))
-                    searchPredicate = x => DefaultPredicate(x) || x.Id == elementId;
-                else
-                    searchPredicate = DefaultPredicate;
-
-                foundQuests = _questRegistry.AllQuests
-                    .Where(x => x.Id is not SatisfactionSupplyNpcId and not AlliedSocietyDailyId)
-                    .Where(searchPredicate)
-                    .Where(x => !_questFunctions.IsQuestUnobtainable(x.Id));
-            }
-            else
-            {
-                foundQuests = _questRegistry.AllQuests.Where(x => _questFunctions.IsQuestAccepted(x.Id));
-            }
-
-            foreach (var quest in foundQuests)
-            {
-                if (quest.Info.IsMainScenarioQuest || _questController.ManualPriorityQuests.Any(x => x.Id == quest.Id))
-                    continue;
-
-                bool addThis = ImGui.Selectable(quest.Info.Name);
-                if (addThis || addFirst)
-                {
-                    _questController.ManualPriorityQuests.Add(quest);
-
-                    if (addFirst)
-                    {
-                        ImGui.CloseCurrentPopup();
-                        addFirst = false;
-                    }
-                }
-            }
-
-            ImGui.EndCombo();
-        }
-
-        ImGui.Spacing();
-    }
-
     private void DrawQuestList()
     {
         List<Quest> priorityQuests = _questController.ManualPriorityQuests;
@@ -161,63 +115,64 @@ internal sealed class PriorityWindow : LWindow
             Vector2 topLeft = ImGui.GetCursorScreenPos() +
                               new Vector2(0, -ImGui.GetStyle().ItemSpacing.Y / 2);
             var quest = priorityQuests[i];
-            ImGui.PushID($"Quest{quest.Id}");
-
-            var style = _uiUtils.GetQuestStyle(quest.Id);
-            bool hovered;
-            using (var _ = _pluginInterface.UiBuilder.IconFontFixedWidthHandle.Push())
+            using (ImRaii.PushId($"Quest{quest.Id}"))
             {
-                ImGui.AlignTextToFramePadding();
-                ImGui.TextColored(style.Color, style.Icon.ToIconString());
-                hovered = ImGui.IsItemHovered();
-            }
+                var style = _uiUtils.GetQuestStyle(quest.Id);
+                bool hovered;
+                using (var _ = _pluginInterface.UiBuilder.IconFontFixedWidthHandle.Push())
+                {
+                    ImGui.AlignTextToFramePadding();
+                    ImGui.TextColored(style.Color, style.Icon.ToIconString());
+                    hovered = ImGui.IsItemHovered();
+                }
 
-            ImGui.SameLine();
-            ImGui.AlignTextToFramePadding();
-            ImGui.Text(quest.Info.Name);
-            hovered |= ImGui.IsItemHovered();
+                ImGui.SameLine();
+                ImGui.AlignTextToFramePadding();
+                ImGui.Text(quest.Info.Name);
+                hovered |= ImGui.IsItemHovered();
 
-            if (hovered)
-                _questTooltipComponent.Draw(quest.Info);
+                if (hovered)
+                    _questTooltipComponent.Draw(quest.Info);
 
-            if (priorityQuests.Count > 1)
-            {
-                ImGui.PushFont(UiBuilder.IconFont);
-                ImGui.SameLine(ImGui.GetContentRegionAvail().X +
-                               ImGui.GetStyle().WindowPadding.X -
-                               ImGui.CalcTextSize(FontAwesomeIcon.ArrowsUpDown.ToIconString()).X -
-                               ImGui.CalcTextSize(FontAwesomeIcon.Times.ToIconString()).X -
-                               ImGui.GetStyle().FramePadding.X * 4 -
-                               ImGui.GetStyle().ItemSpacing.X);
-                ImGui.PopFont();
-
-                if (_draggedItem == quest.Id)
+                if (priorityQuests.Count > 1)
                 {
-                    ImGuiComponents.IconButton("##Move", FontAwesomeIcon.ArrowsUpDown,
-                        ImGui.ColorConvertU32ToFloat4(ImGui.GetColorU32(ImGuiCol.ButtonActive)));
-                }
-                else
-                    ImGuiComponents.IconButton("##Move", FontAwesomeIcon.ArrowsUpDown);
+                    using (ImRaii.PushFont(UiBuilder.IconFont))
+                    {
+                        ImGui.SameLine(ImGui.GetContentRegionAvail().X +
+                                       ImGui.GetStyle().WindowPadding.X -
+                                       ImGui.CalcTextSize(FontAwesomeIcon.ArrowsUpDown.ToIconString()).X -
+                                       ImGui.CalcTextSize(FontAwesomeIcon.Times.ToIconString()).X -
+                                       ImGui.GetStyle().FramePadding.X * 4 -
+                                       ImGui.GetStyle().ItemSpacing.X);
+                    }
 
-                if (_draggedItem == null && ImGui.IsItemActive() && ImGui.IsMouseDragging(ImGuiMouseButton.Left))
-                    _draggedItem = quest.Id;
+                    if (_draggedItem == quest.Id)
+                    {
+                        ImGuiComponents.IconButton("##Move", FontAwesomeIcon.ArrowsUpDown,
+                            ImGui.ColorConvertU32ToFloat4(ImGui.GetColorU32(ImGuiCol.ButtonActive)));
+                    }
+                    else
+                        ImGuiComponents.IconButton("##Move", FontAwesomeIcon.ArrowsUpDown);
 
-                ImGui.SameLine();
-            }
-            else
-            {
-                ImGui.PushFont(UiBuilder.IconFont);
-                ImGui.SameLine(ImGui.GetContentRegionAvail().X +
-                               ImGui.GetStyle().WindowPadding.X -
-                               ImGui.CalcTextSize(FontAwesomeIcon.Times.ToIconString()).X -
-                               ImGui.GetStyle().FramePadding.X * 2);
-                ImGui.PopFont();
-            }
+                    if (_draggedItem == null && ImGui.IsItemActive() && ImGui.IsMouseDragging(ImGuiMouseButton.Left))
+                        _draggedItem = quest.Id;
 
-            if (ImGuiComponents.IconButton($"##Remove{i}", FontAwesomeIcon.Times))
-                itemToRemove = quest;
+                    ImGui.SameLine();
+                }
+                else
+                {
+                    using (ImRaii.PushFont(UiBuilder.IconFont))
+                    {
+                        ImGui.SameLine(ImGui.GetContentRegionAvail().X +
+                                       ImGui.GetStyle().WindowPadding.X -
+                                       ImGui.CalcTextSize(FontAwesomeIcon.Times.ToIconString()).X -
+                                       ImGui.GetStyle().FramePadding.X * 2);
+                    }
+                }
 
-            ImGui.PopID();
+                if (ImGuiComponents.IconButton($"##Remove{i}", FontAwesomeIcon.Times))
+                    itemToRemove = quest;
+            }
 
             Vector2 bottomRight = new Vector2(topLeft.X + width,
                 ImGui.GetCursorScreenPos().Y - ImGui.GetStyle().ItemSpacing.Y + 2);
index 586d260ce9a6632a9618de2f15660e933e2ecb9a..d9b156ad6252998c4cc379b91be0f70e80bbc33e 100644 (file)
@@ -33,6 +33,7 @@ internal sealed partial class ActiveQuestComponent
     private readonly Configuration _configuration;
     private readonly QuestRegistry _questRegistry;
     private readonly PriorityWindow _priorityWindow;
+    private readonly UiUtils _uiUtils;
     private readonly IChatGui _chatGui;
     private readonly ILogger<ActiveQuestComponent> _logger;
 
@@ -46,6 +47,7 @@ internal sealed partial class ActiveQuestComponent
         Configuration configuration,
         QuestRegistry questRegistry,
         PriorityWindow priorityWindow,
+        UiUtils uiUtils,
         IChatGui chatGui,
         ILogger<ActiveQuestComponent> logger)
     {
@@ -58,6 +60,7 @@ internal sealed partial class ActiveQuestComponent
         _configuration = configuration;
         _questRegistry = questRegistry;
         _priorityWindow = priorityWindow;
+        _uiUtils = uiUtils;
         _chatGui = chatGui;
         _logger = logger;
     }
@@ -184,6 +187,29 @@ internal sealed partial class ActiveQuestComponent
                     ImGui.TextColored(ImGuiColors.DalamudRed, "Disabled");
                 }
 
+                if (_configuration.Stop.Enabled &&
+                    _configuration.Stop.QuestsToStopAfter.Any(x => !_questFunctions.IsQuestComplete(x) && !_questFunctions.IsQuestUnobtainable(x)))
+                {
+                    ImGui.SameLine();
+                    ImGui.TextColored(ImGuiColors.ParsedPurple, SeIconChar.Clock.ToIconString());
+                    if (ImGui.IsItemHovered())
+                    {
+                        using var tooltip = ImRaii.Tooltip();
+                        if (tooltip)
+                        {
+                            ImGui.Text("Questionable will stop after completing any of the following quests:");
+                            foreach (var questId in _configuration.Stop.QuestsToStopAfter)
+                            {
+                                if (_questRegistry.TryGetQuest(questId, out var quest))
+                                {
+                                    (Vector4 color, FontAwesomeIcon icon, _) = _uiUtils.GetQuestStyle(questId);
+                                    _uiUtils.ChecklistItem($"{quest.Info.Name} ({questId})", color, icon);
+                                }
+                            }
+                        }
+                    }
+                }
+
                 if (_configuration.Advanced.AdditionalStatusInformation && _questController.IsInterruptible())
                 {
                     ImGui.SameLine();
diff --git a/Questionable/Windows/Utils/QuestSelector.cs b/Questionable/Windows/Utils/QuestSelector.cs
new file mode 100644 (file)
index 0000000..1220d8c
--- /dev/null
@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Dalamud.Bindings.ImGui;
+using Questionable.Controller;
+using Questionable.Functions;
+using Questionable.Model;
+using Questionable.Model.Questing;
+
+namespace Questionable.Windows.Utils;
+
+internal sealed class QuestSelector(QuestRegistry questRegistry, QuestFunctions questFunctions)
+{
+    private string _searchString = string.Empty;
+
+    public Predicate<Quest>? SuggestionPredicate { private get; set; }
+    public Predicate<Quest>? DefaultPredicate { private get; set; }
+    public Action<Quest>? QuestSelected { private get; set; }
+
+    public void DrawSelection()
+    {
+        if (QuestSelected == null)
+            throw new InvalidOperationException("QuestSelected action must be set before drawing the quest selector.");
+
+        ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X);
+        if (ImGui.BeginCombo("##QuestSelection", "Add Quest...", ImGuiComboFlags.HeightLarge))
+        {
+            ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X);
+            bool addFirst = ImGui.InputTextWithHint("", "Filter...", ref _searchString, 256,
+                ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.EnterReturnsTrue);
+
+            IEnumerable<Quest> foundQuests;
+            if (!string.IsNullOrEmpty(_searchString))
+            {
+                bool DefaultPredicate(Quest x) =>
+                    x.Info.Name.Contains(_searchString, StringComparison.CurrentCultureIgnoreCase);
+
+                Func<Quest, bool> searchPredicate;
+                if (ElementId.TryFromString(_searchString, out ElementId? elementId))
+                    searchPredicate = x => DefaultPredicate(x) || x.Id == elementId;
+                else
+                    searchPredicate = DefaultPredicate;
+
+                foundQuests = questRegistry.AllQuests
+                    .Where(x => x.Id is not SatisfactionSupplyNpcId and not AlliedSocietyDailyId)
+                    .Where(searchPredicate);
+            }
+            else
+            {
+                foundQuests = questRegistry.AllQuests.Where(x => DefaultPredicate?.Invoke(x) ?? true);
+            }
+
+            foreach (var quest in foundQuests)
+            {
+                if (SuggestionPredicate != null && !SuggestionPredicate.Invoke(quest))
+                    continue;
+
+                bool addThis = ImGui.Selectable(quest.Info.Name);
+                if (addThis || addFirst)
+                {
+                    QuestSelected(quest);
+
+                    if (addFirst)
+                    {
+                        ImGui.CloseCurrentPopup();
+                        addFirst = false;
+                    }
+                }
+            }
+
+            ImGui.EndCombo();
+        }
+
+        ImGui.Spacing();
+    }
+}