From cce40a85dddc11e8d88f50679f3ba46fbb3d5537 Mon Sep 17 00:00:00 2001
From: Silent <zdanio95@gmail.com>
Date: Fri, 18 Jun 2021 23:40:11 +0200
Subject: [PATCH] Implemented Leaderboards UI

---
 src/frontend-common/cheevos.cpp       | 137 +++++++++-
 src/frontend-common/cheevos.h         |  17 +-
 src/frontend-common/fullscreen_ui.cpp | 348 +++++++++++++++++++++++++-
 src/frontend-common/fullscreen_ui.h   |   1 +
 4 files changed, 499 insertions(+), 4 deletions(-)

diff --git a/src/frontend-common/cheevos.cpp b/src/frontend-common/cheevos.cpp
index feec63480..34ac35a22 100644
--- a/src/frontend-common/cheevos.cpp
+++ b/src/frontend-common/cheevos.cpp
@@ -79,6 +79,9 @@ static bool s_has_rich_presence = false;
 static std::string s_rich_presence_string;
 static Common::Timer s_last_ping_time;
 
+static u32 s_last_queried_lboard;
+static std::optional<std::vector<LeaderboardEntry>> s_lboard_entries;
+
 static u32 s_total_image_downloads;
 static u32 s_completed_image_downloads;
 static bool s_image_download_progress_active;
@@ -745,7 +748,7 @@ static void GetPatchesCallback(s32 status_code, const FrontendCommon::HTTPDownlo
       lboard.id = id;
       lboard.title = title;
       lboard.description = std::move(description);
-      lboard.format = format;
+      lboard.format = rc_parse_format(format);
       s_leaderboards.push_back(std::move(lboard));
 
       const int err = rc_runtime_activate_lboard(&s_rcheevos_runtime, id, memaddr, nullptr, 0);
@@ -798,6 +801,69 @@ static void GetPatchesCallback(s32 status_code, const FrontendCommon::HTTPDownlo
   }
 }
 
+static void GetLbInfoCallback(s32 status_code, const FrontendCommon::HTTPDownloader::Request::Data& data)
+{
+  rapidjson::Document doc;
+  if (!ParseResponseJSON("Get Leaderboard Info", status_code, data, doc))
+    return;
+
+  if (!doc.HasMember("LeaderboardData") || !doc["LeaderboardData"].IsObject())
+  {
+    FormattedError("No leaderboard returned from server.");
+    return;
+  }
+
+  // parse info
+  const auto lb_data(doc["LeaderboardData"].GetObject());
+  if (!lb_data["LBID"].IsUint())
+  {
+    FormattedError("Leaderboard data is missing leadeboard ID");
+    return;
+  }
+
+  const u32 lbid = lb_data["LBID"].GetUint();
+  if (lbid != s_last_queried_lboard)
+  {
+    // User has already requested another leaderboard, drop this data
+    return;
+  }
+
+  if (lb_data.HasMember("Entries") && lb_data["Entries"].IsArray())
+  {
+    const Leaderboard* leaderboard = GetLeaderboardByID(lbid);
+    if (leaderboard == nullptr)
+    {
+      Log_ErrorPrintf("Attempting to list unknown leaderboard %u", lbid);
+      return;
+    }
+
+    std::vector<LeaderboardEntry> entries;
+
+    const auto lb_entries(lb_data["Entries"].GetArray());
+    for (const auto& entry : lb_entries)
+    {
+      if (!entry.HasMember("User") || !entry["User"].IsString() || !entry.HasMember("Score") ||
+          !entry["Score"].IsNumber() || !entry.HasMember("Rank") || !entry["Rank"].IsNumber())
+      {
+        continue;
+      }
+
+      char score[128];
+      rc_format_value(score, sizeof(score), entry["Score"].GetInt(), leaderboard->format);
+
+      LeaderboardEntry lbe;
+      lbe.user = entry["User"].GetString();
+      lbe.rank = entry["Rank"].GetUint();
+      lbe.formatted_score = score;
+      lbe.is_self = lbe.user == s_username;
+
+      entries.push_back(std::move(lbe));
+    }
+
+    s_lboard_entries = std::move(entries);
+  }
+}
+
 static void GetPatches(u32 game_id)
 {
   char url[512];
@@ -1074,6 +1140,72 @@ u32 GetCurrentPointsForGame()
   return points;
 }
 
