// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See ui.h. // // Settings > Advanced > Check for Updates: the row's value and help, and the // dialog over the menu with the result (update/updater.h runs the check). // - Checking: says so; Close leaves the check running (the row, the log // and a toast get the result). // - Up to date, or a one-line reason it couldn't check (and Try again). // - A newer version: its notes (plain text, scrolled by the D-pad), "Open // download page" (the system's browser) and the page as a QR code with // its address, for when no browser opens (Steam Deck Game Mode, the // Steam Frame's headset): a phone's camera reads it off the screen. // Controller first, like the menu: left/right picks a button, A presses it, // up/down scrolls the notes, B goes back to the menu. #include #include "ui.h" #include #include #include #include #include namespace ui { namespace { constexpr const char* kPopup = "Check for Updates"; bool s_openRequested = false; // OpenUpdateDialog: open it inside the settings window, next frame bool s_showing = false; // drawn last frame std::string s_focusKey; // what the dialog showed when its button was last focused // The download page's QR code, made once per address. struct Qr { std::string text; int size = 0; // modules per side; 0: none std::vector dark; // size * size, row by row }; Qr s_qr; const Qr& QrFor(const std::string& text) { if (s_qr.text == text) return s_qr; s_qr = {}; s_qr.text = text; std::vector code(qrcodegen_BUFFER_LEN_MAX), temp(qrcodegen_BUFFER_LEN_MAX); // Medium error correction (15%) for a picture of a screen, // raised where the version has room; version 10 at most (57 // modules), which a release page's address never comes near. if (qrcodegen_encodeText(text.c_str(), temp.data(), code.data(), qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN, 10, qrcodegen_Mask_AUTO, true)) { s_qr.size = qrcodegen_getSize(code.data()); s_qr.dark.resize(size_t(s_qr.size) * size_t(s_qr.size)); for (int y = 0; y < s_qr.size; y++) for (int x = 0; x < s_qr.size; x++) s_qr.dark[size_t(y) * size_t(s_qr.size) + size_t(x)] = qrcodegen_getModule(code.data(), x, y) ? 1 : 0; fprintf(stderr, "[update] QR code of %s: version %d, %d modules a side\n", text.c_str(), (s_qr.size - 17) / 4, s_qr.size); } else fprintf(stderr, "[update] QR code of %s: too long to encode\n", text.c_str()); return s_qr; } // Dark modules on white with the four-module quiet zone the standard // asks for; every module the same whole number of points, at most // `side` points in all (at least 2 a module). void DrawQr(const Qr& qr, float side) { constexpr int kQuiet = 4; const int total = qr.size + 2 * kQuiet; const float m = std::max(2.0f, std::floor(side / float(total))); const ImVec2 at = ImGui::GetCursorScreenPos(); const ImVec2 origin(std::floor(at.x), std::floor(at.y)); ImDrawList* draw = ImGui::GetWindowDrawList(); draw->AddRectFilled(origin, ImVec2(origin.x + total * m, origin.y + total * m), IM_COL32_WHITE); for (int y = 0; y < qr.size; y++) for (int x = 0; x < qr.size;) { if (!qr.dark[size_t(y) * size_t(qr.size) + size_t(x)]) { x++; continue; } int run = 1; // a row's dark modules side by side are one rectangle while (x + run < qr.size && qr.dark[size_t(y) * size_t(qr.size) + size_t(x + run)]) run++; const ImVec2 a(origin.x + (kQuiet + x) * m, origin.y + (kQuiet + y) * m); draw->AddRectFilled(a, ImVec2(a.x + run * m, a.y + m), IM_COL32_BLACK); x += run; } ImGui::Dummy(ImVec2(total * m, total * m)); } void OpenPage(const std::string& url) { if (const char* v = std::getenv("NFSMW_TEST_NO_BROWSER"); v && *v && *v != '0') { fprintf(stderr, "[update] Open download page: %s (NFSMW_TEST_NO_BROWSER: not opened)\n", url.c_str()); return; } if (SDL_OpenURL(url.c_str())) fprintf(stderr, "[update] Open download page: %s (handed to the system)\n", url.c_str()); else { fprintf(stderr, "[update] Open download page: %s failed: %s\n", url.c_str(), SDL_GetError()); Toast("No browser opened. Scan the code with a phone, or go to " + url, 12.0); } } void Legend(const char* text) { ImGui::SameLine(); float w = ImGui::CalcTextSize(text).x; ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - w)); ImGui::TextDisabled("%s", text); } // The focused button, once per thing shown (the result replaces // "Checking..." under the player's thumb). void FocusOnce(const std::string& key) { if (s_focusKey != key) { s_focusKey = key; ImGui::SetKeyboardFocusHere(); } } void DrawNotes(const update::Result& r, ImVec2 size) { const float lineHeight = ImGui::GetTextLineHeightWithSpacing(); if (ImGui::BeginChild("notes", size, ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened)) { ImGui::PushTextWrapPos(0.0f); if (!r.name.empty() && r.name != "v" + r.latest && r.name != r.latest) { PushAccentText(); ImGui::TextWrapped("%s", r.name.c_str()); PopAccentText(); ImGui::Spacing(); } for (const version::NoteLine& line : r.notes.lines) switch (line.kind) { case version::NoteLine::Kind::Gap: ImGui::Spacing(); break; case version::NoteLine::Kind::Heading: PushAccentText(); ImGui::TextWrapped("%s", line.text.c_str()); PopAccentText(); break; case version::NoteLine::Kind::Bullet: ImGui::Bullet(); ImGui::TextWrapped("%s", line.text.c_str()); break; case version::NoteLine::Kind::Text: ImGui::TextWrapped("%s", line.text.c_str()); break; } if (r.notes.lines.empty()) ImGui::TextDisabled("This release has no notes."); else if (r.notes.truncated) { ImGui::Spacing(); ImGui::TextDisabled("The rest is on the download page."); } ImGui::PopTextWrapPos(); // The D-pad, the stick or the arrow keys scroll (the buttons keep the focus). const float step = lineHeight * 0.5f; if (ImGui::IsKeyDown(ImGuiKey_GamepadDpadDown) || ImGui::IsKeyDown(ImGuiKey_GamepadLStickDown) || ImGui::IsKeyDown(ImGuiKey_DownArrow)) ImGui::SetScrollY(ImGui::GetScrollY() + step); if (ImGui::IsKeyDown(ImGuiKey_GamepadDpadUp) || ImGui::IsKeyDown(ImGuiKey_GamepadLStickUp) || ImGui::IsKeyDown(ImGuiKey_UpArrow)) ImGui::SetScrollY(ImGui::GetScrollY() - step); } ImGui::EndChild(); } // True: close the dialog. bool DrawAvailable(const update::Result& r) { const float s = Scale(); TitleText(r.headline.c_str()); ImGui::TextDisabled("%s", r.message.c_str()); ImGui::Spacing(); // The notes on the left, the code on the right, the address under both. const ImGuiStyle& style = ImGui::GetStyle(); const float footer = ImGui::GetFrameHeight() + ImGui::GetTextLineHeightWithSpacing() * 2 + style.ItemSpacing.y * 2; const float body = std::max(ImGui::GetContentRegionAvail().y - footer, ImGui::GetTextLineHeightWithSpacing() * 4); const Qr& qr = QrFor(r.url); const float caption = ImGui::GetTextLineHeightWithSpacing() * 2; const float side = qr.size ? std::min(body - caption - style.ItemSpacing.y, 300.0f * s) : 0.0f; const float gutter = kSpaceL * s; const float notesWidth = ImGui::GetContentRegionAvail().x - (qr.size ? side + gutter : 0.0f); DrawNotes(r, ImVec2(notesWidth, body)); if (qr.size) { ImGui::SameLine(0.0f, gutter); ImGui::BeginGroup(); DrawQr(qr, side); ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + side); ImGui::TextDisabled("No browser? Point a phone's camera at the code."); ImGui::PopTextWrapPos(); ImGui::EndGroup(); } ImGui::TextDisabled("Download page: %s", r.url.c_str()); ButtonRowAtBottom(); FocusOnce("available " + r.latest); bool open = ImGui::Button("Open download page"); ImGui::SameLine(); bool close = ImGui::Button("Close"); Legend("D-pad: scroll, choose A: select B: back"); if (open) OpenPage(r.url); return close; } // Up to date, or why not: one line, and OK (and Try again). bool DrawShort(const update::Result& r, uint64_t checks) { TitleText(r.headline.c_str()); ImGui::Spacing(); ImGui::PushTextWrapPos(0.0f); ImGui::TextUnformatted(r.message.c_str()); if (r.outcome == update::Outcome::Unavailable) ImGui::TextDisabled("Releases: %s", update::kReleasesPage); ImGui::PopTextWrapPos(); ImGui::Spacing(); ImGui::Spacing(); const bool failed = r.outcome != update::Outcome::UpToDate && r.outcome != update::Outcome::Unavailable; FocusOnce(std::format("result {}", checks)); bool close = ImGui::Button("OK"); bool retry = false; if (failed) { ImGui::SameLine(); retry = ImGui::Button("Try again"); } Legend(failed ? "D-pad: choose A: select B: back" : "A or B: back"); if (retry) update::StartCheck(); return close; } bool DrawChecking() { TitleText("Checking for updates"); ImGui::Spacing(); const int dots = int(ImGui::GetTime() * 2.0) % 4; ImGui::Text("Asking GitHub for the newest release%.*s", dots, "..."); ImGui::PushTextWrapPos(0.0f); ImGui::TextDisabled("It takes a few seconds at most. Only the question goes out: nothing about you or this device."); ImGui::PopTextWrapPos(); ImGui::Spacing(); ImGui::Spacing(); FocusOnce("checking"); bool close = ImGui::Button("Close"); Legend("A or B: back (the check carries on)"); return close; } } const char* UpdateRowValue() { static std::string text; update::Status status = update::GetStatus(); if (status.phase == update::Phase::Checking) return "Checking..."; if (!status.result) return "Check"; switch (status.result->outcome) { case update::Outcome::UpToDate: return "Up to date"; case update::Outcome::Available: text = "v" + status.result->latest + " is out"; return text.c_str(); default: return "Failed"; } } void UpdateRowHelp() { ImGui::TextWrapped("Asks GitHub whether a newer SpeedBreaker is out. The game goes online only when you press this, " "and sends nothing but the question."); update::Status status = update::GetStatus(); if (status.phase == update::Phase::Checking) ImGui::TextDisabled("Checking..."); else if (!status.result) ImGui::TextDisabled("This version: v%s", update::RunningVersion().c_str()); else if (status.result->outcome == update::Outcome::Available) { PushAccentText(); ImGui::Text("%s (you have v%s)", status.result->headline.c_str(), status.result->current.c_str()); PopAccentText(); } else if (status.result->outcome == update::Outcome::UpToDate) ImGui::TextDisabled("%s", status.result->headline.c_str()); else ImGui::TextDisabled("%s", status.result->message.c_str()); } void OpenUpdateDialog() { fprintf(stderr, "[update] Check for Updates pressed\n"); update::StartCheck(); s_openRequested = true; } bool DrawUpdateDialog() { if (s_openRequested) { s_openRequested = false; s_focusKey.clear(); ImGui::OpenPopup(kPopup); } if (!ImGui::IsPopupOpen(kPopup)) { if (s_showing) ForgetUpdateDialog(); return false; } const float s = Scale(); update::Status status = update::GetStatus(); const update::Result* result = status.phase == update::Phase::Done ? status.result.get() : nullptr; const bool available = result && result->outcome == update::Outcome::Available; ImGuiViewport* viewport = ImGui::GetMainViewport(); const float width = std::min(viewport->WorkSize.x - 2 * kSpaceL * s, (available ? 900.0f : 760.0f) * s); const float height = available ? std::min(viewport->WorkSize.y - 2 * kSpaceL * s, 600.0f * s) : 0.0f; // 0: fit ImGui::SetNextWindowPos(viewport->GetWorkCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); ImGui::SetNextWindowSize(ImVec2(width, height), ImGuiCond_Always); constexpr ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; // Opaque: the menu's text mustn't show through the notes. ImVec4 background = ImGui::GetStyleColorVec4(ImGuiCol_PopupBg); background.w = 1.0f; ImGui::PushStyleColor(ImGuiCol_PopupBg, background); const bool open = ImGui::BeginPopupModal(kPopup, nullptr, flags); ImGui::PopStyleColor(); if (!open) { if (s_showing) ForgetUpdateDialog(); return false; } if (!s_showing) { s_showing = true; update::SetDialogShowing(true); } bool close; if (!result) close = DrawChecking(); else if (available) close = DrawAvailable(*result); else close = DrawShort(*result, status.checks); if (!close && (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false))) close = true; if (close) { ImGui::CloseCurrentPopup(); ForgetUpdateDialog(); } ImGui::EndPopup(); return true; } bool UpdateDialogOpen() { return ImGui::IsPopupOpen(kPopup); } void ForgetUpdateDialog() { s_openRequested = false; s_showing = false; update::SetDialogShowing(false); } }