Files
ReSkate/Launcher/gui_mods_browse.cpp
T
AyeZeeBB 201571c225 Merge mods before launch; name the mod that could not be merged
A mod the merge could not use was left out silently. The game started
without it, three minutes into a loading screen, and only ReSkate.log said
which one.

Merging before launch
- PLAY runs the merge the game would have run behind its splash, on the
  launcher's worker, with the step and progress on the STATUS tile
  (Phase::merging). The launcher links dingosdk_mods for it, which costs
  about 600 KB of exe.
- When every mod merges, the launch goes ahead and the game's own
  load_catalog finds the patch already built, so the splash is shorter
  rather than the work being done twice.
- When mods were left out, the launch stops at Phase::mods_broken and a
  dialog names each one and its first problem, with three ways on: switch
  them off and play (which writes mods.json, so the next launch does not
  rediscover it the hard way), open the mod manager, or play anyway.
- Mods built for another game version do not block. They already have
  their own notice and message box, and blocking on them would stop play
  after every Steam update.
- The launcher resolves -dataPath out of the extra-arguments setting, so
  it merges against the root the game will read.

One SDK identity for both binaries
- Two places recorded "which ReSkate did this" as the module the code was
  running in, which is ReSkate.dll in the game and ReSkateLauncher.exe in
  the launcher now that both merge. merge_fingerprint hashes it, so the
  game matched no stamp the launcher wrote and merged everything a second
  time; sdk_identity keys the exclusions file, so each would have called
  the other's exclusions stale.
- Both now name ReSkate.dll beside the running executable. Skate.exe and
  ReSkateLauncher.exe sit beside it and launch.cpp enforces that, so the
  two agree, and unlike the data root it does not move under -dataPath.
  The value is unchanged from what the runtime computed before, so nothing
  re-merges on upgrade.

Naming the mod
- In game, a mod left out of the merge altogether said nothing on screen:
  only a mod that merged partially did, which is the lesser failure. Both
  are named now.
- The MOD MANAGER tile says which mod did not load, in the detail line's
  colour rather than a pill, clipped so a long name cannot run under the
  tile's artwork.
- MY MODS carries a line naming them above the list, and NOT LOADED and
  OUTDATED are badges on the row instead of words at the front of its
  summary.

MY MODS reaches Thunderstore parity
- Rows carry the package's icon, the same one the store shows, through a
  shared mod_icon(); a mod that never came from Thunderstore keeps the
  placeholder.
- An update is a button on the row, not a badge you had to open the mod to
  act on, and every row has Uninstall. Rows grew to fit, and "Remove" is
  "Uninstall" throughout.
- No pill for a mod that is up to date: the absence of an update button
  says it, and eight green pills said nothing eight times.

Fixes
- The install panel was drawn on the Mods page's own draw list after its
  children, and ImGui renders a child after its parent, so it was painted
  behind the mod rows along with its Cancel button: installs ran with no
  sign of it. It is its own window over the page now.
- pump_icons, which uploads decoded icons, only ran on the store page, so
  MY MODS showed placeholders until a visit to GET MODS uploaded them for
  it. The page does it for both lists.
- The merge dialog's closing advice overlapped its buttons.
2026-10-03 17:08:03 -05:00

683 lines
31 KiB
C++