+bool EnumerateLeaderboards(std::function<bool(const Leaderboard&)> callback)
+{
+  for (const Leaderboard& lboard : s_leaderboards)
+  {
+    if (!callback(lboard))
+      return false;
+  }
+
+  return true;
+}
+
+std::optional<bool> TryEnumerateLeaderboardEntries(u32 id, std::function<bool(const LeaderboardEntry&)> callback)
+{
+  if (id == s_last_queried_lboard)
+  {
+    if (s_lboard_entries)
+    {
+      for (const LeaderboardEntry& entry : *s_lboard_entries)
+      {
+        if (!callback(entry))
+          return false;
+      }
+      return true;
+    }
+  }
+  else
+  {
+    // TODO: Add paging? For now, stick to defaults
+    char url[512];
+
+    size_t written = 0;
+    rc_url_build_dorequest(url, sizeof(url), &written, "lbinfo", s_username.c_str());
+    rc_url_append_unum(url, sizeof(url), &written, "i", id);
+    rc_url_append_unum(url, sizeof(url), &written, "c",
+                       15); // Just over what a single page can store, should be a reasonable amount for now
+    // rc_url_append_unum(url, sizeof(url), &written, "o", 0);
+
+    s_last_queried_lboard = id;
+    s_lboard_entries.reset();
+    s_http_downloader->CreateRequest(url, GetLbInfoCallback);
+  }
+
+  return std::nullopt;
+}
+
+const Leaderboard* GetLeaderboardByID(u32 id)
+{
+  for (const Leaderboard& lb : s_leaderboards)
+  {
+    if (lb.id == id)
+      return &lb;
+  }
+
+  return nullptr;
+}
+
+u32 GetLeaderboardCount()
+{
+  return static_cast<u32>(s_leaderboards.size());
+}
+
+bool IsLeaderboardTimeType(const Leaderboard& leaderboard)
+{
+  return leaderboard.format != RC_FORMAT_SCORE && leaderboard.format != RC_FORMAT_VALUE;
+}
+
 void ActivateLockedAchievements()
 {
   for (Achievement& cheevo : s_achievements)
@@ -1124,7 +1256,8 @@ static void UnlockAchievementCallback(s32 status_code, const FrontendCommon::HTT
 
 static void SubmitLeaderboardCallback(s32 status_code, const FrontendCommon::HTTPDownloader::Request::Data& data)
 {
-  // Do nothing (for now?)
+  // Force the next leaderboard query to repopulate everything, just in case the user wants to see their new score
+  s_last_queried_lboard = 0;
 }
 
 void UnlockAchievement(u32 achievement_id, bool add_notification /* = true*/)
diff --git a/src/frontend-common/cheevos.h b/src/frontend-common/cheevos.h
index b7b033d32..69d9941b4 100644
--- a/src/frontend-common/cheevos.h
+++ b/src/frontend-common/cheevos.h
@@ -1,6 +1,7 @@
 #pragma once
 #include "core/types.h"
 #include <functional>
+#include <optional>
 #include <string>
 
 class CDImage;
@@ -33,7 +34,15 @@ struct Leaderboard
   u32 id;
   std::string title;
   std::string description;
-  std::string format;
+  int format;
+};
+
+struct LeaderboardEntry
+{
+  std::string user;
+  std::string formatted_score;
+  u32 rank;
+  bool is_self;
 };
 
 extern bool g_active;
@@ -98,6 +107,12 @@ u32 GetAchievementCount();
 u32 GetMaximumPointsForGame();
 u32 GetCurrentPointsForGame();
 
+bool EnumerateLeaderboards(std::function<bool(const Leaderboard&)> callback);
+std::optional<bool> TryEnumerateLeaderboardEntries(u32 id, std::function<bool(const LeaderboardEntry&)> callback);
+const Leaderboard* GetLeaderboardByID(u32 id);
+u32 GetLeaderboardCount();
+bool IsLeaderboardTimeType(const Leaderboard& leaderboard);
+
 void UnlockAchievement(u32 achievement_id, bool add_notification = true);
 void SubmitLeaderboard(u32 leaderboard_id, int value);
 
diff --git a/src/frontend-common/fullscreen_ui.cpp b/src/frontend-common/fullscreen_ui.cpp
index cfdb4a6ed..33bb305d6 100644
--- a/src/frontend-common/fullscreen_ui.cpp
+++ b/src/frontend-common/fullscreen_ui.cpp
@@ -85,6 +85,7 @@ static void ReturnToMainWindow();
 static void DrawLandingWindow();
 static void DrawQuickMenu(MainWindowType type);
 static void DrawAchievementWindow();
+static void DrawLeaderboardsWindow();
 static void DrawDebugMenu();
 static void DrawStatsOverlay();
 static void DrawOSDMessages();
@@ -112,6 +113,7 @@ static bool s_quick_menu_was_open = false;
 static bool s_was_paused_on_quick_menu_open = false;
 static bool s_about_window_open = false;
 static u32 s_close_button_state = 0;
+static std::optional<u32> s_open_leaderboard_id;
 
 //////////////////////////////////////////////////////////////////////////
 // Resources
@@ -343,6 +345,9 @@ void Render()
     case MainWindowType::Achievements:
       DrawAchievementWindow();
       break;
+    case MainWindowType::Leaderboards:
+      DrawLeaderboardsWindow();
+      break;
     default:
       break;
   }
