From 886503da663145ec3b6b3ddbdc2931ee2d856e57 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 1 Feb 2026 18:14:57 -0800 Subject: [PATCH 1/3] feat: add feature constraints --- src/Feature.h | 14 ++++ src/FeatureConstraints.cpp | 133 +++++++++++++++++++++++++++++++ src/FeatureConstraints.h | 104 ++++++++++++++++++++++++ src/Features/TerrainBlending.cpp | 37 +++++++++ src/Features/TerrainBlending.h | 3 +- src/Features/Upscaling.cpp | 27 +++++++ src/Features/Upscaling.h | 2 + src/Features/VR.cpp | 64 +++++++-------- src/Menu/HomePageRenderer.cpp | 78 ++++++++++++++++++ src/Menu/HomePageRenderer.h | 1 + src/Utils/UI.cpp | 128 +++++++++++++++++++++++++++++ src/Utils/UI.h | 44 ++++++++++ 12 files changed, 599 insertions(+), 36 deletions(-) create mode 100644 src/FeatureConstraints.cpp create mode 100644 src/FeatureConstraints.h diff --git a/src/Feature.h b/src/Feature.h index ee8240ca9a..3df58dbc22 100644 --- a/src/Feature.h +++ b/src/Feature.h @@ -1,5 +1,6 @@ #pragma once +#include "FeatureConstraints.h" #include "FeatureVersions.h" #ifdef TRACY_ENABLE # include @@ -138,6 +139,19 @@ struct Feature */ virtual void RegisterWeatherVariables() {} + /** + * @brief Returns constraints this feature imposes on other features' settings + * + * Features override this to declare runtime incompatibilities with other features. + * The constraint system will automatically: + * - Force the target setting to the specified value + * - Disable the UI control for the constrained setting + * - Show a tooltip explaining which features caused the constraint + * + * @return Vector of constraints this feature currently imposes (empty if none) + */ + virtual std::vector GetActiveConstraints() const { return {}; } + virtual bool ValidateCache(CSimpleIniA& a_ini); virtual void WriteDiskCacheInfo(CSimpleIniA& a_ini); virtual void ClearShaderCache() {} diff --git a/src/FeatureConstraints.cpp b/src/FeatureConstraints.cpp new file mode 100644 index 0000000000..cc41fa59c3 --- /dev/null +++ b/src/FeatureConstraints.cpp @@ -0,0 +1,133 @@ +#include "FeatureConstraints.h" +#include "Feature.h" + +namespace FeatureConstraints +{ + ConstraintResult GetConstraints(const SettingId& setting) + { + ConstraintResult result; + + for (auto* feature : Feature::GetFeatureList()) { + if (!feature->loaded) + continue; + + auto constraints = feature->GetActiveConstraints(); + for (const auto& constraint : constraints) { + if (constraint.targetSetting == setting) { + if (!result.isConstrained) { + result.isConstrained = true; + result.forcedValue = constraint.forcedValue; + } + result.sources.push_back({ feature->GetName(), + constraint.reason, + constraint.recommendDisableAtBoot }); + } + } + } + + return result; + } + + std::vector> GetAllActiveConstraints() + { + std::vector> allConstraints; + std::vector processedSettings; + + for (auto* feature : Feature::GetFeatureList()) { + if (!feature->loaded) + continue; + + auto constraints = feature->GetActiveConstraints(); + for (const auto& constraint : constraints) { + // Check if we've already processed this setting + bool found = false; + for (const auto& processed : processedSettings) { + if (processed == constraint.targetSetting) { + found = true; + break; + } + } + + if (!found) { + processedSettings.push_back(constraint.targetSetting); + auto result = GetConstraints(constraint.targetSetting); + if (result.isConstrained) { + allConstraints.push_back({ constraint.targetSetting, result }); + } + } + } + } + + return allConstraints; + } + + std::string BuildConstraintTooltip(const ConstraintResult& result) + { + if (!result.isConstrained || result.sources.empty()) + return ""; + + std::string tooltip = "This setting is constrained by:\n"; + for (const auto& src : result.sources) { + tooltip += "\n- " + src.featureName + ":\n " + src.reason; + if (src.recommendDisableAtBoot) { + tooltip += "\n (Consider disabling this feature at boot for best compatibility)"; + } + } + + tooltip += "\n\nForced value: " + FormatConstraintValue(result.forcedValue); + + return tooltip; + } + + std::string FormatConstraintValue(const std::variant& value) + { + if (std::holds_alternative(value)) { + return std::get(value) ? "Enabled" : "Disabled"; + } else if (std::holds_alternative(value)) { + return std::to_string(std::get(value)); + } else if (std::holds_alternative(value)) { + char buf[32]; + snprintf(buf, sizeof(buf), "%.2f", std::get(value)); + return buf; + } + return "Unknown"; + } + + std::vector GetPotentialConstraints(const std::string& featureShortName) + { + for (auto* feature : Feature::GetFeatureList()) { + if (feature->GetShortName() == featureShortName) { + return feature->GetActiveConstraints(); + } + } + return {}; + } + + bool WouldCauseConflicts(const std::string& featureShortName, std::vector>& outConflicts) + { + outConflicts.clear(); + + // Get potential constraints from the feature + auto potentialConstraints = GetPotentialConstraints(featureShortName); + if (potentialConstraints.empty()) { + return false; + } + + // For each constraint, check if the target setting is currently in conflict + for (const auto& constraint : potentialConstraints) { + // A conflict exists if: + // 1. The constraint would force a setting to a value + // 2. The setting is currently set to a different (conflicting) value + // For bool constraints forcing to false, conflict if currently true + if (std::holds_alternative(constraint.forcedValue)) { + bool forcedValue = std::get(constraint.forcedValue); + // We can't easily check the current value of arbitrary settings here + // without more infrastructure, so we just report all constraints + // The UI can then check if the specific setting is currently different + outConflicts.push_back({ constraint, !forcedValue }); + } + } + + return !outConflicts.empty(); + } +} diff --git a/src/FeatureConstraints.h b/src/FeatureConstraints.h new file mode 100644 index 0000000000..8696c24753 --- /dev/null +++ b/src/FeatureConstraints.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include + +namespace FeatureConstraints +{ + /** + * @brief Identifies a specific setting that can be constrained + */ + struct SettingId + { + std::string featureShortName; // e.g., "VR" + std::string settingPath; // e.g., "EnableDepthBufferCullingExterior" + + bool operator==(const SettingId& other) const + { + return featureShortName == other.featureShortName && settingPath == other.settingPath; + } + }; + + /** + * @brief A constraint that one feature places on another feature's setting + */ + struct Constraint + { + SettingId targetSetting; // Which setting is affected + std::variant forcedValue; // Value to force + std::string reason; // UI tooltip explanation + bool recommendDisableAtBoot = false; // Suggest disabling the source feature entirely + }; + + /** + * @brief Result of checking constraints on a setting + */ + struct ConstraintResult + { + bool isConstrained = false; + std::variant forcedValue; + + struct Source + { + std::string featureName; + std::string reason; + bool recommendDisableAtBoot; + }; + std::vector sources; + + /** + * @brief Check if any source recommends disabling at boot + */ + bool AnyRecommendDisableAtBoot() const + { + for (const auto& src : sources) { + if (src.recommendDisableAtBoot) + return true; + } + return false; + } + }; + + /** + * @brief Query if a setting is constrained by any active feature + * @param setting The setting to check + * @return ConstraintResult with all sources causing the constraint + */ + ConstraintResult GetConstraints(const SettingId& setting); + + /** + * @brief Get all active constraints across all features + * @return Vector of setting IDs and their constraint results + */ + std::vector> GetAllActiveConstraints(); + + /** + * @brief Build a formatted tooltip string for a constrained setting + * @param result The constraint result to format + * @return Formatted string suitable for ImGui tooltip + */ + std::string BuildConstraintTooltip(const ConstraintResult& result); + + /** + * @brief Format a constraint value as a string for display + * @param value The variant value to format + * @return String representation of the value + */ + std::string FormatConstraintValue(const std::variant& value); + + /** + * @brief Check what constraints a feature would impose if enabled + * @param featureShortName The short name of the feature to check + * @return Vector of constraints that would be imposed + */ + std::vector GetPotentialConstraints(const std::string& featureShortName); + + /** + * @brief Check if enabling a feature would conflict with currently-enabled settings + * @param featureShortName The short name of the feature being enabled + * @param outConflicts Output vector of constraint conflicts (setting currently enabled but would be forced off) + * @return True if there are conflicts that the user should be warned about + */ + bool WouldCauseConflicts(const std::string& featureShortName, std::vector>& outConflicts); +} diff --git a/src/Features/TerrainBlending.cpp b/src/Features/TerrainBlending.cpp index e1f04ad37e..7dcb869a5d 100644 --- a/src/Features/TerrainBlending.cpp +++ b/src/Features/TerrainBlending.cpp @@ -1,18 +1,55 @@ #include "TerrainBlending.h" #include "Deferred.h" +#include "Globals.h" #include "ShaderCache.h" #include "State.h" +#include "VR.h" NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT( TerrainBlending::Settings, Enabled) +std::vector TerrainBlending::GetActiveConstraints() const +{ + std::vector constraints; + + // Only impose constraints when the feature is loaded, enabled, and we're in VR + if (!loaded || !settings.Enabled || !globals::game::isVR) { + return constraints; + } + + // Terrain Blending has visual issues with VR depth buffer culling in exteriors + constraints.push_back({ { "VR", "EnableDepthBufferCullingExterior" }, + false, + "Terrain Blending has visual issues with VR depth buffer culling in exteriors.", + false }); + + return constraints; +} + void TerrainBlending::DrawSettings() { + bool wasEnabled = settings.Enabled; ImGui::Checkbox("Enable Terrain Blending", (bool*)&settings.Enabled); + + // Show warning if enabling in VR and depth culling is currently enabled + if (globals::game::isVR && settings.Enabled && !wasEnabled) { + // Check if VR depth culling exterior is currently enabled + auto& vr = globals::features::vr; + if (vr.settings.EnableDepthBufferCullingExterior) { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), + "Note: VR Depth Buffer Culling (Exteriors) will be disabled while this feature is enabled."); + } + } + if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Enable seamless blending between terrain and objects."); + if (globals::game::isVR) { + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "VR Note:"); + ImGui::TextWrapped("When enabled in VR, this feature requires disabling Depth Buffer Culling in exteriors to prevent visual issues."); + } } } diff --git a/src/Features/TerrainBlending.h b/src/Features/TerrainBlending.h index 676e003a3a..33211048f1 100644 --- a/src/Features/TerrainBlending.h +++ b/src/Features/TerrainBlending.h @@ -19,6 +19,8 @@ struct TerrainBlending : Feature }; } virtual inline bool HasShaderDefine(RE::BSShader::Type) override { return true; } + virtual bool SupportsVR() override { return true; } + virtual std::vector GetActiveConstraints() const override; struct Settings { @@ -110,5 +112,4 @@ struct TerrainBlending : Feature logger::info("[Terrain Blending] Installed hooks"); } }; - virtual bool SupportsVR() override { return false; }; }; diff --git a/src/Features/Upscaling.cpp b/src/Features/Upscaling.cpp index faae04e175..e86b590291 100644 --- a/src/Features/Upscaling.cpp +++ b/src/Features/Upscaling.cpp @@ -1107,6 +1107,33 @@ bool Upscaling::IsUpscalingActive() return resolutionScale.x < .99f; } +std::vector Upscaling::GetActiveConstraints() const +{ + std::vector constraints; + + // Check if upscaling is active - need to cast away const for method call + auto* self = const_cast(this); + if (!self->IsUpscalingActive()) { + return constraints; + } + + // When upscaling is active in VR, depth buffer culling must be disabled + // because upscalers modify the depth buffer, causing incorrect occlusion + if (globals::game::isVR) { + constraints.push_back({ { "VR", "EnableDepthBufferCullingExterior" }, + false, + "Upscaling modifies the depth buffer, causing incorrect VR occlusion tests in exteriors.", + false }); + + constraints.push_back({ { "VR", "EnableDepthBufferCullingInterior" }, + false, + "Upscaling modifies the depth buffer, causing incorrect VR occlusion tests in interiors.", + false }); + } + + return constraints; +} + /** * @brief Retrieves the current frame time for frame generation. * diff --git a/src/Features/Upscaling.h b/src/Features/Upscaling.h index cdeeda0818..7c5a74c73e 100644 --- a/src/Features/Upscaling.h +++ b/src/Features/Upscaling.h @@ -36,6 +36,8 @@ struct Upscaling : Feature }; } + virtual std::vector GetActiveConstraints() const override; + float2 jitter = { 0, 0 }; enum class UpscaleMethod diff --git a/src/Features/VR.cpp b/src/Features/VR.cpp index ee1a82a5ad..2294e765ac 100644 --- a/src/Features/VR.cpp +++ b/src/Features/VR.cpp @@ -588,45 +588,30 @@ namespace auto& vr = globals::features::vr; VR::Settings& settings = vr.settings; if (ImGui::CollapsingHeader("General Settings", ImGuiTreeNodeFlags_DefaultOpen)) { - // If an upscaler is active that rewrites or repurposes the depth buffer, - // depth-buffer-culling must be disabled to avoid incorrect occlusion tests - // (which are especially problematic in VR). Query the Upscaling feature - // to see whether we're running FSR or DLSS. - // Determine if an external upscaler is active by reading the numeric - // setting value directly. Avoid referencing Upscaling types here to - // prevent header/type collisions in this translation unit. - // Query the Upscaling feature for an authoritative state flag. - bool upscalingActive = globals::features::upscaling.IsUpscalingActive(); - - // Exteriors - if (upscalingActive) - ImGui::BeginDisabled(); - ImGui::Checkbox("Enable Depth Buffer Culling in Exteriors", &settings.EnableDepthBufferCullingExterior); - if (upscalingActive) { - if (auto _tt = Util::HoverTooltipWrapper()) { - ImGui::Text("Disabled while an external upscaler is active (FSR/DLSS) because upscalers may modify depth.\nThis prevents incorrect occlusion in VR."); - } - ImGui::EndDisabled(); - } else { + // Use constraint-aware checkboxes that automatically handle disabling + // and showing tooltips when other features constrain these settings + Util::ConstrainedUI::Checkbox("Enable Depth Buffer Culling in Exteriors", + &settings.EnableDepthBufferCullingExterior, + { "VR", "EnableDepthBufferCullingExterior" }); + // Show normal tooltip when not constrained + auto exteriorConstraint = FeatureConstraints::GetConstraints({ "VR", "EnableDepthBufferCullingExterior" }); + if (!exteriorConstraint.isConstrained) { if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Improves performance in exteriors, recommended ON."); } } - // Interiors - if (upscalingActive) - ImGui::BeginDisabled(); - ImGui::Checkbox("Enable Depth Buffer Culling in Interiors", &settings.EnableDepthBufferCullingInterior); - if (upscalingActive) { - if (auto _tt = Util::HoverTooltipWrapper()) { - ImGui::Text("Disabled while an external upscaler is active (FSR/DLSS) because upscalers may modify depth.\nThis prevents incorrect occlusion in VR."); - } - ImGui::EndDisabled(); - } else { + Util::ConstrainedUI::Checkbox("Enable Depth Buffer Culling in Interiors", + &settings.EnableDepthBufferCullingInterior, + { "VR", "EnableDepthBufferCullingInterior" }); + // Show normal tooltip when not constrained + auto interiorConstraint = FeatureConstraints::GetConstraints({ "VR", "EnableDepthBufferCullingInterior" }); + if (!interiorConstraint.isConstrained) { if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Improves performance in interiors, recommended OFF due to occasional visual glitches."); } } + if (ImGui::SliderFloat("Min Occludee Box Extent", &settings.MinOccludeeBoxExtent, 0.0f, 1000.0f, "%.1f")) *vr.gMinOccludeeBoxExtent = settings.MinOccludeeBoxExtent; if (auto _tt = Util::HoverTooltipWrapper()) { @@ -1663,15 +1648,24 @@ void VR::SubmitOverlayFrame() // Helper to centralize VR depth buffer culling logic, reducing duplication between DataLoaded and EarlyPrepass. void VR::UpdateDepthBufferCulling(bool desired) { - if (globals::features::upscaling.IsUpscalingActive()) { - if (gDepthBufferCulling && *gDepthBufferCulling) { - logger::info("Upscaling detected, disabling incompatible depth buffer culling."); - *gDepthBufferCulling = false; + // Check if any feature is constraining this setting + auto constraint = FeatureConstraints::GetConstraints({ "VR", "EnableDepthBufferCullingExterior" }); + + if (constraint.isConstrained) { + bool forcedValue = std::get(constraint.forcedValue); + if (gDepthBufferCulling && *gDepthBufferCulling != forcedValue) { + *gDepthBufferCulling = forcedValue; + for (const auto& src : constraint.sources) { + logger::info("{} forcing depth buffer culling {}: {}", + src.featureName, + forcedValue ? "ON" : "OFF", + src.reason); + } } } else { if (gDepthBufferCulling && *gDepthBufferCulling != desired) { *gDepthBufferCulling = desired; - logger::info("VR depth buffer culling restored to {}", desired); + logger::info("VR depth buffer culling set to {}", desired); } } } diff --git a/src/Menu/HomePageRenderer.cpp b/src/Menu/HomePageRenderer.cpp index 1ffcee5235..a3c8a82b13 100644 --- a/src/Menu/HomePageRenderer.cpp +++ b/src/Menu/HomePageRenderer.cpp @@ -3,6 +3,7 @@ #include +#include "FeatureConstraints.h" #include "Globals.h" #include "Menu.h" #include "Plugin.h" @@ -19,6 +20,8 @@ void HomePageRenderer::RenderHomePage() RenderWelcomeSection(); ImGui::Spacing(); + RenderActiveConstraintsSection(); + RenderQuickLinksSection(); ImGui::Spacing(); @@ -247,6 +250,81 @@ void HomePageRenderer::RenderFAQSection() } } +void HomePageRenderer::RenderActiveConstraintsSection() +{ + auto constraints = FeatureConstraints::GetAllActiveConstraints(); + if (constraints.empty()) { + return; // Don't show section if there are no active constraints + } + + ImGui::Spacing(); + + // Use warning color for the header to draw attention + auto menu = Menu::GetSingleton(); + ImVec4 warningColor = menu ? menu->GetTheme().StatusPalette.Warning : ImVec4(1.0f, 0.8f, 0.2f, 1.0f); + + ImGui::PushStyleColor(ImGuiCol_Text, warningColor); + bool headerOpen = ImGui::CollapsingHeader("Active Setting Constraints", ImGuiTreeNodeFlags_None); + ImGui::PopStyleColor(); + + if (headerOpen) { + ImGui::TextWrapped( + "Some settings are constrained by other features. Hover over rows for details."); + + ImGui::Spacing(); + + // Compact table format + if (ImGui::BeginTable("ConstraintsTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableSetupColumn("Setting", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Forced To", ImGuiTableColumnFlags_WidthFixed, 80.0f); + ImGui::TableSetupColumn("Constrained By", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + + for (const auto& [settingId, result] : constraints) { + ImGui::TableNextRow(); + + // Setting column + ImGui::TableSetColumnIndex(0); + ImGui::TextColored(warningColor, "%s.%s", settingId.featureShortName.c_str(), settingId.settingPath.c_str()); + + // Forced value column + ImGui::TableSetColumnIndex(1); + ImGui::Text("%s", FeatureConstraints::FormatConstraintValue(result.forcedValue).c_str()); + + // Constrained by column - show feature names, tooltip has details + ImGui::TableSetColumnIndex(2); + std::string sourceNames; + for (size_t i = 0; i < result.sources.size(); ++i) { + if (i > 0) + sourceNames += ", "; + sourceNames += result.sources[i].featureName; + } + ImGui::Text("%s", sourceNames.c_str()); + + // Tooltip with full details on row hover + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 25.0f); + for (const auto& src : result.sources) { + ImGui::TextColored(warningColor, "%s:", src.featureName.c_str()); + ImGui::TextWrapped("%s", src.reason.c_str()); + if (src.recommendDisableAtBoot) { + ImVec4 errorColor = menu ? menu->GetTheme().StatusPalette.Error : ImVec4(1.0f, 0.4f, 0.4f, 1.0f); + ImGui::TextColored(errorColor, "Consider disabling at boot."); + } + ImGui::Spacing(); + } + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + } + ImGui::EndTable(); + } + } + + ImGui::Spacing(); +} + void HomePageRenderer::RenderFirstTimeSetupDialog() { // Block input to the game and make cursor visible - input blocking is handled by ShouldSwallowInput() diff --git a/src/Menu/HomePageRenderer.h b/src/Menu/HomePageRenderer.h index bfb7f89742..26e58daea7 100644 --- a/src/Menu/HomePageRenderer.h +++ b/src/Menu/HomePageRenderer.h @@ -31,6 +31,7 @@ class HomePageRenderer static void RenderWelcomeSection(); static void RenderQuickLinksSection(); static void RenderFAQSection(); + static void RenderActiveConstraintsSection(); static void MarkFirstTimeSetupComplete(); diff --git a/src/Utils/UI.cpp b/src/Utils/UI.cpp index 6e360f4049..6a46c66168 100644 --- a/src/Utils/UI.cpp +++ b/src/Utils/UI.cpp @@ -1702,4 +1702,132 @@ namespace Util } } + namespace ConstrainedUI + { + bool Checkbox(const char* label, bool* value, const FeatureConstraints::SettingId& settingId) + { + auto constraint = FeatureConstraints::GetConstraints(settingId); + + if (constraint.isConstrained) { + ImGui::BeginDisabled(); + } + + bool changed = ImGui::Checkbox(label, value); + + if (constraint.isConstrained) { + ImGui::EndDisabled(); + + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Setting Constrained"); + ImGui::Text("This setting is constrained by:"); + ImGui::Spacing(); + for (const auto& src : constraint.sources) { + ImGui::BulletText("%s", src.featureName.c_str()); + ImGui::Indent(); + ImGui::TextWrapped("%s", src.reason.c_str()); + if (src.recommendDisableAtBoot) { + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), + "Consider disabling this feature at boot for best compatibility."); + } + ImGui::Unindent(); + } + ImGui::Separator(); + ImGui::Text("Forced value: %s", FeatureConstraints::FormatConstraintValue(constraint.forcedValue).c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + + return false; + } + + return changed; + } + + bool SliderFloat(const char* label, float* value, float min, float max, + const FeatureConstraints::SettingId& settingId, const char* format) + { + auto constraint = FeatureConstraints::GetConstraints(settingId); + + if (constraint.isConstrained) { + ImGui::BeginDisabled(); + } + + bool changed = ImGui::SliderFloat(label, value, min, max, format); + + if (constraint.isConstrained) { + ImGui::EndDisabled(); + + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Setting Constrained"); + ImGui::Text("This setting is constrained by:"); + ImGui::Spacing(); + for (const auto& src : constraint.sources) { + ImGui::BulletText("%s", src.featureName.c_str()); + ImGui::Indent(); + ImGui::TextWrapped("%s", src.reason.c_str()); + if (src.recommendDisableAtBoot) { + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), + "Consider disabling this feature at boot for best compatibility."); + } + ImGui::Unindent(); + } + ImGui::Separator(); + ImGui::Text("Forced value: %s", FeatureConstraints::FormatConstraintValue(constraint.forcedValue).c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + + return false; + } + + return changed; + } + + bool SliderInt(const char* label, int* value, int min, int max, + const FeatureConstraints::SettingId& settingId, const char* format) + { + auto constraint = FeatureConstraints::GetConstraints(settingId); + + if (constraint.isConstrained) { + ImGui::BeginDisabled(); + } + + bool changed = ImGui::SliderInt(label, value, min, max, format); + + if (constraint.isConstrained) { + ImGui::EndDisabled(); + + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Setting Constrained"); + ImGui::Text("This setting is constrained by:"); + ImGui::Spacing(); + for (const auto& src : constraint.sources) { + ImGui::BulletText("%s", src.featureName.c_str()); + ImGui::Indent(); + ImGui::TextWrapped("%s", src.reason.c_str()); + if (src.recommendDisableAtBoot) { + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), + "Consider disabling this feature at boot for best compatibility."); + } + ImGui::Unindent(); + } + ImGui::Separator(); + ImGui::Text("Forced value: %s", FeatureConstraints::FormatConstraintValue(constraint.forcedValue).c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + + return false; + } + + return changed; + } + } + } // namespace Util diff --git a/src/Utils/UI.h b/src/Utils/UI.h index f3196ad17a..61fd8218ee 100644 --- a/src/Utils/UI.h +++ b/src/Utils/UI.h @@ -7,6 +7,7 @@ #include #include // For WPARAM and virtual key constants +#include "../FeatureConstraints.h" #include "../Menu/Fonts.h" // Forward declarations @@ -246,6 +247,49 @@ namespace Util bool ColorEdit4(const char* label, Feature* feature, const char* settingName, float col[4]); } + /** + * Constraint-aware UI helpers + * These functions automatically check if a setting is constrained by another feature + * and disable the control with an informative tooltip explaining why + */ + namespace ConstrainedUI + { + /** + * Constraint-aware checkbox that greys out when constrained by another feature + * @param label The label for the checkbox + * @param value Pointer to the bool value + * @param settingId The setting identifier for constraint lookup + * @return True if value was changed (only possible when not constrained) + */ + bool Checkbox(const char* label, bool* value, const FeatureConstraints::SettingId& settingId); + + /** + * Constraint-aware slider float that greys out when constrained by another feature + * @param label The label for the slider + * @param value Pointer to the float value + * @param min Minimum value + * @param max Maximum value + * @param settingId The setting identifier for constraint lookup + * @param format Display format + * @return True if value was changed (only possible when not constrained) + */ + bool SliderFloat(const char* label, float* value, float min, float max, + const FeatureConstraints::SettingId& settingId, const char* format = "%.3f"); + + /** + * Constraint-aware slider int that greys out when constrained by another feature + * @param label The label for the slider + * @param value Pointer to the int value + * @param min Minimum value + * @param max Maximum value + * @param settingId The setting identifier for constraint lookup + * @param format Display format + * @return True if value was changed (only possible when not constrained) + */ + bool SliderInt(const char* label, int* value, int min, int max, + const FeatureConstraints::SettingId& settingId, const char* format = "%d"); + } + /** * Draws a custom styled collapsible category header with lines extending from both sides * @param categoryName The name of the category to display From 3ffe300eb23115f71e7c2e310cd9daba50d78f4d Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Mon, 2 Feb 2026 08:57:09 -0800 Subject: [PATCH 2/3] chore: address AI comments --- src/FeatureConstraints.cpp | 141 +++++++++++++------- src/FeatureConstraints.h | 37 +++--- src/Features/Upscaling.cpp | 8 +- src/Features/Upscaling.h | 4 +- src/Features/VR.cpp | 35 +++-- src/Features/VR.h | 3 +- src/Menu/FeatureListRenderer.cpp | 221 ++++++++++++++++++++++++++++++- src/Menu/FeatureListRenderer.h | 24 +++- src/Menu/HomePageRenderer.cpp | 109 +++++++++------ src/Utils/UI.cpp | 159 ++++++++++------------ 10 files changed, 514 insertions(+), 227 deletions(-) diff --git a/src/FeatureConstraints.cpp b/src/FeatureConstraints.cpp index cc41fa59c3..7de185778b 100644 --- a/src/FeatureConstraints.cpp +++ b/src/FeatureConstraints.cpp @@ -1,6 +1,8 @@ #include "FeatureConstraints.h" #include "Feature.h" +#include + namespace FeatureConstraints { ConstraintResult GetConstraints(const SettingId& setting) @@ -31,7 +33,7 @@ namespace FeatureConstraints std::vector> GetAllActiveConstraints() { std::vector> allConstraints; - std::vector processedSettings; + std::unordered_set processedKeys; // featureShortName|settingPath for O(1) lookup for (auto* feature : Feature::GetFeatureList()) { if (!feature->loaded) @@ -39,17 +41,8 @@ namespace FeatureConstraints auto constraints = feature->GetActiveConstraints(); for (const auto& constraint : constraints) { - // Check if we've already processed this setting - bool found = false; - for (const auto& processed : processedSettings) { - if (processed == constraint.targetSetting) { - found = true; - break; - } - } - - if (!found) { - processedSettings.push_back(constraint.targetSetting); + std::string key = constraint.targetSetting.featureShortName + "|" + constraint.targetSetting.settingPath; + if (processedKeys.insert(key).second) { auto result = GetConstraints(constraint.targetSetting); if (result.isConstrained) { allConstraints.push_back({ constraint.targetSetting, result }); @@ -61,6 +54,92 @@ namespace FeatureConstraints return allConstraints; } + /** + * @brief Get constraints that would be created by enabling a specific feature + * @param featureToEnable The feature that would be enabled + * @return Vector of setting IDs and constraint results that would be created + */ + std::vector> GetConstraintsFromEnablingFeature(Feature* featureToEnable) + { + std::vector> newConstraints; + std::unordered_set processedKeys; + + // Get constraints from the feature we're enabling + auto constraints = featureToEnable->GetActiveConstraints(); + for (const auto& constraint : constraints) { + std::string key = constraint.targetSetting.featureShortName + "|" + constraint.targetSetting.settingPath; + if (processedKeys.insert(key).second) { + // Check if this setting is already constrained by other features + auto existingResult = GetConstraints(constraint.targetSetting); + if (!existingResult.isConstrained) { + // This constraint would be new + ConstraintResult newResult; + newResult.isConstrained = true; + newResult.forcedValue = constraint.forcedValue; + newResult.sources.push_back({ featureToEnable->GetName(), + constraint.reason, + constraint.recommendDisableAtBoot }); + newConstraints.push_back({ constraint.targetSetting, newResult }); + } + } + } + + return newConstraints; + } + + /** + * @brief Get constraints that would be created by a setting change + * @param feature The feature whose setting is changing + * @param applyChange Function to apply the setting change temporarily + * @param revertChange Function to revert the setting change + * @return Vector of setting IDs and constraint results that would be created by the change + */ + std::vector> GetConstraintsFromSettingChange( + Feature* feature, + const std::function& applyChange, + const std::function& revertChange) + { + std::vector> newConstraints; + std::unordered_set processedKeys; + + // Get current constraints from this feature + auto currentConstraints = feature->GetActiveConstraints(); + std::unordered_set currentKeys; + for (const auto& constraint : currentConstraints) { + currentKeys.insert(constraint.targetSetting.featureShortName + "|" + constraint.targetSetting.settingPath); + } + + // Apply the setting change temporarily + applyChange(); + + // Get new constraints after the change + auto newConstraintsFromFeature = feature->GetActiveConstraints(); + + // Revert the change + revertChange(); + + // Find constraints that would be newly created + for (const auto& constraint : newConstraintsFromFeature) { + std::string key = constraint.targetSetting.featureShortName + "|" + constraint.targetSetting.settingPath; + if (processedKeys.insert(key).second && currentKeys.find(key) == currentKeys.end()) { + // This constraint would be new - check if the setting is already constrained by other features + auto existingResult = GetConstraints(constraint.targetSetting); + if (!existingResult.isConstrained) { + // This constraint would be newly created + ConstraintResult newResult; + newResult.isConstrained = true; + newResult.forcedValue = constraint.forcedValue; + newResult.sources.push_back({ feature->GetName(), + constraint.reason, + constraint.recommendDisableAtBoot }); + newConstraints.push_back({ constraint.targetSetting, newResult }); + } + } + } + + return newConstraints; + } + std::string BuildConstraintTooltip(const ConstraintResult& result) { if (!result.isConstrained || result.sources.empty()) @@ -92,42 +171,4 @@ namespace FeatureConstraints } return "Unknown"; } - - std::vector GetPotentialConstraints(const std::string& featureShortName) - { - for (auto* feature : Feature::GetFeatureList()) { - if (feature->GetShortName() == featureShortName) { - return feature->GetActiveConstraints(); - } - } - return {}; - } - - bool WouldCauseConflicts(const std::string& featureShortName, std::vector>& outConflicts) - { - outConflicts.clear(); - - // Get potential constraints from the feature - auto potentialConstraints = GetPotentialConstraints(featureShortName); - if (potentialConstraints.empty()) { - return false; - } - - // For each constraint, check if the target setting is currently in conflict - for (const auto& constraint : potentialConstraints) { - // A conflict exists if: - // 1. The constraint would force a setting to a value - // 2. The setting is currently set to a different (conflicting) value - // For bool constraints forcing to false, conflict if currently true - if (std::holds_alternative(constraint.forcedValue)) { - bool forcedValue = std::get(constraint.forcedValue); - // We can't easily check the current value of arbitrary settings here - // without more infrastructure, so we just report all constraints - // The UI can then check if the specific setting is currently different - outConflicts.push_back({ constraint, !forcedValue }); - } - } - - return !outConflicts.empty(); - } } diff --git a/src/FeatureConstraints.h b/src/FeatureConstraints.h index 8696c24753..9d516f9b95 100644 --- a/src/FeatureConstraints.h +++ b/src/FeatureConstraints.h @@ -1,9 +1,12 @@ #pragma once +#include #include #include #include +struct Feature; + namespace FeatureConstraints { /** @@ -73,6 +76,25 @@ namespace FeatureConstraints */ std::vector> GetAllActiveConstraints(); + /** + * @brief Get constraints that would be created by enabling a specific feature + * @param featureToEnable The feature that would be enabled + * @return Vector of setting IDs and constraint results that would be created + */ + std::vector> GetConstraintsFromEnablingFeature(Feature* featureToEnable); + + /** + * @brief Get constraints that would be created by a setting change + * @param feature The feature whose setting is changing + * @param applyChange Function to apply the setting change temporarily + * @param revertChange Function to revert the setting change + * @return Vector of setting IDs and constraint results that would be created by the change + */ + std::vector> GetConstraintsFromSettingChange( + Feature* feature, + const std::function& applyChange, + const std::function& revertChange); + /** * @brief Build a formatted tooltip string for a constrained setting * @param result The constraint result to format @@ -86,19 +108,4 @@ namespace FeatureConstraints * @return String representation of the value */ std::string FormatConstraintValue(const std::variant& value); - - /** - * @brief Check what constraints a feature would impose if enabled - * @param featureShortName The short name of the feature to check - * @return Vector of constraints that would be imposed - */ - std::vector GetPotentialConstraints(const std::string& featureShortName); - - /** - * @brief Check if enabling a feature would conflict with currently-enabled settings - * @param featureShortName The short name of the feature being enabled - * @param outConflicts Output vector of constraint conflicts (setting currently enabled but would be forced off) - * @return True if there are conflicts that the user should be warned about - */ - bool WouldCauseConflicts(const std::string& featureShortName, std::vector>& outConflicts); } diff --git a/src/Features/Upscaling.cpp b/src/Features/Upscaling.cpp index e86b590291..193e0c41c3 100644 --- a/src/Features/Upscaling.cpp +++ b/src/Features/Upscaling.cpp @@ -447,7 +447,7 @@ void Upscaling::PostPostLoad() logger::info("[Upscaling] Installed hooks"); } -Upscaling::UpscaleMethod Upscaling::GetUpscaleMethod() +Upscaling::UpscaleMethod Upscaling::GetUpscaleMethod() const { if (streamline.featureDLSS) return (UpscaleMethod)settings.upscaleMethod; @@ -1091,7 +1091,7 @@ bool Upscaling::IsFrameGenerationActive() const return d3d12SwapChainActive && settings.frameGenerationMode && fidelityFX.isFrameGenActive && !globals::game::isVR; } -bool Upscaling::IsUpscalingActive() +bool Upscaling::IsUpscalingActive() const { auto method = GetUpscaleMethod(); @@ -1111,9 +1111,7 @@ std::vector Upscaling::GetActiveConstraints() co { std::vector constraints; - // Check if upscaling is active - need to cast away const for method call - auto* self = const_cast(this); - if (!self->IsUpscalingActive()) { + if (!IsUpscalingActive()) { return constraints; } diff --git a/src/Features/Upscaling.h b/src/Features/Upscaling.h index 7c5a74c73e..53f06f996a 100644 --- a/src/Features/Upscaling.h +++ b/src/Features/Upscaling.h @@ -92,7 +92,7 @@ struct Upscaling : Feature // FG FPS Measurement for Overlay bool IsFrameGenerationActive() const; float GetFrameGenerationFrameTime() const; - bool IsUpscalingActive(); + bool IsUpscalingActive() const; // Feature interface overrides virtual void DrawSettings() override; @@ -110,7 +110,7 @@ struct Upscaling : Feature virtual void PostPostLoad() override; virtual void SetupResources() override; - UpscaleMethod GetUpscaleMethod(); + UpscaleMethod GetUpscaleMethod() const; void CheckResources(UpscaleMethod a_upscalemethod); void CreateUpscalingTextureResources(UpscaleMethod a_upscalemethod); diff --git a/src/Features/VR.cpp b/src/Features/VR.cpp index 2294e765ac..7cadf93e04 100644 --- a/src/Features/VR.cpp +++ b/src/Features/VR.cpp @@ -1,4 +1,5 @@ #include "VR.h" +#include "FeatureConstraints.h" #include "Menu.h" #include "Menu/Fonts.h" #include "RE/B/BSOpenVR.h" @@ -134,7 +135,7 @@ void VR::DataLoaded() // Initialize occlusion culling based on settings, but force-disable if an external // upscaler is active (FSR/DLSS) since upscalers may modify the depth buffer. bool desired = settings.EnableDepthBufferCullingExterior; - UpdateDepthBufferCulling(desired); + UpdateDepthBufferCulling(desired, { "VR", "EnableDepthBufferCullingExterior" }); if (gMinOccludeeBoxExtent) { *gMinOccludeeBoxExtent = settings.MinOccludeeBoxExtent; @@ -147,8 +148,10 @@ void VR::EarlyPrepass() { // Respect user settings unless an external upscaler is active; if so, force-disable // depth-buffer culling to avoid incorrect occlusion tests in VR. - bool desired = RE::TES::GetSingleton()->interiorCell ? settings.EnableDepthBufferCullingInterior : settings.EnableDepthBufferCullingExterior; - UpdateDepthBufferCulling(desired); + bool isInterior = RE::TES::GetSingleton()->interiorCell != nullptr; + auto settingId = isInterior ? FeatureConstraints::SettingId{ "VR", "EnableDepthBufferCullingInterior" } : FeatureConstraints::SettingId{ "VR", "EnableDepthBufferCullingExterior" }; + bool desired = isInterior ? settings.EnableDepthBufferCullingInterior : settings.EnableDepthBufferCullingExterior; + UpdateDepthBufferCulling(desired, settingId); } //============================================================================= @@ -1646,21 +1649,27 @@ void VR::SubmitOverlayFrame() } // Helper to centralize VR depth buffer culling logic, reducing duplication between DataLoaded and EarlyPrepass. -void VR::UpdateDepthBufferCulling(bool desired) +void VR::UpdateDepthBufferCulling(bool desired, const FeatureConstraints::SettingId& settingId) { // Check if any feature is constraining this setting - auto constraint = FeatureConstraints::GetConstraints({ "VR", "EnableDepthBufferCullingExterior" }); + auto constraint = FeatureConstraints::GetConstraints(settingId); if (constraint.isConstrained) { - bool forcedValue = std::get(constraint.forcedValue); - if (gDepthBufferCulling && *gDepthBufferCulling != forcedValue) { - *gDepthBufferCulling = forcedValue; - for (const auto& src : constraint.sources) { - logger::info("{} forcing depth buffer culling {}: {}", - src.featureName, - forcedValue ? "ON" : "OFF", - src.reason); + // Use std::get_if to safely extract bool value and avoid std::bad_variant_access + if (auto* forcedValuePtr = std::get_if(&constraint.forcedValue)) { + bool forcedValue = *forcedValuePtr; + if (gDepthBufferCulling && *gDepthBufferCulling != forcedValue) { + *gDepthBufferCulling = forcedValue; + for (const auto& src : constraint.sources) { + logger::info("{} forcing depth buffer culling {}: {}", + src.featureName, + forcedValue ? "ON" : "OFF", + src.reason); + } } + } else { + // Constraint has non-bool value type - log warning and skip + logger::warn("VR::UpdateDepthBufferCulling: Constraint on {} has non-bool forced value, ignoring", settingId.settingPath); } } else { if (gDepthBufferCulling && *gDepthBufferCulling != desired) { diff --git a/src/Features/VR.h b/src/Features/VR.h index cb80d0059c..8df2038dae 100644 --- a/src/Features/VR.h +++ b/src/Features/VR.h @@ -1,4 +1,5 @@ #pragma once +#include "FeatureConstraints.h" #include "Menu.h" #include "OverlayFeature.h" #include @@ -282,7 +283,7 @@ struct VR : OverlayFeature virtual void DataLoaded() override; virtual void EarlyPrepass() override; - void UpdateDepthBufferCulling(bool desired); + void UpdateDepthBufferCulling(bool desired, const FeatureConstraints::SettingId& settingId); virtual void LoadSettings(json& o_json) override; virtual void SaveSettings(json& o_json) override; diff --git a/src/Menu/FeatureListRenderer.cpp b/src/Menu/FeatureListRenderer.cpp index 5566467ad7..13acf67e01 100644 --- a/src/Menu/FeatureListRenderer.cpp +++ b/src/Menu/FeatureListRenderer.cpp @@ -9,6 +9,7 @@ #include #include "Feature.h" +#include "FeatureConstraints.h" #include "FeatureIssues.h" #include "Fonts.h" #include "Globals.h" @@ -409,12 +410,13 @@ void FeatureListRenderer::RenderLeftColumn( void FeatureListRenderer::RenderRightColumn( const std::vector& menuList, - size_t selectedMenu) + size_t selectedMenu, + std::string& pendingFeatureSelection) { ImGui::TableNextColumn(); if (selectedMenu < menuList.size()) { - std::visit(DrawMenuVisitor{}, menuList[selectedMenu]); + std::visit(DrawMenuVisitor{ pendingFeatureSelection }, menuList[selectedMenu]); } else { ImGui::TextDisabled("Please select an item on the left."); } @@ -556,6 +558,8 @@ void FeatureListRenderer::DrawMenuVisitor::operator()(Feature* feat) RenderRestoreDefaultsButton(feat, isDisabled, isLoaded); } ImGui::EndChild(); + // Render reactive constraint warning dialog if needed + RenderReactiveConstraintWarningDialog(); } bool FeatureListRenderer::DrawMenuVisitor::IsFeatureInstalled(const std::string& featureName) @@ -621,8 +625,19 @@ void FeatureListRenderer::DrawMenuVisitor::RenderFeatureHeader(Feature* feat, bo } if (Util::FeatureToggle("##BootToggle", &bootEnabled)) { - bool newState = feat->ToggleAtBootSetting(); - logger::info("{}: {} at boot.", featureName, newState ? "Enabled" : "Disabled"); + // Check if enabling this feature would create new constraints + auto potentialConstraints = FeatureConstraints::GetConstraintsFromEnablingFeature(feat); + + if (!potentialConstraints.empty()) { + // Show confirmation dialog + showConstraintConfirmation = true; + featureToEnable = feat; + constraintsToCreate = std::move(potentialConstraints); + } else { + // No constraints would be created, proceed normally + bool newState = feat->ToggleAtBootSetting(); + logger::info("{}: {} at boot.", featureName, newState ? "Enabled" : "Disabled"); + } } if (!feat->failedLoadedMessage.empty()) { @@ -685,10 +700,50 @@ void FeatureListRenderer::DrawMenuVisitor::RenderFeatureSettings(Feature* feat, ImGui::Separator(); } + // Capture constraints before drawing settings for reactive constraint detection + auto constraintsBefore = FeatureConstraints::GetAllActiveConstraints(); + logger::debug("Reactive constraint detection: {} constraints before DrawSettings() for {}", constraintsBefore.size(), feat->GetShortName()); + ImVec2 cursorPosBefore = ImGui::GetCursorPos(); feat->DrawSettings(); ImVec2 cursorPosAfter = ImGui::GetCursorPos(); + // Capture constraints after drawing settings + auto constraintsAfter = FeatureConstraints::GetAllActiveConstraints(); + logger::debug("Reactive constraint detection: {} constraints after DrawSettings() for {}", constraintsAfter.size(), feat->GetShortName()); + + // Detect new or changed constraints created by setting changes + std::vector> changedConstraints; + for (const auto& [settingId, newResult] : constraintsAfter) { + auto it = std::find_if(constraintsBefore.begin(), constraintsBefore.end(), + [&settingId](const auto& pair) { return pair.first == settingId; }); + if (it == constraintsBefore.end()) { + // New constraint added + changedConstraints.emplace_back(settingId, newResult); + } else { + // Check if existing constraint changed + const auto& oldResult = it->second; + if (oldResult.forcedValue != newResult.forcedValue || + oldResult.sources.size() != newResult.sources.size() || + !std::equal(oldResult.sources.begin(), oldResult.sources.end(), newResult.sources.begin(), + [](const auto& a, const auto& b) { return a.featureName == b.featureName && a.reason == b.reason; })) { + // Constraint changed + changedConstraints.emplace_back(settingId, newResult); + } + } + } + + // Show reactive constraint warning if constraints were created or changed + if (!changedConstraints.empty()) { + logger::info("Reactive constraint detection: {} constraints changed by {}", changedConstraints.size(), feat->GetShortName()); + for (const auto& [settingId, result] : changedConstraints) { + logger::info(" - {} ({}) forced to {}", settingId.settingPath, result.sources[0].featureName, FeatureConstraints::FormatConstraintValue(result.forcedValue)); + } + showReactiveConstraintWarning = true; + reactiveConstraintFeature = feat; + newReactiveConstraints = std::move(changedConstraints); + } + const float cursorEpsilon = 0.1f; bool cursorMoved = (std::abs(cursorPosAfter.x - cursorPosBefore.x) > cursorEpsilon || std::abs(cursorPosAfter.y - cursorPosBefore.y) > cursorEpsilon); @@ -763,4 +818,162 @@ void FeatureListRenderer::DrawMenuVisitor::RenderRestoreDefaultsButton(Feature* if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Restore default settings for this feature"); } +} + +void FeatureListRenderer::DrawMenuVisitor::RenderConstraintConfirmationDialog() +{ + if (!showConstraintConfirmation || !featureToEnable) { + return; + } + + ImGui::OpenPopup("Enable Feature Confirmation"); + + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSize(ImVec2(500, 0), ImGuiCond_Appearing); + + if (ImGui::BeginPopupModal("Enable Feature Confirmation", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextWrapped("Enabling %s will create the following setting constraints:", featureToEnable->GetName().c_str()); + ImGui::Spacing(); + ImGui::Separator(); + + if (ImGui::BeginTable("##ConstraintTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableSetupColumn("Feature", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Setting", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Forced Value", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + + size_t rowIndex = 0; + for (const auto& [settingId, result] : constraintsToCreate) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + if (ImGui::Selectable(fmt::format("{}##{}", result.sources[0].featureName, rowIndex).c_str())) { + pendingFeatureSelection = result.sources[0].featureName; + ImGui::CloseCurrentPopup(); + } + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Click to navigate to %s feature", result.sources[0].featureName.c_str()); + } + ImGui::TableSetColumnIndex(1); + ImGui::Text("%s", settingId.settingPath.c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::Text("%s", FeatureConstraints::FormatConstraintValue(result.forcedValue).c_str()); + rowIndex++; + } + + ImGui::EndTable(); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::TextWrapped("These settings will be disabled in their respective feature menus and forced to the specified values. You can re-enable the constrained features to remove these constraints."); + + ImGui::Spacing(); + + if (ImGui::Button("Confirm Enable", ImVec2(120, 0))) { + // Proceed with enabling the feature + bool newState = featureToEnable->ToggleAtBootSetting(); + logger::info("{}: {} at boot.", featureToEnable->GetShortName(), newState ? "Enabled" : "Disabled"); + + // Reset dialog state + showConstraintConfirmation = false; + featureToEnable = nullptr; + constraintsToCreate.clear(); + + ImGui::CloseCurrentPopup(); + } + + ImGui::SameLine(); + + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + // Reset dialog state without enabling + showConstraintConfirmation = false; + featureToEnable = nullptr; + constraintsToCreate.clear(); + + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); + } +} + +void FeatureListRenderer::DrawMenuVisitor::RenderReactiveConstraintWarningDialog() +{ + if (!showReactiveConstraintWarning || !reactiveConstraintFeature) { + return; + } + + // Only open the popup once when the flag is first set + static bool popupOpened = false; + if (!popupOpened) { + logger::info("Opening reactive constraint warning dialog for {}", reactiveConstraintFeature->GetName()); + ImGui::OpenPopup("Setting Change Warning"); + popupOpened = true; + } + + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSize(ImVec2(500, 0), ImGuiCond_Appearing); + + if (ImGui::BeginPopupModal("Setting Change Warning", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextWrapped("Your recent setting changes in %s have created the following constraints:", reactiveConstraintFeature->GetName().c_str()); + ImGui::Spacing(); + ImGui::Separator(); + + if (ImGui::BeginTable("##ReactiveConstraintTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableSetupColumn("Feature", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Setting", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Forced Value", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + + size_t rowIndex = 0; + for (const auto& [settingId, result] : newReactiveConstraints) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + if (ImGui::Selectable(fmt::format("{}##{}", result.sources[0].featureName, rowIndex).c_str())) { + pendingFeatureSelection = result.sources[0].featureName; + ImGui::CloseCurrentPopup(); + } + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Click to navigate to %s feature", result.sources[0].featureName.c_str()); + } + ImGui::TableSetColumnIndex(1); + ImGui::Text("%s", settingId.settingPath.c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::Text("%s", FeatureConstraints::FormatConstraintValue(result.forcedValue).c_str()); + rowIndex++; + } + + ImGui::EndTable(); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::TextWrapped("These settings are now disabled in their respective feature menus and forced to the specified values. You can adjust the constrained features to remove these constraints."); + + ImGui::Spacing(); + + if (ImGui::Button("OK", ImVec2(120, 0))) { + // Reset dialog state + showReactiveConstraintWarning = false; + reactiveConstraintFeature = nullptr; + newReactiveConstraints.clear(); + popupOpened = false; // Reset for next time + + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); + } else { + // Popup was closed externally, reset state + showReactiveConstraintWarning = false; + reactiveConstraintFeature = nullptr; + newReactiveConstraints.clear(); + popupOpened = false; + } } \ No newline at end of file diff --git a/src/Menu/FeatureListRenderer.h b/src/Menu/FeatureListRenderer.h index ff2d578e37..89f1729f69 100644 --- a/src/Menu/FeatureListRenderer.h +++ b/src/Menu/FeatureListRenderer.h @@ -48,17 +48,34 @@ class FeatureListRenderer struct DrawMenuVisitor { + explicit DrawMenuVisitor(std::string& pendingFeatureSelectionRef) : + pendingFeatureSelection(pendingFeatureSelectionRef) {} + void operator()(const BuiltInMenu& menu); void operator()(const std::string&); void operator()(const CategoryHeader&); void operator()(Feature* feat); private: + std::string& pendingFeatureSelection; + + // State for confirmation dialog + bool showConstraintConfirmation = false; + Feature* featureToEnable = nullptr; + std::vector> constraintsToCreate; + + // State for reactive constraint warning dialog + bool showReactiveConstraintWarning = false; + std::vector> newReactiveConstraints; + Feature* reactiveConstraintFeature = nullptr; + // Helper methods for Feature rendering static bool IsFeatureInstalled(const std::string& featureName); - static void RenderFeatureHeader(Feature* feat, bool isDisabled, bool isLoaded); - static void RenderFeatureSettings(Feature* feat, bool isDisabled, bool isLoaded, bool hasFailedMessage); + void RenderFeatureHeader(Feature* feat, bool isDisabled, bool isLoaded); + void RenderFeatureSettings(Feature* feat, bool isDisabled, bool isLoaded, bool hasFailedMessage); static void RenderRestoreDefaultsButton(Feature* feat, bool isDisabled, bool isLoaded); + void RenderConstraintConfirmationDialog(); + void RenderReactiveConstraintWarningDialog(); }; static std::vector BuildMenuList( @@ -80,5 +97,6 @@ class FeatureListRenderer static void RenderRightColumn( const std::vector& menuList, - size_t selectedMenu); + size_t selectedMenu, + std::string& pendingFeatureSelection); }; \ No newline at end of file diff --git a/src/Menu/HomePageRenderer.cpp b/src/Menu/HomePageRenderer.cpp index a3c8a82b13..8455bf3d54 100644 --- a/src/Menu/HomePageRenderer.cpp +++ b/src/Menu/HomePageRenderer.cpp @@ -9,6 +9,7 @@ #include "Plugin.h" #include "State.h" #include "Util.h" +#include "Utils/UI.h" // Static member definitions bool HomePageRenderer::isFirstTimeSetupShown = false; @@ -273,53 +274,73 @@ void HomePageRenderer::RenderActiveConstraintsSection() ImGui::Spacing(); - // Compact table format - if (ImGui::BeginTable("ConstraintsTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { - ImGui::TableSetupColumn("Setting", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Forced To", ImGuiTableColumnFlags_WidthFixed, 80.0f); - ImGui::TableSetupColumn("Constrained By", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableHeadersRow(); - - for (const auto& [settingId, result] : constraints) { - ImGui::TableNextRow(); - - // Setting column - ImGui::TableSetColumnIndex(0); - ImGui::TextColored(warningColor, "%s.%s", settingId.featureShortName.c_str(), settingId.settingPath.c_str()); - - // Forced value column - ImGui::TableSetColumnIndex(1); - ImGui::Text("%s", FeatureConstraints::FormatConstraintValue(result.forcedValue).c_str()); - - // Constrained by column - show feature names, tooltip has details - ImGui::TableSetColumnIndex(2); - std::string sourceNames; - for (size_t i = 0; i < result.sources.size(); ++i) { - if (i > 0) - sourceNames += ", "; - sourceNames += result.sources[i].featureName; - } - ImGui::Text("%s", sourceNames.c_str()); - - // Tooltip with full details on row hover - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::PushTextWrapPos(ImGui::GetFontSize() * 25.0f); - for (const auto& src : result.sources) { - ImGui::TextColored(warningColor, "%s:", src.featureName.c_str()); - ImGui::TextWrapped("%s", src.reason.c_str()); - if (src.recommendDisableAtBoot) { - ImVec4 errorColor = menu ? menu->GetTheme().StatusPalette.Error : ImVec4(1.0f, 0.4f, 0.4f, 1.0f); - ImGui::TextColored(errorColor, "Consider disabling at boot."); - } - ImGui::Spacing(); - } - ImGui::PopTextWrapPos(); - ImGui::EndTooltip(); + // Prepare data for table + struct ConstraintRow + { + std::string setting; + std::string forcedTo; + std::string constrainedBy; + std::string tooltip; + }; + + std::vector rows; + for (const auto& [settingId, result] : constraints) { + ConstraintRow row; + row.setting = std::format("{}.{}", settingId.featureShortName, settingId.settingPath); + row.forcedTo = FeatureConstraints::FormatConstraintValue(result.forcedValue); + for (size_t i = 0; i < result.sources.size(); ++i) { + if (i > 0) + row.constrainedBy += ", "; + row.constrainedBy += result.sources[i].featureName; + } + // Build tooltip + for (const auto& src : result.sources) { + if (!row.tooltip.empty()) + row.tooltip += "\n"; + row.tooltip += std::format("{}: {}", src.featureName, src.reason); + if (src.recommendDisableAtBoot) { + row.tooltip += "\nConsider disabling at boot."; } } - ImGui::EndTable(); + rows.push_back(row); } + + // Define headers + std::vector headers = { "Setting", "Forced To", "Constrained By" }; + + // Custom sorts (string comparators for each column) + std::vector> customSorts = { + [](const ConstraintRow& a, const ConstraintRow& b, bool asc) { return Util::StringSortComparator(a.setting, b.setting, asc); }, + [](const ConstraintRow& a, const ConstraintRow& b, bool asc) { return Util::StringSortComparator(a.forcedTo, b.forcedTo, asc); }, + [](const ConstraintRow& a, const ConstraintRow& b, bool asc) { return Util::StringSortComparator(a.constrainedBy, b.constrainedBy, asc); } + }; + + // Cell render + auto cellRender = [warningColor](int, int colIdx, const ConstraintRow& row) { + std::string value; + std::string tooltip; + ImVec4 textColor = ImVec4(0, 0, 0, 0); + if (colIdx == 0) { + value = row.setting; + textColor = warningColor; + } else if (colIdx == 1) { + value = row.forcedTo; + } else if (colIdx == 2) { + value = row.constrainedBy; + tooltip = row.tooltip; + } + Util::RenderTableCell(value, "", tooltip, nullptr, ImVec4(1, 1, 1, 1), true, textColor); + }; + + // Render table + Util::ShowSortedStringTableCustom( + "ConstraintsTable", + headers, + rows, + 0, // sortColumn + true, // ascending + customSorts, + cellRender); } ImGui::Spacing(); diff --git a/src/Utils/UI.cpp b/src/Utils/UI.cpp index 6a46c66168..71173f5d07 100644 --- a/src/Utils/UI.cpp +++ b/src/Utils/UI.cpp @@ -1704,45 +1704,58 @@ namespace Util namespace ConstrainedUI { - bool Checkbox(const char* label, bool* value, const FeatureConstraints::SettingId& settingId) + namespace { - auto constraint = FeatureConstraints::GetConstraints(settingId); - - if (constraint.isConstrained) { - ImGui::BeginDisabled(); + // Helper to render constraint tooltip + void RenderConstraintTooltip(const FeatureConstraints::ConstraintResult& constraint) + { + if (!ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + return; + + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Setting Constrained"); + ImGui::Text("This setting is constrained by:"); + ImGui::Spacing(); + for (const auto& src : constraint.sources) { + ImGui::BulletText("%s", src.featureName.c_str()); + ImGui::Indent(); + ImGui::TextWrapped("%s", src.reason.c_str()); + if (src.recommendDisableAtBoot) { + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), + "Consider disabling this feature at boot for best compatibility."); + } + ImGui::Unindent(); + } + ImGui::Separator(); + ImGui::Text("Forced value: %s", FeatureConstraints::FormatConstraintValue(constraint.forcedValue).c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); } + } - bool changed = ImGui::Checkbox(label, value); + bool Checkbox(const char* label, bool* value, const FeatureConstraints::SettingId& settingId) + { + auto constraint = FeatureConstraints::GetConstraints(settingId); if (constraint.isConstrained) { - ImGui::EndDisabled(); - - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { - ImGui::BeginTooltip(); - ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Setting Constrained"); - ImGui::Text("This setting is constrained by:"); - ImGui::Spacing(); - for (const auto& src : constraint.sources) { - ImGui::BulletText("%s", src.featureName.c_str()); - ImGui::Indent(); - ImGui::TextWrapped("%s", src.reason.c_str()); - if (src.recommendDisableAtBoot) { - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), - "Consider disabling this feature at boot for best compatibility."); - } - ImGui::Unindent(); - } - ImGui::Separator(); - ImGui::Text("Forced value: %s", FeatureConstraints::FormatConstraintValue(constraint.forcedValue).c_str()); - ImGui::PopTextWrapPos(); - ImGui::EndTooltip(); + // Display the forced value instead of the stored value + if (auto* forcedBool = std::get_if(&constraint.forcedValue)) { + bool displayValue = *forcedBool; + ImGui::BeginDisabled(); + ImGui::Checkbox(label, &displayValue); + ImGui::EndDisabled(); + RenderConstraintTooltip(constraint); + } else { + // Fallback: wrong type, show disabled with stored value + ImGui::BeginDisabled(); + ImGui::Checkbox(label, value); + ImGui::EndDisabled(); } - return false; } - return changed; + return ImGui::Checkbox(label, value); } bool SliderFloat(const char* label, float* value, float min, float max, @@ -1751,40 +1764,23 @@ namespace Util auto constraint = FeatureConstraints::GetConstraints(settingId); if (constraint.isConstrained) { - ImGui::BeginDisabled(); - } - - bool changed = ImGui::SliderFloat(label, value, min, max, format); - - if (constraint.isConstrained) { - ImGui::EndDisabled(); - - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { - ImGui::BeginTooltip(); - ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Setting Constrained"); - ImGui::Text("This setting is constrained by:"); - ImGui::Spacing(); - for (const auto& src : constraint.sources) { - ImGui::BulletText("%s", src.featureName.c_str()); - ImGui::Indent(); - ImGui::TextWrapped("%s", src.reason.c_str()); - if (src.recommendDisableAtBoot) { - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), - "Consider disabling this feature at boot for best compatibility."); - } - ImGui::Unindent(); - } - ImGui::Separator(); - ImGui::Text("Forced value: %s", FeatureConstraints::FormatConstraintValue(constraint.forcedValue).c_str()); - ImGui::PopTextWrapPos(); - ImGui::EndTooltip(); + // Display the forced value instead of the stored value + if (auto* forcedFloat = std::get_if(&constraint.forcedValue)) { + float displayValue = *forcedFloat; + ImGui::BeginDisabled(); + ImGui::SliderFloat(label, &displayValue, min, max, format); + ImGui::EndDisabled(); + RenderConstraintTooltip(constraint); + } else { + // Fallback: wrong type, show disabled with stored value + ImGui::BeginDisabled(); + ImGui::SliderFloat(label, value, min, max, format); + ImGui::EndDisabled(); } - return false; } - return changed; + return ImGui::SliderFloat(label, value, min, max, format); } bool SliderInt(const char* label, int* value, int min, int max, @@ -1793,40 +1789,23 @@ namespace Util auto constraint = FeatureConstraints::GetConstraints(settingId); if (constraint.isConstrained) { - ImGui::BeginDisabled(); - } - - bool changed = ImGui::SliderInt(label, value, min, max, format); - - if (constraint.isConstrained) { - ImGui::EndDisabled(); - - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { - ImGui::BeginTooltip(); - ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Setting Constrained"); - ImGui::Text("This setting is constrained by:"); - ImGui::Spacing(); - for (const auto& src : constraint.sources) { - ImGui::BulletText("%s", src.featureName.c_str()); - ImGui::Indent(); - ImGui::TextWrapped("%s", src.reason.c_str()); - if (src.recommendDisableAtBoot) { - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), - "Consider disabling this feature at boot for best compatibility."); - } - ImGui::Unindent(); - } - ImGui::Separator(); - ImGui::Text("Forced value: %s", FeatureConstraints::FormatConstraintValue(constraint.forcedValue).c_str()); - ImGui::PopTextWrapPos(); - ImGui::EndTooltip(); + // Display the forced value instead of the stored value + if (auto* forcedInt = std::get_if(&constraint.forcedValue)) { + int displayValue = *forcedInt; + ImGui::BeginDisabled(); + ImGui::SliderInt(label, &displayValue, min, max, format); + ImGui::EndDisabled(); + RenderConstraintTooltip(constraint); + } else { + // Fallback: wrong type, show disabled with stored value + ImGui::BeginDisabled(); + ImGui::SliderInt(label, value, min, max, format); + ImGui::EndDisabled(); } - return false; } - return changed; + return ImGui::SliderInt(label, value, min, max, format); } } From 4548307046c6dbb0ca71a32bb4be451fd6b334d5 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Tue, 3 Feb 2026 18:39:24 -0800 Subject: [PATCH 3/3] feat: add confirmation dialogue --- src/FeatureConstraints.cpp | 94 +--------- src/FeatureConstraints.h | 25 +-- src/Menu.cpp | 1 + src/Menu.h | 1 + src/Menu/FeatureListRenderer.cpp | 311 +++++++++++++++---------------- src/Menu/FeatureListRenderer.h | 11 -- src/Menu/HomePageRenderer.cpp | 36 ++-- 7 files changed, 186 insertions(+), 293 deletions(-) diff --git a/src/FeatureConstraints.cpp b/src/FeatureConstraints.cpp index 7de185778b..4192045214 100644 --- a/src/FeatureConstraints.cpp +++ b/src/FeatureConstraints.cpp @@ -19,8 +19,16 @@ namespace FeatureConstraints if (!result.isConstrained) { result.isConstrained = true; result.forcedValue = constraint.forcedValue; + } else if (constraint.forcedValue != result.forcedValue) { + // Two features disagree on the forced value; first one wins. + // Log once so it surfaces during development / testing. + logger::warn("[FeatureConstraints] Conflict on {}.{}: {} wants {}, but {} already forced {}", + setting.featureShortName, setting.settingPath, + feature->GetName(), FormatConstraintValue(constraint.forcedValue), + result.sources[0].featureName, FormatConstraintValue(result.forcedValue)); } result.sources.push_back({ feature->GetName(), + feature->GetShortName(), constraint.reason, constraint.recommendDisableAtBoot }); } @@ -54,92 +62,6 @@ namespace FeatureConstraints return allConstraints; } - /** - * @brief Get constraints that would be created by enabling a specific feature - * @param featureToEnable The feature that would be enabled - * @return Vector of setting IDs and constraint results that would be created - */ - std::vector> GetConstraintsFromEnablingFeature(Feature* featureToEnable) - { - std::vector> newConstraints; - std::unordered_set processedKeys; - - // Get constraints from the feature we're enabling - auto constraints = featureToEnable->GetActiveConstraints(); - for (const auto& constraint : constraints) { - std::string key = constraint.targetSetting.featureShortName + "|" + constraint.targetSetting.settingPath; - if (processedKeys.insert(key).second) { - // Check if this setting is already constrained by other features - auto existingResult = GetConstraints(constraint.targetSetting); - if (!existingResult.isConstrained) { - // This constraint would be new - ConstraintResult newResult; - newResult.isConstrained = true; - newResult.forcedValue = constraint.forcedValue; - newResult.sources.push_back({ featureToEnable->GetName(), - constraint.reason, - constraint.recommendDisableAtBoot }); - newConstraints.push_back({ constraint.targetSetting, newResult }); - } - } - } - - return newConstraints; - } - - /** - * @brief Get constraints that would be created by a setting change - * @param feature The feature whose setting is changing - * @param applyChange Function to apply the setting change temporarily - * @param revertChange Function to revert the setting change - * @return Vector of setting IDs and constraint results that would be created by the change - */ - std::vector> GetConstraintsFromSettingChange( - Feature* feature, - const std::function& applyChange, - const std::function& revertChange) - { - std::vector> newConstraints; - std::unordered_set processedKeys; - - // Get current constraints from this feature - auto currentConstraints = feature->GetActiveConstraints(); - std::unordered_set currentKeys; - for (const auto& constraint : currentConstraints) { - currentKeys.insert(constraint.targetSetting.featureShortName + "|" + constraint.targetSetting.settingPath); - } - - // Apply the setting change temporarily - applyChange(); - - // Get new constraints after the change - auto newConstraintsFromFeature = feature->GetActiveConstraints(); - - // Revert the change - revertChange(); - - // Find constraints that would be newly created - for (const auto& constraint : newConstraintsFromFeature) { - std::string key = constraint.targetSetting.featureShortName + "|" + constraint.targetSetting.settingPath; - if (processedKeys.insert(key).second && currentKeys.find(key) == currentKeys.end()) { - // This constraint would be new - check if the setting is already constrained by other features - auto existingResult = GetConstraints(constraint.targetSetting); - if (!existingResult.isConstrained) { - // This constraint would be newly created - ConstraintResult newResult; - newResult.isConstrained = true; - newResult.forcedValue = constraint.forcedValue; - newResult.sources.push_back({ feature->GetName(), - constraint.reason, - constraint.recommendDisableAtBoot }); - newConstraints.push_back({ constraint.targetSetting, newResult }); - } - } - } - - return newConstraints; - } - std::string BuildConstraintTooltip(const ConstraintResult& result) { if (!result.isConstrained || result.sources.empty()) diff --git a/src/FeatureConstraints.h b/src/FeatureConstraints.h index 9d516f9b95..8437025f5b 100644 --- a/src/FeatureConstraints.h +++ b/src/FeatureConstraints.h @@ -1,12 +1,9 @@ #pragma once -#include #include #include #include -struct Feature; - namespace FeatureConstraints { /** @@ -44,7 +41,8 @@ namespace FeatureConstraints struct Source { - std::string featureName; + std::string featureName; // Display name (e.g. "Terrain Blending") + std::string featureShortName; // Menu navigation key (e.g. "TerrainBlending") std::string reason; bool recommendDisableAtBoot; }; @@ -76,25 +74,6 @@ namespace FeatureConstraints */ std::vector> GetAllActiveConstraints(); - /** - * @brief Get constraints that would be created by enabling a specific feature - * @param featureToEnable The feature that would be enabled - * @return Vector of setting IDs and constraint results that would be created - */ - std::vector> GetConstraintsFromEnablingFeature(Feature* featureToEnable); - - /** - * @brief Get constraints that would be created by a setting change - * @param feature The feature whose setting is changing - * @param applyChange Function to apply the setting change temporarily - * @param revertChange Function to revert the setting change - * @return Vector of setting IDs and constraint results that would be created by the change - */ - std::vector> GetConstraintsFromSettingChange( - Feature* feature, - const std::function& applyChange, - const std::function& revertChange); - /** * @brief Build a formatted tooltip string for a constrained setting * @param result The constraint result to format diff --git a/src/Menu.cpp b/src/Menu.cpp index 8883fcb1e0..ea9095064e 100644 --- a/src/Menu.cpp +++ b/src/Menu.cpp @@ -156,6 +156,7 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT( FirstTimeSetupCompleted, SkipClearCacheConfirmation, AutoHideFeatureList, + SkipConstraintWarning, Theme, SelectedThemePreset) diff --git a/src/Menu.h b/src/Menu.h index f4869f9cb3..cd6c199819 100644 --- a/src/Menu.h +++ b/src/Menu.h @@ -385,6 +385,7 @@ class Menu bool FirstTimeSetupCompleted = false; // Track if first-time setup has been completed bool SkipClearCacheConfirmation = false; // Skip confirmation dialog when clearing shader cache bool AutoHideFeatureList = false; // Auto-hide left feature list panel, show on hover + bool SkipConstraintWarning = false; // Skip popup when a setting change creates new constraints ThemeSettings Theme; std::string SelectedThemePreset = ""; // Currently selected theme preset (empty = custom/user theme) }; diff --git a/src/Menu/FeatureListRenderer.cpp b/src/Menu/FeatureListRenderer.cpp index 13acf67e01..0b2a06f392 100644 --- a/src/Menu/FeatureListRenderer.cpp +++ b/src/Menu/FeatureListRenderer.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include "Feature.h" #include "FeatureConstraints.h" @@ -194,6 +195,25 @@ namespace return titleOnlyHeight; } + + // --------------------------------------------------------------------------- + // Persistent state for the reactive constraint warning popup. + // DrawMenuVisitor is reconstructed every frame (it's a temporary passed to + // std::visit), so member state is lost immediately. These file-scope + // variables survive across frames so the popup can actually render. + // --------------------------------------------------------------------------- + + // Set of constraint keys we have already "seen" (and therefore warned about + // or suppressed). Keyed as "featureShortName|settingPath". + std::unordered_set g_knownConstraintKeys; + bool g_knownConstraintKeysInitialised = false; + + // Pending popup state: non-empty when we have new constraints to show. + bool g_reactiveWarningShow = false; + std::vector> g_reactiveWarningConstraints; + + // "Don't show again" checkbox state inside the modal (reset each time popup opens). + bool g_dontShowAgainCheckbox = false; } void FeatureListRenderer::RenderFeatureList( @@ -221,11 +241,11 @@ void FeatureListRenderer::RenderFeatureList( ImGui::TableSetupColumn("##ListOfMenus", 0, 2); ImGui::TableSetupColumn("##MenuConfig", 0, 8); RenderLeftColumn(menuList, selectedMenu, featureSearch, categoryExpansionStates); - RenderRightColumn(menuList, selectedMenu); + RenderRightColumn(menuList, selectedMenu, pendingFeatureSelection); } else { // When left panel is hidden, right column takes full width ImGui::TableSetupColumn("##MenuConfig", 0, 1); - RenderRightColumn(menuList, selectedMenu); + RenderRightColumn(menuList, selectedMenu, pendingFeatureSelection); } ImGui::EndTable(); @@ -558,7 +578,7 @@ void FeatureListRenderer::DrawMenuVisitor::operator()(Feature* feat) RenderRestoreDefaultsButton(feat, isDisabled, isLoaded); } ImGui::EndChild(); - // Render reactive constraint warning dialog if needed + // Render reactive constraint warning outside the child window so it can appear as a top-level popup RenderReactiveConstraintWarningDialog(); } @@ -625,19 +645,8 @@ void FeatureListRenderer::DrawMenuVisitor::RenderFeatureHeader(Feature* feat, bo } if (Util::FeatureToggle("##BootToggle", &bootEnabled)) { - // Check if enabling this feature would create new constraints - auto potentialConstraints = FeatureConstraints::GetConstraintsFromEnablingFeature(feat); - - if (!potentialConstraints.empty()) { - // Show confirmation dialog - showConstraintConfirmation = true; - featureToEnable = feat; - constraintsToCreate = std::move(potentialConstraints); - } else { - // No constraints would be created, proceed normally - bool newState = feat->ToggleAtBootSetting(); - logger::info("{}: {} at boot.", featureName, newState ? "Enabled" : "Disabled"); - } + bool newState = feat->ToggleAtBootSetting(); + logger::info("{}: {} at boot.", featureName, newState ? "Enabled" : "Disabled"); } if (!feat->failedLoadedMessage.empty()) { @@ -700,50 +709,52 @@ void FeatureListRenderer::DrawMenuVisitor::RenderFeatureSettings(Feature* feat, ImGui::Separator(); } - // Capture constraints before drawing settings for reactive constraint detection - auto constraintsBefore = FeatureConstraints::GetAllActiveConstraints(); - logger::debug("Reactive constraint detection: {} constraints before DrawSettings() for {}", constraintsBefore.size(), feat->GetShortName()); - ImVec2 cursorPosBefore = ImGui::GetCursorPos(); feat->DrawSettings(); ImVec2 cursorPosAfter = ImGui::GetCursorPos(); - // Capture constraints after drawing settings - auto constraintsAfter = FeatureConstraints::GetAllActiveConstraints(); - logger::debug("Reactive constraint detection: {} constraints after DrawSettings() for {}", constraintsAfter.size(), feat->GetShortName()); - - // Detect new or changed constraints created by setting changes - std::vector> changedConstraints; - for (const auto& [settingId, newResult] : constraintsAfter) { - auto it = std::find_if(constraintsBefore.begin(), constraintsBefore.end(), - [&settingId](const auto& pair) { return pair.first == settingId; }); - if (it == constraintsBefore.end()) { - // New constraint added - changedConstraints.emplace_back(settingId, newResult); + // --- Reactive constraint detection --- + // Compare the current full constraint set against g_knownConstraintKeys. + // On the very first frame we just seed the set (no popup); after that + // any key that wasn't previously known triggers the warning. + // This catches both same-frame changes (e.g. TerrainBlending toggle) + // and next-frame changes (e.g. Upscaling, whose resolutionScale is + // updated in the render loop, not in DrawSettings). + if (!g_reactiveWarningShow) { // don't overwrite a pending popup + auto currentConstraints = FeatureConstraints::GetAllActiveConstraints(); + + if (!g_knownConstraintKeysInitialised) { + // First time: seed known set, no popup + for (const auto& [settingId, result] : currentConstraints) { + g_knownConstraintKeys.insert(settingId.featureShortName + "|" + settingId.settingPath); + } + g_knownConstraintKeysInitialised = true; } else { - // Check if existing constraint changed - const auto& oldResult = it->second; - if (oldResult.forcedValue != newResult.forcedValue || - oldResult.sources.size() != newResult.sources.size() || - !std::equal(oldResult.sources.begin(), oldResult.sources.end(), newResult.sources.begin(), - [](const auto& a, const auto& b) { return a.featureName == b.featureName && a.reason == b.reason; })) { - // Constraint changed - changedConstraints.emplace_back(settingId, newResult); + // Diff: find keys present now but not previously known + std::vector> newConstraints; + std::unordered_set currentKeys; + for (const auto& [settingId, result] : currentConstraints) { + std::string key = settingId.featureShortName + "|" + settingId.settingPath; + currentKeys.insert(key); + if (g_knownConstraintKeys.find(key) == g_knownConstraintKeys.end()) { + newConstraints.emplace_back(settingId, result); + } + } + // Update known set to current (removes keys for constraints that went away) + g_knownConstraintKeys = std::move(currentKeys); + + if (!newConstraints.empty() && !globals::menu->GetSettings().SkipConstraintWarning) { + logger::info("Reactive constraint detection: {} new constraints", newConstraints.size()); + for (const auto& [settingId, result] : newConstraints) { + logger::info(" - {}.{} forced to {} by {}", settingId.featureShortName, settingId.settingPath, FeatureConstraints::FormatConstraintValue(result.forcedValue), result.sources.empty() ? "?" : result.sources[0].featureName); + } + g_reactiveWarningShow = true; + g_reactiveWarningConstraints = std::move(newConstraints); + g_dontShowAgainCheckbox = false; } } } - // Show reactive constraint warning if constraints were created or changed - if (!changedConstraints.empty()) { - logger::info("Reactive constraint detection: {} constraints changed by {}", changedConstraints.size(), feat->GetShortName()); - for (const auto& [settingId, result] : changedConstraints) { - logger::info(" - {} ({}) forced to {}", settingId.settingPath, result.sources[0].featureName, FeatureConstraints::FormatConstraintValue(result.forcedValue)); - } - showReactiveConstraintWarning = true; - reactiveConstraintFeature = feat; - newReactiveConstraints = std::move(changedConstraints); - } - const float cursorEpsilon = 0.1f; bool cursorMoved = (std::abs(cursorPosAfter.x - cursorPosBefore.x) > cursorEpsilon || std::abs(cursorPosAfter.y - cursorPosBefore.y) > cursorEpsilon); @@ -820,44 +831,93 @@ void FeatureListRenderer::DrawMenuVisitor::RenderRestoreDefaultsButton(Feature* } } -void FeatureListRenderer::DrawMenuVisitor::RenderConstraintConfirmationDialog() +void FeatureListRenderer::DrawMenuVisitor::RenderReactiveConstraintWarningDialog() { - if (!showConstraintConfirmation || !featureToEnable) { + if (!g_reactiveWarningShow) { return; } - ImGui::OpenPopup("Enable Feature Confirmation"); + // OpenPopup is idempotent while the popup is already open, so calling it + // every frame while the flag is set is safe and ensures we don't miss the + // one-frame window where ImGui expects it. + ImGui::OpenPopup("Setting Change Warning"); + // Center the popup (ImGuiCond_Always matches the Clear Cache dialog pattern) ImVec2 center = ImGui::GetMainViewport()->GetCenter(); - ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); - ImGui::SetNextWindowSize(ImVec2(500, 0), ImGuiCond_Appearing); + ImGui::SetNextWindowPos(center, ImGuiCond_Always, ImVec2(0.5f, 0.5f)); - if (ImGui::BeginPopupModal("Enable Feature Confirmation", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::TextWrapped("Enabling %s will create the following setting constraints:", featureToEnable->GetName().c_str()); + if (ImGui::BeginPopupModal("Setting Change Warning", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextWrapped("Some of your settings have been automatically adjusted due to feature incompatibilities."); ImGui::Spacing(); ImGui::Separator(); + ImGui::Spacing(); - if (ImGui::BeginTable("##ConstraintTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { - ImGui::TableSetupColumn("Feature", ImGuiTableColumnFlags_WidthStretch); + // Table columns: Impacted Feature | Setting | Constrained By | Forced To + if (ImGui::BeginTable("##ReactiveConstraintTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableSetupColumn("Impacted Feature", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Setting", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Forced Value", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Constrained By", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Forced To", ImGuiTableColumnFlags_WidthStretch); ImGui::TableHeadersRow(); size_t rowIndex = 0; - for (const auto& [settingId, result] : constraintsToCreate) { + for (const auto& [settingId, result] : g_reactiveWarningConstraints) { ImGui::TableNextRow(); + + // --- Column 0: Impacted Feature (clickable -> navigate to that feature) --- ImGui::TableSetColumnIndex(0); - if (ImGui::Selectable(fmt::format("{}##{}", result.sources[0].featureName, rowIndex).c_str())) { - pendingFeatureSelection = result.sources[0].featureName; - ImGui::CloseCurrentPopup(); - } - if (auto _tt = Util::HoverTooltipWrapper()) { - ImGui::Text("Click to navigate to %s feature", result.sources[0].featureName.c_str()); + { + // Look up the display name of the target feature from its short name + std::string targetDisplayName = settingId.featureShortName; + for (auto* f : Feature::GetFeatureList()) { + if (f->GetShortName() == settingId.featureShortName) { + targetDisplayName = f->GetName(); + break; + } + } + if (ImGui::Selectable(fmt::format("{}##imp{}", targetDisplayName, rowIndex).c_str())) { + pendingFeatureSelection = settingId.featureShortName; + ImGui::CloseCurrentPopup(); + g_reactiveWarningShow = false; + g_reactiveWarningConstraints.clear(); + return; + } + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Click to navigate to %s", targetDisplayName.c_str()); + } } + + // --- Column 1: Setting name --- ImGui::TableSetColumnIndex(1); ImGui::Text("%s", settingId.settingPath.c_str()); + + // --- Column 2: Constrained By (source features, clickable) --- ImGui::TableSetColumnIndex(2); + if (!result.sources.empty()) { + if (ImGui::Selectable(fmt::format("{}##src{}", result.sources[0].featureName, rowIndex).c_str())) { + pendingFeatureSelection = result.sources[0].featureShortName; + ImGui::CloseCurrentPopup(); + g_reactiveWarningShow = false; + g_reactiveWarningConstraints.clear(); + return; + } + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Click to navigate to %s", result.sources[0].featureName.c_str()); + if (result.sources.size() > 1) { + ImGui::Separator(); + for (size_t i = 1; i < result.sources.size(); ++i) { + ImGui::Text("Also: %s", result.sources[i].featureName.c_str()); + } + } + ImGui::Separator(); + ImGui::Text("%s", result.sources[0].reason.c_str()); + } + } + + // --- Column 3: Forced value --- + ImGui::TableSetColumnIndex(3); ImGui::Text("%s", FeatureConstraints::FormatConstraintValue(result.forcedValue).c_str()); + rowIndex++; } @@ -868,112 +928,39 @@ void FeatureListRenderer::DrawMenuVisitor::RenderConstraintConfirmationDialog() ImGui::Separator(); ImGui::Spacing(); - ImGui::TextWrapped("These settings will be disabled in their respective feature menus and forced to the specified values. You can re-enable the constrained features to remove these constraints."); + ImGui::TextWrapped( + "These settings are disabled in their respective feature menus while the constraints are active. " + "Adjust the constraining features to remove them."); ImGui::Spacing(); - if (ImGui::Button("Confirm Enable", ImVec2(120, 0))) { - // Proceed with enabling the feature - bool newState = featureToEnable->ToggleAtBootSetting(); - logger::info("{}: {} at boot.", featureToEnable->GetShortName(), newState ? "Enabled" : "Disabled"); - - // Reset dialog state - showConstraintConfirmation = false; - featureToEnable = nullptr; - constraintsToCreate.clear(); - - ImGui::CloseCurrentPopup(); - } - - ImGui::SameLine(); - - if (ImGui::Button("Cancel", ImVec2(120, 0))) { - // Reset dialog state without enabling - showConstraintConfirmation = false; - featureToEnable = nullptr; - constraintsToCreate.clear(); - - ImGui::CloseCurrentPopup(); - } - - ImGui::EndPopup(); - } -} - -void FeatureListRenderer::DrawMenuVisitor::RenderReactiveConstraintWarningDialog() -{ - if (!showReactiveConstraintWarning || !reactiveConstraintFeature) { - return; - } - - // Only open the popup once when the flag is first set - static bool popupOpened = false; - if (!popupOpened) { - logger::info("Opening reactive constraint warning dialog for {}", reactiveConstraintFeature->GetName()); - ImGui::OpenPopup("Setting Change Warning"); - popupOpened = true; - } - - ImVec2 center = ImGui::GetMainViewport()->GetCenter(); - ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); - ImGui::SetNextWindowSize(ImVec2(500, 0), ImGuiCond_Appearing); + // "Don't show again" checkbox -- same pattern as Clear Cache dialog + ImGui::Checkbox("Don't show this warning again", &g_dontShowAgainCheckbox); - if (ImGui::BeginPopupModal("Setting Change Warning", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::TextWrapped("Your recent setting changes in %s have created the following constraints:", reactiveConstraintFeature->GetName().c_str()); ImGui::Spacing(); - ImGui::Separator(); - if (ImGui::BeginTable("##ReactiveConstraintTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { - ImGui::TableSetupColumn("Feature", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Setting", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Forced Value", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableHeadersRow(); - - size_t rowIndex = 0; - for (const auto& [settingId, result] : newReactiveConstraints) { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - if (ImGui::Selectable(fmt::format("{}##{}", result.sources[0].featureName, rowIndex).c_str())) { - pendingFeatureSelection = result.sources[0].featureName; - ImGui::CloseCurrentPopup(); - } - if (auto _tt = Util::HoverTooltipWrapper()) { - ImGui::Text("Click to navigate to %s feature", result.sources[0].featureName.c_str()); + // Centered OK button + constexpr float buttonWidth = ThemeManager::Constants::POPUP_BUTTON_WIDTH; + const float windowWidth = ImGui::GetWindowWidth(); + const float offset = (windowWidth - buttonWidth) * 0.5f; + if (offset > 0) + ImGui::SetCursorPosX(offset); + + if (ImGui::Button("OK", ImVec2(buttonWidth, 0))) { + if (g_dontShowAgainCheckbox) { + if (auto* menu = globals::menu) { + menu->GetSettings().SkipConstraintWarning = true; } - ImGui::TableSetColumnIndex(1); - ImGui::Text("%s", settingId.settingPath.c_str()); - ImGui::TableSetColumnIndex(2); - ImGui::Text("%s", FeatureConstraints::FormatConstraintValue(result.forcedValue).c_str()); - rowIndex++; } - - ImGui::EndTable(); - } - - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - - ImGui::TextWrapped("These settings are now disabled in their respective feature menus and forced to the specified values. You can adjust the constrained features to remove these constraints."); - - ImGui::Spacing(); - - if (ImGui::Button("OK", ImVec2(120, 0))) { - // Reset dialog state - showReactiveConstraintWarning = false; - reactiveConstraintFeature = nullptr; - newReactiveConstraints.clear(); - popupOpened = false; // Reset for next time - + g_reactiveWarningShow = false; + g_reactiveWarningConstraints.clear(); ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); } else { - // Popup was closed externally, reset state - showReactiveConstraintWarning = false; - reactiveConstraintFeature = nullptr; - newReactiveConstraints.clear(); - popupOpened = false; + // Popup was closed externally (e.g. clicked outside), reset state + g_reactiveWarningShow = false; + g_reactiveWarningConstraints.clear(); } } \ No newline at end of file diff --git a/src/Menu/FeatureListRenderer.h b/src/Menu/FeatureListRenderer.h index 89f1729f69..13289371df 100644 --- a/src/Menu/FeatureListRenderer.h +++ b/src/Menu/FeatureListRenderer.h @@ -59,22 +59,11 @@ class FeatureListRenderer private: std::string& pendingFeatureSelection; - // State for confirmation dialog - bool showConstraintConfirmation = false; - Feature* featureToEnable = nullptr; - std::vector> constraintsToCreate; - - // State for reactive constraint warning dialog - bool showReactiveConstraintWarning = false; - std::vector> newReactiveConstraints; - Feature* reactiveConstraintFeature = nullptr; - // Helper methods for Feature rendering static bool IsFeatureInstalled(const std::string& featureName); void RenderFeatureHeader(Feature* feat, bool isDisabled, bool isLoaded); void RenderFeatureSettings(Feature* feat, bool isDisabled, bool isLoaded, bool hasFailedMessage); static void RenderRestoreDefaultsButton(Feature* feat, bool isDisabled, bool isLoaded); - void RenderConstraintConfirmationDialog(); void RenderReactiveConstraintWarningDialog(); }; diff --git a/src/Menu/HomePageRenderer.cpp b/src/Menu/HomePageRenderer.cpp index 8455bf3d54..85840d815f 100644 --- a/src/Menu/HomePageRenderer.cpp +++ b/src/Menu/HomePageRenderer.cpp @@ -280,6 +280,7 @@ void HomePageRenderer::RenderActiveConstraintsSection() std::string setting; std::string forcedTo; std::string constrainedBy; + std::string firstSourceShortName; // For "navigate to feature" on click std::string tooltip; }; @@ -293,6 +294,9 @@ void HomePageRenderer::RenderActiveConstraintsSection() row.constrainedBy += ", "; row.constrainedBy += result.sources[i].featureName; } + if (!result.sources.empty()) { + row.firstSourceShortName = result.sources[0].featureShortName; + } // Build tooltip for (const auto& src : result.sources) { if (!row.tooltip.empty()) @@ -315,21 +319,31 @@ void HomePageRenderer::RenderActiveConstraintsSection() [](const ConstraintRow& a, const ConstraintRow& b, bool asc) { return Util::StringSortComparator(a.constrainedBy, b.constrainedBy, asc); } }; - // Cell render - auto cellRender = [warningColor](int, int colIdx, const ConstraintRow& row) { - std::string value; - std::string tooltip; - ImVec4 textColor = ImVec4(0, 0, 0, 0); + // Cell render -- column 2 ("Constrained By") is clickable to navigate + // to the first source feature's settings page. + auto cellRender = [warningColor](int rowIdx, int colIdx, const ConstraintRow& row) { if (colIdx == 0) { - value = row.setting; - textColor = warningColor; + Util::RenderTableCell(row.setting, "", "", nullptr, ImVec4(1, 1, 1, 1), true, warningColor); } else if (colIdx == 1) { - value = row.forcedTo; + Util::RenderTableCell(row.forcedTo, "", "", nullptr, ImVec4(1, 1, 1, 1), true); } else if (colIdx == 2) { - value = row.constrainedBy; - tooltip = row.tooltip; + if (!row.firstSourceShortName.empty()) { + if (ImGui::Selectable(std::format("{}##nav{}", row.constrainedBy, rowIdx).c_str())) { + if (auto* menu = Menu::GetSingleton()) { + menu->SelectFeatureMenu(row.firstSourceShortName); + } + } + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Click to navigate to %s", row.constrainedBy.c_str()); + if (!row.tooltip.empty()) { + ImGui::Separator(); + ImGui::Text("%s", row.tooltip.c_str()); + } + } + } else { + Util::RenderTableCell(row.constrainedBy, "", row.tooltip, nullptr, ImVec4(1, 1, 1, 1), true); + } } - Util::RenderTableCell(value, "", tooltip, nullptr, ImVec4(1, 1, 1, 1), true, textColor); }; // Render table