diff --git a/package/Interface/CommunityShaders/Icons/Action Icons/free-camera.png b/package/Interface/CommunityShaders/Icons/Action Icons/free-camera.png new file mode 100644 index 0000000000..0de1b88ab7 Binary files /dev/null and b/package/Interface/CommunityShaders/Icons/Action Icons/free-camera.png differ diff --git a/package/Interface/CommunityShaders/Icons/Action Icons/pause.png b/package/Interface/CommunityShaders/Icons/Action Icons/pause.png index 4763dab681..36ddaa041d 100644 Binary files a/package/Interface/CommunityShaders/Icons/Action Icons/pause.png and b/package/Interface/CommunityShaders/Icons/Action Icons/pause.png differ diff --git a/package/Interface/CommunityShaders/Icons/Action Icons/play-mode.png b/package/Interface/CommunityShaders/Icons/Action Icons/play-mode.png new file mode 100644 index 0000000000..13fc672e1a Binary files /dev/null and b/package/Interface/CommunityShaders/Icons/Action Icons/play-mode.png differ diff --git a/package/Interface/CommunityShaders/Icons/Action Icons/undo.png b/package/Interface/CommunityShaders/Icons/Action Icons/undo.png index 78043ca422..893b346e74 100644 Binary files a/package/Interface/CommunityShaders/Icons/Action Icons/undo.png and b/package/Interface/CommunityShaders/Icons/Action Icons/undo.png differ diff --git a/src/Hooks.cpp b/src/Hooks.cpp index 156d754d66..4f0218cf41 100644 --- a/src/Hooks.cpp +++ b/src/Hooks.cpp @@ -391,6 +391,11 @@ struct BSInputDeviceManager_PollInputDevices } if (blockedDevice && menu->ShouldSwallowInput()) { //the menu is open, eat all keypresses + // During active flying preview, let input reach the game for movement/camera + if (menu->IsPreviewFlying()) { + func(a_dispatcher, a_events); + return; + } constexpr RE::InputEvent* const dummy[] = { nullptr }; func(a_dispatcher, dummy); return; diff --git a/src/Menu.cpp b/src/Menu.cpp index b85abe8571..515cb13e1c 100644 --- a/src/Menu.cpp +++ b/src/Menu.cpp @@ -225,6 +225,9 @@ Menu::~Menu() uiIcons.debug.Release(); uiIcons.materials.Release(); uiIcons.postProcessing.Release(); + uiIcons.freeCamera.Release(); + uiIcons.playMode.Release(); + uiIcons.search.Release(); // Clean up blur resources BackgroundBlur::Cleanup(); @@ -949,9 +952,15 @@ void Menu::ProcessInputEventQueue() } if (event.device == RE::INPUT_DEVICE::kMouse) { logger::trace("Detect mouse scan code {} value {} pressed: {}", event.keyCode, event.value, event.IsPressed()); + auto* ew = EditorWindow::GetSingleton(); + bool flying = ew && ew->IsPreviewFlying(); if (event.keyCode > 7) { // middle scroll - io.AddMouseWheelEvent(0, event.value * (event.keyCode == 8 ? 1 : -1)); - } else { + if (ew && ew->previewMode == EditorWindow::PreviewMode::FreeCamera) { + ew->AdjustFlySpeed(event.keyCode == 8 ? 1.0f : -1.0f); + } else if (!flying) { + io.AddMouseWheelEvent(0, event.value * (event.keyCode == 8 ? 1 : -1)); + } + } else if (!flying) { if (event.keyCode > 5) event.keyCode = 5; io.AddMouseButtonEvent(event.keyCode, event.IsPressed()); @@ -1043,7 +1052,22 @@ void Menu::ProcessInputEventQueue() { settings.ShaderBlockPrevKey, [this, shaderCache]() { if (settings.EnableShaderBlocking) shaderCache->IterateShaderBlock(); } }, { settings.ShaderBlockNextKey, [this, shaderCache]() { if (settings.EnableShaderBlocking) shaderCache->IterateShaderBlock(false); } }, { settings.OverlayToggleKey, []() { Menu::GetSingleton()->overlayVisible = !Menu::GetSingleton()->overlayVisible; } }, - { settings.WeatherEditorToggleKey, []() { auto p = RE::PlayerCharacter::GetSingleton(); if (p && p->parentCell) EditorWindow::GetSingleton()->open = !EditorWindow::GetSingleton()->open; } }, + { settings.WeatherEditorToggleKey, []() { + auto* ew = EditorWindow::GetSingleton(); + if (!ew) + return; + if (ew->GetPreviewMode() == EditorWindow::PreviewMode::FreeCamera) { + // Flying → lock camera position for editing + ew->ToggleFreeCameraLock(); + } else if (ew->IsInPreviewMode()) { + // Locked or PlayMode → fully exit preview + ew->ExitPreviewMode(); + } else { + auto p = RE::PlayerCharacter::GetSingleton(); + if (p && p->parentCell) + ew->open = !ew->open; + } + } }, }; for (const auto& ka : keyActions) { // Check if key matches last key in combo and all modifiers are held (exact match) @@ -1083,7 +1107,9 @@ void Menu::ProcessInputEventQueue() // Handle ESC key for menu and editor window auto* editorWindow = EditorWindow::GetSingleton(); if (key == VK_ESCAPE) { - if (editorWindow && editorWindow->open && editorWindow->ShouldHandleEscapeKey()) { + if (editorWindow && editorWindow->IsInPreviewMode()) { + editorWindow->ExitPreviewMode(); + } else if (editorWindow && editorWindow->open && editorWindow->ShouldHandleEscapeKey()) { editorWindow->open = false; } else if (IsEnabled && (!editorWindow || !editorWindow->open)) { IsEnabled = false; @@ -1154,6 +1180,12 @@ bool Menu::ShouldSwallowInput() return IsEnabled || HomePageRenderer::ShouldShowFirstTimeSetup() || (editorWindow && editorWindow->open); } +bool Menu::IsPreviewFlying() +{ + auto editorWindow = EditorWindow::GetSingleton(); + return editorWindow && editorWindow->IsPreviewFlying(); +} + void Menu::SelectFeatureMenu(const std::string& featureName) { pendingFeatureSelection = featureName; diff --git a/src/Menu.h b/src/Menu.h index 55b3916ff1..3ef3d6d927 100644 --- a/src/Menu.h +++ b/src/Menu.h @@ -129,6 +129,7 @@ class Menu void ProcessInputEvents(RE::InputEvent* const* a_events); bool ShouldSwallowInput(); + bool IsPreviewFlying(); std::string BuildFontSignature(float baseFontSize) const; public: @@ -200,6 +201,8 @@ class Menu UIIcon applyToGame; // Apply changes to game icon (weather editor) UIIcon pauseTime; // Pause time icon (weather editor) UIIcon undo; // Undo icon (weather editor) + UIIcon freeCamera; // Free camera preview icon (weather editor) + UIIcon playMode; // Play mode preview icon (weather editor) // Social media/external link icons UIIcon discord; diff --git a/src/Menu/IconLoader.cpp b/src/Menu/IconLoader.cpp index 951100f49f..823d497df2 100644 --- a/src/Menu/IconLoader.cpp +++ b/src/Menu/IconLoader.cpp @@ -105,6 +105,8 @@ namespace Util::IconLoader { std::string(iconFolder) + "\\apply-to-game.png", &menu->uiIcons.applyToGame.texture, &menu->uiIcons.applyToGame.size }, { std::string(iconFolder) + "\\pause.png", &menu->uiIcons.pauseTime.texture, &menu->uiIcons.pauseTime.size }, { std::string(iconFolder) + "\\undo.png", &menu->uiIcons.undo.texture, &menu->uiIcons.undo.size }, + { std::string(iconFolder) + "\\free-camera.png", &menu->uiIcons.freeCamera.texture, &menu->uiIcons.freeCamera.size }, + { std::string(iconFolder) + "\\play-mode.png", &menu->uiIcons.playMode.texture, &menu->uiIcons.playMode.size }, { "Categories\\characters.png", &menu->uiIcons.characters.texture, &menu->uiIcons.characters.size }, { "Categories\\display.png", &menu->uiIcons.display.texture, &menu->uiIcons.display.size }, @@ -194,20 +196,18 @@ namespace Util::IconLoader auto iconDefs = GetIconDefinitions(menu); - for (auto* texturePtr : { &menu->uiIcons.saveSettings.texture, &menu->uiIcons.loadSettings.texture, - &menu->uiIcons.clearCache.texture, &menu->uiIcons.deleteSettings.texture, &menu->uiIcons.logo.texture, - &menu->uiIcons.featureSettingRevert.texture, &menu->uiIcons.applyToGame.texture, &menu->uiIcons.pauseTime.texture, - &menu->uiIcons.undo.texture, &menu->uiIcons.search.texture, &menu->uiIcons.discord.texture, - &menu->uiIcons.characters.texture, &menu->uiIcons.display.texture, - &menu->uiIcons.grass.texture, &menu->uiIcons.lighting.texture, - &menu->uiIcons.sky.texture, &menu->uiIcons.landscape.texture, - &menu->uiIcons.water.texture, &menu->uiIcons.debug.texture, - &menu->uiIcons.materials.texture, &menu->uiIcons.postProcessing.texture }) { - if (*texturePtr) { - (*texturePtr)->Release(); - *texturePtr = nullptr; + // Release all existing textures using the same definitions list (avoids stale hardcoded list) + for (const auto& iconDef : iconDefs) { + if (*iconDef.texture) { + (*iconDef.texture)->Release(); + *iconDef.texture = nullptr; } } + // Also release search icon (not in iconDefs) + if (menu->uiIcons.search.texture) { + menu->uiIcons.search.texture->Release(); + menu->uiIcons.search.texture = nullptr; + } bool anyIconLoaded = false; int iconsLoaded = 0; @@ -219,24 +219,17 @@ namespace Util::IconLoader anyIconLoaded = true; } else { // If monochrome icon failed to load, try fallback to colored version - if (basePath.find("Monochrome") != std::string::npos) { - std::string fallbackPath = basePath; + if (fullPath.find("Monochrome") != std::string::npos) { + std::string fallbackPath = fullPath; size_t pos = fallbackPath.find("\\Monochrome"); if (pos != std::string::npos) { fallbackPath.erase(pos, 11); // Remove "\Monochrome" } - fallbackPath += iconDef.filename; - // Try to extract just the filename from iconDef.filename if it has path - size_t lastSlash = iconDef.filename.find_last_of("\\/"); - if (lastSlash != std::string::npos) { - std::string justFilename = iconDef.filename.substr(lastSlash + 1); - fallbackPath = fallbackPath.substr(0, fallbackPath.find_last_of("\\/") + 1) + justFilename; - } if (LoadTextureFromFile(device, fallbackPath.c_str(), iconDef.texture, *iconDef.size)) { iconsLoaded++; anyIconLoaded = true; } else { - logger::warn("InitializeMenuIcons: Failed to load icon from: {} (and fallback)", fullPath); + logger::warn("InitializeMenuIcons: Failed to load icon from: {} (and fallback: {})", fullPath, fallbackPath); } } else { logger::warn("InitializeMenuIcons: Failed to load icon from: {}", fullPath); diff --git a/src/Menu/OverlayRenderer.cpp b/src/Menu/OverlayRenderer.cpp index 503ec06569..d959ed4564 100644 --- a/src/Menu/OverlayRenderer.cpp +++ b/src/Menu/OverlayRenderer.cpp @@ -58,9 +58,15 @@ void OverlayRenderer::RenderOverlay( auto player = RE::PlayerCharacter::GetSingleton(); if (editorWindow->open && !(player && player->parentCell)) { editorWindow->open = false; + if (editorWindow->IsInPreviewMode()) + editorWindow->ExitPreviewMode(); } if (editorWindow->open) { - ImGui::GetIO().MouseDrawCursor = true; + bool flying = editorWindow->IsPreviewFlying(); + auto& io = ImGui::GetIO(); + io.MouseDrawCursor = !flying; + if (flying) + io.MousePos = { -FLT_MAX, -FLT_MAX }; // prevent hover/tooltips during active flying editorWindow->Draw(); } else if (menu.IsEnabled || HomePageRenderer::ShouldShowFirstTimeSetup()) { ImGui::GetIO().MouseDrawCursor = true; diff --git a/src/Utils/UI.cpp b/src/Utils/UI.cpp index b2135c299e..39bf3e4495 100644 --- a/src/Utils/UI.cpp +++ b/src/Utils/UI.cpp @@ -520,6 +520,20 @@ namespace Util return StyledButtonWrapper(color, hover, active); } + StyledButtonWrapper TransparentIconButtonStyle() + { + constexpr float kHoverAlpha = 0.25f; + auto hoverColor = Menu::GetSingleton()->GetTheme().Palette.Text; + hoverColor.w = kHoverAlpha; + return StyledButtonWrapper(ImVec4(0, 0, 0, 0), hoverColor, hoverColor); + } + + ImVec4 GetIconTint() + { + const auto& theme = Menu::GetSingleton()->GetTheme(); + return theme.UseMonochromeIcons ? theme.Palette.Text : ImVec4(1, 1, 1, 1); + } + // SectionWrapper implementation SectionWrapper::SectionWrapper(const char* title, const char* description, const ImVec4& titleColor, bool isVisible) : m_shouldDraw(isVisible), diff --git a/src/Utils/UI.h b/src/Utils/UI.h index 254161db42..8ccd4927c5 100644 --- a/src/Utils/UI.h +++ b/src/Utils/UI.h @@ -220,6 +220,14 @@ namespace Util */ StyledButtonWrapper ErrorButtonStyle(); + /** + * Creates a transparent button with theme text color hover. Caller must push/pop FrameBorderSize=0 separately. + */ + StyledButtonWrapper TransparentIconButtonStyle(); + + /** Returns theme text color if monochrome icons enabled, otherwise white. */ + ImVec4 GetIconTint(); + /** * Button with simple flash feedback (matches action icon hover effect style) * @param label Button text diff --git a/src/WeatherEditor/EditorWindow.cpp b/src/WeatherEditor/EditorWindow.cpp index 642a6aad0d..b719034cae 100644 --- a/src/WeatherEditor/EditorWindow.cpp +++ b/src/WeatherEditor/EditorWindow.cpp @@ -1090,140 +1090,213 @@ void EditorWindow::RenderUI() ImGui::EndMenu(); } - // Pause Time button + // Clip buttons above the bottom border so highlights don't overlap it + const auto clipMin = ImGui::GetWindowDrawList()->GetClipRectMin(); + const auto clipMax = ImGui::GetWindowDrawList()->GetClipRectMax(); + ImGui::PushClipRect(clipMin, ImVec2(clipMax.x, clipMax.y - ImGui::GetStyle().WindowBorderSize), true); + auto menu = globals::menu; - if (menu && menu->uiIcons.pauseTime.texture) { - bool isPaused = IsTimePaused(); + constexpr float kIconButtonPadding = 1.0f; // minimal padding so icons render larger and smoother + const float iconButtonDim = ImGui::GetFrameHeight() - kIconButtonPadding * 2; + const ImVec2 iconButtonSize(iconButtonDim, iconButtonDim); + const auto iconTint = Util::GetIconTint(); + // Undo button (stays on left side) + if (menu && menu->uiIcons.undo.texture) { + bool canUndo = CanUndo(); 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::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(kIconButtonPadding, kIconButtonPadding)); + { + auto _style = Util::TransparentIconButtonStyle(); + auto textColor = canUndo ? menu->GetTheme().Palette.Text : menu->GetTheme().StatusPalette.Disable; + if (!canUndo) + textColor.w = 0.5f; + ImGui::PushStyleColor(ImGuiCol_Text, textColor); + if (ImGui::ImageButton("##GlobalUndo", menu->uiIcons.undo.texture, iconButtonSize, ImVec2(0, 0), ImVec2(1, 1), ImVec4(0, 0, 0, 0), iconTint) && canUndo) + PerformUndo(); + ImGui::PopStyleColor(); } - - 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(); - + ImGui::PopStyleVar(2); if (ImGui::IsItemHovered()) - ImGui::SetTooltip(isPaused ? "Resume Time" : "Pause Time"); + ImGui::SetTooltip(canUndo ? "Undo (Ctrl+Z) - %d states" : "Undo (Ctrl+Z) - No changes to undo", (int)undoStack.size()); } - // Undo button - if (menu && menu->uiIcons.undo.texture) { - bool canUndo = CanUndo(); + // Right-aligned items — use SetCursorScreenPos to bypass menu bar GroupOffset + const float scale = Util::GetUIScale(); + const float clipRight = ImGui::GetWindowDrawList()->GetClipRectMax().x; + const float cursorY = ImGui::GetCursorScreenPos().y; + const float closeButtonSize = ImGui::GetFrameHeight(); + const float& itemSpacing = ImGui::GetStyle().ItemSpacing.x; + const float sliderWidth = kMenuBarSliderWidth * scale; + + // Measure right-side elements to compute positions right-to-left + float rightCursor = clipRight; + + // X button + rightCursor -= closeButtonSize; + const float xButtonX = rightCursor; + + // Time slider + rightCursor -= itemSpacing + sliderWidth; + const float sliderX = rightCursor; + + // Period text + char periodBuf[64]; + std::snprintf(periodBuf, sizeof(periodBuf), "(%s)", TOD::GetPeriodName(TOD::GetActivePeriod())); + float periodWidth = ImGui::CalcTextSize(periodBuf).x; + rightCursor -= itemSpacing + periodWidth; + const float periodX = rightCursor; - 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); - } + // Pause Time button + float pauseButtonX = 0; + bool hasPauseButton = menu && menu->uiIcons.pauseTime.texture; + if (hasPauseButton) { + rightCursor -= itemSpacing + iconButtonDim + kIconButtonPadding * 2; + pauseButtonX = rightCursor; + } - const float menuBarHeight = ImGui::GetFrameHeight(); - const float buttonDim = menuBarHeight * 0.85f; - const ImVec2 buttonSize(buttonDim, buttonDim); + // Preview mode buttons (free camera / play mode) + const float previewButtonWidth = iconButtonDim + kIconButtonPadding * 2; + float freeCameraX = 0, playModeX = 0; + bool hasFreeCam = menu && menu->uiIcons.freeCamera.texture; + bool hasPlayMode = menu && menu->uiIcons.playMode.texture; + if (hasPlayMode) { + rightCursor -= itemSpacing + previewButtonWidth; + playModeX = rightCursor; + } + if (hasFreeCam) { + rightCursor -= itemSpacing + previewButtonWidth; + freeCameraX = rightCursor; + } - if (ImGui::ImageButton("##GlobalUndo", menu->uiIcons.undo.texture, buttonSize) && canUndo) { - PerformUndo(); - } + // Preview mode status text (mirrors TIME PAUSED pattern, with hotkey + pulsating color) + float previewStatusX = 0; + char previewStatusBuf[128] = {}; + bool showPreviewStatus = previewMode != PreviewMode::None; + if (showPreviewStatus) { + std::string hotkey = Util::Input::KeyIdToString(menu->GetSettings().WeatherEditorToggleKey); + if (previewMode == PreviewMode::FreeCamera) + std::snprintf(previewStatusBuf, sizeof(previewStatusBuf), " [ %s ] FREE CAMERA (Speed: %.0f)", hotkey.c_str(), flySpeed); + else if (previewMode == PreviewMode::FreeCameraLocked) + std::snprintf(previewStatusBuf, sizeof(previewStatusBuf), " [ %s ] FREE CAMERA LOCKED", hotkey.c_str()); + else + std::snprintf(previewStatusBuf, sizeof(previewStatusBuf), " [ %s ] PLAY MODE", hotkey.c_str()); + rightCursor -= itemSpacing + ImGui::CalcTextSize(previewStatusBuf).x; + previewStatusX = rightCursor; + } - ImGui::PopStyleColor(3); - ImGui::PopStyleVar(); + // Time paused text + float timePausedX = 0; + bool showTimePaused = IsTimePaused(); + const char* timePausedText = " [TIME PAUSED]"; + if (showTimePaused) { + rightCursor -= itemSpacing + ImGui::CalcTextSize(timePausedText).x; + timePausedX = rightCursor; + } - 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); + // Weather lock text + float weatherLockX = 0; + char weatherLockBuf[128] = {}; + bool showWeatherLock = weatherLockActive && lockedWeather; + if (showWeatherLock) { const char* weatherName = lockedWeather->GetFormEditorID(); - ImGui::Text(" [LOCKED: %s]", weatherName ? weatherName : "Unknown"); + std::snprintf(weatherLockBuf, sizeof(weatherLockBuf), " [LOCKED: %s]", weatherName ? weatherName : "Unknown"); + rightCursor -= itemSpacing + ImGui::CalcTextSize(weatherLockBuf).x; + weatherLockX = rightCursor; + } + + // Render right-aligned items left to right + const auto& statusPalette = menu->GetTheme().StatusPalette; + + if (showWeatherLock) { + ImGui::SetCursorScreenPos(ImVec2(weatherLockX, cursorY)); + ImGui::PushStyleColor(ImGuiCol_Text, statusPalette.SuccessColor); + ImGui::TextUnformatted(weatherLockBuf); ImGui::PopStyleColor(); } - // Time pause indicator - if (IsTimePaused()) { - ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_Text, Menu::GetSingleton()->GetSettings().Theme.StatusPalette.CurrentHotkey); - ImGui::Text(" [TIME PAUSED]"); + if (showTimePaused) { + ImGui::SetCursorScreenPos(ImVec2(timePausedX, cursorY)); + ImGui::PushStyleColor(ImGuiCol_Text, statusPalette.CurrentHotkey); + ImGui::TextUnformatted(timePausedText); ImGui::PopStyleColor(); } - // Time slider and close button - float menuBarHeight = ImGui::GetFrameHeight(); - float closeButtonSize = menuBarHeight * 0.9f; - const float scale = Util::GetUIScale(); - const float closeButtonMargin = 10.0f * scale; + if (showPreviewStatus) { + ImGui::SetCursorScreenPos(ImVec2(previewStatusX, cursorY)); + ImGui::TextColored(Util::GetPulsingColor(statusPalette.CurrentHotkey), "%s", previewStatusBuf); + } - // Time slider anchored to the right - { - const float& itemSpacing = ImGui::GetStyle().ItemSpacing.x; - char periodBuf[64]; - std::snprintf(periodBuf, sizeof(periodBuf), "(%s)", TOD::GetPeriodName(TOD::GetActivePeriod())); - float periodWidth = ImGui::CalcTextSize(periodBuf).x; - const float sliderStartX = ImGui::GetWindowWidth() - closeButtonSize - closeButtonMargin - itemSpacing - kMenuBarSliderWidth * scale; - auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); - if (calendar && calendar->gameHour) { - ImGui::SameLine(sliderStartX - itemSpacing - periodWidth); - ImGui::TextUnformatted(periodBuf); - ImGui::SameLine(sliderStartX); - ImGui::SetNextItemWidth(kMenuBarSliderWidth * scale); - DrawGameHourSlider("##MenuBarSlider", "Time: %.2f"); + // Toggle-style icon button helper (active: SuccessColor bg, inactive: transparent) + auto DrawToggleIconButton = [&](const char* id, ImTextureID texture, bool isActive, float posX) -> bool { + ImGui::SetCursorScreenPos(ImVec2(posX, cursorY)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(kIconButtonPadding, kIconButtonPadding)); + if (isActive) { + auto color = statusPalette.SuccessColor; + color.w = kToggleActiveAlpha; + auto hover = color; + hover.w = kToggleHoverAlpha; + ImGui::PushStyleColor(ImGuiCol_Button, color); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hover); + } else { + auto hover = menu->GetTheme().Palette.Text; + hover.w = kInactiveHoverAlpha; + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hover); } + bool clicked = ImGui::ImageButton(id, texture, iconButtonSize, ImVec2(0, 0), ImVec2(1, 1), ImVec4(0, 0, 0, 0), iconTint); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(2); + return clicked; + }; + + // Preview mode buttons + if (hasFreeCam) { + bool isActive = previewMode == PreviewMode::FreeCamera || previewMode == PreviewMode::FreeCameraLocked; + if (DrawToggleIconButton("##FreeCamera", menu->uiIcons.freeCamera.texture, isActive, freeCameraX)) + EnterPreviewMode(PreviewMode::FreeCamera); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(isActive ? "Exit Free Camera" : "Free Camera (scroll to adjust speed)"); + } + if (hasPlayMode) { + bool isActive = previewMode == PreviewMode::PlayMode; + if (DrawToggleIconButton("##PlayMode", menu->uiIcons.playMode.texture, isActive, playModeX)) + EnterPreviewMode(PreviewMode::PlayMode); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(isActive ? "Exit Play Mode" : "Play Mode - Walk around normally"); + } + + if (hasPauseButton) { + bool isPaused = IsTimePaused(); + if (DrawToggleIconButton("##GlobalPauseTime", menu->uiIcons.pauseTime.texture, isPaused, pauseButtonX)) + TogglePause(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(isPaused ? "Resume Time" : "Pause Time"); + } + + // Period text and time slider + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (calendar && calendar->gameHour) { + ImGui::SetCursorScreenPos(ImVec2(periodX, cursorY)); + ImGui::TextUnformatted(periodBuf); + ImGui::SetCursorScreenPos(ImVec2(sliderX, cursorY)); + ImGui::SetNextItemWidth(sliderWidth); + DrawGameHourSlider("##MenuBarSlider", "Time: %.2f"); } - ImGui::SameLine(ImGui::GetWindowWidth() - closeButtonSize - closeButtonMargin); - 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; + // Close button + ImGui::SetCursorScreenPos(ImVec2(xButtonX, cursorY)); + { + auto _style = Util::ErrorButtonStyle(); + if (ImGui::Button("X", ImVec2(closeButtonSize, closeButtonSize))) { + open = false; + } } - ImGui::PopStyleColor(3); if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Close Weather Editor (Esc)"); } + ImGui::PopClipRect(); ImGui::EndMainMenuBar(); } @@ -1868,6 +1941,78 @@ void EditorWindow::RestoreVanityCamera() } } +void EditorWindow::EnterPreviewMode(PreviewMode mode) +{ + if (mode == PreviewMode::None) + return; + + // Already in free camera flying — ignore duplicate click + if (mode == previewMode) + return; + + // Re-enter flying from locked state via button click + if (mode == PreviewMode::FreeCamera && previewMode == PreviewMode::FreeCameraLocked) { + previewMode = PreviewMode::FreeCamera; + logger::info("Free camera unlocked (re-entered flying)"); + return; + } + + // Switch from a different active mode first + if (previewMode != PreviewMode::None) + ExitPreviewMode(); + + previewMode = mode; + savedMousePos = ImGui::GetIO().MousePos; + + if (mode == PreviewMode::FreeCamera) { + flySpeed = kDefaultFlySpeed; + RE::Console::ExecuteCommand("tfc"); + RE::Console::ExecuteCommand(std::format("sucsm {:.0f}", flySpeed).c_str()); + } + + logger::info("Entered preview mode: {}", mode == PreviewMode::FreeCamera ? "FreeCamera" : "PlayMode"); +} + +void EditorWindow::ExitPreviewMode() +{ + bool wasFlying = IsPreviewFlying(); + + if (previewMode == PreviewMode::FreeCamera || previewMode == PreviewMode::FreeCameraLocked) + RE::Console::ExecuteCommand("tfc"); + + logger::info("Exited preview mode"); + previewMode = PreviewMode::None; + + // Only restore cursor if exiting from a flying state; FreeCameraLocked already has the cursor active + if (wasFlying) { + ImGui::GetIO().MousePos = savedMousePos; + ImGui::GetIO().WantSetMousePos = true; + } +} + +void EditorWindow::ToggleFreeCameraLock() +{ + if (previewMode == PreviewMode::FreeCamera) { + previewMode = PreviewMode::FreeCameraLocked; + ImGui::GetIO().MousePos = savedMousePos; + ImGui::GetIO().WantSetMousePos = true; + logger::info("Free camera locked"); + } else if (previewMode == PreviewMode::FreeCameraLocked) { + savedMousePos = ImGui::GetIO().MousePos; + previewMode = PreviewMode::FreeCamera; + logger::info("Free camera unlocked"); + } +} + +void EditorWindow::AdjustFlySpeed(float scrollDelta) +{ + if (previewMode != PreviewMode::FreeCamera) + return; + + flySpeed = std::clamp(flySpeed + scrollDelta * kFlySpeedScrollStep, kMinFlySpeed, kMaxFlySpeed); + RE::Console::ExecuteCommand(std::format("sucsm {:.0f}", flySpeed).c_str()); +} + bool EditorWindow::ShouldHandleEscapeKey() const { return !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel); diff --git a/src/WeatherEditor/EditorWindow.h b/src/WeatherEditor/EditorWindow.h index 2ad7505865..251507ac69 100644 --- a/src/WeatherEditor/EditorWindow.h +++ b/src/WeatherEditor/EditorWindow.h @@ -24,7 +24,17 @@ class EditorWindow return &singleton; } + // Preview modes for exploring the scene without the full editor UI + enum class PreviewMode + { + None, // Full editor UI visible + FreeCamera, // Flying free camera (tfc), input to game + FreeCameraLocked, // Camera locked in place, editor interactive + PlayMode // Normal gameplay, no scroll interception + }; + bool open = false; + PreviewMode previewMode = PreviewMode::None; const static int maxRecordMarkers = 10; // Owned by EditorWindow, created in Draw(), released in destructor @@ -61,6 +71,27 @@ class EditorWindow static constexpr float kTimeScaleMax = 4000.0f; static constexpr float kMenuBarSliderWidth = 400.0f; + // Preview mode constants + static constexpr float kDefaultFlySpeed = 10.0f; + static constexpr float kMinFlySpeed = 1.0f; + static constexpr float kMaxFlySpeed = 100.0f; + static constexpr float kFlySpeedScrollStep = 2.0f; + static constexpr float kToggleActiveAlpha = 0.6f; + static constexpr float kToggleHoverAlpha = 0.8f; + static constexpr float kInactiveHoverAlpha = 0.25f; + + // Preview mode state + float flySpeed = kDefaultFlySpeed; + ImVec2 savedMousePos = { -FLT_MAX, -FLT_MAX }; + + void EnterPreviewMode(PreviewMode mode); + void ExitPreviewMode(); + bool IsInPreviewMode() const { return previewMode != PreviewMode::None; } + bool IsPreviewFlying() const { return previewMode == PreviewMode::FreeCamera || previewMode == PreviewMode::PlayMode; } + PreviewMode GetPreviewMode() const { return previewMode; } + void ToggleFreeCameraLock(); + void AdjustFlySpeed(float scrollDelta); + // Vanity camera control bool vanityCameraDisabled = false; float savedVanityCameraDelay = 180.0f;