Files
ReSkate/Launcher/gui_mods.cpp
T
AyeZeeBB aef269497e Rebuild the launcher's mod manager, settings and status tile
The mod browser was a 960x620 panel inside a 1180x680 window, reached from
a tile the same size as SETTINGS. People could not find it, and once they
did there was room for about six mods.

Window
- The design size is 1440x840. Everything is laid out in design units and
  multiplied by g_scale, which now scales down when that would not fit the
  screen's work area, so a small laptop or a high DPI no longer gets a
  window running off under the taskbar.
- The MODS tile is MOD MANAGER: taller than SETTINGS, directly above PLAY,
  with the pending update count as a pill on its subtitle line.
- background.jpg is 1920x1027, so the bigger window is not upscaling a
  1280x720 photo.

Mod manager, a page rather than a panel
- begin_page fills the window and draws its own minimise and close, since
  it covers the main screen's. BACK or Escape leaves it.
- A nav rail holds MY MODS and GET MODS (count badges, blue for the open
  one), the install actions and BACK. Refresh sits with what it refreshes,
  top right of the content.
- Every mod is a full-width row: icon, name, author, description, version
  and downloads, with a blue INSTALL or UPDATE and a tick box on the right.
  Ticking several installs them in one run.
- Clicking a row opens that mod's overview in a popup, replacing the
  side-by-side details pane that halved the list's width.
- virtual_rows draws only the rows a scrolling child actually shows.
  ImGuiListClipper cannot do this, as it assumes every row is a fixed
  height.
- Installing is a centred panel over a dimmed page with the activity, a
  striped bar and Cancel, instead of a progress bar in the footer.

Settings
- The same page and rail: GAME, DISPLAY, KEYS and ADVANCED are nav tiles
  rather than a row of tab buttons, and the content keeps to a 760 px
  column so the explanations stay a readable measure.
- The Steam sign-in, Steam Guard and QR dialogs stay centred panels. They
  are prompts, not pages.

Status and failures
- The STATUS tile carries its state in a coloured edge and a pill (WORKING,
  READY, ACTION NEEDED, PROBLEM), its headline is 22 pt over muted detail,
  and the standing facts (game build, ReSkate version) sit under a rule
  instead of competing with the live status as equal rows.
