// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin // SPDX-License-Identifier: CC-BY-NC-ND-4.0 #include "fullscreenui.h" #include "achievements_private.h" #include "controller.h" #include "core.h" #include "fullscreenui_private.h" #include "fullscreenui_widgets.h" #include "game_list.h" #include "host.h" #include "sound_effect_manager.h" #include "system.h" #include "video_thread.h" #include "scmversion/scmversion.h" #include "util/cd_image.h" #include "util/gpu_device.h" #include "util/imgui_manager.h" #include "util/shadergen.h" #include "common/assert.h" #include "common/error.h" #include "common/file_system.h" #include "common/log.h" #include "common/path.h" #include "common/timer.h" #include "IconsEmoji.h" #include "IconsPromptFont.h" LOG_CHANNEL(FullscreenUI); #ifndef __ANDROID__ namespace FullscreenUI { enum class PauseSubMenu : u8 { None, Exit, Achievements, }; ////////////////////////////////////////////////////////////////////////// // Main ////////////////////////////////////////////////////////////////////////// static void DrawLandingTemplate(ImVec2* menu_pos, ImVec2* menu_size); static void DrawLandingWindow(); static void DrawStartGameWindow(); static void DrawExitWindow(); static void DrawPauseMenu(); static void DrawAboutWindow(); static void FixStateIfPaused(); static void UnpauseForMenuClose(); ////////////////////////////////////////////////////////////////////////// // Backgrounds ////////////////////////////////////////////////////////////////////////// static bool HasBackground(); static bool LoadBackgroundShader(const std::string& path, Error* error); static bool LoadBackgroundImage(const std::string& path, Error* error); static void DrawBackground(); static void DrawShaderBackgroundCallback(const ImDrawList* parent_list, const ImDrawCmd* cmd, u32 base_vertex, u32 base_index); ////////////////////////////////////////////////////////////////////////// // Resources ////////////////////////////////////////////////////////////////////////// static GPUTexture* GetUserThemeableTexture( const std::string_view png_name, const std::string_view svg_name, bool* is_colorable = nullptr, const ImVec2& svg_size = LayoutScale(LAYOUT_HORIZONTAL_MENU_ITEM_IMAGE_SIZE, LAYOUT_HORIZONTAL_MENU_ITEM_IMAGE_SIZE)); static bool UserThemeableHorizontalButton(const std::string_view png_name, const std::string_view svg_name, std::string_view title, std::string_view description); static void UpdateCurrentTimeString(); ////////////////////////////////////////////////////////////////////////// // Landing ////////////////////////////////////////////////////////////////////////// static void DoStartFile(); static void DoStartBIOS(); static void DoStartDisc(); static void DoToggleFastForward(); static void ConfirmWithSafetyCheck(std::string action, bool check_achievements, std::function callback); static void RequestShutdown(bool save_state); static void RequestRestart(); static void BeginChangeDiscOnCoreThread(bool return_to_game); static void StartChangeDiscFromFile(bool return_to_game); static void DoRequestExit(); static void DoDesktopMode(); static void DoToggleFullscreen(); static void DoToggleAnalogMode(); ////////////////////////////////////////////////////////////////////////// // Save State List ////////////////////////////////////////////////////////////////////////// struct SaveStateListEntry { std::string title; std::string summary; std::string game_path; std::string state_path; std::unique_ptr preview_texture; time_t timestamp; s32 slot; bool global; }; static void InitializePlaceholderSaveStateListEntry(SaveStateListEntry* li, s32 slot, bool global); static bool InitializeSaveStateListEntryFromSerial(SaveStateListEntry* li, const std::string& serial, s32 slot, bool global); static bool InitializeSaveStateListEntryFromPath(SaveStateListEntry* li, std::string path, s32 slot, bool global, Error* error, bool* exists); static void ClearSaveStateEntryList(); static u32 PopulateSaveStateListEntries(const std::string& serial, std::optional undo_save_state, bool is_loading); static void DrawSaveStateSelector(); static void DrawResumeStateSelector(); ////////////////////////////////////////////////////////////////////////// // Constants ////////////////////////////////////////////////////////////////////////// static constexpr std::string_view RESUME_STATE_SELECTOR_DIALOG_NAME = "##resume_state_selector"; static constexpr std::string_view ABOUT_DIALOG_NAME = "##about_duckstation"; const char* SFX_NAV_ACTIVATE = "sounds/nav_activate.wav"; const char* SFX_NAV_BACK = "sounds/nav_back.wav"; const char* SFX_NAV_MOVE = "sounds/nav_move.wav"; ////////////////////////////////////////////////////////////////////////// // State ////////////////////////////////////////////////////////////////////////// namespace { struct Locals { // Main MainWindowType current_main_window = MainWindowType::None; PauseSubMenu current_pause_submenu = PauseSubMenu::None; MainWindowType previous_main_window = MainWindowType::None; MainWindowType toggled_main_window = MainWindowType::None; bool initialized = false; bool background_loaded = false; bool was_paused_on_quick_menu_open = false; bool has_pending_window_switch = false; // Background std::unique_ptr app_background_texture; std::unique_ptr app_background_shader; Timer::Value app_background_load_time = 0; // Pause Menu std::time_t current_time = 0; std::string current_time_string; // Save State List std::vector save_state_selector_slots; bool save_state_selector_loading = true; }; } // namespace ALIGN_TO_CACHE_LINE static Locals s_locals; } // namespace FullscreenUI ////////////////////////////////////////////////////////////////////////// // Main ////////////////////////////////////////////////////////////////////////// void FullscreenUI::Initialize() { // some achievement callbacks fire early while e.g. there is a load state popup blocking system init if (s_locals.initialized || !ImGuiManager::IsInitialized()) return; s_locals.initialized = true; // in case we open the pause menu while the game is running if (s_locals.current_main_window == MainWindowType::None && !VideoThread::HasGPUBackend() && !VideoThread::IsGPUBackendRequested()) { ReturnToMainWindow(); ForceKeyNavEnabled(); } else { UpdateRunIdleState(); } SoundEffectManager::EnsureInitialized(); INFO_LOG("Fullscreen UI initialized."); } bool FullscreenUI::IsInitialized() { return s_locals.initialized; } bool FullscreenUI::HasActiveWindow() { return s_locals.initialized && (s_locals.current_main_window != MainWindowType::None || AreAnyDialogsOpen()); } bool FullscreenUI::HasActiveOrPendingWindow() { return s_locals.initialized && (s_locals.current_main_window != MainWindowType::None || s_locals.has_pending_window_switch || AreAnyDialogsOpen()); } bool FullscreenUI::AreAnyDialogsOpen() { return (IsInputBindingDialogOpen() || AreAnyWidgetsDialogOpen()); } bool FullscreenUI::AreAnyDialogsInteractable() { return (IsInputBindingDialogInteractable() || AreAnyWidgetsDialogInteractable()); } void FullscreenUI::CheckForConfigChanges(const GPUSettings& old_settings) { // NOTE: Called on GPU thread. } void FullscreenUI::UpdateRunIdleState() { VideoThread::SetRunIdleReason(VideoThread::RunIdleReason::FullscreenUIActive, (HasActiveWindow() || GetTransitionState() != TransitionState::Inactive)); } void FullscreenUI::OnSystemStarting() { // NOTE: Called on CPU thread. if (!IsInitialized()) return; VideoThread::RunOnThread([]() { if (!IsInitialized()) return; BeginTransition(LONG_TRANSITION_TIME, []() { ClearSaveStateEntryList(); s_locals.current_main_window = MainWindowType::None; s_locals.toggled_main_window = MainWindowType::None; QueueResetFocus(FocusResetType::ViewChanged); UpdateRunIdleState(); }); }); } void FullscreenUI::OnSystemPaused() { // NOTE: Called on CPU thread. if (!IsInitialized()) return; VideoThread::RunOnThread([]() { if (!IsInitialized()) return; UpdateRunIdleState(); }); } void FullscreenUI::OnSystemResumed() { // NOTE: Called on CPU thread. if (!IsInitialized()) return; VideoThread::RunOnThread([]() { if (!IsInitialized()) return; // get rid of pause menu if we unpaused another way if (s_locals.current_main_window != MainWindowType::None) ClosePauseMenuImmediately(); UpdateRunIdleState(); }); } void FullscreenUI::OnSystemDestroyed() { // NOTE: Called on CPU thread. if (!IsInitialized()) return; VideoThread::RunOnThread([]() { if (!IsInitialized()) return; s_locals.was_paused_on_quick_menu_open = false; s_locals.current_pause_submenu = PauseSubMenu::None; s_locals.toggled_main_window = MainWindowType::None; ReturnToMainWindow(TransitionEffect::Fade, LONG_TRANSITION_TIME); }); } void FullscreenUI::PauseAndOpenMenuFromCoreThread(void (*callback)()) { DebugAssert(Host::IsOnCoreThread()); if (!System::IsValid()) return; // Do the pause early, that way the core thread doesn't push extra frames while the transition is happening. // But, we also don't want extra frames between the pause and the open, because otherwise we'll see the pause icon. const bool was_paused = System::IsPaused(); VideoThread::RunOnThread([callback, was_paused]() { if (s_locals.current_main_window != MainWindowType::None) return; Initialize(); if (!SetPendingMainWindowSwitch()) return; // Explicit open actions supersede a menu which was hidden by PauseAndToggleMenuFromCoreThread(). if (s_locals.toggled_main_window != MainWindowType::None) { s_locals.toggled_main_window = MainWindowType::None; s_locals.previous_main_window = MainWindowType::None; s_locals.current_pause_submenu = PauseSubMenu::None; } s_locals.was_paused_on_quick_menu_open = was_paused; callback(); }); // See comment above. We want the video thread to start transitioning before the pause goes through. if (!was_paused) System::PauseSystem(true); } void FullscreenUI::OpenPauseMenu() { PauseAndOpenMenuFromCoreThread([]() { BeginTransition(TransitionEffect::ZoomIn, SHORT_TRANSITION_TIME, []() { ForceKeyNavEnabled(); EnqueueSoundEffect(SFX_NAV_ACTIVATE); UpdateAchievementsPauseScreenInfo(); s_locals.current_pause_submenu = PauseSubMenu::None; SwitchToMainWindow(MainWindowType::PauseMenu); }); }); } void FullscreenUI::PauseAndToggleMenuFromCoreThread(void (*open_callback)(), void (*restored_callback)(), float transition_time) { DebugAssert(Host::IsOnCoreThread()); if (!System::IsValid()) return; const bool was_paused = System::IsPaused(); VideoThread::RunOnThread([open_callback, restored_callback, transition_time, was_paused]() { Initialize(); // Dialogs cannot be hidden safely, since doing so would leave their callbacks pending while the game is running. // A pending switch means another action already owns the current transition. if (AreAnyDialogsOpen() || !SetPendingMainWindowSwitch()) return; // Hide the current window without disturbing the state it owns. if (s_locals.current_main_window != MainWindowType::None) { s_locals.toggled_main_window = s_locals.current_main_window; BeginTransition(TransitionEffect::ZoomOut, SHORT_TRANSITION_TIME, []() { s_locals.current_main_window = MainWindowType::None; s_locals.has_pending_window_switch = false; UpdateRunIdleState(); FixStateIfPaused(); UnpauseForMenuClose(); }); return; } // Restore the hidden window, or invoke the requested open action when there is no saved window. Start the // transition before requesting the pause so an extra frame does not display the pause icon. s_locals.was_paused_on_quick_menu_open = was_paused; const bool restoring = (s_locals.toggled_main_window != MainWindowType::None); BeginTransition(TransitionEffect::ZoomIn, transition_time, [open_callback, restored_callback, restoring]() { ForceKeyNavEnabled(); EnqueueSoundEffect(SFX_NAV_ACTIVATE); if (restoring) { s_locals.current_main_window = std::exchange(s_locals.toggled_main_window, MainWindowType::None); s_locals.has_pending_window_switch = false; UpdateRunIdleState(); FixStateIfPaused(); if (restored_callback) restored_callback(); } else { open_callback(); } }); if (!was_paused) Host::RunOnCoreThread([]() { System::PauseSystem(true); }); }); } void FullscreenUI::TogglePauseMenu() { PauseAndToggleMenuFromCoreThread( []() { UpdateAchievementsPauseScreenInfo(); s_locals.current_pause_submenu = PauseSubMenu::None; SwitchToMainWindow(MainWindowType::PauseMenu); }, &UpdateAchievementsPauseScreenInfo); } void FullscreenUI::OpenCheatsMenu() { PauseAndOpenMenuFromCoreThread([]() { BeginTransition(TransitionEffect::ZoomIn, SHORT_TRANSITION_TIME, []() { if (!SwitchToGameSettings(SettingsPage::Cheats)) { // We'll end up here when we're in batch mode and using a runtime-populated game list entry. There _might_ be // one frame when we display the main settings instead of the game settings page, but it'll be part of the // transition and not noticeable. That's what you get for not using the game list. SwitchToMainWindow(MainWindowType::Settings); } ForceKeyNavEnabled(); EnqueueSoundEffect(SFX_NAV_ACTIVATE); }); }); } void FullscreenUI::OpenDiscChangeMenu() { if (!System::IsValid()) return; // bit of back and forth here, but needed because we're not coming from the pause menu PauseAndOpenMenuFromCoreThread([]() { Host::RunOnCoreThread([]() { BeginChangeDiscOnCoreThread(true); }); }); } void FullscreenUI::FixStateIfPaused() { if (!VideoThread::HasGPUBackend() || System::IsRunning()) return; // When we're paused, we won't have trickled the key up event for escape yet. Do it now. ImGui::UpdateInputEvents(false); } void FullscreenUI::ClosePauseMenu(TransitionEffect effect /*= TransitionEffect::Fade*/, float transition_time /*= SHORT_TRANSITION_TIME*/) { if (!VideoThread::HasGPUBackend()) return; s_locals.toggled_main_window = MainWindowType::None; BeginTransition(effect, transition_time, []() { s_locals.current_pause_submenu = PauseSubMenu::None; SwitchToMainWindow(MainWindowType::None); }); UnpauseForMenuClose(); } void FullscreenUI::ClosePauseMenuImmediately() { if (!VideoThread::HasGPUBackend()) return; CancelTransition(); UnpauseForMenuClose(); s_locals.toggled_main_window = MainWindowType::None; s_locals.current_pause_submenu = PauseSubMenu::None; s_locals.has_pending_window_switch = false; SwitchToMainWindow(MainWindowType::None); // Present frame with menu closed. We have to defer this for a frame so imgui loses keyboard focus. if (VideoThread::IsSystemPaused()) Host::RunOnCoreThread(&VideoThread::PresentCurrentFrame); } FullscreenUI::MainWindowType FullscreenUI::GetCurrentMainWindow() { return s_locals.current_main_window; } bool FullscreenUI::CanCurrentMainWindowStack() { // windows that are actually stackable return (s_locals.current_main_window < MainWindowType::SaveStateSelector); } bool FullscreenUI::SetPendingMainWindowSwitch() { // Needed because otherwise we could switch two windows within the same frame. if (s_locals.has_pending_window_switch) return false; s_locals.has_pending_window_switch = true; return true; } void FullscreenUI::SwitchToMainWindow(MainWindowType type) { if (!AreAnyDialogsInteractable()) { ImGui::SetWindowFocus(nullptr); QueueResetFocus(FocusResetType::ViewChanged); } if (s_locals.current_main_window == type) return; // avoid a race where if we shutdown with achievements up, we end up back at the pause menu if (!VideoThread::HasGPUBackend() && (type == MainWindowType::PauseMenu || type >= MainWindowType::Achievements)) { WARNING_LOG("Trying to switch to main window requiring system without one"); s_locals.current_main_window = MainWindowType::None; type = MainWindowType::Landing; } // windows that are actually stacked else if (type != MainWindowType::None && !CanCurrentMainWindowStack()) { WARNING_LOG("Trying to stack incompatible window type {} on {}, ignoring", static_cast(type), static_cast(s_locals.current_main_window)); s_locals.previous_main_window = MainWindowType::None; } else { s_locals.previous_main_window = (type == MainWindowType::None) ? MainWindowType::None : s_locals.current_main_window; } s_locals.current_main_window = type; s_locals.has_pending_window_switch = false; UpdateRunIdleState(); FixStateIfPaused(); } void FullscreenUI::ReturnToPreviousWindow(TransitionEffect effect) { if (s_locals.previous_main_window == MainWindowType::None) { // if we're coming from a pause menu, we need to release the pause if (s_locals.current_main_window >= MainWindowType::Settings && s_locals.current_main_window <= MainWindowType::Leaderboards) { ClosePauseMenu(effect); } else { ReturnToMainWindow(effect); } } else { BeginTransition(effect, DEFAULT_TRANSITION_TIME, [window = s_locals.previous_main_window]() { SwitchToMainWindow(window); // return stack is only one deep s_locals.previous_main_window = window; }); } } void FullscreenUI::ReturnToMainWindow(TransitionEffect effect) { const float transition_time = (effect == TransitionEffect::Fade) ? (VideoThread::HasGPUBackend() ? SHORT_TRANSITION_TIME : DEFAULT_TRANSITION_TIME) : DEFAULT_TRANSITION_TIME; ReturnToMainWindow(effect, transition_time); } void FullscreenUI::ReturnToMainWindow(TransitionEffect effect, float transition_time) { BeginTransition(effect, transition_time, []() { s_locals.previous_main_window = MainWindowType::None; s_locals.toggled_main_window = MainWindowType::None; s_locals.current_pause_submenu = PauseSubMenu::None; s_locals.has_pending_window_switch = false; if (VideoThread::HasGPUBackend()) { SwitchToMainWindow(MainWindowType::None); } else { if (ShouldOpenToGameList()) SwitchToGameList(); else SwitchToMainWindow(MainWindowType::Landing); } }); } void FullscreenUI::UnpauseForMenuClose() { if (!VideoThread::IsSystemPaused()) return; s_locals.has_pending_window_switch = false; // Represent the frame if we're not running idle, so that the pause indicator is displayed. if (!s_locals.was_paused_on_quick_menu_open) Host::RunOnCoreThread([]() { System::PauseSystem(false); }); else Host::RunOnCoreThread([]() { VideoThread::PresentCurrentFrame(); }); } void FullscreenUI::Shutdown() { DestroyGPUResources(); SoundEffectManager::Shutdown(); s_locals.current_main_window = MainWindowType::None; s_locals.current_pause_submenu = PauseSubMenu::None; s_locals.previous_main_window = MainWindowType::None; s_locals.toggled_main_window = MainWindowType::None; s_locals.was_paused_on_quick_menu_open = false; s_locals.has_pending_window_switch = false; ClearAchievementsState(); ClearSettingsState(); ClearGameListState(); s_locals.current_time_string = {}; s_locals.current_time = 0; s_locals.initialized = false; UpdateRunIdleState(); } void FullscreenUI::DestroyGPUResources() { // has textures... ClearSaveStateEntryList(); s_locals.app_background_texture.reset(); s_locals.app_background_shader.reset(); s_locals.background_loaded = false; } void FullscreenUI::Render() { if (!s_locals.initialized) return; // draw background before any overlays if (!VideoThread::HasGPUBackend() && s_locals.current_main_window != MainWindowType::None) DrawBackground(); BeginLayout(); switch (s_locals.current_main_window) { case MainWindowType::Landing: DrawLandingWindow(); break; case MainWindowType::StartGame: DrawStartGameWindow(); break; case MainWindowType::Exit: DrawExitWindow(); break; case MainWindowType::GameList: DrawGameListWindow(); break; case MainWindowType::Settings: DrawSettingsWindow(); break; case MainWindowType::PauseMenu: DrawPauseMenu(); break; case MainWindowType::SaveStateSelector: DrawSaveStateSelector(); break; case MainWindowType::Achievements: DrawAchievementsWindow(); break; case MainWindowType::Leaderboards: DrawLeaderboardsWindow(); break; default: break; } if (IsFixedPopupDialogOpen(ABOUT_DIALOG_NAME)) DrawAboutWindow(); else if (IsFixedPopupDialogOpen(RESUME_STATE_SELECTOR_DIALOG_NAME)) DrawResumeStateSelector(); EndLayout(); ResetCloseMenuIfNeeded(); UpdateTransitionState(); } GPUTexture* FullscreenUI::GetUserThemeableTexture(const std::string_view png_name, const std::string_view svg_name, bool* is_colorable, const ImVec2& svg_size) { GPUTexture* tex = FindCachedTexture(png_name); if (tex) { if (is_colorable) *is_colorable = false; return tex; } const u32 svg_width = static_cast(svg_size.x); const u32 svg_height = static_cast(svg_size.y); tex = FindCachedTexture(svg_name, svg_width, svg_height); if (tex) { if (is_colorable) *is_colorable = true; return tex; } // slow path, check filesystem for override if (EmuFolders::Resources != EmuFolders::UserResources && FileSystem::FileExists(Path::Combine(EmuFolders::UserResources, png_name).c_str())) { // use the user's png if (is_colorable) *is_colorable = false; return GetCachedTexture(png_name); } // otherwise use the system/user svg if (is_colorable) *is_colorable = true; if (!svg_name.empty()) return GetCachedTexture(svg_name, svg_width, svg_height); else return GetCachedTexture(png_name); } bool FullscreenUI::UserThemeableHorizontalButton(const std::string_view png_name, const std::string_view svg_name, std::string_view title, std::string_view description) { bool is_colorable; GPUTexture* icon = GetUserThemeableTexture( png_name, svg_name, &is_colorable, LayoutScale(LAYOUT_HORIZONTAL_MENU_ITEM_IMAGE_SIZE, LAYOUT_HORIZONTAL_MENU_ITEM_IMAGE_SIZE)); return HorizontalMenuItem(icon, title, description, is_colorable ? ImGui::GetColorU32(ImGuiCol_Text) : IM_COL32(255, 255, 255, 255)); } void FullscreenUI::UpdateCurrentTimeString() { const std::time_t current_time = std::time(nullptr); if (s_locals.current_time == current_time) return; s_locals.current_time = current_time; s_locals.current_time_string = {}; s_locals.current_time_string = Host::FormatTime(current_time, false); } ////////////////////////////////////////////////////////////////////////// // Utility ////////////////////////////////////////////////////////////////////////// void FullscreenUI::DoStartPath(std::string path, std::string state, std::optional fast_boot) { if (VideoThread::HasGPUBackend()) return; // Stop running idle to prevent game list from being redrawn until we know if startup succeeded. VideoThread::SetRunIdleReason(VideoThread::RunIdleReason::FullscreenUIActive, false); SystemBootParameters params; params.path = std::move(path); params.save_state = std::move(state); params.override_fast_boot = std::move(fast_boot); Host::RunOnCoreThread([params = std::move(params)]() mutable { if (System::IsValid()) return; // This can "fail" if HC mode is enabled and the user cancels, or other startup cancel paths. Error error; if (const System::BootResult result = System::BootSystem(std::move(params), &error); result != System::BootResult::Success) { VideoThread::RunOnThread([error_desc = error.TakeDescription(), result]() { if (!IsInitialized()) return; std::string title = TRANSLATE_STR("System", "Error"); std::string message = fmt::format("{}\n\n{}", TRANSLATE_SV("System", "Failed to boot system:"), error_desc); if (result == System::BootResult::MissingBIOS) { fmt::format_to(std::back_inserter(message), "\n\n{}", TRANSLATE_SV("System", "Do you want to install a BIOS file now?")); OpenConfirmMessageDialog(ICON_EMOJI_NO_ENTRY_SIGN, std::move(title), std::move(message), [](bool result) { if (result) StartInstallBIOS(); }); } else { OpenInfoMessageDialog(ICON_EMOJI_NO_ENTRY_SIGN, std::move(title), std::move(message)); } ClearSaveStateEntryList(); UpdateRunIdleState(); }); } }); } void FullscreenUI::DoResume() { std::string path = System::GetMostRecentResumeSaveStatePath(); if (path.empty()) { ShowToast(OSDMessageType::Info, {}, FSUI_STR("No resume save state found.")); return; } Error error; bool exists; SaveStateListEntry slentry; if (!InitializeSaveStateListEntryFromPath(&slentry, std::move(path), -1, false, &error, &exists)) { ShowToast(OSDMessageType::Error, FSUI_STR("Failed to load resume save state info."), error.TakeDescription()); return; } ClearSaveStateEntryList(); s_locals.save_state_selector_slots.push_back(std::move(slentry)); OpenFixedPopupDialog(RESUME_STATE_SELECTOR_DIALOG_NAME); } void FullscreenUI::DoStartFile() { OpenFileSelector(FSUI_ICONVSTR(ICON_EMOJI_OPTICAL_DISK, "Select Disc Image"), GetDiscImageFilters(), {}, [](const std::string& path) { if (!path.empty()) DoStartPath(path); }); } void FullscreenUI::DoStartBIOS() { DoStartPath(std::string(), std::string(), std::nullopt); } void FullscreenUI::DoStartDisc() { std::vector> devices = CDImage::GetDeviceList(); if (devices.empty()) { ShowToast(OSDMessageType::Info, {}, FSUI_STR("Could not find any CD/DVD-ROM devices. Please ensure you have a drive connected and sufficient " "permissions to access it.")); return; } // if there's only one, select it automatically if (devices.size() == 1) { DoStartPath(std::move(devices.front().first), std::string(), std::nullopt); return; } ChoiceDialogOptions options; std::vector paths; options.reserve(devices.size()); paths.reserve(paths.size()); for (auto& [path, name] : devices) { options.emplace_back(std::move(name), false); paths.push_back(std::move(path)); } OpenChoiceDialog(FSUI_ICONVSTR(ICON_FA_COMPACT_DISC, "Select Disc Drive"), false, std::move(options), [paths = std::move(paths)](s32 index, const std::string&, bool) mutable { if (index < 0) return; DoStartPath(std::move(paths[index]), std::string(), std::nullopt); }); } void FullscreenUI::ConfirmWithSafetyCheck(std::string action, bool check_achievements, std::function callback) { Host::RunOnCoreThread([action = std::move(action), callback = std::move(callback), check_achievements]() mutable { const u32 pending_unlock_count = check_achievements ? Achievements::GetPendingUnlockCount() : 0; const bool was_saving = System::IsSavingMemoryCards(); VideoThread::RunOnThread([action = std::move(action), callback = std::move(callback), pending_unlock_count, was_saving]() mutable { if (!was_saving && pending_unlock_count == 0) { callback(true); return; } if (was_saving) { OpenConfirmMessageDialog( ICON_EMOJI_WARNING, FSUI_ICONVSTR(ICON_PF_MEMORY_CARD, "Memory Card Busy"), fmt::format( FSUI_FSTR("WARNING: Your game is still saving to the memory card. Continuing to {0} may IRREVERSIBLY " "DESTROY YOUR MEMORY CARD. We recommend resuming your game and waiting 5 seconds for it to " "finish saving.\n\nDo you want to {0} anyway?"), action), std::move(callback), fmt::format( fmt::runtime(FSUI_ICONVSTR(ICON_FA_TRIANGLE_EXCLAMATION, "Yes, {} now and risk memory card corruption.")), action), FSUI_ICONSTR(ICON_FA_PLAY, "No, resume the game.")); } else { OpenConfirmMessageDialog( ICON_EMOJI_WARNING, FSUI_ICONVSTR(ICON_FA_TROPHY, "Achievement Unlocks Unconfirmed"), fmt::format(FSUI_FSTR("{0} achievement unlocks have not been confirmed by the server. Continuing to {1} will " "result in loss of these unlocks. Once network connectivity has been re-established, " "these unlocks will be confirmed automatically.\n\nDo you want to {1} anyway?"), pending_unlock_count, action), std::move(callback)); } }); }); } void FullscreenUI::RequestShutdown(bool save_state) { ConfirmWithSafetyCheck(FSUI_STR("shut down"), true, [save_state](bool result) { if (result) { BeginTransition(TransitionEffect::Fade, LONG_TRANSITION_TIME, [save_state]() { Host::RunOnCoreThread([save_state]() { Host::RequestSystemShutdown(false, save_state, false); }); }); } else { ClosePauseMenu(); } }); } void FullscreenUI::RequestRestart() { ConfirmWithSafetyCheck(FSUI_STR("restart"), false, [](bool result) { if (result) { Host::RunOnCoreThread(System::ResetSystem); ClosePauseMenu(TransitionEffect::Fade, LONG_TRANSITION_TIME); } else { ClosePauseMenu(); } }); } void FullscreenUI::DoToggleFastForward() { Host::RunOnCoreThread([]() { if (!System::IsValid()) return; System::SetFastForwardEnabled(!System::IsFastForwardEnabled()); }); } void FullscreenUI::StartChangeDiscFromFile(bool return_to_game) { OpenFileSelector( FSUI_ICONVSTR(ICON_FA_COMPACT_DISC, "Select Disc Image"), GetDiscImageFilters(), std::string(Path::GetDirectory(VideoThread::GetGamePath())), [return_to_game](const std::string& path) { if (path.empty()) { if (return_to_game) UnpauseForMenuClose(); return; } ConfirmWithSafetyCheck(FSUI_STR("change disc"), false, [path, return_to_game](bool result) { if (result) { if (!GameList::IsScannableFilename(path)) { OpenInfoMessageDialog(ICON_EMOJI_NO_ENTRY_SIGN, FSUI_STR("Error"), fmt::format(FSUI_FSTR("{} is not a valid disc image."), Path::GetFileName(path))); if (return_to_game) UnpauseForMenuClose(); } else { Host::RunOnCoreThread([path]() { System::InsertMedia(path.c_str()); }); ClosePauseMenuImmediately(); } } }); }); // This can come from the core thread without the menu, so need to to trigger run idle. UpdateRunIdleState(); } void FullscreenUI::BeginChangeDiscOnCoreThread(bool return_to_game) { ChoiceDialogOptions options; if (System::HasMediaSubImages()) { const u32 current_index = System::GetMediaSubImageIndex(); const u32 count = System::GetMediaSubImageCount(); options.reserve(count + 1); options.emplace_back(FSUI_STR("From File..."), false); for (u32 i = 0; i < count; i++) options.emplace_back(System::GetMediaSubImageTitle(i), i == current_index); VideoThread::RunOnThread([options = std::move(options), return_to_game]() mutable { auto callback = [return_to_game](s32 index, const std::string& title, bool checked) { if (index == 0) { StartChangeDiscFromFile(return_to_game); } else if (index > 0) { ConfirmWithSafetyCheck(FSUI_STR("change disc"), false, [index](bool result) { if (result) { System::SwitchMediaSubImage(static_cast(index - 1)); ClosePauseMenuImmediately(); } }); } else { if (return_to_game) UnpauseForMenuClose(); } }; OpenChoiceDialog(FSUI_ICONVSTR(ICON_FA_COMPACT_DISC, "Select Disc Image"), false, std::move(options), std::move(callback)); }); return; } if (const GameDatabase::Entry* entry = System::GetGameDatabaseEntry(); entry && entry->disc_set) { auto lock = GameList::GetLock(); GameList::EnsureLoaded(lock); auto matches = GameList::GetEntriesInDiscSet(entry->disc_set, GameList::ShouldShowLocalizedTitles()); if (matches.size() > 1) { options.reserve(matches.size() + 1); options.emplace_back(FSUI_STR("From File..."), false); std::vector paths; paths.reserve(matches.size()); const std::string& current_path = System::GetGamePath(); for (auto& [title, glentry] : matches) { options.emplace_back(std::move(title), current_path == glentry->path); paths.push_back(glentry->path); } VideoThread::RunOnThread([options = std::move(options), paths = std::move(paths), return_to_game]() mutable { Initialize(); auto callback = [paths = std::move(paths), return_to_game](s32 index, const std::string& title, bool checked) mutable { if (index == 0) { StartChangeDiscFromFile(return_to_game); } else if (index > 0) { ConfirmWithSafetyCheck(FSUI_STR("change disc"), false, [paths = std::move(paths), index](bool result) { if (result) { Host::RunOnCoreThread([path = std::move(paths[index - 1])]() { System::InsertMedia(path.c_str()); }); ClosePauseMenu(); } }); } else { if (return_to_game) UnpauseForMenuClose(); } }; OpenChoiceDialog(FSUI_ICONVSTR(ICON_FA_COMPACT_DISC, "Select Disc Image"), false, std::move(options), std::move(callback)); }); return; } } VideoThread::RunOnThread([return_to_game]() { StartChangeDiscFromFile(return_to_game); }); } void FullscreenUI::DoToggleAnalogMode() { // hacky way to toggle analog mode Host::RunOnCoreThread([]() { for (u32 i = 0; i < NUM_CONTROLLER_AND_CARD_PORTS; i++) { Controller* const ctrl = System::GetController(i); if (!ctrl) continue; for (const Controller::ControllerBindingInfo& bi : Controller::GetControllerInfo(ctrl->GetType()).bindings) { if (std::strcmp(bi.name, "Analog") == 0) { ctrl->SetBindState(bi.bind_index, 1.0f); ctrl->SetBindState(bi.bind_index, 0.0f); break; } } } }); } void FullscreenUI::DoRequestExit() { Host::RunOnCoreThread([]() { Host::RequestExitApplication(true); }); } void FullscreenUI::DoDesktopMode() { Host::RunOnCoreThread([]() { Host::RequestExitBigPicture(); }); } void FullscreenUI::DoToggleFullscreen() { Host::RunOnCoreThread([]() { VideoThread::SetFullscreen(!VideoThread::IsFullscreen()); }); } ////////////////////////////////////////////////////////////////////////// // Landing Window ////////////////////////////////////////////////////////////////////////// bool FullscreenUI::HasBackground() { return static_cast(s_locals.app_background_texture || s_locals.app_background_shader); } void FullscreenUI::UpdateBackground() { if (!IsInitialized()) return; g_gpu_device->RecycleTexture(std::move(s_locals.app_background_texture)); s_locals.app_background_shader.reset(); s_locals.background_loaded = true; const TinyString background_name = Core::GetBaseTinyStringSettingValue("Main", "FullscreenUIBackground", DEFAULT_BACKGROUND_NAME); if (background_name.empty() || background_name == "None") return; static constexpr std::pair loaders[] = { {"glsl", &FullscreenUI::LoadBackgroundShader}, {"jpg", &FullscreenUI::LoadBackgroundImage}, {"png", &FullscreenUI::LoadBackgroundImage}, {"webp", &FullscreenUI::LoadBackgroundImage}, }; for (const auto& [extension, loader] : loaders) { static constexpr auto get_path = [](const std::string& dir, const TinyString& name, const char* extension) { return fmt::format("{}" FS_OSPATH_SEPARATOR_STR "fullscreenui" FS_OSPATH_SEPARATOR_STR "backgrounds" FS_OSPATH_SEPARATOR_STR "{}.{}", dir, name, extension); }; // try user directory first std::string path = get_path(EmuFolders::UserResources, background_name, extension); if (!FileSystem::FileExists(path.c_str())) { path = get_path(EmuFolders::Resources, background_name, extension); if (!FileSystem::FileExists(path.c_str())) continue; } Error error; if (!loader(path, &error)) { ERROR_LOG("Failed to load background '{}' with {} loader: {}", background_name, extension, error.GetDescription()); return; } INFO_LOG("Loaded background '{}' with {} loader", background_name, extension); return; } ERROR_LOG("No loader or file found for background '{}'", background_name); } FullscreenUI::ChoiceDialogOptions FullscreenUI::GetBackgroundOptions(const TinyString& current_value) { static constexpr const char* dir = FS_OSPATH_SEPARATOR_STR "fullscreenui" FS_OSPATH_SEPARATOR_STR "backgrounds"; ChoiceDialogOptions options; options.emplace_back(FSUI_STR("None"), (current_value == NONE_BACKGROUND_NAME)); FileSystem::FindResultsArray results; FileSystem::FindFiles(Path::Combine(EmuFolders::UserResources, dir).c_str(), "*", FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_RELATIVE_PATHS, &results); FileSystem::FindFiles(Path::Combine(EmuFolders::Resources, dir).c_str(), "*", FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_RELATIVE_PATHS | FILESYSTEM_FIND_KEEP_ARRAY, &results); for (const auto& it : results) { const std::string_view name = Path::GetFileTitle(it.FileName); if (std::any_of(options.begin(), options.end(), [&name](const auto& it) { return it.first == name; })) continue; options.emplace_back(name, current_value == name); } return options; } bool FullscreenUI::LoadBackgroundShader(const std::string& path, Error* error) { std::optional shader_body = FileSystem::ReadFileToString(path.c_str(), error); if (!shader_body.has_value()) return false; const std::string::size_type main_pos = shader_body->find("void main()"); if (main_pos == std::string::npos) { Error::SetStringView(error, "main() definition not found in shader."); return false; } const ShaderGen shadergen(g_gpu_device->GetRenderAPI(), GPUShaderLanguage::GLSLVK, false, false); std::stringstream shader; shadergen.WriteHeader(shader); shadergen.DeclareVertexEntryPoint(shader, {}, 0, 1, {}, true); shader << R"( { v_tex0 = vec2(float((v_id << 1) & 2u), float(v_id & 2u)); v_pos = vec4(v_tex0 * vec2(2.0f, -2.0f) + vec2(-1.0f, 1.0f), 0.0f, 1.0f); #if API_VULKAN v_pos.y = -v_pos.y; #endif })"; std::unique_ptr vs = g_gpu_device->CreateShader(GPUShaderStage::Vertex, GPUShaderLanguage::GLSLVK, shader.str(), error); if (!vs) return false; shader.str(std::string()); shadergen.WriteHeader(shader, false, false, false); shadergen.DeclareUniformBuffer(shader, {"vec2 u_display_size", "vec2 u_rcp_display_size", "float u_time"}, true); if (main_pos > 0) shader << std::string_view(shader_body.value()).substr(0, main_pos); shadergen.DeclareFragmentEntryPoint(shader, 0, 1); shader << std::string_view(shader_body.value()).substr(main_pos + 11); std::unique_ptr fs = g_gpu_device->CreateShader(GPUShaderStage::Fragment, GPUShaderLanguage::GLSLVK, shader.str(), error); if (!fs) return false; GPUPipeline::GraphicsConfig plconfig = {}; plconfig.primitive = GPUPipeline::Primitive::Triangles; plconfig.rasterization = GPUPipeline::RasterizationState::GetNoCullState(); plconfig.depth = GPUPipeline::DepthState::GetNoTestsState(); plconfig.blend = GPUPipeline::BlendState::GetNoBlendingState(); plconfig.geometry_shader = nullptr; plconfig.depth_format = GPUTextureFormat::Unknown; plconfig.render_pass_flags = GPUPipeline::NoRenderPassFlags; plconfig.layout = GPUPipeline::Layout::SingleTextureAndPushConstants; plconfig.SetTargetFormats(g_gpu_device->HasMainSwapChain() ? g_gpu_device->GetMainSwapChain()->GetFormat() : GPUTextureFormat::RGBA8); plconfig.vertex_shader = vs.get(); plconfig.fragment_shader = fs.get(); s_locals.app_background_shader = g_gpu_device->CreatePipeline(plconfig, error); if (!s_locals.app_background_shader) return false; return true; } void FullscreenUI::DrawShaderBackgroundCallback(const ImDrawList* parent_list, const ImDrawCmd* cmd, u32 base_vertex, u32 base_index) { if (!g_gpu_device->HasMainSwapChain()) return; struct alignas(16) Uniforms { float u_display_size[2]; float u_rcp_display_size[2]; float u_time; }; const GSVector2 display_size = GSVector2(g_gpu_device->GetMainSwapChain()->GetSizeVec()); Uniforms uniforms; GSVector2::store(uniforms.u_display_size, display_size); GSVector2::store(uniforms.u_rcp_display_size, GSVector2::cxpr(1.0f) / display_size); uniforms.u_time = static_cast(Timer::ConvertValueToSeconds(Timer::GetCurrentValue() - s_locals.app_background_load_time)); g_gpu_device->SetPipeline(s_locals.app_background_shader.get()); g_gpu_device->SetTextureSampler(0, nullptr, nullptr); g_gpu_device->DrawWithPushConstants(3, 0, &uniforms, sizeof(uniforms)); } bool FullscreenUI::LoadBackgroundImage(const std::string& path, Error* error) { Image image; if (!image.LoadFromFile(path.c_str(), error)) return false; s_locals.app_background_texture = g_gpu_device->FetchAndUploadTextureImage(image, GPUTexture::Flags::None, error); if (!s_locals.app_background_texture) return false; return true; } void FullscreenUI::DrawBackground() { if (!s_locals.background_loaded) UpdateBackground(); if (s_locals.app_background_shader) { ImDrawList* dl = ImGui::GetBackgroundDrawList(); dl->AddCallback(&FullscreenUI::DrawShaderBackgroundCallback, nullptr); } else if (s_locals.app_background_texture) { ImDrawList* dl = ImGui::GetBackgroundDrawList(); const ImVec2 size = ImGui::GetIO().DisplaySize; const ImRect uv_rect = FitImage(size, ImVec2(static_cast(s_locals.app_background_texture->GetWidth()), static_cast(s_locals.app_background_texture->GetHeight()))); dl->AddImage(s_locals.app_background_texture.get(), ImVec2(0.0f, 0.0f), size, uv_rect.Min, uv_rect.Max); } } ImVec4 FullscreenUI::GetTransparentBackgroundColor(const ImVec4& no_background_color /* = UIStyle.BackgroundColor */) { // use transparent colour if background is visible for things like game list if (!HasBackground()) return ModAlpha(no_background_color, GetBackgroundAlpha()); else return ImVec4{}; } bool FullscreenUI::ShouldOpenToGameList() { return Core::GetBaseBoolSettingValue("Main", "FullscreenUIOpenToGameList", false); } void FullscreenUI::DrawLandingTemplate(ImVec2* menu_pos, ImVec2* menu_size) { const ImGuiIO& io = ImGui::GetIO(); const ImVec2 heading_size = ImVec2( io.DisplaySize.x, UIStyle.LargeFontSize + (LayoutScale(LAYOUT_MENU_BUTTON_Y_PADDING) * 2.0f) + LayoutScale(2.0f)); *menu_pos = ImVec2(0.0f, heading_size.y); *menu_size = ImVec2(io.DisplaySize.x, io.DisplaySize.y - heading_size.y - LayoutScale(LAYOUT_FOOTER_HEIGHT)); if (BeginFullscreenWindow(ImVec2(0.0f, 0.0f), heading_size, "landing_heading", ModAlpha(UIStyle.PrimaryColor, GetBackgroundAlpha()))) { ImFont* const heading_font = UIStyle.Font; const float heading_font_size = UIStyle.LargeFontSize; const float heading_font_weight = UIStyle.BoldFontWeight; ImDrawList* const dl = ImGui::GetWindowDrawList(); SmallString heading_str; const u32 text_color = ImGui::GetColorU32(UIStyle.PrimaryTextColor); // draw branding { const ImVec2 logo_pos = LayoutScale(LAYOUT_MENU_BUTTON_X_PADDING, LAYOUT_MENU_BUTTON_Y_PADDING); const ImVec2 logo_size = ImVec2(UIStyle.LargeFontSize, UIStyle.LargeFontSize); GPUTexture* const logo_texture = GetUserThemeableTexture("images/duck.png", {}, nullptr, logo_size); dl->AddImage(logo_texture, logo_pos, logo_pos + logo_size); const std::string_view heading_text = "DuckStation"; const ImVec2 text_size = heading_font->CalcTextSizeA(heading_font_size, heading_font_weight, FLT_MAX, 0.0f, IMSTR_START_END(heading_text)); const ImVec2 text_pos = ImVec2(logo_pos.x + logo_size.x + LayoutScale(LAYOUT_MENU_BUTTON_X_PADDING), logo_pos.y); RenderShadowedTextClipped(heading_font, heading_font_size, heading_font_weight, text_pos, text_pos + text_size, text_color, heading_text, &text_size); } // draw time ImVec2 time_pos; { UpdateCurrentTimeString(); const ImVec2 time_size = heading_font->CalcTextSizeA(heading_font_size, heading_font_weight, FLT_MAX, 0.0f, IMSTR_START_END(s_locals.current_time_string)); time_pos = ImVec2(heading_size.x - LayoutScale(LAYOUT_MENU_BUTTON_X_PADDING) - time_size.x, LayoutScale(LAYOUT_MENU_BUTTON_Y_PADDING)); RenderShadowedTextClipped(heading_font, heading_font_size, heading_font_weight, time_pos, time_pos + time_size, text_color, s_locals.current_time_string, &time_size); } // draw achievements info if (Achievements::IsActive()) { const auto lock = Achievements::GetLock(); if (const std::string& username = Achievements::GetLoggedInUserName(); !username.empty()) { const ImVec2 name_size = heading_font->CalcTextSizeA(heading_font_size, heading_font_weight, FLT_MAX, 0.0f, IMSTR_START_END(username)); const ImVec2 name_pos = ImVec2(time_pos.x - name_size.x - LayoutScale(LAYOUT_MENU_BUTTON_X_PADDING), time_pos.y); RenderShadowedTextClipped(heading_font, heading_font_size, heading_font_weight, name_pos, name_pos + name_size, text_color, username, &name_size); if (const std::string& badge_path = Achievements::GetLoggedInUserIconURL(); !badge_path.empty()) { const ImVec2 badge_size = ImVec2(UIStyle.LargeFontSize, UIStyle.LargeFontSize); const ImVec2 badge_pos = ImVec2(name_pos.x - badge_size.x - LayoutScale(LAYOUT_MENU_BUTTON_X_PADDING), time_pos.y); dl->AddImage(reinterpret_cast(GetCachedTextureAsync(badge_path)), badge_pos, badge_pos + badge_size); } } } } EndFullscreenWindow(); } void FullscreenUI::DrawLandingWindow() { ImVec2 menu_pos, menu_size; DrawLandingTemplate(&menu_pos, &menu_size); ImGui::PushStyleColor(ImGuiCol_Text, UIStyle.BackgroundTextColor); if (BeginHorizontalMenu("landing_window", menu_pos, menu_size, GetTransparentBackgroundColor(), 4)) { ResetFocusHere(); if (UserThemeableHorizontalButton("fullscreenui/game-list.png", "fullscreenui/game-list.svg", FSUI_VSTR("Game List"), FSUI_VSTR("Launch a game from images scanned from your game directories."))) { BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, &SwitchToGameList); } ImGui::SetItemDefaultFocus(); if (UserThemeableHorizontalButton( "fullscreenui/start-disc.png", "fullscreenui/start-disc.svg", FSUI_VSTR("Start Game"), FSUI_VSTR("Launch a game from a file, disc, or starts the console without any disc inserted."))) { BeginTransition([]() { SwitchToMainWindow(MainWindowType::StartGame); }); } if (UserThemeableHorizontalButton("fullscreenui/settings.png", "fullscreenui/settings.svg", FSUI_VSTR("Settings"), FSUI_VSTR("Changes settings for the application."))) { BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, []() { SwitchToSettings(); }); } if (UserThemeableHorizontalButton("fullscreenui/exit.png", "fullscreenui/exit.svg", FSUI_VSTR("Exit"), FSUI_VSTR("Return to desktop mode, or exit the application.")) || (!AreAnyDialogsOpen() && WantsToCloseMenu())) { BeginTransition([]() { SwitchToMainWindow(MainWindowType::Exit); }); } } EndHorizontalMenu(); ImGui::PopStyleColor(); if (!AreAnyDialogsOpen()) { if (ImGui::IsKeyPressed(ImGuiKey_GamepadBack, false) || ImGui::IsKeyPressed(ImGuiKey_F1, false)) { EnqueueSoundEffect(SFX_NAV_ACTIVATE); OpenFixedPopupDialog(ABOUT_DIALOG_NAME); } else if (ImGui::IsKeyPressed(ImGuiKey_GamepadStart, false) || ImGui::IsKeyPressed(ImGuiKey_F3, false)) { EnqueueSoundEffect(SFX_NAV_ACTIVATE); DoResume(); } else if (ImGui::IsKeyPressed(ImGuiKey_NavGamepadMenu, false) || ImGui::IsKeyPressed(ImGuiKey_F11, false)) { DoToggleFullscreen(); } } if (IsGamepadInputSource()) { SetFullscreenFooterText(std::array{std::make_pair(ICON_PF_SHARE_CAPTURE, FSUI_VSTR("About")), std::make_pair(ICON_PF_BURGER_MENU, FSUI_VSTR("Resume Last Session")), std::make_pair(ICON_PF_BUTTON_X, FSUI_VSTR("Toggle Fullscreen")), std::make_pair(ICON_PF_XBOX_DPAD_LEFT_RIGHT, FSUI_VSTR("Navigate")), std::make_pair(ICON_PF_BUTTON_A, FSUI_VSTR("Select")), std::make_pair(ICON_PF_BUTTON_B, FSUI_VSTR("Exit"))}); } else { SetFullscreenFooterText(std::array{ std::make_pair(ICON_PF_F1, FSUI_VSTR("About")), std::make_pair(ICON_PF_F3, FSUI_VSTR("Resume Last Session")), std::make_pair(ICON_PF_F11, FSUI_VSTR("Toggle Fullscreen")), std::make_pair(ICON_PF_ARROW_LEFT ICON_PF_ARROW_RIGHT, FSUI_VSTR("Navigate")), std::make_pair(ICON_PF_ENTER, FSUI_VSTR("Select")), std::make_pair(ICON_PF_ESC, FSUI_VSTR("Exit"))}); } } void FullscreenUI::DrawStartGameWindow() { ImVec2 menu_pos, menu_size; DrawLandingTemplate(&menu_pos, &menu_size); ImGui::PushStyleColor(ImGuiCol_Text, UIStyle.BackgroundTextColor); if (BeginHorizontalMenu("start_game_window", menu_pos, menu_size, GetTransparentBackgroundColor(), 4)) { ResetFocusHere(); if (UserThemeableHorizontalButton("fullscreenui/start-file.png", "fullscreenui/start-file.svg", FSUI_VSTR("Start File"), FSUI_VSTR("Launch a game by selecting a file/disc image."))) { DoStartFile(); } if (UserThemeableHorizontalButton("fullscreenui/start-disc.png", "fullscreenui/start-disc.svg", FSUI_VSTR("Start Disc"), FSUI_VSTR("Start a game from a disc in your PC's DVD drive."))) { DoStartDisc(); } if (UserThemeableHorizontalButton("fullscreenui/start-bios.png", "fullscreenui/start-bios.svg", FSUI_VSTR("Start BIOS"), FSUI_VSTR("Start the console without any disc inserted."))) { DoStartBIOS(); } if (UserThemeableHorizontalButton("fullscreenui/back-icon.png", "fullscreenui/back-icon.svg", FSUI_VSTR("Back"), FSUI_VSTR("Return to the previous menu.")) || (!AreAnyDialogsOpen() && WantsToCloseMenu())) { BeginTransition([]() { SwitchToMainWindow(MainWindowType::Landing); }); } } EndHorizontalMenu(); ImGui::PopStyleColor(); if (!AreAnyDialogsOpen()) { if (ImGui::IsKeyPressed(ImGuiKey_NavGamepadMenu, false) || ImGui::IsKeyPressed(ImGuiKey_F1, false)) { EnqueueSoundEffect(SFX_NAV_ACTIVATE); OpenSaveStateSelector(std::string(), std::string(), true); } } if (IsGamepadInputSource()) { SetFullscreenFooterText(std::array{std::make_pair(ICON_PF_XBOX_DPAD_LEFT_RIGHT, FSUI_VSTR("Navigate")), std::make_pair(ICON_PF_BUTTON_Y, FSUI_VSTR("Load Global State")), std::make_pair(ICON_PF_BUTTON_A, FSUI_VSTR("Select")), std::make_pair(ICON_PF_BUTTON_B, FSUI_VSTR("Back"))}); } else { SetFullscreenFooterText(std::array{std::make_pair(ICON_PF_ARROW_LEFT ICON_PF_ARROW_RIGHT, FSUI_VSTR("Navigate")), std::make_pair(ICON_PF_F1, FSUI_VSTR("Load Global State")), std::make_pair(ICON_PF_ENTER, FSUI_VSTR("Select")), std::make_pair(ICON_PF_ESC, FSUI_VSTR("Back"))}); } } void FullscreenUI::DrawExitWindow() { ImVec2 menu_pos, menu_size; DrawLandingTemplate(&menu_pos, &menu_size); ImGui::PushStyleColor(ImGuiCol_Text, UIStyle.BackgroundTextColor); if (BeginHorizontalMenu("exit_window", menu_pos, menu_size, GetTransparentBackgroundColor(), 3)) { ResetFocusHere(); if (UserThemeableHorizontalButton("fullscreenui/back-icon.png", "fullscreenui/back-icon.svg", FSUI_VSTR("Back"), FSUI_VSTR("Return to the previous menu.")) || WantsToCloseMenu()) { BeginTransition([]() { SwitchToMainWindow(MainWindowType::Landing); }); } if (UserThemeableHorizontalButton("fullscreenui/exit.png", "fullscreenui/exit.svg", FSUI_VSTR("Exit DuckStation"), FSUI_VSTR("Completely exits the application, returning you to your desktop."))) { DoRequestExit(); } if (UserThemeableHorizontalButton("fullscreenui/desktop-mode.png", "fullscreenui/desktop-mode.svg", FSUI_VSTR("Desktop Mode"), FSUI_VSTR("Exits Big Picture mode, returning to the desktop interface."))) { DoDesktopMode(); } } EndHorizontalMenu(); ImGui::PopStyleColor(); SetStandardSelectionFooterText(true); } float FullscreenUI::GetBackgroundAlpha() { if (VideoThread::HasGPUBackend()) { // reduce background opacity when changing postfx settings if (s_locals.current_main_window == MainWindowType::Settings && GetCurrentSettingsPage() == SettingsPage::PostProcessing) { return 0.50f; } else { return UIStyle.IsDarkTheme ? 0.90f : 0.92f; } } else { return HasBackground() ? 0.5f : 1.0f; } } void FullscreenUI::DrawPauseMenu() { static constexpr auto switch_submenu = [](PauseSubMenu submenu) { s_locals.current_pause_submenu = submenu; QueueResetFocus(FocusResetType::ViewChanged); }; static constexpr float top_bar_height = 90.0f; static constexpr float top_bar_padding = 10.0f; SmallString buffer; ImDrawList* const dl = ImGui::GetBackgroundDrawList(); const ImVec2 display_size(ImGui::GetIO().DisplaySize); const ImU32 title_text_color = ImGui::GetColorU32(UIStyle.BackgroundTextColor); const ImU32 text_color = ImGui::GetColorU32(DarkerColor(UIStyle.BackgroundTextColor, 0.85f)); const ImU32 last_text_color = ImGui::GetColorU32(DarkerColor(DarkerColor(UIStyle.BackgroundTextColor, 0.85f))); // top bar const float scaled_top_bar_height = LayoutScale(top_bar_height); { const float scaled_text_spacing = LayoutScale(4.0f); const float scaled_top_bar_padding = LayoutScale(top_bar_padding); const ImVec2 top_bar_min = ImVec2(0.0f, 0.0f); const ImVec2 top_bar_max = ImVec2(display_size.x, scaled_top_bar_height); if (UIStyle.BlurMenuBackground && BeginBlurBackground(dl, top_bar_min, top_bar_max)) { dl->AddRectFilled(top_bar_min, top_bar_max, ImGui::GetColorU32(ModAlpha(UIStyle.BackgroundColor, 1.0f)), 0.0f); EndBlurBackground(dl); } else { dl->AddRectFilled(top_bar_min, top_bar_max, ImGui::GetColorU32(ModAlpha(UIStyle.BackgroundColor, 0.95f)), 0.0f); } const std::string& game_title = VideoThread::GetGameTitle(); const std::string& game_serial = VideoThread::GetGameSerial(); const std::string& game_path = VideoThread::GetGamePath(); GPUTexture* const cover = GetCoverForCurrentGame(game_path); const float image_padding = LayoutScale(5.0f); // compensate for font baseline const float image_size = scaled_top_bar_height - scaled_top_bar_padding - scaled_top_bar_padding - image_padding; const ImRect image_rect( CenterImage(ImRect(scaled_top_bar_padding + image_padding, scaled_top_bar_padding + image_padding, scaled_top_bar_padding + image_size, scaled_top_bar_padding + image_size), ImVec2(static_cast(cover->GetWidth()), static_cast(cover->GetHeight())))); dl->AddImage(cover, image_rect.Min, image_rect.Max); if (!game_serial.empty()) buffer.format("{} - {}", game_serial, Path::GetFileName(game_path)); else buffer.assign(Path::GetFileName(game_path)); ImVec2 text_pos = ImVec2(scaled_top_bar_padding + image_size + scaled_top_bar_padding, scaled_top_bar_padding); RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, text_pos, display_size, title_text_color, game_title); text_pos.y += UIStyle.LargeFontSize + scaled_text_spacing; if (Achievements::IsActive()) { const auto lock = Achievements::GetLock(); if (const std::string& rp = Achievements::GetRichPresenceString(); !rp.empty()) { RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, text_pos, display_size, text_color, rp); text_pos.y += UIStyle.MediumFontSize + scaled_text_spacing; } } RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, text_pos, display_size, last_text_color, buffer); // current time / play time UpdateCurrentTimeString(); ImVec2 text_size = UIStyle.Font->CalcTextSizeA(UIStyle.LargeFontSize, UIStyle.BoldFontWeight, std::numeric_limits::max(), -1.0f, IMSTR_START_END(s_locals.current_time_string)); text_pos = ImVec2(display_size.x - scaled_top_bar_padding - text_size.x, scaled_top_bar_padding); RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, text_pos, display_size, title_text_color, s_locals.current_time_string); text_pos.y += UIStyle.LargeFontSize + scaled_text_spacing; if (!game_serial.empty()) { const std::time_t cached_played_time = GameList::GetCachedPlayedTimeForSerial(game_serial); const std::time_t session_time = static_cast(System::GetSessionPlayedTime()); buffer.format(FSUI_FSTR("Session: {}"), Host::FormatTimespan(session_time, true)); text_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight, std::numeric_limits::max(), -1.0f, buffer.c_str(), buffer.end_ptr()); text_pos.x = display_size.x - scaled_top_bar_padding - text_size.x; RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, text_pos, display_size, text_color, buffer); text_pos.y += UIStyle.MediumFontSize + scaled_text_spacing; buffer.format(FSUI_FSTR("All Time: {}"), Host::FormatTimespan(cached_played_time + session_time, true)); text_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight, std::numeric_limits::max(), -1.0f, IMSTR_START_END(buffer)); text_pos.x = display_size.x - scaled_top_bar_padding - text_size.x; RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, text_pos, display_size, text_color, buffer); text_pos.y += UIStyle.MediumFontSize + scaled_text_spacing; } } const ImVec2 window_size(LayoutScale(500.0f), display_size.y - scaled_top_bar_height - LayoutScale(LAYOUT_FOOTER_HEIGHT)); const ImVec2 window_pos(0.0f, scaled_top_bar_height); // background dl->AddRectFilled(ImVec2(0.0f, scaled_top_bar_height), ImVec2(display_size.x, display_size.y - LayoutScale(LAYOUT_FOOTER_HEIGHT)), ImGui::GetColorU32(ModAlpha(UIStyle.BackgroundColor, 0.9f))); DrawAchievementsPauseMenuOverlays(scaled_top_bar_height); if (BeginFullscreenWindow(window_pos, window_size, "pause_menu", ImVec4(0.0f, 0.0f, 0.0f, 0.0f), 0.0f, ImVec2(10.0f, 10.0f), ImGuiWindowFlags_NoBackground)) { static constexpr u32 submenu_item_count[] = { 11, // None 4, // Exit 3, // Achievements }; // reduce spacing to fit all the buttons ResetFocusHere(); BeginMenuButtons(submenu_item_count[static_cast(s_locals.current_pause_submenu)], 1.0f, LAYOUT_MENU_BUTTON_X_PADDING, LAYOUT_MENU_BUTTON_Y_PADDING, 0.0f, 4.0f); switch (s_locals.current_pause_submenu) { case PauseSubMenu::None: { // NOTE: Menu close must come first, because otherwise VM destruction options will race. const bool has_game = VideoThread::HasGPUBackend(); if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_PLAY, "Resume Game")) || (!AreAnyDialogsOpen() && WantsToCloseMenu())) { ClosePauseMenu(TransitionEffect::ZoomOut); } ImGui::SetItemDefaultFocus(); if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_FORWARD, "Toggle Fast Forward"))) { ClosePauseMenu(TransitionEffect::ZoomOut); DoToggleFastForward(); } if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_PF_DOWNLOAD, "Load State"), has_game)) OpenSaveStateSelector(VideoThread::GetGameSerial(), VideoThread::GetGamePath(), true); if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_PF_DISKETTE, "Save State"), has_game)) OpenSaveStateSelector(VideoThread::GetGameSerial(), VideoThread::GetGamePath(), false); if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_PF_GAMEPAD_ALT, "Toggle Analog"))) { ClosePauseMenu(TransitionEffect::ZoomOut); DoToggleAnalogMode(); } if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_WRENCH, "Game Properties"), has_game && GameList::CanEditGameSettingsForPath(VideoThread::GetGamePath(), VideoThread::GetGameSerial()))) { BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, []() { SwitchToGameSettings(); }); } if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_TROPHY, "Achievements"), Achievements::HasAchievementsOrLeaderboards())) { // skip second menu and go straight to cheevos if there's no lbs if (!Achievements::HasLeaderboards()) BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, &SwitchToAchievements); else BeginTransition([]() { switch_submenu(PauseSubMenu::Achievements); }); } if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_CAMERA, "Save Screenshot"))) { Host::RunOnCoreThread([]() { System::SaveScreenshot(); }); ClosePauseMenu(TransitionEffect::ZoomOut); } if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_COMPACT_DISC, "Change Disc"))) Host::RunOnCoreThread([]() { BeginChangeDiscOnCoreThread(false); }); if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_SLIDERS, "Settings"))) BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, []() { SwitchToSettings(); }); if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_POWER_OFF, "Close Game"))) { // skip submenu when we can't save anyway if (!has_game) RequestShutdown(false); else BeginTransition([]() { switch_submenu(PauseSubMenu::Exit); }); } } break; case PauseSubMenu::Exit: { if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_PF_NAVIGATION_BACK, "Back To Pause Menu")) || WantsToCloseMenu()) { BeginTransition([]() { switch_submenu(PauseSubMenu::None); }); } else { ImGui::SetItemDefaultFocus(); } if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_ARROWS_ROTATE, "Restart Game"))) RequestRestart(); if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_FLOPPY_DISK, "Close and Save State"))) RequestShutdown(true); if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_POWER_OFF, "Close Without Saving"))) RequestShutdown(false); } break; case PauseSubMenu::Achievements: { if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_PF_NAVIGATION_BACK, "Back To Pause Menu")) || WantsToCloseMenu()) { BeginTransition([]() { switch_submenu(PauseSubMenu::None); }); } else { ImGui::SetItemDefaultFocus(); } if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_TROPHY, "Achievements"))) BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, &SwitchToAchievements); if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_STOPWATCH, "Leaderboards"))) BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, &SwitchToLeaderboards); } break; } EndMenuButtons(); } EndFullscreenWindow(); SetFullscreenFooterBlur(false); if (IsGamepadInputSource()) { SetFullscreenFooterText(std::array{std::make_pair(ICON_PF_XBOX_DPAD_UP_DOWN, FSUI_VSTR("Change Selection")), std::make_pair(ICON_PF_BUTTON_A, FSUI_VSTR("Select")), std::make_pair(ICON_PF_BUTTON_B, FSUI_VSTR("Return To Game"))}); } else { SetFullscreenFooterText(std::array{ std::make_pair(ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN, FSUI_VSTR("Change Selection")), std::make_pair(ICON_PF_ENTER, FSUI_VSTR("Select")), std::make_pair(ICON_PF_ESC, FSUI_VSTR("Return To Game"))}); } } void FullscreenUI::InitializePlaceholderSaveStateListEntry(SaveStateListEntry* li, s32 slot, bool global) { li->title = (global || slot > 0) ? fmt::format(global ? FSUI_FSTR("Global Slot {0}##global_slot_{0}") : FSUI_FSTR("Game Slot {0}##game_slot_{0}"), slot) : FSUI_STR("Quick Save"); li->summary = FSUI_STR("No save present in this slot."); li->timestamp = 0; li->slot = slot; li->preview_texture = {}; li->global = global; } bool FullscreenUI::InitializeSaveStateListEntryFromSerial(SaveStateListEntry* li, const std::string& serial, s32 slot, bool global) { Error error; bool exists; std::string path = (global ? System::GetGlobalSaveStatePath(slot) : System::GetGameSaveStatePath(serial, slot)); if (!InitializeSaveStateListEntryFromPath(li, std::move(path), slot, global, &error, &exists)) { InitializePlaceholderSaveStateListEntry(li, slot, global); if (exists) { // Show error instead. li->summary = error.TakeDescription(); return true; } return false; } return true; } bool FullscreenUI::InitializeSaveStateListEntryFromPath(SaveStateListEntry* li, std::string path, s32 slot, bool global, Error* error, bool* exists) { li->state_path = std::move(path); std::optional ssi = System::GetExtendedSaveStateInfo(li->state_path.c_str(), error, exists); if (!ssi.has_value()) return false; if (global) { li->title = fmt::format(FSUI_FSTR("Global Slot {0} - {1}##global_slot_{0}"), slot, ssi->serial); } else { li->title = (slot > 0) ? fmt::format(FSUI_FSTR("Game Slot {0}##game_slot_{0}"), slot) : FSUI_STR("Game Quick Save"); } li->summary = fmt::format(FSUI_FSTR("Saved {}"), Host::FormatRelativeDateTime(ssi->timestamp, false, false)); li->timestamp = ssi->timestamp; li->slot = slot; li->game_path = std::move(ssi->media_path); li->global = global; if (ssi->screenshot.IsValid()) li->preview_texture = g_gpu_device->FetchAndUploadTextureImage(ssi->screenshot); return true; } void FullscreenUI::ClearSaveStateEntryList() { for (SaveStateListEntry& entry : s_locals.save_state_selector_slots) QueueTextureRecycle(std::move(entry.preview_texture)); s_locals.save_state_selector_slots.clear(); } u32 FullscreenUI::PopulateSaveStateListEntries(const std::string& serial, std::optional undo_save_state, bool is_loading) { ClearSaveStateEntryList(); if (undo_save_state.has_value()) { SaveStateListEntry li; li.title = FSUI_STR("Undo Load State"); li.summary = fmt::format(FSUI_FSTR("Saved {}"), Host::FormatRelativeDateTime(undo_save_state->timestamp, false, false)); if (undo_save_state->screenshot.IsValid()) li.preview_texture = g_gpu_device->FetchAndUploadTextureImage(undo_save_state->screenshot); s_locals.save_state_selector_slots.push_back(std::move(li)); } if (!serial.empty()) { for (s32 i = 1; i <= System::PER_GAME_SAVE_STATE_SLOTS; i++) { SaveStateListEntry li; if (InitializeSaveStateListEntryFromSerial(&li, serial, i, false) || !is_loading) s_locals.save_state_selector_slots.push_back(std::move(li)); } } if (System::AreGlobalSaveStatesEnabled()) { for (s32 i = 1; i <= System::GLOBAL_SAVE_STATE_SLOTS; i++) { SaveStateListEntry li; if (InitializeSaveStateListEntryFromSerial(&li, serial, i, true) || !is_loading) s_locals.save_state_selector_slots.push_back(std::move(li)); } } return static_cast(s_locals.save_state_selector_slots.size()); } void FullscreenUI::OpenSaveStateSelector(const std::string& serial, const std::string& path, bool is_loading) { if (VideoThread::HasGPUBackend()) { // need to get the undo state, if any Host::RunOnCoreThread([serial = serial, is_loading]() { std::optional undo_state; if (is_loading) undo_state = System::GetUndoSaveStateInfo(); VideoThread::RunOnThread([serial = std::move(serial), undo_state = std::move(undo_state), is_loading]() mutable { if (PopulateSaveStateListEntries(serial, std::move(undo_state), is_loading) > 0) { s_locals.save_state_selector_loading = is_loading; BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, []() { SwitchToMainWindow(MainWindowType::SaveStateSelector); }); } else { ShowToast(OSDMessageType::Info, {}, FSUI_STR("No save states found.")); } }); }); } else { if (PopulateSaveStateListEntries(serial, std::nullopt, is_loading) > 0) { s_locals.save_state_selector_loading = is_loading; BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, []() { SwitchToMainWindow(MainWindowType::SaveStateSelector); }); } else { ShowToast(OSDMessageType::Info, {}, FSUI_STR("No save states found.")); } } } void FullscreenUI::DrawSaveStateSelector() { static constexpr auto do_load_state = [](const SaveStateListEntry& entry) { if (VideoThread::HasGPUBackend()) { // Loading undo state? if (entry.state_path.empty()) { Host::RunOnCoreThread(&System::UndoLoadState); } else { Host::RunOnCoreThread( [global = entry.global, slot = entry.slot]() { System::LoadStateFromSlot(global, slot); }); } ClearSaveStateEntryList(); ClosePauseMenu(TransitionEffect::Fade, LONG_TRANSITION_TIME); } else { DoStartPath(entry.game_path, entry.state_path); } }; static constexpr auto do_save_state = [](const SaveStateListEntry& entry) { Host::RunOnCoreThread([slot = entry.slot, global = entry.global]() { System::SaveStateToSlot(global, slot); }); ClearSaveStateEntryList(); ClosePauseMenu(TransitionEffect::Fade, LONG_TRANSITION_TIME); }; ImGuiIO& io = ImGui::GetIO(); const ImVec2 heading_size = ImVec2( io.DisplaySize.x, UIStyle.LargeFontSize + (LayoutScale(LAYOUT_MENU_BUTTON_Y_PADDING) * 2.0f) + LayoutScale(2.0f)); SaveStateListEntry* pressed_entry = nullptr; bool closed = false; // last state deleted? if (s_locals.save_state_selector_slots.empty()) closed = true; if (BeginFullscreenWindow(ImVec2(0.0f, 0.0f), heading_size, "##save_state_selector_title", ModAlpha(UIStyle.PrimaryColor, GetBackgroundAlpha()), 0.0f, ImVec2(), 0, true)) { BeginNavBar(); if (NavButton(ICON_PF_NAVIGATION_BACK, true, true)) closed = true; NavTitle(s_locals.save_state_selector_loading ? FSUI_VSTR("Load State") : FSUI_VSTR("Save State")); EndNavBar(); } EndFullscreenWindow(); if (IsFocusResetFromWindowChange()) ImGui::SetNextWindowScroll(ImVec2(0.0f, 0.0f)); if (BeginFullscreenWindow( ImVec2(0.0f, heading_size.y), ImVec2(io.DisplaySize.x, io.DisplaySize.y - heading_size.y - LayoutScale(LAYOUT_FOOTER_HEIGHT)), "##save_state_selector_list", ModAlpha(UIStyle.BackgroundColor, GetBackgroundAlpha()), 0.0f, ImVec2(LAYOUT_MENU_WINDOW_X_PADDING, LAYOUT_MENU_WINDOW_Y_PADDING), 0, true) && !s_locals.save_state_selector_slots.empty()) { ResetFocusHere(); BeginMenuButtons(); const ImGuiStyle& style = ImGui::GetStyle(); const float title_spacing = LayoutScale(10.0f); const float summary_spacing = LayoutScale(4.0f); const float item_spacing = LayoutScale(20.0f); const float item_width_with_spacing = std::floor(LayoutScale(LAYOUT_SCREEN_WIDTH / 4.0f)); const float item_width = item_width_with_spacing - item_spacing; const float image_width = item_width - (style.FramePadding.x * 2.0f); const float image_height = image_width / 1.33f; const ImVec2 image_size(image_width, image_height); const float item_height = (style.FramePadding.y * 2.0f) + image_height + title_spacing + UIStyle.LargeFontSize + summary_spacing + UIStyle.MediumFontSize; const ImVec2 item_size(item_width, item_height); const u32 grid_count_x = static_cast(std::floor(ImGui::GetContentRegionAvail().x / item_width_with_spacing)); const float start_x = (static_cast(ImGui::GetWindowWidth()) - (item_width_with_spacing * static_cast(grid_count_x))) * 0.5f; u32 grid_x = 0; ImGui::SetCursorPosX(start_x); for (u32 i = 0;;) { SaveStateListEntry& entry = s_locals.save_state_selector_slots[i]; ImGuiWindow* window = ImGui::GetCurrentWindow(); if (window->SkipItems) { i++; continue; } const ImGuiID id = window->GetID(static_cast(i)); const ImVec2 pos(window->DC.CursorPos); ImRect bb(pos, pos + item_size); ImGui::ItemSize(item_size); if (ImGui::ItemAdd(bb, id)) { bool held; bool hovered; bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held, 0); if (hovered) DrawMenuButtonFrame(bb.Min, bb.Max, held); bb.Min += style.FramePadding; bb.Max -= style.FramePadding; GPUTexture* const screenshot = entry.preview_texture ? entry.preview_texture.get() : GetCachedTextureAsync("no-save.png"); const ImRect image_rect( CenterImage(ImRect(bb.Min, bb.Min + image_size), ImVec2(static_cast(screenshot->GetWidth()), static_cast(screenshot->GetHeight())))); ImGui::GetWindowDrawList()->AddImage(screenshot, image_rect.Min, image_rect.Max, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255)); const ImVec2 title_pos(bb.Min.x, bb.Min.y + image_height + title_spacing); const ImRect title_bb(title_pos, ImVec2(bb.Max.x, title_pos.y + UIStyle.LargeFontSize)); RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, title_bb.Min, title_bb.Max, ImGui::GetColorU32(ImGuiCol_Text), entry.title, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &title_bb); if (!entry.summary.empty()) { const ImVec2 summary_pos(bb.Min.x, title_pos.y + UIStyle.LargeFontSize + summary_spacing); const ImRect summary_bb(summary_pos, ImVec2(bb.Max.x, summary_pos.y + UIStyle.MediumFontSize)); RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, summary_bb.Min, summary_bb.Max, ImGui::GetColorU32(ImGuiCol_Text), entry.summary, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &summary_bb); } if (pressed) { // avoid closing while drawing pressed_entry = &entry; } else if (hovered && (ImGui::IsItemClicked(ImGuiMouseButton_Right) || ImGui::IsKeyPressed(ImGuiKey_NavGamepadContextMenu, false) || ImGui::IsKeyPressed(ImGuiKey_F1, false))) { CancelPendingMenuClose(); std::string title; if (s_locals.save_state_selector_loading) title = FSUI_ICONVSTR(ICON_FA_FOLDER_OPEN, "Load State"); else title = FSUI_ICONVSTR(ICON_FA_FOLDER_OPEN, "Save State"); ChoiceDialogOptions options; options.reserve(3); options.emplace_back(title, false); if (!entry.state_path.empty()) options.emplace_back(FSUI_ICONVSTR(ICON_FA_FOLDER_MINUS, "Delete Save"), false); options.emplace_back(FSUI_ICONVSTR(ICON_FA_SQUARE_XMARK, "Close Menu"), false); OpenChoiceDialog( std::move(title), false, std::move(options), [i](s32 index, const std::string& title, bool checked) mutable { if (index < 0 || i >= s_locals.save_state_selector_slots.size()) return; SaveStateListEntry& entry = s_locals.save_state_selector_slots[i]; if (index == 0) { // load state if (s_locals.save_state_selector_loading) do_load_state(entry); else do_save_state(entry); } else if (!entry.state_path.empty() && index == 1) { // delete state if (!FileSystem::FileExists(entry.state_path.c_str())) { ShowToast(OSDMessageType::Error, {}, fmt::format(FSUI_FSTR("{} does not exist."), RemoveHash(entry.title))); } else if (FileSystem::DeleteFile(entry.state_path.c_str())) { ShowToast(OSDMessageType::Quick, {}, fmt::format(FSUI_FSTR("{} deleted."), RemoveHash(entry.title))); // need to preserve the texture, since it's going to be drawn this frame QueueTextureRecycle(std::move(entry.preview_texture)); if (s_locals.save_state_selector_loading) { s_locals.save_state_selector_slots.erase(s_locals.save_state_selector_slots.begin() + i); } else { entry.state_path = {}; InitializePlaceholderSaveStateListEntry(&entry, entry.slot, entry.global); } } else { ShowToast(OSDMessageType::Quick, {}, fmt::format(FSUI_FSTR("Failed to delete {}."), RemoveHash(entry.title))); } } }); } } // avoid triggering imgui warning i++; if (i == s_locals.save_state_selector_slots.size()) break; grid_x++; if (grid_x == grid_count_x) { grid_x = 0; ImGui::SetCursorPosX(start_x); ImGui::SetCursorPosY(ImGui::GetCursorPosY() + item_spacing); } else { ImGui::SameLine(start_x + static_cast(grid_x) * (item_width + item_spacing)); } } EndMenuButtons(); SetWindowNavWrapping(true, true); } EndFullscreenWindow(); if (IsGamepadInputSource()) { SetFullscreenFooterText( std::array{std::make_pair(ICON_PF_XBOX_DPAD, FSUI_VSTR("Select State")), std::make_pair(ICON_PF_BUTTON_Y, FSUI_VSTR("Delete State")), std::make_pair(ICON_PF_BUTTON_A, s_locals.save_state_selector_loading ? FSUI_VSTR("Load State") : FSUI_VSTR("Save State")), std::make_pair(ICON_PF_BUTTON_B, FSUI_VSTR("Cancel"))}); } else { SetFullscreenFooterText( std::array{std::make_pair(ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN ICON_PF_ARROW_LEFT ICON_PF_ARROW_RIGHT, FSUI_VSTR("Select State")), std::make_pair(ICON_PF_F1, FSUI_VSTR("Delete State")), std::make_pair(ICON_PF_ENTER, s_locals.save_state_selector_loading ? FSUI_VSTR("Load State") : FSUI_VSTR("Save State")), std::make_pair(ICON_PF_ESC, FSUI_VSTR("Cancel"))}); } if (pressed_entry) { if (s_locals.save_state_selector_loading) do_load_state(*pressed_entry); else do_save_state(*pressed_entry); } else if ((!AreAnyDialogsOpen() && WantsToCloseMenu()) || closed) { ClearSaveStateEntryList(); ReturnToPreviousWindow(TransitionEffect::ZoomOut); } } bool FullscreenUI::OpenLoadStateSelectorForGameResume(const GameList::Entry* entry) { Error error; bool exists; SaveStateListEntry slentry; if (!InitializeSaveStateListEntryFromPath(&slentry, System::GetGameSaveStatePath(entry->serial, -1), -1, false, &error, &exists)) { if (!exists) return false; OpenConfirmMessageDialog( ICON_EMOJI_NO_ENTRY_SIGN, FSUI_ICONVSTR(ICON_FA_FOLDER_OPEN, "Load Resume State"), fmt::format( FSUI_FSTR("A resume save state was found for this game, but it is corrupted and cannot be loaded:\n\n{}\n\n" "Do you want to delete the save state and boot the game anyway?"), error.GetDescription()), [path = System::GetGameSaveStatePath(entry->serial, -1), game_path = entry->path](bool accepted) { if (accepted) { Error error; if (FileSystem::DeleteFile(path.c_str(), &error)) ShowToast(OSDMessageType::Info, {}, FSUI_STR("Save state deleted.")); else ShowToast(OSDMessageType::Error, FSUI_STR("Failed to delete save state."), error.TakeDescription()); DoStartPath(std::move(game_path)); } }, FSUI_ICONSTR(ICON_FA_FOLDER_MINUS, "Delete And Boot"), FSUI_ICONSTR(ICON_FA_XMARK, "Cancel")); ClearSaveStateEntryList(); return true; } slentry.game_path = entry->path; s_locals.save_state_selector_slots.push_back(std::move(slentry)); OpenFixedPopupDialog(RESUME_STATE_SELECTOR_DIALOG_NAME); return true; } void FullscreenUI::DrawResumeStateSelector() { if (!BeginFixedPopupDialog(LayoutScale(30.0f), LayoutScale(40.0f), ImVec2(LayoutScale(850.0f), 0.0f), LayoutScale(0.0, 300.0f))) { ClearSaveStateEntryList(); return; } SaveStateListEntry& entry = s_locals.save_state_selector_slots.front(); SmallString sick; sick.format(FSUI_FSTR("Do you want to continue from the automatic save created {}?"), Host::FormatRelativeDateTime(entry.timestamp, false, false)); ImGui::PushFont(nullptr, 0.0f, UIStyle.BoldFontWeight); TextAlignedMultiLine(0.5f, IMSTR_START_END(sick)); ImGui::PopFont(); const GPUTexture* image = entry.preview_texture ? entry.preview_texture.get() : GetPlaceholderTexture(); const float image_height = LayoutScale(400.0f); const float image_width = image_height * (static_cast(image->GetWidth()) / static_cast(image->GetHeight())); const ImVec2 pos(ImGui::GetCursorScreenPos() + ImVec2((ImGui::GetCurrentWindow()->WorkRect.GetWidth() - image_width) * 0.5f, LayoutScale(20.0f))); const ImRect image_bb(pos, pos + ImVec2(image_width, image_height)); ImGui::GetWindowDrawList()->AddImage( static_cast(entry.preview_texture ? entry.preview_texture.get() : GetPlaceholderTexture()), image_bb.Min, image_bb.Max, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), ImGui::GetColorU32(IM_COL32(255, 255, 255, 255))); ImGui::SetCursorPosY(ImGui::GetCursorPosY() + image_height + LayoutScale(40.0f)); ResetFocusHere(); BeginHorizontalMenuButtons(4); if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_FOLDER_OPEN, "Load State"), true, LAYOUT_CENTER_ALIGN_TEXT)) { std::string game_path = std::move(entry.game_path); std::string state_path = std::move(entry.state_path); ClearSaveStateEntryList(); CloseFixedPopupDialogImmediately(); DoStartPath(std::move(game_path), std::move(state_path)); } if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_PLAY, "Clean Boot"), true, LAYOUT_CENTER_ALIGN_TEXT)) { std::string game_path = std::move(entry.game_path); ClearSaveStateEntryList(); CloseFixedPopupDialogImmediately(); DoStartPath(std::move(game_path)); } if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_TRASH, "Delete State"), true, LAYOUT_CENTER_ALIGN_TEXT)) { Error error; if (FileSystem::DeleteFile(entry.state_path.c_str(), &error)) { ShowToast(OSDMessageType::Info, {}, FSUI_STR("Save state deleted.")); std::string game_path = std::move(entry.game_path); ClearSaveStateEntryList(); CloseFixedPopupDialogImmediately(); DoStartPath(std::move(game_path)); } else { ShowToast(OSDMessageType::Error, FSUI_STR("Failed to delete save state."), error.TakeDescription()); } } if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_SQUARE_XMARK, "Cancel"), true, LAYOUT_CENTER_ALIGN_TEXT)) CloseFixedPopupDialog(); EndHorizontalMenuButtons(); SetStandardSelectionFooterText(false); EndFixedPopupDialog(); } ////////////////////////////////////////////////////////////////////////// // Overlays ////////////////////////////////////////////////////////////////////////// void FullscreenUI::ExitFullscreenAndOpenURL(std::string_view url) { Host::RunOnCoreThread([url = std::string(url)]() { VideoThread::SetFullscreen(false); Host::OpenURL(url); }); } void FullscreenUI::CopyTextToClipboard(std::string title, std::string_view text) { if (Host::CopyTextToClipboard(text)) ShowToast(OSDMessageType::Quick, {}, std::move(title)); else ShowToast(OSDMessageType::Error, {}, FSUI_STR("Failed to copy text to clipboard.")); } void FullscreenUI::DrawAboutWindow() { if (!BeginFixedPopupDialog(LayoutScale(LAYOUT_LARGE_POPUP_PADDING), LayoutScale(LAYOUT_LARGE_POPUP_ROUNDING), LayoutScale(1100.0f, 0.0f), LayoutScale(0.0, 400.0f))) { return; } const ImVec2 image_size = LayoutScale(64.0f, 64.0f); const float indent = image_size.x + LayoutScale(8.0f); GPUTexture* const logo_texture = GetUserThemeableTexture("images/duck.png", "images/duck.svg", nullptr, image_size); ImGui::GetWindowDrawList()->AddImage(logo_texture, ImGui::GetCursorScreenPos(), ImGui::GetCursorScreenPos() + image_size); ImGui::SetCursorPosX(ImGui::GetCursorPosX() + indent); ImGui::PushFont(nullptr, 0.0f, UIStyle.BoldFontWeight); ImGui::TextUnformatted("DuckStation"); ImGui::PopFont(); ImGui::PushStyleColor(ImGuiCol_Text, DarkerColor(UIStyle.BackgroundTextColor)); ImGui::SetCursorPosX(ImGui::GetCursorPosX() + indent); ImGui::TextUnformatted(g_scm_tag_str); ImGui::PopStyleColor(); ImGui::NewLine(); ImGui::TextWrapped("%s", FSUI_CSTR("DuckStation is a free simulator/emulator of the Sony PlayStation(TM) " "console, focusing on playability, speed, and long-term maintainability.")); ImGui::NewLine(); ImGui::TextWrapped("%s", FSUI_CSTR("Duck icon by icons8 (https://icons8.com/icon/74847/platforms.undefined.short-title)")); ImGui::NewLine(); ImGui::TextWrapped( "%s", FSUI_CSTR("\"PlayStation\" and \"PSX\" are registered trademarks of Sony Interactive Entertainment Europe " "Limited. This software is not affiliated in any way with Sony Interactive Entertainment.")); ImGui::NewLine(); BeginHorizontalMenuButtons(4); if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_GLOBE, "GitHub Repository"))) ExitFullscreenAndOpenURL("https://github.com/stenzek/duckstation/"); if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_COMMENT, "Discord Server"))) ExitFullscreenAndOpenURL("https://www.duckstation.org/discord.html"); if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_PEOPLE_CARRY_BOX, "Contributor List"))) ExitFullscreenAndOpenURL("https://github.com/stenzek/duckstation/blob/master/CONTRIBUTORS.md"); if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_SQUARE_XMARK, "Close")) || WantsToCloseMenu()) CloseFixedPopupDialog(); else SetStandardSelectionFooterText(true); EndHorizontalMenuButtons(); EndFixedPopupDialog(); } #endif // __ANDROID__