From b63e2d62bd1956f0a3ad331beda6ea34806d7c34 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Fri, 16 Oct 2026 20:01:53 -0500 Subject: [PATCH] Launcher: the mod manager's pages redesigned, with a banner and cards - page_banner: each page opens with its heading, what it is for, and how things stand at the right (a status pill and a caption). card: the panel the pages draw on, lighter edged on hover, blue edged when marked. - MY MODS and GET MODS rows are cards with a gap between them (list_row), in lists without a border. MY MODS' banner counts problems and waiting updates and holds the load-order explanation; GET MODS' says whether the listing loaded and what is installed or waiting. The ticked bar on GET MODS is a card. - OFFICIAL: a card per mod with its status and version pills, its Thunderstore facts, and THUNDERSTORE and OPEN FOLDER. - PROFILES: new-profile and import cards side by side; a card per profile with a picture of its first mods' icons, pills for what it holds, LOAD and UPDATE, and the rest in a menu. --- Launcher/gui.cpp | 58 ++++++- Launcher/gui_internal.h | 14 +- Launcher/gui_mods.cpp | 40 +++-- Launcher/gui_mods_browse.cpp | 182 ++++++++++++++++----- Launcher/gui_mods_profiles.cpp | 288 +++++++++++++++++++++++---------- 5 files changed, 437 insertions(+), 145 deletions(-) diff --git a/Launcher/gui.cpp b/Launcher/gui.cpp index 17aec3d..eff5fc9 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -117,6 +117,50 @@ void badge(ImDrawList* draw, const Fonts& fonts, ImVec2 position, const std::str draw->AddText(fonts.caption, fonts.caption->FontSize, ImVec2(position.x + S(9), position.y + S(4)), ink, text.c_str()); } +float text_height(ImFont* font, float wrap, const std::string& text) { + return text.empty() ? 0 : font->CalcTextSizeA(font->FontSize, FLT_MAX, wrap, text.c_str()).y; +} + +void page_banner(const Fonts& fonts, const char* heading, const char* pill, const std::string& about, + const std::string& status, ImU32 status_fill, const std::string& caption) { + auto* draw = ImGui::GetWindowDrawList(); + const ImVec2 top = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x; + const float pad = S(20), side = S(230); + const float wrap = width - pad * 2 - side - S(20); + const float tall = std::max(S(100), pad + fonts.heading->FontSize + S(10) + text_height(fonts.body, wrap, about) + pad); + draw->AddRectFilled(top, ImVec2(top.x + width, top.y + tall), color::tile, S(6)); + draw->AddRectFilled(top, ImVec2(top.x + S(5), top.y + tall), color::blue, S(6), ImDrawFlags_RoundCornersLeft); + const float text_x = top.x + pad + S(4); + draw->AddText(fonts.heading, fonts.heading->FontSize, ImVec2(text_x, top.y + pad), color::text, heading); + if (pill && *pill) { + const float heading_width = fonts.heading->CalcTextSizeA(fonts.heading->FontSize, FLT_MAX, 0, heading).x; + badge(draw, fonts, ImVec2(text_x + heading_width + S(12), top.y + pad + (fonts.heading->FontSize - fonts.caption->FontSize - S(8)) * 0.5f), + pill, color::blue, color::ink); + } + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, top.y + pad + fonts.heading->FontSize + S(10)), color::muted, + about.c_str(), nullptr, wrap); + const float right = top.x + width - pad; + const float block = fonts.caption->FontSize + S(8) + (caption.empty() ? 0 : S(8) + fonts.caption->FontSize); + const float block_y = top.y + (tall - block) * 0.5f; + if (!status.empty()) badge(draw, fonts, ImVec2(right - badge_width(fonts, status), block_y), status, status_fill, color::ink); + if (!caption.empty()) { + const float caption_width = fonts.caption->CalcTextSizeA(fonts.caption->FontSize, FLT_MAX, 0, caption.c_str()).x; + draw->AddText(fonts.caption, fonts.caption->FontSize, ImVec2(right - caption_width, block_y + fonts.caption->FontSize + S(16)), + color::muted, caption.c_str()); + } + ImGui::Dummy(ImVec2(width, tall)); + ImGui::Dummy(ImVec2(0, S(6))); +} + +void card(ImDrawList* draw, ImVec2 start, ImVec2 size, bool marked) { + const ImVec2 end(start.x + size.x, start.y + size.y); + const bool hovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && ImGui::IsMouseHoveringRect(start, end); + draw->AddRectFilled(start, end, color::tile, S(6)); + draw->AddRect(start, end, hovered ? rgba(255, 255, 255, 0.22f) : color::outline, S(6)); + if (marked) draw->AddRectFilled(start, ImVec2(start.x + S(5), end.y), color::blue, S(6), ImDrawFlags_RoundCornersLeft); +} + void field(const Fonts& fonts, const char* name, const std::string& value) { if (value.empty()) return; ImGui::PushFont(fonts.caption); @@ -131,16 +175,18 @@ bool list_row(const char* id, float width, float height, bool ticked) { auto* draw = ImGui::GetWindowDrawList(); const ImVec2 start = ImGui::GetCursorScreenPos(); const ImVec2 end(start.x + width, start.y + height); - draw->AddRectFilled(start, end, ticked ? rgba(28, 38, 52) : rgba(31, 31, 34)); - // The hover tint is the Selectable's; the row's own colour is under it. + // A card, as the OFFICIAL and PROFILES pages draw theirs: its own hover, so the corners stay round. + const bool hovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && ImGui::IsMouseHoveringRect(start, end); + draw->AddRectFilled(start, end, ticked ? rgba(28, 38, 52) : color::tile, S(6)); + if (hovered) draw->AddRectFilled(start, end, rgba(255, 255, 255, 0.035f), S(6)); + draw->AddRect(start, end, hovered ? rgba(255, 255, 255, 0.22f) : color::outline, S(6)); ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0)); - ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImGui::ColorConvertU32ToFloat4(rgba(255, 255, 255, 0.055f))); - ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImGui::ColorConvertU32ToFloat4(rgba(255, 255, 255, 0.09f))); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImGui::ColorConvertU32ToFloat4(rgba(255, 255, 255, 0.05f))); const bool pressed = ImGui::Selectable(id, false, ImGuiSelectableFlags_AllowOverlap, ImVec2(width, height)); ImGui::PopStyleColor(3); if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - if (ticked) draw->AddRectFilled(start, ImVec2(start.x + S(3), end.y), color::blue); - draw->AddLine(ImVec2(start.x, end.y - S(1)), ImVec2(end.x, end.y - S(1)), rgba(255, 255, 255, 0.07f), S(1)); + if (ticked) draw->AddRectFilled(start, ImVec2(start.x + S(5), end.y), color::blue, S(6), ImDrawFlags_RoundCornersLeft); return pressed; } diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index f6875bd..d511d7b 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -120,12 +120,22 @@ bool nav_tile(const Fonts& fonts, float width, const char* label, bool selected, float badge_width(const Fonts& fonts, const std::string& text); void badge(ImDrawList* draw, const Fonts& fonts, ImVec2 position, const std::string& text, ImU32 fill = color::blue, ImU32 ink = color::ink); +// How tall `text` is in `font`, wrapped at `wrap` (0: not wrapped); 0 for no text. +float text_height(ImFont* font, float wrap, const std::string& text); +// A page's banner, as wide as the page: its heading with a blue pill beside it (`pill` may be +// empty), a line on what the page is for, and at the right how things stand: a pill in +// `status_fill`, with `caption` under it (either may be empty). Leaves the cursor under it. +void page_banner(const Fonts& fonts, const char* heading, const char* pill, const std::string& about, + const std::string& status = {}, ImU32 status_fill = color::good, const std::string& caption = {}); +// A card behind what a page draws on it: lighter edged while hovered, with a blue edge when `marked`. +void card(ImDrawList* draw, ImVec2 start, ImVec2 size, bool marked = false); // A caption over its value: the detail lines of an open list row. void field(const Fonts& fonts, const char* name, const std::string& value); -// One row of a mod list: its background, a hover tint, a rule under it and a -// blue edge when it is ticked. Returns true when the row itself was clicked, +// One row of a mod list, `height` tall: a card, lighter edged while hovered, with a blue edge +// when it is ticked. Leave a gap under it (row_gap). Returns true when the row itself was clicked, // which opens the mod's overview; the widgets drawn over it keep their clicks. bool list_row(const char* id, float width, float height, bool ticked); +inline float row_gap() { return S(8); } // An on/off switch, blue while on. Returns true when it was flipped. bool toggle(const char* id, bool* on); // A square button with three dots, for a row's menu. diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 4d7b1a3..f3c3b41 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -496,6 +496,25 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co float height, bool installing) { auto& entries = panel.list.entries; const float top = ImGui::GetCursorPosY(); + { + const auto enabled = std::count_if(entries.begin(), entries.end(), [](const auto& entry) { return entry.enabled; }); + const auto problems = std::count_if(entries.begin(), entries.end(), [&](const auto& entry) { + return !entry.mod.outdated.empty() || (entry.enabled && panel.list.excluded.contains(entry.mod.name)); + }); + const auto waiting = std::count_if(entries.begin(), entries.end(), + [&](const auto& entry) { return update_waiting(panel, installed, entry.mod.name); }); + std::uint64_t bytes{}; + for (const auto& [name, size] : panel.sizes) bytes += size; + auto caption = std::format("{} {} / {} on", entries.size(), entries.size() == 1 ? "mod" : "mods", enabled); + if (bytes) caption += " / " + size_text(bytes); + page_banner(fonts, "MY MODS", nullptr, + "Your installed mods. They load top to bottom: where two change the same thing, the higher one wins. " + "Changes apply the next time Skate starts.", + problems ? std::format("{} {}", problems, problems == 1 ? "PROBLEM" : "PROBLEMS") + : waiting ? std::format("{} {} WAITING", waiting, waiting == 1 ? "UPDATE" : "UPDATES") + : entries.empty() ? std::string("NONE YET") : std::string("ALL GOOD"), + problems ? color::danger : waiting ? color::blue : entries.empty() ? color::tile_grey : color::good, caption); + } // ------------------------------------------------ search, filter, order, refresh const float combo = S(170), refresh = S(110); @@ -555,11 +574,9 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co return bytes; }; if (panel.marked.empty()) { - const auto enabled = std::count_if(entries.begin(), entries.end(), [](const auto& entry) { return entry.enabled; }); - auto line = std::format("{} {} / {} enabled", entries.size(), entries.size() == 1 ? "mod" : "mods", enabled); - if (const auto bytes = total([](const std::string&) { return true; })) line += " / " + size_text(bytes); - if (view.size() != entries.size()) line += std::format(" / {} shown", view.size()); - ImGui::TextDisabled("%s", line.c_str()); + ImGui::TextDisabled("%s", view.size() != entries.size() + ? std::format("{} of {} shown", view.size(), entries.size()).c_str() + : "Tick mods to change several at once. Shift-click ticks a run of them, Ctrl+A every one."); } else { const std::vector names(panel.marked.begin(), panel.marked.end()); auto line = std::format("{} selected", names.size()); @@ -626,9 +643,8 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co ImGui::Spacing(); // ------------------------------------------------ the list, and under it how its order works - const float hint = ImGui::GetTextLineHeightWithSpacing(); - const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top) - hint); - ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened); + const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top)); + ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_NavFlattened); const auto note = [](const char* first, const char* second = nullptr) { ImGui::Spacing(); ImGui::Indent(S(14)); @@ -640,16 +656,14 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co note(panel.list.present ? "No mods installed yet." : "No Mods folder yet.", "Use GET MODS to browse Thunderstore, or drop a mod .zip on the window."); else if (view.empty()) note("No mods match."); - const float row = S(68); + const float row = S(72) + row_gap(); ImGui::BeginDisabled(installing); - virtual_rows(static_cast(view.size()), [&](int) { return row; }, [&](int position, float tall) { - installed_row(fonts, panel, installed, view, position, tall, reorder, request); + virtual_rows(static_cast(view.size()), [&](int) { return row; }, [&](int position, float slot) { + installed_row(fonts, panel, installed, view, position, slot - row_gap(), reorder, request); }); ImGui::EndDisabled(); keep_focus_in_list(); ImGui::EndChild(); - ImGui::TextDisabled("Mods load top to bottom: where two change the same thing, the higher one wins. " - "Changes apply the next time Skate starts."); // Ctrl+A and Delete, as in a file list, while nothing is being typed. if (!installing && !ImGui::GetIO().WantTextInput && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) { diff --git a/Launcher/gui_mods_browse.cpp b/Launcher/gui_mods_browse.cpp index f53a8f1..9467b35 100644 --- a/Launcher/gui_mods_browse.cpp +++ b/Launcher/gui_mods_browse.cpp @@ -2,6 +2,8 @@ #include "gui_renderer.h" #include "official_mods.h" +#include "Engine/Vfs/builtin_packs.h" + #include "mod_manager.h" #include "updater.h" @@ -457,47 +459,129 @@ void pick(Store& store, const std::string& full_name, bool on) { else if (!on && found != store.picked.end()) store.picked.erase(found); } +namespace { +// How one official mod is doing, as a pill: its word and colour. +struct Standing { + const char* word; + ImU32 fill; + bool trouble; +}; +Standing standing_of(const official_mods::Mod& mod) { + if (mod.working) return {mod.status.empty() ? "CHECKING" : mod.installed.empty() ? "DOWNLOADING" : "UPDATING", color::blue, false}; + if (mod.status == "Up to date") return {"UP TO DATE", color::good, false}; + if (mod.status.empty()) return {"WAITING", color::tile_grey, false}; + return {mod.installed.empty() ? "NOT DOWNLOADED" : "UPDATE FAILED", mod.installed.empty() ? color::danger : color::warning, true}; +} +std::string source_text(const official_mods::State& state) { + if (state.source == "ReSkate") return "List from ReSkate's server"; + if (state.source == "the last list read") return "Offline: using the last list"; + if (state.source.empty()) return state.syncing ? "Reading the list..." : ""; + return "Using the list ReSkate came with"; +} +} + void official_page(const Fonts& fonts, ModsPanel& panel, float height) { - // (The listing gives their icons.) + // (The listing gives their icons, sizes and download counts.) refresh_listing(panel, ImGui::GetTime()); const auto state = official_mods::snapshot(); - ImGui::PushTextWrapPos(0); - ImGui::TextDisabled("Every player has these, always on: ReSkate keeps them at their latest version from Thunderstore."); - const char* source = state.syncing ? "Getting them now..." - : state.source == "ReSkate" ? "The list comes from ReSkate." - : state.source == "the last list read" ? "ReSkate could not be reached: this is the last list it gave." - : state.source.empty() ? "" : "No list has been read yet: these are the ones ReSkate starts with."; - ImGui::TextDisabled("%s", source); - ImGui::PopTextWrapPos(); - ImGui::Spacing(); - ImGui::BeginChild("##official", ImVec2(0, std::max(S(80), height - ImGui::GetCursorPosY())), ImGuiChildFlags_NavFlattened); - const float icon = S(64); + const float top_y = ImGui::GetCursorPosY(); + + const bool trouble = std::any_of(state.mods.begin(), state.mods.end(), [](const auto& mod) { return standing_of(mod).trouble; }); + page_banner(fonts, "OFFICIAL MODS", "ALWAYS ON", + "Mods made for ReSkate's own features. Every player has them, kept at their latest version from Thunderstore: " + "they load after your mods and are always on.", + state.syncing ? "CHECKING FOR UPDATES" : trouble ? "NEEDS ATTENTION" : state.mods.empty() ? "NONE RIGHT NOW" : "ALL UP TO DATE", + state.syncing ? color::blue : trouble ? color::warning : state.mods.empty() ? color::tile_grey : color::good, + source_text(state)); + + // ------------------------------------------------ the mods, a card each + ImGui::BeginChild("##official", ImVec2(0, std::max(S(80), height - (ImGui::GetCursorPosY() - top_y))), ImGuiChildFlags_NavFlattened); + auto* draw = ImGui::GetWindowDrawList(); + if (state.mods.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("%s", state.syncing ? "Reading the list of official mods..." : "There are no official mods right now."); + } + const float pad = S(18); + const float icon = S(96); + const float button = S(150); for (const auto& mod : state.mods) { ImGui::PushID(mod.full_name.c_str()); const auto* package = ts::find_package(panel.store.packages, mod.full_name); - const auto top = ImGui::GetCursorScreenPos(); - mod_icon(panel, package, top, icon); - ImGui::SetCursorScreenPos(ImVec2(top.x + icon + S(14), top.y)); - ImGui::BeginGroup(); - ImGui::PushFont(fonts.bold); - ImGui::TextUnformatted(mod.title.c_str()); - ImGui::PopFont(); - const auto author = mod.full_name.substr(0, mod.full_name.find('-')); - const auto version = !mod.installed.empty() ? "v" + mod.installed : !mod.version.empty() ? "v" + mod.version : std::string("-"); - ImGui::TextDisabled("%s | by %s | always on", version.c_str(), author.c_str()); - const auto status = mod.working ? (mod.status.empty() ? std::string("Checking for an update...") : mod.status + "...") - : mod.status.empty() ? std::string("Waiting") : mod.status; - const bool fine = !mod.working && mod.status == "Up to date"; - const bool trouble = !mod.working && !fine && !mod.status.empty(); - ImGui::PushTextWrapPos(0); - ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(fine ? color::good : trouble ? color::warning : color::muted), "%s", status.c_str()); - if (!mod.description.empty()) ImGui::TextWrapped("%s", mod.description.c_str()); - ImGui::PopTextWrapPos(); - ImGui::EndGroup(); - ImGui::SetCursorScreenPos(ImVec2(top.x, std::max(ImGui::GetCursorScreenPos().y, top.y + icon) + S(14))); + const auto standing = standing_of(mod); + const ImVec2 start = ImGui::GetCursorScreenPos(); + const float across = ImGui::GetContentRegionAvail().x; + const float text_x = start.x + pad + icon + S(18); + const float buttons_x = start.x + across - pad - button; + const float wrap = buttons_x - S(24) - text_x; + + auto author = mod.full_name.substr(0, mod.full_name.find('-')); + std::replace(author.begin(), author.end(), '_', ' '); + const auto& description = !mod.description.empty() ? mod.description + : package ? package->latest().description : std::string(); + const auto version = !mod.installed.empty() ? mod.installed : mod.version; + std::string facts = version.empty() ? std::string() : "v" + version; + const auto add = [&](const std::string& fact) { facts += (facts.empty() ? "" : " / ") + fact; }; + if (package) { + if (const auto size = package->latest().file_size) add(size_text(size)); + add(count_text(package->downloads) + " downloads"); + add("updated " + date_text(package->date_updated)); + } + if (!mod.installed.empty() && !mod.version.empty() && mod.installed != mod.version) add("v" + mod.version + " is out"); + const std::string problem = standing.trouble ? mod.status : std::string(); + + // Measured first, so the card is drawn behind what is on it. + float y = start.y + pad; + const float title_y = y; + y += fonts.bold->FontSize + S(10); + const float pills_y = y; + y += fonts.caption->FontSize + S(8) + S(12); + const float description_y = y; + if (const float tall = text_height(fonts.body, wrap, description)) y += tall + S(10); + const float facts_y = y; + if (!facts.empty()) y += fonts.caption->FontSize + S(6); + const float problem_y = y; + if (const float tall = text_height(fonts.caption, wrap, problem)) y += tall + S(6); + const float tall = std::max(y - start.y + pad - S(6), icon + pad * 2); + + card(draw, start, ImVec2(across, tall)); + mod_icon(panel, package, ImVec2(start.x + pad, start.y + pad), icon); + + draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(text_x, title_y), color::text, mod.title.c_str()); + const float title_width = fonts.bold->CalcTextSizeA(fonts.bold->FontSize, FLT_MAX, 0, mod.title.c_str()).x; + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x + title_width + S(8), title_y + S(2)), color::muted, + ("by " + author).c_str()); + float pill_x = text_x; + badge(draw, fonts, ImVec2(pill_x, pills_y), standing.word, standing.fill, color::ink); + pill_x += badge_width(fonts, standing.word) + S(8); + if (!version.empty()) badge(draw, fonts, ImVec2(pill_x, pills_y), "v" + version, color::tile_grey, color::text); + if (!description.empty()) + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, description_y), color::text, description.c_str(), nullptr, wrap); + if (!facts.empty()) + draw->AddText(fonts.caption, fonts.caption->FontSize, ImVec2(text_x, facts_y), color::muted, facts.c_str()); + if (!problem.empty()) + draw->AddText(fonts.caption, fonts.caption->FontSize, ImVec2(text_x, problem_y), standing.fill, problem.c_str(), nullptr, wrap); + + // ------------------------------------------- at the right + ImGui::SetCursorScreenPos(ImVec2(buttons_x, start.y + pad)); + if (ImGui::Button("THUNDERSTORE", ImVec2(button, S(32)))) { + const auto dash = mod.full_name.find('-'); + open_url(package ? package->package_url + : std::format("https://thunderstore.io/c/{}/p/{}/{}/", ts::community(), mod.full_name.substr(0, dash), + mod.full_name.substr(dash + 1))); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Its page on Thunderstore"); + const auto folder = panel.root / std::string(mods::official_folder) / wide(mod.full_name); + ImGui::SetCursorScreenPos(ImVec2(buttons_x, start.y + pad + S(40))); + ImGui::BeginDisabled(mod.installed.empty()); + if (ImGui::Button("OPEN FOLDER", ImVec2(button, S(32)))) open_path(folder); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("%s", mod.installed.empty() ? "Not downloaded yet" : "Where ReSkate keeps it"); + + ImGui::SetCursorScreenPos(ImVec2(start.x, start.y + tall + S(12))); + ImGui::Dummy(ImVec2(across, 0)); ImGui::PopID(); } - if (state.mods.empty()) ImGui::TextDisabled("%s", state.syncing ? "Reading the list..." : "There are no official mods right now."); ImGui::EndChild(); } @@ -505,8 +589,23 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float auto& store = panel.store; const auto installed = installed_versions(panel.list); - // ------------------------------------------------ search, category, sort, refresh const float top = ImGui::GetCursorPosY(); + { + const auto waiting = store.loaded ? updates(store, installed).size() : 0; + const auto have = std::count_if(store.packages.begin(), store.packages.end(), + [&](const ts::Package& package) { return installed.contains(ts::folder_for(package.full_name)); }); + page_banner(fonts, "GET MODS", nullptr, + "Mods for ReSkate from Thunderstore. Open one to read about it, or tick several and install them together. " + "Official mods are kept for you, so they are not listed here.", + !store.loaded ? (store.loading || store.error.empty() ? "LOADING" : "OFFLINE") + : waiting ? std::format("{} {} WAITING", waiting, waiting == 1 ? "UPDATE" : "UPDATES") + : std::format("{} INSTALLED", have), + !store.loaded ? (store.loading || store.error.empty() ? color::blue : color::danger) : waiting ? color::blue : color::good, + !store.loaded ? (store.loading || store.error.empty() ? "Reading Thunderstore..." : "Thunderstore could not be reached") + : std::format("{} mods on Thunderstore", count_text(store.packages.size()))); + } + + // ------------------------------------------------ search, category, sort, refresh const float combo = S(190), refresh = S(110); ImGui::SetNextItemWidth(std::max(S(140), ImGui::GetContentRegionAvail().x - (combo + S(10)) * 2 - refresh - S(10))); @@ -541,6 +640,10 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float // ------------------------------------------------ what is ticked if (!store.picked.empty()) { ImGui::Spacing(); + const ImVec2 bar = ImGui::GetCursorScreenPos(); + const float pad = S(10), bar_tall = ImGui::GetFrameHeight() + pad * 2; + card(ImGui::GetWindowDrawList(), bar, ImVec2(ImGui::GetContentRegionAvail().x, bar_tall), true); + ImGui::SetCursorScreenPos(ImVec2(bar.x + S(20), bar.y + pad)); ImGui::AlignTextToFramePadding(); // What installing them all downloads: each one's newest version. std::uint64_t bytes{}; @@ -553,7 +656,7 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float ImGui::TextUnformatted(chosen_text.c_str()); ImGui::SameLine(); const float action = S(190); - ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - action - S(90) - S(8)); + ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - action - S(90) - S(8) - pad); if (ImGui::Button("Clear", ImVec2(S(90), 0))) store.picked.clear(); ImGui::SameLine(); ImGui::BeginDisabled(installing); @@ -569,13 +672,15 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float } pop_primary_button(); ImGui::EndDisabled(); + ImGui::SetCursorScreenPos(ImVec2(bar.x, bar.y + bar_tall)); + ImGui::Dummy(ImVec2(0, 0)); } 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 | ImGuiChildFlags_NavFlattened); + ImGui::BeginChild("##store_list", ImVec2(0, body), ImGuiChildFlags_NavFlattened); const auto note = [](const char* text) { ImGui::Spacing(); ImGui::Indent(S(14)); @@ -603,9 +708,10 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float note("No mods match your search."); } - const float row = S(78); + const float row = S(80) + row_gap(); ImGui::BeginDisabled(installing); - virtual_rows(static_cast(packages.size()), [&](int) { return row; }, [&](int index, float tall) { + virtual_rows(static_cast(packages.size()), [&](int) { return row; }, [&](int index, float slot) { + const float tall = slot - row_gap(); const auto& package = *packages[static_cast(index)]; const auto& version = package.latest(); const auto found = installed.find(ts::folder_for(package.full_name)); diff --git a/Launcher/gui_mods_profiles.cpp b/Launcher/gui_mods_profiles.cpp index fb4da34..607556e 100644 --- a/Launcher/gui_mods_profiles.cpp +++ b/Launcher/gui_mods_profiles.cpp @@ -5,6 +5,8 @@ #include "Engine/Core/Log/logging.h" +#include +#include #include #include @@ -265,75 +267,143 @@ void settle_profile(ModsPanel& panel) { else say(panel, "Loaded \"" + name + "\" and downloaded what it needed. Changes apply the next time Skate starts."); } +namespace { +// A mod as a profile card names it: its Thunderstore title, else its folder without the namespace. +std::string mod_title(const ModsPanel& panel, const std::string& folder) { + if (const auto* package = package_for(panel.store, folder)) return package->title(); + auto name = folder.substr(folder.find('-') + 1); + std::replace(name.begin(), name.end(), '_', ' '); + return name; +} + +// The picture on a profile's card: the icons of its first mods that are on, four at most. +void profile_picture(const Fonts& fonts, ModsPanel& panel, const profiles::Profile& profile, ImVec2 at, float size) { + std::vector shown; + for (const auto& mod : profile.mods) { + if (shown.size() == 4) break; + if (const auto* package = mod.enabled ? package_for(panel.store, mod.name) : nullptr) shown.push_back(package); + } + auto* draw = ImGui::GetWindowDrawList(); + if (shown.size() == 1) { + mod_icon(panel, shown[0], at, size); + return; + } + if (shown.empty()) { + draw->AddRectFilled(at, ImVec2(at.x + size, at.y + size), rgba(255, 255, 255, 0.06f), S(4)); + const std::string letter(1, profile.name.empty() ? '?' : static_cast(std::toupper(static_cast(profile.name[0])))); + const auto extent = fonts.tile->CalcTextSizeA(fonts.tile->FontSize, FLT_MAX, 0, letter.c_str()); + draw->AddText(fonts.tile, fonts.tile->FontSize, ImVec2(at.x + (size - extent.x) * 0.5f, at.y + (size - extent.y) * 0.5f), + color::muted, letter.c_str()); + return; + } + const float gap = S(4), cell = (size - gap) * 0.5f; + for (std::size_t i = 0; i < 4; ++i) { + const ImVec2 corner(at.x + static_cast(i % 2) * (cell + gap), at.y + static_cast(i / 2) * (cell + gap)); + if (i < shown.size()) mod_icon(panel, shown[i], corner, cell); + else draw->AddRectFilled(corner, ImVec2(corner.x + cell, corner.y + cell), rgba(255, 255, 255, 0.04f), S(4)); + } +} + +// A small heading over a line saying what the card is for. +void card_heading(const Fonts& fonts, ImVec2 at, const char* heading, const char* line) { + auto* draw = ImGui::GetWindowDrawList(); + draw->AddText(fonts.bold, fonts.bold->FontSize, at, color::text, heading); + draw->AddText(fonts.caption, fonts.caption->FontSize, ImVec2(at.x, at.y + fonts.bold->FontSize + S(4)), color::muted, line); +} +} + void profiles_page(const Fonts& fonts, ModsPanel& panel, HWND window, float height, bool installing) { collect_profile(panel); const bool busy = installing || panel.profile_busy; const float top = ImGui::GetCursorPosY(); - ImGui::PushTextWrapPos(0); - ImGui::TextDisabled("A profile remembers which of your mods are on and in what order. Every mod stays installed " - "once; loading a profile switches the rest off. Share one as a code or a file, with ReSkate " - "or r2modman."); - ImGui::PopTextWrapPos(); - ImGui::Spacing(); + const profiles::Profile* using_now = nullptr; + for (const auto& profile : panel.profiles.list) + if (!using_now && profiles::in_use(profile, panel.list)) using_now = &profile; + page_banner(fonts, "PROFILES", nullptr, + "A profile remembers which of your mods are on and in what order. Every mod stays installed once; loading a " + "profile switches the rest off. Share one as a code or a file, with ReSkate or r2modman.", + using_now ? "IN USE: " + using_now->name : "NO PROFILE IN USE", using_now ? color::good : color::tile_grey, + std::format("{} of {} saved", panel.profiles.list.size(), profiles::max_profiles)); - ImGui::BeginDisabled(busy); - // ------------------------------------------------ new, and the two ways in - const float button = S(280), gap = S(10); - ImGui::SetNextItemWidth(std::max(S(160), ImGui::GetContentRegionAvail().x - button - gap)); - const bool entered_name = ImGui::InputTextWithHint("##profile_name", "Name for a new profile", panel.profile_name.data(), - panel.profile_name.size(), ImGuiInputTextFlags_EnterReturnsTrue); - ImGui::SameLine(0, gap); - push_primary_button(); - const bool create = ImGui::Button("SAVE CURRENT MODS", ImVec2(button, 0)) || entered_name; - pop_primary_button(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Save what MY MODS shows now as a new profile"); - if (create) { - const auto name = trimmed(panel.profile_name.data()); - if (!profiles::valid_profile_name(name)) - say(panel, "Give the profile a name first (up to 48 characters, none of < > : \" / \\ | ? *).", true); - else if (panel.profiles.find(name)) say(panel, "A profile named \"" + name + "\" already exists.", true); - else { - const auto before = panel.profiles.list.size(); - add_profile(panel, profiles::capture(name, panel.list), "Saved"); - if (panel.profiles.list.size() > before) { - panel.profiles.active = panel.profiles.list.back().name; - store(panel); - panel.profile_name.fill(0); + // ------------------------------------------------ new, and the two ways in, a card each + { + auto* draw = ImGui::GetWindowDrawList(); + const ImVec2 start = ImGui::GetCursorScreenPos(); + const float gap = S(12), pad = S(16); + const float across = (ImGui::GetContentRegionAvail().x - gap) * 0.5f; + const float frame = ImGui::GetFrameHeight(); + const float heading = fonts.bold->FontSize + S(4) + fonts.caption->FontSize + S(12); + const float tall = pad + heading + frame + S(8) + frame + pad; + const ImVec2 left = start, right(start.x + across + gap, start.y); + card(draw, left, ImVec2(across, tall)); + card(draw, right, ImVec2(across, tall)); + card_heading(fonts, ImVec2(left.x + pad, left.y + pad), "NEW PROFILE", "Saves what MY MODS shows now"); + card_heading(fonts, ImVec2(right.x + pad, right.y + pad), "IMPORT", + panel.profile_busy ? "Talking to Thunderstore..." : "A code or a .r2z file, from ReSkate or r2modman"); + const float inner = across - pad * 2; + const float row = start.y + pad + heading; + + ImGui::BeginDisabled(busy); + ImGui::SetCursorScreenPos(ImVec2(left.x + pad, row)); + ImGui::SetNextItemWidth(inner); + const bool entered_name = ImGui::InputTextWithHint("##profile_name", "Name for a new profile", panel.profile_name.data(), + panel.profile_name.size(), ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SetCursorScreenPos(ImVec2(left.x + pad, row + frame + S(8))); + push_primary_button(); + const bool create = ImGui::Button("SAVE CURRENT MODS", ImVec2(inner, 0)) || entered_name; + pop_primary_button(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Save what MY MODS shows now as a new profile"); + if (create) { + const auto name = trimmed(panel.profile_name.data()); + if (!profiles::valid_profile_name(name)) + say(panel, "Give the profile a name first (up to 48 characters, none of < > : \" / \\ | ? *).", true); + else if (panel.profiles.find(name)) say(panel, "A profile named \"" + name + "\" already exists.", true); + else { + const auto before = panel.profiles.list.size(); + add_profile(panel, profiles::capture(name, panel.list), "Saved"); + if (panel.profiles.list.size() > before) { + panel.profiles.active = panel.profiles.list.back().name; + store(panel); + panel.profile_name.fill(0); + } } } - } - const float half = (button - gap) * 0.5f; - ImGui::SetNextItemWidth(std::max(S(160), ImGui::GetContentRegionAvail().x - button - gap)); - const bool entered_code = ImGui::InputTextWithHint("##profile_code", "Paste a profile code", panel.profile_code.data(), - panel.profile_code.size(), ImGuiInputTextFlags_EnterReturnsTrue); - ImGui::SameLine(0, gap); - if (ImGui::Button("IMPORT CODE", ImVec2(half, 0)) || entered_code) { - const auto code = trimmed(panel.profile_code.data()); - if (!profiles::valid_code(code)) say(panel, "That is not a profile code. Paste the whole code you were given.", true); - else { - import_code(panel, code); - panel.profile_code.fill(0); + + ImGui::SetCursorScreenPos(ImVec2(right.x + pad, row)); + ImGui::SetNextItemWidth(inner); + const bool entered_code = ImGui::InputTextWithHint("##profile_code", "Paste a profile code", panel.profile_code.data(), + panel.profile_code.size(), ImGuiInputTextFlags_EnterReturnsTrue); + const float half = (inner - S(8)) * 0.5f; + ImGui::SetCursorScreenPos(ImVec2(right.x + pad, row + frame + S(8))); + if (ImGui::Button("IMPORT CODE", ImVec2(half, 0)) || entered_code) { + const auto code = trimmed(panel.profile_code.data()); + if (!profiles::valid_code(code)) say(panel, "That is not a profile code. Paste the whole code you were given.", true); + else { + import_code(panel, code); + panel.profile_code.fill(0); + } } - } - ImGui::SameLine(0, gap); - if (ImGui::Button("IMPORT FILE", ImVec2(half, 0))) import_file(panel, window); - ImGui::EndDisabled(); - if (panel.profile_busy) { - ImGui::SameLine(0, gap); - ImGui::TextDisabled("Talking to Thunderstore..."); + ImGui::SameLine(0, S(8)); + if (ImGui::Button("IMPORT FILE", ImVec2(half, 0))) import_file(panel, window); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Pick a .r2z or .r2x file"); + ImGui::EndDisabled(); + + ImGui::SetCursorScreenPos(ImVec2(start.x, start.y + tall + S(12))); + ImGui::Dummy(ImVec2(0, 0)); } - // ------------------------------------------------ the profiles - ImGui::Spacing(); - ImGui::BeginChild("##profiles", ImVec2(0, height - (ImGui::GetCursorPosY() - top)), ImGuiChildFlags_NavFlattened); + // ------------------------------------------------ the profiles, a card each + ImGui::BeginChild("##profiles", ImVec2(0, std::max(S(80), height - (ImGui::GetCursorPosY() - top))), ImGuiChildFlags_NavFlattened); + auto* draw = ImGui::GetWindowDrawList(); if (panel.profiles.list.empty()) { - ImGui::Dummy(ImVec2(0, S(24))); + ImGui::Dummy(ImVec2(0, S(8))); ImGui::TextDisabled("No profiles yet. Set up MY MODS the way you like, name it above and save it."); } - // What a row asked for, carried out after the list is drawn: most of it changes the list. + // What a card asked for, carried out after the list is drawn: most of it changes the list. enum class Ask { none, load, update, code, file, rename, remove } ask{Ask::none}; std::size_t asked{}; + const float pad = S(18), picture = S(84), button = S(110), more = S(32); for (std::size_t index = 0; index < panel.profiles.list.size(); ++index) { const auto& profile = panel.profiles.list[index]; ImGui::PushID(static_cast(index)); @@ -341,42 +411,88 @@ void profiles_page(const Fonts& fonts, ModsPanel& panel, HWND window, float heig const auto missing = profiles::apply(profile, panel.list).missing.size(); const bool current = profiles::in_use(profile, panel.list); const bool last = !current && profile.name == panel.profiles.active; + std::vector names; + for (const auto& mod : profile.mods) + if (mod.enabled) names.push_back(mod_title(panel, mod.name)); - ImGui::Dummy(ImVec2(0, S(6))); - ImGui::PushFont(fonts.bold); - ImGui::TextUnformatted(profile.name.c_str()); - ImGui::PopFont(); - if (current || last) { - ImGui::SameLine(0, S(12)); - ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(current ? color::good : color::warning), "%s", - current ? "IN USE" : "CHANGED SINCE LOADED"); - } - ImGui::TextDisabled("%s", (std::format("{} mods, {} on", profile.mods.size(), on) + - (missing ? std::format(" | {} to download", missing) : std::string())).c_str()); - ImGui::BeginDisabled(busy); - const auto act = [&](const char* label, Ask what, const char* tip, bool enabled = true, bool primary = false) { - ImGui::BeginDisabled(!enabled); - if (primary) push_primary_button(); - if (ImGui::Button(label, ImVec2(S(122), 0))) { - ask = what; - asked = index; - } - if (primary) pop_primary_button(); - ImGui::EndDisabled(); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", tip); - ImGui::SameLine(0, S(8)); + const ImVec2 start = ImGui::GetCursorScreenPos(); + const float across = ImGui::GetContentRegionAvail().x; + const float tall = std::max(picture + pad * 2, + pad + fonts.bold->FontSize + S(10) + fonts.caption->FontSize + S(8) + S(10) + fonts.body->FontSize + pad); + const float text_x = start.x + pad + picture + S(18); + const float more_x = start.x + across - pad - more; + const float update_x = more_x - S(8) - button; + const float load_x = update_x - S(8) - button; + card(draw, start, ImVec2(across, tall), current); + profile_picture(fonts, panel, profile, ImVec2(start.x + pad, start.y + (tall - picture) * 0.5f), picture); + + const ImVec4 clip(text_x, start.y, load_x - S(18), start.y + tall); + float y = start.y + pad; + draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(text_x, y), color::text, profile.name.c_str(), nullptr, 0, &clip); + y += fonts.bold->FontSize + S(10); + float pill_x = text_x; + const auto pill = [&](const std::string& word, ImU32 fill, ImU32 ink) { + badge(draw, fonts, ImVec2(pill_x, y), word, fill, ink); + pill_x += badge_width(fonts, word) + S(8); }; - act("LOAD", Ask::load, current ? "MY MODS already matches this profile" - : "Switch MY MODS to this profile, downloading what is missing", !current, true); - act("UPDATE", Ask::update, "Overwrite this profile with what MY MODS shows now", !current); - act("COPY CODE", Ask::code, "Upload this profile to Thunderstore and copy its code"); - act("SAVE FILE", Ask::file, "Save this profile as a .r2z file to send to someone"); - act("RENAME", Ask::rename, "Give this profile another name"); - act("DELETE", Ask::remove, "Delete this profile. No mod is uninstalled."); - ImGui::NewLine(); + if (current) pill("IN USE", color::good, color::ink); + else if (last) pill("CHANGED SINCE LOADED", color::warning, color::ink); + pill(std::format("{} MODS", profile.mods.size()), color::tile_grey, color::text); + pill(std::format("{} ON", on), color::tile_grey, color::text); + if (missing) pill(std::format("{} TO DOWNLOAD", missing), color::blue, color::ink); + y += fonts.caption->FontSize + S(8) + S(10); + const auto list = names.empty() ? std::string("Every mod off") : named(names); + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, y), color::muted, list.c_str(), nullptr, 0, &clip); + + // ------------------------------------------- at the right + const float button_y = start.y + (tall - S(34)) * 0.5f; + ImGui::BeginDisabled(busy); + ImGui::SetCursorScreenPos(ImVec2(load_x, button_y)); + ImGui::BeginDisabled(current); + push_primary_button(); + if (ImGui::Button(current ? "LOADED" : "LOAD", ImVec2(button, S(34)))) { + ask = Ask::load; + asked = index; + } + pop_primary_button(); ImGui::EndDisabled(); - ImGui::Dummy(ImVec2(0, S(6))); - ImGui::Separator(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("%s", current ? "MY MODS already matches this profile" + : missing ? "Switch MY MODS to this profile, downloading what is missing" + : "Switch MY MODS to this profile"); + ImGui::SetCursorScreenPos(ImVec2(update_x, button_y)); + ImGui::BeginDisabled(current); + if (ImGui::Button("UPDATE", ImVec2(button, S(34)))) { + ask = Ask::update; + asked = index; + } + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("%s", current ? "It already matches MY MODS" : "Overwrite this profile with what MY MODS shows now"); + ImGui::SetCursorScreenPos(ImVec2(more_x, start.y + (tall - more) * 0.5f)); + if (more_button("##more", more)) ImGui::OpenPopup("##profile_menu"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Share, rename or delete"); + ImGui::EndDisabled(); + if (ImGui::BeginPopup("##profile_menu")) { + const auto item = [&](const char* label, Ask what) { + if (ImGui::MenuItem(label)) { + ask = what; + asked = index; + } + }; + item("Copy code", Ask::code); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Upload this profile to Thunderstore and copy its code"); + item("Save as file", Ask::file); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Save this profile as a .r2z file to send to someone"); + ImGui::Separator(); + item("Rename", Ask::rename); + item("Delete", Ask::remove); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Delete this profile. No mod is uninstalled."); + ImGui::EndPopup(); + } + + ImGui::SetCursorScreenPos(ImVec2(start.x, start.y + tall + S(12))); + ImGui::Dummy(ImVec2(across, 0)); ImGui::PopID(); } ImGui::EndChild();