#include "gui_internal.h"
#include "gui_renderer.h"
#include "mod_manager.h"
#include "updater.h"
#include "Engine/Core/Log/logging.h"
#include <cstdio>
#include <format>
// The Mods panel's BROWSE page: packages from Thunderstore, their icons, and
// downloading and installing them on the panel's worker.
namespace dingosdk::launcher_gui::detail {
namespace {
namespace ts = thunderstore;
constexpr int listing_timeout_ms = 20000;
constexpr int download_timeout_ms = 30000;
constexpr std::size_t max_icons = 128; // loaded icon textures, of the renderer's 160 slots
constexpr std::uint64_t max_icon_bytes = 6 * 1024 * 1024;
void log(logging::Level level, const std::string& message) {
logging::write(level, logging::Channel::launcher, message);
}
std::string fetch(const std::wstring& url, std::uint64_t limit, const std::atomic<bool>& stop) {
std::string body;
launcher_update::http_stream(url, limit, listing_timeout_ms, [&](const char* data, DWORD size) {
if (stop) throw std::runtime_error("stopped");
body.append(data, size);
});
return body;
}
// The listing index and its chunks (what r2modman reads), else the plain listing.
std::vector<ts::Package> fetch_listing(const std::string& community, const std::atomic<bool>& stop) {
try {
const auto index = ts::parse_index(ts::gunzip(fetch(ts::listing_index_url(community), 1 << 20, stop), 4 << 20));
std::vector<ts::Package> packages;
for (const auto& url : index) {
auto chunk = ts::parse_listing(ts::gunzip(fetch(url, 64ull << 20, stop), 256ull << 20));
std::move(chunk.begin(), chunk.end(), std::back_inserter(packages));
}
return packages;
} catch (const std::exception& failure) {
if (stop) throw;
log(logging::Level::warning, std::string("Thunderstore listing index unavailable (") + failure.what() +
"); reading the plain listing");
}
return ts::parse_listing(ts::gunzip(fetch(ts::listing_url(community), 256ull << 20, stop), 256ull << 20));
}
fs::path icon_cache() {
std::array<wchar_t, 32768> local{};
const auto length = GetEnvironmentVariableW(L"LOCALAPPDATA", local.data(), static_cast<DWORD>(local.size()));
if (!length || length >= local.size()) return {};
return fs::path(local.data()) / L"ReSkate" / L"thunderstore" / L"icons";
}
// Icon URLs end in Namespace-Name-1.2.3.png, so a file name keyed on that never goes stale.
std::wstring icon_file(std::string_view url) {
const auto slash = url.rfind('/');
std::string name(url.substr(slash == std::string_view::npos ? 0 : slash + 1));
for (auto& ch : name)
if (!((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '.' || ch == '-' || ch == '_'))
ch = '_';
if (name.empty() || name.front() == '.') name.insert(name.begin(), 'i');
return wide(name);
}
std::vector<unsigned char> read_bytes(const fs::path& path) {
std::vector<unsigned char> bytes;
FILE* file{};
if (_wfopen_s(&file, path.c_str(), L"rb") || !file) return bytes;
std::array<unsigned char, 65536> buffer{};
for (std::size_t read; (read = std::fread(buffer.data(), 1, buffer.size(), file)) > 0;)
bytes.insert(bytes.end(), buffer.begin(), buffer.begin() + static_cast<std::ptrdiff_t>(read));
std::fclose(file);
return bytes;
}
void icon_worker(Icons& icons) {
const bool com = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
const auto cache = icon_cache();
std::atomic<bool> never{};
for (;;) {
std::string url;
{
std::unique_lock lock(icons.mutex);
icons.wake.wait(lock, [&] { return icons.stop || !icons.queue.empty(); });
if (icons.stop) break;
url = std::move(icons.queue.front());
icons.queue.pop_front();
}
Icons::Decoded decoded{url};
try {
const auto cached = cache.empty() ? fs::path() : cache / icon_file(url);
auto bytes = cached.empty() ? std::vector<unsigned char>() : read_bytes(cached);
if (bytes.empty()) {
const auto body = fetch(wide(url), max_icon_bytes, never);
bytes.assign(body.begin(), body.end());
if (!cached.empty()) {
std::error_code error;
fs::create_directories(cache, error);
FILE* file{};
if (!_wfopen_s(&file, cached.c_str(), L"wb") && file) {
std::fwrite(bytes.data(), 1, bytes.size(), file);
std::fclose(file);
}
}
}
UINT width{}, height{};
if (!decode_image(bytes, ImVec2(S(96), S(96)), decoded.pixels, width, height)) decoded.pixels.clear();
decoded.width = width;
decoded.height = height;
} catch (const std::exception&) {
decoded.pixels.clear();
}
std::lock_guard lock(icons.mutex);
icons.done.push_back(std::move(decoded));
}
if (com) CoUninitialize();
}
std::string lower(std::string_view text) {
std::string result(text);
for (auto& ch : result) if (ch >= 'A' && ch <= 'Z') ch = static_cast<char>(ch + ('a' - 'A'));
return result;
}
std::string count_text(std::uint64_t value) {
if (value >= 1'000'000) return std::format("{:.1f}M", static_cast<double>(value) / 1e6);
if (value >= 10'000) return std::format("{}k", value / 1000);
if (value >= 1'000) return std::format("{:.1f}k", static_cast<double>(value) / 1e3);
return std::to_string(value);
}
std::string size_text(std::uint64_t bytes) {
if (bytes >= 1024ull * 1024 * 1024) return std::format("{:.2f} GB", static_cast<double>(bytes) / (1024.0 * 1024 * 1024));
if (bytes >= 1024ull * 1024) return std::format("{:.0f} MB", static_cast<double>(bytes) / (1024.0 * 1024));
return std::format("{} KB", std::max<std::uint64_t>(1, bytes / 1024));
}
std::string date_text(const std::string& iso) { return iso.substr(0, std::min<std::size_t>(iso.size(), 10)); }
void set_activity(ModsPanel& panel, std::string text) {
std::lock_guard lock(panel.mutex);
panel.activity = std::move(text);
}
// Downloads one package into Mods/.reskate-download, then installs it over any
// installed copy (which goes to the Recycle Bin, keeping its place in mods.json).
std::string install_package(ModsPanel& panel, const fs::path& root, const ts::Package& package) {
const auto& version = package.latest();
const auto folder = root / L".reskate-download";
const auto archive = folder / (wide(package.full_name) + L".zip");
std::error_code error;
fs::create_directories(folder, error);
struct Cleanup {
fs::path folder, archive;
~Cleanup() { std::error_code ignored; fs::remove(archive, ignored); fs::remove(folder, ignored); }
} cleanup{folder, archive};
set_activity(panel, "Downloading " + package.title() + " v" + version.number +
(version.file_size ? " (" + size_text(version.file_size) + ")" : std::string()));
panel.progress = 0;
{
FILE* file{};
if (_wfopen_s(&file, archive.c_str(), L"wb") || !file)
throw std::runtime_error("Could not write the download into the Mods folder.");
std::uint64_t received = 0;
const auto expected = version.file_size;
try {
launcher_update::http_stream(wide(version.download_url), expected ? expected + (1 << 20) : 8ull << 30,
download_timeout_ms, [&](const char* data, DWORD size) {
if (panel.cancel) throw std::runtime_error("Install cancelled.");
if (std::fwrite(data, 1, size, file) != size)
throw std::runtime_error("Could not write the download (is the disk full?).");
received += size;
if (expected) panel.progress = static_cast<float>(static_cast<double>(received) / static_cast<double>(expected));
});
} catch (...) {
std::fclose(file);
throw;
}
std::fclose(file);
if (expected && received != expected)
throw std::runtime_error(std::format("The download of {} stopped early ({} of {}).", package.title(),
size_text(received), size_text(expected)));
}
set_activity(panel, "Installing " + package.title() + " v" + version.number);
panel.progress = 0;
launcher_mods::InstallOptions options;
options.folder = ts::folder_for(package.full_name);
options.author = package.owner;
options.require_content = true;
return launcher_mods::install(root, archive, true, [&panel](float fraction) { panel.progress = fraction; },
panel.cancel, options);
}
// Search, category and the sort order, pinned packages first like the site.
std::vector<const ts::Package*> visible_packages(const Store& store, const ts::Installed& installed) {
const auto query = lower(store.search.data());
std::vector<const ts::Package*> result;
for (const auto& package : store.packages) {
const bool have = installed.contains(ts::folder_for(package.full_name));
// Deprecated and NSFW packages only show once installed.
if ((package.deprecated || package.nsfw) && !have) continue;
if (!store.category.empty() && !package.in_category(store.category)) continue;
if (!query.empty() && lower(package.title()).find(query) == std::string::npos &&
lower(package.owner).find(query) == std::string::npos &&
lower(package.latest().description).find(query) == std::string::npos) continue;
result.push_back(&package);
}
const auto order = [&](const ts::Package* a, const ts::Package* b) {
if (a->pinned != b->pinned) return a->pinned;
switch (store.sort) {
case 1: if (a->downloads != b->downloads) return a->downloads > b->downloads; break;
case 2: if (a->date_created != b->date_created) return a->date_created > b->date_created; break;
case 3: if (a->rating != b->rating) return a->rating > b->rating; break;
case 4: break;
default: if (a->date_updated != b->date_updated) return a->date_updated > b->date_updated; break;
}
return lower(a->title()) < lower(b->title());
};
std::stable_sort(result.begin(), result.end(), order);
return result;
}
constexpr std::array<const char*, 5> sort_names{"Last updated", "Most downloaded", "Newest", "Top rated", "Name"};
// What installing `package` does now: install, update or reinstall.
std::string action_label(const ts::Package& package, const ts::Installed& installed) {
const auto found = installed.find(ts::folder_for(package.full_name));
if (found == installed.end()) return "INSTALL";
if (ts::update_available(package, installed)) return "UPDATE TO v" + package.latest().number;
return "REINSTALL";
}
} // namespace
// Placed by hand: a row lays its own icon, text and badges out.
void mod_icon(ModsPanel& panel, const thunderstore::Package* package, ImVec2 position, float size) {
const ImVec2 end(position.x + size, position.y + size);
auto* draw = ImGui::GetWindowDrawList();
if (const auto id = package ? package_icon(panel, *package) : ImTextureID{})
draw->AddImageRounded(id, position, end, ImVec2(0, 0), ImVec2(1, 1), IM_COL32_WHITE, S(4));
else
draw->AddRectFilled(position, end, rgba(255, 255, 255, 0.06f), S(4));
}
void refresh_listing(ModsPanel& panel, double time, bool force) {
auto& store = panel.store;
// Once per launch, unless asked; a failed fetch retries after a minute.
if (store.loading || (!force && store.fetched > -1e8 && (store.error.empty() || time - store.fetched < 60))) return;
if (store.worker.joinable()) store.worker.join();
store.fetched = time;
store.loading = true;
store.worker = std::thread([&store] {
std::vector<ts::Package> packages;
std::string error;
const auto community = ts::community();
try {
packages = fetch_listing(community, store.stop);
log(logging::Level::info, std::format("Thunderstore: {} package(s) in {}", packages.size(), community));
} catch (const std::exception& failure) {
error = failure.what();
if (!store.stop) log(logging::Level::warning, "Thunderstore listing could not be read: " + error);
}
std::lock_guard lock(store.mutex);
store.incoming = std::move(packages);
store.incoming_error = std::move(error);
store.arrived = true;
store.loading = false;
});
}
void collect_listing(ModsPanel& panel) {
auto& store = panel.store;
std::lock_guard lock(store.mutex);
if (!store.arrived) return;
store.arrived = false;
if (store.incoming_error.empty()) {
store.packages = std::move(store.incoming);
store.loaded = true;
store.error.clear();
} else {
store.error = std::move(store.incoming_error);
}
store.incoming.clear();
}
thunderstore::Installed installed_versions(const mods::ModList& list) {
ts::Installed installed;
for (const auto& entry : list.entries) installed[entry.mod.name] = entry.mod.version;
return installed;
}
const thunderstore::Package* package_for(const Store& store, std::string_view folder) {
for (const auto& package : store.packages)
if (ts::folder_for(package.full_name) == folder) return &package;
return nullptr;
}
std::vector<const thunderstore::Package*> updates(const Store& store, const thunderstore::Installed& installed) {
std::vector<const ts::Package*> result;
for (const auto& package : store.packages)
if (ts::update_available(package, installed)) result.push_back(&package);
return result;
}
void start_store_install(ModsPanel& panel, std::vector<thunderstore::Package> packages) {
if (panel.installing || packages.empty()) return;
if (panel.worker.joinable()) panel.worker.join();
panel.installing = true;
panel.cancel = false;
panel.progress = 0;
panel.message.clear();
set_activity(panel, "Starting download...");
const auto root = panel.root;
panel.worker = std::thread([&panel, root, packages = std::move(packages)] {
std::string name, error, note;
std::size_t done = 0;
for (const auto& package : packages) {
try {
const bool update = fs::exists(root / wide(ts::folder_for(package.full_name)));
name = install_package(panel, root, package);
++done;
log(logging::Level::info, std::format("Mod {} from Thunderstore: {} v{} into Mods\\{}",
update ? "updated" : "installed", package.full_name, package.latest().number, name));
note = packages.size() > 1
? std::format("Updated {} mods. Changes apply the next time Skate starts.", done)
: std::format("{} {} v{}. It loads the next time Skate starts.", update ? "Updated" : "Installed",
package.title(), package.latest().number);
} catch (const std::exception& failure) {
error = package.title() + ": " + failure.what();
log(logging::Level::warning, "Thunderstore install of " + package.full_name + " failed: " + failure.what());
break;
}
}
std::lock_guard lock(panel.mutex);
panel.finished = true;
panel.finished_name = name;
panel.finished_error = error;
panel.finished_conflict.clear();
panel.finished_note = note;
panel.finished_source.clear();
panel.activity.clear();
panel.installing = false;
});
}
ImTextureID package_icon(ModsPanel& panel, const thunderstore::Package& package) {
const auto& url = package.latest().icon;
if (url.empty() || !g_renderer) return {};
auto& icons = panel.icons;
auto& entry = icons.entries[url];
entry.used = ImGui::GetFrameCount();
if (entry.id || entry.failed || entry.queued) return entry.id;
entry.queued = true;
{
std::lock_guard lock(icons.mutex);
icons.queue.push_back(url);
}
if (!icons.worker.joinable()) icons.worker = std::thread([&icons] { icon_worker(icons); });
icons.wake.notify_one();
return {};
}
void pump_icons(ModsPanel& panel) {
auto& icons = panel.icons;
std::vector<Icons::Decoded> done;
{
std::lock_guard lock(icons.mutex);
done.swap(icons.done);
}
if (!g_renderer) return;
for (auto& decoded : done) {
auto& entry = icons.entries[decoded.url];
entry.queued = false;
if (decoded.pixels.empty()) { entry.failed = true; continue; }
// Make room by dropping the icons drawn longest ago.
std::size_t loaded = 0;
for (const auto& [url, other] : icons.entries) loaded += other.id ? 1 : 0;
while (loaded >= max_icons) {
auto oldest = icons.entries.end();
for (auto it = icons.entries.begin(); it != icons.entries.end(); ++it)
if (it->second.id && (oldest == icons.entries.end() || it->second.used < oldest->second.used)) oldest = it;
if (oldest == icons.entries.end() || oldest->second.used >= ImGui::GetFrameCount() - 1) break;
g_renderer->release_texture(oldest->second.id);
oldest->second.id = {};
--loaded;
}
entry.id = g_renderer->upload_texture(decoded.pixels, decoded.width, decoded.height);
if (!entry.id) entry.failed = true;
}
}
bool picked(const Store& store, const std::string& full_name) {
return std::find(store.picked.begin(), store.picked.end(), full_name) != store.picked.end();
}
void pick(Store& store, const std::string& full_name, bool on) {
const auto found = std::find(store.picked.begin(), store.picked.end(), full_name);
if (on && found == store.picked.end()) store.picked.push_back(full_name);
else if (!on && found != store.picked.end()) store.picked.erase(found);
}
void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float height, bool installing) {
auto& store = panel.store;
const auto installed = installed_versions(panel.list);
// ------------------------------------------------ search, category, sort, refresh
const float top = ImGui::GetCursorPosY();
const float combo = S(190), refresh = S(110);
ImGui::SetNextItemWidth(std::max(S(140),
ImGui::GetContentRegionAvail().x - (combo + S(10)) * 2 - refresh - S(10)));
ImGui::InputTextWithHint("##search", "Search mods", store.search.data(), store.search.size());
ImGui::SameLine();
std::vector<std::string> categories;
for (const auto& package : store.packages)
for (const auto& category : package.categories)
if (std::find(categories.begin(), categories.end(), category) == categories.end()) categories.push_back(category);
std::sort(categories.begin(), categories.end());
ImGui::SetNextItemWidth(combo);
if (ImGui::BeginCombo("##category", store.category.empty() ? "All categories" : store.category.c_str())) {
if (ImGui::Selectable("All categories", store.category.empty())) store.category.clear();
for (const auto& category : categories)
if (ImGui::Selectable(category.c_str(), store.category == category)) store.category = category;
ImGui::EndCombo();
}
ImGui::SameLine();
ImGui::SetNextItemWidth(combo);
if (ImGui::BeginCombo("##sort", sort_names[static_cast<std::size_t>(std::clamp(store.sort, 0, 4))])) {
for (int i = 0; i < static_cast<int>(sort_names.size()); ++i)
if (ImGui::Selectable(sort_names[static_cast<std::size_t>(i)], store.sort == i)) store.sort = i;
ImGui::EndCombo();
}
ImGui::SameLine();
ImGui::BeginDisabled(store.loading);
if (ImGui::Button("Refresh", ImVec2(refresh, 0))) refresh_mods(launcher, panel);
ImGui::EndDisabled();
// ------------------------------------------------ what is ticked
if (!store.picked.empty()) {
ImGui::Spacing();
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(store.picked.size() == 1
? "1 mod selected" : std::format("{} mods selected", store.picked.size()).c_str());
ImGui::SameLine();
const float action = S(190);
ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - action - S(90) - S(8));
if (ImGui::Button("Clear", ImVec2(S(90), 0))) store.picked.clear();
ImGui::SameLine();
ImGui::BeginDisabled(installing);
push_primary_button();
if (ImGui::Button(store.picked.size() == 1 ? "INSTALL 1 MOD"
: std::format("INSTALL {} MODS", store.picked.size()).c_str(), ImVec2(action, 0))) {
std::vector<ts::Package> chosen;
for (const auto& name : store.picked)
for (const auto& package : store.packages)
if (package.full_name == name) chosen.push_back(package);
store.picked.clear();
start_store_install(panel, std::move(chosen));
}
pop_primary_button();
ImGui::EndDisabled();
}
ImGui::Spacing();
const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top));
const auto packages = visible_packages(store, installed);
// ------------------------------------------------ the list
ImGui::BeginChild("##store_list", ImVec2(0, body), ImGuiChildFlags_Borders);
const auto note = [](const char* text) {
ImGui::Spacing();
ImGui::Indent(S(14));
ImGui::PushTextWrapPos(0);
ImGui::TextDisabled("%s", text);
ImGui::PopTextWrapPos();
ImGui::Unindent(S(14));
};
if (!store.loaded) {
if (store.loading || store.error.empty()) note("Loading mods from Thunderstore...");
else {
ImGui::Spacing();
ImGui::Indent(S(14));
ImGui::PushTextWrapPos(0);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger), "Thunderstore could not be reached: %s",
store.error.c_str());
ImGui::PopTextWrapPos();
if (ImGui::Button("Try again")) refresh_listing(panel, ImGui::GetTime(), true);
ImGui::Unindent(S(14));
}
} else if (store.packages.empty()) {
note("No mods on Thunderstore yet. Made one? Package it with a manifest.json, icon.png and README.md "
"and upload it to thunderstore.io/c/reskate.");
} else if (packages.empty()) {
note("No mods match your search.");
}
const float row = S(78);
ImGui::BeginDisabled(installing);
virtual_rows(static_cast<int>(packages.size()), [&](int) { return row; }, [&](int index, float tall) {
const auto& package = *packages[static_cast<std::size_t>(index)];
const auto& version = package.latest();
const auto found = installed.find(ts::folder_for(package.full_name));
const bool update = ts::update_available(package, installed);
// Tools (Blender add-ons, utilities) are not game mods: their page has the download.
const bool tool = package.in_category("Tools") && !package.in_category("Mods");
bool ticked = picked(store, package.full_name);
ImGui::PushID(package.full_name.c_str());
auto* draw = ImGui::GetWindowDrawList();
const ImVec2 start = ImGui::GetCursorScreenPos();
const float width = ImGui::GetContentRegionAvail().x;
// The row itself opens the overview; the widgets on it keep their clicks.
if (list_row("##row", width, tall, ticked)) {
store.selected = package.full_name;
store.overview = false;
}
const float right = start.x + width;
const float text_x = start.x + S(82);
mod_icon(panel, &package, ImVec2(start.x + S(14), start.y + S(11)), S(56));
draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(text_x, start.y + S(10)), color::text,
package.title().c_str());
const float title_width =
fonts.bold->CalcTextSizeA(fonts.bold->FontSize, FLT_MAX, 0, package.title().c_str()).x;
draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x + title_width + S(8), start.y + S(12)),
color::muted, ("by " + package.owner).c_str());
// ------------------------------------------- tick and install, at the right
const float box = ImGui::GetFrameHeight();
const float tick_x = right - S(16) - box;
const float button = S(118);
const float button_x = tick_x - S(12) - button;
ImGui::SetCursorScreenPos(ImVec2(tick_x, start.y + (tall - box) * 0.5f));
ImGui::BeginDisabled(tool);
if (ImGui::Checkbox("##pick", &ticked)) pick(store, package.full_name, ticked);
ImGui::EndDisabled();
if (ImGui::IsItemHovered())
ImGui::SetTooltip(tool ? "A tool, not a game mod: get it from its Thunderstore page."
: "Tick to install this with the others you tick");
ImGui::SetCursorScreenPos(ImVec2(button_x, start.y + (tall - S(32)) * 0.5f));
if (tool) {
if (ImGui::Button("THUNDERSTORE", ImVec2(button, S(32)))) open_url(package.package_url);
} else if (update) {
push_primary_button();
if (ImGui::Button("UPDATE", ImVec2(button, S(32)))) start_store_install(panel, {package});
pop_primary_button();
} else if (found == installed.end()) {
push_primary_button();
if (ImGui::Button("INSTALL", ImVec2(button, S(32)))) start_store_install(panel, {package});
pop_primary_button();
} else {
const auto mark = "INSTALLED";
badge(draw, fonts, ImVec2(button_x + button - badge_width(fonts, mark),
start.y + (tall - fonts.caption->FontSize - S(8)) * 0.5f), mark, color::good, color::ink);
}
auto line = version.description;
std::replace_if(line.begin(), line.end(),
[](char ch) { return ch == 0x0a || ch == 0x0d || ch == 0x09; }, ' ');
// One line, clipped rather than wrapped, so every row is the same height.
const ImVec4 clip(text_x, start.y, button_x - S(14), start.y + tall);
draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, start.y + S(32)), color::text, line.c_str(),
nullptr, 0, &clip);
draw->AddText(fonts.caption, fonts.caption->FontSize, ImVec2(text_x, start.y + S(55)), color::muted,
std::format("v{} / {} downloads / {}", version.number, count_text(package.downloads),
date_text(package.date_updated)).c_str());
ImGui::PopID();
});
ImGui::EndDisabled();
ImGui::EndChild();
}
void package_overview(const Fonts& fonts, ModsPanel& panel, ImVec2 size, bool installing) {
auto& store = panel.store;
if (store.selected.empty()) return;
const ts::Package* selected = nullptr;
for (const auto& package : store.packages)
if (package.full_name == store.selected) selected = &package;
if (!selected) {
store.selected.clear();
return;
}
const auto& package = *selected;
const auto& version = package.latest();
const auto installed = installed_versions(panel.list);
const auto found = installed.find(ts::folder_for(package.full_name));
if (!store.overview) {
ImGui::OpenPopup("##package_overview");
store.overview = true;
}
const ImVec2 extent(std::min(S(680), size.x - S(80)), std::min(S(560), size.y - S(80)));
ImGui::SetNextWindowPos(ImVec2((size.x - extent.x) * 0.5f, (size.y - extent.y) * 0.5f));
ImGui::SetNextWindowSize(extent);
if (!ImGui::BeginPopupModal("##package_overview", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings)) {
store.overview = false; // dismissed with Escape
store.selected.clear();
return;
}
const auto close = [&] {
store.overview = false;
store.selected.clear();
ImGui::CloseCurrentPopup();
};
mod_icon(panel, &package, ImGui::GetCursorScreenPos(), S(84));
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + S(84) + S(16));
ImGui::BeginGroup();
ImGui::PushFont(fonts.heading);
ImGui::PushTextWrapPos(0);
ImGui::TextUnformatted(package.title().c_str());
ImGui::PopTextWrapPos();
ImGui::PopFont();
ImGui::TextDisabled("by %s", package.owner.c_str());
const auto facts = std::format("v{}{} / {} downloads / updated {}", version.number,
version.file_size ? " / " + size_text(version.file_size) : std::string(),
count_text(package.downloads), date_text(package.date_updated));
ImGui::TextDisabled("%s", facts.c_str());
ImGui::EndGroup();
ImGui::Spacing();
const bool tool = package.in_category("Tools") && !package.in_category("Mods");
if (tool) {
push_primary_button();
if (ImGui::Button("GET IT ON THUNDERSTORE", ImVec2(0, S(34)))) open_url(package.package_url);
pop_primary_button();
} else {
ImGui::BeginDisabled(installing);
push_primary_button();
if (ImGui::Button(action_label(package, installed).c_str(), ImVec2(0, S(34)))) {
start_store_install(panel, {package});
close();
}
pop_primary_button();
ImGui::EndDisabled();
if (!package.package_url.empty()) {
ImGui::SameLine();
if (ImGui::Button("Thunderstore page", ImVec2(0, S(34)))) open_url(package.package_url);
}
}
if (version.website_url.starts_with("https://")) {
ImGui::SameLine();
if (ImGui::Button("Website", ImVec2(0, S(34)))) open_url(version.website_url);
}
ImGui::Spacing();
ImGui::BeginChild("##overview_body",
ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight())));
ImGui::PushTextWrapPos(0);
if (package.deprecated)
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning),
"Deprecated: its author no longer supports it.");
if (package.nsfw)
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning), "Marked as not safe for work.");
field(fonts, "DESCRIPTION", version.description);
if (found != installed.end())
field(fonts, "INSTALLED", found->second.empty() ? std::string("yes") : "v" + found->second);
std::string names;
for (const auto& category : package.categories) names += (names.empty() ? "" : ", ") + category;
field(fonts, "CATEGORIES", names);
field(fonts, "FOLDER", "Mods\\" + ts::folder_for(package.full_name));
ImGui::PopTextWrapPos();
ImGui::EndChild();
ImGui::SetCursorPosY(extent.y - S(24) - ImGui::GetFrameHeight());
bool ticked = picked(store, package.full_name);
ImGui::BeginDisabled(tool);
if (ImGui::Checkbox("Install with the others I tick", &ticked)) pick(store, package.full_name, ticked);
ImGui::EndDisabled();
ImGui::SameLine(extent.x - S(28) - S(110));
if (ImGui::Button("CLOSE", ImVec2(S(110), 0))) close();
ImGui::EndPopup();
}
} // namespace dingosdk::launcher_gui::detail