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..4192045214 --- /dev/null +++ b/src/FeatureConstraints.cpp @@ -0,0 +1,96 @@ +#include "FeatureConstraints.h" +#include "Feature.h" + +#include + +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; + } 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 }); + } + } + } + + return result; + } + + std::vector> GetAllActiveConstraints() + { + std::vector> allConstraints; + std::unordered_set processedKeys; // featureShortName|settingPath for O(1) lookup + + for (auto* feature : Feature::GetFeatureList()) { + if (!feature->loaded) + continue; + + auto constraints = feature->GetActiveConstraints(); + for (const auto& constraint : constraints) { + 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 }); + } + } + } + } + + 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"; + } +} diff --git a/src/FeatureConstraints.h b/src/FeatureConstraints.h new file mode 100644 index 0000000000..8437025f5b --- /dev/null +++ b/src/FeatureConstraints.h @@ -0,0 +1,90 @@ +#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; // Display name (e.g. "Terrain Blending") + std::string featureShortName; // Menu navigation key (e.g. "TerrainBlending") + 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); +} 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..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(); @@ -1107,6 +1107,31 @@ bool Upscaling::IsUpscalingActive() return resolutionScale.x < .99f; } +std::vector Upscaling::GetActiveConstraints() const +{ + std::vector constraints; + + if (!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..53f06f996a 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 @@ -90,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; @@ -108,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 444edc8e6e..47d4f6a9d7 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); } //============================================================================= @@ -588,45 +591,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()) { @@ -1661,17 +1649,32 @@ 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) { - 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(settingId); + + if (constraint.isConstrained) { + // 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) { *gDepthBufferCulling = desired; - logger::info("VR depth buffer culling restored to {}", desired); + logger::info("VR depth buffer culling set to {}", desired); } } } diff --git a/src/Features/VR.h b/src/Features/VR.h index 9e553e5356..67f02de31d 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 "Utils/Input.h" @@ -105,7 +106,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.cpp b/src/Menu.cpp index e076800943..56829e4b48 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 0b9f303663..08f6dc7fb8 100644 --- a/src/Menu.h +++ b/src/Menu.h @@ -386,6 +386,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 5566467ad7..0b2a06f392 100644 --- a/src/Menu/FeatureListRenderer.cpp +++ b/src/Menu/FeatureListRenderer.cpp @@ -7,8 +7,10 @@ #include #include #include +#include #include "Feature.h" +#include "FeatureConstraints.h" #include "FeatureIssues.h" #include "Fonts.h" #include "Globals.h" @@ -193,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( @@ -220,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(); @@ -409,12 +430,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 +578,8 @@ void FeatureListRenderer::DrawMenuVisitor::operator()(Feature* feat) RenderRestoreDefaultsButton(feat, isDisabled, isLoaded); } ImGui::EndChild(); + // Render reactive constraint warning outside the child window so it can appear as a top-level popup + RenderReactiveConstraintWarningDialog(); } bool FeatureListRenderer::DrawMenuVisitor::IsFeatureInstalled(const std::string& featureName) @@ -689,6 +713,48 @@ void FeatureListRenderer::DrawMenuVisitor::RenderFeatureSettings(Feature* feat, feat->DrawSettings(); ImVec2 cursorPosAfter = ImGui::GetCursorPos(); + // --- 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 { + // 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; + } + } + } + const float cursorEpsilon = 0.1f; bool cursorMoved = (std::abs(cursorPosAfter.x - cursorPosBefore.x) > cursorEpsilon || std::abs(cursorPosAfter.y - cursorPosBefore.y) > cursorEpsilon); @@ -763,4 +829,138 @@ void FeatureListRenderer::DrawMenuVisitor::RenderRestoreDefaultsButton(Feature* if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Restore default settings for this feature"); } +} + +void FeatureListRenderer::DrawMenuVisitor::RenderReactiveConstraintWarningDialog() +{ + if (!g_reactiveWarningShow) { + return; + } + + // 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_Always, ImVec2(0.5f, 0.5f)); + + 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(); + + // 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("Constrained By", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Forced To", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + + size_t rowIndex = 0; + for (const auto& [settingId, result] : g_reactiveWarningConstraints) { + ImGui::TableNextRow(); + + // --- Column 0: Impacted Feature (clickable -> navigate to that feature) --- + ImGui::TableSetColumnIndex(0); + { + // 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++; + } + + ImGui::EndTable(); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + 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(); + + // "Don't show again" checkbox -- same pattern as Clear Cache dialog + ImGui::Checkbox("Don't show this warning again", &g_dontShowAgainCheckbox); + + ImGui::Spacing(); + + // 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; + } + } + g_reactiveWarningShow = false; + g_reactiveWarningConstraints.clear(); + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); + } else { + // 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 ff2d578e37..13289371df 100644 --- a/src/Menu/FeatureListRenderer.h +++ b/src/Menu/FeatureListRenderer.h @@ -48,17 +48,23 @@ 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; + // 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 RenderReactiveConstraintWarningDialog(); }; static std::vector BuildMenuList( @@ -80,5 +86,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 7adf0eacf0..de632a0d90 100644 --- a/src/Menu/HomePageRenderer.cpp +++ b/src/Menu/HomePageRenderer.cpp @@ -3,11 +3,13 @@ #include +#include "FeatureConstraints.h" #include "Globals.h" #include "Menu.h" #include "Plugin.h" #include "State.h" #include "Util.h" +#include "Utils/UI.h" // Static member definitions bool HomePageRenderer::isFirstTimeSetupShown = false; @@ -29,6 +31,8 @@ void HomePageRenderer::RenderHomePage() RenderWelcomeSection(); ImGui::Spacing(); + RenderActiveConstraintsSection(); + RenderQuickLinksSection(); ImGui::Spacing(); @@ -257,6 +261,115 @@ 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(); + + // Prepare data for table + struct ConstraintRow + { + std::string setting; + std::string forcedTo; + std::string constrainedBy; + std::string firstSourceShortName; // For "navigate to feature" on click + 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; + } + if (!result.sources.empty()) { + row.firstSourceShortName = result.sources[0].featureShortName; + } + // 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."; + } + } + 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 -- 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) { + Util::RenderTableCell(row.setting, "", "", nullptr, ImVec4(1, 1, 1, 1), true, warningColor); + } else if (colIdx == 1) { + Util::RenderTableCell(row.forcedTo, "", "", nullptr, ImVec4(1, 1, 1, 1), true); + } else if (colIdx == 2) { + 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); + } + } + }; + + // Render table + Util::ShowSortedStringTableCustom( + "ConstraintsTable", + headers, + rows, + 0, // sortColumn + true, // ascending + customSorts, + cellRender); + } + + ImGui::Spacing(); +} + void HomePageRenderer::RenderFirstTimeSetupDialog() { if (!ShouldShowFirstTimeSetup()) { diff --git a/src/Menu/HomePageRenderer.h b/src/Menu/HomePageRenderer.h index dc8525358e..aa7bb8e1e8 100644 --- a/src/Menu/HomePageRenderer.h +++ b/src/Menu/HomePageRenderer.h @@ -34,6 +34,7 @@ class HomePageRenderer static void RenderWelcomeSection(); static void RenderQuickLinksSection(); static void RenderFAQSection(); + static void RenderActiveConstraintsSection(); static void MarkFirstTimeSetupComplete(uint32_t closingKey); diff --git a/src/Utils/UI.cpp b/src/Utils/UI.cpp index 165de87958..5cd63b3fe1 100644 --- a/src/Utils/UI.cpp +++ b/src/Utils/UI.cpp @@ -22,7 +22,7 @@ #include #include "../Feature.h" -#include "../Features/VR.h" // Include VR.h to get VR::VRButton definition +#include "../Features/VR.h" #include "../Globals.h" #include "../Menu.h" #include "FileSystem.h" @@ -1852,4 +1852,110 @@ namespace Util return changed; } + namespace ConstrainedUI + { + namespace + { + // 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 Checkbox(const char* label, bool* value, const FeatureConstraints::SettingId& settingId) + { + auto constraint = FeatureConstraints::GetConstraints(settingId); + + if (constraint.isConstrained) { + // 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(); + } else { + // Fallback: wrong type, show disabled with stored value + ImGui::BeginDisabled(); + ImGui::Checkbox(label, value); + ImGui::EndDisabled(); + } + RenderConstraintTooltip(constraint); + return false; + } + + return ImGui::Checkbox(label, value); + } + + 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) { + // 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(); + } else { + // Fallback: wrong type, show disabled with stored value + ImGui::BeginDisabled(); + ImGui::SliderFloat(label, value, min, max, format); + ImGui::EndDisabled(); + } + RenderConstraintTooltip(constraint); + return false; + } + + return ImGui::SliderFloat(label, value, min, max, format); + } + + 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) { + // 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(); + } else { + // Fallback: wrong type, show disabled with stored value + ImGui::BeginDisabled(); + ImGui::SliderInt(label, value, min, max, format); + ImGui::EndDisabled(); + } + RenderConstraintTooltip(constraint); + return false; + } + + return ImGui::SliderInt(label, value, min, max, format); + } + } } // namespace Util diff --git a/src/Utils/UI.h b/src/Utils/UI.h index bf6f9c7f2f..51afbbb05f 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" #include "Utils/Input.h" @@ -250,6 +251,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