// SPDX-FileCopyrightText: 2019-2026 Connor McLaughlin // SPDX-License-Identifier: CC-BY-NC-ND-4.0 #include "imgui_overlays.h" #include "achievements.h" #include "cdrom.h" #include "controller.h" #include "core.h" #include "cpu_core_private.h" #include "dma.h" #include "fullscreenui.h" #include "fullscreenui_widgets.h" #include "gpu.h" #include "gpu_backend.h" #include "gte.h" #include "host.h" #include "mdec.h" #include "performance_counters.h" #include "settings.h" #include "spu.h" #include "system.h" #include "timers.h" #include "video_thread.h" #include "util/gpu_device.h" #include "util/imgui_animated.h" #include "util/imgui_manager.h" #include "util/input_manager.h" #include "util/media_capture.h" #include "util/translation.h" #include "common/align.h" #include "common/assert.h" #include "common/error.h" #include "common/file_system.h" #include "common/gsvector.h" #include "common/log.h" #include "common/path.h" #include "common/string_util.h" #include "common/thirdparty/SmallVector.h" #include "common/time_helpers.h" #include "common/timer.h" #include "IconsEmoji.h" #include "IconsPromptFont.h" #include "fmt/chrono.h" #include "imgui.h" #include "imgui_internal.h" #include #include #include #include #include #include LOG_CHANNEL(ImGuiManager); namespace ImGuiManager { static constexpr float FIXED_BOLD_WEIGHT = 700.0f; namespace { struct InputOverlayState { static constexpr u32 MAX_BINDS = 32; struct PadState { ControllerType ctype; u8 port; u8 slot; bool multitap; u32 icon_color; float bind_state[MAX_BINDS]; }; std::array pads; u32 num_active_pads = 0; }; struct InputOverlayStateUpdateBuffer { u32 num_active_pads = 0; InputOverlayState::PadState pads[0]; }; #ifndef __ANDROID__ struct DebugWindowInfo { const char* name; const char* window_title; const char* icon_name; void (*draw_func)(float); u32 default_width; u32 default_height; }; #endif } // namespace static void FormatProcessorStat(SmallStringBase& text, double usage, double time); static void DrawPerformanceStat(ImDrawList* dl, float& position_y, ImFont* font, float size, float alt_weight, ImU32 alt_color, float rbounds, std::string_view text); static void DrawStatusIndicators(ImDrawList* dl, float& position_y, ImFont* font, float size, float rbounds, SmallStringBase& text, bool paused); static void DrawPerformanceOverlay(const GPUBackend* gpu, float& position_y, float scale, float margin, float spacing); static void DrawMediaCaptureOverlay(float& position_y, float scale, float margin, float spacing); static void DrawFrameTimeOverlay(float& position_y, float scale, float margin, float spacing); static void DrawEnhancementsOverlay(const GPUBackend* gpu); static void DrawInputsOverlay(); static void UpdateInputOverlay(void* buffer); #ifndef __ANDROID__ static constexpr size_t NUM_DEBUG_WINDOWS = 7; static constexpr const char* DEBUG_WINDOW_CONFIG_SECTION = "DebugWindows"; static constexpr const std::array s_debug_window_info = {{ {"Freecam", "Free Camera", ":icons/applications-system.png", >E::DrawFreecamWindow, 510, 500}, {"SPU", "SPU State", ":icons/applications-system.png", &SPU::DrawDebugStateWindow, 820, 950}, {"CDROM", "CD-ROM State", ":icons/applications-system.png", &CDROM::DrawDebugWindow, 820, 555}, {"GPU", "GPU State", ":icons/applications-system.png", &GPU::DrawDebugStateWindow, 450, 550}, {"DMA", "DMA State", ":icons/applications-system.png", &DMA::DrawDebugStateWindow, 860, 180}, {"MDEC", "MDEC State", ":icons/applications-system.png", &MDEC::DrawDebugStateWindow, 300, 350}, {"Timers", "Timers State", ":icons/applications-system.png", &Timers::DrawDebugStateWindow, 800, 95}, }}; static std::array s_debug_window_state = {}; #endif static InputOverlayState s_input_overlay_state = {}; } // namespace ImGuiManager bool ImGuiManager::AreAnyDebugWindowsEnabled(const SettingsInterface& si) { #ifndef __ANDROID__ const bool block_all = Achievements::IsHardcoreModeActive(); if (block_all) return false; for (size_t i = 0; i < NUM_DEBUG_WINDOWS; i++) { const DebugWindowInfo& info = s_debug_window_info[i]; if (si.GetBoolValue(DEBUG_WINDOW_CONFIG_SECTION, info.name, false)) return true; } #endif return false; } bool ImGuiManager::IsSPUDebugWindowEnabled() { #ifndef __ANDROID__ return (s_debug_window_state[1].window_handle != nullptr); #else return false; #endif } bool ImGuiManager::UpdateDebugWindowConfig() { #ifndef __ANDROID__ const bool block_all = Achievements::IsHardcoreModeActive(); bool was_changed = false; for (size_t i = 0; i < NUM_DEBUG_WINDOWS; i++) { AuxiliaryRenderWindowState& state = s_debug_window_state[i]; const DebugWindowInfo& info = s_debug_window_info[i]; const bool current = (state.window_handle != nullptr); const bool enabled = (!block_all && Core::GetBaseBoolSettingValue(DEBUG_WINDOW_CONFIG_SECTION, info.name, false)); if (enabled == current) continue; if (!enabled) { DestroyAuxiliaryRenderWindow(&state, DEBUG_WINDOW_CONFIG_SECTION, info.name); } else { Error error; if (!CreateAuxiliaryRenderWindow(&state, info.window_title, info.icon_name, DEBUG_WINDOW_CONFIG_SECTION, info.name, info.default_width, info.default_height, &error)) { ERROR_LOG("Failed to create aux render window for {}: {}", info.name, error.GetDescription()); } else { was_changed = true; } } } return was_changed; #else return false; #endif } void ImGuiManager::RenderDebugWindows() { #ifndef __ANDROID__ for (size_t i = 0; i < NUM_DEBUG_WINDOWS; i++) { AuxiliaryRenderWindowState& state = s_debug_window_state[i]; if (!state.window_handle) continue; if (!RenderAuxiliaryRenderWindow(&state, s_debug_window_info[i].draw_func)) { // window was closed, destroy it and update the configuration const DebugWindowInfo& info = s_debug_window_info[i]; DestroyAuxiliaryRenderWindow(&state, DEBUG_WINDOW_CONFIG_SECTION, info.name); Core::SetBaseBoolSettingValue(DEBUG_WINDOW_CONFIG_SECTION, info.name, false); Host::CommitBaseSettingChanges(); } } #endif } void ImGuiManager::DestroyAllDebugWindows() { #ifndef __ANDROID__ for (size_t i = 0; i < NUM_DEBUG_WINDOWS; i++) { AuxiliaryRenderWindowState& state = s_debug_window_state[i]; if (!state.window_handle) continue; ImGuiManager::DestroyAuxiliaryRenderWindow(&state, DEBUG_WINDOW_CONFIG_SECTION, s_debug_window_info[i].name); } #endif } void ImGuiManager::RenderTextOverlays(const GPUBackend* gpu) { // Don't draw anything with loading screen or FSUI open, it'll be nonsensical. if (FullscreenUI::IsLoadingScreenOpen() || FullscreenUI::HasActiveOrPendingWindow()) return; const bool paused = VideoThread::IsSystemPaused(); const float scale = ImGuiManager::GetGlobalScale(); const float margin = ImGuiManager::GetScreenMargin(); const float spacing = ImCeil(5.0f * scale); float position_y = margin; DrawPerformanceOverlay(gpu, position_y, scale, margin, spacing); DrawMediaCaptureOverlay(position_y, scale, margin, spacing); if (g_gpu_settings.display_show_enhancements && !paused) DrawEnhancementsOverlay(gpu); if (g_gpu_settings.display_show_inputs && !paused) DrawInputsOverlay(); } void ImGuiManager::FormatProcessorStat(SmallStringBase& text, double usage, double time) { // Some values, such as GPU (and even CPU to some extent) can be out of phase with the wall clock, // which the processor time is divided by to get a utilization percentage. Let's clamp it at 100%, // so that people don't get confused, and remove the decimal places when it's there while we're at it. if (usage >= 99.95) text.append_format("100% ({:.2f}ms)", time); else text.append_format("{:.1f}% ({:.2f}ms)", usage, time); } void ImGuiManager::DrawStatusIndicators(ImDrawList* dl, float& position_y, ImFont* font, float size, float rbounds, SmallStringBase& text, bool paused) { text.clear(); if (GTE::IsFreecamEnabled()) text.append(ICON_EMOJI_MAGNIFIYING_GLASS_TILTED_LEFT " "); if (paused) { text.append(ICON_EMOJI_PAUSE); } else { const bool rewinding = System::IsRewinding(); if (rewinding || System::IsFastForwardEnabled() || System::IsTurboEnabled()) text.append(rewinding ? ICON_EMOJI_FAST_REVERSE : ICON_EMOJI_FAST_FORWARD); } if (!text.empty() && text.back() == ' ') text.pop_back(); if (text.empty()) return; constexpr ImU32 color = IM_COL32(255, 255, 255, 255); constexpr float default_weight = 0.0f; const ImVec2 text_size = font->CalcTextSizeA(size, default_weight, FLT_MAX, 0.0f, IMSTR_START_END(text)); const ImVec2 position = ImVec2(rbounds - text_size.x, position_y); dl->AddText(font, size, default_weight, position, color, IMSTR_START_END(text), 0.0f, nullptr); position_y += text_size.y; } void ImGuiManager::DrawPerformanceStat(ImDrawList* dl, float& position_y, ImFont* font, float size, float alt_weight, ImU32 alt_color, float rbounds, std::string_view text) { static constexpr auto find_control_char = [](const std::string_view& sv, std::string_view::size_type pos) { const size_t len = sv.length(); for (; pos < len; pos++) { if (sv[pos] > 0 && sv[pos] <= '\x04') break; } return pos; }; if (text.empty()) return; constexpr ImU32 color = IM_COL32(255, 255, 255, 255); constexpr float default_weight = 0.0f; std::string_view::size_type pos = 0; float current_weight = default_weight; float width = 0.0f; float height = 0.0f; do { if (text[pos] >= '\x01' && text[pos] <= '\x04') { current_weight = (text[pos] == '\x02') ? alt_weight : ((text[pos] == '\x01') ? default_weight : current_weight); pos++; } std::string_view::size_type epos = find_control_char(text, pos); const char* start_ptr = text.data() + pos; const char* end_ptr = text.data() + epos; if (start_ptr != end_ptr) { const ImVec2 text_size = font->CalcTextSizeA(size, current_weight, FLT_MAX, 0.0f, start_ptr, end_ptr); width += text_size.x; height = std::max(height, text_size.y); } pos = epos; } while (pos < text.length()); ImVec2 position = ImVec2(rbounds - width, position_y); ImU32 current_color = color; pos = 0; current_weight = default_weight; do { const char ch = text[pos]; if (ch >= '\x01' && ch <= '\x02') { current_weight = (ch == '\x02') ? alt_weight : default_weight; pos++; } else if (ch >= '\x03' && ch <= '\x04') { current_color = (ch == '\x04') ? alt_color : color; pos++; } std::string_view::size_type epos = find_control_char(text, pos); const char* start_ptr = text.data() + pos; const char* end_ptr = text.data() + ((epos == std::string_view::npos) ? text.length() : epos); if (start_ptr != end_ptr) { position.x += FullscreenUI::RenderOutlinedText(dl, font, size, current_weight, position, current_color, std::string_view(start_ptr, end_ptr)) .x; } pos = epos; } while (pos < text.length()); position_y += height; } void ImGuiManager::DrawPerformanceOverlay(const GPUBackend* gpu, float& position_y, float scale, float margin, float spacing) { #define BOLD(text) "\x02" text "\x01" #define COLOR(text) "\x04" text "\x03" if (!(g_gpu_settings.display_show_fps || g_gpu_settings.display_show_speed || g_gpu_settings.display_show_gpu_stats || g_gpu_settings.display_show_resolution || g_gpu_settings.display_show_latency_stats || g_gpu_settings.display_show_cpu_usage || g_gpu_settings.display_show_gpu_usage || g_gpu_settings.display_show_frame_times || (g_gpu_settings.display_show_status_indicators && (VideoThread::IsSystemPaused() || System::IsFastForwardEnabled() || System::IsTurboEnabled())))) { return; } const float status_size = std::ceil(50.0f * scale); ImFont* const fixed_font = ImGuiManager::GetFixedFont(); const float fixed_font_size = ImGuiManager::GetFixedFontSize(); ImFont* ui_font = ImGuiManager::GetTextFont(); const float rbound = ImGui::GetIO().DisplaySize.x - margin; ImDrawList* dl = ImGui::GetBackgroundDrawList(); SmallString text; if (!VideoThread::IsSystemPaused()) { const float speed = PerformanceCounters::GetEmulationSpeed(); if (g_gpu_settings.display_show_fps) { const float fps = PerformanceCounters::GetFPS(); const float vps = PerformanceCounters::GetVPS(); if (vps < 100.0f) text.append_format(COLOR("{:.2f}") " " BOLD("FPS") " | " COLOR("{:.2f}") " " BOLD("VPS"), fps, vps); else if (vps < 1000.0f) text.append_format(COLOR("{:.1f}") " " BOLD("FPS") " | " COLOR("{:.1f}") " " BOLD("VPS"), fps, vps); else text.append_format(COLOR("{:.0f}") " " BOLD("FPS") " | " COLOR("{:.0f}") " " BOLD("VPS"), fps, vps); } if (g_gpu_settings.display_show_speed) { text.append_format("{}" COLOR("{}%") " ", text.empty() ? "" : " | ", static_cast(std::round(speed))); const float target_speed = System::GetTargetSpeed(); if (target_speed <= 0.0f) text.append(BOLD("(Max)")); else text.append_format(BOLD("({:.0f}%)"), target_speed * 100.0f); } if (!text.empty()) { ImU32 alt_color; if (speed < 95.0f) alt_color = IM_COL32(255, 100, 100, 255); else if (speed > 105.0f) alt_color = IM_COL32(100, 255, 100, 255); else alt_color = IM_COL32(255, 255, 255, 255); DrawPerformanceStat(dl, position_y, fixed_font, fixed_font_size, FIXED_BOLD_WEIGHT, alt_color, rbound, text); position_y += spacing; } if (g_gpu_settings.display_show_gpu_stats) { gpu->GetStatsString(text); DrawPerformanceStat(dl, position_y, fixed_font, fixed_font_size, FIXED_BOLD_WEIGHT, 0, rbound, text); position_y += spacing; gpu->GetMemoryStatsString(text); DrawPerformanceStat(dl, position_y, fixed_font, fixed_font_size, FIXED_BOLD_WEIGHT, 0, rbound, text); position_y += spacing; } if (g_gpu_settings.display_show_resolution) { const u32 resolution_scale = gpu->GetResolutionScale(); const bool pgxp = gpu->IsUsingHardwareBackend() && g_gpu_settings.gpu_pgxp_enable; const auto [display_width, display_height] = GPU::GetFullDisplayResolution(); // NOTE: Racey read. const bool interlaced = GPU::IsInterlacedDisplayEnabled(); const bool progressive_forced = GPU::IsProgressiveDisplayScanForced(); const bool pal = GPU::IsInPALMode(); text.format("{}x{} " BOLD("{} {}") " | {}x " BOLD("IR") "{}", display_width * resolution_scale, display_height * resolution_scale, pal ? "PAL" : "NTSC", interlaced ? "Interlaced" : (progressive_forced ? "Forced-Progressive" : "Progressive"), resolution_scale, pgxp ? " | " BOLD("PGXP") : ""); DrawPerformanceStat(dl, position_y, fixed_font, fixed_font_size, FIXED_BOLD_WEIGHT, 0, rbound, text); position_y += spacing; } if (g_gpu_settings.display_show_latency_stats) { System::FormatLatencyStats(text); DrawPerformanceStat(dl, position_y, fixed_font, fixed_font_size, FIXED_BOLD_WEIGHT, 0, rbound, text); position_y += spacing; } if (g_gpu_settings.display_show_cpu_usage) { text.format(" {:.2f}ms " BOLD("Min") " | {:.2f}ms " BOLD("Avg") " | {:.2f}ms " BOLD("Max"), PerformanceCounters::GetMinimumFrameTime(), PerformanceCounters::GetAverageFrameTime(), PerformanceCounters::GetMaximumFrameTime()); DrawPerformanceStat(dl, position_y, fixed_font, fixed_font_size, FIXED_BOLD_WEIGHT, 0, rbound, text); position_y += spacing; if (g_settings.cpu_overclock_active || CPU::g_state.using_interpreter || g_settings.cpu_execution_mode != CPUExecutionMode::Recompiler || g_settings.cpu_recompiler_icache || g_settings.cpu_recompiler_memory_exceptions) { bool first = true; text.assign("\x02" "CPU["); if (g_settings.cpu_overclock_active) { text.append_format("{}", g_settings.GetCPUOverclockPercent()); first = false; } if (CPU::g_state.using_interpreter) { text.append_format("{}{}", first ? "" : "/", "I"); } else if (g_settings.cpu_execution_mode == CPUExecutionMode::CachedInterpreter) { text.append_format("{}{}", first ? "" : "/", "CI"); } else { if (g_settings.cpu_recompiler_icache) { text.append_format("{}{}", first ? "" : "/", "IC"); first = false; } if (g_settings.cpu_recompiler_memory_exceptions) text.append_format("{}{}", first ? "" : "/", "ME"); } text.append("]:\x01 "); } else { text.assign(BOLD("CPU:") " "); } FormatProcessorStat(text, PerformanceCounters::GetCoreThreadUsage(), PerformanceCounters::GetCoreThreadAverageTime()); DrawPerformanceStat(dl, position_y, fixed_font, fixed_font_size, FIXED_BOLD_WEIGHT, 0, rbound, text); position_y += spacing; if (g_gpu_settings.gpu_use_thread) { text.assign(BOLD("RNDR:") " "); FormatProcessorStat(text, PerformanceCounters::GetVideoThreadUsage(), PerformanceCounters::GetVideoThreadAverageTime()); DrawPerformanceStat(dl, position_y, fixed_font, fixed_font_size, FIXED_BOLD_WEIGHT, 0, rbound, text); position_y += spacing; } #ifndef __ANDROID__ if (MediaCapture* cap = System::GetMediaCapture()) { text.assign(BOLD("CAP:") " "); FormatProcessorStat(text, cap->GetCaptureThreadUsage(), cap->GetCaptureThreadTime()); DrawPerformanceStat(dl, position_y, fixed_font, fixed_font_size, FIXED_BOLD_WEIGHT, 0, rbound, text); position_y += spacing; } #endif } if (g_gpu_settings.display_show_gpu_usage && g_gpu_device->IsGPUTimingEnabled()) { text.assign(BOLD("GPU:") " "); FormatProcessorStat(text, PerformanceCounters::GetGPUUsage(), PerformanceCounters::GetGPUAverageTime()); DrawPerformanceStat(dl, position_y, fixed_font, fixed_font_size, FIXED_BOLD_WEIGHT, 0, rbound, text); position_y += spacing; } if (g_gpu_settings.display_show_frame_times) DrawFrameTimeOverlay(position_y, scale, margin, spacing); if (g_gpu_settings.display_show_status_indicators) DrawStatusIndicators(dl, position_y, ui_font, status_size, rbound, text, false); } else if (g_gpu_settings.display_show_status_indicators) { DrawStatusIndicators(dl, position_y, ui_font, status_size, rbound, text, true); } #undef COLOR #undef BOLD } void ImGuiManager::DrawEnhancementsOverlay(const GPUBackend* gpu) { LargeString text; text.append_format("{} {}-{}", Settings::GetConsoleRegionName(System::GetRegion()), GPUDevice::RenderAPIToString(g_gpu_device->GetRenderAPI()), GPUBackend::IsUsingHardwareBackend() ? "HW" : "SW"); if (g_settings.rewind_enable) text.append_format(" RW={}/{}", g_settings.rewind_save_frequency, g_settings.rewind_save_slots); if (g_settings.IsRunaheadEnabled()) text.append_format(" RA={}{}", g_settings.runahead_frames, g_settings.runahead_for_analog_input ? "+A" : ""); if (g_settings.cpu_overclock_active) text.append_format(" CPU={}%", g_settings.GetCPUOverclockPercent()); if (g_settings.cpu_enable_8mb_ram) text.append(" 8MB"); if (g_settings.cdrom_read_speedup != 1) text.append_format(" CDR={}x", g_settings.cdrom_read_speedup); if (g_settings.cdrom_seek_speedup != 1) text.append_format(" CDS={}x", g_settings.cdrom_seek_speedup); if (g_gpu_settings.gpu_resolution_scale != 1) text.append_format(" IR={}x", g_gpu_settings.gpu_resolution_scale); if (g_gpu_settings.gpu_multisamples != 1) { text.append_format(" {}x{}", g_gpu_settings.gpu_multisamples, g_gpu_settings.gpu_per_sample_shading ? "SSAA" : "MSAA"); } if (g_gpu_settings.gpu_dithering_mode != GPUDitheringMode::Unscaled) text.append_format(" DT={}", Settings::GetGPUDitheringModeName(g_gpu_settings.gpu_dithering_mode)); text.append_format(" DI={}", Settings::GetDisplayDeinterlacingModeName(g_gpu_settings.display_deinterlacing_mode)); if (g_settings.gpu_force_video_timing == ForceVideoTimingMode::NTSC && System::GetRegion() == ConsoleRegion::PAL) text.append(" PAL60"); if (g_settings.gpu_force_video_timing == ForceVideoTimingMode::PAL && System::GetRegion() != ConsoleRegion::PAL) text.append(" NTSC50"); if (g_gpu_settings.gpu_texture_filter != GPUTextureFilter::Nearest) { if (g_gpu_settings.gpu_sprite_texture_filter != g_gpu_settings.gpu_texture_filter) { text.append_format(" {}/{}", Settings::GetTextureFilterName(g_gpu_settings.gpu_texture_filter), Settings::GetTextureFilterName(g_gpu_settings.gpu_sprite_texture_filter)); } else { text.append_format(" {}", Settings::GetTextureFilterName(g_gpu_settings.gpu_texture_filter)); } } if (g_settings.gpu_widescreen_hack && g_settings.display_aspect_ratio != DisplayAspectRatio::Auto() && g_settings.display_aspect_ratio != DisplayAspectRatio{4, 3}) { text.append(" WSHack"); } if (g_gpu_settings.gpu_line_detect_mode != GPULineDetectMode::Disabled) text.append_format(" LD={}", Settings::GetLineDetectModeName(g_gpu_settings.gpu_line_detect_mode)); if (g_settings.gpu_pgxp_enable) { text.append(" PGXP"); if (g_settings.gpu_pgxp_culling) text.append("/Cull"); if (g_settings.gpu_pgxp_texture_correction) text.append("/Tex"); if (g_settings.gpu_pgxp_color_correction) text.append("/Col"); if (g_settings.gpu_pgxp_vertex_cache) text.append("/VC"); if (g_settings.gpu_pgxp_cpu) text.append("/CPU"); if (g_settings.gpu_pgxp_depth_buffer) text.append("/Depth"); } const float margin = ImGuiManager::GetScreenMargin(); ImFont* const font = ImGuiManager::GetFixedFont(); const float font_size = ImGuiManager::GetFixedFontSize(); const float font_weight = 600.0f; const float position_y = ImGui::GetIO().DisplaySize.y - margin - font_size; const ImVec2 text_size = font->CalcTextSizeA(font_size, font_weight, std::numeric_limits::max(), -1.0f, text.c_str(), text.end_ptr(), nullptr); FullscreenUI::RenderOutlinedText(ImGui::GetBackgroundDrawList(), font, font_size, font_weight, ImVec2(ImGui::GetIO().DisplaySize.x - margin - text_size.x, position_y), IM_COL32(255, 255, 255, 255), text); } void ImGuiManager::DrawMediaCaptureOverlay(float& position_y, float scale, float margin, float spacing) { #ifndef __ANDROID__ MediaCapture* const cap = System::GetMediaCapture(); if (!cap) return; const float shadow_offset = std::ceil(scale); ImFont* const font = ImGuiManager::GetTextFont(); const float font_size = ImGuiManager::GetOSDFontSize(); static constexpr const float& font_weight = FullscreenUI::UIStyle.BoldFontWeight; ImDrawList* dl = ImGui::GetBackgroundDrawList(); static constexpr const char* ICON = ICON_PF_CIRCLE; const time_t elapsed_time = cap->GetElapsedTime(); const TinyString text_msg = TinyString::from_format(" {:02d}:{:02d}:{:02d}", elapsed_time / 3600, (elapsed_time % 3600) / 60, (elapsed_time % 3600) % 60); const ImVec2 icon_size = font->CalcTextSizeA(font_size, font_weight, std::numeric_limits::max(), -1.0f, ICON, nullptr, nullptr); const ImVec2 text_size = font->CalcTextSizeA(font_size, font_weight, std::numeric_limits::max(), -1.0f, text_msg.c_str(), text_msg.end_ptr(), nullptr); const float box_margin = 5.0f * scale; const ImVec2 box_size = ImVec2(ImCeil(icon_size.x + shadow_offset + text_size.x + box_margin * 2.0f), ImCeil(std::max(icon_size.y, text_size.y) + box_margin * 2.0f)); const ImVec2 box_pos = ImVec2(ImGui::GetIO().DisplaySize.x - margin - box_size.x, position_y); dl->AddRectFilled(box_pos, box_pos + box_size, IM_COL32(0, 0, 0, 64), box_margin); const ImVec2 text_start = ImVec2(box_pos.x + box_margin, box_pos.y + box_margin); dl->AddText(font, font_size, font_weight, ImVec2(text_start.x + shadow_offset, text_start.y + shadow_offset), IM_COL32(0, 0, 0, 100), ICON); dl->AddText(font, font_size, font_weight, ImVec2(text_start.x + icon_size.x + shadow_offset, text_start.y + shadow_offset), IM_COL32(0, 0, 0, 100), text_msg.c_str(), text_msg.end_ptr()); dl->AddText(font, font_size, font_weight, text_start, IM_COL32(255, 0, 0, 255), ICON); dl->AddText(font, font_size, font_weight, ImVec2(text_start.x + icon_size.x, text_start.y), IM_COL32(255, 255, 255, 255), text_msg.c_str(), text_msg.end_ptr()); position_y += box_size.y + spacing; #endif } void ImGuiManager::DrawFrameTimeOverlay(float& position_y, float scale, float margin, float spacing) { constexpr ImU32 text_color = IM_COL32(240, 240, 240, 255); constexpr ImU32 line_color = IM_COL32(240, 240, 240, 255); const float shadow_offset = OSDScale(1.0f); const float font_size = OSDScale(13.0f); static constexpr ImFont*& font = FullscreenUI::UIStyle.Font; static constexpr const float& font_weight = FullscreenUI::UIStyle.BoldFontWeight; const ImVec2 window_padding = ImVec2(OSDScale(8.0f), OSDScale(4.0f)); const ImVec2 window_size = ImVec2(OSDScale(200.0f), OSDScale(40.0f)); const ImVec2 padded_window_size = window_size + (window_padding * 2.0f); ImGui::SetNextWindowSize(padded_window_size); ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x - margin - padded_window_size.x, position_y)); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.25f)); ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); ImGui::PushStyleColor(ImGuiCol_PlotLines, line_color); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, OSDScale(8.0f)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, window_padding); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); if (ImGui::Begin("##frame_times", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoFocusOnAppearing)) { // LLVM likes to unroll this... whatever. float min, max; { const PerformanceCounters::FrameTimeHistory& history = PerformanceCounters::GetFrameTimeHistory(); static_assert((PerformanceCounters::NUM_FRAME_TIME_SAMPLES % 4) == 0); GSVector4 vmin = GSVector4::load(history.data()); GSVector4 vmax = vmin; for (size_t i = 4; i < history.size(); i += 4) { const GSVector4 v = GSVector4::load(&history[i]); vmin = vmin.min(v); vmax = vmax.max(v); } min = vmin.minv(); max = vmax.maxv(); } // add a little bit of space either side, so we're not constantly resizing if ((max - min) < 4.0f) { min = min - std::fmod(min, 1.0f); max = max - std::fmod(max, 1.0f) + 1.0f; min = std::max(min - 2.0f, 0.0f); max += 2.0f; } ImGui::PlotEx( ImGuiPlotType_Lines, "##frame_times", [](void*, int idx) -> float { return PerformanceCounters::GetFrameTimeHistory()[((PerformanceCounters::GetFrameTimeHistoryPos() + idx) % PerformanceCounters::NUM_FRAME_TIME_SAMPLES)]; }, nullptr, PerformanceCounters::NUM_FRAME_TIME_SAMPLES, 0, nullptr, min, max, window_size); ImGuiWindow* const win = ImGui::GetCurrentWindow(); ImDrawList* const win_dl = win->DrawList; TinyString text; text.format("{:.0f} ms", max); ImVec2 text_size = font->CalcTextSizeA(font_size, -1.0f, FLT_MAX, 0.0f, IMSTR_START_END(text)); FullscreenUI::RenderShadowedTextClipped(win_dl, font, font_size, font_weight, win->WorkRect.Min, win->WorkRect.Max, text_color, text, &text_size, ImVec2(1.0f, 0.0f), 0.0f, &win->WorkRect, shadow_offset); text.format("{:.0f} ms", min); text_size = font->CalcTextSizeA(font_size, -1.0f, FLT_MAX, 0.0f, text.c_str(), text.end_ptr()); FullscreenUI::RenderShadowedTextClipped(win_dl, font, font_size, font_weight, win->WorkRect.Min, win->WorkRect.Max, text_color, text, &text_size, ImVec2(1.0f, 1.0f), 0.0f, &win->WorkRect, shadow_offset); } ImGui::End(); ImGui::PopStyleVar(5); ImGui::PopStyleColor(3); position_y += padded_window_size.y + spacing; } void ImGuiManager::UpdateInputOverlay() { static constexpr u32 NORMAL_ICON_COLOR = 0xFFCCCCCCu; static constexpr u32 ALTERNATE_ICON_COLOR = 0xFF2534F0u; u32 num_active_pads = 0; for (u32 port = 0; port < NUM_CONTROLLER_AND_CARD_PORTS; port++) { if (g_settings.controller_types[port] != ControllerType::None) num_active_pads++; } const u32 buffer_size = sizeof(InputOverlayStateUpdateBuffer) + (static_cast(sizeof(InputOverlayState::PadState)) * num_active_pads); const auto& [cmd, buffer] = VideoThread::BeginASyncBufferCall(&ImGuiManager::UpdateInputOverlay, buffer_size); InputOverlayStateUpdateBuffer* const ubuffer = static_cast(buffer); ubuffer->num_active_pads = num_active_pads; size_t out_index = 0; const std::array multitap_ports = Controller::GetMultitapEnabledPorts(g_settings.multitap_mode); for (const u32 pad : Controller::PortDisplayOrder) { const Controller* controller = System::GetController(pad); if (!controller) continue; const ControllerType ctype = controller->GetType(); const auto& [port, slot] = Controller::ConvertPadToPortAndSlot(pad); InputOverlayState::PadState& pstate = ubuffer->pads[out_index++]; pstate.port = Truncate8(port); pstate.slot = Truncate8(slot); pstate.multitap = multitap_ports[port]; pstate.ctype = ctype; pstate.icon_color = NORMAL_ICON_COLOR; const Controller::ControllerInfo& cinfo = Controller::GetControllerInfo(ctype); for (const Controller::ControllerBindingInfo& bi : cinfo.bindings) { const u32 bidx = bi.bind_index; // this will leave some uninitalized, but who cares, it won't be read on the other side if (bi.type >= InputBindingInfo::Type::Button && bi.type <= InputBindingInfo::Type::Motor) { DebugAssert(bidx < InputOverlayState::MAX_BINDS); pstate.bind_state[bidx] = controller->GetBindState(bidx); } else if (bi.type == InputBindingInfo::Type::LED) { const float intensity = controller->GetBindState(bidx); pstate.icon_color = (GSVector4::cxpr_rgba32(NORMAL_ICON_COLOR) + (GSVector4::cxpr_rgba32(ALTERNATE_ICON_COLOR) - GSVector4::cxpr_rgba32(NORMAL_ICON_COLOR)) * GSVector4(intensity)) .rgba32(); } } } VideoThread::EndASyncBufferCall(cmd); } void ImGuiManager::UpdateInputOverlay(void* buffer) { InputOverlayStateUpdateBuffer* const RESTRICT ubuffer = static_cast(buffer); DebugAssert(ubuffer->num_active_pads <= NUM_CONTROLLER_AND_CARD_PORTS); s_input_overlay_state.num_active_pads = ubuffer->num_active_pads; for (u32 i = 0; i < ubuffer->num_active_pads; i++) s_input_overlay_state.pads[i] = ubuffer->pads[i]; } void ImGuiManager::DrawInputsOverlay() { const float scale = ImGuiManager::GetGlobalScale(); const float margin = ImGuiManager::GetScreenMargin(); const float spacing = ImCeil(5.0f * scale); ImFont* const font = ImGuiManager::GetTextFont(); const float font_size = ImGuiManager::GetOSDFontSize(); const float font_weight = 400.0f; static constexpr u32 text_color = IM_COL32(0xff, 0xff, 0xff, 255); const ImVec2& display_size = ImGui::GetIO().DisplaySize; ImDrawList* dl = ImGui::GetBackgroundDrawList(); float current_x = ImFloor(margin); float current_y = ImFloor(display_size.y - margin - ((static_cast(s_input_overlay_state.num_active_pads) * (font_size + spacing)) - spacing)); // This is a bit of a pain. Some of the glyphs slightly overhang/overshoot past the baseline, resulting // in the glyphs getting clipped if we use the text height/margin as a clip point. Instead, just clamp it // to the display size, the margin should be enough to allow for overshooting. const ImVec4 clip_rect(current_x, current_y, display_size.x - margin, display_size.y); SmallString text; for (u32 i = 0; i < s_input_overlay_state.num_active_pads; i++) { const InputOverlayState::PadState& pstate = s_input_overlay_state.pads[i]; const Controller::ControllerInfo& cinfo = Controller::GetControllerInfo(pstate.ctype); const char* port_label = Controller::GetPortDisplayName(pstate.port, pstate.slot, pstate.multitap); float text_start_x = current_x; if (cinfo.icon_name) { const ImVec2 icon_size = FullscreenUI::RenderOutlinedText( dl, font, font_size, font_weight, ImVec2(current_x, current_y), pstate.icon_color, cinfo.icon_name); text_start_x += icon_size.x; text.format(" {}", port_label); } else { text.format("{} |", port_label); } for (const Controller::ControllerBindingInfo& bi : cinfo.bindings) { if (bi.type >= InputBindingInfo::Type::Button && bi.type <= InputBindingInfo::Type::HalfAxis) { // axes are always shown when not near resting, buttons only shown when active const float value = pstate.bind_state[bi.bind_index]; const float threshold = (bi.type == InputBindingInfo::Type::Button) ? 0.5f : (254.0f / 255.0f); if (value >= threshold) text.append_format(" {}", bi.icon_name ? bi.icon_name : bi.name); else if (value > (1.0f / 255.0f)) text.append_format(" {}: {:.2f}", bi.icon_name ? bi.icon_name : bi.name, value); } else if (bi.type == InputBindingInfo::Type::Motor) { const float value = pstate.bind_state[bi.bind_index]; if (value >= 1.0f) text.append_format(" {}", bi.icon_name ? bi.icon_name : bi.name); else if (value > 0.0f) text.append_format(" {}: {:.0f}%", bi.icon_name ? bi.icon_name : bi.name, std::ceil(value * 100.0f)); } } FullscreenUI::RenderOutlinedText(dl, font, font_size, font_weight, ImVec2(text_start_x, current_y), text_color, text); current_y += font_size + spacing; } } namespace SaveStateSelectorUI { namespace { struct ListEntry { std::string summary; std::string game_details; // only in global slots std::string filename; std::unique_ptr preview_texture; s32 slot; bool global; }; } // namespace static void InitializePlaceholderListEntry(ListEntry* li, const std::string& path, s32 slot, bool global, bool exists, Error&& error); static void InitializeListEntry(ListEntry* li, ExtendedSaveStateInfo* ssi, const std::string& path, s32 slot, bool global); static void DestroyTextures(); static void RefreshHotkeyLegend(); static void Draw(); static void ShowSlotOSDMessage(); static constexpr const char* DATE_TIME_FORMAT = TRANSLATE_NOOP("SaveStateSelectorUI", "Saved at {0:%H:%M} on {0:%a} {0:%Y/%m/%d}."); namespace { struct State { std::shared_ptr placeholder_texture; std::string load_legend; std::string save_legend; std::string prev_legend; std::string next_legend; llvm::SmallVector slots; s32 current_slot = 0; bool current_slot_global = false; bool global_slots_enabled = false; float open_time = 0.0f; float close_time = 0.0f; ImAnimatedFloat scroll_animated; ImAnimatedFloat background_animated; bool is_open = false; }; } // namespace ALIGN_TO_CACHE_LINE static State s_state; } // namespace SaveStateSelectorUI bool SaveStateSelectorUI::IsOpen() { return s_state.is_open; } void SaveStateSelectorUI::Open(float open_time /* = DEFAULT_OPEN_TIME */) { s_state.open_time = 0.0f; s_state.close_time = open_time; if (s_state.is_open) return; if (!s_state.placeholder_texture) s_state.placeholder_texture = FullscreenUI::LoadTexture("no-save.png"); s_state.is_open = true; s_state.global_slots_enabled = System::AreGlobalSaveStatesEnabled(); RefreshList(); RefreshHotkeyLegend(); if (s_state.slots.empty()) { FullscreenUI::ShowToast(OSDMessageType::Info, {}, TRANSLATE_STR("SaveStateSelectorUI", "No save state slots available.")); Close(); } } void SaveStateSelectorUI::Close() { s_state.is_open = false; s_state.load_legend = {}; s_state.save_legend = {}; s_state.prev_legend = {}; s_state.next_legend = {}; } void SaveStateSelectorUI::RefreshList() { for (ListEntry& entry : s_state.slots) { if (entry.preview_texture) g_gpu_device->RecycleTexture(std::move(entry.preview_texture)); } s_state.slots.clear(); const std::string& serial = VideoThread::GetGameSerial(); if (!serial.empty()) { for (s32 i = 1; i <= System::PER_GAME_SAVE_STATE_SLOTS; i++) { Error error; bool exists; std::string path = System::GetGameSaveStatePath(serial, i); std::optional ssi = System::GetExtendedSaveStateInfo(path.c_str(), &error, &exists); ListEntry li; if (ssi) InitializeListEntry(&li, &ssi.value(), std::move(path), i, false); else InitializePlaceholderListEntry(&li, std::move(path), i, false, exists, std::move(error)); s_state.slots.push_back(std::move(li)); } } if (s_state.global_slots_enabled) { // reset slot if it's not global if (serial.empty() && !s_state.current_slot_global) { s_state.current_slot = 0; s_state.current_slot_global = true; } for (s32 i = 1; i <= System::GLOBAL_SAVE_STATE_SLOTS; i++) { Error error; bool exists; std::string path = System::GetGlobalSaveStatePath(i); std::optional ssi = System::GetExtendedSaveStateInfo(path.c_str(), &error, &exists); ListEntry li; if (ssi) InitializeListEntry(&li, &ssi.value(), std::move(path), i, true); else InitializePlaceholderListEntry(&li, std::move(path), i, true, exists, std::move(error)); s_state.slots.push_back(std::move(li)); } } } void SaveStateSelectorUI::Clear() { // called on CPU thread at shutdown, textures should already be deleted, unless running // big picture UI, in which case we have to delete them here... ClearList(); s_state.current_slot = 0; s_state.current_slot_global = false; s_state.scroll_animated.Reset(0.0f); s_state.background_animated.Reset(0.0f); } void SaveStateSelectorUI::ClearList() { DebugAssert(VideoThread::IsOnThread()); for (ListEntry& li : s_state.slots) { if (li.preview_texture) g_gpu_device->RecycleTexture(std::move(li.preview_texture)); } s_state.slots.clear(); } void SaveStateSelectorUI::DestroyTextures() { Close(); for (ListEntry& entry : s_state.slots) { if (entry.preview_texture) g_gpu_device->RecycleTexture(std::move(entry.preview_texture)); } s_state.placeholder_texture.reset(); } void SaveStateSelectorUI::RefreshHotkeyLegend() { auto format_legend_entry = [](SmallString binding, std::string_view caption) { InputManager::PrettifyInputBinding(binding, true, &FullscreenUI::GetControllerIconMapping); return fmt::format("{} {}", binding, caption); }; s_state.load_legend = format_legend_entry(Core::GetSmallStringSettingValue("Hotkeys", "LoadSelectedSaveState"), TRANSLATE_SV("SaveStateSelectorUI", "Load")); s_state.save_legend = format_legend_entry(Core::GetSmallStringSettingValue("Hotkeys", "SaveSelectedSaveState"), TRANSLATE_SV("SaveStateSelectorUI", "Save")); s_state.prev_legend = format_legend_entry(Core::GetSmallStringSettingValue("Hotkeys", "SelectPreviousSaveStateSlot"), TRANSLATE_SV("SaveStateSelectorUI", "Select Previous")); s_state.next_legend = format_legend_entry(Core::GetSmallStringSettingValue("Hotkeys", "SelectNextSaveStateSlot"), TRANSLATE_SV("SaveStateSelectorUI", "Select Next")); } void SaveStateSelectorUI::SelectNextSlot(bool open_selector) { const s32 total_slots = s_state.current_slot_global ? System::GLOBAL_SAVE_STATE_SLOTS : System::PER_GAME_SAVE_STATE_SLOTS; s_state.current_slot++; if (s_state.current_slot >= total_slots) { if (s_state.global_slots_enabled && !VideoThread::GetGameSerial().empty()) s_state.current_slot_global ^= true; s_state.current_slot -= total_slots; } if (open_selector) { if (!s_state.is_open) Open(); else s_state.open_time = 0.0f; // stay open for the full duration } else { ShowSlotOSDMessage(); } } void SaveStateSelectorUI::SelectPreviousSlot(bool open_selector) { s_state.current_slot--; if (s_state.current_slot < 0) { if (s_state.global_slots_enabled && !VideoThread::GetGameSerial().empty()) s_state.current_slot_global ^= true; s_state.current_slot += s_state.current_slot_global ? System::GLOBAL_SAVE_STATE_SLOTS : System::PER_GAME_SAVE_STATE_SLOTS; } if (open_selector) { if (!s_state.is_open) Open(); else s_state.open_time = 0.0f; // stay open for the full duration } else { ShowSlotOSDMessage(); } } void SaveStateSelectorUI::InitializeListEntry(ListEntry* li, ExtendedSaveStateInfo* ssi, const std::string& path, s32 slot, bool global) { if (global) li->game_details = fmt::format(TRANSLATE_FS("SaveStateSelectorUI", "{} ({})"), ssi->title, ssi->serial); li->summary = fmt::format(TRANSLATE_FS("SaveStateSelectorUI", DATE_TIME_FORMAT), Common::LocalTime(static_cast(ssi->timestamp)).value_or(std::tm{})); li->filename = Path::GetFileName(path); li->slot = slot; li->global = global; // Might not have a display yet, we're called at startup.. if (g_gpu_device) { g_gpu_device->RecycleTexture(std::move(li->preview_texture)); if (ssi->screenshot.IsValid()) { li->preview_texture = g_gpu_device->FetchTexture( ssi->screenshot.GetWidth(), ssi->screenshot.GetHeight(), 1, 1, 1, GPUTexture::Type::Texture, GPUTextureFormat::RGBA8, GPUTexture::Flags::None, ssi->screenshot.GetPixels(), ssi->screenshot.GetPitch()); if (!li->preview_texture) [[unlikely]] ERROR_LOG("Failed to upload save state image to GPU"); } } } void SaveStateSelectorUI::InitializePlaceholderListEntry(ListEntry* li, const std::string& path, s32 slot, bool global, bool exists, Error&& error) { if (!exists) li->summary = TRANSLATE_STR("SaveStateSelectorUI", "No save present in this slot."); else if (error.IsValid()) li->summary = error.TakeDescription(); else li->summary = TRANSLATE_STR("SaveStateSelectorUI", "Unknown error."); li->slot = slot; li->global = global; } void SaveStateSelectorUI::Draw() { using FullscreenUI::DarkerColor; using FullscreenUI::LayoutScale; using FullscreenUI::UIStyle; static constexpr float SCROLL_ANIMATION_TIME = 0.25f; static constexpr float BG_ANIMATION_TIME = 0.15f; const auto& io = ImGui::GetIO(); const float width = LayoutScale(640.0f); const float height = LayoutScale(480.0f); const float padding_and_rounding = LayoutScale(18.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, padding_and_rounding); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding_and_rounding, padding_and_rounding)); ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, LayoutScale(14.0f)); ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, LayoutScale(10.0f)); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, LayoutScale(8.0f, 4.0f)); ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, LayoutScale(4.0f, 2.0f)); ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, UIStyle.PrimaryColor); ImGui::PushStyleColor(ImGuiCol_ScrollbarBg, UIStyle.BackgroundColor); ImGui::PushStyleColor(ImGuiCol_WindowBg, DarkerColor(UIStyle.PopupBackgroundColor)); ImGui::PushStyleColor(ImGuiCol_Text, UIStyle.BackgroundTextColor); ImGui::SetNextWindowSize(ImVec2(width, height), ImGuiCond_Always); ImGui::SetNextWindowPos(ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); if (FullscreenUI::BeginBlurWindow("##save_state_selector", nullptr, ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar, UIStyle.BlurMenuBackground)) { // Leave 2 lines for the legend const ImGuiStyle& style = ImGui::GetStyle(); const float padding = LayoutScale(12.0f); const float legend_margin = UIStyle.MediumFontSize * 2.0f + padding + style.ItemSpacing.y * 2.0f + style.CellPadding.y * 2.0f; ImGui::BeginChild("##item_list", ImVec2(0, -legend_margin), false, ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoBackground); { const s32 current_slot = GetCurrentSlot(); const bool current_slot_global = IsCurrentSlotGlobal(); const ImVec2 image_size = LayoutScale(128.0f, (128.0f / (4.0f / 3.0f))); const float item_width = std::floor(width - (padding_and_rounding * 2.0f) - style.ScrollbarSize); const float item_height = std::floor(image_size.y + padding * 2.0f); const float text_indent = image_size.x + padding + padding; ImDrawList* const dl = ImGui::GetWindowDrawList(); dl->ChannelsSplit(2); dl->ChannelsSetCurrent(1); for (size_t i = 0; i < s_state.slots.size(); i++) { const ListEntry& entry = s_state.slots[i]; const float y_start = item_height * static_cast(i); if (entry.slot == current_slot && entry.global == current_slot_global) { ImGui::SetCursorPosY(y_start); ImGui::PushStyleColor(ImGuiCol_Text, UIStyle.PrimaryTextColor); const ImVec2 p_start(ImGui::GetCursorScreenPos()); const ImVec2 p_end(p_start.x + item_width, p_start.y + item_height); const ImRect item_rect(p_start, p_end); const ImRect& window_rect = ImGui::GetCurrentWindow()->ClipRect; if (!window_rect.Contains(item_rect)) { float scroll_target = ImGui::GetScrollY(); if (item_rect.Min.y < window_rect.Min.y) scroll_target = (ImGui::GetScrollY() - (window_rect.Min.y - item_rect.Min.y)); else if (item_rect.Max.y > window_rect.Max.y) scroll_target = (ImGui::GetScrollY() + (item_rect.Max.y - window_rect.Max.y)); if (scroll_target != s_state.scroll_animated.GetEndValue()) s_state.scroll_animated.Start(ImGui::GetScrollY(), scroll_target, SCROLL_ANIMATION_TIME); } if (s_state.scroll_animated.IsActive()) ImGui::SetScrollY(s_state.scroll_animated.UpdateAndGetValue()); if (s_state.background_animated.GetEndValue() != p_start.y) s_state.background_animated.Start(s_state.background_animated.UpdateAndGetValue(), p_start.y, BG_ANIMATION_TIME); ImVec2 highlight_pos; if (s_state.background_animated.IsActive()) highlight_pos = ImVec2(p_start.x, s_state.background_animated.UpdateAndGetValue()); else highlight_pos = p_start; dl->ChannelsSetCurrent(0); dl->AddRectFilled(highlight_pos, ImVec2(highlight_pos.x + item_width, highlight_pos.y + item_height), ImGui::GetColorU32(UIStyle.PopupHighlight), padding_and_rounding); dl->ChannelsSetCurrent(1); } if (GPUTexture* preview_texture = entry.preview_texture ? entry.preview_texture.get() : s_state.placeholder_texture.get()) { ImGui::SetCursorPosY(y_start + padding); ImGui::SetCursorPosX(padding); ImGui::Image(preview_texture, image_size); } ImGui::SetCursorPosY(y_start + padding); ImGui::Indent(text_indent); ImGui::PushFont(ImGuiManager::GetTextFont(), UIStyle.MediumLargeFontSize, UIStyle.BoldFontWeight); ImGui::TextUnformatted(TinyString::from_format(entry.global ? TRANSLATE_FS("SaveStateSelectorUI", "Global Slot {}") : TRANSLATE_FS("SaveStateSelectorUI", "Game Slot {}"), entry.slot) .c_str()); ImGui::PopFont(); ImGui::PushFont(ImGuiManager::GetTextFont(), UIStyle.MediumFontSize, UIStyle.NormalFontWeight); ImGui::PushStyleColor(ImGuiCol_Text, FullscreenUI::DarkerColor(style.Colors[ImGuiCol_Text])); if (entry.global) ImGui::TextUnformatted(IMSTR_START_END(entry.game_details)); ImGui::TextUnformatted(IMSTR_START_END(entry.summary)); ImGui::PushStyleColor(ImGuiCol_Text, FullscreenUI::DarkerColor(style.Colors[ImGuiCol_Text])); ImGui::TextUnformatted(IMSTR_START_END(entry.filename)); ImGui::PopFont(); ImGui::PopStyleColor(2); ImGui::Unindent(text_indent); ImGui::SetCursorPosY(y_start); ImGui::ItemSize(ImVec2(item_width, item_height)); if (entry.slot == current_slot && entry.global == current_slot_global) ImGui::PopStyleColor(); } dl->ChannelsMerge(); } ImGui::EndChild(); ImGui::SetCursorPosY(ImGui::GetCursorPosY() + padding); ImGui::BeginChild("##legend", ImVec2(0, 0), false, ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground); { ImGui::PushFont(ImGuiManager::GetTextFont(), UIStyle.MediumFontSize, UIStyle.BoldFontWeight); ImGui::SetCursorPosX(padding); if (ImGui::BeginTable("table", 2)) { ImGui::TableNextColumn(); ImGui::TextUnformatted(s_state.load_legend.c_str()); ImGui::TableNextColumn(); ImGui::TextUnformatted(s_state.prev_legend.c_str()); ImGui::TableNextColumn(); ImGui::TextUnformatted(s_state.save_legend.c_str()); ImGui::TableNextColumn(); ImGui::TextUnformatted(s_state.next_legend.c_str()); ImGui::EndTable(); } ImGui::PopFont(); } ImGui::EndChild(); } ImGui::End(); ImGui::PopStyleVar(6); ImGui::PopStyleColor(4); // auto-close s_state.open_time += io.DeltaTime; if (s_state.open_time >= s_state.close_time) { Close(); } else if (ImGui::IsKeyPressed(ImGuiKey_Escape)) { // Need to cancel the hotkey bindings, otherwise the pause menu will open. InputManager::ClearBindStateFromSource(InputManager::MakeHostKeyboardKey(0)); Close(); } } s32 SaveStateSelectorUI::GetCurrentSlot() { return s_state.current_slot + 1; } bool SaveStateSelectorUI::IsCurrentSlotGlobal() { return s_state.current_slot_global; } void SaveStateSelectorUI::LoadCurrentSlot() { DebugAssert(VideoThread::IsOnThread()); Host::RunOnCoreThread( [global = IsCurrentSlotGlobal(), slot = GetCurrentSlot()]() { System::LoadStateFromSlot(global, slot); }); Close(); } void SaveStateSelectorUI::SaveCurrentSlot() { Host::RunOnCoreThread( [global = IsCurrentSlotGlobal(), slot = GetCurrentSlot()]() { System::SaveStateToSlot(global, slot); }); Close(); } void SaveStateSelectorUI::ShowSlotOSDMessage() { const std::string path = IsCurrentSlotGlobal() ? System::GetGlobalSaveStatePath(GetCurrentSlot()) : System::GetGameSaveStatePath(VideoThread::GetGameSerial(), GetCurrentSlot()); FILESYSTEM_STAT_DATA sd; std::string date; if (!path.empty() && FileSystem::StatFile(path.c_str(), &sd)) { date = fmt::format(TRANSLATE_FS("SaveStateSelectorUI", DATE_TIME_FORMAT), Common::LocalTime(sd.ModificationTime).value_or(std::tm{})); } else { date = TRANSLATE_STR("SaveStateSelectorUI", "No save in this slot."); } Host::AddIconOSDMessage( OSDMessageType::Quick, "ShowSlotOSDMessage", ICON_EMOJI_MAGNIFIYING_GLASS_TILTED_LEFT, IsCurrentSlotGlobal() ? fmt::format(TRANSLATE_FS("SaveStateSelectorUI", "Global Save Slot {} selected."), GetCurrentSlot()) : fmt::format(TRANSLATE_FS("SaveStateSelectorUI", "Save Slot {0} selected."), GetCurrentSlot()), std::move(date)); } void ImGuiManager::RenderOverlayWindows() { const System::State state = System::GetState(); if (state == System::State::Paused || state == System::State::Running) { if (SaveStateSelectorUI::s_state.is_open) SaveStateSelectorUI::Draw(); } } void ImGuiManager::DestroyOverlayTextures() { SaveStateSelectorUI::DestroyTextures(); }