- Launcher::fail keeps the raw message for the log, and problem.h turns it
  into a headline and the next thing to try. The injection and PE-identity
  failures ("VirtualAllocEx failed", "Injected ReSkate.dll is not present
  in the child module list") all become "ReSkate could not attach to
  Skate", with the anti-virus and overlay advice that actually fixes it;
  the Steam download, a missing or damaged ReSkate.dll, the content cache
  and a shared menu/console key each get their own. Anything unmatched
  keeps its raw text.
- A failed tile grows Open logs and Copy details. Copy details puts the raw
  message and the versions on the clipboard, ready to paste into Discord.
2026-10-03 16:10:55 -05:00

562 lines
26 KiB
C++

#include "gui_internal.h"
#include "mod_manager.h"
#include "Engine/Core/Log/logging.h"
#include <shobjidl.h>
#include <wrl/client.h>
#include <format>
using Microsoft::WRL::ComPtr;
// The Mods page: a nav rail on the left, and a list where every mod is a
// full-width row that opens where it sits. MY MODS is install order and
// enabled state; GET MODS is Thunderstore (gui_mods_browse.cpp).
namespace dingosdk::launcher_gui::detail {
namespace {
void save(ModsPanel& panel) {
try {
mods::save_mod_order(panel.root, panel.list.entries);
panel.list.issue.clear();
panel.message = "Saved. Changes apply the next time Skate starts.";
panel.message_error = false;
} catch (const std::exception& failure) {
panel.message = failure.what();
panel.message_error = true;
}
}
// Picks up a finished install: rescan and select the new mod, or ask to replace.
void collect_install(ModsPanel& panel, const launcher_app::Session& session) {
std::lock_guard lock(panel.mutex);
if (!panel.finished) return;
panel.finished = false;
panel.progress = -1;
if (!panel.finished_conflict.empty()) {
panel.conflict_name = panel.finished_conflict;
panel.conflict_source = panel.finished_source;
panel.message.clear();
return;
}
scan(panel, session);
if (!panel.finished_error.empty()) {
panel.message = panel.finished_error;
panel.message_error = true;
return;
}
for (std::size_t i = 0; i < panel.list.entries.size(); ++i)
if (panel.list.entries[i].mod.name == panel.finished_name) panel.selected = static_cast<int>(i);
panel.message_error = false;
if (!panel.finished_note.empty()) {
panel.message = panel.finished_note; // Thunderstore installs say what they did and log it themselves
panel.finished_note.clear();
return;
}
panel.message = "Installed " + panel.finished_name + ". It loads the next time Skate starts.";
logging::write(logging::Level::info, logging::Channel::launcher, "Mod installed: " + panel.finished_name);
}
// The Windows file (or folder) picker; empty when cancelled.
fs::path pick(HWND owner, bool folder) {
ComPtr<IFileOpenDialog> dialog;
if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) return {};
FILEOPENDIALOGOPTIONS options{};
dialog->GetOptions(&options);
dialog->SetOptions(options | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | (folder ? FOS_PICKFOLDERS : FOS_FILEMUSTEXIST));
if (!folder) {
const COMDLG_FILTERSPEC filter[]{{L"Mod archive (*.zip)", L"*.zip"}};
dialog->SetFileTypes(1, filter);
}
dialog->SetTitle(folder ? L"Choose a mod folder to install" : L"Choose a mod .zip to install");
if (FAILED(dialog->Show(owner))) return {};
ComPtr<IShellItem> item;
PWSTR path{};
if (FAILED(dialog->GetResult(&item)) || FAILED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) return {};
fs::path result(path);
CoTaskMemFree(path);
return result;
}
std::string summary(const mods::Mod& mod, bool excluded = false) {
std::vector<std::string> parts;
if (!mod.version.empty()) parts.push_back("v" + mod.version);
if (!mod.author.empty()) parts.push_back("by " + mod.author);
if (!mod.levels.empty()) parts.push_back(mod.levels.size() == 1 ? "1 map" : std::to_string(mod.levels.size()) + " maps");
else if (mod.provides_layout) parts.push_back("game data");
if (!mod.park_maps.empty()) parts.push_back(mod.park_maps.size() == 1 ? "1 park" : std::to_string(mod.park_maps.size()) + " parks");
if (!mod.provides_layout && !mod.provides_levels && mod.park_maps.empty()) parts.push_back("nothing to load");
if (excluded) parts.insert(parts.begin(), "NOT LOADED: could not be merged");
if (!mod.outdated.empty()) parts.insert(parts.begin(), "OUTDATED: update it for this game version");
std::string text;
for (const auto& part : parts) text += (text.empty() ? "" : " / ") + part;
return text;
}
// ---------------------------------------------------------------- nav rail
// The rail: the two pages, then what you can do to the Mods folder. Returns
// true when BACK was pressed.
bool rail(const Fonts& fonts, ModsPanel& panel, HWND window, float width,
const std::vector<const thunderstore::Package*>& pending, bool installing) {
const auto installed = panel.list.entries.size();
const auto& store = panel.store;
bool leave = false;
ImGui::BeginDisabled(installing);
if (nav_tile(fonts, width, "MY MODS", panel.tab == 0,
pending.empty() ? std::to_string(installed) : std::to_string(pending.size()), !pending.empty(),
pending.empty() ? std::string()
: pending.size() == 1 ? std::string("1 update on Thunderstore")
: std::format("{} updates on Thunderstore", pending.size())))
panel.tab = 0;
if (nav_tile(fonts, width, "GET MODS", panel.tab == 1,
store.loaded ? std::to_string(store.packages.size()) : std::string("..."), false,
store.loaded ? std::string() : std::string("Loading the Thunderstore listing")))
panel.tab = 1;
ImGui::Dummy(ImVec2(0, S(8)));
if (!pending.empty()) {
push_primary_button();
if (ImGui::Button(std::format("UPDATE ALL ({})", pending.size()).c_str(), ImVec2(-1, S(34)))) {
std::vector<thunderstore::Package> packages;
for (const auto* package : pending) packages.push_back(*package);
start_store_install(panel, std::move(packages));
}
pop_primary_button();
ImGui::Dummy(ImVec2(0, S(4)));
}
if (ImGui::Button("Install .zip", ImVec2(-1, S(32))))
if (const auto path = pick(window, false); !path.empty()) start_install(panel, path, false);
if (ImGui::Button("Install folder", ImVec2(-1, S(32))))
if (const auto path = pick(window, true); !path.empty()) start_install(panel, path, false);
if (ImGui::Button("Open Mods folder", ImVec2(-1, S(32)))) open_path(panel.root);
if (ImGui::Button("Open Thunderstore", ImVec2(-1, S(32))))
open_url(utf8(thunderstore::community_page(thunderstore::community())));
ImGui::EndDisabled();
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - S(38));
push_primary_button();
if (ImGui::Button("\xe2\x86\x90 BACK", ImVec2(-1, S(34)))) leave = true;
pop_primary_button();
return leave;
}
// ---------------------------------------------------------------- MY MODS
// One installed mod: enabled state, its place in the load order, and a click
// anywhere else for everything the mod says about itself.
void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Installed& installed, int index,
float tall, bool& changed, int& move_from, int& move_to) {
auto& entry = panel.list.entries[static_cast<std::size_t>(index)];
const auto& mod = entry.mod;
ImGui::PushID(index);
auto* draw = ImGui::GetWindowDrawList();
const ImVec2 start = ImGui::GetCursorScreenPos();
const float width = ImGui::GetContentRegionAvail().x;
if (list_row("##row", width, tall, false)) {
panel.selected = index;
panel.overview = false;
}
const float right = start.x + width;
const bool left_out = panel.list.excluded.contains(mod.name);
const auto* package = package_for(panel.store, mod.name);
const bool update = package && thunderstore::update_available(*package, installed);
const auto title = std::to_string(index + 1) + ". " + mod.title;
draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(start.x + S(54), start.y + S(12)),
entry.enabled ? color::text : color::muted, title.c_str());
if (const auto detail = summary(mod, left_out); !detail.empty())
draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(start.x + S(54), start.y + S(36)), color::muted,
detail.c_str());
// The widgets that sit on the row, over its Selectable.
ImGui::SetCursorScreenPos(ImVec2(start.x + S(16), start.y + (tall - ImGui::GetFrameHeight()) * 0.5f));
if (ImGui::Checkbox("##enabled", &entry.enabled)) changed = true;
if (ImGui::IsItemHovered()) ImGui::SetTooltip(entry.enabled ? "Enabled: loads when Skate starts" : "Disabled");
const float arrow = ImGui::GetFrameHeight();
const float arrows_x = right - S(20) - arrow * 2 - S(6);
if (update) {
const float pill = badge_width(fonts, "UPDATE");
badge(draw, fonts, ImVec2(arrows_x - S(14) - pill, start.y + (tall - fonts.caption->FontSize - S(8)) * 0.5f),
"UPDATE");
}
ImGui::SetCursorScreenPos(ImVec2(arrows_x, start.y + (tall - arrow) * 0.5f));
ImGui::BeginDisabled(index == 0);
if (ImGui::ArrowButton("##up", ImGuiDir_Up)) { move_from = index; move_to = index - 1; }
ImGui::EndDisabled();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Load earlier: later mods win where they overlap");
ImGui::SameLine(0, S(6));
ImGui::BeginDisabled(index + 1 == static_cast<int>(panel.list.entries.size()));
if (ImGui::ArrowButton("##down", ImGuiDir_Down)) { move_from = index; move_to = index + 1; }
ImGui::EndDisabled();
ImGui::PopID();
}
void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, const thunderstore::Installed& installed,
float height, bool installing) {
auto& entries = panel.list.entries;
const float top = ImGui::GetCursorPosY();
ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - S(110));
if (ImGui::Button("Refresh", ImVec2(S(110), 0))) refresh_mods(launcher, panel);
ImGui::PushTextWrapPos(0);
ImGui::TextDisabled("Mods load top to bottom: where two change the same thing, the higher one wins. Changes apply "
"the next time Skate starts. Click a mod to see everything about it.");
if (launcher.game())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning),
"Skate is running: restart it to apply changes.");
if (!panel.list.issue.empty())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger),
"mods.json could not be read (%s), so the game loads no mods. Any change here rewrites it.",
panel.list.issue.c_str());
if (!panel.list.missing.empty()) {
std::string missing;
for (const auto& name : panel.list.missing) missing += (missing.empty() ? "" : ", ") + name;
ImGui::TextDisabled("mods.json also lists folders that are not installed: %s", missing.c_str());
}
ImGui::PopTextWrapPos();
ImGui::Spacing();
const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top));
ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_Borders);
if (entries.empty()) {
ImGui::Spacing();
ImGui::Indent(S(14));
ImGui::TextDisabled(panel.list.present ? "No mods installed yet." : "No Mods folder yet.");
ImGui::TextDisabled("Use GET MODS to browse Thunderstore, or drop a mod .zip on the window.");
ImGui::Unindent(S(14));
}
bool changed = false;
int move_from = -1, move_to = -1;
const float row = S(66);
ImGui::BeginDisabled(installing);
virtual_rows(static_cast<int>(entries.size()), [&](int) { return row; }, [&](int index, float tall) {
installed_row(fonts, panel, installed, index, tall, changed, move_from, move_to);
});
ImGui::EndDisabled();
ImGui::EndChild();
if (move_from >= 0) {
std::swap(entries[static_cast<std::size_t>(move_from)], entries[static_cast<std::size_t>(move_to)]);
if (panel.selected == move_from) panel.selected = move_to;
else if (panel.selected == move_to) panel.selected = move_from;
changed = true;
}
if (changed) save(panel);
}
// Everything an installed mod says about itself, in a popup over the page.
void mod_overview(const Fonts& fonts, ModsPanel& panel, const thunderstore::Installed& installed, ImVec2 size,
bool installing) {
if (panel.selected < 0 || panel.selected >= static_cast<int>(panel.list.entries.size())) {
panel.selected = -1;
return;
}
auto& entry = panel.list.entries[static_cast<std::size_t>(panel.selected)];
const auto& mod = entry.mod;
const auto* package = package_for(panel.store, mod.name);
const bool update = package && thunderstore::update_available(*package, installed);
if (!panel.overview) {
ImGui::OpenPopup("##mod_overview");
panel.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("##mod_overview", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings)) {
panel.overview = false; // dismissed with Escape
panel.selected = -1;
return;
}
const auto close = [&] {
panel.overview = false;
panel.selected = -1;
ImGui::CloseCurrentPopup();
};
ImGui::PushFont(fonts.heading);
ImGui::PushTextWrapPos(0);
ImGui::TextUnformatted(mod.title.c_str());
ImGui::PopTextWrapPos();
ImGui::PopFont();
ImGui::TextDisabled("%s", summary(mod, panel.list.excluded.contains(mod.name)).c_str());
ImGui::Spacing();
if (update) {
ImGui::BeginDisabled(installing);
push_primary_button();
if (ImGui::Button(("UPDATE TO v" + package->latest().number).c_str(), ImVec2(0, S(34)))) {
start_store_install(panel, {*package});
close();
}
pop_primary_button();
ImGui::EndDisabled();
ImGui::SameLine();
}
if (ImGui::Button("Open folder", ImVec2(0, S(34)))) open_path(mod.directory);
if (package && !package->package_url.empty()) {
ImGui::SameLine();
if (ImGui::Button("Thunderstore page", ImVec2(0, S(34)))) open_url(package->package_url);
}
ImGui::SameLine();
ImGui::BeginDisabled(installing);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger));
// The confirmation is its own modal, so this one has to go first.
if (ImGui::Button("Remove", ImVec2(0, S(34)))) {
const auto name = mod.name;
close();
panel.confirm_remove = name;
}
ImGui::PopStyleColor();
ImGui::EndDisabled();
ImGui::Spacing();
ImGui::BeginChild("##mod_overview_body",
ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight())));
ImGui::PushTextWrapPos(0);
if (!mod.outdated.empty())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger),
"Outdated: this mod does not load. It was made for another version of Skate (%s). Get an updated "
"version, or rebuild it with the latest ReSkate Studio.", mod.outdated.c_str());
if (const auto missing = panel.list.excluded.find(mod.name); missing != panel.list.excluded.end()) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger),
"Not loaded: the game could not merge this mod cleanly, so none of it is used. Reinstall the whole "
"mod folder, or rebuild it with a current ReSkate Studio.");
if (!missing->second.empty()) ImGui::TextDisabled("%s", missing->second.front().c_str());
}
field(fonts, "AUTHOR", mod.author);
field(fonts, "VERSION", mod.version);
field(fonts, "DESCRIPTION", mod.description);
field(fonts, "FOLDER", "Mods\\" + mod.name);
if (package)
field(fonts, "THUNDERSTORE", package->full_name + (update ? " (v" + package->latest().number + " available)"
: std::string(" (up to date)")));
if (!mod.tool.empty() || !mod.built.empty())
field(fonts, "BUILT WITH", mod.tool + (mod.built.empty() ? "" : (mod.tool.empty() ? "" : ", ") + mod.built));
std::string levels;
for (const auto& level : mod.levels) {
const auto slash = level.rfind('/');
levels += (levels.empty() ? "" : "\n") + (slash == std::string::npos ? level : level.substr(slash + 1));
}
field(fonts, "MAPS", levels);
std::string parks;
for (const auto& map : mod.park_maps) parks += (parks.empty() ? "" : ", ") + map;
field(fonts, "PARKS", parks);
if (!mod.provides_layout && !mod.provides_levels && mod.park_maps.empty())
field(fonts, "NOTE", "This folder has no layout.toc or reskate-levels.json, so the game has nothing to load from it.");
ImGui::PopTextWrapPos();
ImGui::EndChild();
ImGui::SetCursorPosY(extent.y - S(24) - ImGui::GetFrameHeight());
bool enabled = entry.enabled;
if (ImGui::Checkbox("Loads when Skate starts", &enabled)) {
entry.enabled = enabled;
save(panel);
}
ImGui::SameLine(extent.x - S(28) - S(110));
if (ImGui::Button("CLOSE", ImVec2(S(110), 0))) close();
ImGui::EndPopup();
}
// ---------------------------------------------------------------- installing
// What a mod manager shows while it works: everything else dimmed, and one
// panel saying what is being downloaded.
void install_modal(const Fonts& fonts, ModsPanel& panel, ImVec2 size) {
std::string activity;
{
std::lock_guard lock(panel.mutex);
activity = panel.activity;
}
const float fraction = std::clamp(panel.progress.load(), 0.0f, 1.0f);
const auto time = static_cast<float>(ImGui::GetTime());
auto* draw = ImGui::GetWindowDrawList();
draw->AddRectFilled(ImVec2(0, 0), size, rgba(4, 6, 9, 0.72f));
const ImVec2 extent(S(540), S(206));
const ImVec2 origin((size.x - extent.x) * 0.5f, (size.y - extent.y) * 0.5f);
const ImVec2 end(origin.x + extent.x, origin.y + extent.y);
skate_theme::rough_rect(draw, origin, end, color::blue, 11, g_scale);
draw->AddText(fonts.tile, fonts.tile->FontSize, ImVec2(origin.x + S(26), origin.y + S(18)), color::ink, "INSTALLING");
if (!activity.empty())
draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(origin.x + S(26), origin.y + S(70)),
rgba(0, 0, 0, 0.78f), activity.c_str(), nullptr, extent.x - S(52));
skate_theme::striped_bar(draw, ImVec2(origin.x + S(26), origin.y + S(118)),
ImVec2(end.x - S(26), origin.y + S(134)), fraction, time, g_scale);
draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(origin.x + S(26), origin.y + S(142)), rgba(0, 0, 0, 0.78f),
std::format("{:.0f}%", fraction * 100).c_str());
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(rgba(0, 0, 0, 0.38f)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(rgba(0, 0, 0, 0.52f)));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(rgba(0, 0, 0, 0.66f)));
ImGui::SetCursorScreenPos(ImVec2(end.x - S(26) - S(130), end.y - S(24) - S(34)));
if (ImGui::Button("CANCEL", ImVec2(S(130), S(34)))) panel.cancel = true;
ImGui::PopStyleColor(3);
}
} // namespace
void refresh_mods(Launcher& launcher, ModsPanel& panel) {
scan(panel, launcher.session());
refresh_listing(panel, ImGui::GetTime(), true);
panel.message.clear();
}
void scan(ModsPanel& panel, const launcher_app::Session& session) {
panel.root = launcher_mods::mods_root(session.paths.directory);
panel.list = mods::scan_mods(panel.root.parent_path());
panel.scanned = true;
if (panel.selected >= static_cast<int>(panel.list.entries.size())) panel.selected = -1;
}
void start_install(ModsPanel& panel, const fs::path& source, bool replace) {
if (panel.installing) return;
if (panel.worker.joinable()) panel.worker.join();
panel.installing = true;
panel.cancel = false;
panel.progress = 0;
panel.message.clear();
panel.message_error = false;
{
std::lock_guard lock(panel.mutex);
panel.activity = "Installing " + utf8(source.filename().wstring());
}
const auto root = panel.root;
panel.worker = std::thread([&panel, root, source, replace] {
std::string name, error, conflict;
try {
name = launcher_mods::install(root, source, replace,
[&panel](float fraction) { panel.progress = fraction; }, panel.cancel);
} catch (const launcher_mods::AlreadyInstalled& existing) {
conflict = existing.name;
} catch (const std::exception& failure) {
error = failure.what();
}
std::lock_guard lock(panel.mutex);
panel.finished = true;
panel.finished_name = name;
panel.finished_error = error;
panel.finished_conflict = conflict;
panel.finished_source = source;
panel.activity.clear();
panel.installing = false;
});
}
void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, ModsPanel& panel, HWND window) {
const auto& session = launcher.session();
if (!panel.scanned) scan(panel, session);
collect_install(panel, session);
refresh_listing(panel, ImGui::GetTime());
// A page, not a panel: the mod list and the Thunderstore browser both want
// the whole window. It sits at (0, 0), so window and screen space agree.
const auto frame = begin_page("##mods_panel", size);
auto* draw = ImGui::GetWindowDrawList();
const bool installing = panel.installing;
auto& entries = panel.list.entries;
const auto installed = installed_versions(panel.list);
const auto pending = updates(panel.store, installed);
const auto close = [&] { ui.mods = false; panel.message.clear(); panel.scanned = false; };
// The page covers the main screen's buttons, so it draws its own.
window_buttons(draw, window, frame);
const float rail_x = S(28), rail_width = S(236);
const float content_x = rail_x + rail_width + S(26);
const float top = S(52), bottom = frame.y - S(24);
const float title_size = S(44);
page_title(draw, fonts, ImVec2(rail_x + S(2), top - S(4)), "MOD\nMANAGER", title_size);
const float rail_top = top + title_size * 2 + S(16);
ImGui::SetCursorPos(ImVec2(rail_x, rail_top));
ImGui::BeginChild("##rail", ImVec2(rail_width, bottom - rail_top));
const bool leave = rail(fonts, panel, window, rail_width, pending, installing);
ImGui::EndChild();
ImGui::SetCursorPos(ImVec2(content_x, top));
ImGui::BeginChild("##content", ImVec2(frame.x - content_x - S(28), bottom - top));
// With nothing to say, the list runs all the way down to BACK's bottom
// edge; a message takes two lines off it until it is gone.
const float status = panel.message.empty() ? S(4) : ImGui::GetTextLineHeight() * 2 + S(12);
const float body = ImGui::GetWindowHeight() - status;
if (panel.tab == 1) browse_page(launcher, fonts, panel, body, installing);
else installed_page(launcher, fonts, panel, installed, body, installing);
if (!panel.message.empty()) {
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - status + S(8));
ImGui::PushTextWrapPos(0);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(panel.message_error ? color::danger : color::good), "%s",
panel.message.c_str());
ImGui::PopTextWrapPos();
}
ImGui::EndChild();
if (panel.tab == 1) package_overview(fonts, panel, frame, installing);
else mod_overview(fonts, panel, installed, frame, installing);
if (installing) install_modal(fonts, panel, frame);
if (leave) close();
if (!installing && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId) && !ImGui::IsAnyItemActive() &&
ImGui::IsKeyPressed(ImGuiKey_Escape, false)) close();
// ------------------------------------------------ confirmations
if (!panel.confirm_remove.empty() && !ImGui::IsPopupOpen("Remove mod")) ImGui::OpenPopup("Remove mod");
if (!panel.conflict_name.empty() && !ImGui::IsPopupOpen("Replace mod")) ImGui::OpenPopup("Replace mod");
ImGui::SetNextWindowSize(ImVec2(S(440), 0));
if (ImGui::BeginPopupModal("Remove mod", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) {
ImGui::PushTextWrapPos(0);
ImGui::Text("Remove \"%s\"? Its folder goes to the Recycle Bin.", panel.confirm_remove.c_str());
ImGui::PopTextWrapPos();
ImGui::Spacing();
if (ImGui::Button("Cancel", ImVec2(S(110), 0))) { panel.confirm_remove.clear(); ImGui::CloseCurrentPopup(); }
ImGui::SameLine();
push_primary_button();
if (ImGui::Button("REMOVE", ImVec2(S(110), 0))) {
const auto name = panel.confirm_remove;
try {
launcher_mods::remove(panel.root, name);
std::erase_if(entries, [&](const auto& entry) { return entry.mod.name == name; });
save(panel);
panel.message = "Removed " + name + ". It is in the Recycle Bin if you want it back.";
panel.selected = -1;
logging::write(logging::Level::info, logging::Channel::launcher, "Mod removed: " + name);
} catch (const std::exception& failure) {
panel.message = failure.what();
panel.message_error = true;
}
const auto message = panel.message;
const bool error = panel.message_error;
scan(panel, session);
panel.message = message;
panel.message_error = error;
panel.confirm_remove.clear();
ImGui::CloseCurrentPopup();
}
pop_primary_button();
ImGui::EndPopup();
}
ImGui::SetNextWindowSize(ImVec2(S(440), 0));
if (ImGui::BeginPopupModal("Replace mod", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) {
ImGui::PushTextWrapPos(0);
ImGui::Text("\"%s\" is already installed. Replace it? The installed copy goes to the Recycle Bin.",
panel.conflict_name.c_str());
ImGui::PopTextWrapPos();
ImGui::Spacing();
if (ImGui::Button("Cancel", ImVec2(S(110), 0))) {
panel.conflict_name.clear();
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
push_primary_button();
if (ImGui::Button("REPLACE", ImVec2(S(110), 0))) {
const auto source = panel.conflict_source;
panel.conflict_name.clear();
ImGui::CloseCurrentPopup();
start_install(panel, source, true);
}
pop_primary_button();
ImGui::EndPopup();
}
g_drag_allowed = !ImGui::IsAnyItemHovered() && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId);
ImGui::End();
}
} // namespace dingosdk::launcher_gui::detail