From 30673d2ab9d9ee1a1844505ee94310f6eabd6b20 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:55:50 -0700 Subject: [PATCH 01/36] feat(UI): Time of Day --- src/SceneSettingsManager.cpp | 529 +++++++++++++++++++++++---- src/SceneSettingsManager.h | 110 +++++- src/WeatherEditor/EditorWindow.cpp | 12 +- src/WeatherEditor/TimeOfDayPanel.cpp | 348 ++++++++++++++++++ src/WeatherEditor/TimeOfDayPanel.h | 12 + 5 files changed, 918 insertions(+), 93 deletions(-) create mode 100644 src/WeatherEditor/TimeOfDayPanel.cpp create mode 100644 src/WeatherEditor/TimeOfDayPanel.h diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index fccd83dfca..b968fc5eda 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -7,6 +7,7 @@ #include #include +#include #include // --- Path Resolution --- @@ -16,6 +17,8 @@ std::string SceneSettingsManager::GetSceneTypeName(SceneType type) switch (type) { case SceneType::InteriorOnly: return "InteriorOnly"; + case SceneType::TimeOfDay: + return "TimeOfDay"; default: return "Unknown"; } @@ -31,15 +34,106 @@ std::filesystem::path SceneSettingsManager::GetOverwritesPath(SceneType type) return Util::PathHelpers::GetSceneSettingsPath() / GetSceneTypeName(type); } +// --- Time of Day Period Helpers --- + +const char* SceneSettingsManager::GetPeriodName(TimeOfDayPeriod period) +{ + static const char* names[] = { "Dawn", "Sunrise", "Day", "Sunset", "Dusk", "Night" }; + int idx = static_cast(period); + return (idx >= 0 && idx < kPeriodCount) ? names[idx] : "Unknown"; +} + +SceneSettingsManager::TimeOfDayPeriod SceneSettingsManager::GetPeriodFromName(const std::string& name) +{ + for (int i = 0; i < kPeriodCount; ++i) { + if (name == GetPeriodName(static_cast(i))) + return static_cast(i); + } + return TimeOfDayPeriod::Count; +} + +float SceneSettingsManager::GetCurrentGameHour() +{ + // Prefer calendar (ground truth), which the Weather Editor slider writes to. + // sky->currentGameHour may lag when timeScale is 0 (time paused). + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (calendar && calendar->gameHour) + return std::clamp(calendar->gameHour->value, 0.0f, 24.0f); + + auto sky = globals::game::sky; + return sky ? std::clamp(sky->currentGameHour, 0.0f, 24.0f) : 12.0f; +} + +void SceneSettingsManager::GetTimeOfDayFactors(float outFactors[kPeriodCount]) +{ + for (int i = 0; i < kPeriodCount; ++i) + outFactors[i] = 0.0f; + + float hour = GetCurrentGameHour(); + + // Normalize to [0, 24) — Night wraps, so also check hour + 24 for pre-dawn hours + for (int i = 0; i < kPeriodCount; ++i) { + float start = kPeriodHours[i][0]; + float end = kPeriodHours[i][1]; + float h = (end > 24.0f && hour < start) ? hour + 24.0f : hour; + + if (h >= start && h < end) { + // Inside this period — check if we're in a transition zone + float distFromStart = h - start; + float distFromEnd = end - h; + + if (distFromStart < kTransitionHours) { + // Blending in from previous period + float t = distFromStart / kTransitionHours; + outFactors[i] = t; + outFactors[(i + kPeriodCount - 1) % kPeriodCount] = 1.0f - t; + } else if (distFromEnd < kTransitionHours) { + // Blending out to next period + float t = distFromEnd / kTransitionHours; + outFactors[i] = t; + outFactors[(i + 1) % kPeriodCount] = 1.0f - t; + } else { + outFactors[i] = 1.0f; + } + return; + } + } + + // Fallback: noon = Day + outFactors[static_cast(TimeOfDayPeriod::Day)] = 1.0f; +} + +SceneSettingsManager::TimeOfDayPeriod SceneSettingsManager::GetDominantPeriod() +{ + float factors[kPeriodCount]; + GetTimeOfDayFactors(factors); + + int best = 0; + for (int i = 1; i < kPeriodCount; ++i) + if (factors[i] > factors[best]) + best = i; + return static_cast(best); +} + // --- Feature Metadata (static helpers, zero coupling) --- +static std::vector FilterFeatureNames(const std::unordered_set& whitelist) +{ + auto allNames = Feature::GetLoadedFeatureNames(); + std::vector filtered; + filtered.reserve(allNames.size()); + for (auto& name : allNames) + if (whitelist.contains(name)) + filtered.push_back(std::move(name)); + return filtered; +} + std::vector SceneSettingsManager::GetInteriorRelevantFeatureNames() { - // Features that are relevant for interior-only setting overrides. - // Excludes exterior-only features: terrain, grass, LOD, sky, cloud shadows. - static const std::unordered_set interiorRelevantFeatures = { + static const std::unordered_set whitelist = { "ScreenSpaceGI", "ScreenSpaceShadows", + "SubsurfaceScattering", "LinearLighting", "ImageBasedLighting", "PostProcessing", @@ -47,15 +141,25 @@ std::vector SceneSettingsManager::GetInteriorRelevantFeatureNames() "ScreenSpaceRayTracing", "VanillaFresnel", }; + return FilterFeatureNames(whitelist); +} - auto allNames = Feature::GetLoadedFeatureNames(); - std::vector filtered; - filtered.reserve(allNames.size()); - for (auto& name : allNames) { - if (interiorRelevantFeatures.contains(name)) - filtered.push_back(std::move(name)); - } - return filtered; +std::vector SceneSettingsManager::GetExteriorRelevantFeatureNames() +{ + // NOTE: ScreenSpaceGI excluded — its LoadSettings() unconditionally triggers + // synchronous recompilation of 6 compute shaders, causing massive lag. + static const std::unordered_set whitelist = { + "CloudShadows", + "ExponentialHeightFog", + "GrassLighting", + "ImageBasedLighting", + "LinearLighting", + "Skylighting", + "SubsurfaceScattering", + "TerrainShadows", + "WetnessEffects", + }; + return FilterFeatureNames(whitelist); } std::vector SceneSettingsManager::GetFeatureSettingKeys(const std::string& featureShortName) @@ -132,6 +236,17 @@ bool SceneSettingsManager::HasEntryFromSource(SceneType type, const std::string& return false; } +bool SceneSettingsManager::HasEntryForPeriod(const std::string& featureShortName, const std::string& settingKey, + TimeOfDayPeriod period, EntrySource source) const +{ + for (const auto& entry : GetEntries(SceneType::TimeOfDay)) { + if (entry.source == source && entry.period == period && + entry.featureShortName == featureShortName && entry.settingKey == settingKey) + return true; + } + return false; +} + bool SceneSettingsManager::HasActiveOverwrite(SceneType type, const std::string& featureShortName, const std::string& settingKey) const { for (const auto& entry : GetEntries(type)) { @@ -142,9 +257,18 @@ bool SceneSettingsManager::HasActiveOverwrite(SceneType type, const std::string& return false; } -void SceneSettingsManager::AddSetting(SceneType type, const std::string& featureShortName, const std::string& settingKey, const json& value) +bool SceneSettingsManager::HasDuplicateEntry(SceneType type, const std::string& featureShortName, + const std::string& settingKey, EntrySource source, TimeOfDayPeriod period) const { - if (HasEntryFromSource(type, featureShortName, settingKey, EntrySource::User)) + if (type == SceneType::TimeOfDay) + return HasEntryForPeriod(featureShortName, settingKey, period, source); + return HasEntryFromSource(type, featureShortName, settingKey, source); +} + +void SceneSettingsManager::AddSetting(SceneType type, const std::string& featureShortName, const std::string& settingKey, const json& value, + TimeOfDayPeriod period) +{ + if (HasDuplicateEntry(type, featureShortName, settingKey, EntrySource::User, period)) return; auto& vec = GetEntriesMut(type); @@ -154,6 +278,7 @@ void SceneSettingsManager::AddSetting(SceneType type, const std::string& feature entry.settingKey = settingKey; entry.value = value; entry.source = EntrySource::User; + entry.period = period; vec.push_back(std::move(entry)); SaveUserSettings(type); ReapplyIfActive(); @@ -167,7 +292,11 @@ void SceneSettingsManager::RemoveSetting(SceneType type, size_t index) auto& entry = vec[index]; if (entry.source == EntrySource::Overwrite && !entry.sourceFilename.empty()) { - auto filepath = GetOverwritesPath(type) / entry.sourceFilename; + // For TimeOfDay overwrites, files are in period subfolders + auto basePath = GetOverwritesPath(type); + auto filepath = (type == SceneType::TimeOfDay && entry.period != TimeOfDayPeriod::Count) + ? basePath / GetPeriodName(entry.period) / entry.sourceFilename + : basePath / entry.sourceFilename; std::error_code ec; if (std::filesystem::remove(filepath, ec)) logger::info("[SceneSettings] Deleted overwrite file: {}", filepath.string()); @@ -273,8 +402,11 @@ void SceneSettingsManager::UpdateEntryValue(SceneType type, size_t index, const if (!deferSave && vec[index].source == EntrySource::User) SaveUserSettings(type); - // Only apply if no active overwrite covers this key (overwrites take priority) - if (isCurrentlyApplied && !vec[index].paused && !IsFeaturePaused(vec[index].featureShortName)) { + // For TimeOfDay, recompute blended values; for others, apply directly + if (type == SceneType::TimeOfDay) { + if (isTimeOfDayActive) + ApplyTimeOfDayBlended(); + } else if (isCurrentlyApplied && !vec[index].paused && !IsFeaturePaused(vec[index].featureShortName)) { if (vec[index].source == EntrySource::Overwrite || !HasActiveOverwrite(type, vec[index].featureShortName, vec[index].settingKey)) ApplySettingToFeature(vec[index]); @@ -300,20 +432,28 @@ RE::BSEventNotifyControl SceneSettingsManager::MenuOpenCloseEventHandler::Proces void SceneSettingsManager::Update() { - // Revert interior overrides on main/loading menu (same check as LinearLighting) - if (isCurrentlyApplied) { - bool isMainOrLoading = globals::game::ui && - (globals::game::ui->IsMenuOpen(RE::MainMenu::MENU_NAME) || globals::game::ui->IsMenuOpen(RE::LoadingMenu::MENU_NAME)); - if (isMainOrLoading) { + // Revert overrides on main/loading menu (same check as LinearLighting) + bool isMainOrLoading = globals::game::ui && + (globals::game::ui->IsMenuOpen(RE::MainMenu::MENU_NAME) || globals::game::ui->IsMenuOpen(RE::LoadingMenu::MENU_NAME)); + + if (isMainOrLoading) { + if (isCurrentlyApplied) { RevertToExteriorSettings(); isCurrentlyApplied = false; } + if (isTimeOfDayActive) + DeactivateTimeOfDay(); + return; } if (queuedCellTransition) { queuedCellTransition = false; OnCellTransition(); } + + // Continuously update time-of-day blended values when exterior + if (isTimeOfDayActive) + UpdateTimeOfDay(); } void SceneSettingsManager::OnCellTransition() @@ -323,36 +463,71 @@ void SceneSettingsManager::OnCellTransition() if (auto sky = globals::game::sky) interior = sky->mode.get() != RE::Sky::Mode::kFull; - if (interior && !isCurrentlyApplied) { - SaveExteriorSettings(SceneType::InteriorOnly); - ApplySettings(SceneType::InteriorOnly); - isCurrentlyApplied = true; - } else if (!interior && isCurrentlyApplied) { - RevertToExteriorSettings(); - isCurrentlyApplied = false; + if (interior) { + // Entering interior: deactivate TOD first, then apply interior overrides + if (isTimeOfDayActive) + DeactivateTimeOfDay(); + if (!isCurrentlyApplied) { + SaveExteriorSettings(SceneType::InteriorOnly); + ApplySettings(SceneType::InteriorOnly); + isCurrentlyApplied = true; + } + } else { + // Entering exterior: revert interior overrides, then activate TOD + if (isCurrentlyApplied) { + RevertToExteriorSettings(); + isCurrentlyApplied = false; + } + if (!isTimeOfDayActive) + ActivateTimeOfDay(); } } void SceneSettingsManager::ReapplyIfActive() { - if (!isCurrentlyApplied) - return; + if (isCurrentlyApplied) { + RevertToExteriorSettings(); + SaveExteriorSettings(SceneType::InteriorOnly); + ApplySettings(SceneType::InteriorOnly); + } - // Full revert + re-apply so removed/paused entries get exterior values restored - RevertToExteriorSettings(); - SaveExteriorSettings(SceneType::InteriorOnly); - ApplySettings(SceneType::InteriorOnly); + // Determine if we're in an exterior right now + bool isExterior = false; + if (auto sky = globals::game::sky) + isExterior = sky->mode.get() == RE::Sky::Mode::kFull; + + bool hasEntries = !GetEntries(SceneType::TimeOfDay).empty(); + + if (isTimeOfDayActive) { + if (hasEntries) { + // Re-blend with updated entries + RevertTimeOfDayBaseline(); + SaveTimeOfDayBaseline(); + ApplyTimeOfDayBlended(); + } else { + // All entries removed — deactivate + DeactivateTimeOfDay(); + } + } else if (isExterior && hasEntries && !isCurrentlyApplied) { + // User added first TOD entry while already in an exterior — activate now + ActivateTimeOfDay(); + } } bool SceneSettingsManager::IsSettingControlled(const std::string& featureShortName, const std::string& settingKey) const { - if (!isCurrentlyApplied) + if (!isCurrentlyApplied && !isTimeOfDayActive) return false; if (IsFeaturePaused(featureShortName)) return false; // Check all scene types for active overrides for (const auto& [type, vec] : entries) { + // Skip inactive scene types + if (type == SceneType::InteriorOnly && !isCurrentlyApplied) + continue; + if (type == SceneType::TimeOfDay && !isTimeOfDayActive) + continue; for (const auto& entry : vec) { if (entry.paused) continue; @@ -365,10 +540,18 @@ bool SceneSettingsManager::IsSettingControlled(const std::string& featureShortNa bool SceneSettingsManager::HasActiveSettingsForFeature(const std::string& featureShortName) const { - if (!isCurrentlyApplied) + if (!isCurrentlyApplied && !isTimeOfDayActive) return false; for (const auto& [type, vec] : entries) { + // Only report entries from scene types that are currently active. + // InteriorOnly entries should not show as active when in an exterior, + // and TimeOfDay entries should not show as active when in an interior. + if (type == SceneType::InteriorOnly && !isCurrentlyApplied) + continue; + if (type == SceneType::TimeOfDay && !isTimeOfDayActive) + continue; + for (const auto& entry : vec) { if (!entry.paused && entry.featureShortName == featureShortName) return true; @@ -391,16 +574,13 @@ void SceneSettingsManager::SetFeaturePaused(const std::string& featureShortName, // --- Apply / Revert --- -void SceneSettingsManager::SaveExteriorSettings(SceneType type) +void SceneSettingsManager::SavePartialBaseline(SceneType type, std::map& outBaseline) { - // Collect which keys per feature need saving (only the keys we'll override) std::map> keysToSave; - for (const auto& entry : GetEntries(type)) { + for (const auto& entry : GetEntries(type)) if (IsEntryActive(entry)) keysToSave[entry.featureShortName].insert(entry.settingKey); - } - // Save only the specific keys we'll override, not the entire settings blob for (const auto& [shortName, keys] : keysToSave) { auto* feature = Feature::FindFeatureByShortName(shortName); if (!feature) @@ -409,18 +589,21 @@ void SceneSettingsManager::SaveExteriorSettings(SceneType type) json fullSettings; feature->SaveSettings(fullSettings); - // Merge into existing saved settings (don't overwrite keys saved by other scene types) - json& partial = savedExteriorSettings[shortName]; + json& partial = outBaseline[shortName]; if (!partial.is_object()) partial = json::object(); - for (const auto& key : keys) { + for (const auto& key : keys) if (fullSettings.contains(key) && !partial.contains(key)) partial[key] = fullSettings[key]; - } } } +void SceneSettingsManager::SaveExteriorSettings(SceneType type) +{ + SavePartialBaseline(type, savedExteriorSettings); +} + void SceneSettingsManager::ApplySettings(SceneType type) { // Apply user entries first, then overwrites — overwrites win via last-write-wins @@ -436,22 +619,25 @@ void SceneSettingsManager::ApplySettings(SceneType type) } } -void SceneSettingsManager::RevertToExteriorSettings() +void SceneSettingsManager::RevertFromBaseline(std::map& baseline) { - for (const auto& [shortName, savedKeys] : savedExteriorSettings) { + for (const auto& [shortName, savedKeys] : baseline) { auto* feature = Feature::FindFeatureByShortName(shortName); if (!feature) continue; json current; feature->SaveSettings(current); - for (auto& [key, val] : savedKeys.items()) current[key] = val; - feature->LoadSettings(current); } - savedExteriorSettings.clear(); + baseline.clear(); +} + +void SceneSettingsManager::RevertToExteriorSettings() +{ + RevertFromBaseline(savedExteriorSettings); } void SceneSettingsManager::ApplySettingToFeature(const SettingEntry& entry) @@ -484,6 +670,172 @@ void SceneSettingsManager::ApplySettingToFeature(const SettingEntry& entry) } } +// --- Time of Day --- + +void SceneSettingsManager::ActivateTimeOfDay() +{ + if (isTimeOfDayActive || GetEntries(SceneType::TimeOfDay).empty()) + return; + // TOD and InteriorOnly are mutually exclusive — don't activate TOD while + // interior overrides are applied, as they write to the same feature values. + if (isCurrentlyApplied) { + logger::debug("[SceneSettings] Skipping TOD activation — interior overrides are active"); + return; + } + SaveTimeOfDayBaseline(); + isTimeOfDayActive = true; + lastDominantPeriod = GetDominantPeriod(); + ApplyTimeOfDayBlended(); + logger::info("[SceneSettings] Time of Day activated"); +} + +void SceneSettingsManager::DeactivateTimeOfDay() +{ + if (!isTimeOfDayActive) + return; + RevertTimeOfDayBaseline(); + isTimeOfDayActive = false; + lastDominantPeriod = TimeOfDayPeriod::Count; + logger::info("[SceneSettings] Time of Day deactivated"); +} + +void SceneSettingsManager::SaveTimeOfDayBaseline() +{ + SavePartialBaseline(SceneType::TimeOfDay, savedTimeOfDayBaseline); +} + +void SceneSettingsManager::RevertTimeOfDayBaseline() +{ + RevertFromBaseline(savedTimeOfDayBaseline); + lastAppliedTODFloats.clear(); + lastAppliedTODOther.clear(); + lastBlendedHour = -1.0f; +} + +void SceneSettingsManager::UpdateTimeOfDay() +{ + if (GetEntries(SceneType::TimeOfDay).empty()) { + if (isTimeOfDayActive) + DeactivateTimeOfDay(); + return; + } + // Safety: if interior overrides are somehow active while TOD is running, + // deactivate TOD to prevent conflicting writes to the same feature values. + if (isCurrentlyApplied) { + logger::warn("[SceneSettings] TOD was active while interior overrides applied — deactivating TOD"); + DeactivateTimeOfDay(); + return; + } + ApplyTimeOfDayBlended(); +} + +void SceneSettingsManager::ApplyTimeOfDayBlended() +{ + // Throttle: skip the expensive map rebuild + blend when the game hour + // hasn't moved enough to produce a visible change. On a hot per-frame + // path this avoids thousands of string-keyed map operations per second. + float currentHour = GetCurrentGameHour(); + if (lastBlendedHour >= 0.0f && std::abs(currentHour - lastBlendedHour) < kHourUpdateThreshold) + return; + lastBlendedHour = currentHour; + + float factors[kPeriodCount]; + GetTimeOfDayFactors(factors); + + // Inline dominant period computation to avoid a second GetTimeOfDayFactors call + int bestIdx = 0; + for (int i = 1; i < kPeriodCount; ++i) + if (factors[i] > factors[bestIdx]) + bestIdx = i; + auto dominant = static_cast(bestIdx); + + // Group active entries by feature, using pointers to avoid JSON copies + struct PeriodRef + { + int periodIdx; + const json* value; + }; + std::map>> featureSettings; + for (const auto& entry : GetEntries(SceneType::TimeOfDay)) { + if (!IsEntryActive(entry) || entry.period == TimeOfDayPeriod::Count) + continue; + featureSettings[entry.featureShortName][entry.settingKey].push_back( + { static_cast(entry.period), &entry.value }); + } + + for (auto& [shortName, settingsMap] : featureSettings) { + // Compute blended values and check which keys actually changed + std::vector> dirtyKeys; + + for (auto& [key, periodRefs] : settingsMap) { + // Get baseline value (saved once at activation, never changes) + const json* baseline = nullptr; + auto baseIt = savedTimeOfDayBaseline.find(shortName); + if (baseIt != savedTimeOfDayBaseline.end() && baseIt->second.contains(key)) + baseline = &baseIt->second[key]; + if (!baseline) + continue; + + auto type = DetectSettingType(*baseline); + + if (type == SettingType::Float) { + float baseVal = baseline->get(); + float result = 0.0f; + float coveredFactor = 0.0f; + + for (auto& pr : periodRefs) { + float f = factors[pr.periodIdx]; + if (f > 0.0f) { + result += f * pr.value->get(); + coveredFactor += f; + } + } + result += (1.0f - coveredFactor) * baseVal; + + // Epsilon comparison — skip if the float barely changed + auto& cachedFloat = lastAppliedTODFloats[shortName][key]; + if (std::abs(cachedFloat - result) < kBlendEpsilon) + continue; + cachedFloat = result; + dirtyKeys.emplace_back(key, result); + } else { + // Non-float: snap to dominant period's value, or baseline if none + json blendedValue = *baseline; + for (auto& pr : periodRefs) + if (static_cast(pr.periodIdx) == dominant) + blendedValue = *pr.value; + + // Exact comparison for non-float (bools, ints snap — rarely change) + auto& cachedOther = lastAppliedTODOther[shortName][key]; + if (cachedOther == blendedValue) + continue; + cachedOther = blendedValue; + dirtyKeys.emplace_back(key, std::move(blendedValue)); + } + } + + if (dirtyKeys.empty()) + continue; + + // Get FRESH settings from the feature (cheap to_json, keeps non-TOD keys current) + auto* feature = Feature::FindFeatureByShortName(shortName); + if (!feature) + continue; + + json current; + feature->SaveSettings(current); + + // Patch only our TOD-controlled keys into the fresh blob + for (auto& [k, v] : dirtyKeys) + current[k] = std::move(v); + + // Single LoadSettings with up-to-date non-TOD values intact + feature->LoadSettings(current); + } + + lastDominantPeriod = dominant; +} + // --- Persistence --- void SceneSettingsManager::SaveUserSettings(SceneType type) @@ -502,6 +854,8 @@ void SceneSettingsManager::SaveUserSettings(SceneType type) item["setting"] = entry.settingKey; item["value"] = entry.value; item["paused"] = entry.paused; + if (type == SceneType::TimeOfDay && entry.period != TimeOfDayPeriod::Count) + item["period"] = GetPeriodName(entry.period); data.push_back(std::move(item)); } @@ -550,11 +904,19 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) entry.paused = item.value("paused", false); entry.source = EntrySource::User; + // Parse period for TimeOfDay entries + if (type == SceneType::TimeOfDay && item.contains("period")) { + entry.period = GetPeriodFromName(item["period"].get()); + if (entry.period == TimeOfDayPeriod::Count) + continue; // Invalid period name + } + if (!Feature::FindFeatureByShortName(entry.featureShortName)) continue; - if (!HasEntryFromSource(type, entry.featureShortName, entry.settingKey, EntrySource::User)) - vec.push_back(std::move(entry)); + if (HasDuplicateEntry(type, entry.featureShortName, entry.settingKey, EntrySource::User, entry.period)) + continue; + vec.push_back(std::move(entry)); } logger::info("[SceneSettings] Loaded {} {} user settings", data.size(), typeName); @@ -565,23 +927,36 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) void SceneSettingsManager::DiscoverOverwrites(SceneType type) { - auto overwritesPath = GetOverwritesPath(type); - auto typeName = GetSceneTypeName(type); + // TimeOfDay has period subfolders; delegate to a shared loader + if (type == SceneType::TimeOfDay) { + auto basePath = GetOverwritesPath(type); + for (int i = 0; i < kPeriodCount; ++i) { + auto period = static_cast(i); + auto periodPath = basePath / GetPeriodName(period); + DiscoverOverwritesInDir(type, periodPath, period); + } + return; + } + + DiscoverOverwritesInDir(type, GetOverwritesPath(type)); +} - logger::info("[SceneSettings] Discovering {} overwrites in: {}", typeName, overwritesPath.string()); +void SceneSettingsManager::DiscoverOverwritesInDir(SceneType type, const std::filesystem::path& dir, TimeOfDayPeriod period) +{ + auto typeName = GetSceneTypeName(type); std::error_code ec; - if (!std::filesystem::exists(overwritesPath, ec)) { - logger::info("[SceneSettings] Overwrites directory does not exist: {}", overwritesPath.string()); + if (!std::filesystem::exists(dir, ec)) return; - } + + logger::info("[SceneSettings] Discovering {} overwrites in: {}", typeName, dir.string()); auto& vec = GetEntriesMut(type); - int filesFound = 0; - int overwritesLoaded = 0; - for (const auto& dirEntry : std::filesystem::directory_iterator(overwritesPath, ec)) { + int filesFound = 0, overwritesLoaded = 0; + + for (const auto& dirEntry : std::filesystem::directory_iterator(dir, ec)) { if (ec) { - logger::error("[SceneSettings] Error iterating {} overwrites directory: {}", typeName, ec.message()); + logger::error("[SceneSettings] Error iterating {} overwrites: {}", typeName, ec.message()); break; } if (!dirEntry.is_regular_file() || dirEntry.path().extension() != ".json") @@ -604,31 +979,24 @@ void SceneSettingsManager::DiscoverOverwrites(SceneType type) json data = json::parse(file); - // Resolve feature name: explicit _feature field, or infer from filename (ModName_FeatureName.json) + // Resolve feature name: explicit _feature field, or infer from filename std::string featureShortName = data.value("_feature", ""); if (featureShortName.empty()) { auto stem = dirEntry.path().stem().string(); auto lastUnderscore = stem.rfind('_'); if (lastUnderscore != std::string::npos) { auto candidate = stem.substr(lastUnderscore + 1); - if (Feature::FindFeatureByShortName(candidate)) { + if (Feature::FindFeatureByShortName(candidate)) featureShortName = candidate; - logger::info("[SceneSettings] Inferred feature '{}' from filename '{}'", featureShortName, filename); - } } } - if (featureShortName.empty()) { - logger::warn("[SceneSettings] Skipping overwrite '{}': no _feature field and could not infer feature from filename", filename); - continue; - } - - if (!Feature::FindFeatureByShortName(featureShortName)) { - logger::warn("[SceneSettings] Skipping overwrite '{}': feature '{}' not found", filename, featureShortName); + if (featureShortName.empty() || !Feature::FindFeatureByShortName(featureShortName)) { + logger::warn("[SceneSettings] Skipping overwrite '{}': feature not resolved", filename); continue; } - // Count non-metadata settings — must have exactly one + // Exactly one non-metadata setting per file int settingCount = 0; std::string settingKey; json settingValue; @@ -647,10 +1015,9 @@ void SceneSettingsManager::DiscoverOverwrites(SceneType type) continue; } - if (HasEntryFromSource(type, featureShortName, settingKey, EntrySource::Overwrite)) { - logger::warn("[SceneSettings] Skipping overwrite '{}': duplicate overwrite for {}.{}", filename, featureShortName, settingKey); + // Duplicate check + if (HasDuplicateEntry(type, featureShortName, settingKey, EntrySource::Overwrite, period)) continue; - } SettingEntry entry; entry.featureShortName = featureShortName; @@ -658,7 +1025,7 @@ void SceneSettingsManager::DiscoverOverwrites(SceneType type) entry.value = settingValue; entry.source = EntrySource::Overwrite; entry.sourceFilename = filename; - + entry.period = period; vec.push_back(std::move(entry)); overwritesLoaded++; @@ -668,12 +1035,14 @@ void SceneSettingsManager::DiscoverOverwrites(SceneType type) } } - logger::info("[SceneSettings] {} overwrite discovery complete. Found {} JSON files, loaded {} overwrites", typeName, filesFound, overwritesLoaded); + if (filesFound > 0) + logger::info("[SceneSettings] {} overwrite scan: {} files, {} loaded", typeName, filesFound, overwritesLoaded); } void SceneSettingsManager::LoadAll() { DiscoverOverwrites(SceneType::InteriorOnly); LoadUserSettings(SceneType::InteriorOnly); - // Future: add other scene types here + DiscoverOverwrites(SceneType::TimeOfDay); + LoadUserSettings(SceneType::TimeOfDay); } diff --git a/src/SceneSettingsManager.h b/src/SceneSettingsManager.h index 2d0853186a..86bd5d44c4 100644 --- a/src/SceneSettingsManager.h +++ b/src/SceneSettingsManager.h @@ -13,8 +13,9 @@ using json = nlohmann::json; struct Feature; -/// Manages scene-specific setting overrides (Interior Only, TimeOfDay, WeatherSpecific). -/// Zero coupling to individual features — operates via JSON round-trip through Feature::SaveSettings/LoadSettings. +/// Manages scene-specific setting overrides (Interior Only, TimeOfDay). +/// Applies overrides via Feature::SaveSettings/LoadSettings JSON round-trips with +/// epsilon-cached blending to minimise redundant updates during time-of-day transitions. /// Event-driven: cell transitions detected via MenuOpenCloseEvent, mutations applied immediately. class SceneSettingsManager { @@ -29,10 +30,39 @@ class SceneSettingsManager enum class SceneType { - InteriorOnly - // Future: TimeOfDay, WeatherSpecific + InteriorOnly, + TimeOfDay }; + // --- Time of Day Periods --- + + enum class TimeOfDayPeriod + { + Dawn = 0, + Sunrise, + Day, + Sunset, + Dusk, + Night, + Count + }; + + /// Hour boundaries for each period [start, end). Night wraps around midnight (21–28 i.e. 21–4). + static constexpr float kPeriodHours[6][2] = { + { 4.0f, 6.0f }, // Dawn + { 6.0f, 8.0f }, // Sunrise + { 8.0f, 17.0f }, // Day + { 17.0f, 19.0f }, // Sunset + { 19.0f, 21.0f }, // Dusk + { 21.0f, 28.0f } // Night (wraps past midnight) + }; + + /// Transition blend zone in hours at each period boundary. + static constexpr float kTransitionHours = 0.5f; + + /// Number of time-of-day periods (avoids repeated static_cast). + static constexpr int kPeriodCount = static_cast(TimeOfDayPeriod::Count); + // --- Event Handler --- /// Listens for LoadingMenu close to detect cell transitions. @@ -76,7 +106,8 @@ class SceneSettingsManager json value; // Override value (bool, float, int, etc.) bool paused = false; // Temporarily disabled EntrySource source = EntrySource::User; - std::string sourceFilename; // For overwrites: the filename it came from + std::string sourceFilename; // For overwrites: the filename it came from + TimeOfDayPeriod period = TimeOfDayPeriod::Count; // Which period this entry belongs to (TimeOfDay only) }; // --- Generic Entry Management (scene-type agnostic) --- @@ -85,11 +116,17 @@ class SceneSettingsManager bool HasEntryFromSource(SceneType type, const std::string& featureShortName, const std::string& settingKey, EntrySource source) const; bool HasActiveOverwrite(SceneType type, const std::string& featureShortName, const std::string& settingKey) const; - void AddSetting(SceneType type, const std::string& featureShortName, const std::string& settingKey, const json& value); + /// Add a setting. For TimeOfDay entries, specify the target period. + void AddSetting(SceneType type, const std::string& featureShortName, const std::string& settingKey, const json& value, + TimeOfDayPeriod period = TimeOfDayPeriod::Count); void RemoveSetting(SceneType type, size_t index); void TogglePauseEntry(SceneType type, size_t index); void UpdateEntryValue(SceneType type, size_t index, const json& newValue, bool deferSave = false); + /// Check if an entry already exists for a specific period (TimeOfDay) + bool HasEntryForPeriod(const std::string& featureShortName, const std::string& settingKey, + TimeOfDayPeriod period, EntrySource source) const; + void SetAllOverwritesPaused(SceneType type, bool paused); bool AreAllOverwritesPaused(SceneType type) const; bool HasOverwriteEntries(SceneType type) const; @@ -101,12 +138,10 @@ class SceneSettingsManager // --- Scene Application --- - /// Called each frame from State::Draw() to process deferred cell transitions. - /// Cell data is not yet available when the LoadingMenu close event fires, - /// so we defer the actual transition check to the next rendered frame. + /// Called every frame from State::Update(). void Update(); - /// Called by Update() when a deferred cell transition is pending. + /// Called by MenuOpenCloseEventHandler when a cell transition is detected. void OnCellTransition(); /// Check if a specific feature+setting is currently being overridden by any active scene setting @@ -134,11 +169,22 @@ class SceneSettingsManager static std::filesystem::path GetSettingsFilePath(SceneType type); static std::filesystem::path GetOverwritesPath(SceneType type); + // --- Time of Day Helpers (public for UI) --- + + static const char* GetPeriodName(TimeOfDayPeriod period); + static TimeOfDayPeriod GetPeriodFromName(const std::string& name); + static float GetCurrentGameHour(); + void GetTimeOfDayFactors(float outFactors[static_cast(TimeOfDayPeriod::Count)]); + TimeOfDayPeriod GetDominantPeriod(); + // --- Feature Metadata --- /// Get loaded feature short names filtered to only interior-relevant features static std::vector GetInteriorRelevantFeatureNames(); + /// Get loaded feature short names filtered to exterior/TOD-relevant features + static std::vector GetExteriorRelevantFeatureNames(); + /// Get setting keys for a feature by JSON round-tripping its current settings static std::vector GetFeatureSettingKeys(const std::string& featureShortName); @@ -168,12 +214,38 @@ class SceneSettingsManager std::map allUserPausedMap; // --- Interior state tracking --- - bool isCurrentlyApplied = false; bool queuedCellTransition = false; + bool isCurrentlyApplied = false; // Stored exterior settings per-feature (only the overridden keys) std::map savedExteriorSettings; + // --- Time of Day state --- + bool isTimeOfDayActive = false; + TimeOfDayPeriod lastDominantPeriod = TimeOfDayPeriod::Count; + + /// Baseline settings saved before TOD activation, for reverting on deactivate. + std::map savedTimeOfDayBaseline; + + /// Cache of last-applied blended float values per feature+key. + /// Used with epsilon comparison to skip redundant LoadSettings calls. + std::map> lastAppliedTODFloats; + + /// Cache of last-applied non-float values per feature+key. + std::map> lastAppliedTODOther; + + /// Float epsilon — changes smaller than this skip the LoadSettings call. + static constexpr float kBlendEpsilon = 1e-3f; + + /// Cached game hour from last blend update. Used to skip redundant + /// per-frame map rebuilds when the game hour hasn't moved enough. + float lastBlendedHour = -1.0f; + + /// Minimum game-hour delta before re-running the blend. At default + /// timescale (20×) this equals ~0.36 real seconds — imperceptible yet + /// saves 98%+ of per-frame map construction work. + static constexpr float kHourUpdateThreshold = 1e-3f; + // --- Pause states --- std::map featurePauseStates; @@ -182,10 +254,26 @@ class SceneSettingsManager // --- Helpers --- std::vector& GetEntriesMut(SceneType type); bool IsEntryActive(const SettingEntry& entry) const; + bool HasDuplicateEntry(SceneType type, const std::string& featureShortName, const std::string& settingKey, + EntrySource source, TimeOfDayPeriod period = TimeOfDayPeriod::Count) const; void ReapplyIfActive(); void ApplySettings(SceneType type); + void SavePartialBaseline(SceneType type, std::map& outBaseline); + void RevertFromBaseline(std::map& baseline); void RevertToExteriorSettings(); void SaveExteriorSettings(SceneType type); static void ApplySettingToFeature(const SettingEntry& entry); + + // --- Time of Day lifecycle --- + void UpdateTimeOfDay(); + void ActivateTimeOfDay(); + void DeactivateTimeOfDay(); + void SaveTimeOfDayBaseline(); + void RevertTimeOfDayBaseline(); + void ApplyTimeOfDayBlended(); + + // --- Overwrite discovery helper --- + void DiscoverOverwritesInDir(SceneType type, const std::filesystem::path& dir, + TimeOfDayPeriod period = TimeOfDayPeriod::Count); }; diff --git a/src/WeatherEditor/EditorWindow.cpp b/src/WeatherEditor/EditorWindow.cpp index 0b60d46cea..aa009b04a7 100644 --- a/src/WeatherEditor/EditorWindow.cpp +++ b/src/WeatherEditor/EditorWindow.cpp @@ -2,6 +2,7 @@ #include "Features/WeatherEditor.h" #include "InteriorOnlyPanel.h" +#include "TimeOfDayPanel.h" #include "Menu.h" #include "PaletteWindow.h" #include "State.h" @@ -180,7 +181,7 @@ void EditorWindow::ShowObjectsWindow() ImGui::Spacing(); // List of categories - const char* categories[] = { "Weather", "ImageSpace", "Lighting Template", "Cell Lighting", "Volumetric Lighting", "Shader Particle Geometry", "Lens Flare", "Visual Effect", "Interior Only" }; + const char* categories[] = { "Weather", "ImageSpace", "Lighting Template", "Cell Lighting", "Volumetric Lighting", "Shader Particle Geometry", "Lens Flare", "Visual Effect", "Interior Only", "Time of Day" }; for (int i = 0; i < IM_ARRAYSIZE(categories); ++i) { // Highlight the selected category if (ImGui::Selectable(categories[i], selectedCategory == categories[i])) { @@ -196,7 +197,7 @@ void EditorWindow::ShowObjectsWindow() ImGui::TableSetColumnIndex(1); if (ImGui::BeginChild("##ObjectsContent", { 0, 0 }, ImGuiChildFlags_Border)) { - // Interior Only category has its own panel + // Interior Only / Time of Day categories have their own panels if (selectedCategory == "Interior Only") { InteriorOnlyPanel::Draw(); ImGui::EndChild(); @@ -204,6 +205,13 @@ void EditorWindow::ShowObjectsWindow() ImGui::End(); return; } + if (selectedCategory == "Time of Day") { + TimeOfDayPanel::Draw(); + ImGui::EndChild(); + ImGui::EndTable(); + ImGui::End(); + return; + } // Display current active weather auto sky = globals::game::sky; diff --git a/src/WeatherEditor/TimeOfDayPanel.cpp b/src/WeatherEditor/TimeOfDayPanel.cpp new file mode 100644 index 0000000000..c00a23d13f --- /dev/null +++ b/src/WeatherEditor/TimeOfDayPanel.cpp @@ -0,0 +1,348 @@ +#include "TimeOfDayPanel.h" + +#include "../Globals.h" +#include "../Menu.h" +#include "../Menu/ThemeManager.h" +#include "../SceneSettingsManager.h" +#include "EditorWindow.h" + +namespace TimeOfDayPanel +{ + using SceneType = SceneSettingsManager::SceneType; + using EntrySource = SceneSettingsManager::EntrySource; + using Period = SceneSettingsManager::TimeOfDayPeriod; + static constexpr auto kSceneType = SceneType::TimeOfDay; + static constexpr int kPeriodCount = static_cast(Period::Count); + + // Layout constants from centralized theme + using C = ThemeManager::Constants; + + // Per-period persistent state for the "Add Setting" workflow + struct PeriodUIState + { + int selectedFeatureIdx = -1; + int selectedSettingIdx = -1; + std::vector cachedFeatureNames; + std::vector cachedSettingKeys; + }; + static PeriodUIState periodState[kPeriodCount]; + + // Confirmation popups (shared across tabs) + static Util::ConfirmationPopup deleteAllOverwritesPopup{ + "Delete All Overwrites?", + "Are you sure you want to delete all time-of-day overwrite files?\nThis cannot be undone.", + "Delete All" + }; + + static Util::ConfirmationPopup deleteSingleOverwritePopup{ + "Delete Overwrite File?", + "", + "Delete" + }; + static size_t pendingDeleteIndex = SIZE_MAX; + + static Util::ConfirmationPopup deleteAllUserPopup{ + "Delete All User Settings?", + "Are you sure you want to remove all user-added time-of-day settings?", + "Delete All" + }; + + // --- Helpers to filter entries by period --- + + static void CollectPeriodIndices(Period period, const std::vector& entries, + std::vector& overwriteOut, std::vector& userOut) + { + for (size_t i = 0; i < entries.size(); ++i) { + if (entries[i].period != period) + continue; + if (entries[i].source == EntrySource::Overwrite) + overwriteOut.push_back(i); + else + userOut.push_back(i); + } + } + + static void DrawAddSettingUI(Period period) + { + auto* manager = SceneSettingsManager::GetSingleton(); + auto& state = periodState[static_cast(period)]; + + ImGui::Spacing(); + + // Feature dropdown + if (state.cachedFeatureNames.empty()) + state.cachedFeatureNames = SceneSettingsManager::GetExteriorRelevantFeatureNames(); + + const char* featurePreview = (state.selectedFeatureIdx >= 0 && + state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) + ? state.cachedFeatureNames[state.selectedFeatureIdx].c_str() + : "Select Feature..."; + + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_FEATURE_DROPDOWN_RATIO); + if (ImGui::BeginCombo("##FeatureSelect", featurePreview)) { + for (int i = 0; i < static_cast(state.cachedFeatureNames.size()); ++i) { + bool selected = (i == state.selectedFeatureIdx); + if (ImGui::Selectable(state.cachedFeatureNames[i].c_str(), selected)) { + state.selectedFeatureIdx = i; + state.selectedSettingIdx = -1; + state.cachedSettingKeys = SceneSettingsManager::GetFeatureSettingKeys(state.cachedFeatureNames[i]); + } + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + ImGui::SameLine(); + + // Setting dropdown + { + auto _ = Util::DisableGuard(state.selectedFeatureIdx < 0); + + const char* settingPreview = (state.selectedSettingIdx >= 0 && + state.selectedSettingIdx < static_cast(state.cachedSettingKeys.size())) + ? state.cachedSettingKeys[state.selectedSettingIdx].c_str() + : "Select Setting..."; + + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_SETTING_DROPDOWN_RATIO); + if (ImGui::BeginCombo("##SettingSelect", settingPreview)) { + for (int i = 0; i < static_cast(state.cachedSettingKeys.size()); ++i) { + bool selected = (i == state.selectedSettingIdx); + bool alreadyAdded = state.selectedFeatureIdx >= 0 && + manager->HasEntryForPeriod( + state.cachedFeatureNames[state.selectedFeatureIdx], + state.cachedSettingKeys[i], period, EntrySource::User); + if (alreadyAdded) { + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_TextDisabled]); + ImGui::Selectable(state.cachedSettingKeys[i].c_str(), false, ImGuiSelectableFlags_Disabled); + ImGui::PopStyleColor(); + } else { + if (ImGui::Selectable(state.cachedSettingKeys[i].c_str(), selected)) + state.selectedSettingIdx = i; + } + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } + + ImGui::SameLine(); + + // Add button + bool canAdd = state.selectedFeatureIdx >= 0 && state.selectedSettingIdx >= 0; + { + auto _ = Util::DisableGuard(!canAdd); + if (ImGui::Button("Add")) { + auto& featureName = state.cachedFeatureNames[state.selectedFeatureIdx]; + auto& settingKey = state.cachedSettingKeys[state.selectedSettingIdx]; + auto currentValue = SceneSettingsManager::GetFeatureSettingValue(featureName, settingKey); + manager->AddSetting(kSceneType, featureName, settingKey, currentValue, period); + state.selectedSettingIdx = -1; + return; + } + } + } + + static void DrawSettingEntry(size_t index) + { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(kSceneType); + if (index >= entries.size()) + return; + + const auto& entry = entries[index]; + + ImGui::PushID(static_cast(index)); + + // Feature.Setting label + float availWidth = ImGui::GetContentRegionAvail().x; + ImGui::Text("%s.%s", entry.featureShortName.c_str(), entry.settingKey.c_str()); + + // Value editor (right-aligned) + ImGui::SameLine(availWidth * C::SCENE_VALUE_LABEL_OFFSET_RATIO); + + bool isOverwrite = entry.source == EntrySource::Overwrite; + auto type = SceneSettingsManager::DetectSettingType(entry.value); + bool readOnly = isOverwrite; + + if (readOnly) + ImGui::BeginDisabled(); + + switch (type) { + case SceneSettingsManager::SettingType::Boolean: + { + bool val = entry.value.is_boolean() ? entry.value.get() : (entry.value.get() != 0); + if (ImGui::Checkbox("##val", &val)) { + if (entry.value.is_boolean()) + manager->UpdateEntryValue(kSceneType, index, val); + else + manager->UpdateEntryValue(kSceneType, index, val ? 1 : 0); + } + } + break; + case SceneSettingsManager::SettingType::Float: + { + float val = entry.value.get(); + ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); + if (ImGui::InputFloat("##val", &val, 0.01f, 0.1f, "%.3f")) + manager->UpdateEntryValue(kSceneType, index, val, true); + if (ImGui::IsItemDeactivatedAfterEdit()) + manager->SaveUserSettings(kSceneType); + } + break; + case SceneSettingsManager::SettingType::Integer: + { + int val = entry.value.get(); + ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); + if (ImGui::InputInt("##val", &val)) + manager->UpdateEntryValue(kSceneType, index, val, true); + if (ImGui::IsItemDeactivatedAfterEdit()) + manager->SaveUserSettings(kSceneType); + } + break; + default: + ImGui::TextDisabled("(unsupported type)"); + break; + } + + if (readOnly) + ImGui::EndDisabled(); + + // Active/Pause toggle + ImGui::SameLine(); + bool active = !entry.paused; + if (Util::FeatureToggle("##active", &active)) + manager->TogglePauseEntry(kSceneType, index); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(entry.paused ? "Paused - click to resume" : "Active - click to pause"); + + // Delete button + ImGui::SameLine(); + { + auto styledButton = Util::ErrorButtonStyle(); + if (ImGui::Button("X", ImVec2(C::SCENE_DELETE_BUTTON_WIDTH, 0))) { + if (isOverwrite) { + pendingDeleteIndex = index; + deleteSingleOverwritePopup.message = std::format( + "Delete overwrite file '{}'?\nThis will permanently remove the file from disk.", + entry.sourceFilename); + deleteSingleOverwritePopup.Request(); + } else { + manager->RemoveSetting(kSceneType, index); + ImGui::PopID(); + return; + } + } + } + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(isOverwrite ? "Delete overwrite file from disk" : "Remove this setting"); + + ImGui::PopID(); + } + + static void DrawPeriodTab(Period period) + { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(kSceneType); + auto& theme = globals::menu->GetSettings().Theme; + + // Draw confirmation popups + if (deleteSingleOverwritePopup.Draw()) { + if (pendingDeleteIndex < entries.size()) + manager->RemoveSetting(kSceneType, pendingDeleteIndex); + pendingDeleteIndex = SIZE_MAX; + } + + DrawAddSettingUI(period); + + // Collect indices for this period + std::vector overwriteIndices, userIndices; + CollectPeriodIndices(period, entries, overwriteIndices, userIndices); + + if (overwriteIndices.empty() && userIndices.empty()) { + ImGui::Spacing(); + ImGui::TextColored(theme.StatusPalette.Disable, + "No settings for %s.", SceneSettingsManager::GetPeriodName(period)); + return; + } + + // Overwrite section + if (!overwriteIndices.empty()) { + ImGui::Spacing(); + ImGui::TextColored(theme.StatusPalette.InfoColor, "Overwrite Files"); + ImGui::Separator(); + for (auto i : overwriteIndices) + DrawSettingEntry(i); + } + + // User section + if (!userIndices.empty()) { + if (!overwriteIndices.empty()) { + ImGui::Spacing(); + ImGui::TextColored(theme.FeatureHeading.ColorDefault, "User Settings"); + ImGui::Separator(); + } + for (auto i : userIndices) + DrawSettingEntry(i); + } + } + + void Draw() + { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(kSceneType); + auto& theme = globals::menu->GetSettings().Theme; + + ImGui::Text("Time of Day Settings"); + ImGui::SameLine(); + ImGui::TextDisabled("(Exterior Only)"); + + // Show current period indicator + auto dominant = manager->GetDominantPeriod(); + ImGui::SameLine(); + ImGui::TextColored(theme.StatusPalette.InfoColor, "[%s %.1fh]", + SceneSettingsManager::GetPeriodName(dominant), + SceneSettingsManager::GetCurrentGameHour()); + + ImGui::Separator(); + + // Global confirmation popups + if (deleteAllOverwritesPopup.Draw()) + manager->DeleteAllOverwrites(kSceneType); + if (deleteAllUserPopup.Draw()) + manager->DeleteAllUserSettings(kSceneType); + + // Global controls + if (!entries.empty()) { + if (manager->HasOverwriteEntries(kSceneType)) { + bool allPaused = manager->AreAllOverwritesPaused(kSceneType); + if (ImGui::SmallButton(allPaused ? "Unpause All Overwrite" : "Pause All Overwrite")) + manager->SetAllOverwritesPaused(kSceneType, !allPaused); + ImGui::SameLine(); + if (ImGui::SmallButton("Delete All Overwrite")) + deleteAllOverwritesPopup.Request(); + ImGui::SameLine(); + } + + bool allUserPaused = manager->AreAllUserPaused(kSceneType); + if (ImGui::SmallButton(allUserPaused ? "Unpause All User" : "Pause All User")) + manager->SetAllUserPaused(kSceneType, !allUserPaused); + ImGui::SameLine(); + if (ImGui::SmallButton("Delete All User")) + deleteAllUserPopup.Request(); + } + + // Period tabs + if (ImGui::BeginTabBar("##TODPeriods")) { + for (int i = 0; i < kPeriodCount; ++i) { + auto period = static_cast(i); + if (ImGui::BeginTabItem(SceneSettingsManager::GetPeriodName(period))) { + DrawPeriodTab(period); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + } +} diff --git a/src/WeatherEditor/TimeOfDayPanel.h b/src/WeatherEditor/TimeOfDayPanel.h new file mode 100644 index 0000000000..458776d01c --- /dev/null +++ b/src/WeatherEditor/TimeOfDayPanel.h @@ -0,0 +1,12 @@ +#pragma once + +#include "Utils/UI.h" + +/// UI panel for managing Time of Day scene settings within the Weather Editor. +/// Shows 6 period tabs (Dawn, Sunrise, Day, Sunset, Dusk, Night) with +/// add/pause/delete controls under each. +namespace TimeOfDayPanel +{ + /// Draw the full Time of Day settings panel + void Draw(); +} From 22cae6dc891544338aa855e6276c7ed496fd4566 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 04:07:26 +0000 Subject: [PATCH 02/36] =?UTF-8?q?style:=20=F0=9F=8E=A8=20apply=20pre-commi?= =?UTF-8?q?t.ci=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated formatting by clang-format, prettier, and other hooks. See https://pre-commit.ci for details. --- src/SceneSettingsManager.cpp | 4 +--- src/SceneSettingsManager.h | 2 +- src/WeatherEditor/EditorWindow.cpp | 2 +- src/WeatherEditor/TimeOfDayPanel.cpp | 12 ++++++------ 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index b968fc5eda..16df05f547 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -294,9 +294,7 @@ void SceneSettingsManager::RemoveSetting(SceneType type, size_t index) if (entry.source == EntrySource::Overwrite && !entry.sourceFilename.empty()) { // For TimeOfDay overwrites, files are in period subfolders auto basePath = GetOverwritesPath(type); - auto filepath = (type == SceneType::TimeOfDay && entry.period != TimeOfDayPeriod::Count) - ? basePath / GetPeriodName(entry.period) / entry.sourceFilename - : basePath / entry.sourceFilename; + auto filepath = (type == SceneType::TimeOfDay && entry.period != TimeOfDayPeriod::Count) ? basePath / GetPeriodName(entry.period) / entry.sourceFilename : basePath / entry.sourceFilename; std::error_code ec; if (std::filesystem::remove(filepath, ec)) logger::info("[SceneSettings] Deleted overwrite file: {}", filepath.string()); diff --git a/src/SceneSettingsManager.h b/src/SceneSettingsManager.h index 86bd5d44c4..3b2b76af39 100644 --- a/src/SceneSettingsManager.h +++ b/src/SceneSettingsManager.h @@ -106,7 +106,7 @@ class SceneSettingsManager json value; // Override value (bool, float, int, etc.) bool paused = false; // Temporarily disabled EntrySource source = EntrySource::User; - std::string sourceFilename; // For overwrites: the filename it came from + std::string sourceFilename; // For overwrites: the filename it came from TimeOfDayPeriod period = TimeOfDayPeriod::Count; // Which period this entry belongs to (TimeOfDay only) }; diff --git a/src/WeatherEditor/EditorWindow.cpp b/src/WeatherEditor/EditorWindow.cpp index aa009b04a7..d1989ae4cc 100644 --- a/src/WeatherEditor/EditorWindow.cpp +++ b/src/WeatherEditor/EditorWindow.cpp @@ -2,10 +2,10 @@ #include "Features/WeatherEditor.h" #include "InteriorOnlyPanel.h" -#include "TimeOfDayPanel.h" #include "Menu.h" #include "PaletteWindow.h" #include "State.h" +#include "TimeOfDayPanel.h" #include "Utils/UI.h" #include "Weather/LightingTemplateWidget.h" #include "WeatherUtils.h" diff --git a/src/WeatherEditor/TimeOfDayPanel.cpp b/src/WeatherEditor/TimeOfDayPanel.cpp index c00a23d13f..f1383bdb7f 100644 --- a/src/WeatherEditor/TimeOfDayPanel.cpp +++ b/src/WeatherEditor/TimeOfDayPanel.cpp @@ -74,9 +74,9 @@ namespace TimeOfDayPanel state.cachedFeatureNames = SceneSettingsManager::GetExteriorRelevantFeatureNames(); const char* featurePreview = (state.selectedFeatureIdx >= 0 && - state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) - ? state.cachedFeatureNames[state.selectedFeatureIdx].c_str() - : "Select Feature..."; + state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) ? + state.cachedFeatureNames[state.selectedFeatureIdx].c_str() : + "Select Feature..."; ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_FEATURE_DROPDOWN_RATIO); if (ImGui::BeginCombo("##FeatureSelect", featurePreview)) { @@ -100,9 +100,9 @@ namespace TimeOfDayPanel auto _ = Util::DisableGuard(state.selectedFeatureIdx < 0); const char* settingPreview = (state.selectedSettingIdx >= 0 && - state.selectedSettingIdx < static_cast(state.cachedSettingKeys.size())) - ? state.cachedSettingKeys[state.selectedSettingIdx].c_str() - : "Select Setting..."; + state.selectedSettingIdx < static_cast(state.cachedSettingKeys.size())) ? + state.cachedSettingKeys[state.selectedSettingIdx].c_str() : + "Select Setting..."; ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_SETTING_DROPDOWN_RATIO); if (ImGui::BeginCombo("##SettingSelect", settingPreview)) { From a810b7f8dd4600a7d78bd1fd519f73221b5285dc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:36:34 +0000 Subject: [PATCH 03/36] =?UTF-8?q?style:=20=F0=9F=8E=A8=20apply=20pre-commi?= =?UTF-8?q?t.ci=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated formatting by clang-format, prettier, and other hooks. See https://pre-commit.ci for details. --- src/WeatherEditor/EditorWindow.cpp | 3146 ++++++++++++++-------------- 1 file changed, 1573 insertions(+), 1573 deletions(-) diff --git a/src/WeatherEditor/EditorWindow.cpp b/src/WeatherEditor/EditorWindow.cpp index f9612f784f..01f913771d 100644 --- a/src/WeatherEditor/EditorWindow.cpp +++ b/src/WeatherEditor/EditorWindow.cpp @@ -223,1854 +223,1854 @@ void EditorWindow::ShowObjectsWindow() if (ImGui::BeginChild("##ObjectsContent", { 0, 0 }, ImGuiChildFlags_Border)) { // Interior Only / Time of Day categories have their own panels if (selectedCategory == "Interior Only") { - if (ImGui::BeginChild("##ObjectsContent", { 0, 0 }, ImGuiChildFlags_Border, kStickyHeaderFlags)) { - // Interior Only category has its own panel - if (m_selectedCategory == "Interior Only") { - InteriorOnlyPanel::Draw(); - ImGui::EndChild(); - ImGui::EndTable(); - ImGui::End(); - return; - } - if (selectedCategory == "Time of Day") { - TimeOfDayPanel::Draw(); - ImGui::EndChild(); - ImGui::EndTable(); - ImGui::End(); - return; - } + if (ImGui::BeginChild("##ObjectsContent", { 0, 0 }, ImGuiChildFlags_Border, kStickyHeaderFlags)) { + // Interior Only category has its own panel + if (m_selectedCategory == "Interior Only") { + InteriorOnlyPanel::Draw(); + ImGui::EndChild(); + ImGui::EndTable(); + ImGui::End(); + return; + } + if (selectedCategory == "Time of Day") { + TimeOfDayPanel::Draw(); + ImGui::EndChild(); + ImGui::EndTable(); + ImGui::End(); + return; + } - // Display current active weather - auto sky = globals::game::sky; - if (sky && sky->currentWeather) { - auto currentWeather = sky->currentWeather; - ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetTheme().StatusPalette.RestartNeeded); - ImGui::Text("Current Active Weather:"); - ImGui::PopStyleColor(); - ImGui::SameLine(); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().Palette.Text, "%s", currentWeather->GetFormEditorID()); - ImGui::SameLine(); - ImGui::TextDisabled("(0x%08X)", currentWeather->GetFormID()); + // Display current active weather + auto sky = globals::game::sky; + if (sky && sky->currentWeather) { + auto currentWeather = sky->currentWeather; + ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetTheme().StatusPalette.RestartNeeded); + ImGui::Text("Current Active Weather:"); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().Palette.Text, "%s", currentWeather->GetFormEditorID()); + ImGui::SameLine(); + ImGui::TextDisabled("(0x%08X)", currentWeather->GetFormID()); - // Add button to open the current weather - ImGui::SameLine(); - if (ImGui::SmallButton("Open##CurrentWeather")) { - for (auto& widget : weatherWidgets) { - if (widget->form == currentWeather) { - widget->SetOpen(true); - break; + // Add button to open the current weather + ImGui::SameLine(); + if (ImGui::SmallButton("Open##CurrentWeather")) { + for (auto& widget : weatherWidgets) { + if (widget->form == currentWeather) { + widget->SetOpen(true); + break; + } + } } + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); } - } - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - } - - // Handle Ctrl+F to focus search bar - if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { - if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_F, false)) { - ImGui::SetKeyboardFocusHere(); - } - } - // Compute fixed widths once; reuse for both the search bar and the following combo. - const auto& style = ImGui::GetStyle(); - // comboW = preview text + left/right padding + arrow button - const float comboW = ImGui::CalcTextSize("Editor ID").x + style.FramePadding.x * 2.0f + ImGui::GetFrameHeight(); - const float helpW = ImGui::CalcTextSize("(?)").x; - const float iconW = ImGui::GetFrameHeight(); - // Fixed width is the sum of every item that follows the search bar on the same row. - // Each SameLine() contributes style.ItemSpacing.x; widths are listed explicitly - // so adding or removing a widget only requires updating its own expression. - const float fixedW = - style.ItemSpacing.x + comboW + // combo - style.ItemSpacing.x + helpW + // help marker - style.ItemSpacing.x + 10.0f + // spacer before favorites - style.ItemSpacing.x + iconW + // fav icon - style.ItemSpacing.x + ImGui::CalcTextSize("Favorites").x + // "Favorites" label - style.ItemSpacing.x + 10.0f + // spacer before flagged - style.ItemSpacing.x + iconW + // flag icon - style.ItemSpacing.x + ImGui::CalcTextSize("Flagged").x; // "Flagged" label - ImGui::SetNextItemWidth(std::max(50.0f, ImGui::GetContentRegionAvail().x - fixedW)); - ImGui::InputTextWithHint("##ObjectFilter", "Filter... (Ctrl+F)", m_filterBuffer, sizeof(m_filterBuffer)); - - ImGui::SameLine(); - ImGui::SetNextItemWidth(comboW); - int col = static_cast(m_currentFilterColumn); - if (ImGui::Combo("##FilterBy", &col, kFilterColumnNames, IM_ARRAYSIZE(kFilterColumnNames))) - m_currentFilterColumn = static_cast(col); - - ImGui::SameLine(); - Util::HelpMarker("Filter the object list by the selected column.\nAll: searches Editor ID, Form ID, File, and Status.\nStatus: hides items with no status marker when the search box is non-empty.\nCtrl+F: Focus search\nEnter: Open selected"); - - // Quick filter buttons on same row - ImGui::SameLine(); - ImGui::Dummy(ImVec2(10.0f, 0.0f)); // Spacer - ImGui::SameLine(); - if (IconButton("##filterFavorites", m_showOnlyFavorites, "star")) { - m_showOnlyFavorites = !m_showOnlyFavorites; - } - ImGui::SameLine(); - ImGui::Text("Favorites"); - ImGui::SameLine(); - ImGui::Dummy(ImVec2(10.0f, 0.0f)); // Spacer - ImGui::SameLine(); - if (IconButton("##filterFlagged", m_showOnlyFlagged, "circle")) { - m_showOnlyFlagged = !m_showOnlyFlagged; - } - ImGui::SameLine(); - ImGui::Text("Flagged"); - - // Returns the widget collection for a given category; Cell Lighting and unknown - // categories return an empty collection since they have no standalone widget list. - auto getWidgetsForCategory = [&](const std::string& cat) -> const std::vector>& { - static const std::vector> emptyWidgets; - if (cat == "Weather") - return weatherWidgets; - if (cat == "Lighting Template") - return lightingTemplateWidgets; - if (cat == "ImageSpace") - return imageSpaceWidgets; - if (cat == "Volumetric Lighting") - return volumetricLightingWidgets; - if (cat == "Shader Particle Geometry") - return precipitationWidgets; - if (cat == "Lens Flare") - return lensFlareWidgets; - if (cat == "Visual Effect") - return referenceEffectWidgets; - return emptyWidgets; - }; - - // Show recent widgets section for current category - auto recentIt = settings.recentWidgets.find(m_selectedCategory); - if (recentIt != settings.recentWidgets.end() && !recentIt->second.empty()) { - ImGui::Spacing(); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Recent:"); - ImGui::SameLine(); - for (size_t i = 0; i < std::min(size_t(5), recentIt->second.size()); ++i) { - if (i > 0) + // Handle Ctrl+F to focus search bar + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { + if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_F, false)) { + ImGui::SetKeyboardFocusHere(); + } + } + // Compute fixed widths once; reuse for both the search bar and the following combo. + const auto& style = ImGui::GetStyle(); + // comboW = preview text + left/right padding + arrow button + const float comboW = ImGui::CalcTextSize("Editor ID").x + style.FramePadding.x * 2.0f + ImGui::GetFrameHeight(); + const float helpW = ImGui::CalcTextSize("(?)").x; + const float iconW = ImGui::GetFrameHeight(); + // Fixed width is the sum of every item that follows the search bar on the same row. + // Each SameLine() contributes style.ItemSpacing.x; widths are listed explicitly + // so adding or removing a widget only requires updating its own expression. + const float fixedW = + style.ItemSpacing.x + comboW + // combo + style.ItemSpacing.x + helpW + // help marker + style.ItemSpacing.x + 10.0f + // spacer before favorites + style.ItemSpacing.x + iconW + // fav icon + style.ItemSpacing.x + ImGui::CalcTextSize("Favorites").x + // "Favorites" label + style.ItemSpacing.x + 10.0f + // spacer before flagged + style.ItemSpacing.x + iconW + // flag icon + style.ItemSpacing.x + ImGui::CalcTextSize("Flagged").x; // "Flagged" label + ImGui::SetNextItemWidth(std::max(50.0f, ImGui::GetContentRegionAvail().x - fixedW)); + ImGui::InputTextWithHint("##ObjectFilter", "Filter... (Ctrl+F)", m_filterBuffer, sizeof(m_filterBuffer)); + + ImGui::SameLine(); + ImGui::SetNextItemWidth(comboW); + int col = static_cast(m_currentFilterColumn); + if (ImGui::Combo("##FilterBy", &col, kFilterColumnNames, IM_ARRAYSIZE(kFilterColumnNames))) + m_currentFilterColumn = static_cast(col); + + ImGui::SameLine(); + Util::HelpMarker("Filter the object list by the selected column.\nAll: searches Editor ID, Form ID, File, and Status.\nStatus: hides items with no status marker when the search box is non-empty.\nCtrl+F: Focus search\nEnter: Open selected"); + + // Quick filter buttons on same row + ImGui::SameLine(); + ImGui::Dummy(ImVec2(10.0f, 0.0f)); // Spacer + ImGui::SameLine(); + if (IconButton("##filterFavorites", m_showOnlyFavorites, "star")) { + m_showOnlyFavorites = !m_showOnlyFavorites; + } + ImGui::SameLine(); + ImGui::Text("Favorites"); + + ImGui::SameLine(); + ImGui::Dummy(ImVec2(10.0f, 0.0f)); // Spacer + ImGui::SameLine(); + if (IconButton("##filterFlagged", m_showOnlyFlagged, "circle")) { + m_showOnlyFlagged = !m_showOnlyFlagged; + } + ImGui::SameLine(); + ImGui::Text("Flagged"); + + // Returns the widget collection for a given category; Cell Lighting and unknown + // categories return an empty collection since they have no standalone widget list. + auto getWidgetsForCategory = [&](const std::string& cat) -> const std::vector>& { + static const std::vector> emptyWidgets; + if (cat == "Weather") + return weatherWidgets; + if (cat == "Lighting Template") + return lightingTemplateWidgets; + if (cat == "ImageSpace") + return imageSpaceWidgets; + if (cat == "Volumetric Lighting") + return volumetricLightingWidgets; + if (cat == "Shader Particle Geometry") + return precipitationWidgets; + if (cat == "Lens Flare") + return lensFlareWidgets; + if (cat == "Visual Effect") + return referenceEffectWidgets; + return emptyWidgets; + }; + + // Show recent widgets section for current category + auto recentIt = settings.recentWidgets.find(m_selectedCategory); + if (recentIt != settings.recentWidgets.end() && !recentIt->second.empty()) { + ImGui::Spacing(); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Recent:"); ImGui::SameLine(); - if (ImGui::SmallButton(recentIt->second[i].c_str())) { - // Find and open widget in current category's collection - const auto& widgets = getWidgetsForCategory(m_selectedCategory); - for (auto& widget : widgets) { - if (widget->GetEditorID() == recentIt->second[i]) { - widget->SetOpen(true); - break; + for (size_t i = 0; i < std::min(size_t(5), recentIt->second.size()); ++i) { + if (i > 0) + ImGui::SameLine(); + if (ImGui::SmallButton(recentIt->second[i].c_str())) { + // Find and open widget in current category's collection + const auto& widgets = getWidgetsForCategory(m_selectedCategory); + for (auto& widget : widgets) { + if (widget->GetEditorID() == recentIt->second[i]) { + widget->SetOpen(true); + break; + } + } } } } - } - } - // Scrollable area for the object table - BeginScrollableContent("##ObjectsScrollable"); - - // Stable user IDs for sortable columns — used instead of ColumnIndex so reordering/insertion won't break sorting. - enum ColumnID : ImGuiID - { - ColFav = 0, - ColEditorID, - ColFormID, - ColFile, - ColStatus, - ColJson - }; - - // Create a table for the right column with "Name" and "ID" headers. Different weights to prevent truncation. - if (ImGui::BeginTable("DetailsTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_Sortable)) { - ImGui::TableSetupColumn("Fav", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoSort, 38.0f, ColFav); // Favorite indicator - ImGui::TableSetupColumn("Editor ID", ImGuiTableColumnFlags_WidthStretch, 3.5f, ColEditorID); // Largest - weather/template names - ImGui::TableSetupColumn("Form ID", ImGuiTableColumnFlags_WidthFixed, 90.0f, ColFormID); // Fixed - 8 hex chars - ImGui::TableSetupColumn("File", ImGuiTableColumnFlags_WidthStretch, 2.0f, ColFile); // Medium - plugin names - ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthStretch, 1.5f, ColStatus); // Smaller - status text - ImGui::TableSetupColumn("json", ImGuiTableColumnFlags_WidthFixed, 55.0f, ColJson); // JSON file / delete - - ImGui::TableHeadersRow(); - - // Handle column sorting - if (ImGuiTableSortSpecs* sortSpecs = ImGui::TableGetSortSpecs()) { - if (sortSpecs->SpecsDirty) { - if (sortSpecs->SpecsCount > 0) { - const ImGuiTableColumnSortSpecs& spec = sortSpecs->Specs[0]; - switch (spec.ColumnUserID) { - case ColEditorID: - currentSortColumn = SortColumn::EditorID; - break; - case ColFormID: - currentSortColumn = SortColumn::FormID; - break; - case ColFile: - currentSortColumn = SortColumn::File; - break; - case ColStatus: - currentSortColumn = SortColumn::Status; - break; - case ColJson: - currentSortColumn = SortColumn::JsonAttachment; - break; - default: - currentSortColumn = SortColumn::None; - break; + // Scrollable area for the object table + BeginScrollableContent("##ObjectsScrollable"); + + // Stable user IDs for sortable columns — used instead of ColumnIndex so reordering/insertion won't break sorting. + enum ColumnID : ImGuiID + { + ColFav = 0, + ColEditorID, + ColFormID, + ColFile, + ColStatus, + ColJson + }; + + // Create a table for the right column with "Name" and "ID" headers. Different weights to prevent truncation. + if (ImGui::BeginTable("DetailsTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_Sortable)) { + ImGui::TableSetupColumn("Fav", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoSort, 38.0f, ColFav); // Favorite indicator + ImGui::TableSetupColumn("Editor ID", ImGuiTableColumnFlags_WidthStretch, 3.5f, ColEditorID); // Largest - weather/template names + ImGui::TableSetupColumn("Form ID", ImGuiTableColumnFlags_WidthFixed, 90.0f, ColFormID); // Fixed - 8 hex chars + ImGui::TableSetupColumn("File", ImGuiTableColumnFlags_WidthStretch, 2.0f, ColFile); // Medium - plugin names + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthStretch, 1.5f, ColStatus); // Smaller - status text + ImGui::TableSetupColumn("json", ImGuiTableColumnFlags_WidthFixed, 55.0f, ColJson); // JSON file / delete + + ImGui::TableHeadersRow(); + + // Handle column sorting + if (ImGuiTableSortSpecs* sortSpecs = ImGui::TableGetSortSpecs()) { + if (sortSpecs->SpecsDirty) { + if (sortSpecs->SpecsCount > 0) { + const ImGuiTableColumnSortSpecs& spec = sortSpecs->Specs[0]; + switch (spec.ColumnUserID) { + case ColEditorID: + currentSortColumn = SortColumn::EditorID; + break; + case ColFormID: + currentSortColumn = SortColumn::FormID; + break; + case ColFile: + currentSortColumn = SortColumn::File; + break; + case ColStatus: + currentSortColumn = SortColumn::Status; + break; + case ColJson: + currentSortColumn = SortColumn::JsonAttachment; + break; + default: + currentSortColumn = SortColumn::None; + break; + } + sortAscending = (spec.SortDirection == ImGuiSortDirection_Ascending); + } else { + currentSortColumn = SortColumn::None; + } + sortSpecs->SpecsDirty = false; } - sortAscending = (spec.SortDirection == ImGuiSortDirection_Ascending); - } else { - currentSortColumn = SortColumn::None; } - sortSpecs->SpecsDirty = false; - } - } - // Display objects based on the selected category - const auto& widgets = getWidgetsForCategory(m_selectedCategory); - // Sort widgets based on current sort column - std::vector sortedWidgets; - sortedWidgets.reserve(widgets.size()); - for (const auto& w : widgets) { - sortedWidgets.push_back(w.get()); - } - RefreshJsonAttachmentCache(sortedWidgets); - bool weatherTooltipShownThisFrame = false; - if (currentSortColumn != SortColumn::None) { - std::sort(sortedWidgets.begin(), sortedWidgets.end(), [this](Widget* a, Widget* b) { - int comparison = 0; - switch (currentSortColumn) { - case SortColumn::EditorID: - comparison = _stricmp(a->GetEditorID().c_str(), b->GetEditorID().c_str()); - break; - case SortColumn::FormID: - comparison = _stricmp(a->GetFormID().c_str(), b->GetFormID().c_str()); - break; - case SortColumn::File: - comparison = _stricmp(a->GetFilename().c_str(), b->GetFilename().c_str()); - break; - case SortColumn::Status: - { - auto markerA = settings.markedRecords.find(a->GetEditorID()); - auto markerB = settings.markedRecords.find(b->GetEditorID()); - std::string statusA = (markerA != settings.markedRecords.end()) ? markerA->second : ""; - std::string statusB = (markerB != settings.markedRecords.end()) ? markerB->second : ""; - comparison = _stricmp(statusA.c_str(), statusB.c_str()); - break; + // Display objects based on the selected category + const auto& widgets = getWidgetsForCategory(m_selectedCategory); + // Sort widgets based on current sort column + std::vector sortedWidgets; + sortedWidgets.reserve(widgets.size()); + for (const auto& w : widgets) { + sortedWidgets.push_back(w.get()); + } + RefreshJsonAttachmentCache(sortedWidgets); + bool weatherTooltipShownThisFrame = false; + if (currentSortColumn != SortColumn::None) { + std::sort(sortedWidgets.begin(), sortedWidgets.end(), [this](Widget* a, Widget* b) { + int comparison = 0; + switch (currentSortColumn) { + case SortColumn::EditorID: + comparison = _stricmp(a->GetEditorID().c_str(), b->GetEditorID().c_str()); + break; + case SortColumn::FormID: + comparison = _stricmp(a->GetFormID().c_str(), b->GetFormID().c_str()); + break; + case SortColumn::File: + comparison = _stricmp(a->GetFilename().c_str(), b->GetFilename().c_str()); + break; + case SortColumn::Status: + { + auto markerA = settings.markedRecords.find(a->GetEditorID()); + auto markerB = settings.markedRecords.find(b->GetEditorID()); + std::string statusA = (markerA != settings.markedRecords.end()) ? markerA->second : ""; + std::string statusB = (markerB != settings.markedRecords.end()) ? markerB->second : ""; + comparison = _stricmp(statusA.c_str(), statusB.c_str()); + break; + } + case SortColumn::JsonAttachment: + { + bool aHasJson = HasCachedJsonAttachment(a); + bool bHasJson = HasCachedJsonAttachment(b); + comparison = static_cast(aHasJson) - static_cast(bHasJson); + break; + } + default: + break; + } + return sortAscending ? (comparison < 0) : (comparison > 0); + }); + } + + // Helper lambda: renders the JSON delete button column for a widget + auto drawJsonDeleteButton = [&](Widget* widget) { + ImGui::TableNextColumn(); + if (HasCachedJsonAttachment(widget)) { + auto* menu = globals::menu; + if (menu && menu->uiIcons.deleteSettings.texture) { + const float iconSize = ImGui::GetFrameHeight() * 0.85f; + auto _style = Util::ErrorButtonStyle(); + ImGui::SetNextItemAllowOverlap(); + char idBuf[32]; + snprintf(idBuf, sizeof(idBuf), "##jsondel_%s", widget->GetFormID().c_str()); + if (ImGui::ImageButton(idBuf, menu->uiIcons.deleteSettings.texture, { iconSize, iconSize })) { + pendingDeleteWidget = widget; + pendingDeletePopupRequested = true; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Delete JSON file"); + } } - case SortColumn::JsonAttachment: - { - bool aHasJson = HasCachedJsonAttachment(a); - bool bHasJson = HasCachedJsonAttachment(b); - comparison = static_cast(aHasJson) - static_cast(bHasJson); - break; + }; + + // Special handling for Cell Lighting category + if (m_selectedCategory == "Cell Lighting") { + auto player = RE::PlayerCharacter::GetSingleton(); + if (player && player->parentCell) { + auto cell = player->parentCell; + bool isInterior = cell->IsInteriorCell(); + + if (isInterior) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + + // No favorite star for cell lighting (it's always the current cell) + ImGui::Dummy(ImVec2(ImGui::GetFrameHeight(), ImGui::GetFrameHeight())); + ImGui::TableNextColumn(); + + // Display current cell name + const char* cellName = cell->GetName(); + std::string displayName = cellName && cellName[0] ? cellName : "[Unnamed Cell]"; + std::string label = std::format("[CURRENT CELL] {}", displayName); + + // Highlight current cell (before TableRowSelectable so hover/active can override) + auto highlightColor = Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor; + highlightColor.w = 0.3f; + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::ColorConvertFloat4ToU32(highlightColor)); + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::ColorConvertFloat4ToU32(highlightColor)); + + bool isOpen = currentCellLightingWidget && currentCellLightingWidget->IsOpen(); + if (Util::TableRowSelectable(label.c_str(), isOpen, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { + if (ImGui::IsMouseDoubleClicked(0)) { + // Open or reuse the cell lighting widget + if (currentCellLightingWidget && currentCellLightingWidget->cell == cell) { + currentCellLightingWidget->SetOpen(true); + } else { + currentCellLightingWidget = std::make_unique(cell); + currentCellLightingWidget->CacheFormData(); + currentCellLightingWidget->Load(); + currentCellLightingWidget->SetOpen(true); + } + } + } + + // Enter key to open + if (isOpen && ImGui::IsKeyPressed(ImGuiKey_Enter)) { + if (currentCellLightingWidget && currentCellLightingWidget->cell == cell) { + currentCellLightingWidget->SetOpen(true); + } + } + + // Form ID column + ImGui::TableNextColumn(); + ImGui::Text("0x%08X", cell->GetFormID()); + + // File column + ImGui::TableNextColumn(); + auto file = cell->GetFile(0); + if (file) { + ImGui::Text("%s", file->fileName); + } + + // Status column + ImGui::TableNextColumn(); + ImGui::Text("Interior Cell"); + + // json column (empty for cells - no standalone json) + ImGui::TableNextColumn(); + } else { + // Show message that cell lighting is only for interior cells + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.Warning, "Cell Lighting is only available for interior cells."); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.Disable, "You are currently in an exterior cell."); + } + } else { + // No player or cell + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.Error, "Player cell not available."); } - default: - break; } - return sortAscending ? (comparison < 0) : (comparison > 0); - }); - } - // Helper lambda: renders the JSON delete button column for a widget - auto drawJsonDeleteButton = [&](Widget* widget) { - ImGui::TableNextColumn(); - if (HasCachedJsonAttachment(widget)) { - auto* menu = globals::menu; - if (menu && menu->uiIcons.deleteSettings.texture) { - const float iconSize = ImGui::GetFrameHeight() * 0.85f; - auto _style = Util::ErrorButtonStyle(); - ImGui::SetNextItemAllowOverlap(); - char idBuf[32]; - snprintf(idBuf, sizeof(idBuf), "##jsondel_%s", widget->GetFormID().c_str()); - if (ImGui::ImageButton(idBuf, menu->uiIcons.deleteSettings.texture, { iconSize, iconSize })) { - pendingDeleteWidget = widget; - pendingDeletePopupRequested = true; + // Get current cell's lighting template for prioritization + RE::BGSLightingTemplate* currentCellLightingTemplate = nullptr; + if (m_selectedCategory == "Lighting Template") { + auto player = RE::PlayerCharacter::GetSingleton(); + if (player && player->parentCell) { + auto& cellData = player->parentCell->GetRuntimeData(); + currentCellLightingTemplate = cellData.lightingTemplate; } - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Delete JSON file"); } - } - }; - // Special handling for Cell Lighting category - if (m_selectedCategory == "Cell Lighting") { - auto player = RE::PlayerCharacter::GetSingleton(); - if (player && player->parentCell) { - auto cell = player->parentCell; - bool isInterior = cell->IsInteriorCell(); + // Centralized filter check used by both display loops below. + auto shouldShowWidget = [&](Widget* w) { + if (!MatchesObjectFilter(w)) + return false; + if (m_showOnlyFavorites && !IsFavorite(w->GetEditorID())) + return false; + if (m_showOnlyFlagged && settings.markedRecords.find(w->GetEditorID()) == settings.markedRecords.end()) + return false; + return true; + }; + + // Filtered display of widgets - show current cell's lighting template first + if (currentCellLightingTemplate && m_selectedCategory == "Lighting Template") { + for (int i = 0; i < sortedWidgets.size(); ++i) { + auto* ltWidget = dynamic_cast(sortedWidgets[i]); + if (!ltWidget || ltWidget->lightingTemplate != currentCellLightingTemplate) + continue; + + if (!shouldShowWidget(sortedWidgets[i])) + continue; + + auto editorLabel = std::format("[CURRENT] {}", sortedWidgets[i]->GetEditorID()); + auto markedRecord = settings.markedRecords.find(sortedWidgets[i]->GetEditorID()); + ImGui::TableNextRow(); + + // Highlight current cell's lighting template + auto highlightColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.InfoColor; + highlightColor.w = 0.3f; + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::ColorConvertFloat4ToU32(highlightColor)); + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::ColorConvertFloat4ToU32(highlightColor)); + + ImGui::TableSetColumnIndex(0); + + // Favorite star + if (IconButton("##fav_current", IsFavorite(sortedWidgets[i]->GetEditorID()), "star")) { + ToggleFavorite(sortedWidgets[i]->GetEditorID()); + } - if (isInterior) { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); + ImGui::TableNextColumn(); - // No favorite star for cell lighting (it's always the current cell) - ImGui::Dummy(ImVec2(ImGui::GetFrameHeight(), ImGui::GetFrameHeight())); - ImGui::TableNextColumn(); + // Editor ID column with [CURRENT] prefix + bool isSelected = sortedWidgets[i]->IsOpen(); + if (Util::TableRowSelectable(editorLabel.c_str(), isSelected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_AllowOverlap)) { + if (ImGui::IsMouseDoubleClicked(0)) { + sortedWidgets[i]->SetOpen(true); + AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); + } + } - // Display current cell name - const char* cellName = cell->GetName(); - std::string displayName = cellName && cellName[0] ? cellName : "[Unnamed Cell]"; - std::string label = std::format("[CURRENT CELL] {}", displayName); + // Enter key to open + if (isSelected && ImGui::IsKeyPressed(ImGuiKey_Enter)) { + sortedWidgets[i]->SetOpen(true); + AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); + } - // Highlight current cell (before TableRowSelectable so hover/active can override) - auto highlightColor = Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor; - highlightColor.w = 0.3f; - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::ColorConvertFloat4ToU32(highlightColor)); - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::ColorConvertFloat4ToU32(highlightColor)); + // Context menu + if (ImGui::BeginPopupContextItem(std::format("widget_context_menu##{}", sortedWidgets[i]->GetFormID()).c_str(), ImGuiPopupFlags_MouseButtonRight)) { + auto& markedRecords = settings.markedRecords; - bool isOpen = currentCellLightingWidget && currentCellLightingWidget->IsOpen(); - if (Util::TableRowSelectable(label.c_str(), isOpen, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { - if (ImGui::IsMouseDoubleClicked(0)) { - // Open or reuse the cell lighting widget - if (currentCellLightingWidget && currentCellLightingWidget->cell == cell) { - currentCellLightingWidget->SetOpen(true); - } else { - currentCellLightingWidget = std::make_unique(cell); - currentCellLightingWidget->CacheFormData(); - currentCellLightingWidget->Load(); - currentCellLightingWidget->SetOpen(true); + for (auto& recordMarker : settings.recordMarkers) { + if (ImGui::MenuItem(recordMarker.first.c_str())) { + settings.markedRecords[sortedWidgets[i]->GetEditorID()] = recordMarker.first; + Save(); + } + } + + if (ImGui::MenuItem("Remove")) { + markedRecords.erase(sortedWidgets[i]->GetEditorID()); + Save(); } + + ImGui::EndPopup(); } - } - // Enter key to open - if (isOpen && ImGui::IsKeyPressed(ImGuiKey_Enter)) { - if (currentCellLightingWidget && currentCellLightingWidget->cell == cell) { - currentCellLightingWidget->SetOpen(true); + // Form ID column + ImGui::TableNextColumn(); + ImGui::Text(sortedWidgets[i]->GetFormID().c_str()); + + // File column + ImGui::TableNextColumn(); + ImGui::Text(sortedWidgets[i]->GetFilename().c_str()); + + // Status column + ImGui::TableNextColumn(); + if (markedRecord != settings.markedRecords.end()) { + ImGui::Text("%s", markedRecord->second.c_str()); } - } - // Form ID column - ImGui::TableNextColumn(); - ImGui::Text("0x%08X", cell->GetFormID()); + // json / delete column + drawJsonDeleteButton(sortedWidgets[i]); + } + } - // File column - ImGui::TableNextColumn(); - auto file = cell->GetFile(0); - if (file) { - ImGui::Text("%s", file->fileName); + // Filtered display of widgets - regular list + for (int i = 0; i < sortedWidgets.size(); ++i) { + // Skip current cell's lighting template if already shown + if (currentCellLightingTemplate && m_selectedCategory == "Lighting Template") { + auto* ltWidget = dynamic_cast(sortedWidgets[i]); + if (ltWidget && ltWidget->lightingTemplate == currentCellLightingTemplate) + continue; } - // Status column - ImGui::TableNextColumn(); - ImGui::Text("Interior Cell"); + if (!shouldShowWidget(sortedWidgets[i])) + continue; - // json column (empty for cells - no standalone json) - ImGui::TableNextColumn(); - } else { - // Show message that cell lighting is only for interior cells + auto editorLabel = sortedWidgets[i]->GetEditorID(); + auto markedRecord = settings.markedRecords.find(editorLabel); ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(1); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.Warning, "Cell Lighting is only available for interior cells."); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.Disable, "You are currently in an exterior cell."); - } - } else { - // No player or cell - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(1); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.Error, "Player cell not available."); - } - } - // Get current cell's lighting template for prioritization - RE::BGSLightingTemplate* currentCellLightingTemplate = nullptr; - if (m_selectedCategory == "Lighting Template") { - auto player = RE::PlayerCharacter::GetSingleton(); - if (player && player->parentCell) { - auto& cellData = player->parentCell->GetRuntimeData(); - currentCellLightingTemplate = cellData.lightingTemplate; - } - } + // Set background colour + if (markedRecord != settings.markedRecords.end()) { + auto& color = settings.recordMarkers[markedRecord->second]; + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::ColorConvertFloat4ToU32(color)); + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::ColorConvertFloat4ToU32(color)); + } - // Centralized filter check used by both display loops below. - auto shouldShowWidget = [&](Widget* w) { - if (!MatchesObjectFilter(w)) - return false; - if (m_showOnlyFavorites && !IsFavorite(w->GetEditorID())) - return false; - if (m_showOnlyFlagged && settings.markedRecords.find(w->GetEditorID()) == settings.markedRecords.end()) - return false; - return true; - }; - - // Filtered display of widgets - show current cell's lighting template first - if (currentCellLightingTemplate && m_selectedCategory == "Lighting Template") { - for (int i = 0; i < sortedWidgets.size(); ++i) { - auto* ltWidget = dynamic_cast(sortedWidgets[i]); - if (!ltWidget || ltWidget->lightingTemplate != currentCellLightingTemplate) - continue; - - if (!shouldShowWidget(sortedWidgets[i])) - continue; - - auto editorLabel = std::format("[CURRENT] {}", sortedWidgets[i]->GetEditorID()); - auto markedRecord = settings.markedRecords.find(sortedWidgets[i]->GetEditorID()); - ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); - // Highlight current cell's lighting template - auto highlightColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.InfoColor; - highlightColor.w = 0.3f; - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::ColorConvertFloat4ToU32(highlightColor)); - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::ColorConvertFloat4ToU32(highlightColor)); + // Favorite star + if (IconButton(std::format("##fav_{}", i).c_str(), IsFavorite(sortedWidgets[i]->GetEditorID()), "star")) { + ToggleFavorite(sortedWidgets[i]->GetEditorID()); + } - ImGui::TableSetColumnIndex(0); + ImGui::TableNextColumn(); - // Favorite star - if (IconButton("##fav_current", IsFavorite(sortedWidgets[i]->GetEditorID()), "star")) { - ToggleFavorite(sortedWidgets[i]->GetEditorID()); - } + // Editor ID column + bool isSelected = sortedWidgets[i]->IsOpen(); + if (Util::TableRowSelectable(editorLabel.c_str(), isSelected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_AllowOverlap)) { + if (ImGui::IsMouseDoubleClicked(0)) { + sortedWidgets[i]->SetOpen(true); + AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); + } + } - ImGui::TableNextColumn(); + // Show ImageSpace and VolumetricLighting info for weather widgets + if (!weatherTooltipShownThisFrame && m_selectedCategory == "Weather" && ImGui::IsItemHovered()) { + auto* weatherWidget = dynamic_cast(sortedWidgets[i]); + if (weatherWidget && weatherWidget->weather) { + const float lineHeight = ImGui::GetTextLineHeightWithSpacing(); + const ImVec2 pad = ImGui::GetStyle().WindowPadding; + const float spacingHeight = ImGui::GetStyle().ItemSpacing.y; + constexpr int kSectionHeaders = 2; // "ImageSpace:" + "Volumetric Lighting:" + constexpr int kTodValuesPerSection = 4; + constexpr int kSpacingSeparators = 1; // Spacing between sections + const float estimatedTooltipHeight = (kSectionHeaders + kTodValuesPerSection * 2) * lineHeight + kSpacingSeparators * spacingHeight + pad.y * 2.0f; + Util::SetTooltipPositionNearMouse(estimatedTooltipHeight); + if (ImGui::BeginTooltip()) { + // ImageSpace info + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "ImageSpace:"); + for (int tod = 0; tod < 4; tod++) { + auto imgSpace = weatherWidget->weather->imageSpaces[tod]; + ImGui::Text(" %s: %s", + TOD::GetPeriodName(tod), + imgSpace ? imgSpace->GetFormEditorID() : "None"); + } + + ImGui::Spacing(); + + // VolumetricLighting info + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Volumetric Lighting:"); + for (int tod = 0; tod < 4; tod++) { + auto volLight = weatherWidget->weather->volumetricLighting[tod]; + ImGui::Text(" %s: %s", + TOD::GetPeriodName(tod), + volLight ? volLight->GetFormEditorID() : "None"); + } + + ImGui::EndTooltip(); + } + weatherTooltipShownThisFrame = true; + } + } - // Editor ID column with [CURRENT] prefix - bool isSelected = sortedWidgets[i]->IsOpen(); - if (Util::TableRowSelectable(editorLabel.c_str(), isSelected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_AllowOverlap)) { - if (ImGui::IsMouseDoubleClicked(0)) { + // Enter key to open + if (isSelected && ImGui::IsKeyPressed(ImGuiKey_Enter)) { sortedWidgets[i]->SetOpen(true); AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); } - } - // Enter key to open - if (isSelected && ImGui::IsKeyPressed(ImGuiKey_Enter)) { - sortedWidgets[i]->SetOpen(true); - AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); - } + // Opens a context menu on right click to mark records by color + if (ImGui::BeginPopupContextItem(std::format("widget_context_menu##{}", sortedWidgets[i]->GetFormID()).c_str(), ImGuiPopupFlags_MouseButtonRight)) { + auto& markedRecords = settings.markedRecords; - // Context menu - if (ImGui::BeginPopupContextItem(std::format("widget_context_menu##{}", sortedWidgets[i]->GetFormID()).c_str(), ImGuiPopupFlags_MouseButtonRight)) { - auto& markedRecords = settings.markedRecords; + for (auto& recordMarker : settings.recordMarkers) { + if (ImGui::MenuItem(recordMarker.first.c_str())) { + settings.markedRecords[editorLabel] = recordMarker.first; + Save(); + } + } - for (auto& recordMarker : settings.recordMarkers) { - if (ImGui::MenuItem(recordMarker.first.c_str())) { - settings.markedRecords[sortedWidgets[i]->GetEditorID()] = recordMarker.first; + if (ImGui::MenuItem("Remove")) { + markedRecords.erase(editorLabel); Save(); } + + ImGui::EndPopup(); } - if (ImGui::MenuItem("Remove")) { - markedRecords.erase(sortedWidgets[i]->GetEditorID()); - Save(); + // Form ID column + ImGui::TableNextColumn(); + ImGui::Text(sortedWidgets[i]->GetFormID().c_str()); + + // File column + ImGui::TableNextColumn(); + ImGui::Text(sortedWidgets[i]->GetFilename().c_str()); + + // Status column + ImGui::TableNextColumn(); + + // Re-check if the record exists after potential removal + markedRecord = settings.markedRecords.find(editorLabel); + if (markedRecord != settings.markedRecords.end()) { + ImGui::Text("%s", markedRecord->second.c_str()); } - ImGui::EndPopup(); + // json / delete column + drawJsonDeleteButton(sortedWidgets[i]); } - // Form ID column - ImGui::TableNextColumn(); - ImGui::Text(sortedWidgets[i]->GetFormID().c_str()); + ImGui::EndTable(); // End DetailsTable + } // End if BeginTable("DetailsTable") - // File column - ImGui::TableNextColumn(); - ImGui::Text(sortedWidgets[i]->GetFilename().c_str()); + EndScrollableContent(); // End ObjectsScrollable - // Status column - ImGui::TableNextColumn(); - if (markedRecord != settings.markedRecords.end()) { - ImGui::Text("%s", markedRecord->second.c_str()); - } + } // End if BeginChild("##ObjectsContent") + ImGui::EndChild(); // End ObjectsContent child - // json / delete column - drawJsonDeleteButton(sortedWidgets[i]); - } + ImGui::EndTable(); // End ObjectTable + } // End if BeginTable("ObjectTable") + + // Confirmation modal for json deletion - must be outside BeginChild so the modal can block the root window + if (pendingDeleteWidget) { + if (pendingDeletePopupRequested) { + ImGui::OpenPopup("ListDeleteConfirmation"); + pendingDeletePopupRequested = false; } + pendingDeleteWidget->DrawDeleteConfirmationModal("ListDeleteConfirmation"); + if (!ImGui::IsPopupOpen("ListDeleteConfirmation")) { + pendingDeleteWidget = nullptr; + } + } - // Filtered display of widgets - regular list - for (int i = 0; i < sortedWidgets.size(); ++i) { - // Skip current cell's lighting template if already shown - if (currentCellLightingTemplate && m_selectedCategory == "Lighting Template") { - auto* ltWidget = dynamic_cast(sortedWidgets[i]); - if (ltWidget && ltWidget->lightingTemplate == currentCellLightingTemplate) - continue; - } + // End the window + ImGui::End(); + } - if (!shouldShowWidget(sortedWidgets[i])) - continue; + void EditorWindow::ShowViewportWindow() + { + ImGui::Begin("Viewport"); - auto editorLabel = sortedWidgets[i]->GetEditorID(); - auto markedRecord = settings.markedRecords.find(editorLabel); - ImGui::TableNextRow(); + // Top bar + if (DrawGameHourSlider("##ViewportSlider", "Time: %.2f")) { + ImGui::SameLine(); + int activePeriod = TOD::GetActivePeriod(); + ImGui::Text("(%s)", TOD::GetPeriodName(activePeriod)); + } - // Set background colour - if (markedRecord != settings.markedRecords.end()) { - auto& color = settings.recordMarkers[markedRecord->second]; - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::ColorConvertFloat4ToU32(color)); - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::ColorConvertFloat4ToU32(color)); - } + // The size of the image in ImGui // Get the available space in the current window + ImVec2 availableSpace = ImGui::GetContentRegionAvail(); - ImGui::TableSetColumnIndex(0); + // Calculate aspect ratio of the image + float aspectRatio = ImGui::GetIO().DisplaySize.x / ImGui::GetIO().DisplaySize.y; - // Favorite star - if (IconButton(std::format("##fav_{}", i).c_str(), IsFavorite(sortedWidgets[i]->GetEditorID()), "star")) { - ToggleFavorite(sortedWidgets[i]->GetEditorID()); - } + // Determine the size to fit while preserving the aspect ratio + ImVec2 imageSize; + if (availableSpace.x / availableSpace.y < aspectRatio) { + // Fit width + imageSize.x = availableSpace.x; + imageSize.y = availableSpace.x / aspectRatio; + } else { + // Fit height + imageSize.y = availableSpace.y; + imageSize.x = availableSpace.y * aspectRatio; + } - ImGui::TableNextColumn(); + ImGui::Image((void*)tempTexture->srv.get(), imageSize); - // Editor ID column - bool isSelected = sortedWidgets[i]->IsOpen(); - if (Util::TableRowSelectable(editorLabel.c_str(), isSelected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_AllowOverlap)) { - if (ImGui::IsMouseDoubleClicked(0)) { - sortedWidgets[i]->SetOpen(true); - AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); - } + ImGui::End(); + } + + void EditorWindow::ShowWidgetWindow() + { + // Global shortcut for closing focused widget (Ctrl+W) + if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_W, false)) { + if (lastFocusedWidget && lastFocusedWidget->IsOpen()) { + lastFocusedWidget->SetOpen(false); + lastFocusedWidget = nullptr; + } + } + + // Draw all open widgets using WidgetFactory template + WidgetFactory::DrawOpenWidgets(weatherWidgets, lastFocusedWidget); + WidgetFactory::DrawOpenWidgets(lightingTemplateWidgets, lastFocusedWidget); + WidgetFactory::DrawOpenWidgets(imageSpaceWidgets, lastFocusedWidget); + WidgetFactory::DrawOpenWidgets(volumetricLightingWidgets, lastFocusedWidget); + WidgetFactory::DrawOpenWidgets(precipitationWidgets, lastFocusedWidget); + WidgetFactory::DrawOpenWidgets(lensFlareWidgets, lastFocusedWidget); + WidgetFactory::DrawOpenWidgets(referenceEffectWidgets, lastFocusedWidget); + + // Draw current cell lighting widget if open + if (currentCellLightingWidget && currentCellLightingWidget->IsOpen()) { + currentCellLightingWidget->DrawWidget(); + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) + lastFocusedWidget = currentCellLightingWidget.get(); + } + } + + void EditorWindow::RenderUI() + { + auto renderer = RE::BSGraphics::Renderer::GetSingleton(); + auto& framebuffer = renderer->GetRuntimeData().renderTargets[RE::RENDER_TARGETS::kFRAMEBUFFER]; + auto& context = globals::d3d::context; + + context->ClearRenderTargetView(framebuffer.RTV, (float*)&ImGui::GetStyle().Colors[ImGuiCol_WindowBg]); + + // Apply editor UI scale + ImGuiIO& io = ImGui::GetIO(); + float previousScale = io.FontGlobalScale; + io.FontGlobalScale = settings.editorUIScale; + + // Increase background opacity for all editor windows + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 1.0f); + + // Check for Ctrl+Z to undo + if ((ImGui::IsKeyDown(ImGuiKey_LeftCtrl) || ImGui::IsKeyDown(ImGuiKey_RightCtrl)) && ImGui::IsKeyPressed(ImGuiKey_Z, false)) { + if (CanUndo()) { + PerformUndo(); + } + } + + if (ImGui::BeginMainMenuBar()) { + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("Save All Open Widgets", "Ctrl+S")) { + SaveAll(); } - // Show ImageSpace and VolumetricLighting info for weather widgets - if (!weatherTooltipShownThisFrame && m_selectedCategory == "Weather" && ImGui::IsItemHovered()) { - auto* weatherWidget = dynamic_cast(sortedWidgets[i]); - if (weatherWidget && weatherWidget->weather) { - const float lineHeight = ImGui::GetTextLineHeightWithSpacing(); - const ImVec2 pad = ImGui::GetStyle().WindowPadding; - const float spacingHeight = ImGui::GetStyle().ItemSpacing.y; - constexpr int kSectionHeaders = 2; // "ImageSpace:" + "Volumetric Lighting:" - constexpr int kTodValuesPerSection = 4; - constexpr int kSpacingSeparators = 1; // Spacing between sections - const float estimatedTooltipHeight = (kSectionHeaders + kTodValuesPerSection * 2) * lineHeight + kSpacingSeparators * spacingHeight + pad.y * 2.0f; - Util::SetTooltipPositionNearMouse(estimatedTooltipHeight); - if (ImGui::BeginTooltip()) { - // ImageSpace info - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "ImageSpace:"); - for (int tod = 0; tod < 4; tod++) { - auto imgSpace = weatherWidget->weather->imageSpaces[tod]; - ImGui::Text(" %s: %s", - TOD::GetPeriodName(tod), - imgSpace ? imgSpace->GetFormEditorID() : "None"); - } + // Save individual widgets submenu + if (ImGui::BeginMenu("Save")) { + bool hasOpenWidgets = false; - ImGui::Spacing(); + // Weather widgets + for (auto& widget : weatherWidgets) { + if (widget->IsOpen()) { + hasOpenWidgets = true; + if (ImGui::MenuItem(std::format("Save {}", widget->GetEditorID()).c_str())) { + widget->Save(); + } + } + } - // VolumetricLighting info - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Volumetric Lighting:"); - for (int tod = 0; tod < 4; tod++) { - auto volLight = weatherWidget->weather->volumetricLighting[tod]; - ImGui::Text(" %s: %s", - TOD::GetPeriodName(tod), - volLight ? volLight->GetFormEditorID() : "None"); + // Lighting Template widgets + for (auto& widget : lightingTemplateWidgets) { + if (widget->IsOpen()) { + hasOpenWidgets = true; + if (ImGui::MenuItem(std::format("Save {}", widget->GetEditorID()).c_str())) { + widget->Save(); } + } + } - ImGui::EndTooltip(); + // ImageSpace widgets + for (auto& widget : imageSpaceWidgets) { + if (widget->IsOpen()) { + hasOpenWidgets = true; + if (ImGui::MenuItem(std::format("Save {}", widget->GetEditorID()).c_str())) { + widget->Save(); + } } - weatherTooltipShownThisFrame = true; } + + if (!hasOpenWidgets) { + ImGui::TextDisabled("No open widgets"); + } + + ImGui::EndMenu(); } - // Enter key to open - if (isSelected && ImGui::IsKeyPressed(ImGuiKey_Enter)) { - sortedWidgets[i]->SetOpen(true); - AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); + ImGui::Separator(); + if (ImGui::MenuItem("Close All Weather Widgets")) { + for (auto& widget : weatherWidgets) widget->SetOpen(false); + } + if (ImGui::MenuItem("Close All Lighting Widgets")) { + for (auto& widget : lightingTemplateWidgets) widget->SetOpen(false); + } + if (ImGui::MenuItem("Close All ImageSpace Widgets")) { + for (auto& widget : imageSpaceWidgets) widget->SetOpen(false); + } + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Settings")) { + if (ImGui::MenuItem("General Settings")) { + showSettingsWindow = true; + settingsSelectedCategory = "General"; + } + if (ImGui::MenuItem("Editor Flags")) { + showSettingsWindow = true; + settingsSelectedCategory = "Flags"; } + ImGui::Separator(); - // Opens a context menu on right click to mark records by color - if (ImGui::BeginPopupContextItem(std::format("widget_context_menu##{}", sortedWidgets[i]->GetFormID()).c_str(), ImGuiPopupFlags_MouseButtonRight)) { - auto& markedRecords = settings.markedRecords; + // Current cell lighting + auto player = RE::PlayerCharacter::GetSingleton(); + if (player && player->parentCell && player->parentCell->IsInteriorCell()) { + if (ImGui::MenuItem("Edit Current Cell Lighting")) { + // Check if widget already exists + bool found = false; + if (currentCellLightingWidget && currentCellLightingWidget->cell == player->parentCell) { + currentCellLightingWidget->SetOpen(true); + found = true; + } - for (auto& recordMarker : settings.recordMarkers) { - if (ImGui::MenuItem(recordMarker.first.c_str())) { - settings.markedRecords[editorLabel] = recordMarker.first; - Save(); + if (!found) { + // Create new widget for current cell + currentCellLightingWidget = std::make_unique(player->parentCell); + currentCellLightingWidget->CacheFormData(); + currentCellLightingWidget->Load(); + currentCellLightingWidget->SetOpen(true); } } - - if (ImGui::MenuItem("Remove")) { - markedRecords.erase(editorLabel); - Save(); + } else { + ImGui::BeginDisabled(); + ImGui::MenuItem("Edit Current Cell Lighting"); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + ImGui::SetTooltip("Only available in interior cells"); } - - ImGui::EndPopup(); } - // Form ID column - ImGui::TableNextColumn(); - ImGui::Text(sortedWidgets[i]->GetFormID().c_str()); + ImGui::Separator(); + + if (ImGui::Checkbox("Auto-Apply Changes", &settings.autoApplyChanges)) { + Save(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Automatically apply weather changes to the game as you edit"); + } + if (ImGui::Checkbox("Remember Open Widgets", &settings.rememberOpenWidgets)) { + Save(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Restore previously open widgets when editor reopens"); + } + if (ImGui::Checkbox("Enable Inherit From Parent", &settings.enableInheritFromParent)) { + Save(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Show inherit from parent options in weather widgets"); + } + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Window")) { + if (ImGui::MenuItem("Palette", nullptr, PaletteWindow::GetSingleton()->open)) { + PaletteWindow::GetSingleton()->open = !PaletteWindow::GetSingleton()->open; + } - // File column - ImGui::TableNextColumn(); - ImGui::Text(sortedWidgets[i]->GetFilename().c_str()); + ImGui::Separator(); + ImGui::Text("Open Widgets:"); + ImGui::Separator(); - // Status column - ImGui::TableNextColumn(); + int openCount = 0; + for (auto& widget : weatherWidgets) { + if (widget->IsOpen()) { + openCount++; + if (ImGui::MenuItem(std::format("Weather: {}", widget->GetEditorID()).c_str())) { + // Focus window (ImGui will bring to front when clicked) + } + } + } + for (auto& widget : lightingTemplateWidgets) { + if (widget->IsOpen()) { + openCount++; + if (ImGui::MenuItem(std::format("Lighting: {}", widget->GetEditorID()).c_str())) { + // Focus window + } + } + } + for (auto& widget : imageSpaceWidgets) { + if (widget->IsOpen()) { + openCount++; + if (ImGui::MenuItem(std::format("ImageSpace: {}", widget->GetEditorID()).c_str())) { + // Focus window + } + } + } - // Re-check if the record exists after potential removal - markedRecord = settings.markedRecords.find(editorLabel); - if (markedRecord != settings.markedRecords.end()) { - ImGui::Text("%s", markedRecord->second.c_str()); + if (openCount == 0) { + ImGui::TextDisabled("No widgets open"); } - // json / delete column - drawJsonDeleteButton(sortedWidgets[i]); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Help")) { + ImGui::Text("Weather Editor"); + ImGui::Separator(); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Keyboard Shortcuts:"); + ImGui::BulletText("Ctrl+F: Focus search"); + ImGui::BulletText("Ctrl+S: Save all open widgets"); + ImGui::BulletText("Ctrl+W: Close focused widget"); + ImGui::BulletText("Enter: Open selected widget"); + ImGui::BulletText("Esc: Close editor"); + ImGui::Separator(); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Quick Tips:"); + ImGui::BulletText("Double-click to edit"); + ImGui::BulletText("Right-click to mark status"); + ImGui::BulletText("Click star icon to favorite"); + ImGui::BulletText("Use quick filters for fast sorting"); + ImGui::BulletText("Auto-Apply updates game live"); + ImGui::BulletText("Lock weather to prevent changes"); + ImGui::BulletText("Undo button reverts recent changes (Ctrl+Z)"); + ImGui::Separator(); + ImGui::Text("Total Objects:"); + ImGui::BulletText("Weathers: %d", (int)weatherWidgets.size()); + ImGui::BulletText("Lighting: %d", (int)lightingTemplateWidgets.size()); + ImGui::BulletText("ImageSpaces: %d", (int)imageSpaceWidgets.size()); + ImGui::Separator(); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.CurrentHotkey, "Favorites: %d", (int)settings.favoriteWidgets.size()); + + // Count total recent widgets across all categories + int totalRecent = 0; + for (const auto& [category, widgets] : settings.recentWidgets) { + totalRecent += static_cast(widgets.size()); + } + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.SuccessColor, "Recent: %d", totalRecent); + ImGui::EndMenu(); } - ImGui::EndTable(); // End DetailsTable - } // End if BeginTable("DetailsTable") + // Pause Time button + auto menu = globals::menu; + if (menu && menu->uiIcons.pauseTime.texture) { + bool isPaused = IsTimePaused(); + + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + if (isPaused) { + auto pausedColor = Menu::GetSingleton()->GetTheme().StatusPalette.SuccessColor; + pausedColor.w = 0.6f; + auto pausedHoverColor = pausedColor; + pausedHoverColor.w = 0.8f; + ImGui::PushStyleColor(ImGuiCol_Button, pausedColor); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, pausedHoverColor); + } else { + auto transparentColor = ImVec4(0, 0, 0, 0); + ImGui::PushStyleColor(ImGuiCol_Button, transparentColor); + auto hoverColor = Menu::GetSingleton()->GetSettings().Theme.Palette.Text; + hoverColor.w = 0.25f; + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hoverColor); + } - EndScrollableContent(); // End ObjectsScrollable + const float menuBarHeight = ImGui::GetFrameHeight(); + const float buttonDim = menuBarHeight * 0.85f; + const ImVec2 buttonSize(buttonDim, buttonDim); - } // End if BeginChild("##ObjectsContent") - ImGui::EndChild(); // End ObjectsContent child + if (ImGui::ImageButton("##GlobalPauseTime", menu->uiIcons.pauseTime.texture, buttonSize)) + TogglePause(); - ImGui::EndTable(); // End ObjectTable - } // End if BeginTable("ObjectTable") + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); - // Confirmation modal for json deletion - must be outside BeginChild so the modal can block the root window - if (pendingDeleteWidget) { - if (pendingDeletePopupRequested) { - ImGui::OpenPopup("ListDeleteConfirmation"); - pendingDeletePopupRequested = false; - } - pendingDeleteWidget->DrawDeleteConfirmationModal("ListDeleteConfirmation"); - if (!ImGui::IsPopupOpen("ListDeleteConfirmation")) { - pendingDeleteWidget = nullptr; - } - } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(isPaused ? "Resume Time" : "Pause Time"); + } - // End the window - ImGui::End(); -} + // Undo button + if (menu && menu->uiIcons.undo.texture) { + bool canUndo = CanUndo(); + + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + if (!canUndo) { + auto transparentColor = ImVec4(0, 0, 0, 0); + ImGui::PushStyleColor(ImGuiCol_Button, transparentColor); + auto disabledColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.Disable; + disabledColor.w = 0.25f; + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, disabledColor); + auto disabledTextColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.Disable; + disabledTextColor.w = 0.5f; + ImGui::PushStyleColor(ImGuiCol_Text, disabledTextColor); + } else { + auto transparentColor = ImVec4(0, 0, 0, 0); + ImGui::PushStyleColor(ImGuiCol_Button, transparentColor); + auto hoverColor = Menu::GetSingleton()->GetSettings().Theme.Palette.Text; + hoverColor.w = 0.25f; + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hoverColor); + ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetSettings().Theme.Palette.Text); + } -void EditorWindow::ShowViewportWindow() -{ - ImGui::Begin("Viewport"); + const float menuBarHeight = ImGui::GetFrameHeight(); + const float buttonDim = menuBarHeight * 0.85f; + const ImVec2 buttonSize(buttonDim, buttonDim); - // Top bar - if (DrawGameHourSlider("##ViewportSlider", "Time: %.2f")) { - ImGui::SameLine(); - int activePeriod = TOD::GetActivePeriod(); - ImGui::Text("(%s)", TOD::GetPeriodName(activePeriod)); - } + if (ImGui::ImageButton("##GlobalUndo", menu->uiIcons.undo.texture, buttonSize) && canUndo) { + PerformUndo(); + } - // The size of the image in ImGui // Get the available space in the current window - ImVec2 availableSpace = ImGui::GetContentRegionAvail(); + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(); - // Calculate aspect ratio of the image - float aspectRatio = ImGui::GetIO().DisplaySize.x / ImGui::GetIO().DisplaySize.y; + if (ImGui::IsItemHovered()) { + if (canUndo) { + ImGui::SetTooltip("Undo (Ctrl+Z) - %d states", (int)undoStack.size()); + } else { + ImGui::SetTooltip("Undo (Ctrl+Z) - No changes to undo"); + } + } + } // Weather lock indicator + if (weatherLockActive && lockedWeather) { + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetSettings().Theme.StatusPalette.SuccessColor); + const char* weatherName = lockedWeather->GetFormEditorID(); + ImGui::Text(" [LOCKED: %s]", weatherName ? weatherName : "Unknown"); + ImGui::PopStyleColor(); + } - // Determine the size to fit while preserving the aspect ratio - ImVec2 imageSize; - if (availableSpace.x / availableSpace.y < aspectRatio) { - // Fit width - imageSize.x = availableSpace.x; - imageSize.y = availableSpace.x / aspectRatio; - } else { - // Fit height - imageSize.y = availableSpace.y; - imageSize.x = availableSpace.y * aspectRatio; - } + // Time pause indicator + if (IsTimePaused()) { + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetSettings().Theme.StatusPalette.CurrentHotkey); + ImGui::Text(" [TIME PAUSED]"); + ImGui::PopStyleColor(); + } - ImGui::Image((void*)tempTexture->srv.get(), imageSize); + // Close button on the right side + float menuBarHeight = ImGui::GetFrameHeight(); + float closeButtonSize = menuBarHeight * 0.9f; // 10% smaller than menu bar + ImGui::SameLine(ImGui::GetWindowWidth() - closeButtonSize - 10.0f); + auto errorColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.Error; + auto errorHoverColor = errorColor; + errorHoverColor.x = std::min(1.0f, errorColor.x * 1.2f); + errorHoverColor.y = std::min(1.0f, errorColor.y * 0.75f); + auto errorActiveColor = errorColor; + errorActiveColor.x = std::max(0.0f, errorColor.x * 0.875f); + errorActiveColor.y = std::max(0.0f, errorColor.y * 0.25f); + ImGui::PushStyleColor(ImGuiCol_Button, errorColor); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, errorHoverColor); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, errorActiveColor); + if (ImGui::Button("X", ImVec2(closeButtonSize, closeButtonSize))) { + open = false; + } + ImGui::PopStyleColor(3); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Close Weather Editor (Esc)"); + } + ImGui::EndMainMenuBar(); + } - ImGui::End(); -} + // Establish a viewport-wide DockSpace so all editor windows are snappable and dockable + ImGui::DockSpaceOverViewport(nullptr, ImGuiDockNodeFlags_PassthruCentralNode); + + auto width = ImGui::GetIO().DisplaySize.x; + auto height = ImGui::GetIO().DisplaySize.y; + auto viewportWidth = width * 0.5f; // Make the viewport take up 50% of the width + auto sideWidth = (width - viewportWidth) / 2.0f; // Divide the remaining width equally between the side windows + ImGui::SetNextWindowSize(ImVec2(sideWidth, ImGui::GetIO().DisplaySize.y * 0.75f), ImGuiCond_FirstUseEver); + ShowObjectsWindow(); + + ImGui::SetNextWindowSize(ImVec2(viewportWidth, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_FirstUseEver); + ShowViewportWindow(); + + auto settingsWindowHeight = height * 0.25f; + auto settingsWindowWidth = width * 0.25f; + ImGui::SetNextWindowSizeConstraints(ImVec2(settingsWindowWidth, settingsWindowHeight), ImVec2(FLT_MAX, FLT_MAX)); + ImGui::SetNextWindowPos({ (width / 2.0f) - (settingsWindowWidth / 2.0f), (height / 2.0f) - (settingsWindowHeight / 2.0f) }, ImGuiCond_Appearing); + if (showSettingsWindow) { + ShowSettingsWindow(); + } -void EditorWindow::ShowWidgetWindow() -{ - // Global shortcut for closing focused widget (Ctrl+W) - if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_W, false)) { - if (lastFocusedWidget && lastFocusedWidget->IsOpen()) { - lastFocusedWidget->SetOpen(false); - lastFocusedWidget = nullptr; - } - } + ShowWidgetWindow(); - // Draw all open widgets using WidgetFactory template - WidgetFactory::DrawOpenWidgets(weatherWidgets, lastFocusedWidget); - WidgetFactory::DrawOpenWidgets(lightingTemplateWidgets, lastFocusedWidget); - WidgetFactory::DrawOpenWidgets(imageSpaceWidgets, lastFocusedWidget); - WidgetFactory::DrawOpenWidgets(volumetricLightingWidgets, lastFocusedWidget); - WidgetFactory::DrawOpenWidgets(precipitationWidgets, lastFocusedWidget); - WidgetFactory::DrawOpenWidgets(lensFlareWidgets, lastFocusedWidget); - WidgetFactory::DrawOpenWidgets(referenceEffectWidgets, lastFocusedWidget); - - // Draw current cell lighting widget if open - if (currentCellLightingWidget && currentCellLightingWidget->IsOpen()) { - currentCellLightingWidget->DrawWidget(); - if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) - lastFocusedWidget = currentCellLightingWidget.get(); - } -} + // Show palette window + PaletteWindow::GetSingleton()->Draw(); -void EditorWindow::RenderUI() -{ - auto renderer = RE::BSGraphics::Renderer::GetSingleton(); - auto& framebuffer = renderer->GetRuntimeData().renderTargets[RE::RENDER_TARGETS::kFRAMEBUFFER]; - auto& context = globals::d3d::context; + // Render notifications on top of everything + RenderNotifications(); - context->ClearRenderTargetView(framebuffer.RTV, (float*)&ImGui::GetStyle().Colors[ImGuiCol_WindowBg]); + // Pop the alpha style var + ImGui::PopStyleVar(); - // Apply editor UI scale - ImGuiIO& io = ImGui::GetIO(); - float previousScale = io.FontGlobalScale; - io.FontGlobalScale = settings.editorUIScale; - - // Increase background opacity for all editor windows - ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 1.0f); - - // Check for Ctrl+Z to undo - if ((ImGui::IsKeyDown(ImGuiKey_LeftCtrl) || ImGui::IsKeyDown(ImGuiKey_RightCtrl)) && ImGui::IsKeyPressed(ImGuiKey_Z, false)) { - if (CanUndo()) { - PerformUndo(); + // Restore previous font scale + io.FontGlobalScale = previousScale; } - } - if (ImGui::BeginMainMenuBar()) { - if (ImGui::BeginMenu("File")) { - if (ImGui::MenuItem("Save All Open Widgets", "Ctrl+S")) { - SaveAll(); + void EditorWindow::OpenWeatherFeatureSetting(RE::TESWeather * weather, const std::string& featureName, const std::string& settingName) + { + if (!weather) { + return; } - // Save individual widgets submenu - if (ImGui::BeginMenu("Save")) { - bool hasOpenWidgets = false; - - // Weather widgets - for (auto& widget : weatherWidgets) { - if (widget->IsOpen()) { - hasOpenWidgets = true; - if (ImGui::MenuItem(std::format("Save {}", widget->GetEditorID()).c_str())) { - widget->Save(); - } - } - } + // Open the editor if it's not already open + if (!open) { + open = true; + } - // Lighting Template widgets - for (auto& widget : lightingTemplateWidgets) { - if (widget->IsOpen()) { - hasOpenWidgets = true; - if (ImGui::MenuItem(std::format("Save {}", widget->GetEditorID()).c_str())) { - widget->Save(); - } + // Find the weather widget + for (auto& widget : weatherWidgets) { + auto* weatherWidget = dynamic_cast(widget.get()); + if (weatherWidget && weatherWidget->weather == weather) { + // Open the widget if it's not already open + if (!weatherWidget->open) { + weatherWidget->open = true; } - } - // ImageSpace widgets - for (auto& widget : imageSpaceWidgets) { - if (widget->IsOpen()) { - hasOpenWidgets = true; - if (ImGui::MenuItem(std::format("Save {}", widget->GetEditorID()).c_str())) { - widget->Save(); - } - } - } + // Set up navigation to the specific feature/setting + weatherWidget->NavigateToFeatureSetting(featureName, settingName); - if (!hasOpenWidgets) { - ImGui::TextDisabled("No open widgets"); + // Focus the widget window + std::string windowName = std::format("{}###widget_{}", weatherWidget->GetEditorID(), (void*)weatherWidget); + ImGui::SetWindowFocus(windowName.c_str()); + break; } - - ImGui::EndMenu(); - } - - ImGui::Separator(); - if (ImGui::MenuItem("Close All Weather Widgets")) { - for (auto& widget : weatherWidgets) widget->SetOpen(false); - } - if (ImGui::MenuItem("Close All Lighting Widgets")) { - for (auto& widget : lightingTemplateWidgets) widget->SetOpen(false); - } - if (ImGui::MenuItem("Close All ImageSpace Widgets")) { - for (auto& widget : imageSpaceWidgets) widget->SetOpen(false); } - ImGui::EndMenu(); } - if (ImGui::BeginMenu("Settings")) { - if (ImGui::MenuItem("General Settings")) { - showSettingsWindow = true; - settingsSelectedCategory = "General"; - } - if (ImGui::MenuItem("Editor Flags")) { - showSettingsWindow = true; - settingsSelectedCategory = "Flags"; - } - ImGui::Separator(); - // Current cell lighting - auto player = RE::PlayerCharacter::GetSingleton(); - if (player && player->parentCell && player->parentCell->IsInteriorCell()) { - if (ImGui::MenuItem("Edit Current Cell Lighting")) { - // Check if widget already exists - bool found = false; - if (currentCellLightingWidget && currentCellLightingWidget->cell == player->parentCell) { - currentCellLightingWidget->SetOpen(true); - found = true; - } - - if (!found) { - // Create new widget for current cell - currentCellLightingWidget = std::make_unique(player->parentCell); - currentCellLightingWidget->CacheFormData(); - currentCellLightingWidget->Load(); - currentCellLightingWidget->SetOpen(true); - } - } - } else { - ImGui::BeginDisabled(); - ImGui::MenuItem("Edit Current Cell Lighting"); - ImGui::EndDisabled(); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { - ImGui::SetTooltip("Only available in interior cells"); - } - } - - ImGui::Separator(); + EditorWindow::~EditorWindow() + { + delete tempTexture; + weatherWidgets.clear(); + lightingTemplateWidgets.clear(); + imageSpaceWidgets.clear(); + volumetricLightingWidgets.clear(); + precipitationWidgets.clear(); + referenceEffectWidgets.clear(); + artObjectWidgets.clear(); + effectShaderWidgets.clear(); + currentCellLightingWidget.reset(); + } - if (ImGui::Checkbox("Auto-Apply Changes", &settings.autoApplyChanges)) { - Save(); - } - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Automatically apply weather changes to the game as you edit"); - } - if (ImGui::Checkbox("Remember Open Widgets", &settings.rememberOpenWidgets)) { - Save(); - } - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Restore previously open widgets when editor reopens"); - } - if (ImGui::Checkbox("Enable Inherit From Parent", &settings.enableInheritFromParent)) { - Save(); - } - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Show inherit from parent options in weather widgets"); - } - ImGui::EndMenu(); + void EditorWindow::SetupResources() + { + Load(); + PaletteWindow::GetSingleton()->Load(); + InvalidateJsonAttachmentCache(); + + // Populate all widget collections using WidgetFactory templates + WidgetFactory::PopulateWidgets(weatherWidgets); + WidgetFactory::PopulateWidgets(lightingTemplateWidgets); + WidgetFactory::PopulateWidgets(imageSpaceWidgets); + WidgetFactory::PopulateWidgets(volumetricLightingWidgets); + WidgetFactory::PopulateWidgets(precipitationWidgets); + WidgetFactory::PopulateWidgets(lensFlareWidgets); + WidgetFactory::PopulateWidgets(referenceEffectWidgets); + + // Cache simple form widgets for form picker performance + WidgetFactory::PopulateSimpleWidgets(artObjectWidgets); + WidgetFactory::PopulateSimpleWidgets(effectShaderWidgets); } - if (ImGui::BeginMenu("Window")) { - if (ImGui::MenuItem("Palette", nullptr, PaletteWindow::GetSingleton()->open)) { - PaletteWindow::GetSingleton()->open = !PaletteWindow::GetSingleton()->open; + + void EditorWindow::Draw() + { + // Track editor open state for vanity camera management + static bool wasOpen = false; + + if (open && !wasOpen) { + // Editor just opened - disable vanity camera and restore session + DisableVanityCamera(); + RestoreSessionWidgets(); + } else if (!open && wasOpen) { + // Editor just closed - restore vanity camera and save session + RestoreVanityCamera(); + SaveSessionWidgets(); } - ImGui::Separator(); - ImGui::Text("Open Widgets:"); - ImGui::Separator(); + wasOpen = open; - int openCount = 0; - for (auto& widget : weatherWidgets) { - if (widget->IsOpen()) { - openCount++; - if (ImGui::MenuItem(std::format("Weather: {}", widget->GetEditorID()).c_str())) { - // Focus window (ImGui will bring to front when clicked) - } - } - } - for (auto& widget : lightingTemplateWidgets) { - if (widget->IsOpen()) { - openCount++; - if (ImGui::MenuItem(std::format("Lighting: {}", widget->GetEditorID()).c_str())) { - // Focus window - } - } - } - for (auto& widget : imageSpaceWidgets) { - if (widget->IsOpen()) { - openCount++; - if (ImGui::MenuItem(std::format("ImageSpace: {}", widget->GetEditorID()).c_str())) { - // Focus window - } + // Re-enforce weather lock if active (handles time changes) + if (weatherLockActive && lockedWeather) { + auto sky = RE::Sky::GetSingleton(); + if (sky && sky->currentWeather != lockedWeather) { + sky->ForceWeather(lockedWeather, false); } } - if (openCount == 0) { - ImGui::TextDisabled("No widgets open"); - } + auto renderer = RE::BSGraphics::Renderer::GetSingleton(); + auto& framebuffer = renderer->GetRuntimeData().renderTargets[RE::RENDER_TARGETS::kFRAMEBUFFER]; - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Help")) { - ImGui::Text("Weather Editor"); - ImGui::Separator(); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Keyboard Shortcuts:"); - ImGui::BulletText("Ctrl+F: Focus search"); - ImGui::BulletText("Ctrl+S: Save all open widgets"); - ImGui::BulletText("Ctrl+W: Close focused widget"); - ImGui::BulletText("Enter: Open selected widget"); - ImGui::BulletText("Esc: Close editor"); - ImGui::Separator(); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Quick Tips:"); - ImGui::BulletText("Double-click to edit"); - ImGui::BulletText("Right-click to mark status"); - ImGui::BulletText("Click star icon to favorite"); - ImGui::BulletText("Use quick filters for fast sorting"); - ImGui::BulletText("Auto-Apply updates game live"); - ImGui::BulletText("Lock weather to prevent changes"); - ImGui::BulletText("Undo button reverts recent changes (Ctrl+Z)"); - ImGui::Separator(); - ImGui::Text("Total Objects:"); - ImGui::BulletText("Weathers: %d", (int)weatherWidgets.size()); - ImGui::BulletText("Lighting: %d", (int)lightingTemplateWidgets.size()); - ImGui::BulletText("ImageSpaces: %d", (int)imageSpaceWidgets.size()); - ImGui::Separator(); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.CurrentHotkey, "Favorites: %d", (int)settings.favoriteWidgets.size()); + ID3D11Resource* resource = nullptr; + framebuffer.SRV->GetResource(&resource); - // Count total recent widgets across all categories - int totalRecent = 0; - for (const auto& [category, widgets] : settings.recentWidgets) { - totalRecent += static_cast(widgets.size()); - } - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.SuccessColor, "Recent: %d", totalRecent); - ImGui::EndMenu(); - } + if (!tempTexture) { + D3D11_TEXTURE2D_DESC texDesc{}; + ((ID3D11Texture2D*)resource)->GetDesc(&texDesc); - // Pause Time button - auto menu = globals::menu; - if (menu && menu->uiIcons.pauseTime.texture) { - bool isPaused = IsTimePaused(); - - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - if (isPaused) { - auto pausedColor = Menu::GetSingleton()->GetTheme().StatusPalette.SuccessColor; - pausedColor.w = 0.6f; - auto pausedHoverColor = pausedColor; - pausedHoverColor.w = 0.8f; - ImGui::PushStyleColor(ImGuiCol_Button, pausedColor); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, pausedHoverColor); - } else { - auto transparentColor = ImVec4(0, 0, 0, 0); - ImGui::PushStyleColor(ImGuiCol_Button, transparentColor); - auto hoverColor = Menu::GetSingleton()->GetSettings().Theme.Palette.Text; - hoverColor.w = 0.25f; - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hoverColor); + D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc{}; + framebuffer.SRV->GetDesc(&srvDesc); + + tempTexture = new Texture2D(texDesc); + tempTexture->CreateSRV(srvDesc); } - const float menuBarHeight = ImGui::GetFrameHeight(); - const float buttonDim = menuBarHeight * 0.85f; - const ImVec2 buttonSize(buttonDim, buttonDim); + auto& context = globals::d3d::context; - if (ImGui::ImageButton("##GlobalPauseTime", menu->uiIcons.pauseTime.texture, buttonSize)) - TogglePause(); + context->CopyResource(tempTexture->resource.get(), resource); - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(); + if (resource) { + resource->Release(); + } - if (ImGui::IsItemHovered()) - ImGui::SetTooltip(isPaused ? "Resume Time" : "Pause Time"); + RenderUI(); } - // Undo button - if (menu && menu->uiIcons.undo.texture) { - bool canUndo = CanUndo(); - - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - if (!canUndo) { - auto transparentColor = ImVec4(0, 0, 0, 0); - ImGui::PushStyleColor(ImGuiCol_Button, transparentColor); - auto disabledColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.Disable; - disabledColor.w = 0.25f; - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, disabledColor); - auto disabledTextColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.Disable; - disabledTextColor.w = 0.5f; - ImGui::PushStyleColor(ImGuiCol_Text, disabledTextColor); - } else { - auto transparentColor = ImVec4(0, 0, 0, 0); - ImGui::PushStyleColor(ImGuiCol_Button, transparentColor); - auto hoverColor = Menu::GetSingleton()->GetSettings().Theme.Palette.Text; - hoverColor.w = 0.25f; - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hoverColor); - ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetSettings().Theme.Palette.Text); + void EditorWindow::SaveAll() + { + for (auto& weather : weatherWidgets) { + if (weather->IsOpen()) + weather->Save(); } - const float menuBarHeight = ImGui::GetFrameHeight(); - const float buttonDim = menuBarHeight * 0.85f; - const ImVec2 buttonSize(buttonDim, buttonDim); - - if (ImGui::ImageButton("##GlobalUndo", menu->uiIcons.undo.texture, buttonSize) && canUndo) { - PerformUndo(); + for (auto& lightingTemplate : lightingTemplateWidgets) { + if (lightingTemplate->IsOpen()) + lightingTemplate->Save(); } - ImGui::PopStyleColor(3); - ImGui::PopStyleVar(); - - if (ImGui::IsItemHovered()) { - if (canUndo) { - ImGui::SetTooltip("Undo (Ctrl+Z) - %d states", (int)undoStack.size()); - } else { - ImGui::SetTooltip("Undo (Ctrl+Z) - No changes to undo"); - } + for (auto& imageSpace : imageSpaceWidgets) { + if (imageSpace->IsOpen()) + imageSpace->Save(); } - } // Weather lock indicator - if (weatherLockActive && lockedWeather) { - ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetSettings().Theme.StatusPalette.SuccessColor); - const char* weatherName = lockedWeather->GetFormEditorID(); - ImGui::Text(" [LOCKED: %s]", weatherName ? weatherName : "Unknown"); - ImGui::PopStyleColor(); - } - // Time pause indicator - if (IsTimePaused()) { - ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetSettings().Theme.StatusPalette.CurrentHotkey); - ImGui::Text(" [TIME PAUSED]"); - ImGui::PopStyleColor(); + Save(); } - // Close button on the right side - float menuBarHeight = ImGui::GetFrameHeight(); - float closeButtonSize = menuBarHeight * 0.9f; // 10% smaller than menu bar - ImGui::SameLine(ImGui::GetWindowWidth() - closeButtonSize - 10.0f); - auto errorColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.Error; - auto errorHoverColor = errorColor; - errorHoverColor.x = std::min(1.0f, errorColor.x * 1.2f); - errorHoverColor.y = std::min(1.0f, errorColor.y * 0.75f); - auto errorActiveColor = errorColor; - errorActiveColor.x = std::max(0.0f, errorColor.x * 0.875f); - errorActiveColor.y = std::max(0.0f, errorColor.y * 0.25f); - ImGui::PushStyleColor(ImGuiCol_Button, errorColor); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, errorHoverColor); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, errorActiveColor); - if (ImGui::Button("X", ImVec2(closeButtonSize, closeButtonSize))) { - open = false; - } - ImGui::PopStyleColor(3); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Close Weather Editor (Esc)"); + void EditorWindow::SaveSettings() + { + j = settings; } - ImGui::EndMainMenuBar(); - } - - // Establish a viewport-wide DockSpace so all editor windows are snappable and dockable - ImGui::DockSpaceOverViewport(nullptr, ImGuiDockNodeFlags_PassthruCentralNode); - - auto width = ImGui::GetIO().DisplaySize.x; - auto height = ImGui::GetIO().DisplaySize.y; - auto viewportWidth = width * 0.5f; // Make the viewport take up 50% of the width - auto sideWidth = (width - viewportWidth) / 2.0f; // Divide the remaining width equally between the side windows - ImGui::SetNextWindowSize(ImVec2(sideWidth, ImGui::GetIO().DisplaySize.y * 0.75f), ImGuiCond_FirstUseEver); - ShowObjectsWindow(); - - ImGui::SetNextWindowSize(ImVec2(viewportWidth, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_FirstUseEver); - ShowViewportWindow(); - - auto settingsWindowHeight = height * 0.25f; - auto settingsWindowWidth = width * 0.25f; - ImGui::SetNextWindowSizeConstraints(ImVec2(settingsWindowWidth, settingsWindowHeight), ImVec2(FLT_MAX, FLT_MAX)); - ImGui::SetNextWindowPos({ (width / 2.0f) - (settingsWindowWidth / 2.0f), (height / 2.0f) - (settingsWindowHeight / 2.0f) }, ImGuiCond_Appearing); - if (showSettingsWindow) { - ShowSettingsWindow(); - } - ShowWidgetWindow(); + void EditorWindow::LoadSettings() + { + if (!j.empty()) + settings = j; + } - // Show palette window - PaletteWindow::GetSingleton()->Draw(); + void EditorWindow::ShowSettingsWindow() + { + ImGui::Begin("Settings", &showSettingsWindow); - // Render notifications on top of everything - RenderNotifications(); + if (ImGui::BeginTable("SettingsTable", 2, ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInner | ImGuiTableFlags_NoHostExtendX)) { + ImGui::TableSetupColumn("Options", ImGuiTableColumnFlags_WidthStretch, 0.3f); + ImGui::TableSetupColumn("##Settings", ImGuiTableColumnFlags_WidthStretch, 0.7f); - // Pop the alpha style var - ImGui::PopStyleVar(); + ImGui::TableNextRow(); - // Restore previous font scale - io.FontGlobalScale = previousScale; -} + ImGui::TableSetColumnIndex(0); + const char* options[] = { "General", "Flags" }; + for (int i = 0; i < IM_ARRAYSIZE(options); ++i) { + if (ImGui::Selectable(options[i], settingsSelectedCategory == options[i])) { + settingsSelectedCategory = options[i]; + } + } -void EditorWindow::OpenWeatherFeatureSetting(RE::TESWeather* weather, const std::string& featureName, const std::string& settingName) -{ - if (!weather) { - return; - } + ImGui::TableSetColumnIndex(1); - // Open the editor if it's not already open - if (!open) { - open = true; - } + if (settingsSelectedCategory == "General") { + ImGui::Checkbox("Auto-apply changes", &settings.autoApplyChanges); + Util::AddTooltip("Automatically apply changes to weather/lighting when editing"); - // Find the weather widget - for (auto& widget : weatherWidgets) { - auto* weatherWidget = dynamic_cast(widget.get()); - if (weatherWidget && weatherWidget->weather == weather) { - // Open the widget if it's not already open - if (!weatherWidget->open) { - weatherWidget->open = true; - } + ImGui::Checkbox("Use text buttons instead of icons", &settings.useTextButtons); + Util::AddTooltip("Display action buttons as text labels instead of icons"); - // Set up navigation to the specific feature/setting - weatherWidget->NavigateToFeatureSetting(featureName, settingName); + ImGui::Checkbox("Enable 'Inherit From Parent' feature", &settings.enableInheritFromParent); + Util::AddTooltip("Show checkboxes to copy settings from parent weather (editor-only feature)"); - // Focus the widget window - std::string windowName = std::format("{}###widget_{}", weatherWidget->GetEditorID(), (void*)weatherWidget); - ImGui::SetWindowFocus(windowName.c_str()); - break; - } - } -} + ImGui::Separator(); + ImGui::TextUnformatted("UI Scale"); + ImGui::Spacing(); -EditorWindow::~EditorWindow() -{ - delete tempTexture; - weatherWidgets.clear(); - lightingTemplateWidgets.clear(); - imageSpaceWidgets.clear(); - volumetricLightingWidgets.clear(); - precipitationWidgets.clear(); - referenceEffectWidgets.clear(); - artObjectWidgets.clear(); - effectShaderWidgets.clear(); - currentCellLightingWidget.reset(); -} + if (ImGui::SliderFloat("Editor UI Scale", &settings.editorUIScale, 0.5f, 2.0f, "%.2f")) { + Save(); + } + Util::AddTooltip("Scale the size of all editor UI elements (0.5 = 50%, 2.0 = 200%)"); -void EditorWindow::SetupResources() -{ - Load(); - PaletteWindow::GetSingleton()->Load(); - InvalidateJsonAttachmentCache(); - - // Populate all widget collections using WidgetFactory templates - WidgetFactory::PopulateWidgets(weatherWidgets); - WidgetFactory::PopulateWidgets(lightingTemplateWidgets); - WidgetFactory::PopulateWidgets(imageSpaceWidgets); - WidgetFactory::PopulateWidgets(volumetricLightingWidgets); - WidgetFactory::PopulateWidgets(precipitationWidgets); - WidgetFactory::PopulateWidgets(lensFlareWidgets); - WidgetFactory::PopulateWidgets(referenceEffectWidgets); - - // Cache simple form widgets for form picker performance - WidgetFactory::PopulateSimpleWidgets(artObjectWidgets); - WidgetFactory::PopulateSimpleWidgets(effectShaderWidgets); -} + if (Util::ButtonWithFlash("Reset to 1.0")) { + settings.editorUIScale = 1.0f; + Save(); + } + ImGui::SameLine(); + Util::AddTooltip("Reset UI scale to default (100%)"); -void EditorWindow::Draw() -{ - // Track editor open state for vanity camera management - static bool wasOpen = false; - - if (open && !wasOpen) { - // Editor just opened - disable vanity camera and restore session - DisableVanityCamera(); - RestoreSessionWidgets(); - } else if (!open && wasOpen) { - // Editor just closed - restore vanity camera and save session - RestoreVanityCamera(); - SaveSessionWidgets(); - } + ImGui::Separator(); + ImGui::TextUnformatted("Session & History"); + ImGui::Spacing(); - wasOpen = open; + ImGui::Checkbox("Remember open widgets", &settings.rememberOpenWidgets); + Util::AddTooltip("Automatically reopen widgets that were open when you last closed the editor"); - // Re-enforce weather lock if active (handles time changes) - if (weatherLockActive && lockedWeather) { - auto sky = RE::Sky::GetSingleton(); - if (sky && sky->currentWeather != lockedWeather) { - sky->ForceWeather(lockedWeather, false); - } - } + ImGui::SliderInt("Max recent widgets", &settings.maxRecentWidgets, 5, 20); + Util::AddTooltip("Maximum number of recent widgets to remember"); - auto renderer = RE::BSGraphics::Renderer::GetSingleton(); - auto& framebuffer = renderer->GetRuntimeData().renderTargets[RE::RENDER_TARGETS::kFRAMEBUFFER]; + if (Util::ButtonWithFlash("Clear Recent History")) { + settings.recentWidgets.clear(); + Save(); + } + ImGui::SameLine(); + if (Util::ButtonWithFlash("Clear Favorites")) { + settings.favoriteWidgets.clear(); + Save(); + } - ID3D11Resource* resource = nullptr; - framebuffer.SRV->GetResource(&resource); + } else if (settingsSelectedCategory == "Flags") { + if (ImGui::BeginTable("FlagsTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Label", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Colour", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, 60.0f); - if (!tempTexture) { - D3D11_TEXTURE2D_DESC texDesc{}; - ((ID3D11Texture2D*)resource)->GetDesc(&texDesc); + auto& recordMarkers = settings.recordMarkers; - D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc{}; - framebuffer.SRV->GetDesc(&srvDesc); + // Store markers to delete (can't delete while iterating) + static std::string markerToDelete; + markerToDelete.clear(); - tempTexture = new Texture2D(texDesc); - tempTexture->CreateSRV(srvDesc); - } + // Store rename info (old name -> new name) + static std::pair renameInfo; + static bool needsRename = false; - auto& context = globals::d3d::context; + // Store separate buffers for each marker + static std::unordered_map> labelBuffers; - context->CopyResource(tempTexture->resource.get(), resource); + for (auto& recordMarker : recordMarkers) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); - if (resource) { - resource->Release(); - } + // Editable label - use separate buffer for each marker + auto& labelBuffer = labelBuffers[recordMarker.first]; + if (labelBuffer[0] == '\0' || labelBuffers.find(recordMarker.first) == labelBuffers.end()) { + strncpy_s(labelBuffer.data(), labelBuffer.size(), recordMarker.first.c_str(), labelBuffer.size() - 1); + labelBuffer[labelBuffer.size() - 1] = '\0'; + } - RenderUI(); -} + ImGui::SetNextItemWidth(-1); + if (ImGui::InputText(std::format("##Label{}", recordMarker.first).c_str(), labelBuffer.data(), labelBuffer.size(), ImGuiInputTextFlags_EnterReturnsTrue)) { + // Mark for rename only on Enter + renameInfo = { recordMarker.first, std::string(labelBuffer.data()) }; + needsRename = true; + } -void EditorWindow::SaveAll() -{ - for (auto& weather : weatherWidgets) { - if (weather->IsOpen()) - weather->Save(); - } + ImGui::TableSetColumnIndex(1); + if (ImGui::ColorEdit3(std::format("Color##{}", recordMarker.first).c_str(), (float*)&recordMarker.second)) { + Save(); + } - for (auto& lightingTemplate : lightingTemplateWidgets) { - if (lightingTemplate->IsOpen()) - lightingTemplate->Save(); - } + ImGui::TableSetColumnIndex(2); + auto deleteColor = Menu::GetSingleton()->GetTheme().StatusPalette.Warning; + deleteColor.y = deleteColor.y * 0.5f; + auto deleteHovered = deleteColor; + deleteHovered.w = 0.8f; + auto deleteActive = deleteColor; + deleteActive.w = 1.0f; + { + auto styledButton = Util::StyledButtonWrapper(deleteColor, deleteHovered, deleteActive); + if (ImGui::Button(std::format("Delete##{}", recordMarker.first).c_str(), ImVec2(-1, 0))) { + markerToDelete = recordMarker.first; + } + } + } - for (auto& imageSpace : imageSpaceWidgets) { - if (imageSpace->IsOpen()) - imageSpace->Save(); - } + // Process rename + if (needsRename && renameInfo.first != renameInfo.second && !renameInfo.second.empty()) { + // Check if new name doesn't already exist + if (recordMarkers.find(renameInfo.second) == recordMarkers.end()) { + auto color = recordMarkers[renameInfo.first]; + recordMarkers.erase(renameInfo.first); + recordMarkers[renameInfo.second] = color; + + // Update any records that were using the old marker name + for (auto& [recordId, markerName] : settings.markedRecords) { + if (markerName == renameInfo.first) { + markerName = renameInfo.second; + } + } - Save(); -} + Save(); + } + needsRename = false; + } -void EditorWindow::SaveSettings() -{ - j = settings; -} + // Process deletion + if (!markerToDelete.empty()) { + recordMarkers.erase(markerToDelete); -void EditorWindow::LoadSettings() -{ - if (!j.empty()) - settings = j; -} + // Remove any records that were using this marker + for (auto it = settings.markedRecords.begin(); it != settings.markedRecords.end();) { + if (it->second == markerToDelete) { + it = settings.markedRecords.erase(it); + } else { + ++it; + } + } -void EditorWindow::ShowSettingsWindow() -{ - ImGui::Begin("Settings", &showSettingsWindow); + Save(); + } - if (ImGui::BeginTable("SettingsTable", 2, ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInner | ImGuiTableFlags_NoHostExtendX)) { - ImGui::TableSetupColumn("Options", ImGuiTableColumnFlags_WidthStretch, 0.3f); - ImGui::TableSetupColumn("##Settings", ImGuiTableColumnFlags_WidthStretch, 0.7f); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); - ImGui::TableNextRow(); + if (recordMarkers.size() < maxRecordMarkers && ImGui::Selectable("Add new marker")) { + recordMarkers.insert({ std::format("New marker {}", recordMarkers.size()), { 0.5f, 0.5f, 0.5f, 1.0f } }); + Save(); + } - ImGui::TableSetColumnIndex(0); - const char* options[] = { "General", "Flags" }; - for (int i = 0; i < IM_ARRAYSIZE(options); ++i) { - if (ImGui::Selectable(options[i], settingsSelectedCategory == options[i])) { - settingsSelectedCategory = options[i]; + ImGui::EndTable(); + } + } + ImGui::EndTable(); } - } - ImGui::TableSetColumnIndex(1); - - if (settingsSelectedCategory == "General") { - ImGui::Checkbox("Auto-apply changes", &settings.autoApplyChanges); - Util::AddTooltip("Automatically apply changes to weather/lighting when editing"); - - ImGui::Checkbox("Use text buttons instead of icons", &settings.useTextButtons); - Util::AddTooltip("Display action buttons as text labels instead of icons"); + ImGui::End(); + } - ImGui::Checkbox("Enable 'Inherit From Parent' feature", &settings.enableInheritFromParent); - Util::AddTooltip("Show checkboxes to copy settings from parent weather (editor-only feature)"); + void EditorWindow::Save() + { + SaveSettings(); + const std::string filePath = Util::PathHelpers::GetCommunityShaderPath().string(); + const std::string file = std::format("{}\\{}.json", filePath, settingsFilename); - ImGui::Separator(); - ImGui::TextUnformatted("UI Scale"); - ImGui::Spacing(); + std::ofstream settingsFile(file); - if (ImGui::SliderFloat("Editor UI Scale", &settings.editorUIScale, 0.5f, 2.0f, "%.2f")) { - Save(); + if (!settingsFile.good() || !settingsFile.is_open()) { + logger::warn("Failed to open settings file: {}", file); + return; } - Util::AddTooltip("Scale the size of all editor UI elements (0.5 = 50%, 2.0 = 200%)"); - if (Util::ButtonWithFlash("Reset to 1.0")) { - settings.editorUIScale = 1.0f; - Save(); + if (settingsFile.fail()) { + logger::warn("Unable to create settings file: {}", file); + settingsFile.close(); + return; } - ImGui::SameLine(); - Util::AddTooltip("Reset UI scale to default (100%)"); - ImGui::Separator(); - ImGui::TextUnformatted("Session & History"); - ImGui::Spacing(); - - ImGui::Checkbox("Remember open widgets", &settings.rememberOpenWidgets); - Util::AddTooltip("Automatically reopen widgets that were open when you last closed the editor"); + logger::info("Saving settings file: {}", file); - ImGui::SliderInt("Max recent widgets", &settings.maxRecentWidgets, 5, 20); - Util::AddTooltip("Maximum number of recent widgets to remember"); + try { + settingsFile << j.dump(1); - if (Util::ButtonWithFlash("Clear Recent History")) { - settings.recentWidgets.clear(); - Save(); + settingsFile.close(); + } catch (const nlohmann::json::parse_error& e) { + logger::warn("Error parsing settings for settings file ({}) : {}\n", filePath, e.what()); + settingsFile.close(); } - ImGui::SameLine(); - if (Util::ButtonWithFlash("Clear Favorites")) { - settings.favoriteWidgets.clear(); - Save(); - } - - } else if (settingsSelectedCategory == "Flags") { - if (ImGui::BeginTable("FlagsTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { - ImGui::TableSetupColumn("Label", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Colour", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, 60.0f); - - auto& recordMarkers = settings.recordMarkers; - - // Store markers to delete (can't delete while iterating) - static std::string markerToDelete; - markerToDelete.clear(); + } - // Store rename info (old name -> new name) - static std::pair renameInfo; - static bool needsRename = false; + void EditorWindow::Load() + { + std::string filePath = std::format("{}\\{}.json", Util::PathHelpers::GetCommunityShaderPath().string(), settingsFilename); - // Store separate buffers for each marker - static std::unordered_map> labelBuffers; + std::ifstream settingsFile(filePath); - for (auto& recordMarker : recordMarkers) { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); + if (!std::filesystem::exists(filePath)) { + // Does not have any settings so just return. + return; + } - // Editable label - use separate buffer for each marker - auto& labelBuffer = labelBuffers[recordMarker.first]; - if (labelBuffer[0] == '\0' || labelBuffers.find(recordMarker.first) == labelBuffers.end()) { - strncpy_s(labelBuffer.data(), labelBuffer.size(), recordMarker.first.c_str(), labelBuffer.size() - 1); - labelBuffer[labelBuffer.size() - 1] = '\0'; - } + if (!settingsFile.good() || !settingsFile.is_open()) { + logger::warn("Failed to load settings file: {}", filePath); + return; + } - ImGui::SetNextItemWidth(-1); - if (ImGui::InputText(std::format("##Label{}", recordMarker.first).c_str(), labelBuffer.data(), labelBuffer.size(), ImGuiInputTextFlags_EnterReturnsTrue)) { - // Mark for rename only on Enter - renameInfo = { recordMarker.first, std::string(labelBuffer.data()) }; - needsRename = true; - } + try { + j << settingsFile; + settingsFile.close(); + } catch (const nlohmann::json::parse_error& e) { + logger::warn("Error parsing settings for file ({}) : {}\n", filePath, e.what()); + settingsFile.close(); + } + LoadSettings(); + } - ImGui::TableSetColumnIndex(1); - if (ImGui::ColorEdit3(std::format("Color##{}", recordMarker.first).c_str(), (float*)&recordMarker.second)) { - Save(); - } + void EditorWindow::LockWeather(RE::TESWeather * weather) + { + if (!weather) + return; - ImGui::TableSetColumnIndex(2); - auto deleteColor = Menu::GetSingleton()->GetTheme().StatusPalette.Warning; - deleteColor.y = deleteColor.y * 0.5f; - auto deleteHovered = deleteColor; - deleteHovered.w = 0.8f; - auto deleteActive = deleteColor; - deleteActive.w = 1.0f; - { - auto styledButton = Util::StyledButtonWrapper(deleteColor, deleteHovered, deleteActive); - if (ImGui::Button(std::format("Delete##{}", recordMarker.first).c_str(), ImVec2(-1, 0))) { - markerToDelete = recordMarker.first; - } - } - } + auto sky = RE::Sky::GetSingleton(); + if (!sky) + return; - // Process rename - if (needsRename && renameInfo.first != renameInfo.second && !renameInfo.second.empty()) { - // Check if new name doesn't already exist - if (recordMarkers.find(renameInfo.second) == recordMarkers.end()) { - auto color = recordMarkers[renameInfo.first]; - recordMarkers.erase(renameInfo.first); - recordMarkers[renameInfo.second] = color; - - // Update any records that were using the old marker name - for (auto& [recordId, markerName] : settings.markedRecords) { - if (markerName == renameInfo.first) { - markerName = renameInfo.second; - } - } + // Force the weather to be active + sky->ForceWeather(weather, false); - Save(); - } - needsRename = false; - } + lockedWeather = weather; + weatherLockActive = true; - // Process deletion - if (!markerToDelete.empty()) { - recordMarkers.erase(markerToDelete); + logger::info("Weather locked: {}", weather->GetFormEditorID() ? weather->GetFormEditorID() : "Unknown"); + } - // Remove any records that were using this marker - for (auto it = settings.markedRecords.begin(); it != settings.markedRecords.end();) { - if (it->second == markerToDelete) { - it = settings.markedRecords.erase(it); - } else { - ++it; - } - } + void EditorWindow::UnlockWeather() + { + if (!weatherLockActive) + return; - Save(); - } + auto sky = RE::Sky::GetSingleton(); + if (sky) { + // Release weather override to allow natural progression + sky->ReleaseWeatherOverride(); + } - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); + logger::info("Weather unlocked: {}", lockedWeather && lockedWeather->GetFormEditorID() ? lockedWeather->GetFormEditorID() : "Unknown"); - if (recordMarkers.size() < maxRecordMarkers && ImGui::Selectable("Add new marker")) { - recordMarkers.insert({ std::format("New marker {}", recordMarkers.size()), { 0.5f, 0.5f, 0.5f, 1.0f } }); - Save(); - } + lockedWeather = nullptr; + weatherLockActive = false; + } - ImGui::EndTable(); + void EditorWindow::PauseTime() + { + if (timePaused) + return; + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (calendar && calendar->timeScale) { + savedTimeScale = calendar->timeScale->value; + calendar->timeScale->value = 0.0f; + timePaused = true; + logger::info("Time paused (saved timescale: {})", savedTimeScale); } } - ImGui::EndTable(); - } - - ImGui::End(); -} -void EditorWindow::Save() -{ - SaveSettings(); - const std::string filePath = Util::PathHelpers::GetCommunityShaderPath().string(); - const std::string file = std::format("{}\\{}.json", filePath, settingsFilename); + void EditorWindow::ResumeTime() + { + if (!timePaused) + return; + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (calendar && calendar->timeScale) { + calendar->timeScale->value = savedTimeScale; + timePaused = false; + logger::info("Time resumed (timescale: {})", savedTimeScale); + } + } - std::ofstream settingsFile(file); + void EditorWindow::ResetTimeScale() + { + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (!calendar || !calendar->timeScale) + return; + if (timePaused) + savedTimeScale = kVanillaTimeScale; + else + calendar->timeScale->value = kVanillaTimeScale; + timeScaleSlider = kVanillaTimeScale; + } - if (!settingsFile.good() || !settingsFile.is_open()) { - logger::warn("Failed to open settings file: {}", file); - return; - } + void EditorWindow::UpdateTimeState() + { + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + auto ui = globals::game::ui ? globals::game::ui : RE::UI::GetSingleton(); + if (!calendar || !calendar->timeScale) + return; - if (settingsFile.fail()) { - logger::warn("Unable to create settings file: {}", file); - settingsFile.close(); - return; - } + bool sleepWaitOpen = ui && ui->IsMenuOpen(RE::SleepWaitMenu::MENU_NAME); - logger::info("Saving settings file: {}", file); + // External state sync (skip during sleep/wait) + if (!sleepWaitOpen) { + if (calendar->timeScale->value == 0.0f && !timePaused) + savedTimeScale = kVanillaTimeScale; + else if (calendar->timeScale->value > 0.0f && timePaused) + timePaused = false; + } - try { - settingsFile << j.dump(1); + // Sleep/wait handling — temporarily restore time so the wait can proceed + if (sleepWaitOpen && calendar->timeScale->value == 0.0f) { + if (!wasRestoredForWait) { + wasPausedBeforeWait = true; + if (timePaused) + ResumeTime(); + else + calendar->timeScale->value = std::max(savedTimeScale, kVanillaTimeScale); + wasRestoredForWait = true; + } + } else if (!sleepWaitOpen && wasRestoredForWait) { + if (wasPausedBeforeWait && !timePaused) + PauseTime(); + wasRestoredForWait = false; + wasPausedBeforeWait = false; + } + } - settingsFile.close(); - } catch (const nlohmann::json::parse_error& e) { - logger::warn("Error parsing settings for settings file ({}) : {}\n", filePath, e.what()); - settingsFile.close(); - } -} + bool EditorWindow::DrawGameHourSlider(const char* label, const char* format) + { + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (!calendar || !calendar->gameHour) + return false; + ImGui::SliderFloat(label, &calendar->gameHour->value, 0.0f, kGameHourMax, format); + return true; + } -void EditorWindow::Load() -{ - std::string filePath = std::format("{}\\{}.json", Util::PathHelpers::GetCommunityShaderPath().string(), settingsFilename); + void EditorWindow::DrawTimeControls() + { + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (!calendar || !calendar->gameHour || !calendar->timeScale) + return; - std::ifstream settingsFile(filePath); + // Row 1: Pause/Resume + Game Time + if (ImGui::Button(timePaused ? "Resume Time" : "Pause Time", ImVec2(120, 0))) + TogglePause(); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text("Pause or resume game time progression"); + ImGui::SameLine(); + DrawGameHourSlider(); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text("Adjust the current game time"); - if (!std::filesystem::exists(filePath)) { - // Does not have any settings so just return. - return; - } + // Sync slider with actual value + if (timePaused) + timeScaleSlider = std::max(savedTimeScale, kTimeScaleMin); + else if (std::abs(calendar->timeScale->value - timeScaleSlider) > 0.01f) + timeScaleSlider = calendar->timeScale->value; - if (!settingsFile.good() || !settingsFile.is_open()) { - logger::warn("Failed to load settings file: {}", filePath); - return; - } + // Row 2: Reset Speed + TimeScale slider + speed label + if (ImGui::Button("Reset Speed", ImVec2(120, 0))) + ResetTimeScale(); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text("Reset time speed to vanilla (%.1fx)", kVanillaTimeScale); - try { - j << settingsFile; - settingsFile.close(); - } catch (const nlohmann::json::parse_error& e) { - logger::warn("Error parsing settings for file ({}) : {}\n", filePath, e.what()); - settingsFile.close(); - } - LoadSettings(); -} + ImGui::SameLine(); + ImGui::BeginDisabled(timePaused); + if (ImGui::SliderFloat("##TimeScale", &timeScaleSlider, kTimeScaleMin, kTimeScaleMax, + timeScaleSlider == kVanillaTimeScale ? "Vanilla Speed" : "", ImGuiSliderFlags_Logarithmic)) + calendar->timeScale->value = timeScaleSlider; + ImGui::EndDisabled(); -void EditorWindow::LockWeather(RE::TESWeather* weather) -{ - if (!weather) - return; + ImGui::SameLine(); + ImGui::Text("%.1fx", calendar->timeScale->value); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text("Adjust how fast time passes (vanilla: %.1fx)", kVanillaTimeScale); + } - auto sky = RE::Sky::GetSingleton(); - if (!sky) - return; + void EditorWindow::DisableVanityCamera() + { + if (vanityCameraDisabled) + return; - // Force the weather to be active - sky->ForceWeather(weather, false); + auto setting = RE::GetINISetting("fAutoVanityModeDelay:Camera"); + if (setting) { + savedVanityCameraDelay = setting->GetFloat(); + setting->data.f = 10000.0f; + vanityCameraDisabled = true; + logger::info("Vanity camera disabled (saved delay: {})", savedVanityCameraDelay); + } + } - lockedWeather = weather; - weatherLockActive = true; + void EditorWindow::RestoreVanityCamera() + { + if (!vanityCameraDisabled) + return; - logger::info("Weather locked: {}", weather->GetFormEditorID() ? weather->GetFormEditorID() : "Unknown"); -} + auto setting = RE::GetINISetting("fAutoVanityModeDelay:Camera"); + if (setting) { + setting->data.f = savedVanityCameraDelay; + vanityCameraDisabled = false; + logger::info("Vanity camera restored (delay: {})", savedVanityCameraDelay); + } + } -void EditorWindow::UnlockWeather() -{ - if (!weatherLockActive) - return; + bool EditorWindow::ShouldHandleEscapeKey() const + { + return !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel); + } - auto sky = RE::Sky::GetSingleton(); - if (sky) { - // Release weather override to allow natural progression - sky->ReleaseWeatherOverride(); - } + void EditorWindow::PushUndoState(Widget * widget) + { + if (!widget) + return; - logger::info("Weather unlocked: {}", lockedWeather && lockedWeather->GetFormEditorID() ? lockedWeather->GetFormEditorID() : "Unknown"); + UndoState state; + state.widget = widget; + state.widgetId = widget->GetEditorID(); + state.settings = widget->js; - lockedWeather = nullptr; - weatherLockActive = false; -} + undoStack.push_back(state); -void EditorWindow::PauseTime() -{ - if (timePaused) - return; - auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); - if (calendar && calendar->timeScale) { - savedTimeScale = calendar->timeScale->value; - calendar->timeScale->value = 0.0f; - timePaused = true; - logger::info("Time paused (saved timescale: {})", savedTimeScale); - } -} + if (undoStack.size() > maxUndoStates) { + undoStack.erase(undoStack.begin()); + } + } -void EditorWindow::ResumeTime() -{ - if (!timePaused) - return; - auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); - if (calendar && calendar->timeScale) { - calendar->timeScale->value = savedTimeScale; - timePaused = false; - logger::info("Time resumed (timescale: {})", savedTimeScale); - } -} + void EditorWindow::PerformUndo() + { + if (undoStack.empty()) + return; -void EditorWindow::ResetTimeScale() -{ - auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); - if (!calendar || !calendar->timeScale) - return; - if (timePaused) - savedTimeScale = kVanillaTimeScale; - else - calendar->timeScale->value = kVanillaTimeScale; - timeScaleSlider = kVanillaTimeScale; -} + UndoState state = undoStack.back(); + undoStack.pop_back(); -void EditorWindow::UpdateTimeState() -{ - auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); - auto ui = globals::game::ui ? globals::game::ui : RE::UI::GetSingleton(); - if (!calendar || !calendar->timeScale) - return; - - bool sleepWaitOpen = ui && ui->IsMenuOpen(RE::SleepWaitMenu::MENU_NAME); - - // External state sync (skip during sleep/wait) - if (!sleepWaitOpen) { - if (calendar->timeScale->value == 0.0f && !timePaused) - savedTimeScale = kVanillaTimeScale; - else if (calendar->timeScale->value > 0.0f && timePaused) - timePaused = false; - } + if (!state.widget) { + for (auto& w : weatherWidgets) { + if (w->GetEditorID() == state.widgetId) { + state.widget = w.get(); + break; + } + } + if (!state.widget) { + for (auto& w : imageSpaceWidgets) { + if (w->GetEditorID() == state.widgetId) { + state.widget = w.get(); + break; + } + } + } + if (!state.widget) { + for (auto& w : lightingTemplateWidgets) { + if (w->GetEditorID() == state.widgetId) { + state.widget = w.get(); + break; + } + } + } + } - // Sleep/wait handling — temporarily restore time so the wait can proceed - if (sleepWaitOpen && calendar->timeScale->value == 0.0f) { - if (!wasRestoredForWait) { - wasPausedBeforeWait = true; - if (timePaused) - ResumeTime(); - else - calendar->timeScale->value = std::max(savedTimeScale, kVanillaTimeScale); - wasRestoredForWait = true; + if (state.widget) { + state.widget->js = state.settings; + state.widget->LoadSettings(); + state.widget->ApplyChanges(); + ShowNotification( + std::format("Undone changes to {}", state.widgetId), + Menu::GetSingleton()->GetSettings().Theme.StatusPalette.InfoColor, + 2.0f); + } } - } else if (!sleepWaitOpen && wasRestoredForWait) { - if (wasPausedBeforeWait && !timePaused) - PauseTime(); - wasRestoredForWait = false; - wasPausedBeforeWait = false; - } -} -bool EditorWindow::DrawGameHourSlider(const char* label, const char* format) -{ - auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); - if (!calendar || !calendar->gameHour) - return false; - ImGui::SliderFloat(label, &calendar->gameHour->value, 0.0f, kGameHourMax, format); - return true; -} + void EditorWindow::ShowNotification(const std::string& message, const ImVec4& color, float duration) + { + // Guard against calls before ImGui is initialized + if (!ImGui::GetCurrentContext()) { + logger::warn("ShowNotification called before ImGui initialization: {}", message); + return; + } -void EditorWindow::DrawTimeControls() -{ - auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); - if (!calendar || !calendar->gameHour || !calendar->timeScale) - return; - - // Row 1: Pause/Resume + Game Time - if (ImGui::Button(timePaused ? "Resume Time" : "Pause Time", ImVec2(120, 0))) - TogglePause(); - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text("Pause or resume game time progression"); - ImGui::SameLine(); - DrawGameHourSlider(); - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text("Adjust the current game time"); - - // Sync slider with actual value - if (timePaused) - timeScaleSlider = std::max(savedTimeScale, kTimeScaleMin); - else if (std::abs(calendar->timeScale->value - timeScaleSlider) > 0.01f) - timeScaleSlider = calendar->timeScale->value; - - // Row 2: Reset Speed + TimeScale slider + speed label - if (ImGui::Button("Reset Speed", ImVec2(120, 0))) - ResetTimeScale(); - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text("Reset time speed to vanilla (%.1fx)", kVanillaTimeScale); - - ImGui::SameLine(); - ImGui::BeginDisabled(timePaused); - if (ImGui::SliderFloat("##TimeScale", &timeScaleSlider, kTimeScaleMin, kTimeScaleMax, - timeScaleSlider == kVanillaTimeScale ? "Vanilla Speed" : "", ImGuiSliderFlags_Logarithmic)) - calendar->timeScale->value = timeScaleSlider; - ImGui::EndDisabled(); - - ImGui::SameLine(); - ImGui::Text("%.1fx", calendar->timeScale->value); - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text("Adjust how fast time passes (vanilla: %.1fx)", kVanillaTimeScale); -} + Notification notif; + notif.message = message; + notif.color = color; + notif.startTime = static_cast(ImGui::GetTime()); + notif.duration = duration; + notifications.push_back(notif); + } -void EditorWindow::DisableVanityCamera() -{ - if (vanityCameraDisabled) - return; - - auto setting = RE::GetINISetting("fAutoVanityModeDelay:Camera"); - if (setting) { - savedVanityCameraDelay = setting->GetFloat(); - setting->data.f = 10000.0f; - vanityCameraDisabled = true; - logger::info("Vanity camera disabled (saved delay: {})", savedVanityCameraDelay); - } -} + void EditorWindow::RenderNotifications() + { + // Guard against calls before ImGui is initialized + if (!ImGui::GetCurrentContext()) { + return; + } -void EditorWindow::RestoreVanityCamera() -{ - if (!vanityCameraDisabled) - return; - - auto setting = RE::GetINISetting("fAutoVanityModeDelay:Camera"); - if (setting) { - setting->data.f = savedVanityCameraDelay; - vanityCameraDisabled = false; - logger::info("Vanity camera restored (delay: {})", savedVanityCameraDelay); - } -} + float currentTime = static_cast(ImGui::GetTime()); + float yOffset = 10.0f; -bool EditorWindow::ShouldHandleEscapeKey() const -{ - return !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel); -} + // Remove expired notifications + notifications.erase( + std::remove_if(notifications.begin(), notifications.end(), + [currentTime](const Notification& n) { return currentTime - n.startTime > n.duration; }), + notifications.end()); -void EditorWindow::PushUndoState(Widget* widget) -{ - if (!widget) - return; + // Render active notifications + for (auto& notif : notifications) { + float elapsed = currentTime - notif.startTime; + float fadeStart = notif.duration - 0.5f; // Start fading 0.5s before end + float alpha = 1.0f; - UndoState state; - state.widget = widget; - state.widgetId = widget->GetEditorID(); - state.settings = widget->js; + // Fade out in the last 0.5 seconds + if (elapsed > fadeStart) { + alpha = 1.0f - ((elapsed - fadeStart) / 0.5f); + } - undoStack.push_back(state); + // Position in top-left corner + ImGui::SetNextWindowPos(ImVec2(10.0f, yOffset), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.8f * alpha); - if (undoStack.size() > maxUndoStates) { - undoStack.erase(undoStack.begin()); - } -} + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 5.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(15.0f, 10.0f)); -void EditorWindow::PerformUndo() -{ - if (undoStack.empty()) - return; + if (ImGui::Begin(std::format("##Notification{}", (void*)¬if).c_str(), + nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking)) { + ImVec4 colorWithAlpha = notif.color; + colorWithAlpha.w *= alpha; + ImGui::PushStyleColor(ImGuiCol_Text, colorWithAlpha); + ImGui::TextUnformatted(notif.message.c_str()); + ImGui::PopStyleColor(); - UndoState state = undoStack.back(); - undoStack.pop_back(); + yOffset += ImGui::GetWindowSize().y + 5.0f; + } + ImGui::End(); - if (!state.widget) { - for (auto& w : weatherWidgets) { - if (w->GetEditorID() == state.widgetId) { - state.widget = w.get(); - break; + ImGui::PopStyleVar(2); } } - if (!state.widget) { - for (auto& w : imageSpaceWidgets) { - if (w->GetEditorID() == state.widgetId) { - state.widget = w.get(); - break; + + void EditorWindow::RefreshJsonAttachmentCache(const std::vector& widgets) + { + for (auto* widget : widgets) { + if (!widget) { + continue; } - } - } - if (!state.widget) { - for (auto& w : lightingTemplateWidgets) { - if (w->GetEditorID() == state.widgetId) { - state.widget = w.get(); - break; + if (!jsonAttachmentCache.contains(widget)) { + jsonAttachmentCache.emplace(widget, widget->HasSavedFile()); } } } - } - - if (state.widget) { - state.widget->js = state.settings; - state.widget->LoadSettings(); - state.widget->ApplyChanges(); - ShowNotification( - std::format("Undone changes to {}", state.widgetId), - Menu::GetSingleton()->GetSettings().Theme.StatusPalette.InfoColor, - 2.0f); - } -} - -void EditorWindow::ShowNotification(const std::string& message, const ImVec4& color, float duration) -{ - // Guard against calls before ImGui is initialized - if (!ImGui::GetCurrentContext()) { - logger::warn("ShowNotification called before ImGui initialization: {}", message); - return; - } - - Notification notif; - notif.message = message; - notif.color = color; - notif.startTime = static_cast(ImGui::GetTime()); - notif.duration = duration; - notifications.push_back(notif); -} - -void EditorWindow::RenderNotifications() -{ - // Guard against calls before ImGui is initialized - if (!ImGui::GetCurrentContext()) { - return; - } - - float currentTime = static_cast(ImGui::GetTime()); - float yOffset = 10.0f; - - // Remove expired notifications - notifications.erase( - std::remove_if(notifications.begin(), notifications.end(), - [currentTime](const Notification& n) { return currentTime - n.startTime > n.duration; }), - notifications.end()); - // Render active notifications - for (auto& notif : notifications) { - float elapsed = currentTime - notif.startTime; - float fadeStart = notif.duration - 0.5f; // Start fading 0.5s before end - float alpha = 1.0f; - - // Fade out in the last 0.5 seconds - if (elapsed > fadeStart) { - alpha = 1.0f - ((elapsed - fadeStart) / 0.5f); + bool EditorWindow::HasCachedJsonAttachment(Widget * widget) const + { + if (!widget) { + return false; + } + if (auto it = jsonAttachmentCache.find(widget); it != jsonAttachmentCache.end()) { + return it->second; + } + return false; } - // Position in top-left corner - ImGui::SetNextWindowPos(ImVec2(10.0f, yOffset), ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.8f * alpha); - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 5.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(15.0f, 10.0f)); - - if (ImGui::Begin(std::format("##Notification{}", (void*)¬if).c_str(), - nullptr, - ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking)) { - ImVec4 colorWithAlpha = notif.color; - colorWithAlpha.w *= alpha; - ImGui::PushStyleColor(ImGuiCol_Text, colorWithAlpha); - ImGui::TextUnformatted(notif.message.c_str()); - ImGui::PopStyleColor(); - - yOffset += ImGui::GetWindowSize().y + 5.0f; + void EditorWindow::InvalidateJsonAttachmentCache(Widget * widget) + { + if (widget) { + jsonAttachmentCache.erase(widget); + return; + } + jsonAttachmentCache.clear(); } - ImGui::End(); - - ImGui::PopStyleVar(2); - } -} -void EditorWindow::RefreshJsonAttachmentCache(const std::vector& widgets) -{ - for (auto* widget : widgets) { - if (!widget) { - continue; - } - if (!jsonAttachmentCache.contains(widget)) { - jsonAttachmentCache.emplace(widget, widget->HasSavedFile()); + void EditorWindow::OnWidgetJsonAttachmentChanged(Widget * widget) + { + InvalidateJsonAttachmentCache(widget); } - } -} - -bool EditorWindow::HasCachedJsonAttachment(Widget* widget) const -{ - if (!widget) { - return false; - } - if (auto it = jsonAttachmentCache.find(widget); it != jsonAttachmentCache.end()) { - return it->second; - } - return false; -} - -void EditorWindow::InvalidateJsonAttachmentCache(Widget* widget) -{ - if (widget) { - jsonAttachmentCache.erase(widget); - return; - } - jsonAttachmentCache.clear(); -} -void EditorWindow::OnWidgetJsonAttachmentChanged(Widget* widget) -{ - InvalidateJsonAttachmentCache(widget); -} + void EditorWindow::AddToRecent(const std::string& widgetId, const std::string& category) + { + auto& categoryRecent = settings.recentWidgets[category]; -void EditorWindow::AddToRecent(const std::string& widgetId, const std::string& category) -{ - auto& categoryRecent = settings.recentWidgets[category]; + // Remove if already exists + auto it = std::find(categoryRecent.begin(), categoryRecent.end(), widgetId); + if (it != categoryRecent.end()) { + categoryRecent.erase(it); + } - // Remove if already exists - auto it = std::find(categoryRecent.begin(), categoryRecent.end(), widgetId); - if (it != categoryRecent.end()) { - categoryRecent.erase(it); - } + // Add to front + categoryRecent.insert(categoryRecent.begin(), widgetId); - // Add to front - categoryRecent.insert(categoryRecent.begin(), widgetId); + // Limit size + if (categoryRecent.size() > static_cast(settings.maxRecentWidgets)) { + categoryRecent.resize(settings.maxRecentWidgets); + } - // Limit size - if (categoryRecent.size() > static_cast(settings.maxRecentWidgets)) { - categoryRecent.resize(settings.maxRecentWidgets); - } + Save(); + } - Save(); -} + void EditorWindow::ToggleFavorite(const std::string& widgetId) + { + auto it = std::find(settings.favoriteWidgets.begin(), settings.favoriteWidgets.end(), widgetId); + if (it != settings.favoriteWidgets.end()) { + settings.favoriteWidgets.erase(it); + } else { + settings.favoriteWidgets.push_back(widgetId); + } + Save(); + } -void EditorWindow::ToggleFavorite(const std::string& widgetId) -{ - auto it = std::find(settings.favoriteWidgets.begin(), settings.favoriteWidgets.end(), widgetId); - if (it != settings.favoriteWidgets.end()) { - settings.favoriteWidgets.erase(it); - } else { - settings.favoriteWidgets.push_back(widgetId); - } - Save(); -} + bool EditorWindow::IsFavorite(const std::string& widgetId) const + { + return std::find(settings.favoriteWidgets.begin(), settings.favoriteWidgets.end(), widgetId) != settings.favoriteWidgets.end(); + } -bool EditorWindow::IsFavorite(const std::string& widgetId) const -{ - return std::find(settings.favoriteWidgets.begin(), settings.favoriteWidgets.end(), widgetId) != settings.favoriteWidgets.end(); -} + void EditorWindow::SaveSessionWidgets() + { + settings.lastOpenWidgets.clear(); -void EditorWindow::SaveSessionWidgets() -{ - settings.lastOpenWidgets.clear(); + // Save all currently open widgets + for (auto& widget : weatherWidgets) { + if (widget->IsOpen()) { + settings.lastOpenWidgets.push_back(widget->GetEditorID()); + } + } + for (auto& widget : lightingTemplateWidgets) { + if (widget->IsOpen()) { + settings.lastOpenWidgets.push_back(widget->GetEditorID()); + } + } - // Save all currently open widgets - for (auto& widget : weatherWidgets) { - if (widget->IsOpen()) { - settings.lastOpenWidgets.push_back(widget->GetEditorID()); + Save(); } - } - for (auto& widget : lightingTemplateWidgets) { - if (widget->IsOpen()) { - settings.lastOpenWidgets.push_back(widget->GetEditorID()); - } - } - Save(); -} - -void EditorWindow::RestoreSessionWidgets() -{ - if (!settings.rememberOpenWidgets || settings.lastOpenWidgets.empty()) { - return; - } - - // Open widgets that were open in last session - for (const auto& widgetId : settings.lastOpenWidgets) { - // Search in all widget collections - for (auto& widget : weatherWidgets) { - if (widget->GetEditorID() == widgetId) { - widget->SetOpen(true); - break; + void EditorWindow::RestoreSessionWidgets() + { + if (!settings.rememberOpenWidgets || settings.lastOpenWidgets.empty()) { + return; } - } - for (auto& widget : lightingTemplateWidgets) { - if (widget->GetEditorID() == widgetId) { - widget->SetOpen(true); - break; + + // Open widgets that were open in last session + for (const auto& widgetId : settings.lastOpenWidgets) { + // Search in all widget collections + for (auto& widget : weatherWidgets) { + if (widget->GetEditorID() == widgetId) { + widget->SetOpen(true); + break; + } + } + for (auto& widget : lightingTemplateWidgets) { + if (widget->GetEditorID() == widgetId) { + widget->SetOpen(true); + break; + } + } } } - } -} From 5c00a307b2bfa635114f78d6f528bc2e8976aaf8 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:24:31 -0700 Subject: [PATCH 04/36] Update EditorWindow.cpp --- src/WeatherEditor/EditorWindow.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/WeatherEditor/EditorWindow.cpp b/src/WeatherEditor/EditorWindow.cpp index 01f913771d..7869efc278 100644 --- a/src/WeatherEditor/EditorWindow.cpp +++ b/src/WeatherEditor/EditorWindow.cpp @@ -220,9 +220,7 @@ void EditorWindow::ShowObjectsWindow() // Right column: Objects ImGui::TableSetColumnIndex(1); - if (ImGui::BeginChild("##ObjectsContent", { 0, 0 }, ImGuiChildFlags_Border)) { - // Interior Only / Time of Day categories have their own panels - if (selectedCategory == "Interior Only") { + // Interior Only / Time of Day categories have their own panels if (ImGui::BeginChild("##ObjectsContent", { 0, 0 }, ImGuiChildFlags_Border, kStickyHeaderFlags)) { // Interior Only category has its own panel if (m_selectedCategory == "Interior Only") { @@ -232,7 +230,7 @@ void EditorWindow::ShowObjectsWindow() ImGui::End(); return; } - if (selectedCategory == "Time of Day") { + if (m_selectedCategory == "Time of Day") { TimeOfDayPanel::Draw(); ImGui::EndChild(); ImGui::EndTable(); From 38f23a4b1ba7eeb8880ca3e43729c6de04bb91b1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:24:56 +0000 Subject: [PATCH 05/36] =?UTF-8?q?style:=20=F0=9F=8E=A8=20apply=20pre-commi?= =?UTF-8?q?t.ci=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated formatting by clang-format, prettier, and other hooks. See https://pre-commit.ci for details. --- src/WeatherEditor/EditorWindow.cpp | 3146 ++++++++++++++-------------- 1 file changed, 1573 insertions(+), 1573 deletions(-) diff --git a/src/WeatherEditor/EditorWindow.cpp b/src/WeatherEditor/EditorWindow.cpp index 7869efc278..818f441ebc 100644 --- a/src/WeatherEditor/EditorWindow.cpp +++ b/src/WeatherEditor/EditorWindow.cpp @@ -221,1854 +221,1854 @@ void EditorWindow::ShowObjectsWindow() ImGui::TableSetColumnIndex(1); // Interior Only / Time of Day categories have their own panels - if (ImGui::BeginChild("##ObjectsContent", { 0, 0 }, ImGuiChildFlags_Border, kStickyHeaderFlags)) { - // Interior Only category has its own panel - if (m_selectedCategory == "Interior Only") { - InteriorOnlyPanel::Draw(); - ImGui::EndChild(); - ImGui::EndTable(); - ImGui::End(); - return; - } - if (m_selectedCategory == "Time of Day") { - TimeOfDayPanel::Draw(); - ImGui::EndChild(); - ImGui::EndTable(); - ImGui::End(); - return; - } - - // Display current active weather - auto sky = globals::game::sky; - if (sky && sky->currentWeather) { - auto currentWeather = sky->currentWeather; - ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetTheme().StatusPalette.RestartNeeded); - ImGui::Text("Current Active Weather:"); - ImGui::PopStyleColor(); - ImGui::SameLine(); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().Palette.Text, "%s", currentWeather->GetFormEditorID()); - ImGui::SameLine(); - ImGui::TextDisabled("(0x%08X)", currentWeather->GetFormID()); + if (ImGui::BeginChild("##ObjectsContent", { 0, 0 }, ImGuiChildFlags_Border, kStickyHeaderFlags)) { + // Interior Only category has its own panel + if (m_selectedCategory == "Interior Only") { + InteriorOnlyPanel::Draw(); + ImGui::EndChild(); + ImGui::EndTable(); + ImGui::End(); + return; + } + if (m_selectedCategory == "Time of Day") { + TimeOfDayPanel::Draw(); + ImGui::EndChild(); + ImGui::EndTable(); + ImGui::End(); + return; + } - // Add button to open the current weather - ImGui::SameLine(); - if (ImGui::SmallButton("Open##CurrentWeather")) { - for (auto& widget : weatherWidgets) { - if (widget->form == currentWeather) { - widget->SetOpen(true); - break; - } - } - } - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - } + // Display current active weather + auto sky = globals::game::sky; + if (sky && sky->currentWeather) { + auto currentWeather = sky->currentWeather; + ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetTheme().StatusPalette.RestartNeeded); + ImGui::Text("Current Active Weather:"); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().Palette.Text, "%s", currentWeather->GetFormEditorID()); + ImGui::SameLine(); + ImGui::TextDisabled("(0x%08X)", currentWeather->GetFormID()); - // Handle Ctrl+F to focus search bar - if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { - if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_F, false)) { - ImGui::SetKeyboardFocusHere(); - } - } - // Compute fixed widths once; reuse for both the search bar and the following combo. - const auto& style = ImGui::GetStyle(); - // comboW = preview text + left/right padding + arrow button - const float comboW = ImGui::CalcTextSize("Editor ID").x + style.FramePadding.x * 2.0f + ImGui::GetFrameHeight(); - const float helpW = ImGui::CalcTextSize("(?)").x; - const float iconW = ImGui::GetFrameHeight(); - // Fixed width is the sum of every item that follows the search bar on the same row. - // Each SameLine() contributes style.ItemSpacing.x; widths are listed explicitly - // so adding or removing a widget only requires updating its own expression. - const float fixedW = - style.ItemSpacing.x + comboW + // combo - style.ItemSpacing.x + helpW + // help marker - style.ItemSpacing.x + 10.0f + // spacer before favorites - style.ItemSpacing.x + iconW + // fav icon - style.ItemSpacing.x + ImGui::CalcTextSize("Favorites").x + // "Favorites" label - style.ItemSpacing.x + 10.0f + // spacer before flagged - style.ItemSpacing.x + iconW + // flag icon - style.ItemSpacing.x + ImGui::CalcTextSize("Flagged").x; // "Flagged" label - ImGui::SetNextItemWidth(std::max(50.0f, ImGui::GetContentRegionAvail().x - fixedW)); - ImGui::InputTextWithHint("##ObjectFilter", "Filter... (Ctrl+F)", m_filterBuffer, sizeof(m_filterBuffer)); - - ImGui::SameLine(); - ImGui::SetNextItemWidth(comboW); - int col = static_cast(m_currentFilterColumn); - if (ImGui::Combo("##FilterBy", &col, kFilterColumnNames, IM_ARRAYSIZE(kFilterColumnNames))) - m_currentFilterColumn = static_cast(col); - - ImGui::SameLine(); - Util::HelpMarker("Filter the object list by the selected column.\nAll: searches Editor ID, Form ID, File, and Status.\nStatus: hides items with no status marker when the search box is non-empty.\nCtrl+F: Focus search\nEnter: Open selected"); - - // Quick filter buttons on same row - ImGui::SameLine(); - ImGui::Dummy(ImVec2(10.0f, 0.0f)); // Spacer - ImGui::SameLine(); - if (IconButton("##filterFavorites", m_showOnlyFavorites, "star")) { - m_showOnlyFavorites = !m_showOnlyFavorites; - } - ImGui::SameLine(); - ImGui::Text("Favorites"); - - ImGui::SameLine(); - ImGui::Dummy(ImVec2(10.0f, 0.0f)); // Spacer - ImGui::SameLine(); - if (IconButton("##filterFlagged", m_showOnlyFlagged, "circle")) { - m_showOnlyFlagged = !m_showOnlyFlagged; - } - ImGui::SameLine(); - ImGui::Text("Flagged"); - - // Returns the widget collection for a given category; Cell Lighting and unknown - // categories return an empty collection since they have no standalone widget list. - auto getWidgetsForCategory = [&](const std::string& cat) -> const std::vector>& { - static const std::vector> emptyWidgets; - if (cat == "Weather") - return weatherWidgets; - if (cat == "Lighting Template") - return lightingTemplateWidgets; - if (cat == "ImageSpace") - return imageSpaceWidgets; - if (cat == "Volumetric Lighting") - return volumetricLightingWidgets; - if (cat == "Shader Particle Geometry") - return precipitationWidgets; - if (cat == "Lens Flare") - return lensFlareWidgets; - if (cat == "Visual Effect") - return referenceEffectWidgets; - return emptyWidgets; - }; - - // Show recent widgets section for current category - auto recentIt = settings.recentWidgets.find(m_selectedCategory); - if (recentIt != settings.recentWidgets.end() && !recentIt->second.empty()) { - ImGui::Spacing(); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Recent:"); - ImGui::SameLine(); - for (size_t i = 0; i < std::min(size_t(5), recentIt->second.size()); ++i) { - if (i > 0) - ImGui::SameLine(); - if (ImGui::SmallButton(recentIt->second[i].c_str())) { - // Find and open widget in current category's collection - const auto& widgets = getWidgetsForCategory(m_selectedCategory); - for (auto& widget : widgets) { - if (widget->GetEditorID() == recentIt->second[i]) { - widget->SetOpen(true); - break; - } - } - } + // Add button to open the current weather + ImGui::SameLine(); + if (ImGui::SmallButton("Open##CurrentWeather")) { + for (auto& widget : weatherWidgets) { + if (widget->form == currentWeather) { + widget->SetOpen(true); + break; } } + } + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + } - // Scrollable area for the object table - BeginScrollableContent("##ObjectsScrollable"); - - // Stable user IDs for sortable columns — used instead of ColumnIndex so reordering/insertion won't break sorting. - enum ColumnID : ImGuiID - { - ColFav = 0, - ColEditorID, - ColFormID, - ColFile, - ColStatus, - ColJson - }; - - // Create a table for the right column with "Name" and "ID" headers. Different weights to prevent truncation. - if (ImGui::BeginTable("DetailsTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_Sortable)) { - ImGui::TableSetupColumn("Fav", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoSort, 38.0f, ColFav); // Favorite indicator - ImGui::TableSetupColumn("Editor ID", ImGuiTableColumnFlags_WidthStretch, 3.5f, ColEditorID); // Largest - weather/template names - ImGui::TableSetupColumn("Form ID", ImGuiTableColumnFlags_WidthFixed, 90.0f, ColFormID); // Fixed - 8 hex chars - ImGui::TableSetupColumn("File", ImGuiTableColumnFlags_WidthStretch, 2.0f, ColFile); // Medium - plugin names - ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthStretch, 1.5f, ColStatus); // Smaller - status text - ImGui::TableSetupColumn("json", ImGuiTableColumnFlags_WidthFixed, 55.0f, ColJson); // JSON file / delete - - ImGui::TableHeadersRow(); - - // Handle column sorting - if (ImGuiTableSortSpecs* sortSpecs = ImGui::TableGetSortSpecs()) { - if (sortSpecs->SpecsDirty) { - if (sortSpecs->SpecsCount > 0) { - const ImGuiTableColumnSortSpecs& spec = sortSpecs->Specs[0]; - switch (spec.ColumnUserID) { - case ColEditorID: - currentSortColumn = SortColumn::EditorID; - break; - case ColFormID: - currentSortColumn = SortColumn::FormID; - break; - case ColFile: - currentSortColumn = SortColumn::File; - break; - case ColStatus: - currentSortColumn = SortColumn::Status; - break; - case ColJson: - currentSortColumn = SortColumn::JsonAttachment; - break; - default: - currentSortColumn = SortColumn::None; - break; - } - sortAscending = (spec.SortDirection == ImGuiSortDirection_Ascending); - } else { - currentSortColumn = SortColumn::None; - } - sortSpecs->SpecsDirty = false; - } - } - - // Display objects based on the selected category - const auto& widgets = getWidgetsForCategory(m_selectedCategory); - // Sort widgets based on current sort column - std::vector sortedWidgets; - sortedWidgets.reserve(widgets.size()); - for (const auto& w : widgets) { - sortedWidgets.push_back(w.get()); - } - RefreshJsonAttachmentCache(sortedWidgets); - bool weatherTooltipShownThisFrame = false; - if (currentSortColumn != SortColumn::None) { - std::sort(sortedWidgets.begin(), sortedWidgets.end(), [this](Widget* a, Widget* b) { - int comparison = 0; - switch (currentSortColumn) { - case SortColumn::EditorID: - comparison = _stricmp(a->GetEditorID().c_str(), b->GetEditorID().c_str()); - break; - case SortColumn::FormID: - comparison = _stricmp(a->GetFormID().c_str(), b->GetFormID().c_str()); - break; - case SortColumn::File: - comparison = _stricmp(a->GetFilename().c_str(), b->GetFilename().c_str()); - break; - case SortColumn::Status: - { - auto markerA = settings.markedRecords.find(a->GetEditorID()); - auto markerB = settings.markedRecords.find(b->GetEditorID()); - std::string statusA = (markerA != settings.markedRecords.end()) ? markerA->second : ""; - std::string statusB = (markerB != settings.markedRecords.end()) ? markerB->second : ""; - comparison = _stricmp(statusA.c_str(), statusB.c_str()); - break; - } - case SortColumn::JsonAttachment: - { - bool aHasJson = HasCachedJsonAttachment(a); - bool bHasJson = HasCachedJsonAttachment(b); - comparison = static_cast(aHasJson) - static_cast(bHasJson); - break; - } - default: - break; - } - return sortAscending ? (comparison < 0) : (comparison > 0); - }); - } - - // Helper lambda: renders the JSON delete button column for a widget - auto drawJsonDeleteButton = [&](Widget* widget) { - ImGui::TableNextColumn(); - if (HasCachedJsonAttachment(widget)) { - auto* menu = globals::menu; - if (menu && menu->uiIcons.deleteSettings.texture) { - const float iconSize = ImGui::GetFrameHeight() * 0.85f; - auto _style = Util::ErrorButtonStyle(); - ImGui::SetNextItemAllowOverlap(); - char idBuf[32]; - snprintf(idBuf, sizeof(idBuf), "##jsondel_%s", widget->GetFormID().c_str()); - if (ImGui::ImageButton(idBuf, menu->uiIcons.deleteSettings.texture, { iconSize, iconSize })) { - pendingDeleteWidget = widget; - pendingDeletePopupRequested = true; - } - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Delete JSON file"); - } - } - }; - - // Special handling for Cell Lighting category - if (m_selectedCategory == "Cell Lighting") { - auto player = RE::PlayerCharacter::GetSingleton(); - if (player && player->parentCell) { - auto cell = player->parentCell; - bool isInterior = cell->IsInteriorCell(); - - if (isInterior) { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - - // No favorite star for cell lighting (it's always the current cell) - ImGui::Dummy(ImVec2(ImGui::GetFrameHeight(), ImGui::GetFrameHeight())); - ImGui::TableNextColumn(); - - // Display current cell name - const char* cellName = cell->GetName(); - std::string displayName = cellName && cellName[0] ? cellName : "[Unnamed Cell]"; - std::string label = std::format("[CURRENT CELL] {}", displayName); - - // Highlight current cell (before TableRowSelectable so hover/active can override) - auto highlightColor = Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor; - highlightColor.w = 0.3f; - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::ColorConvertFloat4ToU32(highlightColor)); - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::ColorConvertFloat4ToU32(highlightColor)); - - bool isOpen = currentCellLightingWidget && currentCellLightingWidget->IsOpen(); - if (Util::TableRowSelectable(label.c_str(), isOpen, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { - if (ImGui::IsMouseDoubleClicked(0)) { - // Open or reuse the cell lighting widget - if (currentCellLightingWidget && currentCellLightingWidget->cell == cell) { - currentCellLightingWidget->SetOpen(true); - } else { - currentCellLightingWidget = std::make_unique(cell); - currentCellLightingWidget->CacheFormData(); - currentCellLightingWidget->Load(); - currentCellLightingWidget->SetOpen(true); - } - } - } + // Handle Ctrl+F to focus search bar + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) { + if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_F, false)) { + ImGui::SetKeyboardFocusHere(); + } + } + // Compute fixed widths once; reuse for both the search bar and the following combo. + const auto& style = ImGui::GetStyle(); + // comboW = preview text + left/right padding + arrow button + const float comboW = ImGui::CalcTextSize("Editor ID").x + style.FramePadding.x * 2.0f + ImGui::GetFrameHeight(); + const float helpW = ImGui::CalcTextSize("(?)").x; + const float iconW = ImGui::GetFrameHeight(); + // Fixed width is the sum of every item that follows the search bar on the same row. + // Each SameLine() contributes style.ItemSpacing.x; widths are listed explicitly + // so adding or removing a widget only requires updating its own expression. + const float fixedW = + style.ItemSpacing.x + comboW + // combo + style.ItemSpacing.x + helpW + // help marker + style.ItemSpacing.x + 10.0f + // spacer before favorites + style.ItemSpacing.x + iconW + // fav icon + style.ItemSpacing.x + ImGui::CalcTextSize("Favorites").x + // "Favorites" label + style.ItemSpacing.x + 10.0f + // spacer before flagged + style.ItemSpacing.x + iconW + // flag icon + style.ItemSpacing.x + ImGui::CalcTextSize("Flagged").x; // "Flagged" label + ImGui::SetNextItemWidth(std::max(50.0f, ImGui::GetContentRegionAvail().x - fixedW)); + ImGui::InputTextWithHint("##ObjectFilter", "Filter... (Ctrl+F)", m_filterBuffer, sizeof(m_filterBuffer)); - // Enter key to open - if (isOpen && ImGui::IsKeyPressed(ImGuiKey_Enter)) { - if (currentCellLightingWidget && currentCellLightingWidget->cell == cell) { - currentCellLightingWidget->SetOpen(true); - } - } + ImGui::SameLine(); + ImGui::SetNextItemWidth(comboW); + int col = static_cast(m_currentFilterColumn); + if (ImGui::Combo("##FilterBy", &col, kFilterColumnNames, IM_ARRAYSIZE(kFilterColumnNames))) + m_currentFilterColumn = static_cast(col); - // Form ID column - ImGui::TableNextColumn(); - ImGui::Text("0x%08X", cell->GetFormID()); + ImGui::SameLine(); + Util::HelpMarker("Filter the object list by the selected column.\nAll: searches Editor ID, Form ID, File, and Status.\nStatus: hides items with no status marker when the search box is non-empty.\nCtrl+F: Focus search\nEnter: Open selected"); - // File column - ImGui::TableNextColumn(); - auto file = cell->GetFile(0); - if (file) { - ImGui::Text("%s", file->fileName); - } + // Quick filter buttons on same row + ImGui::SameLine(); + ImGui::Dummy(ImVec2(10.0f, 0.0f)); // Spacer + ImGui::SameLine(); + if (IconButton("##filterFavorites", m_showOnlyFavorites, "star")) { + m_showOnlyFavorites = !m_showOnlyFavorites; + } + ImGui::SameLine(); + ImGui::Text("Favorites"); - // Status column - ImGui::TableNextColumn(); - ImGui::Text("Interior Cell"); - - // json column (empty for cells - no standalone json) - ImGui::TableNextColumn(); - } else { - // Show message that cell lighting is only for interior cells - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(1); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.Warning, "Cell Lighting is only available for interior cells."); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.Disable, "You are currently in an exterior cell."); - } - } else { - // No player or cell - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(1); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.Error, "Player cell not available."); + ImGui::SameLine(); + ImGui::Dummy(ImVec2(10.0f, 0.0f)); // Spacer + ImGui::SameLine(); + if (IconButton("##filterFlagged", m_showOnlyFlagged, "circle")) { + m_showOnlyFlagged = !m_showOnlyFlagged; + } + ImGui::SameLine(); + ImGui::Text("Flagged"); + + // Returns the widget collection for a given category; Cell Lighting and unknown + // categories return an empty collection since they have no standalone widget list. + auto getWidgetsForCategory = [&](const std::string& cat) -> const std::vector>& { + static const std::vector> emptyWidgets; + if (cat == "Weather") + return weatherWidgets; + if (cat == "Lighting Template") + return lightingTemplateWidgets; + if (cat == "ImageSpace") + return imageSpaceWidgets; + if (cat == "Volumetric Lighting") + return volumetricLightingWidgets; + if (cat == "Shader Particle Geometry") + return precipitationWidgets; + if (cat == "Lens Flare") + return lensFlareWidgets; + if (cat == "Visual Effect") + return referenceEffectWidgets; + return emptyWidgets; + }; + + // Show recent widgets section for current category + auto recentIt = settings.recentWidgets.find(m_selectedCategory); + if (recentIt != settings.recentWidgets.end() && !recentIt->second.empty()) { + ImGui::Spacing(); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Recent:"); + ImGui::SameLine(); + for (size_t i = 0; i < std::min(size_t(5), recentIt->second.size()); ++i) { + if (i > 0) + ImGui::SameLine(); + if (ImGui::SmallButton(recentIt->second[i].c_str())) { + // Find and open widget in current category's collection + const auto& widgets = getWidgetsForCategory(m_selectedCategory); + for (auto& widget : widgets) { + if (widget->GetEditorID() == recentIt->second[i]) { + widget->SetOpen(true); + break; } } + } + } + } - // Get current cell's lighting template for prioritization - RE::BGSLightingTemplate* currentCellLightingTemplate = nullptr; - if (m_selectedCategory == "Lighting Template") { - auto player = RE::PlayerCharacter::GetSingleton(); - if (player && player->parentCell) { - auto& cellData = player->parentCell->GetRuntimeData(); - currentCellLightingTemplate = cellData.lightingTemplate; + // Scrollable area for the object table + BeginScrollableContent("##ObjectsScrollable"); + + // Stable user IDs for sortable columns — used instead of ColumnIndex so reordering/insertion won't break sorting. + enum ColumnID : ImGuiID + { + ColFav = 0, + ColEditorID, + ColFormID, + ColFile, + ColStatus, + ColJson + }; + + // Create a table for the right column with "Name" and "ID" headers. Different weights to prevent truncation. + if (ImGui::BeginTable("DetailsTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_Sortable)) { + ImGui::TableSetupColumn("Fav", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoSort, 38.0f, ColFav); // Favorite indicator + ImGui::TableSetupColumn("Editor ID", ImGuiTableColumnFlags_WidthStretch, 3.5f, ColEditorID); // Largest - weather/template names + ImGui::TableSetupColumn("Form ID", ImGuiTableColumnFlags_WidthFixed, 90.0f, ColFormID); // Fixed - 8 hex chars + ImGui::TableSetupColumn("File", ImGuiTableColumnFlags_WidthStretch, 2.0f, ColFile); // Medium - plugin names + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthStretch, 1.5f, ColStatus); // Smaller - status text + ImGui::TableSetupColumn("json", ImGuiTableColumnFlags_WidthFixed, 55.0f, ColJson); // JSON file / delete + + ImGui::TableHeadersRow(); + + // Handle column sorting + if (ImGuiTableSortSpecs* sortSpecs = ImGui::TableGetSortSpecs()) { + if (sortSpecs->SpecsDirty) { + if (sortSpecs->SpecsCount > 0) { + const ImGuiTableColumnSortSpecs& spec = sortSpecs->Specs[0]; + switch (spec.ColumnUserID) { + case ColEditorID: + currentSortColumn = SortColumn::EditorID; + break; + case ColFormID: + currentSortColumn = SortColumn::FormID; + break; + case ColFile: + currentSortColumn = SortColumn::File; + break; + case ColStatus: + currentSortColumn = SortColumn::Status; + break; + case ColJson: + currentSortColumn = SortColumn::JsonAttachment; + break; + default: + currentSortColumn = SortColumn::None; + break; } + sortAscending = (spec.SortDirection == ImGuiSortDirection_Ascending); + } else { + currentSortColumn = SortColumn::None; } + sortSpecs->SpecsDirty = false; + } + } - // Centralized filter check used by both display loops below. - auto shouldShowWidget = [&](Widget* w) { - if (!MatchesObjectFilter(w)) - return false; - if (m_showOnlyFavorites && !IsFavorite(w->GetEditorID())) - return false; - if (m_showOnlyFlagged && settings.markedRecords.find(w->GetEditorID()) == settings.markedRecords.end()) - return false; - return true; - }; - - // Filtered display of widgets - show current cell's lighting template first - if (currentCellLightingTemplate && m_selectedCategory == "Lighting Template") { - for (int i = 0; i < sortedWidgets.size(); ++i) { - auto* ltWidget = dynamic_cast(sortedWidgets[i]); - if (!ltWidget || ltWidget->lightingTemplate != currentCellLightingTemplate) - continue; - - if (!shouldShowWidget(sortedWidgets[i])) - continue; - - auto editorLabel = std::format("[CURRENT] {}", sortedWidgets[i]->GetEditorID()); - auto markedRecord = settings.markedRecords.find(sortedWidgets[i]->GetEditorID()); - ImGui::TableNextRow(); - - // Highlight current cell's lighting template - auto highlightColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.InfoColor; - highlightColor.w = 0.3f; - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::ColorConvertFloat4ToU32(highlightColor)); - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::ColorConvertFloat4ToU32(highlightColor)); - - ImGui::TableSetColumnIndex(0); - - // Favorite star - if (IconButton("##fav_current", IsFavorite(sortedWidgets[i]->GetEditorID()), "star")) { - ToggleFavorite(sortedWidgets[i]->GetEditorID()); - } - - ImGui::TableNextColumn(); - - // Editor ID column with [CURRENT] prefix - bool isSelected = sortedWidgets[i]->IsOpen(); - if (Util::TableRowSelectable(editorLabel.c_str(), isSelected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_AllowOverlap)) { - if (ImGui::IsMouseDoubleClicked(0)) { - sortedWidgets[i]->SetOpen(true); - AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); - } - } - - // Enter key to open - if (isSelected && ImGui::IsKeyPressed(ImGuiKey_Enter)) { - sortedWidgets[i]->SetOpen(true); - AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); - } - - // Context menu - if (ImGui::BeginPopupContextItem(std::format("widget_context_menu##{}", sortedWidgets[i]->GetFormID()).c_str(), ImGuiPopupFlags_MouseButtonRight)) { - auto& markedRecords = settings.markedRecords; - - for (auto& recordMarker : settings.recordMarkers) { - if (ImGui::MenuItem(recordMarker.first.c_str())) { - settings.markedRecords[sortedWidgets[i]->GetEditorID()] = recordMarker.first; - Save(); - } - } - - if (ImGui::MenuItem("Remove")) { - markedRecords.erase(sortedWidgets[i]->GetEditorID()); - Save(); - } - - ImGui::EndPopup(); - } - - // Form ID column - ImGui::TableNextColumn(); - ImGui::Text(sortedWidgets[i]->GetFormID().c_str()); - - // File column - ImGui::TableNextColumn(); - ImGui::Text(sortedWidgets[i]->GetFilename().c_str()); - - // Status column - ImGui::TableNextColumn(); - if (markedRecord != settings.markedRecords.end()) { - ImGui::Text("%s", markedRecord->second.c_str()); - } - - // json / delete column - drawJsonDeleteButton(sortedWidgets[i]); + // Display objects based on the selected category + const auto& widgets = getWidgetsForCategory(m_selectedCategory); + // Sort widgets based on current sort column + std::vector sortedWidgets; + sortedWidgets.reserve(widgets.size()); + for (const auto& w : widgets) { + sortedWidgets.push_back(w.get()); + } + RefreshJsonAttachmentCache(sortedWidgets); + bool weatherTooltipShownThisFrame = false; + if (currentSortColumn != SortColumn::None) { + std::sort(sortedWidgets.begin(), sortedWidgets.end(), [this](Widget* a, Widget* b) { + int comparison = 0; + switch (currentSortColumn) { + case SortColumn::EditorID: + comparison = _stricmp(a->GetEditorID().c_str(), b->GetEditorID().c_str()); + break; + case SortColumn::FormID: + comparison = _stricmp(a->GetFormID().c_str(), b->GetFormID().c_str()); + break; + case SortColumn::File: + comparison = _stricmp(a->GetFilename().c_str(), b->GetFilename().c_str()); + break; + case SortColumn::Status: + { + auto markerA = settings.markedRecords.find(a->GetEditorID()); + auto markerB = settings.markedRecords.find(b->GetEditorID()); + std::string statusA = (markerA != settings.markedRecords.end()) ? markerA->second : ""; + std::string statusB = (markerB != settings.markedRecords.end()) ? markerB->second : ""; + comparison = _stricmp(statusA.c_str(), statusB.c_str()); + break; } + case SortColumn::JsonAttachment: + { + bool aHasJson = HasCachedJsonAttachment(a); + bool bHasJson = HasCachedJsonAttachment(b); + comparison = static_cast(aHasJson) - static_cast(bHasJson); + break; + } + default: + break; } + return sortAscending ? (comparison < 0) : (comparison > 0); + }); + } - // Filtered display of widgets - regular list - for (int i = 0; i < sortedWidgets.size(); ++i) { - // Skip current cell's lighting template if already shown - if (currentCellLightingTemplate && m_selectedCategory == "Lighting Template") { - auto* ltWidget = dynamic_cast(sortedWidgets[i]); - if (ltWidget && ltWidget->lightingTemplate == currentCellLightingTemplate) - continue; + // Helper lambda: renders the JSON delete button column for a widget + auto drawJsonDeleteButton = [&](Widget* widget) { + ImGui::TableNextColumn(); + if (HasCachedJsonAttachment(widget)) { + auto* menu = globals::menu; + if (menu && menu->uiIcons.deleteSettings.texture) { + const float iconSize = ImGui::GetFrameHeight() * 0.85f; + auto _style = Util::ErrorButtonStyle(); + ImGui::SetNextItemAllowOverlap(); + char idBuf[32]; + snprintf(idBuf, sizeof(idBuf), "##jsondel_%s", widget->GetFormID().c_str()); + if (ImGui::ImageButton(idBuf, menu->uiIcons.deleteSettings.texture, { iconSize, iconSize })) { + pendingDeleteWidget = widget; + pendingDeletePopupRequested = true; } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Delete JSON file"); + } + } + }; - if (!shouldShowWidget(sortedWidgets[i])) - continue; + // Special handling for Cell Lighting category + if (m_selectedCategory == "Cell Lighting") { + auto player = RE::PlayerCharacter::GetSingleton(); + if (player && player->parentCell) { + auto cell = player->parentCell; + bool isInterior = cell->IsInteriorCell(); - auto editorLabel = sortedWidgets[i]->GetEditorID(); - auto markedRecord = settings.markedRecords.find(editorLabel); + if (isInterior) { ImGui::TableNextRow(); - - // Set background colour - if (markedRecord != settings.markedRecords.end()) { - auto& color = settings.recordMarkers[markedRecord->second]; - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::ColorConvertFloat4ToU32(color)); - ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::ColorConvertFloat4ToU32(color)); - } - ImGui::TableSetColumnIndex(0); - // Favorite star - if (IconButton(std::format("##fav_{}", i).c_str(), IsFavorite(sortedWidgets[i]->GetEditorID()), "star")) { - ToggleFavorite(sortedWidgets[i]->GetEditorID()); - } - + // No favorite star for cell lighting (it's always the current cell) + ImGui::Dummy(ImVec2(ImGui::GetFrameHeight(), ImGui::GetFrameHeight())); ImGui::TableNextColumn(); - // Editor ID column - bool isSelected = sortedWidgets[i]->IsOpen(); - if (Util::TableRowSelectable(editorLabel.c_str(), isSelected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_AllowOverlap)) { - if (ImGui::IsMouseDoubleClicked(0)) { - sortedWidgets[i]->SetOpen(true); - AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); - } - } + // Display current cell name + const char* cellName = cell->GetName(); + std::string displayName = cellName && cellName[0] ? cellName : "[Unnamed Cell]"; + std::string label = std::format("[CURRENT CELL] {}", displayName); + + // Highlight current cell (before TableRowSelectable so hover/active can override) + auto highlightColor = Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor; + highlightColor.w = 0.3f; + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::ColorConvertFloat4ToU32(highlightColor)); + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::ColorConvertFloat4ToU32(highlightColor)); - // Show ImageSpace and VolumetricLighting info for weather widgets - if (!weatherTooltipShownThisFrame && m_selectedCategory == "Weather" && ImGui::IsItemHovered()) { - auto* weatherWidget = dynamic_cast(sortedWidgets[i]); - if (weatherWidget && weatherWidget->weather) { - const float lineHeight = ImGui::GetTextLineHeightWithSpacing(); - const ImVec2 pad = ImGui::GetStyle().WindowPadding; - const float spacingHeight = ImGui::GetStyle().ItemSpacing.y; - constexpr int kSectionHeaders = 2; // "ImageSpace:" + "Volumetric Lighting:" - constexpr int kTodValuesPerSection = 4; - constexpr int kSpacingSeparators = 1; // Spacing between sections - const float estimatedTooltipHeight = (kSectionHeaders + kTodValuesPerSection * 2) * lineHeight + kSpacingSeparators * spacingHeight + pad.y * 2.0f; - Util::SetTooltipPositionNearMouse(estimatedTooltipHeight); - if (ImGui::BeginTooltip()) { - // ImageSpace info - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "ImageSpace:"); - for (int tod = 0; tod < 4; tod++) { - auto imgSpace = weatherWidget->weather->imageSpaces[tod]; - ImGui::Text(" %s: %s", - TOD::GetPeriodName(tod), - imgSpace ? imgSpace->GetFormEditorID() : "None"); - } - - ImGui::Spacing(); - - // VolumetricLighting info - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Volumetric Lighting:"); - for (int tod = 0; tod < 4; tod++) { - auto volLight = weatherWidget->weather->volumetricLighting[tod]; - ImGui::Text(" %s: %s", - TOD::GetPeriodName(tod), - volLight ? volLight->GetFormEditorID() : "None"); - } - - ImGui::EndTooltip(); + bool isOpen = currentCellLightingWidget && currentCellLightingWidget->IsOpen(); + if (Util::TableRowSelectable(label.c_str(), isOpen, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { + if (ImGui::IsMouseDoubleClicked(0)) { + // Open or reuse the cell lighting widget + if (currentCellLightingWidget && currentCellLightingWidget->cell == cell) { + currentCellLightingWidget->SetOpen(true); + } else { + currentCellLightingWidget = std::make_unique(cell); + currentCellLightingWidget->CacheFormData(); + currentCellLightingWidget->Load(); + currentCellLightingWidget->SetOpen(true); } - weatherTooltipShownThisFrame = true; } } // Enter key to open - if (isSelected && ImGui::IsKeyPressed(ImGuiKey_Enter)) { - sortedWidgets[i]->SetOpen(true); - AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); - } - - // Opens a context menu on right click to mark records by color - if (ImGui::BeginPopupContextItem(std::format("widget_context_menu##{}", sortedWidgets[i]->GetFormID()).c_str(), ImGuiPopupFlags_MouseButtonRight)) { - auto& markedRecords = settings.markedRecords; - - for (auto& recordMarker : settings.recordMarkers) { - if (ImGui::MenuItem(recordMarker.first.c_str())) { - settings.markedRecords[editorLabel] = recordMarker.first; - Save(); - } - } - - if (ImGui::MenuItem("Remove")) { - markedRecords.erase(editorLabel); - Save(); + if (isOpen && ImGui::IsKeyPressed(ImGuiKey_Enter)) { + if (currentCellLightingWidget && currentCellLightingWidget->cell == cell) { + currentCellLightingWidget->SetOpen(true); } - - ImGui::EndPopup(); } // Form ID column ImGui::TableNextColumn(); - ImGui::Text(sortedWidgets[i]->GetFormID().c_str()); + ImGui::Text("0x%08X", cell->GetFormID()); // File column ImGui::TableNextColumn(); - ImGui::Text(sortedWidgets[i]->GetFilename().c_str()); + auto file = cell->GetFile(0); + if (file) { + ImGui::Text("%s", file->fileName); + } // Status column ImGui::TableNextColumn(); + ImGui::Text("Interior Cell"); - // Re-check if the record exists after potential removal - markedRecord = settings.markedRecords.find(editorLabel); - if (markedRecord != settings.markedRecords.end()) { - ImGui::Text("%s", markedRecord->second.c_str()); - } - - // json / delete column - drawJsonDeleteButton(sortedWidgets[i]); + // json column (empty for cells - no standalone json) + ImGui::TableNextColumn(); + } else { + // Show message that cell lighting is only for interior cells + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.Warning, "Cell Lighting is only available for interior cells."); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.Disable, "You are currently in an exterior cell."); } + } else { + // No player or cell + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.Error, "Player cell not available."); + } + } - ImGui::EndTable(); // End DetailsTable - } // End if BeginTable("DetailsTable") + // Get current cell's lighting template for prioritization + RE::BGSLightingTemplate* currentCellLightingTemplate = nullptr; + if (m_selectedCategory == "Lighting Template") { + auto player = RE::PlayerCharacter::GetSingleton(); + if (player && player->parentCell) { + auto& cellData = player->parentCell->GetRuntimeData(); + currentCellLightingTemplate = cellData.lightingTemplate; + } + } - EndScrollableContent(); // End ObjectsScrollable + // Centralized filter check used by both display loops below. + auto shouldShowWidget = [&](Widget* w) { + if (!MatchesObjectFilter(w)) + return false; + if (m_showOnlyFavorites && !IsFavorite(w->GetEditorID())) + return false; + if (m_showOnlyFlagged && settings.markedRecords.find(w->GetEditorID()) == settings.markedRecords.end()) + return false; + return true; + }; + + // Filtered display of widgets - show current cell's lighting template first + if (currentCellLightingTemplate && m_selectedCategory == "Lighting Template") { + for (int i = 0; i < sortedWidgets.size(); ++i) { + auto* ltWidget = dynamic_cast(sortedWidgets[i]); + if (!ltWidget || ltWidget->lightingTemplate != currentCellLightingTemplate) + continue; + + if (!shouldShowWidget(sortedWidgets[i])) + continue; + + auto editorLabel = std::format("[CURRENT] {}", sortedWidgets[i]->GetEditorID()); + auto markedRecord = settings.markedRecords.find(sortedWidgets[i]->GetEditorID()); + ImGui::TableNextRow(); - } // End if BeginChild("##ObjectsContent") - ImGui::EndChild(); // End ObjectsContent child + // Highlight current cell's lighting template + auto highlightColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.InfoColor; + highlightColor.w = 0.3f; + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::ColorConvertFloat4ToU32(highlightColor)); + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::ColorConvertFloat4ToU32(highlightColor)); - ImGui::EndTable(); // End ObjectTable - } // End if BeginTable("ObjectTable") + ImGui::TableSetColumnIndex(0); - // Confirmation modal for json deletion - must be outside BeginChild so the modal can block the root window - if (pendingDeleteWidget) { - if (pendingDeletePopupRequested) { - ImGui::OpenPopup("ListDeleteConfirmation"); - pendingDeletePopupRequested = false; - } - pendingDeleteWidget->DrawDeleteConfirmationModal("ListDeleteConfirmation"); - if (!ImGui::IsPopupOpen("ListDeleteConfirmation")) { - pendingDeleteWidget = nullptr; - } - } + // Favorite star + if (IconButton("##fav_current", IsFavorite(sortedWidgets[i]->GetEditorID()), "star")) { + ToggleFavorite(sortedWidgets[i]->GetEditorID()); + } - // End the window - ImGui::End(); - } + ImGui::TableNextColumn(); - void EditorWindow::ShowViewportWindow() - { - ImGui::Begin("Viewport"); + // Editor ID column with [CURRENT] prefix + bool isSelected = sortedWidgets[i]->IsOpen(); + if (Util::TableRowSelectable(editorLabel.c_str(), isSelected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_AllowOverlap)) { + if (ImGui::IsMouseDoubleClicked(0)) { + sortedWidgets[i]->SetOpen(true); + AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); + } + } - // Top bar - if (DrawGameHourSlider("##ViewportSlider", "Time: %.2f")) { - ImGui::SameLine(); - int activePeriod = TOD::GetActivePeriod(); - ImGui::Text("(%s)", TOD::GetPeriodName(activePeriod)); - } + // Enter key to open + if (isSelected && ImGui::IsKeyPressed(ImGuiKey_Enter)) { + sortedWidgets[i]->SetOpen(true); + AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); + } - // The size of the image in ImGui // Get the available space in the current window - ImVec2 availableSpace = ImGui::GetContentRegionAvail(); + // Context menu + if (ImGui::BeginPopupContextItem(std::format("widget_context_menu##{}", sortedWidgets[i]->GetFormID()).c_str(), ImGuiPopupFlags_MouseButtonRight)) { + auto& markedRecords = settings.markedRecords; - // Calculate aspect ratio of the image - float aspectRatio = ImGui::GetIO().DisplaySize.x / ImGui::GetIO().DisplaySize.y; + for (auto& recordMarker : settings.recordMarkers) { + if (ImGui::MenuItem(recordMarker.first.c_str())) { + settings.markedRecords[sortedWidgets[i]->GetEditorID()] = recordMarker.first; + Save(); + } + } - // Determine the size to fit while preserving the aspect ratio - ImVec2 imageSize; - if (availableSpace.x / availableSpace.y < aspectRatio) { - // Fit width - imageSize.x = availableSpace.x; - imageSize.y = availableSpace.x / aspectRatio; - } else { - // Fit height - imageSize.y = availableSpace.y; - imageSize.x = availableSpace.y * aspectRatio; - } + if (ImGui::MenuItem("Remove")) { + markedRecords.erase(sortedWidgets[i]->GetEditorID()); + Save(); + } - ImGui::Image((void*)tempTexture->srv.get(), imageSize); + ImGui::EndPopup(); + } - ImGui::End(); - } + // Form ID column + ImGui::TableNextColumn(); + ImGui::Text(sortedWidgets[i]->GetFormID().c_str()); - void EditorWindow::ShowWidgetWindow() - { - // Global shortcut for closing focused widget (Ctrl+W) - if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_W, false)) { - if (lastFocusedWidget && lastFocusedWidget->IsOpen()) { - lastFocusedWidget->SetOpen(false); - lastFocusedWidget = nullptr; - } - } + // File column + ImGui::TableNextColumn(); + ImGui::Text(sortedWidgets[i]->GetFilename().c_str()); - // Draw all open widgets using WidgetFactory template - WidgetFactory::DrawOpenWidgets(weatherWidgets, lastFocusedWidget); - WidgetFactory::DrawOpenWidgets(lightingTemplateWidgets, lastFocusedWidget); - WidgetFactory::DrawOpenWidgets(imageSpaceWidgets, lastFocusedWidget); - WidgetFactory::DrawOpenWidgets(volumetricLightingWidgets, lastFocusedWidget); - WidgetFactory::DrawOpenWidgets(precipitationWidgets, lastFocusedWidget); - WidgetFactory::DrawOpenWidgets(lensFlareWidgets, lastFocusedWidget); - WidgetFactory::DrawOpenWidgets(referenceEffectWidgets, lastFocusedWidget); - - // Draw current cell lighting widget if open - if (currentCellLightingWidget && currentCellLightingWidget->IsOpen()) { - currentCellLightingWidget->DrawWidget(); - if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) - lastFocusedWidget = currentCellLightingWidget.get(); - } - } + // Status column + ImGui::TableNextColumn(); + if (markedRecord != settings.markedRecords.end()) { + ImGui::Text("%s", markedRecord->second.c_str()); + } - void EditorWindow::RenderUI() - { - auto renderer = RE::BSGraphics::Renderer::GetSingleton(); - auto& framebuffer = renderer->GetRuntimeData().renderTargets[RE::RENDER_TARGETS::kFRAMEBUFFER]; - auto& context = globals::d3d::context; + // json / delete column + drawJsonDeleteButton(sortedWidgets[i]); + } + } - context->ClearRenderTargetView(framebuffer.RTV, (float*)&ImGui::GetStyle().Colors[ImGuiCol_WindowBg]); + // Filtered display of widgets - regular list + for (int i = 0; i < sortedWidgets.size(); ++i) { + // Skip current cell's lighting template if already shown + if (currentCellLightingTemplate && m_selectedCategory == "Lighting Template") { + auto* ltWidget = dynamic_cast(sortedWidgets[i]); + if (ltWidget && ltWidget->lightingTemplate == currentCellLightingTemplate) + continue; + } - // Apply editor UI scale - ImGuiIO& io = ImGui::GetIO(); - float previousScale = io.FontGlobalScale; - io.FontGlobalScale = settings.editorUIScale; + if (!shouldShowWidget(sortedWidgets[i])) + continue; - // Increase background opacity for all editor windows - ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 1.0f); + auto editorLabel = sortedWidgets[i]->GetEditorID(); + auto markedRecord = settings.markedRecords.find(editorLabel); + ImGui::TableNextRow(); - // Check for Ctrl+Z to undo - if ((ImGui::IsKeyDown(ImGuiKey_LeftCtrl) || ImGui::IsKeyDown(ImGuiKey_RightCtrl)) && ImGui::IsKeyPressed(ImGuiKey_Z, false)) { - if (CanUndo()) { - PerformUndo(); - } - } + // Set background colour + if (markedRecord != settings.markedRecords.end()) { + auto& color = settings.recordMarkers[markedRecord->second]; + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::ColorConvertFloat4ToU32(color)); + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::ColorConvertFloat4ToU32(color)); + } - if (ImGui::BeginMainMenuBar()) { - if (ImGui::BeginMenu("File")) { - if (ImGui::MenuItem("Save All Open Widgets", "Ctrl+S")) { - SaveAll(); + ImGui::TableSetColumnIndex(0); + + // Favorite star + if (IconButton(std::format("##fav_{}", i).c_str(), IsFavorite(sortedWidgets[i]->GetEditorID()), "star")) { + ToggleFavorite(sortedWidgets[i]->GetEditorID()); } - // Save individual widgets submenu - if (ImGui::BeginMenu("Save")) { - bool hasOpenWidgets = false; + ImGui::TableNextColumn(); - // Weather widgets - for (auto& widget : weatherWidgets) { - if (widget->IsOpen()) { - hasOpenWidgets = true; - if (ImGui::MenuItem(std::format("Save {}", widget->GetEditorID()).c_str())) { - widget->Save(); - } - } + // Editor ID column + bool isSelected = sortedWidgets[i]->IsOpen(); + if (Util::TableRowSelectable(editorLabel.c_str(), isSelected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_AllowOverlap)) { + if (ImGui::IsMouseDoubleClicked(0)) { + sortedWidgets[i]->SetOpen(true); + AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); } + } - // Lighting Template widgets - for (auto& widget : lightingTemplateWidgets) { - if (widget->IsOpen()) { - hasOpenWidgets = true; - if (ImGui::MenuItem(std::format("Save {}", widget->GetEditorID()).c_str())) { - widget->Save(); + // Show ImageSpace and VolumetricLighting info for weather widgets + if (!weatherTooltipShownThisFrame && m_selectedCategory == "Weather" && ImGui::IsItemHovered()) { + auto* weatherWidget = dynamic_cast(sortedWidgets[i]); + if (weatherWidget && weatherWidget->weather) { + const float lineHeight = ImGui::GetTextLineHeightWithSpacing(); + const ImVec2 pad = ImGui::GetStyle().WindowPadding; + const float spacingHeight = ImGui::GetStyle().ItemSpacing.y; + constexpr int kSectionHeaders = 2; // "ImageSpace:" + "Volumetric Lighting:" + constexpr int kTodValuesPerSection = 4; + constexpr int kSpacingSeparators = 1; // Spacing between sections + const float estimatedTooltipHeight = (kSectionHeaders + kTodValuesPerSection * 2) * lineHeight + kSpacingSeparators * spacingHeight + pad.y * 2.0f; + Util::SetTooltipPositionNearMouse(estimatedTooltipHeight); + if (ImGui::BeginTooltip()) { + // ImageSpace info + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "ImageSpace:"); + for (int tod = 0; tod < 4; tod++) { + auto imgSpace = weatherWidget->weather->imageSpaces[tod]; + ImGui::Text(" %s: %s", + TOD::GetPeriodName(tod), + imgSpace ? imgSpace->GetFormEditorID() : "None"); } - } - } - // ImageSpace widgets - for (auto& widget : imageSpaceWidgets) { - if (widget->IsOpen()) { - hasOpenWidgets = true; - if (ImGui::MenuItem(std::format("Save {}", widget->GetEditorID()).c_str())) { - widget->Save(); + ImGui::Spacing(); + + // VolumetricLighting info + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Volumetric Lighting:"); + for (int tod = 0; tod < 4; tod++) { + auto volLight = weatherWidget->weather->volumetricLighting[tod]; + ImGui::Text(" %s: %s", + TOD::GetPeriodName(tod), + volLight ? volLight->GetFormEditorID() : "None"); } - } - } - if (!hasOpenWidgets) { - ImGui::TextDisabled("No open widgets"); + ImGui::EndTooltip(); + } + weatherTooltipShownThisFrame = true; } - - ImGui::EndMenu(); } - ImGui::Separator(); - if (ImGui::MenuItem("Close All Weather Widgets")) { - for (auto& widget : weatherWidgets) widget->SetOpen(false); - } - if (ImGui::MenuItem("Close All Lighting Widgets")) { - for (auto& widget : lightingTemplateWidgets) widget->SetOpen(false); - } - if (ImGui::MenuItem("Close All ImageSpace Widgets")) { - for (auto& widget : imageSpaceWidgets) widget->SetOpen(false); - } - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Settings")) { - if (ImGui::MenuItem("General Settings")) { - showSettingsWindow = true; - settingsSelectedCategory = "General"; - } - if (ImGui::MenuItem("Editor Flags")) { - showSettingsWindow = true; - settingsSelectedCategory = "Flags"; + // Enter key to open + if (isSelected && ImGui::IsKeyPressed(ImGuiKey_Enter)) { + sortedWidgets[i]->SetOpen(true); + AddToRecent(sortedWidgets[i]->GetEditorID(), m_selectedCategory); } - ImGui::Separator(); - // Current cell lighting - auto player = RE::PlayerCharacter::GetSingleton(); - if (player && player->parentCell && player->parentCell->IsInteriorCell()) { - if (ImGui::MenuItem("Edit Current Cell Lighting")) { - // Check if widget already exists - bool found = false; - if (currentCellLightingWidget && currentCellLightingWidget->cell == player->parentCell) { - currentCellLightingWidget->SetOpen(true); - found = true; - } + // Opens a context menu on right click to mark records by color + if (ImGui::BeginPopupContextItem(std::format("widget_context_menu##{}", sortedWidgets[i]->GetFormID()).c_str(), ImGuiPopupFlags_MouseButtonRight)) { + auto& markedRecords = settings.markedRecords; - if (!found) { - // Create new widget for current cell - currentCellLightingWidget = std::make_unique(player->parentCell); - currentCellLightingWidget->CacheFormData(); - currentCellLightingWidget->Load(); - currentCellLightingWidget->SetOpen(true); + for (auto& recordMarker : settings.recordMarkers) { + if (ImGui::MenuItem(recordMarker.first.c_str())) { + settings.markedRecords[editorLabel] = recordMarker.first; + Save(); } } - } else { - ImGui::BeginDisabled(); - ImGui::MenuItem("Edit Current Cell Lighting"); - ImGui::EndDisabled(); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { - ImGui::SetTooltip("Only available in interior cells"); - } - } - ImGui::Separator(); + if (ImGui::MenuItem("Remove")) { + markedRecords.erase(editorLabel); + Save(); + } - if (ImGui::Checkbox("Auto-Apply Changes", &settings.autoApplyChanges)) { - Save(); - } - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Automatically apply weather changes to the game as you edit"); - } - if (ImGui::Checkbox("Remember Open Widgets", &settings.rememberOpenWidgets)) { - Save(); - } - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Restore previously open widgets when editor reopens"); - } - if (ImGui::Checkbox("Enable Inherit From Parent", &settings.enableInheritFromParent)) { - Save(); - } - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Show inherit from parent options in weather widgets"); - } - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Window")) { - if (ImGui::MenuItem("Palette", nullptr, PaletteWindow::GetSingleton()->open)) { - PaletteWindow::GetSingleton()->open = !PaletteWindow::GetSingleton()->open; + ImGui::EndPopup(); } - ImGui::Separator(); - ImGui::Text("Open Widgets:"); - ImGui::Separator(); + // Form ID column + ImGui::TableNextColumn(); + ImGui::Text(sortedWidgets[i]->GetFormID().c_str()); - int openCount = 0; - for (auto& widget : weatherWidgets) { - if (widget->IsOpen()) { - openCount++; - if (ImGui::MenuItem(std::format("Weather: {}", widget->GetEditorID()).c_str())) { - // Focus window (ImGui will bring to front when clicked) - } - } - } - for (auto& widget : lightingTemplateWidgets) { - if (widget->IsOpen()) { - openCount++; - if (ImGui::MenuItem(std::format("Lighting: {}", widget->GetEditorID()).c_str())) { - // Focus window - } - } - } - for (auto& widget : imageSpaceWidgets) { - if (widget->IsOpen()) { - openCount++; - if (ImGui::MenuItem(std::format("ImageSpace: {}", widget->GetEditorID()).c_str())) { - // Focus window - } - } - } + // File column + ImGui::TableNextColumn(); + ImGui::Text(sortedWidgets[i]->GetFilename().c_str()); - if (openCount == 0) { - ImGui::TextDisabled("No widgets open"); - } + // Status column + ImGui::TableNextColumn(); - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Help")) { - ImGui::Text("Weather Editor"); - ImGui::Separator(); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Keyboard Shortcuts:"); - ImGui::BulletText("Ctrl+F: Focus search"); - ImGui::BulletText("Ctrl+S: Save all open widgets"); - ImGui::BulletText("Ctrl+W: Close focused widget"); - ImGui::BulletText("Enter: Open selected widget"); - ImGui::BulletText("Esc: Close editor"); - ImGui::Separator(); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Quick Tips:"); - ImGui::BulletText("Double-click to edit"); - ImGui::BulletText("Right-click to mark status"); - ImGui::BulletText("Click star icon to favorite"); - ImGui::BulletText("Use quick filters for fast sorting"); - ImGui::BulletText("Auto-Apply updates game live"); - ImGui::BulletText("Lock weather to prevent changes"); - ImGui::BulletText("Undo button reverts recent changes (Ctrl+Z)"); - ImGui::Separator(); - ImGui::Text("Total Objects:"); - ImGui::BulletText("Weathers: %d", (int)weatherWidgets.size()); - ImGui::BulletText("Lighting: %d", (int)lightingTemplateWidgets.size()); - ImGui::BulletText("ImageSpaces: %d", (int)imageSpaceWidgets.size()); - ImGui::Separator(); - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.CurrentHotkey, "Favorites: %d", (int)settings.favoriteWidgets.size()); - - // Count total recent widgets across all categories - int totalRecent = 0; - for (const auto& [category, widgets] : settings.recentWidgets) { - totalRecent += static_cast(widgets.size()); + // Re-check if the record exists after potential removal + markedRecord = settings.markedRecords.find(editorLabel); + if (markedRecord != settings.markedRecords.end()) { + ImGui::Text("%s", markedRecord->second.c_str()); } - ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.SuccessColor, "Recent: %d", totalRecent); - ImGui::EndMenu(); + + // json / delete column + drawJsonDeleteButton(sortedWidgets[i]); } - // Pause Time button - auto menu = globals::menu; - if (menu && menu->uiIcons.pauseTime.texture) { - bool isPaused = IsTimePaused(); - - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - if (isPaused) { - auto pausedColor = Menu::GetSingleton()->GetTheme().StatusPalette.SuccessColor; - pausedColor.w = 0.6f; - auto pausedHoverColor = pausedColor; - pausedHoverColor.w = 0.8f; - ImGui::PushStyleColor(ImGuiCol_Button, pausedColor); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, pausedHoverColor); - } else { - auto transparentColor = ImVec4(0, 0, 0, 0); - ImGui::PushStyleColor(ImGuiCol_Button, transparentColor); - auto hoverColor = Menu::GetSingleton()->GetSettings().Theme.Palette.Text; - hoverColor.w = 0.25f; - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hoverColor); - } + ImGui::EndTable(); // End DetailsTable + } // End if BeginTable("DetailsTable") - const float menuBarHeight = ImGui::GetFrameHeight(); - const float buttonDim = menuBarHeight * 0.85f; - const ImVec2 buttonSize(buttonDim, buttonDim); + EndScrollableContent(); // End ObjectsScrollable - if (ImGui::ImageButton("##GlobalPauseTime", menu->uiIcons.pauseTime.texture, buttonSize)) - TogglePause(); + } // End if BeginChild("##ObjectsContent") + ImGui::EndChild(); // End ObjectsContent child - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(); + ImGui::EndTable(); // End ObjectTable + } // End if BeginTable("ObjectTable") - if (ImGui::IsItemHovered()) - ImGui::SetTooltip(isPaused ? "Resume Time" : "Pause Time"); - } + // Confirmation modal for json deletion - must be outside BeginChild so the modal can block the root window + if (pendingDeleteWidget) { + if (pendingDeletePopupRequested) { + ImGui::OpenPopup("ListDeleteConfirmation"); + pendingDeletePopupRequested = false; + } + pendingDeleteWidget->DrawDeleteConfirmationModal("ListDeleteConfirmation"); + if (!ImGui::IsPopupOpen("ListDeleteConfirmation")) { + pendingDeleteWidget = nullptr; + } + } - // Undo button - if (menu && menu->uiIcons.undo.texture) { - bool canUndo = CanUndo(); - - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - if (!canUndo) { - auto transparentColor = ImVec4(0, 0, 0, 0); - ImGui::PushStyleColor(ImGuiCol_Button, transparentColor); - auto disabledColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.Disable; - disabledColor.w = 0.25f; - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, disabledColor); - auto disabledTextColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.Disable; - disabledTextColor.w = 0.5f; - ImGui::PushStyleColor(ImGuiCol_Text, disabledTextColor); - } else { - auto transparentColor = ImVec4(0, 0, 0, 0); - ImGui::PushStyleColor(ImGuiCol_Button, transparentColor); - auto hoverColor = Menu::GetSingleton()->GetSettings().Theme.Palette.Text; - hoverColor.w = 0.25f; - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hoverColor); - ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetSettings().Theme.Palette.Text); - } + // End the window + ImGui::End(); +} - const float menuBarHeight = ImGui::GetFrameHeight(); - const float buttonDim = menuBarHeight * 0.85f; - const ImVec2 buttonSize(buttonDim, buttonDim); +void EditorWindow::ShowViewportWindow() +{ + ImGui::Begin("Viewport"); - if (ImGui::ImageButton("##GlobalUndo", menu->uiIcons.undo.texture, buttonSize) && canUndo) { - PerformUndo(); - } + // Top bar + if (DrawGameHourSlider("##ViewportSlider", "Time: %.2f")) { + ImGui::SameLine(); + int activePeriod = TOD::GetActivePeriod(); + ImGui::Text("(%s)", TOD::GetPeriodName(activePeriod)); + } - ImGui::PopStyleColor(3); - ImGui::PopStyleVar(); + // The size of the image in ImGui // Get the available space in the current window + ImVec2 availableSpace = ImGui::GetContentRegionAvail(); - if (ImGui::IsItemHovered()) { - if (canUndo) { - ImGui::SetTooltip("Undo (Ctrl+Z) - %d states", (int)undoStack.size()); - } else { - ImGui::SetTooltip("Undo (Ctrl+Z) - No changes to undo"); - } - } - } // Weather lock indicator - if (weatherLockActive && lockedWeather) { - ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetSettings().Theme.StatusPalette.SuccessColor); - const char* weatherName = lockedWeather->GetFormEditorID(); - ImGui::Text(" [LOCKED: %s]", weatherName ? weatherName : "Unknown"); - ImGui::PopStyleColor(); - } + // Calculate aspect ratio of the image + float aspectRatio = ImGui::GetIO().DisplaySize.x / ImGui::GetIO().DisplaySize.y; - // Time pause indicator - if (IsTimePaused()) { - ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetSettings().Theme.StatusPalette.CurrentHotkey); - ImGui::Text(" [TIME PAUSED]"); - ImGui::PopStyleColor(); - } + // Determine the size to fit while preserving the aspect ratio + ImVec2 imageSize; + if (availableSpace.x / availableSpace.y < aspectRatio) { + // Fit width + imageSize.x = availableSpace.x; + imageSize.y = availableSpace.x / aspectRatio; + } else { + // Fit height + imageSize.y = availableSpace.y; + imageSize.x = availableSpace.y * aspectRatio; + } - // Close button on the right side - float menuBarHeight = ImGui::GetFrameHeight(); - float closeButtonSize = menuBarHeight * 0.9f; // 10% smaller than menu bar - ImGui::SameLine(ImGui::GetWindowWidth() - closeButtonSize - 10.0f); - auto errorColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.Error; - auto errorHoverColor = errorColor; - errorHoverColor.x = std::min(1.0f, errorColor.x * 1.2f); - errorHoverColor.y = std::min(1.0f, errorColor.y * 0.75f); - auto errorActiveColor = errorColor; - errorActiveColor.x = std::max(0.0f, errorColor.x * 0.875f); - errorActiveColor.y = std::max(0.0f, errorColor.y * 0.25f); - ImGui::PushStyleColor(ImGuiCol_Button, errorColor); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, errorHoverColor); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, errorActiveColor); - if (ImGui::Button("X", ImVec2(closeButtonSize, closeButtonSize))) { - open = false; - } - ImGui::PopStyleColor(3); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Close Weather Editor (Esc)"); - } - ImGui::EndMainMenuBar(); - } + ImGui::Image((void*)tempTexture->srv.get(), imageSize); - // Establish a viewport-wide DockSpace so all editor windows are snappable and dockable - ImGui::DockSpaceOverViewport(nullptr, ImGuiDockNodeFlags_PassthruCentralNode); - - auto width = ImGui::GetIO().DisplaySize.x; - auto height = ImGui::GetIO().DisplaySize.y; - auto viewportWidth = width * 0.5f; // Make the viewport take up 50% of the width - auto sideWidth = (width - viewportWidth) / 2.0f; // Divide the remaining width equally between the side windows - ImGui::SetNextWindowSize(ImVec2(sideWidth, ImGui::GetIO().DisplaySize.y * 0.75f), ImGuiCond_FirstUseEver); - ShowObjectsWindow(); - - ImGui::SetNextWindowSize(ImVec2(viewportWidth, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_FirstUseEver); - ShowViewportWindow(); - - auto settingsWindowHeight = height * 0.25f; - auto settingsWindowWidth = width * 0.25f; - ImGui::SetNextWindowSizeConstraints(ImVec2(settingsWindowWidth, settingsWindowHeight), ImVec2(FLT_MAX, FLT_MAX)); - ImGui::SetNextWindowPos({ (width / 2.0f) - (settingsWindowWidth / 2.0f), (height / 2.0f) - (settingsWindowHeight / 2.0f) }, ImGuiCond_Appearing); - if (showSettingsWindow) { - ShowSettingsWindow(); - } + ImGui::End(); +} - ShowWidgetWindow(); +void EditorWindow::ShowWidgetWindow() +{ + // Global shortcut for closing focused widget (Ctrl+W) + if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_W, false)) { + if (lastFocusedWidget && lastFocusedWidget->IsOpen()) { + lastFocusedWidget->SetOpen(false); + lastFocusedWidget = nullptr; + } + } - // Show palette window - PaletteWindow::GetSingleton()->Draw(); + // Draw all open widgets using WidgetFactory template + WidgetFactory::DrawOpenWidgets(weatherWidgets, lastFocusedWidget); + WidgetFactory::DrawOpenWidgets(lightingTemplateWidgets, lastFocusedWidget); + WidgetFactory::DrawOpenWidgets(imageSpaceWidgets, lastFocusedWidget); + WidgetFactory::DrawOpenWidgets(volumetricLightingWidgets, lastFocusedWidget); + WidgetFactory::DrawOpenWidgets(precipitationWidgets, lastFocusedWidget); + WidgetFactory::DrawOpenWidgets(lensFlareWidgets, lastFocusedWidget); + WidgetFactory::DrawOpenWidgets(referenceEffectWidgets, lastFocusedWidget); + + // Draw current cell lighting widget if open + if (currentCellLightingWidget && currentCellLightingWidget->IsOpen()) { + currentCellLightingWidget->DrawWidget(); + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) + lastFocusedWidget = currentCellLightingWidget.get(); + } +} - // Render notifications on top of everything - RenderNotifications(); +void EditorWindow::RenderUI() +{ + auto renderer = RE::BSGraphics::Renderer::GetSingleton(); + auto& framebuffer = renderer->GetRuntimeData().renderTargets[RE::RENDER_TARGETS::kFRAMEBUFFER]; + auto& context = globals::d3d::context; - // Pop the alpha style var - ImGui::PopStyleVar(); + context->ClearRenderTargetView(framebuffer.RTV, (float*)&ImGui::GetStyle().Colors[ImGuiCol_WindowBg]); - // Restore previous font scale - io.FontGlobalScale = previousScale; + // Apply editor UI scale + ImGuiIO& io = ImGui::GetIO(); + float previousScale = io.FontGlobalScale; + io.FontGlobalScale = settings.editorUIScale; + + // Increase background opacity for all editor windows + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 1.0f); + + // Check for Ctrl+Z to undo + if ((ImGui::IsKeyDown(ImGuiKey_LeftCtrl) || ImGui::IsKeyDown(ImGuiKey_RightCtrl)) && ImGui::IsKeyPressed(ImGuiKey_Z, false)) { + if (CanUndo()) { + PerformUndo(); } + } - void EditorWindow::OpenWeatherFeatureSetting(RE::TESWeather * weather, const std::string& featureName, const std::string& settingName) - { - if (!weather) { - return; + if (ImGui::BeginMainMenuBar()) { + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("Save All Open Widgets", "Ctrl+S")) { + SaveAll(); } - // Open the editor if it's not already open - if (!open) { - open = true; - } + // Save individual widgets submenu + if (ImGui::BeginMenu("Save")) { + bool hasOpenWidgets = false; - // Find the weather widget - for (auto& widget : weatherWidgets) { - auto* weatherWidget = dynamic_cast(widget.get()); - if (weatherWidget && weatherWidget->weather == weather) { - // Open the widget if it's not already open - if (!weatherWidget->open) { - weatherWidget->open = true; + // Weather widgets + for (auto& widget : weatherWidgets) { + if (widget->IsOpen()) { + hasOpenWidgets = true; + if (ImGui::MenuItem(std::format("Save {}", widget->GetEditorID()).c_str())) { + widget->Save(); + } } + } - // Set up navigation to the specific feature/setting - weatherWidget->NavigateToFeatureSetting(featureName, settingName); + // Lighting Template widgets + for (auto& widget : lightingTemplateWidgets) { + if (widget->IsOpen()) { + hasOpenWidgets = true; + if (ImGui::MenuItem(std::format("Save {}", widget->GetEditorID()).c_str())) { + widget->Save(); + } + } + } - // Focus the widget window - std::string windowName = std::format("{}###widget_{}", weatherWidget->GetEditorID(), (void*)weatherWidget); - ImGui::SetWindowFocus(windowName.c_str()); - break; + // ImageSpace widgets + for (auto& widget : imageSpaceWidgets) { + if (widget->IsOpen()) { + hasOpenWidgets = true; + if (ImGui::MenuItem(std::format("Save {}", widget->GetEditorID()).c_str())) { + widget->Save(); + } + } } - } - } - EditorWindow::~EditorWindow() - { - delete tempTexture; - weatherWidgets.clear(); - lightingTemplateWidgets.clear(); - imageSpaceWidgets.clear(); - volumetricLightingWidgets.clear(); - precipitationWidgets.clear(); - referenceEffectWidgets.clear(); - artObjectWidgets.clear(); - effectShaderWidgets.clear(); - currentCellLightingWidget.reset(); - } + if (!hasOpenWidgets) { + ImGui::TextDisabled("No open widgets"); + } - void EditorWindow::SetupResources() - { - Load(); - PaletteWindow::GetSingleton()->Load(); - InvalidateJsonAttachmentCache(); - - // Populate all widget collections using WidgetFactory templates - WidgetFactory::PopulateWidgets(weatherWidgets); - WidgetFactory::PopulateWidgets(lightingTemplateWidgets); - WidgetFactory::PopulateWidgets(imageSpaceWidgets); - WidgetFactory::PopulateWidgets(volumetricLightingWidgets); - WidgetFactory::PopulateWidgets(precipitationWidgets); - WidgetFactory::PopulateWidgets(lensFlareWidgets); - WidgetFactory::PopulateWidgets(referenceEffectWidgets); - - // Cache simple form widgets for form picker performance - WidgetFactory::PopulateSimpleWidgets(artObjectWidgets); - WidgetFactory::PopulateSimpleWidgets(effectShaderWidgets); - } + ImGui::EndMenu(); + } - void EditorWindow::Draw() - { - // Track editor open state for vanity camera management - static bool wasOpen = false; - - if (open && !wasOpen) { - // Editor just opened - disable vanity camera and restore session - DisableVanityCamera(); - RestoreSessionWidgets(); - } else if (!open && wasOpen) { - // Editor just closed - restore vanity camera and save session - RestoreVanityCamera(); - SaveSessionWidgets(); + ImGui::Separator(); + if (ImGui::MenuItem("Close All Weather Widgets")) { + for (auto& widget : weatherWidgets) widget->SetOpen(false); + } + if (ImGui::MenuItem("Close All Lighting Widgets")) { + for (auto& widget : lightingTemplateWidgets) widget->SetOpen(false); } + if (ImGui::MenuItem("Close All ImageSpace Widgets")) { + for (auto& widget : imageSpaceWidgets) widget->SetOpen(false); + } + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Settings")) { + if (ImGui::MenuItem("General Settings")) { + showSettingsWindow = true; + settingsSelectedCategory = "General"; + } + if (ImGui::MenuItem("Editor Flags")) { + showSettingsWindow = true; + settingsSelectedCategory = "Flags"; + } + ImGui::Separator(); - wasOpen = open; + // Current cell lighting + auto player = RE::PlayerCharacter::GetSingleton(); + if (player && player->parentCell && player->parentCell->IsInteriorCell()) { + if (ImGui::MenuItem("Edit Current Cell Lighting")) { + // Check if widget already exists + bool found = false; + if (currentCellLightingWidget && currentCellLightingWidget->cell == player->parentCell) { + currentCellLightingWidget->SetOpen(true); + found = true; + } - // Re-enforce weather lock if active (handles time changes) - if (weatherLockActive && lockedWeather) { - auto sky = RE::Sky::GetSingleton(); - if (sky && sky->currentWeather != lockedWeather) { - sky->ForceWeather(lockedWeather, false); + if (!found) { + // Create new widget for current cell + currentCellLightingWidget = std::make_unique(player->parentCell); + currentCellLightingWidget->CacheFormData(); + currentCellLightingWidget->Load(); + currentCellLightingWidget->SetOpen(true); + } + } + } else { + ImGui::BeginDisabled(); + ImGui::MenuItem("Edit Current Cell Lighting"); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + ImGui::SetTooltip("Only available in interior cells"); } } - auto renderer = RE::BSGraphics::Renderer::GetSingleton(); - auto& framebuffer = renderer->GetRuntimeData().renderTargets[RE::RENDER_TARGETS::kFRAMEBUFFER]; + ImGui::Separator(); - ID3D11Resource* resource = nullptr; - framebuffer.SRV->GetResource(&resource); + if (ImGui::Checkbox("Auto-Apply Changes", &settings.autoApplyChanges)) { + Save(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Automatically apply weather changes to the game as you edit"); + } + if (ImGui::Checkbox("Remember Open Widgets", &settings.rememberOpenWidgets)) { + Save(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Restore previously open widgets when editor reopens"); + } + if (ImGui::Checkbox("Enable Inherit From Parent", &settings.enableInheritFromParent)) { + Save(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Show inherit from parent options in weather widgets"); + } + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Window")) { + if (ImGui::MenuItem("Palette", nullptr, PaletteWindow::GetSingleton()->open)) { + PaletteWindow::GetSingleton()->open = !PaletteWindow::GetSingleton()->open; + } - if (!tempTexture) { - D3D11_TEXTURE2D_DESC texDesc{}; - ((ID3D11Texture2D*)resource)->GetDesc(&texDesc); + ImGui::Separator(); + ImGui::Text("Open Widgets:"); + ImGui::Separator(); - D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc{}; - framebuffer.SRV->GetDesc(&srvDesc); + int openCount = 0; + for (auto& widget : weatherWidgets) { + if (widget->IsOpen()) { + openCount++; + if (ImGui::MenuItem(std::format("Weather: {}", widget->GetEditorID()).c_str())) { + // Focus window (ImGui will bring to front when clicked) + } + } + } + for (auto& widget : lightingTemplateWidgets) { + if (widget->IsOpen()) { + openCount++; + if (ImGui::MenuItem(std::format("Lighting: {}", widget->GetEditorID()).c_str())) { + // Focus window + } + } + } + for (auto& widget : imageSpaceWidgets) { + if (widget->IsOpen()) { + openCount++; + if (ImGui::MenuItem(std::format("ImageSpace: {}", widget->GetEditorID()).c_str())) { + // Focus window + } + } + } - tempTexture = new Texture2D(texDesc); - tempTexture->CreateSRV(srvDesc); + if (openCount == 0) { + ImGui::TextDisabled("No widgets open"); } - auto& context = globals::d3d::context; + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Help")) { + ImGui::Text("Weather Editor"); + ImGui::Separator(); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Keyboard Shortcuts:"); + ImGui::BulletText("Ctrl+F: Focus search"); + ImGui::BulletText("Ctrl+S: Save all open widgets"); + ImGui::BulletText("Ctrl+W: Close focused widget"); + ImGui::BulletText("Enter: Open selected widget"); + ImGui::BulletText("Esc: Close editor"); + ImGui::Separator(); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.InfoColor, "Quick Tips:"); + ImGui::BulletText("Double-click to edit"); + ImGui::BulletText("Right-click to mark status"); + ImGui::BulletText("Click star icon to favorite"); + ImGui::BulletText("Use quick filters for fast sorting"); + ImGui::BulletText("Auto-Apply updates game live"); + ImGui::BulletText("Lock weather to prevent changes"); + ImGui::BulletText("Undo button reverts recent changes (Ctrl+Z)"); + ImGui::Separator(); + ImGui::Text("Total Objects:"); + ImGui::BulletText("Weathers: %d", (int)weatherWidgets.size()); + ImGui::BulletText("Lighting: %d", (int)lightingTemplateWidgets.size()); + ImGui::BulletText("ImageSpaces: %d", (int)imageSpaceWidgets.size()); + ImGui::Separator(); + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.CurrentHotkey, "Favorites: %d", (int)settings.favoriteWidgets.size()); - context->CopyResource(tempTexture->resource.get(), resource); + // Count total recent widgets across all categories + int totalRecent = 0; + for (const auto& [category, widgets] : settings.recentWidgets) { + totalRecent += static_cast(widgets.size()); + } + ImGui::TextColored(Menu::GetSingleton()->GetTheme().StatusPalette.SuccessColor, "Recent: %d", totalRecent); + ImGui::EndMenu(); + } - if (resource) { - resource->Release(); + // Pause Time button + auto menu = globals::menu; + if (menu && menu->uiIcons.pauseTime.texture) { + bool isPaused = IsTimePaused(); + + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + if (isPaused) { + auto pausedColor = Menu::GetSingleton()->GetTheme().StatusPalette.SuccessColor; + pausedColor.w = 0.6f; + auto pausedHoverColor = pausedColor; + pausedHoverColor.w = 0.8f; + ImGui::PushStyleColor(ImGuiCol_Button, pausedColor); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, pausedHoverColor); + } else { + auto transparentColor = ImVec4(0, 0, 0, 0); + ImGui::PushStyleColor(ImGuiCol_Button, transparentColor); + auto hoverColor = Menu::GetSingleton()->GetSettings().Theme.Palette.Text; + hoverColor.w = 0.25f; + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hoverColor); } - RenderUI(); + const float menuBarHeight = ImGui::GetFrameHeight(); + const float buttonDim = menuBarHeight * 0.85f; + const ImVec2 buttonSize(buttonDim, buttonDim); + + if (ImGui::ImageButton("##GlobalPauseTime", menu->uiIcons.pauseTime.texture, buttonSize)) + TogglePause(); + + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); + + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(isPaused ? "Resume Time" : "Pause Time"); } - void EditorWindow::SaveAll() - { - for (auto& weather : weatherWidgets) { - if (weather->IsOpen()) - weather->Save(); + // Undo button + if (menu && menu->uiIcons.undo.texture) { + bool canUndo = CanUndo(); + + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + if (!canUndo) { + auto transparentColor = ImVec4(0, 0, 0, 0); + ImGui::PushStyleColor(ImGuiCol_Button, transparentColor); + auto disabledColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.Disable; + disabledColor.w = 0.25f; + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, disabledColor); + auto disabledTextColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.Disable; + disabledTextColor.w = 0.5f; + ImGui::PushStyleColor(ImGuiCol_Text, disabledTextColor); + } else { + auto transparentColor = ImVec4(0, 0, 0, 0); + ImGui::PushStyleColor(ImGuiCol_Button, transparentColor); + auto hoverColor = Menu::GetSingleton()->GetSettings().Theme.Palette.Text; + hoverColor.w = 0.25f; + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hoverColor); + ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetSettings().Theme.Palette.Text); } - for (auto& lightingTemplate : lightingTemplateWidgets) { - if (lightingTemplate->IsOpen()) - lightingTemplate->Save(); - } + const float menuBarHeight = ImGui::GetFrameHeight(); + const float buttonDim = menuBarHeight * 0.85f; + const ImVec2 buttonSize(buttonDim, buttonDim); - for (auto& imageSpace : imageSpaceWidgets) { - if (imageSpace->IsOpen()) - imageSpace->Save(); + if (ImGui::ImageButton("##GlobalUndo", menu->uiIcons.undo.texture, buttonSize) && canUndo) { + PerformUndo(); } - Save(); + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(); + + if (ImGui::IsItemHovered()) { + if (canUndo) { + ImGui::SetTooltip("Undo (Ctrl+Z) - %d states", (int)undoStack.size()); + } else { + ImGui::SetTooltip("Undo (Ctrl+Z) - No changes to undo"); + } + } + } // Weather lock indicator + if (weatherLockActive && lockedWeather) { + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetSettings().Theme.StatusPalette.SuccessColor); + const char* weatherName = lockedWeather->GetFormEditorID(); + ImGui::Text(" [LOCKED: %s]", weatherName ? weatherName : "Unknown"); + ImGui::PopStyleColor(); } - void EditorWindow::SaveSettings() - { - j = settings; + // Time pause indicator + if (IsTimePaused()) { + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetSettings().Theme.StatusPalette.CurrentHotkey); + ImGui::Text(" [TIME PAUSED]"); + ImGui::PopStyleColor(); } - void EditorWindow::LoadSettings() - { - if (!j.empty()) - settings = j; + // Close button on the right side + float menuBarHeight = ImGui::GetFrameHeight(); + float closeButtonSize = menuBarHeight * 0.9f; // 10% smaller than menu bar + ImGui::SameLine(ImGui::GetWindowWidth() - closeButtonSize - 10.0f); + auto errorColor = Menu::GetSingleton()->GetSettings().Theme.StatusPalette.Error; + auto errorHoverColor = errorColor; + errorHoverColor.x = std::min(1.0f, errorColor.x * 1.2f); + errorHoverColor.y = std::min(1.0f, errorColor.y * 0.75f); + auto errorActiveColor = errorColor; + errorActiveColor.x = std::max(0.0f, errorColor.x * 0.875f); + errorActiveColor.y = std::max(0.0f, errorColor.y * 0.25f); + ImGui::PushStyleColor(ImGuiCol_Button, errorColor); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, errorHoverColor); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, errorActiveColor); + if (ImGui::Button("X", ImVec2(closeButtonSize, closeButtonSize))) { + open = false; } + ImGui::PopStyleColor(3); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Close Weather Editor (Esc)"); + } + ImGui::EndMainMenuBar(); + } - void EditorWindow::ShowSettingsWindow() - { - ImGui::Begin("Settings", &showSettingsWindow); + // Establish a viewport-wide DockSpace so all editor windows are snappable and dockable + ImGui::DockSpaceOverViewport(nullptr, ImGuiDockNodeFlags_PassthruCentralNode); + + auto width = ImGui::GetIO().DisplaySize.x; + auto height = ImGui::GetIO().DisplaySize.y; + auto viewportWidth = width * 0.5f; // Make the viewport take up 50% of the width + auto sideWidth = (width - viewportWidth) / 2.0f; // Divide the remaining width equally between the side windows + ImGui::SetNextWindowSize(ImVec2(sideWidth, ImGui::GetIO().DisplaySize.y * 0.75f), ImGuiCond_FirstUseEver); + ShowObjectsWindow(); + + ImGui::SetNextWindowSize(ImVec2(viewportWidth, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_FirstUseEver); + ShowViewportWindow(); + + auto settingsWindowHeight = height * 0.25f; + auto settingsWindowWidth = width * 0.25f; + ImGui::SetNextWindowSizeConstraints(ImVec2(settingsWindowWidth, settingsWindowHeight), ImVec2(FLT_MAX, FLT_MAX)); + ImGui::SetNextWindowPos({ (width / 2.0f) - (settingsWindowWidth / 2.0f), (height / 2.0f) - (settingsWindowHeight / 2.0f) }, ImGuiCond_Appearing); + if (showSettingsWindow) { + ShowSettingsWindow(); + } - if (ImGui::BeginTable("SettingsTable", 2, ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInner | ImGuiTableFlags_NoHostExtendX)) { - ImGui::TableSetupColumn("Options", ImGuiTableColumnFlags_WidthStretch, 0.3f); - ImGui::TableSetupColumn("##Settings", ImGuiTableColumnFlags_WidthStretch, 0.7f); + ShowWidgetWindow(); - ImGui::TableNextRow(); + // Show palette window + PaletteWindow::GetSingleton()->Draw(); - ImGui::TableSetColumnIndex(0); - const char* options[] = { "General", "Flags" }; - for (int i = 0; i < IM_ARRAYSIZE(options); ++i) { - if (ImGui::Selectable(options[i], settingsSelectedCategory == options[i])) { - settingsSelectedCategory = options[i]; - } - } + // Render notifications on top of everything + RenderNotifications(); - ImGui::TableSetColumnIndex(1); + // Pop the alpha style var + ImGui::PopStyleVar(); - if (settingsSelectedCategory == "General") { - ImGui::Checkbox("Auto-apply changes", &settings.autoApplyChanges); - Util::AddTooltip("Automatically apply changes to weather/lighting when editing"); + // Restore previous font scale + io.FontGlobalScale = previousScale; +} - ImGui::Checkbox("Use text buttons instead of icons", &settings.useTextButtons); - Util::AddTooltip("Display action buttons as text labels instead of icons"); +void EditorWindow::OpenWeatherFeatureSetting(RE::TESWeather* weather, const std::string& featureName, const std::string& settingName) +{ + if (!weather) { + return; + } - ImGui::Checkbox("Enable 'Inherit From Parent' feature", &settings.enableInheritFromParent); - Util::AddTooltip("Show checkboxes to copy settings from parent weather (editor-only feature)"); + // Open the editor if it's not already open + if (!open) { + open = true; + } - ImGui::Separator(); - ImGui::TextUnformatted("UI Scale"); - ImGui::Spacing(); + // Find the weather widget + for (auto& widget : weatherWidgets) { + auto* weatherWidget = dynamic_cast(widget.get()); + if (weatherWidget && weatherWidget->weather == weather) { + // Open the widget if it's not already open + if (!weatherWidget->open) { + weatherWidget->open = true; + } - if (ImGui::SliderFloat("Editor UI Scale", &settings.editorUIScale, 0.5f, 2.0f, "%.2f")) { - Save(); - } - Util::AddTooltip("Scale the size of all editor UI elements (0.5 = 50%, 2.0 = 200%)"); + // Set up navigation to the specific feature/setting + weatherWidget->NavigateToFeatureSetting(featureName, settingName); - if (Util::ButtonWithFlash("Reset to 1.0")) { - settings.editorUIScale = 1.0f; - Save(); - } - ImGui::SameLine(); - Util::AddTooltip("Reset UI scale to default (100%)"); + // Focus the widget window + std::string windowName = std::format("{}###widget_{}", weatherWidget->GetEditorID(), (void*)weatherWidget); + ImGui::SetWindowFocus(windowName.c_str()); + break; + } + } +} - ImGui::Separator(); - ImGui::TextUnformatted("Session & History"); - ImGui::Spacing(); +EditorWindow::~EditorWindow() +{ + delete tempTexture; + weatherWidgets.clear(); + lightingTemplateWidgets.clear(); + imageSpaceWidgets.clear(); + volumetricLightingWidgets.clear(); + precipitationWidgets.clear(); + referenceEffectWidgets.clear(); + artObjectWidgets.clear(); + effectShaderWidgets.clear(); + currentCellLightingWidget.reset(); +} - ImGui::Checkbox("Remember open widgets", &settings.rememberOpenWidgets); - Util::AddTooltip("Automatically reopen widgets that were open when you last closed the editor"); +void EditorWindow::SetupResources() +{ + Load(); + PaletteWindow::GetSingleton()->Load(); + InvalidateJsonAttachmentCache(); + + // Populate all widget collections using WidgetFactory templates + WidgetFactory::PopulateWidgets(weatherWidgets); + WidgetFactory::PopulateWidgets(lightingTemplateWidgets); + WidgetFactory::PopulateWidgets(imageSpaceWidgets); + WidgetFactory::PopulateWidgets(volumetricLightingWidgets); + WidgetFactory::PopulateWidgets(precipitationWidgets); + WidgetFactory::PopulateWidgets(lensFlareWidgets); + WidgetFactory::PopulateWidgets(referenceEffectWidgets); + + // Cache simple form widgets for form picker performance + WidgetFactory::PopulateSimpleWidgets(artObjectWidgets); + WidgetFactory::PopulateSimpleWidgets(effectShaderWidgets); +} - ImGui::SliderInt("Max recent widgets", &settings.maxRecentWidgets, 5, 20); - Util::AddTooltip("Maximum number of recent widgets to remember"); +void EditorWindow::Draw() +{ + // Track editor open state for vanity camera management + static bool wasOpen = false; + + if (open && !wasOpen) { + // Editor just opened - disable vanity camera and restore session + DisableVanityCamera(); + RestoreSessionWidgets(); + } else if (!open && wasOpen) { + // Editor just closed - restore vanity camera and save session + RestoreVanityCamera(); + SaveSessionWidgets(); + } - if (Util::ButtonWithFlash("Clear Recent History")) { - settings.recentWidgets.clear(); - Save(); - } - ImGui::SameLine(); - if (Util::ButtonWithFlash("Clear Favorites")) { - settings.favoriteWidgets.clear(); - Save(); - } + wasOpen = open; - } else if (settingsSelectedCategory == "Flags") { - if (ImGui::BeginTable("FlagsTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { - ImGui::TableSetupColumn("Label", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Colour", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, 60.0f); + // Re-enforce weather lock if active (handles time changes) + if (weatherLockActive && lockedWeather) { + auto sky = RE::Sky::GetSingleton(); + if (sky && sky->currentWeather != lockedWeather) { + sky->ForceWeather(lockedWeather, false); + } + } - auto& recordMarkers = settings.recordMarkers; + auto renderer = RE::BSGraphics::Renderer::GetSingleton(); + auto& framebuffer = renderer->GetRuntimeData().renderTargets[RE::RENDER_TARGETS::kFRAMEBUFFER]; - // Store markers to delete (can't delete while iterating) - static std::string markerToDelete; - markerToDelete.clear(); + ID3D11Resource* resource = nullptr; + framebuffer.SRV->GetResource(&resource); - // Store rename info (old name -> new name) - static std::pair renameInfo; - static bool needsRename = false; + if (!tempTexture) { + D3D11_TEXTURE2D_DESC texDesc{}; + ((ID3D11Texture2D*)resource)->GetDesc(&texDesc); - // Store separate buffers for each marker - static std::unordered_map> labelBuffers; + D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc{}; + framebuffer.SRV->GetDesc(&srvDesc); - for (auto& recordMarker : recordMarkers) { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); + tempTexture = new Texture2D(texDesc); + tempTexture->CreateSRV(srvDesc); + } - // Editable label - use separate buffer for each marker - auto& labelBuffer = labelBuffers[recordMarker.first]; - if (labelBuffer[0] == '\0' || labelBuffers.find(recordMarker.first) == labelBuffers.end()) { - strncpy_s(labelBuffer.data(), labelBuffer.size(), recordMarker.first.c_str(), labelBuffer.size() - 1); - labelBuffer[labelBuffer.size() - 1] = '\0'; - } + auto& context = globals::d3d::context; - ImGui::SetNextItemWidth(-1); - if (ImGui::InputText(std::format("##Label{}", recordMarker.first).c_str(), labelBuffer.data(), labelBuffer.size(), ImGuiInputTextFlags_EnterReturnsTrue)) { - // Mark for rename only on Enter - renameInfo = { recordMarker.first, std::string(labelBuffer.data()) }; - needsRename = true; - } + context->CopyResource(tempTexture->resource.get(), resource); - ImGui::TableSetColumnIndex(1); - if (ImGui::ColorEdit3(std::format("Color##{}", recordMarker.first).c_str(), (float*)&recordMarker.second)) { - Save(); - } + if (resource) { + resource->Release(); + } - ImGui::TableSetColumnIndex(2); - auto deleteColor = Menu::GetSingleton()->GetTheme().StatusPalette.Warning; - deleteColor.y = deleteColor.y * 0.5f; - auto deleteHovered = deleteColor; - deleteHovered.w = 0.8f; - auto deleteActive = deleteColor; - deleteActive.w = 1.0f; - { - auto styledButton = Util::StyledButtonWrapper(deleteColor, deleteHovered, deleteActive); - if (ImGui::Button(std::format("Delete##{}", recordMarker.first).c_str(), ImVec2(-1, 0))) { - markerToDelete = recordMarker.first; - } - } - } + RenderUI(); +} - // Process rename - if (needsRename && renameInfo.first != renameInfo.second && !renameInfo.second.empty()) { - // Check if new name doesn't already exist - if (recordMarkers.find(renameInfo.second) == recordMarkers.end()) { - auto color = recordMarkers[renameInfo.first]; - recordMarkers.erase(renameInfo.first); - recordMarkers[renameInfo.second] = color; - - // Update any records that were using the old marker name - for (auto& [recordId, markerName] : settings.markedRecords) { - if (markerName == renameInfo.first) { - markerName = renameInfo.second; - } - } +void EditorWindow::SaveAll() +{ + for (auto& weather : weatherWidgets) { + if (weather->IsOpen()) + weather->Save(); + } - Save(); - } - needsRename = false; - } + for (auto& lightingTemplate : lightingTemplateWidgets) { + if (lightingTemplate->IsOpen()) + lightingTemplate->Save(); + } - // Process deletion - if (!markerToDelete.empty()) { - recordMarkers.erase(markerToDelete); + for (auto& imageSpace : imageSpaceWidgets) { + if (imageSpace->IsOpen()) + imageSpace->Save(); + } - // Remove any records that were using this marker - for (auto it = settings.markedRecords.begin(); it != settings.markedRecords.end();) { - if (it->second == markerToDelete) { - it = settings.markedRecords.erase(it); - } else { - ++it; - } - } + Save(); +} - Save(); - } +void EditorWindow::SaveSettings() +{ + j = settings; +} - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); +void EditorWindow::LoadSettings() +{ + if (!j.empty()) + settings = j; +} - if (recordMarkers.size() < maxRecordMarkers && ImGui::Selectable("Add new marker")) { - recordMarkers.insert({ std::format("New marker {}", recordMarkers.size()), { 0.5f, 0.5f, 0.5f, 1.0f } }); - Save(); - } +void EditorWindow::ShowSettingsWindow() +{ + ImGui::Begin("Settings", &showSettingsWindow); - ImGui::EndTable(); - } - } - ImGui::EndTable(); - } + if (ImGui::BeginTable("SettingsTable", 2, ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInner | ImGuiTableFlags_NoHostExtendX)) { + ImGui::TableSetupColumn("Options", ImGuiTableColumnFlags_WidthStretch, 0.3f); + ImGui::TableSetupColumn("##Settings", ImGuiTableColumnFlags_WidthStretch, 0.7f); + + ImGui::TableNextRow(); - ImGui::End(); + ImGui::TableSetColumnIndex(0); + const char* options[] = { "General", "Flags" }; + for (int i = 0; i < IM_ARRAYSIZE(options); ++i) { + if (ImGui::Selectable(options[i], settingsSelectedCategory == options[i])) { + settingsSelectedCategory = options[i]; + } } - void EditorWindow::Save() - { - SaveSettings(); - const std::string filePath = Util::PathHelpers::GetCommunityShaderPath().string(); - const std::string file = std::format("{}\\{}.json", filePath, settingsFilename); + ImGui::TableSetColumnIndex(1); - std::ofstream settingsFile(file); + if (settingsSelectedCategory == "General") { + ImGui::Checkbox("Auto-apply changes", &settings.autoApplyChanges); + Util::AddTooltip("Automatically apply changes to weather/lighting when editing"); - if (!settingsFile.good() || !settingsFile.is_open()) { - logger::warn("Failed to open settings file: {}", file); - return; - } + ImGui::Checkbox("Use text buttons instead of icons", &settings.useTextButtons); + Util::AddTooltip("Display action buttons as text labels instead of icons"); - if (settingsFile.fail()) { - logger::warn("Unable to create settings file: {}", file); - settingsFile.close(); - return; - } + ImGui::Checkbox("Enable 'Inherit From Parent' feature", &settings.enableInheritFromParent); + Util::AddTooltip("Show checkboxes to copy settings from parent weather (editor-only feature)"); - logger::info("Saving settings file: {}", file); + ImGui::Separator(); + ImGui::TextUnformatted("UI Scale"); + ImGui::Spacing(); - try { - settingsFile << j.dump(1); + if (ImGui::SliderFloat("Editor UI Scale", &settings.editorUIScale, 0.5f, 2.0f, "%.2f")) { + Save(); + } + Util::AddTooltip("Scale the size of all editor UI elements (0.5 = 50%, 2.0 = 200%)"); - settingsFile.close(); - } catch (const nlohmann::json::parse_error& e) { - logger::warn("Error parsing settings for settings file ({}) : {}\n", filePath, e.what()); - settingsFile.close(); + if (Util::ButtonWithFlash("Reset to 1.0")) { + settings.editorUIScale = 1.0f; + Save(); } - } + ImGui::SameLine(); + Util::AddTooltip("Reset UI scale to default (100%)"); - void EditorWindow::Load() - { - std::string filePath = std::format("{}\\{}.json", Util::PathHelpers::GetCommunityShaderPath().string(), settingsFilename); + ImGui::Separator(); + ImGui::TextUnformatted("Session & History"); + ImGui::Spacing(); - std::ifstream settingsFile(filePath); + ImGui::Checkbox("Remember open widgets", &settings.rememberOpenWidgets); + Util::AddTooltip("Automatically reopen widgets that were open when you last closed the editor"); - if (!std::filesystem::exists(filePath)) { - // Does not have any settings so just return. - return; - } + ImGui::SliderInt("Max recent widgets", &settings.maxRecentWidgets, 5, 20); + Util::AddTooltip("Maximum number of recent widgets to remember"); - if (!settingsFile.good() || !settingsFile.is_open()) { - logger::warn("Failed to load settings file: {}", filePath); - return; + if (Util::ButtonWithFlash("Clear Recent History")) { + settings.recentWidgets.clear(); + Save(); } - - try { - j << settingsFile; - settingsFile.close(); - } catch (const nlohmann::json::parse_error& e) { - logger::warn("Error parsing settings for file ({}) : {}\n", filePath, e.what()); - settingsFile.close(); + ImGui::SameLine(); + if (Util::ButtonWithFlash("Clear Favorites")) { + settings.favoriteWidgets.clear(); + Save(); } - LoadSettings(); - } - void EditorWindow::LockWeather(RE::TESWeather * weather) - { - if (!weather) - return; + } else if (settingsSelectedCategory == "Flags") { + if (ImGui::BeginTable("FlagsTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Label", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Colour", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, 60.0f); - auto sky = RE::Sky::GetSingleton(); - if (!sky) - return; + auto& recordMarkers = settings.recordMarkers; - // Force the weather to be active - sky->ForceWeather(weather, false); + // Store markers to delete (can't delete while iterating) + static std::string markerToDelete; + markerToDelete.clear(); - lockedWeather = weather; - weatherLockActive = true; + // Store rename info (old name -> new name) + static std::pair renameInfo; + static bool needsRename = false; - logger::info("Weather locked: {}", weather->GetFormEditorID() ? weather->GetFormEditorID() : "Unknown"); - } + // Store separate buffers for each marker + static std::unordered_map> labelBuffers; - void EditorWindow::UnlockWeather() - { - if (!weatherLockActive) - return; + for (auto& recordMarker : recordMarkers) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); - auto sky = RE::Sky::GetSingleton(); - if (sky) { - // Release weather override to allow natural progression - sky->ReleaseWeatherOverride(); - } + // Editable label - use separate buffer for each marker + auto& labelBuffer = labelBuffers[recordMarker.first]; + if (labelBuffer[0] == '\0' || labelBuffers.find(recordMarker.first) == labelBuffers.end()) { + strncpy_s(labelBuffer.data(), labelBuffer.size(), recordMarker.first.c_str(), labelBuffer.size() - 1); + labelBuffer[labelBuffer.size() - 1] = '\0'; + } - logger::info("Weather unlocked: {}", lockedWeather && lockedWeather->GetFormEditorID() ? lockedWeather->GetFormEditorID() : "Unknown"); + ImGui::SetNextItemWidth(-1); + if (ImGui::InputText(std::format("##Label{}", recordMarker.first).c_str(), labelBuffer.data(), labelBuffer.size(), ImGuiInputTextFlags_EnterReturnsTrue)) { + // Mark for rename only on Enter + renameInfo = { recordMarker.first, std::string(labelBuffer.data()) }; + needsRename = true; + } - lockedWeather = nullptr; - weatherLockActive = false; - } + ImGui::TableSetColumnIndex(1); + if (ImGui::ColorEdit3(std::format("Color##{}", recordMarker.first).c_str(), (float*)&recordMarker.second)) { + Save(); + } - void EditorWindow::PauseTime() - { - if (timePaused) - return; - auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); - if (calendar && calendar->timeScale) { - savedTimeScale = calendar->timeScale->value; - calendar->timeScale->value = 0.0f; - timePaused = true; - logger::info("Time paused (saved timescale: {})", savedTimeScale); - } - } + ImGui::TableSetColumnIndex(2); + auto deleteColor = Menu::GetSingleton()->GetTheme().StatusPalette.Warning; + deleteColor.y = deleteColor.y * 0.5f; + auto deleteHovered = deleteColor; + deleteHovered.w = 0.8f; + auto deleteActive = deleteColor; + deleteActive.w = 1.0f; + { + auto styledButton = Util::StyledButtonWrapper(deleteColor, deleteHovered, deleteActive); + if (ImGui::Button(std::format("Delete##{}", recordMarker.first).c_str(), ImVec2(-1, 0))) { + markerToDelete = recordMarker.first; + } + } + } - void EditorWindow::ResumeTime() - { - if (!timePaused) - return; - auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); - if (calendar && calendar->timeScale) { - calendar->timeScale->value = savedTimeScale; - timePaused = false; - logger::info("Time resumed (timescale: {})", savedTimeScale); - } - } + // Process rename + if (needsRename && renameInfo.first != renameInfo.second && !renameInfo.second.empty()) { + // Check if new name doesn't already exist + if (recordMarkers.find(renameInfo.second) == recordMarkers.end()) { + auto color = recordMarkers[renameInfo.first]; + recordMarkers.erase(renameInfo.first); + recordMarkers[renameInfo.second] = color; + + // Update any records that were using the old marker name + for (auto& [recordId, markerName] : settings.markedRecords) { + if (markerName == renameInfo.first) { + markerName = renameInfo.second; + } + } - void EditorWindow::ResetTimeScale() - { - auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); - if (!calendar || !calendar->timeScale) - return; - if (timePaused) - savedTimeScale = kVanillaTimeScale; - else - calendar->timeScale->value = kVanillaTimeScale; - timeScaleSlider = kVanillaTimeScale; - } + Save(); + } + needsRename = false; + } - void EditorWindow::UpdateTimeState() - { - auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); - auto ui = globals::game::ui ? globals::game::ui : RE::UI::GetSingleton(); - if (!calendar || !calendar->timeScale) - return; + // Process deletion + if (!markerToDelete.empty()) { + recordMarkers.erase(markerToDelete); - bool sleepWaitOpen = ui && ui->IsMenuOpen(RE::SleepWaitMenu::MENU_NAME); + // Remove any records that were using this marker + for (auto it = settings.markedRecords.begin(); it != settings.markedRecords.end();) { + if (it->second == markerToDelete) { + it = settings.markedRecords.erase(it); + } else { + ++it; + } + } - // External state sync (skip during sleep/wait) - if (!sleepWaitOpen) { - if (calendar->timeScale->value == 0.0f && !timePaused) - savedTimeScale = kVanillaTimeScale; - else if (calendar->timeScale->value > 0.0f && timePaused) - timePaused = false; - } + Save(); + } + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); - // Sleep/wait handling — temporarily restore time so the wait can proceed - if (sleepWaitOpen && calendar->timeScale->value == 0.0f) { - if (!wasRestoredForWait) { - wasPausedBeforeWait = true; - if (timePaused) - ResumeTime(); - else - calendar->timeScale->value = std::max(savedTimeScale, kVanillaTimeScale); - wasRestoredForWait = true; + if (recordMarkers.size() < maxRecordMarkers && ImGui::Selectable("Add new marker")) { + recordMarkers.insert({ std::format("New marker {}", recordMarkers.size()), { 0.5f, 0.5f, 0.5f, 1.0f } }); + Save(); } - } else if (!sleepWaitOpen && wasRestoredForWait) { - if (wasPausedBeforeWait && !timePaused) - PauseTime(); - wasRestoredForWait = false; - wasPausedBeforeWait = false; + + ImGui::EndTable(); } } + ImGui::EndTable(); + } - bool EditorWindow::DrawGameHourSlider(const char* label, const char* format) - { - auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); - if (!calendar || !calendar->gameHour) - return false; - ImGui::SliderFloat(label, &calendar->gameHour->value, 0.0f, kGameHourMax, format); - return true; - } + ImGui::End(); +} - void EditorWindow::DrawTimeControls() - { - auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); - if (!calendar || !calendar->gameHour || !calendar->timeScale) - return; +void EditorWindow::Save() +{ + SaveSettings(); + const std::string filePath = Util::PathHelpers::GetCommunityShaderPath().string(); + const std::string file = std::format("{}\\{}.json", filePath, settingsFilename); - // Row 1: Pause/Resume + Game Time - if (ImGui::Button(timePaused ? "Resume Time" : "Pause Time", ImVec2(120, 0))) - TogglePause(); - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text("Pause or resume game time progression"); - ImGui::SameLine(); - DrawGameHourSlider(); - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text("Adjust the current game time"); + std::ofstream settingsFile(file); - // Sync slider with actual value - if (timePaused) - timeScaleSlider = std::max(savedTimeScale, kTimeScaleMin); - else if (std::abs(calendar->timeScale->value - timeScaleSlider) > 0.01f) - timeScaleSlider = calendar->timeScale->value; + if (!settingsFile.good() || !settingsFile.is_open()) { + logger::warn("Failed to open settings file: {}", file); + return; + } - // Row 2: Reset Speed + TimeScale slider + speed label - if (ImGui::Button("Reset Speed", ImVec2(120, 0))) - ResetTimeScale(); - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text("Reset time speed to vanilla (%.1fx)", kVanillaTimeScale); + if (settingsFile.fail()) { + logger::warn("Unable to create settings file: {}", file); + settingsFile.close(); + return; + } - ImGui::SameLine(); - ImGui::BeginDisabled(timePaused); - if (ImGui::SliderFloat("##TimeScale", &timeScaleSlider, kTimeScaleMin, kTimeScaleMax, - timeScaleSlider == kVanillaTimeScale ? "Vanilla Speed" : "", ImGuiSliderFlags_Logarithmic)) - calendar->timeScale->value = timeScaleSlider; - ImGui::EndDisabled(); + logger::info("Saving settings file: {}", file); - ImGui::SameLine(); - ImGui::Text("%.1fx", calendar->timeScale->value); - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text("Adjust how fast time passes (vanilla: %.1fx)", kVanillaTimeScale); - } + try { + settingsFile << j.dump(1); - void EditorWindow::DisableVanityCamera() - { - if (vanityCameraDisabled) - return; + settingsFile.close(); + } catch (const nlohmann::json::parse_error& e) { + logger::warn("Error parsing settings for settings file ({}) : {}\n", filePath, e.what()); + settingsFile.close(); + } +} - auto setting = RE::GetINISetting("fAutoVanityModeDelay:Camera"); - if (setting) { - savedVanityCameraDelay = setting->GetFloat(); - setting->data.f = 10000.0f; - vanityCameraDisabled = true; - logger::info("Vanity camera disabled (saved delay: {})", savedVanityCameraDelay); - } - } +void EditorWindow::Load() +{ + std::string filePath = std::format("{}\\{}.json", Util::PathHelpers::GetCommunityShaderPath().string(), settingsFilename); - void EditorWindow::RestoreVanityCamera() - { - if (!vanityCameraDisabled) - return; + std::ifstream settingsFile(filePath); - auto setting = RE::GetINISetting("fAutoVanityModeDelay:Camera"); - if (setting) { - setting->data.f = savedVanityCameraDelay; - vanityCameraDisabled = false; - logger::info("Vanity camera restored (delay: {})", savedVanityCameraDelay); - } - } + if (!std::filesystem::exists(filePath)) { + // Does not have any settings so just return. + return; + } - bool EditorWindow::ShouldHandleEscapeKey() const - { - return !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel); - } + if (!settingsFile.good() || !settingsFile.is_open()) { + logger::warn("Failed to load settings file: {}", filePath); + return; + } - void EditorWindow::PushUndoState(Widget * widget) - { - if (!widget) - return; + try { + j << settingsFile; + settingsFile.close(); + } catch (const nlohmann::json::parse_error& e) { + logger::warn("Error parsing settings for file ({}) : {}\n", filePath, e.what()); + settingsFile.close(); + } + LoadSettings(); +} - UndoState state; - state.widget = widget; - state.widgetId = widget->GetEditorID(); - state.settings = widget->js; +void EditorWindow::LockWeather(RE::TESWeather* weather) +{ + if (!weather) + return; - undoStack.push_back(state); + auto sky = RE::Sky::GetSingleton(); + if (!sky) + return; - if (undoStack.size() > maxUndoStates) { - undoStack.erase(undoStack.begin()); - } - } + // Force the weather to be active + sky->ForceWeather(weather, false); - void EditorWindow::PerformUndo() - { - if (undoStack.empty()) - return; + lockedWeather = weather; + weatherLockActive = true; - UndoState state = undoStack.back(); - undoStack.pop_back(); + logger::info("Weather locked: {}", weather->GetFormEditorID() ? weather->GetFormEditorID() : "Unknown"); +} - if (!state.widget) { - for (auto& w : weatherWidgets) { - if (w->GetEditorID() == state.widgetId) { - state.widget = w.get(); - break; - } - } - if (!state.widget) { - for (auto& w : imageSpaceWidgets) { - if (w->GetEditorID() == state.widgetId) { - state.widget = w.get(); - break; - } - } - } - if (!state.widget) { - for (auto& w : lightingTemplateWidgets) { - if (w->GetEditorID() == state.widgetId) { - state.widget = w.get(); - break; - } - } - } - } +void EditorWindow::UnlockWeather() +{ + if (!weatherLockActive) + return; - if (state.widget) { - state.widget->js = state.settings; - state.widget->LoadSettings(); - state.widget->ApplyChanges(); - ShowNotification( - std::format("Undone changes to {}", state.widgetId), - Menu::GetSingleton()->GetSettings().Theme.StatusPalette.InfoColor, - 2.0f); - } - } + auto sky = RE::Sky::GetSingleton(); + if (sky) { + // Release weather override to allow natural progression + sky->ReleaseWeatherOverride(); + } - void EditorWindow::ShowNotification(const std::string& message, const ImVec4& color, float duration) - { - // Guard against calls before ImGui is initialized - if (!ImGui::GetCurrentContext()) { - logger::warn("ShowNotification called before ImGui initialization: {}", message); - return; - } + logger::info("Weather unlocked: {}", lockedWeather && lockedWeather->GetFormEditorID() ? lockedWeather->GetFormEditorID() : "Unknown"); + + lockedWeather = nullptr; + weatherLockActive = false; +} + +void EditorWindow::PauseTime() +{ + if (timePaused) + return; + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (calendar && calendar->timeScale) { + savedTimeScale = calendar->timeScale->value; + calendar->timeScale->value = 0.0f; + timePaused = true; + logger::info("Time paused (saved timescale: {})", savedTimeScale); + } +} + +void EditorWindow::ResumeTime() +{ + if (!timePaused) + return; + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (calendar && calendar->timeScale) { + calendar->timeScale->value = savedTimeScale; + timePaused = false; + logger::info("Time resumed (timescale: {})", savedTimeScale); + } +} + +void EditorWindow::ResetTimeScale() +{ + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (!calendar || !calendar->timeScale) + return; + if (timePaused) + savedTimeScale = kVanillaTimeScale; + else + calendar->timeScale->value = kVanillaTimeScale; + timeScaleSlider = kVanillaTimeScale; +} - Notification notif; - notif.message = message; - notif.color = color; - notif.startTime = static_cast(ImGui::GetTime()); - notif.duration = duration; - notifications.push_back(notif); +void EditorWindow::UpdateTimeState() +{ + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + auto ui = globals::game::ui ? globals::game::ui : RE::UI::GetSingleton(); + if (!calendar || !calendar->timeScale) + return; + + bool sleepWaitOpen = ui && ui->IsMenuOpen(RE::SleepWaitMenu::MENU_NAME); + + // External state sync (skip during sleep/wait) + if (!sleepWaitOpen) { + if (calendar->timeScale->value == 0.0f && !timePaused) + savedTimeScale = kVanillaTimeScale; + else if (calendar->timeScale->value > 0.0f && timePaused) + timePaused = false; + } + + // Sleep/wait handling — temporarily restore time so the wait can proceed + if (sleepWaitOpen && calendar->timeScale->value == 0.0f) { + if (!wasRestoredForWait) { + wasPausedBeforeWait = true; + if (timePaused) + ResumeTime(); + else + calendar->timeScale->value = std::max(savedTimeScale, kVanillaTimeScale); + wasRestoredForWait = true; } + } else if (!sleepWaitOpen && wasRestoredForWait) { + if (wasPausedBeforeWait && !timePaused) + PauseTime(); + wasRestoredForWait = false; + wasPausedBeforeWait = false; + } +} - void EditorWindow::RenderNotifications() - { - // Guard against calls before ImGui is initialized - if (!ImGui::GetCurrentContext()) { - return; - } +bool EditorWindow::DrawGameHourSlider(const char* label, const char* format) +{ + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (!calendar || !calendar->gameHour) + return false; + ImGui::SliderFloat(label, &calendar->gameHour->value, 0.0f, kGameHourMax, format); + return true; +} - float currentTime = static_cast(ImGui::GetTime()); - float yOffset = 10.0f; +void EditorWindow::DrawTimeControls() +{ + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (!calendar || !calendar->gameHour || !calendar->timeScale) + return; + + // Row 1: Pause/Resume + Game Time + if (ImGui::Button(timePaused ? "Resume Time" : "Pause Time", ImVec2(120, 0))) + TogglePause(); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text("Pause or resume game time progression"); + ImGui::SameLine(); + DrawGameHourSlider(); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text("Adjust the current game time"); + + // Sync slider with actual value + if (timePaused) + timeScaleSlider = std::max(savedTimeScale, kTimeScaleMin); + else if (std::abs(calendar->timeScale->value - timeScaleSlider) > 0.01f) + timeScaleSlider = calendar->timeScale->value; + + // Row 2: Reset Speed + TimeScale slider + speed label + if (ImGui::Button("Reset Speed", ImVec2(120, 0))) + ResetTimeScale(); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text("Reset time speed to vanilla (%.1fx)", kVanillaTimeScale); + + ImGui::SameLine(); + ImGui::BeginDisabled(timePaused); + if (ImGui::SliderFloat("##TimeScale", &timeScaleSlider, kTimeScaleMin, kTimeScaleMax, + timeScaleSlider == kVanillaTimeScale ? "Vanilla Speed" : "", ImGuiSliderFlags_Logarithmic)) + calendar->timeScale->value = timeScaleSlider; + ImGui::EndDisabled(); + + ImGui::SameLine(); + ImGui::Text("%.1fx", calendar->timeScale->value); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text("Adjust how fast time passes (vanilla: %.1fx)", kVanillaTimeScale); +} - // Remove expired notifications - notifications.erase( - std::remove_if(notifications.begin(), notifications.end(), - [currentTime](const Notification& n) { return currentTime - n.startTime > n.duration; }), - notifications.end()); +void EditorWindow::DisableVanityCamera() +{ + if (vanityCameraDisabled) + return; + + auto setting = RE::GetINISetting("fAutoVanityModeDelay:Camera"); + if (setting) { + savedVanityCameraDelay = setting->GetFloat(); + setting->data.f = 10000.0f; + vanityCameraDisabled = true; + logger::info("Vanity camera disabled (saved delay: {})", savedVanityCameraDelay); + } +} - // Render active notifications - for (auto& notif : notifications) { - float elapsed = currentTime - notif.startTime; - float fadeStart = notif.duration - 0.5f; // Start fading 0.5s before end - float alpha = 1.0f; +void EditorWindow::RestoreVanityCamera() +{ + if (!vanityCameraDisabled) + return; + + auto setting = RE::GetINISetting("fAutoVanityModeDelay:Camera"); + if (setting) { + setting->data.f = savedVanityCameraDelay; + vanityCameraDisabled = false; + logger::info("Vanity camera restored (delay: {})", savedVanityCameraDelay); + } +} - // Fade out in the last 0.5 seconds - if (elapsed > fadeStart) { - alpha = 1.0f - ((elapsed - fadeStart) / 0.5f); - } +bool EditorWindow::ShouldHandleEscapeKey() const +{ + return !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel); +} - // Position in top-left corner - ImGui::SetNextWindowPos(ImVec2(10.0f, yOffset), ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.8f * alpha); +void EditorWindow::PushUndoState(Widget* widget) +{ + if (!widget) + return; - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 5.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(15.0f, 10.0f)); + UndoState state; + state.widget = widget; + state.widgetId = widget->GetEditorID(); + state.settings = widget->js; - if (ImGui::Begin(std::format("##Notification{}", (void*)¬if).c_str(), - nullptr, - ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking)) { - ImVec4 colorWithAlpha = notif.color; - colorWithAlpha.w *= alpha; - ImGui::PushStyleColor(ImGuiCol_Text, colorWithAlpha); - ImGui::TextUnformatted(notif.message.c_str()); - ImGui::PopStyleColor(); + undoStack.push_back(state); - yOffset += ImGui::GetWindowSize().y + 5.0f; - } - ImGui::End(); + if (undoStack.size() > maxUndoStates) { + undoStack.erase(undoStack.begin()); + } +} + +void EditorWindow::PerformUndo() +{ + if (undoStack.empty()) + return; + + UndoState state = undoStack.back(); + undoStack.pop_back(); - ImGui::PopStyleVar(2); + if (!state.widget) { + for (auto& w : weatherWidgets) { + if (w->GetEditorID() == state.widgetId) { + state.widget = w.get(); + break; } } - - void EditorWindow::RefreshJsonAttachmentCache(const std::vector& widgets) - { - for (auto* widget : widgets) { - if (!widget) { - continue; - } - if (!jsonAttachmentCache.contains(widget)) { - jsonAttachmentCache.emplace(widget, widget->HasSavedFile()); + if (!state.widget) { + for (auto& w : imageSpaceWidgets) { + if (w->GetEditorID() == state.widgetId) { + state.widget = w.get(); + break; } } } - - bool EditorWindow::HasCachedJsonAttachment(Widget * widget) const - { - if (!widget) { - return false; - } - if (auto it = jsonAttachmentCache.find(widget); it != jsonAttachmentCache.end()) { - return it->second; + if (!state.widget) { + for (auto& w : lightingTemplateWidgets) { + if (w->GetEditorID() == state.widgetId) { + state.widget = w.get(); + break; + } } - return false; } + } - void EditorWindow::InvalidateJsonAttachmentCache(Widget * widget) - { - if (widget) { - jsonAttachmentCache.erase(widget); - return; - } - jsonAttachmentCache.clear(); - } + if (state.widget) { + state.widget->js = state.settings; + state.widget->LoadSettings(); + state.widget->ApplyChanges(); + ShowNotification( + std::format("Undone changes to {}", state.widgetId), + Menu::GetSingleton()->GetSettings().Theme.StatusPalette.InfoColor, + 2.0f); + } +} - void EditorWindow::OnWidgetJsonAttachmentChanged(Widget * widget) - { - InvalidateJsonAttachmentCache(widget); - } +void EditorWindow::ShowNotification(const std::string& message, const ImVec4& color, float duration) +{ + // Guard against calls before ImGui is initialized + if (!ImGui::GetCurrentContext()) { + logger::warn("ShowNotification called before ImGui initialization: {}", message); + return; + } - void EditorWindow::AddToRecent(const std::string& widgetId, const std::string& category) - { - auto& categoryRecent = settings.recentWidgets[category]; + Notification notif; + notif.message = message; + notif.color = color; + notif.startTime = static_cast(ImGui::GetTime()); + notif.duration = duration; + notifications.push_back(notif); +} - // Remove if already exists - auto it = std::find(categoryRecent.begin(), categoryRecent.end(), widgetId); - if (it != categoryRecent.end()) { - categoryRecent.erase(it); - } +void EditorWindow::RenderNotifications() +{ + // Guard against calls before ImGui is initialized + if (!ImGui::GetCurrentContext()) { + return; + } - // Add to front - categoryRecent.insert(categoryRecent.begin(), widgetId); + float currentTime = static_cast(ImGui::GetTime()); + float yOffset = 10.0f; - // Limit size - if (categoryRecent.size() > static_cast(settings.maxRecentWidgets)) { - categoryRecent.resize(settings.maxRecentWidgets); - } + // Remove expired notifications + notifications.erase( + std::remove_if(notifications.begin(), notifications.end(), + [currentTime](const Notification& n) { return currentTime - n.startTime > n.duration; }), + notifications.end()); - Save(); + // Render active notifications + for (auto& notif : notifications) { + float elapsed = currentTime - notif.startTime; + float fadeStart = notif.duration - 0.5f; // Start fading 0.5s before end + float alpha = 1.0f; + + // Fade out in the last 0.5 seconds + if (elapsed > fadeStart) { + alpha = 1.0f - ((elapsed - fadeStart) / 0.5f); } - void EditorWindow::ToggleFavorite(const std::string& widgetId) - { - auto it = std::find(settings.favoriteWidgets.begin(), settings.favoriteWidgets.end(), widgetId); - if (it != settings.favoriteWidgets.end()) { - settings.favoriteWidgets.erase(it); - } else { - settings.favoriteWidgets.push_back(widgetId); - } - Save(); + // Position in top-left corner + ImGui::SetNextWindowPos(ImVec2(10.0f, yOffset), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.8f * alpha); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 5.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(15.0f, 10.0f)); + + if (ImGui::Begin(std::format("##Notification{}", (void*)¬if).c_str(), + nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking)) { + ImVec4 colorWithAlpha = notif.color; + colorWithAlpha.w *= alpha; + ImGui::PushStyleColor(ImGuiCol_Text, colorWithAlpha); + ImGui::TextUnformatted(notif.message.c_str()); + ImGui::PopStyleColor(); + + yOffset += ImGui::GetWindowSize().y + 5.0f; } + ImGui::End(); - bool EditorWindow::IsFavorite(const std::string& widgetId) const - { - return std::find(settings.favoriteWidgets.begin(), settings.favoriteWidgets.end(), widgetId) != settings.favoriteWidgets.end(); + ImGui::PopStyleVar(2); + } +} + +void EditorWindow::RefreshJsonAttachmentCache(const std::vector& widgets) +{ + for (auto* widget : widgets) { + if (!widget) { + continue; + } + if (!jsonAttachmentCache.contains(widget)) { + jsonAttachmentCache.emplace(widget, widget->HasSavedFile()); } + } +} - void EditorWindow::SaveSessionWidgets() - { - settings.lastOpenWidgets.clear(); +bool EditorWindow::HasCachedJsonAttachment(Widget* widget) const +{ + if (!widget) { + return false; + } + if (auto it = jsonAttachmentCache.find(widget); it != jsonAttachmentCache.end()) { + return it->second; + } + return false; +} - // Save all currently open widgets - for (auto& widget : weatherWidgets) { - if (widget->IsOpen()) { - settings.lastOpenWidgets.push_back(widget->GetEditorID()); - } - } - for (auto& widget : lightingTemplateWidgets) { - if (widget->IsOpen()) { - settings.lastOpenWidgets.push_back(widget->GetEditorID()); - } - } +void EditorWindow::InvalidateJsonAttachmentCache(Widget* widget) +{ + if (widget) { + jsonAttachmentCache.erase(widget); + return; + } + jsonAttachmentCache.clear(); +} + +void EditorWindow::OnWidgetJsonAttachmentChanged(Widget* widget) +{ + InvalidateJsonAttachmentCache(widget); +} + +void EditorWindow::AddToRecent(const std::string& widgetId, const std::string& category) +{ + auto& categoryRecent = settings.recentWidgets[category]; + + // Remove if already exists + auto it = std::find(categoryRecent.begin(), categoryRecent.end(), widgetId); + if (it != categoryRecent.end()) { + categoryRecent.erase(it); + } + + // Add to front + categoryRecent.insert(categoryRecent.begin(), widgetId); + + // Limit size + if (categoryRecent.size() > static_cast(settings.maxRecentWidgets)) { + categoryRecent.resize(settings.maxRecentWidgets); + } + + Save(); +} + +void EditorWindow::ToggleFavorite(const std::string& widgetId) +{ + auto it = std::find(settings.favoriteWidgets.begin(), settings.favoriteWidgets.end(), widgetId); + if (it != settings.favoriteWidgets.end()) { + settings.favoriteWidgets.erase(it); + } else { + settings.favoriteWidgets.push_back(widgetId); + } + Save(); +} + +bool EditorWindow::IsFavorite(const std::string& widgetId) const +{ + return std::find(settings.favoriteWidgets.begin(), settings.favoriteWidgets.end(), widgetId) != settings.favoriteWidgets.end(); +} + +void EditorWindow::SaveSessionWidgets() +{ + settings.lastOpenWidgets.clear(); - Save(); + // Save all currently open widgets + for (auto& widget : weatherWidgets) { + if (widget->IsOpen()) { + settings.lastOpenWidgets.push_back(widget->GetEditorID()); } + } + for (auto& widget : lightingTemplateWidgets) { + if (widget->IsOpen()) { + settings.lastOpenWidgets.push_back(widget->GetEditorID()); + } + } - void EditorWindow::RestoreSessionWidgets() - { - if (!settings.rememberOpenWidgets || settings.lastOpenWidgets.empty()) { - return; - } + Save(); +} - // Open widgets that were open in last session - for (const auto& widgetId : settings.lastOpenWidgets) { - // Search in all widget collections - for (auto& widget : weatherWidgets) { - if (widget->GetEditorID() == widgetId) { - widget->SetOpen(true); - break; - } - } - for (auto& widget : lightingTemplateWidgets) { - if (widget->GetEditorID() == widgetId) { - widget->SetOpen(true); - break; - } - } +void EditorWindow::RestoreSessionWidgets() +{ + if (!settings.rememberOpenWidgets || settings.lastOpenWidgets.empty()) { + return; + } + + // Open widgets that were open in last session + for (const auto& widgetId : settings.lastOpenWidgets) { + // Search in all widget collections + for (auto& widget : weatherWidgets) { + if (widget->GetEditorID() == widgetId) { + widget->SetOpen(true); + break; + } + } + for (auto& widget : lightingTemplateWidgets) { + if (widget->GetEditorID() == widgetId) { + widget->SetOpen(true); + break; } } + } +} From 841dcc3560a600c4bee2a7c9ecdfecf77725c31a Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:17:36 -0700 Subject: [PATCH 06/36] AI comments --- src/SceneSettingsManager.cpp | 31 +++++++++++++++++++++------- src/WeatherEditor/TimeOfDayPanel.cpp | 8 +++++-- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 16df05f547..a1ab4a0aeb 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -5,6 +5,7 @@ #include "Utils/FileSystem.h" #include "Utils/Game.h" +#include #include #include #include @@ -778,23 +779,31 @@ void SceneSettingsManager::ApplyTimeOfDayBlended() if (type == SettingType::Float) { float baseVal = baseline->get(); + if (!std::isfinite(baseVal)) + baseVal = 0.0f; float result = 0.0f; float coveredFactor = 0.0f; for (auto& pr : periodRefs) { float f = factors[pr.periodIdx]; if (f > 0.0f) { - result += f * pr.value->get(); + float periodVal = pr.value->get(); + if (!std::isfinite(periodVal)) + periodVal = 0.0f; + result += f * periodVal; coveredFactor += f; } } result += (1.0f - coveredFactor) * baseVal; - // Epsilon comparison — skip if the float barely changed - auto& cachedFloat = lastAppliedTODFloats[shortName][key]; - if (std::abs(cachedFloat - result) < kBlendEpsilon) + // Epsilon comparison — skip if the float barely changed. + // Use find() first to avoid default-inserting 0.0f, which would + // cause the first apply to be skipped when result ≈ 0. + auto& featureFloats = lastAppliedTODFloats[shortName]; + auto floatIt = featureFloats.find(key); + if (floatIt != featureFloats.end() && std::abs(floatIt->second - result) < kBlendEpsilon) continue; - cachedFloat = result; + featureFloats[key] = result; dirtyKeys.emplace_back(key, result); } else { // Non-float: snap to dominant period's value, or baseline if none @@ -903,10 +912,18 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) entry.source = EntrySource::User; // Parse period for TimeOfDay entries - if (type == SceneType::TimeOfDay && item.contains("period")) { + if (type == SceneType::TimeOfDay) { + if (!item.contains("period")) { + logger::warn("SceneSettingsManager: TimeOfDay entry for feature '{}' key '{}' is missing 'period' — skipping to avoid ghost entry", + entry.featureShortName, entry.settingKey); + continue; + } entry.period = GetPeriodFromName(item["period"].get()); - if (entry.period == TimeOfDayPeriod::Count) + if (entry.period == TimeOfDayPeriod::Count) { + logger::warn("SceneSettingsManager: TimeOfDay entry for feature '{}' key '{}' has invalid period '{}' — skipping", + entry.featureShortName, entry.settingKey, item["period"].get()); continue; // Invalid period name + } } if (!Feature::FindFeatureByShortName(entry.featureShortName)) diff --git a/src/WeatherEditor/TimeOfDayPanel.cpp b/src/WeatherEditor/TimeOfDayPanel.cpp index f1383bdb7f..26109891bf 100644 --- a/src/WeatherEditor/TimeOfDayPanel.cpp +++ b/src/WeatherEditor/TimeOfDayPanel.cpp @@ -1,5 +1,7 @@ #include "TimeOfDayPanel.h" +#include + #include "../Globals.h" #include "../Menu.h" #include "../Menu/ThemeManager.h" @@ -185,8 +187,10 @@ namespace TimeOfDayPanel { float val = entry.value.get(); ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); - if (ImGui::InputFloat("##val", &val, 0.01f, 0.1f, "%.3f")) - manager->UpdateEntryValue(kSceneType, index, val, true); + if (ImGui::InputFloat("##val", &val, 0.01f, 0.1f, "%.3f")) { + if (std::isfinite(val)) + manager->UpdateEntryValue(kSceneType, index, val, true); + } if (ImGui::IsItemDeactivatedAfterEdit()) manager->SaveUserSettings(kSceneType); } From 64ad0f7d7a5656f5e16423dacb9cdbbe31697095 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:27:08 -0700 Subject: [PATCH 07/36] AI comments --- src/SceneSettingsManager.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index a1ab4a0aeb..ab6470205f 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include // --- Path Resolution --- @@ -403,8 +402,12 @@ void SceneSettingsManager::UpdateEntryValue(SceneType type, size_t index, const // For TimeOfDay, recompute blended values; for others, apply directly if (type == SceneType::TimeOfDay) { - if (isTimeOfDayActive) + if (isTimeOfDayActive) { + // Reset the hour throttle so a user edit (e.g. slider drag) is + // applied immediately rather than waiting for the game clock to advance. + lastBlendedHour = -1.0f; ApplyTimeOfDayBlended(); + } } else if (isCurrentlyApplied && !vec[index].paused && !IsFeaturePaused(vec[index].featureShortName)) { if (vec[index].source == EntrySource::Overwrite || !HasActiveOverwrite(type, vec[index].featureShortName, vec[index].settingKey)) From c483ad0799a14570a09e231f0d2f49b2c2ef0e5a Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:38:26 -0700 Subject: [PATCH 08/36] AI comments --- src/SceneSettingsManager.cpp | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index ab6470205f..1fef281f95 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -350,8 +350,14 @@ void SceneSettingsManager::DeleteAllOverwrites(SceneType type) auto& vec = GetEntriesMut(type); for (const auto& entry : vec) { - if (entry.source == EntrySource::Overwrite && !entry.sourceFilename.empty()) - std::filesystem::remove(overwritesPath / entry.sourceFilename, ec); + if (entry.source == EntrySource::Overwrite && !entry.sourceFilename.empty()) { + // TOD overwrites live in per-period subfolders; use the same path + // construction as SaveOverwritesToDisk to ensure we hit the right file. + auto filepath = (type == SceneType::TimeOfDay && entry.period != TimeOfDayPeriod::Count) + ? overwritesPath / GetPeriodName(entry.period) / entry.sourceFilename + : overwritesPath / entry.sourceFilename; + std::filesystem::remove(filepath, ec); + } } std::erase_if(vec, [](const SettingEntry& e) { @@ -781,6 +787,10 @@ void SceneSettingsManager::ApplyTimeOfDayBlended() auto type = DetectSettingType(*baseline); if (type == SettingType::Float) { + if (!baseline->is_number()) { + logger::warn("SceneSettingsManager: TOD baseline for '{}' key '{}' is not numeric — skipping", shortName, key); + continue; + } float baseVal = baseline->get(); if (!std::isfinite(baseVal)) baseVal = 0.0f; @@ -790,6 +800,11 @@ void SceneSettingsManager::ApplyTimeOfDayBlended() for (auto& pr : periodRefs) { float f = factors[pr.periodIdx]; if (f > 0.0f) { + if (!pr.value->is_number()) { + logger::warn("SceneSettingsManager: TOD period value for '{}' key '{}' is not numeric — treating as 0", shortName, key); + coveredFactor += f; + continue; + } float periodVal = pr.value->get(); if (!std::isfinite(periodVal)) periodVal = 0.0f; @@ -921,6 +936,11 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) entry.featureShortName, entry.settingKey); continue; } + if (!item["period"].is_string()) { + logger::warn("SceneSettingsManager: TimeOfDay entry for feature '{}' key '{}' has non-string 'period' (type: {}) — skipping", + entry.featureShortName, entry.settingKey, item["period"].type_name()); + continue; + } entry.period = GetPeriodFromName(item["period"].get()); if (entry.period == TimeOfDayPeriod::Count) { logger::warn("SceneSettingsManager: TimeOfDay entry for feature '{}' key '{}' has invalid period '{}' — skipping", From b1fd6b8fdb6c9c3770c62645f6e1799eea389317 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:38:54 +0000 Subject: [PATCH 09/36] =?UTF-8?q?style:=20=F0=9F=8E=A8=20apply=20pre-commi?= =?UTF-8?q?t.ci=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated formatting by clang-format, prettier, and other hooks. See https://pre-commit.ci for details. --- src/SceneSettingsManager.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 1fef281f95..b70ee29c58 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -353,9 +353,7 @@ void SceneSettingsManager::DeleteAllOverwrites(SceneType type) if (entry.source == EntrySource::Overwrite && !entry.sourceFilename.empty()) { // TOD overwrites live in per-period subfolders; use the same path // construction as SaveOverwritesToDisk to ensure we hit the right file. - auto filepath = (type == SceneType::TimeOfDay && entry.period != TimeOfDayPeriod::Count) - ? overwritesPath / GetPeriodName(entry.period) / entry.sourceFilename - : overwritesPath / entry.sourceFilename; + auto filepath = (type == SceneType::TimeOfDay && entry.period != TimeOfDayPeriod::Count) ? overwritesPath / GetPeriodName(entry.period) / entry.sourceFilename : overwritesPath / entry.sourceFilename; std::filesystem::remove(filepath, ec); } } From 56e5bdf97723fa2baf0d58b3874d6385e785d31b Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:41:04 -0700 Subject: [PATCH 10/36] AI comments --- src/WeatherEditor/TimeOfDayPanel.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/WeatherEditor/TimeOfDayPanel.cpp b/src/WeatherEditor/TimeOfDayPanel.cpp index 26109891bf..8f1f06a0cd 100644 --- a/src/WeatherEditor/TimeOfDayPanel.cpp +++ b/src/WeatherEditor/TimeOfDayPanel.cpp @@ -185,7 +185,9 @@ namespace TimeOfDayPanel break; case SceneSettingsManager::SettingType::Float: { - float val = entry.value.get(); + float val = entry.value.is_number() ? entry.value.get() : 0.0f; + if (!std::isfinite(val)) + val = 0.0f; ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); if (ImGui::InputFloat("##val", &val, 0.01f, 0.1f, "%.3f")) { if (std::isfinite(val)) From 91c6e2ff235294cef201aefbdbd985f839085e29 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:50:17 -0700 Subject: [PATCH 11/36] AI comments --- src/SceneSettingsManager.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index b70ee29c58..37b146a723 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -822,11 +822,20 @@ void SceneSettingsManager::ApplyTimeOfDayBlended() featureFloats[key] = result; dirtyKeys.emplace_back(key, result); } else { - // Non-float: snap to dominant period's value, or baseline if none + // Non-float: snap to dominant period's value, or baseline if none. + // Validate that the period value type matches the baseline to avoid + // passing a mismatched type into feature->LoadSettings(). json blendedValue = *baseline; - for (auto& pr : periodRefs) - if (static_cast(pr.periodIdx) == dominant) - blendedValue = *pr.value; + for (auto& pr : periodRefs) { + if (static_cast(pr.periodIdx) == dominant) { + if (pr.value->type() == baseline->type()) { + blendedValue = *pr.value; + } else { + logger::warn("SceneSettingsManager: TOD period value for '{}' key '{}' has type '{}' but baseline expects '{}' — using baseline", + shortName, key, pr.value->type_name(), baseline->type_name()); + } + } + } // Exact comparison for non-float (bools, ints snap — rarely change) auto& cachedOther = lastAppliedTODOther[shortName][key]; From 4785cdc9607bc283e34f5d6fb24cf3f41472365a Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:07:46 -0700 Subject: [PATCH 12/36] code consolidation --- src/SceneSettingsManager.cpp | 105 ++++----- src/SceneSettingsManager.h | 38 +++- src/WeatherEditor/EditorWindow.cpp | 17 +- src/WeatherEditor/InteriorOnlyPanel.cpp | 241 ++------------------- src/WeatherEditor/InteriorOnlyPanel.h | 10 +- src/WeatherEditor/SceneSettingsUI.cpp | 277 ++++++++++++++++++++++++ src/WeatherEditor/SceneSettingsUI.h | 71 ++++++ src/WeatherEditor/TimeOfDayPanel.cpp | 267 ++--------------------- src/WeatherEditor/TimeOfDayPanel.h | 6 +- 9 files changed, 475 insertions(+), 557 deletions(-) create mode 100644 src/WeatherEditor/SceneSettingsUI.cpp create mode 100644 src/WeatherEditor/SceneSettingsUI.h diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 37b146a723..665558a4a3 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -38,9 +38,8 @@ std::filesystem::path SceneSettingsManager::GetOverwritesPath(SceneType type) const char* SceneSettingsManager::GetPeriodName(TimeOfDayPeriod period) { - static const char* names[] = { "Dawn", "Sunrise", "Day", "Sunset", "Dusk", "Night" }; int idx = static_cast(period); - return (idx >= 0 && idx < kPeriodCount) ? names[idx] : "Unknown"; + return (idx >= 0 && idx < kPeriodCount) ? kPeriodNames[idx] : "Unknown"; } SceneSettingsManager::TimeOfDayPeriod SceneSettingsManager::GetPeriodFromName(const std::string& name) @@ -756,11 +755,6 @@ void SceneSettingsManager::ApplyTimeOfDayBlended() auto dominant = static_cast(bestIdx); // Group active entries by feature, using pointers to avoid JSON copies - struct PeriodRef - { - int periodIdx; - const json* value; - }; std::map>> featureSettings; for (const auto& entry : GetEntries(SceneType::TimeOfDay)) { if (!IsEntryActive(entry) || entry.period == TimeOfDayPeriod::Count) @@ -770,21 +764,14 @@ void SceneSettingsManager::ApplyTimeOfDayBlended() } for (auto& [shortName, settingsMap] : featureSettings) { - // Compute blended values and check which keys actually changed std::vector> dirtyKeys; for (auto& [key, periodRefs] : settingsMap) { - // Get baseline value (saved once at activation, never changes) - const json* baseline = nullptr; - auto baseIt = savedTimeOfDayBaseline.find(shortName); - if (baseIt != savedTimeOfDayBaseline.end() && baseIt->second.contains(key)) - baseline = &baseIt->second[key]; + const json* baseline = FindTODBaseline(shortName, key); if (!baseline) continue; - auto type = DetectSettingType(*baseline); - - if (type == SettingType::Float) { + if (DetectSettingType(*baseline) == SettingType::Float) { if (!baseline->is_number()) { logger::warn("SceneSettingsManager: TOD baseline for '{}' key '{}' is not numeric — skipping", shortName, key); continue; @@ -792,25 +779,8 @@ void SceneSettingsManager::ApplyTimeOfDayBlended() float baseVal = baseline->get(); if (!std::isfinite(baseVal)) baseVal = 0.0f; - float result = 0.0f; - float coveredFactor = 0.0f; - - for (auto& pr : periodRefs) { - float f = factors[pr.periodIdx]; - if (f > 0.0f) { - if (!pr.value->is_number()) { - logger::warn("SceneSettingsManager: TOD period value for '{}' key '{}' is not numeric — treating as 0", shortName, key); - coveredFactor += f; - continue; - } - float periodVal = pr.value->get(); - if (!std::isfinite(periodVal)) - periodVal = 0.0f; - result += f * periodVal; - coveredFactor += f; - } - } - result += (1.0f - coveredFactor) * baseVal; + + float result = BlendFloatForPeriods(baseVal, periodRefs, factors, shortName, key); // Epsilon comparison — skip if the float barely changed. // Use find() first to avoid default-inserting 0.0f, which would @@ -822,20 +792,7 @@ void SceneSettingsManager::ApplyTimeOfDayBlended() featureFloats[key] = result; dirtyKeys.emplace_back(key, result); } else { - // Non-float: snap to dominant period's value, or baseline if none. - // Validate that the period value type matches the baseline to avoid - // passing a mismatched type into feature->LoadSettings(). - json blendedValue = *baseline; - for (auto& pr : periodRefs) { - if (static_cast(pr.periodIdx) == dominant) { - if (pr.value->type() == baseline->type()) { - blendedValue = *pr.value; - } else { - logger::warn("SceneSettingsManager: TOD period value for '{}' key '{}' has type '{}' but baseline expects '{}' — using baseline", - shortName, key, pr.value->type_name(), baseline->type_name()); - } - } - } + json blendedValue = SnapNonFloatToDominant(*baseline, periodRefs, dominant, shortName, key); // Exact comparison for non-float (bools, ints snap — rarely change) auto& cachedOther = lastAppliedTODOther[shortName][key]; @@ -868,6 +825,56 @@ void SceneSettingsManager::ApplyTimeOfDayBlended() lastDominantPeriod = dominant; } +const json* SceneSettingsManager::FindTODBaseline(const std::string& shortName, const std::string& key) const +{ + auto baseIt = savedTimeOfDayBaseline.find(shortName); + if (baseIt != savedTimeOfDayBaseline.end() && baseIt->second.contains(key)) + return &baseIt->second[key]; + return nullptr; +} + +float SceneSettingsManager::BlendFloatForPeriods(float baseVal, const std::vector& periodRefs, + const float* factors, const std::string& shortName, const std::string& key) const +{ + float result = 0.0f; + float coveredFactor = 0.0f; + + for (auto& pr : periodRefs) { + float f = factors[pr.periodIdx]; + if (f > 0.0f) { + if (!pr.value->is_number()) { + logger::warn("SceneSettingsManager: TOD period value for '{}' key '{}' is not numeric — treating as 0", shortName, key); + coveredFactor += f; + continue; + } + float periodVal = pr.value->get(); + if (!std::isfinite(periodVal)) + periodVal = 0.0f; + result += f * periodVal; + coveredFactor += f; + } + } + + return result + (1.0f - coveredFactor) * baseVal; +} + +json SceneSettingsManager::SnapNonFloatToDominant(const json& baseline, const std::vector& periodRefs, + TimeOfDayPeriod dominant, const std::string& shortName, const std::string& key) const +{ + json blendedValue = baseline; + for (auto& pr : periodRefs) { + if (static_cast(pr.periodIdx) == dominant) { + if (pr.value->type() == baseline.type()) { + blendedValue = *pr.value; + } else { + logger::warn("SceneSettingsManager: TOD period value for '{}' key '{}' has type '{}' but baseline expects '{}' — using baseline", + shortName, key, pr.value->type_name(), baseline.type_name()); + } + } + } + return blendedValue; +} + // --- Persistence --- void SceneSettingsManager::SaveUserSettings(SceneType type) diff --git a/src/SceneSettingsManager.h b/src/SceneSettingsManager.h index 3b2b76af39..7e0b8b0341 100644 --- a/src/SceneSettingsManager.h +++ b/src/SceneSettingsManager.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -47,8 +48,16 @@ class SceneSettingsManager Count }; + /// Number of time-of-day periods (avoids repeated static_cast). + static constexpr int kPeriodCount = static_cast(TimeOfDayPeriod::Count); + + /// Display names for each period — must match TimeOfDayPeriod order. + static constexpr std::array kPeriodNames = { + "Dawn", "Sunrise", "Day", "Sunset", "Dusk", "Night" + }; + /// Hour boundaries for each period [start, end). Night wraps around midnight (21–28 i.e. 21–4). - static constexpr float kPeriodHours[6][2] = { + static constexpr float kPeriodHours[kPeriodCount][2] = { { 4.0f, 6.0f }, // Dawn { 6.0f, 8.0f }, // Sunrise { 8.0f, 17.0f }, // Day @@ -60,9 +69,6 @@ class SceneSettingsManager /// Transition blend zone in hours at each period boundary. static constexpr float kTransitionHours = 0.5f; - /// Number of time-of-day periods (avoids repeated static_cast). - static constexpr int kPeriodCount = static_cast(TimeOfDayPeriod::Count); - // --- Event Handler --- /// Listens for LoadingMenu close to detect cell transitions. @@ -273,6 +279,30 @@ class SceneSettingsManager void RevertTimeOfDayBaseline(); void ApplyTimeOfDayBlended(); + // --- Time of Day blending helpers --- + + /// Lightweight ref to a TOD period entry, used during blending + /// to avoid copying JSON values from the entry storage. + struct PeriodRef + { + int periodIdx; + const json* value; + }; + + /// Look up the saved baseline value for a feature+key pair. + /// @return Pointer to the baseline JSON, or nullptr if not found. + const json* FindTODBaseline(const std::string& shortName, const std::string& key) const; + + /// Compute a weighted blend of float values across active TOD periods. + /// Uncovered periods fall back to @p baseVal so the sum is always complete. + float BlendFloatForPeriods(float baseVal, const std::vector& periodRefs, + const float* factors, const std::string& shortName, const std::string& key) const; + + /// Select the non-float value from the dominant period with type validation. + /// Falls back to @p baseline if no matching period or on type mismatch. + json SnapNonFloatToDominant(const json& baseline, const std::vector& periodRefs, + TimeOfDayPeriod dominant, const std::string& shortName, const std::string& key) const; + // --- Overwrite discovery helper --- void DiscoverOverwritesInDir(SceneType type, const std::filesystem::path& dir, TimeOfDayPeriod period = TimeOfDayPeriod::Count); diff --git a/src/WeatherEditor/EditorWindow.cpp b/src/WeatherEditor/EditorWindow.cpp index 818f441ebc..bba2169333 100644 --- a/src/WeatherEditor/EditorWindow.cpp +++ b/src/WeatherEditor/EditorWindow.cpp @@ -4,6 +4,7 @@ #include "InteriorOnlyPanel.h" #include "Menu.h" #include "PaletteWindow.h" +#include "SceneSettingsUI.h" #include "State.h" #include "TimeOfDayPanel.h" #include "Utils/UI.h" @@ -222,21 +223,9 @@ void EditorWindow::ShowObjectsWindow() // Interior Only / Time of Day categories have their own panels if (ImGui::BeginChild("##ObjectsContent", { 0, 0 }, ImGuiChildFlags_Border, kStickyHeaderFlags)) { - // Interior Only category has its own panel - if (m_selectedCategory == "Interior Only") { - InteriorOnlyPanel::Draw(); - ImGui::EndChild(); - ImGui::EndTable(); - ImGui::End(); + if (SceneSettingsUI::DrawCategoryPanel("Interior Only", m_selectedCategory, InteriorOnlyPanel::Draw) || + SceneSettingsUI::DrawCategoryPanel("Time of Day", m_selectedCategory, TimeOfDayPanel::Draw)) return; - } - if (m_selectedCategory == "Time of Day") { - TimeOfDayPanel::Draw(); - ImGui::EndChild(); - ImGui::EndTable(); - ImGui::End(); - return; - } // Display current active weather auto sky = globals::game::sky; diff --git a/src/WeatherEditor/InteriorOnlyPanel.cpp b/src/WeatherEditor/InteriorOnlyPanel.cpp index 11920707cb..4dcede45fb 100644 --- a/src/WeatherEditor/InteriorOnlyPanel.cpp +++ b/src/WeatherEditor/InteriorOnlyPanel.cpp @@ -2,9 +2,8 @@ #include "../Globals.h" #include "../Menu.h" -#include "../Menu/ThemeManager.h" #include "../SceneSettingsManager.h" -#include "EditorWindow.h" +#include "SceneSettingsUI.h" namespace InteriorOnlyPanel { @@ -12,210 +11,12 @@ namespace InteriorOnlyPanel using EntrySource = SceneSettingsManager::EntrySource; static constexpr auto kSceneType = SceneType::InteriorOnly; - // Layout constants from centralized theme - using C = ThemeManager::Constants; - - // Persistent state for the "Add Setting" workflow - static int selectedFeatureIdx = -1; - static int selectedSettingIdx = -1; - static std::vector cachedFeatureNames; - static std::vector cachedSettingKeys; - - // Confirmation popups - static Util::ConfirmationPopup deleteAllOverwritesPopup{ - "Delete All Overwrites?", + // Shared UI state + static SceneSettingsUI::AddSettingState addState; + static SceneSettingsUI::PopupState popups{ "Are you sure you want to delete all interior-only overwrite files?\nThis cannot be undone.", - "Delete All" - }; - - static Util::ConfirmationPopup deleteSingleOverwritePopup{ - "Delete Overwrite File?", - "", - "Delete" + "Are you sure you want to remove all user-added interior-only settings?" }; - static size_t pendingDeleteIndex = SIZE_MAX; - - static Util::ConfirmationPopup deleteAllUserPopup{ - "Delete All User Settings?", - "Are you sure you want to remove all user-added interior-only settings?", - "Delete All" - }; - - void DrawAddSettingUI() - { - auto* manager = SceneSettingsManager::GetSingleton(); - - ImGui::Spacing(); - - // Feature dropdown - if (cachedFeatureNames.empty()) - cachedFeatureNames = SceneSettingsManager::GetInteriorRelevantFeatureNames(); - - const char* featurePreview = (selectedFeatureIdx >= 0 && selectedFeatureIdx < static_cast(cachedFeatureNames.size())) ? cachedFeatureNames[selectedFeatureIdx].c_str() : "Select Feature..."; - - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_FEATURE_DROPDOWN_RATIO); - if (ImGui::BeginCombo("##FeatureSelect", featurePreview)) { - for (int i = 0; i < static_cast(cachedFeatureNames.size()); ++i) { - bool selected = (i == selectedFeatureIdx); - if (ImGui::Selectable(cachedFeatureNames[i].c_str(), selected)) { - selectedFeatureIdx = i; - selectedSettingIdx = -1; - cachedSettingKeys = SceneSettingsManager::GetFeatureSettingKeys(cachedFeatureNames[i]); - } - if (selected) - ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - - ImGui::SameLine(); - - // Setting dropdown (only if feature is selected) - { - auto _ = Util::DisableGuard(selectedFeatureIdx < 0); - - const char* settingPreview = (selectedSettingIdx >= 0 && selectedSettingIdx < static_cast(cachedSettingKeys.size())) ? cachedSettingKeys[selectedSettingIdx].c_str() : "Select Setting..."; - - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_SETTING_DROPDOWN_RATIO); - if (ImGui::BeginCombo("##SettingSelect", settingPreview)) { - for (int i = 0; i < static_cast(cachedSettingKeys.size()); ++i) { - bool selected = (i == selectedSettingIdx); - bool alreadyAdded = selectedFeatureIdx >= 0 && - manager->HasEntryFromSource(kSceneType, cachedFeatureNames[selectedFeatureIdx], cachedSettingKeys[i], EntrySource::User); - if (alreadyAdded) { - ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_TextDisabled]); - ImGui::Selectable(cachedSettingKeys[i].c_str(), false, ImGuiSelectableFlags_Disabled); - ImGui::PopStyleColor(); - } else { - if (ImGui::Selectable(cachedSettingKeys[i].c_str(), selected)) - selectedSettingIdx = i; - } - - if (selected) - ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - } - - ImGui::SameLine(); - - // Add button - bool canAdd = selectedFeatureIdx >= 0 && selectedSettingIdx >= 0; - { - auto _ = Util::DisableGuard(!canAdd); - if (ImGui::Button("Add")) { - auto& featureName = cachedFeatureNames[selectedFeatureIdx]; - auto& settingKey = cachedSettingKeys[selectedSettingIdx]; - auto currentValue = SceneSettingsManager::GetFeatureSettingValue(featureName, settingKey); - - manager->AddSetting(kSceneType, featureName, settingKey, currentValue); - selectedSettingIdx = -1; - return; - } - } - } - - void DrawSettingEntry(size_t index) - { - auto* manager = SceneSettingsManager::GetSingleton(); - const auto& entries = manager->GetEntries(kSceneType); - if (index >= entries.size()) - return; - - const auto& entry = entries[index]; - - ImGui::PushID(static_cast(index)); - - // Feature.Setting label - float availWidth = ImGui::GetContentRegionAvail().x; - ImGui::Text("%s.%s", entry.featureShortName.c_str(), entry.settingKey.c_str()); - - // Value display/editor on same line (right-aligned) - ImGui::SameLine(availWidth * C::SCENE_VALUE_LABEL_OFFSET_RATIO); - - bool isOverwrite = entry.source == EntrySource::Overwrite; - auto type = SceneSettingsManager::DetectSettingType(entry.value); - - // Overwrites are read-only; user entries overridden by an active overwrite are also disabled - bool readOnly = isOverwrite || - manager->HasActiveOverwrite(kSceneType, entry.featureShortName, entry.settingKey); - - if (readOnly) - ImGui::BeginDisabled(); - - switch (type) { - case SceneSettingsManager::SettingType::Boolean: - { - bool val = entry.value.is_boolean() ? entry.value.get() : (entry.value.get() != 0); - if (ImGui::Checkbox("##val", &val)) { - // Preserve original JSON type (integer for GPU constant buffer settings, boolean otherwise) - if (entry.value.is_boolean()) - manager->UpdateEntryValue(kSceneType, index, val); - else - manager->UpdateEntryValue(kSceneType, index, val ? 1 : 0); - } - } - break; - case SceneSettingsManager::SettingType::Float: - { - float val = entry.value.get(); - ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); - if (ImGui::InputFloat("##val", &val, 0.01f, 0.1f, "%.3f")) - manager->UpdateEntryValue(kSceneType, index, val, true); - if (ImGui::IsItemDeactivatedAfterEdit()) - manager->SaveUserSettings(kSceneType); - } - break; - case SceneSettingsManager::SettingType::Integer: - { - int val = entry.value.get(); - ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); - if (ImGui::InputInt("##val", &val)) - manager->UpdateEntryValue(kSceneType, index, val, true); - if (ImGui::IsItemDeactivatedAfterEdit()) - manager->SaveUserSettings(kSceneType); - } - break; - default: - ImGui::TextDisabled("(unsupported type)"); - break; - } - - if (readOnly) - ImGui::EndDisabled(); - - // Active/Pause toggle - ImGui::SameLine(); - bool active = !entry.paused; - if (Util::FeatureToggle("##active", &active)) - manager->TogglePauseEntry(kSceneType, index); - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text(entry.paused ? "Paused - click to resume" : "Active - click to pause"); - - // Delete button - ImGui::SameLine(); - { - auto styledButton = Util::ErrorButtonStyle(); - if (ImGui::Button("X", ImVec2(C::SCENE_DELETE_BUTTON_WIDTH, 0))) { - if (entry.source == EntrySource::Overwrite) { - pendingDeleteIndex = index; - deleteSingleOverwritePopup.message = std::format( - "Delete overwrite file '{}'?\nThis will permanently remove the file from disk.", - entry.sourceFilename); - deleteSingleOverwritePopup.Request(); - } else { - manager->RemoveSetting(kSceneType, index); - ImGui::PopID(); - return; - } - } - } - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text(entry.source == EntrySource::Overwrite ? "Delete overwrite file from disk" : "Remove this setting"); - - ImGui::PopID(); - } void Draw() { @@ -223,26 +24,11 @@ namespace InteriorOnlyPanel const auto& entries = manager->GetEntries(kSceneType); auto& theme = globals::menu->GetSettings().Theme; - // Header ImGui::Text("Interior Only Settings"); - ImGui::Separator(); - // Draw confirmation popups - if (deleteAllOverwritesPopup.Draw()) - manager->DeleteAllOverwrites(kSceneType); - - if (deleteSingleOverwritePopup.Draw()) { - if (pendingDeleteIndex < entries.size()) - manager->RemoveSetting(kSceneType, pendingDeleteIndex); - pendingDeleteIndex = SIZE_MAX; - } - - if (deleteAllUserPopup.Draw()) - manager->DeleteAllUserSettings(kSceneType); - - // Add setting UI (always visible) - DrawAddSettingUI(); + SceneSettingsUI::DrawPopups(kSceneType, popups); + SceneSettingsUI::DrawAddSettingUI(kSceneType, addState); // Collect indices by source std::vector overwriteIndices, userIndices; @@ -267,7 +53,7 @@ namespace InteriorOnlyPanel return; } - // --- Overwrite Files Section --- + // Overwrite section with controls if (!overwriteIndices.empty()) { ImGui::Spacing(); ImGui::TextColored(theme.StatusPalette.InfoColor, "Overwrite Files"); @@ -279,15 +65,15 @@ namespace InteriorOnlyPanel ImGui::SameLine(); if (ImGui::SmallButton("Delete All")) - deleteAllOverwritesPopup.Request(); + popups.deleteAllOverwrites.Request(); ImGui::Separator(); for (auto i : overwriteIndices) - DrawSettingEntry(i); + SceneSettingsUI::DrawSettingEntry(kSceneType, i, popups); } - // --- User Settings Section (header only shown when overwrites also present) --- + // User section with controls if (!userIndices.empty()) { if (!overwriteIndices.empty()) { ImGui::Spacing(); @@ -301,16 +87,15 @@ namespace InteriorOnlyPanel ImGui::SameLine(); if (ImGui::SmallButton("Delete All##user")) - deleteAllUserPopup.Request(); + popups.deleteAllUser.Request(); if (!overwriteIndices.empty()) ImGui::Separator(); for (auto i : userIndices) { - // Re-check bounds: a prior inline deletion may have shrunk the entries vector if (i >= manager->GetEntries(kSceneType).size()) break; - DrawSettingEntry(i); + SceneSettingsUI::DrawSettingEntry(kSceneType, i, popups); } } } diff --git a/src/WeatherEditor/InteriorOnlyPanel.h b/src/WeatherEditor/InteriorOnlyPanel.h index a282feb358..26e898e822 100644 --- a/src/WeatherEditor/InteriorOnlyPanel.h +++ b/src/WeatherEditor/InteriorOnlyPanel.h @@ -1,17 +1,9 @@ #pragma once -#include "Utils/UI.h" - /// UI panel for managing Interior Only scene settings within the Weather Editor. -/// Renders the list of entries with add/pause/delete controls. +/// Rendering delegates to shared SceneSettingsUI utilities. namespace InteriorOnlyPanel { /// Draw the full Interior Only settings panel (right column of the objects window) void Draw(); - - /// Draw the "add new setting" UI (feature dropdown + setting dropdown + confirm) - void DrawAddSettingUI(); - - /// Draw a single setting entry row - void DrawSettingEntry(size_t index); } diff --git a/src/WeatherEditor/SceneSettingsUI.cpp b/src/WeatherEditor/SceneSettingsUI.cpp new file mode 100644 index 0000000000..fc72ef244c --- /dev/null +++ b/src/WeatherEditor/SceneSettingsUI.cpp @@ -0,0 +1,277 @@ +#include "SceneSettingsUI.h" + +#include + +#include "../Globals.h" +#include "../Menu.h" +#include "../Menu/ThemeManager.h" +#include "../SceneSettingsManager.h" + +namespace SceneSettingsUI +{ + using C = ThemeManager::Constants; + + // --- Feature name resolution by scene type --- + + static std::vector GetFeatureNamesForType(SceneType type) + { + return (type == SceneType::InteriorOnly) + ? SceneSettingsManager::GetInteriorRelevantFeatureNames() + : SceneSettingsManager::GetExteriorRelevantFeatureNames(); + } + + // --- Duplicate checking by scene type --- + + static bool IsAlreadyAdded(SceneType type, const std::string& feature, const std::string& key, Period period) + { + auto* manager = SceneSettingsManager::GetSingleton(); + return (type == SceneType::TimeOfDay) + ? manager->HasEntryForPeriod(feature, key, period, EntrySource::User) + : manager->HasEntryFromSource(type, feature, key, EntrySource::User); + } + + // --- Shared Drawing --- + + void DrawAddSettingUI(SceneType type, AddSettingState& state, Period period) + { + auto* manager = SceneSettingsManager::GetSingleton(); + + ImGui::Spacing(); + + // Feature dropdown + if (state.cachedFeatureNames.empty()) + state.cachedFeatureNames = GetFeatureNamesForType(type); + + const char* featurePreview = (state.selectedFeatureIdx >= 0 && + state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) + ? state.cachedFeatureNames[state.selectedFeatureIdx].c_str() + : "Select Feature..."; + + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_FEATURE_DROPDOWN_RATIO); + if (ImGui::BeginCombo("##FeatureSelect", featurePreview)) { + for (int i = 0; i < static_cast(state.cachedFeatureNames.size()); ++i) { + bool selected = (i == state.selectedFeatureIdx); + if (ImGui::Selectable(state.cachedFeatureNames[i].c_str(), selected)) { + state.selectedFeatureIdx = i; + state.selectedSettingIdx = -1; + state.cachedSettingKeys = SceneSettingsManager::GetFeatureSettingKeys(state.cachedFeatureNames[i]); + } + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + ImGui::SameLine(); + + // Setting dropdown + { + auto _ = Util::DisableGuard(state.selectedFeatureIdx < 0); + + const char* settingPreview = (state.selectedSettingIdx >= 0 && + state.selectedSettingIdx < static_cast(state.cachedSettingKeys.size())) + ? state.cachedSettingKeys[state.selectedSettingIdx].c_str() + : "Select Setting..."; + + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_SETTING_DROPDOWN_RATIO); + if (ImGui::BeginCombo("##SettingSelect", settingPreview)) { + for (int i = 0; i < static_cast(state.cachedSettingKeys.size()); ++i) { + bool selected = (i == state.selectedSettingIdx); + bool alreadyAdded = state.selectedFeatureIdx >= 0 && + IsAlreadyAdded(type, state.cachedFeatureNames[state.selectedFeatureIdx], + state.cachedSettingKeys[i], period); + if (alreadyAdded) { + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_TextDisabled]); + ImGui::Selectable(state.cachedSettingKeys[i].c_str(), false, ImGuiSelectableFlags_Disabled); + ImGui::PopStyleColor(); + } else { + if (ImGui::Selectable(state.cachedSettingKeys[i].c_str(), selected)) + state.selectedSettingIdx = i; + } + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } + + ImGui::SameLine(); + + // Add button + bool canAdd = state.selectedFeatureIdx >= 0 && state.selectedSettingIdx >= 0; + { + auto _ = Util::DisableGuard(!canAdd); + if (ImGui::Button("Add")) { + auto& featureName = state.cachedFeatureNames[state.selectedFeatureIdx]; + auto& settingKey = state.cachedSettingKeys[state.selectedSettingIdx]; + auto currentValue = SceneSettingsManager::GetFeatureSettingValue(featureName, settingKey); + manager->AddSetting(type, featureName, settingKey, currentValue, period); + state.selectedSettingIdx = -1; + return; + } + } + } + + bool DrawSettingEntry(SceneType type, size_t index, PopupState& popups) + { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(type); + if (index >= entries.size()) + return false; + + const auto& entry = entries[index]; + + ImGui::PushID(static_cast(index)); + + // Feature.Setting label + float availWidth = ImGui::GetContentRegionAvail().x; + ImGui::Text("%s.%s", entry.featureShortName.c_str(), entry.settingKey.c_str()); + + // Value editor (right-aligned) + ImGui::SameLine(availWidth * C::SCENE_VALUE_LABEL_OFFSET_RATIO); + + bool isOverwrite = entry.source == EntrySource::Overwrite; + auto settingType = SceneSettingsManager::DetectSettingType(entry.value); + + // Overwrites are always read-only; for non-TOD types, user entries overridden + // by an active overwrite are also disabled. + bool readOnly = isOverwrite || + (type != SceneType::TimeOfDay && + manager->HasActiveOverwrite(type, entry.featureShortName, entry.settingKey)); + + if (readOnly) + ImGui::BeginDisabled(); + + switch (settingType) { + case SceneSettingsManager::SettingType::Boolean: + { + bool val = entry.value.is_boolean() ? entry.value.get() : (entry.value.get() != 0); + if (ImGui::Checkbox("##val", &val)) { + if (entry.value.is_boolean()) + manager->UpdateEntryValue(type, index, val); + else + manager->UpdateEntryValue(type, index, val ? 1 : 0); + } + } + break; + case SceneSettingsManager::SettingType::Float: + { + float val = entry.value.is_number() ? entry.value.get() : 0.0f; + if (!std::isfinite(val)) + val = 0.0f; + ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); + if (ImGui::InputFloat("##val", &val, 0.01f, 0.1f, "%.3f")) { + if (std::isfinite(val)) + manager->UpdateEntryValue(type, index, val, true); + } + if (ImGui::IsItemDeactivatedAfterEdit()) + manager->SaveUserSettings(type); + } + break; + case SceneSettingsManager::SettingType::Integer: + { + int val = entry.value.get(); + ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); + if (ImGui::InputInt("##val", &val)) + manager->UpdateEntryValue(type, index, val, true); + if (ImGui::IsItemDeactivatedAfterEdit()) + manager->SaveUserSettings(type); + } + break; + default: + ImGui::TextDisabled("(unsupported type)"); + break; + } + + if (readOnly) + ImGui::EndDisabled(); + + // Active/Pause toggle + ImGui::SameLine(); + bool active = !entry.paused; + if (Util::FeatureToggle("##active", &active)) + manager->TogglePauseEntry(type, index); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(entry.paused ? "Paused - click to resume" : "Active - click to pause"); + + // Delete button + ImGui::SameLine(); + { + auto styledButton = Util::ErrorButtonStyle(); + if (ImGui::Button("X", ImVec2(C::SCENE_DELETE_BUTTON_WIDTH, 0))) { + if (isOverwrite) { + popups.pendingDeleteIndex = index; + popups.deleteSingleOverwrite.message = std::format( + "Delete overwrite file '{}'?\nThis will permanently remove the file from disk.", + entry.sourceFilename); + popups.deleteSingleOverwrite.Request(); + } else { + manager->RemoveSetting(type, index); + ImGui::PopID(); + return true; + } + } + } + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(isOverwrite ? "Delete overwrite file from disk" : "Remove this setting"); + + ImGui::PopID(); + return false; + } + + void DrawPopups(SceneType type, PopupState& popups) + { + auto* manager = SceneSettingsManager::GetSingleton(); + + if (popups.deleteAllOverwrites.Draw()) + manager->DeleteAllOverwrites(type); + + if (popups.deleteSingleOverwrite.Draw()) { + if (popups.pendingDeleteIndex < manager->GetEntries(type).size()) + manager->RemoveSetting(type, popups.pendingDeleteIndex); + popups.pendingDeleteIndex = SIZE_MAX; + } + + if (popups.deleteAllUser.Draw()) + manager->DeleteAllUserSettings(type); + } + + void DrawEntrySections(SceneType type, PopupState& popups, + const std::vector& overwriteIndices, + const std::vector& userIndices) + { + auto& theme = globals::menu->GetSettings().Theme; + + if (!overwriteIndices.empty()) { + ImGui::Spacing(); + ImGui::TextColored(theme.StatusPalette.InfoColor, "Overwrite Files"); + ImGui::Separator(); + for (auto i : overwriteIndices) + DrawSettingEntry(type, i, popups); + } + + if (!userIndices.empty()) { + if (!overwriteIndices.empty()) { + ImGui::Spacing(); + ImGui::TextColored(theme.FeatureHeading.ColorDefault, "User Settings"); + ImGui::Separator(); + } + for (auto i : userIndices) { + if (i >= SceneSettingsManager::GetSingleton()->GetEntries(type).size()) + break; + DrawSettingEntry(type, i, popups); + } + } + } + + bool DrawCategoryPanel(const char* category, const std::string& selected, void (*drawFn)()) + { + if (selected != category) + return false; + drawFn(); + ImGui::EndChild(); + ImGui::EndTable(); + ImGui::End(); + return true; + } +} diff --git a/src/WeatherEditor/SceneSettingsUI.h b/src/WeatherEditor/SceneSettingsUI.h new file mode 100644 index 0000000000..93c777e389 --- /dev/null +++ b/src/WeatherEditor/SceneSettingsUI.h @@ -0,0 +1,71 @@ +#pragma once + +#include "SceneSettingsManager.h" +#include "Utils/UI.h" + +/// Shared UI drawing utilities for scene-settings panels (Interior Only, Time of Day). +/// Eliminates duplicate ImGui code between InteriorOnlyPanel and TimeOfDayPanel. +namespace SceneSettingsUI +{ + using SceneType = SceneSettingsManager::SceneType; + using EntrySource = SceneSettingsManager::EntrySource; + using Period = SceneSettingsManager::TimeOfDayPeriod; + + /// Persistent state for a single "Add Setting" dropdown row. + struct AddSettingState + { + int selectedFeatureIdx = -1; + int selectedSettingIdx = -1; + std::vector cachedFeatureNames; + std::vector cachedSettingKeys; + }; + + /// Shared confirmation popup state for a panel. + struct PopupState + { + Util::ConfirmationPopup deleteAllOverwrites; + Util::ConfirmationPopup deleteSingleOverwrite{ "Delete Overwrite File?", "", "Delete" }; + Util::ConfirmationPopup deleteAllUser; + size_t pendingDeleteIndex = SIZE_MAX; + + PopupState(const char* overwriteMsg, const char* userMsg) : + deleteAllOverwrites("Delete All Overwrites?", overwriteMsg, "Delete All"), + deleteAllUser("Delete All User Settings?", userMsg, "Delete All") {} + }; + + /// Draw the feature/setting dropdown + Add button. + /// @param type Scene type being edited. + /// @param state Persistent dropdown state (selection indices, caches). + /// @param period For TimeOfDay entries, which period to add to. Count = none. + void DrawAddSettingUI(SceneType type, AddSettingState& state, + Period period = Period::Count); + + /// Draw a single setting entry row (label, value editor, pause toggle, delete). + /// @param type Scene type being edited. + /// @param index Index into the entries vector. + /// @param popups Shared popup state for confirmations. + /// @return true if the entry was deleted inline (caller should stop iterating). + bool DrawSettingEntry(SceneType type, size_t index, PopupState& popups); + + /// Process all three delete-confirmation popups relative to the given type. + void DrawPopups(SceneType type, PopupState& popups); + + /// Draw overwrite + user entry sections with section headers. + /// @param type Scene type being edited. + /// @param popups Shared popup state. + /// @param overwriteIndices Entry indices for overwrite entries. + /// @param userIndices Entry indices for user entries. + void DrawEntrySections(SceneType type, PopupState& popups, + const std::vector& overwriteIndices, + const std::vector& userIndices); + + /// Draw a standalone scene-settings panel that dispatches to this panel's Draw(). + /// Handles ImGui::EndChild / ImGui::EndTable / ImGui::End early-return pattern + /// used by EditorWindow for full-panel categories. + /// @param category Category name to check (e.g. "Interior Only"). + /// @param selected Currently selected category string. + /// @param drawFn Drawing function to call if category matches. + /// @return true if category matched and panel was drawn (caller should return). + bool DrawCategoryPanel(const char* category, const std::string& selected, + void (*drawFn)()); +} diff --git a/src/WeatherEditor/TimeOfDayPanel.cpp b/src/WeatherEditor/TimeOfDayPanel.cpp index 8f1f06a0cd..7cf3c6c46a 100644 --- a/src/WeatherEditor/TimeOfDayPanel.cpp +++ b/src/WeatherEditor/TimeOfDayPanel.cpp @@ -1,12 +1,9 @@ #include "TimeOfDayPanel.h" -#include - #include "../Globals.h" #include "../Menu.h" -#include "../Menu/ThemeManager.h" #include "../SceneSettingsManager.h" -#include "EditorWindow.h" +#include "SceneSettingsUI.h" namespace TimeOfDayPanel { @@ -14,43 +11,16 @@ namespace TimeOfDayPanel using EntrySource = SceneSettingsManager::EntrySource; using Period = SceneSettingsManager::TimeOfDayPeriod; static constexpr auto kSceneType = SceneType::TimeOfDay; - static constexpr int kPeriodCount = static_cast(Period::Count); - // Layout constants from centralized theme - using C = ThemeManager::Constants; + // Per-period add-setting state + static SceneSettingsUI::AddSettingState periodAddState[SceneSettingsManager::kPeriodCount]; - // Per-period persistent state for the "Add Setting" workflow - struct PeriodUIState - { - int selectedFeatureIdx = -1; - int selectedSettingIdx = -1; - std::vector cachedFeatureNames; - std::vector cachedSettingKeys; - }; - static PeriodUIState periodState[kPeriodCount]; - - // Confirmation popups (shared across tabs) - static Util::ConfirmationPopup deleteAllOverwritesPopup{ - "Delete All Overwrites?", + // Shared popups + static SceneSettingsUI::PopupState popups{ "Are you sure you want to delete all time-of-day overwrite files?\nThis cannot be undone.", - "Delete All" + "Are you sure you want to remove all user-added time-of-day settings?" }; - static Util::ConfirmationPopup deleteSingleOverwritePopup{ - "Delete Overwrite File?", - "", - "Delete" - }; - static size_t pendingDeleteIndex = SIZE_MAX; - - static Util::ConfirmationPopup deleteAllUserPopup{ - "Delete All User Settings?", - "Are you sure you want to remove all user-added time-of-day settings?", - "Delete All" - }; - - // --- Helpers to filter entries by period --- - static void CollectPeriodIndices(Period period, const std::vector& entries, std::vector& overwriteOut, std::vector& userOut) { @@ -64,203 +34,20 @@ namespace TimeOfDayPanel } } - static void DrawAddSettingUI(Period period) - { - auto* manager = SceneSettingsManager::GetSingleton(); - auto& state = periodState[static_cast(period)]; - - ImGui::Spacing(); - - // Feature dropdown - if (state.cachedFeatureNames.empty()) - state.cachedFeatureNames = SceneSettingsManager::GetExteriorRelevantFeatureNames(); - - const char* featurePreview = (state.selectedFeatureIdx >= 0 && - state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) ? - state.cachedFeatureNames[state.selectedFeatureIdx].c_str() : - "Select Feature..."; - - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_FEATURE_DROPDOWN_RATIO); - if (ImGui::BeginCombo("##FeatureSelect", featurePreview)) { - for (int i = 0; i < static_cast(state.cachedFeatureNames.size()); ++i) { - bool selected = (i == state.selectedFeatureIdx); - if (ImGui::Selectable(state.cachedFeatureNames[i].c_str(), selected)) { - state.selectedFeatureIdx = i; - state.selectedSettingIdx = -1; - state.cachedSettingKeys = SceneSettingsManager::GetFeatureSettingKeys(state.cachedFeatureNames[i]); - } - if (selected) - ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - - ImGui::SameLine(); - - // Setting dropdown - { - auto _ = Util::DisableGuard(state.selectedFeatureIdx < 0); - - const char* settingPreview = (state.selectedSettingIdx >= 0 && - state.selectedSettingIdx < static_cast(state.cachedSettingKeys.size())) ? - state.cachedSettingKeys[state.selectedSettingIdx].c_str() : - "Select Setting..."; - - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_SETTING_DROPDOWN_RATIO); - if (ImGui::BeginCombo("##SettingSelect", settingPreview)) { - for (int i = 0; i < static_cast(state.cachedSettingKeys.size()); ++i) { - bool selected = (i == state.selectedSettingIdx); - bool alreadyAdded = state.selectedFeatureIdx >= 0 && - manager->HasEntryForPeriod( - state.cachedFeatureNames[state.selectedFeatureIdx], - state.cachedSettingKeys[i], period, EntrySource::User); - if (alreadyAdded) { - ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_TextDisabled]); - ImGui::Selectable(state.cachedSettingKeys[i].c_str(), false, ImGuiSelectableFlags_Disabled); - ImGui::PopStyleColor(); - } else { - if (ImGui::Selectable(state.cachedSettingKeys[i].c_str(), selected)) - state.selectedSettingIdx = i; - } - if (selected) - ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - } - - ImGui::SameLine(); - - // Add button - bool canAdd = state.selectedFeatureIdx >= 0 && state.selectedSettingIdx >= 0; - { - auto _ = Util::DisableGuard(!canAdd); - if (ImGui::Button("Add")) { - auto& featureName = state.cachedFeatureNames[state.selectedFeatureIdx]; - auto& settingKey = state.cachedSettingKeys[state.selectedSettingIdx]; - auto currentValue = SceneSettingsManager::GetFeatureSettingValue(featureName, settingKey); - manager->AddSetting(kSceneType, featureName, settingKey, currentValue, period); - state.selectedSettingIdx = -1; - return; - } - } - } - - static void DrawSettingEntry(size_t index) - { - auto* manager = SceneSettingsManager::GetSingleton(); - const auto& entries = manager->GetEntries(kSceneType); - if (index >= entries.size()) - return; - - const auto& entry = entries[index]; - - ImGui::PushID(static_cast(index)); - - // Feature.Setting label - float availWidth = ImGui::GetContentRegionAvail().x; - ImGui::Text("%s.%s", entry.featureShortName.c_str(), entry.settingKey.c_str()); - - // Value editor (right-aligned) - ImGui::SameLine(availWidth * C::SCENE_VALUE_LABEL_OFFSET_RATIO); - - bool isOverwrite = entry.source == EntrySource::Overwrite; - auto type = SceneSettingsManager::DetectSettingType(entry.value); - bool readOnly = isOverwrite; - - if (readOnly) - ImGui::BeginDisabled(); - - switch (type) { - case SceneSettingsManager::SettingType::Boolean: - { - bool val = entry.value.is_boolean() ? entry.value.get() : (entry.value.get() != 0); - if (ImGui::Checkbox("##val", &val)) { - if (entry.value.is_boolean()) - manager->UpdateEntryValue(kSceneType, index, val); - else - manager->UpdateEntryValue(kSceneType, index, val ? 1 : 0); - } - } - break; - case SceneSettingsManager::SettingType::Float: - { - float val = entry.value.is_number() ? entry.value.get() : 0.0f; - if (!std::isfinite(val)) - val = 0.0f; - ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); - if (ImGui::InputFloat("##val", &val, 0.01f, 0.1f, "%.3f")) { - if (std::isfinite(val)) - manager->UpdateEntryValue(kSceneType, index, val, true); - } - if (ImGui::IsItemDeactivatedAfterEdit()) - manager->SaveUserSettings(kSceneType); - } - break; - case SceneSettingsManager::SettingType::Integer: - { - int val = entry.value.get(); - ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); - if (ImGui::InputInt("##val", &val)) - manager->UpdateEntryValue(kSceneType, index, val, true); - if (ImGui::IsItemDeactivatedAfterEdit()) - manager->SaveUserSettings(kSceneType); - } - break; - default: - ImGui::TextDisabled("(unsupported type)"); - break; - } - - if (readOnly) - ImGui::EndDisabled(); - - // Active/Pause toggle - ImGui::SameLine(); - bool active = !entry.paused; - if (Util::FeatureToggle("##active", &active)) - manager->TogglePauseEntry(kSceneType, index); - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text(entry.paused ? "Paused - click to resume" : "Active - click to pause"); - - // Delete button - ImGui::SameLine(); - { - auto styledButton = Util::ErrorButtonStyle(); - if (ImGui::Button("X", ImVec2(C::SCENE_DELETE_BUTTON_WIDTH, 0))) { - if (isOverwrite) { - pendingDeleteIndex = index; - deleteSingleOverwritePopup.message = std::format( - "Delete overwrite file '{}'?\nThis will permanently remove the file from disk.", - entry.sourceFilename); - deleteSingleOverwritePopup.Request(); - } else { - manager->RemoveSetting(kSceneType, index); - ImGui::PopID(); - return; - } - } - } - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text(isOverwrite ? "Delete overwrite file from disk" : "Remove this setting"); - - ImGui::PopID(); - } - static void DrawPeriodTab(Period period) { auto* manager = SceneSettingsManager::GetSingleton(); const auto& entries = manager->GetEntries(kSceneType); auto& theme = globals::menu->GetSettings().Theme; - // Draw confirmation popups - if (deleteSingleOverwritePopup.Draw()) { - if (pendingDeleteIndex < entries.size()) - manager->RemoveSetting(kSceneType, pendingDeleteIndex); - pendingDeleteIndex = SIZE_MAX; + // Draw single-overwrite delete popup (shared across tabs) + if (popups.deleteSingleOverwrite.Draw()) { + if (popups.pendingDeleteIndex < entries.size()) + manager->RemoveSetting(kSceneType, popups.pendingDeleteIndex); + popups.pendingDeleteIndex = SIZE_MAX; } - DrawAddSettingUI(period); + SceneSettingsUI::DrawAddSettingUI(kSceneType, periodAddState[static_cast(period)], period); // Collect indices for this period std::vector overwriteIndices, userIndices; @@ -273,25 +60,7 @@ namespace TimeOfDayPanel return; } - // Overwrite section - if (!overwriteIndices.empty()) { - ImGui::Spacing(); - ImGui::TextColored(theme.StatusPalette.InfoColor, "Overwrite Files"); - ImGui::Separator(); - for (auto i : overwriteIndices) - DrawSettingEntry(i); - } - - // User section - if (!userIndices.empty()) { - if (!overwriteIndices.empty()) { - ImGui::Spacing(); - ImGui::TextColored(theme.FeatureHeading.ColorDefault, "User Settings"); - ImGui::Separator(); - } - for (auto i : userIndices) - DrawSettingEntry(i); - } + SceneSettingsUI::DrawEntrySections(kSceneType, popups, overwriteIndices, userIndices); } void Draw() @@ -314,9 +83,9 @@ namespace TimeOfDayPanel ImGui::Separator(); // Global confirmation popups - if (deleteAllOverwritesPopup.Draw()) + if (popups.deleteAllOverwrites.Draw()) manager->DeleteAllOverwrites(kSceneType); - if (deleteAllUserPopup.Draw()) + if (popups.deleteAllUser.Draw()) manager->DeleteAllUserSettings(kSceneType); // Global controls @@ -327,7 +96,7 @@ namespace TimeOfDayPanel manager->SetAllOverwritesPaused(kSceneType, !allPaused); ImGui::SameLine(); if (ImGui::SmallButton("Delete All Overwrite")) - deleteAllOverwritesPopup.Request(); + popups.deleteAllOverwrites.Request(); ImGui::SameLine(); } @@ -336,12 +105,12 @@ namespace TimeOfDayPanel manager->SetAllUserPaused(kSceneType, !allUserPaused); ImGui::SameLine(); if (ImGui::SmallButton("Delete All User")) - deleteAllUserPopup.Request(); + popups.deleteAllUser.Request(); } // Period tabs if (ImGui::BeginTabBar("##TODPeriods")) { - for (int i = 0; i < kPeriodCount; ++i) { + for (int i = 0; i < SceneSettingsManager::kPeriodCount; ++i) { auto period = static_cast(i); if (ImGui::BeginTabItem(SceneSettingsManager::GetPeriodName(period))) { DrawPeriodTab(period); diff --git a/src/WeatherEditor/TimeOfDayPanel.h b/src/WeatherEditor/TimeOfDayPanel.h index 458776d01c..8466f86fc0 100644 --- a/src/WeatherEditor/TimeOfDayPanel.h +++ b/src/WeatherEditor/TimeOfDayPanel.h @@ -1,10 +1,8 @@ #pragma once -#include "Utils/UI.h" - /// UI panel for managing Time of Day scene settings within the Weather Editor. -/// Shows 6 period tabs (Dawn, Sunrise, Day, Sunset, Dusk, Night) with -/// add/pause/delete controls under each. +/// Shows period tabs (Dawn, Sunrise, Day, Sunset, Dusk, Night) with +/// add/pause/delete controls under each. Delegates to shared SceneSettingsUI utilities. namespace TimeOfDayPanel { /// Draw the full Time of Day settings panel From d28710e7402f27b3df7a5d41b03638c3f8289edb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 05:08:28 +0000 Subject: [PATCH 13/36] =?UTF-8?q?style:=20=F0=9F=8E=A8=20apply=20pre-commi?= =?UTF-8?q?t.ci=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated formatting by clang-format, prettier, and other hooks. See https://pre-commit.ci for details. --- src/WeatherEditor/SceneSettingsUI.cpp | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/WeatherEditor/SceneSettingsUI.cpp b/src/WeatherEditor/SceneSettingsUI.cpp index fc72ef244c..ff09d757d2 100644 --- a/src/WeatherEditor/SceneSettingsUI.cpp +++ b/src/WeatherEditor/SceneSettingsUI.cpp @@ -15,9 +15,7 @@ namespace SceneSettingsUI static std::vector GetFeatureNamesForType(SceneType type) { - return (type == SceneType::InteriorOnly) - ? SceneSettingsManager::GetInteriorRelevantFeatureNames() - : SceneSettingsManager::GetExteriorRelevantFeatureNames(); + return (type == SceneType::InteriorOnly) ? SceneSettingsManager::GetInteriorRelevantFeatureNames() : SceneSettingsManager::GetExteriorRelevantFeatureNames(); } // --- Duplicate checking by scene type --- @@ -25,9 +23,7 @@ namespace SceneSettingsUI static bool IsAlreadyAdded(SceneType type, const std::string& feature, const std::string& key, Period period) { auto* manager = SceneSettingsManager::GetSingleton(); - return (type == SceneType::TimeOfDay) - ? manager->HasEntryForPeriod(feature, key, period, EntrySource::User) - : manager->HasEntryFromSource(type, feature, key, EntrySource::User); + return (type == SceneType::TimeOfDay) ? manager->HasEntryForPeriod(feature, key, period, EntrySource::User) : manager->HasEntryFromSource(type, feature, key, EntrySource::User); } // --- Shared Drawing --- @@ -43,9 +39,9 @@ namespace SceneSettingsUI state.cachedFeatureNames = GetFeatureNamesForType(type); const char* featurePreview = (state.selectedFeatureIdx >= 0 && - state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) - ? state.cachedFeatureNames[state.selectedFeatureIdx].c_str() - : "Select Feature..."; + state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) ? + state.cachedFeatureNames[state.selectedFeatureIdx].c_str() : + "Select Feature..."; ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_FEATURE_DROPDOWN_RATIO); if (ImGui::BeginCombo("##FeatureSelect", featurePreview)) { @@ -69,9 +65,9 @@ namespace SceneSettingsUI auto _ = Util::DisableGuard(state.selectedFeatureIdx < 0); const char* settingPreview = (state.selectedSettingIdx >= 0 && - state.selectedSettingIdx < static_cast(state.cachedSettingKeys.size())) - ? state.cachedSettingKeys[state.selectedSettingIdx].c_str() - : "Select Setting..."; + state.selectedSettingIdx < static_cast(state.cachedSettingKeys.size())) ? + state.cachedSettingKeys[state.selectedSettingIdx].c_str() : + "Select Setting..."; ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_SETTING_DROPDOWN_RATIO); if (ImGui::BeginCombo("##SettingSelect", settingPreview)) { From 490a5fbeba5a4270b08cfa49e1db20cebdcca05d Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:27:18 -0700 Subject: [PATCH 14/36] fix double conversion --- src/SceneSettingsManager.cpp | 23 +++++++++++++++-------- src/SceneSettingsManager.h | 3 +++ src/WeatherEditor/TimeOfDayPanel.cpp | 4 ++-- src/WeatherEditor/WeatherUtils.cpp | 9 +++++++-- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 665558a4a3..d5e99f26b6 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -77,16 +77,10 @@ void SceneSettingsManager::GetTimeOfDayFactors(float outFactors[kPeriodCount]) float h = (end > 24.0f && hour < start) ? hour + 24.0f : hour; if (h >= start && h < end) { - // Inside this period — check if we're in a transition zone - float distFromStart = h - start; + // Inside this period — check if we're in the blend-out zone near the end. float distFromEnd = end - h; - if (distFromStart < kTransitionHours) { - // Blending in from previous period - float t = distFromStart / kTransitionHours; - outFactors[i] = t; - outFactors[(i + kPeriodCount - 1) % kPeriodCount] = 1.0f - t; - } else if (distFromEnd < kTransitionHours) { + if (distFromEnd < kTransitionHours) { // Blending out to next period float t = distFromEnd / kTransitionHours; outFactors[i] = t; @@ -114,6 +108,19 @@ SceneSettingsManager::TimeOfDayPeriod SceneSettingsManager::GetDominantPeriod() return static_cast(best); } +SceneSettingsManager::TimeOfDayPeriod SceneSettingsManager::GetCurrentPeriod() +{ + float hour = GetCurrentGameHour(); + for (int i = 0; i < kPeriodCount; ++i) { + float start = kPeriodHours[i][0]; + float end = kPeriodHours[i][1]; + float h = (end > 24.0f && hour < start) ? hour + 24.0f : hour; + if (h >= start && h < end) + return static_cast(i); + } + return TimeOfDayPeriod::Day; +} + // --- Feature Metadata (static helpers, zero coupling) --- static std::vector FilterFeatureNames(const std::unordered_set& whitelist) diff --git a/src/SceneSettingsManager.h b/src/SceneSettingsManager.h index 7e0b8b0341..9fe5d78821 100644 --- a/src/SceneSettingsManager.h +++ b/src/SceneSettingsManager.h @@ -183,6 +183,9 @@ class SceneSettingsManager void GetTimeOfDayFactors(float outFactors[static_cast(TimeOfDayPeriod::Count)]); TimeOfDayPeriod GetDominantPeriod(); + /// Returns the period whose hour range contains the current game hour. + static TimeOfDayPeriod GetCurrentPeriod(); + // --- Feature Metadata --- /// Get loaded feature short names filtered to only interior-relevant features diff --git a/src/WeatherEditor/TimeOfDayPanel.cpp b/src/WeatherEditor/TimeOfDayPanel.cpp index 7cf3c6c46a..4e6659fdbe 100644 --- a/src/WeatherEditor/TimeOfDayPanel.cpp +++ b/src/WeatherEditor/TimeOfDayPanel.cpp @@ -74,10 +74,10 @@ namespace TimeOfDayPanel ImGui::TextDisabled("(Exterior Only)"); // Show current period indicator - auto dominant = manager->GetDominantPeriod(); + auto currentPeriod = SceneSettingsManager::GetCurrentPeriod(); ImGui::SameLine(); ImGui::TextColored(theme.StatusPalette.InfoColor, "[%s %.1fh]", - SceneSettingsManager::GetPeriodName(dominant), + SceneSettingsManager::GetPeriodName(currentPeriod), SceneSettingsManager::GetCurrentGameHour()); ImGui::Separator(); diff --git a/src/WeatherEditor/WeatherUtils.cpp b/src/WeatherEditor/WeatherUtils.cpp index 29df56f6e2..c7f9528c26 100644 --- a/src/WeatherEditor/WeatherUtils.cpp +++ b/src/WeatherEditor/WeatherUtils.cpp @@ -298,10 +298,15 @@ namespace TOD float GetCurrentGameTime() { + // Prefer calendar (ground truth), which the Weather Editor slider writes to. + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (calendar && calendar->gameHour) + return std::clamp(calendar->gameHour->value, 0.0f, 24.0f); + auto sky = globals::game::sky; - if (sky) { + if (sky) return std::clamp(sky->currentGameHour, 0.0f, 24.0f); - } + return 12.0f; // Default to noon } From 9c16a60fe41592548d53712cf968b2f19683b50a Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:11:16 -0700 Subject: [PATCH 15/36] add columns instead of tabs --- src/Menu/ThemeManager.h | 18 +- src/SceneSettingsManager.cpp | 6 + src/SceneSettingsManager.h | 3 + src/WeatherEditor/InteriorOnlyPanel.cpp | 57 +---- src/WeatherEditor/SceneSettingsUI.cpp | 180 ++++++++------ src/WeatherEditor/SceneSettingsUI.h | 31 ++- src/WeatherEditor/TimeOfDayPanel.cpp | 315 +++++++++++++++++++----- 7 files changed, 401 insertions(+), 209 deletions(-) diff --git a/src/Menu/ThemeManager.h b/src/Menu/ThemeManager.h index 26d20ddc6f..f90b09137c 100644 --- a/src/Menu/ThemeManager.h +++ b/src/Menu/ThemeManager.h @@ -190,12 +190,20 @@ class ThemeManager static constexpr float AUTOHIDE_EXPAND_DELAY = 0.25f; // Delay before expanding panel (seconds) static constexpr float AUTOHIDE_PANEL_WIDTH_RATIO = 0.2f; // Ratio of window width for panel (2/10) - // Scene settings panel constants - static constexpr float SCENE_VALUE_INPUT_WIDTH = 240.0f; // Width for float/int value inputs - static constexpr float SCENE_DELETE_BUTTON_WIDTH = 40.0f; // Width for delete (X) buttons - static constexpr float SCENE_FEATURE_DROPDOWN_RATIO = 0.45f; // Feature dropdown width ratio - static constexpr float SCENE_SETTING_DROPDOWN_RATIO = 0.6f; // Setting dropdown width ratio + // Scene settings panel constants (multipliers of ImGui::GetFontSize()) + static constexpr float SCENE_VALUE_INPUT_EM = 5.7f; // Width for float/int value inputs + static constexpr float SCENE_DELETE_BUTTON_EM = 1.0f; // Width for delete (X) buttons + static constexpr float SCENE_FEATURE_DROPDOWN_RATIO = 0.5f; // Feature dropdown width ratio static constexpr float SCENE_VALUE_LABEL_OFFSET_RATIO = 0.5f; // Value label right-alignment ratio + static constexpr float SCENE_TOD_PARAM_COL_EM = 6.0f; // Parameter column width (TOD table) + static constexpr float SCENE_TOD_PERIOD_COL_EM = 4.3f; // Per-period column width (TOD table) + static constexpr float SCENE_TOD_INACTIVE_ALPHA = 0.5f; // Alpha for inactive TOD periods + static constexpr float SCENE_ENTRY_INDENT_EM = 0.4f; // Indent for setting entries under feature headers + static constexpr float SCENE_TOD_FEATURE_TEXT_SCALE = 0.85f; // Smaller text scale for feature names in TOD table + static constexpr float SCENE_TOD_LABEL_EM = 2.6f; // Fixed width for period labels in add-setting rows + + /// Resolve a font-relative multiplier to pixels using current font size. + static float Em(float multiplier) { return multiplier * ImGui::GetFontSize(); } // Combo search input constants static constexpr float COMBO_SEARCH_ICON_SIZE = 16.0f; // Icon size for search inside combos diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index d5e99f26b6..59013b2ea9 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -168,6 +168,12 @@ std::vector SceneSettingsManager::GetExteriorRelevantFeatureNames() return FilterFeatureNames(whitelist); } +std::string SceneSettingsManager::GetFeatureDisplayName(const std::string& featureShortName) +{ + auto* feature = Feature::FindFeatureByShortName(featureShortName); + return feature ? feature->GetName() : featureShortName; +} + std::vector SceneSettingsManager::GetFeatureSettingKeys(const std::string& featureShortName) { std::vector keys; diff --git a/src/SceneSettingsManager.h b/src/SceneSettingsManager.h index 9fe5d78821..1000aee51a 100644 --- a/src/SceneSettingsManager.h +++ b/src/SceneSettingsManager.h @@ -194,6 +194,9 @@ class SceneSettingsManager /// Get loaded feature short names filtered to exterior/TOD-relevant features static std::vector GetExteriorRelevantFeatureNames(); + /// Get the display name for a feature (e.g. "Screen Space GI" from "ScreenSpaceGI") + static std::string GetFeatureDisplayName(const std::string& featureShortName); + /// Get setting keys for a feature by JSON round-tripping its current settings static std::vector GetFeatureSettingKeys(const std::string& featureShortName); diff --git a/src/WeatherEditor/InteriorOnlyPanel.cpp b/src/WeatherEditor/InteriorOnlyPanel.cpp index 4dcede45fb..c3285aa426 100644 --- a/src/WeatherEditor/InteriorOnlyPanel.cpp +++ b/src/WeatherEditor/InteriorOnlyPanel.cpp @@ -30,22 +30,13 @@ namespace InteriorOnlyPanel SceneSettingsUI::DrawPopups(kSceneType, popups); SceneSettingsUI::DrawAddSettingUI(kSceneType, addState); - // Collect indices by source - std::vector overwriteIndices, userIndices; - for (size_t i = 0; i < entries.size(); ++i) { - if (entries[i].source == EntrySource::Overwrite) - overwriteIndices.push_back(i); - else - userIndices.push_back(i); - } - // Empty state if (entries.empty()) { ImGui::Spacing(); ImGui::TextColored(theme.StatusPalette.Disable, "No interior-only settings configured."); ImGui::TextColored(theme.StatusPalette.Disable, - "Click + to add settings that will only apply in interiors."); + "Select a feature and setting above to add overrides."); ImGui::Spacing(); ImGui::TextWrapped( "Settings added here will override feature defaults when you enter an interior cell. " @@ -53,50 +44,6 @@ namespace InteriorOnlyPanel return; } - // Overwrite section with controls - if (!overwriteIndices.empty()) { - ImGui::Spacing(); - ImGui::TextColored(theme.StatusPalette.InfoColor, "Overwrite Files"); - ImGui::SameLine(); - - bool allPaused = manager->AreAllOverwritesPaused(kSceneType); - if (ImGui::SmallButton(allPaused ? "Unpause All" : "Pause All")) - manager->SetAllOverwritesPaused(kSceneType, !allPaused); - - ImGui::SameLine(); - if (ImGui::SmallButton("Delete All")) - popups.deleteAllOverwrites.Request(); - - ImGui::Separator(); - - for (auto i : overwriteIndices) - SceneSettingsUI::DrawSettingEntry(kSceneType, i, popups); - } - - // User section with controls - if (!userIndices.empty()) { - if (!overwriteIndices.empty()) { - ImGui::Spacing(); - ImGui::TextColored(theme.FeatureHeading.ColorDefault, "User Settings"); - ImGui::SameLine(); - } - - bool allUserPaused = manager->AreAllUserPaused(kSceneType); - if (ImGui::SmallButton(allUserPaused ? "Unpause All##user" : "Pause All##user")) - manager->SetAllUserPaused(kSceneType, !allUserPaused); - - ImGui::SameLine(); - if (ImGui::SmallButton("Delete All##user")) - popups.deleteAllUser.Request(); - - if (!overwriteIndices.empty()) - ImGui::Separator(); - - for (auto i : userIndices) { - if (i >= manager->GetEntries(kSceneType).size()) - break; - SceneSettingsUI::DrawSettingEntry(kSceneType, i, popups); - } - } + SceneSettingsUI::DrawEntrySections(kSceneType, popups); } } diff --git a/src/WeatherEditor/SceneSettingsUI.cpp b/src/WeatherEditor/SceneSettingsUI.cpp index ff09d757d2..a22d96afab 100644 --- a/src/WeatherEditor/SceneSettingsUI.cpp +++ b/src/WeatherEditor/SceneSettingsUI.cpp @@ -1,6 +1,7 @@ #include "SceneSettingsUI.h" #include +#include #include "../Globals.h" #include "../Menu.h" @@ -28,31 +29,37 @@ namespace SceneSettingsUI // --- Shared Drawing --- - void DrawAddSettingUI(SceneType type, AddSettingState& state, Period period) + void DrawAddSettingUI(SceneType type, AddSettingState& state, Period period, const char* labelPrefix) { auto* manager = SceneSettingsManager::GetSingleton(); ImGui::Spacing(); + // Optional inline label (e.g. period name) with fixed width for alignment + if (labelPrefix) { + ImGui::Text("%s", labelPrefix); + ImGui::SameLine(C::Em(C::SCENE_TOD_LABEL_EM)); + } + // Feature dropdown if (state.cachedFeatureNames.empty()) state.cachedFeatureNames = GetFeatureNamesForType(type); - const char* featurePreview = (state.selectedFeatureIdx >= 0 && - state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) ? - state.cachedFeatureNames[state.selectedFeatureIdx].c_str() : - "Select Feature..."; + auto displayName = (state.selectedFeatureIdx >= 0 && + state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) + ? SceneSettingsManager::GetFeatureDisplayName(state.cachedFeatureNames[state.selectedFeatureIdx]) + : std::string("Select Feature..."); + const char* featurePreview = displayName.c_str(); ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_FEATURE_DROPDOWN_RATIO); if (ImGui::BeginCombo("##FeatureSelect", featurePreview)) { for (int i = 0; i < static_cast(state.cachedFeatureNames.size()); ++i) { - bool selected = (i == state.selectedFeatureIdx); - if (ImGui::Selectable(state.cachedFeatureNames[i].c_str(), selected)) { + auto itemLabel = SceneSettingsManager::GetFeatureDisplayName(state.cachedFeatureNames[i]); + if (ImGui::Selectable(itemLabel.c_str(), i == state.selectedFeatureIdx)) { state.selectedFeatureIdx = i; - state.selectedSettingIdx = -1; state.cachedSettingKeys = SceneSettingsManager::GetFeatureSettingKeys(state.cachedFeatureNames[i]); } - if (selected) + if (i == state.selectedFeatureIdx) ImGui::SetItemDefaultFocus(); } ImGui::EndCombo(); @@ -60,19 +67,12 @@ namespace SceneSettingsUI ImGui::SameLine(); - // Setting dropdown + // Setting dropdown — selecting an entry auto-adds it { auto _ = Util::DisableGuard(state.selectedFeatureIdx < 0); - - const char* settingPreview = (state.selectedSettingIdx >= 0 && - state.selectedSettingIdx < static_cast(state.cachedSettingKeys.size())) ? - state.cachedSettingKeys[state.selectedSettingIdx].c_str() : - "Select Setting..."; - - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_SETTING_DROPDOWN_RATIO); - if (ImGui::BeginCombo("##SettingSelect", settingPreview)) { + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + if (ImGui::BeginCombo("##SettingSelect", "Select Setting...")) { for (int i = 0; i < static_cast(state.cachedSettingKeys.size()); ++i) { - bool selected = (i == state.selectedSettingIdx); bool alreadyAdded = state.selectedFeatureIdx >= 0 && IsAlreadyAdded(type, state.cachedFeatureNames[state.selectedFeatureIdx], state.cachedSettingKeys[i], period); @@ -80,32 +80,15 @@ namespace SceneSettingsUI ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_TextDisabled]); ImGui::Selectable(state.cachedSettingKeys[i].c_str(), false, ImGuiSelectableFlags_Disabled); ImGui::PopStyleColor(); - } else { - if (ImGui::Selectable(state.cachedSettingKeys[i].c_str(), selected)) - state.selectedSettingIdx = i; + } else if (ImGui::Selectable(state.cachedSettingKeys[i].c_str(), false)) { + auto& featureName = state.cachedFeatureNames[state.selectedFeatureIdx]; + auto currentValue = SceneSettingsManager::GetFeatureSettingValue(featureName, state.cachedSettingKeys[i]); + manager->AddSetting(type, featureName, state.cachedSettingKeys[i], currentValue, period); } - if (selected) - ImGui::SetItemDefaultFocus(); } ImGui::EndCombo(); } } - - ImGui::SameLine(); - - // Add button - bool canAdd = state.selectedFeatureIdx >= 0 && state.selectedSettingIdx >= 0; - { - auto _ = Util::DisableGuard(!canAdd); - if (ImGui::Button("Add")) { - auto& featureName = state.cachedFeatureNames[state.selectedFeatureIdx]; - auto& settingKey = state.cachedSettingKeys[state.selectedSettingIdx]; - auto currentValue = SceneSettingsManager::GetFeatureSettingValue(featureName, settingKey); - manager->AddSetting(type, featureName, settingKey, currentValue, period); - state.selectedSettingIdx = -1; - return; - } - } } bool DrawSettingEntry(SceneType type, size_t index, PopupState& popups) @@ -119,9 +102,9 @@ namespace SceneSettingsUI ImGui::PushID(static_cast(index)); - // Feature.Setting label + // Setting key label (no feature prefix — grouped by feature already) float availWidth = ImGui::GetContentRegionAvail().x; - ImGui::Text("%s.%s", entry.featureShortName.c_str(), entry.settingKey.c_str()); + ImGui::Text("%s", entry.settingKey.c_str()); // Value editor (right-aligned) ImGui::SameLine(availWidth * C::SCENE_VALUE_LABEL_OFFSET_RATIO); @@ -129,8 +112,6 @@ namespace SceneSettingsUI bool isOverwrite = entry.source == EntrySource::Overwrite; auto settingType = SceneSettingsManager::DetectSettingType(entry.value); - // Overwrites are always read-only; for non-TOD types, user entries overridden - // by an active overwrite are also disabled. bool readOnly = isOverwrite || (type != SceneType::TimeOfDay && manager->HasActiveOverwrite(type, entry.featureShortName, entry.settingKey)); @@ -142,12 +123,8 @@ namespace SceneSettingsUI case SceneSettingsManager::SettingType::Boolean: { bool val = entry.value.is_boolean() ? entry.value.get() : (entry.value.get() != 0); - if (ImGui::Checkbox("##val", &val)) { - if (entry.value.is_boolean()) - manager->UpdateEntryValue(type, index, val); - else - manager->UpdateEntryValue(type, index, val ? 1 : 0); - } + if (ImGui::Checkbox("##val", &val)) + manager->UpdateEntryValue(type, index, entry.value.is_boolean() ? json(val) : json(val ? 1 : 0)); } break; case SceneSettingsManager::SettingType::Float: @@ -155,11 +132,10 @@ namespace SceneSettingsUI float val = entry.value.is_number() ? entry.value.get() : 0.0f; if (!std::isfinite(val)) val = 0.0f; - ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); - if (ImGui::InputFloat("##val", &val, 0.01f, 0.1f, "%.3f")) { + ImGui::SetNextItemWidth(C::Em(C::SCENE_VALUE_INPUT_EM)); + if (ImGui::InputFloat("##val", &val, 0.0f, 0.0f, "%.3f")) if (std::isfinite(val)) manager->UpdateEntryValue(type, index, val, true); - } if (ImGui::IsItemDeactivatedAfterEdit()) manager->SaveUserSettings(type); } @@ -167,8 +143,8 @@ namespace SceneSettingsUI case SceneSettingsManager::SettingType::Integer: { int val = entry.value.get(); - ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); - if (ImGui::InputInt("##val", &val)) + ImGui::SetNextItemWidth(C::Em(C::SCENE_VALUE_INPUT_EM)); + if (ImGui::InputInt("##val", &val, 0, 0)) manager->UpdateEntryValue(type, index, val, true); if (ImGui::IsItemDeactivatedAfterEdit()) manager->SaveUserSettings(type); @@ -194,7 +170,7 @@ namespace SceneSettingsUI ImGui::SameLine(); { auto styledButton = Util::ErrorButtonStyle(); - if (ImGui::Button("X", ImVec2(C::SCENE_DELETE_BUTTON_WIDTH, 0))) { + if (ImGui::Button("X", ImVec2(C::Em(C::SCENE_DELETE_BUTTON_EM), 0))) { if (isOverwrite) { popups.pendingDeleteIndex = index; popups.deleteSingleOverwrite.message = std::format( @@ -232,31 +208,78 @@ namespace SceneSettingsUI manager->DeleteAllUserSettings(type); } - void DrawEntrySections(SceneType type, PopupState& popups, - const std::vector& overwriteIndices, - const std::vector& userIndices) + /// Draw entries grouped by feature with collapsible tree nodes. + static void DrawGroupedEntries(SceneType type, PopupState& popups, + const std::vector& indices) { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(type); + + std::map> grouped; + for (auto i : indices) + if (i < entries.size()) + grouped[entries[i].featureShortName].push_back(i); + + // Sort settings within each feature group alphabetically by key + for (auto& [_, featureIndices] : grouped) + std::sort(featureIndices.begin(), featureIndices.end(), [&entries](size_t a, size_t b) { + return entries[a].settingKey < entries[b].settingKey; + }); + + for (const auto& [featureName, featureIndices] : grouped) { + auto label = SceneSettingsManager::GetFeatureDisplayName(featureName) + ":"; + if (ImGui::TreeNodeEx(label.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Indent(C::Em(C::SCENE_ENTRY_INDENT_EM)); + for (auto i : featureIndices) + if (i < entries.size()) + DrawSettingEntry(type, i, popups); + ImGui::Unindent(C::Em(C::SCENE_ENTRY_INDENT_EM)); + ImGui::TreePop(); + } + } + } + + void DrawSectionHeader(const char* label, const ImVec4& color, const char* idSuffix, + bool allPaused, std::function onTogglePause, std::function onDeleteAll) + { + ImGui::Spacing(); + ImGui::TextColored(color, "%s", label); + ImGui::SameLine(); + auto pauseLabel = std::format("{}{}" , allPaused ? "Unpause All" : "Pause All", idSuffix); + if (ImGui::SmallButton(pauseLabel.c_str())) + onTogglePause(); + ImGui::SameLine(); + auto deleteLabel = std::format("Delete All{}", idSuffix); + if (ImGui::SmallButton(deleteLabel.c_str())) + onDeleteAll(); + ImGui::Separator(); + } + + void DrawEntrySections(SceneType type, PopupState& popups) + { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(type); auto& theme = globals::menu->GetSettings().Theme; + // Split indices by source + std::vector overwriteIndices, userIndices; + for (size_t i = 0; i < entries.size(); ++i) + (entries[i].source == EntrySource::Overwrite ? overwriteIndices : userIndices).push_back(i); + if (!overwriteIndices.empty()) { - ImGui::Spacing(); - ImGui::TextColored(theme.StatusPalette.InfoColor, "Overwrite Files"); - ImGui::Separator(); - for (auto i : overwriteIndices) - DrawSettingEntry(type, i, popups); + DrawSectionHeader("Overwrite Files", theme.StatusPalette.InfoColor, "##ow", + manager->AreAllOverwritesPaused(type), + [&] { manager->SetAllOverwritesPaused(type, !manager->AreAllOverwritesPaused(type)); }, + [&] { popups.deleteAllOverwrites.Request(); }); + DrawGroupedEntries(type, popups, overwriteIndices); } if (!userIndices.empty()) { - if (!overwriteIndices.empty()) { - ImGui::Spacing(); - ImGui::TextColored(theme.FeatureHeading.ColorDefault, "User Settings"); - ImGui::Separator(); - } - for (auto i : userIndices) { - if (i >= SceneSettingsManager::GetSingleton()->GetEntries(type).size()) - break; - DrawSettingEntry(type, i, popups); - } + DrawSectionHeader("User Settings", theme.FeatureHeading.ColorDefault, "##usr", + manager->AreAllUserPaused(type), + [&] { manager->SetAllUserPaused(type, !manager->AreAllUserPaused(type)); }, + [&] { popups.deleteAllUser.Request(); }); + DrawGroupedEntries(type, popups, userIndices); } } @@ -264,10 +287,15 @@ namespace SceneSettingsUI { if (selected != category) return false; - drawFn(); + // Wrap in a scrollable child since the parent disables scrolling (kStickyHeaderFlags) + if (ImGui::BeginChild("##SceneSettingsScroll", ImVec2(0, 0), ImGuiChildFlags_None)) { + drawFn(); + ImGui::Spacing(); // Ensure bottom table border is visible when scrolled to end + } + ImGui::EndChild(); ImGui::EndChild(); ImGui::EndTable(); ImGui::End(); return true; } -} +} \ No newline at end of file diff --git a/src/WeatherEditor/SceneSettingsUI.h b/src/WeatherEditor/SceneSettingsUI.h index 93c777e389..98735bd2d9 100644 --- a/src/WeatherEditor/SceneSettingsUI.h +++ b/src/WeatherEditor/SceneSettingsUI.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "SceneSettingsManager.h" #include "Utils/UI.h" @@ -11,11 +13,10 @@ namespace SceneSettingsUI using EntrySource = SceneSettingsManager::EntrySource; using Period = SceneSettingsManager::TimeOfDayPeriod; - /// Persistent state for a single "Add Setting" dropdown row. + /// Persistent state for the feature/setting tree selector. struct AddSettingState { int selectedFeatureIdx = -1; - int selectedSettingIdx = -1; std::vector cachedFeatureNames; std::vector cachedSettingKeys; }; @@ -33,12 +34,13 @@ namespace SceneSettingsUI deleteAllUser("Delete All User Settings?", userMsg, "Delete All") {} }; - /// Draw the feature/setting dropdown + Add button. + /// Draw the feature tree selector. Selecting a setting auto-adds it. /// @param type Scene type being edited. /// @param state Persistent dropdown state (selection indices, caches). /// @param period For TimeOfDay entries, which period to add to. Count = none. + /// @param labelPrefix Optional label drawn before the dropdowns (e.g. period name). void DrawAddSettingUI(SceneType type, AddSettingState& state, - Period period = Period::Count); + Period period = Period::Count, const char* labelPrefix = nullptr); /// Draw a single setting entry row (label, value editor, pause toggle, delete). /// @param type Scene type being edited. @@ -50,18 +52,23 @@ namespace SceneSettingsUI /// Process all three delete-confirmation popups relative to the given type. void DrawPopups(SceneType type, PopupState& popups); - /// Draw overwrite + user entry sections with section headers. + /// Draw a section header with Pause All / Delete All inline buttons. + /// @param label Section label (e.g. "Overwrite Files", "User Settings"). + /// @param color Header text color. + /// @param idSuffix ImGui ID suffix for button uniqueness (e.g. "##ow"). + /// @param allPaused Whether all entries in this section are currently paused. + /// @param onTogglePause Callback when Pause/Unpause All is clicked. + /// @param onDeleteAll Callback when Delete All is clicked. + void DrawSectionHeader(const char* label, const ImVec4& color, const char* idSuffix, + bool allPaused, std::function onTogglePause, std::function onDeleteAll); + + /// Draw overwrite + user entry sections with section-header-inline controls, + /// each section's entries grouped by feature name. /// @param type Scene type being edited. /// @param popups Shared popup state. - /// @param overwriteIndices Entry indices for overwrite entries. - /// @param userIndices Entry indices for user entries. - void DrawEntrySections(SceneType type, PopupState& popups, - const std::vector& overwriteIndices, - const std::vector& userIndices); + void DrawEntrySections(SceneType type, PopupState& popups); /// Draw a standalone scene-settings panel that dispatches to this panel's Draw(). - /// Handles ImGui::EndChild / ImGui::EndTable / ImGui::End early-return pattern - /// used by EditorWindow for full-panel categories. /// @param category Category name to check (e.g. "Interior Only"). /// @param selected Currently selected category string. /// @param drawFn Drawing function to call if category matches. diff --git a/src/WeatherEditor/TimeOfDayPanel.cpp b/src/WeatherEditor/TimeOfDayPanel.cpp index 4e6659fdbe..8be0e7ea4a 100644 --- a/src/WeatherEditor/TimeOfDayPanel.cpp +++ b/src/WeatherEditor/TimeOfDayPanel.cpp @@ -1,7 +1,12 @@ #include "TimeOfDayPanel.h" +#include +#include +#include + #include "../Globals.h" #include "../Menu.h" +#include "../Menu/ThemeManager.h" #include "../SceneSettingsManager.h" #include "SceneSettingsUI.h" @@ -10,10 +15,12 @@ namespace TimeOfDayPanel using SceneType = SceneSettingsManager::SceneType; using EntrySource = SceneSettingsManager::EntrySource; using Period = SceneSettingsManager::TimeOfDayPeriod; + using C = ThemeManager::Constants; static constexpr auto kSceneType = SceneType::TimeOfDay; + static constexpr int kPeriodCount = SceneSettingsManager::kPeriodCount; // Per-period add-setting state - static SceneSettingsUI::AddSettingState periodAddState[SceneSettingsManager::kPeriodCount]; + static SceneSettingsUI::AddSettingState periodAddState[kPeriodCount]; // Shared popups static SceneSettingsUI::PopupState popups{ @@ -21,46 +28,221 @@ namespace TimeOfDayPanel "Are you sure you want to remove all user-added time-of-day settings?" }; - static void CollectPeriodIndices(Period period, const std::vector& entries, - std::vector& overwriteOut, std::vector& userOut) + /// Collect all unique feature+setting pairs across all periods, preserving order. + struct SettingId { - for (size_t i = 0; i < entries.size(); ++i) { - if (entries[i].period != period) - continue; - if (entries[i].source == EntrySource::Overwrite) - overwriteOut.push_back(i); - else - userOut.push_back(i); + std::string feature; + std::string key; + bool operator<(const SettingId& o) const + { + return (feature < o.feature) || (feature == o.feature && key < o.key); } - } + }; - static void DrawPeriodTab(Period period) + /// Draw a single value cell for a given entry index (or empty if no entry for this period). + static void DrawValueCell(size_t entryIndex) { auto* manager = SceneSettingsManager::GetSingleton(); const auto& entries = manager->GetEntries(kSceneType); - auto& theme = globals::menu->GetSettings().Theme; - // Draw single-overwrite delete popup (shared across tabs) - if (popups.deleteSingleOverwrite.Draw()) { - if (popups.pendingDeleteIndex < entries.size()) - manager->RemoveSetting(kSceneType, popups.pendingDeleteIndex); - popups.pendingDeleteIndex = SIZE_MAX; + if (entryIndex == SIZE_MAX) { + ImGui::TextDisabled("--"); + return; } - SceneSettingsUI::DrawAddSettingUI(kSceneType, periodAddState[static_cast(period)], period); + const auto& entry = entries[entryIndex]; + bool isOverwrite = entry.source == EntrySource::Overwrite; + auto settingType = SceneSettingsManager::DetectSettingType(entry.value); - // Collect indices for this period - std::vector overwriteIndices, userIndices; - CollectPeriodIndices(period, entries, overwriteIndices, userIndices); + ImGui::PushID(static_cast(entryIndex)); - if (overwriteIndices.empty() && userIndices.empty()) { - ImGui::Spacing(); - ImGui::TextColored(theme.StatusPalette.Disable, - "No settings for %s.", SceneSettingsManager::GetPeriodName(period)); - return; + bool readOnly = isOverwrite; + if (readOnly) + ImGui::BeginDisabled(); + + float colWidth = ImGui::GetContentRegionAvail().x; + + switch (settingType) { + case SceneSettingsManager::SettingType::Boolean: + { + bool val = entry.value.is_boolean() ? entry.value.get() : (entry.value.get() != 0); + if (ImGui::Checkbox("##val", &val)) + manager->UpdateEntryValue(kSceneType, entryIndex, entry.value.is_boolean() ? json(val) : json(val ? 1 : 0)); + } + break; + case SceneSettingsManager::SettingType::Float: + { + float val = entry.value.is_number() ? entry.value.get() : 0.0f; + if (!std::isfinite(val)) + val = 0.0f; + ImGui::SetNextItemWidth(colWidth); + if (ImGui::InputFloat("##val", &val, 0.0f, 0.0f, "%.3f")) + if (std::isfinite(val)) + manager->UpdateEntryValue(kSceneType, entryIndex, val, true); + if (ImGui::IsItemDeactivatedAfterEdit()) + manager->SaveUserSettings(kSceneType); + } + break; + case SceneSettingsManager::SettingType::Integer: + { + int val = entry.value.get(); + ImGui::SetNextItemWidth(colWidth); + if (ImGui::InputInt("##val", &val, 0, 0)) + manager->UpdateEntryValue(kSceneType, entryIndex, val, true); + if (ImGui::IsItemDeactivatedAfterEdit()) + manager->SaveUserSettings(kSceneType); + } + break; + default: + ImGui::TextDisabled("(?)"); + break; } - SceneSettingsUI::DrawEntrySections(kSceneType, popups, overwriteIndices, userIndices); + if (readOnly) + ImGui::EndDisabled(); + + // Toggle + X on a second line + bool active = !entry.paused; + if (Util::FeatureToggle("##active", &active)) + manager->TogglePauseEntry(kSceneType, entryIndex); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(entry.paused ? "Paused" : "Active"); + + ImGui::SameLine(); + { + auto styledButton = Util::ErrorButtonStyle(); + if (ImGui::Button("X", ImVec2(C::Em(C::SCENE_DELETE_BUTTON_EM), 0))) { + if (isOverwrite) { + popups.pendingDeleteIndex = entryIndex; + popups.deleteSingleOverwrite.message = std::format( + "Delete overwrite file '{}'?\nThis will permanently remove the file from disk.", + entry.sourceFilename); + popups.deleteSingleOverwrite.Request(); + } else { + manager->RemoveSetting(kSceneType, entryIndex); + } + } + } + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(isOverwrite ? "Delete overwrite file" : "Remove this setting"); + + ImGui::PopID(); + } + + /// Build a setting map for entries from a specific source. + struct SourceGroup + { + std::vector order; + std::map>> map; + }; + + static SourceGroup BuildSourceGroup(const std::vector& entries, + EntrySource source) + { + SourceGroup group; + for (size_t idx = 0; idx < entries.size(); ++idx) { + const auto& e = entries[idx]; + if (e.source != source) + continue; + int p = static_cast(e.period); + if (p < 0 || p >= kPeriodCount) + continue; + auto& featureMap = group.map[e.featureShortName]; + auto [it, inserted] = featureMap.try_emplace(e.settingKey); + if (inserted) { + it->second.fill(SIZE_MAX); + group.order.push_back({ e.featureShortName, e.settingKey }); + } + it->second[p] = idx; + } + // Sort by feature name then setting key + std::sort(group.order.begin(), group.order.end()); + return group; + } + + /// Draw TOD table rows for a set of entries grouped by feature. + static void DrawSourceRows(const SourceGroup& group, const float* factors) + { + auto& theme = globals::menu->GetSettings().Theme; + std::string lastFeature; + for (const auto& sid : group.order) { + if (sid.feature != lastFeature) { + lastFeature = sid.feature; + + // Feature header row with highlight and smaller text + ImGui::TableNextRow(); + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::GetColorU32(ImGuiCol_TableRowBgAlt)); + ImGui::TableSetColumnIndex(0); + ImGui::SetWindowFontScale(C::SCENE_TOD_FEATURE_TEXT_SCALE); + auto featureLabel = SceneSettingsManager::GetFeatureDisplayName(sid.feature); + ImGui::TextColored(theme.FeatureHeading.ColorDefault, "%s:", featureLabel.c_str()); + ImGui::SetWindowFontScale(1.0f); + } + + auto mapIt = group.map.find(sid.feature); + if (mapIt == group.map.end()) + continue; + auto keyIt = mapIt->second.find(sid.key); + if (keyIt == mapIt->second.end()) + continue; + const auto& perKey = keyIt->second; + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::Indent(C::Em(C::SCENE_ENTRY_INDENT_EM)); + ImGui::SetWindowFontScale(C::SCENE_TOD_FEATURE_TEXT_SCALE); + ImGui::Text("%s", sid.key.c_str()); + ImGui::SetWindowFontScale(1.0f); + ImGui::Unindent(C::Em(C::SCENE_ENTRY_INDENT_EM)); + + for (int p = 0; p < kPeriodCount; ++p) { + ImGui::TableSetColumnIndex(1 + p); + + bool isActive = factors[p] > 0.0f; + if (!isActive) + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, C::SCENE_TOD_INACTIVE_ALPHA); + + DrawValueCell(perKey[p]); + + if (!isActive) + ImGui::PopStyleVar(); + } + } + } + + /// Draw a TOD table for a single source group. + static void DrawSourceTable(const SourceGroup& group, const float* factors, const char* tableId) + { + constexpr int kTotalCols = 1 + kPeriodCount; + + if (ImGui::BeginTable(tableId, kTotalCols, + ImGuiTableFlags_Borders | + ImGuiTableFlags_SizingFixedFit | + ImGuiTableFlags_NoHostExtendX)) { + ImGui::TableSetupColumn("Setting", ImGuiTableColumnFlags_WidthFixed, C::Em(C::SCENE_TOD_PARAM_COL_EM)); + for (int i = 0; i < kPeriodCount; ++i) + ImGui::TableSetupColumn(SceneSettingsManager::kPeriodNames[i], + ImGuiTableColumnFlags_WidthFixed, C::Em(C::SCENE_TOD_PERIOD_COL_EM)); + + ImGui::TableSetupScrollFreeze(0, 1); + + // Header row with period names + active highlighting + ImGui::TableNextRow(ImGuiTableRowFlags_Headers); + ImGui::TableSetColumnIndex(0); + ImGui::TableHeader("Setting"); + for (int i = 0; i < kPeriodCount; ++i) { + ImGui::TableSetColumnIndex(1 + i); + bool isActive = factors[i] > 0.01f; + if (!isActive) + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + ImGui::TableHeader(SceneSettingsManager::kPeriodNames[i]); + if (!isActive) + ImGui::PopStyleColor(); + } + + DrawSourceRows(group, factors); + ImGui::EndTable(); + } } void Draw() @@ -69,11 +251,11 @@ namespace TimeOfDayPanel const auto& entries = manager->GetEntries(kSceneType); auto& theme = globals::menu->GetSettings().Theme; + // Header ImGui::Text("Time of Day Settings"); ImGui::SameLine(); ImGui::TextDisabled("(Exterior Only)"); - // Show current period indicator auto currentPeriod = SceneSettingsManager::GetCurrentPeriod(); ImGui::SameLine(); ImGui::TextColored(theme.StatusPalette.InfoColor, "[%s %.1fh]", @@ -82,42 +264,53 @@ namespace TimeOfDayPanel ImGui::Separator(); - // Global confirmation popups - if (popups.deleteAllOverwrites.Draw()) - manager->DeleteAllOverwrites(kSceneType); - if (popups.deleteAllUser.Draw()) - manager->DeleteAllUserSettings(kSceneType); - - // Global controls - if (!entries.empty()) { - if (manager->HasOverwriteEntries(kSceneType)) { - bool allPaused = manager->AreAllOverwritesPaused(kSceneType); - if (ImGui::SmallButton(allPaused ? "Unpause All Overwrite" : "Pause All Overwrite")) - manager->SetAllOverwritesPaused(kSceneType, !allPaused); - ImGui::SameLine(); - if (ImGui::SmallButton("Delete All Overwrite")) - popups.deleteAllOverwrites.Request(); - ImGui::SameLine(); + // Popups + SceneSettingsUI::DrawPopups(kSceneType, popups); + + // Per-period add-setting dropdowns — period name inline with dropdowns + if (ImGui::CollapsingHeader("Add Settings", ImGuiTreeNodeFlags_DefaultOpen)) { + for (int i = 0; i < kPeriodCount; ++i) { + auto periodLabel = std::format("{}:", SceneSettingsManager::GetPeriodName(static_cast(i))); + ImGui::PushID(i); + SceneSettingsUI::DrawAddSettingUI(kSceneType, periodAddState[i], + static_cast(i), periodLabel.c_str()); + ImGui::PopID(); } + } - bool allUserPaused = manager->AreAllUserPaused(kSceneType); - if (ImGui::SmallButton(allUserPaused ? "Unpause All User" : "Pause All User")) - manager->SetAllUserPaused(kSceneType, !allUserPaused); - ImGui::SameLine(); - if (ImGui::SmallButton("Delete All User")) - popups.deleteAllUser.Request(); + if (entries.empty()) { + ImGui::Spacing(); + ImGui::TextColored(theme.StatusPalette.Disable, + "No time-of-day settings configured."); + ImGui::TextColored(theme.StatusPalette.Disable, + "Select a feature and setting above to add overrides for each period."); + return; } - // Period tabs - if (ImGui::BeginTabBar("##TODPeriods")) { - for (int i = 0; i < SceneSettingsManager::kPeriodCount; ++i) { - auto period = static_cast(i); - if (ImGui::BeginTabItem(SceneSettingsManager::GetPeriodName(period))) { - DrawPeriodTab(period); - ImGui::EndTabItem(); - } - } - ImGui::EndTabBar(); + ImGui::Spacing(); + + // Build separate maps for overwrite and user entries + auto overwriteGroup = BuildSourceGroup(entries, EntrySource::Overwrite); + auto userGroup = BuildSourceGroup(entries, EntrySource::User); + + // Get active period factors for highlighting + float factors[kPeriodCount]; + manager->GetTimeOfDayFactors(factors); + + if (!overwriteGroup.order.empty()) { + SceneSettingsUI::DrawSectionHeader("Overwrite Files", theme.StatusPalette.InfoColor, "##ow", + manager->AreAllOverwritesPaused(kSceneType), + [&] { manager->SetAllOverwritesPaused(kSceneType, !manager->AreAllOverwritesPaused(kSceneType)); }, + [&] { popups.deleteAllOverwrites.Request(); }); + DrawSourceTable(overwriteGroup, factors, "##TODOverwriteTable"); + } + + if (!userGroup.order.empty()) { + SceneSettingsUI::DrawSectionHeader("User Settings", theme.FeatureHeading.ColorDefault, "##usr", + manager->AreAllUserPaused(kSceneType), + [&] { manager->SetAllUserPaused(kSceneType, !manager->AreAllUserPaused(kSceneType)); }, + [&] { popups.deleteAllUser.Request(); }); + DrawSourceTable(userGroup, factors, "##TODUserTable"); } } } From 7c97a4e68d4c6045114f71c5f9a2c640afb2f23d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:11:41 +0000 Subject: [PATCH 16/36] =?UTF-8?q?style:=20=F0=9F=8E=A8=20apply=20pre-commi?= =?UTF-8?q?t.ci=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated formatting by clang-format, prettier, and other hooks. See https://pre-commit.ci for details. --- src/WeatherEditor/SceneSettingsUI.cpp | 18 ++++++------------ src/WeatherEditor/TimeOfDayPanel.cpp | 10 ++-------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/WeatherEditor/SceneSettingsUI.cpp b/src/WeatherEditor/SceneSettingsUI.cpp index a22d96afab..c8fb4a0f64 100644 --- a/src/WeatherEditor/SceneSettingsUI.cpp +++ b/src/WeatherEditor/SceneSettingsUI.cpp @@ -46,9 +46,9 @@ namespace SceneSettingsUI state.cachedFeatureNames = GetFeatureNamesForType(type); auto displayName = (state.selectedFeatureIdx >= 0 && - state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) - ? SceneSettingsManager::GetFeatureDisplayName(state.cachedFeatureNames[state.selectedFeatureIdx]) - : std::string("Select Feature..."); + state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) ? + SceneSettingsManager::GetFeatureDisplayName(state.cachedFeatureNames[state.selectedFeatureIdx]) : + std::string("Select Feature..."); const char* featurePreview = displayName.c_str(); ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_FEATURE_DROPDOWN_RATIO); @@ -245,7 +245,7 @@ namespace SceneSettingsUI ImGui::Spacing(); ImGui::TextColored(color, "%s", label); ImGui::SameLine(); - auto pauseLabel = std::format("{}{}" , allPaused ? "Unpause All" : "Pause All", idSuffix); + auto pauseLabel = std::format("{}{}", allPaused ? "Unpause All" : "Pause All", idSuffix); if (ImGui::SmallButton(pauseLabel.c_str())) onTogglePause(); ImGui::SameLine(); @@ -267,18 +267,12 @@ namespace SceneSettingsUI (entries[i].source == EntrySource::Overwrite ? overwriteIndices : userIndices).push_back(i); if (!overwriteIndices.empty()) { - DrawSectionHeader("Overwrite Files", theme.StatusPalette.InfoColor, "##ow", - manager->AreAllOverwritesPaused(type), - [&] { manager->SetAllOverwritesPaused(type, !manager->AreAllOverwritesPaused(type)); }, - [&] { popups.deleteAllOverwrites.Request(); }); + DrawSectionHeader("Overwrite Files", theme.StatusPalette.InfoColor, "##ow", manager->AreAllOverwritesPaused(type), [&] { manager->SetAllOverwritesPaused(type, !manager->AreAllOverwritesPaused(type)); }, [&] { popups.deleteAllOverwrites.Request(); }); DrawGroupedEntries(type, popups, overwriteIndices); } if (!userIndices.empty()) { - DrawSectionHeader("User Settings", theme.FeatureHeading.ColorDefault, "##usr", - manager->AreAllUserPaused(type), - [&] { manager->SetAllUserPaused(type, !manager->AreAllUserPaused(type)); }, - [&] { popups.deleteAllUser.Request(); }); + DrawSectionHeader("User Settings", theme.FeatureHeading.ColorDefault, "##usr", manager->AreAllUserPaused(type), [&] { manager->SetAllUserPaused(type, !manager->AreAllUserPaused(type)); }, [&] { popups.deleteAllUser.Request(); }); DrawGroupedEntries(type, popups, userIndices); } } diff --git a/src/WeatherEditor/TimeOfDayPanel.cpp b/src/WeatherEditor/TimeOfDayPanel.cpp index 8be0e7ea4a..fd780f1fd5 100644 --- a/src/WeatherEditor/TimeOfDayPanel.cpp +++ b/src/WeatherEditor/TimeOfDayPanel.cpp @@ -298,18 +298,12 @@ namespace TimeOfDayPanel manager->GetTimeOfDayFactors(factors); if (!overwriteGroup.order.empty()) { - SceneSettingsUI::DrawSectionHeader("Overwrite Files", theme.StatusPalette.InfoColor, "##ow", - manager->AreAllOverwritesPaused(kSceneType), - [&] { manager->SetAllOverwritesPaused(kSceneType, !manager->AreAllOverwritesPaused(kSceneType)); }, - [&] { popups.deleteAllOverwrites.Request(); }); + SceneSettingsUI::DrawSectionHeader("Overwrite Files", theme.StatusPalette.InfoColor, "##ow", manager->AreAllOverwritesPaused(kSceneType), [&] { manager->SetAllOverwritesPaused(kSceneType, !manager->AreAllOverwritesPaused(kSceneType)); }, [&] { popups.deleteAllOverwrites.Request(); }); DrawSourceTable(overwriteGroup, factors, "##TODOverwriteTable"); } if (!userGroup.order.empty()) { - SceneSettingsUI::DrawSectionHeader("User Settings", theme.FeatureHeading.ColorDefault, "##usr", - manager->AreAllUserPaused(kSceneType), - [&] { manager->SetAllUserPaused(kSceneType, !manager->AreAllUserPaused(kSceneType)); }, - [&] { popups.deleteAllUser.Request(); }); + SceneSettingsUI::DrawSectionHeader("User Settings", theme.FeatureHeading.ColorDefault, "##usr", manager->AreAllUserPaused(kSceneType), [&] { manager->SetAllUserPaused(kSceneType, !manager->AreAllUserPaused(kSceneType)); }, [&] { popups.deleteAllUser.Request(); }); DrawSourceTable(userGroup, factors, "##TODUserTable"); } } From 9ef3694060df73611c79512cb8e38971b0a87088 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:47:47 -0700 Subject: [PATCH 17/36] add to all times of day --- src/Menu/ThemeManager.h | 1 + src/WeatherEditor/SceneSettingsUI.cpp | 105 +++++++++++++------- src/WeatherEditor/SceneSettingsUI.h | 20 +++- src/WeatherEditor/TimeOfDayPanel.cpp | 136 ++++++++++++++++---------- 4 files changed, 171 insertions(+), 91 deletions(-) diff --git a/src/Menu/ThemeManager.h b/src/Menu/ThemeManager.h index f90b09137c..01d83de874 100644 --- a/src/Menu/ThemeManager.h +++ b/src/Menu/ThemeManager.h @@ -198,6 +198,7 @@ class ThemeManager static constexpr float SCENE_TOD_PARAM_COL_EM = 6.0f; // Parameter column width (TOD table) static constexpr float SCENE_TOD_PERIOD_COL_EM = 4.3f; // Per-period column width (TOD table) static constexpr float SCENE_TOD_INACTIVE_ALPHA = 0.5f; // Alpha for inactive TOD periods + static constexpr float SCENE_TOD_ACTIVE_THRESHOLD = 0.01f; // Factor threshold to consider a period active static constexpr float SCENE_ENTRY_INDENT_EM = 0.4f; // Indent for setting entries under feature headers static constexpr float SCENE_TOD_FEATURE_TEXT_SCALE = 0.85f; // Smaller text scale for feature names in TOD table static constexpr float SCENE_TOD_LABEL_EM = 2.6f; // Fixed width for period labels in add-setting rows diff --git a/src/WeatherEditor/SceneSettingsUI.cpp b/src/WeatherEditor/SceneSettingsUI.cpp index c8fb4a0f64..b2be731ea6 100644 --- a/src/WeatherEditor/SceneSettingsUI.cpp +++ b/src/WeatherEditor/SceneSettingsUI.cpp @@ -29,9 +29,10 @@ namespace SceneSettingsUI // --- Shared Drawing --- - void DrawAddSettingUI(SceneType type, AddSettingState& state, Period period, const char* labelPrefix) + void DrawAddSettingUI(SceneType type, AddSettingState& state, Period period, const char* labelPrefix, bool addToAllPeriods) { auto* manager = SceneSettingsManager::GetSingleton(); + constexpr int kPeriodCount = SceneSettingsManager::kPeriodCount; ImGui::Spacing(); @@ -73,17 +74,34 @@ namespace SceneSettingsUI ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); if (ImGui::BeginCombo("##SettingSelect", "Select Setting...")) { for (int i = 0; i < static_cast(state.cachedSettingKeys.size()); ++i) { - bool alreadyAdded = state.selectedFeatureIdx >= 0 && - IsAlreadyAdded(type, state.cachedFeatureNames[state.selectedFeatureIdx], - state.cachedSettingKeys[i], period); + auto& featureName = state.cachedFeatureNames[state.selectedFeatureIdx]; + auto& key = state.cachedSettingKeys[i]; + + // Check if already added (for all-periods mode, disabled only when present in every period) + bool alreadyAdded = false; + if (state.selectedFeatureIdx >= 0) { + if (addToAllPeriods) { + alreadyAdded = true; + for (int p = 0; p < kPeriodCount && alreadyAdded; ++p) + alreadyAdded = IsAlreadyAdded(type, featureName, key, static_cast(p)); + } else { + alreadyAdded = IsAlreadyAdded(type, featureName, key, period); + } + } + if (alreadyAdded) { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_TextDisabled]); - ImGui::Selectable(state.cachedSettingKeys[i].c_str(), false, ImGuiSelectableFlags_Disabled); + ImGui::Selectable(key.c_str(), false, ImGuiSelectableFlags_Disabled); ImGui::PopStyleColor(); - } else if (ImGui::Selectable(state.cachedSettingKeys[i].c_str(), false)) { - auto& featureName = state.cachedFeatureNames[state.selectedFeatureIdx]; - auto currentValue = SceneSettingsManager::GetFeatureSettingValue(featureName, state.cachedSettingKeys[i]); - manager->AddSetting(type, featureName, state.cachedSettingKeys[i], currentValue, period); + } else if (ImGui::Selectable(key.c_str(), false)) { + auto currentValue = SceneSettingsManager::GetFeatureSettingValue(featureName, key); + if (addToAllPeriods) { + for (int p = 0; p < kPeriodCount; ++p) + if (!IsAlreadyAdded(type, featureName, key, static_cast(p))) + manager->AddSetting(type, featureName, key, currentValue, static_cast(p)); + } else { + manager->AddSetting(type, featureName, key, currentValue, period); + } } } ImGui::EndCombo(); @@ -91,34 +109,12 @@ namespace SceneSettingsUI } } - bool DrawSettingEntry(SceneType type, size_t index, PopupState& popups) + void DrawValueEditor(SceneType type, size_t index, float inputWidth) { auto* manager = SceneSettingsManager::GetSingleton(); - const auto& entries = manager->GetEntries(type); - if (index >= entries.size()) - return false; - - const auto& entry = entries[index]; - - ImGui::PushID(static_cast(index)); - - // Setting key label (no feature prefix — grouped by feature already) - float availWidth = ImGui::GetContentRegionAvail().x; - ImGui::Text("%s", entry.settingKey.c_str()); - - // Value editor (right-aligned) - ImGui::SameLine(availWidth * C::SCENE_VALUE_LABEL_OFFSET_RATIO); - - bool isOverwrite = entry.source == EntrySource::Overwrite; + const auto& entry = manager->GetEntries(type)[index]; auto settingType = SceneSettingsManager::DetectSettingType(entry.value); - bool readOnly = isOverwrite || - (type != SceneType::TimeOfDay && - manager->HasActiveOverwrite(type, entry.featureShortName, entry.settingKey)); - - if (readOnly) - ImGui::BeginDisabled(); - switch (settingType) { case SceneSettingsManager::SettingType::Boolean: { @@ -132,7 +128,7 @@ namespace SceneSettingsUI float val = entry.value.is_number() ? entry.value.get() : 0.0f; if (!std::isfinite(val)) val = 0.0f; - ImGui::SetNextItemWidth(C::Em(C::SCENE_VALUE_INPUT_EM)); + ImGui::SetNextItemWidth(inputWidth); if (ImGui::InputFloat("##val", &val, 0.0f, 0.0f, "%.3f")) if (std::isfinite(val)) manager->UpdateEntryValue(type, index, val, true); @@ -143,7 +139,7 @@ namespace SceneSettingsUI case SceneSettingsManager::SettingType::Integer: { int val = entry.value.get(); - ImGui::SetNextItemWidth(C::Em(C::SCENE_VALUE_INPUT_EM)); + ImGui::SetNextItemWidth(inputWidth); if (ImGui::InputInt("##val", &val, 0, 0)) manager->UpdateEntryValue(type, index, val, true); if (ImGui::IsItemDeactivatedAfterEdit()) @@ -154,6 +150,36 @@ namespace SceneSettingsUI ImGui::TextDisabled("(unsupported type)"); break; } + } + + bool DrawSettingEntry(SceneType type, size_t index, PopupState& popups) + { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(type); + if (index >= entries.size()) + return false; + + const auto& entry = entries[index]; + + ImGui::PushID(static_cast(index)); + + // Setting key label (no feature prefix — grouped by feature already) + float availWidth = ImGui::GetContentRegionAvail().x; + ImGui::Text("%s", entry.settingKey.c_str()); + + // Value editor (right-aligned) + ImGui::SameLine(availWidth * C::SCENE_VALUE_LABEL_OFFSET_RATIO); + + bool isOverwrite = entry.source == EntrySource::Overwrite; + + bool readOnly = isOverwrite || + (type != SceneType::TimeOfDay && + manager->HasActiveOverwrite(type, entry.featureShortName, entry.settingKey)); + + if (readOnly) + ImGui::BeginDisabled(); + + DrawValueEditor(type, index, C::Em(C::SCENE_VALUE_INPUT_EM)); if (readOnly) ImGui::EndDisabled(); @@ -204,6 +230,15 @@ namespace SceneSettingsUI popups.pendingDeleteIndex = SIZE_MAX; } + if (popups.deleteRowOverwrite.Draw()) { + // Delete in reverse index order so earlier indices remain valid + std::sort(popups.pendingDeleteRow.begin(), popups.pendingDeleteRow.end(), std::greater<>()); + for (auto idx : popups.pendingDeleteRow) + if (idx < manager->GetEntries(type).size()) + manager->RemoveSetting(type, idx); + popups.pendingDeleteRow.clear(); + } + if (popups.deleteAllUser.Draw()) manager->DeleteAllUserSettings(type); } diff --git a/src/WeatherEditor/SceneSettingsUI.h b/src/WeatherEditor/SceneSettingsUI.h index 98735bd2d9..df535f5ca0 100644 --- a/src/WeatherEditor/SceneSettingsUI.h +++ b/src/WeatherEditor/SceneSettingsUI.h @@ -26,8 +26,10 @@ namespace SceneSettingsUI { Util::ConfirmationPopup deleteAllOverwrites; Util::ConfirmationPopup deleteSingleOverwrite{ "Delete Overwrite File?", "", "Delete" }; + Util::ConfirmationPopup deleteRowOverwrite{ "Delete Overwrite Row?", "", "Delete" }; Util::ConfirmationPopup deleteAllUser; size_t pendingDeleteIndex = SIZE_MAX; + std::vector pendingDeleteRow; PopupState(const char* overwriteMsg, const char* userMsg) : deleteAllOverwrites("Delete All Overwrites?", overwriteMsg, "Delete All"), @@ -35,12 +37,20 @@ namespace SceneSettingsUI }; /// Draw the feature tree selector. Selecting a setting auto-adds it. - /// @param type Scene type being edited. - /// @param state Persistent dropdown state (selection indices, caches). - /// @param period For TimeOfDay entries, which period to add to. Count = none. - /// @param labelPrefix Optional label drawn before the dropdowns (e.g. period name). + /// @param type Scene type being edited. + /// @param state Persistent dropdown state (selection indices, caches). + /// @param period For TimeOfDay entries, which period to add to. Count = none. + /// @param labelPrefix Optional label drawn before the dropdowns (e.g. period name). + /// @param addToAllPeriods When true, adds the setting to every period at once. void DrawAddSettingUI(SceneType type, AddSettingState& state, - Period period = Period::Count, const char* labelPrefix = nullptr); + Period period = Period::Count, const char* labelPrefix = nullptr, + bool addToAllPeriods = false); + + /// Draw the value editor widget (checkbox/float input/int input) for a setting entry. + /// @param type Scene type being edited. + /// @param index Index into the entries vector. + /// @param inputWidth Width for float/int input widgets. + void DrawValueEditor(SceneType type, size_t index, float inputWidth); /// Draw a single setting entry row (label, value editor, pause toggle, delete). /// @param type Scene type being edited. diff --git a/src/WeatherEditor/TimeOfDayPanel.cpp b/src/WeatherEditor/TimeOfDayPanel.cpp index fd780f1fd5..8f389debdb 100644 --- a/src/WeatherEditor/TimeOfDayPanel.cpp +++ b/src/WeatherEditor/TimeOfDayPanel.cpp @@ -1,8 +1,10 @@ #include "TimeOfDayPanel.h" +#include #include #include #include +#include #include "../Globals.h" #include "../Menu.h" @@ -21,6 +23,8 @@ namespace TimeOfDayPanel // Per-period add-setting state static SceneSettingsUI::AddSettingState periodAddState[kPeriodCount]; + static SceneSettingsUI::AddSettingState allPeriodsAddState; + static bool addToAllPeriods = false; // Shared popups static SceneSettingsUI::PopupState popups{ @@ -52,7 +56,6 @@ namespace TimeOfDayPanel const auto& entry = entries[entryIndex]; bool isOverwrite = entry.source == EntrySource::Overwrite; - auto settingType = SceneSettingsManager::DetectSettingType(entry.value); ImGui::PushID(static_cast(entryIndex)); @@ -60,43 +63,7 @@ namespace TimeOfDayPanel if (readOnly) ImGui::BeginDisabled(); - float colWidth = ImGui::GetContentRegionAvail().x; - - switch (settingType) { - case SceneSettingsManager::SettingType::Boolean: - { - bool val = entry.value.is_boolean() ? entry.value.get() : (entry.value.get() != 0); - if (ImGui::Checkbox("##val", &val)) - manager->UpdateEntryValue(kSceneType, entryIndex, entry.value.is_boolean() ? json(val) : json(val ? 1 : 0)); - } - break; - case SceneSettingsManager::SettingType::Float: - { - float val = entry.value.is_number() ? entry.value.get() : 0.0f; - if (!std::isfinite(val)) - val = 0.0f; - ImGui::SetNextItemWidth(colWidth); - if (ImGui::InputFloat("##val", &val, 0.0f, 0.0f, "%.3f")) - if (std::isfinite(val)) - manager->UpdateEntryValue(kSceneType, entryIndex, val, true); - if (ImGui::IsItemDeactivatedAfterEdit()) - manager->SaveUserSettings(kSceneType); - } - break; - case SceneSettingsManager::SettingType::Integer: - { - int val = entry.value.get(); - ImGui::SetNextItemWidth(colWidth); - if (ImGui::InputInt("##val", &val, 0, 0)) - manager->UpdateEntryValue(kSceneType, entryIndex, val, true); - if (ImGui::IsItemDeactivatedAfterEdit()) - manager->SaveUserSettings(kSceneType); - } - break; - default: - ImGui::TextDisabled("(?)"); - break; - } + SceneSettingsUI::DrawValueEditor(kSceneType, entryIndex, ImGui::GetContentRegionAvail().x); if (readOnly) ImGui::EndDisabled(); @@ -161,10 +128,13 @@ namespace TimeOfDayPanel } /// Draw TOD table rows for a set of entries grouped by feature. - static void DrawSourceRows(const SourceGroup& group, const float* factors) + static void DrawSourceRows(const SourceGroup& group, const float* factors, EntrySource source) { + auto* manager = SceneSettingsManager::GetSingleton(); auto& theme = globals::menu->GetSettings().Theme; + bool isOverwrite = source == EntrySource::Overwrite; std::string lastFeature; + for (const auto& sid : group.order) { if (sid.feature != lastFeature) { lastFeature = sid.feature; @@ -187,18 +157,76 @@ namespace TimeOfDayPanel continue; const auto& perKey = keyIt->second; + // Collect valid indices for this row + std::vector rowIndices; + for (int p = 0; p < kPeriodCount; ++p) + if (perKey[p] != SIZE_MAX) + rowIndices.push_back(perKey[p]); + ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); + ImGui::PushID(sid.key.c_str()); + ImGui::PushID(sid.feature.c_str()); + ImGui::Indent(C::Em(C::SCENE_ENTRY_INDENT_EM)); ImGui::SetWindowFontScale(C::SCENE_TOD_FEATURE_TEXT_SCALE); ImGui::Text("%s", sid.key.c_str()); ImGui::SetWindowFontScale(1.0f); + + // Row-level toggle + delete + { + const auto& entries = manager->GetEntries(kSceneType); + bool allPaused = std::all_of(rowIndices.begin(), rowIndices.end(), + [&](size_t i) { return i < entries.size() && entries[i].paused; }); + bool active = !allPaused; + if (Util::FeatureToggle("##rowActive", &active)) + for (auto idx : rowIndices) + if (idx < entries.size() && entries[idx].paused == active) + manager->TogglePauseEntry(kSceneType, idx); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(allPaused ? "Unpause all periods" : "Pause all periods"); + + ImGui::SameLine(); + { + auto styledButton = Util::ErrorButtonStyle(); + if (ImGui::Button("X", ImVec2(C::Em(C::SCENE_DELETE_BUTTON_EM), 0))) { + if (isOverwrite) { + // Collect unique filenames for the confirmation message + std::set filenames; + for (auto idx : rowIndices) + if (idx < entries.size()) + filenames.insert(entries[idx].sourceFilename); + std::string fileList; + for (const auto& f : filenames) { + if (!fileList.empty()) + fileList += ", "; + fileList += "'" + f + "'"; + } + popups.pendingDeleteRow = rowIndices; + popups.deleteRowOverwrite.message = std::format( + "Delete overwrite entries from {}?\nThis will permanently remove the file(s) from disk.", + fileList); + popups.deleteRowOverwrite.Request(); + } else { + // Delete user entries in reverse order so indices stay valid + std::sort(rowIndices.begin(), rowIndices.end(), std::greater<>()); + for (auto idx : rowIndices) + manager->RemoveSetting(kSceneType, idx); + } + } + } + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(isOverwrite ? "Delete row from disk" : "Remove all periods"); + } + ImGui::Unindent(C::Em(C::SCENE_ENTRY_INDENT_EM)); + ImGui::PopID(); + ImGui::PopID(); for (int p = 0; p < kPeriodCount; ++p) { ImGui::TableSetColumnIndex(1 + p); - bool isActive = factors[p] > 0.0f; + bool isActive = factors[p] > C::SCENE_TOD_ACTIVE_THRESHOLD; if (!isActive) ImGui::PushStyleVar(ImGuiStyleVar_Alpha, C::SCENE_TOD_INACTIVE_ALPHA); @@ -211,7 +239,7 @@ namespace TimeOfDayPanel } /// Draw a TOD table for a single source group. - static void DrawSourceTable(const SourceGroup& group, const float* factors, const char* tableId) + static void DrawSourceTable(const SourceGroup& group, const float* factors, const char* tableId, EntrySource source) { constexpr int kTotalCols = 1 + kPeriodCount; @@ -232,7 +260,7 @@ namespace TimeOfDayPanel ImGui::TableHeader("Setting"); for (int i = 0; i < kPeriodCount; ++i) { ImGui::TableSetColumnIndex(1 + i); - bool isActive = factors[i] > 0.01f; + bool isActive = factors[i] > C::SCENE_TOD_ACTIVE_THRESHOLD; if (!isActive) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); ImGui::TableHeader(SceneSettingsManager::kPeriodNames[i]); @@ -240,7 +268,7 @@ namespace TimeOfDayPanel ImGui::PopStyleColor(); } - DrawSourceRows(group, factors); + DrawSourceRows(group, factors, source); ImGui::EndTable(); } } @@ -269,12 +297,18 @@ namespace TimeOfDayPanel // Per-period add-setting dropdowns — period name inline with dropdowns if (ImGui::CollapsingHeader("Add Settings", ImGuiTreeNodeFlags_DefaultOpen)) { - for (int i = 0; i < kPeriodCount; ++i) { - auto periodLabel = std::format("{}:", SceneSettingsManager::GetPeriodName(static_cast(i))); - ImGui::PushID(i); - SceneSettingsUI::DrawAddSettingUI(kSceneType, periodAddState[i], - static_cast(i), periodLabel.c_str()); - ImGui::PopID(); + ImGui::Checkbox("Add to all times of day", &addToAllPeriods); + if (addToAllPeriods) { + SceneSettingsUI::DrawAddSettingUI(kSceneType, allPeriodsAddState, + Period::Count, nullptr, true); + } else { + for (int i = 0; i < kPeriodCount; ++i) { + auto periodLabel = std::format("{}:", SceneSettingsManager::GetPeriodName(static_cast(i))); + ImGui::PushID(i); + SceneSettingsUI::DrawAddSettingUI(kSceneType, periodAddState[i], + static_cast(i), periodLabel.c_str()); + ImGui::PopID(); + } } } @@ -299,12 +333,12 @@ namespace TimeOfDayPanel if (!overwriteGroup.order.empty()) { SceneSettingsUI::DrawSectionHeader("Overwrite Files", theme.StatusPalette.InfoColor, "##ow", manager->AreAllOverwritesPaused(kSceneType), [&] { manager->SetAllOverwritesPaused(kSceneType, !manager->AreAllOverwritesPaused(kSceneType)); }, [&] { popups.deleteAllOverwrites.Request(); }); - DrawSourceTable(overwriteGroup, factors, "##TODOverwriteTable"); + DrawSourceTable(overwriteGroup, factors, "##TODOverwriteTable", EntrySource::Overwrite); } if (!userGroup.order.empty()) { SceneSettingsUI::DrawSectionHeader("User Settings", theme.FeatureHeading.ColorDefault, "##usr", manager->AreAllUserPaused(kSceneType), [&] { manager->SetAllUserPaused(kSceneType, !manager->AreAllUserPaused(kSceneType)); }, [&] { popups.deleteAllUser.Request(); }); - DrawSourceTable(userGroup, factors, "##TODUserTable"); + DrawSourceTable(userGroup, factors, "##TODUserTable", EntrySource::User); } } } From 08a11523b707a163978b3018d8840a63747cb66a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:48:19 +0000 Subject: [PATCH 18/36] =?UTF-8?q?style:=20=F0=9F=8E=A8=20apply=20pre-commi?= =?UTF-8?q?t.ci=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated formatting by clang-format, prettier, and other hooks. See https://pre-commit.ci for details. --- src/Menu/ThemeManager.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Menu/ThemeManager.h b/src/Menu/ThemeManager.h index 01d83de874..7b21f3aa9b 100644 --- a/src/Menu/ThemeManager.h +++ b/src/Menu/ThemeManager.h @@ -198,7 +198,7 @@ class ThemeManager static constexpr float SCENE_TOD_PARAM_COL_EM = 6.0f; // Parameter column width (TOD table) static constexpr float SCENE_TOD_PERIOD_COL_EM = 4.3f; // Per-period column width (TOD table) static constexpr float SCENE_TOD_INACTIVE_ALPHA = 0.5f; // Alpha for inactive TOD periods - static constexpr float SCENE_TOD_ACTIVE_THRESHOLD = 0.01f; // Factor threshold to consider a period active + static constexpr float SCENE_TOD_ACTIVE_THRESHOLD = 0.01f; // Factor threshold to consider a period active static constexpr float SCENE_ENTRY_INDENT_EM = 0.4f; // Indent for setting entries under feature headers static constexpr float SCENE_TOD_FEATURE_TEXT_SCALE = 0.85f; // Smaller text scale for feature names in TOD table static constexpr float SCENE_TOD_LABEL_EM = 2.6f; // Fixed width for period labels in add-setting rows From 54e9a3b10d8154f86701cb4ed73685e4fac75c85 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:12:06 -0700 Subject: [PATCH 19/36] scene settings manager doc --- docs/development/scene-settings-manager.md | 731 +++++++++++++++++++++ 1 file changed, 731 insertions(+) create mode 100644 docs/development/scene-settings-manager.md diff --git a/docs/development/scene-settings-manager.md b/docs/development/scene-settings-manager.md new file mode 100644 index 0000000000..07e41f1b58 --- /dev/null +++ b/docs/development/scene-settings-manager.md @@ -0,0 +1,731 @@ +# Scene Settings Manager + +> Contextual, automatic setting overrides for Community Shaders — no feature code changes required. + +## Table of Contents + +- [What Is the Scene Settings Manager?](#what-is-the-scene-settings-manager) +- [How Settings Flow (Priority Order)](#how-settings-flow-priority-order) +- [Design Philosophy: Zero Coupling](#design-philosophy-zero-coupling) +- [Interior Only Settings](#interior-only-settings) +- [Time of Day Settings](#time-of-day-settings) +- [UI Guide](#ui-guide) +- [For Mod Authors: Overwrite Files](#for-mod-authors-overwrite-files) +- [For Developers: Adding Features to the Whitelist](#for-developers-adding-features-to-the-whitelist) +- [The Whitelist and Why Features Don't Opt In](#the-whitelist-and-why-features-dont-opt-in) +- [Comparison: Scene Settings Manager vs Settings Override Manager](#comparison-scene-settings-manager-vs-settings-override-manager) +- [FAQ](#faq) + +--- + +## What Is the Scene Settings Manager? + +The Scene Settings Manager lets you automatically adjust Community Shaders feature settings based on **where you are** and **what time it is**. It has two modes: + +- **Interior Only** — Override settings when you enter an interior cell. Values revert automatically when you leave. +- **Time of Day** — Smoothly blend settings across six time-of-day periods (Dawn, Sunrise, Day, Sunset, Dusk, Night) while you're in an exterior cell. + +Both modes work entirely through the existing `SaveSettings`/`LoadSettings` JSON interface that every feature already has. Features don't need to do anything special — the Scene Settings Manager reads their current values, patches in overrides, and writes them back. The feature never knows the difference. + +The two modes are **mutually exclusive by context** — Interior Only is active in interiors, Time of Day is active in exteriors. You can have entries for both; the system automatically activates the correct one based on where you are. It's impossible for both to be active simultaneously. + +--- + +## How Settings Flow (Priority Order) + +Settings in Community Shaders follow a layered override system. Each layer can modify values from the layer below it. Later layers win. + +The feature's settings on its settings page act as the **master settings** — they are the source of truth that the Scene Settings Manager builds from. When the Scene Settings Manager activates (on cell transition or per-frame TOD blending), it reads the feature's current values via `SaveSettings()` and stores them as the **baseline**. All scene overrides are then applied on top of that baseline. When scene settings deactivate (leaving an interior, or TOD reverting), the baseline is restored — putting the feature back to exactly where its master settings had it. + +``` +┌────────────────────────────────────────────────────┐ +│ Scene Settings Manager │ ← Highest priority (runtime, contextual) +│ ┌─────────────────────┐ ┌───────────────────────┐ │ +│ │ Interior Only │ │ Time of Day │ │ +│ │ (overwrite files │ │ (overwrite files │ │ +│ │ + user settings) │ │ + user settings) │ │ +│ └─────────────────────┘ └───────────────────────┘ │ +├────────────────────────────────────────────────────┤ +│ Settings Override Manager │ ← Applied at boot (mod author JSON files) +│ ┌─────────────────────┐ ┌───────────────────────┐ │ +│ │ Mod Override Files │ │ User Override Files │ │ +│ │ (Overrides/*.json) │ │ (Overrides/User/) │ │ +│ └─────────────────────┘ └───────────────────────┘ │ +├────────────────────────────────────────────────────┤ +│ User Settings (In-Game CS Menu) │ ← Runtime (saved to SettingsUser.json) +│ ┌───────────────────────────────────────────────┐ │ +│ │ Slider, checkbox, and input changes made by │ │ +│ │ the user through the CS in-game menu │ │ +│ └───────────────────────────────────────────────┘ │ +├────────────────────────────────────────────────────┤ +│ Feature Default Settings │ ← Lowest priority (hardcoded + INI) +│ ┌─────────────────────┐ ┌───────────────────────┐ │ +│ │ Hardcoded Defaults │ │ Feature INI File │ │ +│ │ (C++ source code) │ │ (loaded at boot) │ │ +│ └─────────────────────┘ └───────────────────────┘ │ +└────────────────────────────────────────────────────┘ +``` + +### Layer Details + +| Layer | When Applied | Persists? | Who Creates It | +|-------|-------------|-----------|----------------| +| **Feature Defaults** | At boot, baked into the feature code and loaded from the feature's INI file | Always present | Feature developers | +| **User Settings** | At runtime, whenever the user changes a setting through the in-game CS menu. Saved to `SettingsUser.json`. | Yes (saved to disk on change) | Users (in-game UI sliders, checkboxes, etc.) | +| **Settings Override Manager** | At boot, after defaults are loaded. Mod author JSON files in `Overrides/` folder merge on top of defaults. User `.user` files sit on top of those. | Yes (files on disk) | Mod authors and users | +| **Scene Settings Manager** | At runtime, contextually. Interior Only applies on cell transitions. Time of Day blends continuously in exteriors. | User settings saved to disk. Overwrite files on disk. Values revert when context changes. | Mod authors (overwrite files) and users (in-game UI) | + +### Flow in Practice + +Here's what happens to a single setting — say, `ScreenSpaceGI.Intensity`: + +1. **Boot**: Feature loads its default (e.g., `1.0` from INI). +2. **Boot**: If a Settings Override Manager file sets `Intensity: 0.8`, the feature now uses `0.8`. +3. **User tweaks**: You open the CS menu and drag the Intensity slider to `0.9`. This is saved to `SettingsUser.json` and becomes the active value. +4. **Gameplay (exterior)**: If a Time of Day entry sets `Intensity` to `0.5` at Night and `1.2` at Day, the Scene Settings Manager saves the current baseline (`0.9`), then blends between period values using the current game hour. Uncovered periods fall back to the saved baseline. +5. **Gameplay (enter interior)**: Time of Day deactivates. If an Interior Only entry sets `Intensity` to `0.3`, the current exterior value is saved and the interior override applies. +6. **Gameplay (exit interior)**: The interior override reverts to the saved exterior value. Time of Day reactivates in the exterior. + +Within the Scene Settings Manager itself, **Overwrite files** (from mod authors) take priority over **User settings** (from the in-game UI). Both are visible and manageable in the same panel. This is a last-write-wins system applied in order: user entries first, then overwrites on top. + +--- + +## Design Philosophy: Zero Coupling + +The Scene Settings Manager's most important design principle is **zero coupling to feature code**. + +### How It Works Under the Hood + +Every feature in Community Shaders already implements two methods: + +```cpp +virtual void SaveSettings(json&) {} // Serialize current settings to JSON +virtual void LoadSettings(json&) {} // Deserialize settings from JSON +``` + +The Scene Settings Manager exploits this existing interface: + +1. **Read**: Call `feature->SaveSettings(json)` to get the feature's current state as a JSON blob. +2. **Patch**: Modify specific keys in that JSON (the overrides). +3. **Write**: Call `feature->LoadSettings(json)` to push the modified settings back. + +That's it. The feature's own serialization code handles all type conversion, validation, and clamping. The Scene Settings Manager never touches feature internals — it only operates on the JSON interface that already exists for saving settings to disk. + +``` +┌──────────────┐ ┌─────────────────────┐ ┌──────────────┐ +│ Feature │──────►│ Scene Settings Mgr │──────►│ Feature │ +│ SaveSettings │ JSON │ (patch overrides) │ JSON │ LoadSettings │ +└──────────────┘ └─────────────────────┘ └──────────────┘ +``` + +### Why This Matters + +- **No feature code changes needed.** A feature gets Scene Settings Manager support by being added to a whitelist — a single line in a static list. The feature itself is unmodified. +- **Forward-compatible.** Features that don't exist yet will work with the Scene Settings Manager the moment they're added to the whitelist. If someone is developing a new feature that hasn't been merged yet, it can still be whitelisted in advance. +- **Any JSON-serializable setting works.** Floats get smoothly blended between time-of-day periods. Booleans and integers snap at the dominant period boundary. If a feature adds new settings, they're automatically available — no registration step needed. +- **Round-trip verification.** After applying an override, the manager reads the value back and logs a warning if the feature clamped it. This catches range violations without requiring the Scene Settings Manager to know anything about valid ranges. + +### Contrast with Tighter Coupling + +To appreciate the zero-coupling approach, consider what a tightly-coupled system would look like: + +- Features would need to **register** each controllable variable with name, type, range, and interpolation function. +- Adding a new setting to scene control would require **code changes in the feature**. +- Type-specific interpolation logic would need to be **duplicated or centralized** for every variable type. + +The Scene Settings Manager avoids all of this. It treats features as black boxes with a JSON interface. This means: +- A mod author can create overwrite files targeting any setting that appears in a feature's JSON — even settings the Scene Settings Manager developers have never heard of. +- The system scales to any number of features and settings without increasing complexity. + +--- + +## Interior Only Settings + +### How It Works + +Interior Only settings activate when you enter an interior cell and deactivate when you leave. + +**Detection**: The system listens for Skyrim's `MenuOpenCloseEvent`. When the Loading Menu closes, it checks the current cell's sky mode. If `sky->mode != kFull`, you're in an interior. + +**Lifecycle**: + +``` +┌────────────┐ Loading Menu ┌──────────────────┐ +│ Exterior │───── closes ──────►│ Check sky mode │ +│ (normal) │ │ sky->mode? │ +└────────────┘ └────────┬─────────┘ + │ + ┌────────────┴────────────┐ + │ │ + kFull (exterior) !kFull (interior) + │ │ + ┌─────────▼──────────┐ ┌──────────▼──────────┐ + │ Revert interior │ │ Save exterior vals │ + │ settings if active │ │ Apply overrides │ + │ Activate TOD │ │ Deactivate TOD │ + └────────────────────┘ └─────────────────────┘ +``` + +**What "save and restore" means:** + +1. Before applying interior overrides, the manager calls `SaveSettings()` on each affected feature and stores **only the keys it's about to override** (a partial baseline). +2. Interior overrides are applied via `LoadSettings()`. +3. When you exit to an exterior, the saved baseline values are written back — restoring the feature to its pre-interior state. + +This partial-save approach means features keep any settings you changed in-game (via the CS menu) that aren't part of the interior override. Only the specific overridden keys revert. + +User settings (entries you add through the in-game UI) are persisted automatically to `SceneSettings/InteriorOnly.json`. They survive game restarts — you don't need to save them + +### Example + +Say you have Interior Only overrides for: +- `ScreenSpaceGI.EnableGI` → `false` (disable GI in interiors) +- `SubsurfaceScattering.Intensity` → `0.2` (reduce SSS indoors) + +When you enter Dragonsreach: +1. Current values of `EnableGI` and `Intensity` are saved. +2. `EnableGI` is set to `false`, `Intensity` to `0.2`. +3. You play through the interior with these settings active. + +When you exit to Whiterun: +1. `EnableGI` reverts to its saved value (e.g., `true`). +2. `Intensity` reverts to its saved value (e.g., `0.5`). +3. Time of Day reactivates (if you have TOD entries). + +--- + +## Time of Day Settings + +### How It Works + +Time of Day (TOD) settings smoothly blend feature values across six periods while you're in an exterior cell. + +**Periods and Hour Boundaries**: + +| Period | Hours | Description | +|----------|---------------|----------------------------| +| Dawn | 4:00 – 6:00 | Pre-sunrise golden hour | +| Sunrise | 6:00 – 8:00 | Sun coming up | +| Day | 8:00 – 17:00 | Full daylight | +| Sunset | 17:00 – 19:00 | Sun going down | +| Dusk | 19:00 – 21:00 | Post-sunset blue hour | +| Night | 21:00 – 4:00 | Full darkness (wraps around midnight) | + +**Blending**: At the boundary between two periods, values blend over a 30-minute (0.5 game-hour) transition zone. Outside the transition zone, the current period's value is used at full weight. + +User settings for Time of Day are persisted automatically to `SceneSettings/TimeOfDay.json` and survive game restarts. + +**Float values** are linearly interpolated between periods based on these factors. If a setting isn't defined for a particular period, the saved baseline value is used for that period's weight — so the blend always sums to the correct total. + +**Non-float values** (booleans, integers) snap to the dominant period's value. They can't be meaningfully interpolated, so the value switches when the dominant period changes. + +### Performance Optimizations + +The blending runs every frame, so the system includes several optimizations: + +- **Hour throttle**: The blend only recalculates when the game hour has changed by more than 0.001 (about 0.36 real-time seconds at default timescale). This skips 98%+ of per-frame work. +- **Epsilon cache**: For each float value, the last-applied result is cached. If the new result differs by less than 0.001, the `LoadSettings()` call is skipped entirely. +- **Non-float cache**: Booleans and integers are cached and only pushed when they actually change. +- **Batch updates**: All dirty keys for a single feature are collected and applied in a single `LoadSettings()` call, rather than calling it once per key. + +### Example + +Say you set `CloudShadows.Opacity`: +- Dawn: `0.3` +- Day: `0.8` +- Sunset: `0.5` +- Night: `0.1` + +(Sunrise and Dusk are left undefined — they'll fall back to the baseline.) + +At 5:30 (mid-Dawn, 30 min before Sunrise transition): +- Dawn factor = 1.0, result = `0.3` + +At 5:45 (Dawn→Sunrise transition starts, 15 min left): +- Dawn factor ≈ 0.5, Sunrise factor ≈ 0.5 +- Sunrise has no override → uses baseline (say `0.6`) +- Result = 0.5 × 0.3 + 0.5 × 0.6 = `0.45` + +At 12:00 (mid-Day): +- Day factor = 1.0, result = `0.8` + +--- + +## UI Guide + +The Scene Settings Manager is accessed through the **Weather Editor** window (`F8` by default). It adds two categories to the objects window sidebar: **Interior Only** and **Time of Day**. + +### Accessing the Scene Settings Panels + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Weather Editor [X] │ +├──────────────────┬──────────────────────────────────────────────┤ +│ Categories │ │ +│ ─────────────── │ (Panel content │ +│ Weather │ depends on │ +│ ImageSpace │ selected category) │ +│ Lighting Templ. │ │ +│ Cell Lighting │ │ +│ Vol. Lighting │ │ +│ Shader Particle │ │ +│ Lens Flare │ │ +│ Visual Effect │ │ +│ ─────────────── │ │ +│▸ Interior Only │ ◄── Scene Settings Manager categories │ +│▸ Time of Day │ │ +│ │ │ +└──────────────────┴──────────────────────────────────────────────┘ +``` + +Select **Interior Only** or **Time of Day** from the left sidebar to open the corresponding panel. + +--- + +### Interior Only Panel (UI) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Interior Only Settings │ +│ ─────────────────────── │ +│ │ +│ ┌────────────────────────────┐ ┌─────────────────────────────┐ │ +│ │ Select Feature... ▼ │ │ Select Setting... ▼ │ │ +│ └────────────────────────────┘ └─────────────────────────────┘ │ +│ │ +│ Overwrite Files [Pause All] [Delete All] │ +│ ───────────────────────────────────────────────────── │ +│ ▼ Screen Space GI: │ +│ EnableGI [V] [●] [X] │ +│ AmbientIntensity [0.500___] [●] [X] │ +│ ▼ Subsurface Scattering: │ +│ Intensity [0.200___] [●] [X] │ +│ │ +│ User Settings [Pause All] [Delete All] │ +│ ───────────────────────────────────────────────────── │ +│ ▼ Linear Lighting: │ +│ GammaCorrection [2.200___] [●] [X] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Elements:** + +| Element | Description | +|---------|-------------| +| **Feature dropdown** | Lists whitelisted interior features. Selecting one populates the setting dropdown. | +| **Setting dropdown** | Lists all JSON keys from the selected feature's `SaveSettings()`. Selecting one immediately adds it with the current value. Already-added settings are greyed out. | +| **Overwrite Files section** | Entries loaded from mod author JSON files. Values are read-only (greyed out) — mod authors set them. You can pause or delete individual entries or all at once. | +| **User Settings section** | Entries you added through the UI. Values are editable. | +| **Value editor** | Checkbox for booleans, number input for floats/integers. | +| **[●] toggle** | Pause/resume individual entries. Paused entries are ignored without being deleted. | +| **[X] button** | Delete the entry. For overwrites, this deletes the file from disk (with confirmation). | +| **Pause All / Delete All** | Bulk controls per section. | + +**Entries are grouped by feature** with collapsible tree nodes, sorted alphabetically. + +--- + +### Time of Day Panel (UI) + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Time of Day Settings (Exterior Only) [Day 12.0h] │ +│ ──────────────────────────────────── │ +│ │ +│ ▼ Add Settings │ +│ [V] Add to all times of day │ +│ ┌────────────────────────────┐ ┌──────────────────────────────┐ │ +│ │ Select Feature... ▼ │ │ Select Setting... ▼ │ │ +│ └────────────────────────────┘ └──────────────────────────────┘ │ +│ │ +│ Overwrite Files [Pause All] [Delete All] │ +│ ┌─────────┬────────┬────────┬────────┬────────┬──────┬───────┐ │ +│ │Setting │ Dawn │Sunrise │ Day │ Sunset │ Dusk │ Night │ │ +│ ├─────────┼────────┼────────┼────────┼────────┼──────┼───────┤ │ +│ │CloudShadows: │ │ +│ │ Opacity │ 0.300 │ -- │ 0.800 │ 0.500 │ -- │ 0.100 │ │ +│ │ │ [●][X] │ │ [●][X] │ [●][X] │ │[●][X] │ │ +│ └─────────┴────────┴────────┴────────┴────────┴──────┴───────┘ │ +│ │ +│ User Settings [Pause All] [Delete All] │ +│ ┌─────────┬────────┬────────┬────────┬────────┬──────┬───────┐ │ +│ │Setting │ Dawn │Sunrise │ Day │ Sunset │ Dusk │ Night │ │ +│ ├─────────┼────────┼────────┼────────┼────────┼──────┼───────┤ │ +│ │Skylighting: │ │ +│ │ MixAmt │ 0.400 │ 0.600 │ 0.800 │ 0.600 │0.400 │ 0.200 │ │ +│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X]││ │ +│ └─────────┴────────┴────────┴────────┴────────┴──────┴───────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**Elements:** + +| Element | Description | +|---------|-------------| +| **Header** | Shows the current period and game hour (e.g., `[Day 12.0h]`). | +| **"Add to all times of day"** | When checked, selecting a setting adds it to all 6 periods at once with the current value. When unchecked, you get a per-period dropdown row for each period. | +| **Period columns** | One column per period. The active period column is highlighted; inactive periods are dimmed. `--` means no override for that period (falls back to baseline). | +| **Row-level controls** | Each setting row has a toggle (pause all periods) and delete (remove all periods) button in the Setting column. | +| **Per-cell controls** | Each individual period cell has its own value editor, pause toggle, and delete button. | + +**Note on per-period add mode**: When "Add to all times of day" is unchecked, you see 6 rows of dropdowns, one per period, each with a period name label: + +``` + Dawn: [Select Feature... ▼] [Select Setting... ▼] + Sunrise: [Select Feature... ▼] [Select Setting... ▼] + Day: [Select Feature... ▼] [Select Setting... ▼] + Sunset: [Select Feature... ▼] [Select Setting... ▼] + Dusk: [Select Feature... ▼] [Select Setting... ▼] + Night: [Select Feature... ▼] [Select Setting... ▼] +``` + +This lets you add a setting to just one or two periods (e.g., only Dawn and Night) without filling all six. + +--- + +### Feature Settings Page (Scene Toggle) + +When scene settings are actively controlling a feature, its settings page in the main CS menu shows a toggle: + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Screen Space GI v2.1.0 [●] [▼] │ +│ High quality ambient occlusion and indirect lighting. │ +│ ──────────────────────────────────────────────────── │ +│ │ +│ [●] Scene Specific Settings │ +│ ──────────────────────────── │ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ +│ │ ░░░ (All settings greyed out while scene settings ░░░ │ │ +│ │ ░░░ are active.) ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**Behaviour:** + +- The **Scene Specific Settings** toggle appears only when scene entries exist for this feature (active or paused). +- When **active** (toggle on, green): All feature settings are **disabled** (greyed out). The Scene Settings Manager is controlling values. +- When **paused** (toggle off): Scene settings stop applying. Feature settings become editable again. This is per-feature — it doesn't affect other features. +- The **"Apply Override" button** (from the Settings Override Manager) is also disabled while scene settings are active, to prevent conflicting writes. + +**Why all settings are greyed out, not just the overridden ones:** + +The entire feature settings page is disabled because the scene settings toggle is drawn at the top of the feature page, before any individual settings are rendered. Since ImGui draws top-to-bottom, disabling at the page level greys out everything below it — there's no per-setting knowledge at that point in the draw call. This is functionally fine because the Scene Settings Manager only modifies the specific keys it has entries for; all other settings stay at their current values. Pausing all scene settings to edit a non-overridden setting is the natural workflow — if you're tweaking settings manually, you'd want scene overwrites paused anyway to see your changes without interference. And if you need a setting to change contextually while scene settings are active, you'd be adding it as a scene setting entry rather than editing it on the feature page. + +--- + +## For Mod Authors: Overwrite Files + +Mod authors can ship pre-configured scene settings as JSON files. These appear in the **Overwrite Files** section of the UI, separate from user settings. Users can pause or delete them, but can't edit their values. + +### Directory Structure + +``` +Data/ +└── SKSE/ + └── Plugins/ + └── CommunityShaders/ + └── SceneSettings/ + ├── InteriorOnly.json ← User settings (auto-saved) + ├── TimeOfDay.json ← User settings (auto-saved) + ├── InteriorOnly/ ← Overwrite files directory + │ ├── MyModPack_ScreenSpaceGI_EnableGI.json + │ ├── MyModPack_SubsurfaceScattering_Intensity.json + │ └── AnotherMod_LinearLighting_GammaCorrection.json + └── TimeOfDay/ ← Overwrite files directory + ├── Dawn/ ← Per-period subdirectories + │ ├── MyModPack_CloudShadows_Opacity.json + │ └── MyModPack_Skylighting_MixAmount.json + ├── Sunrise/ + │ └── MyModPack_CloudShadows_Opacity.json + ├── Day/ + │ └── MyModPack_CloudShadows_Opacity.json + ├── Sunset/ + │ └── MyModPack_CloudShadows_Opacity.json + ├── Dusk/ + │ └── MyModPack_Skylighting_MixAmount.json + └── Night/ + ├── MyModPack_CloudShadows_Opacity.json + └── MyModPack_Skylighting_MixAmount.json +``` + +### Interior Only Overwrites + +Place JSON files in `CommunityShaders/SceneSettings/InteriorOnly/`. + +**File format:** + +```json +{ + "_feature": "ScreenSpaceGI", + "EnableGI": false +} +``` + +**Rules:** + +- Each file must contain **exactly one setting** (one non-metadata key). +- The `_feature` field identifies the target feature. If omitted, the system tries to infer it from the filename (the part after the last underscore must match a feature short name). +- Keys starting with `_` are treated as metadata and ignored when extracting the setting. + +### Time of Day Overwrites + +Place JSON files in `CommunityShaders/SceneSettings/TimeOfDay/{PeriodName}/`. + +Each period has its own subdirectory: `Dawn/`, `Sunrise/`, `Day/`, `Sunset/`, `Dusk/`, `Night/`. + +The file format is the same as Interior Only: + +```json +{ + "_feature": "CloudShadows", + "Opacity": 0.3 +} +``` + +To set `Opacity` across multiple periods, create the same-named file in each period's directory with different values: + +``` +TimeOfDay/Dawn/MyMod_CloudShadows_Opacity.json → {"_feature": "CloudShadows", "Opacity": 0.3} +TimeOfDay/Day/MyMod_CloudShadows_Opacity.json → {"_feature": "CloudShadows", "Opacity": 0.8} +TimeOfDay/Sunset/MyMod_CloudShadows_Opacity.json → {"_feature": "CloudShadows", "Opacity": 0.5} +TimeOfDay/Night/MyMod_CloudShadows_Opacity.json → {"_feature": "CloudShadows", "Opacity": 0.1} +``` + +Periods without a file fall back to the feature's baseline value during blending. + +### Overwrite File Format + +| Field | Required? | Description | +|-------|-----------|-------------| +| `_feature` | Recommended | The feature's short name (e.g., `"ScreenSpaceGI"`, `"CloudShadows"`). If omitted, inferred from filename. | +| `{settingKey}` | Required (exactly 1) | The JSON key matching the feature's `SaveSettings()` output, with the desired override value. | +| `_*` (any key starting with `_`) | Optional | Metadata fields, ignored by the system. Use for comments, authorship, etc. | + +**Example with metadata:** + +```json +{ + "_feature": "Skylighting", + "_author": "MyModPack", + "_description": "Reduce skylighting at night for darker evenings", + "_version": "1.0", + "MixAmount": 0.2 +} +``` + +### Best Practices for Mod Authors + +1. **One setting per file.** The system enforces this — files with multiple non-metadata keys are skipped. This makes overrides granular: users can delete one specific override without losing others. + +2. **Use descriptive filenames.** The naming convention `{ModName}_{FeatureName}_{SettingKey}.json` is recommended. The system can infer the feature name from the part after the last underscore if `_feature` is missing. + +3. **Test with the UI.** After installing your overwrite files, open the Weather Editor and check the Interior Only or Time of Day panel. Your entries should appear in the "Overwrite Files" section. If they don't, check the CS log for warnings. + +4. **Don't override everything.** Only override settings that genuinely need to change for your visual goal. Leave others at baseline so users' personal settings are respected. + +5. **Ship files through your mod manager.** Overwrite files are just JSON in a folder — they can be installed and uninstalled via any mod manager (MO2, Vortex) like any other loose file. + +6. **You can't target non-whitelisted features.** The file will be loaded but the feature won't pass the whitelist filter, so it will be silently skipped with a log warning. This is intentional — some features aren't safe to hot-swap. + +### Conflict Resolution + +If two mods ship overwrite files for the same feature, setting, and period, the **first one loaded wins** (alphabetical order by filename). Duplicate entries are silently skipped. The second mod's file won't appear in the UI. Use distinctive mod name prefixes in filenames to avoid conflicts. + +If both an overwrite file and a user setting exist for the same feature and key, the **overwrite wins**. User settings are applied first, then overwrites layer on top (last-write-wins). The value editor for that user entry is greyed out in the UI to indicate it's being overridden. + +### Troubleshooting: Overwrite File Not Appearing + +If your file doesn't show up in the Overwrite Files section of the UI, check: + +1. The file is in the correct directory (`SceneSettings/InteriorOnly/` or `SceneSettings/TimeOfDay/{Period}/`). +2. The file has a `.json` extension. +3. The file contains exactly one non-metadata key (keys starting with `_` are metadata). +4. The `_feature` field (or inferred feature name from the filename) matches a loaded, whitelisted feature. +5. You restarted Skyrim after adding the file. +6. Check `CommunityShaders.log` for warnings about skipped or malformed files. + +--- + +## For Developers: Adding Features to the Whitelist + +Adding Scene Settings Manager support for a feature is trivial — it requires **no changes to the feature itself**. + +### Steps + +1. Open `src/SceneSettingsManager.cpp`. +2. Find the appropriate whitelist: + - `GetInteriorRelevantFeatureNames()` for interior support. + - `GetExteriorRelevantFeatureNames()` for time-of-day support. +3. Add the feature's short name to the `unordered_set`: + +```cpp +std::vector SceneSettingsManager::GetExteriorRelevantFeatureNames() +{ + static const std::unordered_set whitelist = { + "CloudShadows", + "ExponentialHeightFog", + "GrassLighting", + "YourNewFeature", // ← add here + }; + return FilterFeatureNames(whitelist); +} +``` + +### Requirements + +Before whitelisting a feature, verify: + +1. **`LoadSettings()` is safe to call at runtime.** It should not trigger shader recompilation, buffer reallocation, or other expensive operations. If it does, the feature may only be safe for the Interior whitelist (called once per cell transition) and not the Exterior/TOD whitelist (called per frame). + +2. **Settings are JSON-serializable.** The feature's `SaveSettings()` and `LoadSettings()` should produce and consume a flat JSON object. Nested objects are not supported by the Scene Settings Manager's per-key override system. + +3. **The feature does not crash when unknown keys are present.** `LoadSettings()` receives the full JSON blob with the patched key — it should gracefully ignore keys it doesn't recognize. + +That's it. No interface to implement, no registration call to add. The Scene Settings Manager picks up the feature automatically through `Feature::FindFeatureByShortName()` and interacts with it purely through the SaveSettings/LoadSettings JSON round-trip. + +--- + +## The Whitelist and Why Features Don't Opt In + +### How the Whitelist Works + +Not every feature is exposed to the Scene Settings Manager. Features must be present in one of two static whitelists: + +**Interior-Relevant Features** — settings that make sense to override in interiors: +``` +ScreenSpaceGI SubsurfaceScattering LinearLighting +ImageBasedLighting PostProcessing ScreenSpacePointLightShadows +ScreenSpaceRayTracing VanillaFresnel +``` + +**Exterior/Time-of-Day-Relevant Features** — settings that make sense to vary across the day: +``` +CloudShadows ExponentialHeightFog GrassLighting +ImageBasedLighting LinearLighting Skylighting +SubsurfaceScattering TerrainShadows WetnessEffects +``` + +### Why Not Make It Opt-In Per Feature? + +You might wonder: why not have each feature declare `bool SupportsSceneSettings()` or register itself? There are several reasons: + +1. **Zero feature code changes.** The whole point is that features never need to know the Scene Settings Manager exists. An opt-in system would require every feature to add a method, defeating the decoupled design. + +2. **Centralized safety control.** Some features can't safely have their settings hot-swapped at runtime. For example, `ScreenSpaceGI` is excluded from the exterior/TOD whitelist because its `LoadSettings()` triggers synchronous recompilation of 6 compute shaders — causing massive lag if called every frame during time-of-day blending. A centralized whitelist lets the maintainers exclude problematic features without touching feature code. + +3. **Easy to extend.** Adding a new feature to the whitelist is a single-line diff. There's no API to implement, no registration call to add, no interface to satisfy. When a new feature is developed — even one that hasn't been merged yet — it can be added to the whitelist in the same PR or in a follow-up. + +4. **Even whitelist changes are less work than a coupled system.** In the unlikely event that the whitelist needs updating (a feature is added, removed, or moved between lists), the change is a single line in one file — `SceneSettingsManager.cpp`. In a coupled system where features opt themselves in, the same change would require editing the feature's own code, which is strictly more work. Any change you'd need to make to the whitelist is a change you'd *also* need to make in a coupled system — except in the coupled version, you'd be editing the feature itself, dealing with its build dependencies, and potentially breaking its tests. The whitelist approach is always equal or less effort. + +### Features That Aren't Whitelisted (and Why) + +Some features are intentionally missing: + +- **ScreenSpaceGI** (exterior whitelist): `LoadSettings()` recompiles 6 compute shaders synchronously. Fine for interior transitions (happens once), but not for per-frame TOD blending. It *is* on the interior whitelist since interior overrides only apply once on cell transition. +- **VolumetricLighting, LightLimitFix**: Heavy GPU features where hot-swapping settings could cause transient artifacts or require buffer reallocation. +- **TerrainBlending, TerrainVariation**: Terrain features that work at a mesh level and don't benefit from per-frame setting changes. + +As features are improved and their `LoadSettings()` paths become cheaper, they can be promoted to the whitelist with a single-line change. Even in this scenario, the whitelist approach is less work than a coupled system — a coupled system would require the same decision about whether to enable scene settings support, but the change would live inside the feature's own code rather than in a centralized, reviewable list. + +--- + +## Comparison: Scene Settings Manager vs Settings Override Manager + +Community Shaders has two systems that modify feature settings after boot. They serve different purposes and operate at different layers. + +| | Scene Settings Manager | Settings Override Manager | +|---|---|---| +| **Purpose** | Context-dependent overrides (interior/exterior, time of day) | Permanent baseline overrides (mod author presets) | +| **When applied** | At runtime, on cell transitions and per-frame (TOD) | At boot, when features first load settings | +| **Reverts?** | Yes — automatically when context changes | No — permanent until manually reset | +| **Feature coupling** | Zero — uses SaveSettings/LoadSettings JSON round-trip | Zero — merges JSON on top of defaults at boot | +| **Granularity** | Per-setting, per-context (interior, per-period) | Per-setting, per-feature, or global | +| **User editing** | In-game UI (Weather Editor panels) | "Apply Override" button per feature | +| **Mod author format** | One setting per JSON file, in SceneSettings/ subfolders | Multi-setting JSON files in Overrides/ folder | +| **File naming** | `{ModName}_{FeatureName}_{SettingKey}.json` | `{ModName}_{FeatureName}.json` or `{ModName}_Global.json` | +| **Priority** | Higher — applies on top of everything else | Lower — applies at boot, overwritten by scene settings | +| **Blending** | Yes — float values smoothly interpolate between TOD periods | No — values are merged, not blended | +| **Interior/Exterior awareness** | Yes — core feature | No — applies everywhere | + + +The Settings Override Manager establishes the **baseline** that the Scene Settings Manager saves and modifies. They're complementary: + +- A mod author might use the **Override Manager** to set `CloudShadows.Opacity` to `0.6` as their recommended default. +- They might then use the **Scene Settings Manager** to set `Opacity` to `0.3` at Night and `0.9` at Day. +- The saved baseline would be `0.6` (from the Override Manager), and TOD would blend between `0.3`, `0.9`, and the baseline for uncovered periods. + +--- + +## FAQ + +### General + +**Q: Do I need to restart Skyrim after adding overwrite files?** +A: Yes. Overwrite files are discovered once during initialization. Changes to overwrite files on disk require a restart. + +**Q: Can I use both Interior Only and Time of Day at the same time?** +A: They're mutually exclusive by context. Interior Only applies in interiors; Time of Day applies in exteriors. You can have entries for both — the system activates the correct one based on where you are. + +**Q: What happens if I'm in an interior and exterior settings are active?** +A: This can't happen. The system detects cell type and automatically deactivates the wrong mode. If Interior Only is active, Time of Day is always off (and vice versa). + +**Q: Do user settings persist between game sessions?** +A: Yes. User settings are saved to `SceneSettings/InteriorOnly.json` and `SceneSettings/TimeOfDay.json` automatically whenever you add, remove, or modify entries. + +### Settings & Values + +**Q: A setting I want to override doesn't appear in the dropdown. Why?** +A: The feature may not be on the whitelist, or the setting may have a type that isn't exposed through `SaveSettings()`. Check the whitelist in `SceneSettingsManager.cpp`. + +**Q: Can I set a value outside the feature's normal range?** +A: You can enter any value, but the feature will clamp it to its valid range during `LoadSettings()`. The Scene Settings Manager logs a warning when this happens. Check the in-game log if your override seems to have no effect. + +**Q: My overwrite file isn't showing up in the Overwrite Files section.** +A: Check: +1. The file is in the correct directory (`SceneSettings/InteriorOnly/` or `SceneSettings/TimeOfDay/{Period}/`). +2. The file has a `.json` extension. +3. The file contains exactly one non-metadata key. +4. The `_feature` field (or inferred feature name) matches a loaded, whitelisted feature. +5. You restarted Skyrim after adding the file. +6. Check `CommunityShaders.log` for warnings about skipped files. + +**Q: I have an overwrite and a user setting for the same feature+key. Which wins?** +A: The overwrite wins. User settings are applied first, then overwrites overwrite them (last-write-wins). The user value editor is greyed out for settings that have an active overwrite. + +### Performance + +**Q: Does the Scene Settings Manager affect performance?** +A: The overhead is negligible. Time of Day blending runs per-frame but uses hour throttling (skips recalculation unless the game clock advanced enough) and epsilon caching (skips `LoadSettings()` calls if values haven't meaningfully changed). Interior Only overrides are applied once on cell transition. + +**Q: Why is ScreenSpaceGI excluded from the Time of Day whitelist?** +A: Its `LoadSettings()` triggers synchronous recompilation of 6 compute shaders. This is fine for a one-time interior transition, but would cause massive lag if called every frame during TOD blending. It's on the Interior whitelist for this reason. + +### Mod Authoring + +**Q: Can I ship a single file that overrides multiple settings?** +A: No. Each overwrite file must contain exactly one non-metadata setting. This is by design — it lets users delete individual overrides without losing the rest. Create one file per setting. + +**Q: What if two mods ship overwrite files for the same feature+setting+period?** +A: The first one loaded wins (alphabetical order by filename). Duplicate entries are silently skipped. The second mod's file will not appear. Use distinctive mod prefixes in filenames to avoid conflicts. + +**Q: Can I target features that aren't on the whitelist?** +A: No. The overwrite file will be loaded but the feature won't be found in the whitelist filter, so it will be skipped with a log warning. The whitelist is intentional — some features aren't safe to hot-swap. + +### Development + +**Q: I'm developing a new feature. When should I add it to the whitelist?** +A: Once your feature's `LoadSettings()` is safe to call at runtime without expensive side effects (shader recompilation, buffer reallocation). You can add it to the whitelist in the same PR as the feature, or in a follow-up. No other code changes are needed. + +**Q: Does the Scene Settings Manager work with VR?** +A: Yes. It uses the same SaveSettings/LoadSettings interface, which is VR-agnostic. The cell detection uses `RE::Sky::Mode` which works across all Skyrim variants. + +**Q: How do I test my overwrite files during development?** +A: Place them in the appropriate directory, start Skyrim, and open the Weather Editor. Your entries should appear in the Overwrite Files section. Check the log for any warnings about skipped or malformed files. From 152373a04238e4deafb3d4e7cac616bb7ccd58c0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 03:12:32 +0000 Subject: [PATCH 20/36] =?UTF-8?q?style:=20=F0=9F=8E=A8=20apply=20pre-commi?= =?UTF-8?q?t.ci=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated formatting by clang-format, prettier, and other hooks. See https://pre-commit.ci for details. --- docs/development/scene-settings-manager.md | 214 +++++++++++---------- 1 file changed, 112 insertions(+), 102 deletions(-) diff --git a/docs/development/scene-settings-manager.md b/docs/development/scene-settings-manager.md index 07e41f1b58..0b355eef95 100644 --- a/docs/development/scene-settings-manager.md +++ b/docs/development/scene-settings-manager.md @@ -4,17 +4,17 @@ ## Table of Contents -- [What Is the Scene Settings Manager?](#what-is-the-scene-settings-manager) -- [How Settings Flow (Priority Order)](#how-settings-flow-priority-order) -- [Design Philosophy: Zero Coupling](#design-philosophy-zero-coupling) -- [Interior Only Settings](#interior-only-settings) -- [Time of Day Settings](#time-of-day-settings) -- [UI Guide](#ui-guide) -- [For Mod Authors: Overwrite Files](#for-mod-authors-overwrite-files) -- [For Developers: Adding Features to the Whitelist](#for-developers-adding-features-to-the-whitelist) -- [The Whitelist and Why Features Don't Opt In](#the-whitelist-and-why-features-dont-opt-in) -- [Comparison: Scene Settings Manager vs Settings Override Manager](#comparison-scene-settings-manager-vs-settings-override-manager) -- [FAQ](#faq) +- [What Is the Scene Settings Manager?](#what-is-the-scene-settings-manager) +- [How Settings Flow (Priority Order)](#how-settings-flow-priority-order) +- [Design Philosophy: Zero Coupling](#design-philosophy-zero-coupling) +- [Interior Only Settings](#interior-only-settings) +- [Time of Day Settings](#time-of-day-settings) +- [UI Guide](#ui-guide) +- [For Mod Authors: Overwrite Files](#for-mod-authors-overwrite-files) +- [For Developers: Adding Features to the Whitelist](#for-developers-adding-features-to-the-whitelist) +- [The Whitelist and Why Features Don't Opt In](#the-whitelist-and-why-features-dont-opt-in) +- [Comparison: Scene Settings Manager vs Settings Override Manager](#comparison-scene-settings-manager-vs-settings-override-manager) +- [FAQ](#faq) --- @@ -22,8 +22,8 @@ The Scene Settings Manager lets you automatically adjust Community Shaders feature settings based on **where you are** and **what time it is**. It has two modes: -- **Interior Only** — Override settings when you enter an interior cell. Values revert automatically when you leave. -- **Time of Day** — Smoothly blend settings across six time-of-day periods (Dawn, Sunrise, Day, Sunset, Dusk, Night) while you're in an exterior cell. +- **Interior Only** — Override settings when you enter an interior cell. Values revert automatically when you leave. +- **Time of Day** — Smoothly blend settings across six time-of-day periods (Dawn, Sunrise, Day, Sunset, Dusk, Night) while you're in an exterior cell. Both modes work entirely through the existing `SaveSettings`/`LoadSettings` JSON interface that every feature already has. Features don't need to do anything special — the Scene Settings Manager reads their current values, patches in overrides, and writes them back. The feature never knows the difference. @@ -68,12 +68,12 @@ The feature's settings on its settings page act as the **master settings** — t ### Layer Details -| Layer | When Applied | Persists? | Who Creates It | -|-------|-------------|-----------|----------------| -| **Feature Defaults** | At boot, baked into the feature code and loaded from the feature's INI file | Always present | Feature developers | -| **User Settings** | At runtime, whenever the user changes a setting through the in-game CS menu. Saved to `SettingsUser.json`. | Yes (saved to disk on change) | Users (in-game UI sliders, checkboxes, etc.) | -| **Settings Override Manager** | At boot, after defaults are loaded. Mod author JSON files in `Overrides/` folder merge on top of defaults. User `.user` files sit on top of those. | Yes (files on disk) | Mod authors and users | -| **Scene Settings Manager** | At runtime, contextually. Interior Only applies on cell transitions. Time of Day blends continuously in exteriors. | User settings saved to disk. Overwrite files on disk. Values revert when context changes. | Mod authors (overwrite files) and users (in-game UI) | +| Layer | When Applied | Persists? | Who Creates It | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| **Feature Defaults** | At boot, baked into the feature code and loaded from the feature's INI file | Always present | Feature developers | +| **User Settings** | At runtime, whenever the user changes a setting through the in-game CS menu. Saved to `SettingsUser.json`. | Yes (saved to disk on change) | Users (in-game UI sliders, checkboxes, etc.) | +| **Settings Override Manager** | At boot, after defaults are loaded. Mod author JSON files in `Overrides/` folder merge on top of defaults. User `.user` files sit on top of those. | Yes (files on disk) | Mod authors and users | +| **Scene Settings Manager** | At runtime, contextually. Interior Only applies on cell transitions. Time of Day blends continuously in exteriors. | User settings saved to disk. Overwrite files on disk. Values revert when context changes. | Mod authors (overwrite files) and users (in-game UI) | ### Flow in Practice @@ -120,22 +120,23 @@ That's it. The feature's own serialization code handles all type conversion, val ### Why This Matters -- **No feature code changes needed.** A feature gets Scene Settings Manager support by being added to a whitelist — a single line in a static list. The feature itself is unmodified. -- **Forward-compatible.** Features that don't exist yet will work with the Scene Settings Manager the moment they're added to the whitelist. If someone is developing a new feature that hasn't been merged yet, it can still be whitelisted in advance. -- **Any JSON-serializable setting works.** Floats get smoothly blended between time-of-day periods. Booleans and integers snap at the dominant period boundary. If a feature adds new settings, they're automatically available — no registration step needed. -- **Round-trip verification.** After applying an override, the manager reads the value back and logs a warning if the feature clamped it. This catches range violations without requiring the Scene Settings Manager to know anything about valid ranges. +- **No feature code changes needed.** A feature gets Scene Settings Manager support by being added to a whitelist — a single line in a static list. The feature itself is unmodified. +- **Forward-compatible.** Features that don't exist yet will work with the Scene Settings Manager the moment they're added to the whitelist. If someone is developing a new feature that hasn't been merged yet, it can still be whitelisted in advance. +- **Any JSON-serializable setting works.** Floats get smoothly blended between time-of-day periods. Booleans and integers snap at the dominant period boundary. If a feature adds new settings, they're automatically available — no registration step needed. +- **Round-trip verification.** After applying an override, the manager reads the value back and logs a warning if the feature clamped it. This catches range violations without requiring the Scene Settings Manager to know anything about valid ranges. ### Contrast with Tighter Coupling To appreciate the zero-coupling approach, consider what a tightly-coupled system would look like: -- Features would need to **register** each controllable variable with name, type, range, and interpolation function. -- Adding a new setting to scene control would require **code changes in the feature**. -- Type-specific interpolation logic would need to be **duplicated or centralized** for every variable type. +- Features would need to **register** each controllable variable with name, type, range, and interpolation function. +- Adding a new setting to scene control would require **code changes in the feature**. +- Type-specific interpolation logic would need to be **duplicated or centralized** for every variable type. The Scene Settings Manager avoids all of this. It treats features as black boxes with a JSON interface. This means: -- A mod author can create overwrite files targeting any setting that appears in a feature's JSON — even settings the Scene Settings Manager developers have never heard of. -- The system scales to any number of features and settings without increasing complexity. + +- A mod author can create overwrite files targeting any setting that appears in a feature's JSON — even settings the Scene Settings Manager developers have never heard of. +- The system scales to any number of features and settings without increasing complexity. --- @@ -179,15 +180,18 @@ User settings (entries you add through the in-game UI) are persisted automatical ### Example Say you have Interior Only overrides for: -- `ScreenSpaceGI.EnableGI` → `false` (disable GI in interiors) -- `SubsurfaceScattering.Intensity` → `0.2` (reduce SSS indoors) + +- `ScreenSpaceGI.EnableGI` → `false` (disable GI in interiors) +- `SubsurfaceScattering.Intensity` → `0.2` (reduce SSS indoors) When you enter Dragonsreach: + 1. Current values of `EnableGI` and `Intensity` are saved. 2. `EnableGI` is set to `false`, `Intensity` to `0.2`. 3. You play through the interior with these settings active. When you exit to Whiterun: + 1. `EnableGI` reverts to its saved value (e.g., `true`). 2. `Intensity` reverts to its saved value (e.g., `0.5`). 3. Time of Day reactivates (if you have TOD entries). @@ -202,14 +206,14 @@ Time of Day (TOD) settings smoothly blend feature values across six periods whil **Periods and Hour Boundaries**: -| Period | Hours | Description | -|----------|---------------|----------------------------| -| Dawn | 4:00 – 6:00 | Pre-sunrise golden hour | -| Sunrise | 6:00 – 8:00 | Sun coming up | -| Day | 8:00 – 17:00 | Full daylight | -| Sunset | 17:00 – 19:00 | Sun going down | -| Dusk | 19:00 – 21:00 | Post-sunset blue hour | -| Night | 21:00 – 4:00 | Full darkness (wraps around midnight) | +| Period | Hours | Description | +| ------- | ------------- | ------------------------------------- | +| Dawn | 4:00 – 6:00 | Pre-sunrise golden hour | +| Sunrise | 6:00 – 8:00 | Sun coming up | +| Day | 8:00 – 17:00 | Full daylight | +| Sunset | 17:00 – 19:00 | Sun going down | +| Dusk | 19:00 – 21:00 | Post-sunset blue hour | +| Night | 21:00 – 4:00 | Full darkness (wraps around midnight) | **Blending**: At the boundary between two periods, values blend over a 30-minute (0.5 game-hour) transition zone. Outside the transition zone, the current period's value is used at full weight. @@ -223,31 +227,35 @@ User settings for Time of Day are persisted automatically to `SceneSettings/Time The blending runs every frame, so the system includes several optimizations: -- **Hour throttle**: The blend only recalculates when the game hour has changed by more than 0.001 (about 0.36 real-time seconds at default timescale). This skips 98%+ of per-frame work. -- **Epsilon cache**: For each float value, the last-applied result is cached. If the new result differs by less than 0.001, the `LoadSettings()` call is skipped entirely. -- **Non-float cache**: Booleans and integers are cached and only pushed when they actually change. -- **Batch updates**: All dirty keys for a single feature are collected and applied in a single `LoadSettings()` call, rather than calling it once per key. +- **Hour throttle**: The blend only recalculates when the game hour has changed by more than 0.001 (about 0.36 real-time seconds at default timescale). This skips 98%+ of per-frame work. +- **Epsilon cache**: For each float value, the last-applied result is cached. If the new result differs by less than 0.001, the `LoadSettings()` call is skipped entirely. +- **Non-float cache**: Booleans and integers are cached and only pushed when they actually change. +- **Batch updates**: All dirty keys for a single feature are collected and applied in a single `LoadSettings()` call, rather than calling it once per key. ### Example Say you set `CloudShadows.Opacity`: -- Dawn: `0.3` -- Day: `0.8` -- Sunset: `0.5` -- Night: `0.1` + +- Dawn: `0.3` +- Day: `0.8` +- Sunset: `0.5` +- Night: `0.1` (Sunrise and Dusk are left undefined — they'll fall back to the baseline.) At 5:30 (mid-Dawn, 30 min before Sunrise transition): -- Dawn factor = 1.0, result = `0.3` + +- Dawn factor = 1.0, result = `0.3` At 5:45 (Dawn→Sunrise transition starts, 15 min left): -- Dawn factor ≈ 0.5, Sunrise factor ≈ 0.5 -- Sunrise has no override → uses baseline (say `0.6`) -- Result = 0.5 × 0.3 + 0.5 × 0.6 = `0.45` + +- Dawn factor ≈ 0.5, Sunrise factor ≈ 0.5 +- Sunrise has no override → uses baseline (say `0.6`) +- Result = 0.5 × 0.3 + 0.5 × 0.6 = `0.45` At 12:00 (mid-Day): -- Day factor = 1.0, result = `0.8` + +- Day factor = 1.0, result = `0.8` --- @@ -311,16 +319,16 @@ Select **Interior Only** or **Time of Day** from the left sidebar to open the co **Elements:** -| Element | Description | -|---------|-------------| -| **Feature dropdown** | Lists whitelisted interior features. Selecting one populates the setting dropdown. | -| **Setting dropdown** | Lists all JSON keys from the selected feature's `SaveSettings()`. Selecting one immediately adds it with the current value. Already-added settings are greyed out. | -| **Overwrite Files section** | Entries loaded from mod author JSON files. Values are read-only (greyed out) — mod authors set them. You can pause or delete individual entries or all at once. | -| **User Settings section** | Entries you added through the UI. Values are editable. | -| **Value editor** | Checkbox for booleans, number input for floats/integers. | -| **[●] toggle** | Pause/resume individual entries. Paused entries are ignored without being deleted. | -| **[X] button** | Delete the entry. For overwrites, this deletes the file from disk (with confirmation). | -| **Pause All / Delete All** | Bulk controls per section. | +| Element | Description | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Feature dropdown** | Lists whitelisted interior features. Selecting one populates the setting dropdown. | +| **Setting dropdown** | Lists all JSON keys from the selected feature's `SaveSettings()`. Selecting one immediately adds it with the current value. Already-added settings are greyed out. | +| **Overwrite Files section** | Entries loaded from mod author JSON files. Values are read-only (greyed out) — mod authors set them. You can pause or delete individual entries or all at once. | +| **User Settings section** | Entries you added through the UI. Values are editable. | +| **Value editor** | Checkbox for booleans, number input for floats/integers. | +| **[●] toggle** | Pause/resume individual entries. Paused entries are ignored without being deleted. | +| **[X] button** | Delete the entry. For overwrites, this deletes the file from disk (with confirmation). | +| **Pause All / Delete All** | Bulk controls per section. | **Entries are grouped by feature** with collapsible tree nodes, sorted alphabetically. @@ -362,13 +370,13 @@ Select **Interior Only** or **Time of Day** from the left sidebar to open the co **Elements:** -| Element | Description | -|---------|-------------| -| **Header** | Shows the current period and game hour (e.g., `[Day 12.0h]`). | +| Element | Description | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Header** | Shows the current period and game hour (e.g., `[Day 12.0h]`). | | **"Add to all times of day"** | When checked, selecting a setting adds it to all 6 periods at once with the current value. When unchecked, you get a per-period dropdown row for each period. | -| **Period columns** | One column per period. The active period column is highlighted; inactive periods are dimmed. `--` means no override for that period (falls back to baseline). | -| **Row-level controls** | Each setting row has a toggle (pause all periods) and delete (remove all periods) button in the Setting column. | -| **Per-cell controls** | Each individual period cell has its own value editor, pause toggle, and delete button. | +| **Period columns** | One column per period. The active period column is highlighted; inactive periods are dimmed. `--` means no override for that period (falls back to baseline). | +| **Row-level controls** | Each setting row has a toggle (pause all periods) and delete (remove all periods) button in the Setting column. | +| **Per-cell controls** | Each individual period cell has its own value editor, pause toggle, and delete button. | **Note on per-period add mode**: When "Add to all times of day" is unchecked, you see 6 rows of dropdowns, one per period, each with a period name label: @@ -410,10 +418,10 @@ When scene settings are actively controlling a feature, its settings page in the **Behaviour:** -- The **Scene Specific Settings** toggle appears only when scene entries exist for this feature (active or paused). -- When **active** (toggle on, green): All feature settings are **disabled** (greyed out). The Scene Settings Manager is controlling values. -- When **paused** (toggle off): Scene settings stop applying. Feature settings become editable again. This is per-feature — it doesn't affect other features. -- The **"Apply Override" button** (from the Settings Override Manager) is also disabled while scene settings are active, to prevent conflicting writes. +- The **Scene Specific Settings** toggle appears only when scene entries exist for this feature (active or paused). +- When **active** (toggle on, green): All feature settings are **disabled** (greyed out). The Scene Settings Manager is controlling values. +- When **paused** (toggle off): Scene settings stop applying. Feature settings become editable again. This is per-feature — it doesn't affect other features. +- The **"Apply Override" button** (from the Settings Override Manager) is also disabled while scene settings are active, to prevent conflicting writes. **Why all settings are greyed out, not just the overridden ones:** @@ -471,9 +479,9 @@ Place JSON files in `CommunityShaders/SceneSettings/InteriorOnly/`. **Rules:** -- Each file must contain **exactly one setting** (one non-metadata key). -- The `_feature` field identifies the target feature. If omitted, the system tries to infer it from the filename (the part after the last underscore must match a feature short name). -- Keys starting with `_` are treated as metadata and ignored when extracting the setting. +- Each file must contain **exactly one setting** (one non-metadata key). +- The `_feature` field identifies the target feature. If omitted, the system tries to infer it from the filename (the part after the last underscore must match a feature short name). +- Keys starting with `_` are treated as metadata and ignored when extracting the setting. ### Time of Day Overwrites @@ -503,11 +511,11 @@ Periods without a file fall back to the feature's baseline value during blending ### Overwrite File Format -| Field | Required? | Description | -|-------|-----------|-------------| -| `_feature` | Recommended | The feature's short name (e.g., `"ScreenSpaceGI"`, `"CloudShadows"`). If omitted, inferred from filename. | -| `{settingKey}` | Required (exactly 1) | The JSON key matching the feature's `SaveSettings()` output, with the desired override value. | -| `_*` (any key starting with `_`) | Optional | Metadata fields, ignored by the system. Use for comments, authorship, etc. | +| Field | Required? | Description | +| -------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------- | +| `_feature` | Recommended | The feature's short name (e.g., `"ScreenSpaceGI"`, `"CloudShadows"`). If omitted, inferred from filename. | +| `{settingKey}` | Required (exactly 1) | The JSON key matching the feature's `SaveSettings()` output, with the desired override value. | +| `_*` (any key starting with `_`) | Optional | Metadata fields, ignored by the system. Use for comments, authorship, etc. | **Example with metadata:** @@ -562,8 +570,8 @@ Adding Scene Settings Manager support for a feature is trivial — it requires * 1. Open `src/SceneSettingsManager.cpp`. 2. Find the appropriate whitelist: - - `GetInteriorRelevantFeatureNames()` for interior support. - - `GetExteriorRelevantFeatureNames()` for time-of-day support. + - `GetInteriorRelevantFeatureNames()` for interior support. + - `GetExteriorRelevantFeatureNames()` for time-of-day support. 3. Add the feature's short name to the `unordered_set`: ```cpp @@ -600,6 +608,7 @@ That's it. No interface to implement, no registration call to add. The Scene Set Not every feature is exposed to the Scene Settings Manager. Features must be present in one of two static whitelists: **Interior-Relevant Features** — settings that make sense to override in interiors: + ``` ScreenSpaceGI SubsurfaceScattering LinearLighting ImageBasedLighting PostProcessing ScreenSpacePointLightShadows @@ -607,6 +616,7 @@ ScreenSpaceRayTracing VanillaFresnel ``` **Exterior/Time-of-Day-Relevant Features** — settings that make sense to vary across the day: + ``` CloudShadows ExponentialHeightFog GrassLighting ImageBasedLighting LinearLighting Skylighting @@ -623,15 +633,15 @@ You might wonder: why not have each feature declare `bool SupportsSceneSettings( 3. **Easy to extend.** Adding a new feature to the whitelist is a single-line diff. There's no API to implement, no registration call to add, no interface to satisfy. When a new feature is developed — even one that hasn't been merged yet — it can be added to the whitelist in the same PR or in a follow-up. -4. **Even whitelist changes are less work than a coupled system.** In the unlikely event that the whitelist needs updating (a feature is added, removed, or moved between lists), the change is a single line in one file — `SceneSettingsManager.cpp`. In a coupled system where features opt themselves in, the same change would require editing the feature's own code, which is strictly more work. Any change you'd need to make to the whitelist is a change you'd *also* need to make in a coupled system — except in the coupled version, you'd be editing the feature itself, dealing with its build dependencies, and potentially breaking its tests. The whitelist approach is always equal or less effort. +4. **Even whitelist changes are less work than a coupled system.** In the unlikely event that the whitelist needs updating (a feature is added, removed, or moved between lists), the change is a single line in one file — `SceneSettingsManager.cpp`. In a coupled system where features opt themselves in, the same change would require editing the feature's own code, which is strictly more work. Any change you'd need to make to the whitelist is a change you'd _also_ need to make in a coupled system — except in the coupled version, you'd be editing the feature itself, dealing with its build dependencies, and potentially breaking its tests. The whitelist approach is always equal or less effort. ### Features That Aren't Whitelisted (and Why) Some features are intentionally missing: -- **ScreenSpaceGI** (exterior whitelist): `LoadSettings()` recompiles 6 compute shaders synchronously. Fine for interior transitions (happens once), but not for per-frame TOD blending. It *is* on the interior whitelist since interior overrides only apply once on cell transition. -- **VolumetricLighting, LightLimitFix**: Heavy GPU features where hot-swapping settings could cause transient artifacts or require buffer reallocation. -- **TerrainBlending, TerrainVariation**: Terrain features that work at a mesh level and don't benefit from per-frame setting changes. +- **ScreenSpaceGI** (exterior whitelist): `LoadSettings()` recompiles 6 compute shaders synchronously. Fine for interior transitions (happens once), but not for per-frame TOD blending. It _is_ on the interior whitelist since interior overrides only apply once on cell transition. +- **VolumetricLighting, LightLimitFix**: Heavy GPU features where hot-swapping settings could cause transient artifacts or require buffer reallocation. +- **TerrainBlending, TerrainVariation**: Terrain features that work at a mesh level and don't benefit from per-frame setting changes. As features are improved and their `LoadSettings()` paths become cheaper, they can be promoted to the whitelist with a single-line change. Even in this scenario, the whitelist approach is less work than a coupled system — a coupled system would require the same decision about whether to enable scene settings support, but the change would live inside the feature's own code rather than in a centralized, reviewable list. @@ -641,26 +651,25 @@ As features are improved and their `LoadSettings()` paths become cheaper, they c Community Shaders has two systems that modify feature settings after boot. They serve different purposes and operate at different layers. -| | Scene Settings Manager | Settings Override Manager | -|---|---|---| -| **Purpose** | Context-dependent overrides (interior/exterior, time of day) | Permanent baseline overrides (mod author presets) | -| **When applied** | At runtime, on cell transitions and per-frame (TOD) | At boot, when features first load settings | -| **Reverts?** | Yes — automatically when context changes | No — permanent until manually reset | -| **Feature coupling** | Zero — uses SaveSettings/LoadSettings JSON round-trip | Zero — merges JSON on top of defaults at boot | -| **Granularity** | Per-setting, per-context (interior, per-period) | Per-setting, per-feature, or global | -| **User editing** | In-game UI (Weather Editor panels) | "Apply Override" button per feature | -| **Mod author format** | One setting per JSON file, in SceneSettings/ subfolders | Multi-setting JSON files in Overrides/ folder | -| **File naming** | `{ModName}_{FeatureName}_{SettingKey}.json` | `{ModName}_{FeatureName}.json` or `{ModName}_Global.json` | -| **Priority** | Higher — applies on top of everything else | Lower — applies at boot, overwritten by scene settings | -| **Blending** | Yes — float values smoothly interpolate between TOD periods | No — values are merged, not blended | -| **Interior/Exterior awareness** | Yes — core feature | No — applies everywhere | - +| | Scene Settings Manager | Settings Override Manager | +| ------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------- | +| **Purpose** | Context-dependent overrides (interior/exterior, time of day) | Permanent baseline overrides (mod author presets) | +| **When applied** | At runtime, on cell transitions and per-frame (TOD) | At boot, when features first load settings | +| **Reverts?** | Yes — automatically when context changes | No — permanent until manually reset | +| **Feature coupling** | Zero — uses SaveSettings/LoadSettings JSON round-trip | Zero — merges JSON on top of defaults at boot | +| **Granularity** | Per-setting, per-context (interior, per-period) | Per-setting, per-feature, or global | +| **User editing** | In-game UI (Weather Editor panels) | "Apply Override" button per feature | +| **Mod author format** | One setting per JSON file, in SceneSettings/ subfolders | Multi-setting JSON files in Overrides/ folder | +| **File naming** | `{ModName}_{FeatureName}_{SettingKey}.json` | `{ModName}_{FeatureName}.json` or `{ModName}_Global.json` | +| **Priority** | Higher — applies on top of everything else | Lower — applies at boot, overwritten by scene settings | +| **Blending** | Yes — float values smoothly interpolate between TOD periods | No — values are merged, not blended | +| **Interior/Exterior awareness** | Yes — core feature | No — applies everywhere | The Settings Override Manager establishes the **baseline** that the Scene Settings Manager saves and modifies. They're complementary: -- A mod author might use the **Override Manager** to set `CloudShadows.Opacity` to `0.6` as their recommended default. -- They might then use the **Scene Settings Manager** to set `Opacity` to `0.3` at Night and `0.9` at Day. -- The saved baseline would be `0.6` (from the Override Manager), and TOD would blend between `0.3`, `0.9`, and the baseline for uncovered periods. +- A mod author might use the **Override Manager** to set `CloudShadows.Opacity` to `0.6` as their recommended default. +- They might then use the **Scene Settings Manager** to set `Opacity` to `0.3` at Night and `0.9` at Day. +- The saved baseline would be `0.6` (from the Override Manager), and TOD would blend between `0.3`, `0.9`, and the baseline for uncovered periods. --- @@ -690,6 +699,7 @@ A: You can enter any value, but the feature will clamp it to its valid range dur **Q: My overwrite file isn't showing up in the Overwrite Files section.** A: Check: + 1. The file is in the correct directory (`SceneSettings/InteriorOnly/` or `SceneSettings/TimeOfDay/{Period}/`). 2. The file has a `.json` extension. 3. The file contains exactly one non-metadata key. From b7f771bb98a0bdad05fde10fb4996ef757d7fca1 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Wed, 25 Feb 2026 04:55:18 -0700 Subject: [PATCH 21/36] dialogue box --- docs/development/scene-settings-manager.md | 157 +++++++++++++-------- src/Menu/ThemeManager.h | 6 +- src/SceneSettingsManager.cpp | 32 +++++ src/SceneSettingsManager.h | 3 + src/WeatherEditor/InteriorOnlyPanel.cpp | 7 +- src/WeatherEditor/SceneSettingsUI.cpp | 143 ++++++++++++++----- src/WeatherEditor/SceneSettingsUI.h | 29 +++- src/WeatherEditor/TimeOfDayPanel.cpp | 117 ++++++++++++--- 8 files changed, 365 insertions(+), 129 deletions(-) diff --git a/docs/development/scene-settings-manager.md b/docs/development/scene-settings-manager.md index 0b355eef95..ddd675584f 100644 --- a/docs/development/scene-settings-manager.md +++ b/docs/development/scene-settings-manager.md @@ -122,7 +122,7 @@ That's it. The feature's own serialization code handles all type conversion, val - **No feature code changes needed.** A feature gets Scene Settings Manager support by being added to a whitelist — a single line in a static list. The feature itself is unmodified. - **Forward-compatible.** Features that don't exist yet will work with the Scene Settings Manager the moment they're added to the whitelist. If someone is developing a new feature that hasn't been merged yet, it can still be whitelisted in advance. -- **Any JSON-serializable setting works.** Floats get smoothly blended between time-of-day periods. Booleans and integers snap at the dominant period boundary. If a feature adds new settings, they're automatically available — no registration step needed. +- **Any JSON-serializable setting works.** Floats get smoothly blended between time-of-day periods (integers, booleans, and strings are rejected from TOD — only continuous float sliders can transition). For Interior Only, all setting types are supported. If a feature adds new settings, they're automatically available — no registration step needed. - **Round-trip verification.** After applying an override, the manager reads the value back and logs a warning if the feature clamped it. This catches range violations without requiring the Scene Settings Manager to know anything about valid ranges. ### Contrast with Tighter Coupling @@ -221,7 +221,7 @@ User settings for Time of Day are persisted automatically to `SceneSettings/Time **Float values** are linearly interpolated between periods based on these factors. If a setting isn't defined for a particular period, the saved baseline value is used for that period's weight — so the blend always sums to the correct total. -**Non-float values** (booleans, integers) snap to the dominant period's value. They can't be meaningfully interpolated, so the value switches when the dominant period changes. +**Only float settings are allowed** in Time of Day. Integers, booleans, and strings cannot be smoothly interpolated between periods and are rejected — both from the UI dialog and from overwrite files. If an overwrite file contains a non-float setting, it is skipped with a log warning. ### Performance Optimizations @@ -229,7 +229,6 @@ The blending runs every frame, so the system includes several optimizations: - **Hour throttle**: The blend only recalculates when the game hour has changed by more than 0.001 (about 0.36 real-time seconds at default timescale). This skips 98%+ of per-frame work. - **Epsilon cache**: For each float value, the last-applied result is cached. If the new result differs by less than 0.001, the `LoadSettings()` call is skipped entirely. -- **Non-float cache**: Booleans and integers are cached and only pushed when they actually change. - **Batch updates**: All dirty keys for a single feature are collected and applied in a single `LoadSettings()` call, rather than calling it once per key. ### Example @@ -293,63 +292,94 @@ Select **Interior Only** or **Time of Day** from the left sidebar to open the co ### Interior Only Panel (UI) ``` -┌─────────────────────────────────────────────────────────────────┐ -│ Interior Only Settings │ -│ ─────────────────────── │ -│ │ -│ ┌────────────────────────────┐ ┌─────────────────────────────┐ │ -│ │ Select Feature... ▼ │ │ Select Setting... ▼ │ │ -│ └────────────────────────────┘ └─────────────────────────────┘ │ -│ │ -│ Overwrite Files [Pause All] [Delete All] │ -│ ───────────────────────────────────────────────────── │ -│ ▼ Screen Space GI: │ -│ EnableGI [V] [●] [X] │ -│ AmbientIntensity [0.500___] [●] [X] │ -│ ▼ Subsurface Scattering: │ -│ Intensity [0.200___] [●] [X] │ -│ │ -│ User Settings [Pause All] [Delete All] │ -│ ───────────────────────────────────────────────────── │ -│ ▼ Linear Lighting: │ -│ GammaCorrection [2.200___] [●] [X] │ -│ │ -└─────────────────────────────────────────────────────────────────┘ +┌───────────────────────────────────────────────────────────────┐ +│ Interior Only Settings [+] │ +│ ────────────────────────────────────────────────────────── │ +│ │ +│ Overwrite Files [Pause All] [Delete All] │ +│ ───────────────────────────────────────────────────── │ +│ ▼ Screen Space GI: │ +│ EnableGI [V] [●] [X] │ +│ AmbientIntensity [0.500___] [●] [X] │ +│ · · · · · · · · · · · · · · · · · · · · · · │ +│ ▼ Subsurface Scattering: │ +│ Intensity [0.200___] [●] [X] │ +│ │ +│ User Settings [Pause All] [Delete All] │ +│ ───────────────────────────────────────────────────── │ +│ ▼ Linear Lighting: │ +│ GammaCorrection [2.200___] [●] [X] │ +│ │ +└───────────────────────────────────────────────────────────────┘ ``` -**Elements:** +The **[+]** button is **right-aligned** on the header line. + +Clicking it opens the **Add Feature Settings** dialog: + +``` +┌───────────────────────────────────────┐ +│ Add Feature Settings [X] │ +│ ┌──────────────────────────────────┐ │ +│ │ Select Feature... ▼ │ │ +│ └──────────────────────────────────┘ │ +│ ──────────────────────────────────── │ +│ [Select All] [Select None] │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ [✓] EnableGI │ │ +│ │ [ ] AmbientIntensity │ │ +│ │ [ ] IndirectLightingStrength │ │ +│ │ [ ] MaxDistance │ │ +│ │ [ ] NumSteps │ │ +│ │ ... (scrollable) │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [ Add (1) ] │ +└───────────────────────────────────────┘ +``` -| Element | Description | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Feature dropdown** | Lists whitelisted interior features. Selecting one populates the setting dropdown. | -| **Setting dropdown** | Lists all JSON keys from the selected feature's `SaveSettings()`. Selecting one immediately adds it with the current value. Already-added settings are greyed out. | -| **Overwrite Files section** | Entries loaded from mod author JSON files. Values are read-only (greyed out) — mod authors set them. You can pause or delete individual entries or all at once. | -| **User Settings section** | Entries you added through the UI. Values are editable. | -| **Value editor** | Checkbox for booleans, number input for floats/integers. | -| **[●] toggle** | Pause/resume individual entries. Paused entries are ignored without being deleted. | -| **[X] button** | Delete the entry. For overwrites, this deletes the file from disk (with confirmation). | -| **Pause All / Delete All** | Bulk controls per section. | +**Elements:** -**Entries are grouped by feature** with collapsible tree nodes, sorted alphabetically. +| Element | Description | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **[+] button** | Opens the Add Feature Settings dialog to select a feature and its settings. | +| **Feature dropdown** | Lists whitelisted features. Selecting one populates the setting checkbox list below. | +| **Select All / Select None**| Bulk-select or clear all checkboxes in the settings list. | +| **Settings checkbox list** | Scrollable list of JSON keys from the feature's `SaveSettings()`. For Time of Day, only float keys are shown (integers, booleans, and strings are excluded). Already-added settings appear checked and disabled. | +| **Add button** | Adds all checked settings with their current values. Shows the count of selected settings. Closes the dialog on success. | +| **Overwrite Files section** | Entries loaded from mod author JSON files. Values are read-only (greyed out) — mod authors set them. You can pause or delete individual entries or all at once. | +| **User Settings section** | Entries you added through the UI. Values are editable. | +| **Value editor** | Checkbox for booleans, number input for floats/integers. | +| **[●] toggle** | Pause/resume individual entries. Paused entries are ignored without being deleted. | +| **[X] button** | Delete the entry. For overwrites, this deletes the file from disk (with confirmation). | +| **Pause All / Delete All** | Bulk controls per section. | + +**Entries are grouped by feature** with collapsible tree nodes, sorted alphabetically. Light separators appear between feature groups for visual clarity. --- ### Time of Day Panel (UI) +The default view ("All Periods" unchecked) shows a vertical per-period [+] list: + ``` ┌──────────────────────────────────────────────────────────────────┐ -│ Time of Day Settings (Exterior Only) [Day 12.0h] │ -│ ──────────────────────────────────── │ -│ │ -│ ▼ Add Settings │ -│ [V] Add to all times of day │ -│ ┌────────────────────────────┐ ┌──────────────────────────────┐ │ -│ │ Select Feature... ▼ │ │ Select Setting... ▼ │ │ -│ └────────────────────────────┘ └──────────────────────────────┘ │ +│ Time of Day Settings (Exterior Only) [Day 12.0h] │ +│ ──────────────────────────────────────────────────── │ +│ [ ] All Periods │ +│ Dawn: [+] │ +│ Sunrise: [+] │ +│ Day: [+] │ +│ Sunset: [+] │ +│ Dusk: [+] │ +│ Night: [+] │ +│ ──────────────────────────────────────────────────── │ │ │ │ Overwrite Files [Pause All] [Delete All] │ │ ┌─────────┬────────┬────────┬────────┬────────┬──────┬───────┐ │ │ │Setting │ Dawn │Sunrise │ Day │ Sunset │ Dusk │ Night │ │ +│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X] │ │ │ ├─────────┼────────┼────────┼────────┼────────┼──────┼───────┤ │ │ │CloudShadows: │ │ │ │ Opacity │ 0.300 │ -- │ 0.800 │ 0.500 │ -- │ 0.100 │ │ @@ -359,37 +389,40 @@ Select **Interior Only** or **Time of Day** from the left sidebar to open the co │ User Settings [Pause All] [Delete All] │ │ ┌─────────┬────────┬────────┬────────┬────────┬──────┬───────┐ │ │ │Setting │ Dawn │Sunrise │ Day │ Sunset │ Dusk │ Night │ │ +│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X] │ │ │ ├─────────┼────────┼────────┼────────┼────────┼──────┼───────┤ │ │ │Skylighting: │ │ │ │ MixAmt │ 0.400 │ 0.600 │ 0.800 │ 0.600 │0.400 │ 0.200 │ │ -│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X]││ │ +│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X] │ │ │ └─────────┴────────┴────────┴────────┴────────┴──────┴───────┘ │ │ │ └──────────────────────────────────────────────────────────────────┘ ``` +Clicking a **[+]** button opens the **Add Feature Settings** dialog (see above). **Only float settings appear** in the TOD dialog — integers, booleans, and strings cannot be smoothly transitioned between periods and are excluded. Overwrite files containing non-float TOD settings are also rejected at load time with a log warning. + **Elements:** -| Element | Description | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Header** | Shows the current period and game hour (e.g., `[Day 12.0h]`). | -| **"Add to all times of day"** | When checked, selecting a setting adds it to all 6 periods at once with the current value. When unchecked, you get a per-period dropdown row for each period. | -| **Period columns** | One column per period. The active period column is highlighted; inactive periods are dimmed. `--` means no override for that period (falls back to baseline). | -| **Row-level controls** | Each setting row has a toggle (pause all periods) and delete (remove all periods) button in the Setting column. | -| **Per-cell controls** | Each individual period cell has its own value editor, pause toggle, and delete button. | +| Element | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Header** | Shows the current period and game hour (e.g., `[Day 12.0h]`). When "All Periods" is checked, a right-aligned [+] button appears on this line. | +| **All Periods checkbox** | When checked, the per-period [+] list is hidden and a single right-aligned [+] is shown on the header. When unchecked, per-period [+] buttons are listed vertically. | +| **Per-period [+] list** | One [+] button per period (Dawn through Night), each opening a dialog scoped to that specific period. Lets you add a setting to just one or two periods. | +| **Header controls** | Each period column header includes a toggle [●] (pause/unpause all entries in that period) and [X] (delete all entries in that period) below the period name. | +| **Period columns** | One column per period. The active period column is highlighted; inactive periods are dimmed. `--` means no override for that period (falls back to baseline). | +| **Row-level controls** | Each setting row has a toggle (pause all periods) and delete (remove all periods) button in the Setting column. | +| **Per-cell controls** | Each individual period cell has its own value editor, pause toggle, and delete button. | +| **Setting filter** | The add dialog only shows float settings. Integers, booleans, and strings are excluded since they cannot be smoothly interpolated between periods. Overwrite files are also validated — non-float TOD entries are rejected at load. | -**Note on per-period add mode**: When "Add to all times of day" is unchecked, you see 6 rows of dropdowns, one per period, each with a period name label: +**"All Periods" mode**: When checked, the per-period [+] list is replaced by a single [+] button right-aligned on the header line (matching the Interior Only layout). Adding settings through that dialog populates all 6 periods at once with the current value: ``` - Dawn: [Select Feature... ▼] [Select Setting... ▼] - Sunrise: [Select Feature... ▼] [Select Setting... ▼] - Day: [Select Feature... ▼] [Select Setting... ▼] - Sunset: [Select Feature... ▼] [Select Setting... ▼] - Dusk: [Select Feature... ▼] [Select Setting... ▼] - Night: [Select Feature... ▼] [Select Setting... ▼] + Time of Day Settings (Exterior Only) [Day 12.0h] [+] + ────────────────────────────────────────────────────────── + [✓] All Periods ``` -This lets you add a setting to just one or two periods (e.g., only Dawn and Night) without filling all six. +When unchecked, each [+] button opens the Add Feature Settings dialog scoped to that specific period, letting you add a setting to just one or two periods (e.g., only Dawn and Night) without filling all six. --- @@ -692,7 +725,7 @@ A: Yes. User settings are saved to `SceneSettings/InteriorOnly.json` and `SceneS ### Settings & Values **Q: A setting I want to override doesn't appear in the dropdown. Why?** -A: The feature may not be on the whitelist, or the setting may have a type that isn't exposed through `SaveSettings()`. Check the whitelist in `SceneSettingsManager.cpp`. +A: The feature may not be on the whitelist, or the setting may have a type that isn't exposed through `SaveSettings()`. For Time of Day, only float settings appear — integers, booleans, and strings are excluded because they cannot be smoothly transitioned between periods. This applies to both the UI dialog and overwrite files. Check the whitelist in `SceneSettingsManager.cpp`. **Q: Can I set a value outside the feature's normal range?** A: You can enter any value, but the feature will clamp it to its valid range during `LoadSettings()`. The Scene Settings Manager logs a warning when this happens. Check the in-game log if your override seems to have no effect. diff --git a/src/Menu/ThemeManager.h b/src/Menu/ThemeManager.h index 7b21f3aa9b..a5f5167e06 100644 --- a/src/Menu/ThemeManager.h +++ b/src/Menu/ThemeManager.h @@ -193,7 +193,6 @@ class ThemeManager // Scene settings panel constants (multipliers of ImGui::GetFontSize()) static constexpr float SCENE_VALUE_INPUT_EM = 5.7f; // Width for float/int value inputs static constexpr float SCENE_DELETE_BUTTON_EM = 1.0f; // Width for delete (X) buttons - static constexpr float SCENE_FEATURE_DROPDOWN_RATIO = 0.5f; // Feature dropdown width ratio static constexpr float SCENE_VALUE_LABEL_OFFSET_RATIO = 0.5f; // Value label right-alignment ratio static constexpr float SCENE_TOD_PARAM_COL_EM = 6.0f; // Parameter column width (TOD table) static constexpr float SCENE_TOD_PERIOD_COL_EM = 4.3f; // Per-period column width (TOD table) @@ -202,6 +201,11 @@ class ThemeManager static constexpr float SCENE_ENTRY_INDENT_EM = 0.4f; // Indent for setting entries under feature headers static constexpr float SCENE_TOD_FEATURE_TEXT_SCALE = 0.85f; // Smaller text scale for feature names in TOD table static constexpr float SCENE_TOD_LABEL_EM = 2.6f; // Fixed width for period labels in add-setting rows + static constexpr float SCENE_ADD_BUTTON_EM = 1.5f; // Size for the + add-setting button + static constexpr float SCENE_GROUP_SEPARATOR_ALPHA = 0.4f; // Alpha for light separators between feature groups + static constexpr float SCENE_ADD_DIALOG_WIDTH_EM = 22.0f; // Width of add-setting dialog + static constexpr float SCENE_ADD_DIALOG_HEIGHT_EM = 20.0f; // Max height of add-setting dialog + static constexpr float SCENE_ADD_LIST_HEIGHT_EM = 12.0f; // Height of scrollable setting list in dialog /// Resolve a font-relative multiplier to pixels using current font size. static float Em(float multiplier) { return multiplier * ImGui::GetFontSize(); } diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 59013b2ea9..9dbe08e927 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -220,6 +220,26 @@ SceneSettingsManager::SettingType SceneSettingsManager::DetectSettingType(const return SettingType::Unknown; } +std::vector SceneSettingsManager::GetTransitionableSettingKeys(const std::string& featureShortName) +{ + auto* feature = Feature::FindFeatureByShortName(featureShortName); + if (!feature) + return {}; + + json settings; + feature->SaveSettings(settings); + if (!settings.is_object()) + return {}; + + std::vector keys; + for (auto& [key, val] : settings.items()) { + if (DetectSettingType(val) == SettingType::Float) + keys.push_back(key); + } + std::sort(keys.begin(), keys.end()); + return keys; +} + // --- Generic Entry Management --- std::vector& SceneSettingsManager::GetEntriesMut(SceneType type) @@ -280,6 +300,12 @@ bool SceneSettingsManager::HasDuplicateEntry(SceneType type, const std::string& void SceneSettingsManager::AddSetting(SceneType type, const std::string& featureShortName, const std::string& settingKey, const json& value, TimeOfDayPeriod period) { + // TOD only supports float settings (smooth interpolation) + if (type == SceneType::TimeOfDay && DetectSettingType(value) != SettingType::Float) { + logger::warn("[SceneSettings] Rejecting non-float TOD setting: {}.{}", featureShortName, settingKey); + return; + } + if (HasDuplicateEntry(type, featureShortName, settingKey, EntrySource::User, period)) return; @@ -1080,6 +1106,12 @@ void SceneSettingsManager::DiscoverOverwritesInDir(SceneType type, const std::fi continue; } + // TOD only supports float settings (smooth interpolation) + if (type == SceneType::TimeOfDay && DetectSettingType(settingValue) != SettingType::Float) { + logger::warn("[SceneSettings] Skipping overwrite '{}': non-float setting '{}' not allowed in Time of Day", filename, settingKey); + continue; + } + // Duplicate check if (HasDuplicateEntry(type, featureShortName, settingKey, EntrySource::Overwrite, period)) continue; diff --git a/src/SceneSettingsManager.h b/src/SceneSettingsManager.h index 1000aee51a..6971f3c185 100644 --- a/src/SceneSettingsManager.h +++ b/src/SceneSettingsManager.h @@ -200,6 +200,9 @@ class SceneSettingsManager /// Get setting keys for a feature by JSON round-tripping its current settings static std::vector GetFeatureSettingKeys(const std::string& featureShortName); + /// Get only float setting keys that can be smoothly transitioned in Time of Day + static std::vector GetTransitionableSettingKeys(const std::string& featureShortName); + /// Get current value of a specific setting from a feature static json GetFeatureSettingValue(const std::string& featureShortName, const std::string& settingKey); diff --git a/src/WeatherEditor/InteriorOnlyPanel.cpp b/src/WeatherEditor/InteriorOnlyPanel.cpp index c3285aa426..75e25bb75e 100644 --- a/src/WeatherEditor/InteriorOnlyPanel.cpp +++ b/src/WeatherEditor/InteriorOnlyPanel.cpp @@ -25,10 +25,13 @@ namespace InteriorOnlyPanel auto& theme = globals::menu->GetSettings().Theme; ImGui::Text("Interior Only Settings"); + SceneSettingsUI::RightAlignNextButton(); + SceneSettingsUI::DrawAddSettingButton(kSceneType, addState); + ImGui::Separator(); SceneSettingsUI::DrawPopups(kSceneType, popups); - SceneSettingsUI::DrawAddSettingUI(kSceneType, addState); + SceneSettingsUI::DrawAddSettingDialog(kSceneType, addState); // Empty state if (entries.empty()) { @@ -36,7 +39,7 @@ namespace InteriorOnlyPanel ImGui::TextColored(theme.StatusPalette.Disable, "No interior-only settings configured."); ImGui::TextColored(theme.StatusPalette.Disable, - "Select a feature and setting above to add overrides."); + "Use the + button above to add overrides."); ImGui::Spacing(); ImGui::TextWrapped( "Settings added here will override feature defaults when you enter an interior cell. " diff --git a/src/WeatherEditor/SceneSettingsUI.cpp b/src/WeatherEditor/SceneSettingsUI.cpp index b2be731ea6..aec1a10c73 100644 --- a/src/WeatherEditor/SceneSettingsUI.cpp +++ b/src/WeatherEditor/SceneSettingsUI.cpp @@ -29,36 +29,64 @@ namespace SceneSettingsUI // --- Shared Drawing --- - void DrawAddSettingUI(SceneType type, AddSettingState& state, Period period, const char* labelPrefix, bool addToAllPeriods) + void DrawAddSettingButton([[maybe_unused]] SceneType type, AddSettingState& state, [[maybe_unused]] Period period, const char* labelPrefix, [[maybe_unused]] bool addToAllPeriods) { - auto* manager = SceneSettingsManager::GetSingleton(); - constexpr int kPeriodCount = SceneSettingsManager::kPeriodCount; - - ImGui::Spacing(); - - // Optional inline label (e.g. period name) with fixed width for alignment if (labelPrefix) { ImGui::Text("%s", labelPrefix); ImGui::SameLine(C::Em(C::SCENE_TOD_LABEL_EM)); } + if (ImGui::Button("+", ImVec2(C::Em(C::SCENE_ADD_BUTTON_EM), C::Em(C::SCENE_ADD_BUTTON_EM)))) { + state.Reset(); + state.dialogOpen = true; + state.cachedFeatureNames = GetFeatureNamesForType(type); + } + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text("Add feature settings"); + } + + void RightAlignNextButton() + { + float btnSize = C::Em(C::SCENE_ADD_BUTTON_EM); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - btnSize + ImGui::GetCursorPosX()); + } + + void DrawAddSettingDialog(SceneType type, AddSettingState& state, Period period, bool addToAllPeriods) + { + if (!state.dialogOpen) + return; + + constexpr int kPeriodCount = SceneSettingsManager::kPeriodCount; + auto* manager = SceneSettingsManager::GetSingleton(); + + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSize(ImVec2(C::Em(C::SCENE_ADD_DIALOG_WIDTH_EM), 0)); + + if (!ImGui::Begin("Add Feature Settings", &state.dialogOpen, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::End(); + return; + } + // Feature dropdown if (state.cachedFeatureNames.empty()) state.cachedFeatureNames = GetFeatureNamesForType(type); auto displayName = (state.selectedFeatureIdx >= 0 && - state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) ? - SceneSettingsManager::GetFeatureDisplayName(state.cachedFeatureNames[state.selectedFeatureIdx]) : - std::string("Select Feature..."); - const char* featurePreview = displayName.c_str(); + state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) + ? SceneSettingsManager::GetFeatureDisplayName(state.cachedFeatureNames[state.selectedFeatureIdx]) + : std::string("Select Feature..."); - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_FEATURE_DROPDOWN_RATIO); - if (ImGui::BeginCombo("##FeatureSelect", featurePreview)) { + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::BeginCombo("##FeatureSelect", displayName.c_str())) { for (int i = 0; i < static_cast(state.cachedFeatureNames.size()); ++i) { auto itemLabel = SceneSettingsManager::GetFeatureDisplayName(state.cachedFeatureNames[i]); if (ImGui::Selectable(itemLabel.c_str(), i == state.selectedFeatureIdx)) { state.selectedFeatureIdx = i; - state.cachedSettingKeys = SceneSettingsManager::GetFeatureSettingKeys(state.cachedFeatureNames[i]); + state.cachedSettingKeys = (type == SceneType::TimeOfDay) + ? SceneSettingsManager::GetTransitionableSettingKeys(state.cachedFeatureNames[i]) + : SceneSettingsManager::GetFeatureSettingKeys(state.cachedFeatureNames[i]); + state.selectedSettings.assign(state.cachedSettingKeys.size(), false); } if (i == state.selectedFeatureIdx) ImGui::SetItemDefaultFocus(); @@ -66,47 +94,76 @@ namespace SceneSettingsUI ImGui::EndCombo(); } - ImGui::SameLine(); + bool hasFeature = state.selectedFeatureIdx >= 0 && !state.cachedSettingKeys.empty(); - // Setting dropdown — selecting an entry auto-adds it - { - auto _ = Util::DisableGuard(state.selectedFeatureIdx < 0); - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); - if (ImGui::BeginCombo("##SettingSelect", "Select Setting...")) { + if (hasFeature) { + ImGui::Spacing(); + ImGui::Separator(); + + // Select All / Select None + if (ImGui::SmallButton("Select All")) + std::fill(state.selectedSettings.begin(), state.selectedSettings.end(), true); + ImGui::SameLine(); + if (ImGui::SmallButton("Select None")) + std::fill(state.selectedSettings.begin(), state.selectedSettings.end(), false); + + ImGui::Spacing(); + + // Scrollable checkbox list + auto& featureName = state.cachedFeatureNames[state.selectedFeatureIdx]; + if (ImGui::BeginChild("##SettingList", ImVec2(-FLT_MIN, C::Em(C::SCENE_ADD_LIST_HEIGHT_EM)), ImGuiChildFlags_Border)) { for (int i = 0; i < static_cast(state.cachedSettingKeys.size()); ++i) { - auto& featureName = state.cachedFeatureNames[state.selectedFeatureIdx]; auto& key = state.cachedSettingKeys[i]; + bool alreadyAdded = addToAllPeriods + ? [&] { for (int p = 0; p < kPeriodCount; ++p) if (!IsAlreadyAdded(type, featureName, key, static_cast(p))) return false; return true; }() + : IsAlreadyAdded(type, featureName, key, period); - // Check if already added (for all-periods mode, disabled only when present in every period) - bool alreadyAdded = false; - if (state.selectedFeatureIdx >= 0) { - if (addToAllPeriods) { - alreadyAdded = true; - for (int p = 0; p < kPeriodCount && alreadyAdded; ++p) - alreadyAdded = IsAlreadyAdded(type, featureName, key, static_cast(p)); - } else { - alreadyAdded = IsAlreadyAdded(type, featureName, key, period); - } + if (alreadyAdded) { + auto _ = Util::DisableGuard(true); + bool checked = true; + ImGui::Checkbox(key.c_str(), &checked); + } else { + bool sel = state.selectedSettings[i]; + if (ImGui::Checkbox(key.c_str(), &sel)) + state.selectedSettings[i] = sel; } + } + } + ImGui::EndChild(); - if (alreadyAdded) { - ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_TextDisabled]); - ImGui::Selectable(key.c_str(), false, ImGuiSelectableFlags_Disabled); - ImGui::PopStyleColor(); - } else if (ImGui::Selectable(key.c_str(), false)) { + ImGui::Spacing(); + + // Count selected + int selectedCount = 0; + for (size_t i = 0; i < state.selectedSettings.size(); ++i) + if (state.selectedSettings[i]) + ++selectedCount; + + // Add button + { + auto _ = Util::DisableGuard(selectedCount == 0); + auto label = std::format("Add ({})", selectedCount); + if (ImGui::Button(label.c_str(), ImVec2(-FLT_MIN, 0))) { + for (size_t i = 0; i < state.cachedSettingKeys.size(); ++i) { + if (!state.selectedSettings[i]) + continue; + auto& key = state.cachedSettingKeys[i]; auto currentValue = SceneSettingsManager::GetFeatureSettingValue(featureName, key); if (addToAllPeriods) { for (int p = 0; p < kPeriodCount; ++p) if (!IsAlreadyAdded(type, featureName, key, static_cast(p))) manager->AddSetting(type, featureName, key, currentValue, static_cast(p)); } else { - manager->AddSetting(type, featureName, key, currentValue, period); + if (!IsAlreadyAdded(type, featureName, key, period)) + manager->AddSetting(type, featureName, key, currentValue, period); } } + state.dialogOpen = false; } - ImGui::EndCombo(); } } + + ImGui::End(); } void DrawValueEditor(SceneType type, size_t index, float inputWidth) @@ -261,7 +318,17 @@ namespace SceneSettingsUI return entries[a].settingKey < entries[b].settingKey; }); + bool firstGroup = true; for (const auto& [featureName, featureIndices] : grouped) { + if (!firstGroup) { + auto sepColor = ImGui::GetStyleColorVec4(ImGuiCol_Separator); + sepColor.w *= C::SCENE_GROUP_SEPARATOR_ALPHA; + ImGui::PushStyleColor(ImGuiCol_Separator, sepColor); + ImGui::Separator(); + ImGui::PopStyleColor(); + } + firstGroup = false; + auto label = SceneSettingsManager::GetFeatureDisplayName(featureName) + ":"; if (ImGui::TreeNodeEx(label.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { ImGui::Indent(C::Em(C::SCENE_ENTRY_INDENT_EM)); diff --git a/src/WeatherEditor/SceneSettingsUI.h b/src/WeatherEditor/SceneSettingsUI.h index df535f5ca0..6ad38a2a56 100644 --- a/src/WeatherEditor/SceneSettingsUI.h +++ b/src/WeatherEditor/SceneSettingsUI.h @@ -13,12 +13,23 @@ namespace SceneSettingsUI using EntrySource = SceneSettingsManager::EntrySource; using Period = SceneSettingsManager::TimeOfDayPeriod; - /// Persistent state for the feature/setting tree selector. + /// Persistent state for the "+" add-setting dialog. struct AddSettingState { + bool dialogOpen = false; int selectedFeatureIdx = -1; std::vector cachedFeatureNames; std::vector cachedSettingKeys; + std::vector selectedSettings; // Checkbox state per setting key + + void Reset() + { + dialogOpen = false; + selectedFeatureIdx = -1; + cachedFeatureNames.clear(); + cachedSettingKeys.clear(); + selectedSettings.clear(); + } }; /// Shared confirmation popup state for a panel. @@ -36,16 +47,24 @@ namespace SceneSettingsUI deleteAllUser("Delete All User Settings?", userMsg, "Delete All") {} }; - /// Draw the feature tree selector. Selecting a setting auto-adds it. + /// Draw a "+" button that opens the add-setting dialog. /// @param type Scene type being edited. - /// @param state Persistent dropdown state (selection indices, caches). + /// @param state Persistent dialog state. /// @param period For TimeOfDay entries, which period to add to. Count = none. - /// @param labelPrefix Optional label drawn before the dropdowns (e.g. period name). + /// @param labelPrefix Optional label drawn before the button (e.g. period name). /// @param addToAllPeriods When true, adds the setting to every period at once. - void DrawAddSettingUI(SceneType type, AddSettingState& state, + void DrawAddSettingButton(SceneType type, AddSettingState& state, Period period = Period::Count, const char* labelPrefix = nullptr, bool addToAllPeriods = false); + /// Position the cursor so the next add-setting button is right-aligned on the current line. + void RightAlignNextButton(); + + /// Draw the modal dialog opened by DrawAddSettingButton. + /// Must be called each frame for each active dialog state. + void DrawAddSettingDialog(SceneType type, AddSettingState& state, + Period period = Period::Count, bool addToAllPeriods = false); + /// Draw the value editor widget (checkbox/float input/int input) for a setting entry. /// @param type Scene type being edited. /// @param index Index into the entries vector. diff --git a/src/WeatherEditor/TimeOfDayPanel.cpp b/src/WeatherEditor/TimeOfDayPanel.cpp index 8f389debdb..7eab584d2c 100644 --- a/src/WeatherEditor/TimeOfDayPanel.cpp +++ b/src/WeatherEditor/TimeOfDayPanel.cpp @@ -238,11 +238,28 @@ namespace TimeOfDayPanel } } + /// Collect all entry indices per period from a source group. + static void CollectPerPeriodIndices(const SourceGroup& group, std::array, kPeriodCount>& out) + { + for (const auto& [_, featureMap] : group.map) + for (const auto& [__, perKey] : featureMap) + for (int p = 0; p < kPeriodCount; ++p) + if (perKey[p] != SIZE_MAX) + out[p].push_back(perKey[p]); + } + /// Draw a TOD table for a single source group. static void DrawSourceTable(const SourceGroup& group, const float* factors, const char* tableId, EntrySource source) { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(kSceneType); + bool isOverwrite = source == EntrySource::Overwrite; constexpr int kTotalCols = 1 + kPeriodCount; + // Pre-collect per-period indices for header controls + std::array, kPeriodCount> perPeriod{}; + CollectPerPeriodIndices(group, perPeriod); + if (ImGui::BeginTable(tableId, kTotalCols, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingFixedFit | @@ -254,7 +271,7 @@ namespace TimeOfDayPanel ImGui::TableSetupScrollFreeze(0, 1); - // Header row with period names + active highlighting + // Header row with period names + integrated per-column controls ImGui::TableNextRow(ImGuiTableRowFlags_Headers); ImGui::TableSetColumnIndex(0); ImGui::TableHeader("Setting"); @@ -262,10 +279,60 @@ namespace TimeOfDayPanel ImGui::TableSetColumnIndex(1 + i); bool isActive = factors[i] > C::SCENE_TOD_ACTIVE_THRESHOLD; if (!isActive) - ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); - ImGui::TableHeader(SceneSettingsManager::kPeriodNames[i]); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, C::SCENE_TOD_INACTIVE_ALPHA); + + ImGui::Text("%s", SceneSettingsManager::kPeriodNames[i]); + + const auto& indices = perPeriod[i]; + if (!indices.empty()) { + ImGui::PushID(i); + + bool allPaused = std::all_of(indices.begin(), indices.end(), + [&](size_t idx) { return idx < entries.size() && entries[idx].paused; }); + bool active = !allPaused; + if (Util::FeatureToggle("##colActive", &active)) + for (auto idx : indices) + if (idx < entries.size() && entries[idx].paused == active) + manager->TogglePauseEntry(kSceneType, idx); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(allPaused ? "Unpause all in this period" : "Pause all in this period"); + + ImGui::SameLine(); + { + auto styledButton = Util::ErrorButtonStyle(); + if (ImGui::Button("X", ImVec2(C::Em(C::SCENE_DELETE_BUTTON_EM), 0))) { + if (isOverwrite) { + std::set filenames; + for (auto idx : indices) + if (idx < entries.size()) + filenames.insert(entries[idx].sourceFilename); + std::string fileList; + for (const auto& f : filenames) { + if (!fileList.empty()) + fileList += ", "; + fileList += "'" + f + "'"; + } + popups.pendingDeleteRow = indices; + popups.deleteRowOverwrite.message = std::format( + "Delete all {} overwrite entries?\nThis will permanently remove file(s) {} from disk.", + SceneSettingsManager::kPeriodNames[i], fileList); + popups.deleteRowOverwrite.Request(); + } else { + auto sorted = indices; + std::sort(sorted.begin(), sorted.end(), std::greater<>()); + for (auto idx : sorted) + manager->RemoveSetting(kSceneType, idx); + } + } + } + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(isOverwrite ? "Delete all in this period" : "Remove all in this period"); + + ImGui::PopID(); + } + if (!isActive) - ImGui::PopStyleColor(); + ImGui::PopStyleVar(); } DrawSourceRows(group, factors, source); @@ -290,34 +357,42 @@ namespace TimeOfDayPanel SceneSettingsManager::GetPeriodName(currentPeriod), SceneSettingsManager::GetCurrentGameHour()); + if (addToAllPeriods) { + SceneSettingsUI::RightAlignNextButton(); + SceneSettingsUI::DrawAddSettingButton(kSceneType, allPeriodsAddState, + Period::Count, nullptr, true); + SceneSettingsUI::DrawAddSettingDialog(kSceneType, allPeriodsAddState, + Period::Count, true); + } + ImGui::Separator(); - // Popups - SceneSettingsUI::DrawPopups(kSceneType, popups); + // Add buttons: one for all periods, or per-period + ImGui::Checkbox("All Periods", &addToAllPeriods); - // Per-period add-setting dropdowns — period name inline with dropdowns - if (ImGui::CollapsingHeader("Add Settings", ImGuiTreeNodeFlags_DefaultOpen)) { - ImGui::Checkbox("Add to all times of day", &addToAllPeriods); - if (addToAllPeriods) { - SceneSettingsUI::DrawAddSettingUI(kSceneType, allPeriodsAddState, - Period::Count, nullptr, true); - } else { - for (int i = 0; i < kPeriodCount; ++i) { - auto periodLabel = std::format("{}:", SceneSettingsManager::GetPeriodName(static_cast(i))); - ImGui::PushID(i); - SceneSettingsUI::DrawAddSettingUI(kSceneType, periodAddState[i], - static_cast(i), periodLabel.c_str()); - ImGui::PopID(); - } + if (!addToAllPeriods) { + for (int i = 0; i < kPeriodCount; ++i) { + auto periodLabel = std::format("{}:", SceneSettingsManager::GetPeriodName(static_cast(i))); + ImGui::PushID(i); + SceneSettingsUI::DrawAddSettingButton(kSceneType, periodAddState[i], + static_cast(i), periodLabel.c_str()); + SceneSettingsUI::DrawAddSettingDialog(kSceneType, periodAddState[i], + static_cast(i)); + ImGui::PopID(); } } + ImGui::Separator(); + + // Popups + SceneSettingsUI::DrawPopups(kSceneType, popups); + if (entries.empty()) { ImGui::Spacing(); ImGui::TextColored(theme.StatusPalette.Disable, "No time-of-day settings configured."); ImGui::TextColored(theme.StatusPalette.Disable, - "Select a feature and setting above to add overrides for each period."); + "Use the + button above to add overrides for each period."); return; } From 8a8e325b8d5473f07883d3d22350332cc9e6cf74 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:55:49 +0000 Subject: [PATCH 22/36] =?UTF-8?q?style:=20=F0=9F=8E=A8=20apply=20pre-commi?= =?UTF-8?q?t.ci=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated formatting by clang-format, prettier, and other hooks. See https://pre-commit.ci for details. --- docs/development/scene-settings-manager.md | 44 +++++++++++----------- src/Menu/ThemeManager.h | 2 +- src/WeatherEditor/SceneSettingsUI.cpp | 14 +++---- 3 files changed, 28 insertions(+), 32 deletions(-) diff --git a/docs/development/scene-settings-manager.md b/docs/development/scene-settings-manager.md index ddd675584f..c3a346667d 100644 --- a/docs/development/scene-settings-manager.md +++ b/docs/development/scene-settings-manager.md @@ -341,19 +341,19 @@ Clicking it opens the **Add Feature Settings** dialog: **Elements:** -| Element | Description | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **[+] button** | Opens the Add Feature Settings dialog to select a feature and its settings. | -| **Feature dropdown** | Lists whitelisted features. Selecting one populates the setting checkbox list below. | -| **Select All / Select None**| Bulk-select or clear all checkboxes in the settings list. | -| **Settings checkbox list** | Scrollable list of JSON keys from the feature's `SaveSettings()`. For Time of Day, only float keys are shown (integers, booleans, and strings are excluded). Already-added settings appear checked and disabled. | -| **Add button** | Adds all checked settings with their current values. Shows the count of selected settings. Closes the dialog on success. | -| **Overwrite Files section** | Entries loaded from mod author JSON files. Values are read-only (greyed out) — mod authors set them. You can pause or delete individual entries or all at once. | -| **User Settings section** | Entries you added through the UI. Values are editable. | -| **Value editor** | Checkbox for booleans, number input for floats/integers. | -| **[●] toggle** | Pause/resume individual entries. Paused entries are ignored without being deleted. | -| **[X] button** | Delete the entry. For overwrites, this deletes the file from disk (with confirmation). | -| **Pause All / Delete All** | Bulk controls per section. | +| Element | Description | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **[+] button** | Opens the Add Feature Settings dialog to select a feature and its settings. | +| **Feature dropdown** | Lists whitelisted features. Selecting one populates the setting checkbox list below. | +| **Select All / Select None** | Bulk-select or clear all checkboxes in the settings list. | +| **Settings checkbox list** | Scrollable list of JSON keys from the feature's `SaveSettings()`. For Time of Day, only float keys are shown (integers, booleans, and strings are excluded). Already-added settings appear checked and disabled. | +| **Add button** | Adds all checked settings with their current values. Shows the count of selected settings. Closes the dialog on success. | +| **Overwrite Files section** | Entries loaded from mod author JSON files. Values are read-only (greyed out) — mod authors set them. You can pause or delete individual entries or all at once. | +| **User Settings section** | Entries you added through the UI. Values are editable. | +| **Value editor** | Checkbox for booleans, number input for floats/integers. | +| **[●] toggle** | Pause/resume individual entries. Paused entries are ignored without being deleted. | +| **[X] button** | Delete the entry. For overwrites, this deletes the file from disk (with confirmation). | +| **Pause All / Delete All** | Bulk controls per section. | **Entries are grouped by feature** with collapsible tree nodes, sorted alphabetically. Light separators appear between feature groups for visual clarity. @@ -403,15 +403,15 @@ Clicking a **[+]** button opens the **Add Feature Settings** dialog (see above). **Elements:** -| Element | Description | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Header** | Shows the current period and game hour (e.g., `[Day 12.0h]`). When "All Periods" is checked, a right-aligned [+] button appears on this line. | -| **All Periods checkbox** | When checked, the per-period [+] list is hidden and a single right-aligned [+] is shown on the header. When unchecked, per-period [+] buttons are listed vertically. | -| **Per-period [+] list** | One [+] button per period (Dawn through Night), each opening a dialog scoped to that specific period. Lets you add a setting to just one or two periods. | -| **Header controls** | Each period column header includes a toggle [●] (pause/unpause all entries in that period) and [X] (delete all entries in that period) below the period name. | -| **Period columns** | One column per period. The active period column is highlighted; inactive periods are dimmed. `--` means no override for that period (falls back to baseline). | -| **Row-level controls** | Each setting row has a toggle (pause all periods) and delete (remove all periods) button in the Setting column. | -| **Per-cell controls** | Each individual period cell has its own value editor, pause toggle, and delete button. | +| Element | Description | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Header** | Shows the current period and game hour (e.g., `[Day 12.0h]`). When "All Periods" is checked, a right-aligned [+] button appears on this line. | +| **All Periods checkbox** | When checked, the per-period [+] list is hidden and a single right-aligned [+] is shown on the header. When unchecked, per-period [+] buttons are listed vertically. | +| **Per-period [+] list** | One [+] button per period (Dawn through Night), each opening a dialog scoped to that specific period. Lets you add a setting to just one or two periods. | +| **Header controls** | Each period column header includes a toggle [●] (pause/unpause all entries in that period) and [X] (delete all entries in that period) below the period name. | +| **Period columns** | One column per period. The active period column is highlighted; inactive periods are dimmed. `--` means no override for that period (falls back to baseline). | +| **Row-level controls** | Each setting row has a toggle (pause all periods) and delete (remove all periods) button in the Setting column. | +| **Per-cell controls** | Each individual period cell has its own value editor, pause toggle, and delete button. | | **Setting filter** | The add dialog only shows float settings. Integers, booleans, and strings are excluded since they cannot be smoothly interpolated between periods. Overwrite files are also validated — non-float TOD entries are rejected at load. | **"All Periods" mode**: When checked, the per-period [+] list is replaced by a single [+] button right-aligned on the header line (matching the Interior Only layout). Adding settings through that dialog populates all 6 periods at once with the current value: diff --git a/src/Menu/ThemeManager.h b/src/Menu/ThemeManager.h index a5f5167e06..6d5b55b741 100644 --- a/src/Menu/ThemeManager.h +++ b/src/Menu/ThemeManager.h @@ -202,7 +202,7 @@ class ThemeManager static constexpr float SCENE_TOD_FEATURE_TEXT_SCALE = 0.85f; // Smaller text scale for feature names in TOD table static constexpr float SCENE_TOD_LABEL_EM = 2.6f; // Fixed width for period labels in add-setting rows static constexpr float SCENE_ADD_BUTTON_EM = 1.5f; // Size for the + add-setting button - static constexpr float SCENE_GROUP_SEPARATOR_ALPHA = 0.4f; // Alpha for light separators between feature groups + static constexpr float SCENE_GROUP_SEPARATOR_ALPHA = 0.4f; // Alpha for light separators between feature groups static constexpr float SCENE_ADD_DIALOG_WIDTH_EM = 22.0f; // Width of add-setting dialog static constexpr float SCENE_ADD_DIALOG_HEIGHT_EM = 20.0f; // Max height of add-setting dialog static constexpr float SCENE_ADD_LIST_HEIGHT_EM = 12.0f; // Height of scrollable setting list in dialog diff --git a/src/WeatherEditor/SceneSettingsUI.cpp b/src/WeatherEditor/SceneSettingsUI.cpp index aec1a10c73..5d2ab5c9c5 100644 --- a/src/WeatherEditor/SceneSettingsUI.cpp +++ b/src/WeatherEditor/SceneSettingsUI.cpp @@ -73,9 +73,9 @@ namespace SceneSettingsUI state.cachedFeatureNames = GetFeatureNamesForType(type); auto displayName = (state.selectedFeatureIdx >= 0 && - state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) - ? SceneSettingsManager::GetFeatureDisplayName(state.cachedFeatureNames[state.selectedFeatureIdx]) - : std::string("Select Feature..."); + state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) ? + SceneSettingsManager::GetFeatureDisplayName(state.cachedFeatureNames[state.selectedFeatureIdx]) : + std::string("Select Feature..."); ImGui::SetNextItemWidth(-FLT_MIN); if (ImGui::BeginCombo("##FeatureSelect", displayName.c_str())) { @@ -83,9 +83,7 @@ namespace SceneSettingsUI auto itemLabel = SceneSettingsManager::GetFeatureDisplayName(state.cachedFeatureNames[i]); if (ImGui::Selectable(itemLabel.c_str(), i == state.selectedFeatureIdx)) { state.selectedFeatureIdx = i; - state.cachedSettingKeys = (type == SceneType::TimeOfDay) - ? SceneSettingsManager::GetTransitionableSettingKeys(state.cachedFeatureNames[i]) - : SceneSettingsManager::GetFeatureSettingKeys(state.cachedFeatureNames[i]); + state.cachedSettingKeys = (type == SceneType::TimeOfDay) ? SceneSettingsManager::GetTransitionableSettingKeys(state.cachedFeatureNames[i]) : SceneSettingsManager::GetFeatureSettingKeys(state.cachedFeatureNames[i]); state.selectedSettings.assign(state.cachedSettingKeys.size(), false); } if (i == state.selectedFeatureIdx) @@ -114,9 +112,7 @@ namespace SceneSettingsUI if (ImGui::BeginChild("##SettingList", ImVec2(-FLT_MIN, C::Em(C::SCENE_ADD_LIST_HEIGHT_EM)), ImGuiChildFlags_Border)) { for (int i = 0; i < static_cast(state.cachedSettingKeys.size()); ++i) { auto& key = state.cachedSettingKeys[i]; - bool alreadyAdded = addToAllPeriods - ? [&] { for (int p = 0; p < kPeriodCount; ++p) if (!IsAlreadyAdded(type, featureName, key, static_cast(p))) return false; return true; }() - : IsAlreadyAdded(type, featureName, key, period); + bool alreadyAdded = addToAllPeriods ? [&] { for (int p = 0; p < kPeriodCount; ++p) if (!IsAlreadyAdded(type, featureName, key, static_cast(p))) return false; return true; }() : IsAlreadyAdded(type, featureName, key, period); if (alreadyAdded) { auto _ = Util::DisableGuard(true); From b7cbd9c0f7718b0d605c1373fb51329819371545 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:30:07 -0700 Subject: [PATCH 23/36] row instead of column --- docs/development/scene-settings-manager.md | 79 +++++++++------------- src/WeatherEditor/TimeOfDayPanel.cpp | 48 ++++++------- 2 files changed, 56 insertions(+), 71 deletions(-) diff --git a/docs/development/scene-settings-manager.md b/docs/development/scene-settings-manager.md index c3a346667d..4e9c349f56 100644 --- a/docs/development/scene-settings-manager.md +++ b/docs/development/scene-settings-manager.md @@ -361,69 +361,52 @@ Clicking it opens the **Add Feature Settings** dialog: ### Time of Day Panel (UI) -The default view ("All Periods" unchecked) shows a vertical per-period [+] list: +A row of named add buttons sits below the header, one per period plus an "Add All" shortcut: ``` -┌──────────────────────────────────────────────────────────────────┐ -│ Time of Day Settings (Exterior Only) [Day 12.0h] │ -│ ──────────────────────────────────────────────────── │ -│ [ ] All Periods │ -│ Dawn: [+] │ -│ Sunrise: [+] │ -│ Day: [+] │ -│ Sunset: [+] │ -│ Dusk: [+] │ -│ Night: [+] │ -│ ──────────────────────────────────────────────────── │ -│ │ -│ Overwrite Files [Pause All] [Delete All] │ -│ ┌─────────┬────────┬────────┬────────┬────────┬──────┬───────┐ │ -│ │Setting │ Dawn │Sunrise │ Day │ Sunset │ Dusk │ Night │ │ -│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X] │ │ -│ ├─────────┼────────┼────────┼────────┼────────┼──────┼───────┤ │ -│ │CloudShadows: │ │ -│ │ Opacity │ 0.300 │ -- │ 0.800 │ 0.500 │ -- │ 0.100 │ │ -│ │ │ [●][X] │ │ [●][X] │ [●][X] │ │[●][X] │ │ -│ └─────────┴────────┴────────┴────────┴────────┴──────┴───────┘ │ -│ │ -│ User Settings [Pause All] [Delete All] │ -│ ┌─────────┬────────┬────────┬────────┬────────┬──────┬───────┐ │ -│ │Setting │ Dawn │Sunrise │ Day │ Sunset │ Dusk │ Night │ │ -│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X] │ │ -│ ├─────────┼────────┼────────┼────────┼────────┼──────┼───────┤ │ -│ │Skylighting: │ │ -│ │ MixAmt │ 0.400 │ 0.600 │ 0.800 │ 0.600 │0.400 │ 0.200 │ │ -│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X] │ │ -│ └─────────┴────────┴────────┴────────┴────────┴──────┴───────┘ │ -│ │ -└──────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Time of Day Settings (Exterior Only) [Day 12.0h] │ +│ ────────────────────────────────────────────────────────────────────────── │ +│ [Add Dawn][Add Sunrise][Add Day][Add Sunset][Add Dusk][Add Night][Add All] │ +│ ────────────────────────────────────────────────────────────────────────── │ +│ │ +│ Overwrite Files [Pause All] [Delete All] │ +│ ┌─────────┬────────┬────────┬────────┬────────┬──────┬───────┐ │ +│ │Setting │ Dawn │Sunrise │ Day │ Sunset │ Dusk │ Night │ │ +│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X] │ │ +│ ├─────────┼────────┼────────┼────────┼────────┼──────┼───────┤ │ +│ │CloudShadows: │ │ +│ │ Opacity │ 0.300 │ -- │ 0.800 │ 0.500 │ -- │ 0.100 │ │ +│ │ │ [●][X] │ │ [●][X] │ [●][X] │ │[●][X] │ │ +│ └─────────┴────────┴────────┴────────┴────────┴──────┴───────┘ │ +│ │ +│ User Settings [Pause All] [Delete All] │ +│ ┌─────────┬────────┬────────┬────────┬────────┬──────┬───────┐ │ +│ │Setting │ Dawn │Sunrise │ Day │ Sunset │ Dusk │ Night │ │ +│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X] │ │ +│ ├─────────┼────────┼────────┼────────┼────────┼──────┼───────┤ │ +│ │Skylighting: │ │ +│ │ MixAmt │ 0.400 │ 0.600 │ 0.800 │ 0.600 │0.400 │ 0.200 │ │ +│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X] │ │ +│ └─────────┴────────┴────────┴────────┴────────┴──────┴───────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` -Clicking a **[+]** button opens the **Add Feature Settings** dialog (see above). **Only float settings appear** in the TOD dialog — integers, booleans, and strings cannot be smoothly transitioned between periods and are excluded. Overwrite files containing non-float TOD settings are also rejected at load time with a log warning. +Clicking an **Add** button opens the **Add Feature Settings** dialog (see above). **Only float settings appear** in the TOD dialog — integers, booleans, and strings cannot be smoothly transitioned between periods and are excluded. Overwrite files containing non-float TOD settings are also rejected at load time with a log warning. **Elements:** | Element | Description | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Header** | Shows the current period and game hour (e.g., `[Day 12.0h]`). When "All Periods" is checked, a right-aligned [+] button appears on this line. | -| **All Periods checkbox** | When checked, the per-period [+] list is hidden and a single right-aligned [+] is shown on the header. When unchecked, per-period [+] buttons are listed vertically. | -| **Per-period [+] list** | One [+] button per period (Dawn through Night), each opening a dialog scoped to that specific period. Lets you add a setting to just one or two periods. | +| **Header** | Shows the current period and game hour (e.g., `[Day 12.0h]`). | +| **Add buttons** | An inline row of small buttons — one per period ("Add Dawn" through "Add Night") plus "Add All". Each opens a dialog scoped to that period; "Add All" populates all 6 periods at once. | | **Header controls** | Each period column header includes a toggle [●] (pause/unpause all entries in that period) and [X] (delete all entries in that period) below the period name. | | **Period columns** | One column per period. The active period column is highlighted; inactive periods are dimmed. `--` means no override for that period (falls back to baseline). | | **Row-level controls** | Each setting row has a toggle (pause all periods) and delete (remove all periods) button in the Setting column. | | **Per-cell controls** | Each individual period cell has its own value editor, pause toggle, and delete button. | | **Setting filter** | The add dialog only shows float settings. Integers, booleans, and strings are excluded since they cannot be smoothly interpolated between periods. Overwrite files are also validated — non-float TOD entries are rejected at load. | -**"All Periods" mode**: When checked, the per-period [+] list is replaced by a single [+] button right-aligned on the header line (matching the Interior Only layout). Adding settings through that dialog populates all 6 periods at once with the current value: - -``` - Time of Day Settings (Exterior Only) [Day 12.0h] [+] - ────────────────────────────────────────────────────────── - [✓] All Periods -``` - -When unchecked, each [+] button opens the Add Feature Settings dialog scoped to that specific period, letting you add a setting to just one or two periods (e.g., only Dawn and Night) without filling all six. - --- ### Feature Settings Page (Scene Toggle) diff --git a/src/WeatherEditor/TimeOfDayPanel.cpp b/src/WeatherEditor/TimeOfDayPanel.cpp index 7eab584d2c..664ff995ad 100644 --- a/src/WeatherEditor/TimeOfDayPanel.cpp +++ b/src/WeatherEditor/TimeOfDayPanel.cpp @@ -24,7 +24,13 @@ namespace TimeOfDayPanel // Per-period add-setting state static SceneSettingsUI::AddSettingState periodAddState[kPeriodCount]; static SceneSettingsUI::AddSettingState allPeriodsAddState; - static bool addToAllPeriods = false; + + /// Reset and open the add-setting dialog for a given state. + static void OpenAddDialog(SceneSettingsUI::AddSettingState& state) + { + state.Reset(); + state.dialogOpen = true; + } // Shared popups static SceneSettingsUI::PopupState popups{ @@ -357,30 +363,26 @@ namespace TimeOfDayPanel SceneSettingsManager::GetPeriodName(currentPeriod), SceneSettingsManager::GetCurrentGameHour()); - if (addToAllPeriods) { - SceneSettingsUI::RightAlignNextButton(); - SceneSettingsUI::DrawAddSettingButton(kSceneType, allPeriodsAddState, - Period::Count, nullptr, true); - SceneSettingsUI::DrawAddSettingDialog(kSceneType, allPeriodsAddState, - Period::Count, true); - } - ImGui::Separator(); - // Add buttons: one for all periods, or per-period - ImGui::Checkbox("All Periods", &addToAllPeriods); - - if (!addToAllPeriods) { - for (int i = 0; i < kPeriodCount; ++i) { - auto periodLabel = std::format("{}:", SceneSettingsManager::GetPeriodName(static_cast(i))); - ImGui::PushID(i); - SceneSettingsUI::DrawAddSettingButton(kSceneType, periodAddState[i], - static_cast(i), periodLabel.c_str()); - SceneSettingsUI::DrawAddSettingDialog(kSceneType, periodAddState[i], - static_cast(i)); - ImGui::PopID(); - } + // Add buttons: inline row of named buttons matching section header style + for (int i = 0; i < kPeriodCount; ++i) { + if (i > 0) + ImGui::SameLine(); + ImGui::PushID(i); + auto label = std::format("Add {}", SceneSettingsManager::kPeriodNames[i]); + if (ImGui::SmallButton(label.c_str())) + OpenAddDialog(periodAddState[i]); + ImGui::PopID(); } + ImGui::SameLine(); + if (ImGui::SmallButton("Add All")) + OpenAddDialog(allPeriodsAddState); + + // Draw all add-setting dialogs (no-op when not open) + for (int i = 0; i < kPeriodCount; ++i) + SceneSettingsUI::DrawAddSettingDialog(kSceneType, periodAddState[i], static_cast(i)); + SceneSettingsUI::DrawAddSettingDialog(kSceneType, allPeriodsAddState, Period::Count, true); ImGui::Separator(); @@ -392,7 +394,7 @@ namespace TimeOfDayPanel ImGui::TextColored(theme.StatusPalette.Disable, "No time-of-day settings configured."); ImGui::TextColored(theme.StatusPalette.Disable, - "Use the + button above to add overrides for each period."); + "Use the Add buttons above to add overrides for each period."); return; } From 5bab24a0b694ef3b72f8881cf25b56b56e99514d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:33:21 +0000 Subject: [PATCH 24/36] =?UTF-8?q?style:=20=F0=9F=8E=A8=20apply=20pre-commi?= =?UTF-8?q?t.ci=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated formatting by clang-format, prettier, and other hooks. See https://pre-commit.ci for details. --- docs/development/scene-settings-manager.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/development/scene-settings-manager.md b/docs/development/scene-settings-manager.md index 4e9c349f56..1c76a29df3 100644 --- a/docs/development/scene-settings-manager.md +++ b/docs/development/scene-settings-manager.md @@ -397,15 +397,15 @@ Clicking an **Add** button opens the **Add Feature Settings** dialog (see above) **Elements:** -| Element | Description | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Header** | Shows the current period and game hour (e.g., `[Day 12.0h]`). | -| **Add buttons** | An inline row of small buttons — one per period ("Add Dawn" through "Add Night") plus "Add All". Each opens a dialog scoped to that period; "Add All" populates all 6 periods at once. | -| **Header controls** | Each period column header includes a toggle [●] (pause/unpause all entries in that period) and [X] (delete all entries in that period) below the period name. | -| **Period columns** | One column per period. The active period column is highlighted; inactive periods are dimmed. `--` means no override for that period (falls back to baseline). | -| **Row-level controls** | Each setting row has a toggle (pause all periods) and delete (remove all periods) button in the Setting column. | -| **Per-cell controls** | Each individual period cell has its own value editor, pause toggle, and delete button. | -| **Setting filter** | The add dialog only shows float settings. Integers, booleans, and strings are excluded since they cannot be smoothly interpolated between periods. Overwrite files are also validated — non-float TOD entries are rejected at load. | +| Element | Description | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Header** | Shows the current period and game hour (e.g., `[Day 12.0h]`). | +| **Add buttons** | An inline row of small buttons — one per period ("Add Dawn" through "Add Night") plus "Add All". Each opens a dialog scoped to that period; "Add All" populates all 6 periods at once. | +| **Header controls** | Each period column header includes a toggle [●] (pause/unpause all entries in that period) and [X] (delete all entries in that period) below the period name. | +| **Period columns** | One column per period. The active period column is highlighted; inactive periods are dimmed. `--` means no override for that period (falls back to baseline). | +| **Row-level controls** | Each setting row has a toggle (pause all periods) and delete (remove all periods) button in the Setting column. | +| **Per-cell controls** | Each individual period cell has its own value editor, pause toggle, and delete button. | +| **Setting filter** | The add dialog only shows float settings. Integers, booleans, and strings are excluded since they cannot be smoothly interpolated between periods. Overwrite files are also validated — non-float TOD entries are rejected at load. | --- From 56680638db005b2733b24b5d43ad1312dd178509 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Sun, 1 Mar 2026 19:25:49 -0700 Subject: [PATCH 25/36] grouping loop --- src/SceneSettingsManager.cpp | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 9dbe08e927..fb13420137 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -793,13 +793,35 @@ void SceneSettingsManager::ApplyTimeOfDayBlended() bestIdx = i; auto dominant = static_cast(bestIdx); - // Group active entries by feature, using pointers to avoid JSON copies - std::map>> featureSettings; + // Group active entries by feature, using pointers to avoid JSON copies. + struct PeriodSlot + { + const json* value = nullptr; + EntrySource source = EntrySource::User; + }; + // featureShortName -> settingKey -> periodIdx -> resolved slot + std::map>> collapsedSettings; for (const auto& entry : GetEntries(SceneType::TimeOfDay)) { if (!IsEntryActive(entry) || entry.period == TimeOfDayPeriod::Count) continue; - featureSettings[entry.featureShortName][entry.settingKey].push_back( - { static_cast(entry.period), &entry.value }); + int pIdx = static_cast(entry.period); + auto& slot = collapsedSettings[entry.featureShortName][entry.settingKey][pIdx]; + // First write always wins; Overwrite always supersedes User. + if (!slot.value || (entry.source == EntrySource::Overwrite && slot.source != EntrySource::Overwrite)) { + slot.value = &entry.value; + slot.source = entry.source; + } + } + + // Build the final PeriodRef vectors from the collapsed map + std::map>> featureSettings; + for (auto& [shortName, keyMap] : collapsedSettings) { + for (auto& [key, periodMap] : keyMap) { + auto& refs = featureSettings[shortName][key]; + refs.reserve(periodMap.size()); + for (auto& [pIdx, slot] : periodMap) + refs.push_back({ pIdx, slot.value }); + } } for (auto& [shortName, settingsMap] : featureSettings) { From 1d0e13fee96394278fcd54e37b929d65658309e4 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Sun, 1 Mar 2026 19:36:57 -0700 Subject: [PATCH 26/36] logger fix --- src/SceneSettingsManager.cpp | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index fb13420137..5833300648 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -300,10 +300,18 @@ bool SceneSettingsManager::HasDuplicateEntry(SceneType type, const std::string& void SceneSettingsManager::AddSetting(SceneType type, const std::string& featureShortName, const std::string& settingKey, const json& value, TimeOfDayPeriod period) { - // TOD only supports float settings (smooth interpolation) - if (type == SceneType::TimeOfDay && DetectSettingType(value) != SettingType::Float) { - logger::warn("[SceneSettings] Rejecting non-float TOD setting: {}.{}", featureShortName, settingKey); - return; + if (type == SceneType::TimeOfDay) { + // Reject invalid period values (Count is the sentinel, not a real period) + if (period == TimeOfDayPeriod::Count || static_cast(period) < 0 || static_cast(period) >= kPeriodCount) { + logger::warn("[SceneSettings] Rejecting TOD setting with invalid period: {}.{}", featureShortName, settingKey); + return; + } + + // TOD only supports float settings (smooth interpolation) + if (DetectSettingType(value) != SettingType::Float) { + logger::warn("[SceneSettings] Rejecting non-float TOD setting: {}.{}", featureShortName, settingKey); + return; + } } if (HasDuplicateEntry(type, featureShortName, settingKey, EntrySource::User, period)) @@ -1022,6 +1030,13 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) entry.featureShortName, entry.settingKey, item["period"].get()); continue; // Invalid period name } + + // TOD only supports float settings — reject non-numeric values (mirrors AddSetting) + if (!entry.value.is_number()) { + logger::warn("SceneSettingsManager: TimeOfDay entry for feature '{}' key '{}' has non-numeric value (type: {}) — skipping", + entry.featureShortName, entry.settingKey, entry.value.type_name()); + continue; + } } if (!Feature::FindFeatureByShortName(entry.featureShortName)) From 6667bd74bd67336c8563ad6c800e2389ff1b47e0 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Sun, 1 Mar 2026 19:43:24 -0700 Subject: [PATCH 27/36] AI hell --- src/SceneSettingsManager.cpp | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 5833300648..a3e4188235 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -912,9 +912,8 @@ float SceneSettingsManager::BlendFloatForPeriods(float baseVal, const std::vecto float f = factors[pr.periodIdx]; if (f > 0.0f) { if (!pr.value->is_number()) { - logger::warn("SceneSettingsManager: TOD period value for '{}' key '{}' is not numeric — treating as 0", shortName, key); - coveredFactor += f; - continue; + logger::warn("SceneSettingsManager: TOD period value for '{}' key '{}' is not numeric — falling back to baseline for this period", shortName, key); + continue; // Don't add to coveredFactor — baseline fills in via (1 - coveredFactor) * baseVal } float periodVal = pr.value->get(); if (!std::isfinite(periodVal)) @@ -1031,9 +1030,9 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) continue; // Invalid period name } - // TOD only supports float settings — reject non-numeric values (mirrors AddSetting) - if (!entry.value.is_number()) { - logger::warn("SceneSettingsManager: TimeOfDay entry for feature '{}' key '{}' has non-numeric value (type: {}) — skipping", + // TOD only supports float settings — use DetectSettingType to match AddSetting/DiscoverOverwrites + if (DetectSettingType(entry.value) != SettingType::Float) { + logger::warn("SceneSettingsManager: TimeOfDay entry for feature '{}' key '{}' has non-float value (type: {}) — skipping", entry.featureShortName, entry.settingKey, entry.value.type_name()); continue; } From af9d40f9dcc710d52bf70d6aeb1be7960870630e Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Sun, 1 Mar 2026 19:52:38 -0700 Subject: [PATCH 28/36] AI comments --- src/SceneSettingsManager.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index a3e4188235..27218b9228 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -445,7 +445,18 @@ void SceneSettingsManager::UpdateEntryValue(SceneType type, size_t index, const if (index >= vec.size()) return; - vec[index].value = newValue; + // Enforce float-only invariant for TimeOfDay entries + if (type == SceneType::TimeOfDay) { + if (!newValue.is_number()) { + logger::warn("[SceneSettings] UpdateEntryValue: rejecting non-numeric TOD value for {}.{}", + vec[index].featureShortName, vec[index].settingKey); + return; + } + // Normalize to float so DetectSettingType sees Float, not Integer + vec[index].value = newValue.get(); + } else { + vec[index].value = newValue; + } if (!deferSave && vec[index].source == EntrySource::User) SaveUserSettings(type); @@ -1004,6 +1015,12 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) if (!item.contains("feature") || !item.contains("setting") || !item.contains("value")) continue; + // Validate field types before extracting — get() throws on wrong type + if (!item["feature"].is_string() || !item["setting"].is_string()) { + logger::warn("SceneSettingsManager: Skipping {} entry with non-string feature/setting field", typeName); + continue; + } + SettingEntry entry; entry.featureShortName = item["feature"].get(); entry.settingKey = item["setting"].get(); From e4c65c5a7efbbca55d3d9eb0e7ccf83a1668c5b7 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:00:53 -0700 Subject: [PATCH 29/36] AI comment --- src/SceneSettingsManager.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 27218b9228..5e42a03ec8 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -1025,7 +1025,7 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) entry.featureShortName = item["feature"].get(); entry.settingKey = item["setting"].get(); entry.value = item["value"]; - entry.paused = item.value("paused", false); + entry.paused = (item.contains("paused") && item["paused"].is_boolean()) ? item["paused"].get() : false; entry.source = EntrySource::User; // Parse period for TimeOfDay entries From f7b036276ecf16cdabeff59114aa19fabe698bc5 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:09:25 -0700 Subject: [PATCH 30/36] AI comments --- src/SceneSettingsManager.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 5e42a03ec8..6e4a5091db 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -342,10 +342,15 @@ void SceneSettingsManager::RemoveSetting(SceneType type, size_t index) auto basePath = GetOverwritesPath(type); auto filepath = (type == SceneType::TimeOfDay && entry.period != TimeOfDayPeriod::Count) ? basePath / GetPeriodName(entry.period) / entry.sourceFilename : basePath / entry.sourceFilename; std::error_code ec; - if (std::filesystem::remove(filepath, ec)) + bool removed = std::filesystem::remove(filepath, ec); + if (removed) { logger::info("[SceneSettings] Deleted overwrite file: {}", filepath.string()); - else - logger::error("[SceneSettings] Failed to delete overwrite file: {} ({})", filepath.string(), ec.message()); + } else if (ec && ec.value() != 0) { + // Real I/O error — keep in-memory entry so the overwrite stays active + logger::error("[SceneSettings] Failed to delete overwrite file: {} ({}) — keeping entry", filepath.string(), ec.message()); + return; + } + // ec.value()==0 && !removed means file didn't exist — safe to drop entry } logger::info("[SceneSettings] Removed {} entry: {}.{} (source={})", GetSceneTypeName(type), @@ -1011,6 +1016,7 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) return; auto& vec = GetEntriesMut(type); + int loadedCount = 0; for (const auto& item : data) { if (!item.contains("feature") || !item.contains("setting") || !item.contains("value")) continue; @@ -1061,9 +1067,10 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) if (HasDuplicateEntry(type, entry.featureShortName, entry.settingKey, EntrySource::User, entry.period)) continue; vec.push_back(std::move(entry)); + loadedCount++; } - logger::info("[SceneSettings] Loaded {} {} user settings", data.size(), typeName); + logger::info("[SceneSettings] Loaded {} {} user settings", loadedCount, typeName); } catch (const std::exception& e) { logger::error("[SceneSettings] Failed to load {} settings: {}", typeName, e.what()); } From 8415d2d7b51653195151c6e8ec6e6bc9dbcd56d4 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:19:47 -0700 Subject: [PATCH 31/36] AI comment --- src/SceneSettingsManager.cpp | 37 ++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 6e4a5091db..f2dc6afb88 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -397,21 +397,38 @@ bool SceneSettingsManager::AreAllOverwritesPaused(SceneType type) const void SceneSettingsManager::DeleteAllOverwrites(SceneType type) { auto overwritesPath = GetOverwritesPath(type); - std::error_code ec; auto& vec = GetEntriesMut(type); - for (const auto& entry : vec) { - if (entry.source == EntrySource::Overwrite && !entry.sourceFilename.empty()) { - // TOD overwrites live in per-period subfolders; use the same path - // construction as SaveOverwritesToDisk to ensure we hit the right file. - auto filepath = (type == SceneType::TimeOfDay && entry.period != TimeOfDayPeriod::Count) ? overwritesPath / GetPeriodName(entry.period) / entry.sourceFilename : overwritesPath / entry.sourceFilename; - std::filesystem::remove(filepath, ec); + + // Track which overwrite entries had their files successfully removed (or already absent). + // Entries whose disk delete fails are kept in memory so they stay visible for retry. + std::vector shouldErase(vec.size(), false); + for (size_t i = 0; i < vec.size(); ++i) { + const auto& entry = vec[i]; + if (entry.source != EntrySource::Overwrite) + continue; + if (entry.sourceFilename.empty()) { + // No backing file — safe to drop + shouldErase[i] = true; + continue; + } + auto filepath = (type == SceneType::TimeOfDay && entry.period != TimeOfDayPeriod::Count) ? overwritesPath / GetPeriodName(entry.period) / entry.sourceFilename : overwritesPath / entry.sourceFilename; + std::error_code ec; + bool removed = std::filesystem::remove(filepath, ec); + if (removed || (!ec || ec.value() == 0)) { + // File deleted or already absent — mark for in-memory removal + shouldErase[i] = true; + } else { + logger::error("[SceneSettings] Failed to delete overwrite file: {} ({}) — keeping entry", filepath.string(), ec.message()); } } - std::erase_if(vec, [](const SettingEntry& e) { - return e.source == EntrySource::Overwrite; - }); + // Erase only entries whose backing files were successfully cleaned up + // (iterate in reverse to preserve index validity) + for (size_t i = vec.size(); i-- > 0;) { + if (shouldErase[i]) + vec.erase(vec.begin() + static_cast(i)); + } allOverwritesPausedMap[type] = false; ReapplyIfActive(); From 3c3f27123f82a4f8b1c3ec8a6540df15605ecda1 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:28:49 -0700 Subject: [PATCH 32/36] AI comment --- src/SceneSettingsManager.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index f2dc6afb88..1fd7c90d52 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -475,7 +475,13 @@ void SceneSettingsManager::UpdateEntryValue(SceneType type, size_t index, const return; } // Normalize to float so DetectSettingType sees Float, not Integer - vec[index].value = newValue.get(); + float floatVal = newValue.get(); + if (!std::isfinite(floatVal)) { + logger::warn("[SceneSettings] UpdateEntryValue: rejecting non-finite TOD value ({}) for {}.{}", + floatVal, vec[index].featureShortName, vec[index].settingKey); + return; + } + vec[index].value = floatVal; } else { vec[index].value = newValue; } From a75e466ff77a23e32fbd0606b933a895498527bb Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:36:48 -0700 Subject: [PATCH 33/36] AI comment --- src/SceneSettingsManager.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 1fd7c90d52..38bcf54e8e 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -312,6 +312,12 @@ void SceneSettingsManager::AddSetting(SceneType type, const std::string& feature logger::warn("[SceneSettings] Rejecting non-float TOD setting: {}.{}", featureShortName, settingKey); return; } + + // Reject non-finite values (NaN/Inf) to prevent unstable blending + if (!std::isfinite(value.get())) { + logger::warn("[SceneSettings] Rejecting non-finite TOD value for {}.{}", featureShortName, settingKey); + return; + } } if (HasDuplicateEntry(type, featureShortName, settingKey, EntrySource::User, period)) From 79cc17bb092cfa1a90bb378608c6aa32c7b72e36 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:46:13 -0700 Subject: [PATCH 34/36] AI comment --- src/SceneSettingsManager.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 38bcf54e8e..c113bed196 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -1060,6 +1060,10 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) entry.featureShortName = item["feature"].get(); entry.settingKey = item["setting"].get(); entry.value = item["value"]; + if (item.contains("paused") && !item["paused"].is_boolean()) { + logger::warn("SceneSettingsManager: '{}' entry {}.{} has non-boolean 'paused' (type: {}) — defaulting to false", + typeName, entry.featureShortName, entry.settingKey, item["paused"].type_name()); + } entry.paused = (item.contains("paused") && item["paused"].is_boolean()) ? item["paused"].get() : false; entry.source = EntrySource::User; From a3c160dfc6f24ce30e535b0896ee8bb014953515 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:49:14 -0700 Subject: [PATCH 35/36] AI comment --- src/SceneSettingsManager.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index c113bed196..37bb1d5c1d 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -56,11 +56,17 @@ float SceneSettingsManager::GetCurrentGameHour() // Prefer calendar (ground truth), which the Weather Editor slider writes to. // sky->currentGameHour may lag when timeScale is 0 (time paused). auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + float hour = 12.0f; if (calendar && calendar->gameHour) - return std::clamp(calendar->gameHour->value, 0.0f, 24.0f); - - auto sky = globals::game::sky; - return sky ? std::clamp(sky->currentGameHour, 0.0f, 24.0f) : 12.0f; + hour = calendar->gameHour->value; + else if (auto sky = globals::game::sky) + hour = sky->currentGameHour; + + // Normalize into [0, 24) so midnight is 0 and never 24. + hour = std::clamp(hour, 0.0f, 24.0f); + if (hour >= 24.0f) + hour = 0.0f; + return hour; } void SceneSettingsManager::GetTimeOfDayFactors(float outFactors[kPeriodCount]) From 63304338c5a0cb49c006cdc259e42b7c0237b44b Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:58:06 -0700 Subject: [PATCH 36/36] AI comment --- src/SceneSettingsManager.cpp | 26 ++++++++++++++++++++++---- src/SceneSettingsManager.h | 1 + 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 37bb1d5c1d..127c1d843e 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -265,6 +265,15 @@ bool SceneSettingsManager::IsEntryActive(const SettingEntry& entry) const return !entry.paused && !IsFeaturePaused(entry.featureShortName); } +bool SceneSettingsManager::HasActiveEntries(SceneType type) const +{ + for (const auto& entry : GetEntries(type)) { + if (IsEntryActive(entry)) + return true; + } + return false; +} + bool SceneSettingsManager::HasEntryFromSource(SceneType type, const std::string& featureShortName, const std::string& settingKey, EntrySource source) const { for (const auto& entry : GetEntries(type)) { @@ -599,10 +608,10 @@ void SceneSettingsManager::ReapplyIfActive() if (auto sky = globals::game::sky) isExterior = sky->mode.get() == RE::Sky::Mode::kFull; - bool hasEntries = !GetEntries(SceneType::TimeOfDay).empty(); + bool hasActiveEntries = HasActiveEntries(SceneType::TimeOfDay); if (isTimeOfDayActive) { - if (hasEntries) { + if (hasActiveEntries) { // Re-blend with updated entries RevertTimeOfDayBaseline(); SaveTimeOfDayBaseline(); @@ -611,7 +620,7 @@ void SceneSettingsManager::ReapplyIfActive() // All entries removed — deactivate DeactivateTimeOfDay(); } - } else if (isExterior && hasEntries && !isCurrentlyApplied) { + } else if (isExterior && hasActiveEntries && !isCurrentlyApplied) { // User added first TOD entry while already in an exterior — activate now ActivateTimeOfDay(); } @@ -777,7 +786,7 @@ void SceneSettingsManager::ApplySettingToFeature(const SettingEntry& entry) void SceneSettingsManager::ActivateTimeOfDay() { - if (isTimeOfDayActive || GetEntries(SceneType::TimeOfDay).empty()) + if (isTimeOfDayActive || !HasActiveEntries(SceneType::TimeOfDay)) return; // TOD and InteriorOnly are mutually exclusive — don't activate TOD while // interior overrides are applied, as they write to the same feature values. @@ -1098,6 +1107,11 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) entry.featureShortName, entry.settingKey, entry.value.type_name()); continue; } + if (!std::isfinite(entry.value.get())) { + logger::warn("SceneSettingsManager: TimeOfDay entry for feature '{}' key '{}' has non-finite value — skipping", + entry.featureShortName, entry.settingKey); + continue; + } } if (!Feature::FindFeatureByShortName(entry.featureShortName)) @@ -1210,6 +1224,10 @@ void SceneSettingsManager::DiscoverOverwritesInDir(SceneType type, const std::fi logger::warn("[SceneSettings] Skipping overwrite '{}': non-float setting '{}' not allowed in Time of Day", filename, settingKey); continue; } + if (type == SceneType::TimeOfDay && !std::isfinite(settingValue.get())) { + logger::warn("[SceneSettings] Skipping overwrite '{}': non-finite value for setting '{}'", filename, settingKey); + continue; + } // Duplicate check if (HasDuplicateEntry(type, featureShortName, settingKey, EntrySource::Overwrite, period)) diff --git a/src/SceneSettingsManager.h b/src/SceneSettingsManager.h index 6971f3c185..78d6813c2c 100644 --- a/src/SceneSettingsManager.h +++ b/src/SceneSettingsManager.h @@ -269,6 +269,7 @@ class SceneSettingsManager // --- Helpers --- std::vector& GetEntriesMut(SceneType type); bool IsEntryActive(const SettingEntry& entry) const; + bool HasActiveEntries(SceneType type) const; bool HasDuplicateEntry(SceneType type, const std::string& featureShortName, const std::string& settingKey, EntrySource source, TimeOfDayPeriod period = TimeOfDayPeriod::Count) const;