@@ -2531,7 +2536,7 @@ void DrawQuickMenu(MainWindowType type)
   if (BeginFullscreenWindow(window_pos, window_size, "pause_menu", ImVec4(0.0f, 0.0f, 0.0f, 0.0f), 0.0f, 10.0f,
                             ImGuiWindowFlags_NoBackground))
   {
-    BeginMenuButtons(12, 1.0f, ImGuiFullscreen::LAYOUT_MENU_BUTTON_X_PADDING,
+    BeginMenuButtons(13, 1.0f, ImGuiFullscreen::LAYOUT_MENU_BUTTON_X_PADDING,
                      ImGuiFullscreen::LAYOUT_MENU_BUTTON_Y_PADDING,
                      ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY);
 
@@ -2550,8 +2555,16 @@ void DrawQuickMenu(MainWindowType type)
     if (ActiveButton(ICON_FA_TROPHY "  Achievements", false, achievements_enabled))
       s_current_main_window = MainWindowType::Achievements;
 
+    const bool leaderboards_enabled = Cheevos::HasActiveGame() && (Cheevos::GetLeaderboardCount() > 0);
+    if (ActiveButton(ICON_FA_STOPWATCH "  Leaderboards", false, leaderboards_enabled))
+    {
+      s_current_main_window = MainWindowType::Leaderboards;
+      s_open_leaderboard_id.reset();
+    }
+
 #else
     ActiveButton(ICON_FA_TROPHY "  Achievements", false, false);
+    ActiveButton(ICON_FA_STOPWATCH "  Leaderboards", false, false);
 #endif
 
     if (ActiveButton(ICON_FA_CAMERA "  Save Screenshot", false))
@@ -4259,9 +4272,342 @@ void DrawAchievementWindow()
   EndFullscreenWindow();
 }
 
+static void DrawLeaderboardListEntry(const Cheevos::Leaderboard& lboard)
+{
+  static constexpr float alpha = 0.8f;
+
+  TinyString id_str;
+  id_str.Format("%u", lboard.id);
+
+  ImRect bb;
+  bool visible, hovered;
+  bool pressed =
+    MenuButtonFrame(id_str, true, LAYOUT_MENU_BUTTON_HEIGHT, &visible, &hovered, &bb.Min, &bb.Max, 0, alpha);
+  if (!visible)
+    return;
+
+  const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f);
+  const float text_start_x = bb.Min.x + LayoutScale(15.0f);
+  const ImRect title_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
+  const ImRect summary_bb(ImVec2(text_start_x, midpoint), bb.Max);
+
+  ImGui::PushFont(g_large_font);
+  ImGui::RenderTextClipped(title_bb.Min, title_bb.Max, lboard.title.c_str(), lboard.title.c_str() + lboard.title.size(),
+                           nullptr, ImVec2(0.0f, 0.0f), &title_bb);
+  ImGui::PopFont();
+
+  if (!lboard.description.empty())
+  {
+    ImGui::PushFont(g_medium_font);
+    ImGui::RenderTextClipped(summary_bb.Min, summary_bb.Max, lboard.description.c_str(),
+                             lboard.description.c_str() + lboard.description.size(), nullptr, ImVec2(0.0f, 0.0f),
+                             &summary_bb);
+    ImGui::PopFont();
+  }
+
+  if (pressed)
+  {
+    s_open_leaderboard_id = lboard.id;
+  }
+}
+
+static void DrawLeaderboardEntry(const Cheevos::LeaderboardEntry& lbEntry, float rank_column_width,
+                                 float name_column_width, float column_spacing)
+{
+  static constexpr float alpha = 0.8f;
+
+  ImRect bb;
+  bool visible, hovered;
+  bool pressed = MenuButtonFrame(lbEntry.user.c_str(), true, LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY, &visible, &hovered,
+                                 &bb.Min, &bb.Max, 0, alpha);
+  if (!visible)
+    return;
+
+  const float spacing = LayoutScale(10.0f);
+  const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f);
+  float text_start_x = bb.Min.x + LayoutScale(15.0f);
+  SmallString text;
+
+  text.Format("%u", lbEntry.rank);
+
+  ImGui::PushFont(g_large_font);
+  if (lbEntry.is_self)
+  {
+    ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(255, 242, 0, 255));
+  }
+
+  const ImRect rank_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
+  ImGui::RenderTextClipped(rank_bb.Min, rank_bb.Max, text.GetCharArray(), text.GetCharArray() + text.GetLength(),
+                           nullptr, ImVec2(0.0f, 0.0f), &rank_bb);
+  text_start_x += rank_column_width + column_spacing;
+
+  const ImRect user_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
+  ImGui::RenderTextClipped(user_bb.Min, user_bb.Max, lbEntry.user.c_str(), lbEntry.user.c_str() + lbEntry.user.size(),
+                           nullptr, ImVec2(0.0f, 0.0f), &user_bb);
+  text_start_x += name_column_width + column_spacing;
+
+  const ImRect score_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
+  ImGui::RenderTextClipped(score_bb.Min, score_bb.Max, lbEntry.formatted_score.c_str(),
+                           lbEntry.formatted_score.c_str() + lbEntry.formatted_score.size(), nullptr,
+                           ImVec2(0.0f, 0.0f), &score_bb);
+
+  if (lbEntry.is_self)
+  {
+    ImGui::PopStyleColor();
+  }
+
+  ImGui::PopFont();
+
+  // This API DOES list the submission date/time, but is it relevant?
+#if 0
+  if (!cheevo.locked)
+  {
+    ImGui::PushFont(g_medium_font);
+
+    const ImRect time_bb(ImVec2(text_start_x, bb.Min.y),
+      ImVec2(bb.Max.x, bb.Min.y + g_medium_font->FontSize + LayoutScale(4.0f)));
+    text.Format("Unlocked 21 Feb, 2019 @ 3:14am");
+    ImGui::RenderTextClipped(time_bb.Min, time_bb.Max, text.GetCharArray(), text.GetCharArray() + text.GetLength(),
+      nullptr, ImVec2(1.0f, 0.0f), &time_bb);
+    ImGui::PopFont();
+  }
+#endif
+
+  if (pressed)
+  {
+    // Anything?
+  }
+}
+
+void DrawLeaderboardsWindow()
+{
+  static constexpr float alpha = 0.8f;
+  static constexpr float heading_height_unscaled = 110.0f;
+
+  ImGui::SetNextWindowBgAlpha(alpha);
+
+  const bool is_leaderboard_open = s_open_leaderboard_id.has_value();
+  bool close_leaderboard_on_exit = false;
+
+  const ImVec4 background(0.13f, 0.13f, 0.13f, alpha);
+  const ImVec2 display_size(ImGui::GetIO().DisplaySize);
+  const float padding = LayoutScale(10.0f);
+  const float spacing = LayoutScale(10.0f);
+  float heading_height = LayoutScale(heading_height_unscaled);
+  if (is_leaderboard_open)
+  {
+    // Add space for a legend - spacing + 1 line of text + spacing + line
+    heading_height += spacing + LayoutScale(LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY) + spacing;
+  }
+
+  const float rank_column_width =
+    g_large_font->CalcTextSizeA(g_large_font->FontSize, std::numeric_limits<float>::max(), -1.0f, "99999").x;
+  const float name_column_width =
+    g_large_font
+      ->CalcTextSizeA(g_large_font->FontSize, std::numeric_limits<float>::max(), -1.0f, "WWWWWWWWWWWWWWWWWWWW")
+      .x;
+  const float column_spacing = spacing * 2.0f;
+
+  if (BeginFullscreenWindow(
+        ImVec2(0.0f, 0.0f), ImVec2(display_size.x, heading_height), "leaderboards_heading", background, 0.0f, 0.0f,
+        ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoScrollWithMouse))
+  {
+    ImRect bb;
+    bool visible, hovered;
+    bool pressed = MenuButtonFrame("leaderboards_heading", false, heading_height_unscaled, &visible, &hovered, &bb.Min,
+                                   &bb.Max, 0, alpha);
+    UNREFERENCED_VARIABLE(pressed);
+
+    if (visible)
+    {
+      const float image_height = LayoutScale(85.0f);
+
+      const ImVec2 icon_min(bb.Min + ImVec2(padding, padding));
+      const ImVec2 icon_max(icon_min + ImVec2(image_height, image_height));
+
+      const std::string& icon_path = Cheevos::GetGameIcon();
+      if (!icon_path.empty())
+      {
+        HostDisplayTexture* badge = GetCachedTexture(icon_path);
+        if (badge)
+        {
+          ImGui::GetWindowDrawList()->AddImage(badge->GetHandle(), icon_min, icon_max, ImVec2(0.0f, 0.0f),
+                                               ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255));
+        }
+      }
+
+      float left = bb.Min.x + padding + image_height + spacing;
+      float right = bb.Max.x - padding;
+      float top = bb.Min.y + padding;
+      ImDrawList* dl = ImGui::GetWindowDrawList();
+      SmallString text;
+      ImVec2 text_size;
+
+      const u32 leaderboard_count = Cheevos::GetLeaderboardCount();
+
+      if (!is_leaderboard_open)
+      {
+        if (FloatingButton(ICON_FA_WINDOW_CLOSE, 10.0f, 10.0f, -1.0f, -1.0f, 1.0f, 0.0f, true, g_large_font) ||
+            WantsToCloseMenu())
+        {
+          ReturnToMainWindow();
+        }
+      }
+      else
+      {
+        if (FloatingButton(ICON_FA_CARET_SQUARE_LEFT, 10.0f, 10.0f, -1.0f, -1.0f, 1.0f, 0.0f, true, g_large_font) ||
+            WantsToCloseMenu())
+        {
+          close_leaderboard_on_exit = true;
+        }
+      }
+
+      const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + g_large_font->FontSize));
+      text.Assign(Cheevos::GetGameTitle());
+
+      top += g_large_font->FontSize + spacing;
+
+      ImGui::PushFont(g_large_font);
+      ImGui::RenderTextClipped(title_bb.Min, title_bb.Max, text.GetCharArray(), text.GetCharArray() + text.GetLength(),
+                               nullptr, ImVec2(0.0f, 0.0f), &title_bb);
+      ImGui::PopFont();
+
+      if (s_open_leaderboard_id)
+      {
+        const Cheevos::Leaderboard* lboard = Cheevos::GetLeaderboardByID(*s_open_leaderboard_id);
+        if (lboard != nullptr)
+        {
+          const ImRect subtitle_bb(ImVec2(left, top), ImVec2(right, top + g_large_font->FontSize));
+          text.Assign(lboard->title);
+
+          top += g_large_font->FontSize + spacing;
+
+          ImGui::PushFont(g_large_font);
+          ImGui::RenderTextClipped(subtitle_bb.Min, subtitle_bb.Max, text.GetCharArray(),
+                                   text.GetCharArray() + text.GetLength(), nullptr, ImVec2(0.0f, 0.0f), &subtitle_bb);
+          ImGui::PopFont();
+
+          text.Assign(lboard->description);
+        }
+        else
+        {
+          text.Clear();
+        }
+      }
+      else
+      {
+        text.Format("This game has %u leaderboards.", leaderboard_count);
+      }
+
+      const ImRect summary_bb(ImVec2(left, top), ImVec2(right, top + g_medium_font->FontSize));
+      top += g_medium_font->FontSize + spacing;
+
+      ImGui::PushFont(g_medium_font);
+      ImGui::RenderTextClipped(summary_bb.Min, summary_bb.Max, text.GetCharArray(),
+                               text.GetCharArray() + text.GetLength(), nullptr, ImVec2(0.0f, 0.0f), &summary_bb);
+      ImGui::PopFont();
+    }
+
+    if (is_leaderboard_open)
+    {
+      pressed = MenuButtonFrame("legend", false, LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY, &visible, &hovered, &bb.Min,
+                                &bb.Max, 0, alpha);
+
+      UNREFERENCED_VARIABLE(pressed);
+
+      if (visible)
+      {
+        const Cheevos::Leaderboard* lboard = Cheevos::GetLeaderboardByID(*s_open_leaderboard_id);
+
+        const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f);
+        float text_start_x = bb.Min.x + LayoutScale(15.0f) + padding;
+
+        ImGui::PushFont(g_large_font);
+
+        const ImRect rank_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
+        ImGui::RenderTextClipped(rank_bb.Min, rank_bb.Max, "Rank", nullptr, nullptr, ImVec2(0.0f, 0.0f), &rank_bb);
+        text_start_x += rank_column_width + column_spacing;
+
+        const ImRect user_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
+        ImGui::RenderTextClipped(user_bb.Min, user_bb.Max, "Name", nullptr, nullptr, ImVec2(0.0f, 0.0f), &user_bb);
+        text_start_x += name_column_width + column_spacing;
+
+        const ImRect score_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
+        ImGui::RenderTextClipped(score_bb.Min, score_bb.Max,
+                                 lboard != nullptr && Cheevos::IsLeaderboardTimeType(*lboard) ? "Time" : "Score",
+                                 nullptr, nullptr, ImVec2(0.0f, 0.0f), &score_bb);
+
+        ImGui::PopFont();
+
+        const float line_thickness = LayoutScale(1.0f);
+        const float line_padding = LayoutScale(5.0f);
+        const ImVec2 line_start(bb.Min.x, bb.Min.y + g_large_font->FontSize + line_padding);
+        const ImVec2 line_end(bb.Max.x, line_start.y);
+        ImGui::GetWindowDrawList()->AddLine(line_start, line_end, ImGui::GetColorU32(ImGuiCol_TextDisabled),
+                                            line_thickness);
+      }
+    }
+  }
+  EndFullscreenWindow();
+
+  ImGui::SetNextWindowBgAlpha(alpha);
+
+  if (!is_leaderboard_open)
+  {
+    if (BeginFullscreenWindow(ImVec2(0.0f, heading_height), ImVec2(display_size.x, display_size.y - heading_height),
+                              "leaderboards", background, 0.0f, 0.0f, 0))
+    {
+      BeginMenuButtons();
+
+      Cheevos::EnumerateLeaderboards([](const Cheevos::Leaderboard& lboard) -> bool {
+        DrawLeaderboardListEntry(lboard);
+
+        return true;
+      });
+
+      EndMenuButtons();
+    }
+    EndFullscreenWindow();
+  }
+  else
+  {
+    if (BeginFullscreenWindow(ImVec2(0.0f, heading_height), ImVec2(display_size.x, display_size.y - heading_height),
+                              "leaderboard", background, 0.0f, 0.0f, 0))
+    {
+      BeginMenuButtons();
+
+      const auto result = Cheevos::TryEnumerateLeaderboardEntries(
+        *s_open_leaderboard_id,
+        [rank_column_width, name_column_width, column_spacing](const Cheevos::LeaderboardEntry& lbEntry) -> bool {
+          DrawLeaderboardEntry(lbEntry, rank_column_width, name_column_width, column_spacing);
+          return true;
+        });
+
+      if (!result.has_value())
+      {
+        ImGui::PushFont(g_large_font);
+
+        const ImVec2 pos_min(0.0f, heading_height);
+        const ImVec2 pos_max(display_size.x, display_size.y);
+        ImGui::RenderTextClipped(pos_min, pos_max, "Downloading leaderboard data, please wait...", nullptr, nullptr,
+                                 ImVec2(0.5f, 0.5f));
+
+        ImGui::PopFont();
+      }
+
+      EndMenuButtons();
+    }
+    EndFullscreenWindow();
+  }
+
+  if (close_leaderboard_on_exit)
+    s_open_leaderboard_id.reset();
+}
+
 #else
 
 void DrawAchievementWindow() {}
+void DrawLeaderboardsWindow() {}
 
 #endif
 
diff --git a/src/frontend-common/fullscreen_ui.h b/src/frontend-common/fullscreen_ui.h
index 7b6ec4269..430be1d76 100644
--- a/src/frontend-common/fullscreen_ui.h
+++ b/src/frontend-common/fullscreen_ui.h
@@ -19,6 +19,7 @@ enum class MainWindowType
   Settings,
   QuickMenu,
   Achievements,
+  Leaderboards,
 };
 
 enum class SettingsPage